'use strict'; /** * @author schukai GmbH */ import {internalSymbol} from "../constants.js"; import {diff} from "../data/diff.js"; import {Pathfinder} from "../data/pathfinder.js"; import {Pipe} from "../data/pipe.js"; 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 } from "../dom/constants.js"; import {Base} from "../types/base.js"; import {isArray, isInstance, isIterable} from "../types/is.js"; import {Observer} from "../types/observer.js"; import {ProxyObserver} from "../types/proxyobserver.js"; import {validateArray, validateInstance} from "../types/validate.js"; import {clone} from "../util/clone.js"; import {trimSpaces} from "../util/trimspaces.js"; import {findTargetElementFromEvent} from "./events.js"; import {findDocumentTemplate} from "./template.js"; import {getDocument} from "./util.js"; /** * 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. * * ``` * <script type="module"> * import {Updater} from 'https://cdn.jsdelivr.net/npm/@schukai/monster@latest/source/dom/updater.js'; * new Updater() * </script> * ``` * * @example * * import {Updater} from 'https://cdn.jsdelivr.net/npm/@schukai/monster@latest/source/dom/updater.js'; * * // First we prepare the html document. * // This is done here via script, but can also be inserted into the document as pure html. * // To do this, simply insert the tag <h1 data-monster-replace="path:headline"></h1>. * const body = document.querySelector('body'); * const headline = document.createElement('h1'); * headline.setAttribute('data-monster-replace','path:headline') * body.appendChild(headline); * * // the data structure * let obj = { * headline: "Hello World", * }; * * // Now comes the real magic. we pass the updater the parent HTMLElement * // and the desired data structure. * const updater = new Updater(body, obj); * updater.run(); * * // Now you can change the data structure and the HTML will follow these changes. * const subject = updater.getSubject(); * subject['headline'] = "Hello World!" * * @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 */ export 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 * @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('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.substr(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); } } /** * @since 1.27.0 * @return void * @private */ function retrieveFromBindings() { const self = this; if (self[internalSymbol].element.matches('[' + ATTRIBUTE_UPDATER_BIND + ']')) { retrieveAndSetValue.call(self, element) } for (const [, element] of self[internalSymbol].element.querySelectorAll('[' + ATTRIBUTE_UPDATER_BIND + ']').entries()) { retrieveAndSetValue.call(self, element) } } /** * @private * @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 * @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(); const document = getDocument(); 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 * @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 = findDocumentTemplate(key, container); 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 * @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 * @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 * @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:"]'; 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 * @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:"]'; 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); } } }