Procházet zdrojové kódy

Display selected cells of other users

Joeri Exelmans před 3 roky
rodič
revize
10bc6faced

+ 113 - 0
lib/versioning/DragHandler.js

@@ -0,0 +1,113 @@
+class DragHandler {
+
+  constructor(controller) {
+    this.controller = controller;
+    this.online = false;
+  }
+
+  setOnline(online) {
+    this.online = online;
+  }
+
+  install(graph) {
+    const oldStart = graph.graphHandler.start;
+    const oldUpdate = graph.graphHandler.updateLivePreview;
+    const oldReset = graph.graphHandler.reset;
+    const oldMoveCells = graph.graphHandler.moveCells;
+    const self = this;
+
+    graph.graphHandler.start = function(cell, x, y) {
+      // start dragging (locally)
+      oldStart.apply(this, arguments);
+
+      console.log("drag start");
+
+      if (self.online) {
+        const cells = graph.graphHandler.getCells(cell);
+        const cellIds = cells.map(c => c.id);
+
+        let acquiredCallback, denyCallback;
+        const acquiredPromise = new Promise((resolve, reject) => {
+          acquiredCallback = resolve;
+          denyCallback = reject;
+        }).then(
+          () => {console.log("acquired")},
+          e => {console.log("denied"); throw e},
+        );
+
+        console.log("request lock");
+        self.controller.addInput("request_lock", "in", [cellIds, acquiredCallback, denyCallback], self.controller.wallclockToSimtime());
+
+        let resolveDragEnd;
+        const dragPromise = new Promise(resolve => {
+          resolveDragEnd = resolve;
+        });
+
+        const resetOnce = {
+          reset: () => {
+            graph.graphHandler.reset();
+          },
+        };
+
+        // let's pretend we acquire the lock
+
+        let moveCellsArgs;
+        // In the graph handler, moveCells is called when the mouse is released (end of drag).
+        graph.graphHandler.moveCells = function(cells, dx, dy, clone, target, event) {
+          moveCellsArgs = [cells, dx, dy, clone, target, event];
+        };
+
+        // When drag has completed AND a lock was acquired, release the lock.
+        Promise.all([dragPromise, acquiredPromise])
+        .then(() => {
+          if (moveCellsArgs !== undefined) {
+            oldMoveCells.apply(graph.graphHandler, moveCellsArgs);
+          }
+          console.log("release lock");
+          self.controller.addInput("release_lock", "in", [cellIds], self.controller.wallclockToSimtime());
+        })
+        .catch(() => {
+          console.log("could not acquire lock");
+          resetOnce.reset();
+        });
+
+        let dX = 0;
+        let dY = 0;
+        graph.graphHandler.updateLivePreview = function(dx, dy) {
+          oldUpdate.apply(this, arguments);
+          dX = dx;
+          dY = dy;
+          const msg = {
+            type: "update_drag",
+            cells,
+            dx, dy,
+          };
+        };
+
+        graph.graphHandler.reset = function() {
+          console.log("drag end");
+          oldReset.apply(this, arguments);
+          resetOnce.reset = () => {};
+          resolveDragEnd();
+          console.log(dX, dY);
+        };
+      }
+      else {
+        // offline
+        graph.graphHandler.updateLivePreview = oldUpdate;
+        graph.graphHandler.moveCells = oldMoveCells;
+        graph.graphHandler.reset = oldReset;
+      }
+
+
+    };
+
+    // document.addEventListener('keydown', e => {
+    //   if (e.code === 'ShiftLeft') {
+    //     graph.graphHandler.reset();
+    //   }
+    // });
+  }
+};
+
+module.exports = { DragHandler };

+ 48 - 0
lib/versioning/GhostOverlays.js

@@ -0,0 +1,48 @@
+const { UserColors } = require("./UserColors.js");
+
+class GhostOverlays {
+  constructor(graph, userColors) {
+    // this.userNames = userNames;
+    this.userColors = userColors;
+    this.canvas = graph.view.canvas;
+    this.svg = this.canvas.parentElement;
+
+    this.map = new Map();
+  }
+
+  // Update or create ghost cursor. Idempotent.
+  put(userId, name, x, y) {
+    let state = this.map.get(userId);
+    if (state === undefined) {
+      const g = document.createElementNS("http://www.w3.org/2000/svg", 'g');
+      const image = document.createElementNS("http://www.w3.org/2000/svg", 'image');
+      const text = document.createElementNS("http://www.w3.org/2000/svg", 'text');
+      text.style.fontSize = "10px";
+      text.style.fill = this.userColors.getColor(userId);
+      text.setAttribute('y', 30);
+      const textNode = document.createTextNode("");
+      text.appendChild(textNode);
+      image.setAttribute('href', "/lib/versioning/resources/cursor.svg");
+      image.setAttribute('width', 11.6);
+      image.setAttribute('height', 18.2);
+      g.appendChild(image);
+      g.appendChild(text);
+      this.canvas.appendChild(g);
+      const transform = this.svg.createSVGTransform();
+      g.transform.baseVal.appendItem(transform);
+      state = {transform, textNode, timeout: null};
+      this.map.set(userId, state);
+    }
+    state.transform.setTranslate(
+      (x + graph.view.translate.x) * graph.view.scale,
+      (y + graph.view.translate.y) * graph.view.scale,
+    );
+    state.textNode.data = name;
+    if (state.timeout) {
+      clearTimeout(state.timeout);
+    }
+    state.timeout = setTimeout(() => state.transform.setTranslate(-50, -50), 5000);
+  }
+}
+
+module.exports = { GhostOverlays };

+ 56 - 0
lib/versioning/SelectionHandler.js

@@ -0,0 +1,56 @@
+class SelectionHandler {
+  constructor(getUserId, userColors) {
+    this.userColors = userColors;
+    this.getUserId = getUserId;
+    this.map = new Map();
+  }
+
+  install(graph, controller) {
+    graph.selectionModel.addListener(mxEvent.CHANGE, (source, eventObj) => {
+      if (listenerEnabled) {
+        const {added, removed} = eventObj.properties;
+        controller.addInput(
+          "broadcast",
+          // "selection_change",
+          "in",
+          [{
+            userId: this.getUserId(),
+            addedIds: removed ? removed.map(cell => cell.id) : [],
+            removedIds: added ? added.map(cell => cell.id) : [],
+          }],
+          controller.wallclockToSimtime(),
+        );
+      }
+    });
+
+    controller.addMyOwnOutputListener({
+      'add': event => {
+        if (event.name === "selection_change") {
+          const [{userId, addedIds, removedIds}] = event.parameters;
+          const color = this.userColors.getColor(userId);
+
+          for (const cellId of addedIds) {
+            const cell = graph.model.cells[cellId];
+            const highlight = new mxCellHighlight(graph, color,
+              6); // width
+            highlight.highlight(graph.view.getState(cell));
+            this.map.set(cellId, highlight);
+          }
+
+          for (const cellId of removedIds) {
+            const cell = graph.model.cells[cellId];
+            const highlight = this.map.get(cellId);
+            highlight.destroy();
+            this.map.delete(cellId);
+          }
+        }
+      }
+    })
+  }
+
+  clearAll() {
+    this.map.forEach(highlight => highlight.destroy());
+    this.map.clear();
+  }
+
+}

+ 19 - 0
lib/versioning/UserColors.js

@@ -0,0 +1,19 @@
+class UserColors {
+  constructor() {
+    this.colors = ["#F6511D", "#FFB400", "#00A6ED", "#7FB800", "#0D2C54"];
+    this.i = 0;
+
+    this.map = new Map();
+  }
+
+  getColor(userId) {
+    let color = this.map.get(userId);
+    if (color === undefined) {
+      color = this.colors[this.i++];
+      this.map.set(userId, color);
+    }
+    return color;
+  }
+}
+
+module.exports = { UserColors };

+ 105 - 59
lib/versioning/client.js

@@ -1,5 +1,5 @@
 /*
-Date: Mon Sep 20 13:24:37 2021
+Date: Wed Sep 22 19:32:59 2021
 
 Model author: Joeri Exelmans
 Model name: client
@@ -12,7 +12,7 @@ Example of a browser-based WebSocket client with heartbeats
 let client = {};
 (function() {
 
-let Main = function(controller, uiState) {
+let Main = function(controller, uiState, myId, getMyName) {
     RuntimeClassBase.call(this, controller);
     
     this.inports["ack"] = controller.addInputPort("ack", this);
@@ -28,7 +28,7 @@ let Main = function(controller, uiState) {
     this.buildStatechartStructure();
     
     // call user defined constructor
-    Main.prototype.userDefinedConstructor.call(this, uiState);
+    Main.prototype.userDefinedConstructor.call(this, uiState, myId, getMyName);
 };
 Main.prototype = new Object();
 (function() {
@@ -38,9 +38,11 @@ Main.prototype = new Object();
     }
 })();
 
-Main.prototype.userDefinedConstructor = function(uiState) {
-    console.log("Main constructor");
+Main.prototype.userDefinedConstructor = function(uiState, myId, getMyName) {
     this.uiState = uiState;
+    this.myId = myId;
+    this.getMyName = getMyName;
+    
     this.connected = false;
 };
 
@@ -110,7 +112,6 @@ Main.prototype.buildStatechartStructure = function() {
     // state /p/mode/can_leave/session_set/joined/region_sendreceive/default
     this.states["/p/mode/can_leave/session_set/joined/region_sendreceive/default"] = new State(15, "/p/mode/can_leave/session_set/joined/region_sendreceive/default", this);
     this.states["/p/mode/can_leave/session_set/joined/region_sendreceive/default"].setEnter(this.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefaultEnter);
-    this.states["/p/mode/can_leave/session_set/joined/region_sendreceive/default"].setExit(this.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefaultExit);
     
     // state /p/mode/can_leave/session_set/joined/region_cursor
     this.states["/p/mode/can_leave/session_set/joined/region_cursor"] = new State(16, "/p/mode/can_leave/session_set/joined/region_cursor", this);
@@ -308,20 +309,32 @@ Main.prototype.buildStatechartStructure = function() {
     this.states["/p/mode/can_leave/session_set/joined/region_sendreceive/default"].addTransition(PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_1);
     var PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_2 = new Transition(this, this.states["/p/mode/can_leave/session_set/joined/region_sendreceive/default"], [this.states["/p/mode/can_leave/session_set/joined/region_sendreceive/default"]]);
     PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_2.setAction(this.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_2Exec);
-    PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_2.setTrigger(new Event("received", null));
-    PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_2.setGuard(this.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_2Guard);
+    PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_2.setTrigger(new Event("request_lock", this.getInPortName("in")));
     this.states["/p/mode/can_leave/session_set/joined/region_sendreceive/default"].addTransition(PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_2);
+    var PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_3 = new Transition(this, this.states["/p/mode/can_leave/session_set/joined/region_sendreceive/default"], [this.states["/p/mode/can_leave/session_set/joined/region_sendreceive/default"]]);
+    PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_3.setAction(this.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_3Exec);
+    PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_3.setTrigger(new Event("release_lock", this.getInPortName("in")));
+    this.states["/p/mode/can_leave/session_set/joined/region_sendreceive/default"].addTransition(PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_3);
+    var PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_4 = new Transition(this, this.states["/p/mode/can_leave/session_set/joined/region_sendreceive/default"], [this.states["/p/mode/can_leave/session_set/joined/region_sendreceive/default"]]);
+    PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_4.setAction(this.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_4Exec);
+    PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_4.setTrigger(new Event("received", null));
+    PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_4.setGuard(this.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_4Guard);
+    this.states["/p/mode/can_leave/session_set/joined/region_sendreceive/default"].addTransition(PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_4);
+    var PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_5 = new Transition(this, this.states["/p/mode/can_leave/session_set/joined/region_sendreceive/default"], [this.states["/p/mode/can_leave/session_set/joined/region_sendreceive/default"]]);
+    PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_5.setAction(this.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_5Exec);
+    PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_5.setTrigger(new Event("broadcast_selection", null));
+    this.states["/p/mode/can_leave/session_set/joined/region_sendreceive/default"].addTransition(PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_5);
     
     // transition /p/mode/can_leave/session_set/joined/region_cursor/nodelay
     var PModeCanLeaveSessionSetJoinedRegionCursorNodelay_0 = new Transition(this, this.states["/p/mode/can_leave/session_set/joined/region_cursor/nodelay"], [this.states["/p/mode/can_leave/session_set/joined/region_cursor/delay"]]);
     PModeCanLeaveSessionSetJoinedRegionCursorNodelay_0.setAction(this.PModeCanLeaveSessionSetJoinedRegionCursorNodelay_0Exec);
-    PModeCanLeaveSessionSetJoinedRegionCursorNodelay_0.setTrigger(new Event("update_cursor", this.getInPortName("in")));
+    PModeCanLeaveSessionSetJoinedRegionCursorNodelay_0.setTrigger(new Event("broadcast_cursor", this.getInPortName("in")));
     this.states["/p/mode/can_leave/session_set/joined/region_cursor/nodelay"].addTransition(PModeCanLeaveSessionSetJoinedRegionCursorNodelay_0);
     
     // transition /p/mode/can_leave/session_set/joined/region_cursor/delay/inner
     var PModeCanLeaveSessionSetJoinedRegionCursorDelayInner_0 = new Transition(this, this.states["/p/mode/can_leave/session_set/joined/region_cursor/delay/inner"], [this.states["/p/mode/can_leave/session_set/joined/region_cursor/delay/inner"]]);
     PModeCanLeaveSessionSetJoinedRegionCursorDelayInner_0.setAction(this.PModeCanLeaveSessionSetJoinedRegionCursorDelayInner_0Exec);
-    PModeCanLeaveSessionSetJoinedRegionCursorDelayInner_0.setTrigger(new Event("update_cursor", this.getInPortName("in")));
+    PModeCanLeaveSessionSetJoinedRegionCursorDelayInner_0.setTrigger(new Event("broadcast_cursor", this.getInPortName("in")));
     this.states["/p/mode/can_leave/session_set/joined/region_cursor/delay/inner"].addTransition(PModeCanLeaveSessionSetJoinedRegionCursorDelayInner_0);
     
     // transition /p/mode/can_leave/session_set/rejoin_when_online
@@ -361,7 +374,7 @@ Main.prototype.buildStatechartStructure = function() {
     this.states["/p/socket_region/connecting_or_connected/connected/connection_monitor_region/all_good/send_pings/waiting"].addTransition(PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegionAllGoodSendPingsWaiting_0);
     var PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegionAllGoodSendPingsWaiting_1 = new Transition(this, this.states["/p/socket_region/connecting_or_connected/connected/connection_monitor_region/all_good/send_pings/waiting"], [this.states["/p/socket_region/connecting_or_connected/connected/connection_monitor_region/all_good/send_pings/waiting"]]);
     PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegionAllGoodSendPingsWaiting_1.setAction(this.PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegionAllGoodSendPingsWaiting_1Exec);
-    PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegionAllGoodSendPingsWaiting_1.setTrigger(new Event("event2After"));
+    PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegionAllGoodSendPingsWaiting_1.setTrigger(new Event("event1After"));
     this.states["/p/socket_region/connecting_or_connected/connected/connection_monitor_region/all_good/send_pings/waiting"].addTransition(PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegionAllGoodSendPingsWaiting_1);
     
     // transition /p/socket_region/connecting_or_connected/connected/connection_monitor_region/all_good/receive_pongs/waiting
@@ -370,7 +383,7 @@ Main.prototype.buildStatechartStructure = function() {
     this.states["/p/socket_region/connecting_or_connected/connected/connection_monitor_region/all_good/receive_pongs/waiting"].addTransition(PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegionAllGoodReceivePongsWaiting_0);
     var PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegionAllGoodReceivePongsWaiting_1 = new Transition(this, this.states["/p/socket_region/connecting_or_connected/connected/connection_monitor_region/all_good/receive_pongs/waiting"], [this.states["/p/socket_region/connecting_or_connected/connected/connection_monitor_region/all_good/receive_pongs/waiting"]]);
     PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegionAllGoodReceivePongsWaiting_1.setAction(this.PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegionAllGoodReceivePongsWaiting_1Exec);
-    PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegionAllGoodReceivePongsWaiting_1.setTrigger(new Event("event3After"));
+    PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegionAllGoodReceivePongsWaiting_1.setTrigger(new Event("event2After"));
     this.states["/p/socket_region/connecting_or_connected/connected/connection_monitor_region/all_good/receive_pongs/waiting"].addTransition(PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegionAllGoodReceivePongsWaiting_1);
     
     // transition /p/socket_region/connecting_or_connected/connected/connection_monitor_region/timeout
@@ -381,7 +394,7 @@ Main.prototype.buildStatechartStructure = function() {
     
     // transition /p/socket_region/connecting_or_connected/wait_reconnect
     var PSocketRegionConnectingOrConnectedWaitReconnect_0 = new Transition(this, this.states["/p/socket_region/connecting_or_connected/wait_reconnect"], [this.states["/p/socket_region/connecting_or_connected/connecting"]]);
-    PSocketRegionConnectingOrConnectedWaitReconnect_0.setTrigger(new Event("event4After"));
+    PSocketRegionConnectingOrConnectedWaitReconnect_0.setTrigger(new Event("event3After"));
     this.states["/p/socket_region/connecting_or_connected/wait_reconnect"].addTransition(PSocketRegionConnectingOrConnectedWaitReconnect_0);
     
     // transition /p/mode/can_leave
@@ -403,12 +416,7 @@ Main.prototype.buildStatechartStructure = function() {
     var PModeCanLeaveSessionSetJoinedRegionCursorDelay_0 = new Transition(this, this.states["/p/mode/can_leave/session_set/joined/region_cursor/delay"], [this.states["/p/mode/can_leave/session_set/joined/region_cursor/nodelay"]]);
     PModeCanLeaveSessionSetJoinedRegionCursorDelay_0.setAction(this.PModeCanLeaveSessionSetJoinedRegionCursorDelay_0Exec);
     PModeCanLeaveSessionSetJoinedRegionCursorDelay_0.setTrigger(new Event("event0After"));
-    PModeCanLeaveSessionSetJoinedRegionCursorDelay_0.setGuard(this.PModeCanLeaveSessionSetJoinedRegionCursorDelay_0Guard);
     this.states["/p/mode/can_leave/session_set/joined/region_cursor/delay"].addTransition(PModeCanLeaveSessionSetJoinedRegionCursorDelay_0);
-    var PModeCanLeaveSessionSetJoinedRegionCursorDelay_1 = new Transition(this, this.states["/p/mode/can_leave/session_set/joined/region_cursor/delay"], [this.states["/p/mode/can_leave/session_set/joined/region_cursor/nodelay"]]);
-    PModeCanLeaveSessionSetJoinedRegionCursorDelay_1.setTrigger(new Event("event1After"));
-    PModeCanLeaveSessionSetJoinedRegionCursorDelay_1.setGuard(this.PModeCanLeaveSessionSetJoinedRegionCursorDelay_1Guard);
-    this.states["/p/mode/can_leave/session_set/joined/region_cursor/delay"].addTransition(PModeCanLeaveSessionSetJoinedRegionCursorDelay_1);
     
     // transition /p/socket_region/connecting_or_connected
     var PSocketRegionConnectingOrConnected_0 = new Transition(this, this.states["/p/socket_region/connecting_or_connected"], [this.states["/p/socket_region/disconnected"]]);
@@ -437,8 +445,10 @@ Main.prototype.PResponseHandlerEnter = function() {
         reqId,
         ...obj,
       };
+      if (handler !== undefined) {
+        this.reqHandlers.set(reqId, handler);
+      }
       console.log("Sending req", req);
-      this.reqHandlers.set(reqId, handler);
       this.addEvent(new Event("send", null, [req]));
     
       // allow to cancel the request handler
@@ -464,14 +474,12 @@ Main.prototype.PModeCanLeaveSessionSetExit = function() {
 };
 
 Main.prototype.PModeCanLeaveSessionSetJoinedRegionCursorDelayEnter = function() {
-    this.cursorMsg = null; 
+    this.cursorState = null; 
     this.addTimer(0, 0.1);
-    this.addTimer(1, 0.1);
 };
 
 Main.prototype.PModeCanLeaveSessionSetJoinedRegionCursorDelayExit = function() {
     this.removeTimer(0);
-    this.removeTimer(1);
 };
 
 Main.prototype.PSocketRegionConnectingOrConnectedConnectedEnter = function() {
@@ -527,11 +535,6 @@ Main.prototype.PModeCanLeaveSessionSetWaitingForJoinAckExit = function() {
 
 Main.prototype.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefaultEnter = function() {
     this.uiState.setOnline();
-    this.bigStep.outputEvent(new Event("joined", this.getOutPortName("out"), []));
-};
-
-Main.prototype.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefaultExit = function() {
-    this.bigStep.outputEvent(new Event("left", this.getOutPortName("out"), []));
 };
 
 Main.prototype.PModeCanLeaveSessionSetRejoinWhenOnlineEnter = function() {
@@ -539,12 +542,14 @@ Main.prototype.PModeCanLeaveSessionSetRejoinWhenOnlineEnter = function() {
 };
 
 Main.prototype.PSocketRegionConnectingOrConnectedConnectingEnter = function() {
+    console.log("connecting...");
+    
     this.socket = new WebSocket(this.addr);
     
     // Translate socket events to statechart events:
     
     this.socket.onopen = event => {
-      this.controller.addInput("open", this.inports["socket"], []);
+      this.controller.addInput("open", this.inports["socket"], [], this.controller.wallclockToSimtime());
     };
     
     this.socket.onmessage = event => {
@@ -556,42 +561,42 @@ Main.prototype.PSocketRegionConnectingOrConnectedConnectingEnter = function() {
         return;
       }
     
-      this.controller.addInput("message", this.inports["socket"], [parsed]);
+      this.controller.addInput("message", this.inports["socket"], [parsed], this.controller.wallclockToSimtime());
     };
     
     this.socket.onerror = event => {
       // From what I see, this event only happens when the socket could not connect.
       // An 'error' happening after the connection is established, will be indicated by a close event.
-      this.controller.addInput("error", this.inports["socket"], []);
+      this.controller.addInput("error", this.inports["socket"], [], this.controller.wallclockToSimtime());
     };
     
     this.socket.onclose = event => {
-      this.controller.addInput("close", this.inports["socket"], []);
+      this.controller.addInput("close", this.inports["socket"], [], this.controller.wallclockToSimtime());
     };
 };
 
 Main.prototype.PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegionAllGoodSendPingsWaitingEnter = function() {
-    this.addTimer(2, 500);
+    this.addTimer(1, 500);
 };
 
 Main.prototype.PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegionAllGoodSendPingsWaitingExit = function() {
-    this.removeTimer(2);
+    this.removeTimer(1);
 };
 
 Main.prototype.PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegionAllGoodReceivePongsWaitingEnter = function() {
-    this.addTimer(3, 7000);
+    this.addTimer(2, 7000);
 };
 
 Main.prototype.PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegionAllGoodReceivePongsWaitingExit = function() {
-    this.removeTimer(3);
+    this.removeTimer(2);
 };
 
 Main.prototype.PSocketRegionConnectingOrConnectedWaitReconnectEnter = function() {
-    this.addTimer(4, 1);
+    this.addTimer(3, 1);
 };
 
 Main.prototype.PSocketRegionConnectingOrConnectedWaitReconnectExit = function() {
-    this.removeTimer(4);
+    this.removeTimer(3);
 };
 
 Main.prototype.PModeCanLeave_0Guard = function(parameters) {
@@ -599,19 +604,21 @@ Main.prototype.PModeCanLeave_0Guard = function(parameters) {
 };
 
 Main.prototype.PModeCanLeave_1Guard = function(parameters) {
-    return this.disconnected;
+    return !this.connected;
 };
 
 Main.prototype.PModeCanLeaveSessionSetJoinedRegionCursorDelay_0Exec = function(parameters) {
-    this.raiseInternalEvent(new Event("send", null, [this.cursorMsg]));
-};
-
-Main.prototype.PModeCanLeaveSessionSetJoinedRegionCursorDelay_0Guard = function(parameters) {
-    return this.cursorMsg !== null;
-};
-
-Main.prototype.PModeCanLeaveSessionSetJoinedRegionCursorDelay_1Guard = function(parameters) {
-    return this.cursorMsg === null;
+    if (this.cursorState !== null) {
+      this.addEvent(new Event("send", null, [{
+        type:"broadcast",
+        msg: {
+          type:"update_cursor",
+          userId: this.myId,
+          name: this.getMyName(),
+          ...this.cursorState,
+        },
+      }]));
+    }
 };
 
 Main.prototype.PSocketRegionConnectingOrConnected_0Exec = function(parameters) {
@@ -624,7 +631,7 @@ Main.prototype.PSocketRegionConnectingOrConnectedConnectedConnectionMonitorRegio
 
 Main.prototype.PResponseHandlerDefault_0Exec = function(parameters) {
     var parsed = parameters[0];
-    if (parsed.type === 'ack') {
+    if (parsed.type === 'ack' && parsed.reqId !== undefined) {
       const handler = this.reqHandlers.get(parsed.reqId);
       if (handler) {
         handler(parsed);
@@ -680,7 +687,6 @@ Main.prototype.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_0Guard = fu
 
 Main.prototype.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_1Exec = function(parameters) {
     var serialized = parameters[0];
-    // console.log("sending sessionId", this.sessionId);
     this.sendReq(
       {type:"new_edit", sessionId: this.sessionId, op: serialized},
       res => {
@@ -690,23 +696,63 @@ Main.prototype.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_1Exec = fun
 };
 
 Main.prototype.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_2Exec = function(parameters) {
+    var cellIds = parameters[0];
+    var acquiredCallback = parameters[1];
+    var deniedCallback = parameters[2];
+    this.sendReq(
+      {type:"request_lock", sessionId: this.sessionId, cellIds},
+      res => {
+        if (res.success) {
+          acquiredCallback();
+        } else {
+          deniedCallback();
+        }
+      });
+};
+
+Main.prototype.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_3Exec = function(parameters) {
+    var cellIds = parameters[0];
+    this.sendReq(
+      {type:"release_lock", sessionId: this.sessionId, cellIds},
+      res => {},
+    );
+};
+
+Main.prototype.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_4Exec = function(parameters) {
     var parsed = parameters[0];
-    this.bigStep.outputEvent(new Event("update_cursor", this.getOutPortName("out"), [parsed]));
+    this.bigStep.outputEvent(new Event("broadcast", this.getOutPortName("out"), [parsed.sessionId, parsed.msg]));
 };
 
-Main.prototype.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_2Guard = function(parameters) {
+Main.prototype.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_4Guard = function(parameters) {
     var parsed = parameters[0];
-    return parsed.type === 'update_cursor';
+    return parsed.type === 'broadcast';
+};
+
+Main.prototype.PModeCanLeaveSessionSetJoinedRegionSendreceiveDefault_5Exec = function(parameters) {
+    var selectedIds = parameters[0];
+    if (this.cursorState !== null) {
+      this.addEvent(new Event("send", null, [{
+        type:"broadcast",
+        msg: {
+          type:"update_selection",
+          userId: this.myId,
+          selectedIds,
+          ...this.cursorState,
+        },
+      }]));
+    }
 };
 
 Main.prototype.PModeCanLeaveSessionSetJoinedRegionCursorNodelay_0Exec = function(parameters) {
-    var msg = parameters[0];
-    this.raiseInternalEvent(new Event("send", null, [msg]));
+    var x = parameters[0];
+    var y = parameters[1];
+    this.raiseInternalEvent(new Event("send", null, [{type:"broadcast", msg: {type:"update_cursor", name: this.getMyName(), x, y, userId: this.myId}}]));
 };
 
 Main.prototype.PModeCanLeaveSessionSetJoinedRegionCursorDelayInner_0Exec = function(parameters) {
-    var msg = parameters[0];
-    this.cursorMsg = msg;
+    var x = parameters[0];
+    var y = parameters[1];
+    this.cursorState = {x,y};
 };
 
 Main.prototype.PSocketRegionDisconnected_0Exec = function(parameters) {
@@ -768,7 +814,7 @@ ObjectManager.prototype = new Object();
 
 ObjectManager.prototype.instantiate = function(className, constructParams) {
     if (className === "Main") {
-        var instance = new Main(this.controller, constructParams[0]);
+        var instance = new Main(this.controller, constructParams[0], constructParams[1], constructParams[2]);
         instance.associations = {};
         instance.associations["socket"] = new Association("Socket", 1, 1);
     } else  {
@@ -780,13 +826,13 @@ ObjectManager.prototype.instantiate = function(className, constructParams) {
 // Add symbol 'ObjectManager' to package 'client'
 client.ObjectManager = ObjectManager;
 
-let Controller = function(uiState, eventLoopCallbacks, finishedCallback, behindScheduleCallback) {
+let Controller = function(uiState, myId, getMyName, eventLoopCallbacks, finishedCallback, behindScheduleCallback) {
     if (finishedCallback === undefined) finishedCallback = null;
     if (behindScheduleCallback === undefined) behindScheduleCallback = null;
     EventLoopControllerBase.call(this, new ObjectManager(this), eventLoopCallbacks, finishedCallback, behindScheduleCallback);
     this.addInputPort("in");
     this.addOutputPort("out");
-    this.objectManager.createInstance("Main", [uiState]);
+    this.objectManager.createInstance("Main", [uiState, myId, getMyName]);
 };
 Controller.prototype = new Object();
 (function() {

+ 85 - 28
lib/versioning/client.xml

@@ -12,9 +12,14 @@
     <inport name="socket"/>
     <constructor>
       <parameter name="uiState"/>
+      <parameter name="myId"/>
+      <parameter name="getMyName"/>
+
       <body>
-        console.log("Main constructor");
         this.uiState = uiState;
+        this.myId = myId;
+        this.getMyName = getMyName;
+
         this.connected = false;
       </body>
     </constructor>
@@ -34,8 +39,10 @@
                   reqId,
                   ...obj,
                 };
+                if (handler !== undefined) {
+                  this.reqHandlers.set(reqId, handler);
+                }
                 console.log("Sending req", req);
-                this.reqHandlers.set(reqId, handler);
                 this.addEvent(new Event("send", null, [req]));
 
                 // allow to cancel the request handler
@@ -49,7 +56,7 @@
             <transition event="received" target=".">
               <parameter name="parsed"/>
               <script>
-                if (parsed.type === 'ack') {
+                if (parsed.type === 'ack' &amp;&amp; parsed.reqId !== undefined) {
                   const handler = this.reqHandlers.get(parsed.reqId);
                   if (handler) {
                     handler(parsed);
@@ -109,7 +116,7 @@
           <state id="can_leave" initial="session_set">
 
             <transition event="leave" cond='this.connected' port="in" target="../async_connected"/>
-            <transition event="leave" cond='this.disconnected' port="in" target="../async_disconnected"/>
+            <transition event="leave" cond='!this.connected' port="in" target="../async_disconnected"/>
 
             <onexit><script>
               this.sendReq({type:"leave"});
@@ -188,11 +195,7 @@
                         <script>
                           this.uiState.setOnline();
                         </script>
-                        <raise event="joined" port="out" scope="output"/>
                       </onentry>
-                      <onexit>
-                        <raise event="left" port="out" scope="output"/>
-                      </onexit>
                       <transition event="received" cond="parsed.type === 'pub_edit'" target=".">
                         <parameter name="parsed"/>
                         <script>
@@ -207,7 +210,6 @@
                       <transition event="new_edit" port="in" target=".">
                         <parameter name="serialized"/>
                         <script>
-                          // console.log("sending sessionId", this.sessionId);
                           this.sendReq(
                             {type:"new_edit", sessionId: this.sessionId, op: serialized},
                             res => {
@@ -216,39 +218,92 @@
                           // this.unacknowledged.set(serialized.id, serialized);
                         </script>
                       </transition>
-                      <transition event="received" cond="parsed.type === 'update_cursor'" target=".">
+                      <transition event="request_lock" port="in" target=".">
+                        <parameter name="cellIds"/>
+                        <parameter name="acquiredCallback"/>
+                        <parameter name="deniedCallback"/>
+                        <script>
+                          this.sendReq(
+                            {type:"request_lock", sessionId: this.sessionId, cellIds},
+                            res => {
+                              if (res.success) {
+                                acquiredCallback();
+                              } else {
+                                deniedCallback();
+                              }
+                            });
+                        </script>
+                      </transition>
+                      <transition event="release_lock" port="in" target=".">
+                        <parameter name="cellIds"/>
+                        <script>
+                          this.sendReq(
+                            {type:"release_lock", sessionId: this.sessionId, cellIds},
+                            res => {},
+                          );
+                        </script>
+                      </transition>
+                      <transition event="received" cond="parsed.type === 'broadcast'" target=".">
                         <parameter name="parsed"/>
-                        <raise event="update_cursor" port="out" scope="output">
-                          <parameter expr="parsed"/>
+                        <raise event="broadcast" port="out" scope="output">
+                          <parameter expr="parsed.sessionId"/>
+                          <parameter expr="parsed.msg"/>
                         </raise>
                       </transition>
+                      <transition event="broadcast_selection" target=".">
+                        <parameter name="selectedIds"/>
+                        <script>
+                          if (this.cursorState !== null) {
+                            this.addEvent(new Event("send", null, [{
+                              type:"broadcast",
+                              msg: {
+                                type:"update_selection",
+                                userId: this.myId,
+                                selectedIds,
+                                ...this.cursorState,
+                              },
+                            }]));
+                          }
+                        </script>
+                      </transition>
                     </state><!-- default -->
                   </state><!-- region_sendreceive -->
                   <state id="region_cursor" initial="nodelay">
                     <state id="nodelay">
-                      <transition event="update_cursor" port="in" target="../delay">
-                        <parameter name="msg"/>
+                      <transition event="broadcast_cursor" port="in" target="../delay">
+                        <parameter name="x"/>
+                        <parameter name="y"/>
                         <raise event="send">
-                          <parameter expr="msg"/>
+                          <parameter expr='{type:"broadcast", msg: {type:"update_cursor", name: this.getMyName(), x, y, userId: this.myId}}'/>
                         </raise>
                       </transition>
                     </state><!-- nodelay -->
                     <state id="delay">
-                      <onentry><script> this.cursorMsg = null; </script></onentry>
+                      <onentry><script> this.cursorState = null; </script></onentry>
                       <state id="inner">
-                        <transition event="update_cursor" port="in" target=".">
-                          <parameter name="msg"/>
+                        <transition event="broadcast_cursor" port="in" target=".">
+                          <parameter name="x"/>
+                          <parameter name="y"/>
                           <script>
-                            this.cursorMsg = msg;
+                            this.cursorState = {x,y};
                           </script>
                         </transition>
                       </state><!-- inner -->
-                      <transition after="0.1" cond="this.cursorMsg !== null" target="../nodelay">
-                        <raise event="send">
-                          <parameter expr="this.cursorMsg"/>
-                        </raise>
+                      <transition after="0.1" target="../nodelay">
+                        <script>
+                          if (this.cursorState !== null) {
+                            this.addEvent(new Event("send", null, [{
+                              type:"broadcast",
+                              msg: {
+                                type:"update_cursor",
+                                userId: this.myId,
+                                name: this.getMyName(),
+                                ...this.cursorState,
+                              },
+                            }]));
+                          }
+                        </script>
                       </transition>
-                      <transition after="0.1" cond="this.cursorMsg === null" target="../nodelay"/>
                     </state><!-- delay -->
                   </state><!-- region_cursor -->
 
@@ -288,12 +343,14 @@
             <state id="connecting">
               <onentry>
                 <script>
+                  console.log("connecting...");
+
                   this.socket = new WebSocket(this.addr);
 
                   // Translate socket events to statechart events:
 
                   this.socket.onopen = event => {
-                    this.controller.addInput("open", this.inports["socket"], []);
+                    this.controller.addInput("open", this.inports["socket"], [], this.controller.wallclockToSimtime());
                   };
 
                   this.socket.onmessage = event => {
@@ -305,17 +362,17 @@
                       return;
                     }
 
-                    this.controller.addInput("message", this.inports["socket"], [parsed]);
+                    this.controller.addInput("message", this.inports["socket"], [parsed], this.controller.wallclockToSimtime());
                   };
 
                   this.socket.onerror = event => {
                     // From what I see, this event only happens when the socket could not connect.
                     // An 'error' happening after the connection is established, will be indicated by a close event.
-                    this.controller.addInput("error", this.inports["socket"], []);
+                    this.controller.addInput("error", this.inports["socket"], [], this.controller.wallclockToSimtime());
                   };
 
                   this.socket.onclose = event => {
-                    this.controller.addInput("close", this.inports["socket"], []);
+                    this.controller.addInput("close", this.inports["socket"], [], this.controller.wallclockToSimtime());
                   };
                 </script>
               </onentry>

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 0
lib/versioning/draghandler_statechart.drawio


+ 158 - 84
lib/versioning/run_server.js

@@ -89,47 +89,84 @@ async function startServer() {
     }
   }
 
+  // non-volatile state
   const opsDB = new QueuedIO(opsDir);
   const sessionDB = new QueuedIO(sessionDir);
 
-  // mapping of modelId to set of sockets
-  const subscriptions = new Map();
+  class Session {
+    constructor(id) {
+      this.id = id;
+      this.users = new Set();
+    }
 
-  wsServer.on('connection', function(socket) {
-    console.log("Client connected.")
-    const mySubscriptions = new Set();
+    join(socket) {
+      this.users.add(socket);
+    }
 
-    function join(sessionId) {
-      const subbedSockets = subscriptions.get(sessionId);
-      if (subbedSockets) {
-        subbedSockets.add(socket);
-      } else {
-        subscriptions.set(sessionId, new Set([socket]));
-      }
-      mySubscriptions.add(sessionId);
+    leave(socket) {
+      this.users.delete(socket);
     }
-    function leave(sessionId) {
-      const subbedSockets = subscriptions.get(sessionId);
-      if (subbedSockets) {
-        subbedSockets.delete(socket);
+
+    broadcast(json, skip) {
+      if (json.type !== "update_cursor") {
+        console.log("BROADCAST to", this.users.size-1, "subscribers...", json);
       }
-      mySubscriptions.delete(sessionId);      
+      const stringified = JSON.stringify({type: "broadcast", sessionId: this.sessionId, msg: json});
+      this.users.forEach((_, socket) => {
+        if (socket !== skip) { // don't echo
+          try {
+            socket.send(stringified); // forward incoming request to all
+          } catch (e) {
+            console.log("Error:", e, "Closing socket...")
+            socket.close(); // in case of an error, it is the client's responsibility to re-subscribe and 'catch up'.
+          }
+        }
+      });
     }
-    function leaveAll() {
-      mySubscriptions.forEach(leave);
+  }
+
+  // mapping of sessionId to Session
+  const sessions = new Map();
+
+  function getSession(id) {
+    let session = sessions.get(id);
+    if (session === undefined) {
+      session = new Session(id);
+      sessions.set(id, session);
     }
+    return session;
+  }
 
-    async function handle(req) {
+  const lockedCellsGlobal = new Set();
+
+  wsServer.on('connection', (socket, req) => {
+    const reqUrl = new URL(req.url, req.headers.origin);
+    const myId = reqUrl.searchParams.get('me');
+    console.log("Client connected.", myId);
+
+    socket.sendJSON = function(json) {
+      if (json.type !== "pong") {
+        console.log("<-", json);
+      }
+      this.send(JSON.stringify(json));
+    }
+
+    function commonHandler(req) {
       if (req.type === "ping") {
-        return {type: "pong"};
+        socket.sendJSON({type: "pong"});
+        return true;
       }
-      else {
-        console.log("-> ", req);
+    }
 
-        if (req.type === "join") {
-          const { reqId, sessionId } = req;
-          leaveAll(); // a client can only join 1 session at a time
-          join(sessionId);
+    class LeftHandler {
+      async handle(req) {
+        console.log("LeftHandler", req.type);
+        if (commonHandler(req)) {
+          console.log("PING");
+          return this;
+        }
+        else if (req.type === "join") {
+          const { reqId, sessionId, userId } = req;
           // Reply with all operations in session
           const session = await sessionDB.read(sessionId).catch(e => {
             if (e.code === 'ENOENT') {
@@ -144,85 +181,122 @@ async function startServer() {
             opIds[i] = session.slice(offset, offset + IDLENGTH);
           }
           const ops = await Promise.all(opIds.map(async id => ({id, detail: await opsDB.readJSON(id)})));
-          return {type: "ack", reqId, ops};
-        }
+          socket.sendJSON({type: "ack", reqId, ops});
 
-        else if (req.type === "leave") {
-          const { reqId } = req;
-          leaveAll();
-          return {type: "ack", reqId};
+          this.close();
+          return new JoinedHandler(sessionId);
         }
-
         else if (req.type === "new_share") {
+          console.log("HEY");
           const { reqId, ops } = req;
           const sessionId = uuidv4();
-          leaveAll();
-          join(sessionId);
           await Promise.all(ops.map(({id, detail}) => opsDB.writeJSON(id, detail)));
           await sessionDB.write(sessionId, ops.map(op=>op.id).join(''));
-          return {type: "ack", reqId, sessionId};
+          socket.sendJSON({type: "ack", reqId, sessionId});
+
+          this.close();
+          return new JoinedHandler(sessionId);
         }
+        else {
+          console.log("No handler for request:", req.type);
+          return this;
+        }
+      }
 
+      close() {}
+    }
+
+    class JoinedHandler {
+      constructor(sessionId) {
+        this.session = getSession(sessionId);
+        this.lockedCells = new Set();
+        this.session.join(socket);
+      }
+
+      async handle(req) {
+        if (commonHandler(req)) {
+          return this;
+        }
+        else if (req.type === "broadcast") {
+          const { msg } = req;
+          this.session.broadcast(msg, socket);
+        }
+        else if (req.type === "leave") {
+          const { reqId } = req;
+          socket.sendJSON({type: "ack", reqId});
+
+          this.close();
+          return new LeftHandler();
+        }
         else if (req.type === "new_edit") {
-          const { reqId, sessionId, op: {id, detail} } = req;
+          const { reqId, op: {id, detail} } = req;
           await opsDB.writeJSON(id, detail);
-          await sessionDB.append(sessionId, id); // Creates file if it doesn't exist yet
+          await sessionDB.append(this.session.id, id); // Creates file if it doesn't exist yet
           // Best effort broadcast to all subscribers
-          (async () => {
-            const subbedSockets = subscriptions.get(sessionId) || new Set();
-            const broadcastMsg = {type:"pub_edit", sessionId, op: {id, detail}};
-            console.log("BROADCAST to", subbedSockets.size-1,"subscribers...", broadcastMsg);
-            const stringified = JSON.stringify(broadcastMsg);
-            // await asyncSleep(3000);
-            subbedSockets.forEach(subbed => {
-              if (subbed !== socket) { // don't echo
-                try {
-                  subbed.send(stringified); // forward incoming request to all
-                } catch (e) {
-                  console.log("Error:", e, "Closing socket...")
-                  subbed.close(); // in case of an error, it is the client's responsibility to re-subscribe and 'catch up'.
-                }
-              }
-            });
-          })();
-          return {type: "ack", reqId};
+          this.session.broadcast({type: "pub_edit", op: {id, detail}}, socket);
+          // Send ACK
+          socket.sendJSON({type: "ack", reqId});
         }
-        else if (req.type === "update_cursor") {
-          mySubscriptions.forEach(sessionId => {
-            const sockets = subscriptions.get(sessionId) || new Set();
-            const stringified = JSON.stringify(req);
-            sockets.forEach(s => {
-              if (s === socket) {
-                return;
-              }
-              try {
-                s.send(stringified);
-              } catch (e) {
-                console.log("Error:", e, "Closing socket...")
-                s.close(); // in case of an error, it is the client's responsibility to re-subscribe and 'catch up'.
-              }
-            });
-          });
+        else if (req.type === "request_lock") {
+          const { reqId, cellIds } = req;
+          let canLock = true;
+          for (const cellId of cellIds) {
+            if (lockedCellsGlobal.has(cellId)) {
+              canLock = false;
+              break;
+            }
+          }
+          if (canLock) {
+            for (const cellId of cellIds) {
+              lockedCellsGlobal.add(cellId);
+              this.lockedCells.add(cellId);
+            }
+          }
+          socket.sendJSON({ type: "ack", reqId, success: canLock });
+        }
+        else if (req.type === "release_lock") {
+          const { reqId, cellIds } = req;
+          for (const cellId of cellIds) {
+            if (this.lockedCells.has(cellId)) {
+              lockedCellsGlobal.delete(cellId);
+              this.lockedCells.delete(cellId);
+            }
+          }
+        }
+        else {
+          console.log("No handler for request:", req.type);
         }
+        return this;
       }
 
+      close() {
+        this.session.leave(socket);
+        this.session.broadcast({type: "update_selection", userId: myId, selectedIds: []})
+      }
+    }
+
+    let handlerPromise = Promise.resolve(new LeftHandler());
+    function queuedHandler(req) {
+      handlerPromise = handlerPromise.then(async handler => {
+        try {
+          return await handler.handle(req);
+        } catch (e) {
+          console.log("Error handling", req, e);
+          return handler;
+        }
+      });
     }
 
     socket.on('message', function(data) {
       const req = JSON.parse(data);
-      handle(req).then(json => {
-        if (json) {
-          if (json.type !== 'pong')
-            console.log("<- ", json);
-          socket.send(JSON.stringify(json));
-        }
-      }).catch(e => {
-        console.log("Error handling", req, e);
-      })
+      if (req.type !== "ping" && !(req.type === "broadcast" && req.msg.type === "update_cursor")) {
+        console.log("->", req);
+      }
+      queuedHandler(req);
     });
 
     socket.on('close', function() {
-      leaveAll();
+      handlerPromise.then(h => h.close());
       console.log("Client disconnected.");
     })
   });

+ 349 - 230
src/main/webapp/plugins/cdf/versioning.browser.js

@@ -838,55 +838,169 @@ function version(uuid) {
 var _default = version;
 exports.default = _default;
 },{"./validate.js":14}],16:[function(require,module,exports){
+class DragHandler {
 
-class DisabledCells {
-  constructor() {
-    this.disabledCells = new Set();
-  }
-
-  add(cell) {
-    this.disabledCells.add(cell);
+  constructor(controller) {
+    this.controller = controller;
+    this.online = false;
   }
 
-  delete(cell) {
-    this.disabledCells.delete(cell);
+  setOnline(online) {
+    this.online = online;
   }
 
   install(graph) {
+    const oldStart = graph.graphHandler.start;
+    const oldUpdate = graph.graphHandler.updateLivePreview;
+    const oldReset = graph.graphHandler.reset;
+    const oldMoveCells = graph.graphHandler.moveCells;
     const self = this;
-    // part #1: Intercepting mxGraph.fireMouseEvent
-    const oldFireMouseEvent = graph.fireMouseEvent;
-    graph.fireMouseEvent = function(evtName, me, sender) {
-      if (me.state && self.disabledCells.has(me.state.cell.id)) {
-        // clicked shape is disabled
-        return;
-      }
-      oldFireMouseEvent.apply(this, arguments);
-    }
 
-    // part #2: Ignore double clicks on disabled cells
-    const oldDblClick = graph.dblClick;
-    graph.dblClick = function(evt, cell) {
-      if (cell && self.disabledCells.has(cell.id)) {
-        // clicked shape is disabled
-        return;
+    graph.graphHandler.start = function(cell, x, y) {
+      // start dragging (locally)
+      oldStart.apply(this, arguments);
+
+      console.log("drag start");
+
+      if (self.online) {
+        const cells = graph.graphHandler.getCells(cell);
+        const cellIds = cells.map(c => c.id);
+
+        let acquiredCallback, denyCallback;
+        const acquiredPromise = new Promise((resolve, reject) => {
+          acquiredCallback = resolve;
+          denyCallback = reject;
+        }).then(
+          () => {console.log("acquired")},
+          e => {console.log("denied"); throw e},
+        );
+
+        console.log("request lock");
+        self.controller.addInput("request_lock", "in", [cellIds, acquiredCallback, denyCallback], self.controller.wallclockToSimtime());
+
+        let resolveDragEnd;
+        const dragPromise = new Promise(resolve => {
+          resolveDragEnd = resolve;
+        });
+
+        const resetOnce = {
+          reset: () => {
+            graph.graphHandler.reset();
+          },
+        };
+
+        // let's pretend we acquire the lock
+
+        let moveCellsArgs;
+        // In the graph handler, moveCells is called when the mouse is released (end of drag).
+        graph.graphHandler.moveCells = function(cells, dx, dy, clone, target, event) {
+          moveCellsArgs = [cells, dx, dy, clone, target, event];
+        };
+
+        // When drag has completed AND a lock was acquired, release the lock.
+        Promise.all([dragPromise, acquiredPromise])
+        .then(() => {
+          if (moveCellsArgs !== undefined) {
+            oldMoveCells.apply(graph.graphHandler, moveCellsArgs);
+          }
+          console.log("release lock");
+          self.controller.addInput("release_lock", "in", [cellIds], self.controller.wallclockToSimtime());
+        })
+        .catch(() => {
+          console.log("could not acquire lock");
+          resetOnce.reset();
+        });
+
+        let dX = 0;
+        let dY = 0;
+        graph.graphHandler.updateLivePreview = function(dx, dy) {
+          oldUpdate.apply(this, arguments);
+          dX = dx;
+          dY = dy;
+          const msg = {
+            type: "update_drag",
+            cells,
+            dx, dy,
+          };
+        };
+
+        graph.graphHandler.reset = function() {
+          console.log("drag end");
+          oldReset.apply(this, arguments);
+          resetOnce.reset = () => {};
+          resolveDragEnd();
+          console.log(dX, dY);
+        };
       }
-      oldDblClick.apply(this, arguments);
-    }
-    // part #3: Protect disabled cells from ever being selected
-    const oldMxSelectionChange = mxSelectionChange; // override constructor :)
-    mxSelectionChange = function(selectionModel, added, removed) {
-      oldMxSelectionChange.apply(this, arguments);
-      if (this.added) {
-        this.added = this.added.filter(cell => !self.disabledCells.has(cell.id));
+      else {
+        // offline
+        graph.graphHandler.updateLivePreview = oldUpdate;
+        graph.graphHandler.moveCells = oldMoveCells;
+        graph.graphHandler.reset = oldReset;
       }
+
+
+    };
+
+    // document.addEventListener('keydown', e => {
+    //   if (e.code === 'ShiftLeft') {
+    //     graph.graphHandler.reset();
+    //   }
+    // });
+  }
+};
+
+module.exports = { DragHandler };
+},{}],17:[function(require,module,exports){
+const { UserColors } = require("./UserColors.js");
+
+class GhostOverlays {
+  constructor(graph, userColors) {
+    // this.userNames = userNames;
+    this.userColors = userColors;
+    this.canvas = graph.view.canvas;
+    this.svg = this.canvas.parentElement;
+
+    this.map = new Map();
+  }
+
+  // Update or create ghost cursor. Idempotent.
+  put(userId, name, x, y) {
+    let state = this.map.get(userId);
+    if (state === undefined) {
+      const g = document.createElementNS("http://www.w3.org/2000/svg", 'g');
+      const image = document.createElementNS("http://www.w3.org/2000/svg", 'image');
+      const text = document.createElementNS("http://www.w3.org/2000/svg", 'text');
+      text.style.fontSize = "10px";
+      text.style.fill = this.userColors.getColor(userId);
+      text.setAttribute('y', 30);
+      const textNode = document.createTextNode("");
+      text.appendChild(textNode);
+      image.setAttribute('href', "/lib/versioning/resources/cursor.svg");
+      image.setAttribute('width', 11.6);
+      image.setAttribute('height', 18.2);
+      g.appendChild(image);
+      g.appendChild(text);
+      this.canvas.appendChild(g);
+      const transform = this.svg.createSVGTransform();
+      g.transform.baseVal.appendItem(transform);
+      state = {transform, textNode, timeout: null};
+      this.map.set(userId, state);
     }
-    mxSelectionChange.prototype = oldMxSelectionChange.prototype;
+    state.transform.setTranslate(
+      (x + graph.view.translate.x) * graph.view.scale,
+      (y + graph.view.translate.y) * graph.view.scale,
+    );
+    state.textNode.data = name;
+    if (state.timeout) {
+      clearTimeout(state.timeout);
+    }
+    state.timeout = setTimeout(() => state.transform.setTranslate(-50, -50), 5000);
   }
 }
 
-module.exports = { DisabledCells };
-},{}],17:[function(require,module,exports){
+module.exports = { GhostOverlays };
+},{"./UserColors.js":19}],18:[function(require,module,exports){
 "use strict";
 
 const { v4: uuidv4 } = require("uuid");
@@ -1138,16 +1252,45 @@ class History {
 
 module.exports = { Context, History, uuidv4 };
 
-},{"uuid":1}],18:[function(require,module,exports){
+},{"uuid":1}],19:[function(require,module,exports){
+class UserColors {
+  constructor() {
+    this.colors = ["#F6511D", "#FFB400", "#00A6ED", "#7FB800", "#0D2C54"];
+    this.i = 0;
+
+    this.map = new Map();
+  }
+
+  getColor(userId) {
+    let color = this.map.get(userId);
+    if (color === undefined) {
+      color = this.colors[this.i++];
+      this.map.set(userId, color);
+    }
+    return color;
+  }
+}
+
+module.exports = { UserColors };
+},{}],20:[function(require,module,exports){
 // Build this plugin with 'browserify':
 //   browserify versioning.js > versioning.browser.js
 
 Draw.loadPlugin(async function(ui) {
 
+  const {Context, History, uuidv4} = require("../../../../../lib/versioning/History.js");
+  const {UserColors} = require("../../../../../lib/versioning/UserColors.js")
+  const {DragHandler} = require("../../../../../lib/versioning/DragHandler.js");
+  const {GhostOverlays} = require("../../../../../lib/versioning/GhostOverlays.js");
+  await loadScript("../../../../../sccd/docs/runtimes/javascript/statecharts_core.js");
+  await loadScript("../../../../../lib/versioning/client.js");
+
+  const myId = uuidv4();
+
   const names = ["Billy", "James", "Robert", "John", "Mary", "Patricia", "Jennifer", "Barbara"];
-  let me = window.localStorage.getItem('drawio-username');
-  if (!me) {
-    me = names[Math.floor(Math.random()*names.length)];
+  let myName = window.localStorage.getItem('drawio-username');
+  if (!myName) {
+    myName = names[Math.floor(Math.random()*names.length)];
   }
 
   const graph = ui.editor.graph;
@@ -1158,10 +1301,7 @@ Draw.loadPlugin(async function(ui) {
   window.graph = graph;
   window.model = model;
 
-  await loadScript("../../../../../sccd/docs/runtimes/javascript/statecharts_core.js");
-  await loadScript("../../../../../lib/versioning/client.js");
-
-  const {DisabledCells} = require("../../../../../lib/versioning/DisabledCells.js")
+  const userColors = new UserColors();
 
   class EnabledState {
     constructor() {
@@ -1191,7 +1331,7 @@ Draw.loadPlugin(async function(ui) {
           () => {
             const ops = history.getOpsSequence();
             const serialized = ops.map(op => op.serialize());
-            controller.addInput("new_share", "in", [serialized]);
+            controller.addInput("new_share", "in", [serialized], controller.wallclockToSimtime());
           }, // callback
           menu,
           null, // ?
@@ -1201,7 +1341,7 @@ Draw.loadPlugin(async function(ui) {
           () => {
             const sessionId = window.prompt("Enter session ID:");
             if (sessionId) {
-              controller.addInput("join", "in", [sessionId]);
+              controller.addInput("join", "in", [sessionId], controller.wallclockToSimtime());
             }
           }, // callback
           menu,
@@ -1210,7 +1350,7 @@ Draw.loadPlugin(async function(ui) {
         const buttonLeave = menu.addItem("Leave session",
           null, // image
           () => {
-            controller.addInput("leave", "in", []);
+            controller.addInput("leave", "in", [], controller.wallclockToSimtime());
           }, // callback
           menu,
           null, // ?
@@ -1250,6 +1390,7 @@ Draw.loadPlugin(async function(ui) {
 
     setOfflineDisconnected() {
       graph.setEnabled(true);
+      dragHandler.setOnline(false);
       this.statusTextNode.textContent = "Offline (no server connection)";
 
       this.shareEnabled.disable();
@@ -1259,6 +1400,7 @@ Draw.loadPlugin(async function(ui) {
 
     setOfflineConnected() {
       graph.setEnabled(true);
+      dragHandler.setOnline(false);
       this.statusTextNode.textContent = "Offline";
 
       this.shareEnabled.enable();
@@ -1286,11 +1428,13 @@ Draw.loadPlugin(async function(ui) {
 
     setOnline(sessionId) {
       graph.setEnabled(true);
+      dragHandler.setOnline(true);
       this.statusTextNode.textContent = "Online";
 
       this.shareEnabled.disable();
       this.joinEnabled.disable();
       this.leaveEnabled.enable();
+
     }
 
     setReconnecting() {
@@ -1305,8 +1449,6 @@ Draw.loadPlugin(async function(ui) {
 
   const uiState = new UIState();
 
-  const {Context, History, uuidv4} = require("../../../../../lib/versioning/History.js");
-
   const context = new Context(async id => {
     throw new Error("Fetch is forbidden. ", id)
   });
@@ -1334,107 +1476,97 @@ Draw.loadPlugin(async function(ui) {
 
   const setState = (key, value) => {
     // console.log("setState", key, value);
+    if (key === "root") {
+      // the only global 'key'
 
-
-    try {
-      // temporarily disable listener:
-      // listenForEdits = false;
-
-      if (key === "root") {
-        // the only global 'key'
-
-        const rootId = value;
-        const root = getCell(rootId) || createCell(rootId, false, false);
-        model.setRoot(root);
-      }
-      else {
-        // key is concatenation: <cellId> + '_' + <attribute>
-        const splitPoint = key.lastIndexOf('_');
-        const attribute = key.substring(splitPoint+1);
-        const cellId = key.substring(0, splitPoint-2);
-        const isVertex = key[splitPoint-1] === 'V';
-        const isEdge = key[splitPoint-1] === 'E';
-
-        const cell = getCell(cellId) || createCell(cellId, isVertex, isEdge);
-
-        const attrHandlers = {
-          geometry: () => {
-            new mxGeometryChange(model, )
-            const serializedGeometry = value;
-            const parsed = xmlParser.parseFromString(serializedGeometry, 'text/xml');
-            const geometry = codec.decode(parsed.documentElement);
-            model.setGeometry(cell, geometry);
-          },
-          style: () => {
-            const style = value;
-            model.setStyle(cell, style);
-          },
-          parent: () => {
-            const cellId = value;
-            const parent = model.cells[cellId];
-            // The following appears to create a mxChildChange object, indicating that it is this the correct way to set the parent...
-            if (parent) {
-              model.add(parent, cell, null);
-            } else {
-              model.remove(cell);
-            }
-          },
-          value: () => {
-            let v;
-            if (typeof value === 'object') {
-              const {xmlEncoded} = value;
-              v = xmlParser.parseFromString(xmlEncoded, 'text/xml');
-            } else {
-              v = value;
-            }
-            model.setValue(cell, v);
-          },
-          source: () => {
-            const sourceId = value;
-            if (sourceId === null) {
-              model.setTerminal(cell, null, true);
-            } else {
-              const source = getCell(sourceId);
-              if (source === undefined) {
-                throw new Error("NO SUCH CELL:", cellId);
-              }
-              model.setTerminal(cell, source, true);
+      const rootId = value;
+      const root = getCell(rootId) || createCell(rootId, false, false);
+      model.setRoot(root);
+    }
+    else {
+      // key is concatenation: <cellId> + '_' + <attribute>
+      const splitPoint = key.lastIndexOf('_');
+      const attribute = key.substring(splitPoint+1);
+      const cellId = key.substring(0, splitPoint-2);
+      const isVertex = key[splitPoint-1] === 'V';
+      const isEdge = key[splitPoint-1] === 'E';
+
+      const cell = getCell(cellId) || createCell(cellId, isVertex, isEdge);
+
+      const attrHandlers = {
+        geometry: () => {
+          new mxGeometryChange(model, )
+          const serializedGeometry = value;
+          const parsed = xmlParser.parseFromString(serializedGeometry, 'text/xml');
+          const geometry = codec.decode(parsed.documentElement);
+          model.setGeometry(cell, geometry);
+        },
+        style: () => {
+          const style = value;
+          model.setStyle(cell, style);
+        },
+        parent: () => {
+          const cellId = value;
+          const parent = model.cells[cellId];
+          // The following appears to create a mxChildChange object, indicating that it is this the correct way to set the parent...
+          if (parent) {
+            model.add(parent, cell, null);
+          } else {
+            model.remove(cell);
+          }
+        },
+        value: () => {
+          let v;
+          if (typeof value === 'object') {
+            const {xmlEncoded} = value;
+            v = xmlParser.parseFromString(xmlEncoded, 'text/xml');
+          } else {
+            v = value;
+          }
+          model.setValue(cell, v);
+        },
+        source: () => {
+          const sourceId = value;
+          if (sourceId === null) {
+            model.setTerminal(cell, null, true);
+          } else {
+            const source = getCell(sourceId);
+            if (source === undefined) {
+              throw new Error("NO SUCH CELL:", cellId);
             }
-          },
-          target: () => {
-            const targetId = value;
-            if (targetId === null) {
-              model.setTerminal(cell, null, false);
-            } else {
-              const target = getCell(targetId);
-              if (target === undefined) {
-                throw new Error("NO SUCH CELL:", cellId);
-              }
-              model.setTerminal(cell, target, false);
+            model.setTerminal(cell, source, true);
+          }
+        },
+        target: () => {
+          const targetId = value;
+          if (targetId === null) {
+            model.setTerminal(cell, null, false);
+          } else {
+            const target = getCell(targetId);
+            if (target === undefined) {
+              throw new Error("NO SUCH CELL:", cellId);
             }
-          },
-          collapsed: () => {
-            const collapsed = value;
-            model.setCollapsed(cell, collapsed);
-          },
-          visible: () => {
-            const visible = value;
-            model.setVisible(cell, visible);
-          },
-          vertex: () => {
-            const vertex = value;
-            cell.setVertex(vertex);
-          },
-          edge: () => {
-            const edge = value;
-            cell.setEdge(edge);
-          },
-        };
-        attrHandlers[attribute]();
-      }
-    } finally {
-      // re-enable:
-      // listenForEdits = true;
+            model.setTerminal(cell, target, false);
+          }
+        },
+        collapsed: () => {
+          const collapsed = value;
+          model.setCollapsed(cell, collapsed);
+        },
+        visible: () => {
+          const visible = value;
+          model.setVisible(cell, visible);
+        },
+        vertex: () => {
+          const vertex = value;
+          cell.setVertex(vertex);
+        },
+        edge: () => {
+          const edge = value;
+          cell.setEdge(edge);
+        },
+      };
+      attrHandlers[attribute]();
     }
   };
 
@@ -1471,11 +1603,18 @@ Draw.loadPlugin(async function(ui) {
   function leaveOnError() {
     mergePromise.catch(err => {
       console.log("Unexpected error merging", err);
-      controller.addInput("leave", "in", []);
+      controller.addInput("leave", "in", [], controller.wallclockToSimtime());
     });
   }
 
-  const controller = new client.Controller(uiState, new JsEventLoop());
+  function getMyName() {
+    return myName;
+  }
+
+  const controller = new client.Controller(uiState, myId, getMyName, new JsEventLoop());
+
+  const selectionCounter = new Map();
+  const userSelection = new Map();
 
   controller.addMyOwnOutputListener({
     'add': event => {
@@ -1499,101 +1638,82 @@ Draw.loadPlugin(async function(ui) {
         // nothing needs to happen
         const [sessionId] = event.parameters;
       }
-      else if (event.name === "received_op") {
-        const [op] = event.parameters;
-        queuedMerge(op);
-        leaveOnError();
-      }
-      else if (event.name === "left") {
-
-      }
-      else if (event.name === "update_cursor") {
-        const [msg] = event.parameters;
-        ghostOverlays.put(msg.userId, msg.x, msg.y);
-        // const [msg] = event.parameters;
-        // let obj = ghostOverlays.get(msg.userId);
-        // if (!obj) {
-        //   obj = { x: msg.x, y: msg.y, cell: null };
-        //   ghostOverlays.set(msg.userId, obj);
-        // }
-        // ghostOverlays.set(msg.userId, obj);
-        // updateGhostCursors();
+      else if (event.name === "broadcast") {
+        const [sessionId, msg] = event.parameters;
+        if (msg.type === "pub_edit") {
+          const {op} = msg;
+          queuedMerge(op);
+          leaveOnError();
+        }
+        else if (msg.type === "update_cursor") {
+          const {userId, name, x, y} = msg;
+          ghostOverlays.put(userId, name, x, y);
+        }
+        else if (msg.type === "update_selection") {
+          const {userId, selectedIds} = msg;
+          const oldHighlights = userSelection.get(userId) || [];
+          for (const highlight of oldHighlights) {
+            highlight.destroy();
+          }
+          const color = userColors.getColor(userId);
+          const newHighlights = selectedIds.map(cellId => {
+            const cell = graph.model.cells[cellId];
+            const highlight = new mxCellHighlight(graph, color, 6);
+            highlight.highlight(graph.view.getState(cell));
+            return highlight;
+          });
+          userSelection.set(userId, newHighlights);
+        }
       }
     }
   });
 
-  const disabledCells = new DisabledCells();
-  disabledCells.install(graph);
-
-  class GhostOverlays {
-    constructor(ui) {
-      this.map = new Map();
-      this.canvas = ui.editor.graph.view.canvas;
-      this.svg = this.canvas.parentElement;
-    }
-
-    // Update or create ghost cursor. Idempotent.
-    put(userId, x, y) {
-      let state = this.map.get(userId);
-      if (state === undefined) {
-        const g = document.createElementNS("http://www.w3.org/2000/svg", 'g');
-        const image = document.createElementNS("http://www.w3.org/2000/svg", 'image');
-        const text = document.createElementNS("http://www.w3.org/2000/svg", 'text');
-        text.style.fontSize = "10px";
-        text.setAttribute('y', 30);
-        const textNode = document.createTextNode(userId);
-        text.appendChild(textNode);
-        image.setAttribute('href', "/lib/versioning/resources/cursor.svg");
-        image.setAttribute('width', 11.6);
-        image.setAttribute('height', 18.2);
-        g.appendChild(image);
-        g.appendChild(text);
-        this.canvas.appendChild(g);
-        const transform = this.svg.createSVGTransform();
-        g.transform.baseVal.appendItem(transform);
-        state = {transform, timeout: null};
-        this.map.set(userId, state);
-      }
-      state.transform.setTranslate(
-        (x + graph.view.translate.x) * graph.view.scale,
-        (y + graph.view.translate.y) * graph.view.scale,
-      );
-      if (state.timeout) {
-        clearTimeout(state.timeout);
-      }
-      state.timeout = setTimeout(() => state.transform.setTranslate(-50, -50), 5000);
-    }
-  }
+  const dragHandler = new DragHandler(controller);
+  dragHandler.install(graph);
 
-  const ghostOverlays = new GhostOverlays(ui);
+  const ghostOverlays = new GhostOverlays(graph, userColors);
 
   graph.addMouseListener({
     mouseDown(graph, event) {
-
     },
     mouseMove(graph, event) {
-      const msg = {
-        type: "update_cursor",
-        userId: me,
-        x: event.graphX / graph.view.scale - graph.view.translate.x,
-        y: event.graphY / graph.view.scale - graph.view.translate.y,
-      }
-
-      controller.addInput("update_cursor", "in", [msg]);
+      const x = event.graphX / graph.view.scale - graph.view.translate.x;
+      const y = event.graphY / graph.view.scale - graph.view.translate.y;
+      controller.addInput("broadcast_cursor", "in", [x,y], controller.wallclockToSimtime());
     },
     mouseUp(graph, event) {
-
     },
   });
-  
+
+  graph.selectionModel.addListener(mxEvent.CHANGE, (source, eventObj) => {
+    console.log("selectionModel event CHANGE");
+    // const addedIds = (eventObj.properties.removed || []).map(cell => cell.id);
+    // const removedIds = (eventObj.properties.added || []).map(cell => cell.id);
+    const selectedIds = graph.getSelectionCells().map(cell => cell.id);
+    controller.addInput(
+      "broadcast_selection",
+      "in",
+      [selectedIds],
+      controller.wallclockToSimtime(),
+    );
+  });
+
+  const oldMxSelectionChange = mxSelectionChange; // override constructor :)
+  mxSelectionChange = function(selectionModel, added, removed) {
+    console.log("mxSelectionChange constructor");
+    oldMxSelectionChange.apply(this, arguments);
+  }
+  mxSelectionChange.prototype = oldMxSelectionChange.prototype;
+
+
   {
     // Upon loading the page, if a 'sessionId' URL parameter is given, automatically join the given session once the connection with the server is established.
     const searchParams = new URLSearchParams(window.location.search);
     const sessionId = searchParams.get('sessionId');
     if (sessionId) {
-      controller.addInput("init_join", "in", [sessionId]);
+      controller.addInput("init_join", "in", [sessionId], 0);
     } else {
-      controller.addInput("init_offline", "in", []);
+      controller.addInput("init_offline", "in", [], 0);
     }
   }
 
@@ -1605,10 +1725,9 @@ Draw.loadPlugin(async function(ui) {
   } else {
     throw new Error("unexpected protocol: " + window.location.protocol);
   }
-  websocketAddress += "//" + window.location.host + "/websocket";
-
-  controller.addInput("connect", "in", [websocketAddress]);
+  websocketAddress += "//" + window.location.host + "/websocket?me=" + myId;
 
+  controller.addInput("connect", "in", [websocketAddress], 0);
 
   controller.start();
 
@@ -1730,7 +1849,7 @@ Draw.loadPlugin(async function(ui) {
         );
         const serializedOp = op.serialize();
         console.log("OP:", serializedOp);
-        controller.addInput("new_edit", "in", [serializedOp]);
+        controller.addInput("new_edit", "in", [serializedOp], controller.wallclockToSimtime());
       }
     }
   });
@@ -1752,11 +1871,11 @@ Draw.loadPlugin(async function(ui) {
     }
 
     if (e.code === 'KeyU') {
-      const u = window.prompt("Choose a user name:", me);
+      const u = window.prompt("Choose a user name:", myName);
       if (u) {
-        me = u;
+        myName = u;
       }
     }
   })
 });
-},{"../../../../../lib/versioning/DisabledCells.js":16,"../../../../../lib/versioning/History.js":17}]},{},[18]);
+},{"../../../../../lib/versioning/DragHandler.js":16,"../../../../../lib/versioning/GhostOverlays.js":17,"../../../../../lib/versioning/History.js":18,"../../../../../lib/versioning/UserColors.js":19}]},{},[20]);

+ 90 - 87
src/main/webapp/plugins/cdf/versioning.js

@@ -3,10 +3,19 @@
 
 Draw.loadPlugin(async function(ui) {
 
+  const {Context, History, uuidv4} = require("../../../../../lib/versioning/History.js");
+  const {UserColors} = require("../../../../../lib/versioning/UserColors.js")
+  const {DragHandler} = require("../../../../../lib/versioning/DragHandler.js");
+  const {GhostOverlays} = require("../../../../../lib/versioning/GhostOverlays.js");
+  await loadScript("../../../../../sccd/docs/runtimes/javascript/statecharts_core.js");
+  await loadScript("../../../../../lib/versioning/client.js");
+
+  const myId = uuidv4();
+
   const names = ["Billy", "James", "Robert", "John", "Mary", "Patricia", "Jennifer", "Barbara"];
-  let me = window.localStorage.getItem('drawio-username');
-  if (!me) {
-    me = names[Math.floor(Math.random()*names.length)];
+  let myName = window.localStorage.getItem('drawio-username');
+  if (!myName) {
+    myName = names[Math.floor(Math.random()*names.length)];
   }
 
   const graph = ui.editor.graph;
@@ -17,10 +26,7 @@ Draw.loadPlugin(async function(ui) {
   window.graph = graph;
   window.model = model;
 
-  await loadScript("../../../../../sccd/docs/runtimes/javascript/statecharts_core.js");
-  await loadScript("../../../../../lib/versioning/client.js");
-
-  // const {DisabledCells} = require("../../../../../lib/versioning/DisabledCells.js")
+  const userColors = new UserColors();
 
   class EnabledState {
     constructor() {
@@ -50,7 +56,7 @@ Draw.loadPlugin(async function(ui) {
           () => {
             const ops = history.getOpsSequence();
             const serialized = ops.map(op => op.serialize());
-            controller.addInput("new_share", "in", [serialized]);
+            controller.addInput("new_share", "in", [serialized], controller.wallclockToSimtime());
           }, // callback
           menu,
           null, // ?
@@ -60,7 +66,7 @@ Draw.loadPlugin(async function(ui) {
           () => {
             const sessionId = window.prompt("Enter session ID:");
             if (sessionId) {
-              controller.addInput("join", "in", [sessionId]);
+              controller.addInput("join", "in", [sessionId], controller.wallclockToSimtime());
             }
           }, // callback
           menu,
@@ -69,7 +75,7 @@ Draw.loadPlugin(async function(ui) {
         const buttonLeave = menu.addItem("Leave session",
           null, // image
           () => {
-            controller.addInput("leave", "in", []);
+            controller.addInput("leave", "in", [], controller.wallclockToSimtime());
           }, // callback
           menu,
           null, // ?
@@ -109,6 +115,7 @@ Draw.loadPlugin(async function(ui) {
 
     setOfflineDisconnected() {
       graph.setEnabled(true);
+      dragHandler.setOnline(false);
       this.statusTextNode.textContent = "Offline (no server connection)";
 
       this.shareEnabled.disable();
@@ -118,6 +125,7 @@ Draw.loadPlugin(async function(ui) {
 
     setOfflineConnected() {
       graph.setEnabled(true);
+      dragHandler.setOnline(false);
       this.statusTextNode.textContent = "Offline";
 
       this.shareEnabled.enable();
@@ -145,11 +153,13 @@ Draw.loadPlugin(async function(ui) {
 
     setOnline(sessionId) {
       graph.setEnabled(true);
+      dragHandler.setOnline(true);
       this.statusTextNode.textContent = "Online";
 
       this.shareEnabled.disable();
       this.joinEnabled.disable();
       this.leaveEnabled.enable();
+
     }
 
     setReconnecting() {
@@ -164,8 +174,6 @@ Draw.loadPlugin(async function(ui) {
 
   const uiState = new UIState();
 
-  const {Context, History, uuidv4} = require("../../../../../lib/versioning/History.js");
-
   const context = new Context(async id => {
     throw new Error("Fetch is forbidden. ", id)
   });
@@ -320,11 +328,18 @@ Draw.loadPlugin(async function(ui) {
   function leaveOnError() {
     mergePromise.catch(err => {
       console.log("Unexpected error merging", err);
-      controller.addInput("leave", "in", []);
+      controller.addInput("leave", "in", [], controller.wallclockToSimtime());
     });
   }
 
-  const controller = new client.Controller(uiState, new JsEventLoop());
+  function getMyName() {
+    return myName;
+  }
+
+  const controller = new client.Controller(uiState, myId, getMyName, new JsEventLoop());
+
+  const selectionCounter = new Map();
+  const userSelection = new Map();
 
   controller.addMyOwnOutputListener({
     'add': event => {
@@ -348,93 +363,82 @@ Draw.loadPlugin(async function(ui) {
         // nothing needs to happen
         const [sessionId] = event.parameters;
       }
-      else if (event.name === "received_op") {
-        const [op] = event.parameters;
-        queuedMerge(op);
-        leaveOnError();
-      }
-      else if (event.name === "left") {
-
-      }
-      else if (event.name === "update_cursor") {
-        const [msg] = event.parameters;
-        ghostOverlays.put(msg.userId, msg.x, msg.y);
+      else if (event.name === "broadcast") {
+        const [sessionId, msg] = event.parameters;
+        if (msg.type === "pub_edit") {
+          const {op} = msg;
+          queuedMerge(op);
+          leaveOnError();
+        }
+        else if (msg.type === "update_cursor") {
+          const {userId, name, x, y} = msg;
+          ghostOverlays.put(userId, name, x, y);
+        }
+        else if (msg.type === "update_selection") {
+          const {userId, selectedIds} = msg;
+          const oldHighlights = userSelection.get(userId) || [];
+          for (const highlight of oldHighlights) {
+            highlight.destroy();
+          }
+          const color = userColors.getColor(userId);
+          const newHighlights = selectedIds.map(cellId => {
+            const cell = graph.model.cells[cellId];
+            const highlight = new mxCellHighlight(graph, color, 6);
+            highlight.highlight(graph.view.getState(cell));
+            return highlight;
+          });
+          userSelection.set(userId, newHighlights);
+        }
       }
     }
   });
 
-  // const disabledCells = new DisabledCells();
-  // disabledCells.install(graph);
-
-  class GhostOverlays {
-    constructor(ui) {
-      this.map = new Map();
-      this.canvas = ui.editor.graph.view.canvas;
-      this.svg = this.canvas.parentElement;
-    }
-
-    // Update or create ghost cursor. Idempotent.
-    put(userId, x, y) {
-      let state = this.map.get(userId);
-      if (state === undefined) {
-        const g = document.createElementNS("http://www.w3.org/2000/svg", 'g');
-        const image = document.createElementNS("http://www.w3.org/2000/svg", 'image');
-        const text = document.createElementNS("http://www.w3.org/2000/svg", 'text');
-        text.style.fontSize = "10px";
-        text.setAttribute('y', 30);
-        const textNode = document.createTextNode(userId);
-        text.appendChild(textNode);
-        image.setAttribute('href', "/lib/versioning/resources/cursor.svg");
-        image.setAttribute('width', 11.6);
-        image.setAttribute('height', 18.2);
-        g.appendChild(image);
-        g.appendChild(text);
-        this.canvas.appendChild(g);
-        const transform = this.svg.createSVGTransform();
-        g.transform.baseVal.appendItem(transform);
-        state = {transform, timeout: null};
-        this.map.set(userId, state);
-      }
-      state.transform.setTranslate(
-        (x + graph.view.translate.x) * graph.view.scale,
-        (y + graph.view.translate.y) * graph.view.scale,
-      );
-      if (state.timeout) {
-        clearTimeout(state.timeout);
-      }
-      state.timeout = setTimeout(() => state.transform.setTranslate(-50, -50), 5000);
-    }
-  }
+  const dragHandler = new DragHandler(controller);
+  dragHandler.install(graph);
 
-  const ghostOverlays = new GhostOverlays(ui);
+  const ghostOverlays = new GhostOverlays(graph, userColors);
 
   graph.addMouseListener({
     mouseDown(graph, event) {
-
     },
     mouseMove(graph, event) {
-      const msg = {
-        type: "update_cursor",
-        userId: me,
-        x: event.graphX / graph.view.scale - graph.view.translate.x,
-        y: event.graphY / graph.view.scale - graph.view.translate.y,
-      }
-
-      controller.addInput("update_cursor", "in", [msg]);
+      const x = event.graphX / graph.view.scale - graph.view.translate.x;
+      const y = event.graphY / graph.view.scale - graph.view.translate.y;
+      controller.addInput("broadcast_cursor", "in", [x,y], controller.wallclockToSimtime());
     },
     mouseUp(graph, event) {
-
     },
   });
-  
+
+  graph.selectionModel.addListener(mxEvent.CHANGE, (source, eventObj) => {
+    console.log("selectionModel event CHANGE");
+    // const addedIds = (eventObj.properties.removed || []).map(cell => cell.id);
+    // const removedIds = (eventObj.properties.added || []).map(cell => cell.id);
+    const selectedIds = graph.getSelectionCells().map(cell => cell.id);
+    controller.addInput(
+      "broadcast_selection",
+      "in",
+      [selectedIds],
+      controller.wallclockToSimtime(),
+    );
+  });
+
+  const oldMxSelectionChange = mxSelectionChange; // override constructor :)
+  mxSelectionChange = function(selectionModel, added, removed) {
+    console.log("mxSelectionChange constructor");
+    oldMxSelectionChange.apply(this, arguments);
+  }
+  mxSelectionChange.prototype = oldMxSelectionChange.prototype;
+
+
   {
     // Upon loading the page, if a 'sessionId' URL parameter is given, automatically join the given session once the connection with the server is established.
     const searchParams = new URLSearchParams(window.location.search);
     const sessionId = searchParams.get('sessionId');
     if (sessionId) {
-      controller.addInput("init_join", "in", [sessionId]);
+      controller.addInput("init_join", "in", [sessionId], 0);
     } else {
-      controller.addInput("init_offline", "in", []);
+      controller.addInput("init_offline", "in", [], 0);
     }
   }
 
@@ -446,10 +450,9 @@ Draw.loadPlugin(async function(ui) {
   } else {
     throw new Error("unexpected protocol: " + window.location.protocol);
   }
-  websocketAddress += "//" + window.location.host + "/websocket";
-
-  controller.addInput("connect", "in", [websocketAddress]);
+  websocketAddress += "//" + window.location.host + "/websocket?me=" + myId;
 
+  controller.addInput("connect", "in", [websocketAddress], 0);
 
   controller.start();
 
@@ -571,7 +574,7 @@ Draw.loadPlugin(async function(ui) {
         );
         const serializedOp = op.serialize();
         console.log("OP:", serializedOp);
-        controller.addInput("new_edit", "in", [serializedOp]);
+        controller.addInput("new_edit", "in", [serializedOp], controller.wallclockToSimtime());
       }
     }
   });
@@ -593,9 +596,9 @@ Draw.loadPlugin(async function(ui) {
     }
 
     if (e.code === 'KeyU') {
-      const u = window.prompt("Choose a user name:", me);
+      const u = window.prompt("Choose a user name:", myName);
       if (u) {
-        me = u;
+        myName = u;
       }
     }
   })