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 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
245 lines
8.6 KiB
TypeScript
245 lines
8.6 KiB
TypeScript
/**
|
|
* What inline formatting a composition file is allowed to receive.
|
|
*
|
|
* Editing text in the Studio preview can style a run of characters, which means
|
|
* markup now travels from a contenteditable element into a file on disk. This
|
|
* module is the only thing deciding what may make that trip. The server write
|
|
* boundary applies it unconditionally before returning composition bytes.
|
|
*
|
|
* One module rather than two implementations. Two would drift, and the drift
|
|
* would be a security bug rather than an inconsistency.
|
|
*
|
|
* It works on an element's subtree in place. Untrusted markup must be parsed in
|
|
* an inert document before this function receives it.
|
|
*/
|
|
|
|
/** Tags an inline text edit may contain. Everything else is not text styling. */
|
|
const FORMATTING_TAGS = new Set(["SPAN", "B", "STRONG", "I", "EM", "U", "BR"]);
|
|
|
|
/**
|
|
* Style properties a formatting tag may carry.
|
|
*
|
|
* This was paint-only, on the reasoning that a property which moves or resizes
|
|
* text would let an edit inside one element change the composition's layout,
|
|
* and layout is the design panel's job. The reasoning was wrong about who was
|
|
* being restricted: the design panel writes exactly these typography
|
|
* properties onto exactly these spans, as its text layers. Sanitizing them
|
|
* away did not stop text from changing layout, it deleted the layout the user
|
|
* had already set — colouring one word silently dropped a sibling layer's font
|
|
* size. The line that matters is the one below, values that reach outside the
|
|
* stylesheet, not which of its own properties the editor is allowed to keep.
|
|
*/
|
|
const FORMATTING_STYLE_PROPS = new Set([
|
|
"color",
|
|
"background-color",
|
|
"font-weight",
|
|
"font-style",
|
|
"text-decoration-line",
|
|
"font-family",
|
|
"font-size",
|
|
"letter-spacing",
|
|
"line-height",
|
|
// Paints the glyph fill and inherits, so an ancestor that sets it wins over
|
|
// any `color` below. The editor mirrors a run's colour into it when that is
|
|
// happening, and stripping it here would put the colour back to invisible.
|
|
"-webkit-text-fill-color",
|
|
]);
|
|
|
|
// Keep this list limited to properties whose grammar cannot fetch a resource.
|
|
// Adding a URL-consuming property also requires decoding CSS escapes before
|
|
// UNSAFE_VALUE can be a sufficient guard.
|
|
|
|
/**
|
|
* Attributes a formatting tag may carry.
|
|
*
|
|
* The identity a text layer is tracked by. Everything else is dropped: a
|
|
* contenteditable is a paste target, and an event handler or an id that
|
|
* shadows a composition's own is not formatting.
|
|
*/
|
|
const FORMATTING_ATTRS = new Set(["data-hf-text-key", "data-hf-id"]);
|
|
|
|
/**
|
|
* What those attributes are allowed to look like: a bare token, nothing else.
|
|
* `:` is deliberate because text keys use selector-like tokens such as
|
|
* `child:1`; neither allowed attribute is interpreted as a URL.
|
|
*/
|
|
const SAFE_ATTR_VALUE = /^[A-Za-z0-9_:-]+$/;
|
|
|
|
/**
|
|
* Tags dropped whole rather than unwrapped.
|
|
*
|
|
* Everything else is unwrapped, so an unexpected tag costs the user its
|
|
* formatting and not their words. These are the ones whose contents are not
|
|
* words: unwrapping a script would turn its source into visible text.
|
|
*/
|
|
const OPAQUE_TAGS = new Set([
|
|
"SCRIPT",
|
|
"STYLE",
|
|
"TEMPLATE",
|
|
"NOSCRIPT",
|
|
"IFRAME",
|
|
"OBJECT",
|
|
"EMBED",
|
|
"SVG",
|
|
"MATH",
|
|
]);
|
|
|
|
/** Anything that reaches out of the stylesheet, in a property that should not. */
|
|
const UNSAFE_VALUE = /url\(|expression\(|javascript:|vbscript:|@import|<\//i;
|
|
|
|
const ELEMENT_NODE = 1;
|
|
const TEXT_NODE = 3;
|
|
|
|
type SanitizerFrame =
|
|
| { phase: "visit"; node: Node }
|
|
| { phase: "sanitize"; element: Element; tag: string };
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Strip everything but allowed formatting from an element's contents, in place.
|
|
*
|
|
* The element itself is never touched, only what is inside it. Callers own the
|
|
* element, and it is the composition's, not the editor's, to rewrite.
|
|
*
|
|
* When the children came from untrusted markup, callers must parse that markup
|
|
* into an inert document (for example linkedom or a detached DOMParser document)
|
|
* first. Never assign untrusted HTML to a live DOM element and then call this
|
|
* function: active content can run before sanitization begins.
|
|
*/
|
|
export function sanitizeRichTextChildren(parent: Element): void {
|
|
const pending: SanitizerFrame[] = Array.from(
|
|
parent.childNodes,
|
|
(node): SanitizerFrame => ({ phase: "visit", node }),
|
|
).reverse();
|
|
|
|
// Post-order without recursion: adversarially deep pasted markup must not
|
|
// exhaust either the server or browser call stack.
|
|
for (let frame = pending.pop(); frame; frame = pending.pop()) {
|
|
if (frame.phase === "sanitize") {
|
|
if (!FORMATTING_TAGS.has(frame.tag)) unwrap(frame.element);
|
|
else stripAttributes(frame.element);
|
|
continue;
|
|
}
|
|
|
|
const child = frame.node;
|
|
if (child.nodeType === TEXT_NODE) continue;
|
|
|
|
if (!isElementNode(child)) {
|
|
// Comments and processing instructions are neither words nor formatting.
|
|
child.parentNode?.removeChild(child);
|
|
continue;
|
|
}
|
|
|
|
const element = child;
|
|
const tag = element.tagName.toUpperCase();
|
|
|
|
if (OPAQUE_TAGS.has(tag)) {
|
|
element.parentNode?.removeChild(element);
|
|
continue;
|
|
}
|
|
|
|
pending.push({ phase: "sanitize", element, tag });
|
|
for (const descendant of Array.from(element.childNodes).reverse()) {
|
|
pending.push({ phase: "visit", node: descendant });
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Replace an element with its own children, keeping their order and place. */
|
|
function unwrap(element: Element): void {
|
|
const parent = element.parentNode;
|
|
if (!parent) return;
|
|
while (element.firstChild) parent.insertBefore(element.firstChild, element);
|
|
parent.removeChild(element);
|
|
}
|
|
|
|
/** Leave a kept tag with a filtered style attribute and its identity, no more. */
|
|
function stripAttributes(element: Element): void {
|
|
const style = element.getAttribute("style");
|
|
for (const name of Array.from(element.getAttributeNames())) {
|
|
const value = element.getAttribute(name) ?? "";
|
|
if (isRichTextFormattingAttribute(name, value)) continue;
|
|
element.removeAttribute(name);
|
|
}
|
|
if (style === null) return;
|
|
const safe = filterStyle(style);
|
|
if (safe) element.setAttribute("style", safe);
|
|
else element.removeAttribute("style");
|
|
}
|
|
|
|
/** Keep only the allowlisted declarations, and only if their values are inert. */
|
|
function filterStyle(style: string): string {
|
|
return splitDeclarations(style)
|
|
.map((declaration) => {
|
|
const colon = declaration.indexOf(":");
|
|
if (colon === -1) return null;
|
|
const property = declaration.slice(0, colon).trim().toLowerCase();
|
|
const value = declaration.slice(colon + 1).trim();
|
|
if (!isRichTextFormattingStyle(property, value)) return null;
|
|
return `${property}: ${value}`;
|
|
})
|
|
.filter((declaration): declaration is string => declaration !== null)
|
|
.join("; ");
|
|
}
|
|
|
|
function isQuoteDelimiter(char: string): char is "'" | '"' {
|
|
return char === "'" || char === '"';
|
|
}
|
|
|
|
function nextParenthesisDepth(depth: number, char: string): number {
|
|
if (char === "(") return depth + 1;
|
|
if (char === ")") return Math.max(0, depth - 1);
|
|
return depth;
|
|
}
|
|
|
|
function isDeclarationSeparator(char: string, depth: number, quote: "'" | '"' | null): boolean {
|
|
return char === ";" && depth === 0 && quote === null;
|
|
}
|
|
|
|
/**
|
|
* Split on the semicolons that separate declarations, not the ones inside a
|
|
* value. `color: rgb(1, 2, 3)` is one declaration however many separators its
|
|
* value contains.
|
|
*/
|
|
function splitDeclarations(style: string): string[] {
|
|
const declarations: string[] = [];
|
|
let current = "";
|
|
let depth = 0;
|
|
let quote: "'" | '"' | null = null;
|
|
for (const char of style) {
|
|
if (char === quote) quote = null;
|
|
else if (quote === null && isQuoteDelimiter(char)) quote = char;
|
|
else if (isDeclarationSeparator(char, depth, quote)) {
|
|
declarations.push(current);
|
|
current = "";
|
|
continue;
|
|
} else if (quote === null) {
|
|
depth = nextParenthesisDepth(depth, char);
|
|
}
|
|
current += char;
|
|
}
|
|
if (current.trim()) declarations.push(current);
|
|
return declarations;
|
|
}
|