Port Syndicate HTML library
This commit is contained in:
parent
690ac12cc0
commit
18f087d18e
|
@ -0,0 +1 @@
|
|||
src.ts
|
|
@ -0,0 +1 @@
|
|||
../../Makefile.generic-package
|
|
@ -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 <tonyg@leastfixedpoint.com>",
|
||||
"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"
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
];
|
|
@ -0,0 +1,159 @@
|
|||
//---------------------------------------------------------------------------
|
||||
// @syndicate-lang/html, Browser-based UI for Syndicate
|
||||
// Copyright (C) 2016-2021 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//---------------------------------------------------------------------------
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
export function escape(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export type PlaceholderNodeMap = { [id: string]: Node };
|
||||
|
||||
export type HtmlFragment = string | number | Array<HtmlFragment> | Node | FlattenInto;
|
||||
|
||||
export interface FlattenInto {
|
||||
flattenInto(acc: Array<HtmlFragment>, 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<HtmlFragment>;
|
||||
|
||||
constructor(pieces: Array<HtmlFragment> = []) {
|
||||
this.pieces = pieces;
|
||||
}
|
||||
|
||||
appendTo(n: ParentNode): Array<Node> {
|
||||
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<Node> {
|
||||
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<string> = [];
|
||||
this.flattenInto(allPieces, { nodeMap });
|
||||
return allPieces.join('');
|
||||
}
|
||||
|
||||
flattenInto(acc: Array<string>, options: FlattenIntoOptions) {
|
||||
flattenInto(acc, this.pieces, { ... options, escapeStrings: false });
|
||||
}
|
||||
|
||||
join(pieces: Array<HtmlFragment>): Array<HtmlFragment> {
|
||||
return join(pieces, this);
|
||||
}
|
||||
}
|
||||
|
||||
export function flattenInto(acc: Array<HtmlFragment>,
|
||||
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(`<placeholder id="${id}"></placeholder>`);
|
||||
} else {
|
||||
acc.push(p);
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
((_n: never) => {})(p);
|
||||
}
|
||||
}
|
||||
|
||||
export function join(pieces: Array<HtmlFragment>, separator: HtmlFragment): Array<HtmlFragment> {
|
||||
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<HtmlFragment>): HtmlFragments
|
||||
{
|
||||
const pieces: Array<HtmlFragment> = [];
|
||||
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;
|
|
@ -0,0 +1,579 @@
|
|||
//---------------------------------------------------------------------------
|
||||
// @syndicate-lang/html, Browser-based UI for Syndicate
|
||||
// Copyright (C) 2016-2021 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//---------------------------------------------------------------------------
|
||||
|
||||
import { randomId, Facet, Observe, FlexMap, Value, 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<T>(thisFacet: Facet<T>) {
|
||||
spawn named 'GlobalEventFactory' {
|
||||
during Observe(P.GlobalEvent($selector: string, $eventType: string, _)) =>
|
||||
spawn named ['GlobalEvent', selector, eventType] {
|
||||
let sender = thisFacet.wrapExternal((thisFacet, e: Event) => {
|
||||
send message P.GlobalEvent(selector, eventType, e);
|
||||
});
|
||||
|
||||
function handler(event: Event) {
|
||||
sender(event);
|
||||
return dealWithPreventDefault(eventType, event);
|
||||
}
|
||||
|
||||
function updateEventListeners(install: boolean) {
|
||||
selectorMatch(document.body, selector).forEach(
|
||||
eventUpdater(cleanEventType(eventType), handler, install));
|
||||
}
|
||||
|
||||
on start updateEventListeners(true);
|
||||
on stop updateEventListeners(false);
|
||||
|
||||
on asserted P.UIFragmentVersion($_i, $_v) => updateEventListeners(true);
|
||||
// TODO: don't be so crude about this ^. On the one hand, this
|
||||
// lets us ignore UIFragmentVersion records coming and going; on
|
||||
// the other hand, we do potentially a lot of redundant work.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function spawnWindowEventFactory<T>(thisFacet: Facet<T>) {
|
||||
spawn named 'WindowEventFactory' {
|
||||
during Observe(P.WindowEvent($eventType: string, _)) =>
|
||||
spawn named ['WindowEvent', eventType] {
|
||||
let sender = thisFacet.wrapExternal((thisFacet, e: Event) => {
|
||||
send message P.WindowEvent(eventType, e);
|
||||
});
|
||||
|
||||
let handler = function (event: Event) {
|
||||
sender(event);
|
||||
return dealWithPreventDefault(eventType, event);
|
||||
}
|
||||
|
||||
function updateEventListeners(install: boolean) {
|
||||
if (install) {
|
||||
window.addEventListener(cleanEventType(eventType), handler);
|
||||
} else {
|
||||
window.removeEventListener(cleanEventType(eventType), handler);
|
||||
}
|
||||
}
|
||||
|
||||
on start updateEventListeners(true);
|
||||
on stop updateEventListeners(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type NodeOrderKey = string | number;
|
||||
export type FragmentId = string | number;
|
||||
|
||||
function isFragmentId(x: any): x is FragmentId {
|
||||
return typeof x === 'string' || typeof x === 'number';
|
||||
}
|
||||
|
||||
function isNodeOrderKey(x: any): x is NodeOrderKey {
|
||||
return typeof x === 'string' || typeof x === 'number';
|
||||
}
|
||||
|
||||
type HandlerClosure = (event: Event) => void;
|
||||
|
||||
function spawnUIFragmentFactory<T>(thisFacet: Facet<T>) {
|
||||
type RegistrationKey = [string, string]; // [selector, eventType]
|
||||
|
||||
spawn named 'UIFragmentFactory' {
|
||||
during P.UIFragment($fragmentId0, _, _, _) =>
|
||||
spawn named ['UIFragment', fragmentId0] {
|
||||
if (!isFragmentId(fragmentId0)) return;
|
||||
const fragmentId = fragmentId0;
|
||||
|
||||
field version: number = 0;
|
||||
assert P.UIFragmentVersion(fragmentId, this.version) when (this.version > 0);
|
||||
|
||||
let selector: string;
|
||||
let html: string;
|
||||
let orderBy: NodeOrderKey;
|
||||
let anchorNodes: Array<Element> = [];
|
||||
let eventRegistrations =
|
||||
new FlexMap<RegistrationKey, HandlerClosure>(JSON.stringify);
|
||||
|
||||
on stop removeNodes();
|
||||
|
||||
during Observe(P.UIEvent(fragmentId, $selector: string, $eventType: string, _)) => {
|
||||
on start updateEventListeners([ selector, eventType ], true);
|
||||
on stop updateEventListeners([ selector, eventType ], false);
|
||||
}
|
||||
|
||||
on asserted P.UIFragment(fragmentId, $newSelector: string, $newHtml: 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<ChildNode> {
|
||||
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<T>(thisFacet: Facet<T>) {
|
||||
spawn named 'UIAttributeFactory' {
|
||||
during P.UIAttribute($selector: string, $attribute: string, $value) =>
|
||||
spawn named ['UIAttribute', selector, attribute, value] {
|
||||
_attributeLike(thisFacet, selector, attribute, value, 'attribute');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function spawnUIPropertyFactory<T>(thisFacet: Facet<T>) {
|
||||
spawn named 'UIPropertyFactory' {
|
||||
during P.UIProperty($selector: string, $property: string, $value) =>
|
||||
spawn named ['UIProperty', selector, property, value] {
|
||||
_attributeLike(thisFacet, selector, property, value, 'property');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _attributeLike<T>(thisFacet: Facet<T>,
|
||||
selector: string,
|
||||
key: string,
|
||||
value: Value,
|
||||
kind: 'attribute' | 'property')
|
||||
{
|
||||
let savedValues: Array<{node: Element, value: any}> = [];
|
||||
|
||||
selectorMatch(document.body, selector).forEach(node => {
|
||||
switch (kind) {
|
||||
case 'attribute':
|
||||
if (key === 'class') {
|
||||
// Deliberately maintains duplicates, so we don't interfere with potential
|
||||
// other UIAttribute instances on the same objects for the same attribute.
|
||||
// See also the "on stop" handler.
|
||||
let existing = splitClassValue(node.getAttribute('class'));
|
||||
let toAdd = splitClassValue(value.toString());
|
||||
savedValues.push({ node, value: null });
|
||||
node.setAttribute('class', [... existing, ... toAdd].join(' '));
|
||||
} else {
|
||||
savedValues.push({ node, value: node.getAttribute(key) });
|
||||
node.setAttribute(key, value.toString());
|
||||
}
|
||||
break;
|
||||
case 'property':
|
||||
savedValues.push({node: node, value: (node as any)[key] ?? null});
|
||||
(node as any)[key] = value;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
on stop {
|
||||
savedValues.forEach((entry) => {
|
||||
switch (kind) {
|
||||
case 'attribute':
|
||||
if (key === 'class') {
|
||||
let existing = splitClassValue(entry.node.getAttribute('class'));
|
||||
let toRemove = splitClassValue(value.toString());
|
||||
toRemove.forEach((v) => {
|
||||
let i = existing.indexOf(v);
|
||||
if (i !== -1) { existing.splice(i, 1); }
|
||||
});
|
||||
if (existing.length === 0) {
|
||||
entry.node.removeAttribute('class');
|
||||
} else {
|
||||
entry.node.setAttribute('class', existing.join(' '));
|
||||
}
|
||||
} else {
|
||||
if (entry.value === null) {
|
||||
entry.node.removeAttribute(key);
|
||||
} else {
|
||||
entry.node.setAttribute(key, entry.value);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'property':
|
||||
if (entry.value === null) {
|
||||
delete (entry.node as any)[key];
|
||||
} else {
|
||||
(entry.node as any)[key] = entry.value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
savedValues = [];
|
||||
}
|
||||
};
|
||||
|
||||
function splitClassValue(v: string | null): Array<string> {
|
||||
v = (v ?? '').trim();
|
||||
return v ? v.split(/ +/) : [];
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------------
|
||||
|
||||
function spawnUIChangeablePropertyFactory<T>(thisFacet: Facet<T>) {
|
||||
spawn named 'UIChangeablePropertyFactory' {
|
||||
during Observe(P.UIChangeableProperty($selector: string, $property: string, _)) =>
|
||||
spawn named ['UIChangeableProperty', selector, property] {
|
||||
on start selectorMatch(document.body, selector).forEach(node => {
|
||||
react {
|
||||
field value: any = (node as any)[property];
|
||||
assert P.UIChangeableProperty(selector, property, this.value);
|
||||
const handlerClosure = thisFacet.wrapExternal((_thisFacet, _e: Event) => {
|
||||
this.value = (node as any)[property];
|
||||
});
|
||||
on start eventUpdater('change', handlerClosure, true)(node);
|
||||
on stop eventUpdater('change', handlerClosure, false)(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------------
|
||||
|
||||
function escapeDataAttributeName(s: FragmentId): string {
|
||||
// Per https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset,
|
||||
// the rules seem to be:
|
||||
//
|
||||
// 1. Must not contain a dash immediately followed by an ASCII lowercase letter
|
||||
// 2. Must not contain anything other than:
|
||||
// - letters
|
||||
// - numbers
|
||||
// - dash, dot, colon, underscore
|
||||
//
|
||||
// I'm not implementing this exactly - I'm escaping some things that
|
||||
// don't absolutely need escaping, because it's simpler and I don't
|
||||
// yet need to undo this transformation.
|
||||
|
||||
if (typeof s !== 'string') s = s.toString();
|
||||
|
||||
let result = '';
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
let c = s[i];
|
||||
if (c >= 'a' && c <= 'z') { result = result + c; continue; }
|
||||
if (c >= 'A' && c <= 'Z') { result = result + c; continue; }
|
||||
if (c >= '0' && c <= '9') { result = result + c; continue; }
|
||||
if (c === '.' || c === ':') { result = result + c; continue; }
|
||||
|
||||
result = result + '_' + c.charCodeAt(0) + '_';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface AnchorOptions {
|
||||
fragmentId?: FragmentId,
|
||||
}
|
||||
|
||||
export class Anchor {
|
||||
fragmentId: FragmentId;
|
||||
|
||||
constructor(options: AnchorOptions = {}) {
|
||||
this.fragmentId = options.fragmentId ?? newFragmentId();
|
||||
}
|
||||
|
||||
context(... pieces: FragmentId[]) {
|
||||
let extn = pieces.map(escapeDataAttributeName).join('__');
|
||||
return new Anchor({ fragmentId: this.fragmentId + '__' + extn });
|
||||
}
|
||||
|
||||
html(selector: string, html: string, orderBy: NodeOrderKey = ''): Record {
|
||||
return P.UIFragment(this.fragmentId, selector, html, orderBy);
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------------
|
||||
|
||||
function spawnLocationHashTracker<T>(thisFacet: Facet<T>) {
|
||||
spawn named 'LocationHashTracker' {
|
||||
field hashValue: string = '/';
|
||||
assert P.LocationHash(this.hashValue);
|
||||
|
||||
const loadHash = () => {
|
||||
var h = window.location.hash;
|
||||
if (h.length && h[0] === '#') {
|
||||
h = h.slice(1);
|
||||
}
|
||||
this.hashValue = h || '/';
|
||||
};
|
||||
|
||||
let handlerClosure = thisFacet.wrapExternal(loadHash);
|
||||
|
||||
on start {
|
||||
loadHash();
|
||||
window.addEventListener('hashchange', handlerClosure);
|
||||
}
|
||||
on stop {
|
||||
window.removeEventListener('hashchange', handlerClosure);
|
||||
}
|
||||
|
||||
on message P.SetLocationHash($newHash: string) => {
|
||||
window.location.hash = newHash;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------------
|
||||
|
||||
function spawnAttributeUpdater<T>(thisFacet: Facet<T>) {
|
||||
spawn named 'AttributeUpdater' {
|
||||
on message P.SetAttribute($s: string, $k: string, $v: string) =>
|
||||
update(s, n => n.setAttribute(k, v));
|
||||
on message P.RemoveAttribute($s: string, $k: string) =>
|
||||
update(s, n => n.removeAttribute(k));
|
||||
on message P.SetProperty($s: string, $k: string, $v) =>
|
||||
update(s, n => { (n as any)[k] = v });
|
||||
on message P.RemoveProperty($s: string, $k: string) =>
|
||||
update(s, n => { delete (n as any)[k]; });
|
||||
|
||||
function update(selector: string, nodeUpdater: (n: Element) => void) {
|
||||
selectorMatch(document.body, selector).forEach(nodeUpdater);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------------
|
||||
|
||||
function dealWithPreventDefault(eventType: string, event: Event): boolean {
|
||||
let shouldPreventDefault = eventType[0] !== '+';
|
||||
if (shouldPreventDefault) event.preventDefault();
|
||||
return !shouldPreventDefault;
|
||||
}
|
||||
|
||||
function cleanEventType(eventType: string): string {
|
||||
return (eventType[0] === '+') ? eventType.slice(1) : eventType;
|
||||
}
|
||||
|
||||
function isQueryableNode(x: any): x is ParentNode {
|
||||
return x !== null && typeof x === 'object' && 'querySelectorAll' in x;
|
||||
}
|
||||
|
||||
function selectorMatch(n: Element | Node, selector: string): Array<Element> {
|
||||
if (isQueryableNode(n)) {
|
||||
if (selector === '.') {
|
||||
return [n];
|
||||
} else {
|
||||
return Array.from(n.querySelectorAll(selector));
|
||||
}
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function eventUpdater(eventType: string, handlerClosure: (event: Event) => void, install: boolean) {
|
||||
return function (n: EventTarget) {
|
||||
// addEventListener and removeEventListener are idempotent.
|
||||
if (install) {
|
||||
n.addEventListener(eventType, handlerClosure);
|
||||
} else {
|
||||
n.removeEventListener(eventType, handlerClosure);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
//---------------------------------------------------------------------------
|
||||
// @syndicate-lang/driver-browser-ui, Browser-based UI for Syndicate
|
||||
// Copyright (C) 2016-2018 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
|
||||
// @syndicate-lang/html, Browser-based UI for Syndicate
|
||||
// Copyright (C) 2016-2021 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
|
@ -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);
|
|
@ -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/**/*"]
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"presets": [ "@babel/preset-env" ],
|
||||
"plugins": [ "@syndicate-lang/syntax/plugin" ]
|
||||
}
|
|
@ -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 <tonyg@leastfixedpoint.com>",
|
||||
"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"
|
||||
}
|
||||
}
|
|
@ -1,88 +0,0 @@
|
|||
//---------------------------------------------------------------------------
|
||||
// @syndicate-lang/driver-browser-ui, Browser-based UI for Syndicate
|
||||
// Copyright (C) 2016-2018 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//---------------------------------------------------------------------------
|
||||
|
||||
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, """)
|
||||
.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;
|
||||
}
|
|
@ -1,515 +0,0 @@
|
|||
//---------------------------------------------------------------------------
|
||||
// @syndicate-lang/driver-browser-ui, Browser-based UI for Syndicate
|
||||
// Copyright (C) 2016-2018 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//---------------------------------------------------------------------------
|
||||
|
||||
import { RandomID, 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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue