|
|
@@ -0,0 +1,499 @@
|
|
|
+// Build this plugin with 'browserify':
|
|
|
+// browserify versioning.js > versioning.browser.js
|
|
|
+
|
|
|
+Draw.loadPlugin(async function(ui) {
|
|
|
+ window.ui = ui;
|
|
|
+ const graph = ui.editor.graph;
|
|
|
+ const model = graph.model;
|
|
|
+
|
|
|
+ await loadScript("../../../../../sccd/docs/runtimes/javascript/statecharts_core.js");
|
|
|
+ await loadScript("../../../../../lib/versioning/client.js");
|
|
|
+
|
|
|
+ class EnabledState {
|
|
|
+ constructor() {
|
|
|
+ this.enabled = false;
|
|
|
+ }
|
|
|
+ enable() {
|
|
|
+ this.enabled = true;
|
|
|
+ }
|
|
|
+ disable() {
|
|
|
+ this.enabled = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ class UIState {
|
|
|
+ constructor() {
|
|
|
+ this.statusTextNode = document.createTextNode("")
|
|
|
+ this.shareEnabled = new EnabledState();
|
|
|
+ this.joinEnabled = new EnabledState();
|
|
|
+ this.leaveEnabled = new EnabledState();
|
|
|
+ }
|
|
|
+
|
|
|
+ install(ui) {
|
|
|
+ ui.toolbar.addSeparator();
|
|
|
+ ui.menus.put('collab', new Menu((menu, parent) => {
|
|
|
+ const buttonShare = menu.addItem("Share this diagram",
|
|
|
+ null, // image
|
|
|
+ () => {
|
|
|
+ const ops = history.getOpsSequence();
|
|
|
+ const serialized = ops.map(op => op.serialize());
|
|
|
+ controller.addInput("new_share", "in", [serialized]);
|
|
|
+ }, // callback
|
|
|
+ menu,
|
|
|
+ null, // ?
|
|
|
+ this.shareEnabled.enabled); // enabled
|
|
|
+ const buttonJoin = menu.addItem("Join session",
|
|
|
+ null, // image
|
|
|
+ () => {
|
|
|
+ const sessionId = window.prompt("Enter session ID:");
|
|
|
+ if (sessionId) {
|
|
|
+ controller.addInput("join", "in", [sessionId]);
|
|
|
+ }
|
|
|
+ }, // callback
|
|
|
+ menu,
|
|
|
+ null, // ?
|
|
|
+ this.joinEnabled.enabled); // enabled
|
|
|
+ const buttonLeave = menu.addItem("Leave session",
|
|
|
+ null, // image
|
|
|
+ () => {
|
|
|
+ controller.addInput("leave", "in", []);
|
|
|
+ }, // callback
|
|
|
+ menu,
|
|
|
+ null, // ?
|
|
|
+ this.leaveEnabled.enabled); // enabled
|
|
|
+ }));
|
|
|
+
|
|
|
+ const screenshareMenu = ui.toolbar.addMenu('', "Collaboration", true, 'collab');
|
|
|
+ screenshareMenu.style.width = '100px';
|
|
|
+ screenshareMenu.showDisabled = true;
|
|
|
+ screenshareMenu.style.whiteSpace = 'nowrap';
|
|
|
+ screenshareMenu.style.position = 'relative';
|
|
|
+ screenshareMenu.style.overflow = 'hidden';
|
|
|
+ screenshareMenu.innerHTML = "Collaboration" + ui.toolbar.dropdownImageHtml;
|
|
|
+
|
|
|
+ ui.toolbar.addSeparator();
|
|
|
+
|
|
|
+ const statusEl = document.createElement('div');
|
|
|
+ statusEl.classList.add("geLabel");
|
|
|
+ statusEl.style = "white-space: nowrap; position: relative;";
|
|
|
+ statusEl.appendChild(this.statusTextNode);
|
|
|
+ ui.toolbar.container.appendChild(statusEl);
|
|
|
+ }
|
|
|
+
|
|
|
+ setSession(sessionId) {
|
|
|
+ const searchParams = new URLSearchParams(window.location.search);
|
|
|
+ searchParams.set('sessionId', sessionId);
|
|
|
+ const newLocation = window.location.origin + window.location.pathname + '?' + searchParams.toString() + window.location.hash;
|
|
|
+ window.history.replaceState({}, "", newLocation);
|
|
|
+ }
|
|
|
+
|
|
|
+ unsetSession(sessionId) {
|
|
|
+ const searchParams = new URLSearchParams(window.location.search);
|
|
|
+ searchParams.delete('sessionId');
|
|
|
+ const newLocation = window.location.origin + window.location.pathname + '?' + searchParams.toString() + window.location.hash;
|
|
|
+ window.history.replaceState({}, "", newLocation);
|
|
|
+ }
|
|
|
+
|
|
|
+ setOfflineDisconnected() {
|
|
|
+ graph.setEnabled(true);
|
|
|
+ this.statusTextNode.textContent = "Offline (no server connection)";
|
|
|
+
|
|
|
+ this.shareEnabled.disable();
|
|
|
+ this.joinEnabled.disable();
|
|
|
+ this.leaveEnabled.disable();
|
|
|
+ }
|
|
|
+
|
|
|
+ setOfflineConnected() {
|
|
|
+ graph.setEnabled(true);
|
|
|
+ this.statusTextNode.textContent = "Offline";
|
|
|
+
|
|
|
+ this.shareEnabled.enable();
|
|
|
+ this.joinEnabled.enable();
|
|
|
+ this.leaveEnabled.disable();
|
|
|
+ }
|
|
|
+
|
|
|
+ setJoining(sessionId) {
|
|
|
+ graph.setEnabled(false);
|
|
|
+ this.statusTextNode.textContent = "Joining session...";
|
|
|
+
|
|
|
+ this.shareEnabled.disable();
|
|
|
+ this.joinEnabled.disable();
|
|
|
+ this.leaveEnabled.enable();
|
|
|
+ }
|
|
|
+
|
|
|
+ setCreatingShare() {
|
|
|
+ graph.setEnabled(false);
|
|
|
+ this.statusTextNode.textContent = "Creating share...";
|
|
|
+
|
|
|
+ this.shareEnabled.disable();
|
|
|
+ this.joinEnabled.disable();
|
|
|
+ this.leaveEnabled.enable();
|
|
|
+ }
|
|
|
+
|
|
|
+ setOnline(sessionId) {
|
|
|
+ graph.setEnabled(true);
|
|
|
+ this.statusTextNode.textContent = "Online";
|
|
|
+
|
|
|
+ this.shareEnabled.disable();
|
|
|
+ this.joinEnabled.disable();
|
|
|
+ this.leaveEnabled.enable();
|
|
|
+ }
|
|
|
+
|
|
|
+ setReconnecting() {
|
|
|
+ graph.setEnabled(false);
|
|
|
+ this.statusTextNode.textContent = "(re)Connecting...";
|
|
|
+
|
|
|
+ this.shareEnabled.disable();
|
|
|
+ this.joinEnabled.disable();
|
|
|
+ this.leaveEnabled.enable();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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)
|
|
|
+ });
|
|
|
+
|
|
|
+ const codec = new mxCodec();
|
|
|
+ const xmlSerializer = new XMLSerializer();
|
|
|
+ const xmlParser = new DOMParser();
|
|
|
+
|
|
|
+ const setState = (key, value) => {
|
|
|
+ // console.log("setState", key, value);
|
|
|
+
|
|
|
+ function getCell(cellId) {
|
|
|
+ let cell = model.cells[cellId];
|
|
|
+ if (cell === undefined) {
|
|
|
+ // throw new Error("NO SUCH CELL:", cellId);
|
|
|
+ }
|
|
|
+ return cell;
|
|
|
+ }
|
|
|
+
|
|
|
+ function createCell(cellId, isVertex, isEdge) {
|
|
|
+ cell = new mxCell("");
|
|
|
+ cell.setId(cellId);
|
|
|
+ cell.setVertex(isVertex);
|
|
|
+ cell.setEdge(isEdge);
|
|
|
+ model.cells[cellId] = cell; // HACK!
|
|
|
+ return cell;
|
|
|
+ }
|
|
|
+
|
|
|
+ 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: () => {
|
|
|
+ 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...
|
|
|
+ model.add(parent, cell, null);
|
|
|
+ },
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const resolve = (a, b) => {
|
|
|
+ console.log("CONFLICT between", a.id, "and", b.id);
|
|
|
+ // Cheap & fun way to define a total ordering on all elements:
|
|
|
+ return a.id > b.id;
|
|
|
+ };
|
|
|
+
|
|
|
+ let history, mergePromise;
|
|
|
+ function resetHistory() {
|
|
|
+ history = new History(context, setState, resolve);
|
|
|
+ mergePromise = Promise.resolve();
|
|
|
+ }
|
|
|
+ resetHistory();
|
|
|
+
|
|
|
+ function queuedMerge(serializedOp) {
|
|
|
+ mergePromise = mergePromise.then(() => {
|
|
|
+ return history.context.receiveOperation(serializedOp).then(op => {
|
|
|
+ history.autoMerge(op);
|
|
|
+ console.log("Merged ", op.id);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+ function leaveOnError() {
|
|
|
+ mergePromise.catch(err => {
|
|
|
+ console.log("Unexpected error merging", err);
|
|
|
+ controller.addInput("leave", "in", []);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const controller = new client.Controller(uiState, new JsEventLoop());
|
|
|
+
|
|
|
+ controller.addMyOwnOutputListener({
|
|
|
+ 'add': event => {
|
|
|
+ console.log("output event:", event);
|
|
|
+ if (event.name === "ack_join") {
|
|
|
+ const [ops] = event.parameters;
|
|
|
+ console.log("Outevent ack_join", ops.length, "ops..");
|
|
|
+ try {
|
|
|
+ listenForEdits = false;
|
|
|
+ model.clear();
|
|
|
+ resetHistory();
|
|
|
+ for (const op of ops) {
|
|
|
+ queuedMerge(op);
|
|
|
+ }
|
|
|
+ leaveOnError();
|
|
|
+ } finally {
|
|
|
+ listenForEdits = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ else if (event.name === "ack_new_share") {
|
|
|
+ // 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") {
|
|
|
+
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+
|
|
|
+ {
|
|
|
+ // 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]);
|
|
|
+ } else {
|
|
|
+ controller.addInput("init_offline", "in", []);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ controller.addInput("connect", "in", ["ws://localhost:8700/websocket"]);
|
|
|
+
|
|
|
+
|
|
|
+ controller.start();
|
|
|
+
|
|
|
+ function keyPrefix(cell) {
|
|
|
+ return cell.id + '_' + (cell.isVertex() ? 'V' : cell.isEdge() ? 'E' : 'X') + '_';
|
|
|
+ }
|
|
|
+
|
|
|
+ function encodeCompleteCellState(cell, deltaObj) {
|
|
|
+ const prefix = keyPrefix(cell);
|
|
|
+
|
|
|
+ if (cell.geometry) {
|
|
|
+ deltaObj[prefix + 'geometry'] = xmlSerializer.serializeToString(
|
|
|
+ codec.encode(
|
|
|
+ cell.geometry));
|
|
|
+ }
|
|
|
+ if (cell.style) {
|
|
|
+ deltaObj[prefix + 'style'] = cell.style;
|
|
|
+ }
|
|
|
+ if (cell.parent) {
|
|
|
+ deltaObj[prefix + 'parent'] = cell.parent.id;
|
|
|
+ }
|
|
|
+ if (cell.value) {
|
|
|
+ // For some reason, 'value' can be an HTML string (when only a label is defined, or it can be a DOM object
|
|
|
+ if (cell.value.constructor === String) {
|
|
|
+ deltaObj[prefix + 'value'] = cell.value;
|
|
|
+ } else {
|
|
|
+ deltaObj[prefix + 'value'] = {
|
|
|
+ xmlEncoded: xmlSerializer.serializeToString(cell.value),
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (cell.source) {
|
|
|
+ deltaObj[prefix + 'source'] = cell.source.id;
|
|
|
+ }
|
|
|
+ if (cell.target) {
|
|
|
+ deltaObj[prefix + 'target'] = cell.target.id;
|
|
|
+ }
|
|
|
+ if (cell.collapsed) {
|
|
|
+ deltaObj[prefix + 'collapsed'] = cell.collapsed;
|
|
|
+ }
|
|
|
+ if (cell.visible !== undefined) {
|
|
|
+ deltaObj[prefix + 'visible'] = cell.visible;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const changeToDelta = (change, deltaObj) => {
|
|
|
+ if (change.constructor === mxRootChange) {
|
|
|
+
|
|
|
+ if (change.root === change.previous) {
|
|
|
+ // This doesn't seem to occur:
|
|
|
+ console.log("no root change")
|
|
|
+ return; // no change
|
|
|
+ }
|
|
|
+ deltaObj['root'] = change.root.id;
|
|
|
+
|
|
|
+ for (const cell of Object.values(model.cells)) {
|
|
|
+ encodeCompleteCellState(cell, deltaObj);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ else {
|
|
|
+ if (change.constructor === mxGeometryChange) {
|
|
|
+ deltaObj[keyPrefix(change.cell) + 'geometry'] = change.geometry ?
|
|
|
+ xmlSerializer.serializeToString(
|
|
|
+ codec.encode(change.geometry)) : null;
|
|
|
+ }
|
|
|
+ else if (change.constructor === mxStyleChange) {
|
|
|
+ deltaObj[keyPrefix(change.cell) + 'style'] = change.style;
|
|
|
+ }
|
|
|
+ else if (change.constructor === mxChildChange) {
|
|
|
+ if (change.previous) {
|
|
|
+ // cell has a previous parent
|
|
|
+ console.log("previous parent");
|
|
|
+ deltaObj[keyPrefix(change.child) + 'parent'] = change.parent ? change.parent.id: null;
|
|
|
+ } else {
|
|
|
+ // no previous parent -> cell was created:
|
|
|
+ console.log("no previous parent");
|
|
|
+ encodeCompleteCellState(change.child, deltaObj);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ else if (change.constructor === mxValueChange) {
|
|
|
+ if (change.value.constructor === String) {
|
|
|
+ deltaObj[keyPrefix(change.cell) + 'value'] = change.value;
|
|
|
+ } else {
|
|
|
+ deltaObj[keyPrefix(change.cell) + 'value'] = {
|
|
|
+ xmlEncoded: xmlSerializer.serializeToString(change.value),
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }
|
|
|
+ else if (change.constructor === mxTerminalChange) {
|
|
|
+ deltaObj[keyPrefix(change.cell) + (change.source ? 'source' : 'target')] =
|
|
|
+ change.terminal ? change.terminal.id : null;
|
|
|
+ }
|
|
|
+ else if (change.constructor === mxCollapseChange) {
|
|
|
+ deltaObj[keyPrefix(change.cell) + 'collapsed'] = change.collapsed;
|
|
|
+ }
|
|
|
+ else if (change.constructor === mxVisibleChange) {
|
|
|
+ deltaObj[keyPrefix(change.cell) + 'visible'] = change.visible;
|
|
|
+ }
|
|
|
+ else if (change.constructor === mxCellAttributeChange) {
|
|
|
+ // Mentioned in mxGraph documentation, but what is this, and does it actually occur?
|
|
|
+ throw new Error("DEBUG: Don't know what to do with mxCellAttributeChange");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Fired when a local change happens
|
|
|
+ let listenForEdits = true;
|
|
|
+ model.addListener(mxEvent.NOTIFY, function(sender, event) {
|
|
|
+ if (listenForEdits) {
|
|
|
+ console.log("NOTIFY:", event.properties.edit.changes);
|
|
|
+
|
|
|
+ const delta = {};
|
|
|
+ for (const change of event.properties.edit.changes) {
|
|
|
+ changeToDelta(change, delta);
|
|
|
+ }
|
|
|
+ if (Object.keys(delta).length > 0) {
|
|
|
+ const op = history.new(delta,
|
|
|
+ false // do NOT update mxGraphModel with the change (the change was already executed)
|
|
|
+ );
|
|
|
+ const serializedOp = op.serialize();
|
|
|
+ console.log("OP:", serializedOp);
|
|
|
+ controller.addInput("new_edit", "in", [serializedOp]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // UI stuff
|
|
|
+ uiState.install(ui);
|
|
|
+
|
|
|
+ document.addEventListener('keydown', e => {
|
|
|
+ console.log(e);
|
|
|
+ if (e.code === 'KeyC') {
|
|
|
+ console.log("KeyC pressed: Clear model and reset history");
|
|
|
+ resetHistory();
|
|
|
+ model.clear();
|
|
|
+ }
|
|
|
+
|
|
|
+ if (e.code === 'KeyH') {
|
|
|
+ const seq = history.getOpsSequence();
|
|
|
+ console.log(seq.map(op => op.serialize()));
|
|
|
+ }
|
|
|
+ })
|
|
|
+});
|