[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:
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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("xss")"></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;
|
||||
|
||||
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user