mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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 <b> & 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&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&");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user