mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat: Persist Studio manual edits via manifest (#593)
## Summary Studio manual geometry edits now persist as a project-local manifest instead of being baked into composition source on each gesture. The manifest lives at: ```text .hyperframes/studio-manual-edits.json ``` It is the source of truth for manual drag, resize, rotation, inspector geometry edits, group moves, and selected-layer reset. ## Architecture - **Manifest-backed edits**: each edit stores a kind (`path-offset`, `box-size`, `rotation`), a source-scoped target, and the edit values. - **Source-scoped resolution**: targets include `sourceFile`, `id`, `selector`, and `selectorIndex`, so duplicate selectors in nested compositions resolve against the owning source file. - **Additive CSS layer**: move uses CSS `translate`, resize writes stable dimensions/flex sizing, and rotation uses CSS `rotate` over the authored base. - **Shared replay runtime**: Studio preview, thumbnails, frame capture, producer renders, and CLI Studio renders/thumbnails all use the same core manual-edit render script. - **Animation-safe replay**: Studio reapplies the manual layer after load, refresh, timeline seeks, player operations, playback frames, thumbnail seeks, and render seeks instead of rewriting GSAP timelines. - **History and handoff**: the manifest is a normal project file, so undo/redo and agent edits can preserve, modify, or remove manual visual edits explicitly. ## User Impact Users can move, resize, rotate, group-move, and reset supported layers from the canvas or inspector, then refresh, capture thumbnails/screenshots, play animated compositions, and render videos without manual edits drifting away from the edited state. ## Main Files - `packages/studio/src/components/editor/manualEdits.ts` - `packages/studio/src/components/editor/DomEditOverlay.tsx` - `packages/studio/src/components/editor/PropertyPanel.tsx` - `packages/studio/src/App.tsx` - `packages/core/src/studio-api/helpers/manualEditsRenderScript.ts` - `packages/studio/vite.config.ts` - `packages/cli/src/server/studioServer.ts` - `packages/core/src/compiler/htmlBundler.ts` - `packages/producer/src/services/htmlCompiler.ts` - `packages/core/src/studio-api/routes/thumbnail.ts` - `packages/producer/src/services/fileServer.ts` - `packages/producer/src/services/renderOrchestrator.ts` ## Test Plan ```bash volta run --node 22.20.0 bun run build volta run --node 22.20.0 bun run --filter @hyperframes/core test -- src/studio-api/helpers/manualEditsRenderScript.test.ts volta run --node 22.20.0 bun run --filter @hyperframes/core typecheck volta run --node 22.20.0 bun run --filter @hyperframes/studio typecheck volta run --node 22.20.0 bun run --filter @hyperframes/cli typecheck volta run --node 22.20.0 bunx oxlint <changed files> volta run --node 22.20.0 bunx oxfmt --check <changed files> git diff --check ```
This commit is contained in:
@@ -0,0 +1,669 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Window } from "happy-dom";
|
||||
import {
|
||||
buildDomEditStylePatchOperation,
|
||||
buildElementAgentPrompt,
|
||||
findElementForSelection,
|
||||
getDomEditNonEditableReason,
|
||||
getDomEditTargetKey,
|
||||
isTextEditableSelection,
|
||||
serializeDomEditTextFields,
|
||||
type DomEditSelection,
|
||||
resolveDomEditCapabilities,
|
||||
resolveDomEditSelection,
|
||||
} from "./domEditing";
|
||||
|
||||
function createDocument(markup: string): Document {
|
||||
const window = new Window();
|
||||
Object.assign(window, { SyntaxError });
|
||||
window.document.body.innerHTML = markup;
|
||||
return window.document;
|
||||
}
|
||||
|
||||
describe("resolveDomEditCapabilities", () => {
|
||||
it("marks absolute px-positioned layers as movable and resizable", () => {
|
||||
expect(
|
||||
resolveDomEditCapabilities({
|
||||
selector: "#card",
|
||||
inlineStyles: {
|
||||
left: "120px",
|
||||
top: "80px",
|
||||
width: "240px",
|
||||
height: "140px",
|
||||
},
|
||||
computedStyles: {
|
||||
position: "absolute",
|
||||
left: "120px",
|
||||
top: "80px",
|
||||
width: "240px",
|
||||
height: "140px",
|
||||
transform: "none",
|
||||
},
|
||||
isCompositionHost: false,
|
||||
isMasterView: false,
|
||||
}),
|
||||
).toEqual({
|
||||
canSelect: true,
|
||||
canEditStyles: true,
|
||||
canMove: true,
|
||||
canResize: true,
|
||||
canApplyManualOffset: true,
|
||||
canApplyManualSize: true,
|
||||
canApplyManualRotation: true,
|
||||
reasonIfDisabled: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects flex/grid children for move and resize", () => {
|
||||
expect(
|
||||
resolveDomEditCapabilities({
|
||||
selector: "#chip",
|
||||
tagName: "div",
|
||||
inlineStyles: {},
|
||||
computedStyles: {
|
||||
position: "static",
|
||||
display: "block",
|
||||
left: "auto",
|
||||
top: "auto",
|
||||
width: "180px",
|
||||
height: "64px",
|
||||
transform: "none",
|
||||
},
|
||||
isCompositionHost: false,
|
||||
isMasterView: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
canSelect: true,
|
||||
canEditStyles: true,
|
||||
canMove: false,
|
||||
canResize: false,
|
||||
canApplyManualOffset: true,
|
||||
canApplyManualSize: true,
|
||||
canApplyManualRotation: true,
|
||||
reasonIfDisabled: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects transform-driven geometry", () => {
|
||||
expect(
|
||||
resolveDomEditCapabilities({
|
||||
selector: "#card",
|
||||
inlineStyles: {
|
||||
left: "120px",
|
||||
top: "80px",
|
||||
width: "240px",
|
||||
height: "140px",
|
||||
},
|
||||
computedStyles: {
|
||||
position: "absolute",
|
||||
left: "120px",
|
||||
top: "80px",
|
||||
width: "240px",
|
||||
height: "140px",
|
||||
transform: "matrix(1, 0, 0, 1, 12, 0)",
|
||||
},
|
||||
isCompositionHost: false,
|
||||
isMasterView: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
canMove: false,
|
||||
canResize: false,
|
||||
canApplyManualOffset: true,
|
||||
canApplyManualSize: true,
|
||||
canApplyManualRotation: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats identity transforms left behind by animation libraries as movable", () => {
|
||||
expect(
|
||||
resolveDomEditCapabilities({
|
||||
selector: "#card",
|
||||
inlineStyles: {
|
||||
left: "120px",
|
||||
top: "80px",
|
||||
width: "240px",
|
||||
height: "140px",
|
||||
},
|
||||
computedStyles: {
|
||||
position: "absolute",
|
||||
left: "120px",
|
||||
top: "80px",
|
||||
width: "240px",
|
||||
height: "140px",
|
||||
transform: "matrix(1, 0, 0, 1, 0, 0)",
|
||||
},
|
||||
isCompositionHost: false,
|
||||
isMasterView: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
canMove: true,
|
||||
canResize: true,
|
||||
canApplyManualOffset: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats identity matrix3d transforms as movable", () => {
|
||||
expect(
|
||||
resolveDomEditCapabilities({
|
||||
selector: "#card",
|
||||
inlineStyles: {
|
||||
left: "120px",
|
||||
top: "80px",
|
||||
width: "240px",
|
||||
height: "140px",
|
||||
},
|
||||
computedStyles: {
|
||||
position: "absolute",
|
||||
left: "120px",
|
||||
top: "80px",
|
||||
width: "240px",
|
||||
height: "140px",
|
||||
transform: "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)",
|
||||
},
|
||||
isCompositionHost: false,
|
||||
isMasterView: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
canMove: true,
|
||||
canResize: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows imported absolute media to resize from computed px geometry", () => {
|
||||
expect(
|
||||
resolveDomEditCapabilities({
|
||||
selector: "#photo",
|
||||
inlineStyles: {
|
||||
inset: "0",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
computedStyles: {
|
||||
position: "absolute",
|
||||
left: "0px",
|
||||
top: "0px",
|
||||
width: "330px",
|
||||
height: "228px",
|
||||
transform: "none",
|
||||
},
|
||||
isCompositionHost: false,
|
||||
isMasterView: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
canMove: true,
|
||||
canResize: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDomEditSelection", () => {
|
||||
it("keeps composition host transforms disabled in master view", () => {
|
||||
expect(
|
||||
resolveDomEditCapabilities({
|
||||
selector: "#detail-host",
|
||||
inlineStyles: {
|
||||
left: "80px",
|
||||
top: "60px",
|
||||
width: "320px",
|
||||
height: "220px",
|
||||
},
|
||||
computedStyles: {
|
||||
position: "absolute",
|
||||
left: "80px",
|
||||
top: "60px",
|
||||
width: "320px",
|
||||
height: "220px",
|
||||
transform: "none",
|
||||
},
|
||||
isCompositionHost: true,
|
||||
isMasterView: true,
|
||||
}),
|
||||
).toEqual({
|
||||
canSelect: true,
|
||||
canEditStyles: false,
|
||||
canMove: true,
|
||||
canResize: true,
|
||||
canApplyManualOffset: false,
|
||||
canApplyManualSize: false,
|
||||
canApplyManualRotation: false,
|
||||
reasonIfDisabled: "Select an internal layer to transform it.",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves child clicks inside a composition host to the child in master view", () => {
|
||||
const document = createDocument(`
|
||||
<div data-composition-id="main">
|
||||
<div
|
||||
id="detail-host"
|
||||
class="clip"
|
||||
data-composition-id="detail-card"
|
||||
data-composition-file="compositions/detail-card.html"
|
||||
>
|
||||
<span id="inner-copy">Nested scene</span>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const child = document.getElementById("inner-copy") as HTMLElement;
|
||||
const selection = resolveDomEditSelection(child, {
|
||||
activeCompositionPath: null,
|
||||
isMasterView: true,
|
||||
});
|
||||
|
||||
expect(selection?.id).toBe("inner-copy");
|
||||
expect(selection?.sourceFile).toBe("compositions/detail-card.html");
|
||||
expect(selection?.isCompositionHost).toBe(false);
|
||||
expect(selection?.capabilities.canApplyManualOffset).toBe(true);
|
||||
expect(selection?.capabilities.canEditStyles).toBe(true);
|
||||
});
|
||||
|
||||
it("does not prefer a scene host clip ancestor when selecting inside it", () => {
|
||||
const document = createDocument(`
|
||||
<div data-composition-id="main">
|
||||
<div
|
||||
id="detail-host"
|
||||
class="clip"
|
||||
data-composition-id="detail-card"
|
||||
data-composition-file="compositions/detail-card.html"
|
||||
>
|
||||
<span id="inner-copy">Nested scene</span>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const child = document.getElementById("inner-copy") as HTMLElement;
|
||||
const selection = resolveDomEditSelection(child, {
|
||||
activeCompositionPath: null,
|
||||
isMasterView: true,
|
||||
preferClipAncestor: true,
|
||||
});
|
||||
|
||||
expect(selection?.id).toBe("inner-copy");
|
||||
expect(selection?.sourceFile).toBe("compositions/detail-card.html");
|
||||
expect(selection?.isCompositionHost).toBe(false);
|
||||
});
|
||||
|
||||
it("still prefers an internal clip ancestor inside a scene", () => {
|
||||
const document = createDocument(`
|
||||
<div data-composition-id="main">
|
||||
<div
|
||||
id="detail-host"
|
||||
class="clip"
|
||||
data-composition-id="detail-card"
|
||||
data-composition-file="compositions/detail-card.html"
|
||||
>
|
||||
<section id="nested-card" class="clip">
|
||||
<span id="inner-copy">Nested scene</span>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const child = document.getElementById("inner-copy") as HTMLElement;
|
||||
const selection = resolveDomEditSelection(child, {
|
||||
activeCompositionPath: null,
|
||||
isMasterView: true,
|
||||
preferClipAncestor: true,
|
||||
});
|
||||
|
||||
expect(selection?.id).toBe("nested-card");
|
||||
expect(selection?.sourceFile).toBe("compositions/detail-card.html");
|
||||
expect(selection?.isCompositionHost).toBe(false);
|
||||
});
|
||||
|
||||
it("scopes class selector indexing to the same source file", () => {
|
||||
const document = createDocument(`
|
||||
<div data-composition-id="main">
|
||||
<div class="chip">Root chip</div>
|
||||
<div data-composition-id="nested" data-composition-file="compositions/nested.html">
|
||||
<div class="chip">Nested chip</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const rootChip = document.getElementsByClassName("chip")[0] as HTMLElement;
|
||||
const selection = resolveDomEditSelection(rootChip, {
|
||||
activeCompositionPath: null,
|
||||
isMasterView: true,
|
||||
});
|
||||
|
||||
expect(selection?.sourceFile).toBe("index.html");
|
||||
expect(selection?.selector).toBe(".chip");
|
||||
expect(selection?.selectorIndex).toBe(0);
|
||||
expect(findElementForSelection(document, selection!, null)).toBe(rootChip);
|
||||
});
|
||||
|
||||
it("resolves nested duplicate ids from master view without treating root as the nested source", () => {
|
||||
const document = createDocument(`
|
||||
<div data-composition-id="main">
|
||||
<div id="card">Root card</div>
|
||||
<div data-composition-id="nested" data-composition-file="scenes/nested.html">
|
||||
<div id="card">Nested card</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const nestedCard = document.querySelector(
|
||||
'[data-composition-file="scenes/nested.html"] #card',
|
||||
) as HTMLElement;
|
||||
const selection = resolveDomEditSelection(nestedCard, {
|
||||
activeCompositionPath: null,
|
||||
isMasterView: true,
|
||||
});
|
||||
|
||||
expect(selection?.sourceFile).toBe("scenes/nested.html");
|
||||
expect(findElementForSelection(document, selection!, null)).toBe(nestedCard);
|
||||
});
|
||||
|
||||
it("prefers the nearest clip ancestor on single-click style selection", () => {
|
||||
const document = createDocument(`
|
||||
<section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
|
||||
<p id="copy">Hello</p>
|
||||
</section>
|
||||
`);
|
||||
|
||||
const child = document.getElementById("copy") as HTMLElement;
|
||||
const selection = resolveDomEditSelection(child, {
|
||||
activeCompositionPath: null,
|
||||
isMasterView: false,
|
||||
preferClipAncestor: true,
|
||||
});
|
||||
|
||||
expect(selection?.id).toBe("card");
|
||||
expect(selection?.selector).toBe("#card");
|
||||
});
|
||||
|
||||
it("can resolve the exact child when clip-ancestor preference is disabled", () => {
|
||||
const document = createDocument(`
|
||||
<section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
|
||||
<p id="copy">Hello</p>
|
||||
</section>
|
||||
`);
|
||||
|
||||
const child = document.getElementById("copy") as HTMLElement;
|
||||
const selection = resolveDomEditSelection(child, {
|
||||
activeCompositionPath: null,
|
||||
isMasterView: false,
|
||||
preferClipAncestor: false,
|
||||
});
|
||||
|
||||
expect(selection?.id).toBe("copy");
|
||||
expect(selection?.selector).toBe("#copy");
|
||||
});
|
||||
|
||||
it("collects simple child text blocks as separate editable fields", () => {
|
||||
const document = createDocument(`
|
||||
<section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
|
||||
<strong>Headline</strong>
|
||||
<span>Supporting copy</span>
|
||||
</section>
|
||||
`);
|
||||
|
||||
const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
|
||||
activeCompositionPath: null,
|
||||
isMasterView: false,
|
||||
});
|
||||
|
||||
expect(selection?.textFields.map((field) => field.label)).toEqual(["Text 1", "Text 2"]);
|
||||
expect(selection?.textFields.map((field) => field.value)).toEqual([
|
||||
"Headline",
|
||||
"Supporting copy",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves user-entered text spacing in editable text fields", () => {
|
||||
const document = createDocument(`
|
||||
<section id="card" class="clip" style="position: absolute;">
|
||||
<strong>Headline with trailing space </strong>
|
||||
</section>
|
||||
`);
|
||||
|
||||
const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
|
||||
activeCompositionPath: null,
|
||||
isMasterView: false,
|
||||
});
|
||||
|
||||
expect(selection?.textFields[0]?.value).toBe("Headline with trailing space ");
|
||||
});
|
||||
|
||||
it("keeps an emptied text layer editable so users can type into it again", () => {
|
||||
const document = createDocument(`
|
||||
<div id="card" class="clip" style="position: absolute;"></div>
|
||||
`);
|
||||
|
||||
const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
|
||||
activeCompositionPath: null,
|
||||
isMasterView: false,
|
||||
});
|
||||
|
||||
expect(selection?.textFields).toMatchObject([
|
||||
{
|
||||
key: "self:0:div",
|
||||
label: "Content",
|
||||
value: "",
|
||||
source: "self",
|
||||
},
|
||||
]);
|
||||
expect(selection ? isTextEditableSelection(selection) : false).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps emptied child text layers editable after their content is cleared", () => {
|
||||
const document = createDocument(`
|
||||
<div id="card" class="clip" style="position: absolute;">
|
||||
<strong></strong>
|
||||
<span></span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
|
||||
activeCompositionPath: null,
|
||||
isMasterView: false,
|
||||
});
|
||||
|
||||
expect(selection?.textFields.map((field) => field.tagName)).toEqual(["strong", "span"]);
|
||||
expect(selection?.textFields.map((field) => field.value)).toEqual(["", ""]);
|
||||
});
|
||||
|
||||
it("explains anonymous child elements that resolve to an editable parent", () => {
|
||||
const document = createDocument(`
|
||||
<div data-composition-id="main">
|
||||
<div id="card">
|
||||
<strong>Headline</strong>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const child = document.querySelector("strong") as HTMLElement;
|
||||
const selection = resolveDomEditSelection(child, {
|
||||
activeCompositionPath: null,
|
||||
isMasterView: false,
|
||||
preferClipAncestor: false,
|
||||
});
|
||||
|
||||
expect(selection?.id).toBe("card");
|
||||
expect(getDomEditNonEditableReason(child, selection)).toBe("Selection resolves to Card");
|
||||
});
|
||||
|
||||
it("does not mark an element as non-editable when Studio can edit it directly", () => {
|
||||
const document = createDocument(`
|
||||
<div data-composition-id="main">
|
||||
<div id="card">Editable</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const element = document.getElementById("card") as HTMLElement;
|
||||
const selection = resolveDomEditSelection(element, {
|
||||
activeCompositionPath: null,
|
||||
isMasterView: false,
|
||||
});
|
||||
|
||||
expect(getDomEditNonEditableReason(element, selection)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps duplicate class targets distinct for history keys", () => {
|
||||
const first = getDomEditTargetKey({
|
||||
sourceFile: "index.html",
|
||||
selector: ".card",
|
||||
selectorIndex: 0,
|
||||
});
|
||||
const second = getDomEditTargetKey({
|
||||
sourceFile: "index.html",
|
||||
selector: ".card",
|
||||
selectorIndex: 1,
|
||||
});
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patch builders and prompt builder", () => {
|
||||
it("builds style patch operations", () => {
|
||||
expect(buildDomEditStylePatchOperation("background-color", "rgb(15, 23, 42)")).toEqual({
|
||||
type: "inline-style",
|
||||
property: "background-color",
|
||||
value: "rgb(15, 23, 42)",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds an agent prompt with source and selector context", () => {
|
||||
const selection = {
|
||||
element: {} as HTMLElement,
|
||||
id: "editable-card",
|
||||
selector: "#editable-card",
|
||||
selectorIndex: undefined,
|
||||
sourceFile: "index.html",
|
||||
compositionPath: "index.html",
|
||||
compositionSrc: undefined,
|
||||
isCompositionHost: false,
|
||||
label: "Drag me first",
|
||||
tagName: "div",
|
||||
boundingBox: { x: 108, y: 112, width: 380, height: 196 },
|
||||
textContent: "Drag me first",
|
||||
dataAttributes: {},
|
||||
inlineStyles: {
|
||||
left: "108px",
|
||||
top: "112px",
|
||||
width: "380px",
|
||||
height: "196px",
|
||||
},
|
||||
computedStyles: {
|
||||
position: "absolute",
|
||||
left: "108px",
|
||||
top: "112px",
|
||||
width: "380px",
|
||||
height: "196px",
|
||||
color: "rgb(248, 250, 252)",
|
||||
},
|
||||
textFields: [
|
||||
{
|
||||
key: "self:0:div",
|
||||
label: "Content",
|
||||
value: "Drag me first",
|
||||
tagName: "div",
|
||||
attributes: [],
|
||||
inlineStyles: {},
|
||||
computedStyles: {},
|
||||
source: "self",
|
||||
},
|
||||
],
|
||||
capabilities: {
|
||||
canSelect: true,
|
||||
canEditStyles: true,
|
||||
canMove: true,
|
||||
canResize: true,
|
||||
canApplyManualOffset: true,
|
||||
canApplyManualSize: true,
|
||||
canApplyManualRotation: true,
|
||||
},
|
||||
} satisfies DomEditSelection;
|
||||
|
||||
const prompt = buildElementAgentPrompt({
|
||||
selection,
|
||||
currentTime: 1.25,
|
||||
tagSnippet: `<div id="editable-card" style="position:absolute; left: 108px; top: 112px; width: 380px; height: 196px; color: rgb(248, 250, 252)"`,
|
||||
});
|
||||
|
||||
expect(prompt).toContain("## HyperFrames element edit request v1");
|
||||
expect(prompt).toContain("Schema version: 1");
|
||||
expect(prompt).toContain("Source file: index.html");
|
||||
expect(prompt).toContain("Selector: #editable-card");
|
||||
expect(prompt).toContain("Playback time:");
|
||||
expect(prompt).toContain("Text fields:");
|
||||
expect(prompt).toContain('key=self:0:div; tag=<div>; source=self; text="Drag me first"');
|
||||
expect(prompt).toContain("Inline styles:");
|
||||
expect(prompt).toContain("Computed styles (browser-resolved):");
|
||||
expect(prompt).toContain("Target HTML:");
|
||||
expect(prompt).toContain("Guardrails:");
|
||||
expect(prompt).toContain("Do not modify other elements' data-* attributes or positioning.");
|
||||
});
|
||||
|
||||
it("uses an absolute source path in copied agent prompts when provided", () => {
|
||||
const selection = {
|
||||
element: {} as HTMLElement,
|
||||
id: "editable-card",
|
||||
selector: "#editable-card",
|
||||
selectorIndex: undefined,
|
||||
sourceFile: "index.html",
|
||||
compositionPath: "index.html",
|
||||
compositionSrc: undefined,
|
||||
isCompositionHost: false,
|
||||
label: "Drag me first",
|
||||
tagName: "div",
|
||||
boundingBox: { x: 108, y: 112, width: 380, height: 196 },
|
||||
textContent: "Drag me first",
|
||||
dataAttributes: {},
|
||||
inlineStyles: {},
|
||||
computedStyles: {},
|
||||
textFields: [],
|
||||
capabilities: {
|
||||
canSelect: true,
|
||||
canEditStyles: true,
|
||||
canMove: true,
|
||||
canResize: true,
|
||||
canApplyManualOffset: true,
|
||||
canApplyManualSize: true,
|
||||
canApplyManualRotation: true,
|
||||
},
|
||||
} satisfies DomEditSelection;
|
||||
|
||||
const prompt = buildElementAgentPrompt({
|
||||
selection,
|
||||
currentTime: 1.25,
|
||||
sourceFilePath: "/tmp/hf-studio-project/index.html",
|
||||
});
|
||||
|
||||
expect(prompt).toContain("Source file: /tmp/hf-studio-project/index.html");
|
||||
expect(prompt).not.toContain("Source file: index.html");
|
||||
});
|
||||
|
||||
it("serializes child text fields back into HTML", () => {
|
||||
expect(
|
||||
serializeDomEditTextFields([
|
||||
{
|
||||
key: "child:0:strong",
|
||||
label: "Text 1",
|
||||
value: "Headline <1>",
|
||||
tagName: "strong",
|
||||
attributes: [],
|
||||
inlineStyles: {
|
||||
"font-size": "22px",
|
||||
},
|
||||
computedStyles: {},
|
||||
source: "child",
|
||||
},
|
||||
{
|
||||
key: "child:1:span",
|
||||
label: "Text 2",
|
||||
value: "Details & more",
|
||||
tagName: "span",
|
||||
attributes: [],
|
||||
inlineStyles: {},
|
||||
computedStyles: {},
|
||||
source: "child",
|
||||
},
|
||||
]),
|
||||
).toBe(
|
||||
'<strong data-hf-text-key="child:0:strong" style="font-size: 22px">Headline <1></strong><span data-hf-text-key="child:1:span">Details & more</span>',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user