[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:
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user