[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.
|
widget to apply, depending on the current company.
|
||||||
"""
|
"""
|
||||||
result = super(Http, self).session_info()
|
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
|
company_ids = request.env.user.company_ids
|
||||||
|
|
||||||
for company in 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.
|
""" To be overridable if we need to obtain credentials from another source.
|
||||||
:return: tuple
|
:return: tuple
|
||||||
"""
|
"""
|
||||||
account_sid = self.env['ir.config_parameter'].get_param('mail.twilio_account_sid')
|
account_sid = self.env['ir.config_parameter'].sudo().get_param('mail.twilio_account_sid')
|
||||||
auth_token = self.env['ir.config_parameter'].get_param('mail.twilio_account_token')
|
auth_token = self.env['ir.config_parameter'].sudo().get_param('mail.twilio_account_token')
|
||||||
return account_sid, auth_token
|
return account_sid, auth_token
|
||||||
|
|
||||||
def _get_ice_servers(self):
|
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,
|
:return: List of dict, each of which representing a stun or turn server,
|
||||||
formatted as expected by the specifications of RTCConfiguration.iceServers
|
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()
|
(account_sid, auth_token) = self._get_twilio_credentials()
|
||||||
if account_sid and auth_token:
|
if account_sid and auth_token:
|
||||||
url = f'https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Tokens.json'
|
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': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'data/editor_assets.xml',
|
'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/sanitize.js',
|
||||||
'web_editor/static/lib/odoo-editor/src/utils/serialize.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/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/tablepicker/TablePicker.js',
|
||||||
'web_editor/static/lib/odoo-editor/src/powerbox/patienceDiff.js',
|
'web_editor/static/lib/odoo-editor/src/powerbox/patienceDiff.js',
|
||||||
'web_editor/static/lib/odoo-editor/src/powerbox/Powerbox.js',
|
'web_editor/static/lib/odoo-editor/src/powerbox/Powerbox.js',
|
||||||
@@ -57,6 +58,7 @@ Odoo Web Editor widget.
|
|||||||
|
|
||||||
# utils
|
# utils
|
||||||
'web_editor/static/src/js/wysiwyg/wysiwyg_utils.js',
|
'web_editor/static/src/js/wysiwyg/wysiwyg_utils.js',
|
||||||
|
'web_editor/static/src/js/wysiwyg/PeerToPeer.js',
|
||||||
|
|
||||||
# odoo utils
|
# odoo utils
|
||||||
('include', 'web._assets_helpers'),
|
('include', 'web._assets_helpers'),
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
from . import main
|
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())
|
attachments.append(attachment._get_media_info())
|
||||||
|
|
||||||
return attachments
|
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,
|
leftLeafFirstPath,
|
||||||
preserveCursor,
|
preserveCursor,
|
||||||
rightPos,
|
rightPos,
|
||||||
setCursor,
|
setSelection,
|
||||||
setCursorStart,
|
setCursorStart,
|
||||||
setTagName,
|
setTagName,
|
||||||
splitElement,
|
splitElement,
|
||||||
@@ -256,9 +256,9 @@ export function applyInlineStyle(editor, applyStyle) {
|
|||||||
const firstNode = selectedTextNodes[0];
|
const firstNode = selectedTextNodes[0];
|
||||||
const lastNode = selectedTextNodes[selectedTextNodes.length - 1];
|
const lastNode = selectedTextNodes[selectedTextNodes.length - 1];
|
||||||
if (direction === DIRECTIONS.RIGHT) {
|
if (direction === DIRECTIONS.RIGHT) {
|
||||||
setCursor(firstNode, 0, lastNode, lastNode.length);
|
setSelection(firstNode, 0, lastNode, lastNode.length);
|
||||||
} else {
|
} else {
|
||||||
setCursor(lastNode, lastNode.length, firstNode, 0);
|
setSelection(lastNode, lastNode.length, firstNode, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function addColumn(editor, beforeOrAfter) {
|
function addColumn(editor, beforeOrAfter) {
|
||||||
@@ -285,7 +285,7 @@ function deleteTable(editor, table) {
|
|||||||
p.appendChild(document.createElement('br'));
|
p.appendChild(document.createElement('br'));
|
||||||
table.before(p);
|
table.before(p);
|
||||||
table.remove();
|
table.remove();
|
||||||
setCursor(p, 0);
|
setSelection(p, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is a whitelist of the commands that are implemented by the
|
// 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];
|
const insertedNode = editorCommands.insertHTML(editor, '<i></i>')[0];
|
||||||
insertedNode.className = faClass;
|
insertedNode.className = faClass;
|
||||||
const position = rightPos(insertedNode);
|
const position = rightPos(insertedNode);
|
||||||
setCursor(...position, ...position, false);
|
setSelection(...position, ...position, false);
|
||||||
},
|
},
|
||||||
|
|
||||||
// History
|
// History
|
||||||
@@ -324,7 +324,7 @@ export const editorCommands = {
|
|||||||
block.nodeName,
|
block.nodeName,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
setCursor(block, 0, block, nodeSize(block));
|
setSelection(block, 0, block, nodeSize(block));
|
||||||
editor.historyPauseSteps();
|
editor.historyPauseSteps();
|
||||||
editor.document.execCommand('removeFormat');
|
editor.document.execCommand('removeFormat');
|
||||||
editor.historyUnpauseSteps();
|
editor.historyUnpauseSteps();
|
||||||
@@ -394,13 +394,13 @@ export const editorCommands = {
|
|||||||
link = link || prompt('URL or Email', (currentLink && currentLink.href) || 'http://');
|
link = link || prompt('URL or Email', (currentLink && currentLink.href) || 'http://');
|
||||||
const res = editor.document.execCommand('createLink', false, link);
|
const res = editor.document.execCommand('createLink', false, link);
|
||||||
if (res) {
|
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');
|
const node = findNode(closestPath(sel.focusNode), node => node.tagName === 'A');
|
||||||
for (const [param, value] of Object.entries(editor.options.defaultLinkAttributes)) {
|
for (const [param, value] of Object.entries(editor.options.defaultLinkAttributes)) {
|
||||||
node.setAttribute(param, `${value}`);
|
node.setAttribute(param, `${value}`);
|
||||||
}
|
}
|
||||||
const pos = [node.parentElement, childNodeIndex(node) + 1];
|
const pos = [node.parentElement, childNodeIndex(node) + 1];
|
||||||
setCursor(...pos, ...pos, false);
|
setSelection(...pos, ...pos, false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
unlink: editor => {
|
unlink: editor => {
|
||||||
@@ -415,12 +415,12 @@ export const editorCommands = {
|
|||||||
if (sel.isCollapsed) {
|
if (sel.isCollapsed) {
|
||||||
const cr = preserveCursor(editor.document);
|
const cr = preserveCursor(editor.document);
|
||||||
const node = closestElement(sel.focusNode, 'a');
|
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');
|
editor.document.execCommand('unlink');
|
||||||
cr();
|
cr();
|
||||||
} else {
|
} else {
|
||||||
editor.document.execCommand('unlink');
|
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
|
? splitTextNode(anchorNode, sel.anchorOffset, DIRECTIONS.LEFT) + 1 && anchorNode
|
||||||
: splitElement(anchorNode, sel.anchorOffset).shift();
|
: splitElement(anchorNode, sel.anchorOffset).shift();
|
||||||
const newPosition = rightPos(newAnchorNode);
|
const newPosition = rightPos(newAnchorNode);
|
||||||
setCursor(...newPosition, ...newPosition, false);
|
setSelection(...newPosition, ...newPosition, false);
|
||||||
}
|
}
|
||||||
const [table] = editorCommands.insertHTML(editor, tableHtml);
|
const [table] = editorCommands.insertHTML(editor, tableHtml);
|
||||||
setCursorStart(table.querySelector('td'));
|
setCursorStart(table.querySelector('td'));
|
||||||
@@ -587,7 +587,7 @@ export const editorCommands = {
|
|||||||
const index = cells.findIndex(td => td === cell);
|
const index = cells.findIndex(td => td === cell);
|
||||||
const siblingCell = cells[index - 1] || cells[index + 1];
|
const siblingCell = cells[index - 1] || cells[index + 1];
|
||||||
table.querySelectorAll(`tr td:nth-of-type(${index + 1})`).forEach(td => td.remove());
|
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 => {
|
removeRow: editor => {
|
||||||
getDeepRange(editor.editable, { select: true }); // Ensure deep range for finding tr.
|
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 rowIndex = rows.findIndex(tr => tr === row);
|
||||||
const siblingRow = rows[rowIndex - 1] || rows[rowIndex + 1];
|
const siblingRow = rows[rowIndex - 1] || rows[rowIndex + 1];
|
||||||
row.remove();
|
row.remove();
|
||||||
siblingRow ? setCursor(...startPos(siblingRow)) : deleteTable(editor, table);
|
siblingRow ? setSelection(...startPos(siblingRow)) : deleteTable(editor, table);
|
||||||
},
|
},
|
||||||
deleteTable: (editor, table) => deleteTable(editor, table),
|
deleteTable: (editor, table) => deleteTable(editor, table),
|
||||||
insertHorizontalRule(editor) {
|
insertHorizontalRule(editor) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
moveNodes,
|
moveNodes,
|
||||||
nodeSize,
|
nodeSize,
|
||||||
prepareUpdate,
|
prepareUpdate,
|
||||||
setCursor,
|
setSelection,
|
||||||
splitTextNode,
|
splitTextNode,
|
||||||
isUnbreakable,
|
isUnbreakable,
|
||||||
isMediaElement,
|
isMediaElement,
|
||||||
@@ -56,7 +56,7 @@ Text.prototype.oDeleteBackward = function (offset, alreadyMoved = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fillEmpty(parentNode);
|
fillEmpty(parentNode);
|
||||||
setCursor(parentNode, firstSplitOffset);
|
setSelection(parentNode, firstSplitOffset);
|
||||||
};
|
};
|
||||||
|
|
||||||
HTMLElement.prototype.oDeleteBackward = function (offset, alreadyMoved = false) {
|
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
|
// TODO this handle BR/IMG/etc removals../ to see if we
|
||||||
// prefer to have a dedicated handler for every possible
|
// prefer to have a dedicated handler for every possible
|
||||||
// HTML element or if we let this generic code handle it.
|
// HTML element or if we let this generic code handle it.
|
||||||
setCursor(parentEl, parentOffset);
|
setSelection(parentEl, parentOffset);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,7 +158,7 @@ HTMLElement.prototype.oDeleteBackward = function (offset, alreadyMoved = false)
|
|||||||
firstBlockIndex++;
|
firstBlockIndex++;
|
||||||
}
|
}
|
||||||
let [cursorNode, cursorOffset] = moveNodes(...moveDest, this, offset, 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
|
// Propagate if this is still a block on the left of where the nodes were
|
||||||
// moved.
|
// moved.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
isFakeLineBreak,
|
isFakeLineBreak,
|
||||||
prepareUpdate,
|
prepareUpdate,
|
||||||
rightPos,
|
rightPos,
|
||||||
setCursor,
|
setSelection,
|
||||||
getState,
|
getState,
|
||||||
leftPos,
|
leftPos,
|
||||||
splitTextNode,
|
splitTextNode,
|
||||||
@@ -35,7 +35,7 @@ HTMLElement.prototype.oShiftEnter = function (offset) {
|
|||||||
|
|
||||||
for (const el of brEls) {
|
for (const el of brEls) {
|
||||||
if (el.parentNode) {
|
if (el.parentNode) {
|
||||||
setCursor(...rightPos(el));
|
setSelection(...rightPos(el));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ HTMLElement.prototype.oToggleList = function (offset, mode = 'UL') {
|
|||||||
return inLI.oToggleList(0, mode);
|
return inLI.oToggleList(0, mode);
|
||||||
}
|
}
|
||||||
const restoreCursor = preserveCursor(this.ownerDocument);
|
const restoreCursor = preserveCursor(this.ownerDocument);
|
||||||
// if `this` is the root editable
|
if (this.oid === 'root') {
|
||||||
if (this.oid === 1) {
|
|
||||||
const callingNode = this.childNodes[offset];
|
const callingNode = this.childNodes[offset];
|
||||||
const group = getAdjacents(callingNode, n => !isBlock(n));
|
const group = getAdjacents(callingNode, n => !isBlock(n));
|
||||||
insertListAfter(callingNode, mode, [group]);
|
insertListAfter(callingNode, mode, [group]);
|
||||||
|
|||||||
@@ -301,3 +301,30 @@ i.oe-commandbar-commandImg {
|
|||||||
text-align: inherit;
|
text-align: inherit;
|
||||||
width: 100%;
|
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 **/
|
/** @odoo-module **/
|
||||||
// TODO: avoid empty keys when not necessary to reduce request size
|
// TODO: avoid empty keys when not necessary to reduce request size
|
||||||
export function nodeToObject(node) {
|
export function serializeNode(node, nodesToStripFromChildren = new Set()) {
|
||||||
let result = {
|
let result = {
|
||||||
nodeType: node.nodeType,
|
nodeType: node.nodeType,
|
||||||
oid: node.oid,
|
oid: node.oid,
|
||||||
};
|
};
|
||||||
if (!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) {
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
result.textValue = node.nodeValue;
|
result.textValue = node.nodeValue;
|
||||||
@@ -19,14 +19,16 @@ export function nodeToObject(node) {
|
|||||||
}
|
}
|
||||||
let child = node.firstChild;
|
let child = node.firstChild;
|
||||||
while (child) {
|
while (child) {
|
||||||
result.children.push(nodeToObject(child));
|
if (!nodesToStripFromChildren.has(child.oid)) {
|
||||||
|
result.children.push(serializeNode(child, nodesToStripFromChildren));
|
||||||
|
}
|
||||||
child = child.nextSibling;
|
child = child.nextSibling;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function objectToNode(obj) {
|
export function unserializeNode(obj) {
|
||||||
let result = undefined;
|
let result = undefined;
|
||||||
if (obj.nodeType === Node.TEXT_NODE) {
|
if (obj.nodeType === Node.TEXT_NODE) {
|
||||||
result = document.createTextNode(obj.textValue);
|
result = document.createTextNode(obj.textValue);
|
||||||
@@ -35,10 +37,36 @@ export function objectToNode(obj) {
|
|||||||
for (const key in obj.attributes) {
|
for (const key in obj.attributes) {
|
||||||
result.setAttribute(key, obj.attributes[key]);
|
result.setAttribute(key, obj.attributes[key]);
|
||||||
}
|
}
|
||||||
obj.children.forEach(child => result.append(objectToNode(child)));
|
obj.children.forEach(child => result.append(unserializeNode(child)));
|
||||||
} else {
|
} else {
|
||||||
console.warn('unknown node type');
|
console.warn('unknown node type');
|
||||||
}
|
}
|
||||||
result.oid = obj.oid;
|
result.oid = obj.oid;
|
||||||
return result;
|
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]
|
* @param {boolean} [normalize=true]
|
||||||
* @returns {?Array.<Node, number}
|
* @returns {?Array.<Node, number}
|
||||||
*/
|
*/
|
||||||
export function setCursor(
|
export function setSelection(
|
||||||
anchorNode,
|
anchorNode,
|
||||||
anchorOffset,
|
anchorOffset,
|
||||||
focusNode = anchorNode,
|
focusNode = anchorNode,
|
||||||
@@ -551,7 +551,7 @@ export function setCursor(
|
|||||||
*/
|
*/
|
||||||
export function setCursorStart(node, normalize = true) {
|
export function setCursorStart(node, normalize = true) {
|
||||||
const pos = startPos(node);
|
const pos = startPos(node);
|
||||||
return setCursor(...pos, ...pos, normalize);
|
return setSelection(...pos, ...pos, normalize);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* @param {Node} node
|
* @param {Node} node
|
||||||
@@ -560,7 +560,7 @@ export function setCursorStart(node, normalize = true) {
|
|||||||
*/
|
*/
|
||||||
export function setCursorEnd(node, normalize = true) {
|
export function setCursorEnd(node, normalize = true) {
|
||||||
const pos = endPos(node);
|
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.
|
* 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();
|
replace = replace || new Map();
|
||||||
cursorPos[0] = replace.get(cursorPos[0]) || cursorPos[0];
|
cursorPos[0] = replace.get(cursorPos[0]) || cursorPos[0];
|
||||||
cursorPos[2] = replace.get(cursorPos[2]) || cursorPos[2];
|
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) {
|
export function insertText(sel, content) {
|
||||||
if (sel.anchorNode.nodeType == Node.TEXT_NODE) {
|
if (sel.anchorNode.nodeType == Node.TEXT_NODE) {
|
||||||
const pos = [sel.anchorNode.parentElement, splitTextNode(sel.anchorNode, sel.anchorOffset)];
|
const pos = [sel.anchorNode.parentElement, splitTextNode(sel.anchorNode, sel.anchorOffset)];
|
||||||
setCursor(...pos, ...pos, false);
|
setSelection(...pos, ...pos, false);
|
||||||
}
|
}
|
||||||
const txt = document.createTextNode(content || '#');
|
const txt = document.createTextNode(content || '#');
|
||||||
const restore = prepareUpdate(sel.anchorNode, sel.anchorOffset);
|
const restore = prepareUpdate(sel.anchorNode, sel.anchorOffset);
|
||||||
sel.getRangeAt(0).insertNode(txt);
|
sel.getRangeAt(0).insertNode(txt);
|
||||||
restore();
|
restore();
|
||||||
setCursor(...boundariesOut(txt), false);
|
setSelection(...boundariesOut(txt), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1938,3 +1938,9 @@ export const rightLeafOnlyNotBlockNotEditablePath = createDOMPathGenerator(DIREC
|
|||||||
stopTraverseFunction: node => isNotEditableNode(node) || isBlock(node),
|
stopTraverseFunction: node => isNotEditableNode(node) || isBlock(node),
|
||||||
stopFunction: node => isBlock(node) && !isNotEditableNode(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>
|
<title>Odoo Editor tests</title>
|
||||||
|
|
||||||
<link rel="stylesheet" href="lib/mocha.css" />
|
<link rel="stylesheet" href="lib/mocha.css" />
|
||||||
|
<script src="../src/utils/DOMPurify.js"></script>
|
||||||
<script src="lib/mocha.js"></script>
|
<script src="lib/mocha.js"></script>
|
||||||
<script src="lib/chai.js"></script>
|
<script src="lib/chai.js"></script>
|
||||||
<script>mocha.setup('bdd').fullTrace()</script>
|
<script>mocha.setup('bdd').fullTrace()</script>
|
||||||
@@ -15,5 +16,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="mocha"></div>
|
<div id="mocha"></div>
|
||||||
|
<div id="editor-test-container"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import './spec/insertHTML.test.js';
|
|||||||
import './spec/fontAwesome.test.js';
|
import './spec/fontAwesome.test.js';
|
||||||
import './spec/autostep.test.js';
|
import './spec/autostep.test.js';
|
||||||
import './spec/urlRegex.test.js';
|
import './spec/urlRegex.test.js';
|
||||||
|
import './spec/collab.test.js';
|
||||||
|
|
||||||
mocha.run(failures => {
|
mocha.run(failures => {
|
||||||
if (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,
|
previousLeaf,
|
||||||
rightPos,
|
rightPos,
|
||||||
startPos,
|
startPos,
|
||||||
setCursor,
|
setSelection,
|
||||||
setCursorStart,
|
setCursorStart,
|
||||||
setCursorEnd,
|
setCursorEnd,
|
||||||
getCursorDirection,
|
getCursorDirection,
|
||||||
@@ -707,7 +707,7 @@ describe('Utils', () => {
|
|||||||
describe('collapsed', () => {
|
describe('collapsed', () => {
|
||||||
it('should collapse the cursor at the beginning of an element', () => {
|
it('should collapse the cursor at the beginning of an element', () => {
|
||||||
const [p] = insertTestHtml('<p>abc</p>');
|
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]);
|
window.chai.expect(result).to.eql([p.firstChild, 0, p.firstChild, 0]);
|
||||||
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
||||||
document.getSelection();
|
document.getSelection();
|
||||||
@@ -717,7 +717,7 @@ describe('Utils', () => {
|
|||||||
});
|
});
|
||||||
it('should collapse the cursor within an element', () => {
|
it('should collapse the cursor within an element', () => {
|
||||||
const [p] = insertTestHtml('<p>abcd</p>');
|
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]);
|
window.chai.expect(result).to.eql([p.firstChild, 2, p.firstChild, 2]);
|
||||||
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
||||||
document.getSelection();
|
document.getSelection();
|
||||||
@@ -727,7 +727,7 @@ describe('Utils', () => {
|
|||||||
});
|
});
|
||||||
it('should collapse the cursor at the end of an element', () => {
|
it('should collapse the cursor at the end of an element', () => {
|
||||||
const [p] = insertTestHtml('<p>abc</p>');
|
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]);
|
window.chai.expect(result).to.eql([p.firstChild, 3, p.firstChild, 3]);
|
||||||
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
||||||
document.getSelection();
|
document.getSelection();
|
||||||
@@ -738,7 +738,7 @@ describe('Utils', () => {
|
|||||||
it('should collapse the cursor before a nested inline element', () => {
|
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 [p] = insertTestHtml('<p>ab<span>cd<b>ef</b>gh</span>ij</p>');
|
||||||
const cd = p.childNodes[1].firstChild;
|
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]);
|
window.chai.expect(result).to.eql([cd, 2, cd, 2]);
|
||||||
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
||||||
document.getSelection();
|
document.getSelection();
|
||||||
@@ -749,7 +749,7 @@ describe('Utils', () => {
|
|||||||
it('should collapse the cursor at the beginning of a nested inline element', () => {
|
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 [p] = insertTestHtml('<p>ab<span>cd<b>ef</b>gh</span>ij</p>');
|
||||||
const ef = p.childNodes[1].childNodes[1].firstChild;
|
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]);
|
window.chai.expect(result).to.eql([ef, 0, ef, 0]);
|
||||||
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
||||||
document.getSelection();
|
document.getSelection();
|
||||||
@@ -760,7 +760,7 @@ describe('Utils', () => {
|
|||||||
it('should collapse the cursor within a nested inline element', () => {
|
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 [p] = insertTestHtml('<p>ab<span>cd<b>efgh</b>ij</span>kl</p>');
|
||||||
const efgh = p.childNodes[1].childNodes[1].firstChild;
|
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]);
|
window.chai.expect(result).to.eql([efgh, 2, efgh, 2]);
|
||||||
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
||||||
document.getSelection();
|
document.getSelection();
|
||||||
@@ -771,7 +771,7 @@ describe('Utils', () => {
|
|||||||
it('should collapse the cursor at the end of a nested inline element', () => {
|
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 [p] = insertTestHtml('<p>ab<span>cd<b>ef</b>gh</span>ij</p>');
|
||||||
const ef = p.childNodes[1].childNodes[1].firstChild;
|
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]);
|
window.chai.expect(result).to.eql([ef, 2, ef, 2]);
|
||||||
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
||||||
document.getSelection();
|
document.getSelection();
|
||||||
@@ -783,7 +783,7 @@ describe('Utils', () => {
|
|||||||
const [p] = insertTestHtml('<p>ab<span>cd<b>ef</b>gh</span>ij</p>');
|
const [p] = insertTestHtml('<p>ab<span>cd<b>ef</b>gh</span>ij</p>');
|
||||||
const ef = p.childNodes[1].childNodes[1].firstChild;
|
const ef = p.childNodes[1].childNodes[1].firstChild;
|
||||||
const gh = p.childNodes[1].lastChild;
|
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]);
|
window.chai.expect(result).to.eql([ef, 2, ef, 2]);
|
||||||
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
||||||
document.getSelection();
|
document.getSelection();
|
||||||
@@ -791,7 +791,7 @@ describe('Utils', () => {
|
|||||||
.expect([anchorNode, anchorOffset, focusNode, focusOffset])
|
.expect([anchorNode, anchorOffset, focusNode, focusOffset])
|
||||||
.to.eql([ef, 2, ef, 2]);
|
.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]);
|
window.chai.expect(nonNormalizedResult).to.eql([gh, 0, gh, 0]);
|
||||||
const sel = document.getSelection();
|
const sel = document.getSelection();
|
||||||
window.chai
|
window.chai
|
||||||
@@ -802,7 +802,7 @@ describe('Utils', () => {
|
|||||||
describe('forward', () => {
|
describe('forward', () => {
|
||||||
it('should select the contents of an element', () => {
|
it('should select the contents of an element', () => {
|
||||||
const [p] = insertTestHtml('<p>abc</p>');
|
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]);
|
window.chai.expect(result).to.eql([p.firstChild, 0, p.firstChild, 3]);
|
||||||
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
||||||
document.getSelection();
|
document.getSelection();
|
||||||
@@ -817,7 +817,7 @@ describe('Utils', () => {
|
|||||||
const ef = p1.childNodes[1].childNodes[1].firstChild;
|
const ef = p1.childNodes[1].childNodes[1].firstChild;
|
||||||
const qr = p2.childNodes[1].childNodes[2];
|
const qr = p2.childNodes[1].childNodes[2];
|
||||||
const st = p2.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]);
|
window.chai.expect(result).to.eql([ef, 1, qr, 2]);
|
||||||
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
||||||
document.getSelection();
|
document.getSelection();
|
||||||
@@ -825,7 +825,7 @@ describe('Utils', () => {
|
|||||||
.expect([anchorNode, anchorOffset, focusNode, focusOffset])
|
.expect([anchorNode, anchorOffset, focusNode, focusOffset])
|
||||||
.to.eql([ef, 1, qr, 2]);
|
.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]);
|
window.chai.expect(nonNormalizedResult).to.eql([ef, 1, st, 0]);
|
||||||
const sel = document.getSelection();
|
const sel = document.getSelection();
|
||||||
window.chai
|
window.chai
|
||||||
@@ -836,7 +836,7 @@ describe('Utils', () => {
|
|||||||
describe('backward', () => {
|
describe('backward', () => {
|
||||||
it('should select the contents of an element', () => {
|
it('should select the contents of an element', () => {
|
||||||
const [p] = insertTestHtml('<p>abc</p>');
|
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]);
|
window.chai.expect(result).to.eql([p.firstChild, 3, p.firstChild, 0]);
|
||||||
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
||||||
document.getSelection();
|
document.getSelection();
|
||||||
@@ -851,7 +851,7 @@ describe('Utils', () => {
|
|||||||
const ef = p1.childNodes[1].childNodes[1].firstChild;
|
const ef = p1.childNodes[1].childNodes[1].firstChild;
|
||||||
const qr = p2.childNodes[1].childNodes[2];
|
const qr = p2.childNodes[1].childNodes[2];
|
||||||
const st = p2.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]);
|
window.chai.expect(result).to.eql([qr, 2, ef, 1]);
|
||||||
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
const { anchorNode, anchorOffset, focusNode, focusOffset } =
|
||||||
document.getSelection();
|
document.getSelection();
|
||||||
@@ -859,7 +859,7 @@ describe('Utils', () => {
|
|||||||
.expect([anchorNode, anchorOffset, focusNode, focusOffset])
|
.expect([anchorNode, anchorOffset, focusNode, focusOffset])
|
||||||
.to.eql([qr, 2, ef, 1]);
|
.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]);
|
window.chai.expect(nonNormalizedResult).to.eql([st, 0, ef, 1]);
|
||||||
const sel = document.getSelection();
|
const sel = document.getSelection();
|
||||||
window.chai
|
window.chai
|
||||||
@@ -1217,7 +1217,7 @@ describe('Utils', () => {
|
|||||||
</p>`,
|
</p>`,
|
||||||
);
|
);
|
||||||
const abc = p.childNodes[1].firstChild.firstChild.firstChild.firstChild;
|
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 result = getDeepRange(p.parentElement, { select: true });
|
||||||
const { startContainer, startOffset, endContainer, endOffset } = result;
|
const { startContainer, startOffset, endContainer, endOffset } = result;
|
||||||
window.chai
|
window.chai
|
||||||
@@ -1240,7 +1240,7 @@ describe('Utils', () => {
|
|||||||
const span1 = p1.childNodes[1];
|
const span1 = p1.childNodes[1];
|
||||||
const ef = span1.childNodes[1].firstChild;
|
const ef = span1.childNodes[1].firstChild;
|
||||||
const st = p2.childNodes[2];
|
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 result = getDeepRange(p1.parentElement, { select: true });
|
||||||
const { startContainer, startOffset, endContainer, endOffset } = result;
|
const { startContainer, startOffset, endContainer, endOffset } = result;
|
||||||
window.chai
|
window.chai
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function _toDomLocation(node, index) {
|
|||||||
return [container, offset];
|
return [container, offset];
|
||||||
}
|
}
|
||||||
|
|
||||||
function _parseTextualSelection(testContainer) {
|
export function parseTextualSelection(testContainer) {
|
||||||
let anchorNode;
|
let anchorNode;
|
||||||
let anchorOffset;
|
let anchorOffset;
|
||||||
let focusNode;
|
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.
|
* Set a range in the DOM.
|
||||||
*
|
*
|
||||||
* @param selection
|
* @param selection
|
||||||
*/
|
*/
|
||||||
export function setSelection(selection) {
|
export function setTestSelection(selection, doc = document) {
|
||||||
const domRange = document.createRange();
|
const domRange = doc.createRange();
|
||||||
if (selection.direction === Direction.FORWARD) {
|
if (selection.direction === Direction.FORWARD) {
|
||||||
domRange.setStart(selection.anchorNode, selection.anchorOffset);
|
domRange.setStart(selection.anchorNode, selection.anchorOffset);
|
||||||
domRange.collapse(true);
|
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 {Node} node
|
||||||
* @param {number} offset
|
* @param {number} offset
|
||||||
*/
|
*/
|
||||||
function _insertCharAt(char, node, offset) {
|
export function insertCharsAt(chars, node, offset) {
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
const startValue = node.nodeValue;
|
const startValue = node.nodeValue;
|
||||||
if (offset < 0 || offset > startValue.length) {
|
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 {
|
} else {
|
||||||
if (offset < 0 || offset > node.childNodes.length) {
|
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) {
|
if (offset < node.childNodes.length) {
|
||||||
node.insertBefore(textNode, node.childNodes[offset]);
|
node.insertBefore(textNode, node.childNodes[offset]);
|
||||||
} else {
|
} else {
|
||||||
@@ -211,24 +281,24 @@ export function renderTextualSelection() {
|
|||||||
if (anchorNode === focusNode && anchorOffset <= focusOffset) {
|
if (anchorNode === focusNode && anchorOffset <= focusOffset) {
|
||||||
focusOffset++;
|
focusOffset++;
|
||||||
}
|
}
|
||||||
_insertCharAt('[', ...anchor);
|
insertCharsAt('[', ...anchor);
|
||||||
_insertCharAt(']', focusNode, focusOffset);
|
insertCharsAt(']', focusNode, focusOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testEditor(Editor = OdooEditor, spec) {
|
export async function testEditor(Editor = OdooEditor, spec) {
|
||||||
const testNode = document.createElement('div');
|
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
|
// Add the content to edit and remove the "[]" markers *before* initializing
|
||||||
// the editor as otherwise those would genererate mutations the editor would
|
// the editor as otherwise those would genererate mutations the editor would
|
||||||
// consider and the tests would make no sense.
|
// consider and the tests would make no sense.
|
||||||
testNode.innerHTML = spec.contentBefore;
|
testNode.innerHTML = spec.contentBefore;
|
||||||
const selection = _parseTextualSelection(testNode);
|
const selection = parseTextualSelection(testNode);
|
||||||
|
|
||||||
const editor = new Editor(testNode, { toSanitize: false });
|
const editor = new Editor(testNode, { toSanitize: false });
|
||||||
editor.keyboardType = 'PHYSICAL_KEYBOARD';
|
editor.keyboardType = 'PHYSICAL_KEYBOARD';
|
||||||
if (selection) {
|
if (selection) {
|
||||||
setSelection(selection);
|
setTestSelection(selection);
|
||||||
} else {
|
} else {
|
||||||
document.getSelection().removeAllRanges();
|
document.getSelection().removeAllRanges();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,6 +185,11 @@ var FieldHtml = basic_fields.DebouncedField.extend(TranslatableFieldMixin, {
|
|||||||
res_id: this.res_id,
|
res_id: this.res_id,
|
||||||
},
|
},
|
||||||
placeholder: this.attrs && this.attrs.placeholder,
|
placeholder: this.attrs && this.attrs.placeholder,
|
||||||
|
collaborationChannel: {
|
||||||
|
collaborationModelName: this.model,
|
||||||
|
collaborationFieldName: this.name,
|
||||||
|
collaborationResId: parseInt(this.res_id),
|
||||||
|
},
|
||||||
noAttachment: this.nodeOptions['no-attachment'],
|
noAttachment: this.nodeOptions['no-attachment'],
|
||||||
inIframe: !!this.nodeOptions.cssEdit,
|
inIframe: !!this.nodeOptions.cssEdit,
|
||||||
iframeCssAssets: 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 OdooEditorLib = require('@web_editor/../lib/odoo-editor/src/OdooEditor');
|
||||||
const dom = require('web.dom');
|
const dom = require('web.dom');
|
||||||
|
|
||||||
const setCursor = OdooEditorLib.setCursor;
|
const setSelection = OdooEditorLib.setSelection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allows to customize link content and style.
|
* Allows to customize link content and style.
|
||||||
@@ -48,7 +48,7 @@ const LinkTools = Link.extend({
|
|||||||
this.$button.removeClass('active');
|
this.$button.removeClass('active');
|
||||||
this.options.wysiwyg.odooEditor.observerActive();
|
this.options.wysiwyg.odooEditor.observerActive();
|
||||||
this.applyLinkToDom(this._getData());
|
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.options.wysiwyg.odooEditor.historyStep();
|
||||||
this._super(...arguments);
|
this._super(...arguments);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const Toolbar = require('web_editor.toolbar');
|
|||||||
const weWidgets = require('wysiwyg.widgets');
|
const weWidgets = require('wysiwyg.widgets');
|
||||||
const wysiwygUtils = require('@web_editor/js/wysiwyg/wysiwyg_utils');
|
const wysiwygUtils = require('@web_editor/js/wysiwyg/wysiwyg_utils');
|
||||||
const weUtils = require('web_editor.utils');
|
const weUtils = require('web_editor.utils');
|
||||||
|
const { PeerToPeer } = require('@web_editor/js/wysiwyg/PeerToPeer');
|
||||||
|
const { Mutex } = require('web.concurrency');
|
||||||
|
|
||||||
var _t = core._t;
|
var _t = core._t;
|
||||||
|
|
||||||
@@ -72,7 +74,14 @@ const Wysiwyg = Widget.extend({
|
|||||||
this.toolbar = new Toolbar(this, this.options.toolbarTemplate);
|
this.toolbar = new Toolbar(this, this.options.toolbarTemplate);
|
||||||
await this.toolbar.appendTo(document.createElement('void'));
|
await this.toolbar.appendTo(document.createElement('void'));
|
||||||
const commands = this._getCommands();
|
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,
|
_t: _t,
|
||||||
toolbar: this.toolbar.$el[0],
|
toolbar: this.toolbar.$el[0],
|
||||||
document: this.options.document,
|
document: this.options.document,
|
||||||
@@ -96,7 +105,18 @@ const Wysiwyg = Widget.extend({
|
|||||||
},
|
},
|
||||||
noScrollSelector: 'body, .note-editable, .o_content, #wrapwrap',
|
noScrollSelector: 'body, .note-editable, .o_content, #wrapwrap',
|
||||||
commands: commands,
|
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._observeOdooFieldChanges();
|
||||||
this.$editable.on(
|
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
|
* @override
|
||||||
*/
|
*/
|
||||||
@@ -200,11 +433,22 @@ const Wysiwyg = Widget.extend({
|
|||||||
if (this.odooEditor) {
|
if (this.odooEditor) {
|
||||||
this.odooEditor.destroy();
|
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);
|
this.$editable && this.$editable.off('blur', this._onBlur);
|
||||||
document.removeEventListener('mousedown', this._onDocumentMousedown, true);
|
document.removeEventListener('mousedown', this._onDocumentMousedown, true);
|
||||||
const $body = $(document.body);
|
const $body = $(document.body);
|
||||||
$body.off('mousemove', this.resizerMousemove);
|
$body.off('mousemove', this.resizerMousemove);
|
||||||
$body.off('mouseup', this.resizerMouseup);
|
$body.off('mouseup', this.resizerMouseup);
|
||||||
|
const $wrapwrap = $('#wrapwrap');
|
||||||
|
if ($wrapwrap.length) {
|
||||||
|
$('#wrapwrap')[0].removeEventListener('scroll', this.odooEditor.multiselectionRefresh, { passive: true });
|
||||||
|
}
|
||||||
this._super();
|
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.
|
* Set cursor to the editor latest position before blur or to the last editable node, ready to type.
|
||||||
*/
|
*/
|
||||||
focus: function () {
|
focus: function () {
|
||||||
if(!this.odooEditor.resetCursorOnLastHistoryCursor()) {
|
if(!this.odooEditor.historyResetLatestComputedSelection()) {
|
||||||
// If the editor don't have an history step to focus to,
|
// If the editor don't have an history step to focus to,
|
||||||
// We place the cursor after the end of the editor exiting content.
|
// We place the cursor after the end of the editor exiting content.
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
@@ -805,11 +1049,11 @@ const Wysiwyg = Widget.extend({
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const scrollableContainer = this.$el.scrollParent();
|
const scrollableContainer = this.$el.scrollParent();
|
||||||
if (!options.snippets && scrollableContainer.length) {
|
if (!options.snippets && scrollableContainer.length) {
|
||||||
this.odooEditor.addDomListener(
|
// this.odooEditor.addDomListener(
|
||||||
scrollableContainer[0],
|
// scrollableContainer[0],
|
||||||
'scroll',
|
// 'scroll',
|
||||||
this.odooEditor.updateToolbarPosition.bind(this.odooEditor),
|
// this.odooEditor.updateToolbarPosition.bind(this.odooEditor),
|
||||||
);
|
// );
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
.oe_form_field_html {
|
.oe_form_field_html {
|
||||||
position: relative;
|
position: relative;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
.odoo-editor #codeview-btn-group {
|
.odoo-editor #codeview-btn-group {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -358,13 +358,27 @@ var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_wysiwygInstance: function () {
|
_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;
|
var context;
|
||||||
this.trigger_up('context_get', {
|
this.trigger_up('context_get', {
|
||||||
callback: function (ctx) {
|
callback: function (ctx) {
|
||||||
context = ctx;
|
context = ctx;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const params = {
|
const params = Object.assign({
|
||||||
snippets: 'website.snippets',
|
snippets: 'website.snippets',
|
||||||
recordInfo: {
|
recordInfo: {
|
||||||
context: context,
|
context: context,
|
||||||
@@ -379,7 +393,7 @@ var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
|
|||||||
isRootEditable: false,
|
isRootEditable: false,
|
||||||
controlHistoryFromDocument: true,
|
controlHistoryFromDocument: true,
|
||||||
getContentEditableAreas: this._getContentEditableAreas.bind(this),
|
getContentEditableAreas: this._getContentEditableAreas.bind(this),
|
||||||
};
|
}, collaborationConfig);
|
||||||
return wysiwygLoader.createWysiwyg(this,
|
return wysiwygLoader.createWysiwyg(this,
|
||||||
Object.assign(params, this.wysiwygOptions),
|
Object.assign(params, this.wysiwygOptions),
|
||||||
['website.compiled_assets_wysiwyg']
|
['website.compiled_assets_wysiwyg']
|
||||||
|
|||||||
Reference in New Issue
Block a user