2023-12-01 22:30:50 +00:00
|
|
|
/// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
/// 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";
|
|
|
|
|
|
|
|
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 class Widget implements EventTarget {
|
|
|
|
facet: Facet;
|
|
|
|
node: Dataflow.Field<ChildNode | null>;
|
|
|
|
callbacks =
|
|
|
|
new Map<string, Map<EventListenerOrEventListenerObject, Wrapped>>();
|
|
|
|
_parent: Dataflow.Field<ParentNode | null>;
|
|
|
|
|
|
|
|
constructor (private nodeGenerator: (t: HtmlFragmentsTemplate) => HtmlFragment) {
|
|
|
|
this.facet = Turn.activeFacet;
|
|
|
|
|
|
|
|
on stop {
|
|
|
|
this.node.value?.remove();
|
|
|
|
}
|
|
|
|
|
|
|
|
field node: ChildNode | null = null;
|
|
|
|
this.node = node;
|
|
|
|
|
|
|
|
field parent: ParentNode | null = null;
|
|
|
|
this._parent = parent;
|
|
|
|
|
|
|
|
dataflow {
|
|
|
|
const oldNode = this.node.value;
|
|
|
|
const newNode = template`${this.nodeGenerator(template)}`.node();
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-12-01 22:56:32 +00:00
|
|
|
const wasFocus = oldNode && document.activeElement === oldNode;
|
2023-12-01 22:30:50 +00:00
|
|
|
oldNode?.parentNode?.replaceChild(newNode, oldNode);
|
2023-12-01 22:56:32 +00:00
|
|
|
if (wasFocus) {
|
|
|
|
if (newNode && newNode instanceof HTMLElement) {
|
|
|
|
newNode.focus();
|
|
|
|
}
|
|
|
|
}
|
2023-12-01 22:30:50 +00:00
|
|
|
|
|
|
|
this.node.value = newNode;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
dataflow {
|
|
|
|
if (this.node.value === null) return;
|
|
|
|
const p = this._parent.value;
|
|
|
|
if (this.node.value.parentNode !== p) {
|
|
|
|
if (p === null) {
|
|
|
|
this.node.value.remove();
|
|
|
|
} else {
|
|
|
|
p.appendChild(this.node.value);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
get parent(): ParentNode | null {
|
|
|
|
return this._parent.value;
|
|
|
|
}
|
|
|
|
|
|
|
|
set parent(p: string | ParentNode | null) {
|
|
|
|
if (typeof p === 'string') {
|
|
|
|
p = document.querySelector(p);
|
|
|
|
}
|
|
|
|
this._parent.value = p;
|
|
|
|
}
|
|
|
|
|
|
|
|
setParent(p: string | ParentNode | null): this {
|
|
|
|
this.parent = 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.value?.addEventListener(type, entry.wrapped, options);
|
|
|
|
}
|
|
|
|
|
|
|
|
dispatchEvent(event: Event): boolean {
|
|
|
|
return !this.node.value || this.node.value.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.value?.removeEventListener(type, r.wrapped, options);
|
|
|
|
|
|
|
|
cbs.delete(callback);
|
|
|
|
if (cbs.size === 0) this.callbacks.delete(type);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function spawnLocationHashTracker(ds: Ref) {
|
|
|
|
spawn named 'LocationHashTracker' {
|
|
|
|
at ds {
|
|
|
|
field hashValue: string = '/';
|
|
|
|
assert LocationHash(hashValue.value);
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
loadHash();
|
|
|
|
window.addEventListener('hashchange', handlerClosure);
|
|
|
|
on stop window.removeEventListener('hashchange', handlerClosure);
|
|
|
|
|
|
|
|
on message LocationHash($newHash: string) => {
|
|
|
|
window.location.hash = newHash;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|