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('', htmlTag._label(j), '>');
- }
- } 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);
- }
-}