diff --git a/core/static/bundled/core/components/include-index.ts b/core/static/bundled/core/components/include-index.ts index b50cdbc2..657c3127 100644 --- a/core/static/bundled/core/components/include-index.ts +++ b/core/static/bundled/core/components/include-index.ts @@ -1,40 +1,106 @@ import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts"; +/** + * Create an abstract class for ElementOnce types Web Components + * + * Those class aren't really abstract because that would be complicated with the + * multiple inheritance involved + * Instead, we just raise an unimplemented error + **/ +function elementOnce(tagName: K) { + return class ElementOnce extends inheritHtmlElement(tagName) { + getElementQuerySelector(): string { + throw new Error("Unimplemented"); + } + + clearNode() { + while (this.firstChild) { + this.removeChild(this.lastChild); + } + } + + refresh() { + this.clearNode(); + if (document.querySelectorAll(this.getElementQuerySelector()).length === 0) { + this.appendChild(this.node); + } + } + + connectedCallback() { + super.connectedCallback(false); + this.refresh(); + } + + disconnectedCallback() { + // The MutationObserver can't see web components being removed + // It also can't see if something is removed inside after the component gets deleted + // We need to manually clear the containing node to trigger the observer + this.clearNode(); + } + }; +} + +// Set of ElementOnce type components to refresh with the observer +const registeredComponents: Set = new Set(); + +/** + * Helper to register ElementOnce types Web Components + * It's a wrapper around registerComponent that registers that component on + * a MutationObserver that activates a refresh on them when elements are removed + **/ +function registerElementOnce(name: string, options?: ElementDefinitionOptions) { + registeredComponents.add(name); + return registerComponent(name, options); +} + +const startObserver = (observer: MutationObserver) => { + observer.observe(document, { + // We want to also listen for elements contained in the header (eg: link) + subtree: true, + childList: true, + }); +}; + +// Refresh *-once components when changes happens +const observer = new MutationObserver((mutations: MutationRecord[]) => { + observer.disconnect(); + for (const mutation of mutations) { + for (const node of mutation.removedNodes) { + if (node.nodeType !== node.ELEMENT_NODE) { + continue; + } + const refreshElement = (componentName: string, tagName: string) => { + for (const element of document.getElementsByTagName(componentName)) { + // We can't guess if an element is compatible before we get one + // We exit the function completely if it's not compatible + if ( + (element as any).inheritedTagName.toUpperCase() !== tagName.toUpperCase() + ) { + return; + } + + (element as any).refresh(); + } + }; + for (const registered of registeredComponents) { + refreshElement(registered, (node as HTMLElement).tagName); + } + } + } + startObserver(observer); +}); + +startObserver(observer); + /** * Web component used to import css files only once * If called multiple times or the file was already imported, it does nothing **/ -@registerComponent("link-once") -export class LinkOnce extends inheritHtmlElement("link") { - refresh() { - this.clearNode(); - +@registerElementOnce("link-once") +export class LinkOnce extends elementOnce("link") { + getElementQuerySelector(): string { // We get href from node.attributes instead of node.href to avoid getting the domain part - const href = this.node.attributes.getNamedItem("href").nodeValue; - if (document.querySelectorAll(`link[href='${href}']`).length === 0) { - this.appendChild(this.node); - } - } - - clearNode() { - while (this.firstChild) { - this.removeChild(this.lastChild); - } - } - - connectedCallback() { - super.connectedCallback(false); - this.refresh(); - } - - disconnectedCallback() { - this.clearNode(); - - // This re-triggers link-once elements that still exists and suppressed - // themeselves once it gets removed from the page - for (const link of document.getElementsByTagName("link-once")) { - (link as LinkOnce).refresh(); - } + return `link[href='${this.node.attributes.getNamedItem("href").nodeValue}']`; } } @@ -42,36 +108,10 @@ export class LinkOnce extends inheritHtmlElement("link") { * Web component used to import javascript files only once * If called multiple times or the file was already imported, it does nothing **/ -@registerComponent("script-once") +@registerElementOnce("script-once") export class ScriptOnce extends inheritHtmlElement("script") { - refresh() { - this.clearNode(); - - // We get src from node.attributes instead of node.src to avoid getting the domain part - const src = this.node.attributes.getNamedItem("src").nodeValue; - if (document.querySelectorAll(`script[src='${src}']`).length === 0) { - this.appendChild(this.node); - } - } - - clearNode() { - while (this.firstChild) { - this.removeChild(this.lastChild); - } - } - - connectedCallback() { - super.connectedCallback(false); - this.refresh(); - } - - disconnectedCallback() { - this.clearNode(); - - // This re-triggers script-once elements that still exists and suppressed - // themeselves once it gets removed from the page - for (const link of document.getElementsByTagName("script-once")) { - (link as LinkOnce).refresh(); - } + getElementQuerySelector(): string { + // We get href from node.attributes instead of node.src to avoid getting the domain part + return `script[src='${this.node.attributes.getNamedItem("src").nodeValue}']`; } } diff --git a/core/static/bundled/utils/web-components.ts b/core/static/bundled/utils/web-components.ts index 8d1dbc47..fc4315cc 100644 --- a/core/static/bundled/utils/web-components.ts +++ b/core/static/bundled/utils/web-components.ts @@ -36,6 +36,7 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio **/ export function inheritHtmlElement(tagName: K) { return class Inherited extends HTMLElement { + readonly inheritedTagName = tagName; protected node: HTMLElementTagNameMap[K]; connectedCallback(autoAddNode?: boolean) {