@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
1,990 lines (1,710 loc) • 47.8 kB
JavaScript
/**
* 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;
}