255 lines
8.0 KiB
TypeScript
255 lines
8.0 KiB
TypeScript
/// SPDX-License-Identifier: GPL-3.0-or-later
|
|
/// SPDX-FileCopyrightText: Copyright © 2023-2024 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
|
|
|
|
import { Turn, Facet, Dataflow, Dataspace, Ref } from "@syndicate-lang/core";
|
|
import { HtmlTemplater, template } from "./html";
|
|
export { HtmlTemplater, template, HtmlFragment } from "./html";
|
|
|
|
export assertion type LocationHash(hash: string);
|
|
export type LocationHash = ReturnType<typeof LocationHash>;
|
|
|
|
export function boot(ds = Dataspace.global) {
|
|
spawnLocationHashTracker(ds);
|
|
}
|
|
|
|
type Wrapped = {
|
|
wrapped: EventListenerOrEventListenerObject,
|
|
options?: AddEventListenerOptions | boolean,
|
|
};
|
|
|
|
export type NodeGenerator = (t: HtmlTemplater) => ReturnType<HtmlTemplater>;
|
|
|
|
export class Widget implements EventTarget {
|
|
readonly nodeGenerator: NodeGenerator;
|
|
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 (nodeGenerator: NodeGenerator);
|
|
constructor (template: string | HTMLTemplateElement, data: object);
|
|
constructor (arg0: NodeGenerator | string | HTMLTemplateElement, data?: object) {
|
|
if (data === void 0) {
|
|
this.nodeGenerator = arg0 as NodeGenerator;
|
|
} else {
|
|
this.nodeGenerator = templateGenerator(arg0 as (string | HTMLTemplateElement), data);
|
|
}
|
|
|
|
this.facet = Turn.activeFacet;
|
|
|
|
const cancelAtExit = this.facet.actor.atExit(() => this.node.remove());
|
|
on stop {
|
|
this.node.remove();
|
|
cancelAtExit();
|
|
}
|
|
|
|
const thisTemplate = template();
|
|
dataflow {
|
|
const nodes = this.nodeGenerator(thisTemplate);
|
|
if (nodes.length !== 1) {
|
|
throw new Error(`@syndicate-lang/html2: Expected exactly one node from template`);
|
|
}
|
|
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`);
|
|
}
|
|
}
|
|
|
|
field parentField: ParentNode | null = this._node?.parentNode ?? null;
|
|
this.parentField = parentField;
|
|
|
|
dataflow {
|
|
const p = this.parentField.value;
|
|
if (this.node.parentNode !== p) {
|
|
if (p === null) {
|
|
this.node.remove();
|
|
} else {
|
|
p.appendChild(this.node);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
get parent(): ParentNode | null {
|
|
return this.parentField.value;
|
|
}
|
|
|
|
set parent(p: string | ParentNode | null) {
|
|
this.setParent(p);
|
|
}
|
|
|
|
setParent(p: string | ParentNode | null, wrt: ParentNode = document): this {
|
|
if (typeof p === 'string') {
|
|
p = wrt.querySelector(p);
|
|
}
|
|
this.parentField.value = p;
|
|
return this;
|
|
}
|
|
|
|
on(type: string, callback: EventListenerOrEventListenerObject): this {
|
|
this.addEventListener(type, callback);
|
|
return this;
|
|
}
|
|
|
|
once(type: string, callback: EventListenerOrEventListenerObject): this {
|
|
this.addEventListener(type, callback, { once: true });
|
|
return this;
|
|
}
|
|
|
|
off(type: string, callback: EventListenerOrEventListenerObject): this {
|
|
this.removeEventListener(type, callback);
|
|
return this;
|
|
}
|
|
|
|
addEventListener(
|
|
type: string,
|
|
callback: EventListenerOrEventListenerObject | null,
|
|
options?: AddEventListenerOptions | boolean,
|
|
): void {
|
|
if (callback === null) return;
|
|
|
|
let cbs = this.callbacks.get(type);
|
|
if (cbs === void 0) {
|
|
cbs = new Map();
|
|
this.callbacks.set(type, cbs);
|
|
} else {
|
|
if (cbs.has(callback)) return;
|
|
}
|
|
|
|
const entry: Wrapped = {
|
|
wrapped: (typeof callback === 'function')
|
|
? evt => this.facet.turn(() => callback(evt))
|
|
: evt => this.facet.turn(() => callback.handleEvent(evt)),
|
|
options,
|
|
};
|
|
cbs.set(callback, entry);
|
|
|
|
this.node.addEventListener(type, entry.wrapped, options);
|
|
}
|
|
|
|
dispatchEvent(event: Event): boolean {
|
|
return this.node.dispatchEvent(event);
|
|
}
|
|
|
|
removeEventListener(
|
|
type: string,
|
|
callback: EventListenerOrEventListenerObject | null,
|
|
options?: boolean | EventListenerOptions | undefined,
|
|
): void {
|
|
if (callback === null) return;
|
|
|
|
const cbs = this.callbacks.get(type);
|
|
if (cbs === void 0) return;
|
|
|
|
const r = cbs.get(callback);
|
|
if (r === void 0) return;
|
|
|
|
this.node.removeEventListener(type, r.wrapped, options);
|
|
|
|
cbs.delete(callback);
|
|
if (cbs.size === 0) this.callbacks.delete(type);
|
|
}
|
|
}
|
|
|
|
export class ValueWidget extends Widget {
|
|
_value: Dataflow.Field<string>;
|
|
_valueAsNumber: Dataflow.Field<number>;
|
|
|
|
constructor (nodeGenerator: NodeGenerator, triggerEvent?: 'change' | 'input');
|
|
constructor (template: string | HTMLTemplateElement, data: object, triggerEvent?: 'change' | 'input');
|
|
constructor (arg0: NodeGenerator | string | HTMLTemplateElement, arg1?: object | 'change' | 'input', arg2?: 'change' | 'input') {
|
|
super(arg0 as any, arg1 as any);
|
|
const triggerEvent = typeof arg1 === 'string' ? arg1 : typeof arg2 === 'string' ? arg2 : 'change';
|
|
|
|
field value: string = '';
|
|
this._value = value;
|
|
|
|
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(triggerEvent, e => readValues(e.target));
|
|
readValues(this.node);
|
|
|
|
dataflow { this.valueAsNumber = this._valueAsNumber.value; }
|
|
dataflow { this.value = this._value.value; }
|
|
}
|
|
}
|
|
|
|
get value(): string {
|
|
return this._value.value;
|
|
}
|
|
|
|
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) {
|
|
spawn named 'LocationHashTracker' {
|
|
at ds {
|
|
field hashValue: string = '/';
|
|
|
|
const loadHash = () => {
|
|
var h = window.location.hash;
|
|
if (h.length && h[0] === '#') {
|
|
h = h.slice(1);
|
|
}
|
|
hashValue.value = h || '/';
|
|
};
|
|
const facet = Turn.activeFacet;
|
|
const handlerClosure = () => facet.turn(loadHash);
|
|
|
|
window.addEventListener('hashchange', handlerClosure);
|
|
on stop window.removeEventListener('hashchange', handlerClosure);
|
|
|
|
loadHash();
|
|
assert LocationHash(hashValue.value);
|
|
|
|
on message LocationHash($newHash: string) => {
|
|
window.location.hash = newHash;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function templateGenerator(
|
|
template0: string | HTMLTemplateElement,
|
|
data: object,
|
|
): NodeGenerator {
|
|
const template = typeof template0 === 'string' ? document.querySelector(template0) : template0;
|
|
if (template === null) throw new Error('Cannot find template: ' + template0);
|
|
const body = `return t => t\`${template.innerHTML.trim().split('`').join('\\`')}\``;
|
|
const kvs = Object.entries(data);
|
|
const keys = kvs.map(e => e[0]);
|
|
const values = kvs.map(e => e[1]);
|
|
const factory = new Function(... keys, body);
|
|
return factory(... values);
|
|
}
|