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)