[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
This commit is contained in:
Antoine Guenet
2022-03-22 22:12:54 +01:00
parent 6844ff5a3f
commit fa417893a4
3 changed files with 314 additions and 0 deletions
@@ -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);
}
}
}
/**
@@ -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'
});
});
});
});
});
@@ -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');
}