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 = '';
+ 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 };
+}