UNPKG

@geogirafe/lib-geoportal

Version:

GeoGirafe is a flexible application to build online geoportals.

334 lines (333 loc) 12.1 kB
// SPDX-License-Identifier: Apache-2.0 import { render as uRender, html as uHtml } from 'uhtml'; import { updateRessourcesPaths } from '../api/apiutils.js'; class GirafeHTMLElement extends HTMLElement { template; name; shadow; displayStyle; timeoutId; rendered = false; feedbackTemplateHtml; fullscreenModeActive = false; displayBeforeFullscreen = null; callbacks = []; unsafeCache = new Map(); _context; /** * This attribute is used to allow defining custom style for a component * If it contains data, it will be integrated into the component at runtime */ static _customStyles = {}; get customStyle() { if (this.name in GirafeHTMLElement._customStyles) { return GirafeHTMLElement._customStyles[this.name]; } // No custom css : Return empty string return ''; } static setCustomStyle(componentName, customCss) { GirafeHTMLElement._customStyles[componentName] = customCss; } get context() { if (!this._context) { throw new Error('No context !!!'); } return this._context; } constructor(name, context) { super(); this.name = name; if (context) { this._context = context; } this.shadow = this.attachShadow({ mode: 'open' }); } get state() { return this.context.stateManager.state; } getById(id) { return this.shadow.getElementById(id); } getChildElement(selector) { return this.shadow.querySelector(selector); } girafeTranslate() { this.context.i18nManager.translate(this.shadow); } userInfoChanged() { this.context.pluginManager.filterPlugins(this.shadow); } /** * NOTE REG: We cannot just use truthy here, because javascript comparaison table is really problematic. * For example: * 0 == false * [] == false * "" == false * And there are cases where we want to check null or undefined, because 0 can be a right value. * More here : https://dorey.github.io/JavaScript-Equality-Table/ * @param val * @returns */ isNullOrUndefined(val) { return val === undefined || val === null; } isNullOrUndefinedOrBlank(val) { return val === undefined || val === null || val === ''; } getParentOfType(parentNodeName, elem, initialElem = elem) { // Stop case : we found null or an object of the right type if (elem === null || (elem !== initialElem && elem.nodeName === parentNodeName)) { return elem; } // Otherwise, we try to find a parent recursively const parent = elem instanceof ShadowRoot ? elem.host : elem.parentNode; return this.getParentOfType(parentNodeName, parent, elem); } /** * Render the component's template. */ render() { // TODO REG : Reactivate this check and fix all the code. /*if (this.rendered) { throw Error('Component already rendered. Please call refreshRender() instead.'); }*/ if (this.template) { this.defineDisplayStyle(); this.show(); this.renderInternal(); this.rendered = true; this.userInfoChanged(); } else { console.warn(`Cannot render: no template has been defined for component ${this.name}.`); } } insertFeedbackTemplate(template) { this.feedbackTemplateHtml = template; } /** * Re-Render the component. * The method should be called when the component * has already been rendered and needs to be updated. */ refreshRender() { if (!this.rendered) { throw Error('Component cannot be re-rendered. Please call render() first.'); } this.renderInternal(); // Use a debouncing to prevent multiple execution of this method // If multiple refresh at the same time are called. if (this.timeoutId) { clearTimeout(this.timeoutId); } this.timeoutId = setTimeout(() => { this.girafeTranslate(); this.userInfoChanged(); }); } renderInternal() { // Call to uRender MUST stay synchron (no timeout) // Otherwise, this cause unwanted effects like delay when updating templates uRender(this.shadow, this.template); if (this.context.stateManager.state.interface.isApi) { // For the API, we must ensure that URLs are not relative to the corent page // But relative to the page where they are imported from (import.meta.url) updateRessourcesPaths(this.shadow); } } /** * Renders a hidden span with the name of the component. * Useful to render a placeholder for not visible component. */ renderEmpty() { this.defineDisplayStyle(); this.hide(); uRender(this.shadow, uHtml `<span style="display: none">${this.name}</span>`); this.rendered = false; } /** * Convert the string in parameter with uHtml and return it. * This allows to convert a string with html in a right html object. * For example, htmlUnsafe('<div></div>') will return an html div object. */ htmlUnsafe(str) { // NOTE REG: If this method is used much more in the future, we will have to take care of memory leaks // see discussion here: https://github.com/WebReflection/uhtml/issues/126 const template = this.getUnsafeTemplate(str); return uHtml(template); } /** * Convert a string to TemplateStringsArray * Manage a cache of all the created objects to limit memory usage */ getUnsafeTemplate(str) { let template = this.unsafeCache.get(str); if (!template) { template = [str]; this.unsafeCache.set(str, template); } return template; } /** * Remember the initial display configuration of the component * To be able to restore it */ defineDisplayStyle() { if (!this.displayStyle) { // Remember the initial display style this.displayStyle = getComputedStyle(this).display ?? 'block'; if (this.displayStyle === 'none') { this.displayStyle = 'block'; } } } /** * In the templates, sometimes for accessibility reasons, we have to support the KeyDown Event * In those case, we often juste want to do the same as the click event when Enter or Space is pressed * Then this method can be used : it just calls the click event on the same element */ simulateClick(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); const target = e.target; if (target) { target.click(); } } } /** * Hide the component (display: none). */ hide() { this.setHostDisplay('none'); this.unregisterInteractionListeners(); } /** * Show the component (display: block). */ show() { if (this.displayStyle) { this.setHostDisplay(this.displayStyle); } } /** * Whether this Component must stay visible when the App is in Fullscreen Mode. * Controlled by the "fullscreen" attribute (default: false). */ get isFullscreenComponent() { return this.getAttribute('fullscreen') === 'true'; } /** * Apply a display value to the Host Element, taking the Fullscreen Mode into account. * In Fullscreen Mode, components that are not flagged with fullscreen="true" are forced * hidden, but their intended display value is remembered so it can be restored when * leaving Fullscreen Mode. */ setHostDisplay(display) { if (this.fullscreenModeActive && !this.isFullscreenComponent) { this.displayBeforeFullscreen = display; this.style.display = 'none'; } else { this.style.display = display; } } /** * Handler for Changes of <code>interface.fullscreenMode</code>. * Components flagged with fullscreen="true" are always visible and left untouched. * Other Components are hidden when entering Fullscreen Mode and restored to their * previous visibility when leaving it. */ onFullscreenModeChanged(fullscreenMode) { if (fullscreenMode === this.fullscreenModeActive) { return; } this.fullscreenModeActive = fullscreenMode; if (this.isFullscreenComponent) { return; } if (fullscreenMode) { // Entering Fullscreen Mode: remember the current visibility and hide the component. this.displayBeforeFullscreen = this.style.display; this.style.display = 'none'; } else if (this.displayBeforeFullscreen !== null) { // Leaving Fullscreen Mode: restore the visibility the component had before. this.style.display = this.displayBeforeFullscreen; this.displayBeforeFullscreen = null; } } subscribe(path, callback) { // @ts-expect-error The call would have succeeded against this implementation, // but implementation signatures of overloads are not externally visible. const subscription = this.context.stateManager.subscribe(path, callback); this.callbacks.push(subscription); return subscription; } unsubscribe(callbacks) { (Array.isArray(callbacks) ? callbacks : [callbacks]).forEach((callback) => { const index = this.callbacks.findIndex((c) => c === callback); if (index >= 0) { this.context.stateManager.unsubscribe(callback); this.callbacks.splice(index, 1); } }); } connectedCallback() { this._context = this.getInheritedContext(); this.context.componentManager.registerComponent(this); this.subscribe('language', () => this.girafeTranslate()); this.subscribe('oauth.userInfo', () => this.userInfoChanged()); this.subscribe('interface.fullscreenMode', (_oldValue, newValue) => this.onFullscreenModeChanged(newValue)); } /** * When the component is disconnected from the DOM * all the callbacks will be unregistered */ disconnectedCallback() { for (const callback of this.callbacks) { this.context.stateManager.unsubscribe(callback); } this.callbacks.length = 0; this.unregisterInteractionListeners(); } registerInteractionListener(eventName, isExclusive) { return this.context.userInteractionManager.registerListener(eventName, isExclusive, this.name); } unregisterInteractionListeners(eventNames) { if (!eventNames) { this.context.userInteractionManager.unregisterAllListenersOfTool(this.name); } if (!Array.isArray(eventNames)) { eventNames = [eventNames]; } for (const event of eventNames) { this.context.userInteractionManager.unregisterListener(event, this.name); } } canExecute(eventName) { return this.context.userInteractionManager.canListenerExecute(eventName, this.name); } getInheritedContext() { if (this._context) { // The context was already initialized in the constructor return this._context; } let parent = this.parentNode; do { if (parent instanceof ShadowRoot) { parent = parent.host; } if (parent instanceof GirafeHTMLElement) { return parent.context; } parent = parent?.parentNode ?? null; } while (parent && parent !== document); if (parent === document) { return parent.geogirafe.context; } throw new Error('No context was found !'); } } export default GirafeHTMLElement;