mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 12:08:50 +00:00
Merge pull request #2268 from heygen-com/studio-dnd/pr03-studio-server-files-route
feat(studio-server): files route extensions
This commit is contained in:
@@ -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"),
|
||||
'<div class="sub" style="z-index: 1">A</div><div class="sub" style="z-index: 2">B</div>',
|
||||
);
|
||||
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"),
|
||||
'<div class="sub" style="z-index: 1">A</div><div class="sub" style="z-index: 2">B</div>',
|
||||
);
|
||||
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('<div class="sub" style="z-index: 1">A</div>');
|
||||
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"),
|
||||
'<div class="root" id="main" style="z-index: 5">first</div>' +
|
||||
'<div class="root" id="main" style="z-index: 6">second</div>',
|
||||
);
|
||||
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('<div class="root" id="main" style="z-index: 5">first</div>');
|
||||
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"), `<div id="${id}">x</div>`);
|
||||
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 = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
|
||||
@@ -805,4 +944,101 @@ tl.to("#box", { opacity: 1, duration: 1 }, 0);
|
||||
// The variable target was not flattened to a string-literal selector
|
||||
expect(result.after).toContain("tl.to(kicker,");
|
||||
});
|
||||
|
||||
it("shift-positions-batch equals sequential single shifts (atomic multi-clip)", async () => {
|
||||
const TWO_TWEENS = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#a", { duration: 1, x: 100 }, 1);
|
||||
tl.to("#b", { duration: 1, x: 200 }, 2);
|
||||
</script></body></html>`;
|
||||
|
||||
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",
|
||||
`<!DOCTYPE html><html><body><script data-hyperframes-gsap>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#a", { duration: 1, x: 100 }, 1);
|
||||
</script></body></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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>([
|
||||
// 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,
|
||||
|
||||
Reference in New Issue
Block a user