/** * Copyright schukai GmbH and contributors 2023. All Rights Reserved. * Node module: @schukai/monster * This file is licensed under the AGPLv3 License. * License text available at https://www.gnu.org/licenses/agpl-3.0.en.html */ 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_INSERT, ATTRIBUTE_UPDATER_INSERT_REFERENCE, ATTRIBUTE_UPDATER_REMOVE, ATTRIBUTE_UPDATER_REPLACE, ATTRIBUTE_UPDATER_SELECT_THIS, ATTRIBUTE_UPDATER_INSERT_TEMPLATE_ID } from "../dom/constants.mjs"; import {Base} from "../types/base.mjs"; import {isArray, 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 {addToObjectLink} from "./attributes.mjs"; import {findTargetElementFromEvent} from "./events.mjs"; import {findDocumentTemplate} from "./template.mjs"; export {Updater, addObjectWithUpdaterToElement}; /** * 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 {@tutorial dom-based-templating-implementation}. * * 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. * * @externalExample ../../example/dom/updater.mjs * @license AGPLv3 * @since 1.8.0 * @copyright schukai GmbH * @memberOf Monster.DOM * @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 Monster.DOM.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); for (const [, change] of Object.entries(diffResult)) { removeElement.call(this, change); insertElement.call(this, change); updateContent.call(this, change); updateAttributes.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; } /** * With this method, the eventlisteners are hooked in and the magic begins. * * ``` * 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. * * ``` * 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 * @returns {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() { const self = this; 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() { const self = this; if (self[symbol]) { return self[symbol]; } /** * @throws {Error} the bind argument must start as a value with a path. * @throws {Error} unsupported object * @param {Event} event */ self[symbol] = (event) => { const element = findTargetElementFromEvent(event, ATTRIBUTE_UPDATER_BIND); if (element === undefined) { return; } retrieveAndSetValue.call(self, element); }; return self[symbol]; } /** * @throws {Error} the bind argument must start as a value with a path * @param {HTMLElement} element * @return void * @memberOf Monster.DOM * @private */ function retrieveAndSetValue(element) { const self = this; const pathfinder = new Pathfinder(self[internalSymbol].subject.getSubject()); let path = element.getAttribute(ATTRIBUTE_UPDATER_BIND); if (path.indexOf("path:") !== 0) { throw new Error("the bind argument must start as a value with a path"); } path = path.substring(5); 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 customelements } 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 copy = clone(self[internalSymbol].subject.getRealSubject()); const pf = new Pathfinder(copy); pf.setVia(path, value); const diffResult = diff(copy, self[internalSymbol].subject.getRealSubject()); if (diffResult.length > 0) { pathfinder.setVia(path, value); } } /** * @license AGPLv3 * @since 1.27.0 * @return void * @private */ function retrieveFromBindings() { const self = this; if (self[internalSymbol].element.matches(`[${ATTRIBUTE_UPDATER_BIND}]`)) { retrieveAndSetValue.call(self, self[internalSymbol].element); } for (const [, element] of self[internalSymbol].element.querySelectorAll(`[${ATTRIBUTE_UPDATER_BIND}]`).entries()) { retrieveAndSetValue.call(self, element); } } /** * @private * @license AGPLv3 * @since 1.8.0 * @param {object} change * @return {void} */ function removeElement(change) { const self = this; for (const [, element] of self[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 self = this; const subject = self[internalSymbol].subject.getRealSubject(); let mem = new WeakSet(); let wd = 0; const container = self[internalSymbol].element; while (true) { let found = false; wd++; let p = clone(change?.["path"]); if (!isArray(p)) return self; 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); let def = trimSpaces(attributes); let i = def.indexOf(" "); let key = trimSpaces(def.substr(0, i)); let refPrefix = `${key}-`; let 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."); } let pipe = new Pipe(cmd); self[internalSymbol].callbacks.forEach((f, n) => { pipe.setCallback(n, f); }); let value; try { containerElement.removeAttribute(ATTRIBUTE_ERRORMESSAGE); value = pipe.run(subject); } catch (e) { containerElement.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.message); } let dataPath = cmd.split(":").pop(); let insertPoint; if (containerElement.hasChildNodes()) { insertPoint = containerElement.lastChild; } if (!isIterable(value)) { throw new Error("the value is not iterable"); } let available = new Set(); for (const [i, obj] of Object.entries(value)) { let ref = refPrefix + i; let currentPath = `${dataPath}.${i}`; available.add(ref); let refElement = containerElement.querySelector(`[${ATTRIBUTE_UPDATER_INSERT_REFERENCE}="${ref}"]`); if (refElement instanceof HTMLElement) { insertPoint = refElement; continue; } appendNewDocumentFragment(containerElement, key, ref, currentPath); } let 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) { containerElement.setAttribute( ATTRIBUTE_ERRORMESSAGE, `${containerElement.getAttribute(ATTRIBUTE_ERRORMESSAGE)}, ${e.message}`.trim(), ); } } } } p.pop(); } if (found === false) break; if (wd++ > 200) { throw new Error("the maximum depth for the recursion is reached."); } } } /** * @private * @param container * @param key * @param ref * @param path * @returns {any} */ function internalTemplateLookUp(container, key, ref, path) { let templateID = key; let template; if (container.hasAttribute(ATTRIBUTE_UPDATER_INSERT_TEMPLATE_ID)) { templateID = container.getAttribute(ATTRIBUTE_UPDATER_INSERT_TEMPLATE_ID); template = findDocumentTemplate(templateID, container); if (template instanceof HTMLTemplateElement) { return template; } } if (container.closest(`[${ATTRIBUTE_UPDATER_INSERT_TEMPLATE_ID}]`)) { templateID = container.closest(`[${ATTRIBUTE_UPDATER_INSERT_TEMPLATE_ID}]`).getAttribute(ATTRIBUTE_UPDATER_INSERT_TEMPLATE_ID); template = findDocumentTemplate(templateID, container); if (template instanceof HTMLTemplateElement) { return template; } } return findDocumentTemplate(templateID, container); } /** * * @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) { let template = internalTemplateLookUp(container, key, ref, path); let 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) { if (node.hasAttribute(ATTRIBUTE_UPDATER_REPLACE)) { let value = node.getAttribute(ATTRIBUTE_UPDATER_REPLACE); node.setAttribute(ATTRIBUTE_UPDATER_REPLACE, value.replaceAll(`path:${key}`, `path:${path}`)); } if (node.hasAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES)) { let 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)) { applyRecursive(child, key, path); } } } /** * @private * @license AGPLv3 * @since 1.8.0 * @param {object} change * @return {void} * @this Updater */ function updateContent(change) { const self = this; const subject = self[internalSymbol].subject.getRealSubject(); let 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); let 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); let cmd = trimSpaces(attributes); let 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) { element.setAttribute( ATTRIBUTE_ERRORMESSAGE, `${element.getAttribute(ATTRIBUTE_ERRORMESSAGE)}, ${e.message}`.trim(), ); } } else { element.innerHTML = value; } } } } /** * @private * @license AGPLv3 * @since 1.8.0 * @param {string} path * @param {object} change * @return {void} */ function updateAttributes(change) { const subject = this[internalSymbol].subject.getRealSubject(); let 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) { const self = this; if (!isArray(parts)) return; parts = clone(parts); let 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}*="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); const attributes = element.getAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES); for (let [, def] of Object.entries(attributes.split(","))) { def = trimSpaces(def); let i = def.indexOf(" "); let name = trimSpaces(def.substr(0, i)); let cmd = trimSpaces(def.substr(i)); let pipe = new Pipe(cmd); self[internalSymbol].callbacks.forEach((f, n) => { pipe.setCallback(n, f, element); }); let value; try { element.removeAttribute(ATTRIBUTE_ERRORMESSAGE); value = pipe.run(subject); } catch (e) { element.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.message); } 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) { const self = this; if (element instanceof HTMLSelectElement) { switch (element.type) { case "select-multiple": for (const [index, opt] of Object.entries(element.options)) { if (value.indexOf(opt.value) !== -1) { opt.selected = true; } else { opt.selected = false; } } 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") { if (value !== undefined) { element.checked = true; } else { element.checked = false; } } break; case "checkbox": if (name === "checked") { if (value !== undefined) { element.checked = true; } else { element.checked = false; } } 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 * @return {Promise[]} * @license AGPLv3 * @since 1.23.0 * @memberOf Monster.DOM * @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) { const self = this; if (!(self 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})`); } let result = []; elements.forEach((element) => { if (!(element instanceof HTMLElement)) return; if (element instanceof HTMLTemplateElement) return; const u = new Updater(element, object); updaters.add(u); result.push( u.run().then(() => { return u.enableEventProcessing(); }), ); }); if (updaters.size > 0) { addToObjectLink(self, symbol, updaters); } return result; }