From fa417893a4dbe742e3e08bae7501e6119c0117d3 Mon Sep 17 00:00:00 2001 From: Antoine Guenet <age@odoo.com> Date: Fri, 25 Feb 2022 11:56:43 +0000 Subject: [PATCH] [FIX] web_editor: handle arrow keys next to zero-width spaces Consider the following situation: `<p>a[]b<span><zws></span>c</p>`, where `<zws>` is a zero-width space and `[]` is the collapsed selection. On pressing the `ArrowRight` key, we want to enter the `<span>` so we don't press once to be after the "b" then once again to be before the `<zws>`. The cases with a non-collapsed selection, and with the left arrow are analogous. This is what this commit allows us to handle. X-original-commit: 8e1d5ff3f3bdef8ca2f0eb069c62140260aad3f6 Part-of: odoo/odoo#86930 --- .../static/lib/odoo-editor/src/OdooEditor.js | 47 ++++ .../lib/odoo-editor/test/spec/editor.test.js | 261 ++++++++++++++++++ .../static/lib/odoo-editor/test/utils.js | 6 + 3 files changed, 314 insertions(+) diff --git a/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js b/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js index e5386c1d2c31..e1d63b184278 100644 --- a/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js +++ b/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js @@ -36,6 +36,7 @@ import { getDeepRange, ancestors, firstLeaf, + previousLeaf, nextLeaf, isUnremovable, fillEmpty, @@ -76,6 +77,8 @@ const IS_KEYBOARD_EVENT_BOLD = ev => ev.key === 'b' && (ev.ctrlKey || ev.metaKey const IS_KEYBOARD_EVENT_ITALIC = ev => ev.key === 'i' && (ev.ctrlKey || ev.metaKey); const IS_KEYBOARD_EVENT_UNDERLINE = ev => ev.key === 'u' && (ev.ctrlKey || ev.metaKey); const IS_KEYBOARD_EVENT_STRIKETHROUGH = ev => ev.key === '5' && (ev.ctrlKey || ev.metaKey); +const IS_KEYBOARD_EVENT_LEFT_ARROW = ev => ev.key === 'ArrowLeft' && !(ev.ctrlKey || ev.metaKey); +const IS_KEYBOARD_EVENT_RIGHT_ARROW = ev => ev.key === 'ArrowRight' && !(ev.ctrlKey || ev.metaKey); const CLIPBOARD_BLACKLISTS = { unwrap: ['.Apple-interchange-newline', 'DIV'], // These elements' children will be unwrapped. @@ -2385,6 +2388,50 @@ export class OdooEditor extends EventTarget { ev.preventDefault(); ev.stopPropagation(); this.execCommand('strikeThrough'); + } else if (IS_KEYBOARD_EVENT_LEFT_ARROW(ev)) { + getDeepRange(this.editable); + const selection = this.document.getSelection(); + // Find previous character. + let { focusNode, focusOffset } = selection; + let previousCharacter = focusOffset > 0 && focusNode.textContent[focusOffset - 1]; + if (!previousCharacter) { + focusNode = previousLeaf(focusNode); + focusOffset = nodeSize(focusNode); + previousCharacter = focusNode.textContent[focusOffset - 1]; + } + // Move selection if previous character is zero-width space + if (previousCharacter === '\u200B') { + focusOffset -= 1; + while (focusNode && (focusOffset < 0 || !focusNode.textContent[focusOffset])) { + focusNode = nextLeaf(focusNode); + focusOffset = focusNode && nodeSize(focusNode); + } + const startContainer = ev.shiftKey ? selection.anchorNode : focusNode; + const startOffset = ev.shiftKey ? selection.anchorOffset : focusOffset; + setSelection(startContainer, startOffset, focusNode, focusOffset); + } + } else if (IS_KEYBOARD_EVENT_RIGHT_ARROW(ev)) { + getDeepRange(this.editable); + const selection = this.document.getSelection(); + // Find next character. + let { focusNode, focusOffset } = selection; + let nextCharacter = focusNode.textContent[focusOffset]; + if (!nextCharacter) { + focusNode = nextLeaf(focusNode); + focusOffset = 0; + nextCharacter = focusNode.textContent[focusOffset]; + } + // Move selection if next character is zero-width space + if (nextCharacter === '\u200B') { + focusOffset += 1; + while (focusNode && !focusNode.textContent[focusOffset]) { + focusNode = nextLeaf(focusNode); + focusOffset = 0; + } + const startContainer = ev.shiftKey ? selection.anchorNode : focusNode; + const startOffset = ev.shiftKey ? selection.anchorOffset : focusOffset; + setSelection(startContainer, startOffset, focusNode, focusOffset); + } } } /** diff --git a/addons/web_editor/static/lib/odoo-editor/test/spec/editor.test.js b/addons/web_editor/static/lib/odoo-editor/test/spec/editor.test.js index 8222b24987bd..2f0d75a9135e 100644 --- a/addons/web_editor/static/lib/odoo-editor/test/spec/editor.test.js +++ b/addons/web_editor/static/lib/odoo-editor/test/spec/editor.test.js @@ -8,6 +8,7 @@ import { insertLineBreak, insertParagraphBreak, insertText, + keydown, redo, testEditor, undo, @@ -3210,4 +3211,264 @@ X[] }); }); }); + + // Note that arrow keys test have a contentAfter that is not reflective of + // reality. The browser doesn't apply the selection change after triggering + // an event programmatically so what we are testing here is that if a custom + // behavior has to happen _before_ the browser's behavior, we do indeed have + // it. + describe('arrow keys', () => { + describe('ArrowRight', () => { + it('should move past a zws (collapsed)', async () => { + await testEditor(BasicEditor, { + contentBefore: 'ab[]<span>\u200B</span>cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowRight'); + }, + contentAfter: 'ab<span>\u200B[]</span>cd', + // Final state: 'ab<span>\u200B</span>c[]d' + }); + await testEditor(BasicEditor, { + contentBefore: 'ab<span>[]\u200B</span>cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowRight'); + }, + contentAfter: 'ab<span>\u200B[]</span>cd', + // Final state: 'ab<span>\u200B</span>c[]d' + }); + }); + it('should select a zws', async () => { + await testEditor(BasicEditor, { + contentBefore: '[ab]<span>\u200B</span>cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowRight', true); + }, + contentAfter: '[ab<span>\u200B]</span>cd', + // Final state: '[ab<span>\u200B</span>c]d' + }); + await testEditor(BasicEditor, { + contentBefore: '[ab<span>]\u200B</span>cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowRight', true); + }, + contentAfter: '[ab<span>\u200B]</span>cd', + // Final state: '[ab<span>\u200B</span>c]d' + }); + }); + it('should select a zws (2)', async () => { + await testEditor(BasicEditor, { + contentBefore: 'a[b]<span>\u200B</span>cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowRight', true); + }, + contentAfter: 'a[b<span>\u200B]</span>cd', + // Final state: 'a[b<span>\u200B</span>c]d' + }); + await testEditor(BasicEditor, { + contentBefore: 'a[b<span>]\u200B</span>cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowRight', true); + }, + contentAfter: 'a[b<span>\u200B]</span>cd', + // Final state: 'a[b<span>\u200B</span>c]d' + }); + }); + it('should select a zws (3)', async () => { + await testEditor(BasicEditor, { + contentBefore: 'ab[]<span>\u200B</span>cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowRight', true); + }, + contentAfter: 'ab[<span>\u200B]</span>cd', + // Final state: 'ab[<span>\u200B</span>c]d' + }); + await testEditor(BasicEditor, { + contentBefore: 'ab<span>[]\u200B</span>cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowRight', true); + }, + contentAfter: 'ab<span>[\u200B]</span>cd', + // Final state: 'ab<span>[\u200B</span>c]d' + }); + }); + it('should select a zws backwards', async () => { + await testEditor(BasicEditor, { + contentBefore: 'ab<span>]\u200B[</span>cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowRight', true); + }, + contentAfter: 'ab<span>\u200B[]</span>cd', + // Final state: 'ab<span>\u200B</span>[c]d' + }); + await testEditor(BasicEditor, { + contentBefore: 'ab<span>]\u200B</span>[cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowRight', true); + }, + contentAfter: 'ab<span>\u200B[]</span>cd', + // Final state: 'ab<span>\u200B</span>[c]d' + }); + await testEditor(BasicEditor, { + contentBefore: 'ab]<span>\u200B</span>[cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowRight', true); + }, + contentAfter: 'ab<span>\u200B[]</span>cd', + // Final state: 'ab<span>\u200B</span>[c]d' + }); + await testEditor(BasicEditor, { + contentBefore: 'ab]<span>\u200B[</span>cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowRight', true); + }, + contentAfter: 'ab<span>\u200B[]</span>cd', + // Final state: 'ab<span>\u200B</span>[c]d' + }); + }); + it('should select a zws backwards (2)', async () => { + await testEditor(BasicEditor, { + contentBefore: 'ab<span>]\u200B</span>c[d', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowRight', true); + }, + contentAfter: 'ab<span>\u200B]</span>c[d', + // Final state: 'ab<span>\u200B</span>c[]d' + }); + await testEditor(BasicEditor, { + contentBefore: 'ab]<span>\u200B</span>c[d', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowRight', true); + }, + contentAfter: 'ab<span>\u200B]</span>c[d', + // Final state: 'ab<span>\u200B</span>c[]d' + }); + }); + }); + describe('ArrowLeft', () => { + it('should move past a zws (collapsed)', async () => { + await testEditor(BasicEditor, { + contentBefore: 'ab<span>\u200B[]</span>cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowLeft'); + }, + contentAfter: 'ab<span>[]\u200B</span>cd', + // Final state: 'a[]b<span>\u200B</span>cd' + }); + await testEditor(BasicEditor, { + contentBefore: 'ab<span>\u200B</span>[]cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowLeft'); + }, + contentAfter: 'ab<span>[]\u200B</span>cd', + // Final state: 'a[]b<span>\u200B</span>cd' + }); + }); + it('should select a zws backwards', async () => { + await testEditor(BasicEditor, { + contentBefore: 'ab<span>\u200B[]</span>cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowLeft', true); + }, + contentAfter: 'ab<span>]\u200B[</span>cd', + // Final state: 'a]b<span>\u200B[</span>cd' + }); + await testEditor(BasicEditor, { + contentBefore: 'ab<span>\u200B</span>[]cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowLeft', true); + }, + contentAfter: 'ab<span>]\u200B[</span>cd', + // Final state: 'a]b<span>\u200B[</span>cd' + }); + }); + it('should select a zws backwards (2)', async () => { + await testEditor(BasicEditor, { + contentBefore: 'ab<span>\u200B</span>]cd[', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowLeft', true); + }, + contentAfter: 'ab<span>]\u200B</span>cd[', + // Final state: 'a]b<span>\u200B</span>cd[' + }); + await testEditor(BasicEditor, { + contentBefore: 'ab<span>\u200B]</span>cd[', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowLeft', true); + }, + contentAfter: 'ab<span>]\u200B</span>cd[', + // Final state: 'a]b<span>\u200B</span>cd[' + }); + }); + it('should select a zws backwards (3)', async () => { + await testEditor(BasicEditor, { + contentBefore: 'ab<span>\u200B</span>]c[d', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowLeft', true); + }, + contentAfter: 'ab<span>]\u200B</span>c[d', + // Final state: 'a]b<span>\u200B</span>c[d' + }); + await testEditor(BasicEditor, { + contentBefore: 'ab<span>\u200B]</span>c[d', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowLeft', true); + }, + contentAfter: 'ab<span>]\u200B</span>c[d', + // Final state: 'a]b<span>\u200B</span>c[d' + }); + }); + it('should deselect a zws', async () => { + await testEditor(BasicEditor, { + contentBefore: 'ab<span>[\u200B]</span>cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowLeft', true); + }, + contentAfter: 'ab<span>[]\u200B</span>cd', + // Final state: 'a]b<span>[\u200B</span>cd' + }); + await testEditor(BasicEditor, { + contentBefore: 'ab<span>[\u200B</span>]cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowLeft', true); + }, + contentAfter: 'ab<span>[]\u200B</span>cd', + // Final state: 'a]b<span>[\u200B</span>cd' + }); + await testEditor(BasicEditor, { + contentBefore: 'ab[<span>\u200B]</span>cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowLeft', true); + }, + contentAfter: 'ab[<span>]\u200B</span>cd', + // Final state: 'a]b[<span>\u200B</span>cd' + }); + await testEditor(BasicEditor, { + contentBefore: 'ab[<span>\u200B</span>]cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowLeft', true); + }, + contentAfter: 'ab[<span>]\u200B</span>cd', + // Final state: 'a]b[<span>\u200B</span>cd' + }); + }); + it('should deselect a zws (2)', async () => { + await testEditor(BasicEditor, { + contentBefore: 'a[b<span>\u200B]</span>cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowLeft', true); + }, + contentAfter: 'a[b<span>]\u200B</span>cd', + // Final state: 'a[]b<span>\u200B</span>cd' + }); + await testEditor(BasicEditor, { + contentBefore: 'a[b<span>\u200B</span>]cd', + stepFunction: async editor => { + await keydown(editor.editable, 'ArrowLeft', true); + }, + contentAfter: 'a[b<span>]\u200B</span>cd', + // Final state: 'a[]b<span>\u200B</span>cd' + }); + }); + }); + }); }); diff --git a/addons/web_editor/static/lib/odoo-editor/test/utils.js b/addons/web_editor/static/lib/odoo-editor/test/utils.js index 0606b6e37c94..f6119488c365 100644 --- a/addons/web_editor/static/lib/odoo-editor/test/utils.js +++ b/addons/web_editor/static/lib/odoo-editor/test/utils.js @@ -412,6 +412,12 @@ export async function click(el, options) { await nextTickFrame(); } +export async function keydown(editable, key, shiftKey) { + const ev = new KeyboardEvent('keydown', { key, shiftKey: !!shiftKey }); + editable.dispatchEvent(ev); + await nextTickFrame(); +} + export async function deleteForward(editor) { editor.execCommand('oDeleteForward'); } -- GitLab