//--------------------------------------------------------------------------- // @syndicate-lang/html, Browser-based UI for Syndicate // Copyright (C) 2016-2021 Tony Garnock-Jones // // 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 . //--------------------------------------------------------------------------- import { randomId, Facet, Observe, FlexMap, Value, Record } 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(thisFacet: Facet) { 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, 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(thisFacet: Facet) { 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, 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(thisFacet: Facet) { 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: string; let orderBy: NodeOrderKey; let anchorNodes: Array = []; let eventRegistrations = new FlexMap(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: string, $newOrderBy) => { if (!isNodeOrderKey(newOrderBy)) return; removeNodes(); selector = newSelector; html = newHtml; 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); htmlToNodes(anchorNode, 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)) { // auto-updates previousSibling/lastChild n.parentNode?.removeChild(n); } } }); } 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, 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 === null) break; if (!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 htmlToNodes(parent: Element, html: string): Array { let e = parent.cloneNode(false) as Element; e.innerHTML = html; return Array.from(e.childNodes); } 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(thisFacet: Facet) { 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(thisFacet: Facet) { 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(thisFacet: Facet, 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 { v = (v ?? '').trim(); return v ? v.split(/ +/) : []; } //--------------------------------------------------------------------------- function spawnUIChangeablePropertyFactory(thisFacet: Facet) { 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 | string, orderBy: NodeOrderKey = ''): Record { return P.UIFragment(this.fragmentId, selector, typeof html === 'string' ? html : html.toString(), orderBy); } } //--------------------------------------------------------------------------- function spawnLocationHashTracker(thisFacet: Facet) { 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(thisFacet: Facet) { 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 { 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); } }; }