[FIX] web_editor: loop non-editable in getNormalizedCursorPosition

There were two issues with the `getNormalizedCursorPosition`:

1. If the selection was in a non-editable element, the function called
`rightPos` only once, without ensuring that the new position was editable.
  -> The current implementation fixes that by making it loop while in a
  non-editable element, and also allows the normalized position to use `leftPos`
  if the offset of a position is 0.

2. When searching for the deepest leaf to the right, the `DOMPathGenerator` was
allowed to traverse non-editable elements, which is not correct since the result
should always be editable for a normalized position.
  -> This is fixed, and the name of the `DOMPathGenerator` utils is updated for
  clarity.

task-3280704

closes odoo/odoo#125204

X-original-commit: 3d44028a7722f61900b8669489ff929244b1a4b8
Related: odoo/enterprise#42615
Signed-off-by: David Beguin (dbe) <dbe@odoo.com>
Signed-off-by: Damien Abeloos (abd) <abd@odoo.com>
This commit is contained in:
abd-msyukyu-odoo
2023-06-16 09:34:55 +02:00
parent 9d2e33b3c6
commit 6958f719e8
2 changed files with 101 additions and 13 deletions
@@ -157,10 +157,10 @@ const PATH_END_REASONS = {
*
* @see leftLeafFirstPath
* @see leftLeafOnlyNotBlockPath
* @see leftLeafOnlyInScopeNotBlockNoEditablePath
* @see leftLeafOnlyInScopeNotBlockEditablePath
* @see rightLeafOnlyNotBlockPath
* @see rightLeafOnlyPathNotBlockNotEditablePath
* @see rightLeafOnlyInScopeNotBlockPath
* @see rightLeafOnlyInScopeNotBlockEditablePath
* @see rightLeafOnlyNotBlockNotEditablePath
*
* @param {number} direction
@@ -469,9 +469,17 @@ export function hasValidSelection(editable) {
* positions which are not possible, like the cursor inside an image).
*/
export function getNormalizedCursorPosition(node, offset, full = true) {
if (isSelfClosingElement(node) || !closestElement(node).isContentEditable) {
// Cannot put cursor inside those elements, put it after instead.
[node, offset] = rightPos(node);
const editable = closestElement(node, '.odoo-editor-editable');
let closest = closestElement(node);
while (
closest &&
closest !== editable &&
(isSelfClosingElement(node) || !closest.isContentEditable)
) {
// Cannot put the cursor inside those elements, put it before if the
// offset is 0 and the node is not empty, else after instead.
[node, offset] = offset || !nodeSize(node) ? rightPos(node) : leftPos(node);
closest = closestElement(node);
}
// Be permissive about the received offset.
@@ -495,8 +503,7 @@ export function getNormalizedCursorPosition(node, offset, full = true) {
}
}
if (el) {
const leftInlineNode = leftLeafOnlyInScopeNotBlockNoEditablePath(el, elOffset).next()
.value;
const leftInlineNode = leftLeafOnlyInScopeNotBlockEditablePath(el, elOffset).next().value;
let leftVisibleEmpty = false;
if (leftInlineNode) {
leftVisibleEmpty =
@@ -507,7 +514,7 @@ export function getNormalizedCursorPosition(node, offset, full = true) {
: endPos(leftInlineNode);
}
if (!leftInlineNode || leftVisibleEmpty) {
const rightInlineNode = rightLeafOnlyInScopeNotBlockPath(el, elOffset).next().value;
const rightInlineNode = rightLeafOnlyInScopeNotBlockEditablePath(el, elOffset).next().value;
if (rightInlineNode) {
const closest = closestElement(rightInlineNode);
const rightVisibleEmpty =
@@ -2530,7 +2537,7 @@ export const leftLeafOnlyNotBlockPath = createDOMPathGenerator(DIRECTIONS.LEFT,
stopTraverseFunction: isBlock,
stopFunction: isBlock,
});
export const leftLeafOnlyInScopeNotBlockNoEditablePath = createDOMPathGenerator(DIRECTIONS.LEFT, {
export const leftLeafOnlyInScopeNotBlockEditablePath = createDOMPathGenerator(DIRECTIONS.LEFT, {
leafOnly: true,
inScope: true,
stopTraverseFunction: node => isNotEditableNode(node) || isBlock(node),
@@ -2546,11 +2553,11 @@ export const rightLeafOnlyNotBlockPath = createDOMPathGenerator(DIRECTIONS.RIGHT
export const rightLeafOnlyPathNotBlockNotEditablePath = createDOMPathGenerator(DIRECTIONS.RIGHT, {
leafOnly: true,
});
export const rightLeafOnlyInScopeNotBlockPath = createDOMPathGenerator(DIRECTIONS.RIGHT, {
export const rightLeafOnlyInScopeNotBlockEditablePath = createDOMPathGenerator(DIRECTIONS.RIGHT, {
leafOnly: true,
inScope: true,
stopTraverseFunction: isBlock,
stopFunction: isBlock,
stopTraverseFunction: node => isNotEditableNode(node) || isBlock(node),
stopFunction: node => isNotEditableNode(node) || isBlock(node),
});
export const rightLeafOnlyNotBlockNotEditablePath = createDOMPathGenerator(DIRECTIONS.RIGHT, {
leafOnly: true,
@@ -824,12 +824,93 @@ describe('Utils', () => {
});
});
describe('getNormalizedCursorPosition', () => {
// TODO: test more.
it('should move the cursor from after a <b> to within it', () => {
const [p] = insertTestHtml('<p><b>abc</b>def</p>');
const result = getNormalizedCursorPosition(p.lastChild, 0);
window.chai.expect(result).to.eql([p.firstChild.firstChild, 3]);
});
it('should move the cursor before a non-editable element with offset === 0', () => {
const [p] = insertTestHtml('<p><span contenteditable="false">leavemealone</span></p>');
const result = getNormalizedCursorPosition(p.lastChild, 0);
window.chai.expect(result).to.eql([p, 0]);
});
it('should move the cursor after a non-editable element with offset > 0', () => {
const [p] = insertTestHtml('<p><span contenteditable="false">leavemealone</span></p>');
const result = getNormalizedCursorPosition(p.lastChild, 1);
window.chai.expect(result).to.eql([p, 1]);
});
it('should move the cursor after a "visibleEmpty" element', () => {
const [p] = insertTestHtml('<p>ab<br>cd</p>');
const result = getNormalizedCursorPosition(p.lastElementChild, 0);
window.chai.expect(result).to.eql([p.lastChild, 0]);
});
it('should move the cursor before a "fake line break element"', () => {
const [p] = insertTestHtml('<p><br></p>');
const result = getNormalizedCursorPosition(p.lastElementChild, 0);
window.chai.expect(result).to.eql([p, 0]);
});
it('should loop outside (left) a non-editable context and then find the deepest editable leaf position', () => {
const [p] = insertTestHtml(unformat(`
<p>
<a class="end">text</a>
<span contenteditable="false">
<b class="start">
text
</b>
</span>
</p>
`));
const start = p.querySelector(".start");
const end = p.querySelector(".end");
const result = getNormalizedCursorPosition(start.lastChild, 0);
window.chai.expect(result).to.eql([end.firstChild, 4]);
});
it('should loop outside (right) a non-editable context and then find the deepest editable leaf position', () => {
const [p] = insertTestHtml(unformat(`
<p>
<span contenteditable="false">
<b class="start">
text
</b>
</span>
<a class="end">text</a>
</p>
`));
const start = p.querySelector(".start");
const end = p.querySelector(".end");
const result = getNormalizedCursorPosition(start.lastChild, 1);
window.chai.expect(result).to.eql([end.lastChild, 0]);
});
it('should loop outside (left) a non-editable context and not traverse a non-editable leaf position', () => {
const [p] = insertTestHtml(unformat(`
<p>
<a contenteditable="false">leavemealone</a>
<span contenteditable="false">
<b class="start">
text
</b>
</span>
</p>
`));
const start = p.querySelector(".start");
const result = getNormalizedCursorPosition(start.lastChild, 0);
window.chai.expect(result).to.eql([p, 1]);
});
it('should loop outside (right) a non-editable context and not traverse a non-editable leaf position', () => {
const [p] = insertTestHtml(unformat(`
<p>
<span contenteditable="false">
<b class="start">
text
</b>
</span>
<a contenteditable="false">leavemealone</a>
</p>
`));
const start = p.querySelector(".start");
const result = getNormalizedCursorPosition(start.lastChild, 1);
window.chai.expect(result).to.eql([p, 1]);
});
});
describe('setCursor', () => {
describe('collapsed', () => {