From 2941dd908224a6209367cabf2807f549479dfb1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 27 May 2026 22:15:22 -0400 Subject: [PATCH 01/19] feat(studio): GSAP tween editing in Design panel (#1092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an ANIMATION section to the Design panel that lets users inspect and edit GSAP tween properties directly — no code editing required. - Recast AST parser replaces regex for GSAP script manipulation - Collapsible per-tween cards with video-editor language - Editable properties with add/remove, easing curve preview - DOMParser for HTML script extraction (no regex) - Selection position fix: force reapply before rect reads - GSAP translate doubling fix: Proxy on window.__timelines auto-wraps seek/play/pause the instant a timeline registers, closing the gap between GSAP init and seek wrapper installation - PropertyPanel visual offset: X/Y show actual position - Input validation: duration/position reject negatives - Feature flag: VITE_STUDIO_ENABLE_GSAP_PANEL (default false) --- bun.lock | 28 +- packages/core/package.json | 12 +- packages/core/src/parsers/gsapParser.test.ts | 15 +- packages/core/src/parsers/gsapParser.ts | 481 +++++++-------- .../src/components/StudioRightPanel.tsx | 14 + .../editor/GsapAnimationSection.tsx | 563 ++++++++++++++++++ .../src/components/editor/PropertyPanel.tsx | 37 +- .../src/components/editor/domEditingTypes.ts | 2 + .../editor/manualEditingAvailability.ts | 6 + .../src/components/editor/manualEdits.test.ts | 60 ++ .../src/components/editor/manualEdits.ts | 22 + .../editor/propertyPanelPrimitives.tsx | 7 +- .../studio/src/contexts/DomEditContext.tsx | 21 + .../studio/src/hooks/useDomEditSession.ts | 95 ++- packages/studio/src/hooks/useDomSelection.ts | 8 + .../studio/src/hooks/useGsapScriptCommits.ts | 250 ++++++++ .../studio/src/hooks/useGsapTweenCache.ts | 61 ++ .../studio/src/hooks/usePreviewPersistence.ts | 1 + .../studio/src/utils/gsapScriptHelpers.ts | 11 + 19 files changed, 1437 insertions(+), 257 deletions(-) create mode 100644 packages/studio/src/components/editor/GsapAnimationSection.tsx create mode 100644 packages/studio/src/hooks/useGsapScriptCommits.ts create mode 100644 packages/studio/src/hooks/useGsapTweenCache.ts create mode 100644 packages/studio/src/utils/gsapScriptHelpers.ts diff --git a/bun.lock b/bun.lock index 2d52b7a44..00c10ccde 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.6.29", + "version": "0.6.51", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -54,7 +54,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.6.29", + "version": "0.6.51", "bin": { "hyperframes": "./dist/cli.js", }, @@ -99,10 +99,12 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.6.29", + "version": "0.6.51", "dependencies": { + "@babel/parser": "^7.27.0", "@chenglou/pretext": "^0.0.5", "postcss": "^8.5.8", + "recast": "^0.23.11", }, "devDependencies": { "@types/jsdom": "^28.0.0", @@ -126,7 +128,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.6.29", + "version": "0.6.51", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -144,7 +146,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.6.29", + "version": "0.6.51", "devDependencies": { "@types/bun": "^1.1.0", "gsap": "^3.12.5", @@ -156,7 +158,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.6.29", + "version": "0.6.51", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -196,7 +198,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.6.29", + "version": "0.6.51", "dependencies": { "html2canvas": "^1.4.1", }, @@ -208,7 +210,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.6.29", + "version": "0.6.51", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -1041,7 +1043,7 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.12", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g=="], @@ -1675,6 +1677,8 @@ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1795,6 +1799,8 @@ "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], @@ -1951,6 +1957,8 @@ "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + "degenerator/ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -1997,6 +2005,8 @@ "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], diff --git a/packages/core/package.json b/packages/core/package.json index b2daefcfb..6c71578bc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -58,6 +58,10 @@ "import": "./src/registry/index.ts", "types": "./src/registry/index.ts" }, + "./gsap-parser": { + "import": "./src/parsers/gsapParser.ts", + "types": "./src/parsers/gsapParser.ts" + }, "./schemas/registry.json": "./schemas/registry.json", "./schemas/registry-item.json": "./schemas/registry-item.json" }, @@ -105,6 +109,10 @@ "import": "./dist/registry/index.js", "types": "./dist/registry/index.d.ts" }, + "./gsap-parser": { + "import": "./dist/parsers/gsapParser.js", + "types": "./dist/parsers/gsapParser.d.ts" + }, "./schemas/registry.json": "./schemas/registry.json", "./schemas/registry-item.json": "./schemas/registry-item.json" }, @@ -134,8 +142,10 @@ "prepublishOnly": "echo skip" }, "dependencies": { + "@babel/parser": "^7.27.0", "@chenglou/pretext": "^0.0.5", - "postcss": "^8.5.8" + "postcss": "^8.5.8", + "recast": "^0.23.11" }, "devDependencies": { "@types/jsdom": "^28.0.0", diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index 3dae45caf..532f9e3ef 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -79,9 +79,7 @@ describe("parseGsapScript", () => { expect(anim.position).toBe(2); }); - it("parseObjectLiteral does not match negative numbers (known limitation)", () => { - // The regex in parseObjectLiteral only matches [\d.]+, not negative numbers. - // Negative values like x: -100 won't be parsed by the object literal parser. + it("parses negative numbers in property values", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.fromTo("#el5", { opacity: 0, x: -100 }, { opacity: 1, x: 0, duration: 1 }, 0); @@ -92,8 +90,7 @@ describe("parseGsapScript", () => { const anim = result.animations[0]; expect(anim.fromProperties).toBeDefined(); expect(anim.fromProperties?.opacity).toBe(0); - // -100 is not parseable by the regex, so x won't be in fromProperties - expect(anim.fromProperties?.x).toBeUndefined(); + expect(anim.fromProperties?.x).toBe(-100); }); it("handles an empty script", () => { @@ -142,7 +139,7 @@ describe("parseGsapScript", () => { expect(result.animations[2].method).toBe("to"); }); - it("filters out unsupported properties from animations", () => { + it("extracts all GSAP properties including non-standard ones", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el1", { opacity: 1, backgroundColor: "red", x: 50, duration: 0.5 }, 0); @@ -151,8 +148,7 @@ describe("parseGsapScript", () => { expect(result.animations[0].properties.opacity).toBe(1); expect(result.animations[0].properties.x).toBe(50); - // backgroundColor is not in SUPPORTED_PROPS, so it's filtered out - expect(result.animations[0].properties.backgroundColor).toBeUndefined(); + expect(result.animations[0].properties.backgroundColor).toBe("red"); }); it("extracts ease from properties", () => { @@ -244,7 +240,7 @@ describe("gsapAnimationsToKeyframes", () => { targetSelector: "#el1", method: "set", position: 5, - properties: { x: 0, y: 0, scale: 1 }, + properties: { x: 0, y: 0 }, }, { id: "anim-2", @@ -258,7 +254,6 @@ describe("gsapAnimationsToKeyframes", () => { const keyframes = gsapAnimationsToKeyframes(animations, 5, { skipBaseSet: true }); - // The set at position 5 (time=0) with x=0, y=0, scale=1 (base values) should be skipped expect(keyframes).toHaveLength(1); expect(keyframes[0].id).toBe("anim-2"); }); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index c8db6a4cb..6fcdb6313 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -1,3 +1,5 @@ +import * as recast from "recast"; +import { parse as babelParse } from "@babel/parser"; import type { Keyframe, KeyframeProperties, ValidationResult } from "../core.types"; export type GsapMethod = "set" | "to" | "from" | "fromTo"; @@ -64,184 +66,218 @@ export const SUPPORTED_EASES = [ "expo.inOut", ]; -function parseObjectLiteral(str: string): Record { - const result: Record = {}; +// ── Recast AST Helpers ────────────────────────────────────────────────────── - const cleaned = str.replace(/^\{|\}$/g, "").trim(); - if (!cleaned) return result; - - const propRegex = /(\w+)\s*:\s*("[^"]*"|'[^']*'|[\d.]+|[a-zA-Z_][\w.]*)/g; - let match; - - while ((match = propRegex.exec(cleaned)) !== null) { - const key = match[1] ?? ""; - let value: string | number = match[2] ?? ""; - - if (typeof value === "string") { - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } else if (!isNaN(Number(value))) { - value = Number(value); - } - } - - result[key] = value; - } - - return result; +function parseScript(script: string) { + return recast.parse(script, { + parser: { + parse(source: string) { + return babelParse(source, { sourceType: "script", plugins: [], tokens: true }); + }, + }, + }); } -function findMatchingBrace(str: string, startIndex: number): number { - let depth = 0; - for (let i = startIndex; i < str.length; i++) { - if (str[i] === "{") depth++; - else if (str[i] === "}") { - depth--; - if (depth === 0) return i; - } +function extractLiteralValue(node: any): unknown { + if (!node) return undefined; + if (node.type === "NumericLiteral" || node.type === "Literal") return node.value; + if (node.type === "StringLiteral") return node.value; + if (node.type === "BooleanLiteral") return node.value; + if (node.type === "UnaryExpression" && node.operator === "-" && node.argument) { + const val = extractLiteralValue(node.argument); + return typeof val === "number" ? -val : undefined; } - return -1; + return undefined; } -export function parseGsapScript(script: string): ParsedGsap { - const animations: GsapAnimation[] = []; - let idCounter = 0; - - const timelineMatch = script.match(/(?:const|let|var)\s+(\w+)\s*=\s*gsap\.timeline/); - const timelineVar = timelineMatch ? (timelineMatch[1] ?? "tl") : "tl"; - - const preambleMatch = script.match( - new RegExp( - `^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`, - ), - ); - const preamble = preambleMatch - ? preambleMatch[0] - : `const ${timelineVar} = gsap.timeline({ paused: true });`; - - const methodPattern = new RegExp( - `${timelineVar}\\.(set|to|from|fromTo)\\s*\\(([^)]+(?:\\{[^}]*\\}[^)]*)+)\\)`, - "g", - ); - - let match; - while ((match = methodPattern.exec(script)) !== null) { - const rawMethod = match[1]; - if (!rawMethod || !GSAP_METHODS.has(rawMethod)) continue; - const method: GsapMethod = rawMethod as GsapMethod; - const argsStr = match[2] ?? ""; - - const animation = parseGsapCall(method, argsStr, ++idCounter); - if (animation) { - animations.push(animation); - } +function objectExpressionToRecord(node: any): Record { + const result: Record = {}; + if (node?.type !== "ObjectExpression") return result; + for (const prop of node.properties ?? []) { + if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue; + const key = prop.key?.name ?? prop.key?.value; + if (!key) continue; + result[key] = extractLiteralValue(prop.value); } - - const lastAnimIdx = script.lastIndexOf(`${timelineVar}.`); - let postamble = ""; - if (lastAnimIdx !== -1) { - const afterLastAnim = script.slice(lastAnimIdx); - const endOfCall = afterLastAnim.indexOf(";"); - if (endOfCall !== -1) { - postamble = script.slice(lastAnimIdx + endOfCall + 1).trim(); - } - } - - return { animations, timelineVar, preamble, postamble }; + return result; } -function parseGsapCall(method: GsapMethod, argsStr: string, idNum: number): GsapAnimation | null { - const selectorMatch = argsStr.match(/^\s*["']([^"']+)["']\s*,/); - if (!selectorMatch) return null; - - const targetSelector = selectorMatch[1] ?? ""; - const afterSelector = argsStr.slice(selectorMatch[0].length); - - let properties: Record = {}; - let fromProperties: Record | undefined; - let position = 0; +// ── Timeline Variable Detection ───────────────────────────────────────────── - if (method === "fromTo") { - const firstBrace = afterSelector.indexOf("{"); - const firstEnd = findMatchingBrace(afterSelector, firstBrace); - if (firstBrace === -1 || firstEnd === -1) return null; - - fromProperties = parseObjectLiteral(afterSelector.slice(firstBrace, firstEnd + 1)); - - const secondPart = afterSelector.slice(firstEnd + 1); - const secondBrace = secondPart.indexOf("{"); - const secondEnd = findMatchingBrace(secondPart, secondBrace); - if (secondBrace === -1 || secondEnd === -1) return null; - - properties = parseObjectLiteral(secondPart.slice(secondBrace, secondEnd + 1)); +function findTimelineVar(ast: any): string | null { + let timelineVar: string | null = null; + recast.types.visit(ast, { + visitVariableDeclarator(path: any) { + const init = path.node.init; + if ( + init?.type === "CallExpression" && + init.callee?.type === "MemberExpression" && + init.callee.object?.name === "gsap" && + init.callee.property?.name === "timeline" + ) { + timelineVar = path.node.id?.name ?? null; + } + this.traverse(path); + }, + }); + return timelineVar; +} - const afterProps = secondPart.slice(secondEnd + 1); - const posMatch = afterProps.match(/,\s*([\d.]+)/); - if (posMatch) position = parseFloat(posMatch[1] ?? ""); - } else { - const braceStart = afterSelector.indexOf("{"); - const braceEnd = findMatchingBrace(afterSelector, braceStart); +// ── Find All Tween Calls ──────────────────────────────────────────────────── - if (braceStart !== -1 && braceEnd !== -1) { - properties = parseObjectLiteral(afterSelector.slice(braceStart, braceEnd + 1)); +interface TweenCallInfo { + path: any; + node: any; + method: GsapMethod; + selector: string; + varsArg: any; + fromArg?: any; + positionArg?: any; +} - const afterProps = afterSelector.slice(braceEnd + 1); - const posMatch = afterProps.match(/,\s*([\d.]+)/); - if (posMatch) position = parseFloat(posMatch[1] ?? ""); - } - } +function findAllTweenCalls(ast: any, timelineVar: string): TweenCallInfo[] { + const results: TweenCallInfo[] = []; + recast.types.visit(ast, { + visitCallExpression(path: any) { + const node = path.node; + const callee = node.callee; + if ( + callee?.type === "MemberExpression" && + callee.object?.type === "Identifier" && + callee.object.name === timelineVar && + callee.property?.type === "Identifier" + ) { + const method = callee.property.name; + if (!GSAP_METHODS.has(method)) { + this.traverse(path); + return; + } + const args = node.arguments; + if (args.length < 2) { + this.traverse(path); + return; + } + const selectorArg = args[0]; + const selectorValue = + selectorArg.type === "StringLiteral" || selectorArg.type === "Literal" + ? String(selectorArg.value) + : null; + if (!selectorValue) { + this.traverse(path); + return; + } - const duration = typeof properties.duration === "number" ? properties.duration : undefined; - const ease = typeof properties.ease === "string" ? properties.ease : undefined; + if (method === "fromTo") { + results.push({ + path, + node, + method: "fromTo", + selector: selectorValue, + fromArg: args[1], + varsArg: args[2], + positionArg: args[3], + }); + } else { + results.push({ + path, + node, + method: method as GsapMethod, + selector: selectorValue, + varsArg: args[1], + positionArg: args[2], + }); + } + } + this.traverse(path); + }, + }); + return results; +} - const filteredProps: Record = {}; - for (const [key, value] of Object.entries(properties)) { - if (SUPPORTED_PROPS.includes(key)) { - filteredProps[key] = value; +function tweenCallToAnimation(call: TweenCallInfo, index: number): GsapAnimation { + const vars = objectExpressionToRecord(call.varsArg); + const properties: Record = {}; + for (const [key, val] of Object.entries(vars)) { + if (key === "duration" || key === "ease" || key === "delay" || key === "stagger") continue; + if (key === "keyframes" || key === "onComplete" || key === "onStart") continue; + if (typeof val === "number" || typeof val === "string") { + properties[key] = val; } } - let filteredFromProps: Record | undefined; - if (fromProperties) { - filteredFromProps = {}; - for (const [key, value] of Object.entries(fromProperties)) { - if (SUPPORTED_PROPS.includes(key)) { - filteredFromProps[key] = value; + let fromProperties: Record | undefined; + if (call.method === "fromTo" && call.fromArg) { + fromProperties = {}; + const fromVars = objectExpressionToRecord(call.fromArg); + for (const [key, val] of Object.entries(fromVars)) { + if (typeof val === "number" || typeof val === "string") { + fromProperties[key] = val; } } } + const posVal = call.positionArg ? extractLiteralValue(call.positionArg) : 0; + const position = typeof posVal === "number" ? posVal : 0; + const duration = typeof vars.duration === "number" ? vars.duration : undefined; + const ease = typeof vars.ease === "string" ? vars.ease : undefined; + return { - id: `anim-${idNum}`, - targetSelector, - method, + id: `anim-${index + 1}`, + targetSelector: call.selector, + method: call.method, position, - properties: filteredProps, - fromProperties: filteredFromProps, + properties, + fromProperties, duration, ease, }; } +// ── Public API ────────────────────────────────────────────────────────────── + +export function parseGsapScript(script: string): ParsedGsap { + try { + const ast = parseScript(script); + const timelineVar = findTimelineVar(ast) ?? "tl"; + const calls = findAllTweenCalls(ast, timelineVar); + const animations = calls.map((call, i) => tweenCallToAnimation(call, i)); + + const timelineMatch = script.match( + new RegExp( + `^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`, + ), + ); + const preamble = + timelineMatch?.[0] ?? `const ${timelineVar} = gsap.timeline({ paused: true });`; + + const lastCallIdx = script.lastIndexOf(`${timelineVar}.`); + let postamble = ""; + if (lastCallIdx !== -1) { + const afterLast = script.slice(lastCallIdx); + const endOfCall = afterLast.indexOf(";"); + if (endOfCall !== -1) { + postamble = script.slice(lastCallIdx + endOfCall + 1).trim(); + } + } + + return { animations, timelineVar, preamble, postamble }; + } catch { + return { animations: [], timelineVar: "tl", preamble: "", postamble: "" }; + } +} + export function serializeGsapAnimations( animations: GsapAnimation[], timelineVar = "tl", options?: { includeMediaSync?: boolean }, ): string { const sorted = [...animations].sort((a, b) => a.position - b.position); - const lines = sorted.map((anim) => { const selector = `"${anim.targetSelector}"`; - const props: Record = { ...anim.properties }; if (anim.duration !== undefined) props.duration = anim.duration; if (anim.ease) props.ease = anim.ease; - const propsStr = serializeObject(props); - switch (anim.method) { case "set": return ` ${timelineVar}.set(${selector}, ${propsStr}, ${anim.position});`; @@ -259,7 +295,6 @@ export function serializeGsapAnimations( let mediaSync = ""; if (options?.includeMediaSync) { mediaSync = ` - // Sync media playback ${timelineVar}.eventCallback("onUpdate", function() { const time = ${timelineVar}.time(); document.querySelectorAll("video[data-start], audio[data-start]").forEach(function(media) { @@ -288,29 +323,47 @@ ${lines.join("\n")}${mediaSync} function serializeObject(obj: Record): string { const entries = Object.entries(obj).map(([key, value]) => { - if (typeof value === "string") { - return `${key}: "${value}"`; - } + if (typeof value === "string") return `${key}: "${value}"`; return `${key}: ${value}`; }); return `{ ${entries.join(", ")} }`; } +function serializeWithContext(parsed: ParsedGsap, animations: GsapAnimation[]): string { + const sorted = [...animations].sort((a, b) => a.position - b.position); + const lines = sorted.map((anim) => { + const selector = `"${anim.targetSelector}"`; + const props: Record = { ...anim.properties }; + if (anim.duration !== undefined) props.duration = anim.duration; + if (anim.ease) props.ease = anim.ease; + const propsStr = serializeObject(props); + switch (anim.method) { + case "set": + return ` ${parsed.timelineVar}.set(${selector}, ${propsStr}, ${anim.position});`; + case "to": + return ` ${parsed.timelineVar}.to(${selector}, ${propsStr}, ${anim.position});`; + case "from": + return ` ${parsed.timelineVar}.from(${selector}, ${propsStr}, ${anim.position});`; + case "fromTo": { + const fromStr = serializeObject(anim.fromProperties || {}); + return ` ${parsed.timelineVar}.fromTo(${selector}, ${fromStr}, ${propsStr}, ${anim.position});`; + } + } + }); + const postamble = parsed.postamble ? `\n ${parsed.postamble}` : ""; + return `${parsed.preamble}\n${lines.join("\n")}${postamble}\n`; +} + export function updateAnimationInScript( script: string, animationId: string, updates: Partial, ): string { const parsed = parseGsapScript(script); - - const updated = parsed.animations.map((anim) => { - if (anim.id === animationId) { - return { ...anim, ...updates }; - } - return anim; - }); - - return serializeGsapAnimations(updated, parsed.timelineVar); + const updated = parsed.animations.map((anim) => + anim.id === animationId ? { ...anim, ...updates } : anim, + ); + return serializeWithContext(parsed, updated); } export function addAnimationToScript( @@ -318,22 +371,16 @@ export function addAnimationToScript( animation: Omit, ): { script: string; id: string } { const parsed = parseGsapScript(script); - const id = `anim-${Date.now()}`; const newAnim: GsapAnimation = { ...animation, id }; - parsed.animations.push(newAnim); - - return { - script: serializeGsapAnimations(parsed.animations, parsed.timelineVar), - id, - }; + return { script: serializeWithContext(parsed, parsed.animations), id }; } export function removeAnimationFromScript(script: string, animationId: string): string { const parsed = parseGsapScript(script); const filtered = parsed.animations.filter((a) => a.id !== animationId); - return serializeGsapAnimations(filtered, parsed.timelineVar); + return serializeWithContext(parsed, filtered); } export function getAnimationsForElement( @@ -344,73 +391,48 @@ export function getAnimationsForElement( return animations.filter((a) => a.targetSelector === selector); } +// ── Validation (regex-based, no AST needed) ───────────────────────────────── + const FORBIDDEN_GSAP_PATTERNS: Array<{ pattern: RegExp; message: string }> = [ { pattern: /\.call\s*\(/, message: "call() method not allowed" }, - { - pattern: /\.add\s*\(\s*function/, - message: "add(function) not allowed", - }, - { - pattern: /\.add\s*\(\s*\(/, - message: "add() with arrow function not allowed", - }, + { pattern: /\.add\s*\(/, message: "add() method not allowed" }, + { pattern: /\.addLabel\s*\(/, message: "addLabel() method not allowed" }, + { pattern: /\.addPause\s*\(/, message: "addPause() method not allowed" }, + { pattern: /gsap\.registerPlugin\s*\(/, message: "registerPlugin() not allowed" }, + { pattern: /gsap\.registerEffect\s*\(/, message: "registerEffect() not allowed" }, + { pattern: /ScrollTrigger/, message: "ScrollTrigger not allowed" }, + { pattern: /MotionPathPlugin/, message: "MotionPathPlugin not allowed" }, { pattern: /onComplete\s*:/, message: "onComplete callback not allowed" }, - { pattern: /onStart\s*:/, message: "onStart callback not allowed" }, { pattern: /onUpdate\s*:/, message: "onUpdate callback not allowed" }, - { - pattern: /onRepeat\s*:/, - message: "onRepeat callback not allowed", - }, - { - pattern: /onReverseComplete\s*:/, - message: "onReverseComplete callback not allowed", - }, - { - pattern: /repeat\s*:\s*-1/, - message: "Infinite repeat (repeat: -1) not allowed", - }, - { - pattern: /Math\.random\s*\(/, - message: "Random values (Math.random) not allowed", - }, - { - pattern: /Date\.now\s*\(/, - message: "Date-dependent values (Date.now) not allowed", - }, + { pattern: /onStart\s*:/, message: "onStart callback not allowed" }, + { pattern: /onRepeat\s*:/, message: "onRepeat callback not allowed" }, + { pattern: /onReverseComplete\s*:/, message: "onReverseComplete callback not allowed" }, + { pattern: /repeat\s*:\s*-1/, message: "Infinite repeat (repeat: -1) not allowed" }, + { pattern: /Math\.random\s*\(/, message: "Random values (Math.random) not allowed" }, + { pattern: /Date\.now\s*\(/, message: "Date-dependent values (Date.now) not allowed" }, { pattern: /new\s+Date\s*\(/, message: "Date constructor not allowed" }, { pattern: /setTimeout\s*\(/, message: "setTimeout not allowed" }, { pattern: /setInterval\s*\(/, message: "setInterval not allowed" }, - { - pattern: /requestAnimationFrame\s*\(/, - message: "requestAnimationFrame not allowed", - }, + { pattern: /requestAnimationFrame\s*\(/, message: "requestAnimationFrame not allowed" }, ]; export function validateCompositionGsap(script: string): ValidationResult { const errors: string[] = []; const warnings: string[] = []; - for (const { pattern, message } of FORBIDDEN_GSAP_PATTERNS) { - if (pattern.test(script)) { - errors.push(message); - } + if (pattern.test(script)) errors.push(message); } - if (/yoyo\s*:\s*true/.test(script)) { warnings.push("yoyo animations may behave unexpectedly when scrubbing"); } - if (/stagger\s*:/.test(script)) { warnings.push("stagger animations may not serialize correctly"); } - - return { - valid: errors.length === 0, - errors, - warnings, - }; + return { valid: errors.length === 0, errors, warnings }; } +// ── Keyframe Conversion Helpers ───────────────────────────────────────────── + export function keyframesToGsapAnimations( elementId: string, keyframes: Keyframe[], @@ -426,7 +448,6 @@ export function keyframesToGsapAnimations( sorted.forEach((kf, i) => { const absoluteTime = elementStartTime + kf.time; const isFirst = i === 0; - const prevKf = i > 0 ? sorted[i - 1] : null; const duration = prevKf ? kf.time - prevKf.time : undefined; const position = prevKf ? elementStartTime + prevKf.time : absoluteTime; @@ -481,40 +502,34 @@ export function gsapAnimationsToKeyframes( const time = clampTimeToZero ? Math.max(0, relativeTimeRaw) : relativeTimeRaw; const properties: Partial = {}; - for (const [key, value] of Object.entries(a.properties)) { - if (SUPPORTED_PROPS.includes(key) && typeof value === "number") { - if (key === "x") { - (properties as Record).x = value - baseX; - } else if (key === "y") { - (properties as Record).y = value - baseY; - } else if (key === "scale") { - (properties as Record).scale = - baseScale !== 0 ? value / baseScale : value; - } else { - (properties as Record)[key] = value; - } + if (typeof value !== "number") continue; + if (key === "x") properties.x = value - baseX; + else if (key === "y") properties.y = value - baseY; + else if (key === "scale") { + properties.scale = baseScale !== 0 ? value / baseScale : value; + } else { + (properties as Record)[key] = value; } } - if (skipBaseSet && a.method === "set" && Math.abs(time) <= baseTimeEpsilon) { - const propKeys = Object.keys(properties); - const isOnlyBaseProps = propKeys.every((k) => k === "x" || k === "y" || k === "scale"); - if (isOnlyBaseProps && propKeys.length > 0) { - const hasNonBaseOffset = - (properties.x !== undefined && Math.abs(properties.x) > baseValueEpsilon) || - (properties.y !== undefined && Math.abs(properties.y) > baseValueEpsilon) || - (properties.scale !== undefined && Math.abs(properties.scale - 1) > baseValueEpsilon); - if (!hasNonBaseOffset) { - return null; - } - } + if ( + skipBaseSet && + a.method === "set" && + time < baseTimeEpsilon && + Object.values(properties).every( + (v) => typeof v === "number" && Math.abs(v) < baseValueEpsilon, + ) + ) { + return null; } - const kf: Keyframe = { id: a.id, time, properties }; - if (a.ease !== undefined) kf.ease = a.ease; - return kf; + return { + id: a.id.replace(/^.*-kf-/, ""), + time, + properties: properties as KeyframeProperties, + ease: a.ease, + }; }) - .filter((kf): kf is Keyframe => kf !== null) - .sort((a, b) => a.time - b.time); + .filter((kf): kf is NonNullable => kf !== null) as Keyframe[]; } diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index f009e6426..748c30374 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -78,6 +78,13 @@ export function StudioRightPanel({ handleAskAgent, handleDomMotionCommit, handleDomMotionClear, + selectedGsapAnimations, + handleGsapUpdateProperty, + handleGsapUpdateMeta, + handleGsapDeleteAnimation, + handleGsapAddAnimation, + handleGsapAddProperty, + handleGsapRemoveProperty, } = useDomEditContext(); const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } = @@ -198,6 +205,13 @@ export function StudioRightPanel({ onImportAssets={handleImportFiles} fontAssets={fontAssets} onImportFonts={handleImportFonts} + gsapAnimations={selectedGsapAnimations} + onUpdateGsapProperty={handleGsapUpdateProperty} + onUpdateGsapMeta={handleGsapUpdateMeta} + onDeleteGsapAnimation={handleGsapDeleteAnimation} + onAddGsapProperty={handleGsapAddProperty} + onRemoveGsapProperty={handleGsapRemoveProperty} + onAddGsapAnimation={handleGsapAddAnimation} /> ) : motionPanelActive ? ( void; + onUpdateMeta: ( + animationId: string, + updates: { duration?: number; ease?: string; position?: number }, + ) => void; + onDeleteAnimation: (animationId: string) => void; + onAddProperty: (animationId: string, property: string) => void; + onRemoveProperty: (animationId: string, property: string) => void; + onAddAnimation: (method: "to" | "from" | "set") => void; + onLivePreview?: (property: string, value: number | string) => void; + onLivePreviewEnd?: () => void; +} + +const METHOD_LABELS: Record = { + set: "Set", + to: "Animate", + from: "Animate In", + fromTo: "Animate", +}; + +const METHOD_TOOLTIPS: Record = { + set: "Instantly snap to these values — no transition", + to: "Smoothly animate the element to these target values", + from: "Element starts at these values and transitions to its normal state", + fromTo: "Animate from one state to another", +}; + +const PROP_LABELS: Record = { + x: "Move X", + y: "Move Y", + width: "Width", + height: "Height", + rotation: "Rotate", + opacity: "Opacity", + scale: "Scale", + scaleX: "Scale X", + scaleY: "Scale Y", + autoAlpha: "Visibility", + visibility: "Visible", + scaleX_alias: "Stretch X", +}; + +const PROP_UNITS: Record = { + x: "px", + y: "px", + width: "px", + height: "px", + rotation: "°", + opacity: "", + scale: "×", + scaleX: "×", + scaleY: "×", + autoAlpha: "", + visibility: "", +}; + +const PROP_TOOLTIPS: Record = { + x: "Move left/right (negative = left, positive = right)", + y: "Move up/down (negative = up, positive = down)", + opacity: "How visible (0 = invisible, 1 = fully visible)", + scale: "Size multiplier (1 = normal, 2 = double, 0.5 = half)", + scaleX: "Horizontal stretch (1 = normal)", + scaleY: "Vertical stretch (1 = normal)", + rotation: "Spin angle (360 = full rotation)", + width: "Element width", + height: "Element height", + autoAlpha: "Like opacity but hides element completely at 0", + visibility: "Show or hide the element", +}; + +const EASE_LABELS: Record = { + none: "Constant speed", + "power1.out": "Gentle slowdown", + "power2.out": "Smooth slowdown", + "power3.out": "Snappy slowdown", + "power4.out": "Sharp slowdown", + "power1.in": "Gentle speedup", + "power2.in": "Smooth speedup", + "power3.in": "Strong speedup", + "power4.in": "Sharp speedup", + "power1.inOut": "Gentle ease", + "power2.inOut": "Smooth ease", + "power3.inOut": "Strong ease", + "power4.inOut": "Sharp ease", + "back.out": "Overshoot & settle", + "back.in": "Pull back & go", + "back.inOut": "Pull & overshoot", + "elastic.out": "Springy bounce", + "elastic.in": "Wind up spring", + "elastic.inOut": "Full spring", + "bounce.out": "Drop & bounce", + "bounce.in": "Reverse bounce", + "bounce.inOut": "Double bounce", + "expo.out": "Very snappy stop", + "expo.in": "Very slow start", + "expo.inOut": "Dramatic ease", +}; + +const EASE_CURVES: Record = { + none: [0, 0, 1, 1], + "power1.out": [0, 0, 0.58, 1], + "power2.out": [0.16, 1, 0.3, 1], + "power3.out": [0.08, 0.82, 0.17, 1], + "power4.out": [0.06, 0.73, 0.09, 1], + "power1.in": [0.42, 0, 1, 1], + "power2.in": [0.55, 0.06, 0.68, 0.19], + "power3.in": [0.6, 0.04, 0.98, 0.34], + "power4.in": [0.7, 0, 0.84, 0], + "power1.inOut": [0.42, 0, 0.58, 1], + "power2.inOut": [0.45, 0.05, 0.55, 0.95], + "power3.inOut": [0.65, 0.05, 0.35, 1], + "power4.inOut": [0.76, 0, 0.24, 1], + "back.out": [0.34, 1.56, 0.64, 1], + "back.in": [0.36, 0, 0.66, -0.56], + "back.inOut": [0.68, -0.55, 0.27, 1.55], + "expo.out": [0.16, 1, 0.3, 1], + "expo.in": [0.7, 0, 0.84, 0], + "expo.inOut": [0.87, 0, 0.13, 1], +}; + +function EaseCurveSection({ ease }: { ease: string }) { + const curve = EASE_CURVES[ease]; + const [progress, setProgress] = useState(null); + const rafRef = useRef(0); + + const play = useCallback(() => { + const start = performance.now(); + const duration = 1000; + const tick = (now: number) => { + const t = Math.min((now - start) / duration, 1); + setProgress(t); + if (t < 1) { + rafRef.current = requestAnimationFrame(tick); + } else { + setTimeout(() => setProgress(null), 400); + } + }; + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(tick); + }, []); + + if (!curve) return null; + const [x1, y1, x2, y2] = curve; + + const w = 200; + const h = 80; + const pad = 12; + const gw = w - pad * 2; + const gh = h - pad * 2; + + const curvePath = `M${pad},${h - pad} C${pad + gw * x1},${h - pad - gh * y1} ${pad + gw * x2},${h - pad - gh * y2} ${w - pad},${pad}`; + + let dotX = pad; + let dotY = h - pad; + if (progress !== null) { + const t = progress; + const mt = 1 - t; + const bx = mt * mt * mt * 0 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t * 1; + const by = mt * mt * mt * 0 + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t * 1; + dotX = pad + gw * bx; + dotY = h - pad - gh * by; + } + + const label = EASE_LABELS[ease] ?? ease; + + return ( +
+
+ Speed curve + +
+ + + + + {progress !== null && } + +

{label}

+
+ ); +} + +const ADD_METHODS = ["to", "from", "set"] as const; + +const ADD_METHOD_LABELS: Record = { + to: "Animate", + from: "Animate In", + set: "Set Instantly", +}; + +function buildTweenSummary(animation: GsapAnimation): string { + const easeName = animation.ease ?? "none"; + const ease = EASE_LABELS[easeName] ?? easeName; + const props = Object.entries(animation.properties); + const target = animation.targetSelector; + const dur = animation.duration ?? 0; + const pos = animation.position; + const propDescs = props.map(([p, v]) => { + const label = (PROP_LABELS[p] ?? p).toLowerCase(); + const unit = PROP_UNITS[p] ?? ""; + return `${label} to ${v}${unit}`; + }); + const propText = propDescs.length > 0 ? propDescs.join(", ") : "no properties yet"; + if (animation.method === "set") return `At ${pos}s, instantly set ${target}'s ${propText}.`; + if (animation.method === "from") + return `Starting at ${pos}s, over ${dur}s, ${target} enters from ${propText} using a ${ease.toLowerCase()} curve.`; + return `Starting at ${pos}s, over ${dur}s, animate ${target}'s ${propText} using a ${ease.toLowerCase()} curve.`; +} + +function parseNumericOrString(raw: string): number | string { + const num = Number(raw); + return Number.isFinite(num) ? num : raw; +} + +// fallow-ignore-next-line complexity +const AnimationCard = memo(function AnimationCard({ + animation, + defaultExpanded, + onUpdateProperty, + onUpdateMeta, + onDeleteAnimation, + onAddProperty, + onRemoveProperty, + onLivePreview, + onLivePreviewEnd, +}: { + animation: GsapAnimation; + defaultExpanded: boolean; + onUpdateProperty: GsapAnimationSectionProps["onUpdateProperty"]; + onUpdateMeta: GsapAnimationSectionProps["onUpdateMeta"]; + onDeleteAnimation: GsapAnimationSectionProps["onDeleteAnimation"]; + onAddProperty: GsapAnimationSectionProps["onAddProperty"]; + onRemoveProperty: GsapAnimationSectionProps["onRemoveProperty"]; + onLivePreview?: GsapAnimationSectionProps["onLivePreview"]; + onLivePreviewEnd?: GsapAnimationSectionProps["onLivePreviewEnd"]; +}) { + const [expanded, setExpanded] = useState(defaultExpanded); + const [addingProp, setAddingProp] = useState(false); + + const usedProps = useMemo( + () => new Set(Object.keys(animation.properties)), + [animation.properties], + ); + const availableProps = useMemo( + () => SUPPORTED_PROPS.filter((p) => !usedProps.has(p)), + [usedProps], + ); + + const commitProperty = useCallback( + (prop: string, raw: string) => { + const value = parseNumericOrString(raw); + onUpdateProperty(animation.id, prop, value); + onLivePreviewEnd?.(); + }, + [animation.id, onUpdateProperty, onLivePreviewEnd], + ); + + const scrubProperty = useCallback( + (prop: string, raw: string) => { + onLivePreview?.(prop, parseNumericOrString(raw)); + }, + [onLivePreview], + ); + + const commitDuration = useCallback( + (raw: string) => { + const num = Number(raw); + if (Number.isFinite(num) && num >= 0) + onUpdateMeta(animation.id, { duration: Math.max(0, num) }); + }, + [animation.id, onUpdateMeta], + ); + + const commitPosition = useCallback( + (raw: string) => { + const num = Number(raw); + if (Number.isFinite(num) && num >= 0) + onUpdateMeta(animation.id, { position: Math.max(0, num) }); + }, + [animation.id, onUpdateMeta], + ); + + const [copied, setCopied] = useState(false); + + const methodLabel = METHOD_LABELS[animation.method] ?? animation.method; + const easeName = animation.ease ?? "none"; + const easeLabel = EASE_LABELS[easeName] ?? easeName; + const endTime = animation.position + (animation.duration ?? 0); + + const summary = useMemo(() => buildTweenSummary(animation), [animation]); + + return ( +
+ + + {expanded && ( +
+
+
+

+ {summary} +

+ +
+
+ {animation.method !== "set" && ( + + )} + +
+ + {animation.method !== "set" && ( + <> + onUpdateMeta(animation.id, { ease: next })} + /> + + + )} + + {Object.keys(animation.properties).length > 0 && ( +
+ {Object.entries(animation.properties).map(([prop, val]) => ( +
+
+ { + scrubProperty(prop, raw); + commitProperty(prop, raw); + }} + /> +
+ +
+ ))} +
+ )} + +
+ {addingProp && availableProps.length > 0 ? ( + + ) : ( + availableProps.length > 0 && ( + + ) + )} + +
+
+
+ )} +
+ ); +}); + +export const GsapAnimationSection = memo(function GsapAnimationSection({ + animations, + onUpdateProperty, + onUpdateMeta, + onDeleteAnimation, + onAddProperty, + onRemoveProperty, + onAddAnimation, + onLivePreview, + onLivePreviewEnd, +}: GsapAnimationSectionProps) { + const [addMenuOpen, setAddMenuOpen] = useState(false); + + return ( +
}> +
+ {animations.map((anim, index) => ( + + ))} + +
+ {addMenuOpen ? ( +
+ {ADD_METHODS.map((method) => ( + + ))} + +
+ ) : ( + + )} +
+
+
+ ); +}); diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index c8a8d14fe..aff3744d6 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -18,12 +18,13 @@ import { import { MetricField, Section } from "./propertyPanelPrimitives"; import { isMediaElement, MediaSection } from "./propertyPanelMediaSection"; import { TextSection, StyleSections } from "./propertyPanelSections"; +import { GsapAnimationSection } from "./GsapAnimationSection"; +import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability"; // Re-export helpers that external consumers import from this module export { buildStrokeStyleUpdates, buildStrokeWidthStyleUpdates, - clampPanelNumber, getCssFilterFunctionPx, getClipPathInsetPx, inferBoxShadowPreset, @@ -54,6 +55,16 @@ interface PropertyPanelProps { onImportAssets?: (files: FileList) => Promise; fontAssets?: ImportedFontAsset[]; onImportFonts?: (files: FileList | File[]) => Promise; + gsapAnimations?: import("@hyperframes/core/gsap-parser").GsapAnimation[]; + onUpdateGsapProperty?: (animId: string, prop: string, value: number | string) => void; + onUpdateGsapMeta?: ( + animId: string, + updates: { duration?: number; ease?: string; position?: number }, + ) => void; + onDeleteGsapAnimation?: (animId: string) => void; + onAddGsapProperty?: (animId: string, prop: string) => void; + onRemoveGsapProperty?: (animId: string, prop: string) => void; + onAddGsapAnimation?: (method: "to" | "from" | "set") => void; } /* ------------------------------------------------------------------ */ @@ -146,6 +157,13 @@ export const PropertyPanel = memo(function PropertyPanel({ onImportAssets, fontAssets = [], onImportFonts, + gsapAnimations = [], + onUpdateGsapProperty, + onUpdateGsapMeta, + onDeleteGsapAnimation, + onAddGsapProperty, + onRemoveGsapProperty, + onAddGsapAnimation, }: PropertyPanelProps) { const styles = element?.computedStyles ?? EMPTY_STYLES; @@ -342,6 +360,23 @@ export const PropertyPanel = memo(function PropertyPanel({ + {STUDIO_GSAP_PANEL_ENABLED && + onUpdateGsapProperty && + onUpdateGsapMeta && + onDeleteGsapAnimation && + onAddGsapProperty && + onAddGsapAnimation && ( + {})} + onAddAnimation={onAddGsapAnimation} + /> + )} + {showEditableSections && ( ; textFields: DomEditTextField[]; capabilities: DomEditCapabilities; + gsapAnimations?: GsapAnimation[]; } export interface DomEditLayerItem { diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index 98057a44a..6845956a6 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -65,6 +65,12 @@ export const STUDIO_BLOCKS_PANEL_ENABLED = resolveStudioBooleanEnvFlag( true, ); +export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag( + env, + ["VITE_STUDIO_ENABLE_GSAP_PANEL", "VITE_STUDIO_GSAP_PANEL_ENABLED"], + false, +); + export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED; export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled"; diff --git a/packages/studio/src/components/editor/manualEdits.test.ts b/packages/studio/src/components/editor/manualEdits.test.ts index 8492ef746..69aab5bed 100644 --- a/packages/studio/src/components/editor/manualEdits.test.ts +++ b/packages/studio/src/components/editor/manualEdits.test.ts @@ -516,3 +516,63 @@ describe("studio manual edits", () => { expect(frames).toHaveLength(0); }); }); + +describe("applyStudioPathOffset strips GSAP double-counted translate", () => { + it("strips GSAP transform translate when applying offset", () => { + const window = new Window(); + const element = window.document.createElement("div"); + window.document.body.append(element); + + // Simulate GSAP having baked translate into the transform matrix + element.style.setProperty("transform", "matrix(1, 0, 0, 1, 200, 0)"); + + applyStudioPathOffset(element, { x: 200, y: 0 }); + + // The transform translate should be stripped (GSAP's 200px removed) + const transform = element.style.getPropertyValue("transform"); + if (transform && transform !== "none") { + const m = new window.DOMMatrix(transform); + expect(m.m41).toBe(0); + expect(m.m42).toBe(0); + } + // The offset should be stored in CSS vars + expect(readStudioPathOffset(element).x).toBe(200); + }); + + it("strips GSAP y translate while preserving scale in transform", () => { + const window = new Window(); + const element = window.document.createElement("div"); + window.document.body.append(element); + + // GSAP has scale + baked translate + element.style.setProperty("transform", "matrix(0.5, 0, 0, 0.5, 0, -20)"); + + applyStudioPathOffset(element, { x: 0, y: 50 }); + + const transform = element.style.getPropertyValue("transform"); + if (transform && transform !== "none") { + const m = new window.DOMMatrix(transform); + // Scale preserved + expect(m.a).toBeCloseTo(0.5); + expect(m.d).toBeCloseTo(0.5); + // Translate stripped + expect(m.m41).toBe(0); + expect(m.m42).toBe(0); + } + expect(readStudioPathOffset(element).y).toBe(50); + }); + + it("offset survives repeated applyStudioPathOffset calls without drift", () => { + const window = new Window(); + const element = window.document.createElement("div"); + window.document.body.append(element); + + // Apply offset 3 times with same value (simulates reapply hook firing multiple times) + applyStudioPathOffset(element, { x: 100, y: -20 }); + applyStudioPathOffset(element, { x: 100, y: -20 }); + applyStudioPathOffset(element, { x: 100, y: -20 }); + + expect(readStudioPathOffset(element).x).toBe(100); + expect(readStudioPathOffset(element).y).toBe(-20); + }); +}); diff --git a/packages/studio/src/components/editor/manualEdits.ts b/packages/studio/src/components/editor/manualEdits.ts index fd69969d5..ed680d47f 100644 --- a/packages/studio/src/components/editor/manualEdits.ts +++ b/packages/studio/src/components/editor/manualEdits.ts @@ -262,6 +262,28 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi wrapApplyAfterFunction(studioWin, timeline, "pause") || wrappedNamedTimelinePause; } + // Auto-wrap timelines registered AFTER this install runs. GSAP compositions + // register via `window.__timelines[id] = tl` which may happen after the + // Studio hook runs. The Proxy intercepts new registrations and wraps + // seek/play/pause immediately, closing the gap that causes translate doubling. + if (studioWin.__timelines && !(studioWin.__timelines as Record).__proxied) { + const original = studioWin.__timelines; + studioWin.__timelines = new Proxy(original, { + set(target, prop, value) { + target[prop as string] = value; + if (typeof value === "object" && value !== null) { + const tl = value as Record; + wrapSeekReapplyFunction(studioWin, tl, "seek"); + wrapPlayReapplyFunction(studioWin, tl, "play"); + wrapApplyAfterFunction(studioWin, tl, "pause"); + studioWin.__hfStudioManualEditsApply?.(); + } + return true; + }, + }); + (studioWin.__timelines as Record).__proxied = true; + } + if (isStudioManualEditPlaybackActive(studioWin)) { startStudioManualEditPlaybackReapply(studioWin); } diff --git a/packages/studio/src/components/editor/propertyPanelPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelPrimitives.tsx index dc069ec08..449bad38e 100644 --- a/packages/studio/src/components/editor/propertyPanelPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelPrimitives.tsx @@ -103,6 +103,8 @@ export function MetricField({ disabled, liveCommit, scrub, + suffix, + tooltip, onCommit, }: { label: string; @@ -110,6 +112,8 @@ export function MetricField({ disabled?: boolean; liveCommit?: boolean; scrub?: boolean; + suffix?: string; + tooltip?: string; onCommit: (nextValue: string) => void; }) { const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null); @@ -151,7 +155,7 @@ export function MetricField({ : ({ className: "flex-shrink-0 text-[11px] font-medium text-neutral-500" } as const); return ( -
+
{label} + {suffix && {suffix}}
); diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 5160b2610..1fd324d3e 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -53,6 +53,13 @@ export function DomEditProvider({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, + selectedGsapAnimations, + handleGsapUpdateProperty, + handleGsapUpdateMeta, + handleGsapDeleteAnimation, + handleGsapAddAnimation, + handleGsapAddProperty, + handleGsapRemoveProperty, }, children, }: { @@ -101,6 +108,13 @@ export function DomEditProvider({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, + selectedGsapAnimations, + handleGsapUpdateProperty, + handleGsapUpdateMeta, + handleGsapDeleteAnimation, + handleGsapAddAnimation, + handleGsapAddProperty, + handleGsapRemoveProperty, }), [ domEditSelection, @@ -143,6 +157,13 @@ export function DomEditProvider({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, + selectedGsapAnimations, + handleGsapUpdateProperty, + handleGsapUpdateMeta, + handleGsapDeleteAnimation, + handleGsapAddAnimation, + handleGsapAddProperty, + handleGsapRemoveProperty, ], ); return {children}; diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 517f732bd..681a43a38 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -1,7 +1,11 @@ import { useCallback, useEffect, useRef } from "react"; import type { TimelineElement } from "../player"; -import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability"; +import { + STUDIO_INSPECTOR_PANELS_ENABLED, + STUDIO_GSAP_PANEL_ENABLED, +} from "../components/editor/manualEditingAvailability"; import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing"; +import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits"; import type { ImportedFontAsset } from "../components/editor/fontAssets"; import type { EditHistoryKind } from "../utils/editHistory"; import type { RightPanelTab } from "../utils/studioHelpers"; @@ -11,6 +15,8 @@ import { useAskAgentModal } from "./useAskAgentModal"; import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; import { useDomEditCommits } from "./useDomEditCommits"; +import { useGsapScriptCommits } from "./useGsapScriptCommits"; +import { useGsapAnimationsForElement, useGsapCacheVersion } from "./useGsapTweenCache"; // ── Types ── @@ -185,6 +191,34 @@ export function useDomEditSession({ onClickToSource, }); + // ── GSAP script editing ── + + const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion(); + + const selectedGsapAnimations = useGsapAnimationsForElement( + STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null, + domEditSelection?.sourceFile || activeCompPath || "index.html", + domEditSelection?.id ?? null, + gsapCacheVersion, + ); + + const { + updateGsapProperty, + updateGsapMeta, + deleteGsapAnimation, + addGsapAnimation, + addGsapProperty, + removeGsapProperty, + } = useGsapScriptCommits({ + projectIdRef, + activeCompPath, + writeProjectFile, + editHistory, + domEditSaveTimestampRef, + reloadPreview, + onCacheInvalidate: bumpGsapCache, + }); + // ── Commit handlers (delegated to useDomEditCommits) ── const { @@ -224,7 +258,53 @@ export function useDomEditSession({ buildDomSelectionFromTarget, }); - // ── Effects ── + const handleGsapUpdateProperty = useCallback( + (animId: string, prop: string, value: number | string) => { + if (!domEditSelection) return; + updateGsapProperty(domEditSelection, animId, prop, value); + }, + [domEditSelection, updateGsapProperty], + ); + + const handleGsapUpdateMeta = useCallback( + (animId: string, updates: { duration?: number; ease?: string; position?: number }) => { + if (!domEditSelection) return; + updateGsapMeta(domEditSelection, animId, updates); + }, + [domEditSelection, updateGsapMeta], + ); + + const handleGsapDeleteAnimation = useCallback( + (animId: string) => { + if (!domEditSelection) return; + deleteGsapAnimation(domEditSelection, animId); + }, + [domEditSelection, deleteGsapAnimation], + ); + + const handleGsapAddAnimation = useCallback( + (method: "to" | "from" | "set") => { + if (!domEditSelection) return; + addGsapAnimation(domEditSelection, method); + }, + [domEditSelection, addGsapAnimation], + ); + + const handleGsapAddProperty = useCallback( + (animId: string, prop: string) => { + if (!domEditSelection) return; + addGsapProperty(domEditSelection, animId, prop); + }, + [domEditSelection, addGsapProperty], + ); + + const handleGsapRemoveProperty = useCallback( + (animId: string, prop: string) => { + if (!domEditSelection) return; + removeGsapProperty(domEditSelection, animId, prop); + }, + [domEditSelection, removeGsapProperty], + ); // Sync selection from preview document on load / refresh // eslint-disable-next-line no-restricted-syntax @@ -243,6 +323,8 @@ export function useDomEditSession({ } if (!doc) return; + reapplyPositionEditsAfterSeek(doc); + const nextElement = findElementForSelection(doc, currentSelection, activeCompPath); if (!nextElement) { applyDomSelection(null, { revealPanel: false }); @@ -345,5 +427,14 @@ export function useDomEditSession({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, + + // GSAP script editing + selectedGsapAnimations, + handleGsapUpdateProperty, + handleGsapUpdateMeta, + handleGsapDeleteAnimation, + handleGsapAddAnimation, + handleGsapAddProperty, + handleGsapRemoveProperty, }; } diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index c58670be0..0eba293ce 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -16,6 +16,7 @@ import { resolveDomEditSelection, type DomEditSelection, } from "../components/editor/domEditing"; +import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits"; // ── Types ── @@ -218,6 +219,11 @@ export function useDomSelection({ ) => { const iframe = previewIframeRef.current; if (!iframe || captionEditMode) return null; + try { + if (iframe.contentDocument) reapplyPositionEditsAfterSeek(iframe.contentDocument); + } catch { + /* cross-origin guard */ + } const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath); if (!target) return null; return buildDomSelectionFromTarget(target, { @@ -245,6 +251,8 @@ export function useDomSelection({ } if (!doc) return null; + reapplyPositionEditsAfterSeek(doc); + const targetElement = findElementForTimelineElement(doc, element, { activeCompositionPath: activeCompPath, compIdToSrc, diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts new file mode 100644 index 000000000..76a4087d0 --- /dev/null +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -0,0 +1,250 @@ +import { useCallback } from "react"; +import { + parseGsapScript, + updateAnimationInScript, + addAnimationToScript, + removeAnimationFromScript, +} from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import type { EditHistoryKind } from "../utils/editHistory"; +import { extractGsapScriptContent } from "../utils/gsapScriptHelpers"; + +function updateAnimProperties( + script: string, + animationId: string, + updater: (props: Record) => Record, +): string { + const parsed = parseGsapScript(script); + const anim = parsed.animations.find((a) => a.id === animationId); + if (!anim) return script; + return updateAnimationInScript(script, animationId, { properties: updater(anim.properties) }); +} + +const PROPERTY_DEFAULTS: Record = { + opacity: 1, + x: 0, + y: 0, + scale: 1, + scaleX: 1, + scaleY: 1, + rotation: 0, + width: 100, + height: 100, +}; + +function extractAndReplaceScript( + html: string, + transform: (scriptContent: string) => string, +): string | null { + const scriptContent = extractGsapScriptContent(html); + if (!scriptContent) return null; + + const modified = transform(scriptContent); + if (modified === scriptContent) return null; + + return html.replace(scriptContent, modified); +} + +interface GsapScriptCommitsParams { + projectIdRef: React.MutableRefObject; + activeCompPath: string | null; + writeProjectFile: (path: string, content: string) => Promise; + editHistory: { + recordEdit: (entry: { + label: string; + kind: EditHistoryKind; + coalesceKey?: string; + files: Record; + }) => Promise; + }; + domEditSaveTimestampRef: React.MutableRefObject; + reloadPreview: () => void; + onCacheInvalidate: () => void; +} + +// fallow-ignore-next-line complexity unit-size +export function useGsapScriptCommits({ + projectIdRef, + activeCompPath, + writeProjectFile, + editHistory, + domEditSaveTimestampRef, + reloadPreview, + onCacheInvalidate, +}: GsapScriptCommitsParams) { + const readSourceFile = useCallback( + async (sourceFile: string): Promise => { + const pid = projectIdRef.current; + if (!pid) return null; + try { + const response = await fetch( + `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(sourceFile)}`, + ); + if (!response.ok) return null; + const data = (await response.json()) as { content?: string }; + return typeof data.content === "string" ? data.content : null; + } catch { + return null; + } + }, + [projectIdRef], + ); + + const persistScriptEdit = useCallback( + async ( + selection: DomEditSelection, + transform: (scriptContent: string) => string, + options: { label: string; coalesceKey?: string; softReload?: boolean }, + ) => { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const originalHtml = await readSourceFile(targetPath); + if (!originalHtml) return; + + const newHtml = extractAndReplaceScript(originalHtml, transform); + if (!newHtml || newHtml === originalHtml) return; + + domEditSaveTimestampRef.current = Date.now(); + await writeProjectFile(targetPath, newHtml); + await editHistory.recordEdit({ + label: options.label, + kind: "manual", + coalesceKey: options.coalesceKey, + files: { [targetPath]: { before: originalHtml, after: newHtml } }, + }); + + onCacheInvalidate(); + + if (!options.softReload) { + reloadPreview(); + } + }, + [ + activeCompPath, + readSourceFile, + writeProjectFile, + editHistory, + domEditSaveTimestampRef, + reloadPreview, + onCacheInvalidate, + ], + ); + + const updateGsapProperty = useCallback( + ( + selection: DomEditSelection, + animationId: string, + property: string, + value: number | string, + ) => { + void persistScriptEdit( + selection, + (script) => updateAnimProperties(script, animationId, (p) => ({ ...p, [property]: value })), + { + label: `Edit GSAP ${property}`, + coalesceKey: `gsap:${animationId}:${property}`, + softReload: true, + }, + ); + }, + [persistScriptEdit], + ); + + const updateGsapMeta = useCallback( + ( + selection: DomEditSelection, + animationId: string, + updates: { duration?: number; ease?: string; position?: number }, + ) => { + void persistScriptEdit( + selection, + (script) => updateAnimationInScript(script, animationId, updates), + { + label: "Edit GSAP animation", + coalesceKey: `gsap:${animationId}:meta`, + }, + ); + }, + [persistScriptEdit], + ); + + const deleteGsapAnimation = useCallback( + (selection: DomEditSelection, animationId: string) => { + void persistScriptEdit( + selection, + (script) => removeAnimationFromScript(script, animationId), + { label: "Delete GSAP animation" }, + ); + }, + [persistScriptEdit], + ); + + const addGsapAnimation = useCallback( + (selection: DomEditSelection, method: "to" | "from" | "set") => { + const selector = selection.id ? `#${selection.id}` : selection.selector; + if (!selector) return; + + const start = Number.parseFloat(selection.dataAttributes.start ?? "0") || 0; + const defaults: Record> = { + from: { opacity: 0 }, + to: { opacity: 1 }, + set: { opacity: 1 }, + }; + + void persistScriptEdit( + selection, + (script) => { + const result = addAnimationToScript(script, { + targetSelector: selector, + method, + position: start, + duration: method === "set" ? undefined : 0.5, + ease: method === "set" ? undefined : "power2.out", + properties: defaults[method] ?? { opacity: 1 }, + }); + return result.script; + }, + { label: `Add GSAP ${method} animation` }, + ); + }, + [persistScriptEdit], + ); + + const addGsapProperty = useCallback( + (selection: DomEditSelection, animationId: string, property: string) => { + void persistScriptEdit( + selection, + (script) => + updateAnimProperties(script, animationId, (p) => ({ + ...p, + [property]: PROPERTY_DEFAULTS[property] ?? 0, + })), + { label: `Add GSAP ${property}` }, + ); + }, + [persistScriptEdit], + ); + + const removeGsapProperty = useCallback( + (selection: DomEditSelection, animationId: string, property: string) => { + void persistScriptEdit( + selection, + (script) => + updateAnimProperties(script, animationId, (p) => { + const { [property]: _, ...rest } = p; + return rest; + }), + { label: `Remove GSAP ${property}` }, + ); + }, + [persistScriptEdit], + ); + + return { + updateGsapProperty, + updateGsapMeta, + deleteGsapAnimation, + addGsapAnimation, + addGsapProperty, + removeGsapProperty, + }; +} diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts new file mode 100644 index 000000000..c9412afc3 --- /dev/null +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -0,0 +1,61 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + parseGsapScript, + getAnimationsForElement, + type GsapAnimation, +} from "@hyperframes/core/gsap-parser"; +import { extractGsapScriptContent } from "../utils/gsapScriptHelpers"; + +export function useGsapAnimationsForElement( + projectId: string | null, + sourceFile: string, + elementId: string | null, + version: number, +): GsapAnimation[] { + const [animations, setAnimations] = useState([]); + const lastKeyRef = useRef(""); + + useEffect(() => { + const key = `${projectId}:${sourceFile}:${elementId}:${version}`; + if (key === lastKeyRef.current) return; + lastKeyRef.current = key; + + if (!projectId || !elementId) { + setAnimations([]); + return; + } + + let cancelled = false; + + fetch(`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(sourceFile)}`) + .then((r) => (r.ok ? (r.json() as Promise<{ content?: string }>) : null)) + .then((data) => { + if (cancelled || !data?.content) { + if (!cancelled) setAnimations([]); + return; + } + const script = extractGsapScriptContent(data.content); + if (!script) { + setAnimations([]); + return; + } + const parsed = parseGsapScript(script); + setAnimations(getAnimationsForElement(parsed.animations, elementId)); + }) + .catch(() => { + if (!cancelled) setAnimations([]); + }); + + return () => { + cancelled = true; + }; + }, [projectId, sourceFile, elementId, version]); + + return animations; +} + +export function useGsapCacheVersion() { + const [version, setVersion] = useState(0); + const bump = useCallback(() => setVersion((v) => v + 1), []); + return { version, bump }; +} diff --git a/packages/studio/src/hooks/usePreviewPersistence.ts b/packages/studio/src/hooks/usePreviewPersistence.ts index eab2d755d..2ebe73a67 100644 --- a/packages/studio/src/hooks/usePreviewPersistence.ts +++ b/packages/studio/src/hooks/usePreviewPersistence.ts @@ -102,6 +102,7 @@ export function usePreviewPersistence({ } if (d) reapplyPositionEditsAfterSeek(d); }; + const install = () => { reapply(); if (iframe.contentWindow) installStudioManualEditSeekReapply(iframe.contentWindow, reapply); diff --git a/packages/studio/src/utils/gsapScriptHelpers.ts b/packages/studio/src/utils/gsapScriptHelpers.ts new file mode 100644 index 000000000..a1909f20f --- /dev/null +++ b/packages/studio/src/utils/gsapScriptHelpers.ts @@ -0,0 +1,11 @@ +export function extractGsapScriptContent(html: string): string | null { + const doc = new DOMParser().parseFromString(html, "text/html"); + const scripts = doc.querySelectorAll("script:not([src])"); + for (const script of scripts) { + const text = script.textContent ?? ""; + if (text.includes("__timelines") || (text.includes("timeline") && text.includes(".to("))) { + return text; + } + } + return null; +} From 75323350a9b36fc0ecc54f81569539fbf8c1bc03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 28 May 2026 01:25:28 -0400 Subject: [PATCH 02/19] fix(studio): GSAP drag position drift, parser hardening, and review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause fix for element position doubling during drag: - sourceMutation.ts unconditionally prepended data- to attribute names that already had the prefix, producing data-data-hf-studio-* in the persisted HTML. reapplyPathOffsets could not find those elements, so GSAP's baked translate was never stripped. - Guard against double prefix; migrate legacy attributes on the fly. - Revert gsapTranslate compensation in drag — with reapply working, the raw CSS var offset is the correct initial value. Parser and serializer hardening (PR review feedback): - Scope resolution via AST: resolve const/let/var declarations so variable references in tween properties are editable. - String positions preserved instead of coerced to 0. - Consolidated two diverged serializers into one with preamble/postamble. - Parse-fail safety: mutation functions return original script unchanged. - Quote escaping and non-identifier key quoting. - html.replace uses function replacement to avoid $& interpretation. Studio UX improvements: - Debounced property writes (150ms). - Preview reloads after property edits. - Auto-generate unique element ID when adding animations. - New animation starts at current playhead time. - Interactive draggable bezier handles on speed curve for custom eases. - String positions display correctly throughout. - Dead code cleanup (fallow). Tests: 113 passing across 4 test files. --- packages/core/src/lint/rules/gsap.ts | 3 + packages/core/src/parsers/gsapParser.test.ts | 136 ++++++++ packages/core/src/parsers/gsapParser.ts | 172 ++++++--- .../studio-api/helpers/sourceMutation.test.ts | 38 ++ .../src/studio-api/helpers/sourceMutation.ts | 11 +- .../editor/GsapAnimationSection.tsx | 325 ++++++++++-------- .../src/components/editor/PropertyPanel.tsx | 23 +- .../editor/gsapAnimationConstants.ts | 130 +++++++ .../src/components/editor/manualEdits.test.ts | 41 +++ .../src/components/editor/manualEdits.ts | 9 - .../src/components/editor/manualEditsDom.ts | 28 +- .../editor/manualOffsetDrag.test.ts | 57 +-- .../src/components/editor/manualOffsetDrag.ts | 8 +- .../studio/src/hooks/useDomEditSession.ts | 4 +- .../studio/src/hooks/useGsapScriptCommits.ts | 63 +++- 15 files changed, 753 insertions(+), 295 deletions(-) create mode 100644 packages/studio/src/components/editor/gsapAnimationConstants.ts diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index 7c28aaa4f..bf16b44f6 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -135,6 +135,9 @@ function extractGsapWindows(script: string): GsapWindow[] { const animation = parsed.animations[index]; index += 1; if (!animation) continue; + // Skip animations with string positions (e.g. "+=1", "<") — their absolute + // timing depends on runtime evaluation and can't be statically linted. + if (typeof animation.position !== "number") continue; windows.push({ targetSelector: animation.targetSelector, position: animation.position, diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index 532f9e3ef..9ea3d1d50 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -8,6 +8,9 @@ import { validateCompositionGsap, getAnimationsForElement, keyframesToGsapAnimations, + addAnimationToScript, + removeAnimationFromScript, + updateAnimationInScript, } from "./gsapParser.js"; import type { GsapAnimation } from "./gsapParser.js"; import type { Keyframe } from "../core.types"; @@ -171,6 +174,62 @@ describe("parseGsapScript", () => { expect(result.timelineVar).toBe("timeline"); expect(result.animations).toHaveLength(1); }); + + it("preserves string position values like '+=1' and '<'", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }, "+=1"); + tl.to("#el2", { x: 100, duration: 1 }, "<"); + tl.to("#el3", { y: 50, duration: 0.3 }, "-=0.5"); + `; + const result = parseGsapScript(script); + + expect(result.animations).toHaveLength(3); + expect(result.animations[0].position).toBe("+=1"); + expect(result.animations[1].position).toBe("<"); + expect(result.animations[2].position).toBe("-=0.5"); + }); + + it("resolves variable references from const declarations in the same script", () => { + const script = ` + const FADE = 0.8; + const OFFSET = -60; + const MY_EASE = "power3.out"; + const tl = gsap.timeline({ paused: true }); + tl.from("#el1", { y: OFFSET, opacity: 0, duration: FADE, ease: MY_EASE }, 0); + `; + const result = parseGsapScript(script); + + expect(result.animations).toHaveLength(1); + expect(result.animations[0].properties.y).toBe(-60); + expect(result.animations[0].properties.opacity).toBe(0); + expect(result.animations[0].duration).toBe(0.8); + expect(result.animations[0].ease).toBe("power3.out"); + }); + + it("resolves computed expressions from scope bindings", () => { + const script = ` + const BASE = 100; + const HALF = BASE / 2; + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { x: HALF, duration: 1 }, 0); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].properties.x).toBe(50); + }); + + it("skips unresolvable references without crashing", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: someUndefinedVar, x: 50, duration: 1 }, 0); + `; + const result = parseGsapScript(script); + + expect(result.animations).toHaveLength(1); + expect(result.animations[0].properties.x).toBe(50); + expect(result.animations[0].properties.opacity).toBeUndefined(); + }); }); describe("gsapAnimationsToKeyframes", () => { @@ -522,6 +581,83 @@ describe("getAnimationsForElement", () => { }); }); +describe("mutation functions parse-fail safety", () => { + const garbage = "this is not valid javascript @@@ {{{{"; + + it("updateAnimationInScript returns original script on parse failure", () => { + const result = updateAnimationInScript(garbage, "anim-1", { duration: 2 }); + expect(result).toBe(garbage); + }); + + it("addAnimationToScript returns original script on parse failure", () => { + const result = addAnimationToScript(garbage, { + targetSelector: "#el1", + method: "to", + position: 0, + properties: { opacity: 1 }, + duration: 1, + }); + expect(result.script).toBe(garbage); + expect(result.id).toBe(""); + }); + + it("removeAnimationFromScript returns original script on parse failure", () => { + const result = removeAnimationFromScript(garbage, "anim-1"); + expect(result).toBe(garbage); + }); +}); + +describe("serializeGsapAnimations quote escaping", () => { + it("escapes quotes and backslashes in string property values", () => { + const animations: GsapAnimation[] = [ + { + id: "anim-1", + targetSelector: "#el1", + method: "to", + position: 0, + properties: { content: 'say "hello"' }, + duration: 1, + }, + ]; + + const result = serializeGsapAnimations(animations); + // JSON.stringify produces escaped quotes + expect(result).toContain('content: "say \\"hello\\""'); + }); + + it("escapes backslashes in string property values", () => { + const animations: GsapAnimation[] = [ + { + id: "anim-1", + targetSelector: "#el1", + method: "to", + position: 0, + properties: { path: "C:\\Users\\test" }, + duration: 1, + }, + ]; + + const result = serializeGsapAnimations(animations); + expect(result).toContain('path: "C:\\\\Users\\\\test"'); + }); + + it("serializes string position values correctly", () => { + const animations: GsapAnimation[] = [ + { + id: "anim-1", + targetSelector: "#el1", + method: "to", + position: "+=1", + properties: { opacity: 1 }, + duration: 0.5, + }, + ]; + + const result = serializeGsapAnimations(animations); + expect(result).toContain('"+=1"'); + }); +}); + describe("SUPPORTED_PROPS", () => { it("includes expected properties", () => { expect(SUPPORTED_PROPS).toContain("opacity"); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index 6fcdb6313..44c25f4c5 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -8,7 +8,7 @@ export interface GsapAnimation { id: string; targetSelector: string; method: GsapMethod; - position: number; + position: number | string; properties: Record; fromProperties?: Record; duration?: number; @@ -68,6 +68,8 @@ export const SUPPORTED_EASES = [ // ── Recast AST Helpers ────────────────────────────────────────────────────── +type ScopeBindings = ReadonlyMap; + function parseScript(script: string) { return recast.parse(script, { parser: { @@ -78,26 +80,79 @@ function parseScript(script: string) { }); } -function extractLiteralValue(node: any): unknown { +function collectScopeBindings(ast: any): ScopeBindings { + const bindings = new Map(); + recast.types.visit(ast, { + visitVariableDeclarator(path: any) { + const name = path.node.id?.name; + const init = path.node.init; + if (name && init) { + const val = resolveNode(init, bindings); + if (val !== undefined) bindings.set(name, val); + } + this.traverse(path); + }, + }); + return bindings; +} + +function resolveNode( + node: any, + scope: ReadonlyMap, +): number | string | boolean | undefined { if (!node) return undefined; - if (node.type === "NumericLiteral" || node.type === "Literal") return node.value; - if (node.type === "StringLiteral") return node.value; - if (node.type === "BooleanLiteral") return node.value; + if (node.type === "NumericLiteral" || (node.type === "Literal" && typeof node.value === "number")) + return node.value; + if (node.type === "StringLiteral" || (node.type === "Literal" && typeof node.value === "string")) + return node.value; + if ( + node.type === "BooleanLiteral" || + (node.type === "Literal" && typeof node.value === "boolean") + ) + return node.value; if (node.type === "UnaryExpression" && node.operator === "-" && node.argument) { - const val = extractLiteralValue(node.argument); + const val = resolveNode(node.argument, scope); return typeof val === "number" ? -val : undefined; } + if (node.type === "BinaryExpression") { + const left = resolveNode(node.left, scope); + const right = resolveNode(node.right, scope); + if (typeof left === "number" && typeof right === "number") { + switch (node.operator) { + case "+": + return left + right; + case "-": + return left - right; + case "*": + return left * right; + case "/": + return right !== 0 ? left / right : undefined; + } + } + if (typeof left === "string" && node.operator === "+") return left + String(right ?? ""); + if (typeof right === "string" && node.operator === "+") return String(left ?? "") + right; + } + if (node.type === "Identifier" && scope.has(node.name)) { + return scope.get(node.name); + } + if (node.type === "TemplateLiteral" && node.expressions?.length === 0) { + return node.quasis?.[0]?.value?.cooked ?? undefined; + } return undefined; } -function objectExpressionToRecord(node: any): Record { +function extractLiteralValue(node: any, scope: ScopeBindings): unknown { + return resolveNode(node, scope); +} + +function objectExpressionToRecord(node: any, scope: ScopeBindings): Record { const result: Record = {}; if (node?.type !== "ObjectExpression") return result; for (const prop of node.properties ?? []) { if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue; const key = prop.key?.name ?? prop.key?.value; if (!key) continue; - result[key] = extractLiteralValue(prop.value); + result[key] = resolveNode(prop.value, scope); } return result; } @@ -194,9 +249,14 @@ function findAllTweenCalls(ast: any, timelineVar: string): TweenCallInfo[] { return results; } -function tweenCallToAnimation(call: TweenCallInfo, index: number): GsapAnimation { - const vars = objectExpressionToRecord(call.varsArg); +function tweenCallToAnimation( + call: TweenCallInfo, + index: number, + scope: ScopeBindings, +): GsapAnimation { + const vars = objectExpressionToRecord(call.varsArg, scope); const properties: Record = {}; + for (const [key, val] of Object.entries(vars)) { if (key === "duration" || key === "ease" || key === "delay" || key === "stagger") continue; if (key === "keyframes" || key === "onComplete" || key === "onStart") continue; @@ -208,7 +268,7 @@ function tweenCallToAnimation(call: TweenCallInfo, index: number): GsapAnimation let fromProperties: Record | undefined; if (call.method === "fromTo" && call.fromArg) { fromProperties = {}; - const fromVars = objectExpressionToRecord(call.fromArg); + const fromVars = objectExpressionToRecord(call.fromArg, scope); for (const [key, val] of Object.entries(fromVars)) { if (typeof val === "number" || typeof val === "string") { fromProperties[key] = val; @@ -216,8 +276,9 @@ function tweenCallToAnimation(call: TweenCallInfo, index: number): GsapAnimation } } - const posVal = call.positionArg ? extractLiteralValue(call.positionArg) : 0; - const position = typeof posVal === "number" ? posVal : 0; + const posVal = call.positionArg ? extractLiteralValue(call.positionArg, scope) : 0; + const position: number | string = + typeof posVal === "number" ? posVal : typeof posVal === "string" ? posVal : 0; const duration = typeof vars.duration === "number" ? vars.duration : undefined; const ease = typeof vars.ease === "string" ? vars.ease : undefined; @@ -238,9 +299,10 @@ function tweenCallToAnimation(call: TweenCallInfo, index: number): GsapAnimation export function parseGsapScript(script: string): ParsedGsap { try { const ast = parseScript(script); + const scope = collectScopeBindings(ast); const timelineVar = findTimelineVar(ast) ?? "tl"; const calls = findAllTweenCalls(ast, timelineVar); - const animations = calls.map((call, i) => tweenCallToAnimation(call, i)); + const animations = calls.map((call, i) => tweenCallToAnimation(call, i, scope)); const timelineMatch = script.match( new RegExp( @@ -269,25 +331,30 @@ export function parseGsapScript(script: string): ParsedGsap { export function serializeGsapAnimations( animations: GsapAnimation[], timelineVar = "tl", - options?: { includeMediaSync?: boolean }, + options?: { includeMediaSync?: boolean; preamble?: string; postamble?: string }, ): string { - const sorted = [...animations].sort((a, b) => a.position - b.position); + const sorted = [...animations].sort((a, b) => { + const aNum = typeof a.position === "number" ? a.position : Number.MAX_SAFE_INTEGER; + const bNum = typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER; + return aNum - bNum; + }); const lines = sorted.map((anim) => { const selector = `"${anim.targetSelector}"`; const props: Record = { ...anim.properties }; if (anim.duration !== undefined) props.duration = anim.duration; if (anim.ease) props.ease = anim.ease; const propsStr = serializeObject(props); + const posStr = typeof anim.position === "string" ? `"${anim.position}"` : anim.position; switch (anim.method) { case "set": - return ` ${timelineVar}.set(${selector}, ${propsStr}, ${anim.position});`; + return ` ${timelineVar}.set(${selector}, ${propsStr}, ${posStr});`; case "to": - return ` ${timelineVar}.to(${selector}, ${propsStr}, ${anim.position});`; + return ` ${timelineVar}.to(${selector}, ${propsStr}, ${posStr});`; case "from": - return ` ${timelineVar}.from(${selector}, ${propsStr}, ${anim.position});`; + return ` ${timelineVar}.from(${selector}, ${propsStr}, ${posStr});`; case "fromTo": { const fromStr = serializeObject(anim.fromProperties || {}); - return ` ${timelineVar}.fromTo(${selector}, ${fromStr}, ${propsStr}, ${anim.position});`; + return ` ${timelineVar}.fromTo(${selector}, ${fromStr}, ${propsStr}, ${posStr});`; } } }); @@ -315,43 +382,27 @@ export function serializeGsapAnimations( });`; } + const preamble = options?.preamble || `const ${timelineVar} = gsap.timeline({ paused: true });`; + const postamble = options?.postamble ? `\n ${options.postamble}` : ""; + return ` - const ${timelineVar} = gsap.timeline({ paused: true }); -${lines.join("\n")}${mediaSync} + ${preamble} +${lines.join("\n")}${mediaSync}${postamble} `; } function serializeObject(obj: Record): string { const entries = Object.entries(obj).map(([key, value]) => { - if (typeof value === "string") return `${key}: "${value}"`; - return `${key}: ${value}`; + const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key); + if (typeof value === "string") return `${safeKey}: ${JSON.stringify(value)}`; + return `${safeKey}: ${value}`; }); return `{ ${entries.join(", ")} }`; } -function serializeWithContext(parsed: ParsedGsap, animations: GsapAnimation[]): string { - const sorted = [...animations].sort((a, b) => a.position - b.position); - const lines = sorted.map((anim) => { - const selector = `"${anim.targetSelector}"`; - const props: Record = { ...anim.properties }; - if (anim.duration !== undefined) props.duration = anim.duration; - if (anim.ease) props.ease = anim.ease; - const propsStr = serializeObject(props); - switch (anim.method) { - case "set": - return ` ${parsed.timelineVar}.set(${selector}, ${propsStr}, ${anim.position});`; - case "to": - return ` ${parsed.timelineVar}.to(${selector}, ${propsStr}, ${anim.position});`; - case "from": - return ` ${parsed.timelineVar}.from(${selector}, ${propsStr}, ${anim.position});`; - case "fromTo": { - const fromStr = serializeObject(anim.fromProperties || {}); - return ` ${parsed.timelineVar}.fromTo(${selector}, ${fromStr}, ${propsStr}, ${anim.position});`; - } - } - }); - const postamble = parsed.postamble ? `\n ${parsed.postamble}` : ""; - return `${parsed.preamble}\n${lines.join("\n")}${postamble}\n`; +/** Returns true when the parse result is a failure fallback (no animations, no preamble). */ +function isParseFailure(parsed: ParsedGsap): boolean { + return parsed.animations.length === 0 && !parsed.preamble; } export function updateAnimationInScript( @@ -360,10 +411,14 @@ export function updateAnimationInScript( updates: Partial, ): string { const parsed = parseGsapScript(script); + if (isParseFailure(parsed)) return script; const updated = parsed.animations.map((anim) => anim.id === animationId ? { ...anim, ...updates } : anim, ); - return serializeWithContext(parsed, updated); + return serializeGsapAnimations(updated, parsed.timelineVar, { + preamble: parsed.preamble, + postamble: parsed.postamble, + }); } export function addAnimationToScript( @@ -371,16 +426,27 @@ export function addAnimationToScript( animation: Omit, ): { script: string; id: string } { const parsed = parseGsapScript(script); + if (isParseFailure(parsed)) return { script, id: "" }; const id = `anim-${Date.now()}`; const newAnim: GsapAnimation = { ...animation, id }; - parsed.animations.push(newAnim); - return { script: serializeWithContext(parsed, parsed.animations), id }; + const allAnimations = [...parsed.animations, newAnim]; + return { + script: serializeGsapAnimations(allAnimations, parsed.timelineVar, { + preamble: parsed.preamble, + postamble: parsed.postamble, + }), + id, + }; } export function removeAnimationFromScript(script: string, animationId: string): string { const parsed = parseGsapScript(script); + if (isParseFailure(parsed)) return script; const filtered = parsed.animations.filter((a) => a.id !== animationId); - return serializeWithContext(parsed, filtered); + return serializeGsapAnimations(filtered, parsed.timelineVar, { + preamble: parsed.preamble, + postamble: parsed.postamble, + }); } export function getAnimationsForElement( @@ -496,9 +562,9 @@ export function gsapAnimationsToKeyframes( const baseValueEpsilon = 0.00001; return animations - .filter((a) => validMethods.includes(a.method)) + .filter((a) => validMethods.includes(a.method) && typeof a.position === "number") .map((a) => { - const relativeTimeRaw = a.position - elementStartTime; + const relativeTimeRaw = (a.position as number) - elementStartTime; const time = clampTimeToZero ? Math.max(0, relativeTimeRaw) : relativeTimeRaw; const properties: Partial = {}; diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts index 305b93488..5d1e1eb94 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts @@ -70,6 +70,44 @@ describe("patchElementInHtml", () => { expect(result).toContain('data-hf-studio-path-offset="true"'); }); + it("does not double data- prefix when property already has it", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "attribute", property: "data-hf-studio-path-offset", value: "true" }, + ]); + + expect(result).toContain('data-hf-studio-path-offset="true"'); + expect(result).not.toContain("data-data-hf-studio-path-offset"); + }); + + it("does not double data- prefix for any studio attribute", () => { + const attrs = [ + "data-hf-studio-path-offset", + "data-hf-studio-original-translate", + "data-hf-studio-original-inline-translate", + "data-hf-studio-box-size", + "data-hf-studio-rotation", + ]; + for (const attr of attrs) { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "attribute", property: attr, value: "true" }, + ]); + expect(result).toContain(`${attr}="true"`); + expect(result).not.toContain(`data-${attr}`); + } + }); + + it("removes attribute with data- prefix already present", () => { + const withAttr = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "attribute", property: "data-hf-studio-path-offset", value: "true" }, + ]); + expect(withAttr).toContain('data-hf-studio-path-offset="true"'); + + const removed = patchElementInHtml(withAttr, { id: "hero" }, [ + { type: "attribute", property: "data-hf-studio-path-offset", value: null }, + ]); + expect(removed).not.toContain("hf-studio-path-offset"); + }); + it("patches html attribute", () => { const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "html-attribute", property: "title", value: "greeting" }, diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index 2b7e22b95..6c00041e7 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -176,10 +176,13 @@ export function patchElementInHtml( } break; case "attribute": - if (op.value != null) { - htmlEl.setAttribute(`data-${op.property}`, op.value); - } else { - htmlEl.removeAttribute(`data-${op.property}`); + { + const fullAttr = op.property.startsWith("data-") ? op.property : `data-${op.property}`; + if (op.value != null) { + htmlEl.setAttribute(fullAttr, op.value); + } else { + htmlEl.removeAttribute(fullAttr); + } } break; case "html-attribute": diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index 595d09e34..53d2711b2 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -4,6 +4,19 @@ import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-parser" import { Film } from "../../icons/SystemIcons"; import { RESPONSIVE_GRID } from "./propertyPanelHelpers"; import { MetricField, Section, SelectField } from "./propertyPanelPrimitives"; +import { controlPointsForGsapEase } from "./studioMotion"; +import { + ADD_METHODS, + ADD_METHOD_LABELS, + EASE_CURVES, + EASE_LABELS, + METHOD_LABELS, + METHOD_TOOLTIPS, + PROP_LABELS, + PROP_TOOLTIPS, + PROP_UNITS, + parseCustomEaseFromString, +} from "./gsapAnimationConstants"; interface GsapAnimationSectionProps { animations: GsapAnimation[]; @@ -20,157 +33,104 @@ interface GsapAnimationSectionProps { onLivePreviewEnd?: () => void; } -const METHOD_LABELS: Record = { - set: "Set", - to: "Animate", - from: "Animate In", - fromTo: "Animate", -}; - -const METHOD_TOOLTIPS: Record = { - set: "Instantly snap to these values — no transition", - to: "Smoothly animate the element to these target values", - from: "Element starts at these values and transitions to its normal state", - fromTo: "Animate from one state to another", -}; - -const PROP_LABELS: Record = { - x: "Move X", - y: "Move Y", - width: "Width", - height: "Height", - rotation: "Rotate", - opacity: "Opacity", - scale: "Scale", - scaleX: "Scale X", - scaleY: "Scale Y", - autoAlpha: "Visibility", - visibility: "Visible", - scaleX_alias: "Stretch X", -}; - -const PROP_UNITS: Record = { - x: "px", - y: "px", - width: "px", - height: "px", - rotation: "°", - opacity: "", - scale: "×", - scaleX: "×", - scaleY: "×", - autoAlpha: "", - visibility: "", -}; - -const PROP_TOOLTIPS: Record = { - x: "Move left/right (negative = left, positive = right)", - y: "Move up/down (negative = up, positive = down)", - opacity: "How visible (0 = invisible, 1 = fully visible)", - scale: "Size multiplier (1 = normal, 2 = double, 0.5 = half)", - scaleX: "Horizontal stretch (1 = normal)", - scaleY: "Vertical stretch (1 = normal)", - rotation: "Spin angle (360 = full rotation)", - width: "Element width", - height: "Element height", - autoAlpha: "Like opacity but hides element completely at 0", - visibility: "Show or hide the element", -}; - -const EASE_LABELS: Record = { - none: "Constant speed", - "power1.out": "Gentle slowdown", - "power2.out": "Smooth slowdown", - "power3.out": "Snappy slowdown", - "power4.out": "Sharp slowdown", - "power1.in": "Gentle speedup", - "power2.in": "Smooth speedup", - "power3.in": "Strong speedup", - "power4.in": "Sharp speedup", - "power1.inOut": "Gentle ease", - "power2.inOut": "Smooth ease", - "power3.inOut": "Strong ease", - "power4.inOut": "Sharp ease", - "back.out": "Overshoot & settle", - "back.in": "Pull back & go", - "back.inOut": "Pull & overshoot", - "elastic.out": "Springy bounce", - "elastic.in": "Wind up spring", - "elastic.inOut": "Full spring", - "bounce.out": "Drop & bounce", - "bounce.in": "Reverse bounce", - "bounce.inOut": "Double bounce", - "expo.out": "Very snappy stop", - "expo.in": "Very slow start", - "expo.inOut": "Dramatic ease", -}; - -const EASE_CURVES: Record = { - none: [0, 0, 1, 1], - "power1.out": [0, 0, 0.58, 1], - "power2.out": [0.16, 1, 0.3, 1], - "power3.out": [0.08, 0.82, 0.17, 1], - "power4.out": [0.06, 0.73, 0.09, 1], - "power1.in": [0.42, 0, 1, 1], - "power2.in": [0.55, 0.06, 0.68, 0.19], - "power3.in": [0.6, 0.04, 0.98, 0.34], - "power4.in": [0.7, 0, 0.84, 0], - "power1.inOut": [0.42, 0, 0.58, 1], - "power2.inOut": [0.45, 0.05, 0.55, 0.95], - "power3.inOut": [0.65, 0.05, 0.35, 1], - "power4.inOut": [0.76, 0, 0.24, 1], - "back.out": [0.34, 1.56, 0.64, 1], - "back.in": [0.36, 0, 0.66, -0.56], - "back.inOut": [0.68, -0.55, 0.27, 1.55], - "expo.out": [0.16, 1, 0.3, 1], - "expo.in": [0.7, 0, 0.84, 0], - "expo.inOut": [0.87, 0, 0.13, 1], -}; - -function EaseCurveSection({ ease }: { ease: string }) { - const curve = EASE_CURVES[ease]; +function EaseCurveSection({ + ease, + onCustomEaseCommit, +}: { + ease: string; + onCustomEaseCommit: (ease: string) => void; +}) { + const isCustom = ease.startsWith("custom("); + const curveFromPreset = EASE_CURVES[ease]; + const customPoints = isCustom ? parseCustomEaseFromString(ease) : null; + const curve: [number, number, number, number] | null = + isCustom && customPoints + ? [customPoints.x1, customPoints.y1, customPoints.x2, customPoints.y2] + : (curveFromPreset ?? null); + + const [draft, setDraft] = useState<[number, number, number, number] | null>(null); const [progress, setProgress] = useState(null); + const draggingRef = useRef<"p1" | "p2" | null>(null); + const svgRef = useRef(null); const rafRef = useRef(0); + const active = draft ?? curve; + if (!active) return null; + const [x1, y1, x2, y2] = active; + + const w = 200; + const h = 100; + const pad = 14; + const gw = w - pad * 2; + const gh = h - pad * 2; + + const toSvg = (px: number, py: number) => ({ + x: pad + gw * px, + y: h - pad - gh * py, + }); + + const curvePath = `M${pad},${h - pad} C${toSvg(x1, y1).x},${toSvg(x1, y1).y} ${toSvg(x2, y2).x},${toSvg(x2, y2).y} ${w - pad},${pad}`; + const play = useCallback(() => { const start = performance.now(); - const duration = 1000; + const dur = 1000; const tick = (now: number) => { - const t = Math.min((now - start) / duration, 1); + const t = Math.min((now - start) / dur, 1); setProgress(t); - if (t < 1) { - rafRef.current = requestAnimationFrame(tick); - } else { - setTimeout(() => setProgress(null), 400); - } + if (t < 1) rafRef.current = requestAnimationFrame(tick); + else setTimeout(() => setProgress(null), 400); }; cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(tick); }, []); - if (!curve) return null; - const [x1, y1, x2, y2] = curve; - - const w = 200; - const h = 80; - const pad = 12; - const gw = w - pad * 2; - const gh = h - pad * 2; - - const curvePath = `M${pad},${h - pad} C${pad + gw * x1},${h - pad - gh * y1} ${pad + gw * x2},${h - pad - gh * y2} ${w - pad},${pad}`; - let dotX = pad; let dotY = h - pad; if (progress !== null) { const t = progress; const mt = 1 - t; - const bx = mt * mt * mt * 0 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t * 1; - const by = mt * mt * mt * 0 + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t * 1; - dotX = pad + gw * bx; - dotY = h - pad - gh * by; + dotX = pad + gw * (mt * mt * mt * 0 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t); + dotY = + h - pad - gh * (mt * mt * mt * 0 + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t); } - const label = EASE_LABELS[ease] ?? ease; + const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + draggingRef.current = handle; + (e.target as SVGElement).setPointerCapture(e.pointerId); + if (!draft) setDraft([x1, y1, x2, y2]); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!draggingRef.current || !svgRef.current) return; + e.preventDefault(); + const rect = svgRef.current.getBoundingClientRect(); + const sx = ((e.clientX - rect.left) / rect.width) * w; + const sy = ((e.clientY - rect.top) / rect.height) * h; + const px = Math.max(0, Math.min(1, (sx - pad) / gw)); + const py = Math.max(-0.5, Math.min(1.5, (h - pad - sy) / gh)); + const prev = draft ?? [x1, y1, x2, y2]; + const next: [number, number, number, number] = + draggingRef.current === "p1" + ? [round2(px), round2(py), prev[2], prev[3]] + : [prev[0], prev[1], round2(px), round2(py)]; + setDraft(next); + }; + + const handlePointerUp = () => { + if (!draggingRef.current || !draft) return; + draggingRef.current = null; + const path = `M0,0 C${draft[0]},${draft[1]} ${draft[2]},${draft[3]} 1,1`; + onCustomEaseCommit(`custom(${path})`); + setDraft(null); + }; + + const p1 = toSvg(x1, y1); + const p2 = toSvg(x2, y2); + const start = toSvg(0, 0); + const end = toSvg(1, 1); + const label = isCustom ? "Custom curve" : (EASE_LABELS[ease] ?? ease); return (
@@ -184,7 +144,17 @@ function EaseCurveSection({ ease }: { ease: string }) { {progress !== null ? "Playing…" : "Preview"}
- + + + {progress !== null && } + handlePointerDown("p1", e)} + /> + handlePointerDown("p2", e)} + />

{label}

); } -const ADD_METHODS = ["to", "from", "set"] as const; - -const ADD_METHOD_LABELS: Record = { - to: "Animate", - from: "Animate In", - set: "Set Instantly", -}; +function round2(n: number): number { + return Math.round(n * 100) / 100; +} function buildTweenSummary(animation: GsapAnimation): string { const easeName = animation.ease ?? "none"; @@ -315,8 +317,13 @@ const AnimationCard = memo(function AnimationCard({ const methodLabel = METHOD_LABELS[animation.method] ?? animation.method; const easeName = animation.ease ?? "none"; - const easeLabel = EASE_LABELS[easeName] ?? easeName; - const endTime = animation.position + (animation.duration ?? 0); + const easeLabel = easeName.startsWith("custom(") + ? "Custom curve" + : (EASE_LABELS[easeName] ?? easeName); + const endTime = + typeof animation.position === "number" + ? animation.position + (animation.duration ?? 0) + : animation.position; const summary = useMemo(() => buildTweenSummary(animation), [animation]); @@ -334,7 +341,8 @@ const AnimationCard = memo(function AnimationCard({ {methodLabel} - {animation.position}s – {endTime.toFixed(1)}s + {typeof animation.position === "number" ? `${animation.position}s` : animation.position} –{" "} + {typeof endTime === "number" ? `${endTime.toFixed(1)}s` : endTime} {easeLabel} @@ -382,8 +390,12 @@ const AnimationCard = memo(function AnimationCard({ )} @@ -393,11 +405,26 @@ const AnimationCard = memo(function AnimationCard({ <> onUpdateMeta(animation.id, { ease: next })} + value={ + animation.ease?.startsWith("custom(") ? "custom" : (animation.ease ?? "none") + } + options={[...SUPPORTED_EASES, "custom"]} + onChange={(next) => { + if (next === "custom") { + const points = controlPointsForGsapEase(animation.ease ?? "power2.out"); + const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`; + onUpdateMeta(animation.id, { ease: `custom(${path})` }); + } else { + onUpdateMeta(animation.id, { ease: next }); + } + }} + /> + + onUpdateMeta(animation.id, { ease: customEase }) + } /> - )} diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index aff3744d6..09debd3e1 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -1,12 +1,7 @@ import { memo } from "react"; import { Clock, Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons"; import { type DomEditSelection } from "./domEditing"; -import { - readStudioBoxSize, - readStudioPathOffset, - readStudioRotation, - readGsapTranslateFromTransform, -} from "./manualEdits"; +import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits"; import type { ImportedFontAsset } from "./fontAssets"; import { EMPTY_STYLES, @@ -204,11 +199,6 @@ export const PropertyPanel = memo(function PropertyPanel({ const sourceLabel = element.id ? `#${element.id}` : element.selector; const showEditableSections = element.capabilities.canEditStyles; const manualOffset = readStudioPathOffset(element.element); - const gsapTranslate = readGsapTranslateFromTransform(element.element); - const visualOffset = { - x: manualOffset.x + gsapTranslate.x, - y: manualOffset.y + gsapTranslate.y, - }; const manualSize = readStudioBoxSize(element.element); const resolvedWidth = manualSize.width > 0 @@ -222,11 +212,10 @@ export const PropertyPanel = memo(function PropertyPanel({ const commitManualOffset = (axis: "x" | "y", nextValue: string) => { const parsed = parsePxMetricValue(nextValue); if (parsed == null) return; - const currentRaw = readStudioPathOffset(element.element); - const currentGsap = readGsapTranslateFromTransform(element.element); + const current = readStudioPathOffset(element.element); onSetManualOffset(element, { - x: axis === "x" ? parsed - currentGsap.x : currentRaw.x, - y: axis === "y" ? parsed - currentGsap.y : currentRaw.y, + x: axis === "x" ? parsed : current.x, + y: axis === "y" ? parsed : current.y, }); }; @@ -318,14 +307,14 @@ export const PropertyPanel = memo(function PropertyPanel({
commitManualOffset("x", next)} /> commitManualOffset("y", next)} diff --git a/packages/studio/src/components/editor/gsapAnimationConstants.ts b/packages/studio/src/components/editor/gsapAnimationConstants.ts new file mode 100644 index 000000000..62f1c4aa5 --- /dev/null +++ b/packages/studio/src/components/editor/gsapAnimationConstants.ts @@ -0,0 +1,130 @@ +import { controlPointsForGsapEase } from "./studioMotion"; + +export const METHOD_LABELS: Record = { + set: "Set", + to: "Animate", + from: "Animate In", + fromTo: "Animate", +}; + +export const METHOD_TOOLTIPS: Record = { + set: "Instantly snap to these values — no transition", + to: "Smoothly animate the element to these target values", + from: "Element starts at these values and transitions to its normal state", + fromTo: "Animate from one state to another", +}; + +export const PROP_LABELS: Record = { + x: "Move X", + y: "Move Y", + width: "Width", + height: "Height", + rotation: "Rotate", + opacity: "Opacity", + scale: "Scale", + scaleX: "Scale X", + scaleY: "Scale Y", + autoAlpha: "Visibility", + visibility: "Visible", + scaleX_alias: "Stretch X", +}; + +export const PROP_UNITS: Record = { + x: "px", + y: "px", + width: "px", + height: "px", + rotation: "°", + opacity: "", + scale: "×", + scaleX: "×", + scaleY: "×", + autoAlpha: "", + visibility: "", +}; + +export const PROP_TOOLTIPS: Record = { + x: "Move left/right (negative = left, positive = right)", + y: "Move up/down (negative = up, positive = down)", + opacity: "How visible (0 = invisible, 1 = fully visible)", + scale: "Size multiplier (1 = normal, 2 = double, 0.5 = half)", + scaleX: "Horizontal stretch (1 = normal)", + scaleY: "Vertical stretch (1 = normal)", + rotation: "Spin angle (360 = full rotation)", + width: "Element width", + height: "Element height", + autoAlpha: "Like opacity but hides element completely at 0", + visibility: "Show or hide the element", +}; + +export const EASE_LABELS: Record = { + none: "Constant speed", + "power1.out": "Gentle slowdown", + "power2.out": "Smooth slowdown", + "power3.out": "Snappy slowdown", + "power4.out": "Sharp slowdown", + "power1.in": "Gentle speedup", + "power2.in": "Smooth speedup", + "power3.in": "Strong speedup", + "power4.in": "Sharp speedup", + "power1.inOut": "Gentle ease", + "power2.inOut": "Smooth ease", + "power3.inOut": "Strong ease", + "power4.inOut": "Sharp ease", + "back.out": "Overshoot & settle", + "back.in": "Pull back & go", + "back.inOut": "Pull & overshoot", + "elastic.out": "Springy bounce", + "elastic.in": "Wind up spring", + "elastic.inOut": "Full spring", + "bounce.out": "Drop & bounce", + "bounce.in": "Reverse bounce", + "bounce.inOut": "Double bounce", + "expo.out": "Very snappy stop", + "expo.in": "Very slow start", + "expo.inOut": "Dramatic ease", +}; + +export const EASE_CURVES: Record = { + none: [0, 0, 1, 1], + "power1.out": [0, 0, 0.58, 1], + "power2.out": [0.16, 1, 0.3, 1], + "power3.out": [0.08, 0.82, 0.17, 1], + "power4.out": [0.06, 0.73, 0.09, 1], + "power1.in": [0.42, 0, 1, 1], + "power2.in": [0.55, 0.06, 0.68, 0.19], + "power3.in": [0.6, 0.04, 0.98, 0.34], + "power4.in": [0.7, 0, 0.84, 0], + "power1.inOut": [0.42, 0, 0.58, 1], + "power2.inOut": [0.45, 0.05, 0.55, 0.95], + "power3.inOut": [0.65, 0.05, 0.35, 1], + "power4.inOut": [0.76, 0, 0.24, 1], + "back.out": [0.34, 1.56, 0.64, 1], + "back.in": [0.36, 0, 0.66, -0.56], + "back.inOut": [0.68, -0.55, 0.27, 1.55], + "expo.out": [0.16, 1, 0.3, 1], + "expo.in": [0.7, 0, 0.84, 0], + "expo.inOut": [0.87, 0, 0.13, 1], +}; + +export function parseCustomEaseFromString(ease: string): { + x1: number; + y1: number; + x2: number; + y2: number; +} { + const match = ease.match(/^custom\((.+)\)$/); + if (!match) return controlPointsForGsapEase("power2.out"); + const data = match[1]; + const nums = data.match(/[\d.]+/g)?.map(Number); + if (!nums || nums.length < 6) return controlPointsForGsapEase("power2.out"); + return { x1: nums[2], y1: nums[3], x2: nums[4], y2: nums[5] }; +} + +export const ADD_METHODS = ["to", "from", "set"] as const; + +export const ADD_METHOD_LABELS: Record = { + to: "Animate", + from: "Animate In", + set: "Set Instantly", +}; diff --git a/packages/studio/src/components/editor/manualEdits.test.ts b/packages/studio/src/components/editor/manualEdits.test.ts index 69aab5bed..8c2fce997 100644 --- a/packages/studio/src/components/editor/manualEdits.test.ts +++ b/packages/studio/src/components/editor/manualEdits.test.ts @@ -517,6 +517,47 @@ describe("studio manual edits", () => { }); }); +describe("applyStudioPathOffset sets correct attribute name", () => { + it("sets data-hf-studio-path-offset without double data- prefix", () => { + const window = new Window(); + const el = window.document.createElement("div"); + window.document.body.append(el); + + applyStudioPathOffset(el, { x: 100, y: 50 }); + + expect(el.getAttribute("data-hf-studio-path-offset")).toBe("true"); + expect(el.getAttribute("data-data-hf-studio-path-offset")).toBeNull(); + }); + + it("stores offset in CSS vars alongside the attribute marker", () => { + const window = new Window(); + const el = window.document.createElement("div"); + window.document.body.append(el); + + applyStudioPathOffset(el, { x: 50, y: 25 }); + + expect(el.getAttribute("data-hf-studio-path-offset")).toBe("true"); + expect(el.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("50px"); + expect(el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("25px"); + expect(el.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP); + }); + + it("corrects offset applied on top of legacy double-prefix element", () => { + const window = new Window(); + const el = window.document.createElement("div"); + el.setAttribute("data-data-hf-studio-path-offset", "true"); + el.style.setProperty(STUDIO_OFFSET_X_PROP, "200px"); + el.style.setProperty(STUDIO_OFFSET_Y_PROP, "-30px"); + window.document.body.append(el); + + applyStudioPathOffset(el, { x: 200, y: -30 }); + + expect(el.getAttribute("data-hf-studio-path-offset")).toBe("true"); + expect(readStudioPathOffset(el)).toEqual({ x: 200, y: -30 }); + expect(el.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP); + }); +}); + describe("applyStudioPathOffset strips GSAP double-counted translate", () => { it("strips GSAP transform translate when applying offset", () => { const window = new Window(); diff --git a/packages/studio/src/components/editor/manualEdits.ts b/packages/studio/src/components/editor/manualEdits.ts index ed680d47f..05e6a81b0 100644 --- a/packages/studio/src/components/editor/manualEdits.ts +++ b/packages/studio/src/components/editor/manualEdits.ts @@ -3,9 +3,7 @@ export { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP, STUDIO_WIDTH_PROP, - STUDIO_HEIGHT_PROP, STUDIO_ROTATION_PROP, - type StudioManualEditSeekWindow, type StudioBoxSizeSnapshot, type StudioRotationSnapshot, type StudioPathOffsetSnapshot, @@ -20,7 +18,6 @@ export { readStudioPathOffset, readStudioBoxSize, readStudioRotation, - readGsapTranslateFromTransform, applyStudioPathOffset, applyStudioPathOffsetDraft, applyStudioBoxSize, @@ -28,8 +25,6 @@ export { applyStudioRotation, applyStudioRotationDraft, reapplyPositionEditsAfterSeek, - buildMotionPatches, - buildClearMotionPatches, } from "./manualEditsDom"; export { @@ -51,7 +46,6 @@ import { STUDIO_MANUAL_EDITS_PLAYBACK_FRAME_PROP, } from "./manualEditsTypes"; import { finiteNumber } from "./manualEditsParsing"; -import { isStudioManualEditGestureActive } from "./manualEditsDom"; /* ── Seek/play reapply wrappers ───────────────────────────────────── */ function markWrapped(fn: (...args: unknown[]) => unknown): void { @@ -302,6 +296,3 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi wrappedNamedTimelinePause ); } - -// Re-export for internal use (seek hooks need this) -export { isStudioManualEditGestureActive }; diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index f2cc83806..33a9d3e58 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -48,7 +48,7 @@ export function endStudioManualEditGesture(element: HTMLElement, token?: string) element.removeAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR); } -export function isStudioManualEditGestureActive(element: HTMLElement): boolean { +function isStudioManualEditGestureActive(element: HTMLElement): boolean { return element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR); } @@ -219,20 +219,6 @@ function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean { return m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1; } -export function readGsapTranslateFromTransform(element: HTMLElement): { x: number; y: number } { - const transform = element.style.getPropertyValue("transform"); - if (!transform || transform === "none") return { x: 0, y: 0 }; - const DOMMatrixCtor = (element.ownerDocument.defaultView as (Window & typeof globalThis) | null) - ?.DOMMatrix; - if (!DOMMatrixCtor) return { x: 0, y: 0 }; - try { - const m = new DOMMatrixCtor(transform); - return { x: m.m41, y: m.m42 }; - } catch { - return { x: 0, y: 0 }; - } -} - function stripGsapTranslateFromTransform(element: HTMLElement): void { const transform = element.style.getPropertyValue("transform"); if (!transform || transform === "none") return; @@ -493,9 +479,19 @@ export { function queryStudioElements(doc: Document, attr: string): HTMLElement[] { const ctor = doc.defaultView?.HTMLElement; if (!ctor) return []; - return Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter( + const elements = Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter( (el): el is HTMLElement => el instanceof ctor, ); + // Handle legacy HTML files where attributes were persisted with a double data- prefix + const legacyAttr = `data-${attr}`; + for (const el of doc.querySelectorAll(`[${legacyAttr}="true"]`)) { + if (el instanceof ctor && !el.hasAttribute(attr)) { + el.setAttribute(attr, "true"); + el.removeAttribute(legacyAttr); + elements.push(el); + } + } + return elements; } function reapplyPathOffsets(doc: Document): void { diff --git a/packages/studio/src/components/editor/manualOffsetDrag.test.ts b/packages/studio/src/components/editor/manualOffsetDrag.test.ts index 48f9abffd..2d833d04d 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.test.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.test.ts @@ -1,8 +1,10 @@ import { Window } from "happy-dom"; import { describe, expect, it } from "vitest"; import { + applyManualOffsetDragCommit, applyManualOffsetDragMatrix, createManualOffsetDragMember, + endManualOffsetDragMembers, invertManualOffsetDragMatrix, measureManualOffsetDragScreenToOffsetMatrix, resolveManualOffsetForPointerDelta, @@ -140,8 +142,8 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => { }); }); -describe("createManualOffsetDragMember GSAP translate compensation", () => { - it("folds GSAP translate from element.style.transform into initialOffset", () => { +describe("createManualOffsetDragMember uses raw CSS var offset", () => { + it("ignores GSAP transform — initialOffset comes from CSS vars only", () => { const window = new Window(); const element = window.document.createElement("div"); window.document.body.append(element); @@ -164,14 +166,18 @@ describe("createManualOffsetDragMember GSAP translate compensation", () => { expect(result.ok).toBe(true); if (!result.ok) return; expect(result.member.initialOffset.x).toBe(0); - expect(result.member.initialOffset.y).toBe(-20); + expect(result.member.initialOffset.y).toBe(0); }); - it("leaves initialOffset unchanged when no GSAP transform is present", () => { + it("reads only the CSS var offset, not GSAP transform", () => { const window = new Window(); const element = window.document.createElement("div"); window.document.body.append(element); + element.style.setProperty(STUDIO_OFFSET_X_PROP, "30px"); + element.style.setProperty(STUDIO_OFFSET_Y_PROP, "10px"); + element.style.setProperty("transform", "translate(50px, -15px)"); + element.getBoundingClientRect = () => { const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0; const offsetY = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0; @@ -187,35 +193,42 @@ describe("createManualOffsetDragMember GSAP translate compensation", () => { expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.member.initialOffset.x).toBe(0); - expect(result.member.initialOffset.y).toBe(0); + expect(result.member.initialOffset.x).toBe(30); + expect(result.member.initialOffset.y).toBe(10); }); - it("combines existing manual offset with GSAP translate", () => { + it("does not accumulate drift across multiple drag cycles", () => { const window = new Window(); const element = window.document.createElement("div"); window.document.body.append(element); - element.style.setProperty(STUDIO_OFFSET_X_PROP, "30px"); - element.style.setProperty(STUDIO_OFFSET_Y_PROP, "10px"); - element.style.setProperty("transform", "translate(50px, -15px)"); - element.getBoundingClientRect = () => { const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0; const offsetY = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0; return new window.DOMRect(10 + offsetX, 20 + offsetY, 100, 50); }; - const result = createManualOffsetDragMember({ - key: "test", - selection: { element } as never, - element, - rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 }, - }); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.member.initialOffset.x).toBe(80); - expect(result.member.initialOffset.y).toBe(-5); + // Simulate GSAP baking a translate into transform each cycle + for (let cycle = 0; cycle < 3; cycle++) { + element.style.setProperty("transform", `translate(${50 * (cycle + 1)}px, 0px)`); + + const result = createManualOffsetDragMember({ + key: "test", + selection: { element } as never, + element, + rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + // initialOffset should always be the CSS var value, never inflated by GSAP transform + const currentRawX = + Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0; + expect(result.member.initialOffset.x).toBe(currentRawX); + + // Simulate drag commit: apply a small offset + applyManualOffsetDragCommit(result.member, 10, 0); + endManualOffsetDragMembers([result.member]); + } }); }); diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index bc5827ed3..67aae3397 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -5,7 +5,6 @@ import { beginStudioManualEditGesture, captureStudioPathOffset, endStudioManualEditGesture, - readGsapTranslateFromTransform, readStudioPathOffset, restoreStudioPathOffset, type StudioPathOffsetSnapshot, @@ -232,12 +231,7 @@ export function createManualOffsetDragMember(input: { element: HTMLElement; rect: ManualOffsetDragRect; }): ManualOffsetDragMemberResult { - const rawOffset = readStudioPathOffset(input.element); - const gsapTranslate = readGsapTranslateFromTransform(input.element); - const initialOffset = { - x: rawOffset.x + gsapTranslate.x, - y: rawOffset.y + gsapTranslate.y, - }; + const initialOffset = readStudioPathOffset(input.element); const initialPathOffset = captureStudioPathOffset(input.element); const gestureToken = beginStudioManualEditGesture(input.element); const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset); diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 681a43a38..711e2f0e7 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -285,9 +285,9 @@ export function useDomEditSession({ const handleGsapAddAnimation = useCallback( (method: "to" | "from" | "set") => { if (!domEditSelection) return; - addGsapAnimation(domEditSelection, method); + addGsapAnimation(domEditSelection, method, currentTime); }, - [domEditSelection, addGsapAnimation], + [domEditSelection, addGsapAnimation, currentTime], ); const handleGsapAddProperty = useCallback( diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 76a4087d0..f3028ae4b 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import { parseGsapScript, updateAnimationInScript, @@ -42,7 +42,7 @@ function extractAndReplaceScript( const modified = transform(scriptContent); if (modified === scriptContent) return null; - return html.replace(scriptContent, modified); + return html.replace(scriptContent, () => modified); } interface GsapScriptCommitsParams { @@ -62,6 +62,8 @@ interface GsapScriptCommitsParams { onCacheInvalidate: () => void; } +const DEBOUNCE_MS = 150; + // fallow-ignore-next-line complexity unit-size export function useGsapScriptCommits({ projectIdRef, @@ -72,6 +74,14 @@ export function useGsapScriptCommits({ reloadPreview, onCacheInvalidate, }: GsapScriptCommitsParams) { + const pendingPropertyEditRef = useRef<{ + selection: DomEditSelection; + animationId: string; + property: string; + value: number | string; + } | null>(null); + const debounceTimerRef = useRef | null>(null); + const readSourceFile = useCallback( async (sourceFile: string): Promise => { const pid = projectIdRef.current; @@ -129,6 +139,21 @@ export function useGsapScriptCommits({ ], ); + const flushPendingPropertyEdit = useCallback(() => { + const pending = pendingPropertyEditRef.current; + if (!pending) return; + pendingPropertyEditRef.current = null; + const { selection, animationId, property, value } = pending; + void persistScriptEdit( + selection, + (script) => updateAnimProperties(script, animationId, (p) => ({ ...p, [property]: value })), + { + label: `Edit GSAP ${property}`, + coalesceKey: `gsap:${animationId}:${property}`, + }, + ); + }, [persistScriptEdit]); + const updateGsapProperty = useCallback( ( selection: DomEditSelection, @@ -136,17 +161,11 @@ export function useGsapScriptCommits({ property: string, value: number | string, ) => { - void persistScriptEdit( - selection, - (script) => updateAnimProperties(script, animationId, (p) => ({ ...p, [property]: value })), - { - label: `Edit GSAP ${property}`, - coalesceKey: `gsap:${animationId}:${property}`, - softReload: true, - }, - ); + pendingPropertyEditRef.current = { selection, animationId, property, value }; + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = setTimeout(flushPendingPropertyEdit, DEBOUNCE_MS); }, - [persistScriptEdit], + [flushPendingPropertyEdit], ); const updateGsapMeta = useCallback( @@ -179,11 +198,23 @@ export function useGsapScriptCommits({ ); const addGsapAnimation = useCallback( - (selection: DomEditSelection, method: "to" | "from" | "set") => { - const selector = selection.id ? `#${selection.id}` : selection.selector; - if (!selector) return; + (selection: DomEditSelection, method: "to" | "from" | "set", currentTime?: number) => { + let selector = selection.id ? `#${selection.id}` : selection.selector; + if (!selector) { + const el = selection.element; + const doc = el.ownerDocument; + const tag = el.tagName.toLowerCase(); + let id = tag; + let n = 1; + while (doc.getElementById(id)) { + n += 1; + id = `${tag}-${n}`; + } + el.setAttribute("id", id); + selector = `#${id}`; + } - const start = Number.parseFloat(selection.dataAttributes.start ?? "0") || 0; + const start = currentTime ?? (Number.parseFloat(selection.dataAttributes.start ?? "0") || 0); const defaults: Record> = { from: { opacity: 0 }, to: { opacity: 1 }, From 31dcdb48bf570d40b8e9e9f1bf1b4fc25b21c80d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 28 May 2026 01:29:12 -0400 Subject: [PATCH 03/19] fix(studio): preserve GSAP animation values when stripping baked translate stripGsapTranslateFromTransform was zeroing all of m41/m42 in the transform matrix, which destroyed legitimate GSAP animation values (e.g. a to() tween animating y: -20). The fix subtracts only the known studio CSS var offset from the matrix, preserving the animation contribution. This means tweens that animate x/y now render correctly alongside manual position offsets. --- .../src/components/editor/manualEdits.test.ts | 10 +++++----- .../src/components/editor/manualEditsDom.ts | 19 ++++++++++++++----- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/studio/src/components/editor/manualEdits.test.ts b/packages/studio/src/components/editor/manualEdits.test.ts index 8c2fce997..12a37192d 100644 --- a/packages/studio/src/components/editor/manualEdits.test.ts +++ b/packages/studio/src/components/editor/manualEdits.test.ts @@ -580,12 +580,13 @@ describe("applyStudioPathOffset strips GSAP double-counted translate", () => { expect(readStudioPathOffset(element).x).toBe(200); }); - it("strips GSAP y translate while preserving scale in transform", () => { + it("subtracts only the studio offset from GSAP transform, preserving animation values", () => { const window = new Window(); const element = window.document.createElement("div"); window.document.body.append(element); - // GSAP has scale + baked translate + // GSAP has scale + baked translate (offset 50) + animation contribution (-70) + // Total m42 = 50 + (-70) = -20 element.style.setProperty("transform", "matrix(0.5, 0, 0, 0.5, 0, -20)"); applyStudioPathOffset(element, { x: 0, y: 50 }); @@ -593,12 +594,11 @@ describe("applyStudioPathOffset strips GSAP double-counted translate", () => { const transform = element.style.getPropertyValue("transform"); if (transform && transform !== "none") { const m = new window.DOMMatrix(transform); - // Scale preserved expect(m.a).toBeCloseTo(0.5); expect(m.d).toBeCloseTo(0.5); - // Translate stripped + // Only the studio offset (50) is subtracted, animation contribution (-70) preserved expect(m.m41).toBe(0); - expect(m.m42).toBe(0); + expect(m.m42).toBe(-70); } expect(readStudioPathOffset(element).y).toBe(50); }); diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 33a9d3e58..ccdc44879 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -213,8 +213,11 @@ function writeStudioPathOffsetVars( // GSAP 3.x reads the resolved CSS `translate` individual property at initialization and bakes it // into element.style.transform (as a matrix) on every seek. When the studio's reapply hook also -// writes `translate`, both properties compose additively, doubling the visual offset. This helper -// zeroes out only the translate component (m41/m42) so the `translate` prop isn't double-counted. +// writes `translate`, both properties compose additively, doubling the visual offset. +// +// This helper subtracts only the baked studio offset from m41/m42, preserving any GSAP animation +// contribution (e.g. a tween animating y: -20). The studio offset is read from the CSS custom +// properties which tell us exactly how much was baked from the CSS translate. function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean { return m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1; } @@ -228,9 +231,15 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void { try { const m = new DOMMatrixCtor(transform); if (m.m41 === 0 && m.m42 === 0) return; - m.m41 = 0; - m.m42 = 0; - if (isIdentityAfterTranslateStrip(m)) { + const offsetX = readPxCustomProperty(element, STUDIO_OFFSET_X_PROP); + const offsetY = readPxCustomProperty(element, STUDIO_OFFSET_Y_PROP); + m.m41 -= offsetX; + m.m42 -= offsetY; + if ( + Math.abs(m.m41) < 0.01 && + Math.abs(m.m42) < 0.01 && + isIdentityAfterTranslateStrip(m) + ) { element.style.removeProperty("transform"); } else { element.style.setProperty("transform", m.toString()); From 502fab7a7d108d8761e7cdb3f48c6db01eb86c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 28 May 2026 01:35:29 -0400 Subject: [PATCH 04/19] fix(studio): cache parsed animations per file, instant element switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useGsapTweenCache now fetches and parses the script once per file+version, then filters per element via useMemo. Switching between elements in the same composition is now instant — no re-fetch. Previously every element selection triggered a separate API fetch + parse cycle, causing a visible loading delay before animation cards appeared. --- .../studio/src/hooks/useGsapTweenCache.ts | 65 ++++++++++--------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index c9412afc3..4930d4035 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { parseGsapScript, getAnimationsForElement, @@ -6,52 +6,59 @@ import { } from "@hyperframes/core/gsap-parser"; import { extractGsapScriptContent } from "../utils/gsapScriptHelpers"; +function readScriptFromIframe( + projectId: string, + sourceFile: string, +): Promise { + return fetch( + `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(sourceFile)}`, + ) + .then((r) => (r.ok ? (r.json() as Promise<{ content?: string }>) : null)) + .then((data) => { + if (!data?.content) return null; + return extractGsapScriptContent(data.content); + }) + .catch(() => null); +} + export function useGsapAnimationsForElement( projectId: string | null, sourceFile: string, elementId: string | null, version: number, ): GsapAnimation[] { - const [animations, setAnimations] = useState([]); - const lastKeyRef = useRef(""); + const [allAnimations, setAllAnimations] = useState([]); + const lastFetchKeyRef = useRef(""); useEffect(() => { - const key = `${projectId}:${sourceFile}:${elementId}:${version}`; - if (key === lastKeyRef.current) return; - lastKeyRef.current = key; + const fetchKey = `${projectId}:${sourceFile}:${version}`; + if (fetchKey === lastFetchKeyRef.current) return; + lastFetchKeyRef.current = fetchKey; - if (!projectId || !elementId) { - setAnimations([]); + if (!projectId) { + setAllAnimations([]); return; } let cancelled = false; - - fetch(`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(sourceFile)}`) - .then((r) => (r.ok ? (r.json() as Promise<{ content?: string }>) : null)) - .then((data) => { - if (cancelled || !data?.content) { - if (!cancelled) setAnimations([]); - return; - } - const script = extractGsapScriptContent(data.content); - if (!script) { - setAnimations([]); - return; - } - const parsed = parseGsapScript(script); - setAnimations(getAnimationsForElement(parsed.animations, elementId)); - }) - .catch(() => { - if (!cancelled) setAnimations([]); - }); + readScriptFromIframe(projectId, sourceFile).then((script) => { + if (cancelled) return; + if (!script) { + setAllAnimations([]); + return; + } + setAllAnimations(parseGsapScript(script).animations); + }); return () => { cancelled = true; }; - }, [projectId, sourceFile, elementId, version]); + }, [projectId, sourceFile, version]); - return animations; + return useMemo( + () => (elementId ? getAnimationsForElement(allAnimations, elementId) : []), + [allAnimations, elementId], + ); } export function useGsapCacheVersion() { From 3d161aef1deb41ddc9151efa176ece7957287b11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 28 May 2026 01:43:57 -0400 Subject: [PATCH 05/19] fix: format manualEditsDom and useGsapTweenCache --- packages/studio/src/components/editor/manualEditsDom.ts | 6 +----- packages/studio/src/hooks/useGsapTweenCache.ts | 5 +---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index ccdc44879..b08a8c567 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -235,11 +235,7 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void { const offsetY = readPxCustomProperty(element, STUDIO_OFFSET_Y_PROP); m.m41 -= offsetX; m.m42 -= offsetY; - if ( - Math.abs(m.m41) < 0.01 && - Math.abs(m.m42) < 0.01 && - isIdentityAfterTranslateStrip(m) - ) { + if (Math.abs(m.m41) < 0.01 && Math.abs(m.m42) < 0.01 && isIdentityAfterTranslateStrip(m)) { element.style.removeProperty("transform"); } else { element.style.setProperty("transform", m.toString()); diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 4930d4035..4a8e4c780 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -6,10 +6,7 @@ import { } from "@hyperframes/core/gsap-parser"; import { extractGsapScriptContent } from "../utils/gsapScriptHelpers"; -function readScriptFromIframe( - projectId: string, - sourceFile: string, -): Promise { +function readScriptFromIframe(projectId: string, sourceFile: string): Promise { return fetch( `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(sourceFile)}`, ) From bccba37c92b1b1fe855c7f85d9ef20f5b6d76e0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 28 May 2026 01:45:27 -0400 Subject: [PATCH 06/19] fix: restore bun.lock from main --- bun.lock | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/bun.lock b/bun.lock index 00c10ccde..2d52b7a44 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.6.51", + "version": "0.6.29", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -54,7 +54,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.6.51", + "version": "0.6.29", "bin": { "hyperframes": "./dist/cli.js", }, @@ -99,12 +99,10 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.6.51", + "version": "0.6.29", "dependencies": { - "@babel/parser": "^7.27.0", "@chenglou/pretext": "^0.0.5", "postcss": "^8.5.8", - "recast": "^0.23.11", }, "devDependencies": { "@types/jsdom": "^28.0.0", @@ -128,7 +126,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.6.51", + "version": "0.6.29", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -146,7 +144,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.6.51", + "version": "0.6.29", "devDependencies": { "@types/bun": "^1.1.0", "gsap": "^3.12.5", @@ -158,7 +156,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.6.51", + "version": "0.6.29", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -198,7 +196,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.6.51", + "version": "0.6.29", "dependencies": { "html2canvas": "^1.4.1", }, @@ -210,7 +208,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.6.51", + "version": "0.6.29", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -1043,7 +1041,7 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.12", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g=="], @@ -1677,8 +1675,6 @@ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1799,8 +1795,6 @@ "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], - "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], - "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], @@ -1957,8 +1951,6 @@ "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], - "degenerator/ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], - "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -2005,8 +1997,6 @@ "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], - "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], From a49dffea2a985e9bedc046173b5b8273f49c597b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 28 May 2026 01:49:50 -0400 Subject: [PATCH 07/19] fix: regenerate bun.lock with branch dependencies --- bun.lock | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/bun.lock b/bun.lock index 2d52b7a44..00c10ccde 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.6.29", + "version": "0.6.51", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -54,7 +54,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.6.29", + "version": "0.6.51", "bin": { "hyperframes": "./dist/cli.js", }, @@ -99,10 +99,12 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.6.29", + "version": "0.6.51", "dependencies": { + "@babel/parser": "^7.27.0", "@chenglou/pretext": "^0.0.5", "postcss": "^8.5.8", + "recast": "^0.23.11", }, "devDependencies": { "@types/jsdom": "^28.0.0", @@ -126,7 +128,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.6.29", + "version": "0.6.51", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -144,7 +146,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.6.29", + "version": "0.6.51", "devDependencies": { "@types/bun": "^1.1.0", "gsap": "^3.12.5", @@ -156,7 +158,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.6.29", + "version": "0.6.51", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -196,7 +198,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.6.29", + "version": "0.6.51", "dependencies": { "html2canvas": "^1.4.1", }, @@ -208,7 +210,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.6.29", + "version": "0.6.51", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -1041,7 +1043,7 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.12", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g=="], @@ -1675,6 +1677,8 @@ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1795,6 +1799,8 @@ "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], @@ -1951,6 +1957,8 @@ "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + "degenerator/ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -1997,6 +2005,8 @@ "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], From 21af9dc97a72d45362944119a0811c0ee2b04bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 28 May 2026 01:56:13 -0400 Subject: [PATCH 08/19] fix(studio): multi-timeline UI gate, persist auto-generated ids, fix CI --- packages/core/src/parsers/gsapParser.ts | 48 ++++++++++++++----- .../src/components/StudioRightPanel.tsx | 2 + .../editor/GsapAnimationSection.tsx | 8 ++++ .../src/components/editor/PropertyPanel.tsx | 3 ++ .../studio/src/contexts/DomEditContext.tsx | 3 ++ .../studio/src/hooks/useDomEditSession.ts | 14 +++--- .../studio/src/hooks/useGsapScriptCommits.ts | 17 ++++++- .../studio/src/hooks/useGsapTweenCache.ts | 13 +++-- 8 files changed, 86 insertions(+), 22 deletions(-) diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index 44c25f4c5..a86d6bd46 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -20,6 +20,7 @@ export interface ParsedGsap { timelineVar: string; preamble: string; postamble: string; + multipleTimelines?: boolean; } const GSAP_METHODS = new Set(["set", "to", "from", "fromTo"]); @@ -159,23 +160,43 @@ function objectExpressionToRecord(node: any, scope: ScopeBindings): Record tweenCallToAnimation(call, i, scope)); @@ -322,7 +344,9 @@ export function parseGsapScript(script: string): ParsedGsap { } } - return { animations, timelineVar, preamble, postamble }; + const result: ParsedGsap = { animations, timelineVar, preamble, postamble }; + if (detection.timelineCount > 1) result.multipleTimelines = true; + return result; } catch { return { animations: [], timelineVar: "tl", preamble: "", postamble: "" }; } diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 748c30374..b15e0a32a 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -79,6 +79,7 @@ export function StudioRightPanel({ handleDomMotionCommit, handleDomMotionClear, selectedGsapAnimations, + gsapMultipleTimelines, handleGsapUpdateProperty, handleGsapUpdateMeta, handleGsapDeleteAnimation, @@ -206,6 +207,7 @@ export function StudioRightPanel({ fontAssets={fontAssets} onImportFonts={handleImportFonts} gsapAnimations={selectedGsapAnimations} + gsapMultipleTimelines={gsapMultipleTimelines} onUpdateGsapProperty={handleGsapUpdateProperty} onUpdateGsapMeta={handleGsapUpdateMeta} onDeleteGsapAnimation={handleGsapDeleteAnimation} diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index 53d2711b2..fa040757b 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -20,6 +20,7 @@ import { interface GsapAnimationSectionProps { animations: GsapAnimation[]; + multipleTimelines?: boolean; onUpdateProperty: (animationId: string, property: string, value: number | string) => void; onUpdateMeta: ( animationId: string, @@ -519,6 +520,7 @@ const AnimationCard = memo(function AnimationCard({ export const GsapAnimationSection = memo(function GsapAnimationSection({ animations, + multipleTimelines, onUpdateProperty, onUpdateMeta, onDeleteAnimation, @@ -532,6 +534,12 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ return (
}> + {multipleTimelines && ( +

+ This file has multiple GSAP timelines. Only the first timeline is editable — edits won't + affect tweens on other timelines. +

+ )}
{animations.map((anim, index) => ( Promise; gsapAnimations?: import("@hyperframes/core/gsap-parser").GsapAnimation[]; + gsapMultipleTimelines?: boolean; onUpdateGsapProperty?: (animId: string, prop: string, value: number | string) => void; onUpdateGsapMeta?: ( animId: string, @@ -153,6 +154,7 @@ export const PropertyPanel = memo(function PropertyPanel({ fontAssets = [], onImportFonts, gsapAnimations = [], + gsapMultipleTimelines, onUpdateGsapProperty, onUpdateGsapMeta, onDeleteGsapAnimation, @@ -357,6 +359,7 @@ export const PropertyPanel = memo(function PropertyPanel({ onAddGsapAnimation && ( ([]); + const [multipleTimelines, setMultipleTimelines] = useState(false); const lastFetchKeyRef = useRef(""); useEffect(() => { @@ -34,6 +35,7 @@ export function useGsapAnimationsForElement( if (!projectId) { setAllAnimations([]); + setMultipleTimelines(false); return; } @@ -42,9 +44,12 @@ export function useGsapAnimationsForElement( if (cancelled) return; if (!script) { setAllAnimations([]); + setMultipleTimelines(false); return; } - setAllAnimations(parseGsapScript(script).animations); + const parsed = parseGsapScript(script); + setAllAnimations(parsed.animations); + setMultipleTimelines(parsed.multipleTimelines === true); }); return () => { @@ -52,10 +57,12 @@ export function useGsapAnimationsForElement( }; }, [projectId, sourceFile, version]); - return useMemo( + const animations = useMemo( () => (elementId ? getAnimationsForElement(allAnimations, elementId) : []), [allAnimations, elementId], ); + + return { animations, multipleTimelines }; } export function useGsapCacheVersion() { From 5aa1050d515f4441ad4565769e1d49ca2e8942a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 28 May 2026 11:51:23 -0400 Subject: [PATCH 09/19] =?UTF-8?q?fix(studio):=20address=20review=20blocker?= =?UTF-8?q?s=20=E2=80=94=20atomic=20id=20write,=20hard=20multi-timeline=20?= =?UTF-8?q?gate,=20debounce=20flush?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/GsapAnimationSection.tsx | 94 ++++++++++--------- .../studio/src/hooks/useGsapScriptCommits.ts | 42 +++++---- 2 files changed, 70 insertions(+), 66 deletions(-) diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index fa040757b..386ef7670 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -536,63 +536,65 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
}> {multipleTimelines && (

- This file has multiple GSAP timelines. Only the first timeline is editable — edits won't - affect tweens on other timelines. + This file has multiple GSAP timelines. Animation editing is disabled to prevent data loss + — consolidate into a single timeline to enable editing.

)} -
- {animations.map((anim, index) => ( - - ))} - -
- {addMenuOpen ? ( -
- {ADD_METHODS.map((method) => ( + {multipleTimelines ? null : ( +
+ {animations.map((anim, index) => ( + + ))} + +
+ {addMenuOpen ? ( +
+ {ADD_METHODS.map((method) => ( + + ))} - ))} +
+ ) : ( -
- ) : ( - - )} + )} +
-
+ )}
); }); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index e428f67a8..953df1b70 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { parseGsapScript, updateAnimationInScript, @@ -104,12 +104,18 @@ export function useGsapScriptCommits({ async ( selection: DomEditSelection, transform: (scriptContent: string) => string, - options: { label: string; coalesceKey?: string; softReload?: boolean }, + options: { + label: string; + coalesceKey?: string; + softReload?: boolean; + htmlPreTransform?: (html: string) => string; + }, ) => { const targetPath = selection.sourceFile || activeCompPath || "index.html"; - const originalHtml = await readSourceFile(targetPath); + let originalHtml = await readSourceFile(targetPath); if (!originalHtml) return; + if (options.htmlPreTransform) originalHtml = options.htmlPreTransform(originalHtml); const newHtml = extractAndReplaceScript(originalHtml, transform); if (!newHtml || newHtml === originalHtml) return; @@ -168,6 +174,13 @@ export function useGsapScriptCommits({ [flushPendingPropertyEdit], ); + useEffect(() => { + return () => { + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + flushPendingPropertyEdit(); + }; + }, [flushPendingPropertyEdit]); + const updateGsapMeta = useCallback( ( selection: DomEditSelection, @@ -200,6 +213,7 @@ export function useGsapScriptCommits({ const addGsapAnimation = useCallback( (selection: DomEditSelection, method: "to" | "from" | "set", currentTime?: number) => { let selector = selection.id ? `#${selection.id}` : selection.selector; + let htmlPreTransform: ((html: string) => string) | undefined; if (!selector) { const el = selection.element; const doc = el.ownerDocument; @@ -212,21 +226,9 @@ export function useGsapScriptCommits({ } el.setAttribute("id", id); selector = `#${id}`; - const targetPath = selection.sourceFile || activeCompPath || "index.html"; - const pid = projectIdRef.current; - if (pid) { - void fetch( - `/api/projects/${encodeURIComponent(pid)}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - target: { selector: selection.selector || el.tagName.toLowerCase() }, - operations: [{ type: "html-attribute", property: "id", value: id }], - }), - }, - ); - } + const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp(`(<${escapedTag}\\b)([^>]*?)(\\sdata-start=)`); + htmlPreTransform = (html) => html.replace(pattern, `$1 id="${id}"$2$3`); } const start = currentTime ?? (Number.parseFloat(selection.dataAttributes.start ?? "0") || 0); @@ -249,10 +251,10 @@ export function useGsapScriptCommits({ }); return result.script; }, - { label: `Add GSAP ${method} animation` }, + { label: `Add GSAP ${method} animation`, htmlPreTransform }, ); }, - [persistScriptEdit, activeCompPath, projectIdRef], + [persistScriptEdit], ); const addGsapProperty = useCallback( From 52f9a4cb2584d93e89b2463ffdb7c73b04937a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 28 May 2026 12:01:06 -0400 Subject: [PATCH 10/19] fix(studio): auto-id regex matches any element, not just those with data-start --- packages/studio/src/hooks/useGsapScriptCommits.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 953df1b70..8029eff2b 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -227,8 +227,8 @@ export function useGsapScriptCommits({ el.setAttribute("id", id); selector = `#${id}`; const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const pattern = new RegExp(`(<${escapedTag}\\b)([^>]*?)(\\sdata-start=)`); - htmlPreTransform = (html) => html.replace(pattern, `$1 id="${id}"$2$3`); + const pattern = new RegExp(`(<${escapedTag}\\b)([^>]*?>)`); + htmlPreTransform = (html) => html.replace(pattern, `$1 id="${id}"$2`); } const start = currentTime ?? (Number.parseFloat(selection.dataAttributes.start ?? "0") || 0); From 6be4568146781ab9f7e4a5785b47a3f8662387f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 28 May 2026 12:10:33 -0400 Subject: [PATCH 11/19] fix(studio): allow ease curve handles to overflow SVG bounds for overshoot eases --- packages/studio/src/components/editor/GsapAnimationSection.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index 386ef7670..7092246c6 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -145,12 +145,14 @@ function EaseCurveSection({ {progress !== null ? "Playing…" : "Preview"}
+
handlePointerDown("p2", e)} /> +

{label}

); From 9b4160ce7fe2eefb523656e9398d649fa0c2edc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 28 May 2026 12:13:39 -0400 Subject: [PATCH 12/19] fix(studio): remove y-axis clamping on ease curve drag handles --- packages/studio/src/components/editor/GsapAnimationSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index 7092246c6..f0e337739 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -110,7 +110,7 @@ function EaseCurveSection({ const sx = ((e.clientX - rect.left) / rect.width) * w; const sy = ((e.clientY - rect.top) / rect.height) * h; const px = Math.max(0, Math.min(1, (sx - pad) / gw)); - const py = Math.max(-0.5, Math.min(1.5, (h - pad - sy) / gh)); + const py = (h - pad - sy) / gh; const prev = draft ?? [x1, y1, x2, y2]; const next: [number, number, number, number] = draggingRef.current === "p1" From 51369c7e28934acbb8b38786b7bdd98594e46ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 28 May 2026 12:21:12 -0400 Subject: [PATCH 13/19] fix(studio): clamp ease handles to [-1, 2] y-range with enough overflow space --- .../studio/src/components/editor/GsapAnimationSection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index f0e337739..edfa4d5f2 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -110,7 +110,7 @@ function EaseCurveSection({ const sx = ((e.clientX - rect.left) / rect.width) * w; const sy = ((e.clientY - rect.top) / rect.height) * h; const px = Math.max(0, Math.min(1, (sx - pad) / gw)); - const py = (h - pad - sy) / gh; + const py = Math.max(-1, Math.min(2, (h - pad - sy) / gh)); const prev = draft ?? [x1, y1, x2, y2]; const next: [number, number, number, number] = draggingRef.current === "p1" @@ -145,7 +145,7 @@ function EaseCurveSection({ {progress !== null ? "Playing…" : "Preview"} -
+
Date: Thu, 28 May 2026 12:24:36 -0400 Subject: [PATCH 14/19] fix(studio): move hooks above early return in EaseCurveSection --- .../editor/GsapAnimationSection.tsx | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index edfa4d5f2..7d5418e37 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -55,6 +55,19 @@ function EaseCurveSection({ const svgRef = useRef(null); const rafRef = useRef(0); + const play = useCallback(() => { + const start = performance.now(); + const dur = 1000; + const tick = (now: number) => { + const t = Math.min((now - start) / dur, 1); + setProgress(t); + if (t < 1) rafRef.current = requestAnimationFrame(tick); + else setTimeout(() => setProgress(null), 400); + }; + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(tick); + }, []); + const active = draft ?? curve; if (!active) return null; const [x1, y1, x2, y2] = active; @@ -72,19 +85,6 @@ function EaseCurveSection({ const curvePath = `M${pad},${h - pad} C${toSvg(x1, y1).x},${toSvg(x1, y1).y} ${toSvg(x2, y2).x},${toSvg(x2, y2).y} ${w - pad},${pad}`; - const play = useCallback(() => { - const start = performance.now(); - const dur = 1000; - const tick = (now: number) => { - const t = Math.min((now - start) / dur, 1); - setProgress(t); - if (t < 1) rafRef.current = requestAnimationFrame(tick); - else setTimeout(() => setProgress(null), 400); - }; - cancelAnimationFrame(rafRef.current); - rafRef.current = requestAnimationFrame(tick); - }, []); - let dotX = pad; let dotY = h - pad; if (progress !== null) { From 7223af493746157a17879388eb2f590ca32155f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 28 May 2026 12:29:59 -0400 Subject: [PATCH 15/19] =?UTF-8?q?fix(studio):=20show=20range=20hints=20for?= =?UTF-8?q?=20opacity=20(0=E2=80=931)=20and=20autoAlpha=20(0=E2=80=931)=20?= =?UTF-8?q?fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studio/src/components/editor/gsapAnimationConstants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/components/editor/gsapAnimationConstants.ts b/packages/studio/src/components/editor/gsapAnimationConstants.ts index 62f1c4aa5..948ddfac8 100644 --- a/packages/studio/src/components/editor/gsapAnimationConstants.ts +++ b/packages/studio/src/components/editor/gsapAnimationConstants.ts @@ -35,11 +35,11 @@ export const PROP_UNITS: Record = { width: "px", height: "px", rotation: "°", - opacity: "", + opacity: "0–1", scale: "×", scaleX: "×", scaleY: "×", - autoAlpha: "", + autoAlpha: "0–1", visibility: "", }; From 85b9fe2af063514a37a0e71d3d955d36e06ad1e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 28 May 2026 12:39:22 -0400 Subject: [PATCH 16/19] =?UTF-8?q?fix(studio):=20display=20opacity/autoAlph?= =?UTF-8?q?a=20as=200=E2=80=93100%=20with=20automatic=20conversion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/editor/GsapAnimationSection.tsx | 12 +++++++++--- .../src/components/editor/gsapAnimationConstants.ts | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index 7d5418e37..bc522bbda 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -34,6 +34,11 @@ interface GsapAnimationSectionProps { onLivePreviewEnd?: () => void; } +const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]); +function isPercentProp(prop: string): boolean { + return PERCENT_PROPS.has(prop); +} + function EaseCurveSection({ ease, onCustomEaseCommit, @@ -439,14 +444,15 @@ const AnimationCard = memo(function AnimationCard({
{ - scrubProperty(prop, raw); - commitProperty(prop, raw); + const adjusted = isPercentProp(prop) ? String(Number(raw) / 100) : raw; + scrubProperty(prop, adjusted); + commitProperty(prop, adjusted); }} />
diff --git a/packages/studio/src/components/editor/gsapAnimationConstants.ts b/packages/studio/src/components/editor/gsapAnimationConstants.ts index 948ddfac8..8a5e3af9e 100644 --- a/packages/studio/src/components/editor/gsapAnimationConstants.ts +++ b/packages/studio/src/components/editor/gsapAnimationConstants.ts @@ -35,11 +35,11 @@ export const PROP_UNITS: Record = { width: "px", height: "px", rotation: "°", - opacity: "0–1", + opacity: "%", scale: "×", scaleX: "×", scaleY: "×", - autoAlpha: "0–1", + autoAlpha: "%", visibility: "", }; From 73b7956dc2f6bfd373db82e348b027aa84e88d27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 28 May 2026 12:57:07 -0400 Subject: [PATCH 17/19] fix: format GsapAnimationSection --- .../editor/GsapAnimationSection.tsx | 142 +++++++++--------- 1 file changed, 72 insertions(+), 70 deletions(-) diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index bc522bbda..545b1034b 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -151,75 +151,75 @@ function EaseCurveSection({
- - - - - - - {progress !== null && } - handlePointerDown("p1", e)} - /> - handlePointerDown("p2", e)} - /> - + + + + + + + {progress !== null && } + handlePointerDown("p1", e)} + /> + handlePointerDown("p2", e)} + /> +

{label}

@@ -444,7 +444,9 @@ const AnimationCard = memo(function AnimationCard({
Date: Thu, 28 May 2026 17:18:10 +0000 Subject: [PATCH 18/19] =?UTF-8?q?fix(studio):=20four=20GSAP=20panel=20bugs?= =?UTF-8?q?=20=E2=80=94=20id=20targeting,=20unsupported=20timeline,=20file?= =?UTF-8?q?=20size,=20CRAP=20score?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1: Auto-id now stamps the correct Nth same-tag element in source HTML instead of always patching the first occurrence. Uses a counter closure against a global regex to match by index. Bug 2: Add unsupportedTimelinePattern detection for window.__timelines[...] assignments (MemberExpression lhs). Detected in gsapParser, threaded through useGsapTweenCache → useDomEditSession → DomEditContext → StudioRightPanel → PropertyPanel → GsapAnimationSection. Shows an amber banner and suppresses the Add button when the pattern is detected. Bug 3: Extract EaseCurveSection and AnimationCard from GsapAnimationSection into separate files to satisfy the Fallow 600-line gate. GsapAnimationSection is now 112 lines (was 612). Bug 4: Extract ensureElementAddressable helper in useGsapScriptCommits to reduce addGsapAnimation complexity and address CRAP score feedback. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/parsers/gsapParser.ts | 3 + .../src/components/StudioRightPanel.tsx | 2 + .../src/components/editor/AnimationCard.tsx | 324 +++++++++++ .../components/editor/EaseCurveSection.tsx | 193 +++++++ .../editor/GsapAnimationSection.tsx | 527 +----------------- .../src/components/editor/PropertyPanel.tsx | 3 + .../studio/src/contexts/DomEditContext.tsx | 3 + .../studio/src/hooks/useDomEditSession.ts | 18 +- .../studio/src/hooks/useGsapScriptCommits.ts | 60 +- .../studio/src/hooks/useGsapTweenCache.ts | 12 +- 10 files changed, 605 insertions(+), 540 deletions(-) create mode 100644 packages/studio/src/components/editor/AnimationCard.tsx create mode 100644 packages/studio/src/components/editor/EaseCurveSection.tsx diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index a86d6bd46..197f5934f 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -21,6 +21,7 @@ export interface ParsedGsap { preamble: string; postamble: string; multipleTimelines?: boolean; + unsupportedTimelinePattern?: boolean; } const GSAP_METHODS = new Set(["set", "to", "from", "fromTo"]); @@ -346,6 +347,8 @@ export function parseGsapScript(script: string): ParsedGsap { const result: ParsedGsap = { animations, timelineVar, preamble, postamble }; if (detection.timelineCount > 1) result.multipleTimelines = true; + if (detection.timelineCount > 0 && detection.timelineVar === null) + result.unsupportedTimelinePattern = true; return result; } catch { return { animations: [], timelineVar: "tl", preamble: "", postamble: "" }; diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index b15e0a32a..49639ccd8 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -80,6 +80,7 @@ export function StudioRightPanel({ handleDomMotionClear, selectedGsapAnimations, gsapMultipleTimelines, + gsapUnsupportedTimelinePattern, handleGsapUpdateProperty, handleGsapUpdateMeta, handleGsapDeleteAnimation, @@ -208,6 +209,7 @@ export function StudioRightPanel({ onImportFonts={handleImportFonts} gsapAnimations={selectedGsapAnimations} gsapMultipleTimelines={gsapMultipleTimelines} + gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern} onUpdateGsapProperty={handleGsapUpdateProperty} onUpdateGsapMeta={handleGsapUpdateMeta} onDeleteGsapAnimation={handleGsapDeleteAnimation} diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx new file mode 100644 index 000000000..edade2cca --- /dev/null +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -0,0 +1,324 @@ +import { memo, useCallback, useMemo, useState } from "react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-parser"; +import { RESPONSIVE_GRID } from "./propertyPanelHelpers"; +import { MetricField, SelectField } from "./propertyPanelPrimitives"; +import { controlPointsForGsapEase } from "./studioMotion"; +import { + EASE_LABELS, + METHOD_LABELS, + METHOD_TOOLTIPS, + PROP_LABELS, + PROP_TOOLTIPS, + PROP_UNITS, +} from "./gsapAnimationConstants"; +import { EaseCurveSection } from "./EaseCurveSection"; + +const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]); +function isPercentProp(prop: string): boolean { + return PERCENT_PROPS.has(prop); +} + +function buildTweenSummary(animation: GsapAnimation): string { + const easeName = animation.ease ?? "none"; + const ease = EASE_LABELS[easeName] ?? easeName; + const props = Object.entries(animation.properties); + const target = animation.targetSelector; + const dur = animation.duration ?? 0; + const pos = animation.position; + const propDescs = props.map(([p, v]) => { + const label = (PROP_LABELS[p] ?? p).toLowerCase(); + const unit = PROP_UNITS[p] ?? ""; + return `${label} to ${v}${unit}`; + }); + const propText = propDescs.length > 0 ? propDescs.join(", ") : "no properties yet"; + if (animation.method === "set") return `At ${pos}s, instantly set ${target}'s ${propText}.`; + if (animation.method === "from") + return `Starting at ${pos}s, over ${dur}s, ${target} enters from ${propText} using a ${ease.toLowerCase()} curve.`; + return `Starting at ${pos}s, over ${dur}s, animate ${target}'s ${propText} using a ${ease.toLowerCase()} curve.`; +} + +function parseNumericOrString(raw: string): number | string { + const num = Number(raw); + return Number.isFinite(num) ? num : raw; +} + +interface AnimationCardProps { + animation: GsapAnimation; + defaultExpanded: boolean; + onUpdateProperty: (animationId: string, property: string, value: number | string) => void; + onUpdateMeta: ( + animationId: string, + updates: { duration?: number; ease?: string; position?: number }, + ) => void; + onDeleteAnimation: (animationId: string) => void; + onAddProperty: (animationId: string, property: string) => void; + onRemoveProperty: (animationId: string, property: string) => void; + onLivePreview?: (property: string, value: number | string) => void; + onLivePreviewEnd?: () => void; +} + +// fallow-ignore-next-line complexity +export const AnimationCard = memo(function AnimationCard({ + animation, + defaultExpanded, + onUpdateProperty, + onUpdateMeta, + onDeleteAnimation, + onAddProperty, + onRemoveProperty, + onLivePreview, + onLivePreviewEnd, +}: AnimationCardProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + const [addingProp, setAddingProp] = useState(false); + + const usedProps = useMemo( + () => new Set(Object.keys(animation.properties)), + [animation.properties], + ); + const availableProps = useMemo( + () => SUPPORTED_PROPS.filter((p) => !usedProps.has(p)), + [usedProps], + ); + + const commitProperty = useCallback( + (prop: string, raw: string) => { + const value = parseNumericOrString(raw); + onUpdateProperty(animation.id, prop, value); + onLivePreviewEnd?.(); + }, + [animation.id, onUpdateProperty, onLivePreviewEnd], + ); + + const scrubProperty = useCallback( + (prop: string, raw: string) => { + onLivePreview?.(prop, parseNumericOrString(raw)); + }, + [onLivePreview], + ); + + const commitDuration = useCallback( + (raw: string) => { + const num = Number(raw); + if (Number.isFinite(num) && num >= 0) + onUpdateMeta(animation.id, { duration: Math.max(0, num) }); + }, + [animation.id, onUpdateMeta], + ); + + const commitPosition = useCallback( + (raw: string) => { + const num = Number(raw); + if (Number.isFinite(num) && num >= 0) + onUpdateMeta(animation.id, { position: Math.max(0, num) }); + }, + [animation.id, onUpdateMeta], + ); + + const [copied, setCopied] = useState(false); + + const methodLabel = METHOD_LABELS[animation.method] ?? animation.method; + const easeName = animation.ease ?? "none"; + const easeLabel = easeName.startsWith("custom(") + ? "Custom curve" + : (EASE_LABELS[easeName] ?? easeName); + const endTime = + typeof animation.position === "number" + ? animation.position + (animation.duration ?? 0) + : animation.position; + + const summary = useMemo(() => buildTweenSummary(animation), [animation]); + + return ( +
+ + + {expanded && ( +
+
+
+

+ {summary} +

+ +
+
+ {animation.method !== "set" && ( + + )} + +
+ + {animation.method !== "set" && ( + <> + { + if (next === "custom") { + const points = controlPointsForGsapEase(animation.ease ?? "power2.out"); + const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`; + onUpdateMeta(animation.id, { ease: `custom(${path})` }); + } else { + onUpdateMeta(animation.id, { ease: next }); + } + }} + /> + + onUpdateMeta(animation.id, { ease: customEase }) + } + /> + + )} + + {Object.keys(animation.properties).length > 0 && ( +
+ {Object.entries(animation.properties).map(([prop, val]) => ( +
+
+ { + const adjusted = isPercentProp(prop) ? String(Number(raw) / 100) : raw; + scrubProperty(prop, adjusted); + commitProperty(prop, adjusted); + }} + /> +
+ +
+ ))} +
+ )} + +
+ {addingProp && availableProps.length > 0 ? ( + + ) : ( + availableProps.length > 0 && ( + + ) + )} + +
+
+
+ )} +
+ ); +}); diff --git a/packages/studio/src/components/editor/EaseCurveSection.tsx b/packages/studio/src/components/editor/EaseCurveSection.tsx new file mode 100644 index 000000000..4752dd9c5 --- /dev/null +++ b/packages/studio/src/components/editor/EaseCurveSection.tsx @@ -0,0 +1,193 @@ +import { useCallback, useRef, useState } from "react"; +import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants"; + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +export function EaseCurveSection({ + ease, + onCustomEaseCommit, +}: { + ease: string; + onCustomEaseCommit: (ease: string) => void; +}) { + const isCustom = ease.startsWith("custom("); + const curveFromPreset = EASE_CURVES[ease]; + const customPoints = isCustom ? parseCustomEaseFromString(ease) : null; + const curve: [number, number, number, number] | null = + isCustom && customPoints + ? [customPoints.x1, customPoints.y1, customPoints.x2, customPoints.y2] + : (curveFromPreset ?? null); + + const [draft, setDraft] = useState<[number, number, number, number] | null>(null); + const [progress, setProgress] = useState(null); + const draggingRef = useRef<"p1" | "p2" | null>(null); + const svgRef = useRef(null); + const rafRef = useRef(0); + + const play = useCallback(() => { + const start = performance.now(); + const dur = 1000; + const tick = (now: number) => { + const t = Math.min((now - start) / dur, 1); + setProgress(t); + if (t < 1) rafRef.current = requestAnimationFrame(tick); + else setTimeout(() => setProgress(null), 400); + }; + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(tick); + }, []); + + const active = draft ?? curve; + if (!active) return null; + const [x1, y1, x2, y2] = active; + + const w = 200; + const h = 100; + const pad = 14; + const gw = w - pad * 2; + const gh = h - pad * 2; + + const toSvg = (px: number, py: number) => ({ + x: pad + gw * px, + y: h - pad - gh * py, + }); + + const curvePath = `M${pad},${h - pad} C${toSvg(x1, y1).x},${toSvg(x1, y1).y} ${toSvg(x2, y2).x},${toSvg(x2, y2).y} ${w - pad},${pad}`; + + let dotX = pad; + let dotY = h - pad; + if (progress !== null) { + const t = progress; + const mt = 1 - t; + dotX = pad + gw * (mt * mt * mt * 0 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t); + dotY = + h - pad - gh * (mt * mt * mt * 0 + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t); + } + + const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + draggingRef.current = handle; + (e.target as SVGElement).setPointerCapture(e.pointerId); + if (!draft) setDraft([x1, y1, x2, y2]); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!draggingRef.current || !svgRef.current) return; + e.preventDefault(); + const rect = svgRef.current.getBoundingClientRect(); + const sx = ((e.clientX - rect.left) / rect.width) * w; + const sy = ((e.clientY - rect.top) / rect.height) * h; + const px = Math.max(0, Math.min(1, (sx - pad) / gw)); + const py = Math.max(-1, Math.min(2, (h - pad - sy) / gh)); + const prev = draft ?? [x1, y1, x2, y2]; + const next: [number, number, number, number] = + draggingRef.current === "p1" + ? [round2(px), round2(py), prev[2], prev[3]] + : [prev[0], prev[1], round2(px), round2(py)]; + setDraft(next); + }; + + const handlePointerUp = () => { + if (!draggingRef.current || !draft) return; + draggingRef.current = null; + const path = `M0,0 C${draft[0]},${draft[1]} ${draft[2]},${draft[3]} 1,1`; + onCustomEaseCommit(`custom(${path})`); + setDraft(null); + }; + + const p1 = toSvg(x1, y1); + const p2 = toSvg(x2, y2); + const start = toSvg(0, 0); + const end = toSvg(1, 1); + const label = isCustom ? "Custom curve" : (EASE_LABELS[ease] ?? ease); + + return ( +
+
+ Speed curve + +
+
+ + + + + + + {progress !== null && } + handlePointerDown("p1", e)} + /> + handlePointerDown("p2", e)} + /> + +
+

{label}

+
+ ); +} diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index 545b1034b..42b70a956 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -1,26 +1,14 @@ -import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { memo, useState } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; -import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-parser"; import { Film } from "../../icons/SystemIcons"; -import { RESPONSIVE_GRID } from "./propertyPanelHelpers"; -import { MetricField, Section, SelectField } from "./propertyPanelPrimitives"; -import { controlPointsForGsapEase } from "./studioMotion"; -import { - ADD_METHODS, - ADD_METHOD_LABELS, - EASE_CURVES, - EASE_LABELS, - METHOD_LABELS, - METHOD_TOOLTIPS, - PROP_LABELS, - PROP_TOOLTIPS, - PROP_UNITS, - parseCustomEaseFromString, -} from "./gsapAnimationConstants"; +import { Section } from "./propertyPanelPrimitives"; +import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants"; +import { AnimationCard } from "./AnimationCard"; interface GsapAnimationSectionProps { animations: GsapAnimation[]; multipleTimelines?: boolean; + unsupportedTimelinePattern?: boolean; onUpdateProperty: (animationId: string, property: string, value: number | string) => void; onUpdateMeta: ( animationId: string, @@ -34,504 +22,10 @@ interface GsapAnimationSectionProps { onLivePreviewEnd?: () => void; } -const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]); -function isPercentProp(prop: string): boolean { - return PERCENT_PROPS.has(prop); -} - -function EaseCurveSection({ - ease, - onCustomEaseCommit, -}: { - ease: string; - onCustomEaseCommit: (ease: string) => void; -}) { - const isCustom = ease.startsWith("custom("); - const curveFromPreset = EASE_CURVES[ease]; - const customPoints = isCustom ? parseCustomEaseFromString(ease) : null; - const curve: [number, number, number, number] | null = - isCustom && customPoints - ? [customPoints.x1, customPoints.y1, customPoints.x2, customPoints.y2] - : (curveFromPreset ?? null); - - const [draft, setDraft] = useState<[number, number, number, number] | null>(null); - const [progress, setProgress] = useState(null); - const draggingRef = useRef<"p1" | "p2" | null>(null); - const svgRef = useRef(null); - const rafRef = useRef(0); - - const play = useCallback(() => { - const start = performance.now(); - const dur = 1000; - const tick = (now: number) => { - const t = Math.min((now - start) / dur, 1); - setProgress(t); - if (t < 1) rafRef.current = requestAnimationFrame(tick); - else setTimeout(() => setProgress(null), 400); - }; - cancelAnimationFrame(rafRef.current); - rafRef.current = requestAnimationFrame(tick); - }, []); - - const active = draft ?? curve; - if (!active) return null; - const [x1, y1, x2, y2] = active; - - const w = 200; - const h = 100; - const pad = 14; - const gw = w - pad * 2; - const gh = h - pad * 2; - - const toSvg = (px: number, py: number) => ({ - x: pad + gw * px, - y: h - pad - gh * py, - }); - - const curvePath = `M${pad},${h - pad} C${toSvg(x1, y1).x},${toSvg(x1, y1).y} ${toSvg(x2, y2).x},${toSvg(x2, y2).y} ${w - pad},${pad}`; - - let dotX = pad; - let dotY = h - pad; - if (progress !== null) { - const t = progress; - const mt = 1 - t; - dotX = pad + gw * (mt * mt * mt * 0 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t); - dotY = - h - pad - gh * (mt * mt * mt * 0 + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t); - } - - const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => { - e.preventDefault(); - e.stopPropagation(); - draggingRef.current = handle; - (e.target as SVGElement).setPointerCapture(e.pointerId); - if (!draft) setDraft([x1, y1, x2, y2]); - }; - - const handlePointerMove = (e: React.PointerEvent) => { - if (!draggingRef.current || !svgRef.current) return; - e.preventDefault(); - const rect = svgRef.current.getBoundingClientRect(); - const sx = ((e.clientX - rect.left) / rect.width) * w; - const sy = ((e.clientY - rect.top) / rect.height) * h; - const px = Math.max(0, Math.min(1, (sx - pad) / gw)); - const py = Math.max(-1, Math.min(2, (h - pad - sy) / gh)); - const prev = draft ?? [x1, y1, x2, y2]; - const next: [number, number, number, number] = - draggingRef.current === "p1" - ? [round2(px), round2(py), prev[2], prev[3]] - : [prev[0], prev[1], round2(px), round2(py)]; - setDraft(next); - }; - - const handlePointerUp = () => { - if (!draggingRef.current || !draft) return; - draggingRef.current = null; - const path = `M0,0 C${draft[0]},${draft[1]} ${draft[2]},${draft[3]} 1,1`; - onCustomEaseCommit(`custom(${path})`); - setDraft(null); - }; - - const p1 = toSvg(x1, y1); - const p2 = toSvg(x2, y2); - const start = toSvg(0, 0); - const end = toSvg(1, 1); - const label = isCustom ? "Custom curve" : (EASE_LABELS[ease] ?? ease); - - return ( -
-
- Speed curve - -
-
- - - - - - - {progress !== null && } - handlePointerDown("p1", e)} - /> - handlePointerDown("p2", e)} - /> - -
-

{label}

-
- ); -} - -function round2(n: number): number { - return Math.round(n * 100) / 100; -} - -function buildTweenSummary(animation: GsapAnimation): string { - const easeName = animation.ease ?? "none"; - const ease = EASE_LABELS[easeName] ?? easeName; - const props = Object.entries(animation.properties); - const target = animation.targetSelector; - const dur = animation.duration ?? 0; - const pos = animation.position; - const propDescs = props.map(([p, v]) => { - const label = (PROP_LABELS[p] ?? p).toLowerCase(); - const unit = PROP_UNITS[p] ?? ""; - return `${label} to ${v}${unit}`; - }); - const propText = propDescs.length > 0 ? propDescs.join(", ") : "no properties yet"; - if (animation.method === "set") return `At ${pos}s, instantly set ${target}'s ${propText}.`; - if (animation.method === "from") - return `Starting at ${pos}s, over ${dur}s, ${target} enters from ${propText} using a ${ease.toLowerCase()} curve.`; - return `Starting at ${pos}s, over ${dur}s, animate ${target}'s ${propText} using a ${ease.toLowerCase()} curve.`; -} - -function parseNumericOrString(raw: string): number | string { - const num = Number(raw); - return Number.isFinite(num) ? num : raw; -} - -// fallow-ignore-next-line complexity -const AnimationCard = memo(function AnimationCard({ - animation, - defaultExpanded, - onUpdateProperty, - onUpdateMeta, - onDeleteAnimation, - onAddProperty, - onRemoveProperty, - onLivePreview, - onLivePreviewEnd, -}: { - animation: GsapAnimation; - defaultExpanded: boolean; - onUpdateProperty: GsapAnimationSectionProps["onUpdateProperty"]; - onUpdateMeta: GsapAnimationSectionProps["onUpdateMeta"]; - onDeleteAnimation: GsapAnimationSectionProps["onDeleteAnimation"]; - onAddProperty: GsapAnimationSectionProps["onAddProperty"]; - onRemoveProperty: GsapAnimationSectionProps["onRemoveProperty"]; - onLivePreview?: GsapAnimationSectionProps["onLivePreview"]; - onLivePreviewEnd?: GsapAnimationSectionProps["onLivePreviewEnd"]; -}) { - const [expanded, setExpanded] = useState(defaultExpanded); - const [addingProp, setAddingProp] = useState(false); - - const usedProps = useMemo( - () => new Set(Object.keys(animation.properties)), - [animation.properties], - ); - const availableProps = useMemo( - () => SUPPORTED_PROPS.filter((p) => !usedProps.has(p)), - [usedProps], - ); - - const commitProperty = useCallback( - (prop: string, raw: string) => { - const value = parseNumericOrString(raw); - onUpdateProperty(animation.id, prop, value); - onLivePreviewEnd?.(); - }, - [animation.id, onUpdateProperty, onLivePreviewEnd], - ); - - const scrubProperty = useCallback( - (prop: string, raw: string) => { - onLivePreview?.(prop, parseNumericOrString(raw)); - }, - [onLivePreview], - ); - - const commitDuration = useCallback( - (raw: string) => { - const num = Number(raw); - if (Number.isFinite(num) && num >= 0) - onUpdateMeta(animation.id, { duration: Math.max(0, num) }); - }, - [animation.id, onUpdateMeta], - ); - - const commitPosition = useCallback( - (raw: string) => { - const num = Number(raw); - if (Number.isFinite(num) && num >= 0) - onUpdateMeta(animation.id, { position: Math.max(0, num) }); - }, - [animation.id, onUpdateMeta], - ); - - const [copied, setCopied] = useState(false); - - const methodLabel = METHOD_LABELS[animation.method] ?? animation.method; - const easeName = animation.ease ?? "none"; - const easeLabel = easeName.startsWith("custom(") - ? "Custom curve" - : (EASE_LABELS[easeName] ?? easeName); - const endTime = - typeof animation.position === "number" - ? animation.position + (animation.duration ?? 0) - : animation.position; - - const summary = useMemo(() => buildTweenSummary(animation), [animation]); - - return ( -
- - - {expanded && ( -
-
-
-

- {summary} -

- -
-
- {animation.method !== "set" && ( - - )} - -
- - {animation.method !== "set" && ( - <> - { - if (next === "custom") { - const points = controlPointsForGsapEase(animation.ease ?? "power2.out"); - const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`; - onUpdateMeta(animation.id, { ease: `custom(${path})` }); - } else { - onUpdateMeta(animation.id, { ease: next }); - } - }} - /> - - onUpdateMeta(animation.id, { ease: customEase }) - } - /> - - )} - - {Object.keys(animation.properties).length > 0 && ( -
- {Object.entries(animation.properties).map(([prop, val]) => ( -
-
- { - const adjusted = isPercentProp(prop) ? String(Number(raw) / 100) : raw; - scrubProperty(prop, adjusted); - commitProperty(prop, adjusted); - }} - /> -
- -
- ))} -
- )} - -
- {addingProp && availableProps.length > 0 ? ( - - ) : ( - availableProps.length > 0 && ( - - ) - )} - -
-
-
- )} -
- ); -}); - export const GsapAnimationSection = memo(function GsapAnimationSection({ animations, multipleTimelines, + unsupportedTimelinePattern, onUpdateProperty, onUpdateMeta, onDeleteAnimation, @@ -551,7 +45,14 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ — consolidate into a single timeline to enable editing.

)} - {multipleTimelines ? null : ( + {unsupportedTimelinePattern && ( +

+ This composition uses a timeline assignment pattern (window.__timelines[...]) that the + editor doesn't support. Use a variable declaration (const tl = gsap.timeline()) to + enable editing. +

+ )} + {multipleTimelines || unsupportedTimelinePattern ? null : (
{animations.map((anim, index) => ( Promise; gsapAnimations?: import("@hyperframes/core/gsap-parser").GsapAnimation[]; gsapMultipleTimelines?: boolean; + gsapUnsupportedTimelinePattern?: boolean; onUpdateGsapProperty?: (animId: string, prop: string, value: number | string) => void; onUpdateGsapMeta?: ( animId: string, @@ -155,6 +156,7 @@ export const PropertyPanel = memo(function PropertyPanel({ onImportFonts, gsapAnimations = [], gsapMultipleTimelines, + gsapUnsupportedTimelinePattern, onUpdateGsapProperty, onUpdateGsapMeta, onDeleteGsapAnimation, @@ -360,6 +362,7 @@ export const PropertyPanel = memo(function PropertyPanel({ string) | undefined; +} { + if (selection.id) return { selector: `#${selection.id}`, htmlPreTransform: undefined }; + if (selection.selector) return { selector: selection.selector, htmlPreTransform: undefined }; + + const el = selection.element; + const doc = el.ownerDocument; + const tag = el.tagName.toLowerCase(); + let id = tag; + let n = 1; + while (doc.getElementById(id)) { + n += 1; + id = `${tag}-${n}`; + } + el.setAttribute("id", id); + + const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp(`(<${escapedTag}\\b)([^>]*?>)`, "g"); + const allSameTag = Array.from(el.ownerDocument.querySelectorAll(tag)); + const elementIndex = allSameTag.indexOf(el); + const htmlPreTransform = (html: string) => { + let count = 0; + return html.replace(pattern, (match, p1, p2) => { + if (count++ === elementIndex) return `${p1} id="${id}"${p2}`; + return match; + }); + }; + + return { selector: `#${id}`, htmlPreTransform }; +} + // fallow-ignore-next-line complexity unit-size export function useGsapScriptCommits({ projectIdRef, @@ -212,24 +253,7 @@ export function useGsapScriptCommits({ const addGsapAnimation = useCallback( (selection: DomEditSelection, method: "to" | "from" | "set", currentTime?: number) => { - let selector = selection.id ? `#${selection.id}` : selection.selector; - let htmlPreTransform: ((html: string) => string) | undefined; - if (!selector) { - const el = selection.element; - const doc = el.ownerDocument; - const tag = el.tagName.toLowerCase(); - let id = tag; - let n = 1; - while (doc.getElementById(id)) { - n += 1; - id = `${tag}-${n}`; - } - el.setAttribute("id", id); - selector = `#${id}`; - const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const pattern = new RegExp(`(<${escapedTag}\\b)([^>]*?>)`); - htmlPreTransform = (html) => html.replace(pattern, `$1 id="${id}"$2`); - } + const { selector, htmlPreTransform } = ensureElementAddressable(selection); const start = currentTime ?? (Number.parseFloat(selection.dataAttributes.start ?? "0") || 0); const defaults: Record> = { diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index d02a5cf82..2ba3a5b8a 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -23,9 +23,14 @@ export function useGsapAnimationsForElement( sourceFile: string, elementId: string | null, version: number, -): { animations: GsapAnimation[]; multipleTimelines: boolean } { +): { + animations: GsapAnimation[]; + multipleTimelines: boolean; + unsupportedTimelinePattern: boolean; +} { const [allAnimations, setAllAnimations] = useState([]); const [multipleTimelines, setMultipleTimelines] = useState(false); + const [unsupportedTimelinePattern, setUnsupportedTimelinePattern] = useState(false); const lastFetchKeyRef = useRef(""); useEffect(() => { @@ -36,6 +41,7 @@ export function useGsapAnimationsForElement( if (!projectId) { setAllAnimations([]); setMultipleTimelines(false); + setUnsupportedTimelinePattern(false); return; } @@ -45,11 +51,13 @@ export function useGsapAnimationsForElement( if (!script) { setAllAnimations([]); setMultipleTimelines(false); + setUnsupportedTimelinePattern(false); return; } const parsed = parseGsapScript(script); setAllAnimations(parsed.animations); setMultipleTimelines(parsed.multipleTimelines === true); + setUnsupportedTimelinePattern(parsed.unsupportedTimelinePattern === true); }); return () => { @@ -62,7 +70,7 @@ export function useGsapAnimationsForElement( [allAnimations, elementId], ); - return { animations, multipleTimelines }; + return { animations, multipleTimelines, unsupportedTimelinePattern }; } export function useGsapCacheVersion() { From d709a6a484bdd5edbda5f9669d6b8e3bc885c4ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 28 May 2026 13:27:50 -0400 Subject: [PATCH 19/19] =?UTF-8?q?fix(studio):=20eliminate=20round-trip=20d?= =?UTF-8?q?ata=20loss=20=E2=80=94=20preserve=20extras,=20raw=20values,=20s?= =?UTF-8?q?table=20IDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stagger, yoyo, repeat, repeatDelay, snap, overwrite, immediateRender now survive round-trips via extras field with raw source preservation - Unresolvable property values (function calls, complex expressions) stored as __raw: prefixed source text, emitted verbatim on serialize - Animation IDs are now content-based (selector-method-position) instead of positional (anim-1, anim-2), preventing coalesce key drift - Property defaults read element rendered values for width/height/opacity - 122 tests passing (9 new round-trip preservation tests) --- packages/core/src/parsers/gsapParser.test.ts | 139 +++++++++++++++++- packages/core/src/parsers/gsapParser.ts | 119 +++++++++++++-- .../studio/src/hooks/useGsapScriptCommits.ts | 11 +- 3 files changed, 255 insertions(+), 14 deletions(-) diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index 9ea3d1d50..cbdddeb5f 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -219,7 +219,7 @@ describe("parseGsapScript", () => { expect(result.animations[0].properties.x).toBe(50); }); - it("skips unresolvable references without crashing", () => { + it("preserves unresolvable references as __raw: prefixed strings", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el1", { opacity: someUndefinedVar, x: 50, duration: 1 }, 0); @@ -228,7 +228,142 @@ describe("parseGsapScript", () => { expect(result.animations).toHaveLength(1); expect(result.animations[0].properties.x).toBe(50); - expect(result.animations[0].properties.opacity).toBeUndefined(); + expect(result.animations[0].properties.opacity).toBe("__raw:someUndefinedVar"); + }); + + it("generates stable content-based IDs", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }, 0); + tl.to("#el2", { x: 100, duration: 1 }, 1); + `; + const result1 = parseGsapScript(script); + const result2 = parseGsapScript(script); + + // IDs are deterministic across parses + expect(result1.animations[0].id).toBe(result2.animations[0].id); + expect(result1.animations[1].id).toBe(result2.animations[1].id); + + // IDs encode selector, method, and position + expect(result1.animations[0].id).toBe("#el1-to-0"); + expect(result1.animations[1].id).toBe("#el2-to-1000"); + }); + + it("disambiguates colliding IDs with a suffix", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 0, duration: 0.3 }, 0); + tl.to("#el1", { opacity: 1, duration: 0.5 }, 0); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].id).toBe("#el1-to-0"); + expect(result.animations[1].id).toBe("#el1-to-0-2"); + }); + + it("uses string position in ID for relative positions", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }, "+=1"); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].id).toBe("#el1-to-+=1"); + }); +}); + +describe("stagger/yoyo/repeat round-trip", () => { + it("preserves stagger as extras on parse", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to(".items", { opacity: 1, duration: 0.5, stagger: 0.1 }, 0); + `; + const result = parseGsapScript(script); + + expect(result.animations).toHaveLength(1); + expect(result.animations[0].extras).toBeDefined(); + expect(result.animations[0].extras!.stagger).toBe("__raw:0.1"); + expect(result.animations[0].properties.opacity).toBe(1); + // stagger should NOT appear in properties + expect(result.animations[0].properties).not.toHaveProperty("stagger"); + }); + + it("preserves complex stagger object on round-trip", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to(".items", { opacity: 1, duration: 0.5, stagger: { each: 0.15, from: "start" } }, 0); + `; + const parsed = parseGsapScript(script); + const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, { + preamble: parsed.preamble, + postamble: parsed.postamble, + }); + + expect(serialized).toContain("stagger: {"); + expect(serialized).toContain("each: 0.15"); + expect(serialized).toContain('from: "start"'); + }); + + it("preserves yoyo and repeat on round-trip", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { x: 100, duration: 1, yoyo: true, repeat: 3, repeatDelay: 0.2 }, 0); + `; + const parsed = parseGsapScript(script); + const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, { + preamble: parsed.preamble, + postamble: parsed.postamble, + }); + + expect(serialized).toContain("yoyo: true"); + expect(serialized).toContain("repeat: 3"); + expect(serialized).toContain("repeatDelay: 0.2"); + }); + + it("survives a full parse-edit-serialize round-trip with stagger intact", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to(".items", { opacity: 1, x: 50, duration: 0.5, stagger: 0.1, ease: "power2.out" }, 0); + `; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0].id; + // Simulate an edit — change opacity to 0.5 + const updatedScript = updateAnimationInScript(script, animId, { + properties: { opacity: 0.5, x: 50 }, + }); + // stagger should still be in the output + expect(updatedScript).toContain("stagger: 0.1"); + expect(updatedScript).toContain("opacity: 0.5"); + }); +}); + +describe("unresolvable value round-trip", () => { + it("preserves unresolvable property values through serialize", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: someFn(), x: 50, duration: 1 }, 0); + `; + const parsed = parseGsapScript(script); + const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, { + preamble: parsed.preamble, + postamble: parsed.postamble, + }); + + // The raw expression should survive — emitted without quotes + expect(serialized).toContain("opacity: someFn()"); + expect(serialized).toContain("x: 50"); + }); + + it("preserves complex unresolvable expressions", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { x: getOffset() + 10, y: 200, duration: 1 }, 0); + `; + const parsed = parseGsapScript(script); + + // x is unresolvable (function call in expression), y is resolvable + expect(parsed.animations[0].properties.y).toBe(200); + expect(String(parsed.animations[0].properties.x)).toMatch(/^__raw:/); }); }); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index 197f5934f..5ba2bef18 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -13,6 +13,8 @@ export interface GsapAnimation { fromProperties?: Record; duration?: number; ease?: string; + /** Non-editable GSAP config (stagger, yoyo, repeat, etc.) preserved for round-trips. */ + extras?: Record; } export interface ParsedGsap { @@ -154,7 +156,13 @@ function objectExpressionToRecord(node: any, scope: ScopeBindings): Record { const vars = objectExpressionToRecord(call.varsArg, scope); const properties: Record = {}; + const extras: Record = {}; for (const [key, val] of Object.entries(vars)) { - if (key === "duration" || key === "ease" || key === "delay" || key === "stagger") continue; - if (key === "keyframes" || key === "onComplete" || key === "onStart") continue; + if (BUILTIN_VAR_KEYS.has(key)) continue; + if (DROPPED_VAR_KEYS.has(key)) continue; + + if (EXTRAS_KEYS.has(key)) { + // For extras, prefer the raw AST source so complex objects like + // `stagger: { each: 0.15, from: "start" }` survive verbatim. + const rawSource = extractRawPropertySource(call.varsArg, key); + if (rawSource !== undefined) { + extras[key] = `__raw:${rawSource}`; + } else if (val !== undefined) { + extras[key] = val; + } + continue; + } + if (typeof val === "number" || typeof val === "string") { properties[key] = val; } @@ -304,8 +358,7 @@ function tweenCallToAnimation( const duration = typeof vars.duration === "number" ? vars.duration : undefined; const ease = typeof vars.ease === "string" ? vars.ease : undefined; - return { - id: `anim-${index + 1}`, + const anim: Omit = { targetSelector: call.selector, method: call.method, position, @@ -314,6 +367,25 @@ function tweenCallToAnimation( duration, ease, }; + if (Object.keys(extras).length > 0) anim.extras = extras; + return anim; +} + +// ── Stable ID Generation ─────────────────────────────────────────────────── + +function assignStableIds(anims: Omit[]): GsapAnimation[] { + const counts = new Map(); + return anims.map((anim) => { + const posKey = + typeof anim.position === "number" + ? String(Math.round(anim.position * 1000)) + : String(anim.position); + const base = `${anim.targetSelector}-${anim.method}-${posKey}`; + const count = (counts.get(base) ?? 0) + 1; + counts.set(base, count); + const id = count === 1 ? base : `${base}-${count}`; + return { ...anim, id }; + }); } // ── Public API ────────────────────────────────────────────────────────────── @@ -325,7 +397,7 @@ export function parseGsapScript(script: string): ParsedGsap { const detection = findTimelineVar(ast); const timelineVar = detection.timelineVar ?? "tl"; const calls = findAllTweenCalls(ast, timelineVar); - const animations = calls.map((call, i) => tweenCallToAnimation(call, i, scope)); + const animations = assignStableIds(calls.map((call) => tweenCallToAnimation(call, scope))); const timelineMatch = script.match( new RegExp( @@ -370,7 +442,16 @@ export function serializeGsapAnimations( const props: Record = { ...anim.properties }; if (anim.duration !== undefined) props.duration = anim.duration; if (anim.ease) props.ease = anim.ease; - const propsStr = serializeObject(props); + let propsStr = serializeObject(props); + if (anim.extras && Object.keys(anim.extras).length > 0) { + const extrasStr = serializeExtras(anim.extras); + if (Object.keys(props).length === 0) { + propsStr = `{ ${extrasStr} }`; + } else { + // Insert extras before the closing brace + propsStr = propsStr.slice(0, -2) + `, ${extrasStr} }`; + } + } const posStr = typeof anim.position === "string" ? `"${anim.position}"` : anim.position; switch (anim.method) { case "set": @@ -418,15 +499,31 @@ ${lines.join("\n")}${mediaSync}${postamble} `; } +function serializeValue(value: unknown): string { + if (typeof value === "string" && value.startsWith("__raw:")) { + return value.slice(6); + } + if (typeof value === "string") return JSON.stringify(value); + return String(value); +} + function serializeObject(obj: Record): string { const entries = Object.entries(obj).map(([key, value]) => { const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key); - if (typeof value === "string") return `${safeKey}: ${JSON.stringify(value)}`; - return `${safeKey}: ${value}`; + return `${safeKey}: ${serializeValue(value)}`; }); return `{ ${entries.join(", ")} }`; } +function serializeExtras(extras: Record): string { + return Object.entries(extras) + .map(([key, value]) => { + const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key); + return `${safeKey}: ${serializeValue(value)}`; + }) + .join(", "); +} + /** Returns true when the parse result is a failure fallback (no animations, no preamble). */ function isParseFailure(parsed: ParsedGsap): boolean { return parsed.animations.length === 0 && !parsed.preamble; diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 4acae9cd4..8b2283b4b 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -283,12 +283,21 @@ export function useGsapScriptCommits({ const addGsapProperty = useCallback( (selection: DomEditSelection, animationId: string, property: string) => { + let defaultValue = PROPERTY_DEFAULTS[property] ?? 0; + const el = selection.element; + if (property === "width" || property === "height") { + const rect = el.getBoundingClientRect(); + defaultValue = Math.round(property === "width" ? rect.width : rect.height); + } else if (property === "opacity" || property === "autoAlpha") { + const cs = el.ownerDocument.defaultView?.getComputedStyle(el); + defaultValue = cs ? Number.parseFloat(cs.opacity) || 1 : 1; + } void persistScriptEdit( selection, (script) => updateAnimProperties(script, animationId, (p) => ({ ...p, - [property]: PROPERTY_DEFAULTS[property] ?? 0, + [property]: defaultValue, })), { label: `Add GSAP ${property}` }, );