From 0208ae7a7d067a0d52b991b34e73fa54d64c2dbe Mon Sep 17 00:00:00 2001 From: Tony Garnock-Jones Date: Wed, 11 May 2016 21:03:11 -0400 Subject: [PATCH] A better UI library. Replaces DOM and jQuery support. --- js/examples/button/index.expanded.js | 29 +-- js/examples/button/index.html | 1 - js/examples/button/index.js | 25 +- js/examples/chat/index.js | 30 ++- js/examples/dom/index.html | 1 - js/examples/dom/index.js | 27 +- js/examples/index.md | 29 +-- js/examples/iot/index.js | 35 ++- js/examples/jquery/index.html | 16 -- js/examples/jquery/index.js | 34 --- js/examples/smoketest-dsl/index.html | 1 - js/examples/smoketest/index.html | 1 - js/examples/smoketest/index.js | 52 ++-- js/examples/svg/index.html | 1 - js/examples/svg/index.js | 37 ++- js/examples/textfield-dsl/index.html | 1 - js/examples/textfield-dsl/index.js | 53 ++-- js/examples/textfield/index.html | 1 - js/examples/textfield/index.js | 79 +++--- js/src/dom-driver.js | 120 --------- js/src/jquery-driver.js | 90 ------- js/src/main.js | 3 +- js/src/randomid.js | 21 +- js/src/ui.js | 360 +++++++++++++++++++++++++++ 24 files changed, 568 insertions(+), 479 deletions(-) delete mode 100644 js/examples/jquery/index.html delete mode 100644 js/examples/jquery/index.js delete mode 100644 js/src/dom-driver.js delete mode 100644 js/src/jquery-driver.js create mode 100644 js/src/ui.js diff --git a/js/examples/button/index.expanded.js b/js/examples/button/index.expanded.js index efc7055..632fdc8 100644 --- a/js/examples/button/index.expanded.js +++ b/js/examples/button/index.expanded.js @@ -1,19 +1,14 @@ "use strict"; -var DOM = Syndicate.DOM.DOM; -var jQueryEvent = Syndicate.JQuery.jQueryEvent; +new Syndicate.Ground(function () { + Syndicate.UI.spawnUIDriver(); -$(document).ready(function() { - new Syndicate.Ground(function () { - Syndicate.DOM.spawnDOMDriver(); - Syndicate.JQuery.spawnJQueryDriver(); - - Syndicate.Actor.spawnActor(new Object(), function() { - this.counter = 0; - Syndicate.Actor.createFacet() -.addAssertion((function() { var _ = Syndicate.__; return Syndicate.Patch.assert(DOM('#button-label','',''+this.counter), 0); })) -.onEvent(false, "message", (function() { var _ = Syndicate.__; return Syndicate.Patch.sub(jQueryEvent('#counter','click',_), 0); }), (function() { var _ = Syndicate.__; return { assertion: jQueryEvent('#counter','click',_), metalevel: 0 }; }), (function() { - this.counter++; - })).completeBuild(); - }); - }).startStepping(); -}); + Syndicate.Actor.spawnActor(new Object(), function() { + var counter = 0; + var ui = new Syndicate.UI.Anchor(); + Syndicate.Actor.createFacet() +.addAssertion((function() { var _ = Syndicate.__; return Syndicate.Patch.assert(ui.html('#button-label',''+counter), 0); })) +.onEvent(false, "message", (function() { var _ = Syndicate.__; return Syndicate.Patch.sub(Syndicate.UI.globalEvent('#counter','click',_), 0); }), (function() { var _ = Syndicate.__; return { assertion: Syndicate.UI.globalEvent('#counter','click',_), metalevel: 0 }; }), (function() { + counter++; + })).completeBuild(); + }); +}).startStepping(); diff --git a/js/examples/button/index.html b/js/examples/button/index.html index 1387309..6f53804 100644 --- a/js/examples/button/index.html +++ b/js/examples/button/index.html @@ -3,7 +3,6 @@ Syndicate: Button Example - diff --git a/js/examples/button/index.js b/js/examples/button/index.js index c329a07..bb660a8 100644 --- a/js/examples/button/index.js +++ b/js/examples/button/index.js @@ -1,19 +1,14 @@ -var DOM = Syndicate.DOM.DOM; -var jQueryEvent = Syndicate.JQuery.jQueryEvent; +ground dataspace { + Syndicate.UI.spawnUIDriver(); -$(document).ready(function() { - ground dataspace { - Syndicate.DOM.spawnDOMDriver(); - Syndicate.JQuery.spawnJQueryDriver(); - - actor { - this.counter = 0; - react { - assert DOM('#button-label', '', '' + this.counter); - on message jQueryEvent('#counter', 'click', _) { - this.counter++; - } + actor { + var counter = 0; + var ui = new Syndicate.UI.Anchor(); + react { + assert ui.html('#button-label', '' + counter); + on message Syndicate.UI.globalEvent('#counter', 'click', _) { + counter++; } } } -}); +} diff --git a/js/examples/chat/index.js b/js/examples/chat/index.js index c254dd7..881606e 100644 --- a/js/examples/chat/index.js +++ b/js/examples/chat/index.js @@ -1,9 +1,6 @@ assertion type present(name, status); assertion type says(who, message); -var DOM = Syndicate.DOM.DOM; -var jQueryEvent = Syndicate.JQuery.jQueryEvent; - var brokerConnected = Syndicate.Broker.brokerConnected; var brokerConnection = Syndicate.Broker.brokerConnection; var toBroker = Syndicate.Broker.toBroker; @@ -19,14 +16,15 @@ function spawnChatApp() { if (!($("#nym").val())) { $("#nym").val("nym" + Math.floor(Math.random() * 65536)); } actor { + var ui = new Syndicate.UI.Anchor(); react { - on asserted jQueryInput('#nym', $v) { this.nym = v; } - on asserted jQueryInput('#status', $v) { this.status = v; } + on asserted inputValue('#nym', $v) { this.nym = v; } + on asserted inputValue('#status', $v) { this.status = v; } on asserted brokerConnected($url) { outputState('connected to ' + url); } on retracted brokerConnected($url) { outputState('disconnected from ' + url); } - during jQueryInput('#wsurl', $url) { + during inputValue('#wsurl', $url) { assert brokerConnection(url); on message Syndicate.WakeDetector.wakeEvent() { @@ -35,11 +33,12 @@ function spawnChatApp() { assert toBroker(url, present(this.nym, this.status)); during fromBroker(url, present($who, $status)) { - assert DOM('#nymlist', 'present-nym', - Mustache.render($('#nym_template').html(), { who: who, status: status })); + assert ui.context(who) + .html('#nymlist', + Mustache.render($('#nym_template').html(), { who: who, status: status })); } - on message jQueryEvent('#send_chat', 'click', _) { + on message Syndicate.UI.globalEvent('#send_chat', 'click', _) { var inp = $("#chat_input"); var utterance = inp.val(); inp.val(""); @@ -79,21 +78,21 @@ function outputUtterance(who, what) { /////////////////////////////////////////////////////////////////////////// // Input control value monitoring -assertion type jQueryInput(selector, value); +assertion type inputValue(selector, value); function spawnInputChangeMonitor() { actor { react { - on asserted Syndicate.observe(jQueryInput($selector, _)) { + on asserted Syndicate.observe(inputValue($selector, _)) { actor { this.value = $(selector).val(); react { - assert jQueryInput(selector, this.value); - on message jQueryEvent(selector, 'change', $e) { + assert inputValue(selector, this.value); + on message Syndicate.UI.globalEvent(selector, 'change', $e) { this.value = e.target.value; } } until { - case retracted Syndicate.observe(jQueryInput(selector, _)); + case retracted Syndicate.observe(inputValue(selector, _)); } } } @@ -106,8 +105,7 @@ function spawnInputChangeMonitor() { $(document).ready(function () { ground dataspace G { - Syndicate.JQuery.spawnJQueryDriver(); - Syndicate.DOM.spawnDOMDriver(); + Syndicate.UI.spawnUIDriver(); Syndicate.WakeDetector.spawnWakeDetector(); Syndicate.Broker.spawnBrokerClientDriver(); spawnInputChangeMonitor(); diff --git a/js/examples/dom/index.html b/js/examples/dom/index.html index 3b90a38..2530b47 100644 --- a/js/examples/dom/index.html +++ b/js/examples/dom/index.html @@ -3,7 +3,6 @@ Syndicate: DOM Example - diff --git a/js/examples/dom/index.js b/js/examples/dom/index.js index ed7dc84..7748748 100644 --- a/js/examples/dom/index.js +++ b/js/examples/dom/index.js @@ -1,5 +1,5 @@ var G; -$(document).ready(function () { +document.addEventListener('DOMContentLoaded', function () { var Dataspace = Syndicate.Dataspace; var sub = Syndicate.sub; var assert = Syndicate.assert; @@ -10,18 +10,17 @@ $(document).ready(function () { G = new Syndicate.Ground(function () { console.log('starting ground boot'); - Syndicate.DOM.spawnDOMDriver(); - var DOM = Syndicate.DOM.DOM; - var jQueryEvent = Syndicate.JQuery.jQueryEvent; + Syndicate.UI.spawnUIDriver(); Dataspace.spawn({ boot: function () { - return assert(DOM("#clicker-holder", "clicker", - '')) - .andThen(sub(jQueryEvent("button.clicker", "click", __))); + var ui = new Syndicate.UI.Anchor(); + return assert(ui.html("#clicker-holder", + '')) + .andThen(sub(Syndicate.UI.globalEvent("button", "click", __))); }, handleEvent: function (e) { - if (e.type === "message" && jQueryEvent.isClassOf(e.message)) { + if (e.type === "message" && Syndicate.UI.globalEvent.isClassOf(e.message)) { Dataspace.send("bump_count"); } } @@ -29,15 +28,17 @@ $(document).ready(function () { Dataspace.spawn({ counter: 0, + ui: new Syndicate.UI.Anchor(), boot: function () { this.updateState(); return sub("bump_count"); }, updateState: function () { - Dataspace.stateChange(retract(DOM.pattern) - .andThen(assert(DOM("#counter-holder", "counter", - '

The current count is: '+this.counter+ - '

')))); + Dataspace.stateChange(retract(this.ui.htmlPattern) + .andThen(assert(this.ui.html( + "#counter-holder", + '

The current count is: '+this.counter+ + '

')))); }, handleEvent: function (e) { if (e.type === "message" && e.message === "bump_count") { @@ -49,7 +50,7 @@ $(document).ready(function () { }); G.dataspace.setOnStateChange(function (mux, patch) { - $("#spy-holder").text(Syndicate.prettyTrie(mux.routingTable)); + document.getElementById('spy-holder').innerText = Syndicate.prettyTrie(mux.routingTable); }); G.startStepping(); diff --git a/js/examples/index.md b/js/examples/index.md index 537b758..785187a 100644 --- a/js/examples/index.md +++ b/js/examples/index.md @@ -13,36 +13,25 @@ This is a simple clickable button; each time the button is clicked, the number on the face of the button is incremented. The actor maintaining the counter also maintains the button's label -and listens to click events. It uses the Syndicate/js DOM driver to -publish the button's label text based on its internal state, and the -Syndicate/js jQuery driver to subscribe to button click events. +and listens to click events. It uses the Syndicate/js UI driver to +publish the button's label text based on its internal state and to +subscribe to button click events. - [DEMO](button/) - [Source code](button/index.js) using the Syndicate/js DSL ## DOM example -This example demonstrates two actors, each using the Syndicate/js DOM -driver to display user interface, and the jQuery driver to receive -events from it. The first actor presents a button to the user, which -when clicked sends a message to the other actor. The second actor -receives messages from the first, updates its internal state, and -reflects its new internal state in its visible UI. +This example demonstrates two actors, each using the Syndicate/js UI +driver to display user interface and receive events from it. The first +actor presents a button to the user, which when clicked sends a +message to the other actor. The second actor receives messages from +the first, updates its internal state, and reflects its new internal +state in its visible UI. - [DEMO](dom/) - [Source code](dom/index.js) in plain JavaScript -## jQuery Example - -This example is similar to the button example, but uses plain -JavaScript instead of the Syndicate/js DSL, calling out to Syndicate -as a library. It uses the Syndicate/js jQuery driver to receive click -events from the button, but does not use the Syndicate/js DOM driver; -instead, it updates the DOM directly. - - - [DEMO](jquery/) - - [Source code](jquery/index.js) in plain JavaScript - ## Text Entry Widget This is a simple text entry GUI control, following a design of diff --git a/js/examples/iot/index.js b/js/examples/iot/index.js index 445698c..7504683 100644 --- a/js/examples/iot/index.js +++ b/js/examples/iot/index.js @@ -5,17 +5,15 @@ assertion type tvAlert(text); assertion type switchAction(on); assertion type componentPresent(name); -var DOM = Syndicate.DOM.DOM; -var jQueryEvent = Syndicate.JQuery.jQueryEvent; - /////////////////////////////////////////////////////////////////////////// // TV function spawnTV() { actor { + var ui = new Syndicate.UI.Anchor(); react { during tvAlert($text) { - assert DOM('#tv', 'alert', Mustache.render($('#alert_template').html(), { text: text })); + assert ui.context(text).html('#tv', Mustache.render($('#alert_template').html(), { text: text })); } } } @@ -28,7 +26,7 @@ function spawnRemoteControl() { actor { react { assert componentPresent('remote control'); - on message jQueryEvent('#remote-control', 'click', _) { + on message Syndicate.UI.globalEvent('#remote-control', 'click', _) { :: remoteClick(); } } @@ -63,23 +61,24 @@ function spawnRemoteListener() { function spawnStoveSwitch() { actor { this.powerOn = false; + this.ui = new Syndicate.UI.Anchor(); react { assert componentPresent('stove switch'); assert switchState(this.powerOn); - assert DOM('#stove-switch', 'switch-state', - Mustache.render($('#stove_element_template').html(), - { imgurl: ("img/stove-coil-element-" + - (this.powerOn ? "hot" : "cold") + ".jpg") })); + assert this.ui.html('#stove-switch', + Mustache.render($('#stove_element_template').html(), + { imgurl: ("img/stove-coil-element-" + + (this.powerOn ? "hot" : "cold") + ".jpg") })); - on message jQueryEvent('#stove-switch-on', 'click', _) { this.powerOn = true; } - on message jQueryEvent('#stove-switch-off', 'click', _) { this.powerOn = false; } + on message Syndicate.UI.globalEvent('#stove-switch-on', 'click', _) { this.powerOn = true; } + on message Syndicate.UI.globalEvent('#stove-switch-off', 'click', _) { this.powerOn = false; } on message switchAction($newState) { this.powerOn = newState; } } until { - case message jQueryEvent('#kill-stove-switch', 'click', _); + case message Syndicate.UI.globalEvent('#kill-stove-switch', 'click', _); } } } @@ -87,18 +86,19 @@ function spawnStoveSwitch() { function spawnPowerDrawMonitor() { actor { this.watts = 0; + this.ui = new Syndicate.UI.Anchor(); react { assert componentPresent('power draw monitor'); assert powerDraw(this.watts); - assert DOM('#power-draw-meter', 'power-draw', - Mustache.render($('#power_draw_template').html(), { watts: this.watts })); + assert this.ui.html('#power-draw-meter', + Mustache.render($('#power_draw_template').html(), { watts: this.watts })); on asserted switchState($on) { this.watts = on ? 1500 : 0; } } until { - case message jQueryEvent('#kill-power-draw-monitor', 'click', _); + case message Syndicate.UI.globalEvent('#kill-power-draw-monitor', 'click', _); } } } @@ -207,7 +207,7 @@ function spawnChaosMonkey() { jKillButtons.prop('disabled', true); } } - on message jQueryEvent(spawnButtonSelector, 'click', _) { + on message Syndicate.UI.globalEvent(spawnButtonSelector, 'click', _) { spawnFunction(); } } @@ -219,8 +219,7 @@ function spawnChaosMonkey() { $(document).ready(function () { ground dataspace G { - Syndicate.JQuery.spawnJQueryDriver(); - Syndicate.DOM.spawnDOMDriver(); + Syndicate.UI.spawnUIDriver(); Syndicate.Timer.spawnTimerDriver(); spawnTV(); diff --git a/js/examples/jquery/index.html b/js/examples/jquery/index.html deleted file mode 100644 index d5bea5b..0000000 --- a/js/examples/jquery/index.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - Syndicate: jQuery Example - - - - - - -

jQuery example

- -
0
-

-  
-
diff --git a/js/examples/jquery/index.js b/js/examples/jquery/index.js
deleted file mode 100644
index 891cc0b..0000000
--- a/js/examples/jquery/index.js
+++ /dev/null
@@ -1,34 +0,0 @@
-"use strict";
-
-var G;
-$(document).ready(function () {
-    var Dataspace = Syndicate.Dataspace;
-    var sub = Syndicate.sub;
-    var __ = Syndicate.__;
-    var _$ = Syndicate._$;
-
-    G = new Syndicate.Ground(function () {
-      console.log('starting ground boot');
-
-      Syndicate.JQuery.spawnJQueryDriver();
-
-      Dataspace.spawn({
-	boot: function () {
-	  return sub(Syndicate.JQuery.jQueryEvent('#clicker', 'click', __));
-	},
-	handleEvent: function (e) {
-	  if (e.type === 'message'
-              && Syndicate.JQuery.jQueryEvent.isClassOf(e.message)
-              && e.message[0] === '#clicker')
-          {
-	    var r = $('#result');
-	    r.html(Number(r.html()) + 1);
-	  }
-	}
-      });
-    });
-    G.dataspace.setOnStateChange(function (mux, patch) {
-      $("#spy-holder").text(Syndicate.prettyTrie(mux.routingTable));
-    });
-    G.startStepping();
-});
diff --git a/js/examples/smoketest-dsl/index.html b/js/examples/smoketest-dsl/index.html
index b8208b7..b9a3fc6 100644
--- a/js/examples/smoketest-dsl/index.html
+++ b/js/examples/smoketest-dsl/index.html
@@ -3,7 +3,6 @@
   
     Syndicate: Smoketest with DSL
     
-    
     
     
     
diff --git a/js/examples/smoketest/index.html b/js/examples/smoketest/index.html
index f5a020e..e6853cf 100644
--- a/js/examples/smoketest/index.html
+++ b/js/examples/smoketest/index.html
@@ -3,7 +3,6 @@
   
     Syndicate: Smoketest
     
-    
     
     
   
diff --git a/js/examples/smoketest/index.js b/js/examples/smoketest/index.js
index a960659..a64f6c5 100644
--- a/js/examples/smoketest/index.js
+++ b/js/examples/smoketest/index.js
@@ -3,33 +3,33 @@
 var beep = Syndicate.Struct.makeConstructor('beep', ['counter']);
 
 var G;
-$(document).ready(function () {
-    var Dataspace = Syndicate.Dataspace;
-    var sub = Syndicate.sub;
-    var __ = Syndicate.__;
-    var _$ = Syndicate._$;
+document.addEventListener('DOMContentLoaded', function () {
+  var Dataspace = Syndicate.Dataspace;
+  var sub = Syndicate.sub;
+  var __ = Syndicate.__;
+  var _$ = Syndicate._$;
 
-    G = new Syndicate.Ground(function () {
-      console.log('starting ground boot');
+  G = new Syndicate.Ground(function () {
+    console.log('starting ground boot');
 
-      Dataspace.spawn({
-	counter: 0,
-	boot: function () {},
-	handleEvent: function (e) {},
-	step: function () {
-	  Dataspace.send(beep(this.counter++));
-	  return this.counter <= 10;
-	}
-      });
-
-      Dataspace.spawn({
-	boot: function () { return sub(beep.pattern); },
-	handleEvent: function (e) {
-	  if (e.type === 'message') {
-	    console.log("beep!", e.message[0]);
-	  }
-	}
-      });
+    Dataspace.spawn({
+      counter: 0,
+      boot: function () {},
+      handleEvent: function (e) {},
+      step: function () {
+	Dataspace.send(beep(this.counter++));
+	return this.counter <= 10;
+      }
     });
-    G.startStepping();
+
+    Dataspace.spawn({
+      boot: function () { return sub(beep.pattern); },
+      handleEvent: function (e) {
+	if (e.type === 'message') {
+	  console.log("beep!", e.message[0]);
+	}
+      }
+    });
+  });
+  G.startStepping();
 });
diff --git a/js/examples/svg/index.html b/js/examples/svg/index.html
index 243dd81..928f7d5 100644
--- a/js/examples/svg/index.html
+++ b/js/examples/svg/index.html
@@ -4,7 +4,6 @@
     Syndicate: SVG
     
     
-    
     
     
     
diff --git a/js/examples/svg/index.js b/js/examples/svg/index.js
index 153eeda..5811235 100644
--- a/js/examples/svg/index.js
+++ b/js/examples/svg/index.js
@@ -1,25 +1,22 @@
-var DOM = Syndicate.DOM.DOM;
+ground dataspace G {
+  Syndicate.UI.spawnUIDriver();
+  Syndicate.Timer.spawnTimerDriver();
 
-$(document).ready(function () {
-  ground dataspace G {
-    Syndicate.DOM.spawnDOMDriver();
-    Syndicate.Timer.spawnTimerDriver();
+  actor {
+    var ui = new Syndicate.UI.Anchor();
+    react {
+      assert ui.html('#clock',
+                     ''+
+                     ''+
+                     ''+
+                     '')
+      when (typeof this.angle === 'number');
 
-    actor {
-      react {
-        assert DOM('#clock', 'clock',
-                   ''+
-                   ''+
-                   ''+
-                   '')
-          when (typeof this.angle === 'number');
-
-        on message Syndicate.Timer.periodicTick(1000) {
-          this.angle = ((((Date.now() / 1000) % 60) / 60) - 0.25) * 2 * Math.PI;
-          this.handX = 50 + 40 * Math.cos(this.angle);
-          this.handY = 50 + 40 * Math.sin(this.angle);
-        }
+      on message Syndicate.Timer.periodicTick(1000) {
+        this.angle = ((((Date.now() / 1000) % 60) / 60) - 0.25) * 2 * Math.PI;
+        this.handX = 50 + 40 * Math.cos(this.angle);
+        this.handY = 50 + 40 * Math.sin(this.angle);
       }
     }
   }
-});
+}
diff --git a/js/examples/textfield-dsl/index.html b/js/examples/textfield-dsl/index.html
index 9684bd4..a8aa208 100644
--- a/js/examples/textfield-dsl/index.html
+++ b/js/examples/textfield-dsl/index.html
@@ -4,7 +4,6 @@
     Syndicate: Textfield Example (DSL variation)
     
     
-    
     
     
     
diff --git a/js/examples/textfield-dsl/index.js b/js/examples/textfield-dsl/index.js
index 747f709..45e47a5 100644
--- a/js/examples/textfield-dsl/index.js
+++ b/js/examples/textfield-dsl/index.js
@@ -1,7 +1,7 @@
 ///////////////////////////////////////////////////////////////////////////
 // GUI
 
-var jQueryEvent = Syndicate.JQuery.jQueryEvent;
+var globalEvent = Syndicate.UI.globalEvent;
 assertion type fieldCommand(detail);
 assertion type fieldContents(text, pos);
 assertion type highlight(state);
@@ -36,7 +36,7 @@ function spawnGui() {
       var highlight = this.highlightState;
       var hLeft = highlight ? highlight[0] : 0;
       var hRight = highlight ? highlight[1] : 0;
-      $("#fieldContents")[0].innerHTML = highlight
+      document.getElementById("fieldContents").innerHTML = highlight
 	? piece(text, pos, 0, hLeft, "normal") +
 	piece(text, pos, hLeft, hRight, "highlight") +
 	piece(text, pos, hRight, text.length + 1, "normal")
@@ -44,18 +44,22 @@ function spawnGui() {
     };
 
     react {
-      on message jQueryEvent("#inputRow", "+keypress", $event) {
-        var keycode = event.keyCode;
+      on message globalEvent("#inputRow", "+keydown", $event) {
+        switch (event.keyCode) {
+          case 37 /* left  */: :: fieldCommand("cursorLeft"); break;
+          case 39 /* right */: :: fieldCommand("cursorRight"); break;
+          case 9 /* tab */: /* ignore */ break;
+          case 8 /* backspace */:
+            event.preventDefault(); // that this works here is a minor miracle
+	    :: fieldCommand("backspace");
+            break;
+          default: break;
+        }
+      }
+
+      on message globalEvent("#inputRow", "+keypress", $event) {
         var character = String.fromCharCode(event.charCode);
-        if (keycode === 37 /* left */) {
-          :: fieldCommand("cursorLeft");
-        } else if (keycode === 39 /* right */) {
-	  :: fieldCommand("cursorRight");
-        } else if (keycode === 9 /* tab */) {
-	  // ignore
-        } else if (keycode === 8 /* backspace */) {
-	  :: fieldCommand("backspace");
-        } else if (character) {
+        if (event.charCode && character) {
 	  :: fieldCommand(["insert", character]);
         }
       }
@@ -126,7 +130,7 @@ function spawnSearch() {
     this.highlight = false;
 
     this.search = function () {
-      var searchtext = $("#searchBox")[0].value;
+      var searchtext = document.getElementById("searchBox").value;
       if (searchtext) {
 	var pos = this.fieldValue.indexOf(searchtext);
 	this.highlight = (pos !== -1) && [pos, pos + searchtext.length];
@@ -138,7 +142,7 @@ function spawnSearch() {
     react {
       assert highlight(this.highlight);
 
-      on message jQueryEvent("#searchBox", "input", $event) {
+      on message globalEvent("#searchBox", "input", $event) {
         this.search();
       }
 
@@ -153,17 +157,14 @@ function spawnSearch() {
 ///////////////////////////////////////////////////////////////////////////
 // Main
 
-$(document).ready(function () {
-  ground dataspace G {
-    Syndicate.JQuery.spawnJQueryDriver();
-    Syndicate.DOM.spawnDOMDriver();
+ground dataspace G {
+  Syndicate.UI.spawnUIDriver();
 
-    spawnGui();
-    spawnModel();
-    spawnSearch();
-  }
+  spawnGui();
+  spawnModel();
+  spawnSearch();
+}
 
-  G.dataspace.setOnStateChange(function (mux, patch) {
-    $("#spy-holder").text(Syndicate.prettyTrie(mux.routingTable));
-  });
+G.dataspace.setOnStateChange(function (mux, patch) {
+  document.getElementById("spy-holder").innerText = Syndicate.prettyTrie(mux.routingTable);
 });
diff --git a/js/examples/textfield/index.html b/js/examples/textfield/index.html
index 42c64ad..be063ad 100644
--- a/js/examples/textfield/index.html
+++ b/js/examples/textfield/index.html
@@ -4,7 +4,6 @@
     Syndicate: Textfield Example
     
     
-    
     
     
   
diff --git a/js/examples/textfield/index.js b/js/examples/textfield/index.js
index ec8ff04..adf8a86 100644
--- a/js/examples/textfield/index.js
+++ b/js/examples/textfield/index.js
@@ -7,7 +7,7 @@ var Patch = Syndicate.Patch;
 var __ = Syndicate.__;
 var _$ = Syndicate._$;
 
-var jQueryEvent = Syndicate.JQuery.jQueryEvent;
+var globalEvent = Syndicate.UI.globalEvent;
 var fieldContents = Syndicate.Struct.makeConstructor('fieldContents', ['text', 'pos']);
 var highlight = Syndicate.Struct.makeConstructor('highlight', ['state']);
 var fieldCommand = Syndicate.Struct.makeConstructor('fieldCommand', ['detail']);
@@ -36,7 +36,8 @@ function spawnGui() {
     highlight: { state: false },
 
     boot: function () {
-      return Patch.sub(jQueryEvent("#inputRow", "+keypress", __))
+      return Patch.sub(globalEvent("#inputRow", "keypress", __))
+        .andThen(Patch.sub(globalEvent("#inputRow", "+keydown", __)))
 	.andThen(Patch.sub(fieldContents.pattern))
 	.andThen(Patch.sub(highlight.pattern));
     },
@@ -46,31 +47,40 @@ function spawnGui() {
     handleEvent: function (e) {
       var self = this;
       switch (e.type) {
-      case "message":
-	var event = e.message[2];
-	var keycode = event.keyCode;
-	var character = String.fromCharCode(event.charCode);
-	if (keycode === 37 /* left */) {
-	  Dataspace.send(fieldCommand("cursorLeft"));
-	} else if (keycode === 39 /* right */) {
-	  Dataspace.send(fieldCommand("cursorRight"));
-	} else if (keycode === 9 /* tab */) {
-	  // ignore
-	} else if (keycode === 8 /* backspace */) {
-	  Dataspace.send(fieldCommand("backspace"));
-	} else if (character) {
-	  Dataspace.send(fieldCommand(["insert", character]));
-	}
-	break;
-      case "stateChange":
-	Trie.projectObjects(e.patch.added, this.fieldContentsProjection).forEach(function (c) {
-	  self.field = c;
-	});
-	Trie.projectObjects(e.patch.added, this.highlightProjection).forEach(function (c) {
-	  self.highlight = c;
-	});
-	this.updateDisplay();
-	break;
+        case "message":
+	  var event = e.message[2];
+          switch (event.type) {
+            case "keydown":
+              switch (event.keyCode) {
+                case 37 /* left */: Dataspace.send(fieldCommand("cursorLeft")); break;
+                case 39 /* right */: Dataspace.send(fieldCommand("cursorRight")); break;
+                case 9 /* tab */: /* ignore */ break;
+                case 8 /* backspace */:
+                  event.preventDefault(); // that this works here is a minor miracle
+	          Dataspace.send(fieldCommand("backspace"));
+                  break;
+                default: break;
+              }
+              break;
+            case "keypress":
+	      var character = String.fromCharCode(event.charCode);
+              if (event.charCode && character) {
+	        Dataspace.send(fieldCommand(["insert", character]));
+	      }
+              break;
+            default:
+              break;
+          }
+	  break;
+        case "stateChange":
+	  Trie.projectObjects(e.patch.added, this.fieldContentsProjection).forEach(function (c) {
+	    self.field = c;
+	  });
+	  Trie.projectObjects(e.patch.added, this.highlightProjection).forEach(function (c) {
+	    self.highlight = c;
+	  });
+	  this.updateDisplay();
+	  break;
       }
     },
 
@@ -80,7 +90,7 @@ function spawnGui() {
       var highlight = this.highlight ? this.highlight.state : false;
       var hLeft = highlight ? highlight[0] : 0;
       var hRight = highlight ? highlight[1] : 0;
-      $("#fieldContents")[0].innerHTML = highlight
+      document.getElementById("fieldContents").innerHTML = highlight
 	? piece(text, pos, 0, hLeft, "normal") +
 	piece(text, pos, hLeft, hRight, "highlight") +
 	piece(text, pos, hRight, text.length + 1, "normal")
@@ -149,14 +159,14 @@ function spawnSearch() {
 
     boot: function () {
       this.publishState();
-      return Patch.sub(jQueryEvent("#searchBox", "input", __))
+      return Patch.sub(globalEvent("#searchBox", "input", __))
 	.andThen(Patch.sub(fieldContents.pattern));
     },
 
     fieldContentsProjection: fieldContents(_$("text"), _$("pos")),
     handleEvent: function (e) {
       var self = this;
-      if (jQueryEvent.isClassOf(e.message)) {
+      if (globalEvent.isClassOf(e.message)) {
 	this.search();
       }
       if (e.type === "stateChange") {
@@ -174,7 +184,7 @@ function spawnSearch() {
     },
 
     search: function () {
-      var searchtext = $("#searchBox")[0].value;
+      var searchtext = document.getElementById("searchBox").value;
       var oldHighlight = this.highlight;
       if (searchtext) {
 	var pos = this.fieldValue.indexOf(searchtext);
@@ -193,10 +203,9 @@ function spawnSearch() {
 // Main
 
 var G;
-$(document).ready(function () {
+document.addEventListener('DOMContentLoaded', function () {
   G = new Syndicate.Ground(function () {
-    Syndicate.JQuery.spawnJQueryDriver();
-    Syndicate.DOM.spawnDOMDriver();
+    Syndicate.UI.spawnUIDriver();
 
     spawnGui();
     spawnModel();
@@ -204,7 +213,7 @@ $(document).ready(function () {
   });
 
   G.dataspace.setOnStateChange(function (mux, patch) {
-    $("#spy-holder").text(Syndicate.prettyTrie(mux.routingTable));
+    document.getElementById("spy-holder").innerText = Syndicate.prettyTrie(mux.routingTable);
   });
 
   G.startStepping();
diff --git a/js/src/dom-driver.js b/js/src/dom-driver.js
deleted file mode 100644
index 57c540c..0000000
--- a/js/src/dom-driver.js
+++ /dev/null
@@ -1,120 +0,0 @@
-// DOM fragment display driver
-var Patch = require("./patch.js");
-var DemandMatcher = require('./demand-matcher.js').DemandMatcher;
-var Struct = require('./struct.js');
-var Ack = require('./ack.js').Ack;
-
-var Dataspace_ = require("./dataspace.js");
-var Dataspace = Dataspace_.Dataspace;
-var __ = Dataspace_.__;
-var _$ = Dataspace_._$;
-
-var DOM = Struct.makeConstructor('DOM', ['selector', 'fragmentClass', 'fragmentSpec']);
-
-function spawnDOMDriver(domWrapFunction, jQueryWrapFunction) {
-  domWrapFunction = domWrapFunction || DOM;
-  var spec = domWrapFunction(_$('selector'), _$('fragmentClass'), _$('fragmentSpec'));
-  Dataspace.spawn(
-    new DemandMatcher([spec],
-		      [Patch.advertise(spec)],
-		      {
-			onDemandIncrease: function (c) {
-			  Dataspace.spawn(new DOMFragment(c.selector,
-							  c.fragmentClass,
-							  c.fragmentSpec,
-							  domWrapFunction,
-							  jQueryWrapFunction));
-			}
-		      }));
-}
-
-function DOMFragment(selector, fragmentClass, fragmentSpec, domWrapFunction, jQueryWrapFunction) {
-  this.selector = selector;
-  this.fragmentClass = fragmentClass;
-  this.fragmentSpec = fragmentSpec;
-  this.domWrapFunction = domWrapFunction;
-  this.jQueryWrapFunction = jQueryWrapFunction;
-  this.demandExists = false;
-  this.subscriptionEstablished = new Ack();
-  this.nodes = this.buildNodes();
-}
-
-DOMFragment.prototype.boot = function () {
-  var self = this;
-  var specification = self.domWrapFunction(self.selector, self.fragmentClass, self.fragmentSpec);
-
-  Dataspace.spawn(new Dataspace(function () {
-    Syndicate.JQuery.spawnJQueryDriver(self.selector+" > ."+self.fragmentClass,
-				       1,
-				       self.jQueryWrapFunction);
-    Dataspace.spawn({
-      demandExists: false,
-      subscriptionEstablished: new Ack(1),
-      boot: function () {
-	this.subscriptionEstablished.arm();
-	return Patch.sub(Patch.advertise(specification), 1);
-      },
-      handleEvent: function (e) {
-	this.subscriptionEstablished.check(e);
-	if (e.type === "stateChange") {
-	  if (e.patch.hasAdded()) this.demandExists = true;
-	  if (e.patch.hasRemoved()) this.demandExists = false;
-	}
-	if (this.subscriptionEstablished.done && !this.demandExists) {
-	  Dataspace.exitDataspace();
-	}
-      }
-    });
-  }));
-
-  this.subscriptionEstablished.arm();
-  return Patch.sub(specification).andThen(Patch.pub(specification));
-};
-
-DOMFragment.prototype.handleEvent = function (e) {
-  this.subscriptionEstablished.check(e);
-  if (e.type === "stateChange") {
-    if (e.patch.hasAdded()) this.demandExists = true;
-    if (e.patch.hasRemoved()) this.demandExists = false;
-  }
-  if (this.subscriptionEstablished.done && !this.demandExists) {
-    for (var i = 0; i < this.nodes.length; i++) {
-      var n = this.nodes[i];
-      n.parentNode.removeChild(n);
-    }
-    Dataspace.exit();
-  }
-};
-
-///////////////////////////////////////////////////////////////////////////
-
-DOMFragment.prototype.buildNodes = function () {
-  var self = this;
-  var nodes = [];
-  $(self.selector).each(function (index, domNode) {
-    if (typeof self.fragmentSpec !== 'string') {
-      throw new Error("DOM fragmentSpec not a string: " + JSON.stringify(self.fragmentSpec));
-    }
-    var newNodes = $('
' + self.fragmentSpec + '
')[0].childNodes; - // This next loop looks SUPER SUSPICIOUS. What is happening is - // that each time we call domNode.appendChild(n), where n is an - // element of the NodeList newNodes, the DOM is **removing** n - // from the NodeList in order to place it in its new parent. So, - // each call to appendChild shrinks the NodeList by one node until - // it is finally empty, and its length property yields zero. - while (newNodes.length) { - var n = newNodes[0]; - if ('classList' in n) { - n.classList.add(self.fragmentClass); - } - domNode.appendChild(n); - nodes.push(n); - } - }); - return nodes; -}; - -/////////////////////////////////////////////////////////////////////////// - -module.exports.spawnDOMDriver = spawnDOMDriver; -module.exports.DOM = DOM; diff --git a/js/src/jquery-driver.js b/js/src/jquery-driver.js deleted file mode 100644 index 8f6ab2d..0000000 --- a/js/src/jquery-driver.js +++ /dev/null @@ -1,90 +0,0 @@ -// JQuery event driver -var Patch = require("./patch.js"); -var DemandMatcher = require('./demand-matcher.js').DemandMatcher; -var Struct = require('./struct.js'); - -var Dataspace_ = require("./dataspace.js"); -var Dataspace = Dataspace_.Dataspace; -var __ = Dataspace_.__; -var _$ = Dataspace_._$; - -var jQueryEvent = Struct.makeConstructor('jQueryEvent', ['selector', 'eventName', 'eventValue']); - -function spawnJQueryDriver(baseSelector, metaLevel, wrapFunction) { - metaLevel = metaLevel || 0; - wrapFunction = wrapFunction || jQueryEvent; - Dataspace.spawn( - new DemandMatcher([Patch.observe(wrapFunction(_$('selector'), _$('eventName'), __))], - [Patch.advertise(wrapFunction(_$('selector'), _$('eventName'), __))], - { - metaLevel: metaLevel, - onDemandIncrease: function (c) { - Dataspace.spawn(new JQueryEventRouter(baseSelector, - c.selector, - c.eventName, - metaLevel, - wrapFunction)); - } - })); -} - -function JQueryEventRouter(baseSelector, selector, eventName, metaLevel, wrapFunction) { - var self = this; - this.baseSelector = baseSelector || null; - this.selector = selector; - this.eventName = eventName; - this.metaLevel = metaLevel || 0; - this.wrapFunction = wrapFunction || jQueryEvent; - this.preventDefault = (this.eventName.charAt(0) !== "+"); - this.handler = - Dataspace.wrap(function (e) { - Dataspace.send(self.wrapFunction(self.selector, self.eventName, e), self.metaLevel); - if (self.preventDefault) e.preventDefault(); - return !self.preventDefault; - }); - this.computeNodes().on(this.preventDefault ? this.eventName : this.eventName.substring(1), - this.handler); -} - -JQueryEventRouter.prototype.boot = function () { - return Patch.pub(this.wrapFunction(this.selector, this.eventName, __), this.metaLevel) - .andThen(Patch.sub(Patch.observe(this.wrapFunction(this.selector, this.eventName, __)), - this.metaLevel)); -}; - -JQueryEventRouter.prototype.handleEvent = function (e) { - if (e.type === "stateChange" && e.patch.hasRemoved()) { - this.computeNodes().off(this.eventName, this.handler); - Dataspace.exit(); - } -}; - -JQueryEventRouter.prototype.computeNodes = function () { - if (this.baseSelector) { - return $(this.baseSelector).children(this.selector).addBack(this.selector); - } else { - return $(this.selector); - } -}; - -function simplifyDOMEvent(e) { - var keys = []; - for (var k in e) { - var v = e[k]; - if (typeof v === 'object') continue; - if (typeof v === 'function') continue; - keys.push(k); - } - keys.sort(); - var simplified = []; - for (var i = 0; i < keys.length; i++) { - simplified.push([keys[i], e[keys[i]]]); - } - return simplified; -} - -/////////////////////////////////////////////////////////////////////////// - -module.exports.spawnJQueryDriver = spawnJQueryDriver; -module.exports.simplifyDOMEvent = simplifyDOMEvent; -module.exports.jQueryEvent = jQueryEvent; diff --git a/js/src/main.js b/js/src/main.js index 96a580a..53feb8c 100644 --- a/js/src/main.js +++ b/js/src/main.js @@ -27,8 +27,7 @@ module.exports.DemandMatcher = require('./demand-matcher.js').DemandMatcher; module.exports.Ack = require('./ack.js').Ack; module.exports.RandomID = require('./randomid.js'); -module.exports.DOM = require("./dom-driver.js"); -module.exports.JQuery = require("./jquery-driver.js"); +module.exports.UI = require('./ui.js'); module.exports.Timer = require("./timer-driver.js"); module.exports.Reflect = require("./reflect.js"); module.exports.WakeDetector = require("./wake-detector-driver.js"); diff --git a/js/src/randomid.js b/js/src/randomid.js index 18b6061..52473e3 100644 --- a/js/src/randomid.js +++ b/js/src/randomid.js @@ -3,10 +3,19 @@ var randomId; if ((typeof window !== 'undefined') && (typeof window.crypto !== 'undefined') && (typeof window.crypto.getRandomValues !== 'undefined')) { - randomId = function (byteCount) { + randomId = function (byteCount, hexOutput) { var buf = new Uint8Array(byteCount); window.crypto.getRandomValues(buf); - return btoa(String.fromCharCode.apply(null, buf)).replace(/=/g,''); + if (hexOutput) { + var encoded = []; + for (var 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,''); + } }; } else { var crypto; @@ -15,8 +24,12 @@ if ((typeof window !== 'undefined') && } catch (e) {} if ((typeof crypto !== 'undefined') && (typeof crypto.randomBytes !== 'undefined')) { - randomId = function (byteCount) { - return crypto.randomBytes(byteCount).base64Slice().replace(/=/g,''); + 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.'); diff --git a/js/src/ui.js b/js/src/ui.js new file mode 100644 index 0000000..3eed014 --- /dev/null +++ b/js/src/ui.js @@ -0,0 +1,360 @@ +"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 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('globalEvent', ['selector', 'eventType', 'event']); + +// Message. Like globalEvent, but applies only within the scope of the +// UI fragment identified. +var uiEvent = Struct.makeConstructor('uiEvent', ['fragmentId', 'selector', 'eventType', 'event']); + +// Assertion. Causes the setup of DOM nodes corresponding to the given +// HTML fragment, as immediate children of all nodes named by the +// given selector that exist at the time of assertion. +var uiFragment = Struct.makeConstructor('uiFragment', ['fragmentId', 'selector', 'html']); + +// Assertion. Asserted by respondent to a given uiFragment. +var uiFragmentExists = Struct.makeConstructor('uiFragmentExists', ['fragmentId']); + +/////////////////////////////////////////////////////////////////////////// +// ID allocators + +var moduleInstance = RandomID.randomId(16, true); + +var nextFragmentIdNumber = 0; +function newFragmentId() { + return 'ui_' + moduleInstance + '_' + (nextFragmentIdNumber++); +} + +/////////////////////////////////////////////////////////////////////////// + +function spawnUIDriver() { + var globalEventProj = globalEvent(_$('selector'), _$('eventType'), __); + Dataspace.spawn( + new DemandMatcher([Patch.observe(globalEventProj)], + [Patch.advertise(globalEventProj)], + { + onDemandIncrease: function (c) { + Dataspace.spawn(new GlobalEventSupply(c.selector, c.eventType)); + } + })); + + Dataspace.spawn( + new DemandMatcher([uiFragment(_$('fragmentId'), __, __)], + [uiFragmentExists(_$('fragmentId'))], + { + onDemandIncrease: function (c) { + Dataspace.spawn(new UIFragment(c.fragmentId)); + } + })); +} + +/////////////////////////////////////////////////////////////////////////// + +function GlobalEventSupply(selector, eventType) { + this.selector = selector; + this.eventType = eventType; + this.demandPat = Patch.observe(globalEvent(this.selector, this.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(uiFragmentExists(__))) // track new fragments + .andThen(Patch.pub(globalEvent(this.selector, this.eventType, __))) // indicate our presence + ; +}; + +GlobalEventSupply.prototype.updateEventListeners = function (install) { + var nodes = document.querySelectorAll(this.selector); + for (var i = 0; i < nodes.length; i++) { + var n = nodes[i]; + // addEventListener and removeEventListener are apparently idempotent. + if (install) { + n.addEventListener(cleanEventType(this.eventType), this.handlerClosure); + } else { + n.removeEventListener(cleanEventType(this.eventType), this.handlerClosure); + } + } +}; + +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 uiFragmentExists 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 UIFragment(fragmentId) { + this.fragmentId = fragmentId; + this.demandProj = uiFragment(this.fragmentId, _$('selector'), _$('html')); + this.eventDemandProj = + Patch.observe(uiEvent(this.fragmentId, _$('selector'), _$('eventType'), __)); + + this.currentAnchorNodes = []; + this.currentSelector = null; + this.currentHtml = null; + + this.eventClosures = {}; +} + +UIFragment.prototype.boot = function () { + return Patch.sub(Trie.projectionToPattern(this.demandProj)) // track demand + .andThen(Patch.assert(uiFragmentExists(this.fragmentId))) // assert presence + .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); +}; + +function brandNode(n, fragmentId, brandValue) { + if ('dataset' in n) { + // html element nodes etc. + n.dataset[fragmentId] = brandValue; + } else { + // text nodes, svg nodes, etc etc. + n[fragmentId] = brandValue; + } +} + +function getBrand(n, fragmentId) { + if ('dataset' in n && n.dataset[fragmentId]) return n.dataset[fragmentId]; + if (n[fragmentId]) return n[fragmentId]; + return null; +} + +function findInsertionPoint(n, fragmentId) { + for (var i = 0; i < n.childNodes.length; i++) { + var c = n.childNodes[i]; + if (getBrand(c, fragmentId)) return c; + } + return null; +} + +function htmlToNodes(html) { + var e = document.createElement('arbitrarycontainer'); + e.innerHTML = html; + return Array.prototype.slice.call(e.childNodes); +} + +UIFragment.prototype.updateContent = function (newSelector, newHtml) { + var self = this; + var newBrand = '' + (Date.now()); + + var newAnchors = (newSelector !== null) + ? Array.prototype.slice.call(document.querySelectorAll(newSelector)) + : []; + + newAnchors.forEach(function (anchorNode) { + var insertionPoint = findInsertionPoint(anchorNode, self.fragmentId); + htmlToNodes(newHtml).forEach(function (newNode) { + brandNode(newNode, self.fragmentId, newBrand); + anchorNode.insertBefore(newNode, insertionPoint); + }); + }); + + self.currentAnchorNodes.forEach(function (anchorNode) { + var insertionPoint = findInsertionPoint(anchorNode, self.fragmentId); + while (insertionPoint) { + var nextNode = insertionPoint.nextSibling; + var b = getBrand(insertionPoint, self.fragmentId); + if (!b) break; // we know all our-brand nodes will be adjacent + if (b !== newBrand) { + insertionPoint.parentNode.removeChild(insertionPoint); + } + insertionPoint = nextNode; + } + }); + + self.currentAnchorNodes = newAnchors; + self.currentSelector = newSelector; + self.currentHtml = newHtml; +}; + +UIFragment.prototype.handleEvent = function (e) { + var self = this; + + if (e.type === 'stateChange') { + var fragmentChanges = e.patch.projectObjects(self.demandProj); + fragmentChanges[0].forEach(function (c) { self.updateContent(c.selector, c.html); }); + fragmentChanges[1].forEach(function (c) { + if (c.selector === self.currentSelector && c.html === self.currentHtml) { + 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); }) + } +}; + +UIFragment.prototype.eventClosureKey = function (c) { + return c.selector + ' :: ' + c.eventType; +}; + +UIFragment.prototype.getEventClosure = function (c) { + var self = this; + var key = self.eventClosureKey(c); + if (!(key in self.eventClosures)) { + self.eventClosures[key] = Dataspace.wrap(function (e) { return self.handleDomEvent(c, e); }); + } + return self.eventClosures[key]; +}; + +UIFragment.prototype.clearEventClosure = function (c) { + delete this.eventClosures[this.eventClosureKey(c)]; +}; + +UIFragment.prototype.updateEventListeners = function (c, install) { + var self = this; + var handlerClosure = self.getEventClosure(c); + + self.currentAnchorNodes.forEach(function (anchorNode) { + var uiNode = findInsertionPoint(anchorNode, self.fragmentId); + while (uiNode && getBrand(uiNode, self.fragmentId)) { + var nodes = uiNode.querySelectorAll(c.selector); + for (var i = 0; i < nodes.length; i++) { + var n = nodes[i]; + // addEventListener and removeEventListener are apparently idempotent. + if (install) { + n.addEventListener(cleanEventType(c.eventType), handlerClosure); + } else { + n.removeEventListener(cleanEventType(c.eventType), handlerClosure); + } + } + uiNode = uiNode.nextSibling; + } + }); + + if (!install) { + self.clearEventClosure(c); + } +}; + +UIFragment.prototype.handleDomEvent = function (c, e) { + Dataspace.send(uiEvent(this.fragmentId, c.selector, c.eventType, e)); + return dealWithPreventDefault(this.eventType, event); +}; + +/////////////////////////////////////////////////////////////////////////// + +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. + + 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 Anchor(explicitFragmentId) { + this.fragmentId = + (typeof explicitFragmentId === 'undefined') ? newFragmentId() : explicitFragmentId; + this.htmlPattern = uiFragment(this.fragmentId, __, __); + this.eventPattern = uiEvent(this.fragmentId, __, __, __); +} + +Anchor.prototype.context = function (contextId) { + return new Anchor(this.fragmentId + '_' + escapeDataAttributeName(contextId)); +}; + +Anchor.prototype.html = function (selector, html) { + return uiFragment(this.fragmentId, selector, html); +}; + +Anchor.prototype.event = function (selector, eventType, event) { + return uiEvent(this.fragmentId, selector, eventType, event); +}; + +/////////////////////////////////////////////////////////////////////////// + +module.exports.newFragmentId = newFragmentId; +module.exports.spawnUIDriver = spawnUIDriver; +module.exports.Anchor = Anchor; +module.exports.globalEvent = globalEvent; +module.exports.uiEvent = uiEvent; +module.exports.uiFragment = uiFragment; +module.exports.uiFragmentExists = uiFragmentExists;