[IMP] web_editor: add collaborative feature

This commit allows to use the editor collaboratively for any html
field in peer to peer using webRTC.

task-2497931

closes odoo/odoo#75768

Signed-off-by: Antony Lesuisse (al) <al@openerp.com>
This commit is contained in:
Nicolas Bayet
2021-09-06 19:42:56 +00:00
parent b743fda90d
commit 344d82ca25
26 changed files with 3648 additions and 334 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ class Http(models.AbstractModel):
widget to apply, depending on the current company.
"""
result = super(Http, self).session_info()
if self.env.user.has_group('base.group_user'):
if request.env.user.has_group('base.group_user'):
company_ids = request.env.user.company_ids
for company in company_ids:
+3 -3
View File
@@ -36,8 +36,8 @@ class MailIceServer(models.Model):
""" To be overridable if we need to obtain credentials from another source.
:return: tuple
"""
account_sid = self.env['ir.config_parameter'].get_param('mail.twilio_account_sid')
auth_token = self.env['ir.config_parameter'].get_param('mail.twilio_account_token')
account_sid = self.env['ir.config_parameter'].sudo().get_param('mail.twilio_account_sid')
auth_token = self.env['ir.config_parameter'].sudo().get_param('mail.twilio_account_token')
return account_sid, auth_token
def _get_ice_servers(self):
@@ -45,7 +45,7 @@ class MailIceServer(models.Model):
:return: List of dict, each of which representing a stun or turn server,
formatted as expected by the specifications of RTCConfiguration.iceServers
"""
if self.env['ir.config_parameter'].get_param('mail.use_twilio_rtc_servers'):
if self.env['ir.config_parameter'].sudo().get_param('mail.use_twilio_rtc_servers'):
(account_sid, auth_token) = self._get_twilio_credentials()
if account_sid and auth_token:
url = f'https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Tokens.json'
+3 -1
View File
@@ -9,7 +9,7 @@ Odoo Web Editor widget.
==========================
""",
'depends': ['web'],
'depends': ['web', 'mail'],
'data': [
'security/ir.model.access.csv',
'data/editor_assets.xml',
@@ -42,6 +42,7 @@ Odoo Web Editor widget.
'web_editor/static/lib/odoo-editor/src/utils/sanitize.js',
'web_editor/static/lib/odoo-editor/src/utils/serialize.js',
'web_editor/static/lib/odoo-editor/src/utils/utils.js',
'web_editor/static/lib/odoo-editor/src/utils/DOMPurify.js',
'web_editor/static/lib/odoo-editor/src/tablepicker/TablePicker.js',
'web_editor/static/lib/odoo-editor/src/powerbox/patienceDiff.js',
'web_editor/static/lib/odoo-editor/src/powerbox/Powerbox.js',
@@ -57,6 +58,7 @@ Odoo Web Editor widget.
# utils
'web_editor/static/src/js/wysiwyg/wysiwyg_utils.js',
'web_editor/static/src/js/wysiwyg/PeerToPeer.js',
# odoo utils
('include', 'web._assets_helpers'),
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
from . import bus
+41
View File
@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from odoo.http import request
from odoo.addons.bus.controllers.main import BusController
from odoo.exceptions import AccessDenied
class EditorCollaborationController(BusController):
# ---------------------------
# Extends BUS Controller Poll
# ---------------------------
def _poll(self, dbname, channels, last, options):
if request.session.uid:
# Do not alter original list.
channels = list(channels)
for channel in channels:
if isinstance(channel, str):
match = re.match(r'editor_collaboration:(\w+(?:.\w+)*):(\w+):([\d]+)', channel)
if (match):
model_name = match[1]
field_name = match[2]
res_id = int(match[3])
# Verify access to the edition channel.
if not request.env.user.has_group('base.group_user'):
raise AccessDenied()
document = request.env[model_name].browse([res_id])
document.check_access_rights('read')
document.check_field_access_rights('read', [field_name])
document.check_access_rule('read')
document.check_access_rights('write')
document.check_field_access_rights('write', [field_name])
document.check_access_rule('write')
channels.append((request.db, 'editor_collaboration', model_name, field_name, res_id))
return super(EditorCollaborationController, self)._poll(dbname, channels, last, options)
+18
View File
@@ -687,3 +687,21 @@ class Web_Editor(http.Controller):
attachments.append(attachment._get_media_info())
return attachments
@http.route("/web_editor/get_ice_servers", type='json', auth="user")
def get_ice_servers(self):
return request.env['mail.ice.server']._get_ice_servers()
@http.route("/web_editor/bus_broadcast", type="json", auth="user")
def bus_broadcast(self, model_name, field_name, res_id, bus_data):
document = request.env[model_name].browse([res_id])
document.check_access_rights('read')
document.check_field_access_rights('read', [field_name])
document.check_access_rule('read')
document.check_access_rights('write')
document.check_field_access_rights('write', [field_name])
document.check_access_rule('write')
channel = (request.db, 'editor_collaboration', model_name, field_name, int(res_id))
request.env['bus.bus'].sendone(channel, bus_data)
File diff suppressed because it is too large Load Diff
@@ -26,7 +26,7 @@ import {
leftLeafFirstPath,
preserveCursor,
rightPos,
setCursor,
setSelection,
setCursorStart,
setTagName,
splitElement,
@@ -256,9 +256,9 @@ export function applyInlineStyle(editor, applyStyle) {
const firstNode = selectedTextNodes[0];
const lastNode = selectedTextNodes[selectedTextNodes.length - 1];
if (direction === DIRECTIONS.RIGHT) {
setCursor(firstNode, 0, lastNode, lastNode.length);
setSelection(firstNode, 0, lastNode, lastNode.length);
} else {
setCursor(lastNode, lastNode.length, firstNode, 0);
setSelection(lastNode, lastNode.length, firstNode, 0);
}
}
function addColumn(editor, beforeOrAfter) {
@@ -285,7 +285,7 @@ function deleteTable(editor, table) {
p.appendChild(document.createElement('br'));
table.before(p);
table.remove();
setCursor(p, 0);
setSelection(p, 0);
}
// This is a whitelist of the commands that are implemented by the
@@ -307,7 +307,7 @@ export const editorCommands = {
const insertedNode = editorCommands.insertHTML(editor, '<i></i>')[0];
insertedNode.className = faClass;
const position = rightPos(insertedNode);
setCursor(...position, ...position, false);
setSelection(...position, ...position, false);
},
// History
@@ -324,7 +324,7 @@ export const editorCommands = {
block.nodeName,
)
) {
setCursor(block, 0, block, nodeSize(block));
setSelection(block, 0, block, nodeSize(block));
editor.historyPauseSteps();
editor.document.execCommand('removeFormat');
editor.historyUnpauseSteps();
@@ -394,13 +394,13 @@ export const editorCommands = {
link = link || prompt('URL or Email', (currentLink && currentLink.href) || 'http://');
const res = editor.document.execCommand('createLink', false, link);
if (res) {
setCursor(sel.anchorNode, sel.anchorOffset, sel.focusNode, sel.focusOffset);
setSelection(sel.anchorNode, sel.anchorOffset, sel.focusNode, sel.focusOffset);
const node = findNode(closestPath(sel.focusNode), node => node.tagName === 'A');
for (const [param, value] of Object.entries(editor.options.defaultLinkAttributes)) {
node.setAttribute(param, `${value}`);
}
const pos = [node.parentElement, childNodeIndex(node) + 1];
setCursor(...pos, ...pos, false);
setSelection(...pos, ...pos, false);
}
},
unlink: editor => {
@@ -415,12 +415,12 @@ export const editorCommands = {
if (sel.isCollapsed) {
const cr = preserveCursor(editor.document);
const node = closestElement(sel.focusNode, 'a');
setCursor(node, 0, node, node.childNodes.length, false);
setSelection(node, 0, node, node.childNodes.length, false);
editor.document.execCommand('unlink');
cr();
} else {
editor.document.execCommand('unlink');
setCursor(sel.anchorNode, sel.anchorOffset, sel.focusNode, sel.focusOffset);
setSelection(sel.anchorNode, sel.anchorOffset, sel.focusNode, sel.focusOffset);
}
},
@@ -561,7 +561,7 @@ export const editorCommands = {
? splitTextNode(anchorNode, sel.anchorOffset, DIRECTIONS.LEFT) + 1 && anchorNode
: splitElement(anchorNode, sel.anchorOffset).shift();
const newPosition = rightPos(newAnchorNode);
setCursor(...newPosition, ...newPosition, false);
setSelection(...newPosition, ...newPosition, false);
}
const [table] = editorCommands.insertHTML(editor, tableHtml);
setCursorStart(table.querySelector('td'));
@@ -587,7 +587,7 @@ export const editorCommands = {
const index = cells.findIndex(td => td === cell);
const siblingCell = cells[index - 1] || cells[index + 1];
table.querySelectorAll(`tr td:nth-of-type(${index + 1})`).forEach(td => td.remove());
siblingCell ? setCursor(...startPos(siblingCell)) : deleteTable(editor, table);
siblingCell ? setSelection(...startPos(siblingCell)) : deleteTable(editor, table);
},
removeRow: editor => {
getDeepRange(editor.editable, { select: true }); // Ensure deep range for finding tr.
@@ -598,7 +598,7 @@ export const editorCommands = {
const rowIndex = rows.findIndex(tr => tr === row);
const siblingRow = rows[rowIndex - 1] || rows[rowIndex + 1];
row.remove();
siblingRow ? setCursor(...startPos(siblingRow)) : deleteTable(editor, table);
siblingRow ? setSelection(...startPos(siblingRow)) : deleteTable(editor, table);
},
deleteTable: (editor, table) => deleteTable(editor, table),
insertHorizontalRule(editor) {
@@ -18,7 +18,7 @@ import {
moveNodes,
nodeSize,
prepareUpdate,
setCursor,
setSelection,
splitTextNode,
isUnbreakable,
isMediaElement,
@@ -56,7 +56,7 @@ Text.prototype.oDeleteBackward = function (offset, alreadyMoved = false) {
}
fillEmpty(parentNode);
setCursor(parentNode, firstSplitOffset);
setSelection(parentNode, firstSplitOffset);
};
HTMLElement.prototype.oDeleteBackward = function (offset, alreadyMoved = false) {
@@ -127,7 +127,7 @@ HTMLElement.prototype.oDeleteBackward = function (offset, alreadyMoved = false)
// TODO this handle BR/IMG/etc removals../ to see if we
// prefer to have a dedicated handler for every possible
// HTML element or if we let this generic code handle it.
setCursor(parentEl, parentOffset);
setSelection(parentEl, parentOffset);
return;
}
}
@@ -158,7 +158,7 @@ HTMLElement.prototype.oDeleteBackward = function (offset, alreadyMoved = false)
firstBlockIndex++;
}
let [cursorNode, cursorOffset] = moveNodes(...moveDest, this, offset, firstBlockIndex);
setCursor(cursorNode, cursorOffset);
setSelection(cursorNode, cursorOffset);
// Propagate if this is still a block on the left of where the nodes were
// moved.
@@ -5,7 +5,7 @@ import {
isFakeLineBreak,
prepareUpdate,
rightPos,
setCursor,
setSelection,
getState,
leftPos,
splitTextNode,
@@ -35,7 +35,7 @@ HTMLElement.prototype.oShiftEnter = function (offset) {
for (const el of brEls) {
if (el.parentNode) {
setCursor(...rightPos(el));
setSelection(...rightPos(el));
break;
}
}
@@ -23,8 +23,7 @@ HTMLElement.prototype.oToggleList = function (offset, mode = 'UL') {
return inLI.oToggleList(0, mode);
}
const restoreCursor = preserveCursor(this.ownerDocument);
// if `this` is the root editable
if (this.oid === 1) {
if (this.oid === 'root') {
const callingNode = this.childNodes[offset];
const group = getAdjacents(callingNode, n => !isBlock(n));
insertListAfter(callingNode, mode, [group]);
@@ -301,3 +301,30 @@ i.oe-commandbar-commandImg {
text-align: inherit;
width: 100%;
}
/* Collaboration cursor */
.oe-collaboration-selections-container {
position: absolute;
isolation: isolate;
height: 0;
width: 0;
z-index: 1;
}
.oe-collaboration-caret-top-square {
min-height: 5px;
min-width: 5px;
color: #fff;
text-shadow: 0 0 5px #000;
position: absolute;
bottom: 100%;
left: -4px;
white-space: nowrap;
}
.oe-collaboration-caret-top-square:hover::before {
content: attr(data-client-name);
}
.oe-collaboration-caret-top-square:hover {
border-radius: 2px;
padding: 0.3em 0.6em;
}
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,12 @@
/** @odoo-module **/
// TODO: avoid empty keys when not necessary to reduce request size
export function nodeToObject(node) {
export function serializeNode(node, nodesToStripFromChildren = new Set()) {
let result = {
nodeType: node.nodeType,
oid: node.oid,
};
if (!node.oid) {
console.warn('OID can not be falsy!');
throw new Error('node.oid can not be falsy.');
}
if (node.nodeType === Node.TEXT_NODE) {
result.textValue = node.nodeValue;
@@ -19,14 +19,16 @@ export function nodeToObject(node) {
}
let child = node.firstChild;
while (child) {
result.children.push(nodeToObject(child));
if (!nodesToStripFromChildren.has(child.oid)) {
result.children.push(serializeNode(child, nodesToStripFromChildren));
}
child = child.nextSibling;
}
}
return result;
}
export function objectToNode(obj) {
export function unserializeNode(obj) {
let result = undefined;
if (obj.nodeType === Node.TEXT_NODE) {
result = document.createTextNode(obj.textValue);
@@ -35,10 +37,36 @@ export function objectToNode(obj) {
for (const key in obj.attributes) {
result.setAttribute(key, obj.attributes[key]);
}
obj.children.forEach(child => result.append(objectToNode(child)));
obj.children.forEach(child => result.append(unserializeNode(child)));
} else {
console.warn('unknown node type');
}
result.oid = obj.oid;
return result;
}
export function serializeSelection(selection) {
if (
selection &&
selection.anchorNode &&
selection.anchorNode.oid &&
typeof selection.anchorOffset !== 'undefined' &&
selection.focusNode &&
selection.anchorNode.oid &&
typeof selection.focusOffset !== 'undefined'
) {
return {
anchorNodeOid: selection.anchorNode.oid,
anchorOffset: selection.anchorOffset,
focusNodeOid: selection.focusNode.oid,
focusOffset: selection.focusOffset,
};
} else {
return {
anchorNodeOid: undefined,
anchorOffset: undefined,
focusNodeOid: undefined,
focusOffset: undefined,
};
}
}
@@ -500,7 +500,7 @@ export function getNormalizedCursorPosition(node, offset, full = true) {
* @param {boolean} [normalize=true]
* @returns {?Array.<Node, number}
*/
export function setCursor(
export function setSelection(
anchorNode,
anchorOffset,
focusNode = anchorNode,
@@ -551,7 +551,7 @@ export function setCursor(
*/
export function setCursorStart(node, normalize = true) {
const pos = startPos(node);
return setCursor(...pos, ...pos, normalize);
return setSelection(...pos, ...pos, normalize);
}
/**
* @param {Node} node
@@ -560,7 +560,7 @@ export function setCursorStart(node, normalize = true) {
*/
export function setCursorEnd(node, normalize = true) {
const pos = endPos(node);
return setCursor(...pos, ...pos, normalize);
return setSelection(...pos, ...pos, normalize);
}
/**
* From selection position, checks if it is left-to-right or right-to-left.
@@ -781,7 +781,7 @@ export function preserveCursor(document) {
replace = replace || new Map();
cursorPos[0] = replace.get(cursorPos[0]) || cursorPos[0];
cursorPos[2] = replace.get(cursorPos[2]) || cursorPos[2];
setCursor(...cursorPos);
setSelection(...cursorPos);
};
}
@@ -1294,13 +1294,13 @@ export function splitElement(element, offset) {
export function insertText(sel, content) {
if (sel.anchorNode.nodeType == Node.TEXT_NODE) {
const pos = [sel.anchorNode.parentElement, splitTextNode(sel.anchorNode, sel.anchorOffset)];
setCursor(...pos, ...pos, false);
setSelection(...pos, ...pos, false);
}
const txt = document.createTextNode(content || '#');
const restore = prepareUpdate(sel.anchorNode, sel.anchorOffset);
sel.getRangeAt(0).insertNode(txt);
restore();
setCursor(...boundariesOut(txt), false);
setSelection(...boundariesOut(txt), false);
}
/**
@@ -1938,3 +1938,9 @@ export const rightLeafOnlyNotBlockNotEditablePath = createDOMPathGenerator(DIREC
stopTraverseFunction: node => isNotEditableNode(node) || isBlock(node),
stopFunction: node => isBlock(node) && !isNotEditableNode(node),
});
//------------------------------------------------------------------------------
// Miscelaneous
//------------------------------------------------------------------------------
export function peek(arr) {
return arr[arr.length - 1];
}
@@ -8,6 +8,7 @@
<title>Odoo Editor tests</title>
<link rel="stylesheet" href="lib/mocha.css" />
<script src="../src/utils/DOMPurify.js"></script>
<script src="lib/mocha.js"></script>
<script src="lib/chai.js"></script>
<script>mocha.setup('bdd').fullTrace()</script>
@@ -15,5 +16,6 @@
</head>
<body>
<div id="mocha"></div>
<div id="editor-test-container"></div>
</body>
</html>
@@ -8,6 +8,7 @@ import './spec/insertHTML.test.js';
import './spec/fontAwesome.test.js';
import './spec/autostep.test.js';
import './spec/urlRegex.test.js';
import './spec/collab.test.js';
mocha.run(failures => {
if (failures) {
@@ -0,0 +1,572 @@
import { OdooEditor } from '../../src/OdooEditor.js';
import {
insertCharsAt,
parseMultipleTextualSelection,
setTestSelection,
targetDeepest,
} from '../utils.js';
const overridenDomClass = [
'HTMLBRElement',
'HTMLHeadingElement',
'HTMLParagraphElement',
'HTMLPreElement',
'HTMLQuoteElement',
'HTMLTableCellElement',
'Text',
];
const applyConcurentActions = (clientInfos, concurentActions) => {
const clientInfosList = Object.values(clientInfos);
for (const clientInfo of clientInfosList) {
if (typeof concurentActions[clientInfo.clientId] === 'function') {
concurentActions[clientInfo.clientId](clientInfo.editor);
}
}
};
const mergeClientsSteps = clientInfos => {
const clientInfosList = Object.values(clientInfos);
for (const clientInfoA of clientInfosList) {
for (const clientInfoB of clientInfosList) {
if (clientInfoA === clientInfoB) {
continue;
}
for (const step of clientInfoB.recordedHistorySteps) {
clientInfoA.editor.onExternalHistorySteps([JSON.parse(JSON.stringify(step))]);
}
}
}
};
const testSameHistory = clientInfos => {
const clientInfosList = Object.values(clientInfos);
const firstClientInfo = clientInfosList[0];
const historyLength = firstClientInfo.editor._historySteps.length;
for (const clientInfo of clientInfosList.slice(1)) {
window.chai
.expect(firstClientInfo.editor._historySteps.length)
.to.be.equal(historyLength, 'The history size should be the same.');
for (let i = 0; i < historyLength; i++) {
try {
window.chai
.expect(firstClientInfo.editor._historySteps[i].id)
.to.be.equal(
clientInfo.editor._historySteps[i].id,
`History steps are not consistent accross clients.`,
);
} catch (e) {
console.log(
'Clients steps:',
clientInfosList.map(x => x.editor._historySteps.map(y => `${y.id}`)),
);
throw e;
}
}
}
};
const testMultiEditor = spec => {
const clientInfos = {};
const concurentActions = spec.concurentActions || [];
const clientIds = spec.clientIds || Object.keys(concurentActions);
for (const clientId of clientIds) {
clientInfos[clientId] = {
clientId,
recordedHistorySteps: [],
};
const clientInfo = clientInfos[clientId];
clientInfo.iframe = document.createElement('iframe');
if (navigator.userAgent.toLowerCase().indexOf('firefox') > -1) {
// Firefox reset the page without this hack.
// With this hack, chrome does not render content.
clientInfo.iframe.setAttribute('src', ' javascript:void(0);');
}
document.body.appendChild(clientInfo.iframe);
clientInfo.editable = document.createElement('div');
clientInfo.editable.setAttribute('contenteditable', 'true');
clientInfo.editable.innerHTML = spec.contentBefore;
const iframeWindow = clientInfo.iframe.contentWindow;
for (const overridenClass of overridenDomClass) {
const windowClassPrototype = window[overridenClass].prototype;
const iframeWindowClassPrototype = iframeWindow[overridenClass].prototype;
const iframePrototypeMethodNames = Object.keys(iframeWindowClassPrototype);
for (const methodName of Object.keys(windowClassPrototype)) {
if (!iframePrototypeMethodNames.includes(methodName)) {
iframeWindowClassPrototype[methodName] = windowClassPrototype[methodName];
}
}
}
}
const clientInfosList = Object.values(clientInfos);
let shouldListenSteps = false;
// Init the editors
for (const clientInfo of clientInfosList) {
const selections = parseMultipleTextualSelection(clientInfo.editable);
const iframeWindow = clientInfo.iframe.contentWindow;
const iframeDocument = iframeWindow.document;
iframeDocument.body.appendChild(clientInfo.editable);
// Insure all the client will have the same starting id.
let nextId = 1;
OdooEditor.prototype._generateId = () => 'fake_id_' + nextId++;
clientInfo.editor = new OdooEditor(clientInfo.editable, {
toSanitize: false,
document: iframeDocument,
collaborationClientId: clientInfo.clientId,
onHistoryStep: step => {
if (shouldListenSteps) {
clientInfo.recordedHistorySteps.push(step);
}
},
onHistoryMissingParentSteps: ({ step, fromStepId }) => {
const missingSteps = clientInfos[step.clientId].editor.historyGetMissingSteps({
fromStepId,
toStepId: step.id,
});
if (missingSteps === -1 || !missingSteps.length) {
throw new Error('Impossible to get the missing steps.');
}
clientInfo.editor.onExternalHistorySteps(missingSteps.concat([step]));
},
});
clientInfo.editor.keyboardType = 'PHYSICAL_KEYBOARD';
const selection = selections[clientInfo.clientId];
if (selection) {
setTestSelection(selection, iframeDocument);
} else {
iframeDocument.getSelection().removeAllRanges();
}
// Flush the history so that steps generated by the parsing of the
// selection and the editor loading are not recorded.
clientInfo.editor.observerFlush();
}
shouldListenSteps = true;
// From now, any any step from a client must have a different ID.
let concurentNextId = 1;
OdooEditor.prototype._generateId = () => 'fake_concurent_id_' + concurentNextId++;
if (spec.afterCreate) {
spec.afterCreate(clientInfos);
}
shouldListenSteps = false;
// Render textual selection.
const cursorNodes = {};
for (const clientInfo of clientInfosList) {
const iframeDocument = clientInfo.iframe.contentWindow.document;
const clientSelection = iframeDocument.getSelection();
if (clientSelection.anchorNode === null) {
continue;
}
const [anchorNode, anchorOffset] = targetDeepest(
clientSelection.anchorNode,
clientSelection.anchorOffset,
);
const [focusNode, focusOffset] = targetDeepest(
clientSelection.focusNode,
clientSelection.focusOffset,
);
const clientId = clientInfo.clientId;
cursorNodes[focusNode.oid] = cursorNodes[focusNode.oid] || [];
cursorNodes[focusNode.oid].push({ type: 'focus', clientId, offset: focusOffset });
cursorNodes[anchorNode.oid] = cursorNodes[anchorNode.oid] || [];
cursorNodes[anchorNode.oid].push({ type: 'anchor', clientId, offset: anchorOffset });
}
for (const nodeOid of Object.keys(cursorNodes)) {
cursorNodes[nodeOid] = cursorNodes[nodeOid].sort((a, b) => {
return b.offset - a.offset || b.clientId.localeCompare(a.clientId);
});
}
for (const clientInfo of clientInfosList) {
clientInfo.editor.observerUnactive();
for (const [nodeOid, cursorsData] of Object.entries(cursorNodes)) {
const node = clientInfo.editor.idFind(nodeOid);
for (const cursorData of cursorsData) {
const cursorString =
cursorData.type === 'anchor'
? `[${cursorData.clientId}}`
: `{${cursorData.clientId}]`;
insertCharsAt(cursorString, node, cursorData.offset);
}
}
}
if (spec.contentAfter) {
for (const clientInfo of clientInfosList) {
const value = clientInfo.editable.innerHTML;
window.chai
.expect(value)
.to.be.equal(spec.contentAfter, `error with client ${clientInfo.clientId}`);
}
}
if (spec.afterCursorInserted) {
spec.afterCursorInserted(clientInfos);
}
for (const clientInfo of clientInfosList) {
clientInfo.editor.destroy();
clientInfo.iframe.remove();
}
};
describe('Collaboration', () => {
describe('Conflict resolution', () => {
it('all client steps should be on the same order', () => {
testMultiEditor({
clientIds: ['c1', 'c2', 'c3'],
contentBefore: '<x>a[c1}{c1]</x><y>e[c2}{c2]</y><z>i[c3}{c3]</z>',
afterCreate: clientInfos => {
applyConcurentActions(clientInfos, {
c1: editor => {
editor.execCommand('insertText', 'b');
editor.execCommand('insertText', 'c');
editor.execCommand('insertText', 'd');
},
c2: editor => {
editor.execCommand('insertText', 'f');
editor.execCommand('insertText', 'g');
editor.execCommand('insertText', 'h');
},
c3: editor => {
editor.execCommand('insertText', 'j');
editor.execCommand('insertText', 'k');
editor.execCommand('insertText', 'l');
},
});
mergeClientsSteps(clientInfos);
testSameHistory(clientInfos);
},
contentAfter: '<x>abcd[c1}{c1]</x><y>efgh[c2}{c2]</y><z>ijkl[c3}{c3]</z>',
});
});
it('should 2 client insertText in 2 different paragraph', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<p>ab[c1}{c1]</p><p>cd[c2}{c2]</p>',
afterCreate: clientInfos => {
applyConcurentActions(clientInfos, {
c1: editor => {
editor.execCommand('insertText', 'e');
},
c2: editor => {
editor.execCommand('insertText', 'f');
},
});
mergeClientsSteps(clientInfos);
testSameHistory(clientInfos);
},
contentAfter: '<p>abe[c1}{c1]</p><p>cdf[c2}{c2]</p>',
});
});
it('should 2 client insertText twice in 2 different paragraph', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<p>ab[c1}{c1]</p><p>cd[c2}{c2]</p>',
afterCreate: clientInfos => {
applyConcurentActions(clientInfos, {
c1: editor => {
editor.execCommand('insertText', 'e');
editor.execCommand('insertText', 'f');
},
c2: editor => {
editor.execCommand('insertText', 'g');
editor.execCommand('insertText', 'h');
},
});
mergeClientsSteps(clientInfos);
testSameHistory(clientInfos);
},
contentAfter: '<p>abef[c1}{c1]</p><p>cdgh[c2}{c2]</p>',
});
});
it('should insertText with client 1 and deleteBackward with client 2', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: 'ab[c1}{c1][c2}{c2]c',
afterCreate: clientInfos => {
applyConcurentActions(clientInfos, {
c1: editor => {
editor.execCommand('insertText', 'd');
},
c2: editor => {
editor.execCommand('oDeleteBackward');
},
});
mergeClientsSteps(clientInfos);
testSameHistory(clientInfos);
},
contentAfter: 'a[c2}{c2]c[c1}{c1]dc',
});
});
it('should insertText twice with client 1 and deleteBackward twice with client 2', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: 'ab[c1}{c1][c2}{c2]c',
afterCreate: clientInfos => {
applyConcurentActions(clientInfos, {
c1: editor => {
editor.execCommand('insertText', 'd');
editor.execCommand('insertText', 'e');
},
c2: editor => {
editor.execCommand('oDeleteBackward');
editor.execCommand('oDeleteBackward');
},
});
mergeClientsSteps(clientInfos);
testSameHistory(clientInfos);
},
contentAfter: '[c2}{c2]cd[c1}{c1]ec',
});
});
});
it('should reset from snapshot', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: 'a[c1}{c1]',
afterCreate: clientInfos => {
clientInfos.c1.editor.execCommand('insertText', 'b');
clientInfos.c1.editor._historyMakeSnapshot();
// Insure the snapshot is considered to be older than 30 seconds.
clientInfos.c1.editor._historySnapshots[0].time = 1;
const steps = clientInfos.c1.editor.historyGetSnapshotSteps();
clientInfos.c2.editor.historyResetFromSteps(steps);
chai.expect(clientInfos.c2.editor._historySteps.map(x => x.id)).to.deep.equal([
'fake_concurent_id_2',
]);
chai.expect(
clientInfos.c2.editor._historySteps[0].mutations.map(x => x.id),
).to.deep.equal(['fake_id_1', 'fake_concurent_id_1']);
},
contentAfter: 'ab[c1}{c1]',
});
});
describe('steps whith no parent in history', () => {
it('should be able to retreive steps when disconnected from clients that has send step', () => {
testMultiEditor({
clientIds: ['c1', 'c2', 'c3'],
contentBefore: '<x>a[c1}{c1]</x><y>b[c2}{c2]</y><z>c[c3}{c3]</z>',
afterCreate: clientInfos => {
clientInfos.c1.editor.execCommand('insertText', 'd');
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[1],
]);
clientInfos.c2.editor.execCommand('insertText', 'e');
clientInfos.c1.editor.onExternalHistorySteps([
clientInfos.c2.editor._historySteps[2],
]);
clientInfos.c3.editor.onExternalHistorySteps([
clientInfos.c2.editor._historySteps[2],
]);
// receive step 1 after step 2
clientInfos.c3.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[1],
]);
testSameHistory(clientInfos);
},
contentAfter: '<x>ad[c1}{c1]</x><y>be[c2}{c2]</y><z>c[c3}{c3]</z>',
});
});
it('should receive steps where parent was not received', () => {
testMultiEditor({
clientIds: ['c1', 'c2', 'c3'],
contentBefore: '<i>a[c1}{c1]</i><b>b[c2}{c2]</b>',
afterCreate: clientInfos => {
clientInfos.c1.editor.execCommand('insertText', 'c');
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[1],
]);
// Client 3 connect firt to client 1 that made a snapshot.
clientInfos.c1.editor._historyMakeSnapshot();
// Fake the time of the snapshot so it is considered to be
// older than 30 seconds.
clientInfos.c1.editor._historySnapshots[0].time = 1;
const steps = clientInfos.c1.editor.historyGetSnapshotSteps();
clientInfos.c3.editor.historyResetFromSteps(steps);
// In the meantime client 2 send the step to client 1
clientInfos.c2.editor.execCommand('insertText', 'd');
clientInfos.c2.editor.execCommand('insertText', 'e');
clientInfos.c1.editor.onExternalHistorySteps([
clientInfos.c2.editor._historySteps[2],
]);
clientInfos.c1.editor.onExternalHistorySteps([
clientInfos.c2.editor._historySteps[3],
]);
// Now client 2 is connected to client 3 and client 2 make a new step.
clientInfos.c2.editor.execCommand('insertText', 'f');
clientInfos.c1.editor.onExternalHistorySteps([
clientInfos.c2.editor._historySteps[4],
]);
clientInfos.c3.editor.onExternalHistorySteps([
clientInfos.c2.editor._historySteps[4],
]);
},
contentAfter: '<i>ac[c1}{c1]</i><b>bdef[c2}{c2]</b>',
});
});
});
describe('sanitize', () => {
it('should sanitize when adding a node', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<x>a</x>',
afterCreate: clientInfos => {
const script = document.createElement('script');
script.innerHTML = 'console.log("xss")';
clientInfos.c1.editable.append(script);
clientInfos.c1.editor.historyStep();
window.chai.expect(clientInfos.c1.editor._historySteps[1]).is.not.undefined;
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[1],
]);
window.chai
.expect(clientInfos.c2.editor.editable.innerHTML)
.to.equal('<x>a</x>');
},
});
});
it('should sanitize when adding a script as descendant', async () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<x>a[c1}{c1][c2}{c2]</x>',
afterCreate: clientInfos => {
const i = document.createElement('i');
i.innerHTML = '<b>b</b><script>alert("c");</script>';
clientInfos.c1.editable.appendChild(i);
clientInfos.c1.editor.historyStep();
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[1],
]);
},
afterCursorInserted: clientInfos => {
chai.expect(clientInfos.c2.editable.innerHTML).to.equal(
'<x>a[c1}{c1][c2}{c2]</x><i><b>b</b></i>',
);
},
});
});
it('should sanitize when changing an attribute', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<x>a<img></x>',
afterCreate: clientInfos => {
const img = clientInfos.c1.editable.childNodes[0].childNodes[1];
img.setAttribute('class', 'b');
img.setAttribute('onerror', 'console.log("xss")');
clientInfos.c1.editor.historyStep();
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[1],
]);
window.chai
.expect(clientInfos.c1.editor.editable.innerHTML)
.to.equal('<x>a<img class="b" onerror="console.log(&quot;xss&quot;)"></x>');
window.chai
.expect(clientInfos.c2.editor.editable.innerHTML)
.to.equal('<x>a<img class="b"></x>');
},
});
});
it('should sanitize when undo is adding a script node', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<x>a</x>',
afterCreate: clientInfos => {
const script = document.createElement('script');
script.innerHTML = 'console.log("xss")';
clientInfos.c1.editable.append(script);
clientInfos.c1.editor.historyStep();
script.remove();
clientInfos.c1.editor.historyStep();
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[1],
]);
// Change the client in order to be undone from client 2
clientInfos.c1.editor._historySteps[2].clientId = 'c2';
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[2],
]);
clientInfos.c2.editor.historyUndo();
window.chai
.expect(clientInfos.c2.editor.editable.innerHTML)
.to.equal('<x>a</x>');
},
});
});
it('should sanitize when undo is adding a descendant script node', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<x>a</x>',
afterCreate: clientInfos => {
const div = document.createElement('div');
div.innerHTML = '<i>b</i><script>console.log("xss")</script>';
clientInfos.c1.editable.append(div);
clientInfos.c1.editor.historyStep();
div.remove();
clientInfos.c1.editor.historyStep();
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[1],
]);
// Change the client in order to be undone from client 2
clientInfos.c1.editor._historySteps[2].clientId = 'c2';
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[2],
]);
clientInfos.c2.editor.historyUndo();
window.chai
.expect(clientInfos.c2.editor.editable.innerHTML)
.to.equal('<x>a</x><div><i>b</i></div>');
},
});
});
it('should sanitize when undo is changing an attribute', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<x>a<img></x>',
afterCreate: clientInfos => {
const img = clientInfos.c1.editable.childNodes[0].childNodes[1];
img.setAttribute('class', 'b');
img.setAttribute('onerror', 'console.log("xss")');
clientInfos.c1.editor.historyStep();
img.setAttribute('class', '');
img.setAttribute('onerror', '');
clientInfos.c1.editor.historyStep();
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[1],
]);
// Change the client in order to be undone from client 2
clientInfos.c1.editor._historySteps[2].clientId = 'c2';
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[2],
]);
clientInfos.c2.editor.historyUndo();
window.chai
.expect(clientInfos.c2.editor.editable.innerHTML)
.to.equal('<x>a<img class="b"></x>');
},
});
});
});
});
@@ -23,7 +23,7 @@ import {
previousLeaf,
rightPos,
startPos,
setCursor,
setSelection,
setCursorStart,
setCursorEnd,
getCursorDirection,
@@ -707,7 +707,7 @@ describe('Utils', () => {
describe('collapsed', () => {
it('should collapse the cursor at the beginning of an element', () => {
const [p] = insertTestHtml('<p>abc</p>');
const result = setCursor(p.firstChild, 0);
const result = setSelection(p.firstChild, 0);
window.chai.expect(result).to.eql([p.firstChild, 0, p.firstChild, 0]);
const { anchorNode, anchorOffset, focusNode, focusOffset } =
document.getSelection();
@@ -717,7 +717,7 @@ describe('Utils', () => {
});
it('should collapse the cursor within an element', () => {
const [p] = insertTestHtml('<p>abcd</p>');
const result = setCursor(p.firstChild, 2);
const result = setSelection(p.firstChild, 2);
window.chai.expect(result).to.eql([p.firstChild, 2, p.firstChild, 2]);
const { anchorNode, anchorOffset, focusNode, focusOffset } =
document.getSelection();
@@ -727,7 +727,7 @@ describe('Utils', () => {
});
it('should collapse the cursor at the end of an element', () => {
const [p] = insertTestHtml('<p>abc</p>');
const result = setCursor(p.firstChild, 3);
const result = setSelection(p.firstChild, 3);
window.chai.expect(result).to.eql([p.firstChild, 3, p.firstChild, 3]);
const { anchorNode, anchorOffset, focusNode, focusOffset } =
document.getSelection();
@@ -738,7 +738,7 @@ describe('Utils', () => {
it('should collapse the cursor before a nested inline element', () => {
const [p] = insertTestHtml('<p>ab<span>cd<b>ef</b>gh</span>ij</p>');
const cd = p.childNodes[1].firstChild;
const result = setCursor(cd, 2);
const result = setSelection(cd, 2);
window.chai.expect(result).to.eql([cd, 2, cd, 2]);
const { anchorNode, anchorOffset, focusNode, focusOffset } =
document.getSelection();
@@ -749,7 +749,7 @@ describe('Utils', () => {
it('should collapse the cursor at the beginning of a nested inline element', () => {
const [p] = insertTestHtml('<p>ab<span>cd<b>ef</b>gh</span>ij</p>');
const ef = p.childNodes[1].childNodes[1].firstChild;
const result = setCursor(ef, 0);
const result = setSelection(ef, 0);
window.chai.expect(result).to.eql([ef, 0, ef, 0]);
const { anchorNode, anchorOffset, focusNode, focusOffset } =
document.getSelection();
@@ -760,7 +760,7 @@ describe('Utils', () => {
it('should collapse the cursor within a nested inline element', () => {
const [p] = insertTestHtml('<p>ab<span>cd<b>efgh</b>ij</span>kl</p>');
const efgh = p.childNodes[1].childNodes[1].firstChild;
const result = setCursor(efgh, 2);
const result = setSelection(efgh, 2);
window.chai.expect(result).to.eql([efgh, 2, efgh, 2]);
const { anchorNode, anchorOffset, focusNode, focusOffset } =
document.getSelection();
@@ -771,7 +771,7 @@ describe('Utils', () => {
it('should collapse the cursor at the end of a nested inline element', () => {
const [p] = insertTestHtml('<p>ab<span>cd<b>ef</b>gh</span>ij</p>');
const ef = p.childNodes[1].childNodes[1].firstChild;
const result = setCursor(ef, 2);
const result = setSelection(ef, 2);
window.chai.expect(result).to.eql([ef, 2, ef, 2]);
const { anchorNode, anchorOffset, focusNode, focusOffset } =
document.getSelection();
@@ -783,7 +783,7 @@ describe('Utils', () => {
const [p] = insertTestHtml('<p>ab<span>cd<b>ef</b>gh</span>ij</p>');
const ef = p.childNodes[1].childNodes[1].firstChild;
const gh = p.childNodes[1].lastChild;
const result = setCursor(gh, 0);
const result = setSelection(gh, 0);
window.chai.expect(result).to.eql([ef, 2, ef, 2]);
const { anchorNode, anchorOffset, focusNode, focusOffset } =
document.getSelection();
@@ -791,7 +791,7 @@ describe('Utils', () => {
.expect([anchorNode, anchorOffset, focusNode, focusOffset])
.to.eql([ef, 2, ef, 2]);
const nonNormalizedResult = setCursor(gh, 0, gh, 0, false);
const nonNormalizedResult = setSelection(gh, 0, gh, 0, false);
window.chai.expect(nonNormalizedResult).to.eql([gh, 0, gh, 0]);
const sel = document.getSelection();
window.chai
@@ -802,7 +802,7 @@ describe('Utils', () => {
describe('forward', () => {
it('should select the contents of an element', () => {
const [p] = insertTestHtml('<p>abc</p>');
const result = setCursor(p.firstChild, 0, p.firstChild, 3);
const result = setSelection(p.firstChild, 0, p.firstChild, 3);
window.chai.expect(result).to.eql([p.firstChild, 0, p.firstChild, 3]);
const { anchorNode, anchorOffset, focusNode, focusOffset } =
document.getSelection();
@@ -817,7 +817,7 @@ describe('Utils', () => {
const ef = p1.childNodes[1].childNodes[1].firstChild;
const qr = p2.childNodes[1].childNodes[2];
const st = p2.childNodes[2];
const result = setCursor(ef, 1, st, 0);
const result = setSelection(ef, 1, st, 0);
window.chai.expect(result).to.eql([ef, 1, qr, 2]);
const { anchorNode, anchorOffset, focusNode, focusOffset } =
document.getSelection();
@@ -825,7 +825,7 @@ describe('Utils', () => {
.expect([anchorNode, anchorOffset, focusNode, focusOffset])
.to.eql([ef, 1, qr, 2]);
const nonNormalizedResult = setCursor(ef, 1, st, 0, false);
const nonNormalizedResult = setSelection(ef, 1, st, 0, false);
window.chai.expect(nonNormalizedResult).to.eql([ef, 1, st, 0]);
const sel = document.getSelection();
window.chai
@@ -836,7 +836,7 @@ describe('Utils', () => {
describe('backward', () => {
it('should select the contents of an element', () => {
const [p] = insertTestHtml('<p>abc</p>');
const result = setCursor(p.firstChild, 3, p.firstChild, 0);
const result = setSelection(p.firstChild, 3, p.firstChild, 0);
window.chai.expect(result).to.eql([p.firstChild, 3, p.firstChild, 0]);
const { anchorNode, anchorOffset, focusNode, focusOffset } =
document.getSelection();
@@ -851,7 +851,7 @@ describe('Utils', () => {
const ef = p1.childNodes[1].childNodes[1].firstChild;
const qr = p2.childNodes[1].childNodes[2];
const st = p2.childNodes[2];
const result = setCursor(st, 0, ef, 1);
const result = setSelection(st, 0, ef, 1);
window.chai.expect(result).to.eql([qr, 2, ef, 1]);
const { anchorNode, anchorOffset, focusNode, focusOffset } =
document.getSelection();
@@ -859,7 +859,7 @@ describe('Utils', () => {
.expect([anchorNode, anchorOffset, focusNode, focusOffset])
.to.eql([qr, 2, ef, 1]);
const nonNormalizedResult = setCursor(st, 0, ef, 1, false);
const nonNormalizedResult = setSelection(st, 0, ef, 1, false);
window.chai.expect(nonNormalizedResult).to.eql([st, 0, ef, 1]);
const sel = document.getSelection();
window.chai
@@ -1217,7 +1217,7 @@ describe('Utils', () => {
</p>`,
);
const abc = p.childNodes[1].firstChild.firstChild.firstChild.firstChild;
setCursor(p, 2, p, 0, false);
setSelection(p, 2, p, 0, false);
const result = getDeepRange(p.parentElement, { select: true });
const { startContainer, startOffset, endContainer, endOffset } = result;
window.chai
@@ -1240,7 +1240,7 @@ describe('Utils', () => {
const span1 = p1.childNodes[1];
const ef = span1.childNodes[1].firstChild;
const st = p2.childNodes[2];
setCursor(p2, 2, span1, 1, false);
setSelection(p2, 2, span1, 1, false);
const result = getDeepRange(p1.parentElement, { select: true });
const { startContainer, startOffset, endContainer, endOffset } = result;
window.chai
@@ -32,7 +32,7 @@ function _toDomLocation(node, index) {
return [container, offset];
}
function _parseTextualSelection(testContainer) {
export function parseTextualSelection(testContainer) {
let anchorNode;
let anchorOffset;
let focusNode;
@@ -93,13 +93,83 @@ function _parseTextualSelection(testContainer) {
}
}
export function parseMultipleTextualSelection(testContainer) {
let currentNode = testContainer;
const clients = {};
while (currentNode) {
if (currentNode.nodeType === Node.TEXT_NODE) {
// Look for special characters in the text content and remove them.
let match;
const regex = new RegExp(/(?:\[(\w+)\})|(?:\{(\w+)])/, 'gd');
while ((match = regex.exec(currentNode.textContent))) {
regex.lastIndex = 0;
const indexes = match.indices[0];
if (match[0].includes('}')) {
const clientId = match[1];
clients[clientId] = clients[clientId] || {};
clients[clientId].anchorNode = currentNode;
clients[clientId].anchorOffset = indexes[0];
if (clients[clientId].focusNode) {
clients[clientId].direction = Direction.FORWARD;
}
} else {
const clientId = match[2];
clients[clientId] = clients[clientId] || {};
clients[clientId].focusNode = currentNode;
clients[clientId].focusOffset = indexes[0];
if (clients[clientId].anchorNode) {
clients[clientId].direction = Direction.BACKWARD;
}
}
currentNode.textContent =
currentNode.textContent.slice(0, indexes[0]) +
currentNode.textContent.slice(indexes[1]);
}
}
currentNode = _nextNode(currentNode);
}
return clients;
}
/**
* Insert in the DOM:
* - `SELECTION_ANCHOR_CHAR` in place for the selection start
* - `SELECTION_FOCUS_CHAR` in place for the selection end
*
* This is used in the function `testEditor`.
*/
export function renderMultipleTextualSelection() {
const selection = document.getSelection();
if (selection.rangeCount === 0) {
return;
}
const anchor = targetDeepest(selection.anchorNode, selection.anchorOffset);
const focus = targetDeepest(selection.focusNode, selection.focusOffset);
// If the range characters have to be inserted within the same parent and
// the anchor range character has to be before the focus range character,
// the focus offset needs to be adapted to account for the first insertion.
const [anchorNode, anchorOffset] = anchor;
const [focusNode, baseFocusOffset] = focus;
let focusOffset = baseFocusOffset;
if (anchorNode === focusNode && anchorOffset <= focusOffset) {
focusOffset++;
}
insertCharsAt('[', ...anchor);
insertCharsAt(']', focusNode, focusOffset);
}
/**
* Set a range in the DOM.
*
* @param selection
*/
export function setSelection(selection) {
const domRange = document.createRange();
export function setTestSelection(selection, doc = document) {
const domRange = doc.createRange();
if (selection.direction === Direction.FORWARD) {
domRange.setStart(selection.anchorNode, selection.anchorOffset);
domRange.collapse(true);
@@ -118,24 +188,24 @@ export function setSelection(selection) {
}
/**
* Inserts the given character at the given offset of the given node.
* Inserts the given characters at the given offset of the given node.
*
* @param {string} char
* @param {string} chars
* @param {Node} node
* @param {number} offset
*/
function _insertCharAt(char, node, offset) {
export function insertCharsAt(chars, node, offset) {
if (node.nodeType === Node.TEXT_NODE) {
const startValue = node.nodeValue;
if (offset < 0 || offset > startValue.length) {
throw new Error(`Invalid ${char} insertion in text node`);
throw new Error(`Invalid ${chars} insertion in text node`);
}
node.nodeValue = startValue.slice(0, offset) + char + startValue.slice(offset);
node.nodeValue = startValue.slice(0, offset) + chars + startValue.slice(offset);
} else {
if (offset < 0 || offset > node.childNodes.length) {
throw new Error(`Invalid ${char} insertion in non-text node`);
throw new Error(`Invalid ${chars} insertion in non-text node`);
}
const textNode = document.createTextNode(char);
const textNode = document.createTextNode(chars);
if (offset < node.childNodes.length) {
node.insertBefore(textNode, node.childNodes[offset]);
} else {
@@ -211,24 +281,24 @@ export function renderTextualSelection() {
if (anchorNode === focusNode && anchorOffset <= focusOffset) {
focusOffset++;
}
_insertCharAt('[', ...anchor);
_insertCharAt(']', focusNode, focusOffset);
insertCharsAt('[', ...anchor);
insertCharsAt(']', focusNode, focusOffset);
}
export async function testEditor(Editor = OdooEditor, spec) {
const testNode = document.createElement('div');
document.body.appendChild(testNode);
document.querySelector('#editor-test-container').appendChild(testNode);
// Add the content to edit and remove the "[]" markers *before* initializing
// the editor as otherwise those would genererate mutations the editor would
// consider and the tests would make no sense.
testNode.innerHTML = spec.contentBefore;
const selection = _parseTextualSelection(testNode);
const selection = parseTextualSelection(testNode);
const editor = new Editor(testNode, { toSanitize: false });
editor.keyboardType = 'PHYSICAL_KEYBOARD';
if (selection) {
setSelection(selection);
setTestSelection(selection);
} else {
document.getSelection().removeAllRanges();
}
@@ -185,6 +185,11 @@ var FieldHtml = basic_fields.DebouncedField.extend(TranslatableFieldMixin, {
res_id: this.res_id,
},
placeholder: this.attrs && this.attrs.placeholder,
collaborationChannel: {
collaborationModelName: this.model,
collaborationFieldName: this.name,
collaborationResId: parseInt(this.res_id),
},
noAttachment: this.nodeOptions['no-attachment'],
inIframe: !!this.nodeOptions.cssEdit,
iframeCssAssets: this.nodeOptions.cssEdit,
@@ -0,0 +1,570 @@
/** @odoo-module */
const urlParams = new URLSearchParams(window.location.search);
const collaborationDebug = urlParams.get('collaborationDebug');
const COLLABORATION_LOCALSTORAGE_KEY = 'odoo_editor_collaboration_debug';
if (typeof collaborationDebug === 'string') {
if (collaborationDebug === 'false') {
localStorage.removeItem(
COLLABORATION_LOCALSTORAGE_KEY,
urlParams.get('collaborationDebug'),
);
} else {
localStorage.setItem(COLLABORATION_LOCALSTORAGE_KEY, urlParams.get('collaborationDebug'));
}
}
const debugValue = localStorage.getItem(COLLABORATION_LOCALSTORAGE_KEY);
const debugShowLog = ['', 'true', 'all'].includes(debugValue);
const debugShowNotifications = debugValue === 'all';
const baseNotificationMethods = {
ptp_request: async function(notification) {
const { requestId, requestName, requestPayload, requestTransport } =
notification.notificationPayload;
this._onRequest(
notification.fromClientId,
requestId,
requestName,
requestPayload,
requestTransport,
);
},
ptp_request_result: function(notification) {
const { requestId, result } = notification.notificationPayload;
// If not in _pendingRequestResolver, it means it has timeout.
if (this._pendingRequestResolver[requestId]) {
clearTimeout(this._pendingRequestResolver[requestId].rejectTimeout);
this._pendingRequestResolver[requestId].resolve(result);
delete this._pendingRequestResolver[requestId];
}
},
ptp_join: async function (notification) {
this._createClient(notification.fromClientId);
},
rtc_signal_icecandidate: async function (notification) {
if (debugShowLog) console.log(`%creceive candidate`, 'background: darkgreen; color: white;');
const clientInfos = this.clientsInfos[notification.fromClientId];
if (
!clientInfos ||
!clientInfos.peerConnection ||
clientInfos.peerConnection.connectionState === 'closed'
) {
console.groupCollapsed('=== ERROR: Handle Ice Candidate from undefined|closed ===');
console.trace(clientInfos);
console.groupEnd();
return;
}
if (!clientInfos.peerConnection.remoteDescription) {
clientInfos.iceCandidateBuffer.push(notification.notificationPayload);
} else {
this._addIceCandidate(clientInfos, notification.notificationPayload);
}
},
rtc_signal_description: async function (notification) {
const description = notification.notificationPayload;
if (debugShowLog)
console.log(
`%cdescription received:`,
'background: blueviolet; color: white;',
description,
);
const clientInfos =
this.clientsInfos[notification.fromClientId] ||
this._createClient(notification.fromClientId);
const pc = clientInfos.peerConnection;
if (!pc || pc.connectionState === 'closed') {
if (debugShowLog) {
console.groupCollapsed('=== ERROR: handle offer ===');
console.log(
'An offer has been received for a non-existent peer connection - client: ' +
notification.fromClientId,
);
console.trace(pc.connectionState);
console.groupEnd();
}
return;
}
// Skip if we already have an offer.
if (pc.signalingState === 'have-remote-offer') {
return;
}
// If there is a racing conditing with the signaling offer (two
// being sent at the same time). We need one client that abort by
// rollbacking to a stable signaling state where the other is
// continuing the process. The client that is polite is the one that
// will rollback.
const isPolite =
('' + notification.fromClientId).localeCompare('' + this._currentClientId) === 1;
if (debugShowLog)
console.log(
`%cisPolite: %c${isPolite}`,
'background: deepskyblue;',
`background:${isPolite ? 'green' : 'red'}`,
);
const isOfferRacing =
description.type === 'offer' &&
(clientInfos.makingOffer || pc.signalingState !== 'stable');
// If there is a racing conditing with the signaling offer and the
// client is impolite, we must not process this offer and wait for
// the answer for the signaling process to continue.
if (isOfferRacing && !isPolite) {
if (debugShowLog)
console.log(
`%creturn because isOfferRacing && !isPolite. pc.signalingState: ${pc.signalingState}`,
'background: red;',
);
return;
}
if (debugShowLog) console.log(`%cisOfferRacing: ${isOfferRacing}`, 'background: red;');
if (isOfferRacing) {
if (debugShowLog)
console.log(`%c SETREMOTEDESCRIPTION 1`, 'background: navy; color:white;');
await Promise.all([
pc.setLocalDescription({ type: 'rollback' }),
pc.setRemoteDescription(description),
]);
} else {
if (debugShowLog)
console.log(`%c SETREMOTEDESCRIPTION 2`, 'background: navy; color:white;');
await pc.setRemoteDescription(description);
}
if (clientInfos.iceCandidateBuffer.length) {
for (const candidate of clientInfos.iceCandidateBuffer) {
await this._addIceCandidate(clientInfos, candidate);
clientInfos.iceCandidateBuffer.splice(0);
}
}
if (description.type === 'offer') {
const answerDescription = await pc.createAnswer();
await pc.setLocalDescription(answerDescription);
this.notifyClient(
notification.fromClientId,
'rtc_signal_description',
pc.localDescription,
);
}
},
};
export class PeerToPeer {
constructor(options) {
this.options = options;
this._currentClientId = this.options.currentClientId;
if (debugShowLog)
console.log(
`%c currentClientId:${this._currentClientId}`,
'background: blue; color: white;',
);
// clientId -> ClientInfos
this.clientsInfos = {};
this._lastRequestId = -1;
this._pendingRequestResolver = {};
}
getConnectedClientIds() {
return Object.entries(this.clientsInfos)
.filter(
([id, infos]) =>
infos.peerConnection.iceConnectionState === 'connected' &&
infos.dataChannel.readyState === 'open',
)
.map(([id]) => id);
}
removeClient(clientId) {
if (debugShowLog) console.log(`%c REMOVE CLIENT ${clientId}`, 'background: chocolate;');
this.notifySelf('ptp_remove', clientId);
const clientInfos = this.clientsInfos[clientId];
if (!clientInfos) return;
clearTimeout(clientInfos.fallbackTimeout);
clearTimeout(clientInfos.zombieTimeout);
clientInfos.dataChannel.close();
clientInfos.peerConnection.close();
delete this.clientsInfos[clientId];
}
async closeAllConnections() {
for (const clientId of Object.keys(this.clientsInfos)) {
this.notifyAllClients('ptp_disconnect');
this.removeClient(clientId);
}
}
async notifyAllClients(notificationName, notificationPayload, { transport = 'server' } = {}) {
const transportPayload = {
fromClientId: this._currentClientId,
notificationName,
notificationPayload,
};
if (transport === 'server') {
await this.options.broadcastAll(transportPayload);
} else if (transport === 'rtc') {
for (const cliendId of Object.keys(this.clientsInfos)) {
this._channelNotify(cliendId, transportPayload);
}
} else {
throw new Error(
`Transport "${transport}" is not supported. Use "server" or "rtc" transport.`,
);
}
}
notifyClient(clientId, notificationName, notificationPayload, { transport = 'server' } = {}) {
if (debugShowNotifications) {
if (notificationName === 'ptp_request_result') {
console.log(
`%c${Date.now()} - REQUEST RESULT SEND: %c${transport}:${
notificationPayload.requestId
}:${this._currentClientId.slice('-5')}:${clientId.slice('-5')}`,
'color: #aaa;font-weight:bold;',
'color: #aaa;font-weight:normal',
);
} else if (notificationName === 'ptp_request') {
console.log(
`%c${Date.now()} - REQUEST SEND: %c${transport}:${
notificationPayload.requestName
}|${notificationPayload.requestId}:${this._currentClientId.slice(
'-5',
)}:${clientId.slice('-5')}`,
'color: #aaa;font-weight:bold;',
'color: #aaa;font-weight:normal',
);
} else {
console.log(
`%c${Date.now()} - NOTIFICATION SEND: %c${transport}:${notificationName}:${this._currentClientId.slice(
'-5',
)}:${clientId.slice('-5')}`,
'color: #aaa;font-weight:bold;',
'color: #aaa;font-weight:normal',
);
}
}
const transportPayload = {
fromClientId: this._currentClientId,
toClientId: clientId,
notificationName,
notificationPayload,
};
if (transport === 'server') {
this.options.broadcastAll(transportPayload);
} else if (transport === 'rtc') {
this._channelNotify(clientId, transportPayload);
} else {
throw new Error(
`Transport "${transport}" is not supported. Use "server" or "rtc" transport.`,
);
}
}
notifySelf(notificationName, notificationPayload) {
this.handleNotification({ notificationName, notificationPayload });
}
handleNotification(notification) {
const isInternalNotification =
typeof notification.fromClientId === 'undefined' &&
typeof notification.toClientId === 'undefined';
if (
isInternalNotification ||
(notification.fromClientId !== this._currentClientId && !notification.toClientId) ||
notification.toClientId === this._currentClientId
) {
if (debugShowNotifications) {
if (notification.notificationName === 'ptp_request_result') {
console.log(
`%c${Date.now()} - REQUEST RESULT RECEIVE: %c${
notification.notificationPayload.requestId
}:${notification.fromClientId.slice('-5')}:${notification.toClientId.slice(
'-5',
)}`,
'color: #aaa;font-weight:bold;',
'color: #aaa;font-weight:normal',
);
} else if (notification.notificationName === 'ptp_request') {
console.log(
`%c${Date.now()} - REQUEST RECEIVE: %c${
notification.notificationPayload.requestName
}|${
notification.notificationPayload.requestId
}:${notification.fromClientId.slice('-5')}:${notification.toClientId.slice(
'-5',
)}`,
'color: #aaa;font-weight:bold;',
'color: #aaa;font-weight:normal',
);
} else {
console.log(
`%c${Date.now()} - NOTIFICATION RECEIVE: %c${
notification.notificationName
}:${notification.fromClientId}:${notification.toClientId}`,
'color: #aaa;font-weight:bold;',
'color: #aaa;font-weight:normal',
);
}
}
const baseMethod = baseNotificationMethods[notification.notificationName];
if (baseMethod) {
baseMethod.call(this, notification);
}
if (this.options.onNotification) {
this.options.onNotification(notification);
}
}
}
requestClient(clientId, requestName, requestPayload, { transport = 'server' } = {}) {
return new Promise((resolve, reject) => {
const requestId = this._getRequestId();
const rejectTimeout = setTimeout(() => {
reject('Request took too long (more than 10 seconds).');
delete this._pendingRequestResolver[requestId];
}, 10000);
this._pendingRequestResolver[requestId] = {
resolve,
rejectTimeout,
};
this.notifyClient(
clientId,
'ptp_request',
{
requestId,
requestName,
requestPayload,
requestTransport: transport,
},
{ transport },
);
});
}
_createClient(clientId, { makeOffer = true } = {}) {
if (debugShowLog) console.log('CREATE CONNECTION with client id:', clientId);
this.clientsInfos[clientId] = {
makingOffer: false,
iceCandidateBuffer: [],
};
const pc = new RTCPeerConnection(this.options.peerConnectionConfig);
if (makeOffer) {
pc.onnegotiationneeded = async () => {
if (debugShowLog)
console.log(
`%c NEGONATION NEEDED: ${pc.connectionState}`,
'background: deeppink;',
);
try {
this.clientsInfos[clientId].makingOffer = true;
if (debugShowLog)
console.log(
`%ccreating and sending an offer`,
'background: darkmagenta; color: white;',
);
const offer = await pc.createOffer();
// Avoid race condition.
if (pc.signalingState !== 'stable') {
return;
}
await pc.setLocalDescription(offer);
this.notifyClient(clientId, 'rtc_signal_description', pc.localDescription);
} catch (err) {
console.error(err);
} finally {
this.clientsInfos[clientId].makingOffer = false;
}
};
}
pc.onicecandidate = async event => {
if (event.candidate) {
this.notifyClient(clientId, 'rtc_signal_icecandidate', event.candidate);
}
};
pc.oniceconnectionstatechange = async () => {
if (debugShowLog) console.log('ICE STATE UPDATE: ' + pc.iceConnectionState);
switch (pc.iceConnectionState) {
case 'failed':
case 'closed':
this.removeClient(clientId);
break;
case 'disconnected':
await this._recoverConnection(clientId, {
delay: 1000,
reason: 'ice connection disconnected',
});
break;
}
};
// This event does not work in FF. Let's try with oniceconnectionstatechange if it is sufficient.
pc.onconnectionstatechange = async () => {
if (debugShowLog) console.log('CONNECTION STATE UPDATE:' + pc.connectionState);
switch (pc.connectionState) {
case 'failed':
case 'closed':
this.removeClient(clientId);
break;
case 'disconnected':
await this._recoverConnection(clientId, {
delay: 500,
reason: 'connection disconnected',
});
break;
}
};
pc.onicecandidateerror = async error => {
if (debugShowLog) {
console.groupCollapsed('=== ERROR: onIceCandidate ===');
console.log(
'connectionState: ' +
pc.connectionState +
' - iceState: ' +
pc.iceConnectionState,
);
console.trace(error);
console.groupEnd();
}
this._recoverConnection(clientId, { delay: 15000, reason: 'ice candidate error' });
};
const dataChannel = pc.createDataChannel('notifications', { negotiated: true, id: 1 });
let message = [];
dataChannel.onmessage = event => {
if (event.data !== '-') {
message.push(event.data);
} else {
this.handleNotification(JSON.parse(message.join('')));
message = [];
}
};
dataChannel.onopen = event => {
this.notifySelf('rtc_data_channel_open', {
connectionClientId: clientId,
});
};
this.clientsInfos[clientId].peerConnection = pc;
this.clientsInfos[clientId].dataChannel = dataChannel;
return this.clientsInfos[clientId];
}
async _addIceCandidate(clientInfos, candidate) {
const rtcIceCandidate = new RTCIceCandidate(candidate);
try {
await clientInfos.peerConnection.addIceCandidate(rtcIceCandidate);
} catch (error) {
// Ignored.
console.groupCollapsed('=== ERROR: ADD ICE CANDIDATE ===');
console.trace(error);
console.groupEnd();
}
}
_channelNotify(clientId, transportPayload) {
const clientInfo = this.clientsInfos[clientId];
const dataChannel = clientInfo && clientInfo.dataChannel;
if (!dataChannel || dataChannel.readyState !== 'open') {
if (clientInfo && !clientInfo.zombieTimeout) {
if (debugShowLog) console.warn(
`Impossible to communicate with client ${clientId}. The connection be killed in 10 seconds if the datachannel state has not changed.`,
);
this._killPotentialZombie(clientId);
}
} else {
const str = JSON.stringify(transportPayload);
const size = str.length;
const maxStringLength = 5000;
let from = 0;
let to = maxStringLength;
while (from < size) {
dataChannel.send(str.slice(from, to));
from = to;
to = to += maxStringLength;
}
dataChannel.send('-');
}
}
_getRequestId() {
this._lastRequestId++;
return this._lastRequestId;
}
async _onRequest(fromClientId, requestId, requestName, requestPayload, requestTransport) {
const requestFunction = this.options.onRequest && this.options.onRequest[requestName];
const result = await requestFunction({
fromClientId,
requestId,
requestName,
requestPayload,
});
this.notifyClient(
fromClientId,
'ptp_request_result',
{ requestId, result },
{ transport: requestTransport },
);
}
/**
* Attempts a connection recovery by updating the tracks, which will start a new transaction:
* negotiationneeded -> offer -> answer -> ...
*
* @private
* @param {Object} [param1]
* @param {number} [param1.delay] in ms
* @param {string} [param1.reason]
*/
_recoverConnection(clientId, { delay = 0, reason = '' } = {}) {
const clientInfos = this.clientsInfos[clientId];
if (!clientInfos || clientInfos.fallbackTimeout) return;
clientInfos.fallbackTimeout = setTimeout(async () => {
clientInfos.fallbackTimeout = undefined;
const pc = clientInfos.peerConnection;
if (!pc || pc.iceConnectionState === 'connected') {
return;
}
if (['connected', 'closed'].includes(pc.connectionState)) {
return;
}
// hard reset: recreating a RTCPeerConnection
if (debugShowLog)
console.log(
`%c RTC RECOVERY: calling back client ${clientId} to salvage the connection ${pc.iceConnectionState}, reason: ${reason}`,
'background: darkorange; color: white;',
);
this.removeClient(clientId);
await this._createClient(clientId);
}, delay);
}
// todo: do we try to salvage the connection after killing the zombie ?
// Maybe the salvage should be done when the connection is dropped.
_killPotentialZombie(clientId) {
const clientInfos = this.clientsInfos[clientId];
if (!clientInfos || clientInfos.zombieTimeout) {
return;
}
// If there is no connection after 10 seconds, terminate.
clientInfos.zombieTimeout = setTimeout(() => {
if (clientInfos && clientInfos.dataChannel.readyState !== 'open') {
if (debugShowLog) console.log(`%c KILL ZOMBIE ${clientId}`, 'background: red;');
this.removeClient(clientId);
} else {
if (debugShowLog) console.log(`%c NOT A ZOMBIE ${clientId}`, 'background: green;');
}
}, 10000);
}
}
@@ -5,7 +5,7 @@ const Link = require('wysiwyg.widgets.Link');
const OdooEditorLib = require('@web_editor/../lib/odoo-editor/src/OdooEditor');
const dom = require('web.dom');
const setCursor = OdooEditorLib.setCursor;
const setSelection = OdooEditorLib.setSelection;
/**
* Allows to customize link content and style.
@@ -48,7 +48,7 @@ const LinkTools = Link.extend({
this.$button.removeClass('active');
this.options.wysiwyg.odooEditor.observerActive();
this.applyLinkToDom(this._getData());
setCursor(this.$link[0], 0, this.$link[0], 1);
setSelection(this.$link[0], 0, this.$link[0], 1);
this.options.wysiwyg.odooEditor.historyStep();
this._super(...arguments);
},
@@ -15,6 +15,8 @@ const Toolbar = require('web_editor.toolbar');
const weWidgets = require('wysiwyg.widgets');
const wysiwygUtils = require('@web_editor/js/wysiwyg/wysiwyg_utils');
const weUtils = require('web_editor.utils');
const { PeerToPeer } = require('@web_editor/js/wysiwyg/PeerToPeer');
const { Mutex } = require('web.concurrency');
var _t = core._t;
@@ -72,7 +74,14 @@ const Wysiwyg = Widget.extend({
this.toolbar = new Toolbar(this, this.options.toolbarTemplate);
await this.toolbar.appendTo(document.createElement('void'));
const commands = this._getCommands();
this.odooEditor = new OdooEditor(this.$editable[0], {
let editorCollaborationOptions;
if (options.collaborationChannel) {
editorCollaborationOptions = this.setupCollaboration(options.collaborationChannel);
}
this.odooEditor = new OdooEditor(this.$editable[0], Object.assign({
_t: _t,
toolbar: this.toolbar.$el[0],
document: this.options.document,
@@ -96,7 +105,18 @@ const Wysiwyg = Widget.extend({
},
noScrollSelector: 'body, .note-editable, .o_content, #wrapwrap',
commands: commands,
});
}, editorCollaborationOptions));
const $wrapwrap = $('#wrapwrap');
if ($wrapwrap.length) {
$wrapwrap[0].addEventListener('scroll', this.odooEditor.multiselectionRefresh, { passive: true });
}
if (this._peerToPeerLoading) {
// Now that the editor is loaded, wait for the peerToPeer to be
// ready to join.
this._peerToPeerLoading.then(() => this.ptp.notifyAllClients('ptp_join'));
}
this._observeOdooFieldChanges();
this.$editable.on(
@@ -193,6 +213,219 @@ const Wysiwyg = Widget.extend({
}
});
},
setupCollaboration(collaborationChannel) {
const modelName = collaborationChannel.collaborationModelName;
const fieldName = collaborationChannel.collaborationFieldName;
const resId = collaborationChannel.collaborationResId;
if (!(modelName && fieldName && resId)) {
return;
}
const startCollaborationTime = new Date().getTime();
// No need for secure random number.
const currentClientId = Math.floor(Math.random() * Math.pow(2, 52)).toString();
this._collaborationChannelName = `editor_collaboration:${modelName}:${fieldName}:${resId}`;
this.call('bus_service', 'onNotification', this, (notifications) => {
for (const [channel, busData] of notifications) {
if (
channel[1] == 'editor_collaboration' &&
channel[2] === modelName &&
channel[3] === fieldName &&
channel[4] === resId
) {
this._peerToPeerLoading.then(() => this.ptp.handleNotification(busData));
}
}
});
this.call('bus_service', 'addChannel', this._collaborationChannelName);
this.call('bus_service', 'startPolling');
// Wether or not the history has been sent or received at least once.
let historySyncAtLeastOnce = false;
let historySyncFinished = false;
// const syncHistory = async (fromClientId) => {
// }
// Check wether clientA is before clientB.
const isClientFirst = (clientA, clientB) => {
if (clientA.startTime === clientB.startTime) {
return clientA.id.localCompare(clientB.id) === -1;
} else {
return clientA.startTime < clientB.startTime;
}
}
const rpcMutex = new Mutex();
this._peerToPeerLoading = new Promise(async (resolve) => {
let iceServers = await this._rpc({route: '/web_editor/get_ice_servers'});
if (!iceServers.length) {
iceServers = [
{
urls: [
'stun:stun1.l.google.com:19302',
'stun:stun2.l.google.com:19302',
],
}
];
}
this.ptp = new PeerToPeer({
peerConnectionConfig: { iceServers },
currentClientId: currentClientId,
broadcastAll: (rpcData) => {
return rpcMutex.exec(async () => {
return this._rpc({
route: '/web_editor/bus_broadcast',
params: {
model_name: modelName,
field_name: fieldName,
res_id: resId,
bus_data: rpcData,
},
});
})
},
onRequest: {
get_start_time: () => startCollaborationTime,
get_client_name: async () => {
if (!this._userName) {
this._userName = (await this._rpc({
model: "res.users",
method: "search_read",
args: [
[['id', '=', this.getSession().user_id]],
['name']
],
}))[0].name;
}
return this._userName;
},
get_missing_steps: (params) => this.odooEditor.historyGetMissingSteps(params.requestPayload),
get_history_from_snapshot: () => this.odooEditor.historyGetSnapshotSteps(),
get_collaborative_selection: () => this.odooEditor.getCurrentCollaborativeSelection(),
},
onNotification: async ({ fromClientId, notificationName, notificationPayload }) => {
switch (notificationName) {
case 'ptp_remove':
this.odooEditor.multiselectionRemove(notificationPayload);
break;
case 'ptp_disconnect':
this.ptp.removeClient(fromClientId);
this.odooEditor.multiselectionRemove(fromClientId);
break;
case 'rtc_data_channel_open':
fromClientId = notificationPayload.connectionClientId;
const remoteStartTime = await this.ptp.requestClient(fromClientId, 'get_start_time', undefined, { transport: 'rtc' });
this.ptp.clientsInfos[fromClientId].startTime = remoteStartTime;
this.ptp.requestClient(fromClientId, 'get_client_name', undefined, { transport: 'rtc' }).then((clientName) => {
this.ptp.clientsInfos[fromClientId].clientName = clientName;
});
if (!historySyncAtLeastOnce) {
historySyncAtLeastOnce = true;
await editorCollaborationOptions.onHistoryNeedSync();
historySyncFinished = true;
} else {
const remoteSelection = await this.ptp.requestClient(fromClientId, 'get_collaborative_selection', undefined, { transport: 'rtc' });
if (remoteSelection) {
this.odooEditor.onExternalMultiselectionUpdate(remoteSelection);
}
}
break;
case 'oe_history_step':
// Avoid race condition where the step is received
// before the history has synced at least once.
if (historySyncFinished) {
this.odooEditor.onExternalHistorySteps([notificationPayload]);
}
break;
case 'oe_history_set_selection':
const client = this.ptp.clientsInfos[fromClientId];
if (!client) {
return;
}
const selection = notificationPayload;
selection.clientName = client.clientName;
this.odooEditor.onExternalMultiselectionUpdate(selection);
break;
}
}
});
resolve();
});
const editorCollaborationOptions = {
collaborationClientId: currentClientId,
onHistoryStep: (historyStep) => {
if (!this.ptp) return;
this.ptp.notifyAllClients('oe_history_step', historyStep, { transport: 'rtc' });
},
onCollaborativeSelectionChange: _.throttle((collaborativeSelection) => {
if (!this.ptp) return;
this.ptp.notifyAllClients('oe_history_set_selection', collaborativeSelection, { transport: 'rtc' });
}, 50),
onHistoryMissingParentSteps: async ({ step, fromStepId }) => {
if (!this.ptp) return;
const missingSteps = await this.ptp.requestClient(
step.clientId,
'get_missing_steps', {
fromStepId: fromStepId,
toStepId: step.id
},
{ transport: 'rtc' }
);
if (missingSteps === -1 || !missingSteps.length) {
// This case should never happen.
console.warn('Editor get_missing_steps result is erroneous.');
return;
}
this.ptp && this.odooEditor.onExternalHistorySteps(missingSteps.concat([step]));
},
onHistoryNeedSync: async () => {
if (!this.ptp) return;
let firstClientId = currentClientId;
let firstClientStartTime = startCollaborationTime;
const connectedClientIds = this.ptp.getConnectedClientIds();
for (const clientId of connectedClientIds) {
const clientInfo = this.ptp.clientsInfos[clientId];
// Ensure we already retreived remote client starting time.
if (!clientInfo.startTime) {
continue;
}
const isCurrentClientFirst = isClientFirst(
{
startTime: clientInfo.startTime,
id: clientId,
},
{
startTime: firstClientStartTime,
id: firstClientId,
}
);
if (isCurrentClientFirst) {
firstClientStartTime = clientInfo.startTime;
firstClientId = clientId;
}
}
if (firstClientId !== currentClientId) {
const historySteps = await this.ptp.requestClient(firstClientId, 'get_history_from_snapshot', undefined, { transport: 'rtc' });
this.odooEditor.historyResetFromSteps(historySteps);
const remoteSelection = await this.ptp.requestClient(firstClientId, 'get_collaborative_selection', undefined, { transport: 'rtc' });
if (remoteSelection) {
this.odooEditor.onExternalMultiselectionUpdate(remoteSelection);
}
}
},
}
return editorCollaborationOptions;
},
/**
* @override
*/
@@ -200,11 +433,22 @@ const Wysiwyg = Widget.extend({
if (this.odooEditor) {
this.odooEditor.destroy();
}
// If peer to peer is initializing, wait for properly closing it.
if (this._peerToPeerLoading) {
this._peerToPeerLoading.then(()=> {
this.call('bus_service', 'deleteChannel', this._collaborationChannelName);
this.ptp.closeAllConnections();
});
}
this.$editable && this.$editable.off('blur', this._onBlur);
document.removeEventListener('mousedown', this._onDocumentMousedown, true);
const $body = $(document.body);
$body.off('mousemove', this.resizerMousemove);
$body.off('mouseup', this.resizerMouseup);
const $wrapwrap = $('#wrapwrap');
if ($wrapwrap.length) {
$('#wrapwrap')[0].removeEventListener('scroll', this.odooEditor.multiselectionRefresh, { passive: true });
}
this._super();
},
/**
@@ -442,7 +686,7 @@ const Wysiwyg = Widget.extend({
* Set cursor to the editor latest position before blur or to the last editable node, ready to type.
*/
focus: function () {
if(!this.odooEditor.resetCursorOnLastHistoryCursor()) {
if(!this.odooEditor.historyResetLatestComputedSelection()) {
// If the editor don't have an history step to focus to,
// We place the cursor after the end of the editor exiting content.
const range = document.createRange();
@@ -805,11 +1049,11 @@ const Wysiwyg = Widget.extend({
setTimeout(() => {
const scrollableContainer = this.$el.scrollParent();
if (!options.snippets && scrollableContainer.length) {
this.odooEditor.addDomListener(
scrollableContainer[0],
'scroll',
this.odooEditor.updateToolbarPosition.bind(this.odooEditor),
);
// this.odooEditor.addDomListener(
// scrollableContainer[0],
// 'scroll',
// this.odooEditor.updateToolbarPosition.bind(this.odooEditor),
// );
}
}, 0);
},
@@ -1,6 +1,7 @@
.oe_form_field_html {
position: relative;
word-wrap: break-word;
overflow: hidden;
.odoo-editor #codeview-btn-group {
position: absolute;
+16 -2
View File
@@ -358,13 +358,27 @@ var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
* @private
*/
_wysiwygInstance: function () {
// todo: retrieve other config if there is no #wrap element on the page (eg. product, blog, ect.)
let collaborationConfig = {};
// todo: To uncomment when enabling the collaboration on website.
// const $wrap = $('#wrapwrap #wrap[data-oe-model][data-oe-field][data-oe-id]');
// if ($wrap.length) {
// collaborationConfig = {
// collaborationChannel: {
// collaborationModelName: $wrap.attr('data-oe-model'),
// collaborationFieldName: $wrap.attr('data-oe-field'),
// collaborationResId: parseInt($wrap.attr('data-oe-id')),
// }
// };
// }
var context;
this.trigger_up('context_get', {
callback: function (ctx) {
context = ctx;
},
});
const params = {
const params = Object.assign({
snippets: 'website.snippets',
recordInfo: {
context: context,
@@ -379,7 +393,7 @@ var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
isRootEditable: false,
controlHistoryFromDocument: true,
getContentEditableAreas: this._getContentEditableAreas.bind(this),
};
}, collaborationConfig);
return wysiwygLoader.createWysiwyg(this,
Object.assign(params, this.wysiwygOptions),
['website.compiled_assets_wysiwyg']