mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 20:18:35 +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");
|
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 () => {
|
it("update-from-property returns 400 for a non-fromTo animation", async () => {
|
||||||
const projectDir = createProjectDir();
|
const projectDir = createProjectDir();
|
||||||
const TO_COMP = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
|
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
|
// The variable target was not flattened to a string-literal selector
|
||||||
expect(result.after).toContain("tl.to(kicker,");
|
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;
|
targetSelector: string;
|
||||||
delta: number;
|
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";
|
type: "scale-positions";
|
||||||
targetSelector: string;
|
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
|
// 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.
|
// across t=0, flipping hold need; stale holds are not repositioned by these ops.
|
||||||
"shift-positions",
|
"shift-positions",
|
||||||
|
"shift-positions-batch",
|
||||||
"scale-positions",
|
"scale-positions",
|
||||||
// Retargets keyframed position tweens to a cloned element's selector; the old hold is
|
// 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.
|
// 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;
|
if (!targetSelector || !Number.isFinite(delta) || delta === 0) return block.scriptText;
|
||||||
return shiftPositionsInScript(block.scriptText, targetSelector, delta);
|
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": {
|
case "scale-positions": {
|
||||||
const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body;
|
const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body;
|
||||||
if (
|
if (
|
||||||
@@ -1463,6 +1479,15 @@ async function executeGsapMutationRecast(
|
|||||||
const { shiftPositionsInScript } = parser;
|
const { shiftPositionsInScript } = parser;
|
||||||
return shiftPositionsInScript(block.scriptText, targetSelector, delta);
|
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": {
|
case "scale-positions": {
|
||||||
const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body;
|
const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body;
|
||||||
if (
|
if (
|
||||||
@@ -2024,6 +2049,11 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
|||||||
if (unsafeFields.length > 0) {
|
if (unsafeFields.length > 0) {
|
||||||
return rejectUnsafeMutationValues(c, unsafeFields);
|
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 html = readFileSync(res.absPath, "utf-8");
|
||||||
let block = extractGsapScriptBlock(html);
|
let block = extractGsapScriptBlock(html);
|
||||||
@@ -2046,7 +2076,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
|||||||
}
|
}
|
||||||
block = extractGsapScriptBlock(html);
|
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({
|
return c.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
changed: false,
|
changed: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user