UNPKG

samagra-x-enketo-core

Version:

Extensible Enketo form engine

293 lines (263 loc) 10.4 kB
import Widget from '../../js/widget'; import { t } from 'enketo/translator'; 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; } else { 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 ); let width = svg.getAttribute( 'width' ); let 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;