syndicate-js/packages/html/src/index.ts

573 lines
22 KiB
TypeScript

//---------------------------------------------------------------------------
// @syndicate-lang/html, Browser-based UI for Syndicate
// Copyright (C) 2016-2021 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//---------------------------------------------------------------------------
import { randomId, Facet, Observe, FlexMap, Value, embed, Embedded } from "@syndicate-lang/core";
import * as P from "./protocol";
export * from "./protocol";
import { HtmlFragments } from "./html";
export * from "./html";
boot {
spawnGlobalEventFactory(thisFacet);
spawnWindowEventFactory(thisFacet);
spawnUIFragmentFactory(thisFacet);
spawnUIAttributeFactory(thisFacet);
spawnUIPropertyFactory(thisFacet);
spawnUIChangeablePropertyFactory(thisFacet);
spawnLocationHashTracker(thisFacet);
spawnAttributeUpdater(thisFacet);
}
//---------------------------------------------------------------------------
// ID allocators
const moduleInstance = randomId(16, true);
let nextFragmentIdNumber = 0;
export function newFragmentId() {
return 'ui_' + moduleInstance + '_' + (nextFragmentIdNumber++);
}
//---------------------------------------------------------------------------
export function spawnGlobalEventFactory<T>(thisFacet: Facet<T>) {
spawn named 'GlobalEventFactory' {
during Observe(P.GlobalEvent($selector: string, $eventType: string, _)) =>
spawn named ['GlobalEvent', selector, eventType] {
let sender = thisFacet.wrapExternal((thisFacet, e: Event) => {
send message P.GlobalEvent(selector, eventType, embed(e));
});
function handler(event: Event) {
sender(event);
return dealWithPreventDefault(eventType, event);
}
function updateEventListeners(install: boolean) {
selectorMatch(document.body, selector).forEach(
eventUpdater(cleanEventType(eventType), handler, install));
}
on start 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<T>(thisFacet: Facet<T>) {
spawn named 'WindowEventFactory' {
during Observe(P.WindowEvent($eventType: string, _)) =>
spawn named ['WindowEvent', eventType] {
let sender = thisFacet.wrapExternal((thisFacet, e: Event) => {
send message P.WindowEvent(eventType, embed(e));
});
let handler = function (event: Event) {
sender(event);
return dealWithPreventDefault(eventType, event);
}
function updateEventListeners(install: boolean) {
if (install) {
window.addEventListener(cleanEventType(eventType), handler);
} else {
window.removeEventListener(cleanEventType(eventType), handler);
}
}
on start 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<T>(thisFacet: Facet<T>) {
type RegistrationKey = [string, string]; // [selector, eventType]
spawn named 'UIFragmentFactory' {
during P.UIFragment($fragmentId0, _, _, _) =>
spawn named ['UIFragment', fragmentId0] {
if (!isFragmentId(fragmentId0)) return;
const fragmentId = fragmentId0;
field version: number = 0;
assert P.UIFragmentVersion(fragmentId, this.version) when (this.version > 0);
let selector: string;
let html: Array<ChildNode>;
let orderBy: NodeOrderKey;
let anchorNodes: Array<Element> = [];
let eventRegistrations =
new FlexMap<RegistrationKey, HandlerClosure>(JSON.stringify);
on stop removeNodes();
during Observe(P.UIEvent(fragmentId, $selector: string, $eventType: string, _)) => {
on start 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<Array<ChildNode>>).embeddedValue;
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));
this.version++;
}
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)) {
let sender = thisFacet.wrapExternal((thisFacet, e: Event) => {
send message P.UIEvent(fragmentId, selector, eventType, embed(e));
});
function handler(event: Event) {
sender(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(
function (n: Element | HTMLTextAreaElement | HTMLInputElement) {
if ('focus' in n && 'setSelectionRange' in n) {
n.focus();
n.setSelectionRange(n.value.length, n.value.length);
}
});
}
//---------------------------------------------------------------------------
function spawnUIAttributeFactory<T>(thisFacet: Facet<T>) {
spawn named 'UIAttributeFactory' {
during P.UIAttribute($selector: string, $attribute: string, $value) =>
spawn named ['UIAttribute', selector, attribute, value] {
_attributeLike(thisFacet, selector, attribute, value, 'attribute');
}
}
}
function spawnUIPropertyFactory<T>(thisFacet: Facet<T>) {
spawn named 'UIPropertyFactory' {
during P.UIProperty($selector: string, $property: string, $value) =>
spawn named ['UIProperty', selector, property, value] {
_attributeLike(thisFacet, selector, property, value, 'property');
}
}
}
function _attributeLike<T>(thisFacet: Facet<T>,
selector: string,
key: string,
value: Value,
kind: 'attribute' | 'property')
{
let savedValues: Array<{node: Element, value: any}> = [];
selectorMatch(document.body, selector).forEach(node => {
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<T>(thisFacet: Facet<T>) {
spawn named 'UIChangeablePropertyFactory' {
during Observe(P.UIChangeableProperty($selector: string, $property: string, _)) =>
spawn named ['UIChangeableProperty', selector, property] {
on start selectorMatch(document.body, selector).forEach(node => {
react {
field value: any = (node as any)[property];
assert P.UIChangeableProperty(selector, property, this.value);
const handlerClosure = thisFacet.wrapExternal((_thisFacet, _e: Event) => {
this.value = (node as any)[property];
});
on start eventUpdater('change', handlerClosure, true)(node);
on stop eventUpdater('change', handlerClosure, false)(node);
}
});
}
}
}
//---------------------------------------------------------------------------
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, orderBy: NodeOrderKey = ''): ReturnType<typeof P.UIFragment> {
return P.UIFragment(this.fragmentId, selector, embed(html.nodes()), orderBy);
}
}
//---------------------------------------------------------------------------
function spawnLocationHashTracker<T>(thisFacet: Facet<T>) {
spawn named 'LocationHashTracker' {
field hashValue: string = '/';
assert P.LocationHash(this.hashValue);
const loadHash = () => {
var h = window.location.hash;
if (h.length && h[0] === '#') {
h = h.slice(1);
}
this.hashValue = h || '/';
};
let handlerClosure = thisFacet.wrapExternal(loadHash);
on start {
loadHash();
window.addEventListener('hashchange', handlerClosure);
}
on stop {
window.removeEventListener('hashchange', handlerClosure);
}
on message P.SetLocationHash($newHash: string) => {
window.location.hash = newHash;
}
}
}
//---------------------------------------------------------------------------
function spawnAttributeUpdater<T>(thisFacet: Facet<T>) {
spawn named 'AttributeUpdater' {
on message P.SetAttribute($s: string, $k: string, $v: string) =>
update(s, n => n.setAttribute(k, v));
on message P.RemoveAttribute($s: string, $k: string) =>
update(s, 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: Element) => 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 selectorMatch(n: Element | Node, selector: string): Array<Element> {
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 function (n: EventTarget) {
// addEventListener and removeEventListener are idempotent.
if (install) {
n.addEventListener(eventType, handlerClosure);
} else {
n.removeEventListener(eventType, handlerClosure);
}
};
}