feat(studio): apply a style to a run of characters (#3142)

* feat(studio): apply a style to a run of characters

Styling text in a composition cannot be done by wrapping a DOM range in a
span. That is three lines, and then every interesting case is a special
case: recolouring nests spans that shadow each other, removing a style
cannot reach the ancestor that set it, and styling across an existing run's
boundary has to split it. Each fix is a new branch and the branches
interact.

So the element is read into a flat list of styled runs, the style is applied
to a span of characters in that list, and the element is rebuilt from it.
Replacing, removing, splitting and merging stop being cases: the rebuild
emits one span per distinct run and cannot nest or duplicate, whatever was
there before.

Selection offsets count UTF-16 units, so a boundary can land between the
halves of an emoji; the applied range widens to whole characters. A colour
an ancestor overpaints is mirrored into the fill, because a colour that does
not paint reads to the user as a colour that did not save.

The toolbar that drives this arrives with the editor in the next change.

* fix(studio): harden inline text styling boundaries

* fix(studio): align inline styling with persistence

* test(studio): pin inline identity delimiters
This commit is contained in:
Miguel Ángel
2026-08-11 02:48:56 -04:00
committed by GitHub
parent 636dc042a7
commit cb73c8dc2e
3 changed files with 1309 additions and 3 deletions
+16 -3
View File
@@ -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)
@@ -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 = `<h1>${html}</h1>`;
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 <span style="color: red">world</span>');
});
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('ab<span style="color: red">cd</span>ef');
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('<span style="color: blue">abc</span>');
});
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('<span style="color: red">abcd</span>');
});
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(
'<span style="color: red">ab</span><span style="color: blue">cd</span>',
);
});
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('<span style="color: red"><span style="color: red">abc</span></span>');
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('a<span style="color: red"></span>b');
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("<b>abc</b>");
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<br>cd");
applyInlineStyle(rangeOver(host, 0, 2), { color: "red" });
expect(host.querySelectorAll("br")).toHaveLength(1);
expect(host.innerHTML).toBe('<span style="color: red">ab</span><br>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('ab<span style="color: red">cd</span>ef');
});
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<br>cd");
applyInlineStyle(rangeOver(host, 3, 5), { color: "red" });
expect(host.innerHTML).toBe('ab<br><span style="color: red">cd</span>');
});
});
describe("readInlineStyle", () => {
it("reports the styling of a run that is styled the same throughout", () => {
const host = mount('<span style="color: rgb(255, 0, 0)">abc</span>');
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('<span style="color: red">ab</span>cd');
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 <br>
// 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<br>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 &lt;b&gt; &amp; c");
applyInlineStyle(rangeOver(host, 0, 1), { color: "red" });
expect(host.textContent).toBe("a <b> & 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<span style="color: red">${grapheme}</span>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 = '<section><h1 id="a">first</h1><h1 id="b">second</h1></section>';
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("a<span>b<br>c</span>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('<span style="color: red">abcdef</span>');
});
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(
'<span data-hf-text-key="child:0" style="color: red">Hello</span>' +
'<span data-hf-text-key="child:1" style="color: blue">world</span>',
);
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(
'<span data-hf-text-key="child:0" style="font-size: 48px; color: red">Hello</span>',
);
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('<span data-hf-text-key="child:0">Hello</span>');
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('<span style="color: red">ab</span>cd');
applyInlineStyle(rangeOver(host, 2, 4), { color: "red" });
expect(host.innerHTML).toBe('<span style="color: red">abcd</span>');
});
// 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 =
'<div style="display: flex" contenteditable="true">' +
'<span data-hf-text-key="child:0">one<br>two</span></div>';
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 =
'<div style="display: flex" contenteditable="true">' +
'<span data-hf-id="hf-wrap"><span data-hf-text-key="child:0">one<br>two</span></span></div>';
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(
'<span data-hf-text-key="child:0" aria-label="stale" onclick="alert(1)">abc</span>',
);
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('<span style="color: red; text-transform: uppercase">abc</span>');
applyInlineStyle(rangeOver(host, 0, 3), { "font-weight": "700" });
expect(host.innerHTML).toBe('<span style="color: red; font-weight: 700">abc</span>');
});
it("does not edit through a contenteditable=false boundary", () => {
document.body.innerHTML =
'<div contenteditable="true"><span contenteditable="false">locked</span></div>';
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(
'<span data-hf-text-key="child:0" style="color: red">ab</span>' +
'<span data-hf-text-key="child:1" style="color: blue">cd</span>',
);
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(
'<span data-hf-text-key="a&amp;data-hf-text-key=b">ab</span>' +
'<span data-hf-text-key="b">cd</span>',
);
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&amp;");
});
});
/**
* 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.<br>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.<br>ab<span style="color: rgb(3, 3, 3)">cde</span>fg');
});
it("puts the selection back over the characters it styled, past a break", () => {
const host = mount("1.<br>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(
'<span data-hf-text-key="child:0" style="color: blue">left</span>' +
'<span data-hf-text-key="child:1" style="color: green">right</span>',
);
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<HTMLElement>("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");
});
});
@@ -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 `<font>`, 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 `<span>` 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<string, string>;
/**
* 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<string, string | null>;
/**
* 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<string, Record<string, string>> = {
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 `<br>` 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<HTMLElement>("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<string, string> {
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<string, string> = {};
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<HTMLElement>("[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<string, string>;
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<string, string>, origin: Element | null): StyledRun {
return { text, style, origin, identity: identityOf(origin) };
}
function childRunFrames(
element: HTMLElement,
inherited: Record<string, string>,
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<string, string> {
const style: Record<string, string> = {};
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<Omit<StyledRun, "text">> {
const perChar: Array<Omit<StyledRun, "text">> = [];
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<StyledRun, "text"> | undefined,
inside: boolean,
delta: InlineStyleDelta,
): Omit<StyledRun, "text"> {
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<StyledRun, "text">): 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<string, string>, delta: InlineStyleDelta): Record<string, string> {
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<string, string>, b: Record<string, string>): boolean {
return styleKey(a) === styleKey(b);
}
/** Sorted, so two runs styled the same way compare equal whatever the order. */
function styleKey(style: Record<string, string>): 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<Element>();
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 `<br>`, 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<string, string>): 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<string, string> {
const kept = new Map<string, string>();
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 `<br>`
* 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 };
}