@geogirafe/lib-geoportal
Version:
GeoGirafe is a flexible application to build online geoportals.
334 lines (333 loc) • 12.1 kB
JavaScript
// 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;