[MERGE][IMP] web_editor: Implement collaborative for Knowledge
# Editor changes to enable the collaborative mode in Knowledge
Here are the necessary changes to make it possible to enable the collaborative
mode in Knowledge. Knowledge commands introduce a new kind of editor blocks:
`Behaviors`, which are OWL `Component` which need to be mounted. This mounting
is asynchronous and this has to be supported, while not all nodes are observed
by the editor, meaning that parts of a `Behavior` can be collaborative while
others may not.
The following is a brief introduction for every commit in this PR, check them
out individually for more detail.
### [FIX] web_editor: notify current step when joining a collaborator
- Inserting an embedded view from a module in an automated process happens as
soon as the first collaborator connect. This commit notifies the step for the
embedded view during the following collaborators connections (to be sure they
actually received it).
### [IMP] web_editor: fully implement oeProtected and oeTransientContent
- Those attributes will allow a fine control on which nodes are NOT observed by
the editor (`oeProtected="true"`), and which nodes are NOT saved/serialized
for collaboration by the editor (`oeTransientContent="true"`). It can be a
better choice than using `observerUnactive` which stops the observer for all
nodes of the editor, in case a node should never be observed.
### [IMP] web_editor: prevent 'insert' command unwrapping in root
- Modify the 'insert' command of the editor so that it does not unwrap nodes
when the selection is directly inside the editable ('root'). Unwrapping a
paragraph in that case would put text nodes as direct childs of the editable,
which is not desirable with the current implementation.
### [IMP] web_editor: add a hook for historyResetFromSteps
- When joining an `html_field` in collaborative mode, there is no way to know if
there are connected collaborators before actually making said connection. This
is a problem when we want to automate the insertion of an embedded view from
another module in Knowledge. With the hook, it is now possible to execute a
specific action as soon as a collaborator is joined.
### [IMP] web_editor: force load collaborative on form view
- The collaborative mode loads lazily with the focus by default. In Knowledge it
should start as soon as possible, therefore this commit adds an option to do
so.
### [IMP] web_editor: make mocha tests odoo modules
- Since `web_editor` tests were not odoo modules, it was not possible to import
dependencies from i.e. `web`. This commit addresses that.
### [IMP] web_editor: buffer external steps during Component rendering
- In a collaboration, when applying a step requiring a custom OWL `mount`
(which is asynchronous), further external steps should be buffered while the
rendering is done, in case those following external steps should concern
"rendered" nodes which are not yet present in the editor. Once all nodes are
rendered and present in the editor, further steps can be applied safely.
See odoo/enterprise#33483
See odoo/upgrade#4384
closes odoo/odoo#104680
Signed-off-by: David Monjoie (dmo) <dmo@odoo.com>
This commit is contained in:
@@ -171,6 +171,16 @@ Odoo Web Editor widget.
|
||||
'web_editor/static/tests/**/*',
|
||||
'web_editor/static/src/js/editor/odoo-editor/test/utils.js'
|
||||
],
|
||||
'web_editor.mocha_tests': [
|
||||
'web/static/src/legacy/js/promise_extension.js',
|
||||
'web/static/src/boot.js',
|
||||
# insert module dependencies here
|
||||
'web/static/src/core/utils/concurrency.js',
|
||||
|
||||
'web_editor/static/src/js/editor/odoo-editor/src/**/*js',
|
||||
'web_editor/static/src/js/editor/odoo-editor/test/spec/*js',
|
||||
'web_editor/static/src/js/editor/odoo-editor/test/*js',
|
||||
],
|
||||
},
|
||||
'auto_install': True,
|
||||
'license': 'LGPL-3',
|
||||
|
||||
@@ -668,6 +668,10 @@ export const htmlField = {
|
||||
};
|
||||
if ('collaborative' in options) {
|
||||
wysiwygOptions.collaborative = options.collaborative;
|
||||
// Two supported triggers:
|
||||
// 'start': Join the peerToPeer connection immediately
|
||||
// 'focus': Join when the editable has focus
|
||||
wysiwygOptions.collaborativeTrigger = options.collaborative_trigger || 'focus';
|
||||
}
|
||||
if ('allowCommandImage' in options) {
|
||||
// Set the option only if it is explicitly set in the view so a default
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
splitTextNode,
|
||||
isEditorTab,
|
||||
isMacOS,
|
||||
isProtected,
|
||||
isVoidElement,
|
||||
cleanZWS,
|
||||
isZWS,
|
||||
@@ -238,6 +239,23 @@ export class OdooEditor extends EventTarget {
|
||||
preHistoryUndo: () => {},
|
||||
isHintBlacklisted: () => false,
|
||||
filterMutationRecords: (records) => records,
|
||||
/**
|
||||
* In case an external asynchronous post processing has to be
|
||||
* applied on some nodes after an external step (i.e. render
|
||||
* an OWL Component), the owner of the post-processing will
|
||||
* return a Promise through this hook resolved when it is done.
|
||||
* Further collaborative external steps will be buffered as
|
||||
* long as that promise is not resolved, to avoid a situation
|
||||
* where the editor tries to apply mutations inside a node that
|
||||
* is currently being rendered (not ready).
|
||||
*
|
||||
* @param {Element} editable
|
||||
* @returns {Promise|null} Promise that will be resolved when
|
||||
* the rendering is done, or null if there is no
|
||||
* rendering to do. The editor will buffer new external
|
||||
* steps (collaborative) until the promise is resolved.
|
||||
*/
|
||||
postProcessExternalSteps: () => null,
|
||||
onPostSanitize: () => {},
|
||||
direction: 'ltr',
|
||||
_t: string => string,
|
||||
@@ -324,6 +342,11 @@ export class OdooEditor extends EventTarget {
|
||||
this._collabSelectionsContainer.classList.add('oe-collaboration-selections-container');
|
||||
this.editable.before(this._collabSelectionsContainer);
|
||||
|
||||
// Promise for extra rendering, collaborative external steps will be
|
||||
// buffered (delayed) until it is resolved.
|
||||
this._postProcessExternalStepsPromise = null;
|
||||
this._externalStepsBuffer = [];
|
||||
|
||||
this.idSet(editable);
|
||||
this._historyStepsActive = true;
|
||||
this.historyReset();
|
||||
@@ -794,7 +817,9 @@ export class OdooEditor extends EventTarget {
|
||||
}
|
||||
}
|
||||
observerFlush() {
|
||||
this.observerApply(this.filterMutationRecords(this.observer.takeRecords()));
|
||||
const records = this.observer.takeRecords();
|
||||
this.observerIdSet(records);
|
||||
this.observerApply(this.filterMutationRecords(records));
|
||||
}
|
||||
observerActive(label) {
|
||||
this._observerUnactiveLabels.delete(label);
|
||||
@@ -802,6 +827,7 @@ export class OdooEditor extends EventTarget {
|
||||
|
||||
if (!this.observer) {
|
||||
this.observer = new MutationObserver(records => {
|
||||
this.observerIdSet(records);
|
||||
records = this.filterMutationRecords(records);
|
||||
if (!records.length) return;
|
||||
this.dispatchEvent(new Event('contentChanged'));
|
||||
@@ -826,6 +852,14 @@ export class OdooEditor extends EventTarget {
|
||||
this.dispatchEvent(new Event('observerActive'));
|
||||
}
|
||||
|
||||
observerIdSet(records) {
|
||||
for (const record of records) {
|
||||
if (record.type === 'childList') {
|
||||
this.idSet(record.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -956,11 +990,29 @@ export class OdooEditor extends EventTarget {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (record.target && [Node.TEXT_NODE, Node.ELEMENT_NODE].includes(record.target.nodeType)) {
|
||||
const closestProtected = closestElement(record.target, '[data-oe-protected="true"]');
|
||||
if (closestProtected && closestProtected.nodeType === Node.ELEMENT_NODE &&
|
||||
record.target !== closestProtected) {
|
||||
continue;
|
||||
const closestProtectedCandidate = closestElement(record.target, '[data-oe-protected]');
|
||||
if (closestProtectedCandidate) {
|
||||
const protectedValue = closestProtectedCandidate.dataset.oeProtected;
|
||||
switch (protectedValue) {
|
||||
case "true":
|
||||
case "":
|
||||
if (
|
||||
record.type !== "attributes" ||
|
||||
record.target !== closestProtectedCandidate ||
|
||||
isProtected(closestProtectedCandidate.parentElement)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
case "false":
|
||||
if (
|
||||
record.type === "attributes" &&
|
||||
record.target === closestProtectedCandidate &&
|
||||
isProtected(closestProtectedCandidate.parentElement)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
filteredRecords.push(record);
|
||||
@@ -1041,10 +1093,11 @@ export class OdooEditor extends EventTarget {
|
||||
this._historySnapshots = [{ step: steps[0] }];
|
||||
this._historySteps = steps;
|
||||
|
||||
this._postProcessExternalStepsPromise = this.options.postProcessExternalSteps(this.editable);
|
||||
|
||||
this._handleCommandHint();
|
||||
this.multiselectionRefresh();
|
||||
this.observerActive();
|
||||
this.dispatchEvent(new Event('historyResetFromSteps'));
|
||||
}
|
||||
historyGetMissingSteps({fromStepId, toStepId}) {
|
||||
const fromIndex = this._historySteps.findIndex(x => x.id === fromStepId);
|
||||
@@ -1505,19 +1558,40 @@ export class OdooEditor extends EventTarget {
|
||||
this._collabClientId = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply external steps coming from the collaboration. Buffer them if
|
||||
* _postProcessExternalStepsPromise is not null until it is resolved (since
|
||||
* steps could potentially concern elements currently being rendered
|
||||
* asynchronously).
|
||||
*
|
||||
* @param {Object} newSteps External steps to be applied
|
||||
*/
|
||||
onExternalHistorySteps(newSteps) {
|
||||
if (this._postProcessExternalStepsPromise) {
|
||||
this._externalStepsBuffer.push(...newSteps);
|
||||
}
|
||||
this.observerUnactive();
|
||||
this._computeHistorySelection();
|
||||
|
||||
let stepIndex = 0;
|
||||
for (const newStep of newSteps) {
|
||||
this._historyAddExternalStep(newStep);
|
||||
stepIndex++;
|
||||
this._postProcessExternalStepsPromise = this.options.postProcessExternalSteps(this.editable);
|
||||
if (this._postProcessExternalStepsPromise) {
|
||||
this._postProcessExternalStepsPromise.then(() => {
|
||||
this._postProcessExternalStepsPromise = undefined;
|
||||
this.onExternalHistorySteps(this._externalStepsBuffer);
|
||||
});
|
||||
this._externalStepsBuffer = newSteps.slice(stepIndex);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.observerActive();
|
||||
this.historyResetLatestComputedSelection();
|
||||
this._handleCommandHint();
|
||||
this.multiselectionRefresh();
|
||||
this.dispatchEvent(new Event('onExternalHistorySteps'));
|
||||
}
|
||||
|
||||
// Multi selection
|
||||
@@ -2271,7 +2345,7 @@ export class OdooEditor extends EventTarget {
|
||||
const selection = this.document.getSelection();
|
||||
// Selection could be gone if the document comes from an iframe that has been removed.
|
||||
const anchorNode = selection && selection.rangeCount && selection.getRangeAt(0) && selection.anchorNode;
|
||||
if (anchorNode && (closestElement(anchorNode, '[data-oe-protected="true"]') || !ancestors(anchorNode).includes(this.editable))) {
|
||||
if (isProtected(anchorNode) || !ancestors(anchorNode).includes(this.editable)) {
|
||||
return false;
|
||||
}
|
||||
this.deselectTable();
|
||||
@@ -3642,7 +3716,7 @@ export class OdooEditor extends EventTarget {
|
||||
return;
|
||||
}
|
||||
const anchorNode = selection.anchorNode;
|
||||
if (anchorNode && closestElement(anchorNode, '[data-oe-protected="true"]')) {
|
||||
if (isProtected(anchorNode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3838,8 +3912,8 @@ export class OdooEditor extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean all protected nodes because they are not sanitized
|
||||
const protectedNodes = element.querySelectorAll('[data-oe-protected="true"]');
|
||||
// Clean all transient nodes
|
||||
const protectedNodes = element.querySelectorAll('[data-oe-transient-content="true"], [data-oe-transient-content=""]');
|
||||
for (const node of protectedNodes) {
|
||||
node.replaceChildren();
|
||||
}
|
||||
@@ -3875,7 +3949,7 @@ export class OdooEditor extends EventTarget {
|
||||
_handleCommandHint() {
|
||||
const selection = this.document.getSelection();
|
||||
const anchorNode = selection.anchorNode;
|
||||
if (anchorNode && closestElement(anchorNode, '[data-oe-protected="true"]')) {
|
||||
if (isProtected(anchorNode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3950,7 +4024,7 @@ export class OdooEditor extends EventTarget {
|
||||
_fixSelectionOnContenteditableFalse() {
|
||||
const selection = this.document.getSelection();
|
||||
const anchorNode = selection.anchorNode;
|
||||
if (anchorNode && closestElement(anchorNode, '[data-oe-protected="true"]')) {
|
||||
if (isProtected(anchorNode)) {
|
||||
return;
|
||||
}
|
||||
// When the browser set the selection inside a node that is
|
||||
|
||||
@@ -178,27 +178,30 @@ export const editorCommands = {
|
||||
}
|
||||
|
||||
startNode = startNode || editor.document.getSelection().anchorNode;
|
||||
|
||||
// In case the html inserted is all contained in a single root <p> or <li>
|
||||
// tag, we take the all content of the <p> or <li> and avoid inserting the
|
||||
// <p> or <li>. The same is true for a <pre> inside a <pre>.
|
||||
if (container.childElementCount === 1 && (
|
||||
container.firstChild.nodeName === 'P' ||
|
||||
container.firstChild.nodeName === 'LI' ||
|
||||
container.firstChild.nodeName === 'PRE' && closestElement(startNode, 'pre')
|
||||
)) {
|
||||
const p = container.firstElementChild;
|
||||
container.replaceChildren(...p.childNodes);
|
||||
} else if (container.childElementCount > 1) {
|
||||
// Grab the content of the first child block and isolate it.
|
||||
if (isBlock(container.firstChild) && !['TABLE', 'UL', 'OL'].includes(container.firstChild.nodeName)) {
|
||||
containerFirstChild.replaceChildren(...container.firstElementChild.childNodes);
|
||||
container.firstElementChild.remove();
|
||||
}
|
||||
// Grab the content of the last child block and isolate it.
|
||||
if (isBlock(container.lastChild) && !['TABLE', 'UL', 'OL'].includes(container.lastChild.nodeName)) {
|
||||
containerLastChild.replaceChildren(...container.lastElementChild.childNodes);
|
||||
container.lastElementChild.remove();
|
||||
// If the selection anchorNode is the editable itself, the content
|
||||
// should not be unwrapped.
|
||||
if (selection.anchorNode.oid !== 'root') {
|
||||
// In case the html inserted is all contained in a single root <p> or <li>
|
||||
// tag, we take the all content of the <p> or <li> and avoid inserting the
|
||||
// <p> or <li>. The same is true for a <pre> inside a <pre>.
|
||||
if (container.childElementCount === 1 && (
|
||||
container.firstChild.nodeName === 'P' ||
|
||||
container.firstChild.nodeName === 'LI' ||
|
||||
container.firstChild.nodeName === 'PRE' && closestElement(startNode, 'pre')
|
||||
)) {
|
||||
const p = container.firstElementChild;
|
||||
container.replaceChildren(...p.childNodes);
|
||||
} else if (container.childElementCount > 1) {
|
||||
// Grab the content of the first child block and isolate it.
|
||||
if (isBlock(container.firstChild) && !['TABLE', 'UL', 'OL'].includes(container.firstChild.nodeName)) {
|
||||
containerFirstChild.replaceChildren(...container.firstElementChild.childNodes);
|
||||
container.firstElementChild.remove();
|
||||
}
|
||||
// Grab the content of the last child block and isolate it.
|
||||
if (isBlock(container.lastChild) && !['TABLE', 'UL', 'OL'].includes(container.lastChild.nodeName)) {
|
||||
containerLastChild.replaceChildren(...container.lastElementChild.childNodes);
|
||||
container.lastElementChild.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @odoo-module */
|
||||
|
||||
export const qwebSample = /* xml */ `
|
||||
<h1>Qweb examples</h1>
|
||||
<div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getDeepRange,
|
||||
isUnbreakable,
|
||||
isEditorTab,
|
||||
isProtected,
|
||||
isZWS,
|
||||
getUrlsInfosInString,
|
||||
isVoidElement,
|
||||
@@ -124,9 +125,12 @@ class Sanitize {
|
||||
|
||||
_parse(node) {
|
||||
while (node) {
|
||||
const closestProtected = closestElement(node, '[data-oe-protected="true"]');
|
||||
if (closestProtected && node !== closestProtected) {
|
||||
return;
|
||||
if (isProtected(node)) {
|
||||
for (const unprotected of node.querySelectorAll('[data-oe-protected="false"]')) {
|
||||
this._parse(unprotected.firstChild);
|
||||
}
|
||||
node = node.nextSibling;
|
||||
continue;
|
||||
}
|
||||
// Merge identical elements together.
|
||||
while (
|
||||
|
||||
@@ -18,11 +18,14 @@ export function serializeNode(node, nodesToStripFromChildren = new Set()) {
|
||||
result.attributes[node.attributes[i].name] = node.attributes[i].value;
|
||||
}
|
||||
let child = node.firstChild;
|
||||
while (child) {
|
||||
if (!nodesToStripFromChildren.has(child.oid)) {
|
||||
result.children.push(serializeNode(child, nodesToStripFromChildren));
|
||||
// Don't serialize transient nodes
|
||||
if (!["true", ""].includes(node.dataset.oeTransientContent)) {
|
||||
while (child) {
|
||||
if (!nodesToStripFromChildren.has(child.oid)) {
|
||||
result.children.push(serializeNode(child, nodesToStripFromChildren));
|
||||
}
|
||||
child = child.nextSibling;
|
||||
}
|
||||
child = child.nextSibling;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -1338,6 +1338,24 @@ export function isMediaElement(node) {
|
||||
(node.classList.contains('o_image') || node.classList.contains('media_iframe_video')))
|
||||
);
|
||||
}
|
||||
/**
|
||||
* A "protected" node will have its mutations filtered and not be registered
|
||||
* in an history step. Some editor features like selection handling, command
|
||||
* hint, toolbar, tooltip, etc. are also disabled. Protected roots have their
|
||||
* data-oe-protected attribute set to either "" or "true". If the closest parent
|
||||
* with a data-oe-protected attribute has the value "false", it is not
|
||||
* protected. Unknown values are ignored.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isProtected(node) {
|
||||
const closestProtectedElement = closestElement(node, '[data-oe-protected]');
|
||||
if (closestProtectedElement) {
|
||||
return ["", "true"].includes(closestProtectedElement.dataset.oeProtected);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
export function isVoidElement(node) {
|
||||
return isMediaElement(node) || node.tagName === 'HR';
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import './spec/utils.test.js';
|
||||
import './spec/align.test.js';
|
||||
import './spec/color.test.js';
|
||||
@@ -16,31 +18,33 @@ import './spec/odooFields.test.js';
|
||||
import './spec/powerbox.test.js';
|
||||
/* global mocha */
|
||||
|
||||
mocha.run(failures => {
|
||||
if (failures) {
|
||||
for (const faillureElement of [...document.querySelectorAll('.test.fail')]) {
|
||||
const clonedFaillureElement = faillureElement.cloneNode(true);
|
||||
clonedFaillureElement.querySelector('a').remove();
|
||||
console.error(
|
||||
[
|
||||
clonedFaillureElement.querySelector('h2').innerText,
|
||||
clonedFaillureElement.querySelector('.error').innerText,
|
||||
].join('\n\n'),
|
||||
);
|
||||
window.addEventListener("load", () => {
|
||||
mocha.run(failures => {
|
||||
if (failures) {
|
||||
for (const faillureElement of [...document.querySelectorAll('.test.fail')]) {
|
||||
const clonedFaillureElement = faillureElement.cloneNode(true);
|
||||
clonedFaillureElement.querySelector('a').remove();
|
||||
console.error(
|
||||
[
|
||||
clonedFaillureElement.querySelector('h2').innerText,
|
||||
clonedFaillureElement.querySelector('.error').innerText,
|
||||
].join('\n\n'),
|
||||
);
|
||||
}
|
||||
|
||||
// Better visualisation of invisible (ZWS & TABS) character in test
|
||||
// report.
|
||||
const report = document.querySelector("#mocha-report");
|
||||
const allErrors = report.querySelectorAll('.test.fail .error');
|
||||
allErrors.forEach((errorEl) => {
|
||||
let errorElHtml = errorEl.outerHTML
|
||||
errorElHtml = errorElHtml.replaceAll('//zws//', '<b class="zws">zws</b>');
|
||||
errorElHtml = errorElHtml.replaceAll('//TAB//', '<b class="tab">Tab</b>');
|
||||
errorEl.outerHTML = errorElHtml;
|
||||
});
|
||||
} else {
|
||||
console.log('test successful');
|
||||
}
|
||||
|
||||
// Better visualisation of invisible (ZWS & TABS) character in test
|
||||
// report.
|
||||
const report = document.querySelector("#mocha-report");
|
||||
const allErrors = report.querySelectorAll('.test.fail .error');
|
||||
allErrors.forEach((errorEl) => {
|
||||
let errorElHtml = errorEl.outerHTML
|
||||
errorElHtml = errorElHtml.replaceAll('//zws//', '<b class="zws">zws</b>');
|
||||
errorElHtml = errorElHtml.replaceAll('//TAB//', '<b class="tab">Tab</b>');
|
||||
errorEl.outerHTML = errorElHtml;
|
||||
});
|
||||
} else {
|
||||
console.log('test successful');
|
||||
}
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { BasicEditor, testEditor } from '../utils.js';
|
||||
|
||||
const justifyLeft = async function (editor) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { BasicEditor, testEditor } from '../utils.js';
|
||||
|
||||
const timeoutPromise = ms =>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { OdooEditor } from '../../src/OdooEditor.js';
|
||||
/** @odoo-module */
|
||||
|
||||
import { OdooEditor, parseHTML, setSelection } from '../../src/OdooEditor.js';
|
||||
import {
|
||||
insertCharsAt,
|
||||
parseMultipleTextualSelection,
|
||||
@@ -6,7 +8,9 @@ import {
|
||||
setTestSelection,
|
||||
targetDeepest,
|
||||
undo,
|
||||
unformat,
|
||||
} from '../utils.js';
|
||||
import { Deferred } from "@web/core/utils/concurrency";
|
||||
|
||||
const overridenDomClass = [
|
||||
'HTMLBRElement',
|
||||
@@ -70,7 +74,7 @@ const testSameHistory = clientInfos => {
|
||||
}
|
||||
};
|
||||
|
||||
const testMultiEditor = spec => {
|
||||
const testMultiEditor = async spec => {
|
||||
const clientInfos = {};
|
||||
const concurentActions = spec.concurentActions || [];
|
||||
const clientIds = spec.clientIds || Object.keys(concurentActions);
|
||||
@@ -121,7 +125,7 @@ const testMultiEditor = spec => {
|
||||
let nextId = 1;
|
||||
OdooEditor.prototype._generateId = () => 'fake_id_' + nextId++;
|
||||
|
||||
clientInfo.editor = new OdooEditor(clientInfo.editable, {
|
||||
clientInfo.editor = new OdooEditor(clientInfo.editable, Object.assign({
|
||||
toSanitize: false,
|
||||
document: iframeDocument,
|
||||
collaborationClientId: clientInfo.clientId,
|
||||
@@ -140,7 +144,7 @@ const testMultiEditor = spec => {
|
||||
}
|
||||
clientInfo.editor.onExternalHistorySteps(missingSteps.concat([step]));
|
||||
},
|
||||
});
|
||||
}, spec.options || {}));
|
||||
clientInfo.editor.keyboardType = 'PHYSICAL';
|
||||
const selection = selections[clientInfo.clientId];
|
||||
if (selection) {
|
||||
@@ -160,7 +164,7 @@ const testMultiEditor = spec => {
|
||||
OdooEditor.prototype._generateId = () => 'fake_concurent_id_' + concurentNextId++;
|
||||
|
||||
if (spec.afterCreate) {
|
||||
spec.afterCreate(clientInfos);
|
||||
await spec.afterCreate(clientInfos);
|
||||
}
|
||||
|
||||
shouldListenSteps = false;
|
||||
@@ -220,7 +224,7 @@ const testMultiEditor = spec => {
|
||||
}
|
||||
}
|
||||
if (spec.afterCursorInserted) {
|
||||
spec.afterCursorInserted(clientInfos);
|
||||
await spec.afterCursorInserted(clientInfos);
|
||||
}
|
||||
for (const clientInfo of clientInfosList) {
|
||||
clientInfo.editor.destroy();
|
||||
@@ -230,8 +234,8 @@ const testMultiEditor = spec => {
|
||||
|
||||
describe('Collaboration', () => {
|
||||
describe('Conflict resolution', () => {
|
||||
it('all client steps should be on the same order', () => {
|
||||
testMultiEditor({
|
||||
it('all client steps should be on the same order', async () => {
|
||||
await testMultiEditor({
|
||||
clientIds: ['c1', 'c2', 'c3'],
|
||||
contentBefore: '<p><x>a[c1}{c1]</x><y>e[c2}{c2]</y><z>i[c3}{c3]</z></p>',
|
||||
afterCreate: clientInfos => {
|
||||
@@ -258,8 +262,8 @@ describe('Collaboration', () => {
|
||||
contentAfter: '<p><x>abcd[c1}{c1]</x><y>efgh[c2}{c2]</y><z>ijkl[c3}{c3]</z></p>',
|
||||
});
|
||||
});
|
||||
it('should 2 client insertText in 2 different paragraph', () => {
|
||||
testMultiEditor({
|
||||
it('should 2 client insertText in 2 different paragraph', async () => {
|
||||
await testMultiEditor({
|
||||
clientIds: ['c1', 'c2'],
|
||||
contentBefore: '<p>ab[c1}{c1]</p><p>cd[c2}{c2]</p>',
|
||||
afterCreate: clientInfos => {
|
||||
@@ -277,8 +281,8 @@ describe('Collaboration', () => {
|
||||
contentAfter: '<p>abe[c1}{c1]</p><p>cdf[c2}{c2]</p>',
|
||||
});
|
||||
});
|
||||
it('should 2 client insertText twice in 2 different paragraph', () => {
|
||||
testMultiEditor({
|
||||
it('should 2 client insertText twice in 2 different paragraph', async () => {
|
||||
await testMultiEditor({
|
||||
clientIds: ['c1', 'c2'],
|
||||
contentBefore: '<p>ab[c1}{c1]</p><p>cd[c2}{c2]</p>',
|
||||
afterCreate: clientInfos => {
|
||||
@@ -298,8 +302,8 @@ describe('Collaboration', () => {
|
||||
contentAfter: '<p>abef[c1}{c1]</p><p>cdgh[c2}{c2]</p>',
|
||||
});
|
||||
});
|
||||
it('should insertText with client 1 and deleteBackward with client 2', () => {
|
||||
testMultiEditor({
|
||||
it('should insertText with client 1 and deleteBackward with client 2', async () => {
|
||||
await testMultiEditor({
|
||||
clientIds: ['c1', 'c2'],
|
||||
contentBefore: '<p>ab[c1}{c1][c2}{c2]c</p>',
|
||||
afterCreate: clientInfos => {
|
||||
@@ -317,8 +321,8 @@ describe('Collaboration', () => {
|
||||
contentAfter: '<p>a[c2}{c2]c[c1}{c1]dc</p>',
|
||||
});
|
||||
});
|
||||
it('should insertText twice with client 1 and deleteBackward twice with client 2', () => {
|
||||
testMultiEditor({
|
||||
it('should insertText twice with client 1 and deleteBackward twice with client 2', async () => {
|
||||
await testMultiEditor({
|
||||
clientIds: ['c1', 'c2'],
|
||||
contentBefore: '<p>ab[c1}{c1][c2}{c2]c</p>',
|
||||
afterCreate: clientInfos => {
|
||||
@@ -339,8 +343,8 @@ describe('Collaboration', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
it('should reset from snapshot', () => {
|
||||
testMultiEditor({
|
||||
it('should reset from snapshot', async () => {
|
||||
await testMultiEditor({
|
||||
clientIds: ['c1', 'c2'],
|
||||
contentBefore: '<p>a[c1}{c1]</p>',
|
||||
afterCreate: clientInfos => {
|
||||
@@ -362,8 +366,8 @@ describe('Collaboration', () => {
|
||||
});
|
||||
});
|
||||
describe('steps whith no parent in history', () => {
|
||||
it('should be able to retreive steps when disconnected from clients that has send step', () => {
|
||||
testMultiEditor({
|
||||
it('should be able to retreive steps when disconnected from clients that has send step', async () => {
|
||||
await testMultiEditor({
|
||||
clientIds: ['c1', 'c2', 'c3'],
|
||||
contentBefore: '<p><x>a[c1}{c1]</x><y>b[c2}{c2]</y><z>c[c3}{c3]</z></p>',
|
||||
afterCreate: clientInfos => {
|
||||
@@ -387,8 +391,8 @@ describe('Collaboration', () => {
|
||||
contentAfter: '<p><x>ad[c1}{c1]</x><y>be[c2}{c2]</y><z>c[c3}{c3]</z></p>',
|
||||
});
|
||||
});
|
||||
it('should receive steps where parent was not received', () => {
|
||||
testMultiEditor({
|
||||
it('should receive steps where parent was not received', async () => {
|
||||
await testMultiEditor({
|
||||
clientIds: ['c1', 'c2', 'c3'],
|
||||
contentBefore: '<p><i>a[c1}{c1]</i><b>b[c2}{c2]</b></p>',
|
||||
afterCreate: clientInfos => {
|
||||
@@ -430,8 +434,8 @@ describe('Collaboration', () => {
|
||||
});
|
||||
});
|
||||
describe('sanitize', () => {
|
||||
it('should sanitize when adding a node', () => {
|
||||
testMultiEditor({
|
||||
it('should sanitize when adding a node', async () => {
|
||||
await testMultiEditor({
|
||||
clientIds: ['c1', 'c2'],
|
||||
contentBefore: '<p><x>a</x></p>',
|
||||
afterCreate: clientInfos => {
|
||||
@@ -450,7 +454,7 @@ describe('Collaboration', () => {
|
||||
});
|
||||
});
|
||||
it('should sanitize when adding a script as descendant', async () => {
|
||||
testMultiEditor({
|
||||
await testMultiEditor({
|
||||
clientIds: ['c1', 'c2'],
|
||||
contentBefore: '<p>a[c1}{c1][c2}{c2]</p>',
|
||||
afterCreate: clientInfos => {
|
||||
@@ -469,8 +473,8 @@ describe('Collaboration', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should sanitize when changing an attribute', () => {
|
||||
testMultiEditor({
|
||||
it('should sanitize when changing an attribute', async () => {
|
||||
await testMultiEditor({
|
||||
clientIds: ['c1', 'c2'],
|
||||
contentBefore: '<p>a<img></p>',
|
||||
afterCreate: clientInfos => {
|
||||
@@ -491,8 +495,8 @@ describe('Collaboration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize when undo is adding a script node', () => {
|
||||
testMultiEditor({
|
||||
it('should sanitize when undo is adding a script node', async () => {
|
||||
await testMultiEditor({
|
||||
clientIds: ['c1', 'c2'],
|
||||
contentBefore: '<p>a</p>',
|
||||
afterCreate: clientInfos => {
|
||||
@@ -517,8 +521,8 @@ describe('Collaboration', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should sanitize when undo is adding a descendant script node', () => {
|
||||
testMultiEditor({
|
||||
it('should sanitize when undo is adding a descendant script node', async () => {
|
||||
await testMultiEditor({
|
||||
clientIds: ['c1', 'c2'],
|
||||
contentBefore: '<p>a</p>',
|
||||
afterCreate: clientInfos => {
|
||||
@@ -543,8 +547,8 @@ describe('Collaboration', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should sanitize when undo is changing an attribute', () => {
|
||||
testMultiEditor({
|
||||
it('should sanitize when undo is changing an attribute', async () => {
|
||||
await testMultiEditor({
|
||||
clientIds: ['c1', 'c2'],
|
||||
contentBefore: '<p>a<img></p>',
|
||||
afterCreate: clientInfos => {
|
||||
@@ -570,8 +574,8 @@ describe('Collaboration', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should not sanitize contenteditable attribute (check DOMPurify DEFAULT_ALLOWED_ATTR)', () => {
|
||||
testMultiEditor({
|
||||
it('should not sanitize contenteditable attribute (check DOMPurify DEFAULT_ALLOWED_ATTR)', async () => {
|
||||
await testMultiEditor({
|
||||
clientIds: ['c1'],
|
||||
contentBefore: '<div class="remove-me" contenteditable="true">[c1}{c1]<br></div>',
|
||||
afterCreate: clientInfos => {
|
||||
@@ -586,4 +590,141 @@ describe('Collaboration', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('data-oe-protected', () => {
|
||||
it('should not share protected mutations and share unprotected ones', async () => {
|
||||
await testMultiEditor({
|
||||
clientIds: ['c1', 'c2'],
|
||||
contentBefore: '<p>[c1}{c1][c2}{c2]</p>',
|
||||
afterCreate: clientInfos => {
|
||||
clientInfos.c1.editor.editable.prepend(...parseHTML(unformat(`
|
||||
<div data-oe-protected="true">
|
||||
<p id="true"><br></p>
|
||||
<div data-oe-protected="false">
|
||||
<p id="false"><br></p>
|
||||
</div>
|
||||
</div>
|
||||
`)).children);
|
||||
clientInfos.c1.editor.historyStep();
|
||||
const pTrue = clientInfos.c1.editor.editable.querySelector('#true');
|
||||
setSelection(pTrue, 0);
|
||||
clientInfos.c1.editor.execCommand('insert', 'a');
|
||||
const pFalse = clientInfos.c1.editor.editable.querySelector('#false');
|
||||
setSelection(pFalse, 0);
|
||||
clientInfos.c1.editor.execCommand('insert', 'a');
|
||||
clientInfos.c2.editor.onExternalHistorySteps(clientInfos.c1.editor._historySteps);
|
||||
testSameHistory(clientInfos);
|
||||
},
|
||||
afterCursorInserted: clientInfos => {
|
||||
chai.expect(clientInfos.c1.editable.innerHTML).to.equal(unformat(`
|
||||
<div data-oe-protected="true">
|
||||
<p id="true">a<br></p>
|
||||
<div data-oe-protected="false">
|
||||
<p id="false">a[c1}{c1]<br></p>
|
||||
</div>
|
||||
</div>
|
||||
<p>[c2}{c2]</p>
|
||||
`));
|
||||
chai.expect(clientInfos.c2.editable.innerHTML).to.equal(unformat(`
|
||||
<div data-oe-protected="true">
|
||||
<p id="true"><br></p>
|
||||
<div data-oe-protected="false">
|
||||
<p id="false">a[c1}{c1]<br></p>
|
||||
</div>
|
||||
</div>
|
||||
<p>[c2}{c2]</p>
|
||||
`));
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('data-oe-transient-content', () => {
|
||||
it('should send an empty transient-content element', async () => {
|
||||
await testMultiEditor({
|
||||
clientIds: ['c1', 'c2'],
|
||||
contentBefore: '<p>[c1}{c1][c2}{c2]</p>',
|
||||
afterCreate: clientInfos => {
|
||||
clientInfos.c1.editor.editable.prepend(...parseHTML(unformat(`
|
||||
<div data-oe-transient-content="true">
|
||||
<p>secret</p>
|
||||
</div>
|
||||
`)).children);
|
||||
clientInfos.c1.editor.historyStep();
|
||||
clientInfos.c2.editor.onExternalHistorySteps(
|
||||
clientInfos.c1.editor._historySteps
|
||||
);
|
||||
testSameHistory(clientInfos);
|
||||
},
|
||||
afterCursorInserted: clientInfos => {
|
||||
chai.expect(clientInfos.c1.editable.innerHTML).to.equal(unformat(`
|
||||
<div data-oe-transient-content="true">
|
||||
<p>secret</p>
|
||||
</div>
|
||||
<p>[c1}{c1][c2}{c2]</p>
|
||||
`));
|
||||
chai.expect(clientInfos.c2.editable.innerHTML).to.equal(unformat(`
|
||||
<div data-oe-transient-content="true"></div>
|
||||
<p>[c1}{c1][c2}{c2]</p>
|
||||
`));
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('post process external steps', () => {
|
||||
it('should properly await a processing promise before accepting new external steps.', async () => {
|
||||
const deferredPromise = new Deferred();
|
||||
const postProcessExternalSteps = (element) => {
|
||||
if (element.querySelector('.process')) {
|
||||
setTimeout(() => {
|
||||
deferredPromise.resolve();
|
||||
});
|
||||
return deferredPromise;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
await testMultiEditor({
|
||||
options: {
|
||||
postProcessExternalSteps: postProcessExternalSteps,
|
||||
},
|
||||
clientIds: ['c1', 'c2'],
|
||||
contentBefore: '<p>[c1}{c1][c2}{c2]</p>',
|
||||
afterCreate: async clientInfos => {
|
||||
clientInfos.c1.editor.editable.append(...parseHTML(unformat(`
|
||||
<div class="process">
|
||||
<p>secret</p>
|
||||
</div>
|
||||
`)).children);
|
||||
clientInfos.c1.editor.historyStep();
|
||||
clientInfos.c1.editor.editable.append(...parseHTML(unformat(`
|
||||
<p>post-process</p>
|
||||
`)).children);
|
||||
clientInfos.c1.editor.historyStep();
|
||||
clientInfos.c2.editor.onExternalHistorySteps(
|
||||
clientInfos.c1.editor._historySteps
|
||||
);
|
||||
chai.expect(clientInfos.c1.editable.innerHTML).to.equal(unformat(`
|
||||
<p></p>
|
||||
<div class="process">
|
||||
<p>secret</p>
|
||||
</div>
|
||||
<p>post-process</p>
|
||||
`));
|
||||
chai.expect(clientInfos.c2.editable.innerHTML).to.equal(unformat(`
|
||||
<p></p>
|
||||
<div class="process">
|
||||
<p>secret</p>
|
||||
</div>
|
||||
`));
|
||||
await deferredPromise;
|
||||
chai.expect(clientInfos.c2.editable.innerHTML).to.equal(unformat(`
|
||||
<p></p>
|
||||
<div class="process">
|
||||
<p>secret</p>
|
||||
</div>
|
||||
<p>post-process</p>
|
||||
`));
|
||||
testSameHistory(clientInfos);
|
||||
},
|
||||
});
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { BasicEditor, testEditor } from '../utils.js';
|
||||
|
||||
const setColor = (color, mode) => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import {
|
||||
BasicEditor,
|
||||
testEditor,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { OdooEditor } from '../../src/OdooEditor.js';
|
||||
import {
|
||||
getTraversedNodes,
|
||||
@@ -5881,56 +5883,200 @@ X[]
|
||||
});
|
||||
});
|
||||
|
||||
describe('oe-protected', () => {
|
||||
it('should ignore protected elements children mutations', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: unformat(`
|
||||
<div><p>a[]</p></div>
|
||||
<div data-oe-protected="true"><p>a</p></div>
|
||||
`),
|
||||
stepFunction: async editor => {
|
||||
await insertText(editor, 'bc');
|
||||
const protectedParagraph = editor.editable.querySelector('[data-oe-protected="true"] > p');
|
||||
setSelection(protectedParagraph, 1);
|
||||
await insertText(editor, 'b');
|
||||
editor.historyUndo();
|
||||
},
|
||||
contentAfterEdit: unformat(`
|
||||
<div><p>ab[]</p></div>
|
||||
<div data-oe-protected="true"><p>ab</p></div>
|
||||
`),
|
||||
describe('data-oe-protected', () => {
|
||||
describe('true', () => {
|
||||
it('should ignore protected elements children mutations', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: unformat(`
|
||||
<div><p>a[]</p></div>
|
||||
<div data-oe-protected="true"><p>a</p></div>
|
||||
`),
|
||||
stepFunction: async editor => {
|
||||
await insertText(editor, 'bc');
|
||||
const protectedParagraph = editor.editable.querySelector('[data-oe-protected="true"] > p');
|
||||
setSelection(protectedParagraph, 1);
|
||||
await insertText(editor, 'b');
|
||||
editor.historyUndo();
|
||||
},
|
||||
contentAfterEdit: unformat(`
|
||||
<div><p>ab[]</p></div>
|
||||
<div data-oe-protected="true"><p>ab</p></div>
|
||||
`),
|
||||
});
|
||||
});
|
||||
it('should not sanitize (sanitize.js) protected elements children', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: unformat(`
|
||||
<div>
|
||||
<p><i class="fa"></i></p>
|
||||
<ul><li><p><br></p></li></ul>
|
||||
</div>
|
||||
<div data-oe-protected="true">
|
||||
<p><i class="fa"></i></p>
|
||||
<ul><li><p><br></p></li></ul>
|
||||
</div>
|
||||
`),
|
||||
stepFunction: async editor => editor.sanitize(),
|
||||
contentAfterEdit: unformat(`
|
||||
<div>
|
||||
<p><i class="fa" contenteditable="false">\u200B</i></p>
|
||||
<ul><li><br></li></ul>
|
||||
</div>
|
||||
<div data-oe-protected="true">
|
||||
<p><i class="fa"></i></p>
|
||||
<ul><li><p><br></p></li></ul>
|
||||
</div>
|
||||
`),
|
||||
});
|
||||
});
|
||||
it('should not fix selection in contenteditable="false" protected elements children', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: unformat(`
|
||||
<p><br></p>
|
||||
<div contenteditable="false" data-oe-protected="true">
|
||||
<h1>[very important text that needs to be selected]</h1>
|
||||
</div>
|
||||
<p><br></p>
|
||||
`),
|
||||
stepFunction: async editor => editor._fixSelectionOnContenteditableFalse(),
|
||||
contentAfter: unformat(`
|
||||
<p><br></p>
|
||||
<div contenteditable="false" data-oe-protected="true">
|
||||
<h1>[very important text that needs to be selected]</h1>
|
||||
</div>
|
||||
<p><br></p>
|
||||
`),
|
||||
});
|
||||
});
|
||||
it('should not handle table selection in protected elements children', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: unformat(`
|
||||
<div data-oe-protected="true">
|
||||
<p>a[bc</p><table><tbody><tr><td>a]b</td><td>cd</td><td>ef</td></tr></tbody></table>
|
||||
</div>
|
||||
`),
|
||||
contentAfterEdit: unformat(`
|
||||
<div data-oe-protected="true">
|
||||
<p>a[bc</p><table><tbody><tr><td>a]b</td><td>cd</td><td>ef</td></tr></tbody></table>
|
||||
</div>
|
||||
`),
|
||||
});
|
||||
});
|
||||
});
|
||||
it('should not sanitize protected elements children', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: unformat(`
|
||||
<div>
|
||||
<p><i class="fa"></i></p>
|
||||
<ul><li><p><br></p></li></ul>
|
||||
</div>
|
||||
<div data-oe-protected="true">
|
||||
<p><i class="fa"></i></p>
|
||||
<ul><li><p><br></p></li></ul>
|
||||
</div>
|
||||
`),
|
||||
stepFunction: async editor => editor.sanitize(),
|
||||
contentAfterEdit: unformat(`
|
||||
<div>
|
||||
<p><i class="fa" contenteditable="false">\u200B</i></p>
|
||||
<ul><li><br></li></ul>
|
||||
</div>
|
||||
<div data-oe-protected="true">
|
||||
<p><i class="fa"></i></p>
|
||||
<ul><li><p><br></p></li></ul>
|
||||
</div>
|
||||
`),
|
||||
describe('false', () => {
|
||||
it('should not ignore unprotected elements children mutations', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: unformat(`
|
||||
<div><p>a[]</p></div>
|
||||
<div data-oe-protected="true"><div data-oe-protected="false"><p>a</p></div></div>
|
||||
`),
|
||||
stepFunction: async editor => {
|
||||
await insertText(editor, 'bc');
|
||||
const unProtectedParagraph = editor.editable.querySelector('[data-oe-protected="false"] > p');
|
||||
setSelection(unProtectedParagraph, 1);
|
||||
await insertText(editor, 'bc');
|
||||
editor.historyUndo();
|
||||
},
|
||||
contentAfterEdit: unformat(`
|
||||
<div><p>abc</p></div>
|
||||
<div data-oe-protected="true"><div data-oe-protected="false"><p>ab[]</p></div></div>
|
||||
`),
|
||||
});
|
||||
});
|
||||
});
|
||||
it('should remove protected elements children during cleaning', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: '<div><p>a[]</p></div><div data-oe-protected="true"><p>a</p></div>',
|
||||
contentAfter: '<div><p>a[]</p></div><div data-oe-protected="true"></div>',
|
||||
it('should sanitize (sanitize.js) unprotected elements children', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: unformat(`
|
||||
<div data-oe-protected="true">
|
||||
<p><i class="fa"></i></p>
|
||||
<ul><li><p><br></p></li></ul>
|
||||
<div data-oe-protected="false">
|
||||
<p><i class="fa"></i></p>
|
||||
<ul><li><p><br></p></li></ul>
|
||||
</div>
|
||||
</div>
|
||||
`),
|
||||
stepFunction: async editor => editor.sanitize(),
|
||||
contentAfterEdit: unformat(`
|
||||
<div data-oe-protected="true">
|
||||
<p><i class="fa"></i></p>
|
||||
<ul><li><p><br></p></li></ul>
|
||||
<div data-oe-protected="false">
|
||||
<p><i class="fa" contenteditable="false">\u200B</i></p>
|
||||
<ul><li><br></li></ul>
|
||||
</div>
|
||||
</div>
|
||||
`),
|
||||
});
|
||||
});
|
||||
it('should fix selection in contenteditable="false" unprotected elements children', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: unformat(`
|
||||
<p><br></p>
|
||||
<div contenteditable="false" data-oe-protected="true">
|
||||
<div data-oe-protected="false">
|
||||
<h1>[editable text which edition is temporarily disabled]</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p><br></p>
|
||||
`),
|
||||
stepFunction: async editor => editor._fixSelectionOnContenteditableFalse(),
|
||||
contentAfter: unformat(`
|
||||
<p>[]<br></p>
|
||||
<div contenteditable="false" data-oe-protected="true">
|
||||
<div data-oe-protected="false">
|
||||
<h1>editable text which edition is temporarily disabled</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p><br></p>
|
||||
`),
|
||||
});
|
||||
});
|
||||
it('should handle table selection in unprotected elements children', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: unformat(`
|
||||
<div data-oe-protected="true">
|
||||
<div data-oe-protected="false">
|
||||
<p>a[bc</p><table><tbody><tr><td>a]b</td><td>cd</td><td>ef</td></tr></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
`),
|
||||
contentAfterEdit: unformat(`
|
||||
<div data-oe-protected="true">
|
||||
<div data-oe-protected="false">
|
||||
<p>a[bc</p>
|
||||
<table class="o_selected_table"><tbody><tr>
|
||||
<td class="o_selected_td">a]b</td>
|
||||
<td class="o_selected_td">cd</td>
|
||||
<td class="o_selected_td">ef</td>
|
||||
</tr></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
`),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('data-oe-transient-content', () => {
|
||||
it('should remove transient elements children during cleaning', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: '<div><p>a</p></div><div data-oe-transient-content="true"><p>a</p></div>',
|
||||
contentAfter: '<div><p>a</p></div><div data-oe-transient-content="true"></div>',
|
||||
});
|
||||
});
|
||||
it('should ignore transient elements children during serialization', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: '<div><p>a</p></div><div data-oe-transient-content="true"><p>a</p></div>',
|
||||
stepFunction: async editor => {
|
||||
const elements = [];
|
||||
for (const element of [...editor.editable.children]) {
|
||||
elements.push(editor.unserializeNode(editor.serializeNode(element)));
|
||||
}
|
||||
const container = document.createElement('DIV');
|
||||
container.append(...elements);
|
||||
editor.resetContent(container.innerHTML)
|
||||
},
|
||||
contentAfter: '<div><p>a</p></div><div data-oe-transient-content="true"></div>',
|
||||
});
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { BasicEditor, insertText, testEditor, deleteForward, deleteBackward } from '../utils.js';
|
||||
|
||||
describe('FontAwesome', () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { isSelectionFormat } from '../../src/utils/utils.js';
|
||||
import { BasicEditor, testEditor, setTestSelection, Direction, unformat } from '../utils.js';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { parseHTML } from '../../src/utils/utils.js';
|
||||
import { BasicEditor, testEditor, unformat, insertText, deleteBackward } from '../utils.js';
|
||||
|
||||
@@ -90,6 +92,24 @@ describe('insert HTML', () => {
|
||||
contentAfter: '<pre>abcdef[]<br>ghi</pre>',
|
||||
});
|
||||
});
|
||||
it('should not unwrap single node if the selection anchorNode is the editable', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: '<p>content</p>[]',
|
||||
stepFunction: async editor => {
|
||||
await editor.execCommand('insert', parseHTML('<p>def</p>'));
|
||||
},
|
||||
contentAfter: '<p>content</p><p>def[]</p>',
|
||||
});
|
||||
});
|
||||
it('should not unwrap nodes if the selection anchorNode is the editable', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: '<p>content</p>[]',
|
||||
stepFunction: async editor => {
|
||||
await editor.execCommand('insert', parseHTML('<div>abc</div><p>def</p>'));
|
||||
},
|
||||
contentAfter: '<p>content</p><div>abc</div><p>def[]</p>',
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('not collapsed selection', () => {
|
||||
it('should delete selection and insert html in its place', async () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { URL_REGEX, URL_REGEX_WITH_INFOS } from '../../src/OdooEditor.js';
|
||||
import {
|
||||
BasicEditor,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import {
|
||||
BasicEditor,
|
||||
click,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { BasicEditor, testEditor, unformat } from '../utils.js';
|
||||
|
||||
describe('Odoo fields', () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { setSelection } from '../../src/OdooEditor.js';
|
||||
import { Powerbox } from '../../src/powerbox/Powerbox.js';
|
||||
import { BasicEditor, insertText, testEditor, triggerEvent } from '../utils.js';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import {
|
||||
BasicEditor,
|
||||
deleteBackward,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { URL_REGEX, URL_REGEX_WITH_INFOS } from '../../src/OdooEditor.js';
|
||||
|
||||
describe('urlRegex', () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import {
|
||||
ancestors,
|
||||
boundariesIn,
|
||||
|
||||
@@ -29,6 +29,7 @@ const QWeb = core.qweb;
|
||||
const OdooEditor = OdooEditorLib.OdooEditor;
|
||||
const getDeepRange = OdooEditorLib.getDeepRange;
|
||||
const getInSelection = OdooEditorLib.getInSelection;
|
||||
const isProtected = OdooEditorLib.isProtected;
|
||||
const isBlock = OdooEditorLib.isBlock;
|
||||
const rgbToHex = OdooEditorLib.rgbToHex;
|
||||
const preserveCursor = OdooEditorLib.preserveCursor;
|
||||
@@ -74,6 +75,7 @@ const Wysiwyg = Widget.extend({
|
||||
allowCommandImage: true,
|
||||
allowCommandLink: true,
|
||||
insertParagraphAfterColumns: true,
|
||||
onHistoryResetFromSteps: () => {},
|
||||
autostart: true,
|
||||
},
|
||||
init: function (parent, options) {
|
||||
@@ -141,8 +143,12 @@ const Wysiwyg = Widget.extend({
|
||||
this.getSession()['notification_type']
|
||||
) {
|
||||
editorCollaborationOptions = this.setupCollaboration(options.collaborationChannel);
|
||||
// Wait until editor is focused to join the peer to peer network.
|
||||
this.$editable[0].addEventListener('focus', this._joinPeerToPeer);
|
||||
if (this.options.collaborativeTrigger === 'start') {
|
||||
this._joinPeerToPeer();
|
||||
} else if (this.options.collaborativeTrigger === 'focus') {
|
||||
// Wait until editor is focused to join the peer to peer network.
|
||||
this.$editable[0].addEventListener('focus', this._joinPeerToPeer);
|
||||
}
|
||||
}
|
||||
|
||||
const getYoutubeVideoElement = async (url) => {
|
||||
@@ -303,7 +309,7 @@ const Wysiwyg = Widget.extend({
|
||||
|
||||
const selection = self.odooEditor.document.getSelection();
|
||||
const anchorNode = selection.anchorNode;
|
||||
if (anchorNode && closestElement(anchorNode, '[data-oe-protected="true"]')) {
|
||||
if (isProtected(anchorNode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -558,6 +564,7 @@ const Wysiwyg = Widget.extend({
|
||||
if (remoteSelection) {
|
||||
this.odooEditor.onExternalMultiselectionUpdate(remoteSelection);
|
||||
}
|
||||
this.options.onHistoryResetFromSteps();
|
||||
}
|
||||
// In case there are steps received in the meantime, process them.
|
||||
if (historyStepsBuffer.length) {
|
||||
@@ -566,10 +573,12 @@ const Wysiwyg = Widget.extend({
|
||||
}
|
||||
historySyncFinished = true;
|
||||
} else {
|
||||
const currentStep = this.odooEditor._historySteps[this.odooEditor._historySteps.length - 1];
|
||||
const remoteSelection = await this.ptp.requestClient(fromClientId, 'get_collaborative_selection', undefined, { transport: 'rtc' });
|
||||
if (remoteSelection) {
|
||||
this.odooEditor.onExternalMultiselectionUpdate(remoteSelection);
|
||||
}
|
||||
this.ptp.notifyClient(fromClientId, 'oe_history_step', currentStep, { transport: 'rtc' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -685,6 +694,9 @@ const Wysiwyg = Widget.extend({
|
||||
this.ptp && this.odooEditor.onExternalHistorySteps(missingSteps.concat([step]));
|
||||
},
|
||||
};
|
||||
if (this.options.postProcessExternalSteps) {
|
||||
editorCollaborationOptions.postProcessExternalSteps = this.options.postProcessExternalSteps;
|
||||
}
|
||||
return editorCollaborationOptions;
|
||||
},
|
||||
/**
|
||||
@@ -1850,7 +1862,7 @@ const Wysiwyg = Widget.extend({
|
||||
_updateEditorUI: function (e) {
|
||||
let selection = this.odooEditor.document.getSelection();
|
||||
const anchorNode = selection.anchorNode;
|
||||
if (anchorNode && closestElement(anchorNode, '[data-oe-protected="true"]')) {
|
||||
if (isProtected(anchorNode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2605,8 +2617,12 @@ const Wysiwyg = Widget.extend({
|
||||
this.setValue(value);
|
||||
this.odooEditor.historyReset();
|
||||
this.setupCollaboration(collaborationChannel);
|
||||
// Wait until editor is focused to join the peer to peer network.
|
||||
this.$editable[0].addEventListener('focus', this._joinPeerToPeer);
|
||||
if (this.options.collaborativeTrigger === 'start') {
|
||||
this._joinPeerToPeer();
|
||||
} else if (this.options.collaborativeTrigger === 'focus') {
|
||||
// Wait until editor is focused to join the peer to peer network.
|
||||
this.$editable[0].addEventListener('focus', this._joinPeerToPeer);
|
||||
}
|
||||
const initialHistoryId = value && this._getInitialHistoryId(value);
|
||||
if (initialHistoryId) {
|
||||
this.odooEditor.historySetInitialId(initialHistoryId);
|
||||
|
||||
@@ -187,7 +187,7 @@
|
||||
<script src="/web_editor/static/src/js/editor/odoo-editor/test/lib/mocha.js"></script>
|
||||
<script src="/web_editor/static/src/js/editor/odoo-editor/test/lib/chai.js"></script>
|
||||
<script>mocha.setup('bdd').fullTrace()</script>
|
||||
<script src="/web_editor/static/src/js/editor/odoo-editor/test/editor-test.js" type="module"></script>
|
||||
<t t-call-assets="web_editor.mocha_tests"/>
|
||||
</t>
|
||||
<div id="mocha"></div>
|
||||
<div id="editor-test-container"></div>
|
||||
|
||||
+1
-2
@@ -32,11 +32,10 @@ safe_attrs = clean.defs.safe_attrs | frozenset(
|
||||
['style',
|
||||
'data-o-mail-quote', # quote detection
|
||||
'data-oe-model', 'data-oe-id', 'data-oe-field', 'data-oe-type', 'data-oe-expression', 'data-oe-translation-initial-sha', 'data-oe-nodeid',
|
||||
'data-last-history-steps',
|
||||
'data-last-history-steps', 'data-oe-protected', 'data-oe-transient-content',
|
||||
'data-publish', 'data-id', 'data-res_id', 'data-interval', 'data-member_id', 'data-scroll-background-ratio', 'data-view-id',
|
||||
'data-class', 'data-mimetype', 'data-original-src', 'data-original-id', 'data-gl-filter', 'data-quality', 'data-resize-width',
|
||||
'data-shape', 'data-shape-colors', 'data-file-name', 'data-original-mimetype',
|
||||
'data-oe-protected', # editor
|
||||
'data-behavior-props', 'data-prop-name', # knowledge commands
|
||||
])
|
||||
SANITIZE_TAGS = {
|
||||
|
||||
Reference in New Issue
Block a user