"use strict"; // UI (DOM + event) support for Syndicate // // The previous dom-driver.js + jquery-driver.js approach worked kind // of OK, but started to fall down in a couple of areas: Added UI // fragments lacked identity, so would sometimes move around the tree // unexpectedly as they were updated; and there was no convenient // means of scoping event selectors to within a particular UI // fragment, despite various attempts at this. // // The design of this module aims to take these lessons into account. var Immutable = require('immutable'); var Patch = require("./patch.js"); var Trie = require("./trie.js"); var DemandMatcher = require('./demand-matcher.js').DemandMatcher; var Struct = require('./struct.js'); var RandomID = require('./randomid.js'); var Dataspace_ = require("./dataspace.js"); var Dataspace = Dataspace_.Dataspace; var __ = Dataspace_.__; var _$ = Dataspace_._$; /////////////////////////////////////////////////////////////////////////// // Protocol // 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. var globalEvent = Struct.makeConstructor('global-event', ['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. var windowEvent = Struct.makeConstructor('window-event', ['eventType', 'event']); // Message. Like globalEvent, but applies only within the scope of the // UI fragment identified. var uiEvent = Struct.makeConstructor('ui-event', ['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. var uiFragment = Struct.makeConstructor('ui-fragment', ['fragmentId', 'selector', 'html', 'orderBy']); // Assertion. Asserted by respondent to a given uiFragment. var uiFragmentVersion = Struct.makeConstructor('ui-fragment-version', ['fragmentId', 'version']); // 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.) var uiAttribute = Struct.makeConstructor('ui-attribute', ['selector', 'attribute', 'value']); // Assertion. Similar to uiAttribute, but for properties of DOM nodes. var uiProperty = Struct.makeConstructor('ui-property', ['selector', 'property', 'value']); // Messages. // NOTE: These do not treat "class" specially! var setAttribute = Struct.makeConstructor('set-ui-attribute', ['selector', 'attribute', 'value']); var removeAttribute = Struct.makeConstructor('remove-ui-attribute', ['selector', 'attribute']); var setProperty = Struct.makeConstructor('set-ui-property', ['selector', 'property', 'value']); var removeProperty = Struct.makeConstructor('remove-ui-property', ['selector', 'property']); // Assertion. Current "location hash" -- the "#/path/part" fragment at // the end of window.location. var locationHash = Struct.makeConstructor('locationHash', ['value']); // Message. Causes window.location to be updated to have the given new // "location hash" value. var setLocationHash = Struct.makeConstructor('setLocationHash', ['value']); /////////////////////////////////////////////////////////////////////////// // ID allocators var moduleInstance = RandomID.randomId(16, true); var nextFragmentIdNumber = 0; function newFragmentId() { return 'ui_' + moduleInstance + '_' + (nextFragmentIdNumber++); } /////////////////////////////////////////////////////////////////////////// function spawnUIDriver(options) { options = options || {}; var globalEventProj = globalEvent(_$('selector'), _$('eventType'), __); Dataspace.spawn( new DemandMatcher([Patch.observe(globalEventProj)], [Patch.advertise(globalEventProj)], function (c) { Dataspace.spawn(new GlobalEventSupply(c.selector, c.eventType)); }, { name: 'globalEventSupervisor' })); var windowEventProj = windowEvent(_$('eventType'), __); Dataspace.spawn( new DemandMatcher([Patch.observe(windowEventProj)], [Patch.advertise(windowEventProj)], function (c) { Dataspace.spawn(new WindowEventSupply(c.eventType)); }, { name: 'windowEventSupervisor' })); Dataspace.spawn( new DemandMatcher([uiFragment(_$('fragmentId'), __, __, __)], [uiFragmentVersion(_$('fragmentId'), __)], function (c) { Dataspace.spawn(new UIFragment(c.fragmentId)); }, { name: 'uiFragmentSupervisor' })); Dataspace.spawn( new DemandMatcher([uiAttribute(_$('selector'), _$('attribute'), _$('value'))], [Patch.advertise(uiAttribute(_$('selector'), _$('attribute'), _$('value')))], function (c) { Dataspace.spawn(new UIAttribute( c.selector, c.attribute, c.value, 'attribute')); }, { name: 'uiAttributeSupervisor' })); Dataspace.spawn( new DemandMatcher([uiProperty(_$('selector'), _$('property'), _$('value'))], [Patch.advertise(uiProperty(_$('selector'), _$('property'), _$('value')))], function (c) { Dataspace.spawn(new UIAttribute( c.selector, c.property, c.value, 'property')); }, { name: 'uiPropertySupervisor' })); Dataspace.spawn(new AttributeUpdater()); Dataspace.spawn(new LocationHashTracker(options.defaultLocationHash || '/')); } /////////////////////////////////////////////////////////////////////////// function GlobalEventSupply(selector, eventType) { this.selector = selector; this.eventType = eventType; this.demandPat = Patch.observe(globalEvent(this.selector, this.eventType, __)); this.name = ['globalEvent', selector, eventType]; } GlobalEventSupply.prototype.boot = function () { var self = this; this.handlerClosure = Dataspace.wrap(function(e) { return self.handleDomEvent(e); }); this.updateEventListeners(true); return Patch.sub(this.demandPat) // track demand .andThen(Patch.sub(uiFragmentVersion(__, __))) // track new fragments .andThen(Patch.pub(globalEvent(this.selector, this.eventType, __))) // indicate our presence ; }; GlobalEventSupply.prototype.updateEventListeners = function (install) { selectorMatch(document, this.selector).forEach( eventUpdater(cleanEventType(this.eventType), this.handlerClosure, install)); }; GlobalEventSupply.prototype.trapexit = function () { console.log('GlobalEventSupply trapexit running', this.selector, this.eventType); this.updateEventListeners(false); }; GlobalEventSupply.prototype.handleDomEvent = function (event) { Dataspace.send(globalEvent(this.selector, this.eventType, event)); return dealWithPreventDefault(this.eventType, event); }; GlobalEventSupply.prototype.handleEvent = function (e) { this.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. if (e.type === 'stateChange' && e.patch.project(this.demandPat).hasRemoved()) { Dataspace.exit(); // trapexit will uninstall event listeners } }; /////////////////////////////////////////////////////////////////////////// function WindowEventSupply(eventType) { this.eventType = eventType; this.demandPat = Patch.observe(windowEvent(this.eventType, __)); this.name = ['windowEvent', eventType]; } WindowEventSupply.prototype.boot = function () { var self = this; this.handlerClosure = Dataspace.wrap(function(e) { return self.handleDomEvent(e); }); this.updateEventListeners(true); return Patch.sub(this.demandPat) // track demand .andThen(Patch.pub(windowEvent(this.eventType, __))) // indicate our presence ; }; WindowEventSupply.prototype.updateEventListeners = function (install) { if (install) { window.addEventListener(cleanEventType(this.eventType), this.handlerClosure); } else { window.removeEventListener(cleanEventType(this.eventType), this.handlerClosure); } }; WindowEventSupply.prototype.trapexit = function () { console.log('WindowEventSupply trapexit running', this.eventType); this.updateEventListeners(false); }; WindowEventSupply.prototype.handleDomEvent = function (event) { Dataspace.send(windowEvent(this.eventType, event)); return dealWithPreventDefault(this.eventType, event); }; WindowEventSupply.prototype.handleEvent = function (e) { if (e.type === 'stateChange' && e.patch.project(this.demandPat).hasRemoved()) { Dataspace.exit(); // trapexit will uninstall event listeners } }; /////////////////////////////////////////////////////////////////////////// function UIFragment(fragmentId) { this.fragmentId = fragmentId; this.demandProj = uiFragment(this.fragmentId, _$('selector'), _$('html'), _$('orderBy')); this.eventDemandProj = Patch.observe(uiEvent(this.fragmentId, _$('selector'), _$('eventType'), __)); this.currentAnchorNodes = []; this.currentSelector = null; this.currentHtml = null; this.currentOrderBy = null; this.currentEventRegistrations = Immutable.Map(); // ^ Map from (Map of selector/eventType) to closure. this.name = ['uiFragment', fragmentId]; } UIFragment.prototype.boot = function () { return Patch.sub(Trie.projectionToPattern(this.demandProj)) // track demand .andThen(Patch.sub(Trie.projectionToPattern(this.eventDemandProj))) // ^ track demand for fragment-specific events ; }; UIFragment.prototype.trapexit = function () { console.log('UIFragment trapexit running', this.fragmentId); this.updateContent(null, null, null); }; var SYNDICATE_SORT_KEY = '__syndicate_sort_key'; function setSortKey(n, orderBy, fragmentId) { var 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) { var 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 (var 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 (var 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) { var lo = firstChildNodeIndex_withSortKey(n); var 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. var probe = (lo + hi) >> 1; var 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; } } UIFragment.prototype.removeNodes = function () { var self = this; self.currentAnchorNodes.forEach(function (anchorNode) { var insertionPoint = findInsertionPoint(anchorNode, self.currentOrderBy, self.fragmentId); while (1) { var n = insertionPoint ? insertionPoint.previousSibling : anchorNode.lastChild; if (!(n && hasSortKey(n, self.currentOrderBy, self.fragmentId))) break; n.parentNode.removeChild(n); // auto-updates previousSibling/lastChild } }); }; function htmlToNodes(parent, html) { var e = parent.cloneNode(false); e.innerHTML = 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); } }); } UIFragment.prototype.updateContent = function (newSelector, newHtml, newOrderBy) { var self = this; self.removeNodes(); var newAnchors = (newSelector !== null) ? selectorMatch(document, newSelector) : []; newAnchors.forEach(function (anchorNode) { var insertionPoint = findInsertionPoint(anchorNode, newOrderBy, self.fragmentId); htmlToNodes(anchorNode, newHtml).forEach(function (newNode) { setSortKey(newNode, newOrderBy, self.fragmentId); anchorNode.insertBefore(newNode, insertionPoint); configureNode(newNode); }); }); self.currentAnchorNodes = newAnchors; self.currentSelector = newSelector; self.currentHtml = newHtml; self.currentOrderBy = newOrderBy; self.currentEventRegistrations.forEach(function (_handlerClosure, key) { self.updateEventListeners(key.toObject(), true); // (re)install event listeners }); }; var nextVersion = 0; UIFragment.prototype.handleEvent = function (e) { var self = this; if (e.type === 'stateChange') { var fragmentChanges = e.patch.projectObjects(self.demandProj); var hasChanged = false; fragmentChanges[0].forEach(function (c) { self.updateContent(c.selector, c.html, c.orderBy); hasChanged = true; }); fragmentChanges[1].forEach(function (c) { if (c.selector === self.currentSelector && c.html === self.currentHtml && c.orderBy === self.currentOrderBy) { Dataspace.exit(); // trapexit will remove nodes } }); var eventDemand = e.patch.projectObjects(self.eventDemandProj); eventDemand[0].forEach(function (c) { self.updateEventListeners(c, true); }) eventDemand[1].forEach(function (c) { self.updateEventListeners(c, false); }) if (hasChanged) { Dataspace.stateChange(Patch.retract(uiFragmentVersion(this.fragmentId, __)) .andThen(Patch.assert(uiFragmentVersion(this.fragmentId, nextVersion++)))); } } }; UIFragment.prototype.getEventClosure = function (c) { var self = this; var key = Immutable.Map(c); if (!self.currentEventRegistrations.has(key)) { var handlerClosure = Dataspace.wrap(function (e) { return self.handleDomEvent(c, e); }); self.currentEventRegistrations = self.currentEventRegistrations.set(key, handlerClosure); } return self.currentEventRegistrations.get(key); }; UIFragment.prototype.clearEventClosure = function (c) { var key = Immutable.Map(c); this.currentEventRegistrations = this.currentEventRegistrations.remove(key); }; UIFragment.prototype.updateEventListeners = function (c, install) { var self = this; var handlerClosure = self.getEventClosure(c); self.currentAnchorNodes.forEach(function (anchorNode) { var insertionPoint = findInsertionPoint(anchorNode, self.currentOrderBy, self.fragmentId); while (1) { var uiNode = insertionPoint ? insertionPoint.previousSibling : anchorNode.lastChild; if (!(uiNode && hasSortKey(uiNode, self.currentOrderBy, self.fragmentId))) break; if ('querySelectorAll' in uiNode) { selectorMatch(uiNode, c.selector).forEach( eventUpdater(cleanEventType(c.eventType), handlerClosure, install)); } insertionPoint = uiNode; } }); if (!install) { this.clearEventClosure(c); } }; UIFragment.prototype.handleDomEvent = function (c, e) { Dataspace.send(uiEvent(this.fragmentId, c.selector, c.eventType, e)); return dealWithPreventDefault(c.eventType, e); }; /////////////////////////////////////////////////////////////////////////// function UIAttribute(selector, key, value, kind) { if (['attribute', 'property'].indexOf(kind) === -1) { throw new Error("UIAttribute: kind must be 'attribute' or 'property'; got " + kind); } this.selector = selector; this.key = key; this.value = value; this.kind = kind; this.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'. this.name = ['uiAttribute', selector, key, value, kind]; } UIAttribute.prototype.boot = function () { var a = ((this.kind === 'attribute') ? uiAttribute : uiProperty)(this.selector, this.key, this.value); this.install(); return Patch.sub(a).andThen(Patch.pub(a)); }; UIAttribute.prototype.trapexit = function () { console.log('UIAttribute trapexit running', this.selector, this.key, this.value, this.kind); this.restoreSavedValues(); }; function splitClassValue(v) { v = (v || '').trim(); return v ? v.split(/ +/) : []; } UIAttribute.prototype.install = function () { var self = this; selectorMatch(document, self.selector).forEach(function (node) { switch (self.kind) { case 'attribute': if (self.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. var existing = splitClassValue(node.getAttribute('class')); var toAdd = splitClassValue(self.value); self.savedValues.push({node: node}); node.setAttribute('class', existing.concat(toAdd).join(' ')); } else { self.savedValues.push({node: node, value: node.getAttribute(self.key)}); node.setAttribute(self.key, self.value); } break; case 'property': self.savedValues.push({node: node, value: node[self.key]}); node[self.key] = self.value; break; } }); }; UIAttribute.prototype.restoreSavedValues = function () { var self = this; self.savedValues.forEach(function (entry) { switch (self.kind) { case 'attribute': if (self.key === 'class') { var existing = splitClassValue(entry.node.getAttribute('class')); var toRemove = splitClassValue(self.value); toRemove.forEach(function (v) { var 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(self.key); } else { entry.node.setAttribute(self.key, entry.value); } } break; case 'property': if (typeof entry.value === 'undefined') { delete entry.node[self.key]; } else { entry.node[self.key] = entry.value; } break; } }); self.savedValues = []; }; UIAttribute.prototype.handleEvent = function (e) { if (e.type === 'stateChange' && e.patch.hasRemoved()) { Dataspace.exit(); // trapexit will restore attributes } }; /////////////////////////////////////////////////////////////////////////// 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); } var result = ''; for (var i = 0; i < s.length; i++) { var 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) { var 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); } }; } /////////////////////////////////////////////////////////////////////////// function Anchor(explicitFragmentId) { this.fragmentId = (typeof explicitFragmentId === 'undefined') ? newFragmentId() : explicitFragmentId; this.htmlPattern = uiFragment(this.fragmentId, __, __, __); this.eventPattern = uiEvent(this.fragmentId, __, __, __); } Anchor.prototype.context = function (/* ... */) { var extn = Array.prototype.slice.call(arguments).map(escapeDataAttributeName).join('__'); return new Anchor(this.fragmentId + '__' + extn); }; Anchor.prototype.html = function (selector, html, orderBy) { return uiFragment(this.fragmentId, selector, html, typeof orderBy === 'undefined' ? null : orderBy); }; Anchor.prototype.event = function (selector, eventType, event) { return uiEvent(this.fragmentId, selector, eventType, event); }; Anchor.prototype.fragmentVersion = function (v) { return uiFragmentVersion(this.fragmentId, v); }; /////////////////////////////////////////////////////////////////////////// function LocationHashTracker(defaultLocationHash) { this.defaultLocationHash = defaultLocationHash; this.hashValue = null; this.name = 'LocationHashTracker'; } LocationHashTracker.prototype.boot = function () { var self = this; this.loadHash(); this.handlerClosure = Dataspace.wrap(function (e) { self.handleDomEvent(e); }); window.addEventListener('hashchange', this.handlerClosure); return Patch.assert(locationHash(this.hashValue)) .andThen(Patch.sub(setLocationHash(__))); }; LocationHashTracker.prototype.trapexit = function () { window.removeEventListener('hashchange', this.handlerClosure); }; LocationHashTracker.prototype.loadHash = function () { this.hashValue = window.location.hash; if (this.hashValue.length && this.hashValue[0] === '#') { this.hashValue = this.hashValue.slice(1); } if (!this.hashValue) { this.hashValue = this.defaultLocationHash; } }; LocationHashTracker.prototype.handleDomEvent = function (e) { this.loadHash(); Dataspace.stateChange(Patch.retract(locationHash(__)) .andThen(Patch.assert(locationHash(this.hashValue)))); }; LocationHashTracker.prototype.handleEvent = function (e) { if (e.type === 'message' && setLocationHash.isClassOf(e.message)) { window.location.hash = e.message[0]; } }; /////////////////////////////////////////////////////////////////////////// function AttributeUpdater() { this.name = 'AttributeUpdater'; } AttributeUpdater.prototype.boot = function () { return Patch.sub(setAttribute(__, __, __)) .andThen(Patch.sub(removeAttribute(__, __))) .andThen(Patch.sub(setProperty(__, __, __))) .andThen(Patch.sub(removeProperty(__, __))) ; }; AttributeUpdater.prototype.handleEvent = function (e) { if (e.type === 'message') { var f = false; if (setAttribute.isClassOf(e.message)) { f = function (n, k) { n.setAttribute(k, e.message[2]); }; } else if (removeAttribute.isClassOf(e.message)) { f = function (n, k) { n.removeAttribute(k); }; } else if (setProperty.isClassOf(e.message)) { f = function (n, k) { n[k] = e.message[2]; }; } else if (removeProperty.isClassOf(e.message)) { f = function (n, k) { delete n[k]; }; } if (f) { selectorMatch(document, e.message[0]).forEach(function (n) { f(n, e.message[1]); }); } } }; /////////////////////////////////////////////////////////////////////////// module.exports.newFragmentId = newFragmentId; module.exports.spawnUIDriver = spawnUIDriver; module.exports.Anchor = Anchor; module.exports.globalEvent = globalEvent; module.exports.windowEvent = windowEvent; module.exports.uiEvent = uiEvent; module.exports.uiFragment = uiFragment; module.exports.uiFragmentVersion = uiFragmentVersion; module.exports.uiAttribute = uiAttribute; module.exports.uiProperty = uiProperty; module.exports.setAttribute = setAttribute; module.exports.removeAttribute = removeAttribute; module.exports.setProperty = setProperty; module.exports.removeProperty = removeProperty; module.exports.locationHash = locationHash; module.exports.setLocationHash = setLocationHash;