diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc
index 75d0064561..f9714b15a6 100644
--- a/.fallowrc.jsonc
+++ b/.fallowrc.jsonc
@@ -36,6 +36,9 @@
"packages/producer/src/services/__fixtures__/crashOnMessageWorker.mjs",
"scripts/*.{ts,mjs,js}",
"scripts/*/run.mjs",
+ // Standalone agent-browser e2e smoke, run by hand per its header — no
+ // import-graph referrer.
+ "packages/studio/tests/e2e/design-panel.mjs",
// Keyframe UI components — wired dynamically via EaseCurveSection/MotionPanel.
"packages/studio/src/components/editor/KeyframeDiamond.tsx",
"packages/studio/src/components/editor/SpringEaseEditor.tsx",
@@ -263,6 +266,10 @@
// require intrusive middleware changes beyond this PR's scope.
"minLines": 6,
"ignore": [
+ // sourcePatcher.ts: pre-existing internal clones between the inline-style
+ // and attribute tag-patchers; only the PatchOperation type gained two
+ // optional fields here, but the line shift makes fallow re-flag them.
+ "packages/studio/src/utils/sourcePatcher.ts",
// gsapParser.ts: recast/babel GSAP writer — intentional duplication between
// recast and acorn parallel implementations (pre-existing, moved from core).
"packages/parsers/src/gsapParser.ts",
@@ -363,6 +370,11 @@
// complexity pre-dates the computed-timeline work. Exempted at file level
// rather than refactored as scope creep.
"ignore": [
+ // sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations /
+ // patch*InTag pre-date this PR; only the PatchOperation type gained two
+ // optional fields, but the line-shift fingerprint re-flags the inherited
+ // complexity.
+ "packages/studio/src/utils/sourcePatcher.ts",
// timeline.ts: collectRuntimeTimelinePayload (CRITICAL) pre-dates this PR;
// only an import line changed here (slideshow/sceneId → slideshow/index),
// but the line-shift fingerprint makes fallow re-flag inherited complexity.
diff --git a/packages/studio-server/package.json b/packages/studio-server/package.json
index 010158ec73..6c1a5e3b9e 100644
--- a/packages/studio-server/package.json
+++ b/packages/studio-server/package.json
@@ -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",
diff --git a/packages/studio-server/src/helpers/sourceMutation.test.ts b/packages/studio-server/src/helpers/sourceMutation.test.ts
index fd28dca529..4f7f945ff3 100644
--- a/packages/studio-server/src/helpers/sourceMutation.test.ts
+++ b/packages/studio-server/src/helpers/sourceMutation.test.ts
@@ -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 = `
AB
`;
+ 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 = `AB
`;
+ 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 = `AB
`;
+ 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 = ``;
- 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 = ``;
-
- 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(
- "",
- 'Existing
',
- );
- 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 = `Hi
`;
- 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(/]*\bid="title"[^>]*>/)![0];
- expect(original).toContain('data-start="0"');
- expect(original).toContain('data-duration="2"');
- const clone = result.html.match(/]*\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 = `
Hi
`;
- 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 = `
-
Title
-
Logo
-
Badge
-
Outside
-
`;
-
- // 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 = ``;
- 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 = ``;
- 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 = ``;
- const result = unwrapElementsFromHtml(html, { id: "plain" });
- expect(result.unwrapped).toBe(false);
- expect(result.html).toBe(html);
- });
-});
diff --git a/packages/studio-server/src/helpers/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts
index 62cbcc62d8..f188dac4f0 100644
--- a/packages/studio-server/src/helpers/sourceMutation.ts
+++ b/packages/studio-server/src/helpers/sourceMutation.ts
@@ -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; order: string[] } {
- const props = new Map();
- 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);
- }
- return { props, order };
-}
-
-function serializeStyleDecls(props: Map, order: string[]): string {
- return order
- .map((k) => `${k}: ${props.get(k) ?? ""}`)
- .filter((d) => d.trim())
- .join("; ");
+interface ResolvedPatchOperation {
+ op: PatchOperation;
+ target: HTMLElement;
}
-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);
+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 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
diff --git a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts
new file mode 100644
index 0000000000..6bd4d92dcb
--- /dev/null
+++ b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts
@@ -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 = ``;
+ 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 = ``;
+
+ 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(
+ "",
+ 'Existing
',
+ );
+ 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 = `Hi
`;
+ const result = splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split", {
+ start: 0,
+ duration: 6,
+ });
+ expect(result.matched).toBe(true);
+ const original = result.html.match(/]*\bid="title"[^>]*>/);
+ const clone = result.html.match(/]*\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 = `
Hi
`;
+ 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 = `
+
Title
+
Logo
+
Badge
+
Outside
+
`;
+
+ 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 = ``;
+ 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 = ``;
+ 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 = ``;
+ const result = unwrapElementsFromHtml(html, { id: "plain" });
+ expect(result.unwrapped).toBe(false);
+ expect(result.html).toBe(html);
+ });
+});
diff --git a/packages/studio-server/src/helpers/sourceStyleMutation.ts b/packages/studio-server/src/helpers/sourceStyleMutation.ts
new file mode 100644
index 0000000000..fdb21371f8
--- /dev/null
+++ b/packages/studio-server/src/helpers/sourceStyleMutation.ts
@@ -0,0 +1,58 @@
+// fallow-ignore-next-line complexity
+export function parseStyleDecls(style: string): { props: Map; order: string[] } {
+ const props = new Map();
+ 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, 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);
+}
diff --git a/packages/studio-server/tsup.config.ts b/packages/studio-server/tsup.config.ts
index 53aeca203f..296c20ff3f 100644
--- a/packages/studio-server/tsup.config.ts
+++ b/packages/studio-server/tsup.config.ts
@@ -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",
diff --git a/packages/studio/src/components/editor/DomEditOverlay.test.ts b/packages/studio/src/components/editor/DomEditOverlay.test.ts
index 468bebfdfe..db1f2291bc 100644
--- a/packages/studio/src/components/editor/DomEditOverlay.test.ts
+++ b/packages/studio/src/components/editor/DomEditOverlay.test.ts
@@ -345,6 +345,101 @@ describe("DomEditOverlay", () => {
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
host.remove();
});
+
+ it("passes the tracked hover selection when clicking the existing selection box", async () => {
+ const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
+ Element.prototype.getBoundingClientRect = function (): DOMRect {
+ return {
+ left: 0,
+ top: 0,
+ right: 800,
+ bottom: 450,
+ width: 800,
+ height: 450,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ };
+ };
+
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ const selection: DomEditSelection = {
+ element: document.createElement("div"),
+ id: "hero-title",
+ selector: ".hero-title",
+ selectorIndex: 0,
+ sourceFile: "index.html",
+ tagName: "div",
+ label: "Hero Title",
+ textContent: "Hello",
+ textFields: [],
+ capabilities: {
+ canEditText: true,
+ canEditLayout: true,
+ canMove: false,
+ canApplyManualOffset: false,
+ canApplyManualSize: false,
+ canApplyManualRotation: false,
+ canAdjustOpacity: true,
+ canAdjustFill: true,
+ canAdjustBorderRadius: true,
+ canAdjustStroke: true,
+ canAdjustShadow: true,
+ canAdjustZIndex: true,
+ },
+ computedStyle: {
+ display: "block",
+ position: "absolute",
+ },
+ };
+ const hoverSelection: DomEditSelection = { ...selection, id: "hovered-sibling" };
+ const onCanvasMouseDown = vi.fn();
+ const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
+
+ function Harness() {
+ return React.createElement(DomEditOverlay, {
+ ...createOverlayProps({
+ iframeRef,
+ selection,
+ hoverSelection,
+ onSelectionChange: () => {},
+ }),
+ onCanvasMouseDown,
+ });
+ }
+
+ act(() => {
+ root.render(React.createElement(Harness));
+ });
+
+ await act(async () => {
+ await new Promise((resolve) => {
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
+ });
+ });
+
+ const selectionBox = host.querySelector(
+ '[data-dom-edit-selection-box="true"]',
+ ) as HTMLDivElement;
+ expect(selectionBox).toBeTruthy();
+
+ act(() => {
+ selectionBox.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(onCanvasMouseDown).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ hoverSelection }),
+ );
+
+ act(() => {
+ root.unmount();
+ });
+ Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
+ host.remove();
+ });
});
describe("resolveDomEditCoordinateScale", () => {
diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx
index f0861be649..48e563a203 100644
--- a/packages/studio/src/components/editor/DomEditOverlay.tsx
+++ b/packages/studio/src/components/editor/DomEditOverlay.tsx
@@ -1,6 +1,7 @@
import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { type DomEditSelection } from "./domEditing";
+import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
import { useMarqueeGestures } from "./marqueeCommit";
import { MarqueeOverlay } from "./MarqueeOverlay";
import { groupAwareOverlayRect, resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
@@ -44,7 +45,7 @@ interface DomEditOverlayProps {
allowCanvasMovement?: boolean;
onCanvasMouseDown: (
event: React.MouseEvent,
- options?: { preferClipAncestor?: boolean },
+ options?: PreviewMouseDownOptions,
) => void;
onCanvasPointerMove: (
event: React.PointerEvent,
@@ -277,6 +278,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
iframeRef,
boxRef,
selectionRef,
+ hoverSelectionRef,
overlayRectRef,
groupOverlayItemsRef,
gestureRef,
@@ -336,7 +338,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
// Allow clicks anywhere on the overlay — GSAP-translated elements can
// extend beyond the composition rect into the gray zone, and users need
// to select/deselect them by clicking there.
- onCanvasMouseDown(event, { preferClipAncestor: false });
+ onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current });
if (event.shiftKey) {
suppressNextBoxMouseDownRef.current = true;
suppressNextBoxClickRef.current = true;
@@ -401,7 +403,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
event.stopPropagation();
return;
}
- onCanvasMouseDown(event, { preferClipAncestor: false });
+ onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current });
};
const suppressBoxMouseDown = (e: React.MouseEvent) => {
diff --git a/packages/studio/src/components/editor/domEditOverlayGestures.ts b/packages/studio/src/components/editor/domEditOverlayGestures.ts
index d693e974a9..7f3c63c805 100644
--- a/packages/studio/src/components/editor/domEditOverlayGestures.ts
+++ b/packages/studio/src/components/editor/domEditOverlayGestures.ts
@@ -9,6 +9,7 @@ import type { ManualOffsetDragMember } from "./manualOffsetDrag";
import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
import type { SnapContext } from "./snapTargetCollection";
import type { SnapGuidesState } from "./SnapGuideOverlay";
+import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
export type GestureKind = "drag" | "resize" | "rotate";
@@ -161,6 +162,7 @@ export type UseDomEditOverlayGesturesOptions = {
iframeRef: RefObject;
boxRef: RefObject;
selectionRef: RefObject;
+ hoverSelectionRef: RefObject;
overlayRectRef: RefObject;
groupOverlayItemsRef: RefObject;
gestureRef: RefObject;
@@ -194,9 +196,6 @@ export type UseDomEditOverlayGesturesOptions = {
o?: { preferClipAncestor?: boolean },
) => Promise
>;
- onCanvasMouseDown: (
- e: React.MouseEvent,
- o?: { preferClipAncestor?: boolean },
- ) => void;
+ onCanvasMouseDown: (e: React.MouseEvent, o?: PreviewMouseDownOptions) => void;
snapGuidesRef: RefObject;
};
diff --git a/packages/studio/src/components/editor/domEditing.ts b/packages/studio/src/components/editor/domEditing.ts
index 45927cfe75..1dd6a1ffa7 100644
--- a/packages/studio/src/components/editor/domEditing.ts
+++ b/packages/studio/src/components/editor/domEditing.ts
@@ -31,6 +31,7 @@ export {
buildDomEditTextPatchOperation,
collectDomEditLayerItems,
countDomEditChildLayers,
+ buildTextFieldChildLocator,
getDomEditLayerKey,
getDomEditNonEditableReason,
getDomEditTargetKey,
diff --git a/packages/studio/src/components/editor/domEditingLayers.test.ts b/packages/studio/src/components/editor/domEditingLayers.test.ts
index 04099ae919..b817289940 100644
--- a/packages/studio/src/components/editor/domEditingLayers.test.ts
+++ b/packages/studio/src/components/editor/domEditingLayers.test.ts
@@ -4,11 +4,27 @@ import {
collectDomEditLayerItems,
resolveDomEditSelection,
buildDomEditPatchTarget,
+ buildTextFieldChildLocator,
readHfId,
} from "./domEditingLayers";
+import type { DomEditTextField } from "./domEditingTypes";
const opts = { activeCompositionPath: "index.html", isMasterView: true, skipSourceProbe: true };
+function textField(overrides: Partial = {}): DomEditTextField {
+ return {
+ key: "child:0:span",
+ label: "Text 1",
+ value: "Hello",
+ tagName: "span",
+ attributes: [],
+ inlineStyles: {},
+ computedStyles: {},
+ source: "child",
+ ...overrides,
+ };
+}
+
describe("buildDomEditPatchTarget", () => {
it("includes hfId when selection has hfId", () => {
const target = buildDomEditPatchTarget({
@@ -172,3 +188,39 @@ describe("resolveDomEditSelection — data-hf-group capture", () => {
expect(selection?.id).toBe("outside");
});
});
+
+describe("buildTextFieldChildLocator", () => {
+ it("locates a child field using its DOM-derived sourceChildIndex", () => {
+ const fields = [textField({ key: "child:0:span", sourceChildIndex: 0 })];
+
+ expect(buildTextFieldChildLocator(fields, "child:0:span")).toEqual({
+ childSelector: ":scope > span",
+ childIndex: 0,
+ });
+ });
+
+ it("fails closed for a synthetic child field with no sourceChildIndex", () => {
+ // A field built by buildDefaultDomEditTextField (e.g. "add text field")
+ // has never been read back from the live DOM, so its true position among
+ // same-tag siblings is unknown. Guessing it by counting same-tag "child"
+ // fields elsewhere in the array can silently point at the wrong element.
+ const fields = [
+ textField({ key: "child:0:span", sourceChildIndex: 0 }),
+ textField({ key: "child:new:1", tagName: "span" }),
+ ];
+
+ expect(buildTextFieldChildLocator(fields, "child:new:1")).toBeNull();
+ });
+
+ it("returns null for a self-sourced field", () => {
+ const fields = [textField({ key: "self:0:div", source: "self", sourceChildIndex: 0 })];
+
+ expect(buildTextFieldChildLocator(fields, "self:0:div")).toBeNull();
+ });
+
+ it("returns null for an unknown field key", () => {
+ const fields = [textField({ key: "child:0:span", sourceChildIndex: 0 })];
+
+ expect(buildTextFieldChildLocator(fields, "missing")).toBeNull();
+ });
+});
diff --git a/packages/studio/src/components/editor/domEditingLayers.ts b/packages/studio/src/components/editor/domEditingLayers.ts
index 787444e8d4..3102d030f8 100644
--- a/packages/studio/src/components/editor/domEditingLayers.ts
+++ b/packages/studio/src/components/editor/domEditingLayers.ts
@@ -1,7 +1,3 @@
-/**
- * Layer items, text fields, capabilities, selection resolution, and patch operations
- * for dom editing.
- */
import type { PatchOperation } from "../../utils/sourcePatcher";
import {
resolveEditingAffordances,
@@ -36,12 +32,20 @@ import {
} from "./domEditingElement";
import { isCompositionRootLayer } from "./domEditingRootLayer";
-// ─── Text fields ────────────────────────────────────────────────────────────
-
export function isEditableTextLeaf(el: HTMLElement): boolean {
return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
}
+function sameTagChildIndex(el: HTMLElement): number {
+ let index = 0;
+ let sibling = el.previousElementSibling;
+ while (sibling) {
+ if (sibling.tagName === el.tagName) index += 1;
+ sibling = sibling.previousElementSibling;
+ }
+ return index;
+}
+
function getTextFieldLabel(
_tagName: string,
index: number,
@@ -57,6 +61,7 @@ function buildTextField(
index: number,
total: number,
source: "self" | "child",
+ sourceChildIndex?: number,
): DomEditTextField {
const tagName = el.tagName.toLowerCase();
const key = el.getAttribute("data-hf-text-key") ?? `${source}:${index}:${tagName}`;
@@ -74,6 +79,7 @@ function buildTextField(
inlineStyles: getInlineStyles(el),
computedStyles: getCuratedComputedStyles(el),
source,
+ ...(sourceChildIndex == null ? {} : { sourceChildIndex }),
};
}
@@ -105,7 +111,9 @@ export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
});
childIdx++;
} else if (isHtmlElement(node) && isEditableTextLeaf(node)) {
- fields.push(buildTextField(node, childIdx, childElements.length, "child"));
+ fields.push(
+ buildTextField(node, childIdx, childElements.length, "child", sameTagChildIndex(node)),
+ );
childIdx++;
}
}
@@ -113,7 +121,7 @@ export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
}
return childElements.map((child, index) =>
- buildTextField(child, index, childElements.length, "child"),
+ buildTextField(child, index, childElements.length, "child", sameTagChildIndex(child)),
);
}
@@ -172,14 +180,30 @@ export function buildDefaultDomEditTextField(base?: Partial):
};
}
-// ─── Capabilities ────────────────────────────────────────────────────────────
+export interface DomEditChildLocator {
+ childSelector: string;
+ childIndex: number;
+}
+
+export function buildTextFieldChildLocator(
+ fields: DomEditTextField[],
+ fieldKey: string,
+): DomEditChildLocator | null {
+ const field = fields.find((candidate) => candidate.key === fieldKey);
+ if (!field || field.source !== "child") return null;
+ // sourceChildIndex is only absent for a synthetic field that was never read
+ // back from the live DOM (e.g. one built by buildDefaultDomEditTextField).
+ // Guessing its position by counting same-tag "child" fields elsewhere in
+ // the array is unreliable and can silently locate the wrong element — fail
+ // closed instead so the caller falls back to the unsupported-structure path.
+ if (field.sourceChildIndex == null) return null;
+
+ return {
+ childSelector: `:scope > ${field.tagName}`,
+ childIndex: field.sourceChildIndex,
+ };
+}
-/**
- * Build the geometry/capability half of EditableElementFacts. Section inputs
- * (text/timing/animation) are irrelevant to capability resolution, so they are
- * zeroed here. Shared by the wrapper and the live-selection path so the two
- * fact-construction sites can't disagree.
- */
function capabilityFacts(geometry: {
hasStableTarget: boolean;
tag: string;
@@ -276,8 +300,11 @@ async function probeSourceElement(
},
);
if (!response.ok) return true;
- const data = (await response.json()) as { exists?: boolean };
- return data.exists !== false;
+ const data = await response.json();
+ if (data && typeof data === "object" && "exists" in data && data.exists === false) {
+ return false;
+ }
+ return true;
} catch {
return true;
}
@@ -475,19 +502,28 @@ export function collectDomEditLayerItems(
// ─── Patch operations ────────────────────────────────────────────────────────
-export function buildDomEditStylePatchOperation(property: string, value: string): PatchOperation {
+export function buildDomEditStylePatchOperation(
+ property: string,
+ value: string | null,
+ childLocator?: DomEditChildLocator,
+): PatchOperation {
return {
type: "inline-style",
property,
value,
+ ...childLocator,
};
}
-export function buildDomEditTextPatchOperation(value: string): PatchOperation {
+export function buildDomEditTextPatchOperation(
+ value: string,
+ childLocator?: DomEditChildLocator,
+): PatchOperation {
return {
type: "text-content",
property: "text",
value,
+ ...childLocator,
};
}
diff --git a/packages/studio/src/components/editor/domEditingTypes.ts b/packages/studio/src/components/editor/domEditingTypes.ts
index d6ec3cafd8..c25ce19957 100644
--- a/packages/studio/src/components/editor/domEditingTypes.ts
+++ b/packages/studio/src/components/editor/domEditingTypes.ts
@@ -69,6 +69,7 @@ export interface DomEditTextField {
inlineStyles: Record;
computedStyles: Record;
source: "self" | "child" | "text-node";
+ sourceChildIndex?: number;
}
export interface DomEditSelection extends PatchTarget {
diff --git a/packages/studio/src/components/editor/persistSeam.integration.test.ts b/packages/studio/src/components/editor/persistSeam.integration.test.ts
new file mode 100644
index 0000000000..e3ff608861
--- /dev/null
+++ b/packages/studio/src/components/editor/persistSeam.integration.test.ts
@@ -0,0 +1,264 @@
+// @vitest-environment jsdom
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ patchElementInHtml,
+ type PatchOperation,
+ type SourceMutationTarget,
+} from "@hyperframes/studio-server/source-mutation";
+import { describe, expect, it } from "vitest";
+import {
+ collectDomEditTextFields,
+ buildDomEditPatchTarget,
+ buildDomEditStylePatchOperation,
+ buildDomEditTextPatchOperation,
+} from "./domEditingLayers";
+import { buildPathOffsetPatches } from "./manualEditsDomPatches";
+import { STUDIO_OFFSET_X_PROP, STUDIO_PATH_OFFSET_ATTR } from "./manualEditsTypes";
+import { makeSelection } from "../../hooks/domSelectionTestHarness";
+import { buildTextFieldChildOperations } from "../../hooks/domEditTextFieldCommitOps";
+
+const testDir = dirname(fileURLToPath(import.meta.url));
+const fixtureDir = join(testDir, "../../../tests/e2e/fixtures/design-panel-qa");
+
+function readFixture(relativePath: string): string {
+ return readFileSync(join(fixtureDir, relativePath), "utf-8");
+}
+
+function createSelection(input: {
+ id: string;
+ hfId: string;
+ tagName: string;
+}): ReturnType {
+ const element = document.createElement(input.tagName);
+ element.id = input.id;
+ element.setAttribute("data-hf-id", input.hfId);
+ return {
+ ...makeSelection(input.id, element),
+ hfId: input.hfId,
+ };
+}
+
+function clientTarget(input: { id: string; hfId: string; tagName: string }): SourceMutationTarget {
+ return buildDomEditPatchTarget(createSelection(input));
+}
+
+function patchAndExpectChange(
+ sourceHtml: string,
+ target: SourceMutationTarget,
+ operations: PatchOperation[],
+): string {
+ const result = patchElementInHtml(sourceHtml, target, operations);
+ expect(result.matched).toBe(true);
+ expect(result.html).not.toBe(sourceHtml);
+ return result.html;
+}
+
+function parseHtml(html: string): Document {
+ return new DOMParser().parseFromString(html, "text/html");
+}
+
+function findElementInHtml(html: string, selector: string): Element {
+ const document = parseHtml(html);
+ const directMatch = document.querySelector(selector);
+ if (directMatch) return directMatch;
+
+ for (const template of Array.from(document.querySelectorAll("template"))) {
+ const templateMatch = template.content.querySelector(selector);
+ if (templateMatch) return templateMatch;
+ }
+
+ throw new Error(`Expected selector ${selector} to match`);
+}
+
+function findByHfId(html: string, hfId: string): Element {
+ return findElementInHtml(html, `[data-hf-id="${hfId}"]`);
+}
+
+function countOccurrences(value: string, needle: string): number {
+ return value.split(needle).length - 1;
+}
+
+describe("persist seam source mutation", () => {
+ const indexHtml = readFixture("index.html");
+ const subHtml = readFixture("compositions/qa-sub.html");
+
+ it("persists qa-headline text font-size style operation", () => {
+ const html = patchAndExpectChange(
+ indexHtml,
+ clientTarget({ id: "qa-headline", hfId: "qa-headline", tagName: "h1" }),
+ [buildDomEditStylePatchOperation("font-size", "64px")],
+ );
+
+ expect(findByHfId(html, "qa-headline").getAttribute("style")).toContain("font-size: 64px");
+ });
+
+ it("persists qa-shape fill style operation", () => {
+ const html = patchAndExpectChange(
+ indexHtml,
+ clientTarget({ id: "qa-shape", hfId: "qa-shape", tagName: "div" }),
+ [buildDomEditStylePatchOperation("background-color", "#ff0000")],
+ );
+
+ expect(findByHfId(html, "qa-shape").getAttribute("style")).toContain(
+ "background-color: #ff0000",
+ );
+ });
+
+ it("persists qa-multi text color style operation", () => {
+ const html = patchAndExpectChange(
+ indexHtml,
+ clientTarget({ id: "qa-multi", hfId: "qa-multi", tagName: "div" }),
+ [buildDomEditStylePatchOperation("color", "#00ff00")],
+ );
+
+ expect(findByHfId(html, "qa-multi").getAttribute("style")).toContain("color: #00ff00");
+ });
+
+ it("persists qa-image opacity style operation", () => {
+ const html = patchAndExpectChange(
+ indexHtml,
+ clientTarget({ id: "qa-image", hfId: "qa-image", tagName: "img" }),
+ [buildDomEditStylePatchOperation("opacity", "0.4")],
+ );
+
+ expect(findByHfId(html, "qa-image").getAttribute("style")).toContain("opacity: 0.4");
+ });
+
+ it("persists detached jsdom path offset operations", () => {
+ const element = document.createElement("div");
+ element.style.setProperty(STUDIO_OFFSET_X_PROP, "24px");
+
+ const html = patchAndExpectChange(
+ indexHtml,
+ clientTarget({ id: "qa-shape", hfId: "qa-shape", tagName: "div" }),
+ buildPathOffsetPatches(element),
+ );
+ const shape = findByHfId(html, "qa-shape");
+
+ expect(shape.getAttribute("style")).toContain(`${STUDIO_OFFSET_X_PROP}: 24px`);
+ expect(shape.getAttribute("style")).toContain("translate: var(--hf-studio-offset-x, 0px)");
+ expect(shape.getAttribute(STUDIO_PATH_OFFSET_ATTR)).toBe("true");
+ });
+
+ it("persists timeline data-start attribute operation", () => {
+ const html = patchAndExpectChange(
+ indexHtml,
+ clientTarget({ id: "qa-zone-headline", hfId: "qa-zone-headline", tagName: "div" }),
+ [{ type: "attribute", property: "start", value: "2.5" }],
+ );
+
+ expect(findByHfId(html, "qa-zone-headline").getAttribute("data-start")).toBe("2.5");
+ expect(countOccurrences(html, 'data-start="2.5"')).toBe(1);
+ });
+
+ it("persists media volume data attribute operation", () => {
+ const html = patchAndExpectChange(
+ indexHtml,
+ clientTarget({ id: "qa-video", hfId: "qa-video", tagName: "video" }),
+ [{ type: "attribute", property: "volume", value: "0.75" }],
+ );
+
+ expect(findByHfId(html, "qa-video").getAttribute("data-volume")).toBe("0.75");
+ expect(html).not.toContain('data-volume="0.5"');
+ });
+
+ it("returns matched false and unchanged html for a missing hfId target", () => {
+ const result = patchElementInHtml(indexHtml, { hfId: "qa-does-not-exist" }, [
+ buildDomEditStylePatchOperation("font-size", "64px"),
+ ]);
+
+ expect(result.matched).toBe(false);
+ expect(result.html).toBe(indexHtml);
+ });
+
+ it("persists sub-composition child style operation inside a template", () => {
+ const html = patchAndExpectChange(
+ subHtml,
+ clientTarget({ id: "qa-sub-title", hfId: "qa-sub-title", tagName: "h2" }),
+ [buildDomEditStylePatchOperation("font-size", "50px")],
+ );
+
+ expect(findByHfId(html, "qa-sub-title").getAttribute("style")).toContain("font-size: 50px");
+ });
+
+ it("returns matched false for runtime-generated caption words absent from static source", () => {
+ const result = patchElementInHtml(
+ indexHtml,
+ { selector: "#qa-caption-host span", selectorIndex: 0 },
+ [buildDomEditStylePatchOperation("color", "#ffffff")],
+ );
+
+ expect(result.matched).toBe(false);
+ expect(result.html).toBe(indexHtml);
+ });
+
+ it("fixes U4: child text-field style persists as an inline style on the correct child span", () => {
+ const html = patchAndExpectChange(indexHtml, { hfId: "qa-multi" }, [
+ buildDomEditStylePatchOperation("color", "#0000ff", {
+ childSelector: ":scope > span",
+ childIndex: 0,
+ }),
+ ]);
+
+ const lineA = findElementInHtml(html, ".qa-line-a");
+ const lineB = findElementInHtml(html, ".qa-line-b");
+ expect(lineA.getAttribute("style")).toContain("color: #0000ff");
+ expect(lineB.getAttribute("style")).toBeNull();
+ expect(lineB.textContent).toBe("Second styled line");
+ expect(html).not.toContain("<span");
+ });
+
+ it("targets the second direct child when siblings share the same tag and class", () => {
+ const source = `FirstSecond
`;
+ const html = patchAndExpectChange(source, { hfId: "dups" }, [
+ buildDomEditStylePatchOperation("color", "#0000ff", {
+ childSelector: ":scope > span",
+ childIndex: 1,
+ }),
+ ]);
+
+ const document = parseHtml(html);
+ const spans = Array.from(document.querySelectorAll(".dup"));
+ expect(spans[0]?.getAttribute("style")).toBeNull();
+ expect(spans[1]?.getAttribute("style")).toContain("color: #0000ff");
+ });
+
+ it("persists a child text-field content edit as plain text", () => {
+ const value = "A < B & C";
+ const html = patchAndExpectChange(indexHtml, { hfId: "qa-multi" }, [
+ buildDomEditTextPatchOperation(value, {
+ childSelector: ":scope > span",
+ childIndex: 0,
+ }),
+ ]);
+
+ expect(findElementInHtml(html, ".qa-line-a").textContent).toBe(value);
+ expect(findElementInHtml(html, ".qa-line-b").textContent).toBe("Second styled line");
+ expect(html).not.toContain("<span");
+ });
+
+ it("uses same-tag source child indexes when a non-leaf sibling sits between fields", () => {
+ const source = `FirstWrapperSecond
`;
+ const previewHost = document.createElement("div");
+ previewHost.innerHTML = source;
+ const previewTarget = previewHost.querySelector('[data-hf-id="mixed"]');
+ if (!(previewTarget instanceof HTMLElement)) throw new Error("Expected preview target");
+
+ const originalFields = collectDomEditTextFields(previewTarget);
+ const secondField = originalFields.find((field) => field.value === "Second");
+ if (!secondField) throw new Error("Expected second text field");
+ const nextFields = originalFields.map((field) =>
+ field.key === secondField.key ? { ...field, value: "Second updated" } : field,
+ );
+ const operations = buildTextFieldChildOperations(originalFields, nextFields);
+ if (!operations) throw new Error("Expected child operations");
+
+ const html = patchAndExpectChange(source, { hfId: "mixed" }, operations);
+
+ expect(findElementInHtml(html, ".leaf-a").textContent).toBe("First");
+ expect(findElementInHtml(html, ".wrapper").textContent).toBe("Wrapper");
+ expect(findElementInHtml(html, ".leaf-b").textContent).toBe("Second updated");
+ });
+});
diff --git a/packages/studio/src/components/editor/propertyPanelPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelPrimitives.tsx
index 554365c21a..ae12435607 100644
--- a/packages/studio/src/components/editor/propertyPanelPrimitives.tsx
+++ b/packages/studio/src/components/editor/propertyPanelPrimitives.tsx
@@ -371,7 +371,10 @@ export function Section({
);
return (
-
+