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; } }