fix(studio-server): child-scoped patch operations with batch abort (#1908)

* test(studio): add design-panel QA fixture and triage matrix

Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.

* fix(studio): make canvas selection hit intended elements

- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling

* fix(studio): close remaining selection-layer review findings

- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
  blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
  so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
  check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
  playback paused if it was already playing

* fix(studio-server): child-scoped patch operations with batch abort

- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)
This commit is contained in:
Miguel Ángel
2026-07-03 17:53:17 -07:00
committed by GitHub
parent 02e9d6142d
commit 23f67da5e2
6 changed files with 385 additions and 299 deletions
+10
View File
@@ -50,6 +50,12 @@
"node": "./dist/helpers/finiteMutation.js",
"import": "./src/helpers/finiteMutation.ts",
"types": "./src/helpers/finiteMutation.ts"
},
"./source-mutation": {
"bun": "./src/helpers/sourceMutation.ts",
"node": "./dist/helpers/sourceMutation.js",
"import": "./src/helpers/sourceMutation.ts",
"types": "./src/helpers/sourceMutation.ts"
}
},
"publishConfig": {
@@ -79,6 +85,10 @@
"./finite-mutation": {
"import": "./dist/helpers/finiteMutation.js",
"types": "./dist/helpers/finiteMutation.d.ts"
},
"./source-mutation": {
"import": "./dist/helpers/sourceMutation.js",
"types": "./dist/helpers/sourceMutation.d.ts"
}
},
"main": "./dist/index.js",
@@ -3,10 +3,7 @@ import { describe, expect, it } from "vitest";
import {
removeElementFromHtml,
patchElementInHtml,
splitElementInHtml,
probeElementInSource,
wrapElementsInHtml,
unwrapElementsFromHtml,
} from "./sourceMutation.js";
describe("removeElementFromHtml", () => {
@@ -130,6 +127,69 @@ describe("patchElementInHtml", () => {
expect(result).not.toContain("Hello World");
});
it("applies child-scoped inline style without changing the parent style", () => {
const source = `<div data-hf-id="parent" style="color: red"><span class="line">A</span><span class="line">B</span></div>`;
const { html: result, matched } = patchElementInHtml(source, { hfId: "parent" }, [
{
type: "inline-style",
property: "color",
value: "blue",
childSelector: ":scope > span",
childIndex: 1,
},
]);
expect(matched).toBe(true);
const { document } = parseHTML(result);
const parent = document.querySelector('[data-hf-id="parent"]');
const children = Array.from(document.querySelectorAll(".line"));
expect(parent?.getAttribute("style")).toContain("color: red");
expect(children[0]?.getAttribute("style")).toBeNull();
expect(children[1]?.getAttribute("style")).toContain("color: blue");
});
it("applies child-scoped text content to the child only", () => {
const source = `<div data-hf-id="parent"><span class="line">A</span><span class="line">B</span></div>`;
const { html: result, matched } = patchElementInHtml(source, { hfId: "parent" }, [
{
type: "text-content",
property: "text",
value: "B < C & D",
childSelector: ":scope > span",
childIndex: 1,
},
]);
expect(matched).toBe(true);
const { document } = parseHTML(result);
const children = Array.from(document.querySelectorAll(".line"));
expect(children[0]?.textContent).toBe("A");
expect(children[1]?.textContent).toBe("B < C & D");
});
it("rejects the whole batch when a child-scoped operation cannot resolve", () => {
const source = `<div data-hf-id="parent"><span class="line">A</span><span class="line">B</span></div>`;
const result = patchElementInHtml(source, { hfId: "parent" }, [
{
type: "inline-style",
property: "color",
value: "blue",
childSelector: ":scope > span",
childIndex: 0,
},
{
type: "text-content",
property: "text",
value: "missing",
childSelector: ":scope > strong",
childIndex: 0,
},
]);
expect(result.matched).toBe(false);
expect(result.html).toBe(source);
});
it("applies multiple operations in one call", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "inline-style", property: "color", value: "blue" },
@@ -459,230 +519,3 @@ describe("T7 — data-hf-id targeting (spec for R1)", () => {
expect(html).toContain('data-hf-id="hf-a1b2"');
});
});
describe("splitElementInHtml — hfId clone isolation", () => {
it("does not copy data-hf-id to the cloned second half", () => {
const source = `<html><body><div data-composition-id="root"><div id="clip1" class="clip" data-start="0" data-duration="10" data-hf-id="hf-abc123"></div></div></body></html>`;
const { html, matched } = splitElementInHtml(source, { id: "clip1" }, 5, "clip2");
expect(matched).toBe(true);
const occurrences = (html.match(/data-hf-id="hf-abc123"/g) ?? []).length;
expect(occurrences).toBe(1);
});
});
describe("splitElementInHtml", () => {
const source = `<!DOCTYPE html><html><head><style>#box { position: absolute; top: 100px; background: red; }</style></head><body><div data-composition-id="root"><div id="box" class="clip" data-start="1" data-duration="6">Hello</div></div></body></html>`;
it("splits element at the given time", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.matched).toBe(true);
expect(result.html).toContain('data-duration="2"');
expect(result.html).toContain('id="box-split"');
expect(result.html).toContain('data-start="3"');
expect(result.html).toContain('data-duration="4"');
});
it("duplicates CSS rules for the new element ID", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.html).toContain("#box-split");
expect(result.html).toContain("background: red");
const cssMatches = result.html.match(/#box-split\s*\{/g);
expect(cssMatches?.length).toBeGreaterThanOrEqual(1);
});
it("deduplicates IDs when the requested newId already exists", () => {
const withExisting = source.replace(
"</div></div>",
'</div><div id="box-split" data-start="5" data-duration="1">Existing</div></div>',
);
const result = splitElementInHtml(withExisting, { id: "box" }, 3, "box-split");
expect(result.matched).toBe(true);
expect(result.html).toContain('id="box-split-2"');
});
it("keeps clip class on the cloned element", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.html).toMatch(/id="box-split"[^>]*class="clip"/);
});
it("returns matched false for out-of-range split time", () => {
expect(splitElementInHtml(source, { id: "box" }, 0.5, "box-split").matched).toBe(false);
expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false);
});
it("splits a GSAP element with no authored timing using fallback timing", () => {
// #title has no data-start/data-duration (GSAP-driven); the store supplies the range.
const gsapSource = `<html><body><div data-composition-id="root"><h1 id="title" class="title">Hi</h1></div></body></html>`;
const result = splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split", {
start: 0,
duration: 6,
});
expect(result.matched).toBe(true);
// original windowed to [0, 2], clone to [2, 4] (attribute order is serializer-defined)
const original = result.html.match(/<h1[^>]*\bid="title"[^>]*>/)![0];
expect(original).toContain('data-start="0"');
expect(original).toContain('data-duration="2"');
const clone = result.html.match(/<h1[^>]*\bid="title-split"[^>]*>/)![0];
expect(clone).toContain('data-start="2"');
expect(clone).toContain('data-duration="4"');
});
it("still rejects a no-timing element when no fallback timing is given", () => {
const gsapSource = `<html><body><div data-composition-id="root"><h1 id="title">Hi</h1></div></body></html>`;
expect(splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split").matched).toBe(false);
});
it("adjusts media playback-start for the second half", () => {
const mediaSource = source.replace(
'id="box" class="clip" data-start="1" data-duration="6"',
'id="box" class="clip" data-start="1" data-duration="6" data-playback-start="0"',
);
const result = splitElementInHtml(mediaSource, { id: "box" }, 3, "box-split");
expect(result.html).toMatch(/id="box-split"[^>]*data-playback-start="2"/);
});
});
describe("wrapElementsInHtml / unwrapElementsFromHtml", () => {
// Three positioning flavours the rebase must leave visually identical:
// plain inline left/top, a GSAP transform delta, and a --hf-studio-offset var.
const FIXTURE = `<!doctype html><html><body><div data-composition-id="main">
<div id="title" class="clip" style="position: absolute; left: 260px; top: 100px">Title</div>
<div id="logo" class="clip" style="position: absolute; left: 300px; top: 200px; transform: translate(10px, 5px)">Logo</div>
<div id="badge" class="clip" style="position: absolute; left: 400px; top: 50px; --hf-studio-offset: 12px">Badge</div>
<div id="outside" class="clip" style="position: absolute; left: 10px; top: 10px">Outside</div>
</div></body></html>`;
// bbox top-left = (min left, min top) over the three members.
const BBOX = { left: 260, top: 50, width: 300, height: 300 };
const REBASES = [
{ target: { id: "title" }, left: 0, top: 50 }, // 260-260, 100-50
{ target: { id: "logo" }, left: 40, top: 150 }, // 300-260, 200-50
{ target: { id: "badge" }, left: 140, top: 0 }, // 400-260, 50-50
];
const TARGETS = [{ id: "title" }, { id: "logo" }, { id: "badge" }];
function leftTop(el: Element): { left: number; top: number } {
const style = el.getAttribute("style") ?? "";
const left = parseFloat(/(?:^|;)\s*left\s*:\s*([\d.]+)px/.exec(style)?.[1] ?? "NaN");
const top = parseFloat(/(?:^|;)\s*top\s*:\s*([\d.]+)px/.exec(style)?.[1] ?? "NaN");
return { left, top };
}
it("wraps members in a data-hf-group div, preserving order and rebasing left/top", () => {
const { html, matched, groupId } = wrapElementsInHtml(
FIXTURE,
TARGETS,
"Group 1",
BBOX,
REBASES,
);
expect(matched).toBe(true);
expect(groupId).toBe("Group 1");
const { document } = parseHTML(html);
const group = document.querySelector('[data-hf-group="Group 1"]')!;
expect(group).not.toBeNull();
// Wrapper sits at the bbox top-left.
expect(leftTop(group)).toEqual({ left: 260, top: 50 });
// Members are inside the wrapper, in original DOM order (= z-order).
const childIds = Array.from(group.children).map((c) => c.id);
expect(childIds).toEqual(["title", "logo", "badge"]);
// Non-member stays outside.
expect(document.querySelector("#outside")!.parentElement).toBe(
document.querySelector('[data-composition-id="main"]'),
);
// Each member rebased; transform + offset var untouched.
expect(leftTop(document.querySelector("#title")!)).toEqual({ left: 0, top: 50 });
expect(leftTop(document.querySelector("#logo")!)).toEqual({ left: 40, top: 150 });
expect(document.querySelector("#logo")!.getAttribute("style")).toContain(
"transform: translate(10px, 5px)",
);
expect(leftTop(document.querySelector("#badge")!)).toEqual({ left: 140, top: 0 });
expect(document.querySelector("#badge")!.getAttribute("style")).toContain(
"--hf-studio-offset: 12px",
);
});
it("round-trips: unwrap restores original structure and coordinates", () => {
const wrapped = wrapElementsInHtml(FIXTURE, TARGETS, "Group 1", BBOX, REBASES).html;
const { html, unwrapped } = unwrapElementsFromHtml(wrapped, {
selector: '[data-hf-group="Group 1"]',
});
expect(unwrapped).toBe(true);
const { document } = parseHTML(html);
expect(document.querySelector("[data-hf-group]")).toBeNull();
const main = document.querySelector('[data-composition-id="main"]')!;
// Members back in the parent, original order relative to the outside sibling.
expect(Array.from(main.children).map((c) => c.id)).toEqual([
"title",
"logo",
"badge",
"outside",
]);
// Coordinates restored; transform + offset var intact.
expect(leftTop(document.querySelector("#title")!)).toEqual({ left: 260, top: 100 });
expect(leftTop(document.querySelector("#logo")!)).toEqual({ left: 300, top: 200 });
expect(document.querySelector("#logo")!.getAttribute("style")).toContain(
"transform: translate(10px, 5px)",
);
expect(leftTop(document.querySelector("#badge")!)).toEqual({ left: 400, top: 50 });
expect(document.querySelector("#badge")!.getAttribute("style")).toContain(
"--hf-studio-offset: 12px",
);
});
it("rejects members that do not share a single parent", () => {
const split = `<!doctype html><html><body><div data-composition-id="main"><div id="a" style="position:absolute;left:0;top:0"></div><section><div id="b" style="position:absolute;left:0;top:0"></div></section></div></body></html>`;
const result = wrapElementsInHtml(split, [{ id: "a" }, { id: "b" }], "Group 1", BBOX, [
{ target: { id: "a" }, left: 0, top: 0 },
{ target: { id: "b" }, left: 0, top: 0 },
]);
expect(result.matched).toBe(false);
expect(result.error).toMatch(/single parent/);
expect(result.html).toBe(split);
});
it("lifts the group to the topmost member's slot so an interleaved non-member falls below it", () => {
// [low, middle (non-member), high]; group {low, high}. The group adopts the
// topmost member's stacking, so `middle` ends up BELOW the wrapper (not hoisted
// above it), and the wrapper carries the max member z-index.
const fixture = `<!doctype html><html><body><div data-composition-id="main"><div id="low" style="position:absolute;left:0;top:0;z-index:2"></div><div id="middle" style="position:absolute;left:0;top:0;z-index:3"></div><div id="high" style="position:absolute;left:0;top:0;z-index:4"></div></div></body></html>`;
const { html, matched } = wrapElementsInHtml(
fixture,
[{ id: "low" }, { id: "high" }],
"Group 1",
{ left: 0, top: 0, width: 10, height: 10 },
[
{ target: { id: "low" }, left: 0, top: 0 },
{ target: { id: "high" }, left: 0, top: 0 },
],
);
expect(matched).toBe(true);
const { document } = parseHTML(html);
const parent = document.querySelector('[data-composition-id="main"]')!;
const group = document.querySelector('[data-hf-group="Group 1"]')!;
expect(Array.from(group.children).map((c) => c.id)).toEqual(["low", "high"]);
// Non-member sits BEFORE (below) the group, not after (above) it.
const topChildren = Array.from(parent.children).map(
(c) => c.getAttribute("data-hf-group") ?? c.id,
);
expect(topChildren).toEqual(["middle", "Group 1"]);
// Wrapper adopts the topmost member's z-index (max of 2 and 4).
expect(group.getAttribute("style")).toMatch(/z-index:\s*4/);
});
it("refuses to unwrap an element without data-hf-group (no silent corruption)", () => {
const html = `<!doctype html><html><body><div data-composition-id="main"><div id="plain" style="position:absolute;left:0;top:0"><span id="kid"></span></div></div></body></html>`;
const result = unwrapElementsFromHtml(html, { id: "plain" });
expect(result.unwrapped).toBe(false);
expect(result.html).toBe(html);
});
});
@@ -2,6 +2,7 @@ import { parseHTML } from "linkedom";
import postcss from "postcss";
import selectorParser from "postcss-selector-parser";
import { isAllowedHtmlAttribute, isSafeAttributeValue } from "@hyperframes/core/html-attr-safety";
import { parseStyleDecls, patchStyleAttrString } from "./sourceStyleMutation.js";
export interface SourceMutationTarget {
id?: string | null;
@@ -123,75 +124,32 @@ export function removeElementFromHtml(source: string, target: SourceMutationTarg
return wrappedFragment ? document.body.innerHTML || "" : document.toString();
}
export function isHTMLElement(el: Element): el is HTMLElement {
const HTMLEl = el.ownerDocument.defaultView?.HTMLElement;
return HTMLEl ? el instanceof HTMLEl : "style" in el;
export function isHTMLElement(el: Node): el is HTMLElement {
const HTMLEl = el.ownerDocument?.defaultView?.HTMLElement;
return HTMLEl ? el instanceof HTMLEl : el.nodeType === 1 && "style" in el;
}
export interface PatchOperation {
type: "inline-style" | "attribute" | "html-attribute" | "text-content";
property: string;
value: string | null;
childSelector?: string;
childIndex?: number;
}
// fallow-ignore-next-line complexity
function parseStyleDecls(style: string): { props: Map<string, string>; order: string[] } {
const props = new Map<string, string>();
const order: string[] = [];
// Tokenize declarations robustly: values can contain ';' inside quoted strings
// (e.g. content: ';') and ':' inside values (data URIs, url(), etc.).
// Split on ';' only when outside quotes and balanced parens; the first ':' in
// the resulting segment is the property/value separator (property names never
// contain ':').
let i = 0;
while (i < style.length) {
let depth = 0;
let inSingle = false;
let inDouble = false;
const start = i;
while (i < style.length) {
const ch = style[i];
if (ch === "'" && !inDouble) inSingle = !inSingle;
else if (ch === '"' && !inSingle) inDouble = !inDouble;
else if (!inSingle && !inDouble) {
if (ch === "(") depth++;
else if (ch === ")") depth = Math.max(0, depth - 1);
else if (ch === ";" && depth === 0) break;
}
i++;
}
const decl = style.slice(start, i).trim();
i++; // advance past ';'
if (!decl) continue;
const colon = decl.indexOf(":");
if (colon < 0) continue;
const key = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (!key) continue;
if (!props.has(key)) order.push(key);
props.set(key, val);
interface ResolvedPatchOperation {
op: PatchOperation;
target: HTMLElement;
}
function resolveOperationTarget(parent: HTMLElement, op: PatchOperation): HTMLElement | null {
if (op.childSelector === undefined) return parent;
try {
const child = parent.querySelectorAll(op.childSelector)[op.childIndex ?? 0] ?? null;
return child && isHTMLElement(child) ? child : null;
} catch {
return null;
}
return { props, order };
}
function serializeStyleDecls(props: Map<string, string>, order: string[]): string {
return order
.map((k) => `${k}: ${props.get(k) ?? ""}`)
.filter((d) => d.trim())
.join("; ");
}
function patchStyleAttrString(style: string, property: string, value: string | null): string {
const { props, order } = parseStyleDecls(style);
if (value === null) {
props.delete(property);
const idx = order.indexOf(property);
if (idx >= 0) order.splice(idx, 1);
} else {
if (!props.has(property)) order.push(property);
props.set(property, value);
}
return serializeStyleDecls(props, order);
}
// fallow-ignore-next-line complexity
@@ -205,7 +163,14 @@ export function patchElementInHtml(
if (!el || !isHTMLElement(el)) return { html: source, matched: false };
const htmlEl = el;
const resolved: ResolvedPatchOperation[] = [];
for (const op of operations) {
const opTarget = resolveOperationTarget(htmlEl, op);
if (!opTarget) return { html: source, matched: false };
resolved.push({ op, target: opTarget });
}
for (const { op, target: opTarget } of resolved) {
switch (op.type) {
case "inline-style":
// linkedom's CSSStyleDeclaration does not support CSS custom properties
@@ -213,18 +178,18 @@ export function patchElementInHtml(
// scale) via style.setProperty(). Manipulate the style attribute string
// directly so all property names survive the round-trip.
{
const raw = htmlEl.getAttribute("style") ?? "";
const raw = opTarget.getAttribute("style") ?? "";
const patched = patchStyleAttrString(raw, op.property, op.value);
htmlEl.setAttribute("style", patched);
opTarget.setAttribute("style", patched);
}
break;
case "attribute":
{
const fullAttr = op.property.startsWith("data-") ? op.property : `data-${op.property}`;
if (op.value != null) {
htmlEl.setAttribute(fullAttr, op.value);
opTarget.setAttribute(fullAttr, op.value);
} else {
htmlEl.removeAttribute(fullAttr);
opTarget.removeAttribute(fullAttr);
}
}
break;
@@ -232,15 +197,15 @@ export function patchElementInHtml(
if (!isAllowedHtmlAttribute(op.property)) break;
if (op.value != null) {
if (!isSafeAttributeValue(op.property, op.value)) break;
htmlEl.setAttribute(op.property, op.value);
opTarget.setAttribute(op.property, op.value);
} else {
htmlEl.removeAttribute(op.property);
opTarget.removeAttribute(op.property);
}
break;
case "text-content":
if (op.value != null) {
const inner = htmlEl.children.length === 1 ? htmlEl.firstElementChild : null;
const textTarget = inner && isHTMLElement(inner) ? inner : htmlEl;
const inner = opTarget.children.length === 1 ? opTarget.firstElementChild : null;
const textTarget = inner && isHTMLElement(inner) ? inner : opTarget;
textTarget.textContent = op.value;
}
break;
@@ -333,7 +298,8 @@ export function splitElementInHtml(
const firstDuration = splitTime - start;
const secondDuration = duration - firstDuration;
const clone = el.cloneNode(true) as HTMLElement;
const clone = el.cloneNode(true);
if (!isHTMLElement(clone)) return { html: source, matched: false, newId: null };
clone.setAttribute("id", newId);
clone.removeAttribute("data-hf-id");
// Descendants carry their own data-hf-id; leaving them duplicates the id of
@@ -0,0 +1,218 @@
import { parseHTML } from "linkedom";
import { describe, expect, it } from "vitest";
import {
splitElementInHtml,
unwrapElementsFromHtml,
wrapElementsInHtml,
} from "./sourceMutation.js";
describe("splitElementInHtml — hfId clone isolation", () => {
it("does not copy data-hf-id to the cloned second half", () => {
const source = `<html><body><div data-composition-id="root"><div id="clip1" class="clip" data-start="0" data-duration="10" data-hf-id="hf-abc123"></div></div></body></html>`;
const { html, matched } = splitElementInHtml(source, { id: "clip1" }, 5, "clip2");
expect(matched).toBe(true);
const occurrences = (html.match(/data-hf-id="hf-abc123"/g) ?? []).length;
expect(occurrences).toBe(1);
});
});
describe("splitElementInHtml", () => {
const source = `<!DOCTYPE html><html><head><style>#box { position: absolute; top: 100px; background: red; }</style></head><body><div data-composition-id="root"><div id="box" class="clip" data-start="1" data-duration="6">Hello</div></div></body></html>`;
it("splits element at the given time", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.matched).toBe(true);
expect(result.html).toContain('data-duration="2"');
expect(result.html).toContain('id="box-split"');
expect(result.html).toContain('data-start="3"');
expect(result.html).toContain('data-duration="4"');
});
it("duplicates CSS rules for the new element ID", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.html).toContain("#box-split");
expect(result.html).toContain("background: red");
const cssMatches = result.html.match(/#box-split\s*\{/g);
expect(cssMatches?.length).toBeGreaterThanOrEqual(1);
});
it("deduplicates IDs when the requested newId already exists", () => {
const withExisting = source.replace(
"</div></div>",
'</div><div id="box-split" data-start="5" data-duration="1">Existing</div></div>',
);
const result = splitElementInHtml(withExisting, { id: "box" }, 3, "box-split");
expect(result.matched).toBe(true);
expect(result.html).toContain('id="box-split-2"');
});
it("keeps clip class on the cloned element", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.html).toMatch(/id="box-split"[^>]*class="clip"/);
});
it("returns matched false for out-of-range split time", () => {
expect(splitElementInHtml(source, { id: "box" }, 0.5, "box-split").matched).toBe(false);
expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false);
});
it("splits a GSAP element with no authored timing using fallback timing", () => {
const gsapSource = `<html><body><div data-composition-id="root"><h1 id="title" class="title">Hi</h1></div></body></html>`;
const result = splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split", {
start: 0,
duration: 6,
});
expect(result.matched).toBe(true);
const original = result.html.match(/<h1[^>]*\bid="title"[^>]*>/);
const clone = result.html.match(/<h1[^>]*\bid="title-split"[^>]*>/);
expect(original?.[0]).toContain('data-start="0"');
expect(original?.[0]).toContain('data-duration="2"');
expect(clone?.[0]).toContain('data-start="2"');
expect(clone?.[0]).toContain('data-duration="4"');
});
it("still rejects a no-timing element when no fallback timing is given", () => {
const gsapSource = `<html><body><div data-composition-id="root"><h1 id="title">Hi</h1></div></body></html>`;
expect(splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split").matched).toBe(false);
});
it("adjusts media playback-start for the second half", () => {
const mediaSource = source.replace(
'id="box" class="clip" data-start="1" data-duration="6"',
'id="box" class="clip" data-start="1" data-duration="6" data-playback-start="0"',
);
const result = splitElementInHtml(mediaSource, { id: "box" }, 3, "box-split");
expect(result.html).toMatch(/id="box-split"[^>]*data-playback-start="2"/);
});
});
describe("wrapElementsInHtml / unwrapElementsFromHtml", () => {
const FIXTURE = `<!doctype html><html><body><div data-composition-id="main">
<div id="title" class="clip" style="position: absolute; left: 260px; top: 100px">Title</div>
<div id="logo" class="clip" style="position: absolute; left: 300px; top: 200px; transform: translate(10px, 5px)">Logo</div>
<div id="badge" class="clip" style="position: absolute; left: 400px; top: 50px; --hf-studio-offset: 12px">Badge</div>
<div id="outside" class="clip" style="position: absolute; left: 10px; top: 10px">Outside</div>
</div></body></html>`;
const BBOX = { left: 260, top: 50, width: 300, height: 300 };
const REBASES = [
{ target: { id: "title" }, left: 0, top: 50 },
{ target: { id: "logo" }, left: 40, top: 150 },
{ target: { id: "badge" }, left: 140, top: 0 },
];
const TARGETS = [{ id: "title" }, { id: "logo" }, { id: "badge" }];
function leftTop(el: Element): { left: number; top: number } {
const style = el.getAttribute("style") ?? "";
const left = parseFloat(/(?:^|;)\s*left\s*:\s*([\d.]+)px/.exec(style)?.[1] ?? "NaN");
const top = parseFloat(/(?:^|;)\s*top\s*:\s*([\d.]+)px/.exec(style)?.[1] ?? "NaN");
return { left, top };
}
function requireElement(document: Document, selector: string): Element {
const element = document.querySelector(selector);
if (!element) throw new Error(`Expected ${selector} to match`);
return element;
}
it("wraps members in a data-hf-group div, preserving order and rebasing left/top", () => {
const { html, matched, groupId } = wrapElementsInHtml(
FIXTURE,
TARGETS,
"Group 1",
BBOX,
REBASES,
);
expect(matched).toBe(true);
expect(groupId).toBe("Group 1");
const { document } = parseHTML(html);
const group = requireElement(document, '[data-hf-group="Group 1"]');
expect(leftTop(group)).toEqual({ left: 260, top: 50 });
expect(Array.from(group.children).map((c) => c.id)).toEqual(["title", "logo", "badge"]);
expect(requireElement(document, "#outside").parentElement).toBe(
requireElement(document, '[data-composition-id="main"]'),
);
expect(leftTop(requireElement(document, "#title"))).toEqual({ left: 0, top: 50 });
expect(leftTop(requireElement(document, "#logo"))).toEqual({ left: 40, top: 150 });
expect(requireElement(document, "#logo").getAttribute("style")).toContain(
"transform: translate(10px, 5px)",
);
expect(leftTop(requireElement(document, "#badge"))).toEqual({ left: 140, top: 0 });
expect(requireElement(document, "#badge").getAttribute("style")).toContain(
"--hf-studio-offset: 12px",
);
});
it("round-trips: unwrap restores original structure and coordinates", () => {
const wrapped = wrapElementsInHtml(FIXTURE, TARGETS, "Group 1", BBOX, REBASES).html;
const { html, unwrapped } = unwrapElementsFromHtml(wrapped, {
selector: '[data-hf-group="Group 1"]',
});
expect(unwrapped).toBe(true);
const { document } = parseHTML(html);
expect(document.querySelector("[data-hf-group]")).toBeNull();
const main = requireElement(document, '[data-composition-id="main"]');
expect(Array.from(main.children).map((c) => c.id)).toEqual([
"title",
"logo",
"badge",
"outside",
]);
expect(leftTop(requireElement(document, "#title"))).toEqual({ left: 260, top: 100 });
expect(leftTop(requireElement(document, "#logo"))).toEqual({ left: 300, top: 200 });
expect(requireElement(document, "#logo").getAttribute("style")).toContain(
"transform: translate(10px, 5px)",
);
expect(leftTop(requireElement(document, "#badge"))).toEqual({ left: 400, top: 50 });
expect(requireElement(document, "#badge").getAttribute("style")).toContain(
"--hf-studio-offset: 12px",
);
});
it("rejects members that do not share a single parent", () => {
const split = `<!doctype html><html><body><div data-composition-id="main"><div id="a" style="position:absolute;left:0;top:0"></div><section><div id="b" style="position:absolute;left:0;top:0"></div></section></div></body></html>`;
const result = wrapElementsInHtml(split, [{ id: "a" }, { id: "b" }], "Group 1", BBOX, [
{ target: { id: "a" }, left: 0, top: 0 },
{ target: { id: "b" }, left: 0, top: 0 },
]);
expect(result.matched).toBe(false);
expect(result.error).toMatch(/single parent/);
expect(result.html).toBe(split);
});
it("lifts the group to the topmost member's slot so an interleaved non-member falls below it", () => {
const fixture = `<!doctype html><html><body><div data-composition-id="main"><div id="low" style="position:absolute;left:0;top:0;z-index:2"></div><div id="middle" style="position:absolute;left:0;top:0;z-index:3"></div><div id="high" style="position:absolute;left:0;top:0;z-index:4"></div></div></body></html>`;
const { html, matched } = wrapElementsInHtml(
fixture,
[{ id: "low" }, { id: "high" }],
"Group 1",
{ left: 0, top: 0, width: 10, height: 10 },
[
{ target: { id: "low" }, left: 0, top: 0 },
{ target: { id: "high" }, left: 0, top: 0 },
],
);
expect(matched).toBe(true);
const { document } = parseHTML(html);
const parent = requireElement(document, '[data-composition-id="main"]');
const group = requireElement(document, '[data-hf-group="Group 1"]');
expect(Array.from(group.children).map((c) => c.id)).toEqual(["low", "high"]);
const topChildren = Array.from(parent.children).map(
(c) => c.getAttribute("data-hf-group") ?? c.id,
);
expect(topChildren).toEqual(["middle", "Group 1"]);
expect(group.getAttribute("style")).toMatch(/z-index:\s*4/);
});
it("refuses to unwrap an element without data-hf-group (no silent corruption)", () => {
const html = `<!doctype html><html><body><div data-composition-id="main"><div id="plain" style="position:absolute;left:0;top:0"><span id="kid"></span></div></div></body></html>`;
const result = unwrapElementsFromHtml(html, { id: "plain" });
expect(result.unwrapped).toBe(false);
expect(result.html).toBe(html);
});
});
@@ -0,0 +1,58 @@
// fallow-ignore-next-line complexity
export function parseStyleDecls(style: string): { props: Map<string, string>; order: string[] } {
const props = new Map<string, string>();
const order: string[] = [];
let i = 0;
while (i < style.length) {
let depth = 0;
let inSingle = false;
let inDouble = false;
const start = i;
while (i < style.length) {
const ch = style[i];
if (ch === "'" && !inDouble) inSingle = !inSingle;
else if (ch === '"' && !inSingle) inDouble = !inDouble;
else if (!inSingle && !inDouble) {
if (ch === "(") depth++;
else if (ch === ")") depth = Math.max(0, depth - 1);
else if (ch === ";" && depth === 0) break;
}
i++;
}
const decl = style.slice(start, i).trim();
i++;
if (!decl) continue;
const colon = decl.indexOf(":");
if (colon < 0) continue;
const key = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (!key) continue;
if (!props.has(key)) order.push(key);
props.set(key, val);
}
return { props, order };
}
function serializeStyleDecls(props: Map<string, string>, order: string[]): string {
return order
.map((k) => `${k}: ${props.get(k) ?? ""}`)
.filter((d) => d.trim())
.join("; ");
}
export function patchStyleAttrString(
style: string,
property: string,
value: string | null,
): string {
const { props, order } = parseStyleDecls(style);
if (value === null) {
props.delete(property);
const idx = order.indexOf(property);
if (idx >= 0) order.splice(idx, 1);
} else {
if (!props.has(property)) order.push(property);
props.set(property, value);
}
return serializeStyleDecls(props, order);
}
+1
View File
@@ -8,6 +8,7 @@ export default defineConfig({
"helpers/studioMotionRenderScript": "src/helpers/studioMotionRenderScript.ts",
"helpers/draftMarkers": "src/helpers/draftMarkers.ts",
"helpers/finiteMutation": "src/helpers/finiteMutation.ts",
"helpers/sourceMutation": "src/helpers/sourceMutation.ts",
},
format: ["esm"],
outDir: "dist",