enketo-core
Version:
Extensible Enketo form engine
323 lines (292 loc) • 10.7 kB
JavaScript
import { t } from 'enketo/translator';
import Widget from '../../js/widget';
import events from '../../js/event';
import { getSiblingElement } from '../../js/dom-utils';
const SELECTORS = 'path[id], g[id], circle[id]';
/**
* Image Map widget that turns an SVG image into a clickable map
* by matching radiobutton/checkbox values with id attribute values in the SVG.
*
* @augments Widget
*/
class ImageMap extends Widget {
/**
* @type {string}
*/
static get selector() {
return '.simple-select.or-appearance-image-map label:first-child > input';
}
_init() {
const img = this.question.querySelector('img');
this.question.classList.add('or-image-map-initialized');
/*
* To facilitate Enketo Express' offline webforms,
* where the img source is populated after form loading, we initialize upon image load
* if the src attribute is not yet populated.
*
* We could use the same with online-only forms, but that would cause a loading delay.
*/
if (!img) {
this._showSvgNotFoundError();
} else if (img.getAttribute('src')) {
// return a promise, resolving with instance for asynchronous initialization
return this._addMarkup(img)
.then(this._addFunctionality.bind(this))
.then(() => this);
} else {
return new Promise((resolve) => {
img.addEventListener('load', () => {
this._addMarkup(img).then(
this._addFunctionality.bind(this)
);
resolve(this);
});
});
// Ignore errors, because an img element without source may throw one.
// E.g. in Enketo Express inside a repeat: https://github.com/kobotoolbox/enketo-express/issues/961
}
}
/**
* @param {object} widget - the widget element
*/
_addFunctionality(widget) {
if (widget) {
this.svg = widget.querySelector('svg');
this.tooltip = widget.querySelector('.image-map__ui__tooltip');
if (this.props.readonly) {
this.disable();
}
this._setSvgClickHandler();
this._setChangeHandler();
this._setHoverHandler();
this._updateImage();
this._setPageHandler();
}
}
/**
* @param {Element} img - the image element
* @return {Promise} the widget element
*/
_addMarkup(img) {
const that = this;
const src = img.getAttribute('src');
/**
* For translated forms, we now discard everything except the first image,
* since we're assuming the images will be the same in all languages.
*/
return fetch(src)
.then((response) => response.text())
.then((txt) => new DOMParser().parseFromString(txt, 'text/xml'))
.then((doc) => {
if (that._isSvgDoc(doc)) {
const svgFragment = that._removeUnmatchedIds(
doc.querySelector('svg')
);
const fragment = document
.createRange()
.createContextualFragment(
`<div class="widget image-map">
<div class="image-map__ui">
<span class="image-map__ui__tooltip"></span>
</div>
</div>`
);
fragment.querySelector('.widget').append(svgFragment);
// remove images in all languages
that.question
.querySelectorAll('img')
.forEach((el) => el.remove());
that.question
.querySelector('fieldset > .option-wrapper')
.before(fragment);
const widget = that.question.querySelector('.image-map');
const svg = widget.querySelector('svg');
// Use any explicitly defined viewPort and else define one using bounding box or attributes
if (!svg.getAttribute('viewBox')) {
this._setViewBox(svg);
}
return widget;
}
throw 'Image is not an SVG doc';
})
.catch(this._showSvgNotFoundError.bind(that));
}
_setViewBox(svg) {
let viewBox;
try {
// Resize, using original unscaled SVG dimensions
// Note that width and height will be zero if the SVG is currently not visible
const bbox = svg.getBBox();
viewBox = `${bbox.x} ${bbox.y} ${bbox.width} ${bbox.height}`;
} catch (e) {
// svg.getBBox() only works after SVG has been added to DOM.
// In FF getBBox causes an "NS_ERROR_FAILURE" exception likely because the SVG
// image has not finished rendering. This doesn't always happen though.
// For now, we just log the FF error, and hope that resizing is done correctly via
// attributes.
console.error('Could not obtain Boundary Box of SVG element', e);
const width = svg.getAttribute('width');
const height = svg.getAttribute('height');
if (width && height) {
viewBox = `0 0 ${parseInt(width, 10)} ${parseInt(height, 10)}`;
}
}
svg.setAttribute('viewBox', viewBox);
}
/**
* @param {Error} err - error message
*/
_showSvgNotFoundError(err) {
console.error(err);
const fragment = document.createRange().createContextualFragment(
`<div class="widget image-map">
<div class="image-map__error" data-i18n="imagemap.svgNotFound">${t(
'imagemap.svgNotFound'
)}</div>
</div>`
);
this.question.querySelector('.option-wrapper').before(fragment);
}
/**
* Removes id attributes from unmatched path elements in order to prevent hover effect (and click listener).
*
* @param {Element} svg - SVG element
* @return {Element} cleaned up SVG
*/
_removeUnmatchedIds(svg) {
svg.querySelectorAll(SELECTORS).forEach((el) => {
if (!this._getInput(el.id)) {
el.removeAttribute('id');
}
});
return svg;
}
/**
* @param {string} id - the option ID
* @return {Element} input element with matching ID
*/
_getInput(id) {
return this.question.querySelector(`input[value="${CSS.escape(id)}"]`);
}
/**
* Handles SVG click listener
*/
_setSvgClickHandler() {
this.svg.addEventListener('click', (ev) => {
if (
!ev.target.closest('svg').matches('[or-readonly]') &&
(ev.target.matches(SELECTORS) || ev.target.closest(SELECTORS))
) {
const id = ev.target.id || ev.target.closest('g[id]').id;
const input = this._getInput(id);
if (input) {
input.checked = !input.checked;
input.dispatchEvent(events.Change());
input.dispatchEvent(events.FakeFocus());
}
}
});
}
/**
* Handles change listener
*/
_setChangeHandler() {
this.question.addEventListener('change', this._updateImage.bind(this));
}
/**
* Handles hover listener
*/
_setHoverHandler() {
this.svg.querySelectorAll(SELECTORS).forEach((el) => {
el.addEventListener('mouseenter', (ev) => {
const id = ev.target.id || ev.target.closest('g[id]').id;
const label = getSiblingElement(
this._getInput(id),
'.option-label.active'
);
const optionLabel = label ? label.textContent : '';
this.tooltip.textContent = optionLabel;
});
el.addEventListener('mouseleave', (ev) => {
if (ev.target.matches(SELECTORS)) {
this.tooltip.textContent = '';
}
});
});
}
/**
* Handles page flip of page in which the widget is placed.
*/
_setPageHandler() {
const page = this.element.closest('[role="page"]');
if (page) {
page.addEventListener(events.PageFlip().type, () =>
this._setViewBox(this.svg)
);
}
}
/**
* @param {object} data - an object
* @return {boolean} whether provided object is an SVG document
*/
_isSvgDoc(data) {
return typeof data === 'object' && data.querySelector('svg');
}
/**
* Updates 'selected' attributes in SVG
* Always update the map after the value has changed in the original input elements
*/
_updateImage() {
let values = this.originalInputValue;
this.svg
.querySelectorAll(
'path[or-selected], g[or-selected], circle[or-selected]'
)
.forEach((el) => el.removeAttribute('or-selected'));
if (typeof values === 'string') {
values = [values];
}
values.forEach((value) => {
if (value) {
// if multiple values have the same id, change all of them (e.g. a province that is not contiguous)
this.svg
.querySelectorAll(
`path#${CSS.escape(value)},g#${CSS.escape(
value
)},circle#${CSS.escape(value)}`
)
.forEach((el) => el.setAttribute('or-selected', ''));
}
});
}
/**
* Disables widget
*/
disable() {
this.svg.setAttribute('or-readonly', '');
}
/**
* Enables widget
*/
enable() {
this.svg.removeAttribute('or-readonly');
}
/**
* Updates widget image
*/
update() {
this._updateImage();
}
/**
* @type {string}
*/
get value() {
// This widget is unusual. It would better to get the value from the map.
return this.originalInputValue;
}
set value(value) {
// This widget is unusual. It would more consistent to set the value in the map perhaps.
this.originalInputValue = value;
}
}
export default ImageMap;