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'); }