From 4e34dfae1feb20ec45cb43de09ba723a7e0311d2 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 9 Aug 2026 03:14:43 -0400 Subject: [PATCH] feat(studio): apply a style to a run of characters Styling text in a composition cannot be done by wrapping a DOM range in a span. That is three lines, and then every interesting case is a special case: recolouring nests spans that shadow each other, removing a style cannot reach the ancestor that set it, and styling across an existing run's boundary has to split it. Each fix is a new branch and the branches interact. So the element is read into a flat list of styled runs, the style is applied to a span of characters in that list, and the element is rebuilt from it. Replacing, removing, splitting and merging stop being cases: the rebuild emits one span per distinct run and cannot nest or duplicate, whatever was there before. Selection offsets count UTF-16 units, so a boundary can land between the halves of an emoji; the applied range widens to whole characters. A colour an ancestor overpaints is mirrored into the fill, because a colour that does not paint reads to the user as a colour that did not save. The toolbar that drives this arrives with the editor in the next change. --- .../editor/inlineTextStyleRange.test.ts | 557 ++++++++++++++++++ .../components/editor/inlineTextStyleRange.ts | 552 +++++++++++++++++ 2 files changed, 1109 insertions(+) create mode 100644 packages/studio/src/components/editor/inlineTextStyleRange.test.ts create mode 100644 packages/studio/src/components/editor/inlineTextStyleRange.ts diff --git a/packages/studio/src/components/editor/inlineTextStyleRange.test.ts b/packages/studio/src/components/editor/inlineTextStyleRange.test.ts new file mode 100644 index 0000000000..b377fbccd1 --- /dev/null +++ b/packages/studio/src/components/editor/inlineTextStyleRange.test.ts @@ -0,0 +1,557 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { applyInlineStyle, readInlineStyle } from "./inlineTextStyleRange"; + +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; +}); + +function mount(html: string): HTMLElement { + document.body.innerHTML = `

${html}

