Skip to content

Commit 78da2c7

Browse files
committed
feat(studio): design panel — arc controls + ease curve + stagger
Add arc path controls (curviness slider, auto-rotate), motion path SVG overlay, ease curve visualization, stagger controls, and expanded animation card. Includes border-radius editor dependency from #1217.
1 parent 93db037 commit 78da2c7

14 files changed

Lines changed: 904 additions & 121 deletions

packages/studio/src/components/editor/AnimationCard.tsx

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import {
1717
} from "./gsapAnimationConstants";
1818
import { buildTweenSummary } from "./gsapAnimationHelpers";
1919
import { EaseCurveSection } from "./EaseCurveSection";
20+
import { ArcPathControls } from "./ArcPathControls";
21+
import type { ArcPathSegment } from "@hyperframes/core/gsap-parser";
22+
import { P } from "./panelTokens";
2023
const BOOLEAN_PROPS = new Set(["visibility"]);
2124
const STRING_PROPS = new Set(["filter", "clipPath"]);
2225

@@ -97,11 +100,18 @@ function PropertyRow({
97100
<button
98101
type="button"
99102
onClick={() => onCommit(isVisible ? "hidden" : "visible")}
100-
className={`flex-shrink-0 w-7 h-4 rounded-full transition-colors relative ${isVisible ? "bg-emerald-500/30" : "bg-neutral-700"}`}
103+
className={`flex-shrink-0 rounded-full transition-all duration-150 relative`}
104+
style={{ width: 28, height: 16, background: isVisible ? P.accent : P.borderInput }}
101105
title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
102106
>
103107
<span
104-
className={`absolute top-0.5 h-3 w-3 rounded-full transition-transform ${isVisible ? "bg-emerald-400 translate-x-3.5" : "bg-neutral-500 translate-x-0.5"}`}
108+
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
109+
style={{
110+
width: 12,
111+
height: 12,
112+
background: isVisible ? P.white : P.textMuted,
113+
transform: isVisible ? "translateX(14px)" : "translateX(2px)",
114+
}}
105115
/>
106116
</button>
107117
</div>
@@ -241,6 +251,15 @@ interface AnimationCardProps {
241251
onRemoveFromProperty?: (animationId: string, property: string) => void;
242252
onLivePreview?: (property: string, value: number | string) => void;
243253
onLivePreviewEnd?: () => void;
254+
onSetArcPath?: (
255+
animationId: string,
256+
config: { enabled: boolean; autoRotate?: boolean | number; segments?: ArcPathSegment[] },
257+
) => void;
258+
onUpdateArcSegment?: (
259+
animationId: string,
260+
segmentIndex: number,
261+
update: Partial<ArcPathSegment>,
262+
) => void;
244263
}
245264

246265
// fallow-ignore-next-line complexity
@@ -257,6 +276,8 @@ export const AnimationCard = memo(function AnimationCard({
257276
onRemoveFromProperty,
258277
onLivePreview,
259278
onLivePreviewEnd,
279+
onSetArcPath,
280+
onUpdateArcSegment,
260281
}: AnimationCardProps) {
261282
const [expanded, setExpanded] = useState(defaultExpanded);
262283
const [addingProp, setAddingProp] = useState(false);
@@ -329,7 +350,7 @@ export const AnimationCard = memo(function AnimationCard({
329350
const [copied, setCopied] = useState(false);
330351

331352
const methodLabel = METHOD_LABELS[animation.method] ?? animation.method;
332-
const easeName = animation.ease ?? "none";
353+
const easeName = animation.ease ?? animation.keyframes?.easeEach ?? "none";
333354
const easeLabel = easeName.startsWith("custom(")
334355
? "Custom curve"
335356
: (EASE_LABELS[easeName] ?? easeName);
@@ -348,7 +369,7 @@ export const AnimationCard = memo(function AnimationCard({
348369
className="flex w-full items-center gap-2 py-1.5"
349370
>
350371
<span
351-
className="rounded bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-400"
372+
className="rounded bg-panel-accent/10 px-1.5 py-0.5 text-[10px] font-semibold text-panel-accent"
352373
title={METHOD_TOOLTIPS[animation.method]}
353374
>
354375
{methodLabel}
@@ -420,13 +441,13 @@ export const AnimationCard = memo(function AnimationCard({
420441
<>
421442
<SelectField
422443
label="Speed"
423-
value={
424-
animation.ease?.startsWith("custom(") ? "custom" : (animation.ease ?? "none")
425-
}
444+
value={easeName.startsWith("custom(") ? "custom" : easeName}
426445
options={[...SUPPORTED_EASES, "custom"]}
427446
onChange={(next) => {
428447
if (next === "custom") {
429-
const points = controlPointsForGsapEase(animation.ease ?? "power2.out");
448+
const points = controlPointsForGsapEase(
449+
easeName !== "none" ? easeName : "power2.out",
450+
);
430451
const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`;
431452
onUpdateMeta(animation.id, { ease: `custom(${path})` });
432453
} else {
@@ -435,7 +456,7 @@ export const AnimationCard = memo(function AnimationCard({
435456
}}
436457
/>
437458
<EaseCurveSection
438-
ease={animation.ease ?? "none"}
459+
ease={easeName}
439460
duration={animation.duration}
440461
onCustomEaseCommit={(customEase) =>
441462
onUpdateMeta(animation.id, { ease: customEase })
@@ -477,7 +498,7 @@ export const AnimationCard = memo(function AnimationCard({
477498
)}
478499

479500
{animation.method === "fromTo" && Object.keys(animation.properties).length > 0 && (
480-
<p className="text-[9px] font-semibold uppercase tracking-wider text-emerald-400/70">
501+
<p className="text-[9px] font-semibold uppercase tracking-wider text-panel-accent/70">
481502
To
482503
</p>
483504
)}
@@ -500,6 +521,39 @@ export const AnimationCard = memo(function AnimationCard({
500521
</div>
501522
)}
502523

524+
{onSetArcPath &&
525+
(animation.properties.x != null ||
526+
animation.properties.y != null ||
527+
animation.keyframes) && (
528+
<div className="border-t border-neutral-800 pt-3">
529+
<ArcPathControls
530+
arcPath={
531+
animation.arcPath ?? { enabled: false, autoRotate: false, segments: [] }
532+
}
533+
segmentCount={Math.max(
534+
animation.properties.x != null || animation.properties.y != null ? 1 : 0,
535+
(animation.keyframes?.keyframes?.length ?? 0) - 1,
536+
)}
537+
onToggle={(enabled) =>
538+
onSetArcPath(animation.id, {
539+
enabled,
540+
segments: animation.arcPath?.segments,
541+
})
542+
}
543+
onUpdateSegment={(index, update) =>
544+
onUpdateArcSegment?.(animation.id, index, update)
545+
}
546+
onToggleAutoRotate={(autoRotate) =>
547+
onSetArcPath(animation.id, {
548+
enabled: true,
549+
autoRotate,
550+
segments: animation.arcPath?.segments,
551+
})
552+
}
553+
/>
554+
</div>
555+
)}
556+
503557
<div className="flex items-center gap-2 pt-1">
504558
<AddPropertyTrigger
505559
adding={addingProp}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { memo, useCallback } from "react";
2+
import type { ArcPathConfig, ArcPathSegment } from "@hyperframes/core/gsap-parser";
3+
import { SliderControl } from "./propertyPanelPrimitives";
4+
import { LABEL } from "./propertyPanelHelpers";
5+
import { P } from "./panelTokens";
6+
7+
interface ArcPathControlsProps {
8+
arcPath: ArcPathConfig;
9+
segmentCount: number;
10+
onToggle: (enabled: boolean) => void;
11+
onUpdateSegment: (index: number, update: Partial<ArcPathSegment>) => void;
12+
onToggleAutoRotate: (autoRotate: boolean) => void;
13+
disabled?: boolean;
14+
}
15+
16+
export const ArcPathControls = memo(function ArcPathControls({
17+
arcPath,
18+
segmentCount,
19+
onToggle,
20+
onUpdateSegment,
21+
onToggleAutoRotate,
22+
disabled,
23+
}: ArcPathControlsProps) {
24+
const handleToggle = useCallback(() => {
25+
onToggle(!arcPath.enabled);
26+
}, [arcPath.enabled, onToggle]);
27+
28+
const handleAutoRotate = useCallback(() => {
29+
onToggleAutoRotate(!arcPath.autoRotate);
30+
}, [arcPath.autoRotate, onToggleAutoRotate]);
31+
32+
if (segmentCount < 1) {
33+
return (
34+
<div className="rounded-md border border-neutral-800 bg-neutral-900/50 px-3 py-2">
35+
<p className="text-[11px] text-neutral-500">
36+
Add at least 2 position keyframes to enable arc motion.
37+
</p>
38+
</div>
39+
);
40+
}
41+
42+
return (
43+
<div className="space-y-3">
44+
<div className="flex items-center justify-between">
45+
<span className={LABEL}>Arc Motion</span>
46+
<button
47+
type="button"
48+
onClick={handleToggle}
49+
disabled={disabled}
50+
className="relative rounded-full transition-all duration-150"
51+
style={{ width: 28, height: 16, background: arcPath.enabled ? P.accent : P.borderInput }}
52+
title={arcPath.enabled ? "Disable arc motion" : "Enable arc motion"}
53+
>
54+
<span
55+
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
56+
style={{
57+
width: 12,
58+
height: 12,
59+
background: arcPath.enabled ? P.white : P.textMuted,
60+
transform: arcPath.enabled ? "translateX(14px)" : "translateX(2px)",
61+
}}
62+
/>
63+
</button>
64+
</div>
65+
66+
{arcPath.enabled && (
67+
<>
68+
<div className="flex items-center justify-between">
69+
<span className={LABEL}>Auto-Rotate</span>
70+
<button
71+
type="button"
72+
onClick={handleAutoRotate}
73+
disabled={disabled}
74+
className="relative rounded-full transition-all duration-150"
75+
style={{
76+
width: 28,
77+
height: 16,
78+
background: arcPath.autoRotate ? P.accent : "#27272A",
79+
}}
80+
title={
81+
arcPath.autoRotate
82+
? "Disable auto-rotate along path"
83+
: "Rotate element to follow path tangent"
84+
}
85+
>
86+
<span
87+
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
88+
style={{
89+
width: 12,
90+
height: 12,
91+
background: arcPath.autoRotate ? P.white : P.textMuted,
92+
transform: arcPath.autoRotate ? "translateX(14px)" : "translateX(2px)",
93+
}}
94+
/>
95+
</button>
96+
</div>
97+
98+
{arcPath.segments.map((seg, i) => (
99+
<div key={i} className="grid min-w-0 gap-1.5">
100+
<div className="flex items-center justify-between">
101+
<span className={LABEL}>
102+
{segmentCount === 1 ? "Curviness" : `Segment ${i + 1}`}
103+
</span>
104+
{seg.cp1 && seg.cp2 && (
105+
<button
106+
type="button"
107+
onClick={() => onUpdateSegment(i, { cp1: undefined, cp2: undefined })}
108+
className="text-[9px] font-medium text-neutral-500 transition-colors hover:text-neutral-300"
109+
title="Reset to auto-generated control points"
110+
>
111+
Reset
112+
</button>
113+
)}
114+
</div>
115+
<SliderControl
116+
value={seg.curviness}
117+
min={0}
118+
max={3}
119+
step={0.1}
120+
disabled={disabled}
121+
displayValue={seg.curviness.toFixed(1)}
122+
formatDisplayValue={(v) => v.toFixed(1)}
123+
onCommit={(v) => onUpdateSegment(i, { curviness: v })}
124+
/>
125+
</div>
126+
))}
127+
</>
128+
)}
129+
</div>
130+
);
131+
});

0 commit comments

Comments
 (0)