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