Improved DOM node damage repair
This commit is contained in:
parent
208af3ebb6
commit
a9ea553ca1
|
@ -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, "'");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
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]);
|
||||
}
|
||||
walk(f);
|
||||
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)) {
|
||||
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 {
|
||||
throw new TypeError(`Template generated ${n}, but instance of ${c} was expected`);
|
||||
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;
|
||||
}
|
||||
}
|
||||
path.pop();
|
||||
};
|
||||
return templater;
|
||||
walk(this.template.content);
|
||||
}
|
||||
|
||||
export const template = makeTemplate();
|
||||
|
||||
export function raw(str: string, contextElementName?: string) {
|
||||
return new HtmlFragments([str], contextElementName);
|
||||
clone(): ChildNode[] {
|
||||
return Array.from(
|
||||
(this.template.cloneNode(true) as HTMLTemplateElement).content.childNodes);
|
||||
}
|
||||
|
||||
export default template;
|
||||
update(template: ChildNode[], variableParts: Array<HtmlFragment>) {
|
||||
this.placeholderActions.forEach(a => a(variableParts, template));
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
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));
|
||||
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) {
|
||||
|
|
Loading…
Reference in New Issue