@syndicate-lang/driver-browser-ui
This commit is contained in:
parent
4a6d3a110a
commit
5819b41486
6
Makefile
6
Makefile
|
@ -1,4 +1,8 @@
|
||||||
MAKEABLE_PACKAGES=syntax driver-timer syntax-playground
|
MAKEABLE_PACKAGES=\
|
||||||
|
syntax \
|
||||||
|
driver-timer \
|
||||||
|
driver-browser-ui \
|
||||||
|
syntax-playground
|
||||||
|
|
||||||
all:
|
all:
|
||||||
for p in $(MAKEABLE_PACKAGES); do $(MAKE) -C packages/$$p; done
|
for p in $(MAKEABLE_PACKAGES); do $(MAKE) -C packages/$$p; done
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"presets": [ "@babel/preset-env" ],
|
||||||
|
"plugins": [ "@syndicate-lang/syntax/plugin" ]
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
gpl-3.0.txt
|
|
@ -0,0 +1,8 @@
|
||||||
|
.PHONY: build
|
||||||
|
|
||||||
|
build:
|
||||||
|
mkdir -p lib
|
||||||
|
npx syndicate-babel src --out-dir lib
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf lib
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "@syndicate-lang/driver-browser-ui",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/syndicate-lang/syndicate-js/tree/master/packages/driver-browser-ui",
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.1.2",
|
||||||
|
"@babel/preset-env": "^7.1.0",
|
||||||
|
"@syndicate-lang/syntax": "^0.0.9"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@syndicate-lang/core": "^0.0.6",
|
||||||
|
"immutable": "^4.0.0-rc.12"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,80 @@
|
||||||
|
//---------------------------------------------------------------------------
|
||||||
|
// @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('<', j[0]);
|
||||||
|
j[1].forEach((p) => pieces.push(' ', escapeHtml(p[0]), '="', escapeHtml(p[1])));
|
||||||
|
pieces.push('>');
|
||||||
|
j[2].forEach(walk);
|
||||||
|
if (!(j[0] in emptyHtmlElements)) {
|
||||||
|
pieces.push('</', j[0], '>');
|
||||||
|
}
|
||||||
|
} else if (htmlFragment.isClassOf(j)) {
|
||||||
|
j[0].forEach(walk);
|
||||||
|
} else if (htmlLiteral.isClassOf(j)) {
|
||||||
|
pieces.push(j[0]);
|
||||||
|
} 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('');
|
||||||
|
}
|
|
@ -0,0 +1,497 @@
|
||||||
|
//---------------------------------------------------------------------------
|
||||||
|
// @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 { Observe, Dataspace } from "@syndicate-lang/core";
|
||||||
|
|
||||||
|
import { randomId } from "./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) => { ^ 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) => { ^ 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) : [];
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
^ P.UIEvent(fragmentId, c.selector, c.eventType, e);
|
||||||
|
});
|
||||||
|
function handler(event) {
|
||||||
|
sender(event);
|
||||||
|
return dealWithPreventDefault(c.eventType, e);
|
||||||
|
}
|
||||||
|
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(/ +/) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
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(explicitFragmentId) {
|
||||||
|
this.fragmentId =
|
||||||
|
(typeof explicitFragmentId === 'undefined') ? newFragmentId() : explicitFragmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
context(...pieces) {
|
||||||
|
let extn = pieces.map(escapeDataAttributeName).join('__');
|
||||||
|
return new Anchor(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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,85 @@
|
||||||
|
//---------------------------------------------------------------------------
|
||||||
|
// @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/>.
|
||||||
|
//---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Message. Interest in this causes event listeners to be added for
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Assertion. Asserted by respondent to a given UIFragment.
|
||||||
|
assertion type UIFragmentVersion(fragmentId, version);
|
||||||
|
module.exports.UIFragmentVersion = UIFragmentVersion;
|
||||||
|
|
||||||
|
// Assertion. Causes the setup of DOM attributes on all nodes named by
|
||||||
|
// the given selector that exist at the time of assertion.
|
||||||
|
//
|
||||||
|
// NOTE: Attribute "class" is a special case: it treats the value of
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Assertion. Similar to UIAttribute, but for properties of DOM nodes.
|
||||||
|
assertion type UIProperty(selector, property, value);
|
||||||
|
module.exports.UIProperty = UIProperty;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Assertion. Current "location hash" -- the "#/path/part" fragment at
|
||||||
|
// the end of window.location.
|
||||||
|
assertion type LocationHash(value);
|
||||||
|
module.exports.LocationHash = LocationHash;
|
||||||
|
|
||||||
|
// Message. Causes window.location to be updated to have the given new
|
||||||
|
// "location hash" value.
|
||||||
|
message type SetLocationHash(value);
|
||||||
|
module.exports.SetLocationHash = SetLocationHash;
|
|
@ -0,0 +1,73 @@
|
||||||
|
//---------------------------------------------------------------------------
|
||||||
|
// @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/>.
|
||||||
|
//---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let randomId;
|
||||||
|
|
||||||
|
function browserCryptoObject(crypto) {
|
||||||
|
if (typeof crypto.getRandomValues === 'undefined') return false;
|
||||||
|
randomId = function (byteCount, hexOutput) {
|
||||||
|
let buf = new Uint8Array(byteCount);
|
||||||
|
crypto.getRandomValues(buf);
|
||||||
|
if (hexOutput) {
|
||||||
|
let encoded = [];
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
encoded.push("0123456789abcdef"[(buf[i] >> 4) & 15]);
|
||||||
|
encoded.push("0123456789abcdef"[buf[i] & 15]);
|
||||||
|
}
|
||||||
|
return encoded.join('');
|
||||||
|
} else {
|
||||||
|
return btoa(String.fromCharCode.apply(null, buf)).replace(/=/g,'');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((typeof window !== 'undefined') &&
|
||||||
|
(typeof window.crypto !== 'undefined') &&
|
||||||
|
browserCryptoObject(window.crypto)) {
|
||||||
|
// We are in the main page, and window.crypto is available, and
|
||||||
|
// browserCryptoObject has installed a suitable randomId. Do
|
||||||
|
// nothing.
|
||||||
|
} else if ((typeof self !== 'undefined') &&
|
||||||
|
(typeof self.crypto !== 'undefined') &&
|
||||||
|
browserCryptoObject(self.crypto)) {
|
||||||
|
// We are in a web worker, and self.crypto is available, and
|
||||||
|
// browserCryptoObject has installed a suitable randomId. Do
|
||||||
|
// nothing.
|
||||||
|
} else {
|
||||||
|
// See if we're in node.js.
|
||||||
|
|
||||||
|
let crypto;
|
||||||
|
try {
|
||||||
|
crypto = require('crypto');
|
||||||
|
} catch (e) {}
|
||||||
|
if ((typeof crypto !== 'undefined') &&
|
||||||
|
(typeof crypto.randomBytes !== 'undefined')) {
|
||||||
|
randomId = function (byteCount, hexOutput) {
|
||||||
|
if (hexOutput) {
|
||||||
|
return crypto.randomBytes(byteCount).hexSlice().replace(/=/g,'');
|
||||||
|
} else {
|
||||||
|
return crypto.randomBytes(byteCount).base64Slice().replace(/=/g,'');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
console.warn('No suitable implementation for RandomID.randomId available.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.randomId = randomId;
|
|
@ -1,4 +1,8 @@
|
||||||
{
|
{
|
||||||
"presets": [ "@babel/preset-env" ],
|
"presets": [ "@babel/preset-env" ],
|
||||||
"plugins": [ "@syndicate-lang/syntax/plugin" ]
|
"plugins": [
|
||||||
|
"@syndicate-lang/syntax/plugin",
|
||||||
|
"@babel/plugin-transform-react-jsx",
|
||||||
|
"@babel/plugin-syntax-jsx"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,28 @@
|
||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<meta charset=utf-8>
|
<head>
|
||||||
<script src="dist/main.js"></script>
|
<title>Syndicate: Table Example</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<script src="dist/main.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Table Example</h1>
|
||||||
|
<table id="the-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th data-column="0">ID</th>
|
||||||
|
<th data-column="1">First Name</th>
|
||||||
|
<th data-column="2">Last Name</th>
|
||||||
|
<th data-column="3">Address</th>
|
||||||
|
<th data-column="4">Age</th>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
Click on column headings to sort data rows.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Source code: <a href="src/index.js">index.js</a>
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -8,12 +8,15 @@
|
||||||
"author": "Tony Garnock-Jones <tonyg@leastfixedpoint.com>",
|
"author": "Tony Garnock-Jones <tonyg@leastfixedpoint.com>",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.1.2",
|
"@babel/core": "^7.1.2",
|
||||||
|
"@babel/plugin-syntax-jsx": "^7.0.0",
|
||||||
|
"@babel/plugin-transform-react-jsx": "^7.0.0",
|
||||||
"@babel/preset-env": "^7.1.0",
|
"@babel/preset-env": "^7.1.0",
|
||||||
"@syndicate-lang/syntax": "^0.0.9"
|
"@syndicate-lang/syntax": "^0.0.9"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@syndicate-lang/core": "^0.0.6",
|
"@syndicate-lang/core": "^0.0.6",
|
||||||
"@syndicate-lang/driver-timer": "^0.0.3",
|
"@syndicate-lang/driver-timer": "^0.0.3",
|
||||||
|
"@syndicate-lang/driver-browser-ui": "^0.0.0",
|
||||||
"webpack": "^4.23.1",
|
"webpack": "^4.23.1",
|
||||||
"webpack-cli": "^3.1.2"
|
"webpack-cli": "^3.1.2"
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
"use strict";
|
||||||
|
//---------------------------------------------------------------------------
|
||||||
|
// @syndicate-lang/syntax-test, a demo of Syndicate extensions to JS.
|
||||||
|
// 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/>.
|
||||||
|
//---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let UI = activate require("@syndicate-lang/driver-browser-ui");
|
||||||
|
// @jsx UI.html
|
||||||
|
// @jsxFrag UI.htmlFragment
|
||||||
|
|
||||||
|
assertion type Person(id, firstName, lastName, address, age);
|
||||||
|
message type SetSortColumn(number);
|
||||||
|
|
||||||
|
function newRow(id, firstName, lastName, address, age) {
|
||||||
|
spawn named ('model' + id) {
|
||||||
|
assert Person(id, firstName, lastName, address, age);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newRow(1, 'Keith', 'Example', '94 Main St.', 44);
|
||||||
|
newRow(2, 'Karen', 'Fakeperson', '5504 Long Dr.', 34);
|
||||||
|
newRow(3, 'Angus', 'McFictional', '2B Pioneer Heights', 39);
|
||||||
|
newRow(4, 'Sue', 'Donnem', '1 Infinite Loop', 104);
|
||||||
|
newRow(5, 'Boaty', 'McBoatface', 'Arctic Ocean', 1);
|
||||||
|
|
||||||
|
spawn named 'view' {
|
||||||
|
let ui = new UI.Anchor();
|
||||||
|
field this.orderColumn = 2;
|
||||||
|
|
||||||
|
function cell(text) {
|
||||||
|
return <td>{text}</td>;
|
||||||
|
}
|
||||||
|
|
||||||
|
on message SetSortColumn($c) { this.orderColumn = c; }
|
||||||
|
|
||||||
|
during Person($id, $firstName, $lastName, $address, $age) {
|
||||||
|
assert ui.context(id)
|
||||||
|
.html('table#the-table tbody',
|
||||||
|
<tr>{[id, firstName, lastName, address, age].map(cell)}</tr>,
|
||||||
|
[id, firstName, lastName, address, age][this.orderColumn]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn named 'controller' {
|
||||||
|
on message UI.GlobalEvent('table#the-table th', 'click', $e) {
|
||||||
|
^ SetSortColumn(JSON.parse(e.target.dataset.column));
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue