/// SPDX-License-Identifier: GPL-3.0-or-later /// SPDX-FileCopyrightText: Copyright © 2023-2024 Tony Garnock-Jones 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; export function boot(ds = Dataspace.global) { spawnLocationHashTracker(ds); } type Wrapped = { wrapped: EventListenerOrEventListenerObject, options?: AddEventListenerOptions | boolean, }; export type NodeGenerator = (t: HtmlTemplater) => ReturnType; export class Widget implements EventTarget { readonly nodeGenerator: NodeGenerator; readonly facet: Facet; private _node: ChildNode | null = null; parentField: Dataflow.Field; callbacks = new Map>(); 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; _valueAsNumber: Dataflow.Field; 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); }