diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index a14d83a41..07615a97e 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -27,6 +27,9 @@ "packages/producer/src/services/__fixtures__/crashOnMessageWorker.mjs", "scripts/*.{ts,mjs,js}", "scripts/*/run.mjs", + // Keyframe UI components — wired dynamically via EaseCurveSection/MotionPanel. + "packages/studio/src/components/editor/KeyframeDiamond.tsx", + "packages/studio/src/components/editor/SpringEaseEditor.tsx", ], "ignorePatterns": [ "docs/**", diff --git a/packages/core/package.json b/packages/core/package.json index ac36fc325..2ec078f71 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -70,6 +70,10 @@ "import": "./src/parsers/gsapConstants.ts", "types": "./src/parsers/gsapConstants.ts" }, + "./spring-ease": { + "import": "./src/parsers/springEase.ts", + "types": "./src/parsers/springEase.ts" + }, "./schemas/registry.json": "./schemas/registry.json", "./schemas/registry-item.json": "./schemas/registry-item.json" }, @@ -129,6 +133,10 @@ "import": "./dist/parsers/gsapConstants.js", "types": "./dist/parsers/gsapConstants.d.ts" }, + "./spring-ease": { + "import": "./dist/parsers/springEase.js", + "types": "./dist/parsers/springEase.d.ts" + }, "./schemas/registry.json": "./schemas/registry.json", "./schemas/registry-item.json": "./schemas/registry-item.json" }, diff --git a/packages/core/src/parsers/gsapConstants.ts b/packages/core/src/parsers/gsapConstants.ts index 340892ebd..3aa2fc69f 100644 --- a/packages/core/src/parsers/gsapConstants.ts +++ b/packages/core/src/parsers/gsapConstants.ts @@ -6,17 +6,34 @@ */ export const SUPPORTED_PROPS = [ - "opacity", - "visibility", + // Transforms "x", "y", "scale", "scaleX", "scaleY", "rotation", + "skewX", + "skewY", + // Visibility + "opacity", + "visibility", "autoAlpha", + // Dimensions "width", "height", + // Colors + "color", + "backgroundColor", + "borderColor", + // Box model + "borderRadius", + // Typography + "fontSize", + "letterSpacing", + // Filter & Clipping + "filter", + "clipPath", ]; export const SUPPORTED_EASES = [ @@ -45,4 +62,9 @@ export const SUPPORTED_EASES = [ "expo.in", "expo.out", "expo.inOut", + "spring-gentle", + "spring-bouncy", + "spring-stiff", + "spring-wobbly", + "spring-heavy", ]; diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index e1cbc8f06..df9e7ff5d 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -11,6 +11,11 @@ import { addAnimationToScript, removeAnimationFromScript, updateAnimationInScript, + addKeyframeToScript, + removeKeyframeFromScript, + updateKeyframeInScript, + convertToKeyframesInScript, + removeAllKeyframesFromScript, } from "./gsapParser.js"; import type { GsapAnimation } from "./gsapParser.js"; import type { Keyframe } from "../core.types"; @@ -1185,3 +1190,315 @@ describe("fromTo in-place mutation", () => { expect(reparsed.animations[0].properties.scale).toBe(2.2); }); }); + +// ── Native GSAP keyframes parsing ────────────────────────────────────────── + +describe("native GSAP keyframes parsing", () => { + it("parses percentage keyframes format", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { + keyframes: { "0%": { x: 0, opacity: 1 }, "50%": { x: 100, ease: "power2.out" }, "100%": { x: 200 } }, + duration: 5 + }, 0); + `; + const result = parseGsapScript(script); + expect(result.animations).toHaveLength(1); + const anim = result.animations[0]; + expect(anim.keyframes).toBeDefined(); + expect(anim.keyframes!.format).toBe("percentage"); + expect(anim.keyframes!.keyframes).toHaveLength(3); + + expect(anim.keyframes!.keyframes[0].percentage).toBe(0); + expect(anim.keyframes!.keyframes[0].properties.x).toBe(0); + expect(anim.keyframes!.keyframes[0].properties.opacity).toBe(1); + + expect(anim.keyframes!.keyframes[1].percentage).toBe(50); + expect(anim.keyframes!.keyframes[1].properties.x).toBe(100); + expect(anim.keyframes!.keyframes[1].ease).toBe("power2.out"); + + expect(anim.keyframes!.keyframes[2].percentage).toBe(100); + expect(anim.keyframes!.keyframes[2].properties.x).toBe(200); + }); + + it("parses object array keyframes format", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { + keyframes: [ + { x: 0, opacity: 1, duration: 0.5 }, + { x: 100, duration: 1, ease: "power2.out" }, + { x: 200, duration: 0.8 } + ] + }, 0); + `; + const result = parseGsapScript(script); + expect(result.animations).toHaveLength(1); + const anim = result.animations[0]; + expect(anim.keyframes).toBeDefined(); + expect(anim.keyframes!.format).toBe("object-array"); + expect(anim.keyframes!.keyframes).toHaveLength(3); + + // Total duration = 0.5 + 1 + 0.8 = 2.3 + expect(anim.keyframes!.keyframes[0].percentage).toBe(0); + expect(anim.keyframes!.keyframes[0].properties.x).toBe(0); + expect(anim.keyframes!.keyframes[0].properties.opacity).toBe(1); + + // Second: cumulative = 0.5, pct = round(0.5/2.3 * 100) = 22 + expect(anim.keyframes!.keyframes[1].percentage).toBe(22); + expect(anim.keyframes!.keyframes[1].properties.x).toBe(100); + expect(anim.keyframes!.keyframes[1].ease).toBe("power2.out"); + + // Third: cumulative = 1.5, pct = round(1.5/2.3 * 100) = 65 + expect(anim.keyframes!.keyframes[2].percentage).toBe(65); + expect(anim.keyframes!.keyframes[2].properties.x).toBe(200); + }); + + it("parses simple array keyframes format", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { + keyframes: { x: [0, 100, 200, 0], opacity: [0, 1, 1, 0], easeEach: "power2.inOut" }, + duration: 5 + }, 0); + `; + const result = parseGsapScript(script); + expect(result.animations).toHaveLength(1); + const anim = result.animations[0]; + expect(anim.keyframes).toBeDefined(); + expect(anim.keyframes!.format).toBe("simple-array"); + expect(anim.keyframes!.easeEach).toBe("power2.inOut"); + expect(anim.keyframes!.keyframes).toHaveLength(4); + + // Evenly spaced: 0%, 33%, 67%, 100% + expect(anim.keyframes!.keyframes[0].percentage).toBe(0); + expect(anim.keyframes!.keyframes[0].properties.x).toBe(0); + expect(anim.keyframes!.keyframes[0].properties.opacity).toBe(0); + + expect(anim.keyframes!.keyframes[1].percentage).toBe(33); + expect(anim.keyframes!.keyframes[1].properties.x).toBe(100); + expect(anim.keyframes!.keyframes[1].properties.opacity).toBe(1); + + expect(anim.keyframes!.keyframes[2].percentage).toBe(67); + expect(anim.keyframes!.keyframes[2].properties.x).toBe(200); + expect(anim.keyframes!.keyframes[2].properties.opacity).toBe(1); + + expect(anim.keyframes!.keyframes[3].percentage).toBe(100); + expect(anim.keyframes!.keyframes[3].properties.x).toBe(0); + expect(anim.keyframes!.keyframes[3].properties.opacity).toBe(0); + }); + + it("parses three-level easing", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { + keyframes: { "0%": { x: 0 }, "50%": { x: 100, ease: "back.out(1.7)" }, "100%": { x: 200 } }, + ease: "none", + easeEach: "power2.out", + duration: 5 + }, 0); + `; + const result = parseGsapScript(script); + const anim = result.animations[0]; + + // Tween-level ease + expect(anim.ease).toBe("none"); + // easeEach on keyframes data (set from tween-level) + expect(anim.keyframes!.easeEach).toBe("power2.out"); + // Per-keyframe ease + expect(anim.keyframes!.keyframes[1].ease).toBe("back.out(1.7)"); + }); + + it("flat tween without keyframes still works", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { x: 100, duration: 1 }, 0); + `; + const result = parseGsapScript(script); + expect(result.animations).toHaveLength(1); + expect(result.animations[0].keyframes).toBeUndefined(); + expect(result.animations[0].properties.x).toBe(100); + }); + + it("keyframes tween has empty top-level properties", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { + keyframes: { "0%": { x: 0 }, "100%": { x: 200 } }, + duration: 5 + }, 0); + `; + const result = parseGsapScript(script); + const anim = result.animations[0]; + expect(anim.keyframes).toBeDefined(); + expect(Object.keys(anim.properties)).toHaveLength(0); + }); +}); + +// ── Keyframe mutation functions ─────────────────────────────────────────── + +describe("keyframe mutations", () => { + const KF_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { + keyframes: { "0%": { x: 0, opacity: 0 }, "100%": { x: 200, opacity: 1 } }, + duration: 2 + }, 0); + `; + + const KF_SCRIPT_3 = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { + keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200 } }, + duration: 2 + }, 0); + `; + + function getAnimId(script: string): string { + return parseGsapScript(script).animations[0].id; + } + + // ── addKeyframeToScript ───────────────────────────────────────────────── + + it("addKeyframeToScript — inserts at sorted position", () => { + const id = getAnimId(KF_SCRIPT); + const updated = addKeyframeToScript(KF_SCRIPT, id, 50, { x: 100 }); + const reparsed = parseGsapScript(updated); + const kfs = reparsed.animations[0].keyframes!.keyframes; + expect(kfs).toHaveLength(3); + expect(kfs.map((k) => k.percentage)).toEqual([0, 50, 100]); + expect(kfs[1].properties.x).toBe(100); + }); + + it("addKeyframeToScript — updates existing percentage", () => { + const id = getAnimId(KF_SCRIPT_3); + const updated = addKeyframeToScript(KF_SCRIPT_3, id, 50, { x: 999 }); + const reparsed = parseGsapScript(updated); + const kfs = reparsed.animations[0].keyframes!.keyframes; + expect(kfs).toHaveLength(3); + expect(kfs[1].percentage).toBe(50); + expect(kfs[1].properties.x).toBe(999); + }); + + // ── removeKeyframeFromScript ──────────────────────────────────────────── + + it("removeKeyframeFromScript — removes one keyframe", () => { + const id = getAnimId(KF_SCRIPT_3); + const updated = removeKeyframeFromScript(KF_SCRIPT_3, id, 50); + const reparsed = parseGsapScript(updated); + const kfs = reparsed.animations[0].keyframes!.keyframes; + expect(kfs).toHaveLength(2); + expect(kfs.map((k) => k.percentage)).toEqual([0, 100]); + }); + + it("removeKeyframeFromScript — collapses to flat when <2 remain", () => { + const id = getAnimId(KF_SCRIPT); + const updated = removeKeyframeFromScript(KF_SCRIPT, id, 100); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations[0]; + expect(anim.keyframes).toBeUndefined(); + expect(anim.properties.x).toBe(0); + expect(anim.properties.opacity).toBe(0); + }); + + // ── updateKeyframeInScript ────────────────────────────────────────────── + + it("updateKeyframeInScript — replaces properties", () => { + const id = getAnimId(KF_SCRIPT); + const updated = updateKeyframeInScript(KF_SCRIPT, id, 100, { x: 300, y: 50 }); + const reparsed = parseGsapScript(updated); + const kf100 = reparsed.animations[0].keyframes!.keyframes.find((k) => k.percentage === 100)!; + expect(kf100.properties.x).toBe(300); + expect(kf100.properties.y).toBe(50); + }); + + // ── convertToKeyframesInScript ────────────────────────────────────────── + + it("convertToKeyframesInScript — converts flat to() tween", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#title", { x: 100, opacity: 1, duration: 0.8, ease: "power3.out" }, 0.3); + `; + const id = getAnimId(script); + const updated = convertToKeyframesInScript(script, id, { x: 0, opacity: 0 }); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations[0]; + + expect(anim.keyframes).toBeDefined(); + const kfs = anim.keyframes!.keyframes; + expect(kfs).toHaveLength(2); + + expect(kfs[0].percentage).toBe(0); + expect(kfs[0].properties.x).toBe(0); + expect(kfs[0].properties.opacity).toBe(0); + + expect(kfs[1].percentage).toBe(100); + expect(kfs[1].properties.x).toBe(100); + expect(kfs[1].properties.opacity).toBe(1); + + expect(anim.keyframes!.easeEach).toBe("power3.out"); + expect(anim.ease).toBe("none"); + expect(anim.duration).toBe(0.8); + expect(anim.position).toBe(0.3); + }); + + it("convertToKeyframesInScript — converts from() to to() + keyframes", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.from("#title", { x: -200, opacity: 0, duration: 0.8 }, 0.3); + `; + const id = getAnimId(script); + const updated = convertToKeyframesInScript(script, id, { x: 0, opacity: 1 }); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations[0]; + + expect(anim.method).toBe("to"); + expect(anim.keyframes).toBeDefined(); + const kfs = anim.keyframes!.keyframes; + expect(kfs[0].properties.x).toBe(-200); + expect(kfs[0].properties.opacity).toBe(0); + expect(kfs[1].properties.x).toBe(0); + expect(kfs[1].properties.opacity).toBe(1); + }); + + it("convertToKeyframesInScript — converts fromTo() to to() + keyframes", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.fromTo("#title", { x: -100 }, { x: 100, duration: 1 }, 0); + `; + const id = getAnimId(script); + const updated = convertToKeyframesInScript(script, id); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations[0]; + + expect(anim.method).toBe("to"); + expect(anim.keyframes).toBeDefined(); + const kfs = anim.keyframes!.keyframes; + expect(kfs[0].properties.x).toBe(-100); + expect(kfs[1].properties.x).toBe(100); + }); + + it("convertToKeyframesInScript — skips if already has keyframes", () => { + const updated = convertToKeyframesInScript(KF_SCRIPT, getAnimId(KF_SCRIPT)); + expect(updated).toBe(KF_SCRIPT); + }); + + // ── removeAllKeyframesFromScript ──────────────────────────────────────── + + it("removeAllKeyframesFromScript — collapses to last keyframe's props", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { + keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200, opacity: 1 } }, + duration: 2 + }, 0); + `; + const id = getAnimId(script); + const updated = removeAllKeyframesFromScript(script, id); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations[0]; + expect(anim.keyframes).toBeUndefined(); + expect(anim.properties.x).toBe(200); + expect(anim.properties.opacity).toBe(1); + }); +}); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index 4b8df19ad..b4c484bfb 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -10,9 +10,22 @@ */ import * as recast from "recast"; import { parse as babelParse } from "@babel/parser"; -import { type GsapAnimation, type GsapMethod, type ParsedGsap } from "./gsapSerialize"; +import { + type GsapAnimation, + type GsapKeyframesData, + type GsapMethod, + type GsapPercentageKeyframe, + type ParsedGsap, +} from "./gsapSerialize"; -export type { GsapAnimation, GsapMethod, ParsedGsap } from "./gsapSerialize"; +export type { + GsapAnimation, + GsapMethod, + ParsedGsap, + GsapKeyframesData, + GsapPercentageKeyframe, + GsapKeyframeFormat, +} from "./gsapSerialize"; export { serializeGsapAnimations, getAnimationsForElementId, @@ -22,6 +35,8 @@ export { SUPPORTED_PROPS, SUPPORTED_EASES, } from "./gsapSerialize"; +export { generateSpringEaseData, SPRING_PRESETS } from "./springEase"; +export type { SpringPreset } from "./springEase"; const GSAP_METHODS = new Set(["set", "to", "from", "fromTo"]); @@ -441,7 +456,7 @@ function findAllTweenCalls( const BUILTIN_VAR_KEYS = new Set(["duration", "ease", "delay"]); /** Keys that are never preserved (callbacks / advanced patterns). */ -const DROPPED_VAR_KEYS = new Set(["keyframes", "onComplete", "onStart", "onUpdate", "onRepeat"]); +const DROPPED_VAR_KEYS = new Set(["onComplete", "onStart", "onUpdate", "onRepeat"]); /** Keys that belong in `extras` — non-editable GSAP config that must survive round-trips. */ const EXTRAS_KEYS = new Set([ @@ -459,17 +474,221 @@ const EXTRAS_KEYS = new Set([ * Returns the printed source of the value node, suitable for verbatim re-emission. */ function extractRawPropertySource(varsArgNode: any, key: string): string | undefined { + const node = findPropertyNode(varsArgNode, key); + return node ? recast.print(node).code : undefined; +} + +/** Find the raw AST node for a named property inside an ObjectExpression. */ +function findPropertyNode(varsArgNode: any, key: string): any | undefined { if (varsArgNode?.type !== "ObjectExpression") return undefined; for (const prop of varsArgNode.properties ?? []) { + if (!isObjectProperty(prop)) continue; + if (propKeyName(prop) === key) return prop.value; + } + return undefined; +} + +// ── Native GSAP Keyframes Parsing ────────────────────────────────────────── + +const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/; + +/** Extract a string-valued ease or easeEach from an AST property node. */ +function tryResolveStringProp(propValue: any, scope: ScopeBindings): string | undefined { + const val = resolveNode(propValue, scope); + return typeof val === "string" ? val : undefined; +} + +/** + * Parse a `keyframes` property value from a tween vars AST node into a + * normalized `GsapKeyframesData` structure. Handles all three GSAP formats: + * percentage objects, object arrays, and simple (property-array) objects. + */ +// fallow-ignore-next-line complexity +function parseKeyframesNode(node: any, scope: ScopeBindings): GsapKeyframesData | undefined { + if (!node) return undefined; + + // ── Object array format: keyframes: [ { x: 0, duration: 0.5 }, ... ] ── + if (node.type === "ArrayExpression") { + return parseObjectArrayKeyframes(node, scope); + } + + if (node.type !== "ObjectExpression") return undefined; + + // Distinguish percentage vs simple-array by inspecting property keys/values. + const props = node.properties ?? []; + let hasPercentageKey = false; + let hasArrayValue = false; + + for (const prop of props) { if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue; - const propKey = prop.key?.name ?? prop.key?.value; - if (propKey === key) { - return recast.print(prop.value).code; + const key = prop.key?.value ?? prop.key?.name; + if (typeof key === "string" && PERCENTAGE_KEY_RE.test(key)) { + hasPercentageKey = true; + break; + } + if (prop.value?.type === "ArrayExpression") { + hasArrayValue = true; } } + + if (hasPercentageKey) return parsePercentageKeyframes(node, scope); + if (hasArrayValue) return parseSimpleArrayKeyframes(node, scope); + return undefined; } +// fallow-ignore-next-line complexity +function parsePercentageKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData { + const keyframes: GsapPercentageKeyframe[] = []; + let ease: string | undefined; + let easeEach: string | undefined; + + for (const prop of node.properties ?? []) { + if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue; + const key = prop.key?.value ?? prop.key?.name; + if (typeof key !== "string") continue; + + const pctMatch = PERCENTAGE_KEY_RE.exec(key); + if (pctMatch) { + const percentage = Number.parseFloat(pctMatch[1]!); + const record = objectExpressionToRecord(prop.value, scope); + const properties: Record = {}; + let kfEase: string | undefined; + for (const [k, v] of Object.entries(record)) { + if (k === "ease" && typeof v === "string") { + kfEase = v; + } else if (typeof v === "number" || typeof v === "string") { + properties[k] = v; + } + } + keyframes.push({ percentage, properties, ...(kfEase ? { ease: kfEase } : {}) }); + } else if (key === "ease") { + ease = tryResolveStringProp(prop.value, scope) ?? ease; + } else if (key === "easeEach") { + easeEach = tryResolveStringProp(prop.value, scope) ?? easeEach; + } + } + + keyframes.sort((a, b) => a.percentage - b.percentage); + + return { + format: "percentage", + keyframes, + ...(ease ? { ease } : {}), + ...(easeEach ? { easeEach } : {}), + }; +} + +// fallow-ignore-next-line complexity +function parseObjectArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData { + const elements = node.elements ?? []; + const raw: Array<{ + properties: Record; + duration?: number; + ease?: string; + }> = []; + + for (const el of elements) { + if (!el || (el.type !== "ObjectExpression" && el.type !== "ObjectProperty")) { + // Skip non-object elements + if (el?.type !== "ObjectExpression") continue; + } + const record = objectExpressionToRecord(el, scope); + const properties: Record = {}; + let duration: number | undefined; + let ease: string | undefined; + for (const [k, v] of Object.entries(record)) { + if (k === "duration" && typeof v === "number") { + duration = v; + } else if (k === "ease" && typeof v === "string") { + ease = v; + } else if (typeof v === "number" || typeof v === "string") { + properties[k] = v; + } + } + raw.push({ properties, duration, ease }); + } + + // Convert durations to percentage positions. If durations are present, use + // cumulative ratios; otherwise distribute evenly. + const totalDuration = raw.reduce((sum, r) => sum + (r.duration ?? 0), 0); + const keyframes: GsapPercentageKeyframe[] = []; + + if (totalDuration > 0) { + let cumulative = 0; + for (const entry of raw) { + const percentage = Math.round((cumulative / totalDuration) * 100); + keyframes.push({ + percentage, + properties: entry.properties, + ...(entry.ease ? { ease: entry.ease } : {}), + }); + cumulative += entry.duration ?? 0; + } + } else { + for (let i = 0; i < raw.length; i++) { + const entry = raw[i]!; + const percentage = raw.length > 1 ? Math.round((i / (raw.length - 1)) * 100) : 0; + keyframes.push({ + percentage, + properties: entry.properties, + ...(entry.ease ? { ease: entry.ease } : {}), + }); + } + } + + return { format: "object-array", keyframes }; +} + +// fallow-ignore-next-line complexity +function parseSimpleArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData { + const arrayProps: Map = new Map(); + let ease: string | undefined; + let easeEach: string | undefined; + + for (const prop of node.properties ?? []) { + if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue; + const key = prop.key?.name ?? prop.key?.value; + if (typeof key !== "string") continue; + + if (prop.value?.type === "ArrayExpression") { + const values: (number | string)[] = []; + for (const el of prop.value.elements ?? []) { + const val = resolveNode(el, scope); + if (typeof val === "number" || typeof val === "string") { + values.push(val); + } + } + if (values.length > 0) arrayProps.set(key, values); + } else if (key === "ease") { + ease = tryResolveStringProp(prop.value, scope) ?? ease; + } else if (key === "easeEach") { + easeEach = tryResolveStringProp(prop.value, scope) ?? easeEach; + } + } + + // Zip arrays into percentage keyframes (evenly spaced). + const maxLen = Math.max(...[...arrayProps.values()].map((a) => a.length), 0); + const keyframes: GsapPercentageKeyframe[] = []; + + for (let i = 0; i < maxLen; i++) { + const percentage = maxLen > 1 ? Math.round((i / (maxLen - 1)) * 100) : 0; + const properties: Record = {}; + for (const [key, values] of arrayProps) { + if (i < values.length) properties[key] = values[i]!; + } + keyframes.push({ percentage, properties }); + } + + return { + format: "simple-array", + keyframes, + ...(ease ? { ease } : {}), + ...(easeEach ? { easeEach } : {}), + }; +} + +// fallow-ignore-next-line complexity function tweenCallToAnimation( call: TweenCallInfo, scope: ScopeBindings, @@ -477,11 +696,23 @@ function tweenCallToAnimation( const vars = objectExpressionToRecord(call.varsArg, scope); const properties: Record = {}; const extras: Record = {}; + let keyframesData: GsapKeyframesData | undefined; for (const [key, val] of Object.entries(vars)) { if (BUILTIN_VAR_KEYS.has(key)) continue; if (DROPPED_VAR_KEYS.has(key)) continue; + if (key === "keyframes") { + const kfNode = findPropertyNode(call.varsArg, "keyframes"); + keyframesData = parseKeyframesNode(kfNode, scope); + continue; + } + + if (key === "easeEach") { + // easeEach is only meaningful alongside keyframes — handled below. + continue; + } + if (EXTRAS_KEYS.has(key)) { // For extras, prefer the raw AST source so complex objects like // `stagger: { each: 0.15, from: "start" }` survive verbatim. @@ -499,6 +730,11 @@ function tweenCallToAnimation( } } + // Apply tween-level easeEach to keyframes data. + if (keyframesData && typeof vars.easeEach === "string") { + keyframesData.easeEach = vars.easeEach as string; + } + let fromProperties: Record | undefined; if (call.method === "fromTo" && call.fromArg) { fromProperties = {}; @@ -526,6 +762,7 @@ function tweenCallToAnimation( ease, }; if (Object.keys(extras).length > 0) anim.extras = extras; + if (keyframesData) anim.keyframes = keyframesData; return anim; } @@ -859,3 +1096,307 @@ export function removeAnimationFromScript(script: string, animationId: string): } return recast.print(parsed.ast).code; } + +// ── Keyframe Mutation Functions ──────────────────────────────────────────── + +/** Remove a named property from an ObjectExpression's properties array. */ +function removeVarsKey(varsArg: any, key: string): void { + if (varsArg?.type !== "ObjectExpression") return; + varsArg.properties = varsArg.properties.filter( + (p: any) => !(isObjectProperty(p) && propKeyName(p) === key), + ); +} + +/** Extract the numeric percentage from a key like "50%". Returns NaN for non-percentage keys. */ +function percentageFromKey(key: string): number { + const m = PERCENTAGE_KEY_RE.exec(key); + return m ? Number.parseFloat(m[1]!) : Number.NaN; +} + +/** Build a keyframe value AST node from properties and optional ease. */ +function buildKeyframeValueNode(properties: Record, ease?: string): any { + const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + if (ease) entries.push(`ease: ${JSON.stringify(ease)}`); + return parseExpr(`{ ${entries.join(", ")} }`); +} + +/** Parse + locate a target animation, returning null on failure. */ +function locateAnimation( + script: string, + animationId: string, +): { parsed: ParsedGsapAst; target: ParsedGsapAst["located"][number] } | null { + let parsed: ParsedGsapAst; + try { + parsed = parseGsapAst(script); + } catch { + return null; + } + const target = parsed.located.find((l) => l.id === animationId); + return target ? { parsed, target } : null; +} + +/** Find the keyframes ObjectExpression node on a tween's varsArg, or null. */ +function findKeyframesObjectNode(varsArg: any): any | null { + const node = findPropertyNode(varsArg, "keyframes"); + return node?.type === "ObjectExpression" ? node : null; +} + +/** Filter percentage-keyed properties from a keyframes ObjectExpression. */ +function filterPercentageProps(kfNode: any): any[] { + return kfNode.properties.filter((p: any) => { + if (!isObjectProperty(p)) return false; + const key = propKeyName(p); + return typeof key === "string" && PERCENTAGE_KEY_RE.test(key); + }); +} + +/** + * Collapse a keyframes node to flat tween: apply `record` entries as vars keys, + * then remove `keyframes` and `easeEach` from varsArg. Skips the `ease` key + * from the record (per-keyframe ease, not a tween ease). + */ +function collapseKeyframesToFlat(varsArg: any, record: Record): void { + for (const [k, v] of Object.entries(record)) { + if (k === "ease") continue; + if (typeof v === "number" || typeof v === "string") setVarsKey(varsArg, k, v); + } + removeVarsKey(varsArg, "keyframes"); + removeVarsKey(varsArg, "easeEach"); +} + +/** + * Insert a keyframe at the given percentage in an existing percentage-keyframes + * object. If the percentage already exists, its value is replaced. + */ +export function addKeyframeToScript( + script: string, + animationId: string, + percentage: number, + properties: Record, + ease?: string, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); + if (!kfNode) return script; + + const pctKey = `${percentage}%`; + const newValueNode = buildKeyframeValueNode(properties, ease); + + // Replace if this percentage already exists + const existingIdx = kfNode.properties.findIndex( + (p: any) => isObjectProperty(p) && propKeyName(p) === pctKey, + ); + if (existingIdx !== -1) { + kfNode.properties[existingIdx].value = newValueNode; + return recast.print(loc.parsed.ast).code; + } + + // Build the new property node with a quoted percentage key + const newProp = parseExpr(`{ ${JSON.stringify(pctKey)}: {} }`).properties[0]; + newProp.value = newValueNode; + + // Insert in sorted order by percentage + let insertIdx = kfNode.properties.length; + for (let i = 0; i < kfNode.properties.length; i++) { + const key = isObjectProperty(kfNode.properties[i]) + ? propKeyName(kfNode.properties[i]) + : undefined; + if (typeof key === "string" && percentageFromKey(key) > percentage) { + insertIdx = i; + break; + } + } + kfNode.properties.splice(insertIdx, 0, newProp); + return recast.print(loc.parsed.ast).code; +} + +/** + * Remove a keyframe at the given percentage. If fewer than 2 keyframes remain + * after removal, collapse the keyframes object to a flat tween using the + * remaining keyframe's properties. + */ +export function removeKeyframeFromScript( + script: string, + animationId: string, + percentage: number, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); + if (!kfNode) return script; + + const pctKey = `${percentage}%`; + const removeIdx = kfNode.properties.findIndex( + (p: any) => isObjectProperty(p) && propKeyName(p) === pctKey, + ); + if (removeIdx === -1) return script; + + kfNode.properties.splice(removeIdx, 1); + + const remainingKfs = filterPercentageProps(kfNode); + if (remainingKfs.length < 2) { + const record = + remainingKfs.length === 1 + ? objectExpressionToRecord(remainingKfs[0].value, loc.parsed.scope) + : {}; + collapseKeyframesToFlat(loc.target.call.varsArg, record); + } + + return recast.print(loc.parsed.ast).code; +} + +/** + * Replace the properties (and optionally ease) at an existing keyframe percentage. + */ +export function updateKeyframeInScript( + script: string, + animationId: string, + percentage: number, + properties: Record, + ease?: string, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); + if (!kfNode) return script; + + const pctKey = `${percentage}%`; + const existing = kfNode.properties.find( + (p: any) => isObjectProperty(p) && propKeyName(p) === pctKey, + ); + if (!existing) return script; + + existing.value = buildKeyframeValueNode(properties, ease); + return recast.print(loc.parsed.ast).code; +} + +/** Resolve from/to property maps for a tween being converted to keyframes. */ +const CSS_IDENTITY: Record = { + opacity: 1, + autoAlpha: 1, + scale: 1, + scaleX: 1, + scaleY: 1, +}; + +function cssIdentityValue(prop: string): number { + return CSS_IDENTITY[prop] ?? 0; +} + +function resolveConversionProps( + anim: GsapAnimation, + resolvedFromValues?: Record, +): { fromProps: Record; toProps: Record } { + if (anim.method === "to") { + if (resolvedFromValues) { + return { fromProps: resolvedFromValues, toProps: { ...anim.properties } }; + } + const identityFrom: Record = {}; + for (const [key, val] of Object.entries(anim.properties)) { + if (val != null) identityFrom[key] = typeof val === "number" ? cssIdentityValue(key) : val; + } + return { fromProps: identityFrom, toProps: { ...anim.properties } }; + } + if (anim.method === "from") { + if (resolvedFromValues) { + return { fromProps: { ...anim.properties }, toProps: resolvedFromValues }; + } + const identityTo: Record = {}; + for (const [key, val] of Object.entries(anim.properties)) { + if (val != null) identityTo[key] = typeof val === "number" ? cssIdentityValue(key) : val; + } + return { fromProps: { ...anim.properties }, toProps: identityTo }; + } + // fromTo + return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps: { ...anim.properties } }; +} + +/** Strip editable properties and ease/keyframes keys from a varsArg. */ +function stripEditableAndEase(varsArg: any): void { + if (varsArg?.type !== "ObjectExpression") return; + varsArg.properties = varsArg.properties.filter((p: any) => { + if (!isObjectProperty(p)) return true; + const key = propKeyName(p); + if (typeof key !== "string") return true; + if (key === "ease" || key === "keyframes") return false; + return !isEditablePropertyKey(key); + }); +} + +/** Build and prepend a keyframes property node onto varsArg. */ +function insertKeyframesProp( + varsArg: any, + fromProps: Record, + toProps: Record, +): void { + const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} } }`; + const kfProp = parseExpr(`{ keyframes: {} }`).properties[0]; + kfProp.value = parseExpr(kfCode); + if (varsArg?.type === "ObjectExpression") varsArg.properties.unshift(kfProp); +} + +/** + * Convert a flat tween (to/from/fromTo) to percentage-keyframes format. + * `resolvedFromValues` supplies the "from" state for `to()` tweens or + * the "to" state for `from()` tweens (the values the DOM would resolve to). + */ +export function convertToKeyframesInScript( + script: string, + animationId: string, + resolvedFromValues?: Record, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + + const anim = loc.target.animation; + if (anim.keyframes || anim.method === "set") return script; + + const { fromProps, toProps } = resolveConversionProps(anim, resolvedFromValues); + const varsArg = loc.target.call.varsArg; + const originalEase = anim.ease; + + stripEditableAndEase(varsArg); + insertKeyframesProp(varsArg, fromProps, toProps); + + if (originalEase) { + setVarsKey(varsArg, "easeEach", originalEase); + setVarsKey(varsArg, "ease", "none"); + } + + // For from() or fromTo(), convert to to() + if (anim.method === "from" || anim.method === "fromTo") { + loc.target.call.node.callee.property.name = "to"; + if (anim.method === "fromTo") loc.target.call.node.arguments.splice(1, 1); + } + + return recast.print(loc.parsed.ast).code; +} + +/** + * Remove all keyframes from a tween, collapsing to a flat tween with the + * last keyframe's properties. + */ +export function removeAllKeyframesFromScript(script: string, animationId: string): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); + if (!kfNode) return script; + + // Collect all percentage keyframe entries, sorted + const kfEntries = filterPercentageProps(kfNode) + .map((p: any) => ({ pct: percentageFromKey(propKeyName(p)!), prop: p })) + .filter((e) => !Number.isNaN(e.pct)) + .sort((a, b) => a.pct - b.pct); + if (kfEntries.length === 0) return script; + + const lastRecord = objectExpressionToRecord( + kfEntries[kfEntries.length - 1]!.prop.value, + loc.parsed.scope, + ); + collapseKeyframesToFlat(loc.target.call.varsArg, lastRecord); + + return recast.print(loc.parsed.ast).code; +} diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index e9974038e..0e482be5d 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -21,6 +21,23 @@ export interface GsapAnimation { ease?: string; /** Non-editable GSAP config (stagger, yoyo, repeat, etc.) preserved for round-trips. */ extras?: Record; + /** Native GSAP keyframes data — present when the tween uses keyframes: { ... }. */ + keyframes?: GsapKeyframesData; +} + +export interface GsapPercentageKeyframe { + percentage: number; + properties: Record; + ease?: string; +} + +export type GsapKeyframeFormat = "percentage" | "object-array" | "simple-array"; + +export interface GsapKeyframesData { + format: GsapKeyframeFormat; + keyframes: GsapPercentageKeyframe[]; + ease?: string; + easeEach?: string; } export interface ParsedGsap { diff --git a/packages/core/src/parsers/springEase.test.ts b/packages/core/src/parsers/springEase.test.ts new file mode 100644 index 000000000..2057448f1 --- /dev/null +++ b/packages/core/src/parsers/springEase.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { generateSpringEaseData, SPRING_PRESETS } from "./springEase"; + +/** Parse an SVG-path CustomEase string into {x, y} pairs. */ +function parsePairs(data: string): { x: number; y: number }[] { + // Strip "M0,0 L" prefix, then split on whitespace between coordinate pairs + const body = data.replace(/^M0,0\s+L/, ""); + const tokens = body.split(/\s+/); + return [ + { x: 0, y: 0 }, // from M0,0 + ...tokens.map((tok) => { + const [xStr, yStr] = tok.split(","); + return { x: Number(xStr), y: Number(yStr) }; + }), + ]; +} + +describe("generateSpringEaseData", () => { + it("generates a valid SVG-path CustomEase data string", () => { + const data = generateSpringEaseData(1, 180, 12); + expect(typeof data).toBe("string"); + // Must start with M0,0 (SVG moveTo) + expect(data.startsWith("M0,0")).toBe(true); + // Must contain L (lineTo) segments + expect(data).toContain(" L"); + const pairs = parsePairs(data); + expect(pairs.length).toBeGreaterThan(10); + // First point at origin, last at (1,1) + expect(pairs[0]).toEqual({ x: 0, y: 0 }); + expect(pairs[pairs.length - 1]).toEqual({ x: 1, y: 1 }); + }); + + it("underdamped spring produces overshoot", () => { + const data = generateSpringEaseData(1, 180, 8); // low damping = bouncy + const pairs = parsePairs(data); + const hasOvershoot = pairs.some((p) => p.y > 1.01); + expect(hasOvershoot).toBe(true); + }); + + it("critically damped spring has no overshoot", () => { + const mass = 1; + const stiffness = 100; + const criticalDamping = 2 * Math.sqrt(stiffness * mass); // zeta = 1 + const data = generateSpringEaseData(mass, stiffness, criticalDamping); + const pairs = parsePairs(data); + const maxY = Math.max(...pairs.map((p) => p.y)); + expect(maxY).toBeLessThanOrEqual(1.005); + }); + + it("overdamped spring has no overshoot and monotonically increases", () => { + // zeta > 1 — heavy damping + const data = generateSpringEaseData(1, 100, 30); + const pairs = parsePairs(data); + const maxY = Math.max(...pairs.map((p) => p.y)); + expect(maxY).toBeLessThanOrEqual(1.005); + // Monotonically non-decreasing (within floating point tolerance) + for (let i = 1; i < pairs.length; i++) { + expect(pairs[i].y).toBeGreaterThanOrEqual(pairs[i - 1].y - 0.001); + } + }); + + it("all presets generate valid data", () => { + for (const preset of SPRING_PRESETS) { + const data = generateSpringEaseData(preset.mass, preset.stiffness, preset.damping); + expect(data.length).toBeGreaterThan(0); + expect(data.startsWith("M0,0")).toBe(true); + const pairs = parsePairs(data); + expect(pairs.length).toBeGreaterThan(50); + } + }); + + it("output x values span [0,1] monotonically", () => { + const data = generateSpringEaseData(1, 180, 12); + const pairs = parsePairs(data); + expect(pairs[0].x).toBe(0); + expect(pairs[pairs.length - 1].x).toBe(1); + for (let i = 1; i < pairs.length; i++) { + expect(pairs[i].x).toBeGreaterThan(pairs[i - 1].x - 0.0001); + expect(pairs[i].x).toBeLessThanOrEqual(1); + } + }); + + it("respects custom step count", () => { + const data = generateSpringEaseData(1, 100, 15, 60); + const pairs = parsePairs(data); + // 60 steps + the M0,0 origin = 61 points + expect(pairs.length).toBe(61); + }); +}); diff --git a/packages/core/src/parsers/springEase.ts b/packages/core/src/parsers/springEase.ts new file mode 100644 index 000000000..3d4fccbb2 --- /dev/null +++ b/packages/core/src/parsers/springEase.ts @@ -0,0 +1,88 @@ +/** + * Damped harmonic oscillator solver for GSAP CustomEase spring curves. + * + * Generates an SVG path data string compatible with `CustomEase.create(id, data)`. + * The solver supports underdamped (bouncy), critically damped, and overdamped + * spring configurations. Output is normalized to x ∈ [0,1] with y starting at 0 + * and settling to 1. + */ + +export interface SpringPreset { + name: string; + label: string; + mass: number; + stiffness: number; + damping: number; +} + +export const SPRING_PRESETS: SpringPreset[] = [ + { name: "spring-gentle", label: "Gentle", mass: 1, stiffness: 100, damping: 15 }, + { name: "spring-bouncy", label: "Bouncy", mass: 1, stiffness: 180, damping: 12 }, + { name: "spring-stiff", label: "Stiff", mass: 1, stiffness: 300, damping: 20 }, + { name: "spring-wobbly", label: "Wobbly", mass: 1, stiffness: 120, damping: 8 }, + { name: "spring-heavy", label: "Heavy", mass: 3, stiffness: 200, damping: 20 }, +]; + +/** + * Solve a damped harmonic oscillator and return a GSAP CustomEase data string. + * + * The output is an SVG path (`M0,0 L... L...`) that CustomEase.create() accepts. + * The curve is normalized so x spans [0,1] and the spring settles at y = 1. + * + * @param mass - Spring mass (> 0) + * @param stiffness - Spring stiffness constant (> 0) + * @param damping - Damping coefficient (> 0) + * @param steps - Number of sample points (default 120) + */ +export function generateSpringEaseData( + mass: number, + stiffness: number, + damping: number, + steps = 120, +): string { + const w0 = Math.sqrt(stiffness / mass); + const zeta = damping / (2 * Math.sqrt(stiffness * mass)); + + // Determine simulation duration: time until oscillation settles within threshold of 1.0. + // Underdamped: ~5 time constants. Critically/overdamped: characteristic decay time. + let settleDuration: number; + if (zeta < 1) { + settleDuration = Math.min(5 / (zeta * w0), 10); + } else { + const decayRate = zeta * w0 - w0 * Math.sqrt(zeta * zeta - 1); + settleDuration = Math.min(4 / Math.max(decayRate, 0.01), 10); + } + const simDuration = Math.max(settleDuration, 1); + + const segments: string[] = ["M0,0"]; + + for (let i = 1; i <= steps; i++) { + const t = i / steps; + const simT = t * simDuration; + let value: number; + + if (zeta < 1) { + // Underdamped — oscillates before settling + const wd = w0 * Math.sqrt(1 - zeta * zeta); + value = + 1 - + Math.exp(-zeta * w0 * simT) * + (Math.cos(wd * simT) + ((zeta * w0) / wd) * Math.sin(wd * simT)); + } else if (zeta === 1) { + // Critically damped — fastest approach without oscillation + value = 1 - (1 + w0 * simT) * Math.exp(-w0 * simT); + } else { + // Overdamped — slow exponential approach + const s1 = -w0 * (zeta - Math.sqrt(zeta * zeta - 1)); + const s2 = -w0 * (zeta + Math.sqrt(zeta * zeta - 1)); + value = 1 + (s1 * Math.exp(s2 * simT) - s2 * Math.exp(s1 * simT)) / (s2 - s1); + } + + segments.push(`${t.toFixed(4)},${value.toFixed(4)}`); + } + + // Force exact endpoint + segments[segments.length - 1] = "1,1"; + + return `${segments[0]} L${segments.slice(1).join(" ")}`; +} diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index b21160f8f..584e7304a 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -14,6 +14,9 @@ export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAd timeline.pause(); const safeTime = Math.max(0, Number(ctx.time) || 0); if (typeof timeline.totalTime === "function") { + // GSAP 3.x skips rendering when the new totalTime equals _tTime. + // Nudge first to force a dirty state, then seek to the exact time. + timeline.totalTime(safeTime + 0.001, true); timeline.totalTime(safeTime, false); } else { timeline.seek(safeTime, false); diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index c1fa09bad..e6dd5ea77 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -708,7 +708,7 @@ describe("initSandboxRuntimeModular", () => { window.__timelines = { root: tl }; initSandboxRuntimeModular(); - expect(seekTimes.length).toBeGreaterThan(0); - expect(seekTimes[0]).toBe(0); + expect(seekTimes.length).toBeGreaterThanOrEqual(2); + expect(seekTimes[seekTimes.length - 1]).toBe(0); }); }); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 724d3d26b..bf4396dec 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -950,8 +950,42 @@ export function initSandboxRuntimeModular(): void { state.capturedTimeline.pause(); const seekTime = Math.max(0, state.currentTime || 0); if (typeof state.capturedTimeline.totalTime === "function") { + // GSAP 3.x skips rendering when totalTime equals the current _tTime. + // A freshly created paused timeline has _tTime=0, so seeking to 0 is a + // no-op — percentage-keyframe values at 0% are never applied. Nudge to + // a micro-offset first to force GSAP to dirty its internal state, then + // seek to the real time so the render produces exact values. + state.capturedTimeline.totalTime(seekTime + 0.001, true); state.capturedTimeline.totalTime(seekTime, false); } + + // Strip stale CSS offset artifacts from GSAP-targeted elements. + // These leak into the HTML when the CSS offset path fires for a + // GSAP-animated element (stale cache race). On reload, both the + // offset and GSAP transform stack, doubling the visual position. + const staleEls = document.querySelectorAll("[data-hf-studio-path-offset]"); + if (staleEls.length > 0 && state.capturedTimeline.getChildren) { + const tweenTargets = new Set(); + try { + for (const child of state.capturedTimeline.getChildren(true)) { + if (typeof child.targets === "function") { + for (const t of child.targets()) tweenTargets.add(t); + } + } + } catch { + /* timeline access guard */ + } + for (const el of staleEls) { + if (!tweenTargets.has(el)) continue; + const htmlEl = el as HTMLElement; + htmlEl.removeAttribute("data-hf-studio-path-offset"); + htmlEl.removeAttribute("data-hf-studio-original-translate"); + htmlEl.removeAttribute("data-hf-studio-original-inline-translate"); + htmlEl.style.removeProperty("--hf-studio-offset-x"); + htmlEl.style.removeProperty("--hf-studio-offset-y"); + htmlEl.style.removeProperty("translate"); + } + } } if (resolution.diagnostics) { postRuntimeMessage({ diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 15bc71000..8e409a322 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -579,7 +579,28 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { defaultValue: number | string; } | { type: "remove-property"; animationId: string; property: string } - | { type: "remove-from-property"; animationId: string; property: string }; + | { type: "remove-from-property"; animationId: string; property: string } + | { + type: "add-keyframe"; + animationId: string; + percentage: number; + properties: Record; + ease?: string; + } + | { type: "remove-keyframe"; animationId: string; percentage: number } + | { + type: "update-keyframe"; + animationId: string; + percentage: number; + properties: Record; + ease?: string; + } + | { + type: "convert-to-keyframes"; + animationId: string; + resolvedFromValues?: Record; + } + | { type: "remove-all-keyframes"; animationId: string }; api.post("/projects/:id/gsap-mutations/*", async (c) => { const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, { @@ -706,6 +727,47 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { }); break; } + case "add-keyframe": { + const { addKeyframeToScript } = await loadGsapParser(); + newScript = addKeyframeToScript( + block.scriptText, + body.animationId, + body.percentage, + body.properties, + body.ease, + ); + break; + } + case "remove-keyframe": { + const { removeKeyframeFromScript } = await loadGsapParser(); + newScript = removeKeyframeFromScript(block.scriptText, body.animationId, body.percentage); + break; + } + case "update-keyframe": { + const { updateKeyframeInScript } = await loadGsapParser(); + newScript = updateKeyframeInScript( + block.scriptText, + body.animationId, + body.percentage, + body.properties, + body.ease, + ); + break; + } + case "convert-to-keyframes": { + const { convertToKeyframesInScript } = await loadGsapParser(); + newScript = convertToKeyframesInScript( + block.scriptText, + body.animationId, + body.resolvedFromValues, + ); + break; + } + case "remove-all-keyframes": { + const { removeAllKeyframesFromScript } = await loadGsapParser(); + newScript = removeAllKeyframesFromScript(block.scriptText, body.animationId); + break; + } default: return c.json({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400); } diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 56af6bef2..8171302ab 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -266,8 +266,10 @@ export function StudioApp() { const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise>( async () => {}, ); - const domEditDeleteBridge = async (s: DomEditSelection) => - handleDomEditElementDeleteRef.current(s); + const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s); + const resetKeyframesRef = useRef<() => boolean>(() => false); + const deleteSelectedKeyframesRef = useRef<() => void>(() => {}); + const invalidateGsapCacheRef = useRef<() => void>(() => {}); const { handleCopy, handlePaste, handleCut } = useClipboard({ projectId, activeCompPath, @@ -299,8 +301,10 @@ export function StudioApp() { handleCopy, handlePaste, handleCut, + onResetKeyframes: () => resetKeyframesRef.current(), + onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(), + onAfterUndoRedo: () => invalidateGsapCacheRef.current(), }); - const selectSidebarTabStable = useCallback( (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab), [], @@ -345,11 +349,20 @@ export function StudioApp() { selectSidebarTab: selectSidebarTabStable, getSidebarTab: getSidebarTabStable, }); - domEditSelectionBridgeRef.current = domEditSession.domEditSelection; clearDomSelectionRef.current = domEditSession.clearDomSelection; handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete; - + resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes; + invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache; + deleteSelectedKeyframesRef.current = () => { + const sk = usePlayerStore.getState().selectedKeyframes; + const a = domEditSession.selectedGsapAnimations.find((x) => x.keyframes); + if (!a || sk.size === 0) return; + sk.forEach((k) => { + const p = Number(k.split(":")[1]); + if (Number.isFinite(p)) domEditSession.handleGsapRemoveKeyframe(a.id, p); + }); + }; useCaptionDetection({ projectId, activeCompPath, @@ -470,12 +483,14 @@ export function StudioApp() { timelineVisible, toggleTimelineVisibility, }); - - if (resolving || waitingForServer || !projectId) { + if (resolving || waitingForServer || !projectId) return ; - } - - const timelineToolbar = ; + const timelineToolbar = ( + + ); return ( @@ -540,7 +555,6 @@ export function StudioApp() { {lintModal !== null && ( )} - {consoleErrors !== null && consoleErrors.length > 0 && ( setConsoleErrors(null)} /> )} - {domEditSession.agentModalOpen && domEditSession.domEditSelection && ( } - {appToast && (
{ + const anim = selectedGsapAnimations.find((a) => a.keyframes); + if (anim) handleGsapRemoveKeyframe(anim.id, pct); + }} + onChangeKeyframeEase={(_elId, _pct, ease) => { + const anim = selectedGsapAnimations.find((a) => a.keyframes); + if (anim) handleGsapUpdateMeta(anim.id, { ease }); + }} + // fallow-ignore-next-line complexity + onMoveKeyframe={(_el, oldPct, newPct) => { + const anim = selectedGsapAnimations.find((a) => a.keyframes); + if (!anim?.keyframes) return; + const kf = anim.keyframes.keyframes.find((k) => k.percentage === oldPct); + if (!kf) return; + handleGsapRemoveKeyframe(anim.id, oldPct); + for (const [prop, val] of Object.entries(kf.properties)) { + handleGsapAddKeyframe(anim.id, newPct, prop, val); + } + }} + onToggleKeyframeAtPlayhead={(el) => { + const currentTime = usePlayerStore.getState().currentTime; + const pct = + el.duration > 0 + ? Math.max( + 0, + Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100)), + ) + : 0; + const anim = selectedGsapAnimations.find((a) => a.keyframes); + if (anim?.keyframes) { + const existing = anim.keyframes.keyframes.find( + (k) => Math.abs(k.percentage - pct) <= 1, + ); + if (existing) { + handleGsapRemoveKeyframe(anim.id, existing.percentage); + } else { + handleGsapAddKeyframe(anim.id, pct, "x", 0); + } + } else { + const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes); + if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id); + } + }} onCompIdToSrcChange={setCompIdToSrc} onCompositionLoadingChange={setCompositionLoading} onCompositionChange={(compPath) => { diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 39409046c..cd4051069 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -12,6 +12,7 @@ import { STUDIO_INSPECTOR_PANELS_ENABLED, STUDIO_MOTION_PANEL_ENABLED, } from "./editor/manualEditingAvailability"; +import { usePlayerStore } from "../player"; /** Motion data without targeting metadata. */ type StudioMotionData = Omit; @@ -91,6 +92,9 @@ export function StudioRightPanel({ handleGsapUpdateFromProperty, handleGsapAddFromProperty, handleGsapRemoveFromProperty, + handleGsapAddKeyframe, + handleGsapRemoveKeyframe, + handleGsapConvertToKeyframes, } = useDomEditContext(); const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } = @@ -223,6 +227,10 @@ export function StudioRightPanel({ onAddGsapFromProperty={handleGsapAddFromProperty} onRemoveGsapFromProperty={handleGsapRemoveFromProperty} onAddGsapAnimation={handleGsapAddAnimation} + onAddKeyframe={handleGsapAddKeyframe} + onRemoveKeyframe={handleGsapRemoveKeyframe} + onConvertToKeyframes={handleGsapConvertToKeyframes} + onSeekToTime={(time) => usePlayerStore.getState().setCurrentTime(time)} /> ) : motionPanelActive ? ( { + const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage); + const allProps = new Set(); + for (const kf of sorted) { + for (const p of Object.keys(kf.properties)) { + if (typeof kf.properties[p] === "number") allProps.add(p); + } + } + const result: Record = {}; + for (const prop of allProps) { + let prev: { pct: number; val: number } | null = null; + let next: { pct: number; val: number } | null = null; + for (const kf of sorted) { + const v = kf.properties[prop]; + if (typeof v !== "number") continue; + if (kf.percentage <= pct) prev = { pct: kf.percentage, val: v }; + if (kf.percentage >= pct && !next) next = { pct: kf.percentage, val: v }; + } + if (prev && next && prev.pct !== next.pct) { + const t = (pct - prev.pct) / (next.pct - prev.pct); + result[prop] = Math.round(prev.val + t * (next.val - prev.val)); + } else if (prev) { + result[prop] = Math.round(prev.val); + } else if (next) { + result[prop] = Math.round(next.val); + } + } + return result; +} + +function readRuntimeKeyframeValues( + iframe: HTMLIFrameElement | null, + sel: DomEditSelection, + keyframes: GsapPercentageKeyframe[], +): Record { + if (!iframe?.contentWindow) return {}; + let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined; + try { + gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap; + } catch { + return {}; + } + if (!gsap?.getProperty) return {}; + const selector = sel.id ? `#${sel.id}` : sel.selector; + if (!selector) return {}; + let doc: Document | null = null; + try { + doc = iframe.contentDocument; + } catch { + return {}; + } + const element = doc?.querySelector(selector); + if (!element) return {}; + const allProps = new Set(); + for (const kf of keyframes) { + for (const p of Object.keys(kf.properties)) { + if (typeof kf.properties[p] === "number") allProps.add(p); + } + } + const result: Record = {}; + for (const prop of allProps) { + const val = Number(gsap.getProperty(element, prop)); + if (Number.isFinite(val)) result[prop] = Math.round(val); + } + return result; +} + +interface DomEditSessionSlice { + domEditSelection: DomEditSelection | null; + selectedGsapAnimations: GsapAnimation[]; + handleGsapRemoveKeyframe: (animId: string, pct: number) => void; + handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void; + handleGsapConvertToKeyframes: (animId: string) => void; + handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void; + previewIframeRef?: React.RefObject; +} interface TimelineToolbarProps { toggleTimelineVisibility: () => void; + domEditSession?: DomEditSessionSlice; } -export function TimelineToolbar({ toggleTimelineVisibility }: TimelineToolbarProps) { +// fallow-ignore-next-line complexity +function useKeyframeToggle(session?: DomEditSessionSlice) { + const currentTime = usePlayerStore((s) => s.currentTime); + if (!session) return { state: "none" as const, onToggle: undefined }; + + const sel = session.domEditSelection; + const anims = session.selectedGsapAnimations; + const kfAnim = anims.find((a) => a.keyframes); + const flatAnim = anims.find((a) => !a.keyframes); + + let state: "active" | "inactive" | "none" = "none"; + if (kfAnim?.keyframes && sel) { + const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; + const pct = + elDuration > 0 + ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10)) + : 0; + state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1) + ? "active" + : "inactive"; + } + + // fallow-ignore-next-line complexity + const onToggle = sel + ? () => { + const t = usePlayerStore.getState().currentTime; + if (kfAnim?.keyframes) { + const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; + const pct = + elDuration > 0 + ? Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10)) + : 0; + const existing = kfAnim.keyframes.keyframes.find( + (k) => Math.abs(k.percentage - pct) <= 1, + ); + if (existing) { + session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage); + } else { + const runtimeValues = readRuntimeKeyframeValues( + session.previewIframeRef?.current ?? null, + sel, + kfAnim.keyframes.keyframes, + ); + const values = + Object.keys(runtimeValues).length > 0 + ? runtimeValues + : interpolateKeyframeProperties(kfAnim.keyframes.keyframes, pct); + for (const [prop, val] of Object.entries(values)) { + session.handleGsapAddKeyframe(kfAnim.id, pct, prop, val); + } + } + } else if (flatAnim) { + session.handleGsapConvertToKeyframes(flatAnim.id); + } else { + session.handleGsapAddAnimation("to"); + } + } + : undefined; + + return { state, onToggle }; +} + +export function TimelineToolbar({ + toggleTimelineVisibility, + domEditSession, +}: TimelineToolbarProps) { const zoomMode = usePlayerStore((s) => s.zoomMode); const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent); const setZoomMode = usePlayerStore((s) => s.setZoomMode); const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent); const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent); + const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession); return (
-
- Timeline +
+
+ Timeline +
+ {STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && ( + + + + )}
diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index 3849ce048..839d3f7b3 100644 --- a/packages/studio/src/components/editor/AnimationCard.tsx +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -9,13 +9,29 @@ import { METHOD_LABELS, METHOD_TOOLTIPS, PERCENT_PROPS, + PROP_CONSTRAINTS, PROP_LABELS, PROP_TOOLTIPS, PROP_UNITS, + clampPropertyValue, } from "./gsapAnimationConstants"; import { buildTweenSummary } from "./gsapAnimationHelpers"; import { EaseCurveSection } from "./EaseCurveSection"; const BOOLEAN_PROPS = new Set(["visibility"]); +const STRING_PROPS = new Set(["filter", "clipPath"]); + +const FILTER_PRESETS = [ + { label: "Blur", value: "blur(4px)" }, + { label: "Bright", value: "brightness(1.5)" }, + { label: "Gray", value: "grayscale(1)" }, + { label: "None", value: "none" }, +]; + +const CLIP_PATH_PRESETS = [ + { label: "Circle", value: "circle(50% at 50% 50%)" }, + { label: "Inset", value: "inset(10%)" }, + { label: "None", value: "none" }, +]; function isPercentProp(prop: string): boolean { return PERCENT_PROPS.has(prop); @@ -27,7 +43,11 @@ function displayValue(prop: string, val: number | string): string { } function adjustedValue(prop: string, raw: string): string { - if (isPercentProp(prop)) return String(Math.max(0, Math.min(1, Number(raw) / 100))); + if (isPercentProp(prop)) return String(clampPropertyValue(prop, Number(raw) / 100)); + const num = Number(raw); + if (!Number.isNaN(num) && PROP_CONSTRAINTS[prop]) { + return String(clampPropertyValue(prop, num)); + } return raw; } @@ -90,6 +110,48 @@ function PropertyRow({ ); } + if (STRING_PROPS.has(prop)) { + const presets = + prop === "filter" ? FILTER_PRESETS : prop === "clipPath" ? CLIP_PATH_PRESETS : []; + return ( +
+
+
+ + {PROP_LABELS[prop] ?? prop} + + onCommit(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } + }} + /> +
+ +
+ {presets.length > 0 && ( +
+ {presets.map((p) => ( + + ))} +
+ )} +
+ ); + } + return (
@@ -292,8 +354,10 @@ export const AnimationCard = memo(function AnimationCard({ {methodLabel} - {typeof animation.position === "number" ? `${animation.position}s` : animation.position} –{" "} - {typeof endTime === "number" ? `${endTime.toFixed(1)}s` : endTime} + {typeof animation.position === "number" + ? `${parseFloat(animation.position.toFixed(3))}s` + : animation.position}{" "} + – {typeof endTime === "number" ? `${parseFloat(endTime.toFixed(3))}s` : endTime} {easeLabel} @@ -344,7 +408,7 @@ export const AnimationCard = memo(function AnimationCard({ value={ typeof animation.position === "string" ? animation.position - : String(Math.max(0, animation.position)) + : String(parseFloat(Math.max(0, animation.position).toFixed(3))) } suffix={typeof animation.position === "number" ? "s" : undefined} tooltip="When this effect begins on the timeline" diff --git a/packages/studio/src/components/editor/KeyframeDiamond.tsx b/packages/studio/src/components/editor/KeyframeDiamond.tsx new file mode 100644 index 000000000..10c7814c8 --- /dev/null +++ b/packages/studio/src/components/editor/KeyframeDiamond.tsx @@ -0,0 +1,49 @@ +import { memo } from "react"; + +export type DiamondState = "active" | "inactive" | "ghost"; + +interface KeyframeDiamondProps { + state: DiamondState; + onClick: () => void; + title?: string; + size?: number; +} + +// fallow-ignore-next-line complexity +export const KeyframeDiamond = memo(function KeyframeDiamond({ + state, + onClick, + title, + size = 10, +}: KeyframeDiamondProps) { + const isFilled = state === "active"; + const opacity = state === "ghost" ? 0.25 : state === "inactive" ? 0.6 : 1; + const color = state === "active" ? "#3b82f6" : "#a3a3a3"; + + return ( + + ); +}); diff --git a/packages/studio/src/components/editor/KeyframeNavigation.tsx b/packages/studio/src/components/editor/KeyframeNavigation.tsx new file mode 100644 index 000000000..48f2f5177 --- /dev/null +++ b/packages/studio/src/components/editor/KeyframeNavigation.tsx @@ -0,0 +1,139 @@ +import { memo } from "react"; +import { KeyframeDiamond, type DiamondState } from "./KeyframeDiamond"; + +interface KeyframeNavigationProps { + property: string; + /** All keyframes for this element's tween, or null if no keyframes exist */ + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }> | null; + /** Current playhead percentage within the element's lifetime (0-100) */ + currentPercentage: number; + onSeek: (percentage: number) => void; + onAddKeyframe: (percentage: number) => void; + onRemoveKeyframe: (percentage: number) => void; + onConvertToKeyframes: () => void; +} + +const TOLERANCE = 0.5; + +function ArrowLeft({ disabled }: { disabled: boolean }) { + return ( + + + + ); +} + +function ArrowRight({ disabled }: { disabled: boolean }) { + return ( + + + + ); +} + +// fallow-ignore-next-line complexity +export const KeyframeNavigation = memo(function KeyframeNavigation({ + property, + keyframes, + currentPercentage, + onSeek, + onAddKeyframe, + onRemoveKeyframe, + onConvertToKeyframes, +}: KeyframeNavigationProps) { + // Find keyframes that contain this property + const propertyKeyframes = keyframes?.filter((kf) => property in kf.properties) ?? []; + + const prevKf = + propertyKeyframes.filter((kf) => kf.percentage < currentPercentage - TOLERANCE).at(-1) ?? null; + + const nextKf = + propertyKeyframes.find((kf) => kf.percentage > currentPercentage + TOLERANCE) ?? null; + + const atCurrent = + propertyKeyframes.find((kf) => Math.abs(kf.percentage - currentPercentage) <= TOLERANCE) ?? + null; + + // Diamond state + let diamondState: DiamondState; + if (!keyframes || keyframes.length === 0) { + diamondState = "ghost"; + } else if (atCurrent) { + diamondState = "active"; + } else if (propertyKeyframes.length > 0) { + diamondState = "inactive"; + } else { + diamondState = "ghost"; + } + + const handleDiamondClick = () => { + if (diamondState === "ghost") { + onConvertToKeyframes(); + } else if (diamondState === "active") { + onRemoveKeyframe(currentPercentage); + } else { + onAddKeyframe(currentPercentage); + } + }; + + return ( +
+ + + +
+ ); +}); diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index e93d2c1cd..19dc6e8d9 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -14,7 +14,9 @@ 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"; +import { KeyframeNavigation } from "./KeyframeNavigation"; +import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability"; +import { usePlayerStore } from "../../player"; // Re-export helpers that external consumers import from this module export { @@ -65,6 +67,15 @@ interface PropertyPanelProps { onAddGsapFromProperty?: (animId: string, prop: string) => void; onRemoveGsapFromProperty?: (animId: string, prop: string) => void; onAddGsapAnimation?: (method: "to" | "from" | "set" | "fromTo") => void; + onAddKeyframe?: ( + animationId: string, + percentage: number, + property: string, + value: number | string, + ) => void; + onRemoveKeyframe?: (animationId: string, percentage: number) => void; + onConvertToKeyframes?: (animationId: string) => void; + onSeekToTime?: (time: number) => void; } /* ------------------------------------------------------------------ */ @@ -170,6 +181,10 @@ export const PropertyPanel = memo(function PropertyPanel({ onAddGsapFromProperty, onRemoveGsapFromProperty, onAddGsapAnimation, + onAddKeyframe, + onRemoveKeyframe, + onConvertToKeyframes, + onSeekToTime, }: PropertyPanelProps) { const styles = element?.computedStyles ?? EMPTY_STYLES; @@ -223,6 +238,11 @@ export const PropertyPanel = memo(function PropertyPanel({ const commitManualOffset = (axis: "x" | "y", nextValue: string) => { const parsed = parsePxMetricValue(nextValue); if (parsed == null) return; + if (gsapKeyframes && gsapAnimId && onAddKeyframe) { + const pct = Math.max(0, Math.min(100, Math.round(currentPct * 10) / 10)); + onAddKeyframe(gsapAnimId, pct, axis, parsed); + return; + } const current = readStudioPathOffset(element.element); onSetManualOffset(element, { x: axis === "x" ? parsed : current.x, @@ -256,6 +276,16 @@ export const PropertyPanel = memo(function PropertyPanel({ onSetManualRotation(element, { angle: parsed }); }; + // Keyframe navigation state + const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0; + const currentTime = usePlayerStore((s) => s.currentTime); + const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0; + + const gsapKeyframes = gsapAnimations?.find((a) => a.keyframes)?.keyframes?.keyframes ?? null; + const gsapAnimId = + gsapAnimations?.find((a) => a.keyframes)?.id ?? gsapAnimations?.[0]?.id ?? null; + return (
@@ -317,39 +347,118 @@ export const PropertyPanel = memo(function PropertyPanel({
}>
- commitManualOffset("x", next)} - /> - commitManualOffset("y", next)} - /> - commitManualSize("width", next)} - /> - commitManualSize("height", next)} - /> - commitManualRotation(next.replace("°", ""))} - /> +
+
+ commitManualOffset("x", next)} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "x", manualOffset.x)} + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + /> + )} +
+
+
+ commitManualOffset("y", next)} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "y", manualOffset.y)} + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + /> + )} +
+
+
+ commitManualSize("width", next)} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "width", resolvedWidth)} + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + /> + )} +
+
+
+ commitManualSize("height", next)} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={(pct) => + onAddKeyframe?.(gsapAnimId, pct, "height", resolvedHeight) + } + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + /> + )} +
+
+
+ commitManualRotation(next.replace("°", ""))} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={(pct) => + onAddKeyframe?.(gsapAnimId, pct, "rotation", manualRotation.angle) + } + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + /> + )} +
{ x: number; y: number }, +): string { + const steps = 64; + const simDur = springSimDuration(params.mass, params.stiffness, params.damping); + const commands: string[] = []; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const simT = t * simDur; + const y = springValue(params.mass, params.stiffness, params.damping, simT); + const mapped = mapFn({ x: t, y }); + commands.push(`${i === 0 ? "M" : "L"}${mapped.x.toFixed(2)},${mapped.y.toFixed(2)}`); + } + return commands.join(" "); +} + +export function SpringEaseEditor({ + onCommit, +}: { + onCommit: (easeId: string, easeData: string) => void; +}) { + const [params, setParams] = useState(DEFAULT_SPRING); + const commitTimeoutRef = useRef | null>(null); + + const scheduleCommit = useCallback( + (next: SpringParams) => { + if (commitTimeoutRef.current) clearTimeout(commitTimeoutRef.current); + commitTimeoutRef.current = setTimeout(() => { + const data = generateSpringEaseData(next.mass, next.stiffness, next.damping); + const id = `spring-m${next.mass}-k${next.stiffness}-d${next.damping}`; + onCommit(id, data); + }, 120); + }, + [onCommit], + ); + + useEffect(() => { + return () => { + if (commitTimeoutRef.current) clearTimeout(commitTimeoutRef.current); + }; + }, []); + + const updateParam = (key: keyof SpringParams, value: number) => { + const next = { ...params, [key]: value }; + setParams(next); + scheduleCommit(next); + }; + + const applyPreset = (preset: (typeof SPRING_PRESETS)[number]) => { + const next: SpringParams = { + mass: preset.mass, + stiffness: preset.stiffness, + damping: preset.damping, + }; + setParams(next); + const data = generateSpringEaseData(next.mass, next.stiffness, next.damping); + onCommit(preset.name, data); + }; + + const reset = () => { + setParams(DEFAULT_SPRING); + const data = generateSpringEaseData( + DEFAULT_SPRING.mass, + DEFAULT_SPRING.stiffness, + DEFAULT_SPRING.damping, + ); + onCommit("spring-bouncy", data); + }; + + // SVG layout matching EaseCurveEditor proportions + const width = 324; + const height = 214; + const plot = { left: 46, top: 24, width: 242, height: 146 }; + const yMin = -0.2; + const yMax = 1.3; + + const mapPoint = (point: { x: number; y: number }) => ({ + x: plot.left + point.x * plot.width, + y: plot.top + ((yMax - point.y) / (yMax - yMin)) * plot.height, + }); + + const curvePath = buildSpringPath(params, mapPoint); + const start = mapPoint({ x: 0, y: 0 }); + const end = mapPoint({ x: 1, y: 1 }); + + const activePreset = SPRING_PRESETS.find( + (p) => + p.mass === params.mass && p.stiffness === params.stiffness && p.damping === params.damping, + ); + + return ( +
+
+
+
Spring Ease
+
+ {activePreset?.label ?? `m${params.mass} k${params.stiffness} d${params.damping}`} +
+
+ +
+ + {/* Curve preview */} + + + {[0, 0.5, 1].map((value) => { + const mapped = mapPoint({ x: 0, y: value }); + return ( + + + + {value} + + + ); + })} + + + + + + + + {/* Presets */} +
+ {SPRING_PRESETS.map((preset) => { + const isActive = + preset.mass === params.mass && + preset.stiffness === params.stiffness && + preset.damping === params.damping; + return ( + + ); + })} +
+ + {/* Sliders */} +
+ {SLIDERS.map((slider) => ( +
+
+ + {slider.label} + + + {params[slider.key]} + +
+ updateParam(slider.key, Number(e.target.value))} + className="h-1 w-full cursor-pointer appearance-none rounded-full bg-neutral-800 accent-yellow-400 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-yellow-400" + /> +
+ ))} +
+
+ ); +} diff --git a/packages/studio/src/components/editor/gsapAnimationConstants.ts b/packages/studio/src/components/editor/gsapAnimationConstants.ts index 425c0b850..70f9602a6 100644 --- a/packages/studio/src/components/editor/gsapAnimationConstants.ts +++ b/packages/studio/src/components/editor/gsapAnimationConstants.ts @@ -27,6 +27,16 @@ export const PROP_LABELS: Record = { autoAlpha: "Visibility", visibility: "Visible", scaleX_alias: "Stretch X", + filter: "Filter", + clipPath: "Clip Path", + color: "Color", + backgroundColor: "Background", + borderColor: "Border Color", + borderRadius: "Radius", + fontSize: "Font Size", + letterSpacing: "Tracking", + skewX: "Skew X", + skewY: "Skew Y", }; export const PROP_UNITS: Record = { @@ -83,6 +93,11 @@ export const EASE_LABELS: Record = { "expo.out": "Very snappy stop", "expo.in": "Very slow start", "expo.inOut": "Dramatic ease", + "spring-gentle": "Gentle spring", + "spring-bouncy": "Bouncy spring", + "spring-stiff": "Stiff spring", + "spring-wobbly": "Wobbly spring", + "spring-heavy": "Heavy spring", }; export const EASE_CURVES: Record = { @@ -123,6 +138,33 @@ export function parseCustomEaseFromString(ease: string): { export const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]); +export const PROP_CONSTRAINTS: Record = { + opacity: { min: 0, max: 1, step: 0.01 }, + autoAlpha: { min: 0, max: 1, step: 0.01 }, + scale: { min: -10, max: 10, step: 0.01 }, + scaleX: { min: -10, max: 10, step: 0.01 }, + scaleY: { min: -10, max: 10, step: 0.01 }, + rotation: { step: 1 }, + skewX: { min: -90, max: 90, step: 1 }, + skewY: { min: -90, max: 90, step: 1 }, + width: { min: 0, step: 1 }, + height: { min: 0, step: 1 }, + borderRadius: { min: 0, step: 1 }, + x: { step: 1 }, + y: { step: 1 }, + fontSize: { min: 1, step: 1 }, + letterSpacing: { step: 0.1 }, +}; + +export function clampPropertyValue(prop: string, value: number): number { + const constraint = PROP_CONSTRAINTS[prop]; + if (!constraint) return value; + let clamped = value; + if (constraint.min !== undefined) clamped = Math.max(constraint.min, clamped); + if (constraint.max !== undefined) clamped = Math.min(constraint.max, clamped); + return clamped; +} + export const ADD_METHODS = ["to", "from", "fromTo", "set"] as const; export const ADD_METHOD_LABELS: Record = { diff --git a/packages/studio/src/components/editor/gsapAnimationHelpers.ts b/packages/studio/src/components/editor/gsapAnimationHelpers.ts index 87cfca545..911cbcd3c 100644 --- a/packages/studio/src/components/editor/gsapAnimationHelpers.ts +++ b/packages/studio/src/components/editor/gsapAnimationHelpers.ts @@ -14,7 +14,8 @@ export function buildTweenSummary(animation: GsapAnimation): string { const props = Object.entries(animation.properties); const target = animation.targetSelector; const dur = animation.duration ?? 0; - const pos = animation.position; + const rawPos = animation.position; + const pos = typeof rawPos === "number" ? parseFloat(rawPos.toFixed(3)) : rawPos; const propDescs = props.map(([p, v]) => { const label = (PROP_LABELS[p] ?? p).toLowerCase(); return `${label} to ${formatPropValue(p, v)}`; diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index 6845956a6..553bb5e8d 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -68,6 +68,12 @@ export const STUDIO_BLOCKS_PANEL_ENABLED = resolveStudioBooleanEnvFlag( export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag( env, ["VITE_STUDIO_ENABLE_GSAP_PANEL", "VITE_STUDIO_GSAP_PANEL_ENABLED"], + true, +); + +export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag( + env, + ["VITE_STUDIO_ENABLE_KEYFRAMES", "VITE_STUDIO_KEYFRAMES_ENABLED"], false, ); diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index b08a8c567..1f0006410 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -223,6 +223,7 @@ function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean { } function stripGsapTranslateFromTransform(element: HTMLElement): void { + if (element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return; const transform = element.style.getPropertyValue("transform"); if (!transform || transform === "none") return; const DOMMatrixCtor = (element.ownerDocument.defaultView as (Window & typeof globalThis) | null) diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index 67aae3397..9df465a00 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -236,9 +236,25 @@ export function createManualOffsetDragMember(input: { const gestureToken = beginStudioManualEditGesture(input.element); const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset); if (!measured.ok) { - restoreStudioPathOffset(input.element, initialPathOffset); - endStudioManualEditGesture(input.element, gestureToken); - return { ok: false, reason: measured.reason, selection: input.selection }; + // Fallback: when GSAP transforms interfere with probe measurement, use + // the preview scale as an approximation. The commit path reads the actual + // GSAP position from the iframe runtime, so visual imprecision during + // drag is acceptable — the final committed position is always exact. + const scaleX = input.rect.editScaleX || 1; + const scaleY = input.rect.editScaleY || 1; + return { + ok: true, + member: { + key: input.key, + selection: input.selection, + element: input.element, + initialOffset, + initialPathOffset, + gestureToken, + screenToOffset: { a: 1 / scaleX, b: 0, c: 0, d: 1 / scaleY }, + originRect: input.rect, + }, + }; } return { diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 07562e113..adf338209 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -70,6 +70,10 @@ interface NLELayoutProps { ) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onSelectTimelineElement?: (element: TimelineElement | null) => void; + onDeleteKeyframe?: (elementId: string, percentage: number) => void; + onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void; + onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; + onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */ onCompIdToSrcChange?: (map: Map) => void; /** Whether the timeline panel is visible (default: true) */ @@ -118,6 +122,10 @@ export const NLELayout = memo(function NLELayout({ onResizeElement, onBlockedEditAttempt, onSelectTimelineElement, + onDeleteKeyframe, + onChangeKeyframeEase, + onMoveKeyframe, + onToggleKeyframeAtPlayhead, onCompIdToSrcChange, timelineVisible, onToggleTimeline, @@ -448,6 +456,10 @@ export const NLELayout = memo(function NLELayout({ onResizeElement={onResizeElement} onBlockedEditAttempt={onBlockedEditAttempt} onSelectElement={onSelectTimelineElement} + onDeleteKeyframe={onDeleteKeyframe} + onChangeKeyframeEase={onChangeKeyframeEase} + onMoveKeyframe={onMoveKeyframe} + onToggleKeyframeAtPlayhead={onToggleKeyframeAtPlayhead} />
{timelineFooter &&
{timelineFooter}
} diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index b91d54a49..39b7183da 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -65,6 +65,13 @@ export function DomEditProvider({ handleGsapUpdateFromProperty, handleGsapAddFromProperty, handleGsapRemoveFromProperty, + handleGsapAddKeyframe, + handleGsapRemoveKeyframe, + handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, + handleResetSelectedElementKeyframes, + invalidateGsapCache, + previewIframeRef, }, children, }: { @@ -125,6 +132,13 @@ export function DomEditProvider({ handleGsapUpdateFromProperty, handleGsapAddFromProperty, handleGsapRemoveFromProperty, + handleGsapAddKeyframe, + handleGsapRemoveKeyframe, + handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, + handleResetSelectedElementKeyframes, + invalidateGsapCache, + previewIframeRef, }), [ domEditSelection, @@ -179,6 +193,13 @@ export function DomEditProvider({ handleGsapUpdateFromProperty, handleGsapAddFromProperty, handleGsapRemoveFromProperty, + handleGsapAddKeyframe, + handleGsapRemoveKeyframe, + handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, + handleResetSelectedElementKeyframes, + invalidateGsapCache, + previewIframeRef, ], ); return {children}; diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts new file mode 100644 index 000000000..a8841318d --- /dev/null +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -0,0 +1,307 @@ +/** + * Bridge between the Studio drag system and GSAP animations running in the + * preview iframe. + * + * The preview iframe exposes `window.gsap` with a `getProperty(element, prop)` + * method that returns the ACTUAL interpolated value at the current seek time. + * This module reads those runtime values so that drag commits can write correct + * absolute positions back into the GSAP script, regardless of tween type, + * easing, or seek position. + */ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { clearStudioPathOffset } from "../components/editor/manualEdits"; +import { usePlayerStore } from "../player/store/playerStore"; + +// ── Runtime reads ────────────────────────────────────────────────────────── + +interface IframeGsap { + getProperty: (el: Element, prop: string) => number; +} + +// fallow-ignore-next-line complexity +function readGsapPositionFromIframe( + iframe: HTMLIFrameElement | null, + elementSelector: string, +): { x: number; y: number } | null { + if (!iframe?.contentWindow) return null; + + let gsap: IframeGsap | undefined; + try { + gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap; + } catch { + return null; + } + if (!gsap?.getProperty) return null; + + let doc: Document | null = null; + try { + doc = iframe.contentDocument; + } catch { + return null; + } + if (!doc) return null; + + const element = doc.querySelector(elementSelector); + if (!element) return null; + + const x = Number(gsap.getProperty(element, "x")) || 0; + const y = Number(gsap.getProperty(element, "y")) || 0; + return { x, y }; +} + +// ── Animation matching ───────────────────────────────────────────────────── + +// fallow-ignore-next-line complexity +function findGsapPositionAnimation(animations: GsapAnimation[]): GsapAnimation | null { + // Prefer animations that already have x/y + for (const anim of animations) { + if (anim.keyframes) { + const hasPos = anim.keyframes.keyframes.some( + (kf) => "x" in kf.properties || "y" in kf.properties, + ); + if (hasPos) return anim; + } + const props = anim.properties; + const fromProps = anim.fromProperties; + if (anim.method === "fromTo") { + if ("x" in props || "y" in props || (fromProps && ("x" in fromProps || "y" in fromProps))) { + return anim; + } + } else if ("x" in props || "y" in props) { + return anim; + } + } + // Fall back to any keyframed animation — drag will add x/y to it + for (const anim of animations) { + if (anim.keyframes) return anim; + } + // Fall back to any animation — will be converted to keyframes + return animations[0] ?? null; +} + +// ── Selector resolution ──────────────────────────────────────────────────── + +function selectorForSelection(selection: DomEditSelection): string | null { + if (selection.id) return `#${selection.id}`; + if (selection.selector) return selection.selector; + return null; +} + +// ── Percentage computation ───────────────────────────────────────────────── + +function computeCurrentPercentage(selection: DomEditSelection): number { + const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1; + const currentTime = usePlayerStore.getState().currentTime; + return elDuration > 0 + ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10)) + : 0; +} + +// ── High-level intercept ─────────────────────────────────────────────────── + +export interface GsapDragCommitCallbacks { + commitMutation: ( + selection: DomEditSelection, + mutation: Record, + options: { + label: string; + coalesceKey?: string; + softReload?: boolean; + skipReload?: boolean; + beforeReload?: () => void; + }, + ) => Promise; +} + +/** + * Attempt to handle a drag commit via the GSAP script mutation path. + * + * Returns a Promise that resolves to true if the drag was handled via GSAP + * (caller should skip the CSS path), or false if no GSAP position animation + * exists. The promise resolves only AFTER the mutation has been persisted and + * the preview soft-reloaded — the CSS offset stays visible until then so the + * element doesn't snap back during the async gap. + */ +// fallow-ignore-next-line complexity +export async function tryGsapDragIntercept( + selection: DomEditSelection, + offset: { x: number; y: number }, + animations: GsapAnimation[], + iframe: HTMLIFrameElement | null, + commitMutation: GsapDragCommitCallbacks["commitMutation"], + fetchFallbackAnimations?: () => Promise, +): Promise { + let posAnim = findGsapPositionAnimation(animations); + if (!posAnim && fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + posAnim = findGsapPositionAnimation(fresh); + } + if (!posAnim) return false; + + const selector = selectorForSelection(selection); + if (!selector) return false; + + const gsapPos = readGsapPositionFromIframe(iframe, selector); + if (!gsapPos) return false; + + await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, { commitMutation }); + return true; +} + +// ── Commit helpers ───────────────────────────────────────────────────────── + +/** + * Compute the new GSAP position values from runtime-read positions + drag + * offset, then commit the mutation to the GSAP script. + * + * `gsap.getProperty` reads from GSAP's internal cache (element._gsap), not + * from the DOM transform matrix. The strip in `applyStudioPathOffset` does + * not affect the cached values, so the formula is simply: + * newValue = cachedGsapValue + dragOffset + * + * For flat tweens (to/set), the mutation would change the tween endpoint, + * which is invisible at t=0. Instead, we convert to keyframes first so the + * position is set at the exact seek percentage via a keyframe. + */ +// fallow-ignore-next-line complexity +async function commitGsapPositionFromDrag( + selection: DomEditSelection, + anim: GsapAnimation, + studioOffset: { x: number; y: number }, + gsapPos: { x: number; y: number }, + callbacks: GsapDragCommitCallbacks, +): Promise { + const newX = Math.round(gsapPos.x + studioOffset.x); + const newY = Math.round(gsapPos.y + studioOffset.y); + const clearOffset = () => clearStudioPathOffset(selection.element); + + if (anim.keyframes) { + await commitKeyframedPosition(selection, anim, newX, newY, callbacks, clearOffset); + } else if (anim.method === "from") { + await commitFromPosition(selection, anim, studioOffset, callbacks, clearOffset); + } else if (anim.method === "fromTo") { + await commitFromToPosition(selection, anim, studioOffset, callbacks, clearOffset); + } else { + // Flat to()/set() — convert to keyframes first so the drag position + // is captured at the current seek time, not just the tween endpoint. + await commitFlatViaKeyframes(selection, anim, newX, newY, callbacks, clearOffset); + } +} + +// fallow-ignore-next-line complexity +async function commitKeyframedPosition( + selection: DomEditSelection, + anim: GsapAnimation, + newX: number, + newY: number, + callbacks: GsapDragCommitCallbacks, + beforeReload: () => void, +): Promise { + const pct = computeCurrentPercentage(selection); + + await callbacks.commitMutation( + selection, + { + type: "add-keyframe", + animationId: anim.id, + percentage: pct, + properties: { x: newX, y: newY }, + }, + { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, + ); +} + +/** + * For flat to()/set() tweens, convert to keyframes first so we can place the + * drag position at the current percentage. Without conversion, the mutation + * only changes the tween endpoint, which is invisible at t=0. + */ +// fallow-ignore-next-line complexity +async function commitFlatViaKeyframes( + selection: DomEditSelection, + anim: GsapAnimation, + newX: number, + newY: number, + callbacks: GsapDragCommitCallbacks, + beforeReload: () => void, +): Promise { + await callbacks.commitMutation( + selection, + { type: "convert-to-keyframes", animationId: anim.id }, + { label: "Convert to keyframes for drag", skipReload: true }, + ); + + const pct = computeCurrentPercentage(selection); + + await callbacks.commitMutation( + selection, + { + type: "add-keyframe", + animationId: anim.id, + percentage: pct, + properties: { x: newX, y: newY }, + }, + { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, + ); +} + +async function commitFromPosition( + selection: DomEditSelection, + anim: GsapAnimation, + delta: { x: number; y: number }, + callbacks: GsapDragCommitCallbacks, + beforeReload: () => void, +): Promise { + const fromX = Math.round(Number(anim.properties.x ?? 0) + delta.x); + const fromY = Math.round(Number(anim.properties.y ?? 0) + delta.y); + + await callbacks.commitMutation( + selection, + { type: "update-property", animationId: anim.id, property: "x", value: fromX }, + { label: "Move layer (GSAP from x)", skipReload: true }, + ); + await callbacks.commitMutation( + selection, + { type: "update-property", animationId: anim.id, property: "y", value: fromY }, + { label: "Move layer (GSAP from y)", softReload: true, beforeReload }, + ); +} + +// fallow-ignore-next-line complexity +async function commitFromToPosition( + selection: DomEditSelection, + anim: GsapAnimation, + delta: { x: number; y: number }, + callbacks: GsapDragCommitCallbacks, + beforeReload: () => void, +): Promise { + if (anim.fromProperties) { + const fromX = Math.round(Number(anim.fromProperties.x ?? 0) + delta.x); + const fromY = Math.round(Number(anim.fromProperties.y ?? 0) + delta.y); + await callbacks.commitMutation( + selection, + { type: "update-from-property", animationId: anim.id, property: "x", value: fromX }, + { label: "Move (GSAP from x)", skipReload: true }, + ); + await callbacks.commitMutation( + selection, + { type: "update-from-property", animationId: anim.id, property: "y", value: fromY }, + { label: "Move (GSAP from y)", skipReload: true }, + ); + } + + const toX = Math.round(Number(anim.properties.x ?? 0) + delta.x); + const toY = Math.round(Number(anim.properties.y ?? 0) + delta.y); + await callbacks.commitMutation( + selection, + { type: "update-property", animationId: anim.id, property: "x", value: toX }, + { label: "Move (GSAP to x)", skipReload: true }, + ); + await callbacks.commitMutation( + selection, + { type: "update-property", animationId: anim.id, property: "y", value: toY }, + { label: "Move (GSAP to y)", softReload: true, beforeReload }, + ); +} diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 4308b4bcd..61d65af9f 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -77,6 +77,9 @@ interface UseAppHotkeysParams { handleCopy: () => boolean; handlePaste: () => Promise; handleCut: () => Promise; + onResetKeyframes: () => boolean; + onDeleteSelectedKeyframes: () => void; + onAfterUndoRedo?: () => void; } // ── Hook ── @@ -98,6 +101,9 @@ export function useAppHotkeys({ handleCopy, handlePaste, handleCut, + onResetKeyframes, + onDeleteSelectedKeyframes, + onAfterUndoRedo, }: UseAppHotkeysParams) { const previewHotkeyWindowRef = useRef(null); const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined); @@ -144,6 +150,7 @@ export function useAppHotkeys({ return; } if (result.ok && result.label) { + onAfterUndoRedo?.(); await syncHistoryPreviewAfterApply(result.paths); showToast(`Undid ${result.label}`, "info"); } @@ -154,6 +161,7 @@ export function useAppHotkeys({ syncHistoryPreviewAfterApply, waitForPendingDomEditSaves, writeHistoryProjectFile, + onAfterUndoRedo, ]); const handleRedo = useCallback(async () => { @@ -167,6 +175,7 @@ export function useAppHotkeys({ return; } if (result.ok && result.label) { + onAfterUndoRedo?.(); await syncHistoryPreviewAfterApply(result.paths); showToast(`Redid ${result.label}`, "info"); } @@ -177,6 +186,7 @@ export function useAppHotkeys({ syncHistoryPreviewAfterApply, waitForPendingDomEditSaves, writeHistoryProjectFile, + onAfterUndoRedo, ]); // ── Stable refs for the consolidated keydown handler ── @@ -197,6 +207,10 @@ export function useAppHotkeys({ handlePasteRef.current = handlePaste; const handleCutRef = useRef(handleCut); handleCutRef.current = handleCut; + const onResetKeyframesRef = useRef(onResetKeyframes); + onResetKeyframesRef.current = onResetKeyframes; + const onDeleteSelectedKeyframesRef = useRef(onDeleteSelectedKeyframes); + onDeleteSelectedKeyframesRef.current = onDeleteSelectedKeyframes; // ── Consolidated keydown handler ── @@ -292,7 +306,7 @@ export function useAppHotkeys({ return; } - // Delete / Backspace — remove selected element (timeline clip or preview selection) + // Delete / Backspace — remove selected keyframes > reset keyframes > remove element if ( (event.key === "Delete" || event.key === "Backspace") && !event.metaKey && @@ -300,6 +314,26 @@ export function useAppHotkeys({ !event.altKey && !isEditableTarget(event.target) ) { + // Priority: selected keyframes take precedence over clip deletion + const { selectedKeyframes } = usePlayerStore.getState(); + if (selectedKeyframes.size > 0) { + onDeleteSelectedKeyframesRef.current(); + usePlayerStore.getState().clearSelectedKeyframes(); + event.preventDefault(); + return; + } + + // Backspace: try resetting keyframes first; fall through to delete if none found + if (event.key === "Backspace") { + const { selectedElementId, keyframeCache } = usePlayerStore.getState(); + if (selectedElementId && keyframeCache.has(selectedElementId)) { + if (onResetKeyframesRef.current()) { + event.preventDefault(); + return; + } + } + } + const { selectedElementId, elements } = usePlayerStore.getState(); if (selectedElementId) { const element = elements.find((el) => (el.key ?? el.id) === selectedElementId); diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index e733a4cdd..658740151 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -35,6 +35,37 @@ import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditO import type { EditHistoryKind } from "../utils/editHistory"; import { useDomEditTextCommits } from "./useDomEditTextCommits"; +// ── Helpers ── + +type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> }; + +function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean { + if (!iframe?.contentWindow) return false; + let timelines: Record | undefined; + try { + timelines = (iframe.contentWindow as Window & { __timelines?: Record }) + .__timelines; + } catch { + return false; + } + if (!timelines) return false; + const id = element.id; + for (const tl of Object.values(timelines)) { + if (!tl?.getChildren) continue; + try { + for (const child of tl.getChildren(true)) { + if (!child.targets) continue; + for (const t of child.targets()) { + if (t === element || (id && t.id === id)) return true; + } + } + } catch { + continue; + } + } + return false; +} + // ── Types ── interface RecordEditInput { @@ -290,12 +321,13 @@ export function useDomEditCommits({ const handleDomPathOffsetCommit = useCallback( (selection: DomEditSelection, next: { x: number; y: number }) => { applyStudioPathOffset(selection.element, next); + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return; commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { label: "Move layer", coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`, }); }, - [commitPositionPatchToHtml], + [commitPositionPatchToHtml, previewIframeRef], ); const handleDomGroupPathOffsetCommit = useCallback( @@ -307,13 +339,14 @@ export function useDomEditCommits({ .join(":"); for (const { selection, next } of updates) { applyStudioPathOffset(selection.element, next); + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) continue; commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { label: `Move ${updates.length} layers`, coalesceKey: `group-path-offset:${coalesceKey}`, }); } }, - [commitPositionPatchToHtml], + [commitPositionPatchToHtml, previewIframeRef], ); const handleDomBoxSizeCommit = useCallback( diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 5942d8f30..209be746a 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -16,7 +16,14 @@ import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; import { useDomEditCommits } from "./useDomEditCommits"; import { useGsapScriptCommits } from "./useGsapScriptCommits"; -import { useGsapAnimationsForElement, useGsapCacheVersion } from "./useGsapTweenCache"; +import { + useGsapAnimationsForElement, + useGsapCacheVersion, + usePopulateKeyframeCacheForFile, + fetchParsedAnimations, + getAnimationsForElement, +} from "./useGsapTweenCache"; +import { tryGsapDragIntercept } from "./gsapRuntimeBridge"; // ── Types ── @@ -198,13 +205,21 @@ export function useDomEditSession({ const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion(); + const gsapSourceFile = domEditSelection?.sourceFile || activeCompPath || "index.html"; + + usePopulateKeyframeCacheForFile( + STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null, + gsapSourceFile, + gsapCacheVersion, + ); + const { animations: selectedGsapAnimations, multipleTimelines: gsapMultipleTimelines, unsupportedTimelinePattern: gsapUnsupportedTimelinePattern, } = useGsapAnimationsForElement( STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null, - domEditSelection?.sourceFile || activeCompPath || "index.html", + gsapSourceFile, domEditSelection ? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null } : null, @@ -212,6 +227,7 @@ export function useDomEditSession({ ); const { + commitMutation: gsapCommitMutation, updateGsapProperty, updateGsapMeta, deleteGsapAnimation, @@ -221,6 +237,10 @@ export function useDomEditSession({ updateGsapFromProperty, addGsapFromProperty, removeGsapFromProperty, + addKeyframe, + removeKeyframe, + convertToKeyframes, + removeAllKeyframes, } = useGsapScriptCommits({ projectIdRef, activeCompPath, @@ -270,6 +290,42 @@ export function useDomEditSession({ buildDomSelectionFromTarget, }); + // Wrap the CSS-based path offset commit with GSAP-awareness: when the + // selected element has GSAP animations controlling x/y, read the actual + // interpolated position from the iframe runtime and commit via the GSAP + // script mutation path instead of the CSS translate offset. + const handleGsapAwarePathOffsetCommit = useCallback( + async (selection: DomEditSelection, next: { x: number; y: number }) => { + if (gsapCommitMutation) { + const handled = await tryGsapDragIntercept( + selection, + next, + selectedGsapAnimations, + previewIframeRef.current, + gsapCommitMutation, + async () => { + const pid = projectId; + if (!pid) return []; + const parsed = await fetchParsedAnimations(pid, gsapSourceFile); + if (!parsed) return []; + const target = { id: selection.id ?? null, selector: selection.selector ?? null }; + return getAnimationsForElement(parsed.animations, target); + }, + ); + if (handled) return; + } + handleDomPathOffsetCommit(selection, next); + }, + [ + handleDomPathOffsetCommit, + selectedGsapAnimations, + gsapCommitMutation, + previewIframeRef, + projectId, + gsapSourceFile, + ], + ); + const handleGsapUpdateProperty = useCallback( (animId: string, prop: string, value: number | string) => { if (!domEditSelection) return; @@ -298,8 +354,11 @@ export function useDomEditSession({ (method: "to" | "from" | "set" | "fromTo") => { if (!domEditSelection) return; addGsapAnimation(domEditSelection, method, currentTime); + if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) { + handleDomManualEditsReset(domEditSelection); + } }, - [domEditSelection, addGsapAnimation, currentTime], + [domEditSelection, addGsapAnimation, currentTime, handleDomManualEditsReset], ); const handleGsapAddProperty = useCallback( @@ -342,6 +401,52 @@ export function useDomEditSession({ [domEditSelection, removeGsapFromProperty], ); + const handleGsapAddKeyframe = useCallback( + (animId: string, percentage: number, property: string, value: number | string) => { + if (!domEditSelection) return; + addKeyframe(domEditSelection, animId, percentage, property, value); + }, + [domEditSelection, addKeyframe], + ); + + const handleGsapRemoveKeyframe = useCallback( + (animId: string, percentage: number) => { + if (!domEditSelection) return; + removeKeyframe(domEditSelection, animId, percentage); + }, + [domEditSelection, removeKeyframe], + ); + + const handleGsapConvertToKeyframes = useCallback( + (animId: string) => { + if (!domEditSelection) return; + convertToKeyframes(domEditSelection, animId); + }, + [domEditSelection, convertToKeyframes], + ); + + const handleGsapRemoveAllKeyframes = useCallback( + (animId: string) => { + if (!domEditSelection) return; + removeAllKeyframes(domEditSelection, animId); + }, + [domEditSelection, removeAllKeyframes], + ); + + /** + * Reset keyframes for the currently selected element. + * Finds the animation with keyframes from the resolved GSAP animations + * and sends a remove-all-keyframes mutation. Returns true if keyframes + * were found and the mutation was dispatched. + */ + const handleResetSelectedElementKeyframes = useCallback((): boolean => { + if (!domEditSelection) return false; + const withKeyframes = selectedGsapAnimations.find((a) => a.keyframes); + if (!withKeyframes) return false; + removeAllKeyframes(domEditSelection, withKeyframes.id); + return true; + }, [domEditSelection, selectedGsapAnimations, removeAllKeyframes]); + // Sync selection from preview document on load / refresh // eslint-disable-next-line no-restricted-syntax useEffect(() => { @@ -445,7 +550,7 @@ export function useDomEditSession({ handleDomStyleCommit, handleDomAttributeCommit, handleDomHtmlAttributeCommit, - handleDomPathOffsetCommit, + handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, @@ -482,5 +587,12 @@ export function useDomEditSession({ handleGsapUpdateFromProperty, handleGsapAddFromProperty, handleGsapRemoveFromProperty, + handleGsapAddKeyframe, + handleGsapRemoveKeyframe, + handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, + handleResetSelectedElementKeyframes, + invalidateGsapCache: bumpGsapCache, + previewIframeRef, }; } diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index a45333388..75484e307 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -3,6 +3,8 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { EditHistoryKind } from "../utils/editHistory"; import { applySoftReload } from "../utils/gsapSoftReload"; +import { executeOptimistic } from "../utils/optimisticUpdate"; +import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore"; const PROPERTY_DEFAULTS: Record = { opacity: 1, @@ -70,6 +72,27 @@ async function mutateGsapScript( } } +function buildCacheKey(sourceFile: string, elementId: string): string { + return `${sourceFile}#${elementId}`; +} + +function readKeyframeSnapshot( + sourceFile: string, + elementId: string | null | undefined, +): KeyframeCacheEntry | undefined { + if (!elementId) return undefined; + return usePlayerStore.getState().keyframeCache.get(buildCacheKey(sourceFile, elementId)); +} + +function writeKeyframeCache( + sourceFile: string, + elementId: string | null | undefined, + data: KeyframeCacheEntry | undefined, +): void { + if (!elementId) return; + usePlayerStore.getState().setKeyframeCache(buildCacheKey(sourceFile, elementId), data); +} + interface GsapScriptCommitsParams { projectIdRef: React.MutableRefObject; activeCompPath: string | null; @@ -113,7 +136,13 @@ export function useGsapScriptCommits({ async ( selection: DomEditSelection, mutation: Record, - options: { label: string; coalesceKey?: string; softReload?: boolean }, + options: { + label: string; + coalesceKey?: string; + softReload?: boolean; + skipReload?: boolean; + beforeReload?: () => void; + }, ) => { const pid = projectIdRef.current; if (!pid) return; @@ -135,6 +164,10 @@ export function useGsapScriptCommits({ onCacheInvalidate(); + if (options.skipReload) return; + + options.beforeReload?.(); + if (options.softReload && result.scriptText) { if (!applySoftReload(previewIframeRef.current, result.scriptText)) { reloadPreview(); @@ -225,7 +258,7 @@ export function useGsapScriptCommits({ async ( selection: DomEditSelection, method: "to" | "from" | "set" | "fromTo", - currentTime?: number, + _currentTime?: number, ) => { const { selector, autoId } = ensureElementAddressable(selection); @@ -253,12 +286,15 @@ export function useGsapScriptCommits({ if (!data.changed) return; } - const start = currentTime ?? (Number.parseFloat(selection.dataAttributes.start ?? "0") || 0); + const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1; + const position = Math.round(elStart * 1000) / 1000; + const duration = Math.round(elDuration * 1000) / 1000; const toDefaults: Record> = { from: { opacity: 0 }, - to: { opacity: 1 }, + to: { x: 0, y: 0, opacity: 1 }, set: { opacity: 1 }, - fromTo: { opacity: 1 }, + fromTo: { x: 0, y: 0, opacity: 1 }, }; await commitMutation( @@ -267,8 +303,8 @@ export function useGsapScriptCommits({ type: "add", targetSelector: selector, method, - position: start, - duration: method === "set" ? undefined : 0.5, + position, + duration: method === "set" ? undefined : duration, ease: method === "set" ? undefined : "power2.out", properties: toDefaults[method] ?? { opacity: 1 }, fromProperties: method === "fromTo" ? { opacity: 0 } : undefined, @@ -353,7 +389,93 @@ export function useGsapScriptCommits({ [commitMutation], ); + const addKeyframe = useCallback( + ( + selection: DomEditSelection, + animationId: string, + percentage: number, + property: string, + value: number | string, + ) => { + const sf = selection.sourceFile || activeCompPath || "index.html"; + const elementId = selection.id; + void executeOptimistic({ + apply: () => { + const prev = readKeyframeSnapshot(sf, elementId); + if (prev) { + const newKeyframes = [ + ...prev.keyframes, + { percentage, properties: { [property]: value } }, + ].sort((a, b) => a.percentage - b.percentage); + writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes }); + } + return prev; + }, + persist: () => + commitMutation( + selection, + { type: "add-keyframe", animationId, percentage, properties: { [property]: value } }, + { label: `Add keyframe at ${percentage}%`, softReload: true }, + ), + rollback: (prev) => { + writeKeyframeCache(sf, elementId, prev); + }, + }); + }, + [commitMutation, activeCompPath], + ); + + const removeKeyframe = useCallback( + (selection: DomEditSelection, animationId: string, percentage: number) => { + const sf = selection.sourceFile || activeCompPath || "index.html"; + const elementId = selection.id; + void executeOptimistic({ + apply: () => { + const prev = readKeyframeSnapshot(sf, elementId); + if (prev) { + const newKeyframes = prev.keyframes.filter((kf) => kf.percentage !== percentage); + writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes }); + } + return prev; + }, + persist: () => + commitMutation( + selection, + { type: "remove-keyframe", animationId, percentage }, + { label: `Remove keyframe at ${percentage}%`, softReload: true }, + ), + rollback: (prev) => { + writeKeyframeCache(sf, elementId, prev); + }, + }); + }, + [commitMutation, activeCompPath], + ); + + const convertToKeyframes = useCallback( + (selection: DomEditSelection, animationId: string) => { + void commitMutation( + selection, + { type: "convert-to-keyframes", animationId }, + { label: "Convert to keyframes" }, + ); + }, + [commitMutation], + ); + + const removeAllKeyframes = useCallback( + (selection: DomEditSelection, animationId: string) => { + void commitMutation( + selection, + { type: "remove-all-keyframes", animationId }, + { label: "Remove all keyframes", softReload: true }, + ); + }, + [commitMutation], + ); + return { + commitMutation, updateGsapProperty, updateGsapMeta, deleteGsapAnimation, @@ -363,5 +485,9 @@ export function useGsapScriptCommits({ updateGsapFromProperty, addGsapFromProperty, removeGsapFromProperty, + addKeyframe, + removeKeyframe, + convertToKeyframes, + removeAllKeyframes, }; } diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index ecca0b3ba..70fafa9a4 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -1,5 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser"; +import { usePlayerStore } from "../player/store/playerStore"; + +function extractIdFromSelector(selector: string): string | null { + const match = selector.match(/^#([\w-]+)/); + return match ? match[1] : null; +} /** The selected element's identity for matching tweens to it. */ export interface GsapElementTarget { @@ -28,7 +34,7 @@ export function getAnimationsForElement( ); } -async function fetchParsedAnimations( +export async function fetchParsedAnimations( projectId: string, sourceFile: string, ): Promise { @@ -98,6 +104,16 @@ export function useGsapAnimationsForElement( [allAnimations, targetId, targetSelector], ); + // Populate keyframe cache for the selected element. + // Key format must match timeline element keys: "sourceFile#domId". + const elementId = target?.id ?? null; + useEffect(() => { + if (!elementId) return; + const { setKeyframeCache } = usePlayerStore.getState(); + const withKeyframes = animations.find((a) => a.keyframes); + setKeyframeCache(`${sourceFile}#${elementId}`, withKeyframes?.keyframes ?? undefined); + }, [elementId, sourceFile, animations]); + return { animations, multipleTimelines, unsupportedTimelinePattern }; } @@ -106,3 +122,38 @@ export function useGsapCacheVersion() { const bump = useCallback(() => setVersion((v) => v + 1), []); return { version, bump }; } + +/** + * Fetch GSAP animations for a file and populate the keyframe cache for all + * elements. Called from the Timeline component so diamonds show without + * requiring a selection. + */ +export function usePopulateKeyframeCacheForFile( + projectId: string | null, + sourceFile: string, + version: number, +): void { + const lastFetchKeyRef = useRef(""); + + useEffect(() => { + const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`; + if (fetchKey === lastFetchKeyRef.current) return; + lastFetchKeyRef.current = fetchKey; + if (!projectId) return; + + let cancelled = false; + fetchParsedAnimations(projectId, sourceFile).then((parsed) => { + if (cancelled || !parsed) return; + const { setKeyframeCache } = usePlayerStore.getState(); + for (const anim of parsed.animations) { + if (!anim.keyframes) continue; + const id = extractIdFromSelector(anim.targetSelector); + if (id) setKeyframeCache(`${sourceFile}#${id}`, anim.keyframes); + } + }); + + return () => { + cancelled = true; + }; + }, [projectId, sourceFile, version]); +} diff --git a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx new file mode 100644 index 000000000..9f410a0c4 --- /dev/null +++ b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx @@ -0,0 +1,151 @@ +import { memo, useCallback, useEffect, useRef } from "react"; +import { EASE_LABELS } from "../../components/editor/gsapAnimationConstants"; + +export interface KeyframeDiamondContextMenuState { + x: number; + y: number; + elementId: string; + percentage: number; + currentEase?: string; +} + +interface KeyframeDiamondContextMenuProps { + state: KeyframeDiamondContextMenuState; + onClose: () => void; + onDelete: (elementId: string, percentage: number) => void; + onChangeEase: (elementId: string, percentage: number, ease: string) => void; + onCopyProperties: (elementId: string, percentage: number) => void; +} + +const EASE_PRESETS = [ + "none", + "power1.out", + "power2.out", + "power3.out", + "power1.in", + "power2.in", + "power1.inOut", + "power2.inOut", + "back.out", + "elastic.out", + "bounce.out", + "expo.out", +] as const; + +export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({ + state, + onClose, + onDelete, + onChangeEase, + onCopyProperties, +}: KeyframeDiamondContextMenuProps) { + const menuRef = useRef(null); + const easeSubmenuRef = useRef(null); + + const dismiss = useCallback( + (e: MouseEvent | KeyboardEvent) => { + if (e instanceof KeyboardEvent && e.key !== "Escape") return; + if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return; + onClose(); + }, + [onClose], + ); + + useEffect(() => { + document.addEventListener("mousedown", dismiss); + document.addEventListener("keydown", dismiss); + return () => { + document.removeEventListener("mousedown", dismiss); + document.removeEventListener("keydown", dismiss); + }; + }, [dismiss]); + + const adjustedX = Math.min(state.x, window.innerWidth - 200); + const adjustedY = Math.min(state.y, window.innerHeight - 300); + + const currentEaseLabel = state.currentEase + ? (EASE_LABELS[state.currentEase] ?? state.currentEase) + : "Default"; + + return ( +
+ {/* Ease submenu */} +
+ +
+ {EASE_PRESETS.map((ease) => ( + + ))} +
+
+ + {/* Separator */} +
+ + {/* Delete */} + + + {/* Copy Properties */} + +
+ ); +}); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 7e61b10b8..11a0ec7ef 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -11,6 +11,10 @@ import { getTimelinePixelsPerSecond } from "./timelineZoom"; import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop"; import { TimelineEmptyState } from "./TimelineEmptyState"; import { TimelineCanvas } from "./TimelineCanvas"; +import { + KeyframeDiamondContextMenu, + type KeyframeDiamondContextMenuState, +} from "./KeyframeDiamondContextMenu"; import { useTimelineClipDrag } from "./useTimelineClipDrag"; import { GUTTER, @@ -67,6 +71,10 @@ interface TimelineProps { ) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onSelectElement?: (element: TimelineElement | null) => void; + onDeleteKeyframe?: (elementId: string, percentage: number) => void; + onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void; + onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; + onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; theme?: Partial; } @@ -83,6 +91,10 @@ export const Timeline = memo(function Timeline({ onResizeElement, onBlockedEditAttempt, onSelectElement, + onDeleteKeyframe, + onChangeKeyframeEase, + onMoveKeyframe, + onToggleKeyframeAtPlayhead, theme: themeOverrides, }: TimelineProps = {}) { const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]); @@ -120,6 +132,7 @@ export const Timeline = memo(function Timeline({ const [showPopover, setShowPopover] = useState(false); const [showShortcutHint, setShowShortcutHint] = useState(true); + const [kfContextMenu, setKfContextMenu] = useState(null); const [viewportWidth, setViewportWidth] = useState(0); const roRef = useRef(null); const shortcutHintRafRef = useRef(0); @@ -231,6 +244,10 @@ export const Timeline = memo(function Timeline({ }, [draggedClip, trackOrder]); const totalH = getTimelineCanvasHeight(displayTrackOrder.length); + const keyframeCache = usePlayerStore((s) => s.keyframeCache); + const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes); + const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe); + const selectedElement = useMemo( () => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null, [elements, selectedElementId], @@ -477,6 +494,42 @@ export const Timeline = memo(function Timeline({ shiftClickClipRef={shiftClickClipRef} getPreviewElement={getPreviewElement} getTrackStyle={getTrackStyle} + keyframeCache={keyframeCache} + selectedKeyframes={selectedKeyframes} + currentTime={currentTime} + onToggleKeyframeAtPlayhead={onToggleKeyframeAtPlayhead} + onClickKeyframe={(el, pct) => { + usePlayerStore.getState().clearSelectedKeyframes(); + const elKey = el.key ?? el.id; + setSelectedElementId(elKey); + onSelectElement?.(el); + const absTime = el.start + (pct / 100) * el.duration; + onSeek?.(absTime); + }} + onShiftClickKeyframe={(elId, pct) => { + toggleSelectedKeyframe(`${elId}:${pct}`); + }} + onDragKeyframe={(el, oldPct, newPct) => { + onMoveKeyframe?.(el, oldPct, newPct); + }} + onContextMenuKeyframe={(e, elId, pct) => { + const el = elements.find((x) => (x.key ?? x.id) === elId); + if (el) { + setSelectedElementId(elId); + onSelectElement?.(el); + const absTime = el.start + (pct / 100) * el.duration; + onSeek?.(absTime); + } + const kfData = keyframeCache.get(elId); + const kf = kfData?.keyframes.find((k) => k.percentage === pct); + setKfContextMenu({ + x: e.clientX, + y: e.clientY, + elementId: elId, + percentage: pct, + currentEase: kf?.ease ?? kfData?.ease, + }); + }} />
@@ -511,6 +564,22 @@ export const Timeline = memo(function Timeline({ }} /> )} + + {kfContextMenu && ( + setKfContextMenu(null)} + onDelete={(elId, pct) => onDeleteKeyframe?.(elId, pct)} + onChangeEase={(elId, pct, ease) => onChangeKeyframeEase?.(elId, pct, ease)} + onCopyProperties={(elId, pct) => { + const kfData = keyframeCache.get(elId); + const kf = kfData?.keyframes.find((k) => k.percentage === pct); + if (kf) { + void navigator.clipboard.writeText(JSON.stringify(kf.properties, null, 2)); + } + }} + /> + )}
); }); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index e9af933ed..1d9233930 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -1,5 +1,6 @@ import { memo, type ReactNode } from "react"; import { TimelineClip } from "./TimelineClip"; +import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; import { TimelineRuler } from "./TimelineRuler"; import { getTimelineEditCapabilities, @@ -8,9 +9,10 @@ import { } from "./timelineEditing"; import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme"; import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout"; -import type { TimelineElement } from "../store/playerStore"; +import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore"; import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag"; import type { TrackVisualStyle } from "./timelineIcons"; +import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; interface TimelineCanvasProps { major: number[]; @@ -58,6 +60,14 @@ interface TimelineCanvasProps { } | null>; getPreviewElement: (element: TimelineElement) => TimelineElement; getTrackStyle: (tag: string) => TrackVisualStyle; + keyframeCache?: Map; + selectedKeyframes: Set; + currentTime: number; + onClickKeyframe?: (element: TimelineElement, percentage: number) => void; + onShiftClickKeyframe?: (elementId: string, percentage: number) => void; + onDragKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; + onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; + onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; } export const TimelineCanvas = memo(function TimelineCanvas({ @@ -99,6 +109,14 @@ export const TimelineCanvas = memo(function TimelineCanvas({ shiftClickClipRef, getPreviewElement, getTrackStyle, + keyframeCache, + selectedKeyframes, + currentTime, + onClickKeyframe, + onShiftClickKeyframe, + onDragKeyframe, + onContextMenuKeyframe, + onToggleKeyframeAtPlayhead: _onToggleKeyframeAtPlayhead, }: TimelineCanvasProps) { const draggedElement = draggedClip?.element ?? null; const activeDraggedElement = @@ -328,6 +346,28 @@ export const TimelineCanvas = memo(function TimelineCanvas({ }} > {renderClipChildren(previewElement, clipStyle)} + {STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && ( + 0 + ? ((currentTime - previewElement.start) / previewElement.duration) * 100 + : 0 + } + elementId={elementKey} + selectedKeyframes={selectedKeyframes} + onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)} + onShiftClickKeyframe={onShiftClickKeyframe} + onDragKeyframe={(oldPct, newPct) => + onDragKeyframe?.(previewElement, oldPct, newPct) + } + onContextMenuKeyframe={onContextMenuKeyframe} + /> + )} ); })} diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index a003a5cb0..c964545a8 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -69,7 +69,9 @@ export const TimelineClip = memo(function TimelineClip({
; + ease?: string; +} + +interface KeyframeCacheEntry { + format: string; + keyframes: KeyframeEntry[]; + ease?: string; + easeEach?: string; +} + +interface TimelineClipDiamondsProps { + keyframesData: KeyframeCacheEntry; + clipWidthPx: number; + clipHeightPx: number; + accentColor: string; + isSelected: boolean; + currentPercentage: number; + elementId: string; + selectedKeyframes: Set; + onClickKeyframe?: (percentage: number) => void; + onShiftClickKeyframe?: (elementId: string, percentage: number) => void; + onDragKeyframe?: (percentage: number, newPercentage: number) => void; + onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; +} + +const DIAMOND_RATIO = 0.8; + +export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ + keyframesData, + clipWidthPx, + clipHeightPx, + accentColor, + isSelected, + currentPercentage, + elementId, + selectedKeyframes, + onClickKeyframe, + onShiftClickKeyframe, + onDragKeyframe, + onContextMenuKeyframe, +}: TimelineClipDiamondsProps) { + const dragRef = useRef<{ startX: number; startPct: number } | null>(null); + + if (clipWidthPx < 20) return null; + + const diamondSize = Math.round(clipHeightPx * DIAMOND_RATIO); + const half = diamondSize / 2; + const sorted = keyframesData.keyframes.slice().sort((a, b) => a.percentage - b.percentage); + const baseColor = isSelected ? accentColor : "#a3a3a3"; + const baseOpacity = isSelected ? 0.4 : 0.25; + + const handleClick = (e: React.MouseEvent, pct: number) => { + e.stopPropagation(); + if (e.shiftKey) { + onShiftClickKeyframe?.(elementId, pct); + } else { + onClickKeyframe?.(pct); + } + }; + + const handlePointerDown = (e: React.PointerEvent, pct: number) => { + if (e.button !== 0) return; + e.stopPropagation(); + const startX = e.clientX; + + const handleMove = (me: PointerEvent) => { + const dx = me.clientX - startX; + if (Math.abs(dx) > 4) { + dragRef.current = { startX, startPct: pct }; + } + }; + + const handleUp = (ue: PointerEvent) => { + document.removeEventListener("pointermove", handleMove); + document.removeEventListener("pointerup", handleUp); + const start = dragRef.current; + dragRef.current = null; + if (!start) return; + const dx = ue.clientX - start.startX; + const dPct = (dx / clipWidthPx) * 100; + const newPct = Math.max(0, Math.min(100, Math.round(start.startPct + dPct))); + if (Math.abs(newPct - start.startPct) > 0.5) { + onDragKeyframe?.(start.startPct, newPct); + } + }; + + document.addEventListener("pointermove", handleMove); + document.addEventListener("pointerup", handleUp); + }; + + return ( +
+ {sorted.map((kf, i) => { + if (i === 0) return null; + const prev = sorted[i - 1]!; + const x1 = (prev.percentage / 100) * clipWidthPx; + const x2 = (kf.percentage / 100) * clipWidthPx; + return ( +
+ ); + })} + + {sorted.map((kf) => { + const leftPx = (kf.percentage / 100) * clipWidthPx - half; + const kfKey = `${elementId}:${kf.percentage}`; + const isKfSelected = selectedKeyframes.has(kfKey); + const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.05; + const color = isKfSelected || atPlayhead ? accentColor : "#a3a3a3"; + return ( + + ); + })} +
+ ); +}); diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts index a690a200c..abcd13cc8 100644 --- a/packages/studio/src/player/components/timelineLayout.ts +++ b/packages/studio/src/player/components/timelineLayout.ts @@ -3,7 +3,7 @@ import type { ZoomMode } from "../store/playerStore"; /* ── Layout constants ──────────────────────────────────────────────── */ export const GUTTER = 32; -export const TRACK_H = 72; +export const TRACK_H = 48; export const RULER_H = 24; export const CLIP_Y = 3; export const CLIP_HANDLE_W = 18; diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index fdbf18925..d67aad396 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -1,6 +1,18 @@ import { create } from "zustand"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; +/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */ +export interface KeyframeCacheEntry { + format: string; + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>; + ease?: string; + easeEach?: string; +} + export interface TimelineElement { id: string; label?: string; @@ -51,6 +63,15 @@ interface PlayerState { /** Work-area out-point (seconds). When set, loop ends here and E jumps here. */ outPoint: number | null; + /** Set of selected keyframe keys in format `${elementId}:${percentage}`. */ + selectedKeyframes: Set; + toggleSelectedKeyframe: (key: string) => void; + clearSelectedKeyframes: () => void; + + /** Keyframe data per element id, populated from parsed GSAP animations. */ + keyframeCache: Map; + setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void; + setIsPlaying: (playing: boolean) => void; setCurrentTime: (time: number) => void; setDuration: (duration: number) => void; @@ -107,6 +128,25 @@ export const usePlayerStore = create((set) => ({ inPoint: null, outPoint: null, + selectedKeyframes: new Set(), + toggleSelectedKeyframe: (key) => + set((s) => { + const next = new Set(s.selectedKeyframes); + if (next.has(key)) next.delete(key); + else next.add(key); + return { selectedKeyframes: next }; + }), + clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }), + + keyframeCache: new Map(), + setKeyframeCache: (elementId, data) => + set((s) => { + const next = new Map(s.keyframeCache); + if (data) next.set(elementId, data); + else next.delete(elementId); + return { keyframeCache: next }; + }), + requestedSeekTime: null, requestSeek: (time) => set({ requestedSeekTime: time }), clearSeekRequest: () => set({ requestedSeekTime: null }), @@ -169,5 +209,7 @@ export const usePlayerStore = create((set) => ({ selectedElementId: null, inPoint: null, outPoint: null, + selectedKeyframes: new Set(), + keyframeCache: new Map(), }), })); diff --git a/packages/studio/src/utils/editHistory.ts b/packages/studio/src/utils/editHistory.ts index 7e4b52d73..f10474b4b 100644 --- a/packages/studio/src/utils/editHistory.ts +++ b/packages/studio/src/utils/editHistory.ts @@ -61,7 +61,7 @@ export type EditHistoryTransitionResult = }; const DEFAULT_MAX_ENTRIES = 100; -const DEFAULT_COALESCE_MS = 1500; +const DEFAULT_COALESCE_MS = 300; export function hashEditHistoryContent(content: string): string { let hash = 2166136261; diff --git a/packages/studio/src/utils/optimisticUpdate.test.ts b/packages/studio/src/utils/optimisticUpdate.test.ts new file mode 100644 index 000000000..b1c0ba297 --- /dev/null +++ b/packages/studio/src/utils/optimisticUpdate.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from "vitest"; +import { executeOptimistic } from "./optimisticUpdate"; + +describe("executeOptimistic", () => { + it("calls apply then persist on success, never rollback", async () => { + const apply = vi.fn(() => "snapshot"); + const persist = vi.fn(() => Promise.resolve()); + const rollback = vi.fn(); + + await executeOptimistic({ apply, persist, rollback }); + + expect(apply).toHaveBeenCalledOnce(); + expect(persist).toHaveBeenCalledOnce(); + expect(rollback).not.toHaveBeenCalled(); + }); + + it("calls rollback with snapshot on persist failure", async () => { + const apply = vi.fn(() => ({ prev: "data" })); + const persist = vi.fn(() => Promise.reject(new Error("network"))); + const rollback = vi.fn(); + + await executeOptimistic({ apply, persist, rollback }); + + expect(apply).toHaveBeenCalledOnce(); + expect(persist).toHaveBeenCalledOnce(); + expect(rollback).toHaveBeenCalledWith({ prev: "data" }); + }); + + it("preserves complex snapshot objects through rollback", async () => { + const snapshot = { + format: "percentage", + keyframes: [{ percentage: 0, properties: { opacity: 0 } }], + }; + const apply = vi.fn(() => structuredClone(snapshot)); + const persist = vi.fn(() => Promise.reject(new Error("500"))); + const rollback = vi.fn(); + + await executeOptimistic({ apply, persist, rollback }); + + expect(rollback).toHaveBeenCalledOnce(); + expect(rollback.mock.calls[0][0]).toEqual(snapshot); + }); + + it("handles undefined snapshot for rollback", async () => { + const apply = vi.fn(() => undefined); + const persist = vi.fn(() => Promise.reject(new Error("timeout"))); + const rollback = vi.fn(); + + await executeOptimistic({ apply, persist, rollback }); + + expect(rollback).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/packages/studio/src/utils/optimisticUpdate.ts b/packages/studio/src/utils/optimisticUpdate.ts new file mode 100644 index 000000000..90e1bfe94 --- /dev/null +++ b/packages/studio/src/utils/optimisticUpdate.ts @@ -0,0 +1,18 @@ +export interface OptimisticUpdateOptions { + /** Apply the change to local state immediately. Return a snapshot for rollback. */ + apply: () => TSnapshot; + /** Persist the change to the server. */ + persist: () => Promise; + /** Revert local state using the snapshot if persist fails. */ + rollback: (snapshot: TSnapshot) => void; +} + +export async function executeOptimistic(options: OptimisticUpdateOptions): Promise { + const snapshot = options.apply(); + try { + await options.persist(); + } catch (error) { + options.rollback(snapshot); + console.warn("[optimistic] Mutation failed, rolled back:", error); + } +}