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..073cf27ba93 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, @@ -794,7 +795,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 +805,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 +830,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 +968,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); @@ -2271,7 +2301,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 +3672,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 +3868,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 +3905,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 +3980,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/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/spec/collab.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/collab.test.js index 4a9f2d9952a..fb1453ca967 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,4 @@ -import { OdooEditor } from '../../src/OdooEditor.js'; +import { OdooEditor, parseHTML, setSelection } from '../../src/OdooEditor.js'; import { insertCharsAt, parseMultipleTextualSelection, @@ -6,6 +6,7 @@ import { setTestSelection, targetDeepest, undo, + unformat, } from '../utils.js'; const overridenDomClass = [ @@ -586,4 +587,83 @@ describe('Collaboration', () => { }); }); }); + describe('data-oe-protected', () => { + it('should not share protected mutations and share unprotected ones', () => { + 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', () => { + 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]

+ `)); + }, + }); + }); + }); }); 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..e368faaf5b1 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 @@ -5881,56 +5881,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/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js index e608fc95394..5039d251ac8 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; @@ -303,7 +304,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; } @@ -1852,7 +1853,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; } 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 = {