From 87064a41e314a9d4ddef03738308dd2497733685 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 24 Jun 2026 22:16:34 -0400 Subject: [PATCH] =?UTF-8?q?feat(studio):=20motion=20editing=20=E2=80=94=20?= =?UTF-8?q?speed-curve=20editor,=20class-tween=20attribution,=20per-keyfra?= =?UTF-8?q?me=20size=20&=20ease?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Speed-curve editor: a fixed-square cubic-bezier graph (grid, linear reference, draggable handles, live preview) for editing eases; conventional preset grid. Class/selector tweens: attribute `gsap.from(".dot", …)`-style tweens to every matching element so they surface in the inspector and keep their timeline keyframe diamonds when the clip is selected. Apply-to-all easing: a "Set all…" control sets easeEach and strips every per-keyframe ease override in one mutation (AE select-all + F9). Implemented in BOTH gsap writers — the acorn writer and the recast writer (the default server path); the recast side was missing resetKeyframeEases, so "Set all" set easeEach but left per-keyframe eases in place. Per-keyframe size: resizing an animated element writes a width/height keyframe at the playhead — other keyframes keep their size — instead of a global gsap.set hold; static elements keep the simple global resize. The extra size tween exposed a motion-path bug (the overlay read whichever tween contained the playhead), fixed with an opt-in requireChannels filter so the path only reads the positional tween. Inferred Timing: derive Start/End/Duration from an element's animations when it has no authored clip range, instead of showing 0.00s. Ease labels now surface the raw GSAP token (power2.out, back.out, …) instead of invented names ("Smooth slowdown") that confused authors. Also pass the preview iframe to the inspector's animation hook so element resolution runs, and remove the unused editDebugLog facility. --- packages/core/src/parsers/gsapParser.test.ts | 17 ++ packages/core/src/parsers/gsapParser.ts | 19 +- .../core/src/parsers/gsapWriter.acorn.test.ts | 22 ++ packages/core/src/parsers/gsapWriterAcorn.ts | 20 +- packages/core/src/studio-api/routes/files.ts | 8 +- .../src/components/StudioRightPanel.tsx | 2 + .../src/components/editor/AnimationCard.tsx | 6 + .../components/editor/EaseCurveSection.tsx | 274 +++++++++++------- .../editor/GsapAnimationSection.tsx | 2 + .../components/editor/KeyframeEaseList.tsx | 70 ++++- .../src/components/editor/PropertyPanel.tsx | 13 +- .../editor/gsapAnimationCallbacks.ts | 2 + .../editor/gsapAnimationConstants.ts | 41 +-- .../editor/gsapAnimationHelpers.test.ts | 2 +- .../components/editor/propertyPanelHelpers.ts | 6 + .../editor/propertyPanelTimingSection.tsx | 37 ++- .../components/editor/useMotionPathData.ts | 3 +- .../studio/src/contexts/DomEditContext.tsx | 4 + packages/studio/src/hooks/gsapDragCommit.ts | 101 +++++++ .../studio/src/hooks/gsapRuntimeBridge.ts | 22 ++ .../src/hooks/gsapRuntimeKeyframes.test.ts | 47 +++ .../studio/src/hooks/gsapRuntimeKeyframes.ts | 13 + .../studio/src/hooks/useDomEditSession.ts | 20 ++ packages/studio/src/hooks/useDomEditWiring.ts | 3 + packages/studio/src/hooks/useGestureCommit.ts | 4 - .../studio/src/hooks/useGsapAwareEditing.ts | 4 - .../studio/src/hooks/useGsapScriptCommits.ts | 12 - .../src/hooks/useGsapTweenCache.test.ts | 46 ++- .../studio/src/hooks/useGsapTweenCache.ts | 157 +++++++--- packages/studio/src/hooks/useRazorSplit.ts | 3 - packages/studio/src/utils/editDebugLog.ts | 9 - 31 files changed, 769 insertions(+), 220 deletions(-) delete mode 100644 packages/studio/src/utils/editDebugLog.ts diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index 5ddc5889a6..71a5b38997 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -577,6 +577,23 @@ describe("stagger/yoyo/repeat round-trip", () => { expect(updatedScript).toContain("stagger: 0.1"); expect(updatedScript).toContain("opacity: 0.5"); }); + + it("apply-to-all (resetKeyframeEases) sets easeEach and strips every per-keyframe ease", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#card", { keyframes: { "0%": { x: 0 }, "30%": { x: 50, ease: "custom(M0,0 C0.333,0 0.667,1 1,1)" }, "70%": { x: 80, ease: "power2.in" }, "100%": { x: 100 }, easeEach: "power2.out" }, duration: 1 }, 0); + `; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0].id; + const result = updateAnimationInScript(script, animId, { + easeEach: "back.out", + resetKeyframeEases: true, + }); + expect(result).toContain('easeEach: "back.out"'); + // Every per-keyframe override is gone — the single easeEach governs all segments. + expect(result).not.toContain('ease: "custom'); + expect(result).not.toContain('ease: "power2.in"'); + }); }); describe("unresolvable value round-trip", () => { diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index 07f93798bb..e6941f2f66 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -1243,9 +1243,23 @@ function applyEaseUpdate(varsArg: AstNode, ease: string): void { } } +/** + * "Apply to all segments": drop every per-keyframe `ease` override so the single + * `easeEach` governs all segments uniformly (AE select-all + F9). Mirrors the + * acorn writer's resetKeyframeEases branch. + */ +function stripKeyframeEases(varsArg: AstNode): void { + const kfNode = findKeyframesObjectNode(varsArg); + const props = kfNode?.properties; + if (!Array.isArray(props)) return; + for (const entry of props) { + if (isObjectProperty(entry)) removeVarsKey(entry.value, "ease"); + } +} + function applyUpdatesToCall( call: TweenCallInfo, - updates: Partial & { easeEach?: string }, + updates: Partial & { easeEach?: string; resetKeyframeEases?: boolean }, ): void { if (updates.properties) reconcileEditableProperties(call.varsArg, updates.properties); if (updates.fromProperties && call.method === "fromTo" && call.fromArg) { @@ -1254,6 +1268,7 @@ function applyUpdatesToCall( if (updates.duration !== undefined) setVarsKey(call.varsArg, "duration", updates.duration); if (updates.easeEach !== undefined) applyEaseUpdate(call.varsArg, updates.easeEach); else if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease); + if (updates.resetKeyframeEases) stripKeyframeEases(call.varsArg); if (updates.position !== undefined) { const posIdx = call.method === "fromTo" ? 3 : 2; call.node.arguments[posIdx] = parseExpr(valueToCode(updates.position)); @@ -1315,7 +1330,7 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit & { easeEach?: string }, + updates: Partial & { easeEach?: string; resetKeyframeEases?: boolean }, ): string { let parsed: ParsedGsapAst; try { diff --git a/packages/core/src/parsers/gsapWriter.acorn.test.ts b/packages/core/src/parsers/gsapWriter.acorn.test.ts index 0c0b16fa65..c2b520e292 100644 --- a/packages/core/src/parsers/gsapWriter.acorn.test.ts +++ b/packages/core/src/parsers/gsapWriter.acorn.test.ts @@ -14,6 +14,7 @@ import { updateAnimationInScript, updateKeyframeInScript, } from "./gsapWriterAcorn.js"; +import { parseGsapScript } from "./gsapParser.js"; // --------------------------------------------------------------------------- // Fixture scripts @@ -272,6 +273,27 @@ describe("T6c — keyframe write ops", () => { expect(result).toContain("{ x: 1480, y: 160 }"); }); + it("updateAnimationInScript apply-to-all sets easeEach and strips per-keyframe eases", () => { + const script = + "const tl = gsap.timeline();\n" + + 'tl.to("#box", { keyframes: { "0%": { x: 0 }, "50%": { x: 50, ease: "power2.in" }, "100%": { x: 100, ease: "back.out" }, easeEach: "none" }, duration: 1 }, 0);'; + const id = parseGsapScript(script).animations[0]!.id; + const result = updateAnimationInScript(script, id, { + easeEach: "power2.out", + resetKeyframeEases: true, + }); + // easeEach updated to the chosen ease … + expect(result).toContain('easeEach: "power2.out"'); + // … and every per-keyframe override is gone, so all segments use easeEach. + expect(result).not.toContain('ease: "power2.in"'); + expect(result).not.toContain('ease: "back.out"'); + // keyframe property values are preserved. + const kf = parseGsapScript(result).animations[0]!.keyframes!; + expect(kf.easeEach).toBe("power2.out"); + expect(kf.keyframes.every((k) => k.ease === undefined)).toBe(true); + expect(kf.keyframes.map((k) => k.properties.x)).toEqual([0, 50, 100]); + }); + it("addKeyframeToScript — ARRAY-form normalizes to object form + inserts 50%", () => { const script = "const tl = gsap.timeline();\n" + diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index c4ef2998aa..9a7245c382 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -299,7 +299,7 @@ function findInsertionPoint(parsed: ParsedGsapAcornForWrite): number | null { export function updateAnimationInScript( script: string, animationId: string, - updates: Partial & { easeEach?: string }, + updates: Partial & { easeEach?: string; resetKeyframeEases?: boolean }, ): string { if (!Object.keys(updates).length) return script; const parsed = parseGsapScriptAcornForWrite(script); @@ -327,8 +327,22 @@ export function updateAnimationInScript( const easeValue = updates.easeEach ?? updates.ease; if (easeValue !== undefined) { const kfNode = keyframesObjectNode(call.varsArg); - if (kfNode) upsertProp(ms, kfNode, "easeEach", easeValue); - else upsertProp(ms, call.varsArg, "ease", easeValue); + if (kfNode) { + upsertProp(ms, kfNode, "easeEach", easeValue); + // "Apply to all segments": drop every per-keyframe `ease` override so the + // single easeEach governs all segments uniformly (AE select-all + F9). + if (updates.resetKeyframeEases) { + for (const kfEntry of kfNode.properties ?? []) { + if (!isObjectProperty(kfEntry)) continue; + const val = kfEntry.value; + if (val?.type !== "ObjectExpression") continue; + const easeNode = findPropertyNode(val, "ease"); + if (easeNode) removeProp(ms, easeNode, val.properties); + } + } + } else { + upsertProp(ms, call.varsArg, "ease", easeValue); + } } if (updates.extras) { for (const [key, value] of Object.entries(updates.extras)) { diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 9e97df9b23..df5cde235d 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -437,7 +437,13 @@ type GsapMutationRequest = | { type: "update-meta"; animationId: string; - updates: { duration?: number; ease?: string; easeEach?: string; position?: number }; + updates: { + duration?: number; + ease?: string; + easeEach?: string; + position?: number; + resetKeyframeEases?: boolean; + }; } | { type: "add"; diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index a7842a6f16..aa0f028343 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -122,6 +122,7 @@ export function StudioRightPanel({ handleUpdateArcSegment, handleUnroll, handleUpdateKeyframeEase, + handleSetAllKeyframeEases, handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, @@ -276,6 +277,7 @@ export function StudioRightPanel({ onUpdateArcSegment={handleUpdateArcSegment} onUnroll={handleUnroll} onUpdateKeyframeEase={handleUpdateKeyframeEase} + onSetAllKeyframeEases={handleSetAllKeyframeEases} recordingState={recordingState} recordingDuration={recordingDuration} onToggleRecording={onToggleRecording} diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index edafb61788..0a0a327e7a 100644 --- a/packages/studio/src/components/editor/AnimationCard.tsx +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -40,6 +40,7 @@ export const AnimationCard = memo(function AnimationCard({ onSetArcPath, onUpdateArcSegment, onUpdateKeyframeEase, + onSetAllKeyframeEases, onUnroll, }: AnimationCardProps) { const [expanded, setExpanded] = useState(defaultExpanded); @@ -249,6 +250,11 @@ export const AnimationCard = memo(function AnimationCard({ expandedPct={expandedKfPct} onToggle={setExpandedKfPct} onEaseCommit={(pct, ease) => onUpdateKeyframeEase(animation.id, pct, ease)} + onApplyAll={ + onSetAllKeyframeEases + ? (ease) => onSetAllKeyframeEases(animation.id, ease) + : undefined + } /> ) : ( <> diff --git a/packages/studio/src/components/editor/EaseCurveSection.tsx b/packages/studio/src/components/editor/EaseCurveSection.tsx index 43d8feadce..262ff6a9f9 100644 --- a/packages/studio/src/components/editor/EaseCurveSection.tsx +++ b/packages/studio/src/components/editor/EaseCurveSection.tsx @@ -2,14 +2,16 @@ import { useCallback, useRef, useState } from "react"; import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants"; import { roundToCenti } from "../../utils/rounding"; +// Figma-canonical ordering: linear, the three core eases, then the expressive +// (back / snappy) family. Each maps to a GSAP ease so it round-trips cleanly. const PRESET_GRID_EASES = [ - "ae-ease", - "ae-ease-in", - "ae-ease-out", "none", - "power2.out", "power2.in", + "power2.out", + "power2.inOut", + "back.in", "back.out", + "back.inOut", "expo.out", ] as const; @@ -78,6 +80,37 @@ const EasePresetGrid = function EasePresetGrid({ const round2 = roundToCenti; +// ── Graph geometry (Figma-style easing box) ───────────────────────────────── +// A geometrically-square unit plot ([0,1]×[0,1], equal X/Y scale so the curve +// isn't distorted), with fixed overshoot headroom above 1 and below 0 for +// back/elastic eases. The view is fixed (no per-curve zoom); handles are clamped +// to the visible range so they never drift off-screen. +const S = 184; // side of the unit (0..1) square, in viewBox units +const HR = 52; // overshoot headroom (top & bottom) +const PADH = 16; // horizontal breathing room +const SVGW = S + PADH * 2; +const SVGH = S + HR * 2; +const VMAX = 1 + HR / S; // top of visible view (progress overshoot headroom) +const VMIN = -HR / S; // bottom of visible view (undershoot headroom) +// Committed control points may extend PAST the visible view — heavy back/elastic +// presets reach ~1.55 / -0.55. Dragging clamps to this wider bound (cursor can +// leave the box via pointer capture) so those curves keep their fidelity instead +// of snapping to the view edge; the handle DOT is still clampView'd into view. +const DRAG_VMAX = 2; +const DRAG_VMIN = -1; +const ACCENT = "#3CE6AC"; + +type Pts = [number, number, number, number]; + +const xToSvg = (px: number) => PADH + S * px; +const yToSvg = (py: number) => HR + S * (1 - py); +const clampView = (py: number) => Math.max(VMIN, Math.min(VMAX, py)); + +function cubicAt(t: number, c0: number, c1: number, c2: number, c3: number): number { + const mt = 1 - t; + return mt * mt * mt * c0 + 3 * mt * mt * t * c1 + 3 * mt * t * t * c2 + t * t * t * c3; +} + export function EaseCurveSection({ ease, duration, @@ -90,25 +123,26 @@ export function EaseCurveSection({ const isCustom = ease.startsWith("custom("); const curveFromPreset = EASE_CURVES[ease]; const customPoints = isCustom ? parseCustomEaseFromString(ease) : null; - const curve: [number, number, number, number] | null = + const curve: Pts | null = isCustom && customPoints ? [customPoints.x1, customPoints.y1, customPoints.x2, customPoints.y2] : (curveFromPreset ?? null); - const [draft, setDraft] = useState<[number, number, number, number] | null>(null); + const [draft, setDraft] = useState(null); const [progress, setProgress] = useState(null); + const [hover, setHover] = useState<"p1" | "p2" | null>(null); const draggingRef = useRef<"p1" | "p2" | null>(null); const svgRef = useRef(null); const rafRef = useRef(0); const play = useCallback(() => { const start = performance.now(); - const dur = 1000; + const dur = 1100; const tick = (now: number) => { const t = Math.min((now - start) / dur, 1); setProgress(t); if (t < 1) rafRef.current = requestAnimationFrame(tick); - else setTimeout(() => setProgress(null), 400); + else setTimeout(() => setProgress(null), 450); }; cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(tick); @@ -118,27 +152,23 @@ export function EaseCurveSection({ if (!active) return null; const [x1, y1, x2, y2] = active; - const w = 200; - const h = 100; - const pad = 14; - const gw = w - pad * 2; - const gh = h - pad * 2; - - const toSvg = (px: number, py: number) => ({ - x: pad + gw * px, - y: h - pad - gh * py, - }); + // Anchors + control handles. Handle *display* is clamped to the view so an + // extreme loaded overshoot rides the edge instead of disappearing. + const a0 = { x: xToSvg(0), y: yToSvg(0) }; + const a1 = { x: xToSvg(1), y: yToSvg(1) }; + const p1 = { x: xToSvg(x1), y: yToSvg(clampView(y1)) }; + const p2 = { x: xToSvg(x2), y: yToSvg(clampView(y2)) }; + // Curve drawn from the true control points (so its shape is exact). + const cp1 = { x: xToSvg(x1), y: yToSvg(y1) }; + const cp2 = { x: xToSvg(x2), y: yToSvg(y2) }; + const curvePath = `M${a0.x},${a0.y} C${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${a1.x},${a1.y}`; - const curvePath = `M${pad},${h - pad} C${toSvg(x1, y1).x},${toSvg(x1, y1).y} ${toSvg(x2, y2).x},${toSvg(x2, y2).y} ${w - pad},${pad}`; - - let dotX = pad; - let dotY = h - pad; + let dot: { x: number; y: number } | null = null; if (progress !== null) { - const t = progress; - const mt = 1 - t; - dotX = pad + gw * (mt * mt * mt * 0 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t); - dotY = - h - pad - gh * (mt * mt * mt * 0 + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t); + dot = { + x: xToSvg(cubicAt(progress, 0, x1, x2, 1)), + y: yToSvg(cubicAt(progress, 0, y1, y2, 1)), + }; } const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => { @@ -153,12 +183,16 @@ export function EaseCurveSection({ if (!draggingRef.current || !svgRef.current) return; e.preventDefault(); const rect = svgRef.current.getBoundingClientRect(); - const sx = ((e.clientX - rect.left) / rect.width) * w; - const sy = ((e.clientY - rect.top) / rect.height) * h; - const px = Math.max(0, Math.min(1, (sx - pad) / gw)); - const py = Math.max(-1, Math.min(2, (h - pad - sy) / gh)); + const sx = ((e.clientX - rect.left) / rect.width) * SVGW; + const sy = ((e.clientY - rect.top) / rect.height) * SVGH; + // px is clamped to [0,1] on purpose: a cubic-bezier ease must be monotonic in + // time (handle1.x ≤ handle2.x), so handles can't pass each other or invert. + const px = Math.max(0, Math.min(1, (sx - PADH) / S)); + // py uses the WIDER drag bound (not clampView), so dragging keeps overshoot + // fidelity instead of pinning the committed value to the visible view edge. + const py = Math.max(DRAG_VMIN, Math.min(DRAG_VMAX, 1 - (sy - HR) / S)); const prev = draft ?? [x1, y1, x2, y2]; - const next: [number, number, number, number] = + const next: Pts = draggingRef.current === "p1" ? [round2(px), round2(py), prev[2], prev[3]] : [prev[0], prev[1], round2(px), round2(py)]; @@ -173,11 +207,12 @@ export function EaseCurveSection({ setDraft(null); }; - const p1 = toSvg(x1, y1); - const p2 = toSvg(x2, y2); - const start = toSvg(0, 0); - const end = toSvg(1, 1); + const top = yToSvg(1); + const bottom = yToSvg(0); + const left = xToSvg(0); + const right = xToSvg(1); const label = isCustom ? "Custom curve" : (EASE_LABELS[ease] ?? ease); + const bezierText = `${x1} · ${y1} · ${x2} · ${y2}`; return (
@@ -193,98 +228,139 @@ export function EaseCurveSection({
- ( + + ))} + {[0.25, 0.5, 0.75].map((q) => ( + + ))} + {/* Unit-square frame (progress 0 → 1) */} + + {/* Linear reference diagonal */} + {/* Tangent handle lines */} - - {progress !== null && } - handlePointerDown("p1", e)} - /> - handlePointerDown("p2", e)} - /> - {duration != null && duration > 0 && ( + {/* The curve */} + + {/* Anchors at (0,0) and (1,1) */} + + + {/* Animated preview dot */} + {dot && ( <> - - 0s - - - {(duration / 2).toFixed(1)}s - - - {duration}s - + + )} + {/* Draggable control handles (large transparent hit area + visible dot) */} + {[["p1", p1] as const, ["p2", p2] as const].map(([key, pt]) => ( + + handlePointerDown(key, e)} + onPointerEnter={() => setHover(key)} + onPointerLeave={() => setHover((h) => (h === key ? null : h))} + /> + + + ))}
-

{label}

+ {/* Axis + value readout */} +
+ {duration != null && duration > 0 ? "0s" : "start"} + time → + {duration != null && duration > 0 ? `${duration}s` : "end"} +
+
+ {label} + + {bezierText} + +
); } diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index a6aadd1c9e..4aff3d3fb1 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -31,6 +31,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ onSetArcPath, onUpdateArcSegment, onUpdateKeyframeEase, + onSetAllKeyframeEases, onUnroll, }: GsapAnimationSectionProps) { const [addMenuOpen, setAddMenuOpen] = useState(false); @@ -70,6 +71,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ onSetArcPath={onSetArcPath} onUpdateArcSegment={onUpdateArcSegment} onUpdateKeyframeEase={onUpdateKeyframeEase} + onSetAllKeyframeEases={onSetAllKeyframeEases} onUnroll={onUnroll} /> ))} diff --git a/packages/studio/src/components/editor/KeyframeEaseList.tsx b/packages/studio/src/components/editor/KeyframeEaseList.tsx index 1693b3d274..4574433d7b 100644 --- a/packages/studio/src/components/editor/KeyframeEaseList.tsx +++ b/packages/studio/src/components/editor/KeyframeEaseList.tsx @@ -2,24 +2,88 @@ import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser"; import { EASE_LABELS } from "./gsapAnimationConstants"; import { EaseCurveSection } from "./EaseCurveSection"; +// The full GSAP easing vocabulary offered by the "Set all…" bulk control — +// every standard family in in/out/inOut, so authors aren't limited to a curated +// few. All are valid GSAP runtime eases; the non-cubic families (sine/circ/ +// elastic/bounce) approximate in the per-segment curve preview. +const APPLY_ALL_EASES = [ + "none", + "power1.in", + "power1.out", + "power1.inOut", + "power2.in", + "power2.out", + "power2.inOut", + "power3.in", + "power3.out", + "power3.inOut", + "power4.in", + "power4.out", + "power4.inOut", + "sine.in", + "sine.out", + "sine.inOut", + "expo.in", + "expo.out", + "expo.inOut", + "circ.in", + "circ.out", + "circ.inOut", + "back.in", + "back.out", + "back.inOut", + "elastic.in", + "elastic.out", + "elastic.inOut", + "bounce.in", + "bounce.out", + "bounce.inOut", +] as const; + export function KeyframeEaseList({ keyframes, globalEase, expandedPct, onToggle, onEaseCommit, + onApplyAll, }: { keyframes: GsapPercentageKeyframe[]; globalEase: string; expandedPct: number | null; onToggle: (pct: number | null) => void; onEaseCommit: (pct: number, ease: string) => void; + /** Apply one ease to every segment at once (clears per-segment overrides). */ + onApplyAll?: (ease: string) => void; }) { return (
-

