From 6cd42319faafafb004a1663e810019fa0b7cf1e5 Mon Sep 17 00:00:00 2001 From: Antoine Guenet <age@odoo.com> Date: Mon, 14 Mar 2022 15:20:05 +0000 Subject: [PATCH] [FIX] web_editor: properly clean zero-width spaces inserted for formats When inserting a format with a collapsed selection, we need to insert a zero-width space. It should not be saved so we need to isolate it and put the "oe-zws-empty-inline" attribute on its parent so it gets cleaned up by the sanitizer. X-original-commit: 4ce274ba39cd8046b9ef4e0b99df66913375f993 Part-of: odoo/odoo#86930 --- .../lib/odoo-editor/src/commands/commands.js | 28 ++++++ .../lib/odoo-editor/test/spec/format.test.js | 90 ++++++++++++------- 2 files changed, 84 insertions(+), 34 deletions(-) diff --git a/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js b/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js index 3ab43be5d722..a86e501b5d9d 100644 --- a/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js +++ b/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js @@ -354,6 +354,19 @@ export function toggleFormat(editor, format) { } } if (wasCollapsed) { + const siblings = [...zws.parentElement.childNodes]; + if ( + selectedTextNodes.includes(siblings[0]) && + selectedTextNodes.includes(siblings[siblings.length - 1]) + ) { + zws.parentElement.setAttribute('oe-zws-empty-inline', ''); + } else { + const span = document.createElement('span'); + span.setAttribute('oe-zws-empty-inline', ''); + zws.before(span); + span.append(zws); + + } setSelection(zws, 1); } else { setSelection(anchorNode, anchorOffset, focusNode, focusOffset); @@ -376,6 +389,21 @@ export function toggleFormat(editor, format) { } else { el.style[style.name] = style.value; } + if (zws) { + const siblings = [...zws.parentElement.childNodes]; + if ( + selectedTextNodes.includes(siblings[0]) && + selectedTextNodes.includes(siblings[siblings.length - 1]) + ) { + zws.parentElement.setAttribute('oe-zws-empty-inline', ''); + } else { + const span = document.createElement('span'); + span.setAttribute('oe-zws-empty-inline', ''); + zws.before(span); + span.append(zws); + + } + } }, format, !isAlreadyFormatted); } } diff --git a/addons/web_editor/static/lib/odoo-editor/test/spec/format.test.js b/addons/web_editor/static/lib/odoo-editor/test/spec/format.test.js index a8a52978e889..5a86afac58db 100644 --- a/addons/web_editor/static/lib/odoo-editor/test/spec/format.test.js +++ b/addons/web_editor/static/lib/odoo-editor/test/spec/format.test.js @@ -15,8 +15,8 @@ const strikeThrough = async editor => { }; describe('Format', () => { - const b = content => `<span style="font-weight: bolder;">${content}</span>`; - const notB = (content, weight) => `<span style="font-weight: ${weight || 'normal'};">${content}</span>`; + const b = (content, zws) => `<span${zws === 'first' ? ' oe-zws-empty-inline=""' : ''} style="font-weight: bolder;"${zws === 'last' ? ' oe-zws-empty-inline=""' : ''}>${content}</span>`; + const notB = (content, weight, zws) => `<span${zws === 'first' ? ' oe-zws-empty-inline=""' : ''} style="font-weight: ${weight || 'normal'};"${zws === 'last' ? ' oe-zws-empty-inline=""' : ''}>${content}</span>`; describe('bold', () => { it('should make a few characters bold', async () => { await testEditor(BasicEditor, { @@ -86,19 +86,21 @@ describe('Format', () => { await testEditor(BasicEditor, { contentBefore: '<p>ab[]cd</p>', stepFunction: bold, - contentAfter: `<p>ab${b(`[]\u200B`)}cd</p>`, + contentAfterEdit: `<p>ab${b(`[]\u200B`, 'first')}cd</p>`, + contentAfter: `<p>ab[]cd</p>`, }); }); it('should get ready to type in not bold', async () => { await testEditor(BasicEditor, { contentBefore: `<p>${b(`ab[]cd`)}</p>`, stepFunction: bold, - contentAfter: `<p>${b(`ab${notB(`[]\u200B`)}cd`)}</p>`, + contentAfterEdit: `<p>${b(`ab${notB(`[]\u200B`, undefined, 'first')}cd`)}</p>`, + contentAfter: `<p>${b(`ab[]cd`)}</p>`, }); }); }); - const i = content => `<span style="font-style: italic;">${content}</span>`; - const notI = content => `<span style="font-style: normal;">${content}</span>`; + const i = (content, zws) => `<span${zws === 'first' ? ' oe-zws-empty-inline=""' : ''} style="font-style: italic;"${zws === 'last' ? ' oe-zws-empty-inline=""' : ''}>${content}</span>`; + const notI = (content, zws) => `<span${zws === 'first' ? ' oe-zws-empty-inline=""' : ''} style="font-style: normal;"${zws === 'last' ? ' oe-zws-empty-inline=""' : ''}>${content}</span>`; describe('italic', () => { it('should make a few characters italic', async () => { await testEditor(BasicEditor, { @@ -168,18 +170,20 @@ describe('Format', () => { await testEditor(BasicEditor, { contentBefore: `<p>ab[]cd</p>`, stepFunction: italic, - contentAfter: `<p>ab${i(`[]\u200B`)}cd</p>`, + contentAfterEdit: `<p>ab${i(`[]\u200B`, 'first')}cd</p>`, + contentAfter: `<p>ab[]cd</p>`, }); }); it('should get ready to type in not italic', async () => { await testEditor(BasicEditor, { contentBefore: `<p>${i(`ab[]cd`)}</p>`, stepFunction: italic, - contentAfter: `<p>${i(`ab${notI(`[]\u200B`)}cd`)}</p>`, + contentAfterEdit: `<p>${i(`ab${notI(`[]\u200B`, 'first')}cd`)}</p>`, + contentAfter: `<p>${i(`ab[]cd`)}</p>`, }); }); }); - const u = content => `<span style="text-decoration-line: underline;">${content}</span>`; + const u = (content, zws) => `<span${zws === 'first' ? ' oe-zws-empty-inline=""' : ''} style="text-decoration-line: underline;"${zws === 'last' ? ' oe-zws-empty-inline=""' : ''}>${content}</span>`; describe('underline', () => { it('should make a few characters underline', async () => { await testEditor(BasicEditor, { @@ -248,18 +252,20 @@ describe('Format', () => { await testEditor(BasicEditor, { contentBefore: `<p>ab[]cd</p>`, stepFunction: underline, - contentAfter: `<p>ab${u(`[]\u200B`)}cd</p>`, + contentAfterEdit: `<p>ab${u(`[]\u200B`, 'first')}cd</p>`, + contentAfter: `<p>ab[]cd</p>`, }); }); it('should get ready to type in not underline', async () => { await testEditor(BasicEditor, { contentBefore: `<p>${u(`ab[]cd`)}</p>`, stepFunction: underline, - contentAfter: `<p>${u(`ab`)}\u200B[]${u(`cd`)}</p>`, + contentAfterEdit: `<p>${u(`ab`)}<span oe-zws-empty-inline="">\u200B[]</span>${u(`cd`)}</p>`, + contentAfter: `<p>${u(`ab`)}[]${u(`cd`)}</p>`, }); }); }); - const s = content => `<span style="text-decoration-line: line-through;">${content}</span>`; + const s = (content, zws) => `<span${zws === 'first' ? ' oe-zws-empty-inline=""' : ''} style="text-decoration-line: line-through;"${zws === 'last' ? ' oe-zws-empty-inline=""' : ''}>${content}</span>`; describe('strikeThrough', () => { it('should make a few characters strikeThrough', async () => { await testEditor(BasicEditor, { @@ -328,14 +334,16 @@ describe('Format', () => { await testEditor(BasicEditor, { contentBefore: `<p>ab[]cd</p>`, stepFunction: strikeThrough, - contentAfter: `<p>ab${s(`[]\u200B`)}cd</p>`, + contentAfterEdit: `<p>ab${s(`[]\u200B`, 'first')}cd</p>`, + contentAfter: `<p>ab[]cd</p>`, }); }); - it('should get ready to type in not strikeThrough', async () => { + it('should get ready to type in not underline', async () => { await testEditor(BasicEditor, { contentBefore: `<p>${s(`ab[]cd`)}</p>`, stepFunction: strikeThrough, - contentAfter: `<p>${s(`ab`)}\u200B[]${s(`cd`)}</p>`, + contentAfterEdit: `<p>${s(`ab`)}<span oe-zws-empty-inline="">\u200B[]</span>${s(`cd`)}</p>`, + contentAfter: `<p>${s(`ab`)}[]${s(`cd`)}</p>`, }); }); }); @@ -344,32 +352,37 @@ describe('Format', () => { await testEditor(BasicEditor, { contentBefore: `<p>ab${u(s(`cd[]ef`))}</p>`, stepFunction: underline, - contentAfter: `<p>ab${u(s(`cd`))}${s(`\u200b[]`)}${u(s(`ef`))}</p>`, + contentAfterEdit: `<p>ab${u(s(`cd`))}${s(`\u200b[]`, 'last')}${u(s(`ef`))}</p>`, + contentAfter: `<p>ab${u(s(`cd`))}[]${u(s(`ef`))}</p>`, }); }); it('should restore underline after removing it (collapsed, strikeThrough)', async () => { await testEditor(BasicEditor, { - contentBefore: `<p>ab${u(s(`cd`))}${s(`\u200b[]`)}${u(s(`ef`))}</p>`, + contentBefore: `<p>ab${u(s(`cd`))}${s(`\u200b[]`, 'first')}${u(s(`ef`))}</p>`, stepFunction: underline, - contentAfter: `<p>ab${u(s(`cd`))}${s(u(`[]\u200b`))}${u(s(`ef`))}</p>`, + contentAfterEdit: `<p>ab${u(s(`cd`))}${s(u(`[]\u200b`, 'first'), 'first')}${u(s(`ef`))}</p>`, + contentAfter: `<p>ab${u(s(`cd`))}[]${u(s(`ef`))}</p>`, }); }); it('should remove underline after restoring it after removing it (collapsed, strikeThrough)', async () => { await testEditor(BasicEditor, { - contentBefore: `<p>ab${u(s(`cd`))}${s(u(`[]\u200b`))}${u(s(`ef`))}</p>`, + contentBefore: `<p>ab${u(s(`cd`))}${s(u(`[]\u200b`, 'first'))}${u(s(`ef`))}</p>`, stepFunction: underline, - contentAfter: `<p>ab${u(s(`cd`))}${s(`\u200b[]`)}${u(s(`ef`))}</p>`, + contentAfterEdit: `<p>ab${u(s(`cd`))}${s(`\u200b[]`, 'last')}${u(s(`ef`))}</p>`, + contentAfter: `<p>ab${u(s(`cd`))}[]${u(s(`ef`))}</p>`, }); }); it('should remove underline after restoring it and writing after removing it (collapsed, strikeThrough)', async () => { await testEditor(BasicEditor, { contentBefore: `<p>ab${u(s(`cd`))}${s(u(`ghi[]`))}${u(s(`ef`))}</p>`, stepFunction: underline, - contentAfter: `<p>ab${u(s(`cd`))}${s(u(`ghi`) + `\u200b[]`)}${u(s(`ef`))}</p>`, + contentAfterEdit: `<p>ab${u(s(`cd`))}${s(u(`ghi`) + `<span oe-zws-empty-inline="">\u200b[]</span>`)}${u(s(`ef`))}</p>`, + contentAfter: `<p>ab${u(s(`cd`))}${s(u(`ghi`) + `[]`)}${u(s(`ef`))}</p>`, }); }); it('should remove underline, write, restore underline, write, remove underline again, write (collapsed, strikeThrough)', async () => { - const uselessSpan = u(''); // TODO: clean + const uselessSpan = content => `<span>${content}</span>`; // TODO: clean + const uselessU = u(''); // TODO: clean await testEditor(BasicEditor, { contentBefore: `<p>ab${u(s(`cd[]ef`))}</p>`, stepFunction: async editor => { @@ -380,12 +393,12 @@ describe('Format', () => { await editor.execCommand('underline'); await editor.execCommand('insertText', 'C'); }, - contentAfterEdit: `<p>ab${u(s(`cd`))}${s(`A${u(`B`)}C[]${uselessSpan}`)}${u(s(`ef`))}</p>`, + contentAfterEdit: `<p>ab${u(s(`cd`))}${s(`A${u(`B`)}${uselessSpan(`C[]`)}${uselessU}`)}${u(s(`ef`))}</p>`, }); }); }); describe('underline + italic', () => { - const iAndU = content => `<span style="font-style: italic; text-decoration-line: underline;">${content}</span>`; + const iAndU = (content, zws) => `<span${zws === 'first' ? ' oe-zws-empty-inline=""' : ''} style="font-style: italic; text-decoration-line: underline;"${zws === 'last' ? ' oe-zws-empty-inline=""' : ''}>${content}</span>`; it('should get ready to write in italic and underline', async () => { await testEditor(BasicEditor, { contentBefore: `<p>ab[]cd</p>`, @@ -393,7 +406,8 @@ describe('Format', () => { await editor.execCommand('italic'); await editor.execCommand('underline'); }, - contentAfter: `<p>ab${iAndU(`[]\u200B`)}cd</p>`, + contentAfterEdit: `<p>ab${iAndU(`[]\u200B`, 'first')}cd</p>`, + contentAfter: `<p>ab[]cd</p>`, }); }); it('should get ready to write in italic, after changing one\'s mind about underline', async () => { @@ -404,7 +418,8 @@ describe('Format', () => { await editor.execCommand('underline'); await editor.execCommand('underline'); }, - contentAfter: `<p>ab${i(`\u200B[]`)}cd</p>`, + contentAfterEdit: `<p>ab${i(`\u200B[]`, 'first')}cd</p>`, + contentAfter: `<p>ab[]cd</p>`, }); await testEditor(BasicEditor, { contentBefore: `<p>ab[]cd</p>`, @@ -413,7 +428,8 @@ describe('Format', () => { await editor.execCommand('italic'); await editor.execCommand('underline'); }, - contentAfter: `<p>ab${i(`\u200B[]`)}cd</p>`, + contentAfterEdit: `<p>ab${i(`\u200B[]`, 'first')}cd</p>`, + contentAfter: `<p>ab[]cd</p>`, }); await testEditor(BasicEditor, { contentBefore: `<p>ab[]cd</p>`, @@ -422,39 +438,45 @@ describe('Format', () => { await editor.execCommand('underline'); await editor.execCommand('italic'); }, - contentAfter: `<p>ab${i(`[]\u200B`)}cd</p>`, + contentAfterEdit: `<p>ab${i(`[]\u200B`, 'first')}cd</p>`, + contentAfter: `<p>ab[]cd</p>`, }); }); it('should get ready to write in italic without underline (underline was first)', async () => { await testEditor(BasicEditor, { contentBefore: `<p>ab${u(i(`cd[]ef`))}</p>`, stepFunction: underline, - contentAfter: `<p>ab${u(i(`cd`))}${i(`\u200b[]`)}${u(i(`ef`))}</p>`, + contentAfterEdit: `<p>ab${u(i(`cd`))}${i(`\u200b[]`, 'last')}${u(i(`ef`))}</p>`, + contentAfter: `<p>ab${u(i(`cd`))}[]${u(i(`ef`))}</p>`, }); }); it('should restore underline after removing it (collapsed, italic)', async () => { await testEditor(BasicEditor, { contentBefore: `<p>ab${u(i(`cd`))}${i(`\u200b[]`)}${u(i(`ef`))}</p>`, stepFunction: underline, - contentAfter: `<p>ab${u(i(`cd`))}${iAndU(`[]\u200b`)}${u(i(`ef`))}</p>`, + contentAfterEdit: `<p>ab${u(i(`cd`))}${iAndU(`[]\u200b`, 'last')}${u(i(`ef`))}</p>`, + contentAfter: `<p>ab${u(i(`cd`))}[]${u(i(`ef`))}</p>`, }); }); it('should remove underline after restoring it after removing it (collapsed, italic)', async () => { await testEditor(BasicEditor, { contentBefore: `<p>ab${u(i(`cd`))}${i(u(`[]\u200b`))}${u(i(`ef`))}</p>`, stepFunction: underline, - contentAfter: `<p>ab${u(i(`cd`))}${i(`\u200b[]`)}${u(i(`ef`))}</p>`, + contentAfterEdit: `<p>ab${u(i(`cd`))}${i(`\u200b[]`, 'last')}${u(i(`ef`))}</p>`, + contentAfter: `<p>ab${u(i(`cd`))}[]${u(i(`ef`))}</p>`, }); }); it('should remove underline after restoring it and writing after removing it (collapsed, italic)', async () => { await testEditor(BasicEditor, { contentBefore: `<p>ab${u(i(`cd`))}${i(u(`ghi[]`))}${u(i(`ef`))}</p>`, stepFunction: underline, - contentAfter: `<p>ab${u(i(`cd`))}${i(u(`ghi`) + `\u200b[]`)}${u(i(`ef`))}</p>`, + contentAfterEdit: `<p>ab${u(i(`cd`))}${i(u(`ghi`) + `<span oe-zws-empty-inline="">\u200b[]</span>`)}${u(i(`ef`))}</p>`, + contentAfter: `<p>ab${u(i(`cd`))}${i(u(`ghi`) + `[]`)}${u(i(`ef`))}</p>`, }); }); it('should remove underline, write, restore underline, write, remove underline again, write (collapsed, italic)', async () => { - const uselessSpan = u(''); // TODO: clean + const uselessSpan = content => `<span>${content}</span>`; + const uselessU = u(''); // TODO: clean await testEditor(BasicEditor, { contentBefore: `<p>ab${u(i(`cd[]ef`))}</p>`, stepFunction: async editor => { @@ -465,7 +487,7 @@ describe('Format', () => { await editor.execCommand('underline'); await editor.execCommand('insertText', 'C'); }, - contentAfterEdit: `<p>ab${u(i(`cd`))}${i(`A${u(`B`)}C[]${uselessSpan}`)}${u(i(`ef`))}</p>`, + contentAfter: `<p>ab${u(i(`cd`))}${i(`A${u(`B`)}${uselessSpan(`C[]`)}${uselessU}`)}${u(i(`ef`))}</p>`, }); }); }); -- GitLab