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 e5386c1d2c3..e1d63b18427 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 8222b24987b..2f0d75a9135 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[]\u200Bcd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowRight');
+ },
+ contentAfter: 'ab\u200B[]cd',
+ // Final state: 'ab\u200Bc[]d'
+ });
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab[]\u200Bcd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowRight');
+ },
+ contentAfter: 'ab\u200B[]cd',
+ // Final state: 'ab\u200Bc[]d'
+ });
+ });
+ it('should select a zws', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '[ab]\u200Bcd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowRight', true);
+ },
+ contentAfter: '[ab\u200B]cd',
+ // Final state: '[ab\u200Bc]d'
+ });
+ await testEditor(BasicEditor, {
+ contentBefore: '[ab]\u200Bcd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowRight', true);
+ },
+ contentAfter: '[ab\u200B]cd',
+ // Final state: '[ab\u200Bc]d'
+ });
+ });
+ it('should select a zws (2)', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'a[b]\u200Bcd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowRight', true);
+ },
+ contentAfter: 'a[b\u200B]cd',
+ // Final state: 'a[b\u200Bc]d'
+ });
+ await testEditor(BasicEditor, {
+ contentBefore: 'a[b]\u200Bcd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowRight', true);
+ },
+ contentAfter: 'a[b\u200B]cd',
+ // Final state: 'a[b\u200Bc]d'
+ });
+ });
+ it('should select a zws (3)', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab[]\u200Bcd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowRight', true);
+ },
+ contentAfter: 'ab[\u200B]cd',
+ // Final state: 'ab[\u200Bc]d'
+ });
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab[]\u200Bcd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowRight', true);
+ },
+ contentAfter: 'ab[\u200B]cd',
+ // Final state: 'ab[\u200Bc]d'
+ });
+ });
+ it('should select a zws backwards', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab]\u200B[cd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowRight', true);
+ },
+ contentAfter: 'ab\u200B[]cd',
+ // Final state: 'ab\u200B[c]d'
+ });
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab]\u200B[cd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowRight', true);
+ },
+ contentAfter: 'ab\u200B[]cd',
+ // Final state: 'ab\u200B[c]d'
+ });
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab]\u200B[cd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowRight', true);
+ },
+ contentAfter: 'ab\u200B[]cd',
+ // Final state: 'ab\u200B[c]d'
+ });
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab]\u200B[cd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowRight', true);
+ },
+ contentAfter: 'ab\u200B[]cd',
+ // Final state: 'ab\u200B[c]d'
+ });
+ });
+ it('should select a zws backwards (2)', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab]\u200Bc[d',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowRight', true);
+ },
+ contentAfter: 'ab\u200B]c[d',
+ // Final state: 'ab\u200Bc[]d'
+ });
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab]\u200Bc[d',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowRight', true);
+ },
+ contentAfter: 'ab\u200B]c[d',
+ // Final state: 'ab\u200Bc[]d'
+ });
+ });
+ });
+ describe('ArrowLeft', () => {
+ it('should move past a zws (collapsed)', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab\u200B[]cd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowLeft');
+ },
+ contentAfter: 'ab[]\u200Bcd',
+ // Final state: 'a[]b\u200Bcd'
+ });
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab\u200B[]cd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowLeft');
+ },
+ contentAfter: 'ab[]\u200Bcd',
+ // Final state: 'a[]b\u200Bcd'
+ });
+ });
+ it('should select a zws backwards', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab\u200B[]cd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowLeft', true);
+ },
+ contentAfter: 'ab]\u200B[cd',
+ // Final state: 'a]b\u200B[cd'
+ });
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab\u200B[]cd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowLeft', true);
+ },
+ contentAfter: 'ab]\u200B[cd',
+ // Final state: 'a]b\u200B[cd'
+ });
+ });
+ it('should select a zws backwards (2)', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab\u200B]cd[',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowLeft', true);
+ },
+ contentAfter: 'ab]\u200Bcd[',
+ // Final state: 'a]b\u200Bcd['
+ });
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab\u200B]cd[',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowLeft', true);
+ },
+ contentAfter: 'ab]\u200Bcd[',
+ // Final state: 'a]b\u200Bcd['
+ });
+ });
+ it('should select a zws backwards (3)', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab\u200B]c[d',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowLeft', true);
+ },
+ contentAfter: 'ab]\u200Bc[d',
+ // Final state: 'a]b\u200Bc[d'
+ });
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab\u200B]c[d',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowLeft', true);
+ },
+ contentAfter: 'ab]\u200Bc[d',
+ // Final state: 'a]b\u200Bc[d'
+ });
+ });
+ it('should deselect a zws', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab[\u200B]cd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowLeft', true);
+ },
+ contentAfter: 'ab[]\u200Bcd',
+ // Final state: 'a]b[\u200Bcd'
+ });
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab[\u200B]cd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowLeft', true);
+ },
+ contentAfter: 'ab[]\u200Bcd',
+ // Final state: 'a]b[\u200Bcd'
+ });
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab[\u200B]cd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowLeft', true);
+ },
+ contentAfter: 'ab[]\u200Bcd',
+ // Final state: 'a]b[\u200Bcd'
+ });
+ await testEditor(BasicEditor, {
+ contentBefore: 'ab[\u200B]cd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowLeft', true);
+ },
+ contentAfter: 'ab[]\u200Bcd',
+ // Final state: 'a]b[\u200Bcd'
+ });
+ });
+ it('should deselect a zws (2)', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'a[b\u200B]cd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowLeft', true);
+ },
+ contentAfter: 'a[b]\u200Bcd',
+ // Final state: 'a[]b\u200Bcd'
+ });
+ await testEditor(BasicEditor, {
+ contentBefore: 'a[b\u200B]cd',
+ stepFunction: async editor => {
+ await keydown(editor.editable, 'ArrowLeft', true);
+ },
+ contentAfter: 'a[b]\u200Bcd',
+ // Final state: 'a[]b\u200Bcd'
+ });
+ });
+ });
+ });
});
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 0606b6e37c9..f6119488c36 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');
}