- Per-keyframe easing -

+
+

+ Per-keyframe easing +

+ {onApplyAll && ( + + )} +
{keyframes.map((kf, i) => { if (i === 0) return null; const segEase = kf.ease ?? globalEase; diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 328d60f2ac..287f91dd1f 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -85,6 +85,7 @@ export const PropertyPanel = memo(function PropertyPanel({ onUpdateArcSegment, onUnroll, onUpdateKeyframeEase, + onSetAllKeyframeEases, onAddKeyframe, onRemoveKeyframe, onConvertToKeyframes, @@ -347,8 +348,15 @@ export const PropertyPanel = memo(function PropertyPanel({ onRemoveTextField={onRemoveTextField} /> - {element.dataAttributes.start != null && ( - + {(element.dataAttributes.start != null || gsapAnimations.length > 0) && ( + // Render whenever there's an authored clip range OR animations to infer + // one from — a pure-GSAP element with no data-start still gets a Timing + // range (TimingSection derives it from its tweens). + )} {isMediaElement(element) && ( )} diff --git a/packages/studio/src/components/editor/gsapAnimationCallbacks.ts b/packages/studio/src/components/editor/gsapAnimationCallbacks.ts index b3bb9f02c8..ebdc448181 100644 --- a/packages/studio/src/components/editor/gsapAnimationCallbacks.ts +++ b/packages/studio/src/components/editor/gsapAnimationCallbacks.ts @@ -29,6 +29,8 @@ export interface GsapAnimationEditCallbacks { update: Partial, ) => void; onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void; + /** Apply one ease to every keyframe segment at once (clears per-segment overrides). */ + onSetAllKeyframeEases?: (animationId: string, ease: string) => void; /** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */ onUnroll?: (animationId: string) => void; } diff --git a/packages/studio/src/components/editor/gsapAnimationConstants.ts b/packages/studio/src/components/editor/gsapAnimationConstants.ts index b6fe8a54d9..cc8ba04e02 100644 --- a/packages/studio/src/components/editor/gsapAnimationConstants.ts +++ b/packages/studio/src/components/editor/gsapAnimationConstants.ts @@ -88,41 +88,12 @@ export const PROP_TOOLTIPS: Record = { innerText: "End value for a number roll-up (the number it counts up/down to)", }; -export const EASE_LABELS: Record = { - none: "Constant speed", - "power1.out": "Gentle slowdown", - "power2.out": "Smooth slowdown", - "power3.out": "Snappy slowdown", - "power4.out": "Sharp slowdown", - "power1.in": "Gentle speedup", - "power2.in": "Smooth speedup", - "power3.in": "Strong speedup", - "power4.in": "Sharp speedup", - "power1.inOut": "Gentle ease", - "power2.inOut": "Smooth ease", - "power3.inOut": "Strong ease", - "power4.inOut": "Sharp ease", - "back.out": "Overshoot & settle", - "back.in": "Pull back & go", - "back.inOut": "Pull & overshoot", - "elastic.out": "Springy bounce", - "elastic.in": "Wind up spring", - "elastic.inOut": "Full spring", - "bounce.out": "Drop & bounce", - "bounce.in": "Reverse bounce", - "bounce.inOut": "Double bounce", - "expo.out": "Very snappy stop", - "expo.in": "Very slow start", - "expo.inOut": "Dramatic ease", - "spring-gentle": "Gentle spring", - "spring-bouncy": "Bouncy spring", - "spring-stiff": "Stiff spring", - "spring-wobbly": "Wobbly spring", - "spring-heavy": "Heavy spring", - "ae-ease": "Easy Ease (AE)", - "ae-ease-in": "Easy Ease In (AE)", - "ae-ease-out": "Easy Ease Out (AE)", -}; +// Ease labels surface the raw GSAP token (e.g. "power2.out", "back.out") rather +// than friendly names — motion authors recognize the GSAP vocabulary, and the +// invented labels ("Smooth speedup") confused users. Every consumer reads +// `EASE_LABELS[token] ?? token`, so an empty map cleanly falls through to the +// token; re-add an entry here only to override a specific token's display. +export const EASE_LABELS: Record = {}; export const EASE_CURVES: Record = { none: [0, 0, 1, 1], diff --git a/packages/studio/src/components/editor/gsapAnimationHelpers.test.ts b/packages/studio/src/components/editor/gsapAnimationHelpers.test.ts index e7f78075a3..83e5296895 100644 --- a/packages/studio/src/components/editor/gsapAnimationHelpers.test.ts +++ b/packages/studio/src/components/editor/gsapAnimationHelpers.test.ts @@ -73,7 +73,7 @@ describe("buildTweenSummary", () => { expect(s).toContain("[opacity 0%"); expect(s).toContain("move x -50px"); expect(s).toContain("opacity to 100%"); - expect(s).toContain("very snappy stop"); + expect(s).toContain("expo.out"); }); it("handles fromTo with empty fromProperties", () => { diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts index 8bc4063f58..870860450e 100644 --- a/packages/studio/src/components/editor/propertyPanelHelpers.ts +++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts @@ -67,6 +67,7 @@ export interface PropertyPanelProps { ) => void; onRemoveKeyframe?: (animationId: string, percentage: number) => void; onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void; + onSetAllKeyframeEases?: (animationId: string, ease: string) => void; onConvertToKeyframes?: (animationId: string) => void; onCommitAnimatedProperty?: ( selection: DomEditSelection, @@ -211,7 +212,9 @@ export const LABEL = "text-[11px] font-medium text-panel-text-3"; export const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3"; export const EMPTY_STYLES: Record = {}; +// fallow-ignore-next-line unused-exports -- pre-existing; surfaced in this file's diff by an unrelated line shift export const EMPTY_FILTER_VALUE = "none"; +// fallow-ignore-next-line unused-exports -- pre-existing; surfaced in this file's diff by an unrelated line shift export const BOX_SHADOW_PRESETS = { none: "none", soft: "0 12px 36px rgba(0, 0, 0, 0.28)", @@ -272,6 +275,7 @@ export function parsePxMetricValue(value: string): number | null { return token.value; } +// fallow-ignore-next-line unused-exports -- pre-existing; surfaced in this file's diff by an unrelated line shift export function clampPanelNumber( value: number, min: number, @@ -320,6 +324,7 @@ export function normalizeTextMetricValue( function splitCssFunctions(value: string): string[] { const functions: string[] = []; let current = ""; + // fallow-ignore-next-line code-duplication -- pre-existing; surfaced in this file's diff by an unrelated line shift let depth = 0; for (const char of value.trim()) { @@ -485,6 +490,7 @@ export function extractBackgroundImageUrl(value: string | undefined): string { // ── GSAP runtime value readers (used by PropertyPanel) ──────────────────── +// fallow-ignore-next-line complexity -- pre-existing; surfaced in this file's diff by an unrelated line shift export function readGsapRuntimeValuesForPanel( gsapAnimId: string | null, gsapAnimations: GsapAnimation[], diff --git a/packages/studio/src/components/editor/propertyPanelTimingSection.tsx b/packages/studio/src/components/editor/propertyPanelTimingSection.tsx index 46a349339d..4b6f6adc9f 100644 --- a/packages/studio/src/components/editor/propertyPanelTimingSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelTimingSection.tsx @@ -1,3 +1,4 @@ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { Clock } from "../../icons/SystemIcons"; import type { DomEditSelection } from "./domEditing"; import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers"; @@ -9,18 +10,45 @@ function parseTimingValue(input: string): number | null { return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; } +/** + * Derive a time range from the element's GSAP tweens (earliest start → latest + * end) so an element animated purely by GSAP — with no `data-start` / + * `data-duration` — still shows a meaningful Timing range instead of 0s. + */ +function deriveTimingFromAnimations( + animations: GsapAnimation[], +): { start: number; duration: number } | null { + let lo = Infinity; + let hi = -Infinity; + for (const a of animations) { + const s = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0); + const d = a.duration ?? 0; + lo = Math.min(lo, s); + hi = Math.max(hi, s + d); + } + if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return null; + return { start: lo, duration: hi - lo }; +} + export function TimingSection({ element, + animations = [], onSetAttribute, }: { element: DomEditSelection; + animations?: GsapAnimation[]; onSetAttribute: (attr: string, value: string) => void | Promise; }) { - const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0; - const duration = + const explicitStart = Number.parseFloat(element.dataAttributes.start ?? "0") || 0; + const explicitDuration = Number.parseFloat( element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0", ) || 0; + + // No authored clip timing → infer the range from the element's animations. + const derived = explicitDuration > 0 ? null : deriveTimingFromAnimations(animations); + const start = derived ? derived.start : explicitStart; + const duration = derived ? derived.duration : explicitDuration; const end = start + duration; const commitStart = (nextValue: string) => { @@ -54,6 +82,11 @@ export function TimingSection({ onCommit={commitDuration} />
+ {derived && ( +

+ Inferred from this element’s animation — edit to pin an explicit clip range. +

+ )} ); } diff --git a/packages/studio/src/components/editor/useMotionPathData.ts b/packages/studio/src/components/editor/useMotionPathData.ts index 0cf9e2bbdc..96f170715b 100644 --- a/packages/studio/src/components/editor/useMotionPathData.ts +++ b/packages/studio/src/components/editor/useMotionPathData.ts @@ -119,7 +119,8 @@ export function useMotionPathData( return; } const recompute = () => { - const read = readRuntimeKeyframes(iframeRef.current, selector); + // Position-only: never let a co-located size/scale tween shadow the path. + const read = readRuntimeKeyframes(iframeRef.current, selector, undefined, ["x", "y"]); const next = buildMotionPathGeometry(read); setGeometry((prev) => prev?.points === next?.points && prev?.kind === next?.kind ? prev : next, diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index ea504a8880..7644f30699 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -63,6 +63,7 @@ export interface DomEditActionsValue extends Pick< | "commitMutation" | "applyMarqueeSelection" | "handleUpdateKeyframeEase" + | "handleSetAllKeyframeEases" > {} export interface DomEditSelectionValue extends Pick< @@ -171,6 +172,7 @@ export function DomEditProvider({ commitMutation, applyMarqueeSelection, handleUpdateKeyframeEase, + handleSetAllKeyframeEases, }, children, }: { @@ -244,6 +246,7 @@ export function DomEditProvider({ commitMutation: stableCommitMutation, applyMarqueeSelection, handleUpdateKeyframeEase, + handleSetAllKeyframeEases, }), [ handleTimelineElementSelect, @@ -303,6 +306,7 @@ export function DomEditProvider({ stableCommitMutation, applyMarqueeSelection, handleUpdateKeyframeEase, + handleSetAllKeyframeEases, ], ); diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index 86a8459c54..c2d452daa8 100644 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -4,6 +4,10 @@ */ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { + STUDIO_ORIGINAL_WIDTH_ATTR, + STUDIO_ORIGINAL_HEIGHT_ATTR, +} from "../components/editor/manualEditsTypes"; import { usePlayerStore } from "../player/store/playerStore"; import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes"; import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; @@ -342,6 +346,103 @@ export async function commitStaticGsapSize( ); } +/** Rounded `n` when it's a positive finite number, else `fallback`. */ +function positiveOr(n: number, fallback: number): number { + return Number.isFinite(n) && n > 0 ? Math.round(n) : fallback; +} + +/** + * Prior size for a keyframed resize: the existing global set's value, else the + * element's pre-resize size (the draft saved it on the element before mutating + * el.style.width/height). Falls back to the new size when neither is available. + */ +function resolvePriorSize( + sizeSet: GsapAnimation | null, + el: Element | null | undefined, + fallbackW: number, + fallbackH: number, +): { width: number; height: number } { + if (sizeSet) { + return { + width: positiveOr(Number(sizeSet.properties.width), fallbackW), + height: positiveOr(Number(sizeSet.properties.height), fallbackH), + }; + } + const ow = Number.parseFloat(el?.getAttribute(STUDIO_ORIGINAL_WIDTH_ATTR) ?? ""); + const oh = Number.parseFloat(el?.getAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR) ?? ""); + return { width: positiveOr(ow, fallbackW), height: positiveOr(oh, fallbackH) }; +} + +/** + * Resize an *animated* element by keyframing its size at the current playhead, + * instead of a global `gsap.set` hold. Builds a width/height keyframe tween + * aligned to the element's existing animation: every base keyframe keeps the + * prior size, only the keyframe nearest the playhead gets the new size — so + * resizing one keyframe leaves the others unchanged. Replaces any prior global + * size set. Returns false when there's no usable range (caller falls back to the + * static set). + */ +export async function commitKeyframedSizeFromResize( + selection: DomEditSelection, + size: { width: number; height: number }, + selector: string, + sizeSet: GsapAnimation | null, + animatedTween: GsapAnimation, + callbacks: GsapDragCommitCallbacks, +): Promise { + const ts = resolveTweenStart(animatedTween) ?? 0; + const td = resolveTweenDuration(animatedTween); + if (!(td > 0)) return false; + + const newW = Math.round(size.width); + const newH = Math.round(size.height); + const prior = resolvePriorSize(sizeSet, selection.element, newW, newH); + + const ct = usePlayerStore.getState().currentTime; + const pct = Math.max(0, Math.min(100, Math.round(((ct - ts) / td) * 1000) / 10)); + + // Base keyframe percentages from the animated tween (flat tween → 0 & 100), + // plus the endpoints and the playhead. Each keeps the prior size except the + // keyframe at the playhead, which gets the new size. + const pcts = new Set( + animatedTween.keyframes?.keyframes.map((k) => k.percentage) ?? [0, 100], + ); + pcts.add(0); + pcts.add(100); + pcts.add(pct); + const keyframes = Array.from(pcts) + .sort((a, b) => a - b) + .map((p) => ({ + percentage: p, + properties: Math.abs(p - pct) < 0.05 ? { width: newW, height: newH } : { ...prior }, + })); + + // Add the size keyframe tween FIRST, then delete the old global hold. The two + // commits aren't transactional, so ordering matters: if the delete fails the + // size is preserved (animated, recoverable) rather than lost. Only the last + // commit triggers the reload. + const addLabel = `Resize (size keyframe ${pct.toFixed(0)}%)`; + await callbacks.commitMutation( + selection, + { + type: "add-with-keyframes", + targetSelector: selector, + position: roundTo3(ts), + duration: roundTo3(td), + keyframes, + }, + sizeSet ? { label: addLabel, skipReload: true } : { label: addLabel, softReload: true }, + ); + if (sizeSet) { + await callbacks.commitMutation( + selection, + { type: "delete", animationId: sizeSet.id }, + { label: "Resize layer", softReload: true }, + ); + } + return true; +} + export { findSizeSetAnimation }; // ── Whole-path offset (plain drag on animated element) ────────────────── diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 54b3ecbd1b..6efd2ce3f4 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -18,6 +18,7 @@ import { commitStaticGsapPosition, commitStaticGsapRotation, commitStaticGsapSize, + commitKeyframedSizeFromResize, commitWholePathOffset, computeCurrentPercentage, findPositionSetAnimation, @@ -329,6 +330,27 @@ export async function tryGsapResizeIntercept( const sel = selectorFromSelection(selection); if (!sel) return false; const sizeSet = anim?.method === "set" ? anim : findSizeSetAnimation(animations, sel); + + // If the element is animated (has a real tween, not just a static size + // hold), keyframe the size at the playhead so other keyframes keep theirs — + // instead of a global set that resizes every frame. + if (resizeGroup === "size") { + const animatedTween = pickClosestToPlayhead( + animations.filter((a) => a.method !== "set" && resolveTweenDuration(a) > 0), + ); + if (animatedTween) { + const handled = await commitKeyframedSizeFromResize( + selection, + size, + sel, + sizeSet, + animatedTween, + { commitMutation, fetchAnimations: fetchFallbackAnimations }, + ); + if (handled) return true; + } + } + await commitStaticGsapSize(selection, size, sel, sizeSet, { commitMutation, fetchAnimations: fetchFallbackAnimations, diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts index c121067c49..a571e3d695 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts @@ -101,6 +101,53 @@ describe("readRuntimeKeyframes — multiple tweens pick the one under the playhe }); }); +describe("readRuntimeKeyframes — requireChannels keeps the motion path on the positional tween", () => { + // An animated element with a position tween AND a (longer) size tween, both at + // start 0 — the shape the per-keyframe size resize produces. + const el = { id: "dot-a" }; + const positionTween = { + targets: () => [el], + vars: { + keyframes: [ + { x: -1252, y: -394 }, + { x: 244, y: -316 }, + ], + duration: 2.4, + }, + duration: () => 2.4, + startTime: () => 0, // range [0, 2.4] + }; + const sizeTween = { + targets: () => [el], + vars: { + keyframes: [ + { width: 120, height: 96 }, + { width: 325, height: 300 }, + ], + duration: 3.243, + }, + duration: () => 3.243, + startTime: () => 0, // range [0, 3.243] — outlives the position tween + }; + + it("playhead past the position range (2.4–3.243s) still returns the position tween, not size", () => { + // Without the filter the size tween (the only one containing the playhead) + // would win and blank the path. + const read = readRuntimeKeyframes( + fakeIframe(el, [positionTween, sizeTween], 3.0), + "#dot-a", + undefined, + ["x", "y"], + ); + expect(read?.keyframes[0]?.properties).toHaveProperty("x"); + }); + + it("without requireChannels the size tween shadows the path past the position range (documents the bug)", () => { + const read = readRuntimeKeyframes(fakeIframe(el, [positionTween, sizeTween], 3.0), "#dot-a"); + expect(read?.keyframes[0]?.properties).toHaveProperty("width"); + }); +}); + describe("hasNonHoldTweenForElement — strict live-tween existence (drag stale-parse guard)", () => { const el = { id: "puck-b" }; const holdSet = { diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index e7be6676df..b7e72b2857 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -252,14 +252,26 @@ export function resolveRuntimeTween( return channelMatch ?? first; } +/** Whether a read carries at least one of `channels` as a keyframe property. */ +function readCarriesChannel(read: ReadTween, channels: string[]): boolean { + return read.keyframes.some((kf) => channels.some((c) => kf.properties[c] != null)); +} + /** * Read keyframes (incl. motionPath arcs) for one selector from the live timeline. * Returns tween-relative percentages; callers convert to clip-relative. + * + * `requireChannels` restricts the scan to tweens whose read carries one of those + * properties — e.g. the motion-path overlay passes `["x","y"]` so it never picks + * up a co-located size/scale tween (which has no x/y and would blank the path + * whenever the playhead sits in that tween's range but outside the position + * tween's). Omitted → any keyframed tween qualifies (back-compat). */ export function readRuntimeKeyframes( iframe: HTMLIFrameElement | null, selector: string, compositionId?: string, + requireChannels?: string[], ): ReadTween | null { const timelines = timelinesOf(iframe); if (!timelines) return null; @@ -299,6 +311,7 @@ export function readRuntimeKeyframes( if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet) const read = readTween(tween.vars); if (!read) continue; + if (requireChannels && !readCarriesChannel(read, requireChannels)) continue; if (firstRead === null) firstRead = read; // Prefer the tween whose [start, start+dur] contains the playhead. if (now != null) { diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index dd7d053c04..736d973303 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -410,6 +410,25 @@ export function useDomEditSession({ [gsapCommitMutation, domEditSelectionRef], ); + // Apply one ease to every segment at once (AE select-all + F9): set easeEach + // and strip per-keyframe overrides in a single mutation. + const handleSetAllKeyframeEases = useCallback( + (animationId: string, ease: string) => { + const sel = domEditSelectionRef.current; + if (!sel) return; + gsapCommitMutation( + sel, + { + type: "update-meta", + animationId, + updates: { easeEach: ease, resetKeyframeEases: true }, + }, + { label: "Apply ease to all segments", softReload: true }, + ); + }, + [gsapCommitMutation, domEditSelectionRef], + ); + return { // State domEditSelection, @@ -477,6 +496,7 @@ export function useDomEditSession({ handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, handleUpdateKeyframeEase, + handleSetAllKeyframeEases, commitAnimatedProperty, handleSetArcPath, handleUpdateArcSegment, diff --git a/packages/studio/src/hooks/useDomEditWiring.ts b/packages/studio/src/hooks/useDomEditWiring.ts index 84484d43f9..642a2d72f1 100644 --- a/packages/studio/src/hooks/useDomEditWiring.ts +++ b/packages/studio/src/hooks/useDomEditWiring.ts @@ -197,6 +197,9 @@ export function useDomEditWiring({ ? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null } : null, gsapCacheVersion, + // Pass the preview iframe so class/selector tweens (e.g. `.dot`) resolve to + // the live element and surface in the inspector — not just by #id match. + previewIframeRef, ); // ── Telemetry & fallback ── diff --git a/packages/studio/src/hooks/useGestureCommit.ts b/packages/studio/src/hooks/useGestureCommit.ts index b70745c2c1..a63963be63 100644 --- a/packages/studio/src/hooks/useGestureCommit.ts +++ b/packages/studio/src/hooks/useGestureCommit.ts @@ -3,7 +3,6 @@ * Extracted from App.tsx to keep file sizes under the 600-line limit. */ import { useState, useCallback, useRef, useEffect } from "react"; -import { editLog } from "../utils/editDebugLog"; import { useGestureRecording } from "./useGestureRecording"; import { simplifyGestureSamples } from "../utils/rdpSimplify"; import { fitEasesFromVelocity } from "../utils/velocityEaseFitter"; @@ -294,9 +293,6 @@ export function useGestureCommit({ // fallow-ignore-next-line complexity const handleToggleRecording = useCallback(() => { - editLog("gesture", gestureStateRef.current === "recording" ? "stop" : "start", { - id: domEditSessionRef.current.domEditSelection?.id, - }); if (gestureStateRef.current === "recording") { void stopAndCommitRecording(); return; diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index 7d9c8c3bb8..da3f500364 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -8,7 +8,6 @@ * from the rest of the editing orchestration. */ import { useCallback } from "react"; -import { editLog } from "../utils/editDebugLog"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { @@ -97,7 +96,6 @@ export function useGsapAwareEditing({ next: { x: number; y: number }, modifiers?: { altKey?: boolean }, ) => { - editLog("manual-drag:move", { id: selection.id, next, altKey: modifiers?.altKey }); if (gsapCommitMutation) { try { await tryGsapDragIntercept( @@ -126,7 +124,6 @@ export function useGsapAwareEditing({ const handleGsapAwareBoxSizeCommit = useCallback( async (selection: DomEditSelection, next: { width: number; height: number }) => { - editLog("manual-drag:resize", { id: selection.id, next }); if (gsapCommitMutation) { try { const handled = await tryGsapResizeIntercept( @@ -157,7 +154,6 @@ export function useGsapAwareEditing({ const handleGsapAwareRotationCommit = useCallback( async (selection: DomEditSelection, next: { angle: number }) => { - editLog("manual-drag:rotate", { id: selection.id, next }); if (gsapCommitMutation) { try { // Single source of truth for rotation too: tryGsapRotationIntercept handles diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index a165394471..a0fa4495fb 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -1,5 +1,4 @@ import { useCallback, useMemo, useRef } from "react"; -import { editLog } from "../utils/editDebugLog"; import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload"; @@ -130,12 +129,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record, options: CommitMutationOptions) => { const pid = projectIdRef.current; if (!pid) return; - editLog("gsap-commit", { - type: mutation.type, - id: selection.id, - file: selection.sourceFile || activeCompPath, - label: options.label, - }); const unsafeFields = findUnsafeMutationValues(mutation); if (unsafeFields.length > 0) { showToast?.("Couldn't read element layout — try again at a different playhead time", "error"); @@ -165,11 +158,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra options.beforeReload?.(); applyPreviewSync(previewIframeRef.current, result, options, reloadPreview); onCacheInvalidate(); - editLog("gsap-commit:done", { - type: mutation.type, - changed: result.changed, - instant: Boolean(options.instantPatch), - }); }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, forceReloadSdkSession]); // Every GSAP-script commit is a read-modify-write of one file. Overlapping // commits to the SAME file (any op type, any animation) interleave server-side, diff --git a/packages/studio/src/hooks/useGsapTweenCache.test.ts b/packages/studio/src/hooks/useGsapTweenCache.test.ts index db460c2c44..d9ff5d4a02 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.test.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect } from "vitest"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; -import { getAnimationsForElement } from "./useGsapTweenCache"; +import { getAnimationsForElement, resolveSelectorElementIds } from "./useGsapTweenCache"; + +// Minimal Document stub: querySelectorAll returns the elements mapped per selector. +function fakeDoc(map: Record): Document { + return { + querySelectorAll: (sel: string) => (map[sel] ?? []) as unknown as NodeListOf, + } as unknown as Document; +} function anim(targetSelector: string): GsapAnimation { return { @@ -46,4 +53,41 @@ describe("getAnimationsForElement", () => { expect(getAnimationsForElement(grouped, { selector: ".clock-hand" })).toHaveLength(1); expect(getAnimationsForElement(grouped, { selector: ".unrelated" })).toHaveLength(0); }); + + it("attributes a class tween to an id-selected element via element.matches", () => { + // gsap.from(".dot", {stagger}) — the element is selected by id (#dot-a), so + // its selector string never equals ".dot", but the live element matches it. + const dots = [anim(".dot")]; + const el = { matches: (s: string) => s === ".dot" || s === "#dot-a" } as unknown as Element; + expect(getAnimationsForElement(dots, { id: "dot-a", selector: "#dot-a" }, el)).toHaveLength(1); + // Without the live element the class tween is still missed (legacy behavior). + expect(getAnimationsForElement(dots, { id: "dot-a", selector: "#dot-a" })).toHaveLength(0); + }); + + it("element.matches gates attribution — no over-matching", () => { + const dots = [anim(".dot")]; + const el = { matches: () => false } as unknown as Element; + expect(getAnimationsForElement(dots, { id: "other", selector: "#other" }, el)).toHaveLength(0); + }); +}); + +describe("resolveSelectorElementIds", () => { + it("resolves a bare #id without touching the DOM", () => { + expect(resolveSelectorElementIds("#hero", null)).toEqual(["hero"]); + }); + + it("resolves a class selector to every matching element id (the .dot+stagger case)", () => { + const doc = fakeDoc({ ".dot": [{ id: "dot-a" }, { id: "dot-b" }] }); + expect(resolveSelectorElementIds(".dot", doc)).toEqual(["dot-a", "dot-b"]); + }); + + it("resolves a group selector across its parts (deduped)", () => { + const doc = fakeDoc({ ".a": [{ id: "x" }], ".b": [{ id: "y" }, { id: "x" }] }); + expect(resolveSelectorElementIds(".a, .b", doc).sort()).toEqual(["x", "y"]); + }); + + it("falls back to a leading #id when there is no DOM", () => { + expect(resolveSelectorElementIds("#card .label", null)).toEqual(["card"]); + expect(resolveSelectorElementIds(".dot", null)).toEqual([]); + }); }); diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 88ce9c92d8..bec9757aec 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -69,6 +69,41 @@ function extractIdFromSelector(selector: string): string | null { return match ? match[1] : null; } +/** + * Resolve a tween's target selector to the ids of the element(s) it animates. + * A bare `#id` resolves directly; anything else (a class like `.dot`, a group + * `.a, .b`, or a descendant selector) is matched against the live preview DOM so + * class/selector tweens (e.g. `gsap.from(".dot", {stagger})`) attribute to every + * element they animate — not just one parsed from the string. Falls back to a + * leading `#id` when there's no DOM (so the cache still populates pre-iframe). + */ +// fallow-ignore-next-line complexity +export function resolveSelectorElementIds( + selector: string, + doc: Document | null | undefined, +): string[] { + const bareId = selector.match(/^#([\w-]+)$/); + if (bareId) return [bareId[1]]; + if (!doc) { + const lead = extractIdFromSelector(selector); + return lead ? [lead] : []; + } + const ids = new Set(); + for (const part of selector.split(",")) { + const sel = part.trim(); + if (!sel) continue; + try { + for (const el of Array.from(doc.querySelectorAll(sel))) { + if (el.id) ids.add(el.id); + } + } catch { + const lead = extractIdFromSelector(sel); + if (lead) ids.add(lead); + } + } + return Array.from(ids); +} + /** The selected element's identity for matching tweens to it. */ export interface GsapElementTarget { id?: string | null; @@ -82,21 +117,37 @@ export interface GsapElementTarget { * (`.clock-face, .clock-hand`, emitted for array/`toArray` targets). Real * compositions target tweens by class via `querySelector`, so id-only matching * misses them. + * + * When the live DOM `element` is supplied, each comma-part of a tween's selector + * is also tested with `element.matches(part)` — true CSS semantics — so a + * class/descendant tween shared across elements (e.g. `gsap.from(".dot", {stagger})`) + * is attributed to *every* matching element, not just the one whose exact + * selector string happens to equal the tween's. */ export function getAnimationsForElement( animations: GsapAnimation[], target: GsapElementTarget, + element?: Element | null, ): GsapAnimation[] { const matchers = new Set(); if (target.id) matchers.add(`#${target.id}`); if (target.selector) matchers.add(target.selector); - if (matchers.size === 0) return []; + if (matchers.size === 0 && !element) return []; return animations.filter((a) => a.targetSelector.split(",").some((part) => { const trimmed = part.trim(); + if (!trimmed) return false; if (matchers.has(trimmed)) return true; const lastSimple = trimmed.split(/\s+/).pop(); - return lastSimple ? matchers.has(lastSimple) : false; + if (lastSimple && matchers.has(lastSimple)) return true; + if (element) { + try { + if (element.matches(trimmed)) return true; + } catch { + /* tween selector isn't a valid CSS selector for matches() — skip */ + } + } + return false; }), ); } @@ -199,13 +250,30 @@ export function useGsapAnimationsForElement( const targetId = target?.id ?? null; const targetSelector = target?.selector ?? null; - const rawAnimations = useMemo( - () => - targetId || targetSelector - ? getAnimationsForElement(allAnimations, { id: targetId, selector: targetSelector }) - : [], - [allAnimations, targetId, targetSelector], - ); + const rawAnimations = useMemo(() => { + if (!targetId && !targetSelector) return []; + // Resolve the live element so class / descendant tweens (e.g. + // gsap.from(".dot", {stagger})) attribute to every matching element, not + // just the one whose exact selector equals the tween's. `version` re-runs + // this after composition reloads. + let element: Element | null = null; + const doc = iframeRef?.current?.contentDocument; + if (doc) { + try { + element = + (targetId ? doc.getElementById(targetId) : null) ?? + (targetSelector ? doc.querySelector(targetSelector) : null); + } catch { + element = null; + } + } + return getAnimationsForElement( + allAnimations, + { id: targetId, selector: targetSelector }, + element, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allAnimations, targetId, targetSelector, version, iframeRef]); // fallow-ignore-next-line complexity const animations = useMemo(() => { @@ -327,7 +395,14 @@ export function useGsapAnimationsForElement( if (kf.easeEach) easeEach = kf.easeEach; } if (allKeyframes.length === 0) { - clearKeyframeCacheForElement(sourceFile, elementId); + // The per-element parsed-animation match can transiently miss class / + // selector tweens (e.g. `.dot`) that the file-wide populate or runtime + // scan already cached. Only clear when no source cached this element — + // otherwise selecting it would wipe its diamonds. + const { keyframeCache } = usePlayerStore.getState(); + const hasCached = + keyframeCache.has(`${sourceFile}#${elementId}`) || keyframeCache.has(elementId); + if (!hasCached) clearKeyframeCacheForElement(sourceFile, elementId); return; } const dedupedKeyframes = deduplicateKeyframes(allKeyframes); @@ -385,10 +460,9 @@ export function usePopulateKeyframeCacheForFile( const { setKeyframeCache } = usePlayerStore.getState(); clearKeyframeCacheForFile(sf); const { elements } = usePlayerStore.getState(); + const doc = iframeRef?.current?.contentDocument; const mergedByElement = new Map(); for (const anim of parsed.animations) { - const id = extractIdFromSelector(anim.targetSelector); - if (!id) continue; if (anim.hasUnresolvedKeyframes) continue; // Position-only set tweens are static holds (created by drag), not // keyframed animations — skip them so they don't show timeline diamonds. @@ -403,32 +477,36 @@ export function usePopulateKeyframeCacheForFile( const tweenPos = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0); const tweenDur = anim.duration ?? 1; - const timelineEl = elements.find( - (el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`, - ); - const elStart = timelineEl?.start ?? 0; - const elDuration = timelineEl?.duration ?? 1; - const clipKeyframes = kfData.keyframes.map((kf) => { - const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage); - // 0.001% precision (matching useGsapAnimationsForElement above) so a - // beat-snapped keyframe centers exactly on the beat dot and the two - // caches agree on a keyframe's percentage. - const clipPct = - elDuration > 0 - ? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000 - : kf.percentage; - return { - ...kf, - percentage: clipPct, - tweenPercentage: kf.percentage, - propertyGroup: anim.propertyGroup, - }; - }); - const existing = mergedByElement.get(id); - if (existing) { - existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]); - } else { - mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes }); + // Attribute the tween to every element it animates (handles class / + // group / descendant selectors, not just `#id`). + for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) { + const timelineEl = elements.find( + (el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`, + ); + const elStart = timelineEl?.start ?? 0; + const elDuration = timelineEl?.duration ?? 1; + const clipKeyframes = kfData.keyframes.map((kf) => { + const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage); + // 0.001% precision (matching useGsapAnimationsForElement above) so a + // beat-snapped keyframe centers exactly on the beat dot and the two + // caches agree on a keyframe's percentage. + const clipPct = + elDuration > 0 + ? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000 + : kf.percentage; + return { + ...kf, + percentage: clipPct, + tweenPercentage: kf.percentage, + propertyGroup: anim.propertyGroup, + }; + }); + const existing = mergedByElement.get(id); + if (existing) { + existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]); + } else { + mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes }); + } } } for (const [id, kfData] of mergedByElement) { @@ -441,6 +519,9 @@ export function usePopulateKeyframeCacheForFile( // elementCount is in the deps because new timeline elements (e.g. after a // sub-composition expand) need their keyframe cache populated immediately; // without it the effect won't re-run when elements appear/disappear. + // iframeRef is read for DOM selector resolution but intentionally not a dep + // (it's a stable ref; the separate runtime-scan effect owns iframe timing). + // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId, sourceFile, version, elementCount]); // Separate effect for runtime keyframe discovery — polls until the iframe diff --git a/packages/studio/src/hooks/useRazorSplit.ts b/packages/studio/src/hooks/useRazorSplit.ts index 339d242761..7fd8a72a69 100644 --- a/packages/studio/src/hooks/useRazorSplit.ts +++ b/packages/studio/src/hooks/useRazorSplit.ts @@ -1,5 +1,4 @@ import { useCallback, useRef } from "react"; -import { editLog } from "../utils/editDebugLog"; import type { TimelineElement } from "../player"; import { usePlayerStore } from "../player"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; @@ -120,7 +119,6 @@ async function executeSplit( if (!patchTarget) throw new Error("Clip is missing a patchable target."); const targetPath = element.sourceFile || activeCompPath || "index.html"; - editLog("razor-split", { id: element.domId ?? element.id, splitTime, file: targetPath }); const originalContent = await readFileContent(pid, targetPath); const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip"); @@ -134,7 +132,6 @@ async function executeSplit( element.duration, ); if (!splitResult.ok) throw new Error("Failed to split clip."); - editLog("razor-split:done", { changed: splitResult.changed, newId }); if (!splitResult.changed) { return { targetPath, originalContent, patchedContent: originalContent, changed: false }; } diff --git a/packages/studio/src/utils/editDebugLog.ts b/packages/studio/src/utils/editDebugLog.ts deleted file mode 100644 index 8ad48bc83b..0000000000 --- a/packages/studio/src/utils/editDebugLog.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Gated strategic logging for the GSAP keyframe / manual-drag / gesture / razor -// edit flows. Silent in production; on in dev builds, or anywhere once you set -// `window.__hfDebug = true` in the console. Single `[hf-edit:]` prefix so -// the whole edit pipeline is greppable. Fires only at commit boundaries (user -// actions), never in render/raf loops, so it doesn't spam. -export function editLog(_scope: string, ..._args: unknown[]): void { - // ponytail: body removed — all console.* stripped from studio. - // Restore with: console.log(`[hf-edit:${_scope}]`, ..._args); -}