diff --git a/addons/web_editor/__manifest__.py b/addons/web_editor/__manifest__.py index cf0d09a5009..569bc1b9575 100644 --- a/addons/web_editor/__manifest__.py +++ b/addons/web_editor/__manifest__.py @@ -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', diff --git a/addons/web_editor/static/src/js/backend/html_field.js b/addons/web_editor/static/src/js/backend/html_field.js index 676fc314321..0f002da5bb5 100644 --- a/addons/web_editor/static/src/js/backend/html_field.js +++ b/addons/web_editor/static/src/js/backend/html_field.js @@ -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 diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js index 0cf80615eb4..ad928a1a79b 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js @@ -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 diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/commands.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/commands.js index 3e032b0b244..1f0fffc1814 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/commands.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/commands.js @@ -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

or

  • - // tag, we take the all content of the

    or

  • and avoid inserting the - //

    or

  • . The same is true for a
     inside a 
    .
    -        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 

    or

  • + // tag, we take the all content of the

    or

  • and avoid inserting the + //

    or

  • . The same is true for a
     inside a 
    .
    +            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();
    +                }
                 }
             }
     
    diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/qweb_sample.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/qweb_sample.js
    index 7b6ddec23f8..cbdcdaa4ba0 100644
    --- a/addons/web_editor/static/src/js/editor/odoo-editor/src/qweb_sample.js
    +++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/qweb_sample.js
    @@ -1,3 +1,5 @@
    +/** @odoo-module */
    +
     export const qwebSample = /* xml */ `
     

    Qweb examples

    diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/sanitize.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/sanitize.js index fa0a9449411..b17ed6f52e3 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/sanitize.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/sanitize.js @@ -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 ( diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/serialize.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/serialize.js index d651b977433..cf3efff53df 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/serialize.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/serialize.js @@ -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; diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js index 9041fe55814..28e9283d900 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js @@ -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'; } diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/editor-test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/editor-test.js index 3ec76aed5f7..a518e6b5ca8 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/editor-test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/editor-test.js @@ -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//', 'zws'); + errorElHtml = errorElHtml.replaceAll('//TAB//', 'Tab'); + 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//', 'zws'); - errorElHtml = errorElHtml.replaceAll('//TAB//', 'Tab'); - errorEl.outerHTML = errorElHtml; - }); - } else { - console.log('test successful'); - } - + }); }); diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/align.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/align.test.js index fa35cfb2a42..bab895ad440 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/align.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/align.test.js @@ -1,3 +1,5 @@ +/** @odoo-module */ + import { BasicEditor, testEditor } from '../utils.js'; const justifyLeft = async function (editor) { diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/autostep.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/autostep.test.js index ec69e6b7e6e..f42ee1ce729 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/autostep.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/autostep.test.js @@ -1,3 +1,5 @@ +/** @odoo-module */ + import { BasicEditor, testEditor } from '../utils.js'; const timeoutPromise = ms => diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/collab.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/collab.test.js index 4a9f2d9952a..415e3a1db02 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/collab.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/collab.test.js @@ -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: '

    a[c1}{c1]e[c2}{c2]i[c3}{c3]

    ', afterCreate: clientInfos => { @@ -258,8 +262,8 @@ describe('Collaboration', () => { contentAfter: '

    abcd[c1}{c1]efgh[c2}{c2]ijkl[c3}{c3]

    ', }); }); - 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: '

    ab[c1}{c1]

    cd[c2}{c2]

    ', afterCreate: clientInfos => { @@ -277,8 +281,8 @@ describe('Collaboration', () => { contentAfter: '

    abe[c1}{c1]

    cdf[c2}{c2]

    ', }); }); - 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: '

    ab[c1}{c1]

    cd[c2}{c2]

    ', afterCreate: clientInfos => { @@ -298,8 +302,8 @@ describe('Collaboration', () => { contentAfter: '

    abef[c1}{c1]

    cdgh[c2}{c2]

    ', }); }); - 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: '

    ab[c1}{c1][c2}{c2]c

    ', afterCreate: clientInfos => { @@ -317,8 +321,8 @@ describe('Collaboration', () => { contentAfter: '

    a[c2}{c2]c[c1}{c1]dc

    ', }); }); - 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: '

    ab[c1}{c1][c2}{c2]c

    ', 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: '

    a[c1}{c1]

    ', 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: '

    a[c1}{c1]b[c2}{c2]c[c3}{c3]

    ', afterCreate: clientInfos => { @@ -387,8 +391,8 @@ describe('Collaboration', () => { contentAfter: '

    ad[c1}{c1]be[c2}{c2]c[c3}{c3]

    ', }); }); - 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: '

    a[c1}{c1]b[c2}{c2]

    ', 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: '

    a

    ', 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: '

    a[c1}{c1][c2}{c2]

    ', 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: '

    a

    ', 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: '

    a

    ', 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: '

    a

    ', 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: '

    a

    ', 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: '
    [c1}{c1]
    ', 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: '

    [c1}{c1][c2}{c2]

    ', + afterCreate: clientInfos => { + clientInfos.c1.editor.editable.prepend(...parseHTML(unformat(` +
    +


    +
    +


    +
    +
    + `)).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(` +
    +

    a

    +
    +

    a[c1}{c1]

    +
    +
    +

    [c2}{c2]

    + `)); + chai.expect(clientInfos.c2.editable.innerHTML).to.equal(unformat(` +
    +


    +
    +

    a[c1}{c1]

    +
    +
    +

    [c2}{c2]

    + `)); + }, + }); + }); + }); + describe('data-oe-transient-content', () => { + it('should send an empty transient-content element', async () => { + await testMultiEditor({ + clientIds: ['c1', 'c2'], + contentBefore: '

    [c1}{c1][c2}{c2]

    ', + afterCreate: clientInfos => { + clientInfos.c1.editor.editable.prepend(...parseHTML(unformat(` +
    +

    secret

    +
    + `)).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(` +
    +

    secret

    +
    +

    [c1}{c1][c2}{c2]

    + `)); + chai.expect(clientInfos.c2.editable.innerHTML).to.equal(unformat(` +
    +

    [c1}{c1][c2}{c2]

    + `)); + }, + }); + }); + }); + 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: '

    [c1}{c1][c2}{c2]

    ', + afterCreate: async clientInfos => { + clientInfos.c1.editor.editable.append(...parseHTML(unformat(` +
    +

    secret

    +
    + `)).children); + clientInfos.c1.editor.historyStep(); + clientInfos.c1.editor.editable.append(...parseHTML(unformat(` +

    post-process

    + `)).children); + clientInfos.c1.editor.historyStep(); + clientInfos.c2.editor.onExternalHistorySteps( + clientInfos.c1.editor._historySteps + ); + chai.expect(clientInfos.c1.editable.innerHTML).to.equal(unformat(` +

    +
    +

    secret

    +
    +

    post-process

    + `)); + chai.expect(clientInfos.c2.editable.innerHTML).to.equal(unformat(` +

    +
    +

    secret

    +
    + `)); + await deferredPromise; + chai.expect(clientInfos.c2.editable.innerHTML).to.equal(unformat(` +

    +
    +

    secret

    +
    +

    post-process

    + `)); + testSameHistory(clientInfos); + }, + }); + }); + }) }); diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/color.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/color.test.js index 20220caaacf..aebd678e0af 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/color.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/color.test.js @@ -1,3 +1,5 @@ +/** @odoo-module */ + import { BasicEditor, testEditor } from '../utils.js'; const setColor = (color, mode) => { diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/copyPaste.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/copyPaste.test.js index 3dbd4c189e9..d07876b5ebb 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/copyPaste.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/copyPaste.test.js @@ -1,3 +1,5 @@ +/** @odoo-module */ + import { BasicEditor, testEditor, diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js index 25f23bd8d56..b173428ecb9 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js @@ -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(` -

    a[]

    -

    a

    - `), - 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(` -

    ab[]

    -

    ab

    - `), + describe('data-oe-protected', () => { + describe('true', () => { + it('should ignore protected elements children mutations', async () => { + await testEditor(BasicEditor, { + contentBefore: unformat(` +

    a[]

    +

    a

    + `), + 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(` +

    ab[]

    +

    ab

    + `), + }); + }); + it('should not sanitize (sanitize.js) protected elements children', async () => { + await testEditor(BasicEditor, { + contentBefore: unformat(` +
    +

    +

    +
    +
    +

    +

    +
    + `), + stepFunction: async editor => editor.sanitize(), + contentAfterEdit: unformat(` +
    +

    \u200B

    +

    +
    +
    +

    +

    +
    + `), + }); + }); + it('should not fix selection in contenteditable="false" protected elements children', async () => { + await testEditor(BasicEditor, { + contentBefore: unformat(` +


    +
    +

    [very important text that needs to be selected]

    +
    +


    + `), + stepFunction: async editor => editor._fixSelectionOnContenteditableFalse(), + contentAfter: unformat(` +


    +
    +

    [very important text that needs to be selected]

    +
    +


    + `), + }); + }); + it('should not handle table selection in protected elements children', async () => { + await testEditor(BasicEditor, { + contentBefore: unformat(` +
    +

    a[bc

    a]bcdef
    +
    + `), + contentAfterEdit: unformat(` +
    +

    a[bc

    a]bcdef
    +
    + `), + }); }); }); - it('should not sanitize protected elements children', async () => { - await testEditor(BasicEditor, { - contentBefore: unformat(` -
    -

    -

    -
    -
    -

    -

    -
    - `), - stepFunction: async editor => editor.sanitize(), - contentAfterEdit: unformat(` -
    -

    \u200B

    -

    -
    -
    -

    -

    -
    - `), + describe('false', () => { + it('should not ignore unprotected elements children mutations', async () => { + await testEditor(BasicEditor, { + contentBefore: unformat(` +

    a[]

    +

    a

    + `), + 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(` +

    abc

    +

    ab[]

    + `), + }); }); - }); - it('should remove protected elements children during cleaning', async () => { - await testEditor(BasicEditor, { - contentBefore: '

    a[]

    a

    ', - contentAfter: '

    a[]

    ', + it('should sanitize (sanitize.js) unprotected elements children', async () => { + await testEditor(BasicEditor, { + contentBefore: unformat(` +
    +

    +

    +
    +

    +

    +
    +
    + `), + stepFunction: async editor => editor.sanitize(), + contentAfterEdit: unformat(` +
    +

    +

    +
    +

    \u200B

    +

    +
    +
    + `), + }); + }); + it('should fix selection in contenteditable="false" unprotected elements children', async () => { + await testEditor(BasicEditor, { + contentBefore: unformat(` +


    +
    +
    +

    [editable text which edition is temporarily disabled]

    +
    +
    +


    + `), + stepFunction: async editor => editor._fixSelectionOnContenteditableFalse(), + contentAfter: unformat(` +

    []

    +
    +
    +

    editable text which edition is temporarily disabled

    +
    +
    +


    + `), + }); + }); + it('should handle table selection in unprotected elements children', async () => { + await testEditor(BasicEditor, { + contentBefore: unformat(` +
    +
    +

    a[bc

    a]bcdef
    +
    +
    + `), + contentAfterEdit: unformat(` +
    +
    +

    a[bc

    + + + + +
    a]bcdef
    +
    +
    + `), + }); }); }); }); + describe('data-oe-transient-content', () => { + it('should remove transient elements children during cleaning', async () => { + await testEditor(BasicEditor, { + contentBefore: '

    a

    a

    ', + contentAfter: '

    a

    ', + }); + }); + it('should ignore transient elements children during serialization', async () => { + await testEditor(BasicEditor, { + contentBefore: '

    a

    a

    ', + 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: '

    a

    ', + }); + }) + }); }); diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/fontAwesome.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/fontAwesome.test.js index 40522c23166..7089921bdd3 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/fontAwesome.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/fontAwesome.test.js @@ -1,3 +1,5 @@ +/** @odoo-module */ + import { BasicEditor, insertText, testEditor, deleteForward, deleteBackward } from '../utils.js'; describe('FontAwesome', () => { diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/format.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/format.test.js index d4563e92fb9..f086d40c1ba 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/format.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/format.test.js @@ -1,3 +1,5 @@ +/** @odoo-module */ + import { isSelectionFormat } from '../../src/utils/utils.js'; import { BasicEditor, testEditor, setTestSelection, Direction, unformat } from '../utils.js'; diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/insert.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/insert.test.js index d5e2e9552e3..e6438d5ac1a 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/insert.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/insert.test.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: '
    abcdef[]
    ghi
    ', }); }); + it('should not unwrap single node if the selection anchorNode is the editable', async () => { + await testEditor(BasicEditor, { + contentBefore: '

    content

    []', + stepFunction: async editor => { + await editor.execCommand('insert', parseHTML('

    def

    ')); + }, + contentAfter: '

    content

    def[]

    ', + }); + }); + it('should not unwrap nodes if the selection anchorNode is the editable', async () => { + await testEditor(BasicEditor, { + contentBefore: '

    content

    []', + stepFunction: async editor => { + await editor.execCommand('insert', parseHTML('
    abc

    def

    ')); + }, + contentAfter: '

    content

    abc

    def[]

    ', + }); + }); }); describe('not collapsed selection', () => { it('should delete selection and insert html in its place', async () => { diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/link.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/link.test.js index 50ce2b31ca3..819b7e5e2fc 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/link.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/link.test.js @@ -1,3 +1,5 @@ +/** @odoo-module */ + import { URL_REGEX, URL_REGEX_WITH_INFOS } from '../../src/OdooEditor.js'; import { BasicEditor, diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/list.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/list.test.js index dbb42fba1ab..5f456382c6b 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/list.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/list.test.js @@ -1,3 +1,5 @@ +/** @odoo-module */ + import { BasicEditor, click, diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/odooFields.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/odooFields.test.js index f2af54e924a..b3ab2dcc6be 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/odooFields.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/odooFields.test.js @@ -1,3 +1,5 @@ +/** @odoo-module */ + import { BasicEditor, testEditor, unformat } from '../utils.js'; describe('Odoo fields', () => { diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/powerbox.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/powerbox.test.js index a0d79ae9b81..4efaad731ed 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/powerbox.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/powerbox.test.js @@ -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'; diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/tabs.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/tabs.test.js index 7210b6f25e8..fe765bdcd3b 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/tabs.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/tabs.test.js @@ -1,3 +1,5 @@ +/** @odoo-module */ + import { BasicEditor, deleteBackward, diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/urlRegex.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/urlRegex.test.js index a111d80de21..2d0dc6e80f7 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/urlRegex.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/urlRegex.test.js @@ -1,3 +1,5 @@ +/** @odoo-module */ + import { URL_REGEX, URL_REGEX_WITH_INFOS } from '../../src/OdooEditor.js'; describe('urlRegex', () => { diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/utils.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/utils.test.js index 3cc3c5c4aeb..a10b2989724 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/utils.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/utils.test.js @@ -1,3 +1,5 @@ +/** @odoo-module */ + import { ancestors, boundariesIn, diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js index 0852d3e673c..c9578a2ddcb 100644 --- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js @@ -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); diff --git a/addons/web_editor/views/editor.xml b/addons/web_editor/views/editor.xml index 8d84cf93ccb..952d37390b0 100644 --- a/addons/web_editor/views/editor.xml +++ b/addons/web_editor/views/editor.xml @@ -187,7 +187,7 @@ - +
    diff --git a/odoo/tools/mail.py b/odoo/tools/mail.py index 53f2f43a34a..3f0bc5e6756 100644 --- a/odoo/tools/mail.py +++ b/odoo/tools/mail.py @@ -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 = {