diff --git a/addons/hr_timesheet/models/ir_http.py b/addons/hr_timesheet/models/ir_http.py
index cc687a513c4..b7d031ff8d4 100644
--- a/addons/hr_timesheet/models/ir_http.py
+++ b/addons/hr_timesheet/models/ir_http.py
@@ -13,7 +13,7 @@ class Http(models.AbstractModel):
widget to apply, depending on the current company.
"""
result = super(Http, self).session_info()
- if self.env.user.has_group('base.group_user'):
+ if request.env.user.has_group('base.group_user'):
company_ids = request.env.user.company_ids
for company in company_ids:
diff --git a/addons/mail/models/mail_ice_server.py b/addons/mail/models/mail_ice_server.py
index 1ab3a674432..043b34d63e0 100644
--- a/addons/mail/models/mail_ice_server.py
+++ b/addons/mail/models/mail_ice_server.py
@@ -36,8 +36,8 @@ class MailIceServer(models.Model):
""" To be overridable if we need to obtain credentials from another source.
:return: tuple
"""
- account_sid = self.env['ir.config_parameter'].get_param('mail.twilio_account_sid')
- auth_token = self.env['ir.config_parameter'].get_param('mail.twilio_account_token')
+ account_sid = self.env['ir.config_parameter'].sudo().get_param('mail.twilio_account_sid')
+ auth_token = self.env['ir.config_parameter'].sudo().get_param('mail.twilio_account_token')
return account_sid, auth_token
def _get_ice_servers(self):
@@ -45,7 +45,7 @@ class MailIceServer(models.Model):
:return: List of dict, each of which representing a stun or turn server,
formatted as expected by the specifications of RTCConfiguration.iceServers
"""
- if self.env['ir.config_parameter'].get_param('mail.use_twilio_rtc_servers'):
+ if self.env['ir.config_parameter'].sudo().get_param('mail.use_twilio_rtc_servers'):
(account_sid, auth_token) = self._get_twilio_credentials()
if account_sid and auth_token:
url = f'https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Tokens.json'
diff --git a/addons/web_editor/__manifest__.py b/addons/web_editor/__manifest__.py
index fdc7013ffad..418c6278ff5 100644
--- a/addons/web_editor/__manifest__.py
+++ b/addons/web_editor/__manifest__.py
@@ -9,7 +9,7 @@ Odoo Web Editor widget.
==========================
""",
- 'depends': ['web'],
+ 'depends': ['web', 'mail'],
'data': [
'security/ir.model.access.csv',
'data/editor_assets.xml',
@@ -42,6 +42,7 @@ Odoo Web Editor widget.
'web_editor/static/lib/odoo-editor/src/utils/sanitize.js',
'web_editor/static/lib/odoo-editor/src/utils/serialize.js',
'web_editor/static/lib/odoo-editor/src/utils/utils.js',
+ 'web_editor/static/lib/odoo-editor/src/utils/DOMPurify.js',
'web_editor/static/lib/odoo-editor/src/tablepicker/TablePicker.js',
'web_editor/static/lib/odoo-editor/src/powerbox/patienceDiff.js',
'web_editor/static/lib/odoo-editor/src/powerbox/Powerbox.js',
@@ -57,6 +58,7 @@ Odoo Web Editor widget.
# utils
'web_editor/static/src/js/wysiwyg/wysiwyg_utils.js',
+ 'web_editor/static/src/js/wysiwyg/PeerToPeer.js',
# odoo utils
('include', 'web._assets_helpers'),
diff --git a/addons/web_editor/controllers/__init__.py b/addons/web_editor/controllers/__init__.py
index 5d4b25db9c0..bb87d678c16 100644
--- a/addons/web_editor/controllers/__init__.py
+++ b/addons/web_editor/controllers/__init__.py
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
+from . import bus
diff --git a/addons/web_editor/controllers/bus.py b/addons/web_editor/controllers/bus.py
new file mode 100644
index 00000000000..67e329119cb
--- /dev/null
+++ b/addons/web_editor/controllers/bus.py
@@ -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)
diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py
index ab8e583c5a0..a02e3e5d2c7 100644
--- a/addons/web_editor/controllers/main.py
+++ b/addons/web_editor/controllers/main.py
@@ -687,3 +687,21 @@ class Web_Editor(http.Controller):
attachments.append(attachment._get_media_info())
return attachments
+
+ @http.route("/web_editor/get_ice_servers", type='json', auth="user")
+ def get_ice_servers(self):
+ return request.env['mail.ice.server']._get_ice_servers()
+
+ @http.route("/web_editor/bus_broadcast", type="json", auth="user")
+ def bus_broadcast(self, model_name, field_name, res_id, bus_data):
+ document = request.env[model_name].browse([res_id])
+
+ document.check_access_rights('read')
+ document.check_field_access_rights('read', [field_name])
+ document.check_access_rule('read')
+ document.check_access_rights('write')
+ document.check_field_access_rights('write', [field_name])
+ document.check_access_rule('write')
+
+ channel = (request.db, 'editor_collaboration', model_name, field_name, int(res_id))
+ request.env['bus.bus'].sendone(channel, bus_data)
diff --git a/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js b/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js
index b68c4a95467..2580f8665ee 100644
--- a/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js
+++ b/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js
@@ -11,7 +11,7 @@ import './commands/toggleList.js';
import './commands/align.js';
import { sanitize } from './utils/sanitize.js';
-import { nodeToObject, objectToNode } from './utils/serialize.js';
+import { serializeNode, unserializeNode, serializeSelection } from './utils/serialize.js';
import {
closestBlock,
commonParentGet,
@@ -25,7 +25,7 @@ import {
isColorGradient,
nodeSize,
preserveCursor,
- setCursor,
+ setSelection,
startPos,
toggleClass,
closestElement,
@@ -45,6 +45,7 @@ import {
isBold,
YOUTUBE_URL_GET_VIDEO_ID,
unwrapContents,
+ peek,
} from './utils/utils.js';
import { editorCommands } from './commands/commands.js';
import { Powerbox } from './powerbox/Powerbox.js';
@@ -56,6 +57,11 @@ import { UNBREAKABLE_ROLLBACK_CODE, UNREMOVABLE_ROLLBACK_CODE } from './utils/co
const BACKSPACE_ONLY_COMMANDS = ['oDeleteBackward', 'oDeleteForward'];
const BACKSPACE_FIRST_COMMANDS = BACKSPACE_ONLY_COMMANDS.concat(['oEnter', 'oShiftEnter']);
+// 60 seconds
+const HISTORY_SNAPSHOT_INTERVAL = 1000 * 60;
+// 10 seconds
+const HISTORY_SNAPSHOT_BUFFER_TIME = 1000 * 10;
+
const KEYBOARD_TYPES = { VIRTUAL: 'VIRTUAL', PHYSICAL: 'PHYSICAL', UNKNOWN: 'UKNOWN' };
const IS_KEYBOARD_EVENT_UNDO = ev => ev.key === 'z' && (ev.ctrlKey || ev.metaKey);
@@ -133,7 +139,6 @@ function defaultOptions(defaultObject, object) {
}
return newObject;
}
-
export class OdooEditor extends EventTarget {
constructor(editable, options = {}) {
super();
@@ -178,9 +183,6 @@ export class OdooEditor extends EventTarget {
// All dom listeners currently active.
this._domListeners = [];
- this.resetHistory();
- this._historyStepsActive = true;
-
// Set of labels that which prevent the automatic step mechanism if
// it contains at least one element.
this._observerTimeoutUnactive = new Set();
@@ -193,9 +195,6 @@ export class OdooEditor extends EventTarget {
this._onKeyupResetContenteditableNodes = [];
- this._isCollaborativeActive = false;
- this._collaborativeLastSynchronisedId = null;
-
// Track if we need to rollback mutations in case unbreakable or unremovable are being added or removed.
this._toRollback = false;
@@ -210,15 +209,30 @@ export class OdooEditor extends EventTarget {
editable.innerHTML = '
';
}
- // Convention: root node is ID 1.
- editable.oid = 1;
+ // Convention: root node is ID root.
+ editable.oid = 'root';
this._idToNodeMap.set(1, editable);
this.editable = this.options.toSanitize ? sanitize(editable) : editable;
// Set contenteditable before clone as FF updates the content at this point.
this._activateContenteditable();
+ this._collabClientId = this.options.collaborationClientId;
+
+ // Colaborator selection and caret display.
+ this._collabSelectionInfos = new Map();
+ this._collabSelectionColor = `hsl(${(Math.random() * 360).toFixed(0)}, 75%, 50%)`;
+ this._collabSelectionsContainer = this.document.createElement('div');
+ this._collabSelectionsContainer.classList.add('oe-collaboration-selections-container');
+ this.editable.before(this._collabSelectionsContainer);
+
this.idSet(editable);
+ this.historyReset();
+ this._historyStepsActive = true;
+ const firstStep = this._historyGetSnapshotStep();
+ this._firstStepId = firstStep.id;
+ this._historySnapshots = [{ step: firstStep }];
+ this._historySteps.push(firstStep);
this._createCommandBar();
@@ -251,6 +265,16 @@ export class OdooEditor extends EventTarget {
this.addDomListener(this.document, 'mousedown', this._onDoumentMousedown);
this.addDomListener(this.document, 'mouseup', this._onDoumentMouseup);
+ this.multiselectionRefresh = this.multiselectionRefresh.bind(this);
+ this._resizeObserver = new ResizeObserver(this.multiselectionRefresh);
+ this._resizeObserver.observe(this.document.body);
+ this._resizeObserver.observe(this.editable);
+ this.addDomListener(this.editable, 'scroll', this.multiselectionRefresh);
+
+ this._snapshotInterval = setInterval(() => {
+ this._historyMakeSnapshot();
+ }, HISTORY_SNAPSHOT_INTERVAL);
+
// -------
// Toolbar
// -------
@@ -304,15 +328,16 @@ export class OdooEditor extends EventTarget {
this._removeDomListener();
this.commandBar.destroy();
this.commandbarTablePicker.el.remove();
+ this._collabSelectionsContainer.remove();
+ this._resizeObserver.disconnect();
+ clearInterval(this._snapshotInterval);
}
sanitize() {
this.observerFlush();
- // find common ancestror in this.history[-1]
- const step = this._historySteps[this._historySteps.length - 1];
let commonAncestor, record;
- for (record of step.mutations) {
+ for (record of this._currentStep.mutations) {
const node = this.idFind(record.parentId || record.id) || this.editable;
commonAncestor = commonAncestor
? commonParentGet(commonAncestor, node, this.editable)
@@ -332,12 +357,18 @@ export class OdooEditor extends EventTarget {
element.addEventListener(eventName, boundCallback);
}
+ _generateId() {
+ // No need for secure random number.
+ return Math.floor(Math.random() * Math.pow(2,52)).toString();
+ }
+
// Assign IDs to src, and dest if defined
idSet(node, testunbreak = false) {
if (!node.oid) {
- node.oid = (Math.random() * 2 ** 31) | 0; // TODO: uuid4 or higher number
- this._idToNodeMap.set(node.oid, node);
+ node.oid = this._generateId();
}
+ // In case the id was created by another collaboration client.
+ this._idToNodeMap.set(node.oid, node);
// Rollback if node.ouid changed. This ensures that nodes never change
// unbreakable ancestors.
node.ouid = node.ouid || getOuid(node, true);
@@ -359,14 +390,12 @@ export class OdooEditor extends EventTarget {
return this._idToNodeMap.get(id);
}
- // Observer that syncs doms
-
- // if not in collaboration mode, no need to serialize / unserialize
- serialize(node) {
- return this._isCollaborativeActive ? nodeToObject(node) : node;
+ serializeNode(node, mutatedNodes) {
+ return this._collabClientId ? serializeNode(node, mutatedNodes) : node;
}
- unserialize(obj) {
- return this._isCollaborativeActive ? objectToNode(obj) : obj;
+
+ unserializeNode(node) {
+ return this._collabClientId ? unserializeNode(node) : node;
}
automaticStepActive(label) {
@@ -416,10 +445,28 @@ export class OdooEditor extends EventTarget {
}
observerApply(records) {
+ // There is a case where node A is added and node B is a descendant of
+ // node A where node B was not in the observed tree) then node B is
+ // added into another node. In that case, we need to keep track of node
+ // B so when serializing node A, we strip node B from the node A tree to
+ // avoid the duplication of node A.
+ const mutatedNodes = new Set();
+ for (const record of records) {
+ if (record.type === 'childList') {
+ for (const node of record.addedNodes) {
+ this.idSet(node, this._checkStepUnbreakable);
+ mutatedNodes.add(node.oid);
+ }
+ for (const node of record.removedNodes) {
+ this.idSet(node, this._checkStepUnbreakable);
+ mutatedNodes.delete(node.oid);
+ }
+ }
+ }
for (const record of records) {
switch (record.type) {
case 'characterData': {
- this._historySteps[this._historySteps.length - 1].mutations.push({
+ this._currentStep.mutations.push({
'type': 'characterData',
'id': record.target.oid,
'text': record.target.textContent,
@@ -428,7 +475,7 @@ export class OdooEditor extends EventTarget {
break;
}
case 'attributes': {
- this._historySteps[this._historySteps.length - 1].mutations.push({
+ this._currentStep.mutations.push({
'type': 'attributes',
'id': record.target.oid,
'attributeName': record.attributeName,
@@ -456,20 +503,19 @@ export class OdooEditor extends EventTarget {
} else {
return false;
}
- this.idSet(added, this._checkStepUnbreakable);
mutation.id = added.oid;
- mutation.node = this.serialize(added);
- this._historySteps[this._historySteps.length - 1].mutations.push(mutation);
+ mutation.node = this.serializeNode(added, mutatedNodes);
+ this._currentStep.mutations.push(mutation);
});
record.removedNodes.forEach(removed => {
if (!this._toRollback && containsUnremovable(removed)) {
this._toRollback = UNREMOVABLE_ROLLBACK_CODE;
}
- this._historySteps[this._historySteps.length - 1].mutations.push({
+ this._currentStep.mutations.push({
'type': 'remove',
'id': removed.oid,
'parentId': record.target.oid,
- 'node': this.serialize(removed),
+ 'node': this.serializeNode(removed),
'nextId': record.nextSibling ? record.nextSibling.oid : undefined,
'previousId': record.previousSibling
? record.previousSibling.oid
@@ -512,25 +558,74 @@ export class OdooEditor extends EventTarget {
return filteredRecords;
}
- resetHistory() {
- this._historySteps = [
- {
- cursor: {
- // cursor at beginning of step
- anchorNode: undefined,
- anchorOffset: undefined,
- focusNode: undefined,
- focusOffset: undefined,
- },
- mutations: [],
- id: undefined,
+ // History
+ // -------------------------------------------------------------------------
+
+ historyReset() {
+ this._historySteps = [];
+ this._currentStep = {
+ selection: {
+ anchorNodeOid: undefined,
+ anchorOffset: undefined,
+ focusNodeOid: undefined,
+ focusOffset: undefined,
},
- ];
+ mutations: [],
+ id: undefined,
+ clientId: undefined,
+ };
this._historyStepsStates = new Map();
}
- //
- // History
- //
+ historyGetSnapshotSteps() {
+ // If the current snapshot has no time, it means that there is the no
+ // other snapshot that have been made (either it is the one created upon
+ // initialization or reseted by historyResetFromSteps).
+ if (!this._historySnapshots[0].time) {
+ return this._historySteps;
+ }
+ const steps = [];
+ let snapshot;
+ if (this._historySnapshots[0].time + HISTORY_SNAPSHOT_BUFFER_TIME < Date.now()) {
+ snapshot = this._historySnapshots[0];
+ } else {
+ // this._historySnapshots[1] has being created at least 1 minute ago
+ // (HISTORY_SNAPSHOT_INTERVAL) or it is the first step.
+ snapshot = this._historySnapshots[1];
+ }
+ let index = this._historySteps.length - 1;
+ while (this._historySteps[index].id !== snapshot.step.id) {
+ steps.push(this._historySteps[index]);
+ index--;
+ }
+ steps.push(snapshot.step);
+ steps.reverse();
+
+ return steps;
+ }
+ historyResetFromSteps(steps) {
+ this.observerUnactive();
+ for (const node of [...this.editable.childNodes]) {
+ node.remove();
+ }
+ this.historyReset();
+ for (const step of steps) {
+ this.historyApply(step.mutations);
+ }
+ this._historySnapshots = [{ step: steps[0] }];
+ this._historySteps = steps;
+
+ this._handleCommandHint();
+ this.multiselectionRefresh();
+ this.observerActive();
+ }
+ historyGetMissingSteps({fromStepId, toStepId}) {
+ const fromIndex = this._historySteps.findIndex(x => x.id === fromStepId);
+ const toIndex = this._historySteps.findIndex(x => x.id === toStepId);
+ if (fromIndex === -1 || toIndex === -1) {
+ return -1;
+ }
+ return this._historySteps.slice(fromIndex + 1, toIndex);
+ }
// One step completed: apply to vDOM, setup next history step
historyStep(skipRollback = false) {
@@ -545,22 +640,29 @@ export class OdooEditor extends EventTarget {
}
// push history
- const latest = this._historySteps[this._historySteps.length - 1];
- if (!latest.mutations.length) {
+ const currentStep = this._currentStep;
+ if (!currentStep.mutations.length) {
return false;
}
- latest.id = (Math.random() * 2 ** 31) | 0; // TODO: replace by uuid4 generator
- this.historySend(latest);
- this._historySteps.push({
- cursor: {},
- mutations: [],
- });
- this._checkStepUnbreakable = true;
- this._recordHistoryCursor();
- this.dispatchEvent(new Event('historyStep'));
- }
+ currentStep.id = this._generateId();
+ const previousStep = peek(this._historySteps);
+ currentStep.clientId = this._collabClientId;
+ currentStep.previousStepId = previousStep.id;
+ this._historySteps.push(currentStep);
+ if (this.options.onHistoryStep) {
+ this.options.onHistoryStep(currentStep);
+ }
+ this._currentStep = {
+ selection: {},
+ mutations: [],
+ };
+ this._checkStepUnbreakable = true;
+ this._recordHistorySelection();
+ this.dispatchEvent(new Event('historyStep'));
+ this.multiselectionRefresh();
+ }
// apply changes according to some records
historyApply(records) {
for (const record of records) {
@@ -572,7 +674,7 @@ export class OdooEditor extends EventTarget {
} else if (record.type === 'attributes') {
const node = this.idFind(record.id);
if (node) {
- node.setAttribute(record.attributeName, record.value);
+ this._safeSetAttribute(node, record.attributeName, record.value);
}
} else if (record.type === 'remove') {
const toremove = this.idFind(record.id);
@@ -580,135 +682,49 @@ export class OdooEditor extends EventTarget {
toremove.remove();
}
} else if (record.type === 'add') {
- const node = this.unserialize(record.node);
- const newnode = node.cloneNode(1);
- // preserve oid after the clone
- this.idSet(node, newnode);
-
- const destnode = this.idFind(record.node.oid);
- if (destnode && record.node.parentNode.oid === destnode.parentNode.oid) {
- // TODO: optimization: remove record from the history to reduce collaboration bandwidth
+ let node = this.idFind(record.oid) || this.unserializeNode(record.node);
+ const fakeNode = document.createElement('fake-el');
+ fakeNode.appendChild(node);
+ DOMPurify.sanitize(fakeNode, { IN_PLACE: true });
+ node = fakeNode.childNodes[0];
+ if (!node) {
continue;
}
+ this.idSet(node, true);
+
if (record.append && this.idFind(record.append)) {
- this.idFind(record.append).append(newnode);
+ this.idFind(record.append).append(node);
} else if (record.before && this.idFind(record.before)) {
- this.idFind(record.before).before(newnode);
+ this.idFind(record.before).before(node);
} else if (record.after && this.idFind(record.after)) {
- this.idFind(record.after).after(newnode);
+ this.idFind(record.after).after(node);
} else {
continue;
}
}
}
}
-
- // send changes to server
- historyFetch() {
- if (!this._isCollaborativeActive) {
- return;
- }
- window
- .fetch(`/history-get/${this._collaborativeLastSynchronisedId || 0}`, {
- headers: { 'Content-Type': 'application/json;charset=utf-8' },
- method: 'GET',
- })
- .then(response => {
- if (!response.ok) {
- return Promise.reject();
- }
- return response.json();
- })
- .then(result => {
- if (!result.length) {
- return false;
- }
- this.observerUnactive();
-
- let index = this._historySteps.length;
- let updated = false;
- while (
- index &&
- this._historySteps[index - 1].id !== this._collaborativeLastSynchronisedId
- ) {
- index--;
- }
-
- for (let residx = 0; residx < result.length; residx++) {
- const record = result[residx];
- this._collaborativeLastSynchronisedId = record.id;
- if (
- index < this._historySteps.length &&
- record.id === this._historySteps[index].id
- ) {
- index++;
- continue;
- }
- updated = true;
-
- // we are not synched with the server anymore, rollback and replay
- while (this._historySteps.length > index) {
- this.historyRollback();
- this._historySteps.pop();
- }
-
- if (record.id === 1) {
- this.editable.innerHTML = '';
- }
- this.historyApply(record.mutations);
-
- record.mutations = record.id === 1 ? [] : record.mutations;
- this._historySteps.push(record);
- index++;
- }
- if (updated) {
- this._historySteps.push({
- cursor: {},
- mutations: [],
- });
- }
- this.observerActive();
- this.historyFetch();
- })
- .catch(() => {
- // TODO: change that. currently: if error on fetch, fault back to non collaborative mode.
- this._isCollaborativeActive = false;
- });
- }
-
- historySend(item) {
- if (!this._isCollaborativeActive) {
- return;
- }
- window.fetch('/history-push', {
- body: JSON.stringify(item),
- headers: { 'Content-Type': 'application/json;charset=utf-8' },
- method: 'POST',
- });
- }
-
historyRollback(until = 0) {
- const step = this._historySteps[this._historySteps.length - 1];
+ const step = this._currentStep;
this.observerFlush();
- this.historyRevert(step, until);
+ this.historyRevert(step, { until });
this.observerFlush();
step.mutations = step.mutations.slice(0, until);
this._toRollback = false;
}
-
/**
* Undo a step of the history.
*
* this._historyStepsState is a map from it's location (index) in this.history to a state.
* The state can be on of:
* undefined: the position has never been undo or redo.
- * 0: The position is considered as a redo of another.
- * 1: The position is considered as a undo of another.
- * 2: The position has been undone and is considered consumed.
+ * "redo": The position is considered as a redo of another.
+ * "undo": The position is considered as a undo of another.
+ * "consumed": The position has been undone and is considered consumed.
*/
historyUndo() {
// The last step is considered an uncommited draft so always revert it.
- const lastStep = this._historySteps[this._historySteps.length - 1];
+ const lastStep = this._currentStep;
this.historyRevert(lastStep);
// Clean the last step otherwise if no other step is created after, the
// mutations of the revert itself will be added to the same step and
@@ -716,17 +732,17 @@ export class OdooEditor extends EventTarget {
lastStep.mutations = [];
const pos = this._getNextUndoIndex();
- if (pos >= 0) {
+ if (pos > 0) {
// Consider the position consumed.
- this._historyStepsStates.set(pos, 2);
+ this._historyStepsStates.set(this._historySteps[pos].id, 'consumed');
this.historyRevert(this._historySteps[pos]);
- // Consider the last position of the history as an undo.
- this._historyStepsStates.set(this._historySteps.length - 1, 1);
this.historyStep(true);
+ // Consider the last position of the history as an undo.
+ const undoStep = this._historySteps[this._historySteps.length - 1];
+ this._historyStepsStates.set(undoStep.id, 'undo');
this.dispatchEvent(new Event('historyUndo'));
}
}
-
/**
* Redo a step of the history.
*
@@ -734,12 +750,13 @@ export class OdooEditor extends EventTarget {
*/
historyRedo() {
const pos = this._getNextRedoIndex();
- if (pos >= 0) {
- this._historyStepsStates.set(pos, 2);
+ if (pos > 0) {
+ this._historyStepsStates.set(this._historySteps[pos].id, 'consumed');
this.historyRevert(this._historySteps[pos]);
- this._historyStepsStates.set(this._historySteps.length - 1, 0);
- this.historySetCursor(this._historySteps[pos]);
+ this.historySetSelection(this._historySteps[pos]);
this.historyStep(true);
+ const lastStep = this._historySteps[this._historySteps.length - 1];
+ this._historyStepsStates.set(lastStep.id, 'redo');
this.dispatchEvent(new Event('historyRedo'));
}
}
@@ -747,19 +764,19 @@ export class OdooEditor extends EventTarget {
* Check wether undoing is possible.
*/
historyCanUndo() {
- return this._getNextUndoIndex() >= 0;
+ return this._getNextUndoIndex() > 0;
}
/**
* Check wether redoing is possible.
*/
historyCanRedo() {
- return this._getNextRedoIndex() >= 0;
+ return this._getNextRedoIndex() > 0;
}
historySize() {
return this._historySteps.length;
}
- historyRevert(step, until = 0) {
+ historyRevert(step, { until = 0, sideEffect = true } = {} ) {
// apply dom changes by reverting history steps
for (let i = step.mutations.length - 1; i >= until; i--) {
const mutation = step.mutations[i];
@@ -776,7 +793,7 @@ export class OdooEditor extends EventTarget {
const node = this.idFind(mutation.id);
if (node) {
if (mutation.oldValue) {
- node.setAttribute(mutation.attributeName, mutation.oldValue);
+ this._safeSetAttribute(node, mutation.attributeName, mutation.oldValue);
} else {
node.removeAttribute(mutation.attributeName);
}
@@ -784,7 +801,18 @@ export class OdooEditor extends EventTarget {
break;
}
case 'remove': {
- const nodeToRemove = this.unserialize(mutation.node);
+ let nodeToRemove = this.idFind(mutation.id);
+ if (!nodeToRemove) {
+ nodeToRemove = this.unserializeNode(mutation.node);
+ const fakeNode = document.createElement('fake-el');
+ fakeNode.appendChild(nodeToRemove);
+ DOMPurify.sanitize(fakeNode, { IN_PLACE: true });
+ nodeToRemove = fakeNode.childNodes[0];
+ if (!nodeToRemove) {
+ continue;
+ }
+ this.idSet(nodeToRemove);
+ }
if (mutation.nextId && this.idFind(mutation.nextId)) {
const node = this.idFind(mutation.nextId);
node && node.before(nodeToRemove);
@@ -805,39 +833,44 @@ export class OdooEditor extends EventTarget {
}
}
}
- this._activateContenteditable();
- this.historySetCursor(step);
- this.dispatchEvent(new Event('historyRevert'));
+ if (sideEffect) {
+ this._activateContenteditable();
+ this.historySetSelection(step);
+ this.dispatchEvent(new Event('historyRevert'));
+ }
}
-
/**
- * Place the cursor on the last known cursor position from the history steps.
+ * Place the selection on the last known selection position from the history
+ * steps.
*
* @returns {boolean}
*/
- resetCursorOnLastHistoryCursor() {
- const lastHistoryStep = this._historySteps[this._historySteps.length - 1];
- if (lastHistoryStep && lastHistoryStep.cursor && lastHistoryStep.cursor.anchorNode) {
- this.historySetCursor(lastHistoryStep);
- return true;
- }
- return false;
- }
-
- historySetCursor(step) {
- if (step.cursor && step.cursor.anchorNode) {
- const anchorNode = this.idFind(step.cursor.anchorNode);
- const focusNode = step.cursor.focusNode
- ? this.idFind(step.cursor.focusNode)
- : anchorNode;
+ historyResetLatestComputedSelection() {
+ if (this._latestComputedSelection && this._latestComputedSelection.anchorNode) {
+ const anchorNode = this.idFind(this._latestComputedSelection.anchorNode.oid);
+ const focusNode = this.idFind(this._latestComputedSelection.focusNode.oid) || anchorNode;
if (anchorNode) {
- setCursor(
+ setSelection(
anchorNode,
- step.cursor.anchorOffset,
+ this._latestComputedSelection.anchorOffset,
focusNode,
- step.cursor.focusOffset !== undefined
- ? step.cursor.focusOffset
- : step.cursor.anchorOffset,
+ this._latestComputedSelection.focusOffset,
+ );
+ }
+ }
+ }
+ historySetSelection(step) {
+ if (step.selection && step.selection.anchorNodeOid) {
+ const anchorNode = this.idFind(step.selection.anchorNodeOid);
+ const focusNode = this.idFind(step.selection.focusNodeOid) || anchorNode;
+ if (anchorNode) {
+ setSelection(
+ anchorNode,
+ step.selection.anchorOffset,
+ focusNode,
+ step.selection.focusOffset !== undefined
+ ? step.selection.focusOffset
+ : step.selection.anchorOffset,
false,
);
}
@@ -854,6 +887,248 @@ export class OdooEditor extends EventTarget {
historyUnpauseSteps() {
this._historyStepsActive = true;
}
+ _historyGetSnapshotStep() {
+ return {
+ selection: {
+ anchorNode: undefined,
+ anchorOffset: undefined,
+ focusNode: undefined,
+ focusOffset: undefined,
+ },
+ mutations: Array.from(this.editable.childNodes).map(node => ({
+ type: 'add',
+ append: 1,
+ id: node.oid,
+ node: serializeNode(node),
+ })),
+ id: this._generateId(),
+ clientId: this.clientId,
+ previousStepId: undefined,
+ };
+ }
+ _historyMakeSnapshot() {
+ if (
+ !this._lastSnapshotHistoryLength ||
+ this._lastSnapshotHistoryLength < this._historySteps.length
+ ) {
+ this._lastSnapshotHistoryLength = this._historySteps.length;
+ const step = this._historyGetSnapshotStep();
+ step.id = this._historySteps[this._historySteps.length - 1].id;
+ const snapshot = {
+ time: Date.now(),
+ step: step,
+ };
+ this._historySnapshots = [snapshot, this._historySnapshots[0]];
+ }
+ }
+ /**
+ * Insert a step from another collaborator.
+ */
+ _historyAddExternalStep(newStep) {
+ let index = this._historySteps.length - 1;
+ while (index >= 0 && this._historySteps[index].id !== newStep.previousStepId) {
+ // Skip steps that are already in the list.
+ if (this._historySteps[index].id === newStep.id) {
+ return;
+ }
+ index--;
+ }
+
+ // When the previousStepId is not present in the this._historySteps it
+ // could be either:
+ // - the previousStepId is before a snapshot of the same history
+ // - the previousStepId has not been received because clients were
+ // disconnected at that time
+ // - the previousStepId is in another history (in case two totally
+ // differents this._historySteps (but it should not arise)).
+ if (index < 0) {
+ if (this.options.onHistoryMissingParentSteps) {
+ const historySteps = this._historySteps;
+ let index = historySteps.length - 1;
+ // Get the last known step that we are sure the missing step
+ // client has. It could either be a step that has the same
+ // clientId or the first step.
+ while(index !== 0) {
+ if (historySteps[index].clientId === newStep.clientId) {
+ break;
+ }
+ index--;
+ }
+ const fromStepId = historySteps[index].id;
+ this.options.onHistoryMissingParentSteps({
+ step: newStep,
+ fromStepId: fromStepId,
+ });
+ }
+ return;
+ }
+
+ let currentIndex;
+ let concurentSteps = [];
+ index++;
+ while (index < this._historySteps.length) {
+ if (this._historySteps[index].previousStepId === newStep.previousStepId) {
+ if (this._historySteps[index].id.localeCompare(newStep.id) === 1) {
+ currentIndex = index;
+ break;
+ } else {
+ concurentSteps = [this._historySteps[index].id];
+ }
+ } else {
+ if (concurentSteps.includes(this._historySteps[index].previousStepId)) {
+ concurentSteps.push(this._historySteps[index].id);
+ } else {
+ currentIndex = index;
+ break;
+ }
+ }
+ index++;
+ }
+ currentIndex = typeof currentIndex !== 'undefined' ? currentIndex : index;
+
+ const stepsAfterNewStep = this._historySteps.slice(index);
+
+ for (const stepToRevert of stepsAfterNewStep.slice().reverse()) {
+ this.historyRevert(stepToRevert, { sideEffect: false });
+ }
+ this.historyApply(newStep.mutations);
+ this._historySteps.splice(index, 0, newStep);
+ for (const stepToApply of stepsAfterNewStep) {
+ this.historyApply(stepToApply.mutations);
+ }
+ }
+
+ onExternalHistorySteps(newSteps) {
+ this.observerUnactive();
+ this._computeHistorySelection();
+
+ for (const newStep of newSteps) {
+ this._historyAddExternalStep(newStep);
+ }
+
+ this.observerActive();
+ this.historyResetLatestComputedSelection();
+ this._handleCommandHint();
+ this.multiselectionRefresh();
+ }
+
+ // Multi selection
+ // -------------------------------------------------------------------------
+
+ onExternalMultiselectionUpdate(selection) {
+ this._multiselectionDisplayClient(selection);
+ const { clientId } = selection;
+ if (this._collabSelectionInfos.has(clientId)) {
+ this._collabSelectionInfos.get(clientId).selection = selection;
+ } else {
+ this._collabSelectionInfos.set(clientId, { selection });
+ }
+ }
+
+ multiselectionRefresh() {
+ this._collabSelectionsContainer.innerHTML = '';
+ for (const { selection } of this._collabSelectionInfos.values()) {
+ this._multiselectionDisplayClient(selection);
+ }
+ }
+
+ _multiselectionDisplayClient({ selection, color, clientId, clientName = 'Anonyme' }) {
+ let clientRects;
+
+ const anchorNode = this.idFind(selection.anchorNodeOid);
+ const focusNode = this.idFind(selection.focusNodeOid);
+ if (!anchorNode || !focusNode) {
+ return;
+ }
+
+ const direction = getCursorDirection(
+ anchorNode,
+ selection.anchorOffset,
+ focusNode,
+ selection.focusOffset,
+ );
+ const range = new Range();
+ try {
+ if (direction === DIRECTIONS.RIGHT) {
+ range.setStart(anchorNode, selection.anchorOffset);
+ range.setEnd(focusNode, selection.focusOffset);
+ } else {
+ range.setStart(focusNode, selection.focusOffset);
+ range.setEnd(anchorNode, selection.anchorOffset);
+ }
+
+ clientRects = Array.from(range.getClientRects());
+ } catch (e) {
+ // Changes in the dom might prevent the range to be instantiated
+ // (because of a removed node for example), in which case we ignore
+ // the range.
+ clientRects = [];
+ }
+ if (!clientRects.length) {
+ return;
+ }
+
+ // Draw rects (in case the selection is not collapsed).
+ const containerRect = this._collabSelectionsContainer.getBoundingClientRect();
+ const indicators = clientRects.map(({ x, y, width, height }) => {
+ const rectElement = this.document.createElement('div');
+ rectElement.style = `
+ position: absolute;
+ top: ${y - containerRect.y}px;
+ left: ${x - containerRect.x}px;
+ width: ${width}px;
+ height: ${height}px;
+ background-color: ${color};
+ opacity: 0.25;
+ pointer-events: none;
+ `;
+ rectElement.setAttribute('data-selection-client-id', clientId);
+ return rectElement;
+ });
+
+ // Draw carret.
+ const caretElement = this.document.createElement('div');
+ caretElement.style = `border-left: 2px solid ${color}; position: absolute;`;
+ caretElement.setAttribute('data-selection-client-id', clientId);
+ caretElement.className = 'oe-collaboration-caret';
+
+ // Draw carret top square.
+ const caretTopSquare = this.document.createElement('div');
+ caretTopSquare.className = 'oe-collaboration-caret-top-square';
+ caretTopSquare.style['background-color'] = color;
+ caretTopSquare.setAttribute('data-client-name', clientName);
+ caretElement.append(caretTopSquare);
+
+ if (clientRects.length) {
+ if (direction === DIRECTIONS.LEFT) {
+ const rect = clientRects[0];
+ caretElement.style.height = `${rect.height * 1.2}px`;
+ caretElement.style.top = `${rect.y - containerRect.y}px`;
+ caretElement.style.left = `${rect.x - containerRect.x}px`;
+ } else {
+ const rect = peek(clientRects);
+ caretElement.style.height = `${rect.height * 1.2}px`;
+ caretElement.style.top = `${rect.y - containerRect.y}px`;
+ caretElement.style.left = `${rect.right - containerRect.x}px`;
+ }
+ }
+ this._multiselectionRemoveClient(clientId);
+ this._collabSelectionsContainer.append(caretElement, ...indicators);
+ }
+
+ multiselectionRemove(clientId) {
+ this._collabSelectionInfos.delete(clientId);
+ this._multiselectionRemoveClient(clientId);
+ }
+
+ _multiselectionRemoveClient(clientId) {
+ const elements = this._collabSelectionsContainer.querySelectorAll(
+ `[data-selection-client-id="${clientId}"]`,
+ );
+ for (const element of elements) {
+ element.remove();
+ }
+ }
/**
* Same as @see _applyCommand, except that also simulates all the
@@ -870,7 +1145,7 @@ export class OdooEditor extends EventTarget {
* @returns {?}
*/
execCommand(...args) {
- this._computeHistoryCursor();
+ this._computeHistorySelection();
return this._applyCommand(...args);
}
@@ -922,7 +1197,7 @@ export class OdooEditor extends EventTarget {
let next = nextLeaf(end, this.editable);
const splitEndTd = closestElement(end, 'td') && end.nextSibling;
const contents = range.extractContents();
- setCursor(start, nodeSize(start));
+ setSelection(start, nodeSize(start));
range = getDeepRange(this.editable, { sel });
// Restore unremovables removed by extractContents.
[...contents.querySelectorAll('*')].filter(isUnremovable).forEach(n => {
@@ -982,7 +1257,7 @@ export class OdooEditor extends EventTarget {
const shouldPreserveSpace = (doJoin || hasSpaceAfter) && joinWith && oldText.endsWith(' ');
if (shouldPreserveSpace) {
joinWith.textContent = oldText.replace(/ $/, '\u00A0');
- setCursor(joinWith, nodeSize(joinWith));
+ setSelection(joinWith, nodeSize(joinWith));
}
// Rejoin blocks that extractContents may have split in two.
while (
@@ -1000,7 +1275,7 @@ export class OdooEditor extends EventTarget {
} else {
next = firstLeaf(next);
}
- }, this._historySteps[this._historySteps.length - 1].mutations.length);
+ }, this._currentStep.mutations.length);
if ([UNBREAKABLE_ROLLBACK_CODE, UNREMOVABLE_ROLLBACK_CODE].includes(res)) {
restore();
break;
@@ -1013,13 +1288,13 @@ export class OdooEditor extends EventTarget {
) {
// Restore the text we modified in order to preserve trailing space.
joinWith.textContent = oldText;
- setCursor(joinWith, nodeSize(joinWith));
+ setSelection(joinWith, nodeSize(joinWith));
}
if (joinWith) {
const el = closestElement(joinWith);
const { zws } = fillEmpty(el);
if (zws) {
- setCursor(zws, 0, zws, nodeSize(zws));
+ setSelection(zws, 0, zws, nodeSize(zws));
}
}
}
@@ -1144,7 +1419,7 @@ export class OdooEditor extends EventTarget {
* @returns {?}
*/
_applyCommand(...args) {
- this._recordHistoryCursor(true);
+ this._recordHistorySelection(true);
const result = this._protect(() => this._applyRawCommand(...args));
this.sanitize();
this.historyStep();
@@ -1216,56 +1491,80 @@ export class OdooEditor extends EventTarget {
* @private
* @returns {Object}
*/
- _computeHistoryCursor() {
+ _computeHistorySelection() {
const sel = this.document.getSelection();
if (!sel.anchorNode) {
- return this._latestComputedCursor;
+ return this._latestComputedSelection;
}
- this._latestComputedCursor = {
- anchorNode: sel.anchorNode.oid,
+ this._latestComputedSelection = {
+ anchorNode: sel.anchorNode,
anchorOffset: sel.anchorOffset,
- focusNode: sel.focusNode.oid,
+ focusNode: sel.focusNode,
focusOffset: sel.focusOffset,
};
- return this._latestComputedCursor;
+ return this._latestComputedSelection;
}
/**
* @private
* @param {boolean} [useCache=false]
*/
- _recordHistoryCursor(useCache = false) {
- const latest = this._historySteps[this._historySteps.length - 1];
- latest.cursor =
- (useCache ? this._latestComputedCursor : this._computeHistoryCursor()) || {};
+ _recordHistorySelection(useCache = false) {
+ this._currentStep.selection =
+ serializeSelection(
+ useCache ? this._latestComputedSelection : this._computeHistorySelection(),
+ ) || {};
}
/**
* Get the step index in the history to undo.
* Return -1 if no undo index can be found.
*/
_getNextUndoIndex() {
- let index = this._historySteps.length - 2;
- // go back to first step that can be undoed (0 or undefined)
- while (this._historyStepsStates.get(index)) {
- index--;
+ // Go back to first step that can be undone ("redo" or undefined).
+ for (let index = this._historySteps.length - 1; index >= 0; index--) {
+ if (
+ this._historySteps[index] &&
+ this._historySteps[index].clientId === this._collabClientId
+ ) {
+ const state = this._historyStepsStates.get(this._historySteps[index].id);
+ if (state === 'redo' || !state) {
+ return index;
+ }
+ }
}
- return index;
+ // There is no steps left to be undone, return an index that does not
+ // point to any step
+ return -1;
}
/**
* Get the step index in the history to redo.
* Return -1 if no redo index can be found.
*/
_getNextRedoIndex() {
- let pos = this._historySteps.length - 2;
// We cannot redo more than what is consumed.
- // Check if we have no more 2 than 0 until we get to a 1
+ // Check if we have no more "consumed" than "redo" until we get to an
+ // "undo"
let totalConsumed = 0;
- while (this._historyStepsStates.has(pos) && this._historyStepsStates.get(pos) !== 1) {
- // here ._historyStepsState.get(pos) can only be 2 (consumed) or 0 (undoed).
- totalConsumed += this._historyStepsStates.get(pos) === 2 ? 1 : -1;
- pos--;
+ for (let index = this._historySteps.length - 1; index >= 0; index--) {
+ if (
+ this._historySteps[index] &&
+ this._historySteps[index].clientId === this._collabClientId
+ ) {
+ const state = this._historyStepsStates.get(this._historySteps[index].id);
+ switch (state) {
+ case 'undo':
+ return totalConsumed <= 0 ? index : -1;
+ case 'redo':
+ totalConsumed -= 1;
+ break;
+ case 'consumed':
+ totalConsumed += 1;
+ break;
+ default:
+ return -1;
+ }
+ }
}
- const canRedo = this._historyStepsStates.get(pos) === 1 && totalConsumed <= 0;
- return canRedo ? pos : -1;
+ return -1;
}
// COMMAND BAR
@@ -1275,16 +1574,18 @@ export class OdooEditor extends EventTarget {
this.options.noScrollSelector = this.options.noScrollSelector || 'body';
const revertHistoryBeforeCommandbar = () => {
+ const lastStep = this._currentStep;
+ this.historyRevert(lastStep);
let stepIndex = this._historySteps.length - 1;
- while (stepIndex >= this._beforeCommandbarStepIndex) {
- const stepState = this._historyStepsStates.get(stepIndex);
- if (stepState !== 2) {
+ while (stepIndex > this._beforeCommandbarStepIndex) {
+ const step = this._historySteps[stepIndex];
+ const stepState = this._historyStepsStates.get(step.id);
+ if (step.clientId === this._collabClientId && stepState !== 'consumed') {
this.historyRevert(this._historySteps[stepIndex]);
+ this._historyStepsStates.set(step.id, 'consumed');
}
- this._historyStepsStates.set(stepIndex, 2);
stepIndex--;
}
- this._historyStepsStates.set(this._historySteps.length - 1, 1);
this.historyStep(true);
setTimeout(() => {
this.editable.focus();
@@ -1714,6 +2015,22 @@ export class OdooEditor extends EventTarget {
node.matches([].concat(...Object.values(CLIPBOARD_BLACKLISTS)).join(','))
);
}
+ _safeSetAttribute(node, attributeName, attributeValue) {
+ const parent = node.parentNode;
+ const next = node.nextSibling;
+ this.observerFlush();
+ node.remove();
+ this.observer.takeRecords();
+ node.setAttribute(attributeName, attributeValue);
+ this.observerFlush();
+ DOMPurify.sanitize(node, { IN_PLACE: true });
+ if (next) {
+ next.before(node);
+ } else if (parent) {
+ parent.append(node);
+ }
+ this.observer.takeRecords();
+ }
//--------------------------------------------------------------------------
// Handlers
@@ -1730,12 +2047,13 @@ export class OdooEditor extends EventTarget {
* @private
*/
_onInput(ev) {
- // Record the cursor position that was computed on keydown or before
+ // Record the selection position that was computed on keydown or before
// contentEditable execCommand (whatever preceded the 'input' event)
- this._recordHistoryCursor(true);
- const cursor = this._historySteps[this._historySteps.length - 1].cursor;
- const { focusOffset, focusNode, anchorNode, anchorOffset } = cursor || {};
- const wasCollapsed = !cursor || (focusNode === anchorNode && focusOffset === anchorOffset);
+ this._recordHistorySelection(true);
+ const selection = this._currentStep.selection;
+ const { anchorNodeOid, anchorOffset, focusNodeOid, focusOffset } = selection || {};
+ const wasCollapsed =
+ !selection || (focusNodeOid === anchorNodeOid && focusOffset === anchorOffset);
// Sometimes google chrome wrongly triggers an input event with `data`
// being `null` on `deleteContentForward` `insertParagraph`. Luckily,
@@ -1768,12 +2086,12 @@ export class OdooEditor extends EventTarget {
const selection = this.document.getSelection();
// Detect that text was selected and change behavior only if it is the case,
// since it is the only text insertion case that may cause problems.
- if (anchorNode !== focusNode || anchorOffset !== focusOffset) {
+ if (anchorNodeOid !== focusNodeOid || anchorOffset !== focusOffset) {
ev.preventDefault();
this._applyRawCommand('oDeleteBackward');
insertText(selection, ev.data);
const range = selection.getRangeAt(0);
- setCursor(range.endContainer, range.endOffset);
+ setSelection(range.endContainer, range.endOffset);
}
// Check for url after user insert a space so we won't transform an incomplete url.
if (
@@ -1818,8 +2136,8 @@ export class OdooEditor extends EventTarget {
if (ev.key === 'Backspace' && !ev.ctrlKey && !ev.metaKey) {
// backspace
// We need to hijack it because firefox doesn't trigger a
- // deleteBackward input event with a collapsed cursor in front of a
- // contentEditable="false" (eg: font awesome)
+ // deleteBackward input event with a collapsed selection in front of
+ // a contentEditable="false" (eg: font awesome).
const selection = this.document.getSelection();
if (selection.isCollapsed) {
ev.preventDefault();
@@ -1860,9 +2178,9 @@ export class OdooEditor extends EventTarget {
* @private
*/
_onSelectionChange() {
- // Compute the current cursor on selectionchange but do not record it. Leave
+ // Compute the current selection on selectionchange but do not record it. Leave
// that to the command execution or the 'input' event handler.
- this._computeHistoryCursor();
+ this._computeHistorySelection();
const selection = this.document.getSelection();
const isSelectionInEditable =
@@ -1874,6 +2192,23 @@ export class OdooEditor extends EventTarget {
if (this._currentMouseState === 'mouseup') {
this._fixFontAwesomeSelection();
}
+ if (
+ selection.rangeCount &&
+ selection.getRangeAt(0) &&
+ this.options.onCollaborativeSelectionChange
+ ) {
+ this.options.onCollaborativeSelectionChange(this.getCurrentCollaborativeSelection());
+ }
+ }
+
+ getCurrentCollaborativeSelection() {
+ const selection = this._latestComputedSelection || this._computeHistorySelection();
+ if (!selection) return;
+ return Object.assign({
+ selection: serializeSelection(selection),
+ color: this._collabSelectionColor,
+ clientId: this._collabClientId,
+ });
}
clean() {
@@ -2062,7 +2397,6 @@ export class OdooEditor extends EventTarget {
// To avoid this problem, we make all editable zone become uneditable
// except the link. Then when cliking outside the link, reset the
// editable zones.
- this.automaticStepSkipStack();
const link = closestElement(ev.target, 'a');
this._resetContenteditableLinks();
if (
@@ -2092,6 +2426,8 @@ export class OdooEditor extends EventTarget {
} else {
this._activateContenteditable();
}
+ // Ignore any changes that might have happened before this point.
+ this.observer.takeRecords();
const node = ev.target;
// handle checkbox lists
@@ -2099,6 +2435,7 @@ export class OdooEditor extends EventTarget {
if (ev.offsetX < 0) {
toggleClass(node, 'o_checked');
ev.preventDefault();
+ this.historyStep();
}
}
}
@@ -2363,10 +2700,10 @@ export class OdooEditor extends EventTarget {
}
if (this.document.caretPositionFromPoint) {
const range = this.document.caretPositionFromPoint(ev.clientX, ev.clientY);
- setCursor(range.offsetNode, range.offset);
+ setSelection(range.offsetNode, range.offset);
} else if (this.document.caretRangeFromPoint) {
const range = this.document.caretRangeFromPoint(ev.clientX, ev.clientY);
- setCursor(range.startContainer, range.startOffset);
+ setSelection(range.startContainer, range.startOffset);
}
this.execCommand('insertHTML', this._prepareClipboardData(pastedText));
});
@@ -2386,7 +2723,7 @@ export class OdooEditor extends EventTarget {
const cursorDestination =
tds[tds.findIndex(td => closestTd === td) + (direction === DIRECTIONS.LEFT ? -1 : 1)];
if (cursorDestination) {
- setCursor(...startPos(cursorDestination), ...endPos(cursorDestination), true);
+ setSelection(...startPos(cursorDestination), ...endPos(cursorDestination), true);
} else if (direction === DIRECTIONS.RIGHT) {
this._addRowBelow();
this._onTabulationInTable(ev);
@@ -2450,7 +2787,7 @@ export class OdooEditor extends EventTarget {
}
}
if (shouldUpdateSelection) {
- setCursor(
+ setSelection(
fixedSelection.anchorNode,
fixedSelection.anchorOffset,
fixedSelection.focusNode,
diff --git a/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js b/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js
index d2c5cb96443..0f4b8010c56 100644
--- a/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js
+++ b/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js
@@ -26,7 +26,7 @@ import {
leftLeafFirstPath,
preserveCursor,
rightPos,
- setCursor,
+ setSelection,
setCursorStart,
setTagName,
splitElement,
@@ -256,9 +256,9 @@ export function applyInlineStyle(editor, applyStyle) {
const firstNode = selectedTextNodes[0];
const lastNode = selectedTextNodes[selectedTextNodes.length - 1];
if (direction === DIRECTIONS.RIGHT) {
- setCursor(firstNode, 0, lastNode, lastNode.length);
+ setSelection(firstNode, 0, lastNode, lastNode.length);
} else {
- setCursor(lastNode, lastNode.length, firstNode, 0);
+ setSelection(lastNode, lastNode.length, firstNode, 0);
}
}
function addColumn(editor, beforeOrAfter) {
@@ -285,7 +285,7 @@ function deleteTable(editor, table) {
p.appendChild(document.createElement('br'));
table.before(p);
table.remove();
- setCursor(p, 0);
+ setSelection(p, 0);
}
// This is a whitelist of the commands that are implemented by the
@@ -307,7 +307,7 @@ export const editorCommands = {
const insertedNode = editorCommands.insertHTML(editor, '')[0];
insertedNode.className = faClass;
const position = rightPos(insertedNode);
- setCursor(...position, ...position, false);
+ setSelection(...position, ...position, false);
},
// History
@@ -324,7 +324,7 @@ export const editorCommands = {
block.nodeName,
)
) {
- setCursor(block, 0, block, nodeSize(block));
+ setSelection(block, 0, block, nodeSize(block));
editor.historyPauseSteps();
editor.document.execCommand('removeFormat');
editor.historyUnpauseSteps();
@@ -394,13 +394,13 @@ export const editorCommands = {
link = link || prompt('URL or Email', (currentLink && currentLink.href) || 'http://');
const res = editor.document.execCommand('createLink', false, link);
if (res) {
- setCursor(sel.anchorNode, sel.anchorOffset, sel.focusNode, sel.focusOffset);
+ setSelection(sel.anchorNode, sel.anchorOffset, sel.focusNode, sel.focusOffset);
const node = findNode(closestPath(sel.focusNode), node => node.tagName === 'A');
for (const [param, value] of Object.entries(editor.options.defaultLinkAttributes)) {
node.setAttribute(param, `${value}`);
}
const pos = [node.parentElement, childNodeIndex(node) + 1];
- setCursor(...pos, ...pos, false);
+ setSelection(...pos, ...pos, false);
}
},
unlink: editor => {
@@ -415,12 +415,12 @@ export const editorCommands = {
if (sel.isCollapsed) {
const cr = preserveCursor(editor.document);
const node = closestElement(sel.focusNode, 'a');
- setCursor(node, 0, node, node.childNodes.length, false);
+ setSelection(node, 0, node, node.childNodes.length, false);
editor.document.execCommand('unlink');
cr();
} else {
editor.document.execCommand('unlink');
- setCursor(sel.anchorNode, sel.anchorOffset, sel.focusNode, sel.focusOffset);
+ setSelection(sel.anchorNode, sel.anchorOffset, sel.focusNode, sel.focusOffset);
}
},
@@ -561,7 +561,7 @@ export const editorCommands = {
? splitTextNode(anchorNode, sel.anchorOffset, DIRECTIONS.LEFT) + 1 && anchorNode
: splitElement(anchorNode, sel.anchorOffset).shift();
const newPosition = rightPos(newAnchorNode);
- setCursor(...newPosition, ...newPosition, false);
+ setSelection(...newPosition, ...newPosition, false);
}
const [table] = editorCommands.insertHTML(editor, tableHtml);
setCursorStart(table.querySelector('td'));
@@ -587,7 +587,7 @@ export const editorCommands = {
const index = cells.findIndex(td => td === cell);
const siblingCell = cells[index - 1] || cells[index + 1];
table.querySelectorAll(`tr td:nth-of-type(${index + 1})`).forEach(td => td.remove());
- siblingCell ? setCursor(...startPos(siblingCell)) : deleteTable(editor, table);
+ siblingCell ? setSelection(...startPos(siblingCell)) : deleteTable(editor, table);
},
removeRow: editor => {
getDeepRange(editor.editable, { select: true }); // Ensure deep range for finding tr.
@@ -598,7 +598,7 @@ export const editorCommands = {
const rowIndex = rows.findIndex(tr => tr === row);
const siblingRow = rows[rowIndex - 1] || rows[rowIndex + 1];
row.remove();
- siblingRow ? setCursor(...startPos(siblingRow)) : deleteTable(editor, table);
+ siblingRow ? setSelection(...startPos(siblingRow)) : deleteTable(editor, table);
},
deleteTable: (editor, table) => deleteTable(editor, table),
insertHorizontalRule(editor) {
diff --git a/addons/web_editor/static/lib/odoo-editor/src/commands/deleteBackward.js b/addons/web_editor/static/lib/odoo-editor/src/commands/deleteBackward.js
index 18bef0adbfc..828b891c956 100644
--- a/addons/web_editor/static/lib/odoo-editor/src/commands/deleteBackward.js
+++ b/addons/web_editor/static/lib/odoo-editor/src/commands/deleteBackward.js
@@ -18,7 +18,7 @@ import {
moveNodes,
nodeSize,
prepareUpdate,
- setCursor,
+ setSelection,
splitTextNode,
isUnbreakable,
isMediaElement,
@@ -56,7 +56,7 @@ Text.prototype.oDeleteBackward = function (offset, alreadyMoved = false) {
}
fillEmpty(parentNode);
- setCursor(parentNode, firstSplitOffset);
+ setSelection(parentNode, firstSplitOffset);
};
HTMLElement.prototype.oDeleteBackward = function (offset, alreadyMoved = false) {
@@ -127,7 +127,7 @@ HTMLElement.prototype.oDeleteBackward = function (offset, alreadyMoved = false)
// TODO this handle BR/IMG/etc removals../ to see if we
// prefer to have a dedicated handler for every possible
// HTML element or if we let this generic code handle it.
- setCursor(parentEl, parentOffset);
+ setSelection(parentEl, parentOffset);
return;
}
}
@@ -158,7 +158,7 @@ HTMLElement.prototype.oDeleteBackward = function (offset, alreadyMoved = false)
firstBlockIndex++;
}
let [cursorNode, cursorOffset] = moveNodes(...moveDest, this, offset, firstBlockIndex);
- setCursor(cursorNode, cursorOffset);
+ setSelection(cursorNode, cursorOffset);
// Propagate if this is still a block on the left of where the nodes were
// moved.
diff --git a/addons/web_editor/static/lib/odoo-editor/src/commands/shiftEnter.js b/addons/web_editor/static/lib/odoo-editor/src/commands/shiftEnter.js
index fd42a8812bd..3b106e124f2 100644
--- a/addons/web_editor/static/lib/odoo-editor/src/commands/shiftEnter.js
+++ b/addons/web_editor/static/lib/odoo-editor/src/commands/shiftEnter.js
@@ -5,7 +5,7 @@ import {
isFakeLineBreak,
prepareUpdate,
rightPos,
- setCursor,
+ setSelection,
getState,
leftPos,
splitTextNode,
@@ -35,7 +35,7 @@ HTMLElement.prototype.oShiftEnter = function (offset) {
for (const el of brEls) {
if (el.parentNode) {
- setCursor(...rightPos(el));
+ setSelection(...rightPos(el));
break;
}
}
diff --git a/addons/web_editor/static/lib/odoo-editor/src/commands/toggleList.js b/addons/web_editor/static/lib/odoo-editor/src/commands/toggleList.js
index 6c60ebe7832..c1417ccb5c1 100644
--- a/addons/web_editor/static/lib/odoo-editor/src/commands/toggleList.js
+++ b/addons/web_editor/static/lib/odoo-editor/src/commands/toggleList.js
@@ -23,8 +23,7 @@ HTMLElement.prototype.oToggleList = function (offset, mode = 'UL') {
return inLI.oToggleList(0, mode);
}
const restoreCursor = preserveCursor(this.ownerDocument);
- // if `this` is the root editable
- if (this.oid === 1) {
+ if (this.oid === 'root') {
const callingNode = this.childNodes[offset];
const group = getAdjacents(callingNode, n => !isBlock(n));
insertListAfter(callingNode, mode, [group]);
diff --git a/addons/web_editor/static/lib/odoo-editor/src/style.css b/addons/web_editor/static/lib/odoo-editor/src/style.css
index 84120fecb9a..f54edeb88c0 100644
--- a/addons/web_editor/static/lib/odoo-editor/src/style.css
+++ b/addons/web_editor/static/lib/odoo-editor/src/style.css
@@ -301,3 +301,30 @@ i.oe-commandbar-commandImg {
text-align: inherit;
width: 100%;
}
+
+/* Collaboration cursor */
+.oe-collaboration-selections-container {
+ position: absolute;
+ isolation: isolate;
+ height: 0;
+ width: 0;
+ z-index: 1;
+}
+.oe-collaboration-caret-top-square {
+ min-height: 5px;
+ min-width: 5px;
+ color: #fff;
+ text-shadow: 0 0 5px #000;
+ position: absolute;
+ bottom: 100%;
+ left: -4px;
+ white-space: nowrap;
+}
+
+.oe-collaboration-caret-top-square:hover::before {
+ content: attr(data-client-name);
+}
+.oe-collaboration-caret-top-square:hover {
+ border-radius: 2px;
+ padding: 0.3em 0.6em;
+}
diff --git a/addons/web_editor/static/lib/odoo-editor/src/utils/DOMPurify.js b/addons/web_editor/static/lib/odoo-editor/src/utils/DOMPurify.js
new file mode 100644
index 00000000000..388dc1e84b9
--- /dev/null
+++ b/addons/web_editor/static/lib/odoo-editor/src/utils/DOMPurify.js
@@ -0,0 +1,1376 @@
+/*! @license DOMPurify 2.3.1 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/2.3.1/LICENSE */
+
+(function (global, factory) {
+ typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
+ typeof define === 'function' && define.amd ? define(factory) :
+ (global = global || self, global.DOMPurify = factory());
+}(this, function () { 'use strict';
+
+ function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } }
+
+ var hasOwnProperty = Object.hasOwnProperty,
+ setPrototypeOf = Object.setPrototypeOf,
+ isFrozen = Object.isFrozen,
+ getPrototypeOf = Object.getPrototypeOf,
+ getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;
+ var freeze = Object.freeze,
+ seal = Object.seal,
+ create = Object.create; // eslint-disable-line import/no-mutable-exports
+
+ var _ref = typeof Reflect !== 'undefined' && Reflect,
+ apply = _ref.apply,
+ construct = _ref.construct;
+
+ if (!apply) {
+ apply = function apply(fun, thisValue, args) {
+ return fun.apply(thisValue, args);
+ };
+ }
+
+ if (!freeze) {
+ freeze = function freeze(x) {
+ return x;
+ };
+ }
+
+ if (!seal) {
+ seal = function seal(x) {
+ return x;
+ };
+ }
+
+ if (!construct) {
+ construct = function construct(Func, args) {
+ return new (Function.prototype.bind.apply(Func, [null].concat(_toConsumableArray(args))))();
+ };
+ }
+
+ var arrayForEach = unapply(Array.prototype.forEach);
+ var arrayPop = unapply(Array.prototype.pop);
+ var arrayPush = unapply(Array.prototype.push);
+
+ var stringToLowerCase = unapply(String.prototype.toLowerCase);
+ var stringMatch = unapply(String.prototype.match);
+ var stringReplace = unapply(String.prototype.replace);
+ var stringIndexOf = unapply(String.prototype.indexOf);
+ var stringTrim = unapply(String.prototype.trim);
+
+ var regExpTest = unapply(RegExp.prototype.test);
+
+ var typeErrorCreate = unconstruct(TypeError);
+
+ function unapply(func) {
+ return function (thisArg) {
+ for (var _len = arguments.length, args = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
+ args[_key - 1] = arguments[_key];
+ }
+
+ return apply(func, thisArg, args);
+ };
+ }
+
+ function unconstruct(func) {
+ return function () {
+ for (var _len2 = arguments.length, args = Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
+ args[_key2] = arguments[_key2];
+ }
+
+ return construct(func, args);
+ };
+ }
+
+ /* Add properties to a lookup table */
+ function addToSet(set, array) {
+ if (setPrototypeOf) {
+ // Make 'in' and truthy checks like Boolean(set.constructor)
+ // independent of any properties defined on Object.prototype.
+ // Prevent prototype setters from intercepting set as a this value.
+ setPrototypeOf(set, null);
+ }
+
+ var l = array.length;
+ while (l--) {
+ var element = array[l];
+ if (typeof element === 'string') {
+ var lcElement = stringToLowerCase(element);
+ if (lcElement !== element) {
+ // Config presets (e.g. tags.js, attrs.js) are immutable.
+ if (!isFrozen(array)) {
+ array[l] = lcElement;
+ }
+
+ element = lcElement;
+ }
+ }
+
+ set[element] = true;
+ }
+
+ return set;
+ }
+
+ /* Shallow clone an object */
+ function clone(object) {
+ var newObject = create(null);
+
+ var property = void 0;
+ for (property in object) {
+ if (apply(hasOwnProperty, object, [property])) {
+ newObject[property] = object[property];
+ }
+ }
+
+ return newObject;
+ }
+
+ /* IE10 doesn't support __lookupGetter__ so lets'
+ * simulate it. It also automatically checks
+ * if the prop is function or getter and behaves
+ * accordingly. */
+ function lookupGetter(object, prop) {
+ while (object !== null) {
+ var desc = getOwnPropertyDescriptor(object, prop);
+ if (desc) {
+ if (desc.get) {
+ return unapply(desc.get);
+ }
+
+ if (typeof desc.value === 'function') {
+ return unapply(desc.value);
+ }
+ }
+
+ object = getPrototypeOf(object);
+ }
+
+ function fallbackValue(element) {
+ console.warn('fallback value for', element);
+ return null;
+ }
+
+ return fallbackValue;
+ }
+
+ var html = freeze(['a', 'abbr', 'acronym', 'address', 'area', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo', 'big', 'blink', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'content', 'data', 'datalist', 'dd', 'decorator', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'element', 'em', 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'map', 'mark', 'marquee', 'menu', 'menuitem', 'meter', 'nav', 'nobr', 'ol', 'optgroup', 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'select', 'shadow', 'small', 'source', 'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'tr', 'track', 'tt', 'u', 'ul', 'var', 'video', 'wbr']);
+
+ // SVG
+ var svg = freeze(['svg', 'a', 'altglyph', 'altglyphdef', 'altglyphitem', 'animatecolor', 'animatemotion', 'animatetransform', 'circle', 'clippath', 'defs', 'desc', 'ellipse', 'filter', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'line', 'lineargradient', 'marker', 'mask', 'metadata', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialgradient', 'rect', 'stop', 'style', 'switch', 'symbol', 'text', 'textpath', 'title', 'tref', 'tspan', 'view', 'vkern']);
+
+ var svgFilters = freeze(['feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR', 'feGaussianBlur', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset', 'fePointLight', 'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence']);
+
+ // List of SVG elements that are disallowed by default.
+ // We still need to know them so that we can do namespace
+ // checks properly in case one wants to add them to
+ // allow-list.
+ var svgDisallowed = freeze(['animate', 'color-profile', 'cursor', 'discard', 'fedropshadow', 'feimage', 'font-face', 'font-face-format', 'font-face-name', 'font-face-src', 'font-face-uri', 'foreignobject', 'hatch', 'hatchpath', 'mesh', 'meshgradient', 'meshpatch', 'meshrow', 'missing-glyph', 'script', 'set', 'solidcolor', 'unknown', 'use']);
+
+ var mathMl = freeze(['math', 'menclose', 'merror', 'mfenced', 'mfrac', 'mglyph', 'mi', 'mlabeledtr', 'mmultiscripts', 'mn', 'mo', 'mover', 'mpadded', 'mphantom', 'mroot', 'mrow', 'ms', 'mspace', 'msqrt', 'mstyle', 'msub', 'msup', 'msubsup', 'mtable', 'mtd', 'mtext', 'mtr', 'munder', 'munderover']);
+
+ // Similarly to SVG, we want to know all MathML elements,
+ // even those that we disallow by default.
+ var mathMlDisallowed = freeze(['maction', 'maligngroup', 'malignmark', 'mlongdiv', 'mscarries', 'mscarry', 'msgroup', 'mstack', 'msline', 'msrow', 'semantics', 'annotation', 'annotation-xml', 'mprescripts', 'none']);
+
+ var text = freeze(['#text']);
+
+ var html$1 = freeze(['accept', 'action', 'align', 'alt', 'autocapitalize', 'autocomplete', 'autopictureinpicture', 'autoplay', 'background', 'bgcolor', 'border', 'capture', 'cellpadding', 'cellspacing', 'checked', 'cite', 'class', 'clear', 'color', 'cols', 'colspan', 'controls', 'controlslist', 'coords', 'crossorigin', 'datetime', 'decoding', 'default', 'dir', 'disabled', 'disablepictureinpicture', 'disableremoteplayback', 'download', 'draggable', 'enctype', 'enterkeyhint', 'face', 'for', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'id', 'inputmode', 'integrity', 'ismap', 'kind', 'label', 'lang', 'list', 'loading', 'loop', 'low', 'max', 'maxlength', 'media', 'method', 'min', 'minlength', 'multiple', 'muted', 'name', 'noshade', 'novalidate', 'nowrap', 'open', 'optimum', 'pattern', 'placeholder', 'playsinline', 'poster', 'preload', 'pubdate', 'radiogroup', 'readonly', 'rel', 'required', 'rev', 'reversed', 'role', 'rows', 'rowspan', 'spellcheck', 'scope', 'selected', 'shape', 'size', 'sizes', 'span', 'srclang', 'start', 'src', 'srcset', 'step', 'style', 'summary', 'tabindex', 'title', 'translate', 'type', 'usemap', 'valign', 'value', 'width', 'xmlns', 'slot']);
+
+ var svg$1 = freeze(['accent-height', 'accumulate', 'additive', 'alignment-baseline', 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseline-shift', 'begin', 'bias', 'by', 'class', 'clip', 'clippathunits', 'clip-path', 'clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction', 'display', 'divisor', 'dur', 'edgemode', 'elevation', 'end', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'filterunits', 'flood-color', 'flood-opacity', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform', 'height', 'href', 'id', 'image-rendering', 'in', 'in2', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints', 'keysplines', 'keytimes', 'lang', 'lengthadjust', 'letter-spacing', 'kernelmatrix', 'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid', 'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits', 'maskunits', 'max', 'mask', 'media', 'method', 'mode', 'min', 'name', 'numoctaves', 'offset', 'operator', 'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order', 'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits', 'points', 'preservealpha', 'preserveaspectratio', 'primitiveunits', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount', 'repeatdur', 'restart', 'result', 'rotate', 'scale', 'seed', 'shape-rendering', 'specularconstant', 'specularexponent', 'spreadmethod', 'startoffset', 'stddeviation', 'stitchtiles', 'stop-color', 'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width', 'style', 'surfacescale', 'systemlanguage', 'tabindex', 'targetx', 'targety', 'transform', 'text-anchor', 'text-decoration', 'text-rendering', 'textlength', 'type', 'u1', 'u2', 'unicode', 'values', 'viewbox', 'visibility', 'version', 'vert-adv-y', 'vert-origin-x', 'vert-origin-y', 'width', 'word-spacing', 'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2', 'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan']);
+
+ var mathMl$1 = freeze(['accent', 'accentunder', 'align', 'bevelled', 'close', 'columnsalign', 'columnlines', 'columnspan', 'denomalign', 'depth', 'dir', 'display', 'displaystyle', 'encoding', 'fence', 'frame', 'height', 'href', 'id', 'largeop', 'length', 'linethickness', 'lspace', 'lquote', 'mathbackground', 'mathcolor', 'mathsize', 'mathvariant', 'maxsize', 'minsize', 'movablelimits', 'notation', 'numalign', 'open', 'rowalign', 'rowlines', 'rowspacing', 'rowspan', 'rspace', 'rquote', 'scriptlevel', 'scriptminsize', 'scriptsizemultiplier', 'selection', 'separator', 'separators', 'stretchy', 'subscriptshift', 'supscriptshift', 'symmetric', 'voffset', 'width', 'xmlns']);
+
+ var xml = freeze(['xlink:href', 'xml:id', 'xlink:title', 'xml:space', 'xmlns:xlink']);
+
+ // eslint-disable-next-line unicorn/better-regex
+ var MUSTACHE_EXPR = seal(/\{\{[\s\S]*|[\s\S]*\}\}/gm); // Specify template detection regex for SAFE_FOR_TEMPLATES mode
+ var ERB_EXPR = seal(/<%[\s\S]*|[\s\S]*%>/gm);
+ var DATA_ATTR = seal(/^data-[\-\w.\u00B7-\uFFFF]/); // eslint-disable-line no-useless-escape
+ var ARIA_ATTR = seal(/^aria-[\-\w]+$/); // eslint-disable-line no-useless-escape
+ var IS_ALLOWED_URI = seal(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i // eslint-disable-line no-useless-escape
+ );
+ var IS_SCRIPT_OR_DATA = seal(/^(?:\w+script|data):/i);
+ var ATTR_WHITESPACE = seal(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g // eslint-disable-line no-control-regex
+ );
+
+ var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; };
+
+ function _toConsumableArray$1(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } }
+
+ var getGlobal = function getGlobal() {
+ return typeof window === 'undefined' ? null : window;
+ };
+
+ /**
+ * Creates a no-op policy for internal use only.
+ * Don't export this function outside this module!
+ * @param {?TrustedTypePolicyFactory} trustedTypes The policy factory.
+ * @param {Document} document The document object (to determine policy name suffix)
+ * @return {?TrustedTypePolicy} The policy created (or null, if Trusted Types
+ * are not supported).
+ */
+ var _createTrustedTypesPolicy = function _createTrustedTypesPolicy(trustedTypes, document) {
+ if ((typeof trustedTypes === 'undefined' ? 'undefined' : _typeof(trustedTypes)) !== 'object' || typeof trustedTypes.createPolicy !== 'function') {
+ return null;
+ }
+
+ // Allow the callers to control the unique policy name
+ // by adding a data-tt-policy-suffix to the script element with the DOMPurify.
+ // Policy creation with duplicate names throws in Trusted Types.
+ var suffix = null;
+ var ATTR_NAME = 'data-tt-policy-suffix';
+ if (document.currentScript && document.currentScript.hasAttribute(ATTR_NAME)) {
+ suffix = document.currentScript.getAttribute(ATTR_NAME);
+ }
+
+ var policyName = 'dompurify' + (suffix ? '#' + suffix : '');
+
+ try {
+ return trustedTypes.createPolicy(policyName, {
+ createHTML: function createHTML(html$$1) {
+ return html$$1;
+ }
+ });
+ } catch (_) {
+ // Policy creation failed (most likely another DOMPurify script has
+ // already run). Skip creating the policy, as this will only cause errors
+ // if TT are enforced.
+ console.warn('TrustedTypes policy ' + policyName + ' could not be created.');
+ return null;
+ }
+ };
+
+ function createDOMPurify() {
+ var window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal();
+
+ var DOMPurify = function DOMPurify(root) {
+ return createDOMPurify(root);
+ };
+
+ /**
+ * Version label, exposed for easier checks
+ * if DOMPurify is up to date or not
+ */
+ DOMPurify.version = '2.3.1';
+
+ /**
+ * Array of elements that DOMPurify removed during sanitation.
+ * Empty if nothing was removed.
+ */
+ DOMPurify.removed = [];
+
+ if (!window || !window.document || window.document.nodeType !== 9) {
+ // Not running in a browser, provide a factory function
+ // so that you can pass your own Window
+ DOMPurify.isSupported = false;
+
+ return DOMPurify;
+ }
+
+ var originalDocument = window.document;
+
+ var document = window.document;
+ var DocumentFragment = window.DocumentFragment,
+ HTMLTemplateElement = window.HTMLTemplateElement,
+ Node = window.Node,
+ Element = window.Element,
+ NodeFilter = window.NodeFilter,
+ _window$NamedNodeMap = window.NamedNodeMap,
+ NamedNodeMap = _window$NamedNodeMap === undefined ? window.NamedNodeMap || window.MozNamedAttrMap : _window$NamedNodeMap,
+ Text = window.Text,
+ Comment = window.Comment,
+ DOMParser = window.DOMParser,
+ trustedTypes = window.trustedTypes;
+
+
+ var ElementPrototype = Element.prototype;
+
+ var cloneNode = lookupGetter(ElementPrototype, 'cloneNode');
+ var getNextSibling = lookupGetter(ElementPrototype, 'nextSibling');
+ var getChildNodes = lookupGetter(ElementPrototype, 'childNodes');
+ var getParentNode = lookupGetter(ElementPrototype, 'parentNode');
+
+ // As per issue #47, the web-components registry is inherited by a
+ // new document created via createHTMLDocument. As per the spec
+ // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries)
+ // a new empty registry is used when creating a template contents owner
+ // document, so we use that as our parent document to ensure nothing
+ // is inherited.
+ if (typeof HTMLTemplateElement === 'function') {
+ var template = document.createElement('template');
+ if (template.content && template.content.ownerDocument) {
+ document = template.content.ownerDocument;
+ }
+ }
+
+ var trustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, originalDocument);
+ var emptyHTML = trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML('') : '';
+
+ var _document = document,
+ implementation = _document.implementation,
+ createNodeIterator = _document.createNodeIterator,
+ createDocumentFragment = _document.createDocumentFragment,
+ getElementsByTagName = _document.getElementsByTagName;
+ var importNode = originalDocument.importNode;
+
+
+ var documentMode = {};
+ try {
+ documentMode = clone(document).documentMode ? document.documentMode : {};
+ } catch (_) {}
+
+ var hooks = {};
+
+ /**
+ * Expose whether this browser supports running the full DOMPurify.
+ */
+ DOMPurify.isSupported = typeof getParentNode === 'function' && implementation && typeof implementation.createHTMLDocument !== 'undefined' && documentMode !== 9;
+
+ var MUSTACHE_EXPR$$1 = MUSTACHE_EXPR,
+ ERB_EXPR$$1 = ERB_EXPR,
+ DATA_ATTR$$1 = DATA_ATTR,
+ ARIA_ATTR$$1 = ARIA_ATTR,
+ IS_SCRIPT_OR_DATA$$1 = IS_SCRIPT_OR_DATA,
+ ATTR_WHITESPACE$$1 = ATTR_WHITESPACE;
+ var IS_ALLOWED_URI$$1 = IS_ALLOWED_URI;
+
+ /**
+ * We consider the elements and attributes below to be safe. Ideally
+ * don't add any new ones but feel free to remove unwanted ones.
+ */
+
+ /* allowed element names */
+
+ var ALLOWED_TAGS = null;
+ var DEFAULT_ALLOWED_TAGS = addToSet({}, [].concat(_toConsumableArray$1(html), _toConsumableArray$1(svg), _toConsumableArray$1(svgFilters), _toConsumableArray$1(mathMl), _toConsumableArray$1(text)));
+
+ /* Allowed attribute names */
+ var ALLOWED_ATTR = null;
+ var DEFAULT_ALLOWED_ATTR = addToSet({}, [].concat(_toConsumableArray$1(html$1), _toConsumableArray$1(svg$1), _toConsumableArray$1(mathMl$1), _toConsumableArray$1(xml)));
+
+ /* Explicitly forbidden tags (overrides ALLOWED_TAGS/ADD_TAGS) */
+ var FORBID_TAGS = null;
+
+ /* Explicitly forbidden attributes (overrides ALLOWED_ATTR/ADD_ATTR) */
+ var FORBID_ATTR = null;
+
+ /* Decide if ARIA attributes are okay */
+ var ALLOW_ARIA_ATTR = true;
+
+ /* Decide if custom data attributes are okay */
+ var ALLOW_DATA_ATTR = true;
+
+ /* Decide if unknown protocols are okay */
+ var ALLOW_UNKNOWN_PROTOCOLS = false;
+
+ /* Output should be safe for common template engines.
+ * This means, DOMPurify removes data attributes, mustaches and ERB
+ */
+ var SAFE_FOR_TEMPLATES = false;
+
+ /* Decide if document with ... should be returned */
+ var WHOLE_DOCUMENT = false;
+
+ /* Track whether config is already set on this instance of DOMPurify. */
+ var SET_CONFIG = false;
+
+ /* Decide if all elements (e.g. style, script) must be children of
+ * document.body. By default, browsers might move them to document.head */
+ var FORCE_BODY = false;
+
+ /* Decide if a DOM `HTMLBodyElement` should be returned, instead of a html
+ * string (or a TrustedHTML object if Trusted Types are supported).
+ * If `WHOLE_DOCUMENT` is enabled a `HTMLHtmlElement` will be returned instead
+ */
+ var RETURN_DOM = false;
+
+ /* Decide if a DOM `DocumentFragment` should be returned, instead of a html
+ * string (or a TrustedHTML object if Trusted Types are supported) */
+ var RETURN_DOM_FRAGMENT = false;
+
+ /* If `RETURN_DOM` or `RETURN_DOM_FRAGMENT` is enabled, decide if the returned DOM
+ * `Node` is imported into the current `Document`. If this flag is not enabled the
+ * `Node` will belong (its ownerDocument) to a fresh `HTMLDocument`, created by
+ * DOMPurify.
+ *
+ * This defaults to `true` starting DOMPurify 2.2.0. Note that setting it to `false`
+ * might cause XSS from attacks hidden in closed shadowroots in case the browser
+ * supports Declarative Shadow: DOM https://web.dev/declarative-shadow-dom/
+ */
+ var RETURN_DOM_IMPORT = true;
+
+ /* Try to return a Trusted Type object instead of a string, return a string in
+ * case Trusted Types are not supported */
+ var RETURN_TRUSTED_TYPE = false;
+
+ /* Output should be free from DOM clobbering attacks? */
+ var SANITIZE_DOM = true;
+
+ /* Keep element content when removing element? */
+ var KEEP_CONTENT = true;
+
+ /* If a `Node` is passed to sanitize(), then performs sanitization in-place instead
+ * of importing it into a new Document and returning a sanitized copy */
+ var IN_PLACE = false;
+
+ /* Allow usage of profiles like html, svg and mathMl */
+ var USE_PROFILES = {};
+
+ /* Tags to ignore content of when KEEP_CONTENT is true */
+ var FORBID_CONTENTS = null;
+ var DEFAULT_FORBID_CONTENTS = addToSet({}, ['annotation-xml', 'audio', 'colgroup', 'desc', 'foreignobject', 'head', 'iframe', 'math', 'mi', 'mn', 'mo', 'ms', 'mtext', 'noembed', 'noframes', 'noscript', 'plaintext', 'script', 'style', 'svg', 'template', 'thead', 'title', 'video', 'xmp']);
+
+ /* Tags that are safe for data: URIs */
+ var DATA_URI_TAGS = null;
+ var DEFAULT_DATA_URI_TAGS = addToSet({}, ['audio', 'video', 'img', 'source', 'image', 'track']);
+
+ /* Attributes safe for values like "javascript:" */
+ var URI_SAFE_ATTRIBUTES = null;
+ var DEFAULT_URI_SAFE_ATTRIBUTES = addToSet({}, ['alt', 'class', 'for', 'id', 'label', 'name', 'pattern', 'placeholder', 'role', 'summary', 'title', 'value', 'style', 'xmlns']);
+
+ var MATHML_NAMESPACE = 'http://www.w3.org/1998/Math/MathML';
+ var SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
+ var HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
+ /* Document namespace */
+ var NAMESPACE = HTML_NAMESPACE;
+ var IS_EMPTY_INPUT = false;
+
+ /* Keep a reference to config to pass to hooks */
+ var CONFIG = null;
+
+ /* Ideally, do not touch anything below this line */
+ /* ______________________________________________ */
+
+ var formElement = document.createElement('form');
+
+ /**
+ * _parseConfig
+ *
+ * @param {Object} cfg optional config literal
+ */
+ // eslint-disable-next-line complexity
+ var _parseConfig = function _parseConfig(cfg) {
+ if (CONFIG && CONFIG === cfg) {
+ return;
+ }
+
+ /* Shield configuration object from tampering */
+ if (!cfg || (typeof cfg === 'undefined' ? 'undefined' : _typeof(cfg)) !== 'object') {
+ cfg = {};
+ }
+
+ /* Shield configuration object from prototype pollution */
+ cfg = clone(cfg);
+
+ /* Set configuration parameters */
+ ALLOWED_TAGS = 'ALLOWED_TAGS' in cfg ? addToSet({}, cfg.ALLOWED_TAGS) : DEFAULT_ALLOWED_TAGS;
+ ALLOWED_ATTR = 'ALLOWED_ATTR' in cfg ? addToSet({}, cfg.ALLOWED_ATTR) : DEFAULT_ALLOWED_ATTR;
+ URI_SAFE_ATTRIBUTES = 'ADD_URI_SAFE_ATTR' in cfg ? addToSet(clone(DEFAULT_URI_SAFE_ATTRIBUTES), cfg.ADD_URI_SAFE_ATTR) : DEFAULT_URI_SAFE_ATTRIBUTES;
+ DATA_URI_TAGS = 'ADD_DATA_URI_TAGS' in cfg ? addToSet(clone(DEFAULT_DATA_URI_TAGS), cfg.ADD_DATA_URI_TAGS) : DEFAULT_DATA_URI_TAGS;
+ FORBID_CONTENTS = 'FORBID_CONTENTS' in cfg ? addToSet({}, cfg.FORBID_CONTENTS) : DEFAULT_FORBID_CONTENTS;
+ FORBID_TAGS = 'FORBID_TAGS' in cfg ? addToSet({}, cfg.FORBID_TAGS) : {};
+ FORBID_ATTR = 'FORBID_ATTR' in cfg ? addToSet({}, cfg.FORBID_ATTR) : {};
+ USE_PROFILES = 'USE_PROFILES' in cfg ? cfg.USE_PROFILES : false;
+ ALLOW_ARIA_ATTR = cfg.ALLOW_ARIA_ATTR !== false; // Default true
+ ALLOW_DATA_ATTR = cfg.ALLOW_DATA_ATTR !== false; // Default true
+ ALLOW_UNKNOWN_PROTOCOLS = cfg.ALLOW_UNKNOWN_PROTOCOLS || false; // Default false
+ SAFE_FOR_TEMPLATES = cfg.SAFE_FOR_TEMPLATES || false; // Default false
+ WHOLE_DOCUMENT = cfg.WHOLE_DOCUMENT || false; // Default false
+ RETURN_DOM = cfg.RETURN_DOM || false; // Default false
+ RETURN_DOM_FRAGMENT = cfg.RETURN_DOM_FRAGMENT || false; // Default false
+ RETURN_DOM_IMPORT = cfg.RETURN_DOM_IMPORT !== false; // Default true
+ RETURN_TRUSTED_TYPE = cfg.RETURN_TRUSTED_TYPE || false; // Default false
+ FORCE_BODY = cfg.FORCE_BODY || false; // Default false
+ SANITIZE_DOM = cfg.SANITIZE_DOM !== false; // Default true
+ KEEP_CONTENT = cfg.KEEP_CONTENT !== false; // Default true
+ IN_PLACE = cfg.IN_PLACE || false; // Default false
+ IS_ALLOWED_URI$$1 = cfg.ALLOWED_URI_REGEXP || IS_ALLOWED_URI$$1;
+ NAMESPACE = cfg.NAMESPACE || HTML_NAMESPACE;
+ if (SAFE_FOR_TEMPLATES) {
+ ALLOW_DATA_ATTR = false;
+ }
+
+ if (RETURN_DOM_FRAGMENT) {
+ RETURN_DOM = true;
+ }
+
+ /* Parse profile info */
+ if (USE_PROFILES) {
+ ALLOWED_TAGS = addToSet({}, [].concat(_toConsumableArray$1(text)));
+ ALLOWED_ATTR = [];
+ if (USE_PROFILES.html === true) {
+ addToSet(ALLOWED_TAGS, html);
+ addToSet(ALLOWED_ATTR, html$1);
+ }
+
+ if (USE_PROFILES.svg === true) {
+ addToSet(ALLOWED_TAGS, svg);
+ addToSet(ALLOWED_ATTR, svg$1);
+ addToSet(ALLOWED_ATTR, xml);
+ }
+
+ if (USE_PROFILES.svgFilters === true) {
+ addToSet(ALLOWED_TAGS, svgFilters);
+ addToSet(ALLOWED_ATTR, svg$1);
+ addToSet(ALLOWED_ATTR, xml);
+ }
+
+ if (USE_PROFILES.mathMl === true) {
+ addToSet(ALLOWED_TAGS, mathMl);
+ addToSet(ALLOWED_ATTR, mathMl$1);
+ addToSet(ALLOWED_ATTR, xml);
+ }
+ }
+
+ /* Merge configuration parameters */
+ if (cfg.ADD_TAGS) {
+ if (ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) {
+ ALLOWED_TAGS = clone(ALLOWED_TAGS);
+ }
+
+ addToSet(ALLOWED_TAGS, cfg.ADD_TAGS);
+ }
+
+ if (cfg.ADD_ATTR) {
+ if (ALLOWED_ATTR === DEFAULT_ALLOWED_ATTR) {
+ ALLOWED_ATTR = clone(ALLOWED_ATTR);
+ }
+
+ addToSet(ALLOWED_ATTR, cfg.ADD_ATTR);
+ }
+
+ if (cfg.ADD_URI_SAFE_ATTR) {
+ addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR);
+ }
+
+ if (cfg.FORBID_CONTENTS) {
+ if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) {
+ FORBID_CONTENTS = clone(FORBID_CONTENTS);
+ }
+
+ addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS);
+ }
+
+ /* Add #text in case KEEP_CONTENT is set to true */
+ if (KEEP_CONTENT) {
+ ALLOWED_TAGS['#text'] = true;
+ }
+
+ /* Add html, head and body to ALLOWED_TAGS in case WHOLE_DOCUMENT is true */
+ if (WHOLE_DOCUMENT) {
+ addToSet(ALLOWED_TAGS, ['html', 'head', 'body']);
+ }
+
+ /* Add tbody to ALLOWED_TAGS in case tables are permitted, see #286, #365 */
+ if (ALLOWED_TAGS.table) {
+ addToSet(ALLOWED_TAGS, ['tbody']);
+ delete FORBID_TAGS.tbody;
+ }
+
+ // Prevent further manipulation of configuration.
+ // Not available in IE8, Safari 5, etc.
+ if (freeze) {
+ freeze(cfg);
+ }
+
+ CONFIG = cfg;
+ };
+
+ var MATHML_TEXT_INTEGRATION_POINTS = addToSet({}, ['mi', 'mo', 'mn', 'ms', 'mtext']);
+
+ var HTML_INTEGRATION_POINTS = addToSet({}, ['foreignobject', 'desc', 'title', 'annotation-xml']);
+
+ /* Keep track of all possible SVG and MathML tags
+ * so that we can perform the namespace checks
+ * correctly. */
+ var ALL_SVG_TAGS = addToSet({}, svg);
+ addToSet(ALL_SVG_TAGS, svgFilters);
+ addToSet(ALL_SVG_TAGS, svgDisallowed);
+
+ var ALL_MATHML_TAGS = addToSet({}, mathMl);
+ addToSet(ALL_MATHML_TAGS, mathMlDisallowed);
+
+ /**
+ *
+ *
+ * @param {Element} element a DOM element whose namespace is being checked
+ * @returns {boolean} Return false if the element has a
+ * namespace that a spec-compliant parser would never
+ * return. Return true otherwise.
+ */
+ var _checkValidNamespace = function _checkValidNamespace(element) {
+ var parent = getParentNode(element);
+
+ // In JSDOM, if we're inside shadow DOM, then parentNode
+ // can be null. We just simulate parent in this case.
+ if (!parent || !parent.tagName) {
+ parent = {
+ namespaceURI: HTML_NAMESPACE,
+ tagName: 'template'
+ };
+ }
+
+ var tagName = stringToLowerCase(element.tagName);
+ var parentTagName = stringToLowerCase(parent.tagName);
+
+ if (element.namespaceURI === SVG_NAMESPACE) {
+ // The only way to switch from HTML namespace to SVG
+ // is via