606 lines
23 KiB
TypeScript
606 lines
23 KiB
TypeScript
/// SPDX-License-Identifier: GPL-3.0-or-later
|
|
/// SPDX-FileCopyrightText: Copyright © 2016-2023 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
|
|
|
|
import { randomId, Observe, FlexMap, embed, Embedded, Ref, Turn, AnyValue } from "@syndicate-lang/core";
|
|
import { QuasiValue as Q } from "@syndicate-lang/core";
|
|
|
|
import * as P from "./protocol";
|
|
export * from "./protocol";
|
|
export type UIFragmentRecord = ReturnType<typeof P.UIFragment>;
|
|
|
|
import { HtmlFragments } from "./html";
|
|
export * from "./html";
|
|
|
|
export function boot(ds: Ref) {
|
|
spawnGlobalEventFactory(ds);
|
|
spawnWindowEventFactory(ds);
|
|
spawnUIFragmentFactory(ds);
|
|
spawnUIAttributeFactory(ds);
|
|
spawnUIPropertyFactory(ds);
|
|
spawnUIChangeablePropertyFactory(ds);
|
|
spawnLocationHashTracker(ds);
|
|
spawnAttributeUpdater(ds);
|
|
}
|
|
|
|
//---------------------------------------------------------------------------
|
|
// ID allocators
|
|
|
|
const moduleInstance = randomId(16, true);
|
|
|
|
let nextFragmentIdNumber = 0;
|
|
export function newFragmentId() {
|
|
return 'ui_' + moduleInstance + '_' + (nextFragmentIdNumber++);
|
|
}
|
|
|
|
//---------------------------------------------------------------------------
|
|
|
|
export function spawnGlobalEventFactory(ds: Ref) {
|
|
spawn named 'GlobalEventFactory' {
|
|
at ds {
|
|
during Observe({
|
|
"pattern": :pattern P.GlobalEvent(\Q.lit($selector: string),
|
|
\Q.lit($eventType: string),
|
|
\_)
|
|
}) => spawn named ['GlobalEvent', selector, eventType] {
|
|
const facet = Turn.activeFacet;
|
|
|
|
function handler(event: Event) {
|
|
facet.turn(() => {
|
|
send message P.GlobalEvent(selector, eventType, embed(create ({ data: event })));
|
|
});
|
|
return dealWithPreventDefault(eventType, event);
|
|
}
|
|
|
|
function updateEventListeners(install: boolean) {
|
|
selectorMatch(document.body, selector).forEach(
|
|
eventUpdater(cleanEventType(eventType), handler, install));
|
|
}
|
|
|
|
updateEventListeners(true);
|
|
on stop updateEventListeners(false);
|
|
|
|
on asserted P.UIFragmentVersion($_i, $_v) => updateEventListeners(true);
|
|
// TODO: don't be so crude about this ^. On the one hand, this
|
|
// lets us ignore UIFragmentVersion records coming and going; on
|
|
// the other hand, we do potentially a lot of redundant work.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function spawnWindowEventFactory(ds: Ref) {
|
|
spawn named 'WindowEventFactory' {
|
|
at ds {
|
|
during Observe({
|
|
"pattern": :pattern P.WindowEvent(\Q.lit($eventType: string), \_)
|
|
}) => spawn named ['WindowEvent', eventType] {
|
|
const facet = Turn.activeFacet;
|
|
|
|
let handler = (event: Event) => {
|
|
facet.turn(() => {
|
|
send message P.WindowEvent(eventType, embed(create ({ data: event })));
|
|
});
|
|
return dealWithPreventDefault(eventType, event);
|
|
};
|
|
|
|
function updateEventListeners(install: boolean) {
|
|
if (install) {
|
|
window.addEventListener(cleanEventType(eventType), handler);
|
|
} else {
|
|
window.removeEventListener(cleanEventType(eventType), handler);
|
|
}
|
|
}
|
|
|
|
updateEventListeners(true);
|
|
on stop updateEventListeners(false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export type NodeOrderKey = string | number;
|
|
export type FragmentId = string | number;
|
|
|
|
function isFragmentId(x: any): x is FragmentId {
|
|
return typeof x === 'string' || typeof x === 'number';
|
|
}
|
|
|
|
function isNodeOrderKey(x: any): x is NodeOrderKey {
|
|
return typeof x === 'string' || typeof x === 'number';
|
|
}
|
|
|
|
type HandlerClosure = (event: Event) => void;
|
|
|
|
function spawnUIFragmentFactory(ds: Ref) {
|
|
type RegistrationKey = [string, string]; // [selector, eventType]
|
|
|
|
spawn named 'UIFragmentFactory' {
|
|
at ds {
|
|
during P.UIFragment($fragmentId0, _, _, _) =>
|
|
spawn named ['UIFragment', fragmentId0] {
|
|
if (!isFragmentId(fragmentId0)) return;
|
|
const fragmentId = fragmentId0;
|
|
|
|
field version: number = 0;
|
|
assert P.UIFragmentVersion(fragmentId, version.value) when (version.value > 0);
|
|
|
|
let selector: string;
|
|
let html: Array<ChildNode>;
|
|
let orderBy: NodeOrderKey;
|
|
let anchorNodes: Array<ParentNode> = [];
|
|
let eventRegistrations =
|
|
new FlexMap<RegistrationKey, HandlerClosure>(JSON.stringify);
|
|
|
|
on stop removeNodes();
|
|
|
|
during Observe({
|
|
"pattern": :pattern P.UIEvent(fragmentId,
|
|
\Q.lit($selector: string),
|
|
\Q.lit($eventType: string),
|
|
\_)
|
|
}) => {
|
|
updateEventListeners([ selector, eventType ], true);
|
|
on stop updateEventListeners([ selector, eventType ], false);
|
|
}
|
|
|
|
on asserted P.UIFragment(fragmentId, $newSelector: string, $newHtml, $newOrderBy) => {
|
|
if (!isNodeOrderKey(newOrderBy)) return;
|
|
|
|
removeNodes();
|
|
|
|
selector = newSelector;
|
|
html = (newHtml as Embedded<Ref>).embeddedValue.target.data as ChildNode[];
|
|
orderBy = newOrderBy;
|
|
anchorNodes = (selector !== null) ? selectorMatch(document.body, selector) : [];
|
|
|
|
if (anchorNodes.length === 0) {
|
|
console.warn('UIFragment found no parent nodes matching selector', selector, fragmentId);
|
|
}
|
|
|
|
anchorNodes.forEach(anchorNode => {
|
|
let insertionPoint = findInsertionPoint(anchorNode, orderBy, fragmentId);
|
|
html.forEach(newNode => {
|
|
setSortKey(newNode, orderBy, fragmentId);
|
|
anchorNode.insertBefore(newNode, insertionPoint);
|
|
configureNode(newNode);
|
|
});
|
|
});
|
|
|
|
// (re)install event listeners
|
|
eventRegistrations.forEach((_handler, key) => updateEventListeners(key, true));
|
|
|
|
version.value++;
|
|
}
|
|
|
|
function removeNodes() {
|
|
anchorNodes.forEach(anchorNode => {
|
|
let insertionPoint = findInsertionPoint(anchorNode, orderBy, fragmentId);
|
|
while (true) {
|
|
let n = insertionPoint ? insertionPoint.previousSibling : anchorNode.lastChild;
|
|
if (!(n && hasSortKey(n, orderBy, fragmentId))) break;
|
|
n.parentNode?.removeChild(n); // auto-updates previousSibling/lastChild
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateEventListeners(key: RegistrationKey, install: boolean) {
|
|
const [selector, eventType] = key;
|
|
|
|
let handlerClosure: HandlerClosure;
|
|
if (!eventRegistrations.has(key)) {
|
|
const facet = Turn.activeFacet;
|
|
function handler(event: Event) {
|
|
facet.turn(() => {
|
|
send message P.UIEvent(fragmentId, selector, eventType, embed(create ({ data: event })));
|
|
});
|
|
return dealWithPreventDefault(eventType, event);
|
|
}
|
|
eventRegistrations.set(key, handler);
|
|
handlerClosure = handler;
|
|
} else {
|
|
handlerClosure = eventRegistrations.get(key)!;
|
|
}
|
|
|
|
anchorNodes.forEach(anchorNode => {
|
|
let insertionPoint = findInsertionPoint(anchorNode, orderBy, fragmentId);
|
|
while (true) {
|
|
let uiNode = insertionPoint ? insertionPoint.previousSibling : anchorNode.lastChild;
|
|
if (!(uiNode && hasSortKey(uiNode, orderBy, fragmentId))) break;
|
|
if (isQueryableNode(uiNode)) {
|
|
selectorMatch(uiNode, selector).forEach(
|
|
eventUpdater(cleanEventType(eventType), handlerClosure, install));
|
|
}
|
|
insertionPoint = uiNode;
|
|
}
|
|
});
|
|
|
|
if (!install) {
|
|
eventRegistrations.delete(key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const SYNDICATE_SORT_KEY = '__syndicate_sort_key';
|
|
|
|
type NodeSortKey = [NodeOrderKey, FragmentId];
|
|
|
|
function setSortKey(n: HTMLOrSVGElement | Node, orderBy: NodeOrderKey, fragmentId: FragmentId) {
|
|
const key: NodeSortKey = [orderBy, fragmentId];
|
|
const v = JSON.stringify(key);
|
|
if ('dataset' in n) {
|
|
// html element nodes etc.
|
|
n.dataset[SYNDICATE_SORT_KEY] = v;
|
|
} else {
|
|
// text nodes, svg nodes, etc etc.
|
|
(n as any)[SYNDICATE_SORT_KEY] = v;
|
|
}
|
|
}
|
|
|
|
function getSortKey(n: HTMLOrSVGElement | Node): NodeSortKey | null {
|
|
if ('dataset' in n && n.dataset[SYNDICATE_SORT_KEY]) {
|
|
return JSON.parse(n.dataset[SYNDICATE_SORT_KEY]!);
|
|
}
|
|
if ((n as any)[SYNDICATE_SORT_KEY]) {
|
|
return JSON.parse((n as any)[SYNDICATE_SORT_KEY]);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function hasSortKey(n: HTMLOrSVGElement | Node, orderBy: NodeOrderKey, fragmentId: FragmentId): boolean {
|
|
let v = getSortKey(n);
|
|
if (!v) return false;
|
|
if (v[0] !== orderBy) return false;
|
|
if (v[1] !== fragmentId) return false;
|
|
return true;
|
|
}
|
|
|
|
function firstChildNodeIndex_withSortKey(n: Node): number {
|
|
for (let i = 0; i < n.childNodes.length; i++) {
|
|
if (getSortKey(n.childNodes[i])) return i;
|
|
}
|
|
return n.childNodes.length;
|
|
}
|
|
|
|
// If *no* nodes have a sort key, returns a value that yields an empty
|
|
// range in conjunction with firstChildNodeIndex_withSortKey.
|
|
function lastChildNodeIndex_withSortKey(n: Node): number {
|
|
for (let i = n.childNodes.length - 1; i >= 0; i--) {
|
|
if (getSortKey(n.childNodes[i])) return i;
|
|
}
|
|
return n.childNodes.length - 1;
|
|
}
|
|
|
|
function isGreaterThan(a: any, b: any): boolean {
|
|
if (typeof a > typeof b) return true;
|
|
if (typeof a < typeof b) return false;
|
|
return a > b;
|
|
}
|
|
|
|
function findInsertionPoint(n: Node, orderBy: NodeOrderKey, fragmentId: FragmentId): ChildNode | null {
|
|
let lo = firstChildNodeIndex_withSortKey(n);
|
|
let hi = lastChildNodeIndex_withSortKey(n) + 1;
|
|
// lo <= hi, and [lo, hi) have sort keys.
|
|
|
|
while (lo < hi) { // when lo === hi, there's nothing more to examine.
|
|
let probe = (lo + hi) >> 1;
|
|
let probeSortKey = getSortKey(n.childNodes[probe])!;
|
|
|
|
if ((isGreaterThan(probeSortKey[0], orderBy))
|
|
|| ((probeSortKey[0] === orderBy) &&
|
|
(isGreaterThan(probeSortKey[1], fragmentId))))
|
|
{
|
|
hi = probe;
|
|
} else {
|
|
lo = probe + 1;
|
|
}
|
|
}
|
|
|
|
// lo === hi now.
|
|
if (lo < n.childNodes.length) {
|
|
return n.childNodes[lo];
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function configureNode(n: ChildNode) {
|
|
// Runs post-insertion configuration of nodes.
|
|
// TODO: review this design.
|
|
selectorMatch(n, '.-syndicate-focus').forEach(
|
|
(n: ParentNode | HTMLTextAreaElement | HTMLInputElement) => {
|
|
if ('focus' in n && 'setSelectionRange' in n) {
|
|
n.focus();
|
|
n.setSelectionRange(n.value.length, n.value.length);
|
|
}
|
|
});
|
|
}
|
|
|
|
//---------------------------------------------------------------------------
|
|
|
|
function spawnUIAttributeFactory(ds: Ref) {
|
|
spawn named 'UIAttributeFactory' {
|
|
at ds {
|
|
during P.UIAttribute($selector: string, $attribute: string, $value) =>
|
|
spawn named ['UIAttribute', selector, attribute, value] {
|
|
_attributeLike(selector, attribute, value, 'attribute');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function spawnUIPropertyFactory(ds: Ref) {
|
|
spawn named 'UIPropertyFactory' {
|
|
at ds {
|
|
during P.UIProperty($selector: string, $property: string, $value) =>
|
|
spawn named ['UIProperty', selector, property, value] {
|
|
_attributeLike(selector, property, value, 'property');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function _attributeLike(selector: string,
|
|
key: string,
|
|
value: AnyValue,
|
|
kind: 'attribute' | 'property')
|
|
{
|
|
let savedValues: Array<{node: Element, value: any}> = [];
|
|
|
|
selectorMatch(document.body, selector).forEach(node => {
|
|
if (!isElement(node)) return;
|
|
switch (kind) {
|
|
case 'attribute':
|
|
if (key === 'class') {
|
|
// Deliberately maintains duplicates, so we don't interfere with potential
|
|
// other UIAttribute instances on the same objects for the same attribute.
|
|
// See also the "on stop" handler.
|
|
let existing = splitClassValue(node.getAttribute('class'));
|
|
let toAdd = splitClassValue(value.toString());
|
|
savedValues.push({ node, value: null });
|
|
node.setAttribute('class', [... existing, ... toAdd].join(' '));
|
|
} else {
|
|
savedValues.push({ node, value: node.getAttribute(key) });
|
|
node.setAttribute(key, value.toString());
|
|
}
|
|
break;
|
|
case 'property':
|
|
savedValues.push({node: node, value: (node as any)[key] ?? null});
|
|
(node as any)[key] = value;
|
|
break;
|
|
}
|
|
});
|
|
|
|
on stop {
|
|
savedValues.forEach((entry) => {
|
|
switch (kind) {
|
|
case 'attribute':
|
|
if (key === 'class') {
|
|
let existing = splitClassValue(entry.node.getAttribute('class'));
|
|
let toRemove = splitClassValue(value.toString());
|
|
toRemove.forEach((v) => {
|
|
let i = existing.indexOf(v);
|
|
if (i !== -1) { existing.splice(i, 1); }
|
|
});
|
|
if (existing.length === 0) {
|
|
entry.node.removeAttribute('class');
|
|
} else {
|
|
entry.node.setAttribute('class', existing.join(' '));
|
|
}
|
|
} else {
|
|
if (entry.value === null) {
|
|
entry.node.removeAttribute(key);
|
|
} else {
|
|
entry.node.setAttribute(key, entry.value);
|
|
}
|
|
}
|
|
break;
|
|
case 'property':
|
|
if (entry.value === null) {
|
|
delete (entry.node as any)[key];
|
|
} else {
|
|
(entry.node as any)[key] = entry.value;
|
|
}
|
|
break;
|
|
}
|
|
});
|
|
savedValues = [];
|
|
}
|
|
}
|
|
|
|
function splitClassValue(v: string | null): Array<string> {
|
|
v = (v ?? '').trim();
|
|
return v ? v.split(/ +/) : [];
|
|
}
|
|
|
|
//---------------------------------------------------------------------------
|
|
|
|
function spawnUIChangeablePropertyFactory(ds: Ref) {
|
|
spawn named 'UIChangeablePropertyFactory' {
|
|
at ds {
|
|
during Observe({
|
|
"pattern": :pattern P.UIChangeableProperty(\Q.lit($selector: string),
|
|
\Q.lit($property: string),
|
|
\_)
|
|
}) => spawn named ['UIChangeableProperty', selector, property] {
|
|
field needRescan: boolean = false;
|
|
|
|
on asserted P.UIFragmentVersion($_i, $_v) => needRescan.value = true;
|
|
// TODO: don't be so crude about this ^. On the one hand, this
|
|
// lets us ignore UIFragmentVersion records coming and going; on
|
|
// the other hand, we do potentially a lot of redundant work.
|
|
|
|
function installOrReplaceHandlers() {
|
|
react {
|
|
selectorMatch(document.body, selector).forEach(node => {
|
|
field propValue: any = (node as any)[property];
|
|
assert P.UIChangeableProperty(selector, property, propValue.value);
|
|
const facet = Turn.activeFacet;
|
|
const handlerClosure = (_e: Event) => facet.turn(() => {
|
|
propValue.value = (node as any)[property];
|
|
});
|
|
eventUpdater('change', handlerClosure, true)(node);
|
|
on stop eventUpdater('change', handlerClosure, false)(node);
|
|
});
|
|
stop on (needRescan.value) {
|
|
needRescan.value = false;
|
|
installOrReplaceHandlers();
|
|
}
|
|
}
|
|
}
|
|
installOrReplaceHandlers();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
//---------------------------------------------------------------------------
|
|
|
|
function escapeDataAttributeName(s: FragmentId): string {
|
|
// Per https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset,
|
|
// the rules seem to be:
|
|
//
|
|
// 1. Must not contain a dash immediately followed by an ASCII lowercase letter
|
|
// 2. Must not contain anything other than:
|
|
// - letters
|
|
// - numbers
|
|
// - dash, dot, colon, underscore
|
|
//
|
|
// I'm not implementing this exactly - I'm escaping some things that
|
|
// don't absolutely need escaping, because it's simpler and I don't
|
|
// yet need to undo this transformation.
|
|
|
|
if (typeof s !== 'string') s = s.toString();
|
|
|
|
let result = '';
|
|
for (let i = 0; i < s.length; i++) {
|
|
let c = s[i];
|
|
if (c >= 'a' && c <= 'z') { result = result + c; continue; }
|
|
if (c >= 'A' && c <= 'Z') { result = result + c; continue; }
|
|
if (c >= '0' && c <= '9') { result = result + c; continue; }
|
|
if (c === '.' || c === ':') { result = result + c; continue; }
|
|
|
|
result = result + '_' + c.charCodeAt(0) + '_';
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export interface AnchorOptions {
|
|
fragmentId?: FragmentId,
|
|
}
|
|
|
|
export class Anchor {
|
|
fragmentId: FragmentId;
|
|
|
|
constructor(options: AnchorOptions = {}) {
|
|
this.fragmentId = options.fragmentId ?? newFragmentId();
|
|
}
|
|
|
|
context(... pieces: FragmentId[]) {
|
|
let extn = pieces.map(escapeDataAttributeName).join('__');
|
|
return new Anchor({ fragmentId: this.fragmentId + '__' + extn });
|
|
}
|
|
|
|
html(selector: string, html: HtmlFragments | Array<ChildNode>, orderBy: NodeOrderKey = ''): UIFragmentRecord {
|
|
if (!Array.isArray(html)) {
|
|
html = html.nodes();
|
|
}
|
|
return P.UIFragment(this.fragmentId, selector, embed(create ({ data: html })), orderBy);
|
|
}
|
|
}
|
|
|
|
//---------------------------------------------------------------------------
|
|
|
|
function spawnLocationHashTracker(ds: Ref) {
|
|
spawn named 'LocationHashTracker' {
|
|
at ds {
|
|
field hashValue: string = '/';
|
|
assert P.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 P.SetLocationHash($newHash: string) => {
|
|
window.location.hash = newHash;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
//---------------------------------------------------------------------------
|
|
|
|
function spawnAttributeUpdater(ds: Ref) {
|
|
spawn named 'AttributeUpdater' {
|
|
at ds {
|
|
on message P.SetAttribute($s: string, $k: string, $v: string) =>
|
|
update(s, n => isElement(n) && n.setAttribute(k, v));
|
|
on message P.RemoveAttribute($s: string, $k: string) =>
|
|
update(s, n => isElement(n) && n.removeAttribute(k));
|
|
on message P.SetProperty($s: string, $k: string, $v) =>
|
|
update(s, n => { (n as any)[k] = v });
|
|
on message P.RemoveProperty($s: string, $k: string) =>
|
|
update(s, n => { delete (n as any)[k]; });
|
|
|
|
function update(selector: string, nodeUpdater: (n: ParentNode) => void) {
|
|
selectorMatch(document.body, selector).forEach(nodeUpdater);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
//---------------------------------------------------------------------------
|
|
|
|
function dealWithPreventDefault(eventType: string, event: Event): boolean {
|
|
let shouldPreventDefault = eventType[0] !== '+';
|
|
if (shouldPreventDefault) event.preventDefault();
|
|
return !shouldPreventDefault;
|
|
}
|
|
|
|
function cleanEventType(eventType: string): string {
|
|
return (eventType[0] === '+') ? eventType.slice(1) : eventType;
|
|
}
|
|
|
|
function isQueryableNode(x: any): x is ParentNode {
|
|
return x !== null && typeof x === 'object' && 'querySelectorAll' in x;
|
|
}
|
|
|
|
function isElement(x: any): x is Element {
|
|
return x !== null && typeof x === 'object' && 'getAttribute' in x && 'setAttribute' in x;
|
|
}
|
|
|
|
function selectorMatch(n: Node, selector: string): Array<ParentNode> {
|
|
if (isQueryableNode(n)) {
|
|
if (selector === '.') {
|
|
return [n];
|
|
} else {
|
|
return Array.from(n.querySelectorAll(selector));
|
|
}
|
|
} else {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function eventUpdater(eventType: string, handlerClosure: (event: Event) => void, install: boolean) {
|
|
return (n: EventTarget) => {
|
|
// addEventListener and removeEventListener are idempotent.
|
|
if (install) {
|
|
n.addEventListener(eventType, handlerClosure);
|
|
} else {
|
|
n.removeEventListener(eventType, handlerClosure);
|
|
}
|
|
};
|
|
}
|