diff --git a/dev/index.html b/dev/index.html
index 51387b2..f4677ee 100644
--- a/dev/index.html
+++ b/dev/index.html
@@ -125,7 +125,9 @@
+
+
diff --git a/package-lock.json b/package-lock.json
index 7185da9..eb1059a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,6 +7,9 @@
"": {
"version": "0.0.1",
"license": "ISC",
+ "dependencies": {
+ "uuid": "^3.4.0"
+ },
"devDependencies": {
"@rollup/plugin-commonjs": "^18.0.0",
"@rollup/plugin-node-resolve": "^11.2.1",
diff --git a/server.py b/server.py
index be13bf4..81b72e0 100755
--- a/server.py
+++ b/server.py
@@ -1,38 +1,18 @@
#!/usr/bin/env python3
-from flask import Flask, send_from_directory, request
+from flask import Flask, send_from_directory
import flask_restful as restful
from flask_cors import CORS
-
-import time
+from flask_socketio import SocketIO, emit
app = Flask(__name__)
CORS(app)
api = restful.Api(app)
+app.config['SECRET_KEY'] = 'secret!'
+socketio = SocketIO(app)
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0
-history = [1] # TODO: use an ordered dict instead
-history_patch = {1: {
- 'cursor': {},
- 'dom': [
- {
- 'type': 'add', 'append': 1, 'id': 1, 'node':
- {
- 'nodeType': 1, 'oid': 1873262997,
- 'tagName': 'H1',
- 'children': [{
- 'nodeType': 3, 'oid': 1550618946,
- 'textValue': 'A Collaborative Title'
- }],
- 'attributes': {}
- }
- }
- ],
- 'id': 1
-}}
-
-
@app.route('/')
def index():
return open('dev/index.html').read()
@@ -43,29 +23,27 @@ def send_js(path):
return send_from_directory('dev', path)
-@app.route('/history-push', methods=['POST'])
-def history_push():
- data = request.get_json()
- print(data)
- history.append(data['id'])
- history_patch[data['id']] = data
- return {'status': 200}
-class history_get(restful.Resource):
- def get(self, oid=0):
- index = 0
- if oid:
- index = history.index(oid) + 1
- while index == len(history):
- time.sleep(0.1)
+history = []
- result = [history_patch[x] for x in history[index:]]
- print('Get After', oid, ':', [x for x in history[index:]])
- return result
+@socketio.on('step')
+def on_history_step(step):
+ step_index = len(history)
+ step['index'] = step_index
+ history.append(step)
+ emit('step', step, broadcast=True, json=True)
+@socketio.on('init')
+def on_init(incoming_history):
+ if len(history) == 0:
+ history.extend(incoming_history)
+ else:
+ emit('synchronize', history, json=True)
-api.add_resource(history_get, '/history-get/')
+@socketio.on('needSync')
+def on_need_sync():
+ emit('synchronize', history, json=True)
if __name__ == '__main__':
- app.run(port=8000, debug=True)
+ socketio.run(app)
diff --git a/src/editor.js b/src/editor.js
index 3625e25..97a6397 100644
--- a/src/editor.js
+++ b/src/editor.js
@@ -1,5 +1,7 @@
'use strict';
+import { v4 } from 'uuid';
+
import {} from './commands/deleteBackward.js';
import {} from './commands/deleteForward.js';
import {} from './commands/enter.js';
@@ -50,7 +52,6 @@ const TABLEPICKER_ROW_COUNT = 3;
const TABLEPICKER_COL_COUNT = 3;
const KEYBOARD_TYPES = { VIRTUAL: 'VIRTUAL', PHYSICAL: 'PHYSICAL', UNKNOWN: 'UKNOWN' };
-
const isUndo = ev => ev.key === 'z' && (ev.ctrlKey || ev.metaKey);
const isRedo = ev => ev.key === 'y' && (ev.ctrlKey || ev.metaKey);
@@ -63,7 +64,6 @@ function defaultOptions(defaultObject, object) {
}
return newObject;
}
-
export class OdooEditor extends EventTarget {
constructor(editable, options = {}) {
super();
@@ -74,6 +74,7 @@ export class OdooEditor extends EventTarget {
toSanitize: true,
isRootEditable: true,
getContentEditableAreas: () => [],
+ collaborative: false,
},
options,
);
@@ -130,8 +131,17 @@ export class OdooEditor extends EventTarget {
// Set contenteditable before clone as FF updates the content at this point.
this._activateContenteditable();
-
- this.idSet(editable);
+ // User id is only useful in collaborative mode, but to have effective
+ // tests, the editor should work in the same manner as much as possible
+ // in both mode.
+ this._userId = this.options.collaborative.userId || v4();
+ // TODO: remove initCollaboration so that the editor is always
+ // initialised the same way, so that tests are more reliable
+ if (this.options.collaborative) {
+ this.initCollaboration();
+ } else {
+ this.idSet(editable);
+ }
// -----------
// Bind events
@@ -188,6 +198,24 @@ export class OdooEditor extends EventTarget {
}
}
}
+ initCollaboration() {
+ // Create a first step containing all of the document
+ const editableContent = this.editable.innerHTML;
+ [...this.editable.childNodes].forEach(n => n.remove() || n);
+ this.observerActive();
+ this.document.getSelection().setPosition(this.editable);
+ this.execCommand('insertHTML', editableContent);
+ this._isCollaborativeActive = true;
+ }
+
+ historySynchronise(masterHistory) {
+ // Replace current history by parameter history
+ this.observerUnactive();
+ this.resetHistory();
+ [...this.editable.childNodes].forEach(n => n.remove());
+ this.observerActive();
+ masterHistory.forEach(step => this.historyReceive(step));
+ }
/**
* Releases anything that was initialized.
*
@@ -202,7 +230,7 @@ export class OdooEditor extends EventTarget {
this.observerFlush();
// find common ancestror in this.history[-1]
- const step = this._historySteps[this._historySteps.length - 1];
+ const step = this.historyGetCurrentStep();
let commonAncestor, record;
for (record of step.mutations) {
const node = this.idFind(record.parentId || record.id) || this.editable;
@@ -227,9 +255,11 @@ export class OdooEditor extends EventTarget {
// Assign IDs to src, and dest if defined
idSet(node, testunbreak = false) {
if (!node.oid) {
- node.oid = (Math.random() * 2 ** 31) | 0; // TODO: uuid4 or higher number
- this._idToNodeMap.set(node.oid, node);
+ node.oid = v4();
}
+ // Always add to _idNodeMap for nodes whose ids are created through by
+ // another client (in a collaboration setting)
+ this._idToNodeMap.set(node.oid, node);
// Rollback if node.ouid changed. This ensures that nodes never change
// unbreakable ancestors.
node.ouid = node.ouid || getOuid(node, true);
@@ -251,14 +281,11 @@ export class OdooEditor extends EventTarget {
return this._idToNodeMap.get(id);
}
- // Observer that syncs doms
-
- // if not in collaboration mode, no need to serialize / unserialize
- serialize(node) {
- return this._isCollaborativeActive ? nodeToObject(node) : node;
+ serialize(node, stripFromChildren) {
+ return nodeToObject(node, stripFromChildren);
}
unserialize(obj) {
- return this._isCollaborativeActive ? objectToNode(obj) : obj;
+ return objectToNode(obj);
}
automaticStepActive(label) {
@@ -304,10 +331,22 @@ export class OdooEditor extends EventTarget {
}
observerApply(records) {
+ const mutatedNodes = new Set();
+ for (const record of records) {
+ if (record.type === 'childList') {
+ record.removedNodes.forEach(node => {
+ mutatedNodes.add(node.oid);
+ });
+ record.addedNodes.forEach(node => {
+ this.idSet(node, this._checkStepUnbreakable);
+ mutatedNodes.add(node.oid);
+ });
+ }
+ }
for (const record of records) {
switch (record.type) {
case 'characterData': {
- this._historySteps[this._historySteps.length - 1].mutations.push({
+ this.historyGetCurrentStep().mutations.push({
'type': 'characterData',
'id': record.target.oid,
'text': record.target.textContent,
@@ -316,7 +355,7 @@ export class OdooEditor extends EventTarget {
break;
}
case 'attributes': {
- this._historySteps[this._historySteps.length - 1].mutations.push({
+ this.historyGetCurrentStep().mutations.push({
'type': 'attributes',
'id': record.target.oid,
'attributeName': record.attributeName,
@@ -341,16 +380,15 @@ export class OdooEditor extends EventTarget {
} else {
return false;
}
- this.idSet(added, this._checkStepUnbreakable);
mutation.id = added.oid;
- mutation.node = this.serialize(added);
- this._historySteps[this._historySteps.length - 1].mutations.push(mutation);
+ mutation.node = this.serialize(added, mutatedNodes);
+ this.historyGetCurrentStep().mutations.push(mutation);
});
record.removedNodes.forEach(removed => {
if (!this._toRollback && containsUnremovable(removed)) {
this._toRollback = UNREMOVABLE_ROLLBACK_CODE;
}
- this._historySteps[this._historySteps.length - 1].mutations.push({
+ this.historyGetCurrentStep().mutations.push({
'type': 'remove',
'id': removed.oid,
'parentId': record.target.oid,
@@ -396,19 +434,18 @@ export class OdooEditor extends EventTarget {
}
resetHistory() {
- this._historySteps = [
- {
- cursor: {
- // cursor at beginning of step
- anchorNode: undefined,
- anchorOffset: undefined,
- focusNode: undefined,
- focusOffset: undefined,
- },
- mutations: [],
- id: undefined,
+ this._historySteps = [];
+ this._currentStep = {
+ cursor: {
+ // cursor at beginning of step
+ anchorNode: undefined,
+ anchorOffset: undefined,
+ focusNode: undefined,
+ focusOffset: undefined,
},
- ];
+ mutations: [],
+ id: undefined,
+ };
this._historyStepsStates = new Map();
}
//
@@ -425,22 +462,29 @@ export class OdooEditor extends EventTarget {
}
// push history
- const latest = this._historySteps[this._historySteps.length - 1];
+ const latest = this.historyGetCurrentStep();
if (!latest.mutations.length) {
return false;
}
- latest.id = (Math.random() * 2 ** 31) | 0; // TODO: replace by uuid4 generator
- this.historySend(latest);
- this._historySteps.push({
+ latest.id = v4();
+ latest.userId = this._userId;
+ latest.index = this._historySteps.length;
+ this._historySteps.push(latest);
+ this._historySend(latest);
+ this._currentStep = {
cursor: {},
mutations: [],
- });
+ };
this._checkStepUnbreakable = true;
this._recordHistoryCursor();
this.dispatchEvent(new Event('historyStep'));
}
+ historyGetCurrentStep() {
+ return this._currentStep;
+ }
+
// apply changes according to some records
historyApply(records) {
for (const record of records) {
@@ -460,22 +504,22 @@ export class OdooEditor extends EventTarget {
toremove.remove();
}
} else if (record.type === 'add') {
- const node = this.unserialize(record.node);
- const newnode = node.cloneNode(1);
+ const node = this.idFind(record.oid) || this.unserialize(record.node);
+ // const newnode = node.cloneNode(1);
// preserve oid after the clone
- this.idSet(node, newnode);
+ this.idSet(node, true);
- const destnode = this.idFind(record.node.oid);
- if (destnode && record.node.parentNode.oid === destnode.parentNode.oid) {
- // TODO: optimization: remove record from the history to reduce collaboration bandwidth
- continue;
- }
+ // const destnode = this.idFind(record.node.oid);
+ // if (destnode && record.node.parentNode.oid === destnode.parentNode.oid) {
+ // // TODO: optimization: remove record from the history to reduce collaboration bandwidth
+ // continue;
+ // }
if (record.append && this.idFind(record.append)) {
- this.idFind(record.append).append(newnode);
+ this.idFind(record.append).append(node);
} else if (record.before && this.idFind(record.before)) {
- this.idFind(record.before).before(newnode);
+ this.idFind(record.before).before(node);
} else if (record.after && this.idFind(record.after)) {
- this.idFind(record.after).after(newnode);
+ this.idFind(record.after).after(node);
} else {
continue;
}
@@ -483,92 +527,76 @@ export class OdooEditor extends EventTarget {
}
}
- // send changes to server
- historyFetch() {
+ /**
+ * history receive algo:
+ *
+ * if we receive a step whose index is too big to be in sequence:
+ * That's a problem, we're desynchronized, we need to rollback all the
+ * history and resynchronize.
+ *
+ * else if the received step is exactly the next one in the sequence:
+ * we're not up to date but we're still synchronized so apply the step.
+ *
+ * else if the received step has an index that already matches a local
+ * step, but is not the same step:
+ * someone introduced changes before us and the server reordered the
+ * steps: rollback all the changes after this index, apply the changes
+ * introduced by the received step and reapply the rollbacked steps.
+ * If the same nodes are modified, there will be problems, otherwise
+ * everything should be fine.
+ *
+ * else if newStep.id is the same as historyStep[newStep.index].id
+ * do nothing, we're up to date.
+ *
+ */
+ historyReceive(newStep) {
if (!this._isCollaborativeActive) {
return;
}
- window
- .fetch(`/history-get/${this._collaborativeLastSynchronisedId || 0}`, {
- headers: { 'Content-Type': 'application/json;charset=utf-8' },
- method: 'GET',
- })
- .then(response => {
- if (!response.ok) {
- return Promise.reject();
- }
- return response.json();
- })
- .then(result => {
- if (!result.length) {
- return false;
- }
- this.observerUnactive();
-
- let index = this._historySteps.length;
- let updated = false;
- while (
- index &&
- this._historySteps[index - 1].id !== this._collaborativeLastSynchronisedId
- ) {
- index--;
- }
-
- for (let residx = 0; residx < result.length; residx++) {
- const record = result[residx];
- this._collaborativeLastSynchronisedId = record.id;
- if (
- index < this._historySteps.length &&
- record.id === this._historySteps[index].id
- ) {
- index++;
- continue;
- }
- updated = true;
-
- // we are not synched with the server anymore, rollback and replay
- while (this._historySteps.length > index) {
- this.historyRollback();
- this._historySteps.pop();
- }
-
- if (record.id === 1) {
- this.editable.innerHTML = '';
- }
- this.historyApply(record.mutations);
-
- record.mutations = record.id === 1 ? [] : record.mutations;
- this._historySteps.push(record);
- index++;
- }
- if (updated) {
- this._historySteps.push({
- cursor: {},
- mutations: [],
- });
- }
- this.observerActive();
- this.historyFetch();
- })
- .catch(() => {
- // TODO: change that. currently: if error on fetch, fault back to non collaborative mode.
- this._isCollaborativeActive = false;
+ this.observerUnactive();
+ const localStep = this._historySteps[newStep.index];
+ if (!localStep && newStep.index > this._historySteps.length) {
+ this.options.collaborative.requestSynchronization();
+ } else if (!localStep) {
+ // apply step
+ this.historyApply(newStep.mutations);
+ this._historySteps.push(newStep);
+ } else if (localStep.id !== newStep.id) {
+ this._computeHistoryCursor();
+ //rollback and apply
+ const stepsToReapply = [];
+ while (this._historySteps.length - 1 >= newStep.index) {
+ const poppedStep = this._historySteps.pop();
+ this.historyRevert(poppedStep);
+ // Put the step at the beginning of the array, so that the first
+ // reverted step is the last reapplied
+ stepsToReapply.unshift(poppedStep);
+ }
+ this.historyApply(newStep.mutations);
+ this._historySteps.push(newStep);
+ stepsToReapply.forEach(step => {
+ this.historyApply(step.mutations);
+ this._historySteps.push({ ...step, index: step.index + 1 });
});
+ this.historySetCursor(this.historyGetCurrentStep());
+ }
+ //else if (localStep && newStep.id === localStep.id)
+ // do nothin'
+ this.observerActive();
}
- historySend(item) {
- if (!this._isCollaborativeActive) {
- return;
+ getHistorySteps() {
+ return this._historySteps;
+ }
+
+ _historySend(item) {
+ if (this._isCollaborativeActive) {
+ this.options.collaborative.send(item);
}
- window.fetch('/history-push', {
- body: JSON.stringify(item),
- headers: { 'Content-Type': 'application/json;charset=utf-8' },
- method: 'POST',
- });
}
historyRollback(until = 0) {
- const step = this._historySteps[this._historySteps.length - 1];
+ const step = this.historyGetCurrentStep();
this.observerFlush();
this.historyRevert(step, until);
this.observerFlush();
@@ -582,13 +610,13 @@ export class OdooEditor extends EventTarget {
* this._historyStepsState is a map from it's location (index) in this.history to a state.
* The state can be on of:
* undefined: the position has never been undo or redo.
- * 0: The position is considered as a redo of another.
- * 1: The position is considered as a undo of another.
- * 2: The position has been undone and is considered consumed.
+ * "redo": The position is considered as a redo of another.
+ * "undo": The position is considered as a undo of another.
+ * "consumed": The position has been undone and is considered consumed.
*/
historyUndo() {
// The last step is considered an uncommited draft so always revert it.
- const lastStep = this._historySteps[this._historySteps.length - 1];
+ const lastStep = this.historyGetCurrentStep();
this.historyRevert(lastStep);
// Clean the last step otherwise if no other step is created after, the
// mutations of the revert itself will be added to the same step and
@@ -598,11 +626,12 @@ export class OdooEditor extends EventTarget {
const pos = this._getNextUndoIndex();
if (pos >= 0) {
// Consider the position consumed.
- this._historyStepsStates.set(pos, 2);
+ this._historyStepsStates.set(this._historySteps[pos].id, 'consumed');
this.historyRevert(this._historySteps[pos]);
- // Consider the last position of the history as an undo.
- this._historyStepsStates.set(this._historySteps.length - 1, 1);
this.historyStep(true);
+ // Consider the last position of the history as an undo.
+ const undoStep = this._historySteps[this._historySteps.length - 1];
+ this._historyStepsStates.set(undoStep.id, 'undo');
this.dispatchEvent(new Event('historyUndo'));
}
}
@@ -615,11 +644,12 @@ export class OdooEditor extends EventTarget {
historyRedo() {
const pos = this._getNextRedoIndex();
if (pos >= 0) {
- this._historyStepsStates.set(pos, 2);
+ this._historyStepsStates.set(this._historySteps[pos].id, 'consumed');
this.historyRevert(this._historySteps[pos]);
- this._historyStepsStates.set(this._historySteps.length - 1, 0);
this.historySetCursor(this._historySteps[pos]);
this.historyStep(true);
+ const lastStep = this._historySteps[this._historySteps.length - 1];
+ this._historyStepsStates.set(lastStep.id, 'redo');
this.dispatchEvent(new Event('historyRedo'));
}
}
@@ -664,7 +694,11 @@ export class OdooEditor extends EventTarget {
break;
}
case 'remove': {
- const nodeToRemove = this.unserialize(mutation.node);
+ let nodeToRemove = this.idFind(mutation.id);
+ if (!nodeToRemove) {
+ nodeToRemove = this.unserialize(mutation.node);
+ this.idSet(nodeToRemove);
+ }
if (mutation.nextId && this.idFind(mutation.nextId)) {
const node = this.idFind(mutation.nextId);
node && node.before(nodeToRemove);
@@ -696,7 +730,7 @@ export class OdooEditor extends EventTarget {
* @returns {boolean}
*/
resetCursorOnLastHistoryCursor() {
- const lastHistoryStep = this._historySteps[this._historySteps.length - 1];
+ const lastHistoryStep = this.historyGetCurrentStep();
if (lastHistoryStep && lastHistoryStep.cursor && lastHistoryStep.cursor.anchorNode) {
this.historySetCursor(lastHistoryStep);
return true;
@@ -852,7 +886,7 @@ export class OdooEditor extends EventTarget {
} else {
next = firstLeaf(next);
}
- }, this._historySteps[this._historySteps.length - 1].mutations.length);
+ }, this.historyGetCurrentStep().mutations.length);
if ([UNBREAKABLE_ROLLBACK_CODE, UNREMOVABLE_ROLLBACK_CODE].includes(res)) {
restore();
break;
@@ -1015,7 +1049,7 @@ export class OdooEditor extends EventTarget {
* @param {boolean} [useCache=false]
*/
_recordHistoryCursor(useCache = false) {
- const latest = this._historySteps[this._historySteps.length - 1];
+ const latest = this.historyGetCurrentStep();
latest.cursor =
(useCache ? this._latestComputedCursor : this._computeHistoryCursor()) || {};
}
@@ -1024,29 +1058,46 @@ export class OdooEditor extends EventTarget {
* Return -1 if no undo index can be found.
*/
_getNextUndoIndex() {
- let index = this._historySteps.length - 2;
- // go back to first step that can be undoed (0 or undefined)
- while (this._historyStepsStates.get(index)) {
- index--;
+ // go back to first step that can be undone ("redo" or undefined)
+ for (let i = this._historySteps.length - 1; i >= 0; i--) {
+ if (this._historySteps[i] && this._historySteps[i].userId === this._userId) {
+ const state = this._historyStepsStates.get(this._historySteps[i].id);
+ if (state === 'redo' || !state) {
+ return i;
+ }
+ }
}
- return index;
+ // There is no steps left to be undone, return an index that does not
+ // point to any step
+ return -1;
}
/**
* Get the step index in the history to redo.
* Return -1 if no redo index can be found.
*/
_getNextRedoIndex() {
- let pos = this._historySteps.length - 2;
// We cannot redo more than what is consumed.
- // Check if we have no more 2 than 0 until we get to a 1
+ // Check if we have no more "consumed" than "redo" until we get to an
+ // "undo"
let totalConsumed = 0;
- while (this._historyStepsStates.has(pos) && this._historyStepsStates.get(pos) !== 1) {
- // here ._historyStepsState.get(pos) can only be 2 (consumed) or 0 (undoed).
- totalConsumed += this._historyStepsStates.get(pos) === 2 ? 1 : -1;
- pos--;
+ for (let index = this._historySteps.length - 1; index >= 0; index--) {
+ if (this._historySteps[index] && this._historySteps[index].userId === this._userId) {
+ const state = this._historyStepsStates.get(this._historySteps[index].id);
+ switch (state) {
+ case 'undo':
+ return totalConsumed <= 0 ? index : -1;
+ case 'redo':
+ totalConsumed -= 1;
+ break;
+ case 'consumed':
+ totalConsumed += 1;
+ break;
+ default:
+ return -1;
+ }
+ }
}
- const canRedo = this._historyStepsStates.get(pos) === 1 && totalConsumed <= 0;
- return canRedo ? pos : -1;
+ return -1;
}
// TOOLBAR
@@ -1220,7 +1271,7 @@ export class OdooEditor extends EventTarget {
// Record the cursor position that was computed on keydown or before
// contentEditable execCommand (whatever preceded the 'input' event)
this._recordHistoryCursor(true);
- const cursor = this._historySteps[this._historySteps.length - 1].cursor;
+ const cursor = this.historyGetCurrentStep().cursor;
const { focusOffset, focusNode, anchorNode, anchorOffset } = cursor || {};
const wasCollapsed = !cursor || (focusNode === anchorNode && focusOffset === anchorOffset);
if (this.keyboardType === KEYBOARD_TYPES.PHYSICAL || !wasCollapsed) {
@@ -1322,7 +1373,8 @@ export class OdooEditor extends EventTarget {
this._computeHistoryCursor();
const selection = this.document.defaultView.getSelection();
- const isSelectionInEditable = !selection.isCollapsed &&
+ const isSelectionInEditable =
+ !selection.isCollapsed &&
this.editable.contains(selection.anchorNode) &&
this.editable.contains(selection.focusNode);
this._updateToolbar(isSelectionInEditable);
diff --git a/src/main.js b/src/main.js
index e13a811..19aaa1f 100644
--- a/src/main.js
+++ b/src/main.js
@@ -5,12 +5,40 @@ const localStorageKey = 'odoo-editor-localHtmlSaved';
function startEditor(testHTML) {
const editableContainer = document.getElementById('dom');
editableContainer.innerHTML = testHTML;
- const editor = new OdooEditor(editableContainer, {
+ new OdooEditor(editableContainer, {
toolbar: document.querySelector('#toolbar'),
autohideToolbar: true,
});
- editor.historyFetch();
+ // local storage show / hide elements
+ document.getElementById('save-i-html-button').style.display = 'inline-block';
+ document.getElementById('save-c-html-button').style.display = 'inline-block';
+ document.getElementById('saved-html-list').remove();
+}
+function startCollabEditor(testHTML) {
+ const editableContainer = document.getElementById('dom');
+ editableContainer.innerHTML = testHTML;
+ const socket = window.io();
+ const editor = new OdooEditor(editableContainer, {
+ toolbar: document.querySelector('#toolbar'),
+ autohideToolbar: true,
+ collaborative: {
+ send: step => socket.emit('step', step),
+ requestSynchronization: () => {
+ socket.emit('needSync');
+ },
+ },
+ });
+ // Create a first step containing all of the document
+ socket.on('connect', () => {
+ socket.emit('init', editor.getHistorySteps());
+ });
+ socket.on('step', data => {
+ editor.historyReceive(data);
+ });
+ socket.on('synchronize', data => {
+ editor.historySynchronise(data);
+ });
// local storage show / hide elements
document.getElementById('save-i-html-button').style.display = 'inline-block';
document.getElementById('save-c-html-button').style.display = 'inline-block';
@@ -39,6 +67,14 @@ useSampleEl.addEventListener('click', () => {
startEditor(testHTML);
document.getElementById('control-panel').remove();
});
+const startCollaborationEl = document.getElementById('start-collaboration');
+startCollaborationEl.addEventListener('click', () => {
+ startCollaborationEl.disabled = true;
+ const testHTML = document.getElementById('sample-dom').innerHTML;
+ startCollabEditor(testHTML);
+
+ document.getElementById('control-panel').remove();
+});
// url with custom text
const customTextParam = location.search.slice(1);
if (customTextParam && customTextParam.startsWith('html=')) {
diff --git a/src/tests/collab.test.js b/src/tests/collab.test.js
new file mode 100644
index 0000000..a7960c0
--- /dev/null
+++ b/src/tests/collab.test.js
@@ -0,0 +1,185 @@
+import { OdooEditor as Editor } from '../editor.js';
+
+const incomingStep = {
+ 'cursor': { 'anchorNode': 1, 'anchorOffset': 2, 'focusNode': 1, 'focusOffset': 2 },
+ 'mutations': [
+ {
+ 'type': 'add',
+ 'append': 1,
+ 'id': '199bee91-e88e-4681-a2f7-54ec8fe6fe3c',
+ 'node': {
+ 'nodeType': 1,
+ 'oid': '199bee91-e88e-4681-a2f7-54ec8fe6fe3c',
+ 'tagName': 'B',
+ 'children': [
+ {
+ 'nodeType': 3,
+ 'oid': '76498319-5fea-4fda-abf9-9cbd10a279f8',
+ 'textValue': 'foo',
+ },
+ ],
+ 'attributes': {},
+ },
+ },
+ ],
+ 'id': '328e7db4-6abf-48e5-88de-2ac505323735',
+ 'userId': '268d771b-4467-4963-98e3-707c7d05501c',
+ 'index': 1,
+};
+
+describe('Collaboration', () => {
+ describe('Receive step', () => {
+ it('should do nothing when receiving a step already present in the history', () => {
+ const testNode = document.createElement('div');
+ document.body.appendChild(testNode);
+ document.getSelection().setPosition(testNode);
+ const editor = new Editor(testNode, {
+ toSanitize: false,
+ collaborative: {
+ send: () => {},
+ requestSynchronization: () => {},
+ },
+ });
+ editor.keyboardType = 'PHYSICAL_KEYBOARD';
+ editor.execCommand('insertHTML', 'foo');
+ const observerUnactiveSpy = window.sinon.spy(editor, 'observerUnactive');
+ const historyApplySpy = window.sinon.spy(editor, 'historyApply');
+ const historyRevertSpy = window.sinon.spy(editor, 'historyRevert');
+ const observerActiveSpy = window.sinon.spy(editor, 'observerActive');
+
+ const historyStepsBeforeReceive = [...editor._historySteps];
+ const existingStep = editor._historySteps[editor._historySteps.length - 1];
+ editor.historyReceive(existingStep);
+
+ window.chai.expect(observerUnactiveSpy.callCount).to.equal(1);
+ window.chai
+ .expect(historyApplySpy.callCount, 'Should not apply step that is already present')
+ .to.equal(0);
+ window.chai.expect(historyRevertSpy.callCount).to.equal(0);
+ window.chai.expect(observerActiveSpy.callCount).to.equal(1);
+ window.chai.expect(editor._historySteps).to.deep.equal(historyStepsBeforeReceive);
+ });
+ it('should apply a step when receving a step that is not in the history yet', () => {
+ const testNode = document.createElement('div');
+ testNode.setAttribute('contenteditable', 'true');
+ document.body.appendChild(testNode);
+ document.getSelection().setPosition(testNode);
+ const synchRequestSpy = window.sinon.fake();
+ const sendSpy = window.sinon.fake();
+ const editor = new Editor(testNode, {
+ toSanitize: false,
+ collaborative: {
+ send: synchRequestSpy,
+ requestSynchronization: sendSpy,
+ },
+ });
+ editor.keyboardType = 'PHYSICAL_KEYBOARD';
+ const observerUnactiveSpy = window.sinon.spy(editor, 'observerUnactive');
+ const historyApplySpy = window.sinon.spy(editor, 'historyApply');
+ const historyRevertSpy = window.sinon.spy(editor, 'historyRevert');
+ const observerActiveSpy = window.sinon.spy(editor, 'observerActive');
+
+ const historyStepsBeforeReceive = [...editor._historySteps];
+ editor.historyReceive(incomingStep);
+
+ window.chai.expect(synchRequestSpy.callCount).to.equal(0);
+ window.chai.expect(sendSpy.callCount).to.equal(0);
+ window.chai.expect(observerUnactiveSpy.callCount).to.equal(1);
+ window.chai
+ .expect(historyApplySpy.getCall(0).firstArg)
+ .to.deep.equal(incomingStep.mutations);
+ window.chai.expect(historyRevertSpy.callCount).to.equal(0);
+ window.chai.expect(observerActiveSpy.callCount).to.equal(1);
+ window.chai
+ .expect(editor._historySteps)
+ .to.deep.equal([...historyStepsBeforeReceive, incomingStep]);
+ });
+ it('should revert the history if it receives a step where the index does not match the current history', () => {
+ const testNode = document.createElement('div');
+ document.body.appendChild(testNode);
+ document.getSelection().setPosition(testNode);
+ const synchRequestSpy = window.sinon.fake();
+ const sendSpy = window.sinon.fake();
+ const editor = new Editor(testNode, {
+ toSanitize: false,
+ collaborative: {
+ send: sendSpy,
+ requestSynchronization: synchRequestSpy,
+ },
+ });
+ editor.keyboardType = 'PHYSICAL_KEYBOARD';
+ editor.execCommand('insertHTML', 'foo');
+ editor.execCommand('insertHTML', 'bar');
+ editor.execCommand('insertHTML', 'baz');
+ sendSpy.resetHistory();
+ const observerUnactiveSpy = window.sinon.spy(editor, 'observerUnactive');
+ const historyApplySpy = window.sinon.spy(editor, 'historyApply');
+ const historyRevertSpy = window.sinon.spy(editor, 'historyRevert');
+ const observerActiveSpy = window.sinon.spy(editor, 'observerActive');
+
+ const historyStepsBeforeReceive = [...editor._historySteps];
+ // Take everything but the "init" step.
+ const existingSteps = editor._historySteps.slice(1);
+ const incomingSecondStep = { ...incomingStep };
+ editor.historyReceive(incomingSecondStep);
+
+ window.chai.expect(synchRequestSpy.callCount).to.equal(0);
+ window.chai.expect(sendSpy.callCount).to.equal(0);
+ window.chai.expect(observerUnactiveSpy.callCount).to.equal(1);
+ window.chai
+ .expect(historyApplySpy.getCall(0).firstArg)
+ .to.deep.equal(incomingStep.mutations);
+ existingSteps.forEach((step, i) => {
+ // getCall i + 1 because of the new step that is applied first
+ window.chai
+ .expect(historyApplySpy.getCall(i + 1).firstArg, 'should have reapplied step')
+ .to.deep.equal(step.mutations);
+ window.chai
+ .expect(
+ historyRevertSpy.getCall(2 - i).firstArg,
+ 'should have reverted steps in the inverse apply order',
+ )
+ .to.be.equal(step);
+ });
+ window.chai.expect(observerActiveSpy.callCount).to.equal(1);
+ window.chai
+ .expect(editor._historySteps.map(({ id }) => id))
+ .to.deep.equal([
+ historyStepsBeforeReceive.shift().id,
+ incomingSecondStep.id,
+ ...existingSteps.map(({ id }) => id),
+ ]);
+ });
+ it('should request a synchronization if it receives a step which has an index out of bound', () => {
+ const testNode = document.createElement('div');
+ document.body.appendChild(testNode);
+ document.getSelection().setPosition(testNode);
+ const synchRequestSpy = window.sinon.fake();
+ const sendSpy = window.sinon.fake();
+ const editor = new Editor(testNode, {
+ toSanitize: false,
+ collaborative: {
+ send: sendSpy,
+ requestSynchronization: synchRequestSpy,
+ },
+ });
+ editor.keyboardType = 'PHYSICAL_KEYBOARD';
+ const observerUnactiveSpy = window.sinon.spy(editor, 'observerUnactive');
+ const historyApplySpy = window.sinon.spy(editor, 'historyApply');
+ const historyRevertSpy = window.sinon.spy(editor, 'historyRevert');
+ const observerActiveSpy = window.sinon.spy(editor, 'observerActive');
+
+ const historyStepsBeforeReceive = [...editor._historySteps];
+ const incoming6thStep = { ...incomingStep, index: 5 };
+ editor.historyReceive(incoming6thStep);
+
+ window.chai.expect(synchRequestSpy.callCount).to.equal(1);
+ window.chai.expect(sendSpy.callCount).to.equal(0);
+ window.chai.expect(observerUnactiveSpy.callCount).to.equal(1);
+ window.chai.expect(historyApplySpy.callCount).to.equal(0);
+ window.chai.expect(historyRevertSpy.callCount).to.equal(0);
+ window.chai.expect(observerActiveSpy.callCount).to.equal(1);
+ window.chai.expect(editor._historySteps).to.deep.equal(historyStepsBeforeReceive);
+ });
+ });
+});
diff --git a/src/tests/index.js b/src/tests/index.js
index 6dd4c32..1c318b4 100644
--- a/src/tests/index.js
+++ b/src/tests/index.js
@@ -1,3 +1,4 @@
+import './collab.test.js';
import './align.test.js';
import './autostep.test.js';
import './editor.test.js';
diff --git a/src/utils/serialize.js b/src/utils/serialize.js
index 01f4930..b7a43bc 100644
--- a/src/utils/serialize.js
+++ b/src/utils/serialize.js
@@ -1,5 +1,5 @@
// TODO: avoid empty keys when not necessary to reduce request size
-export function nodeToObject(node) {
+export function nodeToObject(node, stripFromChildren = new Set()) {
let result = {
nodeType: node.nodeType,
oid: node.oid,
@@ -18,7 +18,9 @@ export function nodeToObject(node) {
}
let child = node.firstChild;
while (child) {
- result.children.push(nodeToObject(child));
+ if (!stripFromChildren.has(child.oid)) {
+ result.children.push(nodeToObject(child, stripFromChildren));
+ }
child = child.nextSibling;
}
}