🐛 Fix spurious mixed text styles on multi-span selection

This commit is contained in:
Alejandro Alonso 2026-03-30 11:47:27 +02:00
parent 36c23faae0
commit 62cc555084
2 changed files with 70 additions and 11 deletions

View File

@ -278,19 +278,30 @@ export class SelectionController extends EventTarget {
// FIXME: I don't like this approximation. Having to iterate nodes twice
// is bad for performance. I think we need another way of "computing"
// the cascade.
for (const textNode of this.#textNodeIterator.iterateFrom(
startNode,
endNode,
)) {
const textNodesInRange = [
...this.#textNodeIterator.iterateFrom(startNode, endNode),
];
for (const textNode of textNodesInRange) {
const paragraph = textNode.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(paragraph);
}
for (const textNode of this.#textNodeIterator.iterateFrom(
startNode,
endNode,
)) {
const textSpan = textNode.parentElement;
this.#mergeStylesFromElementToCurrentStyle(textSpan);
// Empty trailing text runs (length 0) often carry paragraph fallback styles
// (e.g. font-size 0) and are not user-visible; merging them with real text
// yields false "mixed" in the sidebar. Skip empty nodes when the selection
// also includes non-empty text; if everything is empty, keep prior behavior.
const nonEmptyTextNodes = textNodesInRange.filter(
(textNode) => textNode.length > 0,
);
const spanMergeNodes =
nonEmptyTextNodes.length > 0 ? nonEmptyTextNodes : textNodesInRange;
if (spanMergeNodes.length > 0) {
const firstTextSpan = spanMergeNodes[0].parentElement;
this.#applyStylesFromElementToCurrentStyle(firstTextSpan);
for (let i = 1; i < spanMergeNodes.length; i++) {
const textSpan = spanMergeNodes[i].parentElement;
this.#mergeStylesFromElementToCurrentStyle(textSpan);
}
}
}
return this;

View File

@ -4,7 +4,7 @@ import {
createParagraph,
createParagraphWith,
} from "../content/dom/Paragraph.js";
import { createTextSpan } from "../content/dom/TextSpan.js";
import { createTextSpan, createVoidTextSpan } from "../content/dom/TextSpan.js";
import { createLineBreak } from "../content/dom/LineBreak.js";
import { TextEditorMock } from "../../test/TextEditorMock.js";
import { SelectionController } from "./SelectionController.js";
@ -1683,6 +1683,54 @@ describe("SelectionController", () => {
expect(selectionController.focusAtEnd).toBeTruthy();
})
test("`currentStyle` ignores empty text nodes when merging span styles (no false mixed font-size)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([
createTextSpan(new Text("Hello"), { "font-size": "50" }),
createVoidTextSpan({ "font-size": "0" }),
]),
]);
const root = textEditorMock.root;
const paragraph = root.firstChild;
const firstTextNode = paragraph.firstChild.firstChild;
const lastTextNode = paragraph.firstChild.nextSibling.firstChild;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
textEditorMock.element.focus();
selection.setBaseAndExtent(firstTextNode, 0, lastTextNode, 0);
document.dispatchEvent(new Event("selectionchange"));
expect(selectionController.currentStyle.getPropertyValue("font-size")).toBe(
"50px",
);
});
test("`currentStyle` stays mixed when two non-empty spans have different font sizes", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([
createTextSpan(new Text("Hello"), { "font-size": "50" }),
createTextSpan(new Text("World"), { "font-size": "36" }),
]),
]);
const root = textEditorMock.root;
const paragraph = root.firstChild;
const firstTextNode = paragraph.firstChild.firstChild;
const lastTextNode = paragraph.firstChild.nextSibling.firstChild;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
textEditorMock.element.focus();
selection.setBaseAndExtent(firstTextNode, 0, lastTextNode, 5);
document.dispatchEvent(new Event("selectionchange"));
expect(selectionController.currentStyle.getPropertyValue("font-size")).toBe(
"mixed",
);
});
test("`currentStyle` uses text span font-size when anchor is paragraph (Firefox-style word selection)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([