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