`; + return document.body.firstElementChild as HTMLElement; +} + +/** A range over the host's text, by character offsets across the whole element. */ +/** Every text node and line break in order, with the offset each one starts at. */ +function charSpans(host: HTMLElement): Array<{ node: Node; from: number; length: number }> { + const spans: Array<{ node: Node; from: number; length: number }> = []; + const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT); + let seen = 0; + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const isBreak = node.nodeType === 1 && (node as Element).tagName === "BR"; + if (node.nodeType === 1 && !isBreak) continue; + const length = isBreak ? 1 : (node.textContent?.length ?? 0); + spans.push({ node, from: seen, length }); + seen += length; + } + return spans; +} + +/** + * A range over the host's text, by character offsets across the whole element. + * + * A line break counts as one character, the same way the module does, but is + * never landed on: a boundary there belongs to the text beside it, which is + * where a real selection would put it too. + */ +function rangeOver(host: HTMLElement, start: number, end: number): Range { + const range = document.createRange(); + const text = charSpans(host).filter((span) => span.node.nodeType === 3); + const at = (offset: number) => text.find((span) => span.from + span.length >= offset); + const from = at(start); + const to = at(end); + if (from) range.setStart(from.node, start - from.from); + if (from && to) range.setEnd(to.node, end - to.from); + return range; +} + +/** Elements left holding half a character by a boundary that fell inside one. */ +function elementsWithHalfACharacter(host: HTMLElement): string[] { + return Array.from(host.querySelectorAll("*")) + .map((node) => node.textContent ?? "") + .filter((text) => { + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + if (code >= 0xdc00 && code <= 0xdfff) return true; + if (code >= 0xd800 && code <= 0xdbff) { + const next = text.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return true; + index += 1; + } + } + return false; + }); +} + +describe("applyInlineStyle", () => { + it("styles exactly the characters selected, and nothing else", () => { + const host = mount("hello world"); + + applyInlineStyle(rangeOver(host, 6, 11), { color: "red" }); + + expect(host.innerHTML).toBe('hello world'); + }); + + it("styles a run in the middle, leaving the text either side alone", () => { + const host = mount("abcdef"); + + applyInlineStyle(rangeOver(host, 2, 4), { color: "red" }); + + expect(host.innerHTML).toBe('abcdef'); + expect(host.textContent).toBe("abcdef"); + }); + + it("does nothing at all when nothing is selected", () => { + const host = mount("abc"); + const range = rangeOver(host, 1, 1); + + applyInlineStyle(range, { color: "red" }); + + expect(host.innerHTML).toBe("abc"); + }); + + // Left alone, every recolour would wrap the last one and the markup would + // grow without bound while only the innermost span had any effect. + it("replaces a colour rather than nesting a second span inside the first", () => { + const host = mount("abc"); + + applyInlineStyle(rangeOver(host, 0, 3), { color: "red" }); + applyInlineStyle(rangeOver(host, 0, 3), { color: "blue" }); + + expect(host.innerHTML).toBe('abc'); + }); + + it("merges with the run beside it when the styling matches", () => { + const host = mount("abcd"); + + applyInlineStyle(rangeOver(host, 0, 2), { color: "red" }); + applyInlineStyle(rangeOver(host, 2, 4), { color: "red" }); + + expect(host.innerHTML).toBe('abcd'); + }); + + it("does not merge runs that only look alike", () => { + const host = mount("abcd"); + + applyInlineStyle(rangeOver(host, 0, 2), { color: "red" }); + applyInlineStyle(rangeOver(host, 2, 4), { color: "blue" }); + + expect(host.innerHTML).toBe( + 'abcd', + ); + }); + + it("leaves no empty span behind when the last style is taken off", () => { + const host = mount("abc"); + + applyInlineStyle(rangeOver(host, 0, 3), { color: "red" }); + applyInlineStyle(rangeOver(host, 0, 3), { color: null }); + + expect(host.innerHTML).toBe("abc"); + }); + + it("keeps a property the new styling does not mention", () => { + const host = mount("abc"); + + applyInlineStyle(rangeOver(host, 0, 3), { color: "red" }); + applyInlineStyle(rangeOver(host, 0, 3), { "font-weight": "700" }); + + expect(host.innerHTML).toContain("color: red"); + expect(host.innerHTML).toContain("font-weight: 700"); + expect(host.querySelectorAll("span")).toHaveLength(1); + }); + + it("styles across the boundary of a run that is already styled", () => { + const host = mount("abcdef"); + applyInlineStyle(rangeOver(host, 0, 3), { color: "red" }); + + applyInlineStyle(rangeOver(host, 1, 5), { "font-weight": "700" }); + + expect(host.textContent).toBe("abcdef"); + expect(host.innerHTML).toContain("font-weight: 700"); + }); + + it("leaves the selection over the characters it just styled", () => { + const host = mount("hello world"); + + applyInlineStyle(rangeOver(host, 0, 5), { color: "red" }); + + expect(document.getSelection()?.toString()).toBe("hello"); + }); + + it("styles more than one property at once", () => { + const host = mount("abc"); + + applyInlineStyle(rangeOver(host, 0, 3), { color: "red", "font-style": "italic" }); + + expect(host.innerHTML).toContain("color: red"); + expect(host.innerHTML).toContain("font-style: italic"); + }); +}); + +// The rebuild is what keeps the markup from growing: whatever shape the +// element was in going in, it comes out as one span per distinct run. +describe("applyInlineStyle rebuilds rather than wraps", () => { + it("flattens markup that was already nested", () => { + const host = mount('abc'); + + applyInlineStyle(rangeOver(host, 0, 3), { "font-style": "italic" }); + + expect(host.querySelectorAll("span")).toHaveLength(1); + expect(host.textContent).toBe("abc"); + }); + + it("leaves no span carrying nothing", () => { + const host = mount('ab'); + + applyInlineStyle(rangeOver(host, 0, 2), { "font-style": "italic" }); + + expect(host.querySelectorAll("span")).toHaveLength(1); + expect(host.textContent).toBe("ab"); + }); + + it("reads a bold tag as styling and writes it back as one span", () => { + const host = mount("abc"); + + applyInlineStyle(rangeOver(host, 0, 3), { color: "red" }); + + expect(host.querySelector("span")?.getAttribute("style")).toContain("font-weight: 700"); + expect(host.querySelector("span")?.getAttribute("style")).toContain("color: red"); + }); + + it("keeps line breaks where they were", () => { + const host = mount("ab
cd"); + + applyInlineStyle(rangeOver(host, 0, 2), { color: "red" }); + + expect(host.querySelectorAll("br")).toHaveLength(1); + expect(host.innerHTML).toBe('ab
cd'); + }); + + // The bug: a chip is `display: flex`, so each span became its own flex item. + // Colouring one word broke the centring and rewrapped the whole line. + it("keeps a flex container's text as one item, so colouring a word cannot reflow it", () => { + const host = mount("Hello this is a test to see how this work"); + host.style.display = "flex"; + + applyInlineStyle(rangeOver(host, 28, 31), { color: "red" }); + + expect(host.children).toHaveLength(1); + expect(host.firstElementChild?.tagName).toBe("SPAN"); + expect(host.firstElementChild?.getAttribute("style")).toBeNull(); + expect(host.querySelector("span span")?.textContent).toBe("how"); + expect(host.textContent).toBe("Hello this is a test to see how this work"); + }); + + it("does the same for a grid container", () => { + const host = mount("abcdef"); + host.style.display = "grid"; + + applyInlineStyle(rangeOver(host, 2, 4), { color: "red" }); + + expect(host.children).toHaveLength(1); + }); + + it("does not wrap an ordinary block, which flows its text already", () => { + const host = mount("abcdef"); + + applyInlineStyle(rangeOver(host, 2, 4), { color: "red" }); + + expect(host.innerHTML).toBe('abcdef'); + }); + + it("reads styling back out of the wrapper it added", () => { + const host = mount("abcdef"); + host.style.display = "flex"; + applyInlineStyle(rangeOver(host, 2, 4), { color: "red" }); + + applyInlineStyle(rangeOver(host, 2, 4), { color: "blue" }); + + expect(host.querySelectorAll("span span")).toHaveLength(1); + expect(host.querySelector("span span")?.getAttribute("style")).toBe("color: blue"); + expect(host.textContent).toBe("abcdef"); + }); + + it("styles a run that sits after a line break", () => { + const host = mount("ab
cd"); + + applyInlineStyle(rangeOver(host, 3, 5), { color: "red" }); + + expect(host.innerHTML).toBe('ab
cd'); + }); +}); + +describe("readInlineStyle", () => { + it("reports the styling of a run that is styled the same throughout", () => { + const host = mount('abc'); + + const styles = readInlineStyle(rangeOver(host, 0, 3), ["color"]); + + expect(styles.color).toBe("rgb(255, 0, 0)"); + }); + + it("reports nothing for a property that is not set anywhere", () => { + const host = mount("abc"); + + const styles = readInlineStyle(rangeOver(host, 0, 3), ["background-color"]); + + expect(styles["background-color"]).toBeUndefined(); + }); +}); + +// Edge cases found by asking what a real composition contains that the happy +// path does not: source formatting, containers that box their children, text +// that is not plain ASCII, and a selection that reaches outside the element. +describe("applyInlineStyle edge cases", () => { + it("keeps a newline that came from the source file as text, not a line break", () => { + // Compositions are written across lines. Turning that whitespace into
+ // would add visible breaks to an element that had none. + const host = mount("\n Hello world\n "); + + applyInlineStyle(rangeOver(host, 7, 12), { color: "red" }); + + expect(host.querySelectorAll("br")).toHaveLength(0); + expect(host.textContent).toBe("\n Hello world\n "); + }); + + it("still writes a real line break back as a line break", () => { + const host = mount("ab
cd"); + + applyInlineStyle(rangeOver(host, 3, 5), { color: "red" }); + + expect(host.querySelectorAll("br")).toHaveLength(1); + expect(host.textContent).toBe("abcd"); + }); + + it("keeps text that looks like markup as text", () => { + const host = mount("a <b> & c"); + + applyInlineStyle(rangeOver(host, 0, 1), { color: "red" }); + + expect(host.textContent).toBe("a & c"); + expect(host.querySelectorAll("b")).toHaveLength(0); + }); + + it("does not cut an emoji in half when the boundary lands inside one", () => { + // A selection offset is counted in UTF-16 units, and an emoji is two of + // them. Splitting one leaves half a character in each span. + const host = mount("ab👍cd"); + + applyInlineStyle(rangeOver(host, 0, 3), { color: "red" }); + + expect(host.textContent).toBe("ab👍cd"); + // textContent would read back whole even if the two halves sat in separate + // spans, so the check that matters is that no element holds half a one. + expect(elementsWithHalfACharacter(host)).toEqual([]); + }); + + it("keeps a whole emoji together when the selection starts inside one", () => { + const host = mount("ab👍cd"); + + applyInlineStyle(rangeOver(host, 3, 6), { color: "red" }); + + expect(host.textContent).toBe("ab👍cd"); + expect(elementsWithHalfACharacter(host)).toEqual([]); + }); + + it("leaves the element alone when the selection reaches outside it", () => { + // Rebuilding on a range whose common ancestor is an ancestor of the element + // would rewrite far more of the document than the user selected. + document.body.innerHTML = '

first

second

'; + const section = document.body.firstElementChild as HTMLElement; + const before = section.innerHTML; + const range = document.createRange(); + range.setStart(section.querySelector("#a")!.firstChild!, 1); + range.setEnd(section.querySelector("#b")!.firstChild!, 2); + + applyInlineStyle(range, { color: "red" }); + + expect(section.innerHTML).toBe(before); + }); + + it("keeps a line-clamped element's text as one item", () => { + // -webkit-box is how line clamping is written, and it boxes its children + // exactly like flex does. + const host = mount("abcdef"); + host.style.display = "-webkit-box"; + + applyInlineStyle(rangeOver(host, 2, 4), { color: "red" }); + + expect(host.children).toHaveLength(1); + }); + + it("styles the whole text when everything is selected", () => { + const host = mount("abcdef"); + + applyInlineStyle(rangeOver(host, 0, 6), { color: "red" }); + + expect(host.innerHTML).toBe('abcdef'); + }); + + it("styles right up to an existing run's edge without merging into it", () => { + const host = mount("abcd"); + applyInlineStyle(rangeOver(host, 0, 2), { color: "red" }); + + applyInlineStyle(rangeOver(host, 2, 4), { "font-style": "italic" }); + + expect(host.querySelectorAll("span")).toHaveLength(2); + expect(host.textContent).toBe("abcd"); + }); + + it("survives being asked to style the same run twice over", () => { + const host = mount("abcdef"); + for (let round = 0; round < 5; round += 1) { + applyInlineStyle(rangeOver(host, 1, 4), { color: "red" }); + } + + expect(host.querySelectorAll("span")).toHaveLength(1); + expect(host.textContent).toBe("abcdef"); + }); +}); + +/** + * An element's children are not always anonymous formatting. The design panel + * keeps them as text layers and tracks each by an attribute on it, so a rebuild + * that emits fresh bare spans throws that identity away — and after colouring a + * single word the panel could no longer match a layer to its source, so every + * edit it offered failed with "Couldn't save this text structure change". + */ +describe("applyInlineStyle keeps what the design panel tracks", () => { + it("keeps a layer's key when the styling changes", () => { + const host = mount( + 'Hello' + + 'world', + ); + applyInlineStyle(rangeOver(host, 0, 5), { color: "green" }); + + expect(host.innerHTML).toContain('data-hf-text-key="child:0"'); + expect(host.innerHTML).toContain('data-hf-text-key="child:1"'); + expect(host.innerHTML).toContain("color: green"); + }); + + it("keeps a layer's typography, which the edit never mentioned", () => { + const host = mount( + 'Hello', + ); + applyInlineStyle(rangeOver(host, 0, 5), { color: "green" }); + + expect(host.innerHTML).toContain("font-size: 48px"); + }); + + it("does not put one layer's key on two spans when its text is split", () => { + const host = mount('Hello'); + applyInlineStyle(rangeOver(host, 0, 2), { color: "green" }); + + expect(host.innerHTML.match(/data-hf-text-key="child:0"/g) ?? []).toHaveLength(1); + }); + + it("still merges neighbours that carry no identity to lose", () => { + const host = mount('abcd'); + applyInlineStyle(rangeOver(host, 2, 4), { color: "red" }); + + expect(host.innerHTML).toBe('abcd'); + }); + + // The wrapper the rebuild adds inside a flex container carries nothing. Read + // as the layer it sits around, it hid the real one below it, and the second + // edit of an element threw away every identity the first one had kept. + it("keeps a layer's identity through a second edit inside a flex container", () => { + document.body.innerHTML = + '
' + + 'one
two
'; + const host = document.body.firstElementChild as HTMLElement; + applyInlineStyle(rangeOver(host, 4, 6), { color: "red" }); + const live = document.getSelection()?.getRangeAt(0); + if (live) applyInlineStyle(live, { color: "blue" }); + + expect(host.innerHTML).toContain('data-hf-text-key="child:0"'); + expect(host.innerHTML).toContain("color: blue"); + expect(host.innerHTML).not.toContain("color: red"); + }); + + // Stamped by the writer on every element on the way to disk, so carrying it + // preserves nothing — and it made the wrapper this rebuild adds look like a + // layer as soon as the file had been saved once. + it("does not treat the writer's own id as a layer identity", () => { + document.body.innerHTML = + '
' + + 'one
two
'; + const host = document.body.firstElementChild as HTMLElement; + applyInlineStyle(rangeOver(host, 4, 6), { color: "red" }); + + expect(host.innerHTML).toContain('data-hf-text-key="child:0"'); + expect(host.innerHTML).not.toContain("hf-wrap"); + }); + + it("does not merge two tracked layers that end up looking alike", () => { + const host = mount( + 'ab' + + 'cd', + ); + applyInlineStyle(rangeOver(host, 2, 4), { color: "red" }); + + expect(host.innerHTML).toContain('data-hf-text-key="child:0"'); + expect(host.innerHTML).toContain('data-hf-text-key="child:1"'); + }); +}); + +/** + * A control that fires more than once per gesture, which the colour input does: + * a native picker reports every sample while the pointer moves in it, so one + * choice of colour arrives as a stream of them, each applied to whatever is + * selected at the time. + */ +describe("applyInlineStyle survives a control that fires repeatedly", () => { + it("restyles the same characters each time, in text containing a line break", () => { + const host = mount("1.
abcdefg"); + // "cde", on the line after the break. Only the first sample knows where the + // user pointed; every one after it reads the selection back, which is what + // the toolbar does and what the restore has to have got right. + applyInlineStyle(rangeOver(host, 5, 8), { color: "rgb(1, 1, 1)" }); + for (const color of ["rgb(2, 2, 2)", "rgb(3, 3, 3)"]) { + const live = document.getSelection()?.getRangeAt(0); + if (live) applyInlineStyle(live, { color }); + } + + expect(host.innerHTML).toBe('1.
abcdefg'); + }); + + it("puts the selection back over the characters it styled, past a break", () => { + const host = mount("1.
abcdefg"); + applyInlineStyle(rangeOver(host, 5, 8), { color: "red" }); + + expect(document.getSelection()?.toString()).toBe("cde"); + }); +}); + +/** + * A colour that does not paint is the same to the user as one that did not save. + * + * `-webkit-text-fill-color` inherits and paints the glyph fill, so a composition + * that sets it on a text element wins over any `color` the editor puts on a run + * inside it. The run saved correctly and rendered in someone else's colour, + * which reads as the colour picker being broken. + */ +describe("applyInlineStyle when something else is painting the glyphs", () => { + function stubFill(fill: string | null) { + const real = window.getComputedStyle.bind(window); + vi.spyOn(window, "getComputedStyle").mockImplementation(((element: Element) => { + const computed = real(element as HTMLElement); + return new Proxy(computed, { + get: (target, key) => { + if (key === "webkitTextFillColor") return fill ?? undefined; + if (key === "color") return (element as HTMLElement).style.color || "rgb(0, 0, 0)"; + return Reflect.get(target, key); + }, + }); + }) as typeof window.getComputedStyle); + } + + it("mirrors the colour into the fill when an ancestor is overpainting", () => { + const host = mount("Hello world"); + stubFill("rgb(255, 255, 255)"); + applyInlineStyle(rangeOver(host, 6, 11), { color: "rgb(255, 0, 149)" }); + + expect(host.innerHTML).toContain("-webkit-text-fill-color: rgb(255, 0, 149)"); + expect(host.innerHTML).toContain("color: rgb(255, 0, 149)"); + }); + + it("leaves the markup alone when nothing is overpainting", () => { + const host = mount("Hello world"); + stubFill("rgb(255, 0, 149)"); + applyInlineStyle(rangeOver(host, 6, 11), { color: "rgb(255, 0, 149)" }); + + expect(host.innerHTML).not.toContain("-webkit-text-fill-color"); + expect(host.innerHTML).toContain("color: rgb(255, 0, 149)"); + }); + + it("says nothing about a run that carries no colour", () => { + const host = mount("Hello world"); + stubFill("rgb(255, 255, 255)"); + applyInlineStyle(rangeOver(host, 6, 11), { "font-weight": "700" }); + + expect(host.innerHTML).not.toContain("-webkit-text-fill-color"); + }); +}); diff --git a/packages/studio/src/components/editor/inlineTextStyleRange.ts b/packages/studio/src/components/editor/inlineTextStyleRange.ts new file mode 100644 index 0000000000..53ab11d75c --- /dev/null +++ b/packages/studio/src/components/editor/inlineTextStyleRange.ts @@ -0,0 +1,552 @@ +/** + * Styling a run of characters inside an element being edited in place. + * + * Not `document.execCommand`. That is deprecated, and what it emits varies by + * browser between ``, a class, and an inline style depending on + * `styleWithCSS`. The output of this goes into the user's composition file, so + * it has to be one predictable shape, and the shape is a `` carrying an + * inline style. + * + * Not DOM range surgery either, which is the obvious way and the wrong one. + * Wrapping a range in a span is three lines and then every interesting case is + * a special case: recolouring nests spans that shadow each other, removing a + * style cannot reach the ancestor that set it, and styling across an existing + * run's boundary has to split it. Each fix is a new branch and the branches + * interact. + * + * So the element is read into a flat list of styled runs, the styling is + * applied to a span of characters in that list, and the element is rebuilt + * from it. Replacing, removing, splitting and merging all stop being cases: + * the rebuild emits one span per distinct run and cannot nest or duplicate, + * whatever was there before. Text elements in a composition are a headline or + * a sentence, so reading and rebuilding one is not a cost worth avoiding. + */ + +import { isRichTextFormattingTag } from "@hyperframes/core/rich-text-sanitize"; + +/** One stretch of characters that are all styled the same way. */ +interface StyledRun { + text: string; + style: Record; + /** + * The child element these characters came out of, when they came out of one. + * + * Carried because an element's children are not always anonymous formatting: + * the design panel keeps them as text layers and tracks each by an attribute + * on it. Rebuilding from style alone emitted fresh, bare spans, which threw + * that identity away — after colouring a single word the panel could no + * longer match a layer to its source, so every edit it offered failed to + * save. The rebuild puts the identity back on the run that still holds it. + */ + origin: Element | null; + /** + * That identity as a value, so two runs can be compared without comparing + * the nodes they came from. A child with nothing on it but a style has no + * identity to lose, and merges with its neighbour exactly as before. + */ + identity: string; +} + +export type InlineStyleDelta = Record; + +/** + * Tags that mean a style. They are read as styling and written back as spans, + * so there is one representation to reason about instead of two that have to + * agree. Rendering is unchanged; the markup for an edited element is not. + */ +const TAG_STYLES: Record> = { + B: { "font-weight": "700" }, + STRONG: { "font-weight": "700" }, + I: { "font-style": "italic" }, + EM: { "font-style": "italic" }, + U: { "text-decoration-line": "underline" }, +}; + +/** + * Stands in for a `
` while the element is a flat string, so a break counts + * as one character and offsets survive the rebuild. + * + * Not a newline. Compositions are written across lines, so an element's text + * routinely contains real newlines that are only source formatting, and using + * one as the marker turned every one of them into a visible line break the + * first time a word was styled. A NUL never appears: the HTML parser replaces + * it with U+FFFD, so no document can contain one. + */ +const BREAK = "\u0000"; + +/** Apply `style` to the characters the range covers, then rebuild the element. */ +export function applyInlineStyle(range: Range, style: InlineStyleDelta): void { + if (range.collapsed) return; + // Resolved from where the selection starts, not from where it and its end + // happen to meet. A selection dragged past the element's edge meets its end + // at an ancestor, and taking that as the host would rebuild the ancestor: + // every sibling element inside it flattened into text by an edit that was + // meant to colour a word. + const host = editingHost(range.startContainer); + if (!host || !holdsBothEnds(host, range)) return; + + const runs = readRuns(host); + const span = codePointBounds( + runs, + offsetOf(host, range.startContainer, range.startOffset), + offsetOf(host, range.endContainer, range.endOffset), + ); + if (!span) return; + + const next = restyle(runs, span.start, span.end, style); + render(host, next); + // A colour that does not paint is the same to the user as a colour that did + // not save, so check rather than assume. See `mirrorFillColor`. + if (colourIsOverpainted(host)) render(host, next.map(mirrorFillColor)); + selectRange(host, span.start, span.end); +} + +/** + * Whether something above the run is painting the glyphs a different colour. + * + * `-webkit-text-fill-color` inherits and paints the glyph fill, so an ancestor + * that sets it wins over any `color` a descendant sets. A composition doing so + * is not doing anything wrong, but from the editor it reads as the colour + * picker being broken: the run is saved with the colour asked for and renders + * in someone else's. + * + * Asked of the rendered span rather than worked out from the stylesheet. Its + * own `color` is set, so its computed colour IS the one that was asked for, and + * if the fill differs from it then something else is painting it. Both sides + * come from the same computed style, so neither notation nor inheritance has to + * be untangled by hand. + */ +function colourIsOverpainted(host: Element): boolean { + const view = host.ownerDocument.defaultView; + if (!view?.getComputedStyle) return false; + for (const span of host.querySelectorAll("span[style*='color']")) { + if (!span.style.color) continue; + const computed = view.getComputedStyle(span) as CSSStyleDeclaration & { + webkitTextFillColor?: string; + }; + const fill = computed.webkitTextFillColor; + if (!fill || !computed.color) continue; + if (fill !== computed.color) return true; + } + return false; +} + +/** The same run, with its colour also stated as the fill that actually paints. */ +function mirrorFillColor(run: StyledRun): StyledRun { + const colour = run.style.color; + if (!colour) return run; + return { ...run, style: { ...run.style, "-webkit-text-fill-color": colour } }; +} + +/** + * The offsets to style, widened so they never fall inside a character. + * + * A selection offset counts UTF-16 units and an emoji is two of them, so a + * boundary can land between the halves of one. Styling from there puts half the + * character in one span and half in the next, and both render as a question + * mark in a box. + */ +function codePointBounds( + runs: StyledRun[], + start: number | null, + end: number | null, +): { start: number; end: number } | null { + if (start === null || end === null || start >= end) return null; + const text = runs.map((run) => run.text).join(""); + return { + start: isTrailingHalf(text, start) ? start - 1 : start, + end: isTrailingHalf(text, end) ? end + 1 : end, + }; +} + +/** Whether the whole selection lives inside this element. */ +function holdsBothEnds(host: Element, range: Range): boolean { + return host.contains(range.startContainer) && host.contains(range.endContainer); +} + +/** Whether this offset sits on the second half of a character, mid-pair. */ +function isTrailingHalf(text: string, offset: number): boolean { + const code = text.charCodeAt(offset); + return code >= 0xdc00 && code <= 0xdfff; +} + +/** + * What the range is styled with, for a toolbar that has to open showing the + * truth rather than a default. Reports a property only when the whole range + * agrees about it, which is what a control can honestly display. + */ +export function readInlineStyle(range: Range, properties: string[]): Record { + const host = editingHost(range.startContainer); + if (!host || !holdsBothEnds(host, range)) return {}; + const start = offsetOf(host, range.startContainer, range.startOffset); + const end = offsetOf(host, range.endContainer, range.endOffset); + if (start === null || end === null) return {}; + + const covered = charRuns(readRuns(host)) + .slice(start, Math.max(end, start + 1)) + .map((entry) => entry.style); + if (covered.length === 0) return {}; + + const styles: Record = {}; + for (const property of properties) { + const first = covered[0]?.[property]; + if (first === undefined) continue; + if (covered.every((style) => style[property] === first)) styles[property] = first; + } + return styles; +} + +/** + * The element the caret is in: the one made editable, never a span inside it. + * + * Reading the nearest element instead would rebuild only the run the caret + * happened to land in, which is how a recolour ends up nested inside the + * colour it was meant to replace. + */ +function editingHost(node: Node): HTMLElement | null { + let element = (node.nodeType === 1 ? node : node.parentElement) as HTMLElement | null; + const editable = element?.closest("[contenteditable]"); + if (editable) return editable; + // No open edit, so climb out of the formatting to the element that owns it. + while (element?.parentElement && isRichTextFormattingTag(element.tagName)) { + element = element.parentElement; + } + return element; +} + +/** Read the element as a flat list of runs, in document order. */ +function readRuns(host: Element): StyledRun[] { + const runs: StyledRun[] = []; + // Text sitting directly in the host belongs to no child, so it has no origin. + walk(host, {}, runs, null); + return runs; +} + +function walk( + node: Node, + inherited: Record, + runs: StyledRun[], + origin: Element | null, +): void { + for (const child of Array.from(node.childNodes)) { + if (child.nodeType === 3) { + const text = child.textContent ?? ""; + if (text) runs.push({ text, style: inherited, origin, identity: identityOf(origin) }); + continue; + } + if (child.nodeType !== 1) continue; + const element = child as HTMLElement; + if (element.tagName === "BR") { + runs.push({ text: BREAK, style: inherited, origin, identity: identityOf(origin) }); + continue; + } + // The outermost child that carries anything is the one the panel knows as + // a layer, so nesting below it keeps pointing at it rather than at its + // inner formatting. An element with nothing on it is not an identity and + // must not shadow one below it — the wrapper this rebuild adds inside a + // flex container is exactly that, and taking it as the origin made the + // second edit of an element drop every identity the first one kept. + walk( + element, + { ...inherited, ...TAG_STYLES[element.tagName], ...ownStyle(element) }, + runs, + origin ?? (preservedAttributes(element).size > 0 ? element : null), + ); + } +} + +/** A child's identity as a comparable string, empty when it has none. */ +function identityOf(element: Element | null): string { + if (!element) return ""; + return [...preservedAttributes(element)] + .sort(([a], [b]) => (a < b ? -1 : 1)) + .map(([name, value]) => `${name}=${value}`) + .join("&"); +} + +function ownStyle(element: HTMLElement): Record { + const style: Record = {}; + for (let index = 0; index < element.style.length; index += 1) { + const property = element.style.item(index); + if (property) style[property] = element.style.getPropertyValue(property); + } + return style; +} + +/** One entry per character, which is the easiest thing to slice and compare. */ +function charRuns(runs: StyledRun[]): Array> { + const perChar: Array> = []; + for (const run of runs) { + for (let index = 0; index < run.text.length; index += 1) { + perChar.push({ style: run.style, origin: run.origin, identity: run.identity }); + } + } + return perChar; +} + +/** Apply the delta to `[start, end)` and hand back runs covering the element. */ +function restyle( + runs: StyledRun[], + start: number, + end: number, + delta: InlineStyleDelta, +): StyledRun[] { + const text = runs.map((run) => run.text).join(""); + const perChar = charRuns(runs); + const next: StyledRun[] = []; + // Indexed by UTF-16 unit, not by code point: `perChar`, `start` and `end` all + // count units, and spreading the string would count a surrogate pair once and + // slide every index after an emoji. + for (let index = 0; index < text.length; index += 1) { + appendChar( + next, + text[index] ?? "", + charAfter(perChar[index], index >= start && index < end, delta), + ); + } + return next; +} + +/** What one character is styled with once the delta has been applied to it. */ +function charAfter( + at: Omit | undefined, + inside: boolean, + delta: InlineStyleDelta, +): Omit { + const style = at?.style ?? {}; + return { + style: inside ? withDelta(style, delta) : style, + origin: at?.origin ?? null, + identity: at?.identity ?? "", + }; +} + +/** + * One character onto the run list, merged into the run before it when they + * belong together. + * + * Merged as it is built, so equal neighbours never become two spans. Two that + * the design panel tracks as separate layers stay apart even when they now look + * identical, because merging them deletes one of them. + */ +function appendChar(runs: StyledRun[], char: string, at: Omit): void { + const last = runs[runs.length - 1]; + if (last && last.identity === at.identity && sameStyle(last.style, at.style)) { + last.text += char; + return; + } + runs.push({ text: char, ...at }); +} + +function withDelta(style: Record, delta: InlineStyleDelta): Record { + const next = { ...style }; + for (const [property, value] of Object.entries(delta)) { + if (value === null) delete next[property]; + else next[property] = value; + } + return next; +} + +function sameStyle(a: Record, b: Record): boolean { + return styleKey(a) === styleKey(b); +} + +/** Sorted, so two runs styled the same way compare equal whatever the order. */ +function styleKey(style: Record): string { + return Object.keys(style) + .sort() + .map((property) => `${property}: ${style[property]}`) + .join("; "); +} + +/** Rebuild the element: bare text where there is no styling, one span where there is. */ +function render(host: Element, runs: StyledRun[]): void { + const doc = host.ownerDocument; + const nodes = runNodes(doc, runs); + host.replaceChildren(); + if (nodes.length > 1 && laysOutItsChildren(host)) { + // Wrapped, because in a flex or grid container every child is an item to + // be laid out. Text that was one anonymous item becomes several boxes the + // moment a word inside it is coloured, and the element visibly reflows: + // centring, wrapping and order all change under an edit that was only ever + // meant to change a colour. One wrapper keeps it a single item, and the + // runs inside it stay inline text. + const wrapper = doc.createElement("span"); + wrapper.append(...nodes); + host.append(wrapper); + return; + } + host.append(...nodes); +} + +function runNodes(doc: Document, runs: StyledRun[]): Node[] { + const nodes: Node[] = []; + // One span per origin keeps its attributes: an identity that appeared twice + // would be two layers claiming to be the same one. A run split off from an + // origin is a new layer and is written as one. + const claimed = new Set(); + for (const run of runs) { + const carried = + run.origin && !claimed.has(run.origin) ? preservedAttributes(run.origin) : new Map(); + if (carried.size > 0 && run.origin) claimed.add(run.origin); + nodes.push(...runNode(doc, run, carried)); + } + return nodes; +} + +/** + * One run's nodes: its line breaks as `
`, and its text as bare text when it + * has nothing to carry or a span when it has. The identity goes on the first + * piece only, so a run broken across lines does not claim it twice. + */ +function runNode(doc: Document, run: StyledRun, carried: Map): Node[] { + const key = styleKey(run.style); + const nodes: Node[] = []; + for (const [index, piece] of run.text.split(BREAK).entries()) { + if (index > 0) nodes.push(doc.createElement("br")); + if (!piece) continue; + if (!key && carried.size === 0) { + nodes.push(doc.createTextNode(piece)); + continue; + } + const span = doc.createElement("span"); + for (const [name, value] of carried) span.setAttribute(name, value); + if (key) span.setAttribute("style", key); + span.textContent = piece; + nodes.push(span); + carried.clear(); + } + return nodes; +} + +/** + * The one attribute the writer assigns rather than the author. + * + * Left out on purpose. It is stamped onto every element on the way to disk, so + * carrying it preserves nothing — and it made the wrapper this rebuild adds + * inside a flex container look like a layer as soon as the file had been saved + * once, which put it back to shadowing the real layers underneath it. + */ +const DERIVED_ATTR = "data-hf-id"; + +/** + * What a child carries besides its styling: the identity the design panel + * tracks it by. Its style is not copied — that is what the run holds, already + * merged with whatever the edit changed. + */ +function preservedAttributes(element: Element): Map { + const kept = new Map(); + for (const name of element.getAttributeNames()) { + if (name === "style" || name === DERIVED_ATTR) continue; + kept.set(name, element.getAttribute(name) ?? ""); + } + return kept; +} + +/** Displays whose children are boxes it positions, rather than text it flows. */ +const LAYS_OUT_CHILDREN = new Set([ + "flex", + "inline-flex", + "grid", + "inline-grid", + // How line clamping is written, and it boxes its children like flex. + "-webkit-box", + "-webkit-inline-box", +]); + +function laysOutItsChildren(host: Element): boolean { + const view = host.ownerDocument.defaultView; + if (!view) return false; + return LAYS_OUT_CHILDREN.has(view.getComputedStyle(host).display); +} + +/** Where a DOM position falls, counted in characters from the element's start. */ +function offsetOf(host: Element, container: Node, containerOffset: number): number | null { + // A position between children, expressed as a child index. + if (container === host) { + return Array.from(host.childNodes) + .slice(0, containerOffset) + .reduce((count, child) => count + subtreeCharLength(child), 0); + } + let count = 0; + const walker = host.ownerDocument.createTreeWalker( + host, + NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, + ); + let node = walker.nextNode(); + while (node) { + if (node === container) return count + containerOffset; + count += charLength(node); + node = walker.nextNode(); + } + return null; +} + +/** How many character positions a node occupies itself: its own text, or one + * for a break. An element contributes nothing; the walk visits its text. */ +function charLength(node: Node): number { + if (node.nodeType === 3) return (node.textContent ?? "").length; + return nodeName(node) === "BR" ? 1 : 0; +} + +/** The same count for a child and everything inside it, for a position given as + * a child index rather than a place in a text node. */ +function subtreeCharLength(node: Node): number { + return (node.textContent ?? "").length || (nodeName(node) === "BR" ? 1 : 0); +} + +function nodeName(node: Node): string { + return node.nodeType === 1 ? (node as Element).tagName : ""; +} + +/** Put the selection back over the characters that were just styled. */ +function selectRange(host: Element, start: number, end: number): void { + const doc = host.ownerDocument; + const selection = doc.defaultView?.getSelection(); + const from = positionAt(host, start); + const to = positionAt(host, end); + if (!selection || !from || !to) return; + const range = doc.createRange(); + range.setStart(from.node, from.offset); + range.setEnd(to.node, to.offset); + selection.removeAllRanges(); + selection.addRange(range); +} + +/** + * The DOM position a character offset lands on, after a rebuild. + * + * Counts a line break as one position, because everything that produced the + * offset did. This walked text nodes only, so in an element containing a `
` + * it landed one character early for every break before the offset — and the + * selection it put back was not the one that had just been styled. + * + * Which was invisible until a control fired more than once. The colour input + * does: a native picker reports every sample while the pointer moves in it, and + * each one restyled a selection that had walked one character further along + * than the last. Choosing a colour for three characters painted a different + * shade onto each character of the whole line. + */ +function positionAt(host: Element, offset: number): { node: Node; offset: number } | null { + let count = 0; + const walker = host.ownerDocument.createTreeWalker( + host, + NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, + ); + let node = walker.nextNode(); + let last: Node | null = null; + while (node) { + if (node.nodeType !== 3) { + if (nodeName(node) === "BR") count += 1; + node = walker.nextNode(); + continue; + } + const length = (node.textContent ?? "").length; + if (count + length >= offset) return { node, offset: offset - count }; + count += length; + last = node; + node = walker.nextNode(); + } + if (last) return { node: last, offset: (last.textContent ?? "").length }; + return { node: host, offset: 0 }; +}