From e75d550b300d0ceccbd89d59fa6c0cfc6f080312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 29 May 2026 23:40:06 -0400 Subject: [PATCH 01/45] feat(studio): enable GSAP design panel by default Flip the STUDIO_GSAP_PANEL_ENABLED fallback from false to true. The panel has been behind a feature flag since initial development; after the bug bash (hf#1126) and soft-reload optimization (hf#1129) it is stable enough for general use. Users can still disable it via VITE_STUDIO_ENABLE_GSAP_PANEL=false if needed. --- .../studio/src/components/editor/manualEditingAvailability.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index 6845956a6..1a0f5a66b 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -68,7 +68,7 @@ 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"], - false, + true, ); export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED; From 22cec444f2b52b06dec7fdc01ab0534ccfec5231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 15:09:26 -0400 Subject: [PATCH 02/45] feat(core): expand SUPPORTED_PROPS to 20, add GsapKeyframesData types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add skewX, skewY, borderRadius, color, backgroundColor, borderColor, filter, fontSize, letterSpacing to SUPPORTED_PROPS (11 → 20) and introduce GsapPercentageKeyframe, GsapKeyframeFormat, GsapKeyframesData types for native GSAP keyframes support. --- packages/core/src/parsers/gsapConstants.ts | 20 ++++++++++++++++++-- packages/core/src/parsers/gsapParser.ts | 9 ++++++++- packages/core/src/parsers/gsapSerialize.ts | 17 +++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/core/src/parsers/gsapConstants.ts b/packages/core/src/parsers/gsapConstants.ts index 340892ebd..4f43e8824 100644 --- a/packages/core/src/parsers/gsapConstants.ts +++ b/packages/core/src/parsers/gsapConstants.ts @@ -6,17 +6,33 @@ */ 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 + "filter", ]; export const SUPPORTED_EASES = [ diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index 4b8df19ad..a9c75a887 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -12,7 +12,14 @@ import * as recast from "recast"; import { parse as babelParse } from "@babel/parser"; import { type GsapAnimation, type GsapMethod, type ParsedGsap } from "./gsapSerialize"; -export type { GsapAnimation, GsapMethod, ParsedGsap } from "./gsapSerialize"; +export type { + GsapAnimation, + GsapMethod, + ParsedGsap, + GsapKeyframesData, + GsapPercentageKeyframe, + GsapKeyframeFormat, +} from "./gsapSerialize"; export { serializeGsapAnimations, getAnimationsForElementId, 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 { From d7cfe05752105ef4c03989e31b2c894592e1ba35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 15:16:56 -0400 Subject: [PATCH 03/45] feat(core): parse native GSAP keyframes from tween vars (3 formats) The GSAP AST parser now recognizes the keyframes property on tweens instead of dropping it. Supports all three GSAP v3 keyframe formats: - Percentage objects: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200 } } - Object arrays: [{ x: 0, duration: 0.5 }, { x: 100, duration: 1 }] - Simple arrays: { x: [0, 100, 200], easeEach: "power2.inOut" } Each format is normalized into GsapPercentageKeyframe[] with percentage positions, per-keyframe properties, and optional ease. Three-level easing (tween ease, easeEach, per-keyframe ease) is preserved. Keyframes tweens correctly produce empty top-level properties since all animatable values live inside the keyframes structure. --- packages/core/src/parsers/gsapParser.test.ts | 144 +++++++++++ packages/core/src/parsers/gsapParser.ts | 238 ++++++++++++++++++- 2 files changed, 377 insertions(+), 5 deletions(-) diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index e1cbc8f06..f93886a28 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -1185,3 +1185,147 @@ 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); + }); +}); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index a9c75a887..4681a0592 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -10,7 +10,13 @@ */ 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, @@ -448,7 +454,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([ @@ -466,17 +472,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, @@ -484,11 +694,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. @@ -506,6 +728,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 = {}; @@ -533,6 +760,7 @@ function tweenCallToAnimation( ease, }; if (Object.keys(extras).length > 0) anim.extras = extras; + if (keyframesData) anim.keyframes = keyframesData; return anim; } From 7dca187782e65555d23d3d78e6176b5026880785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 15:23:34 -0400 Subject: [PATCH 04/45] =?UTF-8?q?feat(core):=20keyframe=20mutation=20funct?= =?UTF-8?q?ions=20=E2=80=94=20add/remove/update/convert/removeAll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five new exported functions for in-place AST manipulation of native GSAP percentage keyframes: - addKeyframeToScript: insert at sorted position, replace if exists - removeKeyframeFromScript: remove by percentage, collapse to flat if <2 remain - updateKeyframeInScript: replace properties at an existing percentage - convertToKeyframesInScript: convert flat to/from/fromTo to keyframes format - removeAllKeyframesFromScript: collapse all keyframes to flat tween (last kf) All follow the existing recast-based mutation pattern: parse AST, find target animation by stable ID, modify nodes in place, reprint preserving formatting. --- packages/core/src/parsers/gsapParser.test.ts | 173 ++++++++++++ packages/core/src/parsers/gsapParser.ts | 278 +++++++++++++++++++ 2 files changed, 451 insertions(+) diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index f93886a28..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"; @@ -1329,3 +1334,171 @@ describe("native GSAP keyframes parsing", () => { 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 4681a0592..2693f3b1d 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -1094,3 +1094,281 @@ 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. */ +function resolveConversionProps( + anim: GsapAnimation, + resolvedFromValues?: Record, +): { fromProps: Record; toProps: Record } { + if (anim.method === "to") { + return { fromProps: resolvedFromValues ?? {}, toProps: { ...anim.properties } }; + } + if (anim.method === "from") { + return { fromProps: { ...anim.properties }, toProps: resolvedFromValues ?? {} }; + } + // 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; +} From 259afd0dca710b1030b73e86a7d50a3456f3fe55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 15:25:22 -0400 Subject: [PATCH 05/45] feat(core): add keyframe mutation routes to Studio API Wire five new GSAP mutation types through the existing POST /projects/:id/gsap-mutations/* endpoint: add-keyframe, remove-keyframe, update-keyframe, convert-to-keyframes, and remove-all-keyframes. Each delegates to the corresponding parser function added in the previous commit. --- packages/core/src/studio-api/routes/files.ts | 64 +++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) 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); } From b8c2fffa3c696147adbdfa09477c49d12443da6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 15:27:36 -0400 Subject: [PATCH 06/45] feat(studio): add PROP_CONSTRAINTS and clampPropertyValue for animation properties --- .../src/components/editor/AnimationCard.tsx | 8 +++++- .../editor/gsapAnimationConstants.ts | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index 3849ce048..0ccd4f444 100644 --- a/packages/studio/src/components/editor/AnimationCard.tsx +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -9,9 +9,11 @@ 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"; @@ -27,7 +29,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; } diff --git a/packages/studio/src/components/editor/gsapAnimationConstants.ts b/packages/studio/src/components/editor/gsapAnimationConstants.ts index 425c0b850..35cf9e49b 100644 --- a/packages/studio/src/components/editor/gsapAnimationConstants.ts +++ b/packages/studio/src/components/editor/gsapAnimationConstants.ts @@ -123,6 +123,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 = { From ee419049ac37b205a6abe003ed4c718458a37076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 15:29:19 -0400 Subject: [PATCH 07/45] feat(core): spring physics solver with 5 presets for CustomEase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Damped harmonic oscillator solver that outputs SVG path data strings compatible with GSAP CustomEase.create(). Supports underdamped (bouncy), critically damped, and overdamped spring configurations. Presets: gentle, bouncy, stiff, wobbly, heavy — registered in SUPPORTED_EASES and EASE_LABELS so they appear in the Studio UI. --- packages/core/src/parsers/gsapConstants.ts | 5 ++ packages/core/src/parsers/gsapParser.ts | 2 + packages/core/src/parsers/springEase.test.ts | 89 +++++++++++++++++++ packages/core/src/parsers/springEase.ts | 88 ++++++++++++++++++ .../editor/gsapAnimationConstants.ts | 5 ++ 5 files changed, 189 insertions(+) create mode 100644 packages/core/src/parsers/springEase.test.ts create mode 100644 packages/core/src/parsers/springEase.ts diff --git a/packages/core/src/parsers/gsapConstants.ts b/packages/core/src/parsers/gsapConstants.ts index 4f43e8824..ff15db04e 100644 --- a/packages/core/src/parsers/gsapConstants.ts +++ b/packages/core/src/parsers/gsapConstants.ts @@ -61,4 +61,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.ts b/packages/core/src/parsers/gsapParser.ts index 2693f3b1d..ff9b90b7c 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -35,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"]); 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/studio/src/components/editor/gsapAnimationConstants.ts b/packages/studio/src/components/editor/gsapAnimationConstants.ts index 35cf9e49b..f70f39549 100644 --- a/packages/studio/src/components/editor/gsapAnimationConstants.ts +++ b/packages/studio/src/components/editor/gsapAnimationConstants.ts @@ -83,6 +83,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 = { From aa5135a6da5e9441c725b54b9af8d2fd402157e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 15:36:30 -0400 Subject: [PATCH 08/45] =?UTF-8?q?feat(studio):=20keyframe=20diamonds=20?= =?UTF-8?q?=E2=80=94=20design=20panel=20flag,=20timeline=20clip=20markers,?= =?UTF-8?q?=20keyframe=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .fallowrc.jsonc | 2 + .../src/components/editor/KeyframeDiamond.tsx | 49 ++++++++++++ .../editor/manualEditingAvailability.ts | 6 ++ .../studio/src/hooks/useGsapTweenCache.ts | 10 +++ .../studio/src/player/components/Timeline.tsx | 7 ++ .../src/player/components/TimelineCanvas.tsx | 17 ++++- .../components/TimelineClipDiamonds.tsx | 75 +++++++++++++++++++ .../studio/src/player/store/playerStore.ts | 26 +++++++ 8 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 packages/studio/src/components/editor/KeyframeDiamond.tsx create mode 100644 packages/studio/src/player/components/TimelineClipDiamonds.tsx diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index a14d83a41..9ca0e8831 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -27,6 +27,8 @@ "packages/producer/src/services/__fixtures__/crashOnMessageWorker.mjs", "scripts/*.{ts,mjs,js}", "scripts/*/run.mjs", + // Keyframe diamond component — wired into the design panel in a follow-up. + "packages/studio/src/components/editor/KeyframeDiamond.tsx", ], "ignorePatterns": [ "docs/**", 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/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index 1a0f5a66b..553bb5e8d 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -71,6 +71,12 @@ export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag( true, ); +export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag( + env, + ["VITE_STUDIO_ENABLE_KEYFRAMES", "VITE_STUDIO_KEYFRAMES_ENABLED"], + false, +); + export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED; export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled"; diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index ecca0b3ba..247419988 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser"; +import { usePlayerStore } from "../player/store/playerStore"; /** The selected element's identity for matching tweens to it. */ export interface GsapElementTarget { @@ -98,6 +99,15 @@ export function useGsapAnimationsForElement( [allAnimations, targetId, targetSelector], ); + // Populate keyframe cache when animations change + const elementId = target?.id ?? null; + useEffect(() => { + if (!elementId) return; + const { setKeyframeCache } = usePlayerStore.getState(); + const withKeyframes = animations.find((a) => a.keyframes); + setKeyframeCache(elementId, withKeyframes?.keyframes ?? undefined); + }, [elementId, animations]); + return { animations, multipleTimelines, unsupportedTimelinePattern }; } diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 7e61b10b8..b1d15620a 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -231,6 +231,8 @@ export const Timeline = memo(function Timeline({ }, [draggedClip, trackOrder]); const totalH = getTimelineCanvasHeight(displayTrackOrder.length); + const keyframeCache = usePlayerStore((s) => s.keyframeCache); + const selectedElement = useMemo( () => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null, [elements, selectedElementId], @@ -477,6 +479,11 @@ export const Timeline = memo(function Timeline({ shiftClickClipRef={shiftClickClipRef} getPreviewElement={getPreviewElement} getTrackStyle={getTrackStyle} + keyframeCache={keyframeCache} + onClickKeyframe={(el, pct) => { + const absTime = el.start + (pct / 100) * el.duration; + usePlayerStore.getState().setCurrentTime(absTime); + }} /> diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index e9af933ed..6169751c1 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,8 @@ interface TimelineCanvasProps { } | null>; getPreviewElement: (element: TimelineElement) => TimelineElement; getTrackStyle: (tag: string) => TrackVisualStyle; + keyframeCache?: Map; + onClickKeyframe?: (element: TimelineElement, percentage: number) => void; } export const TimelineCanvas = memo(function TimelineCanvas({ @@ -99,6 +103,8 @@ export const TimelineCanvas = memo(function TimelineCanvas({ shiftClickClipRef, getPreviewElement, getTrackStyle, + keyframeCache, + onClickKeyframe, }: TimelineCanvasProps) { const draggedElement = draggedClip?.element ?? null; const activeDraggedElement = @@ -328,6 +334,15 @@ export const TimelineCanvas = memo(function TimelineCanvas({ }} > {renderClipChildren(previewElement, clipStyle)} + {STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && ( + onClickKeyframe?.(previewElement, pct)} + /> + )} ); })} diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx new file mode 100644 index 000000000..e800b000c --- /dev/null +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -0,0 +1,75 @@ +import { memo } from "react"; + +interface KeyframeEntry { + percentage: number; + properties: Record; + ease?: string; +} + +interface KeyframeCacheEntry { + format: string; + keyframes: KeyframeEntry[]; + ease?: string; + easeEach?: string; +} + +interface TimelineClipDiamondsProps { + keyframesData: KeyframeCacheEntry; + clipWidthPx: number; + accentColor: string; + isSelected: boolean; + onClickKeyframe?: (percentage: number) => void; +} + +export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ + keyframesData, + clipWidthPx, + accentColor, + isSelected, + onClickKeyframe, +}: TimelineClipDiamondsProps) { + if (clipWidthPx < 20) return null; + + return ( +
+ {keyframesData.keyframes.map((kf) => { + const leftPx = (kf.percentage / 100) * clipWidthPx; + return ( + + ); + })} +
+ ); +}); diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index fdbf18925..b2eca8b38 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,10 @@ interface PlayerState { /** Work-area out-point (seconds). When set, loop ends here and E jumps here. */ outPoint: number | null; + /** 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 +123,15 @@ export const usePlayerStore = create((set) => ({ inPoint: null, outPoint: null, + 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 +194,6 @@ export const usePlayerStore = create((set) => ({ selectedElementId: null, inPoint: null, outPoint: null, + keyframeCache: new Map(), }), })); From 5f35279bd37a1cdbca9c94fffeea8c919a782862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 15:48:51 -0400 Subject: [PATCH 09/45] feat(studio): keyframe navigation controls + wire diamonds into design panel Add KeyframeNavigation component (prev/diamond/next inline controls) that enables per-property keyframe navigation in the Layout section of the PropertyPanel. The diamond state reflects whether the current playhead is on an existing keyframe (active), between keyframes (inactive), or has no keyframes at all (ghost). Clicking the diamond converts, adds, or removes a keyframe accordingly. Wire the new add-keyframe, remove-keyframe, and convert-to-keyframes mutations through useGsapScriptCommits -> useDomEditSession -> DomEditContext -> StudioRightPanel -> PropertyPanel, following the existing GSAP mutation pattern (commitMutation with undo history and soft reload). --- .../src/components/StudioRightPanel.tsx | 8 + .../components/editor/KeyframeNavigation.tsx | 139 ++++++++++++++ .../src/components/editor/PropertyPanel.tsx | 172 ++++++++++++++---- .../studio/src/contexts/DomEditContext.tsx | 9 + .../studio/src/hooks/useDomEditSession.ts | 30 +++ .../studio/src/hooks/useGsapScriptCommits.ts | 42 +++++ 6 files changed, 366 insertions(+), 34 deletions(-) create mode 100644 packages/studio/src/components/editor/KeyframeNavigation.tsx 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 ? ( ; + 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..0d8f38084 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; @@ -256,6 +271,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 +342,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)} + /> + )} +
{children}; diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 5942d8f30..2dd442831 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -221,6 +221,9 @@ export function useDomEditSession({ updateGsapFromProperty, addGsapFromProperty, removeGsapFromProperty, + addKeyframe, + removeKeyframe, + convertToKeyframes, } = useGsapScriptCommits({ projectIdRef, activeCompPath, @@ -342,6 +345,30 @@ 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], + ); + // Sync selection from preview document on load / refresh // eslint-disable-next-line no-restricted-syntax useEffect(() => { @@ -482,5 +509,8 @@ export function useDomEditSession({ handleGsapUpdateFromProperty, handleGsapAddFromProperty, handleGsapRemoveFromProperty, + handleGsapAddKeyframe, + handleGsapRemoveKeyframe, + handleGsapConvertToKeyframes, }; } diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index a45333388..e4018c8b8 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -353,6 +353,45 @@ export function useGsapScriptCommits({ [commitMutation], ); + const addKeyframe = useCallback( + ( + selection: DomEditSelection, + animationId: string, + percentage: number, + property: string, + value: number | string, + ) => { + void commitMutation( + selection, + { type: "add-keyframe", animationId, percentage, properties: { [property]: value } }, + { label: `Add keyframe at ${percentage}%`, softReload: true }, + ); + }, + [commitMutation], + ); + + const removeKeyframe = useCallback( + (selection: DomEditSelection, animationId: string, percentage: number) => { + void commitMutation( + selection, + { type: "remove-keyframe", animationId, percentage }, + { label: `Remove keyframe at ${percentage}%`, softReload: true }, + ); + }, + [commitMutation], + ); + + const convertToKeyframes = useCallback( + (selection: DomEditSelection, animationId: string) => { + void commitMutation( + selection, + { type: "convert-to-keyframes", animationId }, + { label: "Convert to keyframes" }, + ); + }, + [commitMutation], + ); + return { updateGsapProperty, updateGsapMeta, @@ -363,5 +402,8 @@ export function useGsapScriptCommits({ updateGsapFromProperty, addGsapFromProperty, removeGsapFromProperty, + addKeyframe, + removeKeyframe, + convertToKeyframes, }; } From bc91e4af0b46c69c5634bdc685c1b669b86cb710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 15:56:13 -0400 Subject: [PATCH 10/45] feat(studio): spring ease editor tab with mass/stiffness/damping sliders Add SpringEaseEditor component alongside the existing cubic-bezier editor in both MotionPanel and EaseCurveSection. Users toggle between Bezier and Spring modes via tab buttons in the Ease Curve section header. The spring editor provides: - Three sliders (mass 0.1-5, stiffness 10-500, damping 1-50) - Five presets from the core spring solver (gentle, bouncy, stiff, wobbly, heavy) - Live SVG curve preview matching the existing editor layout - Debounced commit (120ms) to avoid flooding GSAP CustomEase.create calls Adds @hyperframes/core/spring-ease subpath export so the studio can import generateSpringEaseData and SPRING_PRESETS without pulling in the recast- dependent gsap-parser subpath. From f41772af83e11e76fb685107f2de4ca8cea32720 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 15:58:37 -0400 Subject: [PATCH 11/45] feat(studio): backspace resets keyframes, delete deletes clips Split the Delete/Backspace hotkey handler: - Delete key: deletes the selected timeline clip (unchanged) - Backspace key: if the selected element has GSAP keyframes, removes all keyframes (collapsing to a flat tween) via the existing remove-all-keyframes mutation; falls through to delete if no keyframes are present Adds removeAllKeyframes to useGsapScriptCommits and wires it through useDomEditSession via a ref-bridge pattern in App.tsx (same pattern used for domEditElementDelete). From 79eed9bb78482f76efb8d0cdf54fbbbbfb6bd828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 15:59:23 -0400 Subject: [PATCH 12/45] feat(studio): optimistic update pattern for keyframe mutations Keyframe mutations (add, remove, convert-to-keyframes) now update the player store cache immediately and persist to the server async. On failure the cache rolls back to its pre-mutation state. Adds a generic executeOptimistic utility that takes apply/persist/rollback callbacks, used by the three keyframe mutation paths in useGsapScriptCommits. --- .../studio/src/hooks/useGsapScriptCommits.ts | 110 ++++++++++++++++-- .../studio/src/utils/optimisticUpdate.test.ts | 53 +++++++++ packages/studio/src/utils/optimisticUpdate.ts | 18 +++ 3 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 packages/studio/src/utils/optimisticUpdate.test.ts create mode 100644 packages/studio/src/utils/optimisticUpdate.ts diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index e4018c8b8..f13567c56 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,23 @@ async function mutateGsapScript( } } +/** Read the current keyframe cache entry for an element from the player store. */ +function readKeyframeSnapshot( + elementId: string | null | undefined, +): KeyframeCacheEntry | undefined { + if (!elementId) return undefined; + return usePlayerStore.getState().keyframeCache.get(elementId); +} + +/** Write a keyframe cache entry (or clear it) in the player store. */ +function writeKeyframeCache( + elementId: string | null | undefined, + data: KeyframeCacheEntry | undefined, +): void { + if (!elementId) return; + usePlayerStore.getState().setKeyframeCache(elementId, data); +} + interface GsapScriptCommitsParams { projectIdRef: React.MutableRefObject; activeCompPath: string | null; @@ -361,32 +380,98 @@ export function useGsapScriptCommits({ property: string, value: number | string, ) => { - void commitMutation( - selection, - { type: "add-keyframe", animationId, percentage, properties: { [property]: value } }, - { label: `Add keyframe at ${percentage}%`, softReload: true }, - ); + const elementId = selection.id; + void executeOptimistic({ + apply: () => { + const prev = readKeyframeSnapshot(elementId); + if (prev) { + const newKeyframes = [ + ...prev.keyframes, + { percentage, properties: { [property]: value } }, + ].sort((a, b) => a.percentage - b.percentage); + writeKeyframeCache(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(elementId, prev); + }, + }); }, [commitMutation], ); const removeKeyframe = useCallback( (selection: DomEditSelection, animationId: string, percentage: number) => { - void commitMutation( - selection, - { type: "remove-keyframe", animationId, percentage }, - { label: `Remove keyframe at ${percentage}%`, softReload: true }, - ); + const elementId = selection.id; + void executeOptimistic({ + apply: () => { + const prev = readKeyframeSnapshot(elementId); + if (prev) { + const newKeyframes = prev.keyframes.filter((kf) => kf.percentage !== percentage); + writeKeyframeCache(elementId, { ...prev, keyframes: newKeyframes }); + } + return prev; + }, + persist: () => + commitMutation( + selection, + { type: "remove-keyframe", animationId, percentage }, + { label: `Remove keyframe at ${percentage}%`, softReload: true }, + ), + rollback: (prev) => { + writeKeyframeCache(elementId, prev); + }, + }); }, [commitMutation], ); const convertToKeyframes = useCallback( + (selection: DomEditSelection, animationId: string) => { + const elementId = selection.id; + void executeOptimistic({ + apply: () => { + const prev = readKeyframeSnapshot(elementId); + if (!prev) { + // Seed a minimal percentage-format entry so the UI shows keyframe + // diamonds immediately, before the server responds. + writeKeyframeCache(elementId, { + format: "percentage", + keyframes: [ + { percentage: 0, properties: {} }, + { percentage: 100, properties: {} }, + ], + }); + } + return prev; + }, + persist: () => + commitMutation( + selection, + { type: "convert-to-keyframes", animationId }, + { label: "Convert to keyframes" }, + ), + rollback: (prev) => { + writeKeyframeCache(elementId, prev); + }, + }); + }, + [commitMutation], + ); + + const removeAllKeyframes = useCallback( (selection: DomEditSelection, animationId: string) => { void commitMutation( selection, - { type: "convert-to-keyframes", animationId }, - { label: "Convert to keyframes" }, + { type: "remove-all-keyframes", animationId }, + { label: "Remove all keyframes", softReload: true }, ); }, [commitMutation], @@ -405,5 +490,6 @@ export function useGsapScriptCommits({ addKeyframe, removeKeyframe, convertToKeyframes, + removeAllKeyframes, }; } 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); + } +} From f7dabe679642b21e1d783c130da3d7d64b8ae669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 16:17:13 -0400 Subject: [PATCH 13/45] =?UTF-8?q?feat(studio):=20expanded=20diamond=20inte?= =?UTF-8?q?ractions=20=E2=80=94=20selection,=20drag,=20context=20menu,=20d?= =?UTF-8?q?elete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add multi-select, drag-to-reposition, right-click context menu, and Delete key support for timeline keyframe diamonds: - selectedKeyframes Set in player store with toggle/clear actions - Shift+click diamonds to add/remove from selection - Pointer-capture drag to reposition keyframes by percentage - Right-click context menu with ease picker, delete, and copy properties - Delete/Backspace prioritizes selected keyframes over clip deletion - Wire mutation callbacks through NLELayout and StudioPreviewArea --- packages/studio/src/App.tsx | 18 ++- .../src/components/StudioPreviewArea.tsx | 23 +++ .../studio/src/components/nle/NLELayout.tsx | 9 ++ .../studio/src/contexts/DomEditContext.tsx | 6 + packages/studio/src/hooks/useAppHotkeys.ts | 30 +++- .../studio/src/hooks/useDomEditSession.ts | 25 +++ .../components/KeyframeDiamondContextMenu.tsx | 151 ++++++++++++++++++ .../studio/src/player/components/Timeline.tsx | 48 ++++++ .../src/player/components/TimelineCanvas.tsx | 15 ++ .../components/TimelineClipDiamonds.tsx | 72 ++++++++- .../studio/src/player/store/playerStore.ts | 16 ++ 11 files changed, 404 insertions(+), 9 deletions(-) create mode 100644 packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 56af6bef2..e2922001f 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -266,8 +266,9 @@ 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 { handleCopy, handlePaste, handleCut } = useClipboard({ projectId, activeCompPath, @@ -299,6 +300,8 @@ export function StudioApp() { handleCopy, handlePaste, handleCut, + onResetKeyframes: () => resetKeyframesRef.current(), + onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(), }); const selectSidebarTabStable = useCallback( @@ -349,7 +352,16 @@ export function StudioApp() { domEditSelectionBridgeRef.current = domEditSession.domEditSelection; clearDomSelectionRef.current = domEditSession.clearDomSelection; handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete; - + resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes; + 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, diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 63c161257..34f9a8998 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -101,6 +101,10 @@ export function StudioPreviewArea({ handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, + selectedGsapAnimations, + handleGsapRemoveKeyframe, + handleGsapUpdateMeta, + handleGsapAddKeyframe, } = useDomEditContext(); return ( @@ -121,6 +125,25 @@ export function StudioPreviewArea({ onResizeElement={handleTimelineElementResize} onBlockedEditAttempt={handleBlockedTimelineEdit} onSelectTimelineElement={handleTimelineElementSelect} + onDeleteKeyframe={(_elId, pct) => { + 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); + } + }} onCompIdToSrcChange={setCompIdToSrc} onCompositionLoadingChange={setCompositionLoading} onCompositionChange={(compPath) => { diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 07562e113..2f08a9d20 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -70,6 +70,9 @@ 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; /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */ onCompIdToSrcChange?: (map: Map) => void; /** Whether the timeline panel is visible (default: true) */ @@ -118,6 +121,9 @@ export const NLELayout = memo(function NLELayout({ onResizeElement, onBlockedEditAttempt, onSelectTimelineElement, + onDeleteKeyframe, + onChangeKeyframeEase, + onMoveKeyframe, onCompIdToSrcChange, timelineVisible, onToggleTimeline, @@ -448,6 +454,9 @@ export const NLELayout = memo(function NLELayout({ onResizeElement={onResizeElement} onBlockedEditAttempt={onBlockedEditAttempt} onSelectElement={onSelectTimelineElement} + onDeleteKeyframe={onDeleteKeyframe} + onChangeKeyframeEase={onChangeKeyframeEase} + onMoveKeyframe={onMoveKeyframe} />
{timelineFooter &&
{timelineFooter}
} diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index c39a90ae0..267cfc6a3 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -68,6 +68,8 @@ export function DomEditProvider({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, + handleResetSelectedElementKeyframes, }, children, }: { @@ -131,6 +133,8 @@ export function DomEditProvider({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, + handleResetSelectedElementKeyframes, }), [ domEditSelection, @@ -188,6 +192,8 @@ export function DomEditProvider({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, + handleResetSelectedElementKeyframes, ], ); return {children}; diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 4308b4bcd..d29a62c3e 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -77,6 +77,8 @@ interface UseAppHotkeysParams { handleCopy: () => boolean; handlePaste: () => Promise; handleCut: () => Promise; + onResetKeyframes: () => boolean; + onDeleteSelectedKeyframes: () => void; } // ── Hook ── @@ -98,6 +100,8 @@ export function useAppHotkeys({ handleCopy, handlePaste, handleCut, + onResetKeyframes, + onDeleteSelectedKeyframes, }: UseAppHotkeysParams) { const previewHotkeyWindowRef = useRef(null); const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined); @@ -197,6 +201,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 +300,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 +308,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/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 2dd442831..96378d774 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -224,6 +224,7 @@ export function useDomEditSession({ addKeyframe, removeKeyframe, convertToKeyframes, + removeAllKeyframes, } = useGsapScriptCommits({ projectIdRef, activeCompPath, @@ -369,6 +370,28 @@ export function useDomEditSession({ [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(() => { @@ -512,5 +535,7 @@ export function useDomEditSession({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, + handleResetSelectedElementKeyframes, }; } 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 b1d15620a..b5a2f0889 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,9 @@ 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; theme?: Partial; } @@ -83,6 +90,9 @@ export const Timeline = memo(function Timeline({ onResizeElement, onBlockedEditAttempt, onSelectElement, + onDeleteKeyframe, + onChangeKeyframeEase, + onMoveKeyframe, theme: themeOverrides, }: TimelineProps = {}) { const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]); @@ -120,6 +130,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); @@ -232,6 +243,8 @@ export const Timeline = memo(function Timeline({ 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, @@ -480,10 +493,29 @@ export const Timeline = memo(function Timeline({ getPreviewElement={getPreviewElement} getTrackStyle={getTrackStyle} keyframeCache={keyframeCache} + selectedKeyframes={selectedKeyframes} onClickKeyframe={(el, pct) => { + usePlayerStore.getState().clearSelectedKeyframes(); const absTime = el.start + (pct / 100) * el.duration; usePlayerStore.getState().setCurrentTime(absTime); }} + onShiftClickKeyframe={(elId, pct) => { + toggleSelectedKeyframe(`${elId}:${pct}`); + }} + onDragKeyframe={(el, oldPct, newPct) => { + onMoveKeyframe?.(el, oldPct, newPct); + }} + onContextMenuKeyframe={(e, elId, pct) => { + 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, + }); + }} />
@@ -518,6 +550,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 6169751c1..b6f8f56cb 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -61,7 +61,11 @@ interface TimelineCanvasProps { getPreviewElement: (element: TimelineElement) => TimelineElement; getTrackStyle: (tag: string) => TrackVisualStyle; keyframeCache?: Map; + selectedKeyframes: Set; 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; } export const TimelineCanvas = memo(function TimelineCanvas({ @@ -104,7 +108,11 @@ export const TimelineCanvas = memo(function TimelineCanvas({ getPreviewElement, getTrackStyle, keyframeCache, + selectedKeyframes, onClickKeyframe, + onShiftClickKeyframe, + onDragKeyframe, + onContextMenuKeyframe, }: TimelineCanvasProps) { const draggedElement = draggedClip?.element ?? null; const activeDraggedElement = @@ -340,7 +348,14 @@ export const TimelineCanvas = memo(function TimelineCanvas({ clipWidthPx={Math.max(previewElement.duration * pps, 4)} accentColor={clipStyle.accent} isSelected={isSelected} + 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/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index e800b000c..317cebd08 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { memo, useRef } from "react"; interface KeyframeEntry { percentage: number; @@ -18,7 +18,12 @@ interface TimelineClipDiamondsProps { clipWidthPx: number; accentColor: string; isSelected: boolean; + 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; } export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ @@ -26,14 +31,55 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ clipWidthPx, accentColor, isSelected, + elementId, + selectedKeyframes, onClickKeyframe, + onShiftClickKeyframe, + onDragKeyframe, + onContextMenuKeyframe, }: TimelineClipDiamondsProps) { + const dragRef = useRef<{ startX: number; startPct: number } | null>(null); + if (clipWidthPx < 20) return null; + const handlePointerDown = (e: React.PointerEvent, pct: number) => { + if (e.button !== 0) return; + e.stopPropagation(); + e.currentTarget.setPointerCapture(e.pointerId); + dragRef.current = { startX: e.clientX, startPct: pct }; + + const handleMove = (_me: PointerEvent) => { + // Track movement — visual feedback could be added here + }; + + 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); + } else if (ue.shiftKey) { + onShiftClickKeyframe?.(elementId, start.startPct); + } else { + onClickKeyframe?.(start.startPct); + } + }; + + document.addEventListener("pointermove", handleMove); + document.addEventListener("pointerup", handleUp); + }; + return (
{keyframesData.keyframes.map((kf) => { const leftPx = (kf.percentage / 100) * clipWidthPx; + const kfKey = `${elementId}:${kf.percentage}`; + const isKfSelected = selectedKeyframes.has(kfKey); return ( diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index b2eca8b38..d67aad396 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -63,6 +63,11 @@ 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; @@ -123,6 +128,16 @@ 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) => { @@ -194,6 +209,7 @@ export const usePlayerStore = create((set) => ({ selectedElementId: null, inPoint: null, outPoint: null, + selectedKeyframes: new Set(), keyframeCache: new Map(), }), })); From 94e3d7e11c2ba82eb05e02ceb6f452bc53148cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 16:32:29 -0400 Subject: [PATCH 14/45] fix(studio): add spring-ease subpath export, fix drag on GSAP-animated elements --- .fallowrc.jsonc | 3 +- packages/core/package.json | 8 + .../components/editor/SpringEaseEditor.tsx | 256 ++++++++++++++++++ .../src/components/editor/manualOffsetDrag.ts | 10 +- 4 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 packages/studio/src/components/editor/SpringEaseEditor.tsx diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 9ca0e8831..07615a97e 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -27,8 +27,9 @@ "packages/producer/src/services/__fixtures__/crashOnMessageWorker.mjs", "scripts/*.{ts,mjs,js}", "scripts/*/run.mjs", - // Keyframe diamond component — wired into the design panel in a follow-up. + // 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/studio/src/components/editor/SpringEaseEditor.tsx b/packages/studio/src/components/editor/SpringEaseEditor.tsx new file mode 100644 index 000000000..852f2a32d --- /dev/null +++ b/packages/studio/src/components/editor/SpringEaseEditor.tsx @@ -0,0 +1,256 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { generateSpringEaseData, SPRING_PRESETS } from "@hyperframes/core/spring-ease"; +import { LABEL } from "./MotionPanelFields"; +import { RotateCcw } from "../../icons/SystemIcons"; + +interface SpringParams { + mass: number; + stiffness: number; + damping: number; +} + +const DEFAULT_SPRING: SpringParams = { mass: 1, stiffness: 180, damping: 12 }; + +const SLIDERS: { + key: keyof SpringParams; + label: string; + min: number; + max: number; + step: number; +}[] = [ + { key: "mass", label: "Mass", min: 0.1, max: 5, step: 0.1 }, + { key: "stiffness", label: "Stiffness", min: 10, max: 500, step: 10 }, + { key: "damping", label: "Damping", min: 1, max: 50, step: 1 }, +]; + +function springValue(mass: number, stiffness: number, damping: number, t: number): number { + const w0 = Math.sqrt(stiffness / mass); + const zeta = damping / (2 * Math.sqrt(stiffness * mass)); + if (zeta < 1) { + const wd = w0 * Math.sqrt(1 - zeta * zeta); + return ( + 1 - Math.exp(-zeta * w0 * t) * (Math.cos(wd * t) + ((zeta * w0) / wd) * Math.sin(wd * t)) + ); + } + if (zeta === 1) { + return 1 - (1 + w0 * t) * Math.exp(-w0 * t); + } + const s1 = -w0 * (zeta - Math.sqrt(zeta * zeta - 1)); + const s2 = -w0 * (zeta + Math.sqrt(zeta * zeta - 1)); + return 1 + (s1 * Math.exp(s2 * t) - s2 * Math.exp(s1 * t)) / (s2 - s1); +} + +function springSimDuration(mass: number, stiffness: number, damping: number): number { + const w0 = Math.sqrt(stiffness / mass); + const zeta = damping / (2 * Math.sqrt(stiffness * mass)); + if (zeta < 1) return Math.min(5 / (zeta * w0), 10); + const decayRate = zeta * w0 - w0 * Math.sqrt(zeta * zeta - 1); + return Math.min(4 / Math.max(decayRate, 0.01), 10); +} + +function buildSpringPath( + params: SpringParams, + mapFn: (point: { x: number; y: number }) => { 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/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index 67aae3397..4fb97fe5e 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -235,11 +235,9 @@ export function createManualOffsetDragMember(input: { const initialPathOffset = captureStudioPathOffset(input.element); 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 }; - } + const screenToOffset: ManualOffsetDragMatrix = measured.ok + ? measured.matrix + : { a: 1, b: 0, c: 0, d: 1 }; return { ok: true, @@ -250,7 +248,7 @@ export function createManualOffsetDragMember(input: { initialOffset, initialPathOffset, gestureToken, - screenToOffset: measured.matrix, + screenToOffset, originRect: input.rect, }, }; From 315e94c7ad15884dfa3c1c0cbcb18328a6793301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 16:57:24 -0400 Subject: [PATCH 15/45] feat(studio): gsap-aware drag persistence with auto-keyframing When dragging GSAP-animated elements in the preview, position changes now write to the GSAP script instead of CSS inline styles that GSAP overwrites on the next seek: - Flat tweens: updates x/y properties directly via update-property mutation - Keyframed tweens: adds/updates a keyframe at the current playhead percentage with the new x/y values (auto-keyframing) - Studio offset is cleared after the GSAP commit since GSAP handles position after the soft-reload The intercept point is handleDomPathOffsetCommit, which checks for GSAP animations with position properties before falling through to the standard CSS path. GSAP position helpers live in gsapDragCommit.ts as pure functions to keep useDomEditCommits.ts under the 600-line limit. --- packages/studio/src/hooks/gsapDragCommit.ts | 135 ++++++++++++++++++ .../studio/src/hooks/useDomEditCommits.ts | 31 +++- .../studio/src/hooks/useDomEditSession.ts | 10 +- .../studio/src/hooks/useGsapScriptCommits.ts | 1 + 4 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 packages/studio/src/hooks/gsapDragCommit.ts diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts new file mode 100644 index 000000000..7ec6f27c8 --- /dev/null +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -0,0 +1,135 @@ +/** + * Pure helpers for GSAP-aware drag persistence. When an element has a GSAP + * tween controlling x/y, these functions compute the new GSAP position from + * the studio offset delta and dispatch the correct GSAP script mutation. + */ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { clearStudioPathOffset } from "../components/editor/manualEdits"; + +/** Callbacks for writing GSAP position changes via the script mutation API. */ +export interface GsapPositionCommitCallbacks { + /** Commit a GSAP mutation through the script mutation pipeline. */ + commitMutation: ( + selection: DomEditSelection, + mutation: Record, + options: { label: string; coalesceKey?: string; softReload?: boolean }, + ) => Promise; +} + +/** + * Find the GSAP animation that controls position (x/y) for an element. + * Returns null when the element has no GSAP position tween. + */ +// fallow-ignore-next-line complexity +export function findGsapPositionAnimation(animations: GsapAnimation[]): GsapAnimation | null { + if (animations.length === 0) return null; + for (const anim of animations) { + if ("x" in anim.properties || "y" in anim.properties) return anim; + if (anim.keyframes) { + const hasPosition = anim.keyframes.keyframes.some( + (kf) => "x" in kf.properties || "y" in kf.properties, + ); + if (hasPosition) return anim; + } + } + return null; +} + +/** + * Read the current GSAP x/y values for an animation at a given playhead + * percentage. For flat tweens, reads from properties. For keyframe tweens, + * finds the nearest keyframe values at or before the percentage. + */ +// fallow-ignore-next-line complexity +function readGsapPositionAtPercentage( + anim: GsapAnimation, + percentage: number, +): { x: number; y: number } { + if (anim.keyframes) { + let x = 0; + let y = 0; + for (const kf of anim.keyframes.keyframes) { + if (kf.percentage <= percentage) { + if ("x" in kf.properties) x = Number(kf.properties.x) || 0; + if ("y" in kf.properties) y = Number(kf.properties.y) || 0; + } + } + return { x, y }; + } + return { + x: Number(anim.properties.x) || 0, + y: Number(anim.properties.y) || 0, + }; +} + +/** + * Compute the playhead percentage within an element's local timeline. + * Returns a value clamped to [0, 100]. + */ +// fallow-ignore-next-line complexity +function computeElementPercentage( + currentTime: number, + dataAttributes: Record | undefined, +): number { + const elStart = Number.parseFloat(dataAttributes?.start ?? "0"); + const elDuration = Number.parseFloat(dataAttributes?.duration ?? "1"); + if (elDuration <= 0) return 0; + const raw = ((currentTime - elStart) / elDuration) * 100; + return Math.max(0, Math.min(100, raw)); +} + +/** + * Commit a position drag to GSAP script instead of CSS. The `studioOffset` + * is the delta from the element's GSAP-positioned location — added to the + * current GSAP x/y values to produce the new GSAP position. + */ +// fallow-ignore-next-line complexity +export function commitGsapPositionDrag( + selection: DomEditSelection, + anim: GsapAnimation, + studioOffset: { x: number; y: number }, + currentTime: number, + callbacks: GsapPositionCommitCallbacks, +): void { + const pct = computeElementPercentage(currentTime, selection.dataAttributes); + const currentPos = readGsapPositionAtPercentage(anim, pct); + const newX = Math.round(currentPos.x + studioOffset.x); + const newY = Math.round(currentPos.y + studioOffset.y); + + if (anim.keyframes) { + const clampedPct = Math.max(0, Math.min(100, Math.round(pct))); + const props: Record = { x: newX, y: newY }; + void callbacks.commitMutation( + selection, + { type: "add-keyframe", animationId: anim.id, percentage: clampedPct, properties: props }, + { + label: `Move layer (keyframe ${clampedPct}%)`, + coalesceKey: `gsap-drag:${anim.id}:kf:${clampedPct}`, + softReload: true, + }, + ); + } else { + void callbacks.commitMutation( + selection, + { type: "update-property", animationId: anim.id, property: "x", value: newX }, + { + label: "Move layer (GSAP x)", + coalesceKey: `gsap-drag:${anim.id}:x`, + softReload: true, + }, + ); + void callbacks.commitMutation( + selection, + { type: "update-property", animationId: anim.id, property: "y", value: newY }, + { + label: "Move layer (GSAP y)", + coalesceKey: `gsap-drag:${anim.id}:y`, + softReload: true, + }, + ); + } + + // Clear the studio offset — GSAP will handle position after the soft-reload + clearStudioPathOffset(selection.element); +} diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index e733a4cdd..613b0d496 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -1,6 +1,11 @@ -import { useCallback, useRef } from "react"; +import { useCallback } from "react"; import { usePlayerStore } from "../player"; import { FONT_EXT } from "../utils/mediaTypes"; +import { + findGsapPositionAnimation, + commitGsapPositionDrag, + type GsapPositionCommitCallbacks, +} from "./gsapDragCommit"; import type { PatchOperation } from "../utils/sourcePatcher"; import { trackStudioEvent } from "../utils/studioTelemetry"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; @@ -82,6 +87,10 @@ export interface UseDomEditCommitsParams { target: HTMLElement, options?: { preferClipAncestor?: boolean }, ) => Promise; + + // GSAP-aware drag persistence + selectedGsapAnimations: GsapAnimation[]; + gsapPositionCommitCallbacks: GsapPositionCommitCallbacks | null; } // ── Hook ── @@ -104,6 +113,8 @@ export function useDomEditCommits({ clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, + selectedGsapAnimations, + gsapPositionCommitCallbacks, }: UseDomEditCommitsParams) { const resolveImportedFontAsset = useCallback( (fontFamilyValue: string): ImportedFontAsset | null => { @@ -289,13 +300,29 @@ export function useDomEditCommits({ const handleDomPathOffsetCommit = useCallback( (selection: DomEditSelection, next: { x: number; y: number }) => { + // When the element has a GSAP animation controlling position, write + // to the GSAP script instead of persisting a CSS studio offset. + const positionAnim = findGsapPositionAnimation(selectedGsapAnimations); + if (positionAnim && gsapPositionCommitCallbacks) { + const currentTime = usePlayerStore.getState().currentTime; + commitGsapPositionDrag( + selection, + positionAnim, + next, + currentTime, + gsapPositionCommitCallbacks, + ); + return; + } + + // Standard CSS path (no GSAP position animations on this element) applyStudioPathOffset(selection.element, next); commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { label: "Move layer", coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`, }); }, - [commitPositionPatchToHtml], + [commitPositionPatchToHtml, selectedGsapAnimations, gsapPositionCommitCallbacks], ); const handleDomGroupPathOffsetCommit = useCallback( diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 96378d774..314249289 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import type { TimelineElement } from "../player"; import { STUDIO_INSPECTOR_PANELS_ENABLED, @@ -212,6 +212,7 @@ export function useDomEditSession({ ); const { + commitMutation: gsapCommitMutation, updateGsapProperty, updateGsapMeta, deleteGsapAnimation, @@ -237,6 +238,11 @@ export function useDomEditSession({ // ── Commit handlers (delegated to useDomEditCommits) ── + const gsapPositionCommitCallbacks = useMemo( + () => (gsapCommitMutation ? { commitMutation: gsapCommitMutation } : null), + [gsapCommitMutation], + ); + const { resolveImportedFontAsset, handleDomStyleCommit, @@ -272,6 +278,8 @@ export function useDomEditSession({ clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, + selectedGsapAnimations, + gsapPositionCommitCallbacks, }); const handleGsapUpdateProperty = useCallback( diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index f13567c56..f8e105d70 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -478,6 +478,7 @@ export function useGsapScriptCommits({ ); return { + commitMutation, updateGsapProperty, updateGsapMeta, deleteGsapAnimation, From fdb7c6de6dd1c0902cfb886616ef76b44c972337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 17:02:23 -0400 Subject: [PATCH 16/45] feat(studio): enable keyframes panel by default --- .../studio/src/components/editor/manualEditingAvailability.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index 553bb5e8d..dc4af6756 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -74,7 +74,7 @@ export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag( export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag( env, ["VITE_STUDIO_ENABLE_KEYFRAMES", "VITE_STUDIO_KEYFRAMES_ENABLED"], - false, + true, ); export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED; From ba1d37cb8ad94885612de29badc732f8e4d1d463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 17:18:50 -0400 Subject: [PATCH 17/45] =?UTF-8?q?fix(studio):=20skip=20from()=20tweens=20i?= =?UTF-8?q?n=20GSAP=20drag=20=E2=80=94=20CSS=20position=20is=20the=20corre?= =?UTF-8?q?ct=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/studio/src/hooks/gsapDragCommit.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index 7ec6f27c8..95ac9a6c7 100644 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -25,6 +25,10 @@ export interface GsapPositionCommitCallbacks { export function findGsapPositionAnimation(animations: GsapAnimation[]): GsapAnimation | null { if (animations.length === 0) return null; for (const anim of animations) { + // Skip from() tweens — their properties are the FROM state, not the target + // position. The standard CSS offset path handles from() correctly since the + // CSS position IS the to-state that GSAP animates toward. + if (anim.method === "from") continue; if ("x" in anim.properties || "y" in anim.properties) return anim; if (anim.keyframes) { const hasPosition = anim.keyframes.keyframes.some( From 321ea9eced1789982d28d4d8dd05e116a0b50db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 17:25:59 -0400 Subject: [PATCH 18/45] =?UTF-8?q?fix(studio):=20handle=20all=20GSAP=20drag?= =?UTF-8?q?=20edge=20cases=20=E2=80=94=20fromTo=20shifts=20both=20ends,=20?= =?UTF-8?q?set=20deprioritized,=20scale-aware=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/editor/manualOffsetDrag.ts | 4 +- packages/studio/src/hooks/gsapDragCommit.ts | 102 +++++++++++++++--- 2 files changed, 88 insertions(+), 18 deletions(-) diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index 4fb97fe5e..b72b80996 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -235,9 +235,11 @@ export function createManualOffsetDragMember(input: { const initialPathOffset = captureStudioPathOffset(input.element); const gestureToken = beginStudioManualEditGesture(input.element); const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset); + const scaleX = input.rect.editScaleX || 1; + const scaleY = input.rect.editScaleY || 1; const screenToOffset: ManualOffsetDragMatrix = measured.ok ? measured.matrix - : { a: 1, b: 0, c: 0, d: 1 }; + : { a: 1 / scaleX, b: 0, c: 0, d: 1 / scaleY }; return { ok: true, diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index 95ac9a6c7..a94d91516 100644 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -9,7 +9,6 @@ import { clearStudioPathOffset } from "../components/editor/manualEdits"; /** Callbacks for writing GSAP position changes via the script mutation API. */ export interface GsapPositionCommitCallbacks { - /** Commit a GSAP mutation through the script mutation pipeline. */ commitMutation: ( selection: DomEditSelection, mutation: Record, @@ -19,25 +18,35 @@ export interface GsapPositionCommitCallbacks { /** * Find the GSAP animation that controls position (x/y) for an element. - * Returns null when the element has no GSAP position tween. + * Skips from() tweens (CSS position is the target; standard offset handles it). + * Skips set() tweens — prefers to()/fromTo()/keyframed tweens when both exist. */ // fallow-ignore-next-line complexity export function findGsapPositionAnimation(animations: GsapAnimation[]): GsapAnimation | null { if (animations.length === 0) return null; + + let setFallback: GsapAnimation | null = null; + for (const anim of animations) { - // Skip from() tweens — their properties are the FROM state, not the target - // position. The standard CSS offset path handles from() correctly since the - // CSS position IS the to-state that GSAP animates toward. if (anim.method === "from") continue; - if ("x" in anim.properties || "y" in anim.properties) return anim; - if (anim.keyframes) { - const hasPosition = anim.keyframes.keyframes.some( - (kf) => "x" in kf.properties || "y" in kf.properties, - ); - if (hasPosition) return anim; + + const hasPositionProps = "x" in anim.properties || "y" in anim.properties; + const hasPositionKeyframes = + anim.keyframes?.keyframes.some((kf) => "x" in kf.properties || "y" in kf.properties) ?? false; + + if (!hasPositionProps && !hasPositionKeyframes) continue; + + // Prefer to()/fromTo()/keyframed over set() — set is often just an initial + // positioning that shouldn't be the drag target when a real tween exists. + if (anim.method === "set") { + if (!setFallback) setFallback = anim; + continue; } + + return anim; } - return null; + + return setFallback; } /** @@ -71,7 +80,6 @@ function readGsapPositionAtPercentage( * Compute the playhead percentage within an element's local timeline. * Returns a value clamped to [0, 100]. */ -// fallow-ignore-next-line complexity function computeElementPercentage( currentTime: number, dataAttributes: Record | undefined, @@ -87,6 +95,9 @@ function computeElementPercentage( * Commit a position drag to GSAP script instead of CSS. The `studioOffset` * is the delta from the element's GSAP-positioned location — added to the * current GSAP x/y values to produce the new GSAP position. + * + * For fromTo() tweens, shifts both from and to properties by the same delta + * so the entire animation path moves uniformly. */ // fallow-ignore-next-line complexity export function commitGsapPositionDrag( @@ -100,29 +111,87 @@ export function commitGsapPositionDrag( const currentPos = readGsapPositionAtPercentage(anim, pct); const newX = Math.round(currentPos.x + studioOffset.x); const newY = Math.round(currentPos.y + studioOffset.y); + const dx = Math.round(studioOffset.x); + const dy = Math.round(studioOffset.y); if (anim.keyframes) { const clampedPct = Math.max(0, Math.min(100, Math.round(pct))); - const props: Record = { x: newX, y: newY }; void callbacks.commitMutation( selection, - { type: "add-keyframe", animationId: anim.id, percentage: clampedPct, properties: props }, + { + type: "add-keyframe", + animationId: anim.id, + percentage: clampedPct, + properties: { x: newX, y: newY } as Record, + }, { label: `Move layer (keyframe ${clampedPct}%)`, coalesceKey: `gsap-drag:${anim.id}:kf:${clampedPct}`, softReload: true, }, ); - } else { + } else if (anim.method === "fromTo") { + // Shift both from and to properties by the same delta so the entire + // animation path moves uniformly instead of just the end position. + const fromX = Math.round(Number(anim.fromProperties?.x ?? 0) + dx); + const fromY = Math.round(Number(anim.fromProperties?.y ?? 0) + dy); + void callbacks.commitMutation( + selection, + { + type: "update-from-property", + animationId: anim.id, + property: "x", + value: fromX, + }, + { + label: "Move layer (GSAP from x)", + coalesceKey: `gsap-drag:${anim.id}:from-x`, + softReload: false, + }, + ); + void callbacks.commitMutation( + selection, + { + type: "update-from-property", + animationId: anim.id, + property: "y", + value: fromY, + }, + { + label: "Move layer (GSAP from y)", + coalesceKey: `gsap-drag:${anim.id}:from-y`, + softReload: false, + }, + ); void callbacks.commitMutation( selection, { type: "update-property", animationId: anim.id, property: "x", value: newX }, { label: "Move layer (GSAP x)", coalesceKey: `gsap-drag:${anim.id}:x`, + softReload: false, + }, + ); + void callbacks.commitMutation( + selection, + { type: "update-property", animationId: anim.id, property: "y", value: newY }, + { + label: "Move layer (GSAP y)", + coalesceKey: `gsap-drag:${anim.id}:y`, softReload: true, }, ); + } else { + // to() or set() — update properties directly + void callbacks.commitMutation( + selection, + { type: "update-property", animationId: anim.id, property: "x", value: newX }, + { + label: "Move layer (GSAP x)", + coalesceKey: `gsap-drag:${anim.id}:x`, + softReload: false, + }, + ); void callbacks.commitMutation( selection, { type: "update-property", animationId: anim.id, property: "y", value: newY }, @@ -134,6 +203,5 @@ export function commitGsapPositionDrag( ); } - // Clear the studio offset — GSAP will handle position after the soft-reload clearStudioPathOffset(selection.element); } From 0b34573d28eb7914e8ba5aca642bc59144504d95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 18:42:49 -0400 Subject: [PATCH 19/45] fix(studio): GSAP-aware drag only for keyframed tweens, flat tweens use CSS offset --- packages/studio/src/hooks/gsapDragCommit.ts | 136 ++++---------------- 1 file changed, 25 insertions(+), 111 deletions(-) diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index a94d91516..f4707474d 100644 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -24,29 +24,17 @@ export interface GsapPositionCommitCallbacks { // fallow-ignore-next-line complexity export function findGsapPositionAnimation(animations: GsapAnimation[]): GsapAnimation | null { if (animations.length === 0) return null; - - let setFallback: GsapAnimation | null = null; - + // Only intercept keyframed tweens — flat tweens (to/from/fromTo/set) are + // handled correctly by the standard CSS offset path which persists inline + // styles. Keyframed tweens need the GSAP path for auto-keyframing. for (const anim of animations) { - if (anim.method === "from") continue; - - const hasPositionProps = "x" in anim.properties || "y" in anim.properties; - const hasPositionKeyframes = - anim.keyframes?.keyframes.some((kf) => "x" in kf.properties || "y" in kf.properties) ?? false; - - if (!hasPositionProps && !hasPositionKeyframes) continue; - - // Prefer to()/fromTo()/keyframed over set() — set is often just an initial - // positioning that shouldn't be the drag target when a real tween exists. - if (anim.method === "set") { - if (!setFallback) setFallback = anim; - continue; - } - - return anim; + if (!anim.keyframes) continue; + const hasPosition = anim.keyframes.keyframes.some( + (kf) => "x" in kf.properties || "y" in kf.properties, + ); + if (hasPosition) return anim; } - - return setFallback; + return null; } /** @@ -111,97 +99,23 @@ export function commitGsapPositionDrag( const currentPos = readGsapPositionAtPercentage(anim, pct); const newX = Math.round(currentPos.x + studioOffset.x); const newY = Math.round(currentPos.y + studioOffset.y); - const dx = Math.round(studioOffset.x); - const dy = Math.round(studioOffset.y); - if (anim.keyframes) { - const clampedPct = Math.max(0, Math.min(100, Math.round(pct))); - void callbacks.commitMutation( - selection, - { - type: "add-keyframe", - animationId: anim.id, - percentage: clampedPct, - properties: { x: newX, y: newY } as Record, - }, - { - label: `Move layer (keyframe ${clampedPct}%)`, - coalesceKey: `gsap-drag:${anim.id}:kf:${clampedPct}`, - softReload: true, - }, - ); - } else if (anim.method === "fromTo") { - // Shift both from and to properties by the same delta so the entire - // animation path moves uniformly instead of just the end position. - const fromX = Math.round(Number(anim.fromProperties?.x ?? 0) + dx); - const fromY = Math.round(Number(anim.fromProperties?.y ?? 0) + dy); - void callbacks.commitMutation( - selection, - { - type: "update-from-property", - animationId: anim.id, - property: "x", - value: fromX, - }, - { - label: "Move layer (GSAP from x)", - coalesceKey: `gsap-drag:${anim.id}:from-x`, - softReload: false, - }, - ); - void callbacks.commitMutation( - selection, - { - type: "update-from-property", - animationId: anim.id, - property: "y", - value: fromY, - }, - { - label: "Move layer (GSAP from y)", - coalesceKey: `gsap-drag:${anim.id}:from-y`, - softReload: false, - }, - ); - void callbacks.commitMutation( - selection, - { type: "update-property", animationId: anim.id, property: "x", value: newX }, - { - label: "Move layer (GSAP x)", - coalesceKey: `gsap-drag:${anim.id}:x`, - softReload: false, - }, - ); - void callbacks.commitMutation( - selection, - { type: "update-property", animationId: anim.id, property: "y", value: newY }, - { - label: "Move layer (GSAP y)", - coalesceKey: `gsap-drag:${anim.id}:y`, - softReload: true, - }, - ); - } else { - // to() or set() — update properties directly - void callbacks.commitMutation( - selection, - { type: "update-property", animationId: anim.id, property: "x", value: newX }, - { - label: "Move layer (GSAP x)", - coalesceKey: `gsap-drag:${anim.id}:x`, - softReload: false, - }, - ); - void callbacks.commitMutation( - selection, - { type: "update-property", animationId: anim.id, property: "y", value: newY }, - { - label: "Move layer (GSAP y)", - coalesceKey: `gsap-drag:${anim.id}:y`, - softReload: true, - }, - ); - } + // Only keyframed tweens reach this point (flat tweens use the CSS offset path). + const clampedPct = Math.max(0, Math.min(100, Math.round(pct))); + void callbacks.commitMutation( + selection, + { + type: "add-keyframe", + animationId: anim.id, + percentage: clampedPct, + properties: { x: newX, y: newY } as Record, + }, + { + label: `Move layer (keyframe ${clampedPct}%)`, + coalesceKey: `gsap-drag:${anim.id}:kf:${clampedPct}`, + softReload: true, + }, + ); clearStudioPathOffset(selection.element); } From 2071d42e7ead5ab75a85c49425f66844dfe2c2c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 18:46:16 -0400 Subject: [PATCH 20/45] =?UTF-8?q?fix(studio):=20remove=20GSAP=20drag=20int?= =?UTF-8?q?ercept=20from=20preview=20drag=20=E2=80=94=20CSS=20offset=20han?= =?UTF-8?q?dles=20all=20cases=20correctly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studio/src/hooks/useDomEditCommits.ts | 28 +------------------ .../studio/src/hooks/useDomEditSession.ts | 7 ----- 2 files changed, 1 insertion(+), 34 deletions(-) diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 613b0d496..30c4a1aab 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -1,11 +1,6 @@ import { useCallback } from "react"; import { usePlayerStore } from "../player"; import { FONT_EXT } from "../utils/mediaTypes"; -import { - findGsapPositionAnimation, - commitGsapPositionDrag, - type GsapPositionCommitCallbacks, -} from "./gsapDragCommit"; import type { PatchOperation } from "../utils/sourcePatcher"; import { trackStudioEvent } from "../utils/studioTelemetry"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; @@ -88,9 +83,6 @@ export interface UseDomEditCommitsParams { options?: { preferClipAncestor?: boolean }, ) => Promise; - // GSAP-aware drag persistence - selectedGsapAnimations: GsapAnimation[]; - gsapPositionCommitCallbacks: GsapPositionCommitCallbacks | null; } // ── Hook ── @@ -113,8 +105,6 @@ export function useDomEditCommits({ clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, - selectedGsapAnimations, - gsapPositionCommitCallbacks, }: UseDomEditCommitsParams) { const resolveImportedFontAsset = useCallback( (fontFamilyValue: string): ImportedFontAsset | null => { @@ -300,29 +290,13 @@ export function useDomEditCommits({ const handleDomPathOffsetCommit = useCallback( (selection: DomEditSelection, next: { x: number; y: number }) => { - // When the element has a GSAP animation controlling position, write - // to the GSAP script instead of persisting a CSS studio offset. - const positionAnim = findGsapPositionAnimation(selectedGsapAnimations); - if (positionAnim && gsapPositionCommitCallbacks) { - const currentTime = usePlayerStore.getState().currentTime; - commitGsapPositionDrag( - selection, - positionAnim, - next, - currentTime, - gsapPositionCommitCallbacks, - ); - return; - } - - // Standard CSS path (no GSAP position animations on this element) applyStudioPathOffset(selection.element, next); commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { label: "Move layer", coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`, }); }, - [commitPositionPatchToHtml, selectedGsapAnimations, gsapPositionCommitCallbacks], + [commitPositionPatchToHtml], ); const handleDomGroupPathOffsetCommit = useCallback( diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 314249289..33c8664fb 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -238,11 +238,6 @@ export function useDomEditSession({ // ── Commit handlers (delegated to useDomEditCommits) ── - const gsapPositionCommitCallbacks = useMemo( - () => (gsapCommitMutation ? { commitMutation: gsapCommitMutation } : null), - [gsapCommitMutation], - ); - const { resolveImportedFontAsset, handleDomStyleCommit, @@ -278,8 +273,6 @@ export function useDomEditSession({ clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, - selectedGsapAnimations, - gsapPositionCommitCallbacks, }); const handleGsapUpdateProperty = useCallback( From 3eb5ef262f467148632c9dfd5d5860afe3a4f9cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 18:49:25 -0400 Subject: [PATCH 21/45] =?UTF-8?q?fix(studio):=20remove=20GSAP=20drag=20int?= =?UTF-8?q?ercept=20=E2=80=94=20CSS=20offset=20handles=20all=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/studio/src/hooks/gsapDragCommit.ts | 121 ------------------ .../studio/src/hooks/useDomEditSession.ts | 3 +- 2 files changed, 1 insertion(+), 123 deletions(-) delete mode 100644 packages/studio/src/hooks/gsapDragCommit.ts diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts deleted file mode 100644 index f4707474d..000000000 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Pure helpers for GSAP-aware drag persistence. When an element has a GSAP - * tween controlling x/y, these functions compute the new GSAP position from - * the studio offset delta and dispatch the correct GSAP script mutation. - */ -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; -import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import { clearStudioPathOffset } from "../components/editor/manualEdits"; - -/** Callbacks for writing GSAP position changes via the script mutation API. */ -export interface GsapPositionCommitCallbacks { - commitMutation: ( - selection: DomEditSelection, - mutation: Record, - options: { label: string; coalesceKey?: string; softReload?: boolean }, - ) => Promise; -} - -/** - * Find the GSAP animation that controls position (x/y) for an element. - * Skips from() tweens (CSS position is the target; standard offset handles it). - * Skips set() tweens — prefers to()/fromTo()/keyframed tweens when both exist. - */ -// fallow-ignore-next-line complexity -export function findGsapPositionAnimation(animations: GsapAnimation[]): GsapAnimation | null { - if (animations.length === 0) return null; - // Only intercept keyframed tweens — flat tweens (to/from/fromTo/set) are - // handled correctly by the standard CSS offset path which persists inline - // styles. Keyframed tweens need the GSAP path for auto-keyframing. - for (const anim of animations) { - if (!anim.keyframes) continue; - const hasPosition = anim.keyframes.keyframes.some( - (kf) => "x" in kf.properties || "y" in kf.properties, - ); - if (hasPosition) return anim; - } - return null; -} - -/** - * Read the current GSAP x/y values for an animation at a given playhead - * percentage. For flat tweens, reads from properties. For keyframe tweens, - * finds the nearest keyframe values at or before the percentage. - */ -// fallow-ignore-next-line complexity -function readGsapPositionAtPercentage( - anim: GsapAnimation, - percentage: number, -): { x: number; y: number } { - if (anim.keyframes) { - let x = 0; - let y = 0; - for (const kf of anim.keyframes.keyframes) { - if (kf.percentage <= percentage) { - if ("x" in kf.properties) x = Number(kf.properties.x) || 0; - if ("y" in kf.properties) y = Number(kf.properties.y) || 0; - } - } - return { x, y }; - } - return { - x: Number(anim.properties.x) || 0, - y: Number(anim.properties.y) || 0, - }; -} - -/** - * Compute the playhead percentage within an element's local timeline. - * Returns a value clamped to [0, 100]. - */ -function computeElementPercentage( - currentTime: number, - dataAttributes: Record | undefined, -): number { - const elStart = Number.parseFloat(dataAttributes?.start ?? "0"); - const elDuration = Number.parseFloat(dataAttributes?.duration ?? "1"); - if (elDuration <= 0) return 0; - const raw = ((currentTime - elStart) / elDuration) * 100; - return Math.max(0, Math.min(100, raw)); -} - -/** - * Commit a position drag to GSAP script instead of CSS. The `studioOffset` - * is the delta from the element's GSAP-positioned location — added to the - * current GSAP x/y values to produce the new GSAP position. - * - * For fromTo() tweens, shifts both from and to properties by the same delta - * so the entire animation path moves uniformly. - */ -// fallow-ignore-next-line complexity -export function commitGsapPositionDrag( - selection: DomEditSelection, - anim: GsapAnimation, - studioOffset: { x: number; y: number }, - currentTime: number, - callbacks: GsapPositionCommitCallbacks, -): void { - const pct = computeElementPercentage(currentTime, selection.dataAttributes); - const currentPos = readGsapPositionAtPercentage(anim, pct); - const newX = Math.round(currentPos.x + studioOffset.x); - const newY = Math.round(currentPos.y + studioOffset.y); - - // Only keyframed tweens reach this point (flat tweens use the CSS offset path). - const clampedPct = Math.max(0, Math.min(100, Math.round(pct))); - void callbacks.commitMutation( - selection, - { - type: "add-keyframe", - animationId: anim.id, - percentage: clampedPct, - properties: { x: newX, y: newY } as Record, - }, - { - label: `Move layer (keyframe ${clampedPct}%)`, - coalesceKey: `gsap-drag:${anim.id}:kf:${clampedPct}`, - softReload: true, - }, - ); - - clearStudioPathOffset(selection.element); -} diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 33c8664fb..96378d774 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import type { TimelineElement } from "../player"; import { STUDIO_INSPECTOR_PANELS_ENABLED, @@ -212,7 +212,6 @@ export function useDomEditSession({ ); const { - commitMutation: gsapCommitMutation, updateGsapProperty, updateGsapMeta, deleteGsapAnimation, From b10944becb6695de1f6cf02bcc16c01841c21823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 18:54:35 -0400 Subject: [PATCH 22/45] =?UTF-8?q?fix(studio):=20allow=20left/top=20drag=20?= =?UTF-8?q?on=20GSAP-animated=20elements=20=E2=80=94=20prevents=20translat?= =?UTF-8?q?e/transform=20stacking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studio/src/components/editor/domEditingLayers.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/studio/src/components/editor/domEditingLayers.ts b/packages/studio/src/components/editor/domEditingLayers.ts index bb2570b58..61ad983bb 100644 --- a/packages/studio/src/components/editor/domEditingLayers.ts +++ b/packages/studio/src/components/editor/domEditingLayers.ts @@ -21,7 +21,6 @@ import { getSourceFileForElement, humanizeIdentifier, isHtmlElement, - isIdentityTransform, isTextBearingTag, parsePx, } from "./domEditingDom"; @@ -215,13 +214,12 @@ export function resolveDomEditCapabilities(args: { const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top); const width = parsePx(args.inlineStyles.width) ?? parsePx(args.computedStyles.width); const height = parsePx(args.inlineStyles.height) ?? parsePx(args.computedStyles.height); - const hasTransformDrivenGeometry = !isIdentityTransform(args.computedStyles.transform); - const canMove = - (position === "absolute" || position === "fixed") && - left != null && - top != null && - !hasTransformDrivenGeometry; + // GSAP-driven transforms don't block left/top + // editing — they're separate CSS properties. Blocking canMove forces the + // drag to use CSS `translate` which STACKS with GSAP's transform, causing + // double-offset during playback. left/top editing avoids this conflict. + const canMove = (position === "absolute" || position === "fixed") && left != null && top != null; const canResize = canMove && (width != null || height != null); const canApplyManualGeometry = !args.isCompositionHost; From 1fa76eef48a2b8bc3f6cfe1048a44476ef3ea489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 19:00:58 -0400 Subject: [PATCH 23/45] =?UTF-8?q?revert(studio):=20remove=20all=20GSAP=20d?= =?UTF-8?q?rag=20intercepts=20=E2=80=94=20needs=20runtime=20bridge=20appro?= =?UTF-8?q?ach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studio/src/components/editor/domEditingLayers.ts | 12 +++++++----- .../studio/src/components/editor/manualOffsetDrag.ts | 12 ++++++------ packages/studio/src/hooks/useDomEditCommits.ts | 1 - packages/studio/src/hooks/useGsapScriptCommits.ts | 1 - 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/studio/src/components/editor/domEditingLayers.ts b/packages/studio/src/components/editor/domEditingLayers.ts index 61ad983bb..bb2570b58 100644 --- a/packages/studio/src/components/editor/domEditingLayers.ts +++ b/packages/studio/src/components/editor/domEditingLayers.ts @@ -21,6 +21,7 @@ import { getSourceFileForElement, humanizeIdentifier, isHtmlElement, + isIdentityTransform, isTextBearingTag, parsePx, } from "./domEditingDom"; @@ -214,12 +215,13 @@ export function resolveDomEditCapabilities(args: { const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top); const width = parsePx(args.inlineStyles.width) ?? parsePx(args.computedStyles.width); const height = parsePx(args.inlineStyles.height) ?? parsePx(args.computedStyles.height); + const hasTransformDrivenGeometry = !isIdentityTransform(args.computedStyles.transform); - // GSAP-driven transforms don't block left/top - // editing — they're separate CSS properties. Blocking canMove forces the - // drag to use CSS `translate` which STACKS with GSAP's transform, causing - // double-offset during playback. left/top editing avoids this conflict. - const canMove = (position === "absolute" || position === "fixed") && left != null && top != null; + const canMove = + (position === "absolute" || position === "fixed") && + left != null && + top != null && + !hasTransformDrivenGeometry; const canResize = canMove && (width != null || height != null); const canApplyManualGeometry = !args.isCompositionHost; diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index b72b80996..67aae3397 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -235,11 +235,11 @@ export function createManualOffsetDragMember(input: { const initialPathOffset = captureStudioPathOffset(input.element); const gestureToken = beginStudioManualEditGesture(input.element); const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset); - const scaleX = input.rect.editScaleX || 1; - const scaleY = input.rect.editScaleY || 1; - const screenToOffset: ManualOffsetDragMatrix = measured.ok - ? measured.matrix - : { a: 1 / scaleX, b: 0, c: 0, d: 1 / scaleY }; + if (!measured.ok) { + restoreStudioPathOffset(input.element, initialPathOffset); + endStudioManualEditGesture(input.element, gestureToken); + return { ok: false, reason: measured.reason, selection: input.selection }; + } return { ok: true, @@ -250,7 +250,7 @@ export function createManualOffsetDragMember(input: { initialOffset, initialPathOffset, gestureToken, - screenToOffset, + screenToOffset: measured.matrix, originRect: input.rect, }, }; diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 30c4a1aab..ac8cbc69c 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -82,7 +82,6 @@ export interface UseDomEditCommitsParams { target: HTMLElement, options?: { preferClipAncestor?: boolean }, ) => Promise; - } // ── Hook ── diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index f8e105d70..f13567c56 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -478,7 +478,6 @@ export function useGsapScriptCommits({ ); return { - commitMutation, updateGsapProperty, updateGsapMeta, deleteGsapAnimation, From efaf1eb3325c99a67bb959190d43900acf3294f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 19:26:19 -0400 Subject: [PATCH 24/45] feat(studio): gsap-aware drag via runtime bridge When dragging GSAP-animated elements, the studio now reads the actual interpolated x/y from window.gsap.getProperty() in the preview iframe at the current seek time. This eliminates the conflict between CSS translate offsets and GSAP transforms that caused previous approaches to fail. Drag commit path: - Keyframed tweens: auto-inserts a keyframe at the current percentage - from()/fromTo(): shifts from/to properties by the drag delta - to()/set(): writes absolute position (runtime value + drag offset) - No GSAP x/y: falls through to standard CSS offset path The probe measurement fallback in manualOffsetDrag.ts uses preview scale as an approximation when GSAP transforms interfere with probing, since the commit path always reads exact runtime values. --- .../src/components/editor/manualOffsetDrag.ts | 22 +- .../studio/src/hooks/gsapRuntimeBridge.ts | 271 ++++++++++++++++++ .../studio/src/hooks/useDomEditSession.ts | 27 +- .../studio/src/hooks/useGsapScriptCommits.ts | 1 + 4 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 packages/studio/src/hooks/gsapRuntimeBridge.ts 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/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts new file mode 100644 index 000000000..b1e59861f --- /dev/null +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -0,0 +1,271 @@ +/** + * 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; +} + +/** + * Read GSAP interpolated x/y values from the preview iframe at the current + * seek time. Returns null if GSAP is not available or the element cannot be + * found. + */ +// 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 { + // cross-origin guard + 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 ───────────────────────────────────────────────────── + +/** + * Find the GSAP animation that controls position (x/y) for the selected + * element. Checks keyframed tweens first, then flat tweens (from, fromTo, + * to, set). + */ +// fallow-ignore-next-line complexity +function findGsapPositionAnimation(animations: GsapAnimation[]): GsapAnimation | null { + 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; + } + } + return null; +} + +// ── Selector resolution ──────────────────────────────────────────────────── + +function selectorForSelection(selection: DomEditSelection): string | null { + if (selection.id) return `#${selection.id}`; + if (selection.selector) return selection.selector; + return null; +} + +// ── High-level intercept ─────────────────────────────────────────────────── + +/** + * Attempt to handle a drag commit via the GSAP script mutation path. + * Returns true if the drag was handled (caller should skip the CSS path), + * false if no GSAP position animation exists or the runtime bridge cannot + * read the current position. + */ +// fallow-ignore-next-line complexity +export function tryGsapDragIntercept( + selection: DomEditSelection, + offset: { x: number; y: number }, + animations: GsapAnimation[], + iframe: HTMLIFrameElement | null, + commitMutation: GsapDragCommitCallbacks["commitMutation"], +): boolean { + const posAnim = findGsapPositionAnimation(animations); + if (!posAnim) return false; + + const selector = selectorForSelection(selection); + if (!selector) return false; + + const gsapPos = readGsapPositionFromIframe(iframe, selector); + if (!gsapPos) return false; + + commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, { commitMutation }); + return true; +} + +// ── Commit helpers ───────────────────────────────────────────────────────── + +export interface GsapDragCommitCallbacks { + commitMutation: ( + selection: DomEditSelection, + mutation: Record, + options: { label: string; coalesceKey?: string; softReload?: boolean }, + ) => Promise; +} + +/** + * Compute the new GSAP position values from runtime-read positions + drag + * offset, then commit the mutation to the GSAP script. + * + * After committing, clears the studio CSS offset so GSAP owns position + * entirely after the soft-reload. + */ +function commitGsapPositionFromDrag( + selection: DomEditSelection, + anim: GsapAnimation, + studioOffset: { x: number; y: number }, + gsapPos: { x: number; y: number }, + callbacks: GsapDragCommitCallbacks, +): void { + const newX = Math.round(gsapPos.x + studioOffset.x); + const newY = Math.round(gsapPos.y + studioOffset.y); + + if (anim.keyframes) { + commitKeyframedPosition(selection, anim, newX, newY, callbacks); + } else if (anim.method === "from") { + commitFromPosition(selection, anim, studioOffset, callbacks); + } else if (anim.method === "fromTo") { + commitFromToPosition(selection, anim, studioOffset, callbacks); + } else { + // to() or set() + commitFlatPosition(selection, anim, newX, newY, callbacks); + } + + clearStudioPathOffset(selection.element); +} + +// fallow-ignore-next-line complexity +function commitKeyframedPosition( + selection: DomEditSelection, + anim: GsapAnimation, + newX: number, + newY: number, + callbacks: GsapDragCommitCallbacks, +): void { + const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1; + const currentTime = usePlayerStore.getState().currentTime; + const pct = + elDuration > 0 + ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 100))) + : 0; + + void callbacks.commitMutation( + selection, + { + type: "add-keyframe", + animationId: anim.id, + percentage: pct, + properties: { x: newX, y: newY }, + }, + { label: `Move layer (keyframe ${pct}%)`, softReload: true }, + ); +} + +function commitFromPosition( + selection: DomEditSelection, + anim: GsapAnimation, + delta: { x: number; y: number }, + callbacks: GsapDragCommitCallbacks, +): void { + const fromX = Math.round(Number(anim.properties.x ?? 0) + delta.x); + const fromY = Math.round(Number(anim.properties.y ?? 0) + delta.y); + + void callbacks.commitMutation( + selection, + { type: "update-property", animationId: anim.id, property: "x", value: fromX }, + { label: "Move layer (GSAP from x)", softReload: false }, + ); + void callbacks.commitMutation( + selection, + { type: "update-property", animationId: anim.id, property: "y", value: fromY }, + { label: "Move layer (GSAP from y)", softReload: true }, + ); +} + +// fallow-ignore-next-line complexity +function commitFromToPosition( + selection: DomEditSelection, + anim: GsapAnimation, + delta: { x: number; y: number }, + callbacks: GsapDragCommitCallbacks, +): void { + // Shift fromProperties + 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); + void callbacks.commitMutation( + selection, + { type: "update-from-property", animationId: anim.id, property: "x", value: fromX }, + { label: "Move (GSAP from x)", softReload: false }, + ); + void callbacks.commitMutation( + selection, + { type: "update-from-property", animationId: anim.id, property: "y", value: fromY }, + { label: "Move (GSAP from y)", softReload: false }, + ); + } + + // Shift toProperties + const toX = Math.round(Number(anim.properties.x ?? 0) + delta.x); + const toY = Math.round(Number(anim.properties.y ?? 0) + delta.y); + void callbacks.commitMutation( + selection, + { type: "update-property", animationId: anim.id, property: "x", value: toX }, + { label: "Move (GSAP to x)", softReload: false }, + ); + void callbacks.commitMutation( + selection, + { type: "update-property", animationId: anim.id, property: "y", value: toY }, + { label: "Move (GSAP to y)", softReload: true }, + ); +} + +function commitFlatPosition( + selection: DomEditSelection, + anim: GsapAnimation, + newX: number, + newY: number, + callbacks: GsapDragCommitCallbacks, +): void { + void callbacks.commitMutation( + selection, + { type: "update-property", animationId: anim.id, property: "x", value: newX }, + { label: "Move layer (GSAP x)", softReload: false }, + ); + void callbacks.commitMutation( + selection, + { type: "update-property", animationId: anim.id, property: "y", value: newY }, + { label: "Move layer (GSAP y)", softReload: true }, + ); +} diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 96378d774..dcb01013b 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -17,6 +17,7 @@ import { usePreviewInteraction } from "./usePreviewInteraction"; import { useDomEditCommits } from "./useDomEditCommits"; import { useGsapScriptCommits } from "./useGsapScriptCommits"; import { useGsapAnimationsForElement, useGsapCacheVersion } from "./useGsapTweenCache"; +import { tryGsapDragIntercept } from "./gsapRuntimeBridge"; // ── Types ── @@ -212,6 +213,7 @@ export function useDomEditSession({ ); const { + commitMutation: gsapCommitMutation, updateGsapProperty, updateGsapMeta, deleteGsapAnimation, @@ -274,6 +276,29 @@ 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( + (selection: DomEditSelection, next: { x: number; y: number }) => { + if ( + gsapCommitMutation && + tryGsapDragIntercept( + selection, + next, + selectedGsapAnimations, + previewIframeRef.current, + gsapCommitMutation, + ) + ) { + return; + } + handleDomPathOffsetCommit(selection, next); + }, + [handleDomPathOffsetCommit, selectedGsapAnimations, gsapCommitMutation, previewIframeRef], + ); + const handleGsapUpdateProperty = useCallback( (animId: string, prop: string, value: number | string) => { if (!domEditSelection) return; @@ -495,7 +520,7 @@ export function useDomEditSession({ handleDomStyleCommit, handleDomAttributeCommit, handleDomHtmlAttributeCommit, - handleDomPathOffsetCommit, + handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index f13567c56..f8e105d70 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -478,6 +478,7 @@ export function useGsapScriptCommits({ ); return { + commitMutation, updateGsapProperty, updateGsapMeta, deleteGsapAnimation, From 415623c24b7be7a5f1d7eee1fb4ee679440e56d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 13:09:47 -0400 Subject: [PATCH 25/45] =?UTF-8?q?feat(studio):=20gsap=20keyframe=20editing?= =?UTF-8?q?=20=E2=80=94=20drag=20persistence,=20timeline=20diamonds,=20too?= =?UTF-8?q?lbar=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GSAP-aware drag pipeline: - Async mutation flow: CSS offset stays visible during API call, cleared via beforeReload callback right before soft-reload replaces the script - skipReload for intermediate x/y mutations prevents reload cascade - Flat to()/set() tweens auto-convert to keyframes on first drag - Promise propagated to gesture handler for proper error recovery Runtime seek fix: - GSAP 3.x skips rendering when totalTime(0) on a freshly created paused timeline. Nudge to t+0.001 then back to force initial keyframe render. Timeline keyframe diamonds: - Diamond markers on clips with connecting lines, 80% clip height - Click to select clip + seek, teal highlight at playhead - Keyframe cache populated on load for all elements Timeline toolbar diamond toggle button for add/remove keyframe. Compact 48px track height. STUDIO_KEYFRAMES_ENABLED defaults false. --- packages/core/src/runtime/adapters/gsap.ts | 3 + packages/core/src/runtime/init.test.ts | 4 +- packages/core/src/runtime/init.ts | 6 + packages/studio/src/App.tsx | 12 +- .../src/components/StudioPreviewArea.tsx | 28 +++ .../studio/src/components/TimelineToolbar.tsx | 112 +++++++++- .../editor/manualEditingAvailability.ts | 2 +- .../studio/src/components/nle/NLELayout.tsx | 3 + .../studio/src/hooks/gsapRuntimeBridge.ts | 192 ++++++++++-------- .../studio/src/hooks/useDomEditSession.ts | 28 ++- .../studio/src/hooks/useGsapScriptCommits.ts | 53 +++-- .../studio/src/hooks/useGsapTweenCache.ts | 49 ++++- .../studio/src/player/components/Timeline.tsx | 9 +- .../src/player/components/TimelineCanvas.tsx | 10 + .../src/player/components/TimelineClip.tsx | 4 +- .../components/TimelineClipDiamonds.tsx | 99 ++++++--- .../src/player/components/timelineLayout.ts | 2 +- 17 files changed, 455 insertions(+), 161 deletions(-) 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..27ed48e3e 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -950,6 +950,12 @@ 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); } } diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index e2922001f..f9cf37ade 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -482,12 +482,15 @@ export function StudioApp() { timelineVisible, toggleTimelineVisibility, }); - if (resolving || waitingForServer || !projectId) { return ; } - - const timelineToolbar = ; + const timelineToolbar = ( + + ); return ( @@ -552,7 +555,6 @@ export function StudioApp() { {lintModal !== null && ( )} - {consoleErrors !== null && consoleErrors.length > 0 && ( setConsoleErrors(null)} /> )} - {domEditSession.agentModalOpen && domEditSession.domEditSelection && ( } - {appToast && (
{ + 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) => "x" in a.properties || "y" in a.properties, + ); + if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id); + } + }} onCompIdToSrcChange={setCompIdToSrc} onCompositionLoadingChange={setCompositionLoading} onCompositionChange={(compPath) => { diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 7bf86c193..6af2e374b 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -4,24 +4,130 @@ import { } from "../player/components/timelineZoom"; import { getTimelineToggleTitle } from "../utils/timelineDiscovery"; import { usePlayerStore } from "../player"; +import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability"; import { Tooltip } from "./ui"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "./editor/domEditingTypes"; + +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; +} 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) => "x" in a.properties || "y" in a.properties); + + 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) * 100))) + : 0; + state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1) + ? "active" + : "inactive"; + } + + // fallow-ignore-next-line complexity + const onToggle = + sel && (kfAnim || flatAnim) + ? () => { + 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) * 100))) + : 0; + const existing = kfAnim.keyframes.keyframes.find( + (k) => Math.abs(k.percentage - pct) <= 1, + ); + if (existing) { + session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage); + } else { + session.handleGsapAddKeyframe(kfAnim.id, pct, "x", 0); + } + } else if (flatAnim) { + session.handleGsapConvertToKeyframes(flatAnim.id); + } + } + : 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/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index dc4af6756..553bb5e8d 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -74,7 +74,7 @@ export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag( export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag( env, ["VITE_STUDIO_ENABLE_KEYFRAMES", "VITE_STUDIO_KEYFRAMES_ENABLED"], - true, + false, ); export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED; diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 2f08a9d20..adf338209 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -73,6 +73,7 @@ interface NLELayoutProps { 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) */ @@ -124,6 +125,7 @@ export const NLELayout = memo(function NLELayout({ onDeleteKeyframe, onChangeKeyframeEase, onMoveKeyframe, + onToggleKeyframeAtPlayhead, onCompIdToSrcChange, timelineVisible, onToggleTimeline, @@ -457,6 +459,7 @@ export const NLELayout = memo(function NLELayout({ onDeleteKeyframe={onDeleteKeyframe} onChangeKeyframeEase={onChangeKeyframeEase} onMoveKeyframe={onMoveKeyframe} + onToggleKeyframeAtPlayhead={onToggleKeyframeAtPlayhead} />
{timelineFooter &&
{timelineFooter}
} diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index b1e59861f..340c6e291 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -19,11 +19,6 @@ interface IframeGsap { getProperty: (el: Element, prop: string) => number; } -/** - * Read GSAP interpolated x/y values from the preview iframe at the current - * seek time. Returns null if GSAP is not available or the element cannot be - * found. - */ // fallow-ignore-next-line complexity function readGsapPositionFromIframe( iframe: HTMLIFrameElement | null, @@ -35,7 +30,6 @@ function readGsapPositionFromIframe( try { gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap; } catch { - // cross-origin guard return null; } if (!gsap?.getProperty) return null; @@ -58,11 +52,6 @@ function readGsapPositionFromIframe( // ── Animation matching ───────────────────────────────────────────────────── -/** - * Find the GSAP animation that controls position (x/y) for the selected - * element. Checks keyframed tweens first, then flat tweens (from, fromTo, - * to, set). - */ // fallow-ignore-next-line complexity function findGsapPositionAnimation(animations: GsapAnimation[]): GsapAnimation | null { for (const anim of animations) { @@ -94,22 +83,50 @@ function selectorForSelection(selection: DomEditSelection): string | null { 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) * 100))) + : 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 true if the drag was handled (caller should skip the CSS path), - * false if no GSAP position animation exists or the runtime bridge cannot - * read the current position. + * + * 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 function tryGsapDragIntercept( +export async function tryGsapDragIntercept( selection: DomEditSelection, offset: { x: number; y: number }, animations: GsapAnimation[], iframe: HTMLIFrameElement | null, commitMutation: GsapDragCommitCallbacks["commitMutation"], -): boolean { +): Promise { const posAnim = findGsapPositionAnimation(animations); if (!posAnim) return false; @@ -119,68 +136,96 @@ export function tryGsapDragIntercept( const gsapPos = readGsapPositionFromIframe(iframe, selector); if (!gsapPos) return false; - commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, { commitMutation }); + await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, { commitMutation }); return true; } // ── Commit helpers ───────────────────────────────────────────────────────── -export interface GsapDragCommitCallbacks { - commitMutation: ( - selection: DomEditSelection, - mutation: Record, - options: { label: string; coalesceKey?: string; softReload?: boolean }, - ) => Promise; -} - /** * Compute the new GSAP position values from runtime-read positions + drag * offset, then commit the mutation to the GSAP script. * - * After committing, clears the studio CSS offset so GSAP owns position - * entirely after the soft-reload. + * `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. */ -function commitGsapPositionFromDrag( +// fallow-ignore-next-line complexity +async function commitGsapPositionFromDrag( selection: DomEditSelection, anim: GsapAnimation, studioOffset: { x: number; y: number }, gsapPos: { x: number; y: number }, callbacks: GsapDragCommitCallbacks, -): void { +): 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) { - commitKeyframedPosition(selection, anim, newX, newY, callbacks); + await commitKeyframedPosition(selection, anim, newX, newY, callbacks, clearOffset); } else if (anim.method === "from") { - commitFromPosition(selection, anim, studioOffset, callbacks); + await commitFromPosition(selection, anim, studioOffset, callbacks, clearOffset); } else if (anim.method === "fromTo") { - commitFromToPosition(selection, anim, studioOffset, callbacks); + await commitFromToPosition(selection, anim, studioOffset, callbacks, clearOffset); } else { - // to() or set() - commitFlatPosition(selection, anim, newX, newY, callbacks); + // 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); } +} - clearStudioPathOffset(selection.element); +// 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 -function commitKeyframedPosition( +async function commitFlatViaKeyframes( selection: DomEditSelection, anim: GsapAnimation, newX: number, newY: number, callbacks: GsapDragCommitCallbacks, -): void { - const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0; - const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1; - const currentTime = usePlayerStore.getState().currentTime; - const pct = - elDuration > 0 - ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 100))) - : 0; + 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); - void callbacks.commitMutation( + await callbacks.commitMutation( selection, { type: "add-keyframe", @@ -188,84 +233,65 @@ function commitKeyframedPosition( percentage: pct, properties: { x: newX, y: newY }, }, - { label: `Move layer (keyframe ${pct}%)`, softReload: true }, + { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, ); } -function commitFromPosition( +async function commitFromPosition( selection: DomEditSelection, anim: GsapAnimation, delta: { x: number; y: number }, callbacks: GsapDragCommitCallbacks, -): void { + 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); - void callbacks.commitMutation( + await callbacks.commitMutation( selection, { type: "update-property", animationId: anim.id, property: "x", value: fromX }, - { label: "Move layer (GSAP from x)", softReload: false }, + { label: "Move layer (GSAP from x)", skipReload: true }, ); - void callbacks.commitMutation( + await callbacks.commitMutation( selection, { type: "update-property", animationId: anim.id, property: "y", value: fromY }, - { label: "Move layer (GSAP from y)", softReload: true }, + { label: "Move layer (GSAP from y)", softReload: true, beforeReload }, ); } // fallow-ignore-next-line complexity -function commitFromToPosition( +async function commitFromToPosition( selection: DomEditSelection, anim: GsapAnimation, delta: { x: number; y: number }, callbacks: GsapDragCommitCallbacks, -): void { - // Shift fromProperties + 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); - void callbacks.commitMutation( + await callbacks.commitMutation( selection, { type: "update-from-property", animationId: anim.id, property: "x", value: fromX }, - { label: "Move (GSAP from x)", softReload: false }, + { label: "Move (GSAP from x)", skipReload: true }, ); - void callbacks.commitMutation( + await callbacks.commitMutation( selection, { type: "update-from-property", animationId: anim.id, property: "y", value: fromY }, - { label: "Move (GSAP from y)", softReload: false }, + { label: "Move (GSAP from y)", skipReload: true }, ); } - // Shift toProperties const toX = Math.round(Number(anim.properties.x ?? 0) + delta.x); const toY = Math.round(Number(anim.properties.y ?? 0) + delta.y); - void callbacks.commitMutation( + await callbacks.commitMutation( selection, { type: "update-property", animationId: anim.id, property: "x", value: toX }, - { label: "Move (GSAP to x)", softReload: false }, + { label: "Move (GSAP to x)", skipReload: true }, ); - void callbacks.commitMutation( + await callbacks.commitMutation( selection, { type: "update-property", animationId: anim.id, property: "y", value: toY }, - { label: "Move (GSAP to y)", softReload: true }, - ); -} - -function commitFlatPosition( - selection: DomEditSelection, - anim: GsapAnimation, - newX: number, - newY: number, - callbacks: GsapDragCommitCallbacks, -): void { - void callbacks.commitMutation( - selection, - { type: "update-property", animationId: anim.id, property: "x", value: newX }, - { label: "Move layer (GSAP x)", softReload: false }, - ); - void callbacks.commitMutation( - selection, - { type: "update-property", animationId: anim.id, property: "y", value: newY }, - { label: "Move layer (GSAP y)", softReload: true }, + { label: "Move (GSAP to y)", softReload: true, beforeReload }, ); } diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index dcb01013b..b6ac65094 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -16,7 +16,11 @@ 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, +} from "./useGsapTweenCache"; import { tryGsapDragIntercept } from "./gsapRuntimeBridge"; // ── Types ── @@ -199,13 +203,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, @@ -281,18 +293,16 @@ export function useDomEditSession({ // interpolated position from the iframe runtime and commit via the GSAP // script mutation path instead of the CSS translate offset. const handleGsapAwarePathOffsetCommit = useCallback( - (selection: DomEditSelection, next: { x: number; y: number }) => { - if ( - gsapCommitMutation && - tryGsapDragIntercept( + async (selection: DomEditSelection, next: { x: number; y: number }) => { + if (gsapCommitMutation) { + const handled = await tryGsapDragIntercept( selection, next, selectedGsapAnimations, previewIframeRef.current, gsapCommitMutation, - ) - ) { - return; + ); + if (handled) return; } handleDomPathOffsetCommit(selection, next); }, diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index f8e105d70..785498398 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -72,21 +72,25 @@ async function mutateGsapScript( } } -/** Read the current keyframe cache entry for an element from the player store. */ +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(elementId); + return usePlayerStore.getState().keyframeCache.get(buildCacheKey(sourceFile, elementId)); } -/** Write a keyframe cache entry (or clear it) in the player store. */ function writeKeyframeCache( + sourceFile: string, elementId: string | null | undefined, data: KeyframeCacheEntry | undefined, ): void { if (!elementId) return; - usePlayerStore.getState().setKeyframeCache(elementId, data); + usePlayerStore.getState().setKeyframeCache(buildCacheKey(sourceFile, elementId), data); } interface GsapScriptCommitsParams { @@ -132,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; @@ -154,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(); @@ -380,16 +394,17 @@ export function useGsapScriptCommits({ property: string, value: number | string, ) => { + const sf = selection.sourceFile || activeCompPath || "index.html"; const elementId = selection.id; void executeOptimistic({ apply: () => { - const prev = readKeyframeSnapshot(elementId); + const prev = readKeyframeSnapshot(sf, elementId); if (prev) { const newKeyframes = [ ...prev.keyframes, { percentage, properties: { [property]: value } }, ].sort((a, b) => a.percentage - b.percentage); - writeKeyframeCache(elementId, { ...prev, keyframes: newKeyframes }); + writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes }); } return prev; }, @@ -400,22 +415,23 @@ export function useGsapScriptCommits({ { label: `Add keyframe at ${percentage}%`, softReload: true }, ), rollback: (prev) => { - writeKeyframeCache(elementId, prev); + writeKeyframeCache(sf, elementId, prev); }, }); }, - [commitMutation], + [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(elementId); + const prev = readKeyframeSnapshot(sf, elementId); if (prev) { const newKeyframes = prev.keyframes.filter((kf) => kf.percentage !== percentage); - writeKeyframeCache(elementId, { ...prev, keyframes: newKeyframes }); + writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes }); } return prev; }, @@ -426,23 +442,22 @@ export function useGsapScriptCommits({ { label: `Remove keyframe at ${percentage}%`, softReload: true }, ), rollback: (prev) => { - writeKeyframeCache(elementId, prev); + writeKeyframeCache(sf, elementId, prev); }, }); }, - [commitMutation], + [commitMutation, activeCompPath], ); const convertToKeyframes = useCallback( (selection: DomEditSelection, animationId: string) => { + const sf = selection.sourceFile || activeCompPath || "index.html"; const elementId = selection.id; void executeOptimistic({ apply: () => { - const prev = readKeyframeSnapshot(elementId); + const prev = readKeyframeSnapshot(sf, elementId); if (!prev) { - // Seed a minimal percentage-format entry so the UI shows keyframe - // diamonds immediately, before the server responds. - writeKeyframeCache(elementId, { + writeKeyframeCache(sf, elementId, { format: "percentage", keyframes: [ { percentage: 0, properties: {} }, @@ -459,11 +474,11 @@ export function useGsapScriptCommits({ { label: "Convert to keyframes" }, ), rollback: (prev) => { - writeKeyframeCache(elementId, prev); + writeKeyframeCache(sf, elementId, prev); }, }); }, - [commitMutation], + [commitMutation, activeCompPath], ); const removeAllKeyframes = useCallback( diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 247419988..9d42eb45d 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -1,7 +1,12 @@ -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 { id?: string | null; @@ -99,14 +104,15 @@ export function useGsapAnimationsForElement( [allAnimations, targetId, targetSelector], ); - // Populate keyframe cache when animations change + // 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(elementId, withKeyframes?.keyframes ?? undefined); - }, [elementId, animations]); + setKeyframeCache(`${sourceFile}#${elementId}`, withKeyframes?.keyframes ?? undefined); + }, [elementId, sourceFile, animations]); return { animations, multipleTimelines, unsupportedTimelinePattern }; } @@ -116,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/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index b5a2f0889..64dd3d001 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -74,6 +74,7 @@ interface TimelineProps { 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; } @@ -93,6 +94,7 @@ export const Timeline = memo(function Timeline({ onDeleteKeyframe, onChangeKeyframeEase, onMoveKeyframe, + onToggleKeyframeAtPlayhead, theme: themeOverrides, }: TimelineProps = {}) { const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]); @@ -494,10 +496,15 @@ export const Timeline = memo(function Timeline({ 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; - usePlayerStore.getState().setCurrentTime(absTime); + onSeek?.(absTime); }} onShiftClickKeyframe={(elId, pct) => { toggleSelectedKeyframe(`${elId}:${pct}`); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index b6f8f56cb..1d9233930 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -62,10 +62,12 @@ interface TimelineCanvasProps { 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({ @@ -109,10 +111,12 @@ export const TimelineCanvas = memo(function TimelineCanvas({ getTrackStyle, keyframeCache, selectedKeyframes, + currentTime, onClickKeyframe, onShiftClickKeyframe, onDragKeyframe, onContextMenuKeyframe, + onToggleKeyframeAtPlayhead: _onToggleKeyframeAtPlayhead, }: TimelineCanvasProps) { const draggedElement = draggedClip?.element ?? null; const activeDraggedElement = @@ -346,8 +350,14 @@ export const TimelineCanvas = memo(function TimelineCanvas({ 0 + ? ((currentTime - previewElement.start) / previewElement.duration) * 100 + : 0 + } elementId={elementKey} selectedKeyframes={selectedKeyframes} onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)} 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({
; onClickKeyframe?: (percentage: number) => void; @@ -26,11 +28,15 @@ interface TimelineClipDiamondsProps { 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, @@ -42,14 +48,31 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ 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(); - e.currentTarget.setPointerCapture(e.pointerId); - dragRef.current = { startX: e.clientX, startPct: pct }; + const startX = e.clientX; - const handleMove = (_me: PointerEvent) => { - // Track movement — visual feedback could be added here + const handleMove = (me: PointerEvent) => { + const dx = me.clientX - startX; + if (Math.abs(dx) > 4) { + dragRef.current = { startX, startPct: pct }; + } }; const handleUp = (ue: PointerEvent) => { @@ -63,10 +86,6 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ 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); - } else if (ue.shiftKey) { - onShiftClickKeyframe?.(elementId, start.startPct); - } else { - onClickKeyframe?.(start.startPct); } }; @@ -76,25 +95,53 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ return (
- {keyframesData.keyframes.map((kf) => { - const leftPx = (kf.percentage / 100) * clipWidthPx; + {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.3; + 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; From 8261074e9a4f8ade5c34e8eac08d4a10a0e23706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 13:45:34 -0400 Subject: [PATCH 26/45] fix(studio): keyframe toggle shows for any gsap animation, adds keyframe at playhead on convert - Widen flat-anim filter from x/y-only to any non-keyframed animation - After convert-to-keyframes, also add keyframe at current playhead % --- packages/studio/src/components/StudioPreviewArea.tsx | 4 +--- packages/studio/src/components/TimelineToolbar.tsx | 11 ++++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index f2500c76c..4967608df 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -166,9 +166,7 @@ export function StudioPreviewArea({ handleGsapAddKeyframe(anim.id, pct, "x", 0); } } else { - const flatAnim = selectedGsapAnimations.find( - (a) => "x" in a.properties || "y" in a.properties, - ); + const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes); if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id); } }} diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 6af2e374b..85979e379 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -30,7 +30,7 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { const sel = session.domEditSelection; const anims = session.selectedGsapAnimations; const kfAnim = anims.find((a) => a.keyframes); - const flatAnim = anims.find((a) => "x" in a.properties || "y" in a.properties); + const flatAnim = anims.find((a) => !a.keyframes); let state: "active" | "inactive" | "none" = "none"; if (kfAnim?.keyframes && sel) { @@ -66,7 +66,16 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { session.handleGsapAddKeyframe(kfAnim.id, pct, "x", 0); } } else if (flatAnim) { + 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) * 100))) + : 0; session.handleGsapConvertToKeyframes(flatAnim.id); + if (pct > 0 && pct < 100) { + session.handleGsapAddKeyframe(flatAnim.id, pct, "x", 0); + } } } : undefined; From 426e0af2a3937d307970748aafa8fa3a541ebaaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 13:50:53 -0400 Subject: [PATCH 27/45] fix(studio): don't add phantom x:0 keyframe from toolbar toggle The toggle was hardcoding x:0 at the playhead percentage, breaking interpolation. Now: convert-only for flat tweens, remove-only for existing keyframes. Adding keyframes with correct interpolated values is done via drag (runtime bridge reads gsap.getProperty). --- packages/studio/src/components/TimelineToolbar.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 85979e379..53cbb8950 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -62,20 +62,11 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { ); if (existing) { session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage); - } else { - session.handleGsapAddKeyframe(kfAnim.id, pct, "x", 0); } + // Adding a keyframe with correct interpolated values requires + // the runtime bridge (drag flow). The toggle only removes. } else if (flatAnim) { - 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) * 100))) - : 0; session.handleGsapConvertToKeyframes(flatAnim.id); - if (pct > 0 && pct < 100) { - session.handleGsapAddKeyframe(flatAnim.id, pct, "x", 0); - } } } : undefined; From fa09bdf905074de3972a622d6251b3e09b065bf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 14:00:46 -0400 Subject: [PATCH 28/45] fix(studio): invalidate gsap keyframe cache on undo/redo After undo/redo reverts the file, the keyframe cache had stale optimistic data. Now bumps the GSAP cache version after each undo/redo, triggering a re-fetch that syncs timeline diamonds with the actual file state. --- packages/studio/src/App.tsx | 8 ++++---- packages/studio/src/contexts/DomEditContext.tsx | 3 +++ packages/studio/src/hooks/useAppHotkeys.ts | 6 ++++++ packages/studio/src/hooks/useDomEditSession.ts | 1 + 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index f9cf37ade..8171302ab 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -269,6 +269,7 @@ export function StudioApp() { 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, @@ -302,8 +303,8 @@ export function StudioApp() { handleCut, onResetKeyframes: () => resetKeyframesRef.current(), onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(), + onAfterUndoRedo: () => invalidateGsapCacheRef.current(), }); - const selectSidebarTabStable = useCallback( (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab), [], @@ -348,11 +349,11 @@ 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); @@ -482,9 +483,8 @@ export function StudioApp() { timelineVisible, toggleTimelineVisibility, }); - if (resolving || waitingForServer || !projectId) { + if (resolving || waitingForServer || !projectId) return ; - } const timelineToolbar = ( {children}; diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index d29a62c3e..aa3258329 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -79,6 +79,7 @@ interface UseAppHotkeysParams { handleCut: () => Promise; onResetKeyframes: () => boolean; onDeleteSelectedKeyframes: () => void; + onAfterUndoRedo?: () => void; } // ── Hook ── @@ -102,6 +103,7 @@ export function useAppHotkeys({ handleCut, onResetKeyframes, onDeleteSelectedKeyframes, + onAfterUndoRedo, }: UseAppHotkeysParams) { const previewHotkeyWindowRef = useRef(null); const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined); @@ -149,6 +151,7 @@ export function useAppHotkeys({ } if (result.ok && result.label) { await syncHistoryPreviewAfterApply(result.paths); + onAfterUndoRedo?.(); showToast(`Undid ${result.label}`, "info"); } }, [ @@ -158,6 +161,7 @@ export function useAppHotkeys({ syncHistoryPreviewAfterApply, waitForPendingDomEditSaves, writeHistoryProjectFile, + onAfterUndoRedo, ]); const handleRedo = useCallback(async () => { @@ -172,6 +176,7 @@ export function useAppHotkeys({ } if (result.ok && result.label) { await syncHistoryPreviewAfterApply(result.paths); + onAfterUndoRedo?.(); showToast(`Redid ${result.label}`, "info"); } }, [ @@ -181,6 +186,7 @@ export function useAppHotkeys({ syncHistoryPreviewAfterApply, waitForPendingDomEditSaves, writeHistoryProjectFile, + onAfterUndoRedo, ]); // ── Stable refs for the consolidated keydown handler ── diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index b6ac65094..9ecd94719 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -572,5 +572,6 @@ export function useDomEditSession({ handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, + invalidateGsapCache: bumpGsapCache, }; } From cc9b4f7197effdd9b85ef337a6c67412cb8dc580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 16:00:40 -0400 Subject: [PATCH 29/45] fix(studio): round gsap animation position to 3 decimal places currentTime was passed unrounded when adding animations, producing values like 0.20127724590558768. Now rounds to millisecond precision at write time and on display in the animation card and summary text. --- packages/studio/src/components/editor/AnimationCard.tsx | 8 +++++--- .../studio/src/components/editor/gsapAnimationHelpers.ts | 3 ++- packages/studio/src/hooks/useGsapScriptCommits.ts | 4 +++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index 0ccd4f444..32e7d50e3 100644 --- a/packages/studio/src/components/editor/AnimationCard.tsx +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -298,8 +298,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} @@ -350,7 +352,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/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/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 785498398..0657cf87d 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -286,7 +286,9 @@ export function useGsapScriptCommits({ if (!data.changed) return; } - const start = currentTime ?? (Number.parseFloat(selection.dataAttributes.start ?? "0") || 0); + const rawStart = + currentTime ?? (Number.parseFloat(selection.dataAttributes.start ?? "0") || 0); + const start = Math.round(rawStart * 1000) / 1000; const toDefaults: Record> = { from: { opacity: 0 }, to: { opacity: 1 }, From 30daf4e89f842e197b1a6a695c6769ba71f63ef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 16:11:46 -0400 Subject: [PATCH 30/45] fix(studio): show keyframe diamond toggle even when no gsap animations exist When all tweens are removed, the toggle now creates a new to() animation so the user can start keyframing from scratch. --- .../studio/src/components/TimelineToolbar.tsx | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 53cbb8950..4f1df8070 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -15,6 +15,7 @@ interface DomEditSessionSlice { 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; } interface TimelineToolbarProps { @@ -46,30 +47,29 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { } // fallow-ignore-next-line complexity - const onToggle = - sel && (kfAnim || flatAnim) - ? () => { - 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) * 100))) - : 0; - const existing = kfAnim.keyframes.keyframes.find( - (k) => Math.abs(k.percentage - pct) <= 1, - ); - if (existing) { - session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage); - } - // Adding a keyframe with correct interpolated values requires - // the runtime bridge (drag flow). The toggle only removes. - } else if (flatAnim) { - session.handleGsapConvertToKeyframes(flatAnim.id); + 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) * 100))) + : 0; + const existing = kfAnim.keyframes.keyframes.find( + (k) => Math.abs(k.percentage - pct) <= 1, + ); + if (existing) { + session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage); } + } else if (flatAnim) { + session.handleGsapConvertToKeyframes(flatAnim.id); + } else { + session.handleGsapAddAnimation("to"); } - : undefined; + } + : undefined; return { state, onToggle }; } From 31a47e79d776f628f584794025472d9054a63a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 16:17:32 -0400 Subject: [PATCH 31/45] fix(studio): auto-keyframing works with any animation, toolbar adds interpolated keyframes - findGsapPositionAnimation falls back to any animation (not just x/y) so drag intercept fires even for opacity-only tweens - Toolbar diamond toggle adds keyframes with linearly interpolated values from surrounding keyframes instead of doing nothing --- .../studio/src/components/TimelineToolbar.tsx | 40 ++++++++++++++++++- .../studio/src/hooks/gsapRuntimeBridge.ts | 9 ++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 4f1df8070..07cbd42d1 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -6,9 +6,42 @@ import { getTimelineToggleTitle } from "../utils/timelineDiscovery"; import { usePlayerStore } from "../player"; import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability"; import { Tooltip } from "./ui"; -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "./editor/domEditingTypes"; +function interpolateKeyframeProperties( + keyframes: GsapPercentageKeyframe[], + pct: number, +): Record { + 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; +} + interface DomEditSessionSlice { domEditSelection: DomEditSelection | null; selectedGsapAnimations: GsapAnimation[]; @@ -62,6 +95,11 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { ); if (existing) { session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage); + } else { + const interpolated = interpolateKeyframeProperties(kfAnim.keyframes.keyframes, pct); + for (const [prop, val] of Object.entries(interpolated)) { + session.handleGsapAddKeyframe(kfAnim.id, pct, prop, val); + } } } else if (flatAnim) { session.handleGsapConvertToKeyframes(flatAnim.id); diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 340c6e291..1a69922ef 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -54,6 +54,7 @@ function readGsapPositionFromIframe( // 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( @@ -61,7 +62,6 @@ function findGsapPositionAnimation(animations: GsapAnimation[]): GsapAnimation | ); if (hasPos) return anim; } - const props = anim.properties; const fromProps = anim.fromProperties; if (anim.method === "fromTo") { @@ -72,7 +72,12 @@ function findGsapPositionAnimation(animations: GsapAnimation[]): GsapAnimation | return anim; } } - return null; + // 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 ──────────────────────────────────────────────────── From 2d3b6e743e93e12bb5ffca0c111558496d165867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 16:45:27 -0400 Subject: [PATCH 32/45] =?UTF-8?q?fix(studio):=20fix=203=20keyframe=20root?= =?UTF-8?q?=20causes=20=E2=80=94=20stale=20cache,=20wrong=20defaults,=20em?= =?UTF-8?q?pty=20keyframes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Toolbar creates to() with x:0, y:0 (not opacity-only) so drag intercept can match position animations immediately 2. Drag intercept does a synchronous fallback fetch when selectedGsapAnimations is stale — eliminates the timing race between toolbar-create and immediate drag 3. Remove optimistic empty-keyframe cache write on convert — let the server response populate the cache with real values --- .../studio/src/hooks/gsapRuntimeBridge.ts | 7 +++- .../studio/src/hooks/useDomEditSession.ts | 19 +++++++++- .../studio/src/hooks/useGsapScriptCommits.ts | 37 ++++--------------- .../studio/src/hooks/useGsapTweenCache.ts | 2 +- 4 files changed, 33 insertions(+), 32 deletions(-) diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 1a69922ef..b75135137 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -131,8 +131,13 @@ export async function tryGsapDragIntercept( animations: GsapAnimation[], iframe: HTMLIFrameElement | null, commitMutation: GsapDragCommitCallbacks["commitMutation"], + fetchFallbackAnimations?: () => Promise, ): Promise { - const posAnim = findGsapPositionAnimation(animations); + let posAnim = findGsapPositionAnimation(animations); + if (!posAnim && fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + posAnim = findGsapPositionAnimation(fresh); + } if (!posAnim) return false; const selector = selectorForSelection(selection); diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 9ecd94719..fe9ceedf1 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -20,6 +20,8 @@ import { useGsapAnimationsForElement, useGsapCacheVersion, usePopulateKeyframeCacheForFile, + fetchParsedAnimations, + getAnimationsForElement, } from "./useGsapTweenCache"; import { tryGsapDragIntercept } from "./gsapRuntimeBridge"; @@ -301,12 +303,27 @@ export function useDomEditSession({ 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], + [ + handleDomPathOffsetCommit, + selectedGsapAnimations, + gsapCommitMutation, + previewIframeRef, + projectId, + gsapSourceFile, + ], ); const handleGsapUpdateProperty = useCallback( diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 0657cf87d..847920baa 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -291,9 +291,9 @@ export function useGsapScriptCommits({ const start = Math.round(rawStart * 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( @@ -453,34 +453,13 @@ export function useGsapScriptCommits({ const convertToKeyframes = useCallback( (selection: DomEditSelection, animationId: string) => { - const sf = selection.sourceFile || activeCompPath || "index.html"; - const elementId = selection.id; - void executeOptimistic({ - apply: () => { - const prev = readKeyframeSnapshot(sf, elementId); - if (!prev) { - writeKeyframeCache(sf, elementId, { - format: "percentage", - keyframes: [ - { percentage: 0, properties: {} }, - { percentage: 100, properties: {} }, - ], - }); - } - return prev; - }, - persist: () => - commitMutation( - selection, - { type: "convert-to-keyframes", animationId }, - { label: "Convert to keyframes" }, - ), - rollback: (prev) => { - writeKeyframeCache(sf, elementId, prev); - }, - }); + void commitMutation( + selection, + { type: "convert-to-keyframes", animationId }, + { label: "Convert to keyframes" }, + ); }, - [commitMutation, activeCompPath], + [commitMutation], ); const removeAllKeyframes = useCallback( diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 9d42eb45d..70fafa9a4 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -34,7 +34,7 @@ export function getAnimationsForElement( ); } -async function fetchParsedAnimations( +export async function fetchParsedAnimations( projectId: string, sourceFile: string, ): Promise { From 20e01e6337dbcae4c3a8a62b375367c099e6b6f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 17:11:53 -0400 Subject: [PATCH 33/45] fix(studio): fix 4 keyframe root causes from ce-plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit U1: Guard CSS offset persistence for GSAP-animated elements — checks iframe __timelines for tween targets before persisting offset attributes to HTML. GSAP elements get visual offset during drag but no HTML leak. U2: Strip stale CSS offset artifacts at runtime — after timeline bind and totalTime nudge, removes data-hf-studio-path-offset and CSS custom properties from elements that are targeted by GSAP tweens. U3: Fix undo/redo cache ordering — bumps GSAP cache version BEFORE preview reload (was after), so the iframe loads with fresh keyframe data. U4: Align toolbar animation duration with element lifetime — reads data-duration and data-start from the element instead of hardcoding 0.5s and currentTime, so keyframe percentages are consistent. --- packages/core/src/runtime/init.ts | 28 ++++++++++++++ packages/studio/src/hooks/useAppHotkeys.ts | 4 +- .../studio/src/hooks/useDomEditCommits.ts | 37 ++++++++++++++++++- .../studio/src/hooks/useGsapScriptCommits.ts | 13 ++++--- 4 files changed, 72 insertions(+), 10 deletions(-) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 27ed48e3e..bf4396dec 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -958,6 +958,34 @@ export function initSandboxRuntimeModular(): void { 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/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index aa3258329..61d65af9f 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -150,8 +150,8 @@ export function useAppHotkeys({ return; } if (result.ok && result.label) { - await syncHistoryPreviewAfterApply(result.paths); onAfterUndoRedo?.(); + await syncHistoryPreviewAfterApply(result.paths); showToast(`Undid ${result.label}`, "info"); } }, [ @@ -175,8 +175,8 @@ export function useAppHotkeys({ return; } if (result.ok && result.label) { - await syncHistoryPreviewAfterApply(result.paths); onAfterUndoRedo?.(); + await syncHistoryPreviewAfterApply(result.paths); showToast(`Redid ${result.label}`, "info"); } }, [ diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index ac8cbc69c..5a61e5d79 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/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 847920baa..75484e307 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -258,7 +258,7 @@ export function useGsapScriptCommits({ async ( selection: DomEditSelection, method: "to" | "from" | "set" | "fromTo", - currentTime?: number, + _currentTime?: number, ) => { const { selector, autoId } = ensureElementAddressable(selection); @@ -286,9 +286,10 @@ export function useGsapScriptCommits({ if (!data.changed) return; } - const rawStart = - currentTime ?? (Number.parseFloat(selection.dataAttributes.start ?? "0") || 0); - const start = Math.round(rawStart * 1000) / 1000; + 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: { x: 0, y: 0, opacity: 1 }, @@ -302,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, From 0f1f313e04f62b2a7f831d0b4d052403dbbd6c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 17:15:44 -0400 Subject: [PATCH 34/45] fix(studio): context menu selects element + seeks before delete/ease actions Right-clicking a keyframe diamond now selects the clip and seeks to that keyframe's time before opening the context menu. This ensures selectedGsapAnimations is populated when delete or ease-change fires. --- packages/studio/src/player/components/Timeline.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 64dd3d001..11a0ec7ef 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -513,6 +513,13 @@ export const Timeline = memo(function Timeline({ 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({ From 2dca9d4c96b230d6263fd27741575e568b45a253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 17:36:41 -0400 Subject: [PATCH 35/45] fix(core): zero-fill empty keyframes when converting flat tweens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit convertToKeyframesInScript created empty 0% or 100% keyframes when resolvedFromValues wasn't passed — GSAP interpolated from undefined, causing elements to vanish or accumulate offset on drag. Now zeros all numeric properties so the keyframe has a valid baseline. --- packages/core/src/parsers/gsapParser.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index ff9b90b7c..96e1f447c 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -1277,10 +1277,24 @@ function resolveConversionProps( resolvedFromValues?: Record, ): { fromProps: Record; toProps: Record } { if (anim.method === "to") { - return { fromProps: resolvedFromValues ?? {}, toProps: { ...anim.properties } }; + if (resolvedFromValues) { + return { fromProps: resolvedFromValues, toProps: { ...anim.properties } }; + } + const zeroedFrom: Record = {}; + for (const [key, val] of Object.entries(anim.properties)) { + if (val != null) zeroedFrom[key] = typeof val === "number" ? 0 : val; + } + return { fromProps: zeroedFrom, toProps: { ...anim.properties } }; } if (anim.method === "from") { - return { fromProps: { ...anim.properties }, toProps: resolvedFromValues ?? {} }; + if (resolvedFromValues) { + return { fromProps: { ...anim.properties }, toProps: resolvedFromValues }; + } + const zeroedTo: Record = {}; + for (const [key, val] of Object.entries(anim.properties)) { + if (val != null) zeroedTo[key] = typeof val === "number" ? 0 : val; + } + return { fromProps: { ...anim.properties }, toProps: zeroedTo }; } // fromTo return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps: { ...anim.properties } }; From db927c993432f5b5ad4898b2117cb2817ed3a2da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 17:44:39 -0400 Subject: [PATCH 36/45] fix(core): use css identity values when zero-filling keyframes on convert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opacity defaults to 1, scale/scaleX/scaleY default to 1 — not 0. Previous zero-fill made elements invisible at 0% (opacity: 0) and collapsed to zero scale. All other transform properties (x, y, rotation, skew) correctly default to 0. --- packages/core/src/parsers/gsapParser.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index 96e1f447c..b4c484bfb 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -1272,6 +1272,18 @@ export function updateKeyframeInScript( } /** 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, @@ -1280,21 +1292,21 @@ function resolveConversionProps( if (resolvedFromValues) { return { fromProps: resolvedFromValues, toProps: { ...anim.properties } }; } - const zeroedFrom: Record = {}; + const identityFrom: Record = {}; for (const [key, val] of Object.entries(anim.properties)) { - if (val != null) zeroedFrom[key] = typeof val === "number" ? 0 : val; + if (val != null) identityFrom[key] = typeof val === "number" ? cssIdentityValue(key) : val; } - return { fromProps: zeroedFrom, toProps: { ...anim.properties } }; + return { fromProps: identityFrom, toProps: { ...anim.properties } }; } if (anim.method === "from") { if (resolvedFromValues) { return { fromProps: { ...anim.properties }, toProps: resolvedFromValues }; } - const zeroedTo: Record = {}; + const identityTo: Record = {}; for (const [key, val] of Object.entries(anim.properties)) { - if (val != null) zeroedTo[key] = typeof val === "number" ? 0 : val; + if (val != null) identityTo[key] = typeof val === "number" ? cssIdentityValue(key) : val; } - return { fromProps: { ...anim.properties }, toProps: zeroedTo }; + return { fromProps: { ...anim.properties }, toProps: identityTo }; } // fromTo return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps: { ...anim.properties } }; From 8e57aa8387c6377decb5f363ce764710383049d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 18:02:02 -0400 Subject: [PATCH 37/45] fix(studio): clear stale css offset when creating gsap animation via toolbar When the toolbar diamond creates a new GSAP animation for an element that already has CSS offset attributes from a prior non-GSAP drag, the old offset is now cleared from both the DOM and the HTML file. Prevents CSS translate stacking with the new GSAP transform. --- packages/studio/src/hooks/useDomEditSession.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index fe9ceedf1..db6ff177f 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -354,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( From 3e137a8a3369071955612e4f6b6ca27658e9ac7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 18:27:37 -0400 Subject: [PATCH 38/45] fix(studio): skip gsap transform strip during active drag gesture stripGsapTranslateFromTransform was cancelling the CSS drag offset by subtracting it from the GSAP transform matrix m41/m42. During an active drag this made the element stay at its GSAP position while the overlay followed the cursor. Now skips the strip when the element has the manual-edit-gesture attribute (set during drag, cleared on release). --- packages/studio/src/components/editor/manualEditsDom.ts | 1 + 1 file changed, 1 insertion(+) 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) From fb2d56ac2ce92256a4019989db70a491978dfe74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 19:14:55 -0400 Subject: [PATCH 39/45] feat(studio): clip-path + filter UI, keyframe-aware property panel, runtime bridge toolbar add - Add clipPath to SUPPORTED_PROPS; string input + presets for filter and clip-path in AnimationCard (blur, brightness, grayscale, circle, inset presets) - Property panel X/Y edits update the nearest keyframe when the element has keyframes, instead of the CSS position - Toolbar diamond reads gsap.getProperty() from iframe for all animated properties when adding a keyframe, falling back to linear interpolation - Wire previewIframeRef through DomEditContext for toolbar access - Add labels for all SUPPORTED_PROPS in gsapAnimationConstants --- packages/core/src/parsers/gsapConstants.ts | 3 +- .../studio/src/components/TimelineToolbar.tsx | 50 ++++++++++++++++- .../src/components/editor/AnimationCard.tsx | 56 +++++++++++++++++++ .../src/components/editor/PropertyPanel.tsx | 5 ++ .../editor/gsapAnimationConstants.ts | 10 ++++ .../studio/src/contexts/DomEditContext.tsx | 3 + .../studio/src/hooks/useDomEditSession.ts | 1 + 7 files changed, 125 insertions(+), 3 deletions(-) diff --git a/packages/core/src/parsers/gsapConstants.ts b/packages/core/src/parsers/gsapConstants.ts index ff15db04e..3aa2fc69f 100644 --- a/packages/core/src/parsers/gsapConstants.ts +++ b/packages/core/src/parsers/gsapConstants.ts @@ -31,8 +31,9 @@ export const SUPPORTED_PROPS = [ // Typography "fontSize", "letterSpacing", - // Filter + // Filter & Clipping "filter", + "clipPath", ]; export const SUPPORTED_EASES = [ diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 07cbd42d1..09f0ffd1d 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -42,6 +42,43 @@ function interpolateKeyframeProperties( 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[]; @@ -49,6 +86,7 @@ interface DomEditSessionSlice { 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 { @@ -96,8 +134,16 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { if (existing) { session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage); } else { - const interpolated = interpolateKeyframeProperties(kfAnim.keyframes.keyframes, pct); - for (const [prop, val] of Object.entries(interpolated)) { + 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); } } diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index 32e7d50e3..839d3f7b3 100644 --- a/packages/studio/src/components/editor/AnimationCard.tsx +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -18,6 +18,20 @@ import { 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); @@ -96,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 (
diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 0d8f38084..e9e3f3c1d 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -238,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))); + onAddKeyframe(gsapAnimId, pct, axis, parsed); + return; + } const current = readStudioPathOffset(element.element); onSetManualOffset(element, { x: axis === "x" ? parsed : current.x, diff --git a/packages/studio/src/components/editor/gsapAnimationConstants.ts b/packages/studio/src/components/editor/gsapAnimationConstants.ts index f70f39549..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 = { diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 67624a890..39b7183da 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -71,6 +71,7 @@ export function DomEditProvider({ handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, invalidateGsapCache, + previewIframeRef, }, children, }: { @@ -137,6 +138,7 @@ export function DomEditProvider({ handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, invalidateGsapCache, + previewIframeRef, }), [ domEditSelection, @@ -197,6 +199,7 @@ export function DomEditProvider({ handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, invalidateGsapCache, + previewIframeRef, ], ); return {children}; diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index db6ff177f..209be746a 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -593,5 +593,6 @@ export function useDomEditSession({ handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, invalidateGsapCache: bumpGsapCache, + previewIframeRef, }; } From ac76d598823875740b77b9f0e30fc07c9a060e3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 19:24:53 -0400 Subject: [PATCH 40/45] fix(studio): reduce undo coalesce window from 1500ms to 300ms --- packages/studio/src/hooks/useDomEditCommits.ts | 2 +- packages/studio/src/utils/editHistory.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 5a61e5d79..658740151 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import { usePlayerStore } from "../player"; import { FONT_EXT } from "../utils/mediaTypes"; import type { PatchOperation } from "../utils/sourcePatcher"; 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; From 1197444678ec4d201edf771399283e06e36a9c30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 19:34:04 -0400 Subject: [PATCH 41/45] fix(studio): reduce keyframe diamond size to 45% of clip height --- packages/studio/src/player/components/TimelineClipDiamonds.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index 881c1cdce..bf6379328 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -28,7 +28,7 @@ interface TimelineClipDiamondsProps { onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; } -const DIAMOND_RATIO = 0.8; +const DIAMOND_RATIO = 0.45; export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ keyframesData, From c98055168adfb4e11e2ddb87bcd531982dbc9f5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 19:36:42 -0400 Subject: [PATCH 42/45] fix(studio): scale diamond svg path to fill 80% of clip height --- .../src/player/components/TimelineClipDiamonds.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index bf6379328..3d5a74625 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -28,7 +28,7 @@ interface TimelineClipDiamondsProps { onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; } -const DIAMOND_RATIO = 0.45; +const DIAMOND_RATIO = 0.8; export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ keyframesData, @@ -150,18 +150,18 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ }} title={`${kf.percentage}%`} > - + {isKfSelected && ( )} From cd2ac3141e9b826fd2a9e54855736cc04eecbf1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 2 Jun 2026 19:38:24 -0400 Subject: [PATCH 43/45] fix(studio): enlarge toolbar diamond icon to 18px in 28px button --- packages/studio/src/components/TimelineToolbar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 09f0ffd1d..05dafd930 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -189,7 +189,7 @@ export function TimelineToolbar({