UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

1,990 lines (1,710 loc) 47.8 kB
/** * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact Volker Schukai. * * SPDX-License-Identifier: AGPL-3.0 */ import { internalSymbol } from "../constants.mjs"; import { diff } from "../data/diff.mjs"; import { Pathfinder } from "../data/pathfinder.mjs"; import { Pipe } from "../data/pipe.mjs"; import { ATTRIBUTE_ERRORMESSAGE, ATTRIBUTE_UPDATER_ATTRIBUTES, ATTRIBUTE_UPDATER_BIND, ATTRIBUTE_UPDATER_BIND_TYPE, ATTRIBUTE_UPDATER_INSERT, ATTRIBUTE_UPDATER_INSERT_REFERENCE, ATTRIBUTE_UPDATER_PROPERTIES, ATTRIBUTE_UPDATER_PATCH, ATTRIBUTE_UPDATER_PATCH_KEY, ATTRIBUTE_UPDATER_PATCH_RENDER, ATTRIBUTE_UPDATER_REMOVE, ATTRIBUTE_UPDATER_REPLACE, ATTRIBUTE_UPDATER_SELECT_THIS, customElementUpdaterLinkSymbol, } from "./constants.mjs"; import { Base } from "../types/base.mjs"; import { isArray, isInteger, isString, isInstance, isIterable, } from "../types/is.mjs"; import { Observer } from "../types/observer.mjs"; import { ProxyObserver } from "../types/proxyobserver.mjs"; import { validateArray, validateInstance } from "../types/validate.mjs"; import { clone } from "../util/clone.mjs"; import { trimSpaces } from "../util/trimspaces.mjs"; import { addAttributeToken, addToObjectLink, getLinkedObjects, hasObjectLink, removeObjectLink, } from "./attributes.mjs"; import { CustomElement, updaterTransformerMethodsSymbol, } from "./customelement.mjs"; import { findTargetElementFromEvent } from "./events.mjs"; import { findDocumentTemplate } from "./template.mjs"; import { getWindow } from "./util.mjs"; import { DeadMansSwitch } from "../util/deadmansswitch.mjs"; import { addErrorAttribute, removeErrorAttribute } from "./error.mjs"; export { Updater, addObjectWithUpdaterToElement }; /** * @private * @type {symbol} */ const timerElementEventHandlerSymbol = Symbol("timerElementEventHandler"); /** * @private * @type {symbol} */ const pendingDiffsSymbol = Symbol("pendingDiffs"); /** * @private * @type {symbol} */ const processingSymbol = Symbol("processing"); const processQueueSymbol = Symbol("processQueue"); const applyChangeSymbol = Symbol("applyChange"); const updaterRootSymbol = Symbol.for("@schukai/monster/dom/@@updater-root"); const disposedSymbol = Symbol("disposed"); const subjectObserverSymbol = Symbol("subjectObserver"); const patchNodeKeySymbol = Symbol("patchNodeKey"); const queuedSnapshotSymbol = Symbol("queuedSnapshot"); /** * The updater class connects an object with the DOM. In this way, structures and contents in the DOM can be * programmatically adapted via attributes. * * For example, to include a string from an object, the attribute `data-monster-replace` * or the lifecycle-safe `data-monster-patch` can be used. * a further explanation can be found under [monsterjs.org](https://monsterjs.org/) * * Changes to attributes are made only when the direct values are changed. If you want to assign changes * to other values as well, you have to insert the attribute `data-monster-select-this`. This should be * done with care, as it can reduce performance. * * @fragments /fragments/libraries/dom/updater/ * * @example /examples/libraries/dom/updater/simple/ Simple example * * @license AGPLv3 * @since 1.8.0 * @copyright Volker Schukai * @throws {Error} the value is not iterable * @throws {Error} pipes are not allowed when cloning a node. * @throws {Error} no template was found with the specified key. * @throws {Error} the maximum depth for the recursion is reached. * @throws {TypeError} value is not a object * @throws {TypeError} value is not an instance of HTMLElement * @summary The updater class connects an object with the dom */ class Updater extends Base { /** * @since 1.8.0 * @param {HTMLElement} element * @param {object|ProxyObserver|undefined} subject * @throws {TypeError} value is not a object * @throws {TypeError} value is not an instance of HTMLElement * @see {@link findDocumentTemplate} */ constructor(element, subject) { super(); /** * @type {HTMLElement} */ if (subject === undefined) subject = {}; if (!isInstance(subject, ProxyObserver)) { subject = new ProxyObserver(subject); } this[internalSymbol] = { element: validateInstance(element, HTMLElement), last: {}, callbacks: new Map(), eventTypes: ["keyup", "click", "change", "drop", "touchend", "input"], subject: subject, features: { batchUpdates: false, }, }; this[internalSymbol].callbacks.set( "checkstate", getCheckStateCallback.call(this), ); this[pendingDiffsSymbol] = []; this[processingSymbol] = false; this[disposedSymbol] = false; this[queuedSnapshotSymbol] = clone(this[internalSymbol].last); this[subjectObserverSymbol] = new Observer(() => { if (this[disposedSymbol] === true) { return Promise.resolve(); } const real = this[internalSymbol].subject.getRealSubject(); const diffResult = diff(this[queuedSnapshotSymbol], real); if (diffResult.length === 0) { return Promise.resolve(); } const snapshot = clone(real); this[queuedSnapshotSymbol] = snapshot; this[pendingDiffsSymbol].push({ diffResult, snapshot }); return this[processQueueSymbol](); }); this[internalSymbol].subject.attachObserver(this[subjectObserverSymbol]); } /** * @private * @return {Promise} */ async [processQueueSymbol]() { if (this[disposedSymbol] === true) { return Promise.resolve(); } if (this[processingSymbol]) { return Promise.resolve(); } this[processingSymbol] = true; try { while (this[pendingDiffsSymbol].length) { if (this[disposedSymbol] === true) { this[pendingDiffsSymbol].length = 0; return Promise.resolve(); } const { diffResult, snapshot } = this[pendingDiffsSymbol].shift(); if (this[internalSymbol].features.batchUpdates === true) { const updatePaths = new Map(); for (const change of Object.values(diffResult)) { removeElement.call(this, change); insertElement.call(this, change); const path = isArray(change?.["path"]) ? change["path"] : null; if (!path) { continue; } if (path.length === 0) { updatePaths.set("", path); continue; } const root = path[0]; if (!updatePaths.has(root)) { updatePaths.set(root, [root]); } } for (const path of updatePaths.values()) { updateContent.call(this, { path }); updateAttributes.call(this, { path }); updateProperties.call(this, { path }); } } else { for (const change of Object.values(diffResult)) { await this[applyChangeSymbol](change); } } this[internalSymbol].last = clone(snapshot); } } catch (err) { addErrorAttribute(this[internalSymbol]?.element, err); } finally { this[processingSymbol] = false; } } /** @private **/ async [applyChangeSymbol](change) { if (this[disposedSymbol] === true) { return Promise.resolve(); } removeElement.call(this, change); insertElement.call(this, change); updateContent.call(this, change); await Promise.resolve(); updateAttributes.call(this, change); updateProperties.call(this, change); } /** * Defaults: 'keyup', 'click', 'change', 'drop', 'touchend' * * @see {@link https://developer.mozilla.org/de/docs/Web/Events} * @since 1.9.0 * @param {Array} types * @return {Updater} */ setEventTypes(types) { this[internalSymbol].eventTypes = validateArray(types); return this; } /** * Enable or disable batched update processing. * * @since 4.104.0 * @param {boolean} enabled * @return {Updater} */ setBatchUpdates(enabled) { this[internalSymbol].features.batchUpdates = enabled === true; return this; } /** * With this method, the eventlisteners are hooked in and the magic begins. * * ```js * updater.run().then(() => { * updater.enableEventProcessing(); * }); * ``` * * @since 1.9.0 * @return {Updater} * @throws {Error} the bind argument must start as a value with a path */ enableEventProcessing() { if (this[disposedSymbol] === true) { return this; } this.disableEventProcessing(); this[internalSymbol].element[updaterRootSymbol] = true; for (const type of this[internalSymbol].eventTypes) { // @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener this[internalSymbol].element.addEventListener( type, getControlEventHandler.call(this), { capture: true, passive: true, }, ); } return this; } /** * This method turns off the magic or who loves it more profane it removes the eventListener. * * @since 1.9.0 * @return {Updater} */ disableEventProcessing() { for (const type of this[internalSymbol].eventTypes) { this[internalSymbol].element.removeEventListener( type, getControlEventHandler.call(this), ); } delete this[internalSymbol].element[updaterRootSymbol]; return this; } dispose() { if (this[disposedSymbol] === true) { return this; } this[disposedSymbol] = true; this.disableEventProcessing(); this[pendingDiffsSymbol].length = 0; if (this[subjectObserverSymbol] instanceof Observer) { this[internalSymbol].subject.detachObserver(this[subjectObserverSymbol]); } delete this[timerElementEventHandlerSymbol]; return this; } isDisposed() { return this[disposedSymbol] === true; } /** * The run method must be called for the update to start working. * The method ensures that changes are detected. * * ```js * updater.run().then(() => { * updater.enableEventProcessing(); * }); * ``` * * @summary Let the magic begin * @return {Promise} */ run() { if (this[disposedSymbol] === true) { return Promise.resolve(); } // the key __init__has no further meaning and is only // used to create the diff for empty objects. this[internalSymbol].last = { __init__: true }; this[queuedSnapshotSymbol] = clone(this[internalSymbol].last); return this[internalSymbol].subject.notifyObservers(); } /** * Gets the values of bound elements and changes them in subject * * @since 1.27.0 * @return {Monster.DOM.Updater} */ retrieve() { if (this[disposedSymbol] === true) { return this; } retrieveFromBindings.call(this); return this; } /** * If you have passed a ProxyObserver in the constructor, you will get the object that the ProxyObserver manages here. * However, if you passed a simple object, here you will get a proxy for that object. * * For changes, the ProxyObserver must be used. * * @since 1.8.0 * @return {Proxy} */ getSubject() { return this[internalSymbol].subject.getSubject(); } /** * This method can be used to register commands that can be called via call: instruction. * This can be used to provide a pipe with its own functionality. * * @param {string} name * @param {function} callback * @return {Transformer} * @throws {TypeError} value is not a string * @throws {TypeError} value is not a function */ setCallback(name, callback) { this[internalSymbol].callbacks.set(name, callback); return this; } } /** * @private * @license AGPLv3 * @since 1.9.0 * @return {function * @this Updater */ function getCheckStateCallback() { return function (current) { // this is a reference to the current object (therefore no array function here) if (this instanceof HTMLInputElement) { if (["radio", "checkbox"].indexOf(this.type) !== -1) { return `${this.value}` === `${current}` ? "true" : undefined; } } else if (this instanceof HTMLOptionElement) { if (isArray(current) && current.indexOf(this.value) !== -1) { return "true"; } return undefined; } }; } /** * @private */ const symbol = Symbol("@schukai/monster/updater@@EventHandler"); /** * @private * @return {function} * @this Updater * @throws {Error} the bind argument must start as a value with a path */ function getControlEventHandler() { if (this[symbol]) { return this[symbol]; } /** * @throws {Error} the bind argument must start as a value with a path. * @throws {Error} unsupported object * @param {Event} event */ this[symbol] = (event) => { if ( this[disposedSymbol] === true || !this[internalSymbol].element?.isConnected ) { return; } const root = findClosestUpdaterRootFromEvent(event); if (root !== this[internalSymbol].element) { return; } const element = findTargetElementFromEvent(event, ATTRIBUTE_UPDATER_BIND); if (element === undefined) { return; } if (this[timerElementEventHandlerSymbol] instanceof DeadMansSwitch) { try { this[timerElementEventHandlerSymbol].touch(); return; } catch (e) { delete this[timerElementEventHandlerSymbol]; } } this[timerElementEventHandlerSymbol] = new DeadMansSwitch(50, () => { if ( this[disposedSymbol] === true || !this[internalSymbol].element?.isConnected || !element?.isConnected ) { return; } try { retrieveAndSetValue.call(this, element); } catch (e) { addErrorAttribute(element, e); } }); }; return this[symbol]; } /** * @private * @param {Event} event * @return {HTMLElement|undefined} */ function findClosestUpdaterRootFromEvent(event) { if (typeof event?.composedPath !== "function") { return undefined; } const path = event.composedPath(); for (let i = 0; i < path.length; i++) { const node = path[i]; if (node instanceof HTMLElement && node[updaterRootSymbol] === true) { return node; } } return undefined; } /** * @throws {Error} the bind argument must start as a value with a path * @param {HTMLElement} element * @return void * @private */ function retrieveAndSetValue(element) { const pathfinder = new Pathfinder(this[internalSymbol].subject.getSubject()); let path = element.getAttribute(ATTRIBUTE_UPDATER_BIND); if (path === null) throw new Error("the bind argument must start as a value with a path"); if (path.indexOf("path:") !== 0) { throw new Error("the bind argument must start as a value with a path"); } path = path.substring(5); // remove path: from the string let value; if (element instanceof HTMLInputElement) { switch (element.type) { case "checkbox": value = element.checked ? element.value : undefined; break; default: value = element.value; break; } } else if (element instanceof HTMLTextAreaElement) { value = element.value; } else if (element instanceof HTMLSelectElement) { switch (element.type) { case "select-one": value = element.value; break; case "select-multiple": value = element.value; let options = element?.selectedOptions; if (options === undefined) options = element.querySelectorAll(":scope option:checked"); value = Array.from(options).map(({ value }) => value); break; } // values from custom elements } else if ( (element?.constructor?.prototype && !!Object.getOwnPropertyDescriptor( element.constructor.prototype, "value", )?.["get"]) || "value" in element ) { value = element?.["value"]; } else { throw new Error("unsupported object"); } const type = element.getAttribute(ATTRIBUTE_UPDATER_BIND_TYPE); switch (type) { case "integer?": case "int?": case "number?": { const empty = value === undefined || value === null || (isString(value) && value.trim() === ""); value = Number(value); if (empty || isNaN(value)) { value = undefined; } break; } case "number": case "int": case "float": case "integer": value = Number(value); if (isNaN(value)) { value = 0; } break; case "boolean": case "bool": case "checkbox": value = value === "true" || value === "1" || value === "on" || value === true; break; case "string[]": case "[]string": if (isString(value)) { if (value.trim() === "") { value = []; } else { value = value.split(",").map((v) => `${v}`); } } else if (isInteger(value)) { value = [`${value}`]; } else if (value === undefined || value === null) { value = []; } else if (isArray(value)) { value = value.map((v) => `${v}`); } else { throw new Error("unsupported value"); } break; case "int[]": case "integer[]": case "number[]": case "[]int": case "[]integer": case "[]number": value = parseIntArray(value); break; case "[]": case "array": case "list": if (isString(value)) { if (value.trim() === "") { value = []; } else { value = value.split(","); } } else if (isInteger(value)) { value = [value]; } else if (value === undefined || value === null) { value = []; } else if (isArray(value)) { // nothing to do } else { throw new Error("unsupported value for array"); } break; case "object": case "json": if (isString(value)) { value = JSON.parse(value); } else { throw new Error("unsupported value for object"); } break; default: break; } const copy = clone(this[internalSymbol].subject.getRealSubject()); const pf = new Pathfinder(copy); pf.setVia(path, value); const diffResult = diff(copy, this[internalSymbol].subject.getRealSubject()); if (diffResult.length > 0) { pathfinder.setVia(path, value); } } /** * @private */ function parseIntArray(val) { if (isString(val)) { return val.trim() === "" ? [] : val .split(",") .map((v) => parseInt(v, 10)) .filter((v) => !isNaN(v)); } else if (isInteger(val)) { return [val]; } else if (val === undefined || val === null) { return []; } else if (isArray(val)) { return val.map((v) => parseInt(v, 10)).filter((v) => !isNaN(v)); } throw new Error("unsupported value for int array"); } /** * @license AGPLv3 * @since 1.27.0 * @return void * @private */ function retrieveFromBindings() { if (this[internalSymbol].element.matches(`[${ATTRIBUTE_UPDATER_BIND}]`)) { retrieveAndSetValue.call(this, this[internalSymbol].element); } for (const [, element] of this[internalSymbol].element .querySelectorAll(`[${ATTRIBUTE_UPDATER_BIND}]`) .entries()) { retrieveAndSetValue.call(this, element); } } /** * @private * @license AGPLv3 * @since 1.8.0 * @param {object} change * @return {void} */ function removeElement(change) { for (const [, element] of this[internalSymbol].element .querySelectorAll(`:scope [${ATTRIBUTE_UPDATER_REMOVE}]`) .entries()) { teardownManagedSubtree(element); element.parentNode.removeChild(element); } } /** * @private * @license AGPLv3 * @since 1.8.0 * @param {object} change * @return {void} * @throws {Error} the value is not iterable * @throws {Error} pipes are not allowed when cloning a node. * @throws {Error} no template was found with the specified key. * @throws {Error} the maximum depth for the recursion is reached. * @this Updater */ function insertElement(change) { const subject = this[internalSymbol].subject.getRealSubject(); const mem = new WeakSet(); let wd = 0; const container = this[internalSymbol].element; while (true) { let found = false; wd++; const p = clone(change?.["path"]); if (!isArray(p)) return; while (p.length > 0) { const current = p.join("."); let iterator = new Set(); const query = `[${ATTRIBUTE_UPDATER_INSERT}*="path:${current}"]`; const e = container.querySelectorAll(query); if (e.length > 0) { iterator = new Set([...e]); } if (container.matches(query)) { iterator.add(container); } for (const [, containerElement] of iterator.entries()) { if (mem.has(containerElement)) continue; mem.add(containerElement); found = true; const attributes = containerElement.getAttribute( ATTRIBUTE_UPDATER_INSERT, ); if (attributes === null) continue; const def = trimSpaces(attributes); const i = def.indexOf(" "); const key = trimSpaces(def.substr(0, i)); const refPrefix = `${key}-`; const cmd = trimSpaces(def.substr(i)); // this case is actually excluded by the query but is nevertheless checked again here if (cmd.indexOf("|") > 0) { throw new Error("pipes are not allowed when cloning a node."); } const pipe = new Pipe(cmd); this[internalSymbol].callbacks.forEach((f, n) => { pipe.setCallback(n, f); }); let value; try { containerElement.removeAttribute(ATTRIBUTE_ERRORMESSAGE); value = pipe.run(subject); } catch (e) { addErrorAttribute(containerElement, e); } const dataPath = cmd.split(":").pop(); let insertPoint; if (containerElement.hasChildNodes()) { insertPoint = containerElement.lastChild; } if (!isIterable(value)) { throw new Error("the value is not iterable"); } const available = new Set(); for (const [i] of Object.entries(value)) { const ref = refPrefix + i; const currentPath = `${dataPath}.${i}`; available.add(ref); const refElement = containerElement.querySelector( `[${ATTRIBUTE_UPDATER_INSERT_REFERENCE}="${ref}"]`, ); if (refElement instanceof HTMLElement) { insertPoint = refElement; continue; } appendNewDocumentFragment(containerElement, key, ref, currentPath); } const nodes = containerElement.querySelectorAll( `[${ATTRIBUTE_UPDATER_INSERT_REFERENCE}*="${refPrefix}"]`, ); for (const [, node] of Object.entries(nodes)) { if ( !available.has( node.getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE), ) ) { try { teardownManagedSubtree(node); containerElement.removeChild(node); } catch (e) { addErrorAttribute(containerElement, e); } } } } p.pop(); } if (found === false) break; if (wd++ > 200) { throw new Error("the maximum depth for the recursion is reached."); } } } /** * * @private * @license AGPLv3 * @since 1.8.0 * @param {HTMLElement} container * @param {string} key * @param {string} ref * @param {string} path * @throws {Error} no template was found with the specified key. */ function appendNewDocumentFragment(container, key, ref, path) { const template = findDocumentTemplate(key, container); const nodes = template.createDocumentFragment(); for (const [, node] of Object.entries(nodes.childNodes)) { if (node instanceof HTMLElement) { applyRecursive(node, key, path); node.setAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE, ref); } container.appendChild(node); } } /** * @private * @license AGPLv3 * @since 1.10.0 * @param {HTMLElement} node * @param {string} key * @param {string} path * @return {void} */ function applyRecursive(node, key, path) { if (!(node instanceof HTMLElement)) { return; } if (node.hasAttribute(ATTRIBUTE_UPDATER_REPLACE)) { const value = node.getAttribute(ATTRIBUTE_UPDATER_REPLACE); node.setAttribute( ATTRIBUTE_UPDATER_REPLACE, value.replaceAll(`path:${key}`, `path:${path}`), ); } if (node.hasAttribute(ATTRIBUTE_UPDATER_PATCH)) { const value = node.getAttribute(ATTRIBUTE_UPDATER_PATCH); node.setAttribute( ATTRIBUTE_UPDATER_PATCH, value.replaceAll(`path:${key}`, `path:${path}`), ); } if (node.hasAttribute(ATTRIBUTE_UPDATER_PATCH_KEY)) { const value = node.getAttribute(ATTRIBUTE_UPDATER_PATCH_KEY); node.setAttribute( ATTRIBUTE_UPDATER_PATCH_KEY, value.replaceAll(`path:${key}`, `path:${path}`), ); } if (node.hasAttribute(ATTRIBUTE_UPDATER_PATCH_RENDER)) { const value = node.getAttribute(ATTRIBUTE_UPDATER_PATCH_RENDER); node.setAttribute( ATTRIBUTE_UPDATER_PATCH_RENDER, value.replaceAll(`path:${key}`, `path:${path}`), ); } if (node.hasAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES)) { const value = node.getAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES); node.setAttribute( ATTRIBUTE_UPDATER_ATTRIBUTES, value.replaceAll(`path:${key}`, `path:${path}`), ); } if (node.hasAttribute(ATTRIBUTE_UPDATER_PROPERTIES)) { const value = node.getAttribute(ATTRIBUTE_UPDATER_PROPERTIES); node.setAttribute( ATTRIBUTE_UPDATER_PROPERTIES, value.replaceAll(`path:${key}`, `path:${path}`), ); } if (node.hasAttribute(ATTRIBUTE_UPDATER_BIND)) { const value = node.getAttribute(ATTRIBUTE_UPDATER_BIND); node.setAttribute( ATTRIBUTE_UPDATER_BIND, value.replaceAll(`path:${key}`, `path:${path}`), ); } if (node.hasAttribute(ATTRIBUTE_UPDATER_INSERT)) { const value = node.getAttribute(ATTRIBUTE_UPDATER_INSERT); node.setAttribute( ATTRIBUTE_UPDATER_INSERT, value.replaceAll(`path:${key}`, `path:${path}`), ); } for (const [, child] of Object.entries(node.childNodes)) { if (child instanceof HTMLElement) { applyRecursive(child, key, path); } } } /** * @private * @license AGPLv3 * @since 1.8.0 * @param {object} change * @return {void} * @this Updater */ function updateContent(change) { const subject = this[internalSymbol].subject.getRealSubject(); const p = clone(change?.["path"]); runUpdateContent.call(this, this[internalSymbol].element, p, subject); runUpdatePatch.call(this, this[internalSymbol].element, p, subject); const slots = this[internalSymbol].element.querySelectorAll("slot"); if (slots.length > 0) { for (const [, slot] of Object.entries(slots)) { for (const [, element] of Object.entries(slot.assignedNodes())) { runUpdateContent.call(this, element, p, subject); runUpdatePatch.call(this, element, p, subject); } } } } /** * @private * @license AGPLv3 * @since 1.8.0 * @param {HTMLElement} container * @param {array} parts * @param {object} subject * @return {void} */ function runUpdateContent(container, parts, subject) { if (!isArray(parts)) return; if (!(container instanceof HTMLElement)) return; parts = clone(parts); const mem = new WeakSet(); while (parts.length > 0) { const current = parts.join("."); parts.pop(); // Unfortunately, static data is always changed as well, since it is not possible to react to changes here. const query = `[${ATTRIBUTE_UPDATER_REPLACE}^="path:${current}"], [${ATTRIBUTE_UPDATER_REPLACE}^="static:"], [${ATTRIBUTE_UPDATER_REPLACE}^="i18n:"]`; const e = container.querySelectorAll(`${query}`); const iterator = new Set([...e]); if (container.matches(query)) { iterator.add(container); } /** * @type {HTMLElement} */ for (const [element] of iterator.entries()) { if (mem.has(element)) continue; mem.add(element); const attributes = element.getAttribute(ATTRIBUTE_UPDATER_REPLACE); const cmd = trimSpaces(attributes); const pipe = new Pipe(cmd); this[internalSymbol].callbacks.forEach((f, n) => { pipe.setCallback(n, f); }); let value; try { element.removeAttribute(ATTRIBUTE_ERRORMESSAGE); value = pipe.run(subject); } catch (e) { element.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.message); } if (value instanceof HTMLElement) { teardownChildNodes(element); while (element.firstChild) { element.removeChild(element.firstChild); } try { element.appendChild(value); } catch (e) { addErrorAttribute(element, e); } } else { teardownChildNodes(element); element.innerHTML = value; } } } } function runUpdatePatch(container, parts, subject) { if (!isArray(parts)) return; if (!(container instanceof HTMLElement)) return; parts = clone(parts); const mem = new WeakSet(); while (parts.length > 0) { const current = parts.join("."); parts.pop(); const query = `[${ATTRIBUTE_UPDATER_PATCH}^="path:${current}"], [${ATTRIBUTE_UPDATER_PATCH}^="static:"], [${ATTRIBUTE_UPDATER_PATCH}^="i18n:"]`; const e = container.querySelectorAll(`${query}`); const iterator = new Set([...e]); if (container.matches(query)) { iterator.add(container); } for (const [element] of iterator.entries()) { if (mem.has(element)) continue; mem.add(element); const attributes = element.getAttribute(ATTRIBUTE_UPDATER_PATCH); const cmd = trimSpaces(attributes); const pipe = new Pipe(cmd); this[internalSymbol].callbacks.forEach((f, n) => { pipe.setCallback(n, f); }); let value; try { element.removeAttribute(ATTRIBUTE_ERRORMESSAGE); value = pipe.run(subject); } catch (e) { element.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.message); continue; } applyPatchValue.call(this, element, value); } } } function applyPatchValue(element, value) { if (!(element instanceof HTMLElement)) { return; } if (isArray(value)) { const keyDefinition = element.getAttribute(ATTRIBUTE_UPDATER_PATCH_KEY); if (isString(keyDefinition) && trimSpaces(keyDefinition) !== "") { replaceKeyedPatchedChildren.call( this, element, value, keyDefinition, element.getAttribute(ATTRIBUTE_UPDATER_PATCH_RENDER), ); return; } replacePatchedChildren(element, value); return; } if (isInstance(value, DocumentFragment)) { replacePatchedChildren(element, value); return; } if (value instanceof HTMLElement) { const existingChildren = Array.from(element.children); if (existingChildren.length === 1 && existingChildren[0] === value) { return; } teardownChildNodes(element); while (element.firstChild) { element.removeChild(element.firstChild); } try { element.appendChild(value); } catch (e) { addErrorAttribute(element, e); } return; } const nextValue = value === null || value === undefined ? "" : String(value); if (element.children.length > 0) { teardownChildNodes(element); } element.textContent = nextValue; } function replaceKeyedPatchedChildren( element, values, keyDefinition, renderDefinition, ) { if (!(element instanceof HTMLElement) || !isArray(values)) { return; } const keyPipe = new Pipe(trimSpaces(keyDefinition)); this[internalSymbol].callbacks.forEach((f, n) => { keyPipe.setCallback(n, f); }); const renderPipe = createPatchRenderPipe.call(this, renderDefinition); const existing = new Map(); for (const node of Array.from(element.childNodes)) { const key = node?.[patchNodeKeySymbol]; if (key !== undefined && !existing.has(key)) { existing.set(key, node); } } const nextNodes = []; for (const value of values) { let key; try { key = keyPipe.run(value); } catch (e) { addErrorAttribute(element, e); return; } let renderedValue; try { renderedValue = resolvePatchRenderValue(value, renderPipe); } catch (e) { addErrorAttribute(element, e); return; } const normalizedKey = key === null || key === undefined ? "" : String(key); if (existing.has(normalizedKey)) { const reused = existing.get(normalizedKey); existing.delete(normalizedKey); const nextNode = preparePatchedNode(reused, renderedValue); nextNode[patchNodeKeySymbol] = normalizedKey; if (nextNode !== reused && reused.parentNode === element) { element.replaceChild(nextNode, reused); } nextNodes.push(nextNode); continue; } let created; try { created = createSinglePatchedNode(renderedValue); } catch (e) { addErrorAttribute(element, e); return; } created[patchNodeKeySymbol] = normalizedKey; nextNodes.push(created); } for (const node of existing.values()) { if (node instanceof HTMLElement) { teardownManagedSubtree(node); } if (node.parentNode === element) { element.removeChild(node); } } for (const node of nextNodes) { if (node.parentNode !== element) { element.appendChild(node); continue; } if (element.lastChild !== node) { element.appendChild(node); } } } function preparePatchedNode(node, value) { if (node?.nodeType === Node.TEXT_NODE) { if ( value === null || value === undefined || isString(value) || typeof value === "number" || typeof value === "boolean" ) { node.textContent = value === null || value === undefined ? "" : String(value); return node; } return createSinglePatchedNode(value); } if (node instanceof HTMLElement) { if (value === node) { return node; } return createSinglePatchedNode(value); } return node; } function createPatchRenderPipe(renderDefinition) { if (!isString(renderDefinition) || trimSpaces(renderDefinition) === "") { return null; } const renderPipe = new Pipe(trimSpaces(renderDefinition)); this[internalSymbol].callbacks.forEach((f, n) => { renderPipe.setCallback(n, f); }); return renderPipe; } function resolvePatchRenderValue(value, renderPipe) { if (!(renderPipe instanceof Pipe)) { return value; } return renderPipe.run(value); } function replacePatchedChildren(element, value) { if (!(element instanceof HTMLElement)) { return; } const fragment = document.createDocumentFragment(); if (isArray(value)) { for (const entry of value) { appendPatchChild(fragment, entry); } } else if (isInstance(value, DocumentFragment)) { fragment.appendChild(value); } teardownChildNodes(element); while (element.firstChild) { element.removeChild(element.firstChild); } element.appendChild(fragment); } function appendPatchChild(fragment, value) { if (!(fragment instanceof DocumentFragment)) { return; } if (value === null || value === undefined) { return; } if (isArray(value)) { for (const entry of value) { appendPatchChild(fragment, entry); } return; } if (isInstance(value, DocumentFragment) || value instanceof HTMLElement) { fragment.appendChild(value); return; } fragment.appendChild(document.createTextNode(String(value))); } function createPatchedNodes(value) { const fragment = document.createDocumentFragment(); appendPatchChild(fragment, value); return Array.from(fragment.childNodes); } function createSinglePatchedNode(value) { const nodes = createPatchedNodes(value); if (nodes.length === 0) { return document.createTextNode(""); } if (nodes.length !== 1) { throw new Error("keyed patch values must resolve to a single node"); } return nodes[0]; } function teardownChildNodes(root) { if (!(root instanceof HTMLElement)) { return; } for (const child of Array.from(root.children)) { teardownManagedSubtree(child); } } function teardownManagedSubtree(root) { if (!(root instanceof HTMLElement)) { return; } const elements = [root, ...root.querySelectorAll("*")]; for (const element of elements) { if (!hasObjectLinkSafe(element, customElementUpdaterLinkSymbol)) { continue; } try { const linked = getLinkedObjects(element, customElementUpdaterLinkSymbol); for (const updaterSet of linked) { if (!(updaterSet instanceof Set)) { continue; } for (const updater of updaterSet) { if (typeof updater?.dispose === "function") { updater.dispose(); } } } removeObjectLink(element, customElementUpdaterLinkSymbol); } catch (e) { addErrorAttribute(element, e); } } } function hasObjectLinkSafe(element, symbol) { try { return hasObjectLink(element, symbol); } catch (e) { return false; } } /** * @private * @since 1.8.0 * @param {object} change * @return {void} */ function updateAttributes(change) { const subject = this[internalSymbol].subject.getRealSubject(); const p = clone(change?.["path"]); runUpdateAttributes.call(this, this[internalSymbol].element, p, subject); } /** * @private * @since 4.125.0 * @param {object} change * @return {void} */ function updateProperties(change) { const subject = this[internalSymbol].subject.getRealSubject(); const p = clone(change?.["path"]); runUpdateProperties.call(this, this[internalSymbol].element, p, subject); } /** * @private * @param {HTMLElement} container * @param {array} parts * @param {object} subject * @return {void} * @this Updater */ function runUpdateAttributes(container, parts, subject) { if (!isArray(parts)) return; parts = clone(parts); const mem = new WeakSet(); while (parts.length > 0) { const current = parts.join("."); parts.pop(); let iterator = new Set(); const query = `[${ATTRIBUTE_UPDATER_SELECT_THIS}][${ATTRIBUTE_UPDATER_ATTRIBUTES}], [${ATTRIBUTE_UPDATER_ATTRIBUTES}*="path:${current}"], [${ATTRIBUTE_UPDATER_ATTRIBUTES}^="static:"], [${ATTRIBUTE_UPDATER_ATTRIBUTES}^="i18n:"]`; const e = container.querySelectorAll(query); if (e.length > 0) { iterator = new Set([...e]); } if (container.matches(query)) { iterator.add(container); } for (const [element] of iterator.entries()) { if (mem.has(element)) continue; mem.add(element); // this case occurs when the ATTRIBUTE_UPDATER_SELECT_THIS attribute is set if (!element.hasAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES)) { continue; } const attributes = element.getAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES); for (let [, def] of Object.entries(attributes.split(","))) { def = trimSpaces(def); const i = def.indexOf(" "); const name = trimSpaces(def.substr(0, i)); const cmd = trimSpaces(def.substr(i)); const pipe = new Pipe(cmd); this[internalSymbol].callbacks.forEach((f, n) => { pipe.setCallback(n, f, element); }); let value; try { element.removeAttribute(ATTRIBUTE_ERRORMESSAGE); value = pipe.run(subject); } catch (e) { addErrorAttribute(element, e); } if (value === undefined) { element.removeAttribute(name); } else if (element.getAttribute(name) !== value) { element.setAttribute(name, value); } handleInputControlAttributeUpdate.call(this, element, name, value); } } } } /** * @private * @param {HTMLElement} container * @param {array} parts * @param {object} subject * @return {void} * @this Updater */ function runUpdateProperties(container, parts, subject) { if (!isArray(parts)) return; parts = clone(parts); const mem = new WeakSet(); while (parts.length > 0) { const current = parts.join("."); parts.pop(); let iterator = new Set(); const query = `[${ATTRIBUTE_UPDATER_SELECT_THIS}][${ATTRIBUTE_UPDATER_PROPERTIES}], [${ATTRIBUTE_UPDATER_PROPERTIES}*="path:${current}"], [${ATTRIBUTE_UPDATER_PROPERTIES}^="static:"], [${ATTRIBUTE_UPDATER_PROPERTIES}^="i18n:"]`; const e = container.querySelectorAll(query); if (e.length > 0) { iterator = new Set([...e]); } if (container.matches(query)) { iterator.add(container); } for (const [element] of iterator.entries()) { if (mem.has(element)) continue; mem.add(element); // this case occurs when the ATTRIBUTE_UPDATER_SELECT_THIS attribute is set if (!element.hasAttribute(ATTRIBUTE_UPDATER_PROPERTIES)) { continue; } const properties = element.getAttribute(ATTRIBUTE_UPDATER_PROPERTIES); for (let [, def] of Object.entries(properties.split(","))) { def = trimSpaces(def); const i = def.indexOf(" "); const name = trimSpaces(def.substr(0, i)); const cmd = trimSpaces(def.substr(i)); const pipe = new Pipe(cmd); this[internalSymbol].callbacks.forEach((f, n) => { pipe.setCallback(n, f, element); }); let value; try { element.removeAttribute(ATTRIBUTE_ERRORMESSAGE); value = pipe.run(subject); } catch (e) { addErrorAttribute(element, e); } handleInputControlPropertyUpdate.call(this, element, name, value); } } } } /** * @private * @param {HTMLElement|*} element * @param {string} name * @param {string|number|undefined} value * @return {void} * @this Updater */ function handleInputControlAttributeUpdate(element, name, value) { if (element instanceof HTMLSelectElement) { switch (element.type) { case "select-multiple": const selectedValues = isArray(value) ? value : value === undefined || value === null ? [] : [`${value}`]; for (const [, opt] of Object.entries(element.options)) { opt.selected = selectedValues.indexOf(opt.value) !== -1; } break; case "select-one": // Only one value may be selected if (value === undefined || value === null) { element.selectedIndex = -1; break; } element.selectedIndex = -1; for (const [index, opt] of Object.entries(element.options)) { if (opt.value === value) { element.selectedIndex = index; break; } } break; } } else if (element instanceof HTMLInputElement) { switch (element.type) { case "radio": if (name === "checked") { element.checked = value !== undefined; } break; case "checkbox": if (name === "checked") { element.checked = value !== undefined; } break; case "color": if (name === "value") { // Setting an empty string on a color input is invalid (#rrggbb required) if (value === undefined || value === "") { break; } element.value = value; } break; case "text": default: if (name === "value") { element.value = value === undefined ? "" : value; } break; } } else if (element instanceof HTMLTextAreaElement) { if (name === "value") { element.value = value === undefined ? "" : value; } } else if (name === "value" && element instanceof CustomElement) { if ("value" in element) { element.value = value === undefined ? "" : value; } } } /** * @private * @param {HTMLElement|*} element * @param {string} name * @param {*} value * @return {void} * @this Updater */ function handleInputControlPropertyUpdate(element, name, value) { if (element instanceof HTMLSelectElement && name === "value") { switch (element.type) { case "select-multiple": if (isArray(value)) { for (const [, opt] of Object.entries(element.options)) { opt.selected = value.indexOf(opt.value) !== -1; } return; } break; case "select-one": if (value === undefined) { element.selectedIndex = -1; return; } break; } } if (element instanceof HTMLInputElement) { switch (name) { case "checked": element.checked = value === true || value === "true" || value === "1" || value === "on"; return; case "value": if (element.type === "color") { if (value === undefined || value === "") { return; } } break; } } if (element instanceof HTMLTextAreaElement && name === "value") { element.value = value === undefined ? "" : value; return; } const optionPath = getControlOptionPathFromPropertyName(name); if ( optionPath !== null && typeof element?.setOption === "function" && typeof element?.getOption === "function" ) { try { const current = element.getOption(optionPath); const normalized = normalizeValueForOptionTarget(value, current); element.setOption(optionPath, normalized); } catch (e) { addErrorAttribute(element, e); } return; } if ( optionPath !== null && typeof element?.setOption === "function" && typeof element?.getOption !== "function" ) { try { element.setOption(optionPath, value); } catch (e) { addErrorAttribute(element, e); } return; } try { element[name] = value; } catch (e) { addErrorAttribute(element, e); } } /** * @private * @param {string} name * @return {string|null} */ function getControlOptionPathFromPropertyName(name) { if (!isString(name)) { return null; } let path = null; if (name.startsWith("option:")) { path = name.substring(7); } else if (name.startsWith("option.")) { path = name.substring(7); } else if (name.startsWith("options.")) { path = name.substring(8); } if (!isString(path)) { return null; } path = path .split(".") .map((part) => trimSpaces(part)) .filter((part) => part !== "") .join("."); if (path === "") { return null; } return path; } /** * @private * @param {*} value * @param {*} current * @return {*} */ function normalizeValueForOptionTarget(value, current) { if (current === undefined || current === null) { return value; } if (typeof current === "boolean") { return ( value === true || value === "true" || value === "1" || value === "on" ); } if (typeof current === "number") { const numberValue = Number(value); return isNaN(numberValue) ? current : numberValue; } if (typeof current === "string") { return value === undefined || value === null ? "" : `${value}`; } if (isArray(current)) { if (isArray(value)) { return value; } if (isString(value)) { if (value.trim() === "") { return []; } return value.split("::"); } if (value === undefined || value === null) { return []; } return [value]; } if (typeof current === "object") { if (value !== null && typeof value === "object") { return value; } if (isString(value)) { try { return JSON.parse(value); } catch (e) { return current; } } } return value; } /** * @param {NodeList|HTMLElement|Set<HTMLElement>} elements * @param {Symbol} symbol * @param {object} object * @param {object} config * * Config: enableEventProcessing {boolean} - default: false - enables the event processing * Config: batchUpdates {boolean} - default: false - batches updateContent/updateAttributes per diff * * @return {Promise[]} * @license AGPLv3 * @since 1.23.0 * @throws {TypeError} elements is not an instance of NodeList, HTMLElement or Set * @throws {TypeError} the context of the function is not an instance of HTMLElement * @throws {TypeError} symbol must be an instance of Symbol */ function addObjectWithUpdaterToElement(elements, symbol, object, config = {}) { if (!(this instanceof HTMLElement)) { throw new TypeError( "the context of this function must be an instance of HTMLElement", ); } if (!(typeof symbol === "symbol")) { throw new TypeError("symbol must be an instance of Symbol"); } const updaters = new Set(); if (elements instanceof NodeList) { elements = new Set([...elements]); } else if (elements instanceof HTMLElement) { elements = new Set([elements]); } else if (elements instanceof Set) { } else { throw new TypeError( `elements is not a valid type. (actual: ${typeof elements})`, ); } const result = []; const updaterCallbacks = []; const cb = this?.[updaterTransformerMethodsSymbol]; if (this instanceof HTMLElement && typeof cb === "function") { const callbacks = cb.call(this); if (typeof callbacks === "object") { for (const [name, callback] of Object.entries(callbacks)) { if (typeof callback === "function") { updaterCallbacks.push([name, callback]); } else { addErrorAttribute( this, `onUpdaterPipeCallbacks: ${name} is not a function`, ); } } } else { addErrorAttribute( this, `onUpdaterPipeCallbacks do not return an object with functions`, ); } } elements.forEach((element) => { if (!(element instanceof HTMLElement)) return; if (element instanceof HTMLTemplateElement) return; const u = new Updater(element, object); updaters.add(u); if (updaterCallbacks.length > 0) { for (const [name, callback] of updaterCallbacks) { u.setCallback(name, callback); } } result.push( u.run().then(() => { if (config.batchUpdates === true) { u.setBatchUpdates(true); } if (config.eventProcessing === true) { u.enableEventProcessing(); } return u; }), ); }); if (updaters.size > 0) { addToObjectLink(this, symbol, updaters); } return result; }