diff --git a/packages/core/src/utils/richTextSanitize.ts b/packages/core/src/utils/richTextSanitize.ts
index 1b5a6476ca..4afaeab0c1 100644
--- a/packages/core/src/utils/richTextSanitize.ts
+++ b/packages/core/src/utils/richTextSanitize.ts
@@ -98,6 +98,20 @@ export function isRichTextFormattingTag(tagName: string): boolean {
return FORMATTING_TAGS.has(tagName.toUpperCase());
}
+/** Whether an attribute survives the rich-text persistence boundary. */
+export function isRichTextFormattingAttribute(name: string, value: string): boolean {
+ return FORMATTING_ATTRS.has(name.toLowerCase()) && SAFE_ATTR_VALUE.test(value);
+}
+
+/** Whether a declaration survives the rich-text persistence boundary. */
+export function isRichTextFormattingStyle(property: string, value: string): boolean {
+ return (
+ FORMATTING_STYLE_PROPS.has(property.toLowerCase()) &&
+ value.length > 0 &&
+ !UNSAFE_VALUE.test(value)
+ );
+}
+
function isElementNode(node: Node): node is Element {
return node.nodeType === ELEMENT_NODE;
}
@@ -165,7 +179,7 @@ function stripAttributes(element: Element): void {
const style = element.getAttribute("style");
for (const name of Array.from(element.getAttributeNames())) {
const value = element.getAttribute(name) ?? "";
- if (FORMATTING_ATTRS.has(name.toLowerCase()) && SAFE_ATTR_VALUE.test(value)) continue;
+ if (isRichTextFormattingAttribute(name, value)) continue;
element.removeAttribute(name);
}
if (style === null) return;
@@ -182,8 +196,7 @@ function filterStyle(style: string): string {
if (colon === -1) return null;
const property = declaration.slice(0, colon).trim().toLowerCase();
const value = declaration.slice(colon + 1).trim();
- if (!FORMATTING_STYLE_PROPS.has(property)) return null;
- if (!value || UNSAFE_VALUE.test(value)) return null;
+ if (!isRichTextFormattingStyle(property, value)) return null;
return `${property}: ${value}`;
})
.filter((declaration): declaration is string => declaration !== null)
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..8786d5c01e
--- /dev/null
+++ b/packages/studio/src/components/editor/inlineTextStyleRange.test.ts
@@ -0,0 +1,699 @@
+// @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();
+ });
+
+ it("reports the character before a collapsed caret at a style boundary", () => {
+ const host = mount('abcd');
+
+ const styles = readInlineStyle(rangeOver(host, 2, 2), ["color"]);
+
+ expect(styles.color).toBe("red");
+ });
+});
+
+// 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.each(["a", "sub", "sup", "mark", "s"])(
+ "unwraps unsupported <%s> markup exactly as the persistence sanitizer does",
+ (tag) => {
+ const host = mount(`<${tag} href="https://example.com" style="color: red">abc${tag}>`);
+ host.contentEditable = "true";
+
+ applyInlineStyle(rangeOver(host, 0, 3), { "font-weight": "700" });
+
+ expect(host.querySelector(tag)).toBeNull();
+ expect(host.innerHTML).not.toContain("href");
+ expect(host.innerHTML).not.toContain("color: red");
+ expect(host.innerHTML).toContain("font-weight: 700");
+ },
+ );
+
+ 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.each([
+ ["a zero-width-joiner family", "π¨βπ©βπ§"],
+ ["a regional-indicator flag", "πΊπΈ"],
+ ["an emoji with a skin-tone modifier", "ππ½"],
+ ["a combining-mark character", "e\u0301"],
+ ])("keeps %s together", (_name, grapheme) => {
+ const host = mount(`a${grapheme}b`);
+ // Both boundaries are expressed in UTF-16 units and at least one lands
+ // inside the grapheme rather than at one of its edges.
+ applyInlineStyle(rangeOver(host, 2, Math.max(3, grapheme.length)), { color: "red" });
+
+ expect(host.innerHTML).toBe(`a${grapheme}b`);
+ });
+
+ 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("counts a nested line break in a range whose boundaries are child indexes", () => {
+ const host = mount("ab
cd");
+ const range = document.createRange();
+ range.setStart(host, 1);
+ range.setEnd(host, 2);
+
+ applyInlineStyle(range, { color: "red" });
+
+ expect(Array.from(host.querySelectorAll("span")).map((span) => span.textContent)).toEqual([
+ "b",
+ "c",
+ ]);
+ expect(host.innerHTML.endsWith("d")).toBe(true);
+ });
+
+ it("walks deeply nested formatting without using the call stack", () => {
+ const host = mount("");
+ let parent = host;
+ for (let depth = 0; depth < 2_000; depth += 1) {
+ const span = document.createElement("span");
+ parent.append(span);
+ parent = span;
+ }
+ parent.textContent = "x";
+
+ applyInlineStyle(rangeOver(host, 0, 1), { color: "red" });
+
+ expect(host.textContent).toBe("x");
+ expect(host.querySelectorAll("span")).toHaveLength(1);
+ });
+
+ 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 carry attributes that the persistence sanitizer will remove", () => {
+ const host = mount(
+ 'abc',
+ );
+
+ applyInlineStyle(rangeOver(host, 0, 3), { color: "red" });
+
+ expect(host.innerHTML).toContain('data-hf-text-key="child:0"');
+ expect(host.innerHTML).not.toContain("aria-label");
+ expect(host.innerHTML).not.toContain("onclick");
+ });
+
+ it("does not carry style properties that the persistence sanitizer will remove", () => {
+ const host = mount('abc');
+
+ applyInlineStyle(rangeOver(host, 0, 3), { "font-weight": "700" });
+
+ expect(host.innerHTML).toBe('abc');
+ });
+
+ it("does not edit through a contenteditable=false boundary", () => {
+ document.body.innerHTML =
+ 'locked
';
+ const locked = document.body.querySelector("span") as HTMLElement;
+
+ applyInlineStyle(rangeOver(locked, 0, 6), { color: "red" });
+
+ expect(locked.innerHTML).toBe("locked");
+ });
+
+ 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"');
+ });
+
+ it("cannot merge identities through delimiter-bearing attribute values", () => {
+ const host = mount(
+ 'ab' +
+ 'cd',
+ );
+
+ applyInlineStyle(rangeOver(host, 0, 4), { color: "red" });
+
+ expect(host.querySelectorAll("span")).toHaveLength(2);
+ expect(host.querySelectorAll('[data-hf-text-key="b"]')).toHaveLength(1);
+ expect(host.innerHTML).not.toContain("a&");
+ });
+});
+
+/**
+ * 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 | ((element: HTMLElement) => string | null)) {
+ const real = window.getComputedStyle.bind(window);
+ vi.spyOn(window, "getComputedStyle").mockImplementation(((element: Element) => {
+ const computed = real(element as HTMLElement);
+ const resolvedFill = typeof fill === "function" ? fill(element as HTMLElement) : fill;
+ return new Proxy(computed, {
+ get: (target, key) => {
+ if (key === "webkitTextFillColor") return resolvedFill ?? 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");
+ });
+
+ it("mirrors only the run whose own ancestor path is overpainting", () => {
+ const host = mount(
+ 'left' +
+ 'right',
+ );
+ stubFill((element) =>
+ element.textContent === "left" ? "rgb(255, 255, 255)" : element.style.color,
+ );
+
+ applyInlineStyle(rangeOver(host, 4, 9), { color: "red" });
+
+ const [left, right] = Array.from(host.querySelectorAll("span"));
+ expect(left?.style.getPropertyValue("-webkit-text-fill-color")).toBe("blue");
+ expect(right?.style.getPropertyValue("-webkit-text-fill-color")).toBe("");
+ });
+
+ it("drops a generated mirror when the ancestor stops overpainting", () => {
+ const host = mount("Hello world");
+ let overpainted = true;
+ stubFill((element) => (overpainted ? "rgb(255, 255, 255)" : element.style.color));
+ applyInlineStyle(rangeOver(host, 6, 11), { color: "red" });
+ expect(host.innerHTML).toContain("-webkit-text-fill-color");
+
+ overpainted = false;
+ const live = document.getSelection()?.getRangeAt(0);
+ if (live) applyInlineStyle(live, { "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..89ce77213c
--- /dev/null
+++ b/packages/studio/src/components/editor/inlineTextStyleRange.ts
@@ -0,0 +1,594 @@
+/**
+ * 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 {
+ isRichTextFormattingAttribute,
+ isRichTextFormattingStyle,
+ 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 = graphemeBounds(
+ 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);
+ reconcileFillColors(host);
+ 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 reconcileFillColors(host: Element): void {
+ const view = host.ownerDocument.defaultView;
+ if (!view?.getComputedStyle) return;
+ for (const span of host.querySelectorAll("span")) {
+ if (!span.style.color) continue;
+ // A generated mirror repeats the run's colour. Remove that before asking
+ // what would paint the run without it; an authored, different fill stays
+ // in place long enough to be detected as the overpaint it is.
+ const existingFill = span.style.getPropertyValue("-webkit-text-fill-color");
+ if (existingFill === span.style.color) {
+ span.style.removeProperty("-webkit-text-fill-color");
+ }
+ const computed = view.getComputedStyle(span) as CSSStyleDeclaration & {
+ webkitTextFillColor?: string;
+ };
+ const fill = computed.webkitTextFillColor;
+ if (!fill || !computed.color) continue;
+ if (fill !== computed.color) {
+ span.style.setProperty("-webkit-text-fill-color", span.style.color);
+ }
+ }
+}
+
+/**
+ * The offsets to style, widened so they never fall inside a character.
+ *
+ * Selection offsets count UTF-16 units, while one visible character can be a
+ * surrogate pair, combining sequence, flag, modifier sequence, or a family
+ * joined by zero-width joiners. Splitting any of those across spans corrupts
+ * what the user selected even when every individual code point remains valid.
+ */
+function graphemeBounds(
+ 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("");
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
+ const boundaries = [0, ...Array.from(segmenter.segment(text), atEndOfSegment)];
+ return {
+ start: boundaryAtOrBefore(boundaries, start),
+ end: boundaries.find((boundary) => boundary >= end) ?? end,
+ };
+}
+
+function atEndOfSegment({ index, segment }: Intl.SegmentData): number {
+ return index + segment.length;
+}
+
+function boundaryAtOrBefore(boundaries: number[], offset: number): number {
+ let previous = offset;
+ for (const boundary of boundaries) {
+ if (boundary > offset) return previous;
+ previous = boundary;
+ }
+ return previous;
+}
+
+/** Whether the whole selection lives inside this element. */
+function holdsBothEnds(host: Element, range: Range): boolean {
+ return host.contains(range.startContainer) && host.contains(range.endContainer);
+}
+
+/**
+ * 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 collapsed = start === end;
+ const covered = charRuns(readRuns(host))
+ .slice(collapsed ? Math.max(0, start - 1) : start, collapsed ? Math.max(1, start) : end)
+ .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.getAttribute("contenteditable")?.toLowerCase() === "false" ? null : 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. */
+interface RunWalkFrame {
+ node: Node;
+ inherited: Record;
+ origin: Element | null;
+}
+
+function readRuns(host: Element): StyledRun[] {
+ const runs: StyledRun[] = [];
+ const pending: RunWalkFrame[] = Array.from(
+ host.childNodes,
+ (node): RunWalkFrame => ({ node, inherited: {}, origin: null }),
+ ).reverse();
+ for (let frame = pending.pop(); frame; frame = pending.pop()) {
+ pending.push(...visitRunFrame(frame, runs));
+ }
+ return runs;
+}
+
+function visitRunFrame(frame: RunWalkFrame, runs: StyledRun[]): RunWalkFrame[] {
+ const { node, inherited, origin } = frame;
+ if (node.nodeType === 3) {
+ const text = node.textContent ?? "";
+ if (text) runs.push(styledRun(text, inherited, origin));
+ return [];
+ }
+ if (!isStyleElement(node)) return [];
+ const element = node;
+ if (element.tagName === "BR") {
+ runs.push(styledRun(BREAK, inherited, origin));
+ return [];
+ }
+ return childRunFrames(element, inherited, origin);
+}
+
+function isStyleElement(node: Node): node is HTMLElement {
+ return node.nodeType === 1 && "style" in node;
+}
+
+function styledRun(text: string, style: Record, origin: Element | null): StyledRun {
+ return { text, style, origin, identity: identityOf(origin) };
+}
+
+function childRunFrames(
+ element: HTMLElement,
+ inherited: Record,
+ origin: Element | null,
+): RunWalkFrame[] {
+ const formatting = isRichTextFormattingTag(element.tagName);
+ const nextInherited = formatting
+ ? { ...inherited, ...TAG_STYLES[element.tagName], ...ownStyle(element) }
+ : inherited;
+ // The outermost formatting child that carries anything is the one the panel
+ // knows as a layer. Tags the sanitizer unwraps cannot own a layer.
+ const nextOrigin =
+ formatting && !origin && preservedAttributes(element).size > 0 ? element : origin;
+ return Array.from(
+ element.childNodes,
+ (node): RunWalkFrame => ({ node, inherited: nextInherited, origin: nextOrigin }),
+ ).reverse();
+}
+
+/** A child's identity as a comparable string, empty when it has none. */
+function identityOf(element: Element | null): string {
+ if (!element) return "";
+ return JSON.stringify([...preservedAttributes(element)].sort(([a], [b]) => (a < b ? -1 : 1)));
+}
+
+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) continue;
+ const value = element.style.getPropertyValue(property);
+ if (isRichTextFormattingStyle(property, value)) style[property] = value;
+ }
+ 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;
+ const value = element.getAttribute(name) ?? "";
+ if (isRichTextFormattingAttribute(name, value)) kept.set(name, value);
+ }
+ 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 {
+ let count = 0;
+ const pending = [node];
+ for (let current = pending.pop(); current; current = pending.pop()) {
+ count += charLength(current);
+ pending.push(...Array.from(current.childNodes));
+ }
+ return count;
+}
+
+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 };
+}