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); -}