/** * Copyright © schukai GmbH 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 schukai GmbH. * * 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_REMOVE, ATTRIBUTE_UPDATER_REPLACE, ATTRIBUTE_UPDATER_SELECT_THIS, } 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} 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"); /** * 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` 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. * * @example /examples/libraries/dom/updater/simple/ Simple example * * @license AGPLv3 * @since 1.8.0 * @copyright schukai GmbH * @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, }; this[internalSymbol].callbacks.set( "checkstate", getCheckStateCallback.call(this), ); this[internalSymbol].subject.attachObserver( new Observer(() => { const s = this[internalSymbol].subject.getRealSubject(); const diffResult = diff(this[internalSymbol].last, s); this[internalSymbol].last = clone(s); const promises = []; for (const [, change] of Object.entries(diffResult)) { promises.push( new Promise((resolve, reject) => { getWindow().requestAnimationFrame(() => { try { removeElement.call(this, change); insertElement.call(this, change); updateContent.call(this, change); updateAttributes.call(this, change); resolve(); } catch (error) { reject(error); } }); }), ); } return Promise.all(promises); }), ); } /** * 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; } /** * 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() { this.disableEventProcessing(); 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), ); } return this; } /** * 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() { // the key __init__has no further meaning and is only // used to create the diff for empty objects. this[internalSymbol].last = {__init__: true}; 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() { 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) => { 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, () => { try { retrieveAndSetValue.call(this, element); } catch (e) { addErrorAttribute(element, e); } }); }; return this[symbol]; } /** * @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"]) || element.hasOwnProperty("value") ) { 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?": value = Number(value); if (isNaN(value)||0===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[]": 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[]": if (isString(value)) { if (value.trim() === "") { value = []; } else { value = value.split(",").map((v) => { try { return parseInt(v, 10); } catch (e) { } return -1; }).filter((v) => v !== -1); } } else if (isInteger(value)) { value = [value]; } else if (value === undefined || value === null) { value = []; } else if (isArray(value)) { value = value.split(",").map((v) => { try { return parseInt(v, 10); } catch (e) { } return -1; }).filter((v) => v !== -1); } else { throw new Error("unsupported 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); } } /** * @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()) { 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 { 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_ATTRIBUTES)) { const value = node.getAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES); node.setAttribute( ATTRIBUTE_UPDATER_ATTRIBUTES, 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); 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); } } } } /** * @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)) return; 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) { while (element.firstChild) { element.removeChild(element.firstChild); } try { element.appendChild(value); } catch (e) { addErrorAttribute(element, e); } } else { element.innerHTML = value; } } } } /** * @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 * @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)) return; 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|*} 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": for (const [index, opt] of Object.entries(element.options)) { opt.selected = value.indexOf(opt.value) !== -1; } break; case "select-one": // Only one value may be selected 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 "text": default: if (name === "value") { element.value = value === undefined ? "" : value; } break; } } else if (element instanceof HTMLTextAreaElement) { if (name === "value") { element.value = value === undefined ? "" : 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 * * @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.eventProcessing === true) { u.enableEventProcessing(); } return u; }), ); }); if (updaters.size > 0) { addToObjectLink(this, symbol, updaters); } return result; }