Improved DOM node damage repair

This commit is contained in:
Tony Garnock-Jones 2023-12-03 23:09:18 +01:00
parent 208af3ebb6
commit a9ea553ca1
2 changed files with 227 additions and 230 deletions

View File

@ -1,7 +1,7 @@
/// SPDX-License-Identifier: GPL-3.0-or-later
/// SPDX-FileCopyrightText: Copyright © 2016-2023 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
let nextId = 1;
import { randomId } from "@syndicate-lang/core";
export function escape(s: string): string {
return s
@ -12,169 +12,185 @@ export function escape(s: string): string {
.replace(/'/g, "&#39;");
}
export type PlaceholderNodeMap = { [id: string]: Node };
export type HtmlFragment = string | number | Node | Array<HtmlFragment>;
export type HtmlFragment = string | number | Array<HtmlFragment> | Node | FlattenInto;
export interface FlattenInto {
flattenInto(acc: Array<HtmlFragment>, options?: FlattenIntoOptions): void;
const tag = randomId(8, true);
const placeholderRe = new RegExp(`X-${tag}-(\\d+)-${tag}-X`, 'g');
function placeholder(n: number): string {
return `X-${tag}-${n}-${tag}-X`;
}
export interface FlattenIntoOptions {
nodeMap?: PlaceholderNodeMap;
escapeStrings?: boolean;
function splitByPlaceholders(s: string): { constantParts: string[], placeholders: number[] } {
let match: RegExpExecArray | null = null;
let lastConstantStart = 0;
const constantParts: string[] = [];
const placeholders: number[] = [];
while ((match = placeholderRe.exec(s)) !== null) {
constantParts.push(s.substring(lastConstantStart, match.index));
placeholders.push(parseInt(match[1], 10));
lastConstantStart = placeholderRe.lastIndex;
}
constantParts.push(s.substring(lastConstantStart));
return { constantParts, placeholders };
}
export function isFlattenInto(x: any): x is FlattenInto {
return typeof x === 'object' && x !== null && typeof x.flattenInto === 'function';
}
export class HtmlFragments implements FlattenInto {
readonly contextElementName: string | undefined;
readonly pieces: Array<HtmlFragment>;
constructor(pieces: Array<HtmlFragment> = [], contextElementName?: string) {
this.contextElementName = contextElementName;
this.pieces = pieces;
}
appendTo(n: ParentNode): Array<Node> {
const ns = [... this.nodes()];
n.append(... ns);
return ns;
}
replaceContentOf(n: Element) {
n.innerHTML = '';
return this.appendTo(n);
}
node(): ChildNode {
return this.nodes()[0];
}
nodes(): Array<ChildNode> {
let n = document.createElement('template');
const nodeMap: PlaceholderNodeMap = {};
const source = this.toString(nodeMap);
if (this.contextElementName !== void 0) {
n.innerHTML = `<${this.contextElementName}>${source}</${this.contextElementName}>`;
function renderFragment(f: HtmlFragment, escapeStrings: boolean): string[] {
const result: string[] = [];
function walk(f: HtmlFragment) {
if (Array.isArray(f)) {
f.forEach(walk);
} else {
n.innerHTML = source;
}
for (const p of Array.from(n.content.querySelectorAll('placeholder'))) {
const e = nodeMap[p.id];
if (e) {
p.parentNode!.insertBefore(e, p);
p.remove();
switch (typeof f) {
case 'string': result.push(escapeStrings? escape(f) : f); break;
case 'number': result.push('' + f); break;
default: throw new Error("Cannot render Node in attribute context");
}
}
if (this.contextElementName !== void 0) {
return Array.from(n.content.firstElementChild!.childNodes);
} else {
return Array.from(n.content.childNodes);
}
}
toString(nodeMap?: PlaceholderNodeMap) {
const allPieces: Array<string> = [];
this.flattenInto(allPieces, { nodeMap });
return allPieces.join('');
}
flattenInto(acc: Array<string>, options: FlattenIntoOptions) {
flattenInto(acc, this.pieces, { ... options, escapeStrings: false });
}
join(pieces: Array<HtmlFragment>): Array<HtmlFragment> {
return join(pieces, this);
}
walk(f);
return result;
}
export function flattenInto(acc: Array<HtmlFragment>,
p: HtmlFragment,
options: FlattenIntoOptions = {})
{
switch (typeof p) {
case 'string': acc.push((options.escapeStrings ?? true) ? escape(p) : p); break;
case 'number': acc.push('' + p); break;
case 'object':
if (isFlattenInto(p)) {
p.flattenInto(acc, { nodeMap: options.nodeMap });
} else if (Array.isArray(p)) {
p.forEach(q => flattenInto(acc, q, options));
} else if (typeof Node !== 'undefined' && p instanceof Node) {
if (options.nodeMap !== void 0) {
const id = `__SYNDICATE__html__${nextId++}`;
options.nodeMap[id] = p;
acc.push(`<placeholder id="${id}"></placeholder>`);
} else {
acc.push(p);
function followPath(topNodes: ChildNode[], path: number[]): Node {
let n = topNodes[path[0]];
for (let j = 1; j < path.length; j++) n = n.childNodes[path[j]];
return n;
}
export class HtmlFragmentBuilder {
template: HTMLTemplateElement = document.createElement('template');
placeholderActions: Array<(variableParts: HtmlFragment[], topNodes: ChildNode[]) => void> = [];
constructor(constantParts: TemplateStringsArray) {
const pieces: string[] = [];
constantParts.raw.forEach((r, i) => {
if (i > 0) pieces.push(placeholder(i - 1));
pieces.push(r);
});
this.template.innerHTML = pieces.join('');
this.indexPlaceholders();
}
private indexPlaceholders() {
const path: number[] = [];
const bump = (n: number) => path[path.length - 1] += n;
const walk = (parentNode: ParentNode) => {
path.push(0);
let nextN = parentNode.firstChild;
while (nextN !== null) {
const n = nextN;
switch (n.nodeType) {
case Node.TEXT_NODE: {
const { constantParts, placeholders } = splitByPlaceholders(n.textContent ?? '');
constantParts.forEach((c, i) => {
if (i > 0) n.parentNode?.insertBefore(document.createElement('placeholder'), n);
n.parentNode?.insertBefore(document.createTextNode(c), n);
});
nextN = n.nextSibling;
n.parentNode?.removeChild(n);
placeholders.forEach((n, i) => {
const currentPath = path.slice();
currentPath[currentPath.length - 1] = i * 2 + 1;
this.placeholderActions.push((vs, topNodes) => {
const node = followPath(topNodes, currentPath);
function walk(f: HtmlFragment) {
if (Array.isArray(f)) {
f.forEach(walk);
} else {
switch (typeof f) {
case 'string': node.parentNode?.insertBefore(document.createTextNode(f), node); break;
case 'number': node.parentNode?.insertBefore(document.createTextNode('' + f), node); break;
default: node.parentNode?.insertBefore(f, node); break;
}
}
}
walk(vs[n]);
node.parentNode?.removeChild(node);
});
});
bump(constantParts.length + placeholders.length);
break;
}
case Node.ELEMENT_NODE: {
const currentPath = path.slice();
const e = n as Element;
// TODO: hoist all actions for this node into a single action
for (let i = 0; i < e.attributes.length; i++) {
const attr = e.attributes[i];
const attrName = attr.name;
const nameIsPlaceholder = attrName.match(placeholderRe);
if (nameIsPlaceholder !== null) {
e.removeAttributeNode(attr);
i--;
const n = parseInt(nameIsPlaceholder[1], 10);
this.placeholderActions.push((vs, topNodes) => {
const node = followPath(topNodes, currentPath);
const e = document.createElement('template');
e.innerHTML = `<dummy ${renderFragment(vs[n], true).join('')}></dummy>`;
Array.from(e.attributes).forEach(a =>
(node as Element).setAttribute(a.name, a.value));
});
} else {
const { constantParts, placeholders } = splitByPlaceholders(attr.value);
if (constantParts.length !== 1) {
this.placeholderActions.push((vs, topNodes) => {
const node = followPath(topNodes, currentPath);
const pieces = [constantParts[0]];
placeholders.forEach((n, i) => {
pieces.push(...renderFragment(vs[n], false));
pieces.push(constantParts[i + 1]);
});
(node as Element).setAttribute(attrName, pieces.join(''));
});
}
}
}
walk(e);
nextN = e.nextSibling;
bump(1);
break;
}
default:
nextN = n.nextSibling;
bump(1);
break;
}
}
break;
default:
((_n: never) => {})(p);
}
}
export function join(pieces: Array<HtmlFragment>, separator: HtmlFragment): Array<HtmlFragment> {
if (pieces.length <= 1) {
return [];
} else {
const result = [pieces[0]];
for (let i = 1; i < pieces.length; i++) {
result.push(separator);
result.push(pieces[i]);
}
return result;
}
}
export type HtmlFragmentsTemplate =
(constantParts: TemplateStringsArray, ... variableParts: Array<HtmlFragment>) => HtmlFragments;
export type NodeTemplate<N extends ChildNode> =
(constantParts: TemplateStringsArray, ... variableParts: Array<HtmlFragment>) => N;
export interface TagTemplate {
tag(): NodeTemplate<Element>;
tag<N extends ChildNode>(c: { new (...args: any): N }): NodeTemplate<N>;
};
export function makeTemplate(
contextElementName?: string,
): HtmlFragmentsTemplate & TagTemplate {
const templater = (constantParts: TemplateStringsArray, ... variableParts: Array<HtmlFragment>) => {
const pieces: Array<HtmlFragment> = [];
function pushConst(i: number) {
const r0 = constantParts.raw[i];
const r = i === 0 ? r0.trimLeft() : r0;
if (r) pieces.push(r);
}
pushConst(0);
variableParts.forEach((vp, vpIndex) => {
flattenInto(pieces, vp, { escapeStrings: true });
pushConst(vpIndex + 1);
});
return new HtmlFragments(pieces, contextElementName);
};
templater.tag = <N extends ChildNode>(c?: { new (...args: any): N }) =>
(constantParts: TemplateStringsArray, ... variableParts: Array<HtmlFragment>): any => {
const n = templater(constantParts, ... variableParts).node();
if (n instanceof (c ?? Element)) {
return n;
} else {
throw new TypeError(`Template generated ${n}, but instance of ${c} was expected`);
}
path.pop();
};
return templater;
walk(this.template.content);
}
clone(): ChildNode[] {
return Array.from(
(this.template.cloneNode(true) as HTMLTemplateElement).content.childNodes);
}
update(template: ChildNode[], variableParts: Array<HtmlFragment>) {
this.placeholderActions.forEach(a => a(variableParts, template));
}
}
export const template = makeTemplate();
// Nifty trick: TemplateStringsArray instances are interned so it makes sense to key a cache
// based on their object identity!
const templateCache = new WeakMap<TemplateStringsArray, HtmlFragmentBuilder>();
export function raw(str: string, contextElementName?: string) {
return new HtmlFragments([str], contextElementName);
export type HtmlTemplater =
(constantParts: TemplateStringsArray, ... variableParts: Array<HtmlFragment>)
=> ChildNode[];
export function template(): HtmlTemplater {
let container: ChildNode[] | null = null;
return (constantParts, ... variableParts) => {
let b = templateCache.get(constantParts);
if (b === void 0) {
b = new HtmlFragmentBuilder(constantParts);
templateCache.set(constantParts, b);
}
if (container === null) {
container = b.clone();
}
b.update(container, variableParts);
return container;
};
}
export default template;

View File

@ -2,8 +2,8 @@
/// SPDX-FileCopyrightText: Copyright © 2023 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
import { Turn, Facet, Dataflow, Dataspace, Ref } from "@syndicate-lang/core";
import { HtmlFragmentsTemplate, HtmlFragment, template } from "./html";
export * from "./html";
import { HtmlTemplater, template } from "./html";
export { HtmlTemplater, template, HtmlFragment } from "./html";
export assertion type LocationHash(hash: string);
export type LocationHash = ReturnType<typeof LocationHash>;
@ -17,82 +17,55 @@ type Wrapped = {
options?: AddEventListenerOptions | boolean,
};
export type NodeGenerator = (t: HtmlFragmentsTemplate) => HtmlFragment;
export type NodeGenerator = (t: HtmlTemplater) => ReturnType<HtmlTemplater>;
export class Widget implements EventTarget {
facet: Facet;
node: Dataflow.Field<ChildNode | null>;
callbacks =
new Map<string, Map<EventListenerOrEventListenerObject, Wrapped>>();
_parent: Dataflow.Field<ParentNode | null>;
readonly facet: Facet;
private _node: ChildNode | null = null;
parentField: Dataflow.Field<ParentNode | null>;
callbacks = new Map<string, Map<EventListenerOrEventListenerObject, Wrapped>>();
get node(): ChildNode {
return this._node!;
}
constructor (private nodeGenerator: NodeGenerator) {
this.facet = Turn.activeFacet;
on stop {
this.node.value?.remove();
this.node.remove();
}
field node: ChildNode | null = null;
this.node = node;
field parent: ParentNode | null = null;
this._parent = parent;
field parentField: ParentNode | null = null;
this.parentField = parentField;
const thisTemplate = template();
dataflow {
const oldNode = this.node.value;
const fragments = template`${this.nodeGenerator(template)}`;
const newNodes = fragments.nodes();
if (newNodes.length > 1) {
throw new Error(`@syndicate-lang/html2: Multiple nodes returned from template: ${fragments.toString()}`);
const nodes = nodeGenerator(thisTemplate);
if (nodes.length !== 1) {
throw new Error(`@syndicate-lang/html2: Expected exactly one node from template`);
}
const newNode = newNodes[0];
if (oldNode !== newNode) {
if (oldNode !== null) {
for (const [type, cbs] of this.callbacks.entries()) {
for (const entry of cbs.values()) {
oldNode.removeEventListener(type, entry.wrapped, entry.options);
}
}
}
if (newNode !== null) {
for (const [type, cbs] of this.callbacks.entries()) {
for (const entry of cbs.values()) {
newNode.addEventListener(type, entry.wrapped, entry.options);
}
}
}
const wasFocus = oldNode && document.activeElement === oldNode;
oldNode?.parentNode?.replaceChild(newNode, oldNode);
if (wasFocus) {
if (newNode && newNode instanceof HTMLElement) {
newNode.focus();
}
}
this.node.value = newNode;
if (this._node === null) {
this._node = nodes[0];
} else if (this._node !== nodes[0]) {
throw new Error(`@syndicate-lang/html2: Node generator is not stable`);
}
}
dataflow {
if (this.node.value === null) return;
const p = this._parent.value;
if (this.node.value.parentNode !== p) {
const p = this.parentField.value;
if (this.node.parentNode !== p) {
if (p === null) {
this.node.value.remove();
this.node.remove();
} else {
p.appendChild(this.node.value);
p.appendChild(this.node);
}
}
}
}
get parent(): ParentNode | null {
return this._parent.value;
return this.parentField.value;
}
set parent(p: string | ParentNode | null) {
@ -103,7 +76,7 @@ export class Widget implements EventTarget {
if (typeof p === 'string') {
p = wrt.querySelector(p);
}
this._parent.value = p;
this.parentField.value = p;
return this;
}
@ -145,11 +118,11 @@ export class Widget implements EventTarget {
};
cbs.set(callback, entry);
this.node.value?.addEventListener(type, entry.wrapped, options);
this.node.addEventListener(type, entry.wrapped, options);
}
dispatchEvent(event: Event): boolean {
return !this.node.value || this.node.value.dispatchEvent(event);
return this.node.dispatchEvent(event);
}
removeEventListener(
@ -165,7 +138,7 @@ export class Widget implements EventTarget {
const r = cbs.get(callback);
if (r === void 0) return;
this.node.value?.removeEventListener(type, r.wrapped, options);
this.node.removeEventListener(type, r.wrapped, options);
cbs.delete(callback);
if (cbs.size === 0) this.callbacks.delete(type);
@ -185,35 +158,43 @@ export class ValueWidget extends Widget {
field valueAsNumber: number = NaN;
this._valueAsNumber = valueAsNumber;
const readValues = (n: any) => {
this._value.value = n?.value ?? '';
this._valueAsNumber.value = n?.valueAsNumber ?? NaN;
};
if ('value' in this.node) {
const readValues = (n: any) => {
this.suppressCycleWarning();
this._value.value = n?.value ?? '';
this._valueAsNumber.value = n?.valueAsNumber ?? NaN;
};
this.on('change', e => readValues(e.target));
this.on('change', e => readValues(e.target));
readValues(this.node);
dataflow {
if (this.node.value && 'value' in this.node.value) {
readValues(this.node.value);
}
}
dataflow {
if (this.node.value && 'value' in this.node.value) {
(this.node.value as any).value = '' + this._valueAsNumber.value;
}
}
dataflow {
if (this.node.value && 'value' in this.node.value) {
this.node.value.value = this._value.value;
}
dataflow { this.valueAsNumber = this._valueAsNumber.value; }
dataflow { this.value = this._value.value; }
}
}
get value(): string { return this._value.value; }
set value(v: string) { this._value.value = v; }
get value(): string {
return this._value.value;
}
get valueAsNumber(): number { return this._valueAsNumber.value; }
set valueAsNumber(v: number) { this._valueAsNumber.value = v; }
set value(v: string) {
(this.node as any).value = v;
this._value.value = v;
}
get valueAsNumber(): number {
return this._valueAsNumber.value;
}
set valueAsNumber(v: number) {
(this.node as any).value = Number.isNaN(v) ? '' : '' + v;
this._valueAsNumber.value = v;
}
suppressCycleWarning(): void {
this._value.suppressCycleWarning();
this._valueAsNumber.suppressCycleWarning();
}
}
function spawnLocationHashTracker(ds: Ref) {