mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 01:56:04 +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)
|
||||
|
||||
Reference in New Issue
Block a user