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/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 3dae45caf..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"; @@ -79,9 +82,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 +93,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 +142,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 +151,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", () => { @@ -175,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", () => { @@ -244,7 +299,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 +313,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"); }); @@ -527,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 c8db6a4cb..a86d6bd46 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"; @@ -6,7 +8,7 @@ export interface GsapAnimation { id: string; targetSelector: string; method: GsapMethod; - position: number; + position: number | string; properties: Record; fromProperties?: Record; duration?: number; @@ -18,6 +20,7 @@ export interface ParsedGsap { timelineVar: string; preamble: string; postamble: string; + multipleTimelines?: boolean; } const GSAP_METHODS = new Set(["set", "to", "from", "fromTo"]); @@ -64,194 +67,318 @@ 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; +type ScopeBindings = ReadonlyMap; - const propRegex = /(\w+)\s*:\s*("[^"]*"|'[^']*'|[\d.]+|[a-zA-Z_][\w.]*)/g; - let match; +function parseScript(script: string) { + return recast.parse(script, { + parser: { + parse(source: string) { + return babelParse(source, { sourceType: "script", plugins: [], tokens: true }); + }, + }, + }); +} - while ((match = propRegex.exec(cleaned)) !== null) { - const key = match[1] ?? ""; - let value: string | number = match[2] ?? ""; +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; +} - 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); +function resolveNode( + node: any, + scope: ReadonlyMap, +): number | string | boolean | undefined { + if (!node) return undefined; + 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 = 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; } } - - result[key] = value; + 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; +} - return result; +function extractLiteralValue(node: any, scope: ScopeBindings): unknown { + return resolveNode(node, scope); } -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 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] = resolveNode(prop.value, scope); } - return -1; + return result; } -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"; +// ── Timeline Variable Detection ───────────────────────────────────────────── - const preambleMatch = script.match( - new RegExp( - `^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`, - ), +function isGsapTimelineCall(node: any): boolean { + return ( + node?.type === "CallExpression" && + node.callee?.type === "MemberExpression" && + node.callee.object?.name === "gsap" && + node.callee.property?.name === "timeline" ); - 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); - } - } - - 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 }; } -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; - - 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; +interface TimelineDetection { + timelineVar: string | null; + timelineCount: number; +} - properties = parseObjectLiteral(secondPart.slice(secondBrace, secondEnd + 1)); +function findTimelineVar(ast: any): TimelineDetection { + let timelineVar: string | null = null; + let timelineCount = 0; + recast.types.visit(ast, { + visitVariableDeclarator(path: any) { + if (isGsapTimelineCall(path.node.init)) { + timelineCount += 1; + if (!timelineVar) timelineVar = path.node.id?.name ?? null; + } + this.traverse(path); + }, + visitAssignmentExpression(path: any) { + if (isGsapTimelineCall(path.node.right)) { + timelineCount += 1; + if (!timelineVar) { + const left = path.node.left; + if (left?.type === "Identifier") timelineVar = left.name; + } + } + this.traverse(path); + }, + }); + return { timelineVar, timelineCount }; +} - 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, + 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; + 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, scope); + 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, 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; + 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 scope = collectScopeBindings(ast); + 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 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(); + } + } + + const result: ParsedGsap = { animations, timelineVar, preamble, postamble }; + if (detection.timelineCount > 1) result.multipleTimelines = true; + return result; + } catch { + return { animations: [], timelineVar: "tl", preamble: "", postamble: "" }; + } +} + 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});`; } } }); @@ -259,7 +386,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) { @@ -280,37 +406,43 @@ 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(", ")} }`; } +/** 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( 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; + if (isParseFailure(parsed)) return script; + const updated = parsed.animations.map((anim) => + anim.id === animationId ? { ...anim, ...updates } : anim, + ); + return serializeGsapAnimations(updated, parsed.timelineVar, { + preamble: parsed.preamble, + postamble: parsed.postamble, }); - - return serializeGsapAnimations(updated, parsed.timelineVar); } export function addAnimationToScript( @@ -318,22 +450,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); - + const allAnimations = [...parsed.animations, newAnim]; return { - script: serializeGsapAnimations(parsed.animations, parsed.timelineVar), + 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 serializeGsapAnimations(filtered, parsed.timelineVar); + return serializeGsapAnimations(filtered, parsed.timelineVar, { + preamble: parsed.preamble, + postamble: parsed.postamble, + }); } export function getAnimationsForElement( @@ -344,73 +481,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 +538,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; @@ -475,46 +586,40 @@ 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 = {}; - 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/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/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index f009e6426..b15e0a32a 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -78,6 +78,14 @@ export function StudioRightPanel({ handleAskAgent, handleDomMotionCommit, handleDomMotionClear, + selectedGsapAnimations, + gsapMultipleTimelines, + handleGsapUpdateProperty, + handleGsapUpdateMeta, + handleGsapDeleteAnimation, + handleGsapAddAnimation, + handleGsapAddProperty, + handleGsapRemoveProperty, } = useDomEditContext(); const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } = @@ -198,6 +206,14 @@ export function StudioRightPanel({ onImportAssets={handleImportFiles} fontAssets={fontAssets} onImportFonts={handleImportFonts} + gsapAnimations={selectedGsapAnimations} + gsapMultipleTimelines={gsapMultipleTimelines} + 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; +} + +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]) => ( +
+
+ { + scrubProperty(prop, raw); + commitProperty(prop, raw); + }} + /> +
+ +
+ ))} +
+ )} + +
+ {addingProp && availableProps.length > 0 ? ( + + ) : ( + availableProps.length > 0 && ( + + ) + )} + +
+
+
+ )} +
+ ); +}); + +export const GsapAnimationSection = memo(function GsapAnimationSection({ + animations, + multipleTimelines, + onUpdateProperty, + onUpdateMeta, + onDeleteAnimation, + onAddProperty, + onRemoveProperty, + onAddAnimation, + onLivePreview, + onLivePreviewEnd, +}: GsapAnimationSectionProps) { + const [addMenuOpen, setAddMenuOpen] = useState(false); + + return ( +
}> + {multipleTimelines && ( +

+ This file has multiple GSAP timelines. Animation editing is disabled to prevent data loss + — consolidate into a single timeline to enable editing. +

+ )} + {multipleTimelines ? null : ( +
+ {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..61a3e3c22 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, @@ -18,12 +13,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 +50,17 @@ interface PropertyPanelProps { onImportAssets?: (files: FileList) => Promise; fontAssets?: ImportedFontAsset[]; onImportFonts?: (files: FileList | File[]) => Promise; + gsapAnimations?: import("@hyperframes/core/gsap-parser").GsapAnimation[]; + gsapMultipleTimelines?: boolean; + 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 +153,14 @@ export const PropertyPanel = memo(function PropertyPanel({ onImportAssets, fontAssets = [], onImportFonts, + gsapAnimations = [], + gsapMultipleTimelines, + onUpdateGsapProperty, + onUpdateGsapMeta, + onDeleteGsapAnimation, + onAddGsapProperty, + onRemoveGsapProperty, + onAddGsapAnimation, }: PropertyPanelProps) { const styles = element?.computedStyles ?? EMPTY_STYLES; @@ -186,11 +201,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 @@ -204,11 +214,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, }); }; @@ -300,14 +309,14 @@ export const PropertyPanel = memo(function PropertyPanel({
commitManualOffset("x", next)} /> commitManualOffset("y", next)} @@ -342,6 +351,24 @@ 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/gsapAnimationConstants.ts b/packages/studio/src/components/editor/gsapAnimationConstants.ts new file mode 100644 index 000000000..948ddfac8 --- /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: "0–1", + scale: "×", + scaleX: "×", + scaleY: "×", + autoAlpha: "0–1", + 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/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..12a37192d 100644 --- a/packages/studio/src/components/editor/manualEdits.test.ts +++ b/packages/studio/src/components/editor/manualEdits.test.ts @@ -516,3 +516,104 @@ describe("studio manual edits", () => { expect(frames).toHaveLength(0); }); }); + +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(); + 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("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 (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 }); + + const transform = element.style.getPropertyValue("transform"); + if (transform && transform !== "none") { + const m = new window.DOMMatrix(transform); + expect(m.a).toBeCloseTo(0.5); + expect(m.d).toBeCloseTo(0.5); + // Only the studio offset (50) is subtracted, animation contribution (-70) preserved + expect(m.m41).toBe(0); + expect(m.m42).toBe(-70); + } + 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..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 { @@ -262,6 +256,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); } @@ -280,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..b08a8c567 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); } @@ -213,26 +213,15 @@ 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; } -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; @@ -242,9 +231,11 @@ 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()); @@ -493,9 +484,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/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..5f4b325b9 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -53,6 +53,14 @@ export function DomEditProvider({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, + selectedGsapAnimations, + gsapMultipleTimelines, + handleGsapUpdateProperty, + handleGsapUpdateMeta, + handleGsapDeleteAnimation, + handleGsapAddAnimation, + handleGsapAddProperty, + handleGsapRemoveProperty, }, children, }: { @@ -101,6 +109,14 @@ export function DomEditProvider({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, + selectedGsapAnimations, + gsapMultipleTimelines, + handleGsapUpdateProperty, + handleGsapUpdateMeta, + handleGsapDeleteAnimation, + handleGsapAddAnimation, + handleGsapAddProperty, + handleGsapRemoveProperty, }), [ domEditSelection, @@ -143,6 +159,14 @@ export function DomEditProvider({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, + selectedGsapAnimations, + gsapMultipleTimelines, + 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..487da21c1 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,35 @@ export function useDomEditSession({ onClickToSource, }); + // ── GSAP script editing ── + + const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion(); + + const { animations: selectedGsapAnimations, multipleTimelines: gsapMultipleTimelines } = + 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 +259,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, currentTime); + }, + [domEditSelection, addGsapAnimation, currentTime], + ); + + 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 +324,8 @@ export function useDomEditSession({ } if (!doc) return; + reapplyPositionEditsAfterSeek(doc); + const nextElement = findElementForSelection(doc, currentSelection, activeCompPath); if (!nextElement) { applyDomSelection(null, { revealPanel: false }); @@ -345,5 +428,15 @@ export function useDomEditSession({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, + + // GSAP script editing + selectedGsapAnimations, + gsapMultipleTimelines, + 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..8029eff2b --- /dev/null +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -0,0 +1,298 @@ +import { useCallback, useEffect, useRef } 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; +} + +const DEBOUNCE_MS = 150; + +// fallow-ignore-next-line complexity unit-size +export function useGsapScriptCommits({ + projectIdRef, + activeCompPath, + writeProjectFile, + editHistory, + domEditSaveTimestampRef, + 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; + 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; + htmlPreTransform?: (html: string) => string; + }, + ) => { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + 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; + + 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 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, + animationId: string, + property: string, + value: number | string, + ) => { + pendingPropertyEditRef.current = { selection, animationId, property, value }; + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = setTimeout(flushPendingPropertyEdit, DEBOUNCE_MS); + }, + [flushPendingPropertyEdit], + ); + + useEffect(() => { + return () => { + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + flushPendingPropertyEdit(); + }; + }, [flushPendingPropertyEdit]); + + 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", 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 start = currentTime ?? (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`, htmlPreTransform }, + ); + }, + [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..d02a5cf82 --- /dev/null +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + parseGsapScript, + getAnimationsForElement, + type GsapAnimation, +} 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, +): { animations: GsapAnimation[]; multipleTimelines: boolean } { + const [allAnimations, setAllAnimations] = useState([]); + const [multipleTimelines, setMultipleTimelines] = useState(false); + const lastFetchKeyRef = useRef(""); + + useEffect(() => { + const fetchKey = `${projectId}:${sourceFile}:${version}`; + if (fetchKey === lastFetchKeyRef.current) return; + lastFetchKeyRef.current = fetchKey; + + if (!projectId) { + setAllAnimations([]); + setMultipleTimelines(false); + return; + } + + let cancelled = false; + readScriptFromIframe(projectId, sourceFile).then((script) => { + if (cancelled) return; + if (!script) { + setAllAnimations([]); + setMultipleTimelines(false); + return; + } + const parsed = parseGsapScript(script); + setAllAnimations(parsed.animations); + setMultipleTimelines(parsed.multipleTimelines === true); + }); + + return () => { + cancelled = true; + }; + }, [projectId, sourceFile, version]); + + const animations = useMemo( + () => (elementId ? getAnimationsForElement(allAnimations, elementId) : []), + [allAnimations, elementId], + ); + + return { animations, multipleTimelines }; +} + +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; +}