diff --git a/packages/html/.gitignore b/packages/html/.gitignore new file mode 100644 index 0000000..cff3d7b --- /dev/null +++ b/packages/html/.gitignore @@ -0,0 +1 @@ +src.ts diff --git a/todo/driver-browser-ui/LICENCE b/packages/html/LICENCE similarity index 100% rename from todo/driver-browser-ui/LICENCE rename to packages/html/LICENCE diff --git a/packages/html/Makefile b/packages/html/Makefile new file mode 120000 index 0000000..0e892c3 --- /dev/null +++ b/packages/html/Makefile @@ -0,0 +1 @@ +../../Makefile.generic-package \ No newline at end of file diff --git a/todo/driver-browser-ui/gpl-3.0.txt b/packages/html/gpl-3.0.txt similarity index 100% rename from todo/driver-browser-ui/gpl-3.0.txt rename to packages/html/gpl-3.0.txt diff --git a/packages/html/package.json b/packages/html/package.json new file mode 100644 index 0000000..d396360 --- /dev/null +++ b/packages/html/package.json @@ -0,0 +1,32 @@ +{ + "name": "@syndicate-lang/html", + "version": "0.0.0", + "description": "DOM/HTML UI for Syndicate/JS", + "homepage": "https://github.com/syndicate-lang/syndicate-js/tree/master/packages/html", + "license": "GPL-3.0+", + "publishConfig": { + "access": "public" + }, + "repository": "github:syndicate-lang/syndicate-js", + "main": "dist/syndicate-html.js", + "module": "lib/index.js", + "types": "lib/index.d.ts", + "author": "Tony Garnock-Jones ", + "scripts": { + "prepare": "npm run compile && npm run rollup", + "compile": "npx syndicate-tsc", + "compile-watch": "npx syndicate-tsc -w --verbose --intermediate-directory src.ts", + "rollup": "npx rollup -c", + "rollup-watch": "npx rollup -c -w", + "clean": "rm -rf lib/ dist/ index.js index.js.map" + }, + "dependencies": { + "@syndicate-lang/core": "file:../core" + }, + "devDependencies": { + "@syndicate-lang/syndicatec": "file:../syndicatec", + "@syndicate-lang/ts-plugin": "file:../ts-plugin", + "@syndicate-lang/tsc": "file:../tsc", + "typescript": "^4.1.3" + } +} diff --git a/packages/html/rollup.config.js b/packages/html/rollup.config.js new file mode 100644 index 0000000..1c6ce1b --- /dev/null +++ b/packages/html/rollup.config.js @@ -0,0 +1,6 @@ +import { SyndicateRollup } from '../../rollup.js'; +const r = new SyndicateRollup('syndicate-html', { globalName: 'SyndicateHtml' }); +export default [ + r.configNoCore('lib/index.js', r.umd), + r.configNoCore('lib/index.js', r.es6), +]; diff --git a/packages/html/src/html.ts b/packages/html/src/html.ts new file mode 100644 index 0000000..10d179b --- /dev/null +++ b/packages/html/src/html.ts @@ -0,0 +1,159 @@ +//--------------------------------------------------------------------------- +// @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 . +//--------------------------------------------------------------------------- + +let nextId = 1; + +export function escape(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export type PlaceholderNodeMap = { [id: string]: Node }; + +export type HtmlFragment = string | number | Array | Node | FlattenInto; + +export interface FlattenInto { + flattenInto(acc: Array, options?: FlattenIntoOptions): void; +} + +export interface FlattenIntoOptions { + nodeMap?: PlaceholderNodeMap; + escapeStrings?: boolean; +} + +export function isFlattenInto(x: any): x is FlattenInto { + return typeof x === 'object' && x !== null && typeof x.flattenInto === 'function'; +} + +export class HtmlFragments implements FlattenInto { + readonly pieces: Array; + + constructor(pieces: Array = []) { + this.pieces = pieces; + } + + appendTo(n: ParentNode): Array { + const ns = [... this.nodes()]; + n.append(... ns); + return ns; + } + + replaceContentOf(n: Element) { + n.innerHTML = ''; + return this.appendTo(n); + } + + node(): Node { + return this.nodes()[0]; + } + + nodes(): Array { + let n = document.createElement('div'); + const nodeMap: PlaceholderNodeMap = {}; + n.innerHTML = this.toString(nodeMap); + for (const p of Array.from(n.querySelectorAll('placeholder'))) { + const e = nodeMap[p.id]; + if (e) { + p.parentNode!.insertBefore(e, p); + p.remove(); + } + } + return Array.from(n.childNodes); + } + + toString(nodeMap?: PlaceholderNodeMap) { + const allPieces: Array = []; + this.flattenInto(allPieces, { nodeMap }); + return allPieces.join(''); + } + + flattenInto(acc: Array, options: FlattenIntoOptions) { + flattenInto(acc, this.pieces, { ... options, escapeStrings: false }); + } + + join(pieces: Array): Array { + return join(pieces, this); + } +} + +export function flattenInto(acc: Array, + p: HtmlFragment, + options: FlattenIntoOptions = {}) +{ + switch (typeof p) { + case 'string': acc.push((options.escapeStrings ?? true) ? escape(p) : p); break; + case 'number': acc.push('' + p); break; + case 'object': + if (isFlattenInto(p)) { + p.flattenInto(acc, { nodeMap: options.nodeMap }); + } else if (Array.isArray(p)) { + p.forEach(q => flattenInto(acc, q, options)); + } else if (typeof Node !== 'undefined' && p instanceof Node) { + if (options.nodeMap !== void 0) { + const id = `__SYNDICATE__html__${nextId++}`; + options.nodeMap[id] = p; + acc.push(``); + } else { + acc.push(p); + } + } + break; + default: + ((_n: never) => {})(p); + } +} + +export function join(pieces: Array, separator: HtmlFragment): Array { + if (pieces.length <= 1) { + return []; + } else { + const result = [pieces[0]]; + for (let i = 1; i < pieces.length; i++) { + result.push(separator); + result.push(pieces[i]); + } + return result; + } +} + +export function template( + constantParts: TemplateStringsArray, + ... variableParts: Array): HtmlFragments +{ + const pieces: Array = []; + function pushConst(i: number) { + const r = constantParts.raw[i].trimLeft(); + if (r) pieces.push(r); + } + pushConst(0); + variableParts.forEach((vp, vpIndex) => { + flattenInto(pieces, vp, { escapeStrings: true }); + pushConst(vpIndex + 1); + }); + return new HtmlFragments(pieces); +}; + +export function raw(str: string) { + return new HtmlFragments([str]); +} + +export default template; diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts new file mode 100644 index 0000000..2421108 --- /dev/null +++ b/packages/html/src/index.ts @@ -0,0 +1,579 @@ +//--------------------------------------------------------------------------- +// @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"; +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: string, orderBy: NodeOrderKey = ''): Record { + return P.UIFragment(this.fragmentId, selector, html, 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); + } + }; +} diff --git a/todo/driver-browser-ui/src/protocol.js b/packages/html/src/protocol.ts similarity index 60% rename from todo/driver-browser-ui/src/protocol.js rename to packages/html/src/protocol.ts index 47b717c..48e67a9 100644 --- a/todo/driver-browser-ui/src/protocol.js +++ b/packages/html/src/protocol.ts @@ -1,6 +1,6 @@ //--------------------------------------------------------------------------- -// @syndicate-lang/driver-browser-ui, Browser-based UI for Syndicate -// Copyright (C) 2016-2018 Tony Garnock-Jones +// @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 @@ -20,34 +20,36 @@ // the given eventType to all nodes matching the given selector *at // the time of the subscription*. As nodes *from this library* come // and go, they will have event handlers installed and removed as -// well. WARNING: The simple implementation below currently scans the -// whole document anytime a change is signalled; in future, it may not -// do such a scan. -message type GlobalEvent(selector, eventType, event); -module.exports.GlobalEvent = GlobalEvent; +// well. +// +// WARNING: The current simple implementation re-scans the whole +// document anytime a change is signalled; in future, it may not do +// such a scan. +// +export message type GlobalEvent(selector, eventType, event); // Message. As GlobalEvent, but instead of using a selector to choose // target DOM nodes, attaches an event handler to the browser "window" // object itself. -message type WindowEvent(eventType, event); -module.exports.WindowEvent = WindowEvent; +// +export message type WindowEvent(eventType, event); // Message. Like GlobalEvent, but applies only within the scope of the // UI fragment identified. -message type UIEvent(fragmentId, selector, eventType, event); -module.exports.UIEvent = UIEvent; +// +export message type UIEvent(fragmentId, selector, eventType, event); // Assertion. Causes the setup of DOM nodes corresponding to the given // HTML fragment, as immediate children of all nodes named by the // given selector that exist at the time of assertion. The orderBy // field should be null, a string, or a number. Fragments are ordered // primarily by orderBy, and secondarily by fragmentId. -assertion type UIFragment(fragmentId, selector, html, orderBy); -module.exports.UIFragment = UIFragment; +// +export assertion type UIFragment(fragmentId, selector, html, orderBy); // Assertion. Asserted by respondent to a given UIFragment. -assertion type UIFragmentVersion(fragmentId, version); -module.exports.UIFragmentVersion = UIFragmentVersion; +// +export assertion type UIFragmentVersion(fragmentId, version); // Assertion. Causes the setup of DOM attributes on all nodes named by // the given selector that exist at the time of assertion. @@ -56,35 +58,32 @@ module.exports.UIFragmentVersion = UIFragmentVersion; // the attribute as a (string encoding of a) set. The given value is // split on whitespace, and each piece is added to the set of things // already present. (See the implementation for details.) -assertion type UIAttribute(selector, attribute, value); -module.exports.UIAttribute = UIAttribute; +// +export assertion type UIAttribute(selector, attribute, value); // Assertion. Similar to UIAttribute, but for properties of DOM nodes. -assertion type UIProperty(selector, property, value); -module.exports.UIProperty = UIProperty; +// +export assertion type UIProperty(selector, property, value); // Assertion. For clients to monitor the values of properties that, // when changed, emit 'change' events. -assertion type UIChangeableProperty(selector, property, value); -module.exports.UIChangeableProperty = UIChangeableProperty; +// +export assertion type UIChangeableProperty(selector, property, value); // Messages. // NOTE: These do not treat "class" specially! -message type SetAttribute(selector, attribute, value); -message type RemoveAttribute(selector, attribute); -message type SetProperty(selector, property, value); -message type RemoveProperty(selector, property); -module.exports.SetAttribute = SetAttribute; -module.exports.RemoveAttribute = RemoveAttribute; -module.exports.SetProperty = SetProperty; -module.exports.RemoveProperty = RemoveProperty; +// +export message type SetAttribute(selector, attribute, value); +export message type RemoveAttribute(selector, attribute); +export message type SetProperty(selector, property, value); +export message type RemoveProperty(selector, property); // Assertion. Current "location hash" -- the "#/path/part" fragment at // the end of window.location. -assertion type LocationHash(value); -module.exports.LocationHash = LocationHash; +// +export assertion type LocationHash(value); // Message. Causes window.location to be updated to have the given new // "location hash" value. -message type SetLocationHash(value); -module.exports.SetLocationHash = SetLocationHash; +// +export message type SetLocationHash(value); diff --git a/packages/html/tsconfig.json b/packages/html/tsconfig.json new file mode 100644 index 0000000..07bbdef --- /dev/null +++ b/packages/html/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["ES2017", "DOM"], + "declaration": true, + "baseUrl": "./src", + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./lib", + "esModuleInterop": true, + "moduleResolution": "node", + "module": "es6", + "sourceMap": true, + "strict": true, + "plugins": [ + { "name": "@syndicate-lang/ts-plugin" } + ] + }, + "include": ["src/**/*"] +} diff --git a/todo/driver-browser-ui/.babelrc b/todo/driver-browser-ui/.babelrc deleted file mode 100644 index 8991585..0000000 --- a/todo/driver-browser-ui/.babelrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "presets": [ "@babel/preset-env" ], - "plugins": [ "@syndicate-lang/syntax/plugin" ] -} diff --git a/todo/driver-browser-ui/package.json b/todo/driver-browser-ui/package.json deleted file mode 100644 index a443240..0000000 --- a/todo/driver-browser-ui/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@syndicate-lang/driver-browser-ui", - "version": "0.4.1", - "description": "Browser-based (DOM) UI for Syndicate/js", - "main": "lib/index.js", - "repository": "github:syndicate-lang/syndicate-js", - "author": "Tony Garnock-Jones ", - "license": "GPL-3.0+", - "publishConfig": { - "access": "public" - }, - "scripts": { - "prepare": "which redo >/dev/null && redo || ../../do" - }, - "homepage": "https://github.com/syndicate-lang/syndicate-js/tree/master/packages/driver-browser-ui", - "devDependencies": { - "@syndicate-lang/syntax": "file:../syntax" - }, - "dependencies": { - "@syndicate-lang/core": "file:../core", - "immutable": "^4.0.0-rc.12" - } -} diff --git a/todo/driver-browser-ui/src/html.js b/todo/driver-browser-ui/src/html.js deleted file mode 100644 index 1019a85..0000000 --- a/todo/driver-browser-ui/src/html.js +++ /dev/null @@ -1,88 +0,0 @@ -//--------------------------------------------------------------------------- -// @syndicate-lang/driver-browser-ui, Browser-based UI for Syndicate -// Copyright (C) 2016-2018 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 . -//--------------------------------------------------------------------------- - -assertion type htmlTag(label, properties, children); -assertion type htmlProperty(key, value); -assertion type htmlFragment(children); -assertion type htmlLiteral(text); - -export function html(tag, props, ...kids) { - if (tag === htmlFragment) { - // JSX short syntax for fragments doesn't allow properties, so - // props will never have any defined. - return htmlFragment(kids); - } else { - let properties = [] - for (let k in props) { - properties.push(htmlProperty(k, props[k])); - } - return htmlTag(tag, properties, kids); - } -} - -//--------------------------------------------------------------------------- - -export function escapeHtml(s) { - return s - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -export const emptyHtmlElements = {}; -for (let e of - "area base br col embed hr img input keygen link meta param source track wbr".split(/ +/)) { - emptyHtmlElements[e] = true; -} - -export function htmlToString(j) { - let pieces = []; - - function walk(j) { - if (htmlTag.isClassOf(j)) { - pieces.push('<', htmlTag._label(j)); - htmlTag._properties(j).forEach( - (p) => pieces.push(' ', escapeHtml(htmlProperty._key(p)), - '="', escapeHtml(htmlProperty._value(p)), '"')); - pieces.push('>'); - htmlTag._children(j).forEach(walk); - if (!(htmlTag._label(j) in emptyHtmlElements)) { - pieces.push(''); - } - } else if (htmlFragment.isClassOf(j)) { - htmlFragment._children(j).forEach(walk); - } else if (htmlLiteral.isClassOf(j)) { - pieces.push(htmlLiteral._text(j)); - } else if (typeof j === 'object' && j && typeof j[Symbol.iterator] === 'function') { - for (let k of j) { walk(k); } - } else { - pieces.push(escapeHtml("" + j)); - } - } - - walk(j); - return pieces.join(''); -} - -export function htmlToNode(j) { - var node = document.createElement('div'); - node.innerHTML = htmlToString(j); - return node.firstChild; -} diff --git a/todo/driver-browser-ui/src/index.js b/todo/driver-browser-ui/src/index.js deleted file mode 100644 index bacea3d..0000000 --- a/todo/driver-browser-ui/src/index.js +++ /dev/null @@ -1,515 +0,0 @@ -//--------------------------------------------------------------------------- -// @syndicate-lang/driver-browser-ui, Browser-based UI for Syndicate -// Copyright (C) 2016-2018 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, Observe, Dataspace } from "@syndicate-lang/core"; -const randomId = RandomID.randomId; - -import * as P from "./protocol"; -export * from "./protocol"; - -import * as H from "./html"; -export * from "./html"; - -/////////////////////////////////////////////////////////////////////////// -// ID allocators - -const moduleInstance = randomId(16, true); - -let nextFragmentIdNumber = 0; -export function newFragmentId() { - return 'ui_' + moduleInstance + '_' + (nextFragmentIdNumber++); -} - -/////////////////////////////////////////////////////////////////////////// - -spawn named 'GlobalEventFactory' { - during Observe(P.GlobalEvent($selector, $eventType, _)) - spawn named ['GlobalEvent', selector, eventType] { - let sender = Dataspace.wrapExternal((e) => { send P.GlobalEvent(selector, eventType, e); }); - function handler(event) { - sender(event); - return dealWithPreventDefault(eventType, event); - } - - function updateEventListeners(install) { - selectorMatch(document, 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. - } -} - -/////////////////////////////////////////////////////////////////////////// - -spawn named 'WindowEventFactory' { - during Observe(P.WindowEvent($eventType, _)) - spawn named ['WindowEvent', eventType] { - let sender = Dataspace.wrapExternal((e) => { send P.WindowEvent(eventType, e); }); - let handler = function (event) { - sender(event); - return dealWithPreventDefault(eventType, event); - } - - function updateEventListeners(install) { - if (install) { - window.addEventListener(cleanEventType(eventType), handler); - } else { - window.removeEventListener(cleanEventType(eventType), handler); - } - } - - on start updateEventListeners(true); - on stop updateEventListeners(false); - } -} - -/////////////////////////////////////////////////////////////////////////// - -spawn named 'UIFragmentFactory' { - during P.UIFragment($fragmentId, _, _, _) - spawn named ['UIFragment', fragmentId] { - field this.version = 0; - - let selector, html, orderBy; - let anchorNodes = []; - let eventRegistrations = {}; - // ^ Map from (Map of selector/eventType) to closure. - - assert P.UIFragmentVersion(fragmentId, this.version) when (this.version > 0); - - on stop removeNodes(); - - during Observe(P.UIEvent(fragmentId, $selector, $eventType, _)) { - on start updateEventListeners({ selector, eventType }, true); - on stop updateEventListeners({ selector, eventType }, false); - } - - on asserted P.UIFragment(fragmentId, $newSelector, $newHtml, $newOrderBy) { - removeNodes(); - - selector = newSelector; - html = newHtml; - orderBy = newOrderBy; - anchorNodes = (selector !== null) ? selectorMatch(document, 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); - }); - }); - - for (let key in eventRegistrations) { - updateEventListeners(JSON.parse(key), true); // (re)install event listeners - } - - this.version++; - } - - function removeNodes() { - anchorNodes.forEach((anchorNode) => { - let insertionPoint = findInsertionPoint(anchorNode, orderBy, fragmentId); - while (1) { - let n = insertionPoint ? insertionPoint.previousSibling : anchorNode.lastChild; - if (!(n && hasSortKey(n, orderBy, fragmentId))) break; - n.parentNode.removeChild(n); // auto-updates previousSibling/lastChild - } - }); - } - - function updateEventListeners(c, install) { - let key = JSON.stringify(c); // c is of the form { selector: ..., eventType: ... } - let handlerClosure; - - if (!(key in eventRegistrations)) { - let sender = Dataspace.wrapExternal((e) => { - send P.UIEvent(fragmentId, c.selector, c.eventType, e); - }); - function handler(event) { - sender(event); - return dealWithPreventDefault(c.eventType, event); - } - eventRegistrations[key] = handler; - handlerClosure = handler; - } else { - handlerClosure = eventRegistrations[key]; - } - - anchorNodes.forEach((anchorNode) => { - let insertionPoint = findInsertionPoint(anchorNode, orderBy, fragmentId); - while (1) { - let uiNode = insertionPoint ? insertionPoint.previousSibling : anchorNode.lastChild; - if (!(uiNode && hasSortKey(uiNode, orderBy, fragmentId))) break; - if ('querySelectorAll' in uiNode) { - selectorMatch(uiNode, c.selector).forEach( - eventUpdater(cleanEventType(c.eventType), handlerClosure, install)); - } - insertionPoint = uiNode; - } - }); - - if (!install) { - delete eventRegistrations[key]; - } - } - } -} - -const SYNDICATE_SORT_KEY = '__syndicate_sort_key'; - -function setSortKey(n, orderBy, fragmentId) { - let v = JSON.stringify([orderBy, fragmentId]); - if ('dataset' in n) { - // html element nodes etc. - n.dataset[SYNDICATE_SORT_KEY] = v; - } else { - // text nodes, svg nodes, etc etc. - n[SYNDICATE_SORT_KEY] = v; - } -} - -function getSortKey(n) { - if ('dataset' in n && n.dataset[SYNDICATE_SORT_KEY]) { - return JSON.parse(n.dataset[SYNDICATE_SORT_KEY]); - } - if (n[SYNDICATE_SORT_KEY]) { - return JSON.parse(n[SYNDICATE_SORT_KEY]); - } - return null; -} - -function hasSortKey(n, orderBy, fragmentId) { - 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) { - 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) { - 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, b) { - if (typeof a > typeof b) return true; - if (typeof a < typeof b) return false; - return a > b; -} - -function findInsertionPoint(n, orderBy, fragmentId) { - 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) && (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, html) { - let e = parent.cloneNode(false); - e.innerHTML = H.htmlToString(html); - return Array.prototype.slice.call(e.childNodes); -} - -function configureNode(n) { - // Runs post-insertion configuration of nodes. - // TODO: review this design. - selectorMatch(n, '.-syndicate-focus').forEach(function (n) { - if ('focus' in n && 'setSelectionRange' in n) { - n.focus(); - n.setSelectionRange(n.value.length, n.value.length); - } - }); -} - -/////////////////////////////////////////////////////////////////////////// - -spawn named 'UIAttributeFactory' { - during P.UIAttribute($selector, $attribute, $value) - spawn named ['UIAttribute', selector, attribute, value] { - _attributeLike.call(this, selector, attribute, value, 'attribute'); - } -} - -spawn named 'UIPropertyFactory' { - during P.UIProperty($selector, $property, $value) - spawn named ['UIProperty', selector, property, value] { - _attributeLike.call(this, selector, property, value, 'property'); - } -} - -function _attributeLike(selector, key, value, kind) { - let savedValues = []; - // ^ Array of {node: DOMNode, value: (U Null String)}, - // when attribute !== 'class' or kind !== 'attribute'. - // ^ Array of {node: DOMNode}, - // when attribute === 'class' and kind === 'attribute'. - - selectorMatch(document, 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 - // restoreSavedValues. - let existing = splitClassValue(node.getAttribute('class')); - let toAdd = splitClassValue(value); - savedValues.push({node: node}); - node.SetAttribute('class', existing.concat(toAdd).join(' ')); - } else { - savedValues.push({node: node, value: node.getAttribute(key)}); - node.SetAttribute(key, value); - } - break; - case 'property': - savedValues.push({node: node, value: node[key]}); - node[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); - toRemove.forEach(function (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 (typeof entry.value === 'undefined') { - delete entry.node[key]; - } else { - entry.node[key] = entry.value; - } - break; - } - }); - savedValues = []; - } -}; - -function splitClassValue(v) { - v = (v || '').trim(); - return v ? v.split(/ +/) : []; -} - -/////////////////////////////////////////////////////////////////////////// - -spawn named 'UIChangeablePropertyFactory' { - during Observe(P.UIChangeableProperty($selector, $property, _)) - spawn named ['UIChangeableProperty', selector, property] { - on start selectorMatch(document, selector).forEach((node) => { - react { - field this.value = node[property]; - assert P.UIChangeableProperty(selector, property, this.value); - const handlerClosure = Dataspace.wrapExternal((e) => { this.value = node[property]; }); - on start eventUpdater('change', handlerClosure, true)(node); - on stop eventUpdater('change', handlerClosure, false)(node); - } - }); - } -} - -/////////////////////////////////////////////////////////////////////////// - -function escapeDataAttributeName(s) { - // 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 = JSON.stringify(s); - } - - 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; } - - c = c.charCodeAt(0); - result = result + '_' + c + '_'; - } - return result; -} - -function dealWithPreventDefault(eventType, event) { - let shouldPreventDefault = eventType.charAt(0) !== '+'; - if (shouldPreventDefault) event.preventDefault(); - return !shouldPreventDefault; -} - -function cleanEventType(eventType) { - return (eventType.charAt(0) === '+') ? eventType.slice(1) : eventType; -} - -function selectorMatch(n, selector) { - if (n && typeof n === 'object' && 'querySelectorAll' in n) { - if (selector === '.') { - return [n]; - } else { - return Array.prototype.slice.call(n.querySelectorAll(selector)); - } - } else { - return []; - } -} - -function eventUpdater(eventType, handlerClosure, install) { - return function (n) { - // addEventListener and removeEventListener are idempotent. - if (install) { - n.addEventListener(eventType, handlerClosure); - } else { - n.removeEventListener(eventType, handlerClosure); - } - }; -} - -/////////////////////////////////////////////////////////////////////////// - -export class Anchor { - constructor(options) { - options = Object.assign({ fragmentId: void 0 }, options); - this.fragmentId = - (typeof options.fragmentId === 'undefined') ? newFragmentId() : options.fragmentId; - } - - context(...pieces) { - let extn = pieces.map(escapeDataAttributeName).join('__'); - return new Anchor({ fragmentId: this.fragmentId + '__' + extn }); - } - - html(selector, html, orderBy) { - return P.UIFragment(this.fragmentId, selector, html, orderBy === void 0 ? null : orderBy); - } -} - -/////////////////////////////////////////////////////////////////////////// - -spawn named 'LocationHashTracker' { - field this.hashValue = '/'; - - assert P.LocationHash(this.hashValue); - - let handlerClosure = Dataspace.wrapExternal((_e) => loadHash.call(this)); - - on start { - loadHash.call(this); - window.addEventListener('hashchange', handlerClosure); - } - on stop { - window.removeEventListener('hashchange', handlerClosure); - } - - on message P.SetLocationHash($newHash) { - window.location.hash = newHash; - } - - function loadHash() { - var h = window.location.hash; - if (h.length && h[0] === '#') { - h = h.slice(1); - } - this.hashValue = h || '/'; - } -} - -/////////////////////////////////////////////////////////////////////////// - -spawn named 'AttributeUpdater' { - on message P.SetAttribute($s, $k, $v) update(s, (n) => n.setAttribute(k, v)); - on message P.RemoveAttribute($s, $k) update(s, (n) => n.removeAttribute(k)); - on message P.SetProperty($s, $k, $v) update(s, (n) => { n[k] = v }); - on message P.RemoveProperty($s, $k) update(s, (n) => { delete n[k]; }); - - function update(selector, nodeUpdater) { - selectorMatch(document, selector).forEach(nodeUpdater); - } -}