diff --git a/Makefile b/Makefile index 87ae77a..7ce1cd1 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,8 @@ -MAKEABLE_PACKAGES=syntax driver-timer syntax-playground +MAKEABLE_PACKAGES=\ + syntax \ + driver-timer \ + driver-browser-ui \ + syntax-playground all: for p in $(MAKEABLE_PACKAGES); do $(MAKE) -C packages/$$p; done diff --git a/packages/driver-browser-ui/.babelrc b/packages/driver-browser-ui/.babelrc new file mode 100644 index 0000000..8991585 --- /dev/null +++ b/packages/driver-browser-ui/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": [ "@babel/preset-env" ], + "plugins": [ "@syndicate-lang/syntax/plugin" ] +} diff --git a/packages/driver-browser-ui/LICENCE b/packages/driver-browser-ui/LICENCE new file mode 120000 index 0000000..725d276 --- /dev/null +++ b/packages/driver-browser-ui/LICENCE @@ -0,0 +1 @@ +gpl-3.0.txt \ No newline at end of file diff --git a/packages/driver-browser-ui/Makefile b/packages/driver-browser-ui/Makefile new file mode 100644 index 0000000..64d6f28 --- /dev/null +++ b/packages/driver-browser-ui/Makefile @@ -0,0 +1,8 @@ +.PHONY: build + +build: + mkdir -p lib + npx syndicate-babel src --out-dir lib + +clean: + rm -rf lib diff --git a/packages/driver-browser-ui/package.json b/packages/driver-browser-ui/package.json new file mode 100644 index 0000000..d78e7fe --- /dev/null +++ b/packages/driver-browser-ui/package.json @@ -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 ", + "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" + } +} diff --git a/packages/driver-browser-ui/src/html.js b/packages/driver-browser-ui/src/html.js new file mode 100644 index 0000000..857726a --- /dev/null +++ b/packages/driver-browser-ui/src/html.js @@ -0,0 +1,80 @@ +//--------------------------------------------------------------------------- +// @syndicate-lang/driver-browser-ui, Browser-based UI for Syndicate +// Copyright (C) 2016-2018 Tony Garnock-Jones +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +//--------------------------------------------------------------------------- + +assertion type htmlTag(label, properties, children); +assertion type htmlProperty(key, value); +assertion type htmlFragment(children); +assertion type htmlLiteral(text); + +export function html(tag, props, ...kids) { + if (tag === htmlFragment) { + // JSX short syntax for fragments doesn't allow properties, so + // props will never have any defined. + return htmlFragment(kids); + } else { + let properties = [] + for (let k in props) { + properties.push(htmlProperty(k, props[k])); + } + return htmlTag(tag, properties, kids); + } +} + +//--------------------------------------------------------------------------- + +export function escapeHtml(s) { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export const emptyHtmlElements = {}; +for (let e of + "area base br col embed hr img input keygen link meta param source track wbr".split(/ +/)) { + emptyHtmlElements[e] = true; +} + +export function htmlToString(j) { + let pieces = []; + + function walk(j) { + if (htmlTag.isClassOf(j)) { + pieces.push('<', 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(''); + } + } 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(''); +} diff --git a/packages/driver-browser-ui/src/index.js b/packages/driver-browser-ui/src/index.js new file mode 100644 index 0000000..808fc1f --- /dev/null +++ b/packages/driver-browser-ui/src/index.js @@ -0,0 +1,497 @@ +//--------------------------------------------------------------------------- +// @syndicate-lang/driver-browser-ui, Browser-based UI for Syndicate +// Copyright (C) 2016-2018 Tony Garnock-Jones +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +//--------------------------------------------------------------------------- + +import { 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); + } +} diff --git a/packages/driver-browser-ui/src/protocol.js b/packages/driver-browser-ui/src/protocol.js new file mode 100644 index 0000000..85f05fa --- /dev/null +++ b/packages/driver-browser-ui/src/protocol.js @@ -0,0 +1,85 @@ +//--------------------------------------------------------------------------- +// @syndicate-lang/driver-browser-ui, Browser-based UI for Syndicate +// Copyright (C) 2016-2018 Tony Garnock-Jones +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +//--------------------------------------------------------------------------- + +// 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; diff --git a/packages/driver-browser-ui/src/randomid.js b/packages/driver-browser-ui/src/randomid.js new file mode 100644 index 0000000..45e3812 --- /dev/null +++ b/packages/driver-browser-ui/src/randomid.js @@ -0,0 +1,73 @@ +//--------------------------------------------------------------------------- +// @syndicate-lang/driver-browser-ui, Browser-based UI for Syndicate +// Copyright (C) 2016-2018 Tony Garnock-Jones +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +//--------------------------------------------------------------------------- + +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; diff --git a/packages/syntax-playground/.babelrc b/packages/syntax-playground/.babelrc index 8991585..aebf4f8 100644 --- a/packages/syntax-playground/.babelrc +++ b/packages/syntax-playground/.babelrc @@ -1,4 +1,8 @@ { "presets": [ "@babel/preset-env" ], - "plugins": [ "@syndicate-lang/syntax/plugin" ] + "plugins": [ + "@syndicate-lang/syntax/plugin", + "@babel/plugin-transform-react-jsx", + "@babel/plugin-syntax-jsx" + ] } diff --git a/packages/syntax-playground/index.html b/packages/syntax-playground/index.html index af47313..1f41f96 100644 --- a/packages/syntax-playground/index.html +++ b/packages/syntax-playground/index.html @@ -1,5 +1,28 @@ - + - - + + Syndicate: Table Example + + + + +

Table Example

+ + + + + + + + + + +
IDFirst NameLast NameAddressAge
+

+ Click on column headings to sort data rows. +

+

+ Source code: index.js +

+ diff --git a/packages/syntax-playground/package.json b/packages/syntax-playground/package.json index d9f2d68..89d6f77 100644 --- a/packages/syntax-playground/package.json +++ b/packages/syntax-playground/package.json @@ -8,12 +8,15 @@ "author": "Tony Garnock-Jones ", "devDependencies": { "@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", "@syndicate-lang/syntax": "^0.0.9" }, "dependencies": { "@syndicate-lang/core": "^0.0.6", "@syndicate-lang/driver-timer": "^0.0.3", + "@syndicate-lang/driver-browser-ui": "^0.0.0", "webpack": "^4.23.1", "webpack-cli": "^3.1.2" } diff --git a/packages/syntax-playground/src/index.js b/packages/syntax-playground/src/index.js new file mode 100644 index 0000000..1972656 --- /dev/null +++ b/packages/syntax-playground/src/index.js @@ -0,0 +1,61 @@ +"use strict"; +//--------------------------------------------------------------------------- +// @syndicate-lang/syntax-test, a demo of Syndicate extensions to JS. +// Copyright (C) 2016-2018 Tony Garnock-Jones +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +//--------------------------------------------------------------------------- + +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 {text}; + } + + on message SetSortColumn($c) { this.orderColumn = c; } + + during Person($id, $firstName, $lastName, $address, $age) { + assert ui.context(id) + .html('table#the-table tbody', + {[id, firstName, lastName, address, age].map(cell)}, + [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)); + } +}