diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index d5c5ad7db4..8c98662396 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -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; diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.test.js b/frontend/text-editor/src/editor/controllers/SelectionController.test.js index 076db92f41..5d5b4a7c8c 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.test.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.test.js @@ -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([