diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index dfdcdcb9f8..90699abd5a 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -389,6 +389,145 @@ gsap.set("#box", { rotation: 45 }); expect(payload.content).not.toContain("opacity"); }); + // ── Canvas z-order / patch-target regression suite ──────────────────────── + // A right-click "move to back" on an id-less element (e.g. a caption `.sub` + // div) once serialized `target.id: null`, which findUnsafeDomPatchValues + // rejected as `body.target.id`, bricking the edit. The RULE: `target.id` is + // metadata, not a layout value — a null there is genuinely invalid and stays + // rejected; the fix is that the client omits an absent id instead of sending + // null, so the patch degrades to a hfId / selector + selectorIndex match. + it("rejects a null target.id in a DOM patch (documents the rule)", async () => { + const projectDir = createProjectDir(); + writeFileSync( + join(projectDir, "index.html"), + '
A
B
', + ); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const before = readFileSync(join(projectDir, "index.html"), "utf-8"); + const response = await app.request( + "http://localhost/projects/demo/file-mutations/patch-element/index.html", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + target: { id: null, selector: ".sub", selectorIndex: 1 }, + operations: [{ type: "inline-style", property: "z-index", value: "0" }], + }), + }, + ); + const payload = (await response.json()) as { error?: string; fields?: string[] }; + + expect(response.status).toBe(400); + expect(payload.error).toContain("unsafe values"); + expect(payload.fields).toContain("body.target.id"); + expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(before); + }); + + it("z-reorder with an omitted id degrades to a selector patch (id-less element)", async () => { + const projectDir = createProjectDir(); + writeFileSync( + join(projectDir, "index.html"), + '
A
B
', + ); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request( + "http://localhost/projects/demo/file-mutations/patch-element/index.html", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + // id omitted (undefined) — the fixed client shape for an id-less element. + body: JSON.stringify({ + target: { selector: ".sub", selectorIndex: 1 }, + operations: [{ type: "inline-style", property: "z-index", value: "0" }], + }), + }, + ); + const payload = (await response.json()) as { changed?: boolean; content?: string }; + + expect(response.status).toBe(200); + expect(payload.changed).toBe(true); + // The SECOND `.sub` (selectorIndex 1) is the one restacked, not the first. + expect(payload.content).toContain('
A
'); + expect(payload.content).toContain("z-index: 0"); + }); + + it("duplicate-id document: a selector+index patch hits the right element, not a rejection", async () => { + const projectDir = createProjectDir(); + // Two elements share id="main" AND class="root" (mirrors the user's project, + // where sub-compositions each carry id="main"). Match by selector + index. + writeFileSync( + join(projectDir, "index.html"), + '
first
' + + '
second
', + ); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request( + "http://localhost/projects/demo/file-mutations/patch-element/index.html", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + target: { selector: ".root", selectorIndex: 1 }, + operations: [{ type: "inline-style", property: "z-index", value: "0" }], + }), + }, + ); + const payload = (await response.json()) as { changed?: boolean; content?: string }; + + expect(response.status).toBe(200); + expect(payload.changed).toBe(true); + // First "main" untouched; second one restacked. + expect(payload.content).toContain('
first
'); + expect(payload.content).toContain("z-index: 0"); + }); + + // Sibling canvas commits (position / size / text) carry real string ids like + // "v-hero" / "vo-part1" / "main". The guard must accept them — it only rejects + // null / non-finite numbers, never inspects the id string — so these never hit + // the z-order "unsafe values" variant. + it.each([ + { + label: "position", + id: "v-hero", + op: { type: "inline-style", property: "left", value: "40px" }, + }, + { + label: "size", + id: "vo-part1", + op: { type: "inline-style", property: "width", value: "320px" }, + }, + { + label: "text", + id: "main", + op: { type: "text-content", property: "textContent", value: "Hi" }, + }, + ])("accepts a $label commit with a real fixture id ($id)", async ({ id, op }) => { + const projectDir = createProjectDir(); + writeFileSync(join(projectDir, "index.html"), `
x
`); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request( + "http://localhost/projects/demo/file-mutations/patch-element/index.html", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target: { id }, operations: [op] }), + }, + ); + const payload = (await response.json()) as { changed?: boolean; error?: string }; + + expect(response.status).toBe(200); + expect(payload.error).toBeUndefined(); + expect(payload.changed).toBe(true); + }); + it("update-from-property returns 400 for a non-fromTo animation", async () => { const projectDir = createProjectDir(); const TO_COMP = ``; + + const seqDir = createProjectDir(); + writeHtml(seqDir, "seq.html", TWO_TWEENS); + const seqApp = new Hono(); + registerFileRoutes(seqApp, createAdapter(seqDir)); + await seqApp.request("http://localhost/projects/demo/gsap-mutations/seq.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "shift-positions", targetSelector: "#a", delta: 1 }), + }); + const seqRes = await seqApp.request("http://localhost/projects/demo/gsap-mutations/seq.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "shift-positions", targetSelector: "#b", delta: 0.5 }), + }); + const seqAfter = ((await seqRes.json()) as { after: string }).after; + + const batchDir = createProjectDir(); + writeHtml(batchDir, "batch.html", TWO_TWEENS); + const batchApp = new Hono(); + registerFileRoutes(batchApp, createAdapter(batchDir)); + const batchRes = await batchApp.request( + "http://localhost/projects/demo/gsap-mutations/batch.html", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "shift-positions-batch", + shifts: [ + { targetSelector: "#a", delta: 1 }, + { targetSelector: "#b", delta: 0.5 }, + ], + }), + }, + ); + const batch = (await batchRes.json()) as { ok: boolean; changed: boolean; after: string }; + + expect(batchRes.status).toBe(200); + expect(batch.ok).toBe(true); + expect(batch.changed).toBe(true); + // Batching #a then #b in one write == applying them as two sequential single shifts. + expect(batch.after).toBe(seqAfter); + }); + + it("reports no GSAP mutation for shift-positions-batch in a file with no GSAP script", async () => { + // Same contract as its shift-positions / scale-positions siblings: a file with + // no GSAP block is a no-op {ok, changed:false}, not a 400. + const projectDir = createProjectDir(); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const res = await app.request("http://localhost/projects/demo/gsap-mutations/index.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "shift-positions-batch", + shifts: [{ targetSelector: "#box", delta: 1 }], + }), + }); + const result = (await res.json()) as { ok?: boolean; changed?: boolean; mutated?: boolean }; + + expect(res.status).toBe(200); + expect(result.ok).toBe(true); + expect(result.changed).toBe(false); + expect(result.mutated).toBe(false); + }); + + it("rejects a shift-positions-batch with a missing/non-array `shifts` field (400)", async () => { + const projectDir = createProjectDir(); + writeHtml( + projectDir, + "comp.html", + ``, + ); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "shift-positions-batch" }), + }); + const result = (await res.json()) as { error?: string }; + + expect(res.status).toBe(400); + expect(result.error).toContain("shifts"); + }); }); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index 64124957c2..8ad12293ba 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -773,6 +773,13 @@ type GsapMutationRequest = targetSelector: string; delta: number; } + | { + // Batched shift: fold shiftPositionsInScript over N selectors in one write. + // Lets a multi-clip timeline move (ripple / insert) shift every affected + // clip's tweens atomically instead of one racing server round-trip per clip. + type: "shift-positions-batch"; + shifts: Array<{ targetSelector: string; delta: number }>; + } | { type: "scale-positions"; targetSelector: string; @@ -815,6 +822,7 @@ const HOLD_SYNC_MUTATION_TYPES = new Set([ // Time-shift / time-scale tweens, which can move a keyframed position tween's start // across t=0, flipping hold need; stale holds are not repositioned by these ops. "shift-positions", + "shift-positions-batch", "scale-positions", // Retargets keyframed position tweens to a cloned element's selector; the old hold is // keyed to the prior selector, so holds must be rebuilt for the new target. @@ -1101,6 +1109,14 @@ function executeGsapMutationAcorn( if (!targetSelector || !Number.isFinite(delta) || delta === 0) return block.scriptText; return shiftPositionsInScript(block.scriptText, targetSelector, delta); } + case "shift-positions-batch": { + let script = block.scriptText; + for (const s of body.shifts) { + if (!s.targetSelector || !Number.isFinite(s.delta) || s.delta === 0) continue; + script = shiftPositionsInScript(script, s.targetSelector, s.delta); + } + return script; + } case "scale-positions": { const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body; if ( @@ -1463,6 +1479,15 @@ async function executeGsapMutationRecast( const { shiftPositionsInScript } = parser; return shiftPositionsInScript(block.scriptText, targetSelector, delta); } + case "shift-positions-batch": { + const { shiftPositionsInScript } = parser; + let script = block.scriptText; + for (const s of body.shifts) { + if (!s.targetSelector || !Number.isFinite(s.delta) || s.delta === 0) continue; + script = shiftPositionsInScript(script, s.targetSelector, s.delta); + } + return script; + } case "scale-positions": { const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body; if ( @@ -2024,6 +2049,11 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { if (unsafeFields.length > 0) { return rejectUnsafeMutationValues(c, unsafeFields); } + // Defensive shape guard: a malformed shift-positions-batch (missing/non-array + // `shifts`) would otherwise TypeError inside `for (const s of body.shifts)`. + if (body.type === "shift-positions-batch" && !Array.isArray(body.shifts)) { + return c.json({ error: "shift-positions-batch requires a `shifts` array" }, 400); + } let html = readFileSync(res.absPath, "utf-8"); let block = extractGsapScriptBlock(html); @@ -2046,7 +2076,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { } block = extractGsapScriptBlock(html); } - if (!block && (body.type === "shift-positions" || body.type === "scale-positions")) { + if ( + !block && + (body.type === "shift-positions" || + body.type === "scale-positions" || + body.type === "shift-positions-batch") + ) { return c.json({ ok: true, changed: false,