[FIX] web_editor: fix selection on editable root

Some of the Editor methods relies on not having inline elements at
the editable root. For this reason, the cursor should never be placed
at the editable root, but always inside a block element.

Before this commit, in some situations (notably around tables and
horizontal separators), the cursor could be placed having its anchorNode
at the editable root, allowing the user to insert inlined text at it.

This commit fixes the selection range in such cases, and an empty paragraph
is inserted in cases where there would be no other way to insert text before
or after an existing block.

task-3128747
opw-3106752

Part-of: odoo/odoo#119556
This commit is contained in:
Rodolpho Lima
2023-05-15 15:07:19 +02:00
parent ab0b2440fc
commit 0c9980f1dc
2 changed files with 78 additions and 2 deletions
@@ -73,8 +73,11 @@ import {
isArtificialVoidElement,
cleanZWS,
isZWS,
setCursorEnd,
paragraphRelatedElements,
getDeepestPosition,
leftPos,
isNotAllowedContent,
} from './utils/utils.js';
import { editorCommands } from './commands/commands.js';
import { Powerbox } from './powerbox/Powerbox.js';
@@ -3494,6 +3497,7 @@ export class OdooEditor extends EventTarget {
_onKeyDown(ev) {
this.keyboardType =
ev.key === 'Unidentified' ? KEYBOARD_TYPES.VIRTUAL : KEYBOARD_TYPES.PHYSICAL;
this._currentKeyPress = ev.key;
// If the pressed key has a printed representation, the returned value
// is a non-empty Unicode character string containing the printable
// representation of the key. In this case, call `deleteRange` before
@@ -3707,6 +3711,8 @@ export class OdooEditor extends EventTarget {
* @private
*/
_onSelectionChange() {
const currentKeyPress = this._currentKeyPress;
delete this._currentKeyPress;
const selection = this.document.getSelection();
if (!selection) {
// Because the `selectionchange` event is async, the selection can
@@ -3718,7 +3724,16 @@ export class OdooEditor extends EventTarget {
if (isProtected(anchorNode)) {
return;
}
// Correct cursor if at editable root.
if (
selection.isCollapsed &&
anchorNode === this.editable &&
!this.options.allowInlineAtRoot
) {
this._fixSelectionOnEditableRoot(selection, currentKeyPress);
// The _onSelectionChange handler is going to be triggered again.
return;
}
let appliedCustomSelection = false;
if (selection.rangeCount && selection.getRangeAt(0)) {
appliedCustomSelection = this._handleSelectionInTable();
@@ -4124,6 +4139,67 @@ export class OdooEditor extends EventTarget {
}
}
/**
* Places the cursor in a safe place (not the editable root).
* Inserts an empty paragraph if selection results from mouse click and
* there's no other way to insert text before/after a block.
*
* @param {Selection} selection - Collapsed selection at the editable root.
* @param {String} currentKeyPress
*/
_fixSelectionOnEditableRoot(selection, currentKeyPress) {
let nodeAfterCursor = this.editable.childNodes[selection.anchorOffset];
let nodeBeforeCursor = nodeAfterCursor && nodeAfterCursor.previousElementSibling;
// Handle arrow key presses.
if (currentKeyPress === 'ArrowRight' || currentKeyPress === 'ArrowDown') {
while (nodeAfterCursor && isNotAllowedContent(nodeAfterCursor)) {
nodeAfterCursor = nodeAfterCursor.nextElementSibling;
}
if (nodeAfterCursor) {
setSelection(...getDeepestPosition(nodeAfterCursor, 0));
} else {
this.historyResetLatestComputedSelection(true);
}
} else if (currentKeyPress === 'ArrowLeft' || currentKeyPress === 'ArrowUp') {
while (nodeBeforeCursor && isNotAllowedContent(nodeBeforeCursor)) {
nodeBeforeCursor = nodeBeforeCursor.previousElementSibling;
}
if (nodeBeforeCursor) {
setSelection(...getDeepestPosition(nodeBeforeCursor, nodeSize(nodeBeforeCursor)));
} else {
this.historyResetLatestComputedSelection(true);
}
// Handle cursor next to a 'P'.
} else if (nodeAfterCursor && paragraphRelatedElements.includes(nodeAfterCursor.nodeName)) {
// Cursor is right before a 'P'.
setCursorStart(nodeAfterCursor);
} else if (nodeBeforeCursor && paragraphRelatedElements.includes(nodeBeforeCursor.nodeName)) {
// Cursor is right after a 'P'.
setCursorEnd(nodeBeforeCursor);
// Handle cursor not next to a 'P'.
// Insert a new 'P' if selection resulted from a mouse click.
} else if (this._currentMouseState === 'mousedown') {
this._recordHistorySelection(true);
const p = this.document.createElement('p');
p.append(this.document.createElement('br'));
if (!nodeAfterCursor) {
// Cursor is at the end of the editable.
this.editable.append(p);
} else if (!nodeBeforeCursor) {
// Cursor is at the beginning of the editable.
this.editable.prepend(p);
} else {
// Cursor is between two non-p blocks
nodeAfterCursor.before(p);
}
setCursorStart(p);
this.historyStep();
} else {
// Remove selection as a fallback.
selection.removeAllRanges();
}
}
_onMouseup(ev) {
this._currentMouseState = ev.type;
@@ -1429,7 +1429,7 @@ export function getColumnIndex(td) {
// This is a list of "paragraph-related elements", defined as elements that
// behave like paragraphs.
const paragraphRelatedElements = [
export const paragraphRelatedElements = [
'P',
'H1',
'H2',