feat(studio): motion editing — speed-curve editor, class-tween attribution, per-keyframe size & ease (#1705)

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.
This commit is contained in:
Miguel Ángel
2026-06-24 23:38:13 -04:00
committed by GitHub
parent 814c96cefa
commit 364992203e
31 changed files with 769 additions and 220 deletions
@@ -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
}
/>
) : (
<>
@@ -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<Pts | null>(null);
const [progress, setProgress] = useState<number | null>(null);
const [hover, setHover] = useState<"p1" | "p2" | null>(null);
const draggingRef = useRef<"p1" | "p2" | null>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
const rafRef = useRef<number>(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;
// 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 toSvg = (px: number, py: number) => ({
x: pad + gw * px,
y: h - pad - gh * py,
});
const curvePath = `M${pad},${h - pad} C${toSvg(x1, y1).x},${toSvg(x1, y1).y} ${toSvg(x2, y2).x},${toSvg(x2, y2).y} ${w - pad},${pad}`;
let dotX = pad;
let dotY = h - pad;
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 (
<div className="rounded-lg bg-neutral-900/50 p-2">
@@ -193,98 +228,139 @@ export function EaseCurveSection({
</button>
</div>
<div
className="overflow-hidden rounded pt-[72px] -mt-[72px]"
style={{ aspectRatio: `${w}/${h}` }}
className="mx-auto overflow-hidden rounded-md border border-white/5 bg-black/20"
style={{ aspectRatio: `${SVGW} / ${SVGH}`, width: "100%", maxWidth: 230 }}
>
<svg
ref={svgRef}
width="100%"
height="100%"
viewBox={`0 0 ${w} ${h}`}
viewBox={`0 0 ${SVGW} ${SVGH}`}
preserveAspectRatio="none"
style={{ overflow: "visible" }}
className="touch-none select-none"
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<line
x1={pad}
y1={h - pad}
x2={w - pad}
y2={h - pad}
{/* Grid — quarter lines inside the unit square */}
{[0.25, 0.5, 0.75].map((q) => (
<line
key={`v${q}`}
x1={xToSvg(q)}
y1={top}
x2={xToSvg(q)}
y2={bottom}
stroke="white"
strokeOpacity="0.05"
strokeWidth="1"
/>
))}
{[0.25, 0.5, 0.75].map((q) => (
<line
key={`h${q}`}
x1={left}
y1={yToSvg(q)}
x2={right}
y2={yToSvg(q)}
stroke="white"
strokeOpacity="0.05"
strokeWidth="1"
/>
))}
{/* Unit-square frame (progress 0 → 1) */}
<rect
x={left}
y={top}
width={S}
height={bottom - top}
fill="none"
stroke="white"
strokeOpacity="0.06"
strokeWidth="0.5"
strokeOpacity="0.1"
strokeWidth="1"
/>
{/* Linear reference diagonal */}
<line
x1={pad}
y1={pad}
x2={pad}
y2={h - pad}
x1={a0.x}
y1={a0.y}
x2={a1.x}
y2={a1.y}
stroke="white"
strokeOpacity="0.06"
strokeWidth="0.5"
strokeOpacity="0.08"
strokeWidth="1"
strokeDasharray="3 4"
/>
{/* Tangent handle lines */}
<line
x1={start.x}
y1={start.y}
x1={a0.x}
y1={a0.y}
x2={p1.x}
y2={p1.y}
stroke="rgba(52,211,153,0.25)"
strokeWidth="1"
stroke={ACCENT}
strokeOpacity="0.5"
strokeWidth="1.5"
/>
<line
x1={end.x}
y1={end.y}
x1={a1.x}
y1={a1.y}
x2={p2.x}
y2={p2.y}
stroke="rgba(45,212,191,0.25)"
strokeWidth="1"
stroke={ACCENT}
strokeOpacity="0.5"
strokeWidth="1.5"
/>
<path d={curvePath} fill="none" stroke="#3CE6AC" strokeWidth="2" strokeLinecap="round" />
{progress !== null && <circle cx={dotX} cy={dotY} r="4" fill="#3CE6AC" />}
<circle
cx={p1.x}
cy={p1.y}
r="5"
fill="#0a0a1a"
stroke="#3CE6AC"
strokeWidth="2"
className="cursor-grab active:cursor-grabbing"
onPointerDown={(e) => handlePointerDown("p1", e)}
/>
<circle
cx={p2.x}
cy={p2.y}
r="5"
fill="#0a0a1a"
stroke="#3CE6AC"
strokeWidth="2"
className="cursor-grab active:cursor-grabbing"
onPointerDown={(e) => handlePointerDown("p2", e)}
/>
{duration != null && duration > 0 && (
{/* The curve */}
<path d={curvePath} fill="none" stroke={ACCENT} strokeWidth="2.5" strokeLinecap="round" />
{/* Anchors at (0,0) and (1,1) */}
<circle cx={a0.x} cy={a0.y} r="3" fill={ACCENT} />
<circle cx={a1.x} cy={a1.y} r="3" fill={ACCENT} />
{/* Animated preview dot */}
{dot && (
<>
<text x={pad} y={h - 1} textAnchor="start" className="fill-neutral-600 text-[8px]">
0s
</text>
<text
x={pad + gw / 2}
y={h - 1}
textAnchor="middle"
className="fill-neutral-600 text-[8px]"
>
{(duration / 2).toFixed(1)}s
</text>
<text x={w - pad} y={h - 1} textAnchor="end" className="fill-neutral-600 text-[8px]">
{duration}s
</text>
<circle cx={dot.x} cy={dot.y} r="9" fill={ACCENT} fillOpacity="0.18" />
<circle cx={dot.x} cy={dot.y} r="4.5" fill={ACCENT} />
</>
)}
{/* Draggable control handles (large transparent hit area + visible dot) */}
{[["p1", p1] as const, ["p2", p2] as const].map(([key, pt]) => (
<g key={key}>
<circle
cx={pt.x}
cy={pt.y}
r="14"
fill="transparent"
className="cursor-grab active:cursor-grabbing"
onPointerDown={(e) => handlePointerDown(key, e)}
onPointerEnter={() => setHover(key)}
onPointerLeave={() => setHover((h) => (h === key ? null : h))}
/>
<circle
cx={pt.x}
cy={pt.y}
r={hover === key || draggingRef.current === key ? 7 : 5.5}
fill="#0a0a1a"
stroke={ACCENT}
strokeWidth="2.5"
className="pointer-events-none transition-[r]"
/>
</g>
))}
</svg>
</div>
<p className="mt-1 text-center text-[10px] text-neutral-500">{label}</p>
{/* Axis + value readout */}
<div className="mt-1.5 flex items-center justify-between px-0.5 text-[9px] text-neutral-600">
<span>{duration != null && duration > 0 ? "0s" : "start"}</span>
<span className="tracking-wide text-neutral-500">time </span>
<span>{duration != null && duration > 0 ? `${duration}s` : "end"}</span>
</div>
<div className="mt-1 flex items-center justify-between px-0.5">
<span className="text-[10px] text-neutral-400">{label}</span>
<span
className="font-mono text-[9px] tracking-tight text-neutral-600"
title="cubic-bezier control points"
>
{bezierText}
</span>
</div>
</div>
);
}
@@ -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}
/>
))}
@@ -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 (
<div className="space-y-1">
<p className="text-[9px] font-semibold uppercase tracking-wider text-neutral-500">
Per-keyframe easing
</p>
<div className="flex items-center gap-2">
<p className="text-[9px] font-semibold uppercase tracking-wider text-neutral-500">
Per-keyframe easing
</p>
{onApplyAll && (
<select
aria-label="Apply one ease to all segments"
title="Apply one ease to every segment (clears per-segment overrides)"
value=""
onChange={(e) => {
const next = e.target.value;
if (next) onApplyAll(next);
}}
className="ml-auto cursor-pointer rounded bg-neutral-800 px-1.5 py-0.5 text-[9px] text-neutral-300 outline-none hover:bg-neutral-700 focus:ring-1 focus:ring-panel-accent/40"
>
<option value="" disabled>
Set all
</option>
{APPLY_ALL_EASES.map((name) => (
<option key={name} value={name}>
{EASE_LABELS[name] ?? name}
</option>
))}
</select>
)}
</div>
{keyframes.map((kf, i) => {
if (i === 0) return null;
const segEase = kf.ease ?? globalEase;
@@ -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 && (
<TimingSection element={element} onSetAttribute={onSetAttribute} />
{(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).
<TimingSection
element={element}
animations={gsapAnimations}
onSetAttribute={onSetAttribute}
/>
)}
{isMediaElement(element) && (
<MediaSection
@@ -556,6 +564,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onUpdateArcSegment={onUpdateArcSegment}
onUnroll={onUnroll}
onUpdateKeyframeEase={onUpdateKeyframeEase}
onSetAllKeyframeEases={onSetAllKeyframeEases}
/>
)}
@@ -29,6 +29,8 @@ export interface GsapAnimationEditCallbacks {
update: Partial<ArcPathSegment>,
) => 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;
}
@@ -88,41 +88,12 @@ export const PROP_TOOLTIPS: Record<string, string> = {
innerText: "End value for a number roll-up (the number it counts up/down to)",
};
export const EASE_LABELS: Record<string, string> = {
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<string, string> = {};
export const EASE_CURVES: Record<string, [number, number, number, number]> = {
none: [0, 0, 1, 1],
@@ -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", () => {
@@ -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<string, string> = {};
// 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[],
@@ -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<void>;
}) {
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}
/>
</div>
{derived && (
<p className="mt-2 text-[10px] leading-snug text-neutral-500">
Inferred from this elements animation edit to pin an explicit clip range.
</p>
)}
</Section>
);
}
@@ -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,