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

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("counts a nested line break in a range whose boundaries are child indexes", () => { + const host = mount("ab
c
d"); + 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 }; +}