[FIX] web_editor: properly apply color in nested inline contexts

The colorpicker failed in cases like:
```xml
<font>a<span>b</span>c</font>
```
where we select the letter "b".
This was because `applyColor` only considered direct children of the
`font` element. This extends it to deal with further descendants of it.

task-2623347

closes odoo/odoo#83254

X-original-commit: 5d8d98812a4f351abd27389c4992bf9e753833b7
Signed-off-by: David Monjoie (dmo) <dmo@odoo.com>
This commit is contained in:
Antoine Guenet
2022-01-24 14:13:45 +00:00
parent c616f38f77
commit 93151fb73b
4 changed files with 142 additions and 5 deletions
@@ -1,6 +1,7 @@
/** @odoo-module **/
import {
ancestors,
descendants,
childNodeIndex,
closestBlock,
closestElement,
@@ -29,6 +30,7 @@ import {
setSelection,
setCursorStart,
setTagName,
splitAroundUntil,
splitElement,
splitTextNode,
startPos,
@@ -505,15 +507,15 @@ export const editorCommands = {
const selectedNodes = getSelectedNodes(editor.editable);
const fonts = selectedNodes.flatMap(node => {
let font = closestElement(node, 'font');
const children = font && [...font.childNodes];
const children = font && descendants(font);
if (font && font.nodeName === 'FONT') {
// Partially selected <font>: split it.
const selectedChildren = children.filter(child => selectedNodes.includes(child));
if (selectedChildren.length) {
const after = selectedChildren[selectedChildren.length - 1].nextSibling;
font = after ? splitElement(font, childNodeIndex(after))[0] : font;
const before = selectedChildren[0].previousSibling;
font = before ? splitElement(font, childNodeIndex(before) + 1)[1] : font;
const splitResult = splitAroundUntil(selectedChildren, font);
font = splitResult[0] || splitResult[1] || font;
} else {
font = [];
}
} else if (node.nodeType === Node.TEXT_NODE && isVisibleStr(node)) {
// Node is a visible text node: wrap it in a <font>.
@@ -261,6 +261,20 @@ export function ancestors(node, editable) {
return [node.parentElement, ...ancestors(node.parentElement, editable)];
}
/**
* Take a node, return all of its descendants, in depth-first order.
*
* @param {Node} node
* @returns {Node[]}
*/
export function descendants(node) {
const posterity = [];
for (const child of (node.childNodes || [])) {
posterity.push(child, ...descendants(child));
}
return posterity;
}
export function closestBlock(node) {
return findNode(closestPath(node), node => isBlock(node));
}
@@ -1320,6 +1334,44 @@ export function splitElement(element, offset) {
return [before, after];
}
/**
* Split around the given elements, until a given ancestor (included). Elements
* will be removed in the process so caution is advised in dealing with their
* references. Returns a tuple containing the new elements on both sides of the
* split.
*
* @see splitElement
* @param {Node[] | Node} elements
* @param {Node} limitAncestor
* @returns {[Node, Node]}
*/
export function splitAroundUntil(elements, limitAncestor) {
elements = Array.isArray(elements) ? elements : [elements];
let after = elements[elements.length - 1].nextSibling;
let newUntil = limitAncestor;
let beforeSplit, afterSplit;
// Split up ancestors up to font
while (after && after.parentElement !== limitAncestor) {
afterSplit = splitElement(after.parentElement, childNodeIndex(after))[0];
newUntil = afterSplit;
after = newUntil.nextSibling;
}
if (after) {
afterSplit = splitElement(limitAncestor, childNodeIndex(after))[0];
limitAncestor = afterSplit;
}
let before = elements[0].previousSibling;
while (before && before.parentElement !== limitAncestor) {
beforeSplit = splitElement(before.parentElement, childNodeIndex(before) + 1)[1];
newUntil = beforeSplit;
before = newUntil.previousSibling;
}
if (before) {
beforeSplit = splitElement(limitAncestor, childNodeIndex(before) + 1)[1];
}
return [beforeSplit, afterSplit];
}
export function insertText(sel, content) {
if (sel.anchorNode.nodeType == Node.TEXT_NODE) {
const pos = [sel.anchorNode.parentElement, splitTextNode(sel.anchorNode, sel.anchorOffset)];
@@ -3117,4 +3117,16 @@ X[]
});
});
});
describe('applyColor', () => {
it('should apply a color to a slice of text in a span in a font', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>a<font>b<span>c[def]g</span>h</font>i</p>',
stepFunction: editor => editor.execCommand('applyColor', 'rgb(255, 0, 0)', 'color'),
contentAfter: '<p>a<font>b<span>c</span></font>' +
'<font style="color: rgb(255, 0, 0);"><span>[def]</span></font>' +
'<font><span>g</span>h</font>i</p>',
});
});
});
});
@@ -5,6 +5,7 @@ import {
childNodeIndex,
closestBlock,
closestElement,
descendants,
endPos,
firstLeaf,
getAdjacentPreviousSiblings,
@@ -26,6 +27,8 @@ import {
setSelection,
setCursorStart,
setCursorEnd,
splitAroundUntil,
splitTextNode,
getCursorDirection,
DIRECTIONS,
isBlock,
@@ -351,6 +354,22 @@ describe('Utils', () => {
window.chai.expect(result).to.eql([editable]);
});
});
describe('descendants', () => {
it('should find all the descendants of a div in depth-first order', () => {
const [div] = insertTestHtml(
'<div><div><div><p>abc</p><div><p>def</p></div></div></div></div>',
);
window.chai.expect(descendants(div)).to.eql([
div.firstChild, // <div><div>...
div.firstChild.firstChild, // <div><div><div>...
div.firstChild.firstChild.firstChild, // <p>abc</p>
div.firstChild.firstChild.firstChild.firstChild, // "abc"
div.firstChild.firstChild.childNodes[1], // <div><p>def</p></div>
div.firstChild.firstChild.childNodes[1].firstChild, // <p>def</p>
div.firstChild.firstChild.childNodes[1].firstChild.firstChild, // "def"
]);
});
});
describe('closestBlock', () => {
it('should find the closest block of a deeply nested text node', () => {
const [div] = insertTestHtml(
@@ -1393,4 +1412,56 @@ describe('Utils', () => {
});
});
});
//--------------------------------------------------------------------------
// DOM Modification
//--------------------------------------------------------------------------
describe('splitAroundUntil', () => {
it('should split a slice of text from its inline ancestry', () => {
const [p] = insertTestHtml('<p>a<font>b<span>cde</span>f</font>g</p>');
const cde = p.childNodes[1].childNodes[1].firstChild;
// We want to test with "cde" being three separate text nodes.
splitTextNode(cde, 2);
const cd = cde.previousSibling;
splitTextNode(cd, 1);
const d = cd;
const result = splitAroundUntil(d, p.childNodes[1]);
window.chai.expect(result.every(node => node.tagName === 'FONT')).to.be.ok;
window.chai.expect(p.outerHTML).to.eql(
'<p>a<font>b<span>c</span></font><font><span>d</span></font><font><span>e</span>f</font>g</p>'
);
window.chai.expect(result[0]).to.eql(p.childNodes[2]);
// "before" split happened so "after" split is not in the DOM anymore:
window.chai.expect(result[1].parentElement).to.eql(null);
});
it('should split a slice of text from its inline ancestry', () => {
const [p] = insertTestHtml('<p>a<font>b<span>cdefg</span>h</font>i</p>');
const cdefg = p.childNodes[1].childNodes[1].firstChild;
// We want to test with "cdefg" being five separate text nodes.
splitTextNode(cdefg, 4);
const cdef = cdefg.previousSibling;
splitTextNode(cdef, 3);
const cde = cdef.previousSibling;
splitTextNode(cde, 2);
const cd = cde.previousSibling;
splitTextNode(cd, 1);
const d = cd;
const result = splitAroundUntil([d, d.nextSibling.nextSibling], p.childNodes[1]);
window.chai.expect(result.every(node => node.tagName === 'FONT')).to.be.ok;
window.chai.expect(p.outerHTML).to.eql(
'<p>a<font>b<span>c</span></font><font><span>def</span></font><font><span>g</span>h</font>i</p>'
);
window.chai.expect(result[0]).to.eql(p.childNodes[2]);
// "before" split happened so "after" split is not in the DOM anymore:
window.chai.expect(result[1].parentElement).to.eql(null);
});
it('should not do anything (nothing to split)', () => {
const [p] = insertTestHtml('<p>a<font>b<span>cde</span>f</font>g</p>');
const cde = p.childNodes[1].childNodes[1].firstChild;
const result = splitAroundUntil(cde, p.childNodes[1]);
window.chai.expect(result.every(node => node === undefined)).to.be.ok;
window.chai.expect(p.outerHTML).to.eql('<p>a<font>b<span>cde</span>f</font>g</p>');
});
});
});