mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): keyframe bug fixes — gate delete hooks, fix value corruption, gesture recording (#1314)
- Gate stripStudioEditsFromTarget/bakeVisibilityOnDelete behind a stripStudioEdits flag on the delete mutation type so they only fire on user-initiated deletes, not on internal delete-then-recreate drags. - Add bakeVisibilityOnDelete to the remove-all-keyframes handler so elements with CSS opacity:0 stay visible after collapsing keyframes. - Fix integer rounding in readAllAnimatedProperties: use 3-decimal precision for visual properties (opacity, scale, rotation) instead of Math.round which corrupted mid-fade values to 0. - Guard VISUAL_BASELINE against cross-tween contamination by querying __timelines for properties animated by other tweens on the same element. - Harden bakeVisibilityOnDelete: reverse-scan keyframes for the last one containing opacity, guard against relative values (+=/-=/*=), and add Number.isFinite check. - Fix falsy-zero doubling in drag commit: replace || fallback with Number.isFinite so a base GSAP position of 0 is correctly preserved. - Fix gesture recording sign inversion: remove pointerElementOffset subtraction from dx/dy formula and instead apply it once to basePosition so the element center tracks the pointer. - Fix TypeScript build errors in gsapSoftReload.ts (6 double-casts). - Strip all diagnostic logs from production code.
This commit is contained in:
@@ -867,4 +867,33 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// gsap_group_selector_keyframes
|
||||
({ scripts }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const script of scripts) {
|
||||
const content = stripJsComments(script.content);
|
||||
const pattern = /\.(?:to|from|fromTo)\(\s*["']([^"']+,\s*[^"']+)["']\s*,\s*\{[^}]*keyframes/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(content)) !== null) {
|
||||
const selector = match[1]!;
|
||||
const count = selector.split(",").length;
|
||||
const contextStart = Math.max(0, match.index - 20);
|
||||
const contextEnd = Math.min(content.length, match.index + match[0].length + 40);
|
||||
findings.push({
|
||||
code: "gsap_group_selector_keyframes",
|
||||
severity: "warning",
|
||||
message:
|
||||
`GSAP tween targets ${count} elements with shared keyframes ("${truncateSnippet(selector, 60)}"). ` +
|
||||
`Editing one element's keyframes in Studio will affect all ${count} elements. ` +
|
||||
`Split into individual tweens for per-element keyframe control.`,
|
||||
fixHint:
|
||||
`Replace the group selector with individual tl.to() calls per element, ` +
|
||||
`each with their own keyframes object.`,
|
||||
snippet: truncateSnippet(content.slice(contextStart, contextEnd)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1383,6 +1383,95 @@ describe("keyframe mutations", () => {
|
||||
expect(kfs[1].properties.x).toBe(999);
|
||||
});
|
||||
|
||||
// ── _auto endpoint updates ────────────────────────────────────────────
|
||||
|
||||
const AUTO_SCRIPT = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", {
|
||||
keyframes: { "0%": { x: 0, y: 0, _auto: 1 }, "100%": { x: 200, y: 100, _auto: 1 } },
|
||||
duration: 2
|
||||
}, 0);
|
||||
`;
|
||||
|
||||
const AUTO_5KF_SCRIPT = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", {
|
||||
keyframes: {
|
||||
"0%": { x: 0, y: 0, _auto: 1 },
|
||||
"25%": { x: 50, y: 25 },
|
||||
"50%": { x: 100, y: 50 },
|
||||
"75%": { x: 150, y: 75 },
|
||||
"100%": { x: 200, y: 100, _auto: 1 }
|
||||
},
|
||||
duration: 2
|
||||
}, 0);
|
||||
`;
|
||||
|
||||
it("addKeyframe adjacent to auto 100% — updates 100%", () => {
|
||||
const id = getAnimId(AUTO_SCRIPT);
|
||||
const updated = addKeyframeToScript(AUTO_SCRIPT, id, 50, { x: 300, y: 200 });
|
||||
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
|
||||
const kf100 = kfs.find((k) => k.percentage === 100)!;
|
||||
expect(kf100.properties.x).toBe(300);
|
||||
expect(kf100.properties.y).toBe(200);
|
||||
});
|
||||
|
||||
it("addKeyframe adjacent to auto 0% — updates 0%", () => {
|
||||
const id = getAnimId(AUTO_SCRIPT);
|
||||
const updated = addKeyframeToScript(AUTO_SCRIPT, id, 50, { x: 300, y: 200 });
|
||||
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
|
||||
const kf0 = kfs.find((k) => k.percentage === 0)!;
|
||||
expect(kf0.properties.x).toBe(300);
|
||||
expect(kf0.properties.y).toBe(200);
|
||||
});
|
||||
|
||||
it("addKeyframe NOT adjacent to auto 100% — leaves 100% untouched", () => {
|
||||
const id = getAnimId(AUTO_5KF_SCRIPT);
|
||||
const updated = addKeyframeToScript(AUTO_5KF_SCRIPT, id, 74, { x: 999, y: 888 });
|
||||
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
|
||||
const kf100 = kfs.find((k) => k.percentage === 100)!;
|
||||
expect(kf100.properties.x).toBe(200);
|
||||
expect(kf100.properties.y).toBe(100);
|
||||
});
|
||||
|
||||
it("addKeyframe NOT adjacent to auto 0% — leaves 0% untouched", () => {
|
||||
const id = getAnimId(AUTO_5KF_SCRIPT);
|
||||
const updated = addKeyframeToScript(AUTO_5KF_SCRIPT, id, 30, { x: 999, y: 888 });
|
||||
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
|
||||
const kf0 = kfs.find((k) => k.percentage === 0)!;
|
||||
expect(kf0.properties.x).toBe(0);
|
||||
expect(kf0.properties.y).toBe(0);
|
||||
});
|
||||
|
||||
it("addKeyframe at 88% in 5-keyframe set — updates adjacent 100% only", () => {
|
||||
const id = getAnimId(AUTO_5KF_SCRIPT);
|
||||
const updated = addKeyframeToScript(AUTO_5KF_SCRIPT, id, 88, { x: 500, y: 400 });
|
||||
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
|
||||
const kf100 = kfs.find((k) => k.percentage === 100)!;
|
||||
const kf0 = kfs.find((k) => k.percentage === 0)!;
|
||||
expect(kf100.properties.x).toBe(500);
|
||||
expect(kf0.properties.x).toBe(0);
|
||||
});
|
||||
|
||||
it("addKeyframe at 12% in 5-keyframe set — updates adjacent 0% only", () => {
|
||||
const id = getAnimId(AUTO_5KF_SCRIPT);
|
||||
const updated = addKeyframeToScript(AUTO_5KF_SCRIPT, id, 12, { x: 500, y: 400 });
|
||||
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
|
||||
const kf0 = kfs.find((k) => k.percentage === 0)!;
|
||||
const kf100 = kfs.find((k) => k.percentage === 100)!;
|
||||
expect(kf0.properties.x).toBe(500);
|
||||
expect(kf100.properties.x).toBe(200);
|
||||
});
|
||||
|
||||
it("non-auto 100% is never modified", () => {
|
||||
const id = getAnimId(KF_SCRIPT);
|
||||
const updated = addKeyframeToScript(KF_SCRIPT, id, 50, { x: 999 });
|
||||
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
|
||||
const kf100 = kfs.find((k) => k.percentage === 100)!;
|
||||
expect(kf100.properties.x).toBe(200);
|
||||
expect(kf100.properties.opacity).toBe(1);
|
||||
});
|
||||
|
||||
// ── removeKeyframeFromScript ────────────────────────────────────────────
|
||||
|
||||
it("removeKeyframeFromScript — removes one keyframe", () => {
|
||||
|
||||
@@ -1404,21 +1404,30 @@ export function addKeyframeToScript(
|
||||
kfNode.properties.splice(insertIdx, 0, newProp);
|
||||
}
|
||||
|
||||
// Auto-update 100%: if the 100% keyframe still has `_auto: 1` (never
|
||||
// explicitly edited by the user), update it to match the new keyframe's
|
||||
// values so the element holds its final position instead of snapping back.
|
||||
// Once the user drags at 100%, `_auto` is gone and we stop touching it.
|
||||
if (percentage < 100 && percentage !== 0) {
|
||||
// Auto-update adjacent endpoints: only update an `_auto` 0% or 100%
|
||||
// keyframe when the new keyframe is directly next to it (no other keyframe
|
||||
// between them). This prevents a keyframe at 74% from clobbering 100% when
|
||||
// 75% already exists, and a keyframe at 30% from clobbering 0% when 25%
|
||||
// already exists.
|
||||
if (percentage > 0 && percentage < 100) {
|
||||
const pctProps = filterPercentageProps(kfNode);
|
||||
const hundredProp = pctProps.find((p: any) => percentageFromKey(propKeyName(p) ?? "") === 100);
|
||||
if (hundredProp?.value?.type === "ObjectExpression") {
|
||||
const hasAuto = hundredProp.value.properties.some(
|
||||
const allPcts = pctProps
|
||||
.map((p: any) => percentageFromKey(propKeyName(p) ?? ""))
|
||||
.filter((n: number) => !Number.isNaN(n) && n !== percentage)
|
||||
.sort((a: number, b: number) => a - b);
|
||||
const leftNeighbor = allPcts.filter((p: number) => p < percentage).pop();
|
||||
const rightNeighbor = allPcts.find((p: number) => p > percentage);
|
||||
for (const endPct of [0, 100]) {
|
||||
const isNeighbor = endPct === 0 ? leftNeighbor === 0 : rightNeighbor === 100;
|
||||
if (!isNeighbor) continue;
|
||||
const endProp = pctProps.find((p: any) => percentageFromKey(propKeyName(p) ?? "") === endPct);
|
||||
if (!endProp?.value || endProp.value.type !== "ObjectExpression") continue;
|
||||
const hasAuto = endProp.value.properties.some(
|
||||
(p: any) => isObjectProperty(p) && propKeyName(p) === "_auto",
|
||||
);
|
||||
if (hasAuto) {
|
||||
const updatedProps = { ...properties, _auto: 1 as number | string };
|
||||
hundredProp.value = buildKeyframeValueNode(updatedProps, undefined);
|
||||
}
|
||||
if (!hasAuto) continue;
|
||||
const updatedProps = { ...properties, _auto: 1 as number | string };
|
||||
endProp.value = buildKeyframeValueNode(updatedProps, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1623,18 +1632,18 @@ export function removeAllKeyframesFromScript(script: string, animationId: string
|
||||
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return script;
|
||||
|
||||
// Collect all percentage keyframe entries, sorted
|
||||
const kfEntries = filterPercentageProps(kfNode)
|
||||
.map((p: any) => ({ pct: percentageFromKey(propKeyName(p)!), prop: p }))
|
||||
.filter((e) => !Number.isNaN(e.pct))
|
||||
.sort((a, b) => a.pct - b.pct);
|
||||
if (kfEntries.length === 0) return script;
|
||||
|
||||
const lastRecord = objectExpressionToRecord(
|
||||
kfEntries[kfEntries.length - 1]!.prop.value,
|
||||
loc.parsed.scope,
|
||||
);
|
||||
collapseKeyframesToFlat(loc.target.call.varsArg, lastRecord);
|
||||
// For to()/set(): collapse to last keyframe (the destination = visible state).
|
||||
// For from(): collapse to first keyframe (the starting state).
|
||||
const method = loc.target.call.method;
|
||||
const collapseEntry = method === "from" ? kfEntries[0]! : kfEntries[kfEntries.length - 1]!;
|
||||
const record = objectExpressionToRecord(collapseEntry.prop.value, loc.parsed.scope);
|
||||
collapseKeyframesToFlat(loc.target.call.varsArg, record);
|
||||
|
||||
return recast.print(loc.parsed.ast).code;
|
||||
}
|
||||
|
||||
@@ -197,15 +197,12 @@ function updateReferences(projectDir: string, oldPath: string, newPath: string):
|
||||
* contains GSAP timeline code, and return both its text content and a
|
||||
* function that replaces that script block and serialises back to HTML.
|
||||
*/
|
||||
function extractGsapScriptBlock(
|
||||
html: string,
|
||||
): { scriptText: string; replaceScript: (newText: string) => string } | null {
|
||||
function extractGsapScriptBlock(html: string): {
|
||||
scriptText: string;
|
||||
document: Document;
|
||||
replaceScript: (newText: string) => string;
|
||||
} | null {
|
||||
const { document } = parseHTML(html);
|
||||
// linkedom's querySelectorAll doesn't descend into <template> content, but
|
||||
// sub-compositions wrap their markup (and the GSAP <script>) in a <template>.
|
||||
// Search top-level scripts first, then each template's own scripts. Operate
|
||||
// on the template element directly (NOT .content) so textContent writes are
|
||||
// reflected in document.toString().
|
||||
const scripts = [
|
||||
...document.querySelectorAll("script:not([src])"),
|
||||
...Array.from(document.querySelectorAll("template")).flatMap((tmpl) =>
|
||||
@@ -221,6 +218,7 @@ function extractGsapScriptBlock(
|
||||
) {
|
||||
return {
|
||||
scriptText: content,
|
||||
document,
|
||||
replaceScript(newText: string): string {
|
||||
script.textContent = newText;
|
||||
return document.toString();
|
||||
@@ -231,11 +229,473 @@ function extractGsapScriptBlock(
|
||||
return null;
|
||||
}
|
||||
|
||||
function stripStudioEditsFromTarget(document: Document, selector: string): number {
|
||||
if (!selector) return 0;
|
||||
let stripped = 0;
|
||||
try {
|
||||
for (const el of document.querySelectorAll(selector)) {
|
||||
if (!el.getAttribute("data-hf-studio-path-offset")) continue;
|
||||
const htmlEl = el as unknown as HTMLElement;
|
||||
const originalTranslate = el.getAttribute("data-hf-studio-original-inline-translate");
|
||||
htmlEl.style.removeProperty("--hf-studio-offset-x");
|
||||
htmlEl.style.removeProperty("--hf-studio-offset-y");
|
||||
if (originalTranslate) {
|
||||
htmlEl.style.setProperty("translate", originalTranslate);
|
||||
} else {
|
||||
htmlEl.style.removeProperty("translate");
|
||||
}
|
||||
el.removeAttribute("data-hf-studio-path-offset");
|
||||
el.removeAttribute("data-hf-studio-original-translate");
|
||||
el.removeAttribute("data-hf-studio-original-inline-translate");
|
||||
stripped++;
|
||||
}
|
||||
} catch {
|
||||
// Invalid selector — skip silently.
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
function bakeVisibilityOnDelete(document: Document, anim: GsapAnimation): void {
|
||||
let finalOpacity: number | string | undefined;
|
||||
if (anim.method === "from") {
|
||||
return;
|
||||
}
|
||||
if (anim.keyframes) {
|
||||
const kfs = anim.keyframes.keyframes;
|
||||
for (let i = kfs.length - 1; i >= 0; i--) {
|
||||
if ("opacity" in kfs[i]!.properties) {
|
||||
finalOpacity = kfs[i]!.properties.opacity;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if ("opacity" in anim.properties) {
|
||||
finalOpacity = anim.properties.opacity;
|
||||
}
|
||||
if (finalOpacity == null) {
|
||||
return;
|
||||
}
|
||||
if (typeof finalOpacity === "string" && /^[+\-*]=/.test(finalOpacity)) {
|
||||
return;
|
||||
}
|
||||
const numOpacity = Number(finalOpacity);
|
||||
if (!Number.isFinite(numOpacity) || numOpacity === 0) return;
|
||||
try {
|
||||
for (const el of document.querySelectorAll(anim.targetSelector)) {
|
||||
(el as unknown as HTMLElement).style.setProperty("opacity", String(numOpacity));
|
||||
}
|
||||
} catch {
|
||||
// Invalid selector — skip silently.
|
||||
}
|
||||
}
|
||||
|
||||
/** Lazy-load gsapParser to avoid pulling recast into every file-route import. */
|
||||
async function loadGsapParser() {
|
||||
return import("../../parsers/gsapParser.js");
|
||||
}
|
||||
|
||||
// ── GSAP mutation types ─────────────────────────────────────────────────────
|
||||
|
||||
type GsapMutationRequest =
|
||||
| {
|
||||
type: "update-property";
|
||||
animationId: string;
|
||||
property: string;
|
||||
value: number | string;
|
||||
}
|
||||
| {
|
||||
type: "update-from-property";
|
||||
animationId: string;
|
||||
property: string;
|
||||
value: number | string;
|
||||
}
|
||||
| {
|
||||
type: "update-meta";
|
||||
animationId: string;
|
||||
updates: { duration?: number; ease?: string; position?: number };
|
||||
}
|
||||
| {
|
||||
type: "add";
|
||||
targetSelector: string;
|
||||
method: "to" | "from" | "set" | "fromTo";
|
||||
position: number;
|
||||
duration?: number;
|
||||
ease?: string;
|
||||
properties: Record<string, number | string>;
|
||||
fromProperties?: Record<string, number | string>;
|
||||
}
|
||||
| { type: "delete"; animationId: string; stripStudioEdits?: boolean }
|
||||
| {
|
||||
type: "add-property";
|
||||
animationId: string;
|
||||
property: string;
|
||||
defaultValue: number | string;
|
||||
}
|
||||
| {
|
||||
type: "add-from-property";
|
||||
animationId: string;
|
||||
property: string;
|
||||
defaultValue: number | string;
|
||||
}
|
||||
| { type: "remove-property"; animationId: string; property: string }
|
||||
| { type: "remove-from-property"; animationId: string; property: string }
|
||||
| {
|
||||
type: "add-keyframe";
|
||||
animationId: string;
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
backfillDefaults?: Record<string, number | string>;
|
||||
}
|
||||
| { type: "remove-keyframe"; animationId: string; percentage: number }
|
||||
| {
|
||||
type: "update-keyframe";
|
||||
animationId: string;
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}
|
||||
| {
|
||||
type: "convert-to-keyframes";
|
||||
animationId: string;
|
||||
resolvedFromValues?: Record<string, number | string>;
|
||||
}
|
||||
| { type: "remove-all-keyframes"; animationId: string }
|
||||
| {
|
||||
type: "materialize-keyframes";
|
||||
animationId: string;
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>;
|
||||
easeEach?: string;
|
||||
resolvedSelector?: string;
|
||||
allElements?: Array<{
|
||||
selector: string;
|
||||
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
||||
easeEach?: string;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: "set-arc-path";
|
||||
animationId: string;
|
||||
enabled: boolean;
|
||||
autoRotate?: boolean | number;
|
||||
segments?: Array<{
|
||||
curviness: number;
|
||||
cp1?: { x: number; y: number };
|
||||
cp2?: { x: number; y: number };
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: "update-arc-segment";
|
||||
animationId: string;
|
||||
segmentIndex: number;
|
||||
curviness?: number;
|
||||
cp1?: { x: number; y: number };
|
||||
cp2?: { x: number; y: number };
|
||||
}
|
||||
| { type: "remove-arc-path"; animationId: string }
|
||||
| {
|
||||
type: "add-with-keyframes";
|
||||
targetSelector: string;
|
||||
position: number;
|
||||
duration: number;
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
auto?: boolean;
|
||||
}>;
|
||||
ease?: string;
|
||||
};
|
||||
|
||||
// ── GSAP mutation executor ──────────────────────────────────────────────────
|
||||
|
||||
async function executeGsapMutation(
|
||||
body: GsapMutationRequest,
|
||||
block: NonNullable<ReturnType<typeof extractGsapScriptBlock>>,
|
||||
respond: (data: unknown, status?: number) => Response,
|
||||
): Promise<string | Response> {
|
||||
const parser = await loadGsapParser();
|
||||
const {
|
||||
parseGsapScript,
|
||||
updateAnimationInScript,
|
||||
addAnimationToScript,
|
||||
removeAnimationFromScript,
|
||||
addKeyframeToScript,
|
||||
removeKeyframeFromScript,
|
||||
updateKeyframeInScript,
|
||||
convertToKeyframesInScript,
|
||||
removeAllKeyframesFromScript,
|
||||
materializeKeyframesInScript,
|
||||
unrollDynamicAnimations,
|
||||
setArcPathInScript,
|
||||
updateArcSegmentInScript,
|
||||
removeArcPathFromScript,
|
||||
addAnimationWithKeyframesToScript,
|
||||
} = parser;
|
||||
|
||||
function requireAnimation(
|
||||
scriptText: string,
|
||||
animationId: string,
|
||||
): { anim: GsapAnimation } | { err: Response } {
|
||||
const parsed = parseGsapScript(scriptText);
|
||||
const anim = parsed.animations.find((a) => a.id === animationId);
|
||||
if (!anim) return { err: respond({ error: "animation not found" }, 404) };
|
||||
return { anim };
|
||||
}
|
||||
|
||||
function requireFromToAnimation(
|
||||
scriptText: string,
|
||||
animationId: string,
|
||||
): { anim: GsapAnimation } | { err: Response } {
|
||||
const result = requireAnimation(scriptText, animationId);
|
||||
if ("err" in result) return result;
|
||||
if (result.anim.method !== "fromTo")
|
||||
return { err: respond({ error: "animation is not a fromTo" }, 400) };
|
||||
return result;
|
||||
}
|
||||
|
||||
switch (body.type) {
|
||||
case "update-property": {
|
||||
const r = requireAnimation(block.scriptText, body.animationId);
|
||||
if ("err" in r) return r.err;
|
||||
return updateAnimationInScript(block.scriptText, body.animationId, {
|
||||
properties: { ...r.anim.properties, [body.property]: body.value },
|
||||
});
|
||||
}
|
||||
case "update-from-property": {
|
||||
const r = requireFromToAnimation(block.scriptText, body.animationId);
|
||||
if ("err" in r) return r.err;
|
||||
return updateAnimationInScript(block.scriptText, body.animationId, {
|
||||
fromProperties: { ...(r.anim.fromProperties ?? {}), [body.property]: body.value },
|
||||
});
|
||||
}
|
||||
case "update-meta": {
|
||||
return updateAnimationInScript(block.scriptText, body.animationId, body.updates);
|
||||
}
|
||||
case "add": {
|
||||
if (body.fromProperties && body.method !== "fromTo") {
|
||||
return respond({ error: "fromProperties is only valid for method=fromTo" }, 400);
|
||||
}
|
||||
const result = addAnimationToScript(block.scriptText, {
|
||||
targetSelector: body.targetSelector,
|
||||
method: body.method,
|
||||
position: body.position,
|
||||
duration: body.duration,
|
||||
ease: body.ease,
|
||||
properties: body.properties,
|
||||
fromProperties: body.fromProperties,
|
||||
});
|
||||
return result.script;
|
||||
}
|
||||
case "delete": {
|
||||
const delTarget = requireAnimation(block.scriptText, body.animationId);
|
||||
if (!("err" in delTarget) && body.stripStudioEdits) {
|
||||
stripStudioEditsFromTarget(block.document, delTarget.anim.targetSelector);
|
||||
bakeVisibilityOnDelete(block.document, delTarget.anim);
|
||||
}
|
||||
return removeAnimationFromScript(block.scriptText, body.animationId);
|
||||
}
|
||||
case "add-property": {
|
||||
const r = requireAnimation(block.scriptText, body.animationId);
|
||||
if ("err" in r) return r.err;
|
||||
return updateAnimationInScript(block.scriptText, body.animationId, {
|
||||
properties: { ...r.anim.properties, [body.property]: body.defaultValue },
|
||||
});
|
||||
}
|
||||
case "add-from-property": {
|
||||
const r = requireFromToAnimation(block.scriptText, body.animationId);
|
||||
if ("err" in r) return r.err;
|
||||
return updateAnimationInScript(block.scriptText, body.animationId, {
|
||||
fromProperties: { ...(r.anim.fromProperties ?? {}), [body.property]: body.defaultValue },
|
||||
});
|
||||
}
|
||||
case "remove-property": {
|
||||
const r = requireAnimation(block.scriptText, body.animationId);
|
||||
if ("err" in r) return r.err;
|
||||
const filtered = { ...r.anim.properties };
|
||||
delete filtered[body.property];
|
||||
return updateAnimationInScript(block.scriptText, body.animationId, {
|
||||
properties: filtered,
|
||||
});
|
||||
}
|
||||
case "remove-from-property": {
|
||||
const r = requireFromToAnimation(block.scriptText, body.animationId);
|
||||
if ("err" in r) return r.err;
|
||||
const filtered = { ...(r.anim.fromProperties ?? {}) };
|
||||
delete filtered[body.property];
|
||||
return updateAnimationInScript(block.scriptText, body.animationId, {
|
||||
fromProperties: filtered,
|
||||
});
|
||||
}
|
||||
case "add-keyframe": {
|
||||
return addKeyframeToScript(
|
||||
block.scriptText,
|
||||
body.animationId,
|
||||
body.percentage,
|
||||
body.properties,
|
||||
body.ease,
|
||||
body.backfillDefaults,
|
||||
);
|
||||
}
|
||||
case "remove-keyframe": {
|
||||
return removeKeyframeFromScript(block.scriptText, body.animationId, body.percentage);
|
||||
}
|
||||
case "update-keyframe": {
|
||||
return updateKeyframeInScript(
|
||||
block.scriptText,
|
||||
body.animationId,
|
||||
body.percentage,
|
||||
body.properties,
|
||||
body.ease,
|
||||
);
|
||||
}
|
||||
case "convert-to-keyframes": {
|
||||
return convertToKeyframesInScript(
|
||||
block.scriptText,
|
||||
body.animationId,
|
||||
body.resolvedFromValues,
|
||||
);
|
||||
}
|
||||
case "remove-all-keyframes": {
|
||||
const preCollapse = requireAnimation(block.scriptText, body.animationId);
|
||||
if (!("err" in preCollapse)) {
|
||||
bakeVisibilityOnDelete(block.document, preCollapse.anim);
|
||||
}
|
||||
return removeAllKeyframesFromScript(block.scriptText, body.animationId);
|
||||
}
|
||||
case "materialize-keyframes": {
|
||||
if (body.allElements && body.allElements.length > 0) {
|
||||
return unrollDynamicAnimations(block.scriptText, body.animationId, body.allElements);
|
||||
}
|
||||
return materializeKeyframesInScript(
|
||||
block.scriptText,
|
||||
body.animationId,
|
||||
body.keyframes,
|
||||
body.easeEach,
|
||||
body.resolvedSelector,
|
||||
);
|
||||
}
|
||||
case "set-arc-path": {
|
||||
return setArcPathInScript(block.scriptText, body.animationId, {
|
||||
enabled: body.enabled,
|
||||
autoRotate: body.autoRotate ?? false,
|
||||
segments: body.segments ?? [],
|
||||
});
|
||||
}
|
||||
case "update-arc-segment": {
|
||||
return updateArcSegmentInScript(block.scriptText, body.animationId, body.segmentIndex, {
|
||||
...(body.curviness !== undefined ? { curviness: body.curviness } : {}),
|
||||
...(body.cp1 ? { cp1: body.cp1 } : {}),
|
||||
...(body.cp2 ? { cp2: body.cp2 } : {}),
|
||||
});
|
||||
}
|
||||
case "remove-arc-path": {
|
||||
return removeArcPathFromScript(block.scriptText, body.animationId);
|
||||
}
|
||||
case "add-with-keyframes": {
|
||||
const result = addAnimationWithKeyframesToScript(
|
||||
block.scriptText,
|
||||
body.targetSelector,
|
||||
body.position,
|
||||
body.duration,
|
||||
body.keyframes,
|
||||
body.ease,
|
||||
);
|
||||
return result.script;
|
||||
}
|
||||
default:
|
||||
return respond({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload file processing ──────────────────────────────────────────────────
|
||||
|
||||
async function processUploadedFiles(
|
||||
formData: FormData,
|
||||
targetDir: string,
|
||||
projectDir: string,
|
||||
): Promise<{
|
||||
uploaded: string[];
|
||||
skipped: string[];
|
||||
invalid: Array<{ name: string; reason: string }>;
|
||||
}> {
|
||||
const MAX_UPLOAD_BYTES = 500 * 1024 * 1024; // 500 MB per file
|
||||
const uploaded: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
const invalid: Array<{ name: string; reason: string }> = [];
|
||||
|
||||
// @types/node v25 narrows the ambient `FormData.entries()` to
|
||||
// `[string, string]` in workspaces where another dep declares an
|
||||
// `onmessage` global (it trips the worker branch of v25's conditional
|
||||
// File type). At runtime the value is still `File | string` — cast the
|
||||
// iterator so the rest of this block keeps type-checking on every
|
||||
// bun-install layout (hoisted on Windows surfaces this; isolated on
|
||||
// Linux happens to keep v24 in scope).
|
||||
type FileLike = {
|
||||
readonly name: string;
|
||||
readonly size: number;
|
||||
arrayBuffer(): Promise<ArrayBuffer>;
|
||||
};
|
||||
const entries = formData.entries() as unknown as Iterable<[string, FileLike | string]>;
|
||||
|
||||
// Derive the subdirectory prefix from targetDir relative to projectDir
|
||||
const subDir = targetDir === projectDir ? "" : targetDir.slice(projectDir.length + 1);
|
||||
|
||||
for (const [, value] of entries) {
|
||||
if (typeof value === "string") continue;
|
||||
|
||||
// Strip path separators — browsers may include directory components
|
||||
const name = value.name.split("/").pop()?.split("\\").pop() ?? "";
|
||||
if (!name || name.includes("\0") || name.includes("..")) continue;
|
||||
|
||||
// Reject individual files that exceed the size limit
|
||||
if (value.size > MAX_UPLOAD_BYTES) {
|
||||
skipped.push(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
const destPath = resolve(targetDir, name);
|
||||
if (!isSafePath(projectDir, destPath)) continue;
|
||||
|
||||
// Don't overwrite — append (2), (3), etc.
|
||||
let finalPath = destPath;
|
||||
let finalName = name;
|
||||
if (existsSync(finalPath)) {
|
||||
// Handle dotfiles correctly: .gitignore → ext="", base=".gitignore"
|
||||
const dotIdx = name.indexOf(".", name.startsWith(".") ? 1 : 0);
|
||||
const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
|
||||
const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
|
||||
let n = 2;
|
||||
while (n < 10000 && existsSync(resolve(targetDir, `${base} (${n})${ext}`))) n++;
|
||||
if (n >= 10000) {
|
||||
skipped.push(name);
|
||||
continue;
|
||||
}
|
||||
finalName = `${base} (${n})${ext}`;
|
||||
finalPath = resolve(targetDir, finalName);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await value.arrayBuffer());
|
||||
const validation = validateUploadedMediaBuffer(finalName, buffer);
|
||||
if (!validation.ok) {
|
||||
invalid.push({ name: finalName, reason: validation.reason });
|
||||
continue;
|
||||
}
|
||||
|
||||
writeFileSync(finalPath, buffer);
|
||||
const relativePath = subDir ? join(subDir, finalName) : finalName;
|
||||
uploaded.push(relativePath);
|
||||
if (isAudioFile(finalName)) {
|
||||
generateWaveformCache(projectDir, relativePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
return { uploaded, skipped, invalid };
|
||||
}
|
||||
|
||||
// ── Route registration ──────────────────────────────────────────────────────
|
||||
|
||||
export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
@@ -481,72 +941,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
if (subDir && !existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
const formData = await c.req.formData();
|
||||
const uploaded: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
const invalid: Array<{ name: string; reason: string }> = [];
|
||||
const result = await processUploadedFiles(formData, targetDir, project.dir);
|
||||
|
||||
// @types/node v25 narrows the ambient `FormData.entries()` to
|
||||
// `[string, string]` in workspaces where another dep declares an
|
||||
// `onmessage` global (it trips the worker branch of v25's conditional
|
||||
// File type). At runtime the value is still `File | string` — cast the
|
||||
// iterator so the rest of this block keeps type-checking on every
|
||||
// bun-install layout (hoisted on Windows surfaces this; isolated on
|
||||
// Linux happens to keep v24 in scope).
|
||||
type FileLike = {
|
||||
readonly name: string;
|
||||
readonly size: number;
|
||||
arrayBuffer(): Promise<ArrayBuffer>;
|
||||
};
|
||||
const entries = formData.entries() as unknown as Iterable<[string, FileLike | string]>;
|
||||
for (const [, value] of entries) {
|
||||
if (typeof value === "string") continue;
|
||||
|
||||
// Strip path separators — browsers may include directory components
|
||||
const name = value.name.split("/").pop()?.split("\\").pop() ?? "";
|
||||
if (!name || name.includes("\0") || name.includes("..")) continue;
|
||||
|
||||
// Reject individual files that exceed the size limit
|
||||
if (value.size > MAX_UPLOAD_BYTES) {
|
||||
skipped.push(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
const destPath = resolve(targetDir, name);
|
||||
if (!isSafePath(project.dir, destPath)) continue;
|
||||
|
||||
// Don't overwrite — append (2), (3), etc.
|
||||
let finalPath = destPath;
|
||||
let finalName = name;
|
||||
if (existsSync(finalPath)) {
|
||||
// Handle dotfiles correctly: .gitignore → ext="", base=".gitignore"
|
||||
const dotIdx = name.indexOf(".", name.startsWith(".") ? 1 : 0);
|
||||
const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
|
||||
const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
|
||||
let n = 2;
|
||||
while (n < 10000 && existsSync(resolve(targetDir, `${base} (${n})${ext}`))) n++;
|
||||
if (n >= 10000) {
|
||||
skipped.push(name);
|
||||
continue;
|
||||
}
|
||||
finalName = `${base} (${n})${ext}`;
|
||||
finalPath = resolve(targetDir, finalName);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await value.arrayBuffer());
|
||||
const validation = validateUploadedMediaBuffer(finalName, buffer);
|
||||
if (!validation.ok) {
|
||||
invalid.push({ name: finalName, reason: validation.reason });
|
||||
continue;
|
||||
}
|
||||
writeFileSync(finalPath, buffer);
|
||||
const relativePath = subDir ? join(subDir, finalName) : finalName;
|
||||
uploaded.push(relativePath);
|
||||
if (isAudioFile(finalName)) {
|
||||
generateWaveformCache(project.dir, relativePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ ok: true, files: uploaded, skipped, invalid }, 201);
|
||||
return c.json(
|
||||
{ ok: true, files: result.uploaded, skipped: result.skipped, invalid: result.invalid },
|
||||
201,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -576,121 +976,6 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
|
||||
// ── GSAP Mutations ──
|
||||
|
||||
type GsapMutationRequest =
|
||||
| {
|
||||
type: "update-property";
|
||||
animationId: string;
|
||||
property: string;
|
||||
value: number | string;
|
||||
}
|
||||
| {
|
||||
type: "update-from-property";
|
||||
animationId: string;
|
||||
property: string;
|
||||
value: number | string;
|
||||
}
|
||||
| {
|
||||
type: "update-meta";
|
||||
animationId: string;
|
||||
updates: { duration?: number; ease?: string; position?: number };
|
||||
}
|
||||
| {
|
||||
type: "add";
|
||||
targetSelector: string;
|
||||
method: "to" | "from" | "set" | "fromTo";
|
||||
position: number;
|
||||
duration?: number;
|
||||
ease?: string;
|
||||
properties: Record<string, number | string>;
|
||||
fromProperties?: Record<string, number | string>;
|
||||
}
|
||||
| { type: "delete"; animationId: string }
|
||||
| {
|
||||
type: "add-property";
|
||||
animationId: string;
|
||||
property: string;
|
||||
defaultValue: number | string;
|
||||
}
|
||||
| {
|
||||
type: "add-from-property";
|
||||
animationId: string;
|
||||
property: string;
|
||||
defaultValue: number | string;
|
||||
}
|
||||
| { type: "remove-property"; animationId: string; property: string }
|
||||
| { type: "remove-from-property"; animationId: string; property: string }
|
||||
| {
|
||||
type: "add-keyframe";
|
||||
animationId: string;
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
backfillDefaults?: Record<string, number | string>;
|
||||
}
|
||||
| { type: "remove-keyframe"; animationId: string; percentage: number }
|
||||
| {
|
||||
type: "update-keyframe";
|
||||
animationId: string;
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}
|
||||
| {
|
||||
type: "convert-to-keyframes";
|
||||
animationId: string;
|
||||
resolvedFromValues?: Record<string, number | string>;
|
||||
}
|
||||
| { type: "remove-all-keyframes"; animationId: string }
|
||||
| {
|
||||
type: "materialize-keyframes";
|
||||
animationId: string;
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>;
|
||||
easeEach?: string;
|
||||
resolvedSelector?: string;
|
||||
allElements?: Array<{
|
||||
selector: string;
|
||||
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
||||
easeEach?: string;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: "set-arc-path";
|
||||
animationId: string;
|
||||
enabled: boolean;
|
||||
autoRotate?: boolean | number;
|
||||
segments?: Array<{
|
||||
curviness: number;
|
||||
cp1?: { x: number; y: number };
|
||||
cp2?: { x: number; y: number };
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: "update-arc-segment";
|
||||
animationId: string;
|
||||
segmentIndex: number;
|
||||
curviness?: number;
|
||||
cp1?: { x: number; y: number };
|
||||
cp2?: { x: number; y: number };
|
||||
}
|
||||
| { type: "remove-arc-path"; animationId: string }
|
||||
| {
|
||||
type: "add-with-keyframes";
|
||||
targetSelector: string;
|
||||
position: number;
|
||||
duration: number;
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
auto?: boolean;
|
||||
}>;
|
||||
ease?: string;
|
||||
};
|
||||
|
||||
api.post("/projects/:id/gsap-mutations/*", async (c) => {
|
||||
const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, {
|
||||
mustExist: true,
|
||||
@@ -702,228 +987,46 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
return c.json({ error: "mutation type required" }, 400);
|
||||
}
|
||||
|
||||
const html = readFileSync(res.absPath, "utf-8");
|
||||
const block = extractGsapScriptBlock(html);
|
||||
let html = readFileSync(res.absPath, "utf-8");
|
||||
let block = extractGsapScriptBlock(html);
|
||||
if (!block && (body.type === "add" || body.type === "add-with-keyframes")) {
|
||||
const compId = html.match(/data-composition-id="([^"]+)"/)?.[1] ?? "main";
|
||||
const { GSAP_CDN } = await import("../../templates/constants.js");
|
||||
const gsapCdn = `<script src="${GSAP_CDN}"></script>`;
|
||||
const bootstrap = [
|
||||
gsapCdn,
|
||||
"<script>",
|
||||
"window.__timelines = window.__timelines || {};",
|
||||
`const tl = gsap.timeline({ paused: true });`,
|
||||
`window.__timelines["${compId}"] = tl;`,
|
||||
"</script>",
|
||||
].join("\n");
|
||||
if (html.includes("</body>")) {
|
||||
html = html.replace("</body>", `${bootstrap}\n</body>`);
|
||||
} else {
|
||||
html += `\n${bootstrap}`;
|
||||
}
|
||||
block = extractGsapScriptBlock(html);
|
||||
}
|
||||
if (!block) {
|
||||
return c.json({ error: "no GSAP script found in file" }, 400);
|
||||
}
|
||||
|
||||
const {
|
||||
parseGsapScript,
|
||||
updateAnimationInScript,
|
||||
addAnimationToScript,
|
||||
removeAnimationFromScript,
|
||||
} = await loadGsapParser();
|
||||
const respond = (data: unknown, status?: number) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- bridge between generic status and Hono's literal union
|
||||
status ? c.json(data, status as any) : c.json(data);
|
||||
|
||||
function requireAnimation(
|
||||
scriptText: string,
|
||||
animationId: string,
|
||||
): { anim: GsapAnimation } | { err: Response } {
|
||||
const parsed = parseGsapScript(scriptText);
|
||||
const anim = parsed.animations.find((a) => a.id === animationId);
|
||||
if (!anim) return { err: c.json({ error: "animation not found" }, 404) };
|
||||
return { anim };
|
||||
}
|
||||
|
||||
function requireFromToAnimation(
|
||||
scriptText: string,
|
||||
animationId: string,
|
||||
): { anim: GsapAnimation } | { err: Response } {
|
||||
const result = requireAnimation(scriptText, animationId);
|
||||
if ("err" in result) return result;
|
||||
if (result.anim.method !== "fromTo")
|
||||
return { err: c.json({ error: "animation is not a fromTo" }, 400) };
|
||||
return result;
|
||||
}
|
||||
|
||||
let newScript: string;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
switch (body.type) {
|
||||
case "update-property": {
|
||||
const r = requireAnimation(block.scriptText, body.animationId);
|
||||
if ("err" in r) return r.err;
|
||||
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
|
||||
properties: { ...r.anim.properties, [body.property]: body.value },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "update-from-property": {
|
||||
const r = requireFromToAnimation(block.scriptText, body.animationId);
|
||||
if ("err" in r) return r.err;
|
||||
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
|
||||
fromProperties: { ...(r.anim.fromProperties ?? {}), [body.property]: body.value },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "update-meta": {
|
||||
newScript = updateAnimationInScript(block.scriptText, body.animationId, body.updates);
|
||||
break;
|
||||
}
|
||||
case "add": {
|
||||
if (body.fromProperties && body.method !== "fromTo") {
|
||||
return c.json({ error: "fromProperties is only valid for method=fromTo" }, 400);
|
||||
}
|
||||
const result = addAnimationToScript(block.scriptText, {
|
||||
targetSelector: body.targetSelector,
|
||||
method: body.method,
|
||||
position: body.position,
|
||||
duration: body.duration,
|
||||
ease: body.ease,
|
||||
properties: body.properties,
|
||||
fromProperties: body.fromProperties,
|
||||
});
|
||||
newScript = result.script;
|
||||
break;
|
||||
}
|
||||
case "delete": {
|
||||
newScript = removeAnimationFromScript(block.scriptText, body.animationId);
|
||||
break;
|
||||
}
|
||||
case "add-property": {
|
||||
const r = requireAnimation(block.scriptText, body.animationId);
|
||||
if ("err" in r) return r.err;
|
||||
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
|
||||
properties: { ...r.anim.properties, [body.property]: body.defaultValue },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "add-from-property": {
|
||||
const r = requireFromToAnimation(block.scriptText, body.animationId);
|
||||
if ("err" in r) return r.err;
|
||||
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
|
||||
fromProperties: { ...(r.anim.fromProperties ?? {}), [body.property]: body.defaultValue },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "remove-property": {
|
||||
const r = requireAnimation(block.scriptText, body.animationId);
|
||||
if ("err" in r) return r.err;
|
||||
const filtered = { ...r.anim.properties };
|
||||
delete filtered[body.property];
|
||||
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
|
||||
properties: filtered,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "remove-from-property": {
|
||||
const r = requireFromToAnimation(block.scriptText, body.animationId);
|
||||
if ("err" in r) return r.err;
|
||||
const filtered = { ...(r.anim.fromProperties ?? {}) };
|
||||
delete filtered[body.property];
|
||||
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
|
||||
fromProperties: filtered,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "add-keyframe": {
|
||||
const { addKeyframeToScript } = await loadGsapParser();
|
||||
newScript = addKeyframeToScript(
|
||||
block.scriptText,
|
||||
body.animationId,
|
||||
body.percentage,
|
||||
body.properties,
|
||||
body.ease,
|
||||
body.backfillDefaults,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "remove-keyframe": {
|
||||
const { removeKeyframeFromScript } = await loadGsapParser();
|
||||
newScript = removeKeyframeFromScript(block.scriptText, body.animationId, body.percentage);
|
||||
break;
|
||||
}
|
||||
case "update-keyframe": {
|
||||
const { updateKeyframeInScript } = await loadGsapParser();
|
||||
newScript = updateKeyframeInScript(
|
||||
block.scriptText,
|
||||
body.animationId,
|
||||
body.percentage,
|
||||
body.properties,
|
||||
body.ease,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "convert-to-keyframes": {
|
||||
const { convertToKeyframesInScript } = await loadGsapParser();
|
||||
newScript = convertToKeyframesInScript(
|
||||
block.scriptText,
|
||||
body.animationId,
|
||||
body.resolvedFromValues,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "remove-all-keyframes": {
|
||||
const { removeAllKeyframesFromScript } = await loadGsapParser();
|
||||
newScript = removeAllKeyframesFromScript(block.scriptText, body.animationId);
|
||||
break;
|
||||
}
|
||||
case "materialize-keyframes": {
|
||||
const { materializeKeyframesInScript, unrollDynamicAnimations } = await loadGsapParser();
|
||||
if (body.allElements && body.allElements.length > 0) {
|
||||
newScript = unrollDynamicAnimations(block.scriptText, body.animationId, body.allElements);
|
||||
} else {
|
||||
newScript = materializeKeyframesInScript(
|
||||
block.scriptText,
|
||||
body.animationId,
|
||||
body.keyframes,
|
||||
body.easeEach,
|
||||
body.resolvedSelector,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "set-arc-path": {
|
||||
const { setArcPathInScript } = await loadGsapParser();
|
||||
newScript = setArcPathInScript(block.scriptText, body.animationId, {
|
||||
enabled: body.enabled,
|
||||
autoRotate: body.autoRotate ?? false,
|
||||
segments: body.segments ?? [],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "update-arc-segment": {
|
||||
const { updateArcSegmentInScript } = await loadGsapParser();
|
||||
newScript = updateArcSegmentInScript(
|
||||
block.scriptText,
|
||||
body.animationId,
|
||||
body.segmentIndex,
|
||||
{
|
||||
...(body.curviness !== undefined ? { curviness: body.curviness } : {}),
|
||||
...(body.cp1 ? { cp1: body.cp1 } : {}),
|
||||
...(body.cp2 ? { cp2: body.cp2 } : {}),
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "remove-arc-path": {
|
||||
const { removeArcPathFromScript } = await loadGsapParser();
|
||||
newScript = removeArcPathFromScript(block.scriptText, body.animationId);
|
||||
break;
|
||||
}
|
||||
case "add-with-keyframes": {
|
||||
const { addAnimationWithKeyframesToScript } = await loadGsapParser();
|
||||
const result = addAnimationWithKeyframesToScript(
|
||||
block.scriptText,
|
||||
body.targetSelector,
|
||||
body.position,
|
||||
body.duration,
|
||||
body.keyframes,
|
||||
body.ease,
|
||||
);
|
||||
newScript = result.script;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return c.json({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
|
||||
}
|
||||
const result = await executeGsapMutation(body, block, respond);
|
||||
if (result instanceof Response) return result;
|
||||
|
||||
const newScript = result;
|
||||
const newHtml = block.replaceScript(newScript);
|
||||
if (newHtml !== html) {
|
||||
writeFileSync(res.absPath, newHtml, "utf-8");
|
||||
}
|
||||
|
||||
// Re-parse the mutated script so the UI gets fresh state
|
||||
const { parseGsapScript } = await loadGsapParser();
|
||||
const freshParsed = parseGsapScript(newScript);
|
||||
return c.json({
|
||||
ok: true,
|
||||
|
||||
@@ -67,12 +67,14 @@ function injectScriptTagIntoHead(html: string, scriptTag: string): string {
|
||||
}
|
||||
|
||||
function htmlHasGsap(html: string): boolean {
|
||||
// Keep this heuristic conservative: if user source already loads GSAP, Studio does not add another copy.
|
||||
// Only match GSAP references outside <template> elements — scripts inside
|
||||
// templates are inert when cloned and don't make GSAP globally available.
|
||||
const outsideTemplates = html.replace(/<template\b[^>]*>[\s\S]*?<\/template>/gi, "");
|
||||
return (
|
||||
/<script\b[^>]*src=["'][^"']*gsap/i.test(html) ||
|
||||
/\/\*\s*inlined:.*gsap/i.test(html) ||
|
||||
/\b(GreenSock|_gsScope)\b/.test(html) ||
|
||||
/\bgsap\.(config|defaults|registerPlugin|version)\b/.test(html)
|
||||
/<script\b[^>]*src=["'][^"']*gsap/i.test(outsideTemplates) ||
|
||||
/\/\*\s*inlined:.*gsap/i.test(outsideTemplates) ||
|
||||
/\b(GreenSock|_gsScope)\b/.test(outsideTemplates) ||
|
||||
/\bgsap\.(config|defaults|registerPlugin|version)\b/.test(outsideTemplates)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user