@hyperframes/studio 0.6.85 → 0.6.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/assets/{hyperframes-player-DRpY3xHh.js → hyperframes-player-0esDKGRk.js} +1 -1
  2. package/dist/assets/index-BA19FAPN.js +143 -0
  3. package/dist/assets/index-CGlIm_-E.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +159 -6
  7. package/src/components/StudioHeader.tsx +20 -7
  8. package/src/components/StudioPreviewArea.tsx +6 -1
  9. package/src/components/StudioRightPanel.tsx +13 -0
  10. package/src/components/StudioToast.tsx +47 -7
  11. package/src/components/TimelineToolbar.tsx +12 -122
  12. package/src/components/editor/AnimationCard.tsx +64 -10
  13. package/src/components/editor/ArcPathControls.tsx +131 -0
  14. package/src/components/editor/BorderRadiusEditor.tsx +209 -0
  15. package/src/components/editor/DomEditOverlay.tsx +70 -11
  16. package/src/components/editor/DopesheetStrip.tsx +141 -0
  17. package/src/components/editor/EaseCurveSection.tsx +82 -7
  18. package/src/components/editor/GestureTrailOverlay.tsx +132 -0
  19. package/src/components/editor/GsapAnimationSection.tsx +14 -1
  20. package/src/components/editor/KeyframeDiamond.tsx +27 -12
  21. package/src/components/editor/LayersPanel.tsx +14 -12
  22. package/src/components/editor/MotionPathOverlay.tsx +146 -0
  23. package/src/components/editor/PropertyPanel.tsx +196 -66
  24. package/src/components/editor/SourceEditor.tsx +0 -1
  25. package/src/components/editor/StaggerControls.tsx +61 -0
  26. package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
  27. package/src/components/editor/domEditOverlayGeometry.ts +2 -1
  28. package/src/components/editor/domEditing.test.ts +43 -0
  29. package/src/components/editor/domEditing.ts +2 -0
  30. package/src/components/editor/domEditingElement.ts +25 -2
  31. package/src/components/editor/domEditingLayers.test.ts +78 -0
  32. package/src/components/editor/domEditingLayers.ts +33 -13
  33. package/src/components/editor/domEditingTypes.ts +1 -0
  34. package/src/components/editor/manualEditingAvailability.ts +1 -1
  35. package/src/components/editor/manualEdits.ts +3 -0
  36. package/src/components/editor/manualEditsDom.ts +23 -5
  37. package/src/components/editor/manualOffsetDrag.ts +59 -0
  38. package/src/components/editor/panelTokens.ts +10 -0
  39. package/src/components/editor/propertyPanelColor.tsx +2 -2
  40. package/src/components/editor/propertyPanelFill.tsx +1 -1
  41. package/src/components/editor/propertyPanelHelpers.ts +18 -2
  42. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  43. package/src/components/editor/propertyPanelPrimitives.tsx +38 -25
  44. package/src/components/editor/propertyPanelSections.tsx +4 -6
  45. package/src/components/editor/propertyPanelStyleSections.tsx +30 -8
  46. package/src/components/editor/useDomEditOverlayRects.ts +46 -2
  47. package/src/components/renders/RenderQueue.tsx +121 -100
  48. package/src/components/renders/RenderQueueItem.tsx +13 -13
  49. package/src/contexts/DomEditContext.tsx +12 -0
  50. package/src/contexts/FileManagerContext.tsx +3 -0
  51. package/src/contexts/StudioContext.tsx +0 -4
  52. package/src/hooks/gsapKeyframeCommit.ts +92 -0
  53. package/src/hooks/gsapRuntimeBridge.ts +147 -85
  54. package/src/hooks/gsapRuntimeKeyframes.ts +75 -24
  55. package/src/hooks/gsapRuntimePreview.ts +19 -0
  56. package/src/hooks/useAppHotkeys.ts +18 -0
  57. package/src/hooks/useAskAgentModal.ts +2 -4
  58. package/src/hooks/useDomEditCommits.ts +11 -17
  59. package/src/hooks/useDomEditSession.ts +47 -4
  60. package/src/hooks/useEnableKeyframes.ts +171 -0
  61. package/src/hooks/useFileManager.ts +7 -0
  62. package/src/hooks/useGestureRecording.ts +340 -0
  63. package/src/hooks/useGsapScriptCommits.ts +171 -35
  64. package/src/hooks/useGsapSelectionHandlers.ts +27 -8
  65. package/src/hooks/useGsapTweenCache.ts +169 -11
  66. package/src/hooks/useKeyframeKeyboard.ts +103 -0
  67. package/src/hooks/useStudioContextValue.ts +5 -4
  68. package/src/hooks/useStudioUrlState.ts +1 -2
  69. package/src/hooks/useTimelineEditing.ts +50 -3
  70. package/src/hooks/useToast.ts +6 -1
  71. package/src/player/components/ShortcutsPanel.tsx +40 -0
  72. package/src/player/components/TimelineClipDiamonds.tsx +3 -3
  73. package/src/player/components/TimelinePropertyRows.tsx +120 -0
  74. package/src/player/lib/timelineDOM.test.ts +55 -0
  75. package/src/player/lib/timelineDOM.ts +13 -0
  76. package/src/player/lib/timelineIframeHelpers.test.ts +51 -0
  77. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  78. package/src/player/store/playerStore.ts +43 -0
  79. package/src/utils/audioBeatDetection.ts +58 -0
  80. package/src/utils/globalTimeCompiler.test.ts +169 -0
  81. package/src/utils/globalTimeCompiler.ts +77 -0
  82. package/src/utils/gsapSoftReload.ts +30 -10
  83. package/src/utils/keyframeSnapping.test.ts +74 -0
  84. package/src/utils/keyframeSnapping.ts +63 -0
  85. package/src/utils/rdpSimplify.ts +183 -0
  86. package/src/utils/sourcePatcher.ts +2 -0
  87. package/dist/assets/index-DHcptK1_.css +0 -1
  88. package/dist/assets/index-DtSCUvYQ.js +0 -140
@@ -1,3 +1,5 @@
1
+ import { useRef } from "react";
2
+ import { useEnableKeyframes, type EnableKeyframesSession } from "../hooks/useEnableKeyframes";
1
3
  import {
2
4
  getNextTimelineZoomPercent,
3
5
  getTimelineZoomPercent,
@@ -7,88 +9,12 @@ import { usePlayerStore, type TimelineElement } from "../player";
7
9
  import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
8
10
  import { Tooltip } from "./ui";
9
11
  import { Scissors } from "../icons/SystemIcons";
10
- import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
12
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
11
13
  import type { DomEditSelection } from "./editor/domEditingTypes";
12
14
 
13
- function interpolateKeyframeProperties(
14
- keyframes: GsapPercentageKeyframe[],
15
- pct: number,
16
- ): Record<string, number> {
17
- const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
18
- const allProps = new Set<string>();
19
- for (const kf of sorted) {
20
- for (const p of Object.keys(kf.properties)) {
21
- if (typeof kf.properties[p] === "number") allProps.add(p);
22
- }
23
- }
24
- const result: Record<string, number> = {};
25
- for (const prop of allProps) {
26
- let prev: { pct: number; val: number } | null = null;
27
- let next: { pct: number; val: number } | null = null;
28
- for (const kf of sorted) {
29
- const v = kf.properties[prop];
30
- if (typeof v !== "number") continue;
31
- if (kf.percentage <= pct) prev = { pct: kf.percentage, val: v };
32
- if (kf.percentage >= pct && !next) next = { pct: kf.percentage, val: v };
33
- }
34
- if (prev && next && prev.pct !== next.pct) {
35
- const t = (pct - prev.pct) / (next.pct - prev.pct);
36
- result[prop] = Math.round(prev.val + t * (next.val - prev.val));
37
- } else if (prev) {
38
- result[prop] = Math.round(prev.val);
39
- } else if (next) {
40
- result[prop] = Math.round(next.val);
41
- }
42
- }
43
- return result;
44
- }
45
-
46
- function readRuntimeKeyframeValues(
47
- iframe: HTMLIFrameElement | null,
48
- sel: DomEditSelection,
49
- keyframes: GsapPercentageKeyframe[],
50
- ): Record<string, number> {
51
- if (!iframe?.contentWindow) return {};
52
- let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined;
53
- try {
54
- gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap;
55
- } catch {
56
- return {};
57
- }
58
- if (!gsap?.getProperty) return {};
59
- const selector = sel.id ? `#${sel.id}` : sel.selector;
60
- if (!selector) return {};
61
- let doc: Document | null = null;
62
- try {
63
- doc = iframe.contentDocument;
64
- } catch {
65
- return {};
66
- }
67
- const element = doc?.querySelector(selector);
68
- if (!element) return {};
69
- const allProps = new Set<string>();
70
- for (const kf of keyframes) {
71
- for (const p of Object.keys(kf.properties)) {
72
- if (typeof kf.properties[p] === "number") allProps.add(p);
73
- }
74
- }
75
- const result: Record<string, number> = {};
76
- for (const prop of allProps) {
77
- const val = Number(gsap.getProperty(element, prop));
78
- if (Number.isFinite(val)) result[prop] = Math.round(val);
79
- }
80
- return result;
81
- }
82
-
83
- interface DomEditSessionSlice {
15
+ interface DomEditSessionSlice extends EnableKeyframesSession {
84
16
  domEditSelection: DomEditSelection | null;
85
17
  selectedGsapAnimations: GsapAnimation[];
86
- handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
87
- handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void;
88
- handleGsapConvertToKeyframes: (animId: string) => void;
89
- handleGsapMaterializeKeyframes?: (animId: string) => Promise<void>;
90
- handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
91
- previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
92
18
  }
93
19
 
94
20
  interface TimelineToolbarProps {
@@ -97,15 +23,20 @@ interface TimelineToolbarProps {
97
23
  onSplitElement?: (element: TimelineElement, splitTime: number) => void;
98
24
  }
99
25
 
100
- // fallow-ignore-next-line complexity
101
26
  function useKeyframeToggle(session?: DomEditSessionSlice) {
102
27
  const currentTime = usePlayerStore((s) => s.currentTime);
28
+ const sessionRef = useRef(session);
29
+ sessionRef.current = session;
30
+
31
+ const onToggle = useEnableKeyframes(
32
+ sessionRef as React.RefObject<EnableKeyframesSession | undefined>,
33
+ );
34
+
103
35
  if (!session) return { state: "none" as const, onToggle: undefined };
104
36
 
105
37
  const sel = session.domEditSelection;
106
38
  const anims = session.selectedGsapAnimations;
107
39
  const kfAnim = anims.find((a) => a.keyframes);
108
- const flatAnim = anims.find((a) => !a.keyframes);
109
40
 
110
41
  let state: "active" | "inactive" | "none" = "none";
111
42
  if (kfAnim?.keyframes && sel) {
@@ -120,48 +51,7 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
120
51
  : "inactive";
121
52
  }
122
53
 
123
- // fallow-ignore-next-line complexity
124
- const onToggle = sel
125
- ? async () => {
126
- const t = usePlayerStore.getState().currentTime;
127
- if (kfAnim?.keyframes) {
128
- if (kfAnim.hasUnresolvedKeyframes) {
129
- await session.handleGsapMaterializeKeyframes?.(kfAnim.id);
130
- }
131
- const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
132
- const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
133
- const pct =
134
- elDuration > 0
135
- ? Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10))
136
- : 0;
137
- const existing = kfAnim.keyframes.keyframes.find(
138
- (k) => Math.abs(k.percentage - pct) <= 1,
139
- );
140
- if (existing) {
141
- session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
142
- } else {
143
- const runtimeValues = readRuntimeKeyframeValues(
144
- session.previewIframeRef?.current ?? null,
145
- sel,
146
- kfAnim.keyframes.keyframes,
147
- );
148
- const values =
149
- Object.keys(runtimeValues).length > 0
150
- ? runtimeValues
151
- : interpolateKeyframeProperties(kfAnim.keyframes.keyframes, pct);
152
- for (const [prop, val] of Object.entries(values)) {
153
- session.handleGsapAddKeyframe(kfAnim.id, pct, prop, val);
154
- }
155
- }
156
- } else if (flatAnim) {
157
- session.handleGsapConvertToKeyframes(flatAnim.id);
158
- } else {
159
- session.handleGsapAddAnimation("to");
160
- }
161
- }
162
- : undefined;
163
-
164
- return { state, onToggle };
54
+ return { state, onToggle: sel ? onToggle : undefined };
165
55
  }
166
56
 
167
57
  export function TimelineToolbar({
@@ -17,6 +17,9 @@ import {
17
17
  } from "./gsapAnimationConstants";
18
18
  import { buildTweenSummary } from "./gsapAnimationHelpers";
19
19
  import { EaseCurveSection } from "./EaseCurveSection";
20
+ import { ArcPathControls } from "./ArcPathControls";
21
+ import type { ArcPathSegment } from "@hyperframes/core/gsap-parser";
22
+ import { P } from "./panelTokens";
20
23
  const BOOLEAN_PROPS = new Set(["visibility"]);
21
24
  const STRING_PROPS = new Set(["filter", "clipPath"]);
22
25
 
@@ -97,11 +100,18 @@ function PropertyRow({
97
100
  <button
98
101
  type="button"
99
102
  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 }}
101
105
  title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
102
106
  >
103
107
  <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
+ }}
105
115
  />
106
116
  </button>
107
117
  </div>
@@ -241,6 +251,15 @@ interface AnimationCardProps {
241
251
  onRemoveFromProperty?: (animationId: string, property: string) => void;
242
252
  onLivePreview?: (property: string, value: number | string) => void;
243
253
  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;
244
263
  }
245
264
 
246
265
  // fallow-ignore-next-line complexity
@@ -257,6 +276,8 @@ export const AnimationCard = memo(function AnimationCard({
257
276
  onRemoveFromProperty,
258
277
  onLivePreview,
259
278
  onLivePreviewEnd,
279
+ onSetArcPath,
280
+ onUpdateArcSegment,
260
281
  }: AnimationCardProps) {
261
282
  const [expanded, setExpanded] = useState(defaultExpanded);
262
283
  const [addingProp, setAddingProp] = useState(false);
@@ -329,7 +350,7 @@ export const AnimationCard = memo(function AnimationCard({
329
350
  const [copied, setCopied] = useState(false);
330
351
 
331
352
  const methodLabel = METHOD_LABELS[animation.method] ?? animation.method;
332
- const easeName = animation.ease ?? "none";
353
+ const easeName = animation.ease ?? animation.keyframes?.easeEach ?? "none";
333
354
  const easeLabel = easeName.startsWith("custom(")
334
355
  ? "Custom curve"
335
356
  : (EASE_LABELS[easeName] ?? easeName);
@@ -348,7 +369,7 @@ export const AnimationCard = memo(function AnimationCard({
348
369
  className="flex w-full items-center gap-2 py-1.5"
349
370
  >
350
371
  <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"
352
373
  title={METHOD_TOOLTIPS[animation.method]}
353
374
  >
354
375
  {methodLabel}
@@ -420,13 +441,13 @@ export const AnimationCard = memo(function AnimationCard({
420
441
  <>
421
442
  <SelectField
422
443
  label="Speed"
423
- value={
424
- animation.ease?.startsWith("custom(") ? "custom" : (animation.ease ?? "none")
425
- }
444
+ value={easeName.startsWith("custom(") ? "custom" : easeName}
426
445
  options={[...SUPPORTED_EASES, "custom"]}
427
446
  onChange={(next) => {
428
447
  if (next === "custom") {
429
- const points = controlPointsForGsapEase(animation.ease ?? "power2.out");
448
+ const points = controlPointsForGsapEase(
449
+ easeName !== "none" ? easeName : "power2.out",
450
+ );
430
451
  const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`;
431
452
  onUpdateMeta(animation.id, { ease: `custom(${path})` });
432
453
  } else {
@@ -435,7 +456,7 @@ export const AnimationCard = memo(function AnimationCard({
435
456
  }}
436
457
  />
437
458
  <EaseCurveSection
438
- ease={animation.ease ?? "none"}
459
+ ease={easeName}
439
460
  duration={animation.duration}
440
461
  onCustomEaseCommit={(customEase) =>
441
462
  onUpdateMeta(animation.id, { ease: customEase })
@@ -477,7 +498,7 @@ export const AnimationCard = memo(function AnimationCard({
477
498
  )}
478
499
 
479
500
  {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">
481
502
  To
482
503
  </p>
483
504
  )}
@@ -500,6 +521,39 @@ export const AnimationCard = memo(function AnimationCard({
500
521
  </div>
501
522
  )}
502
523
 
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
+
503
557
  <div className="flex items-center gap-2 pt-1">
504
558
  <AddPropertyTrigger
505
559
  adding={addingProp}
@@ -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,0 +1,209 @@
1
+ import { useCallback, useState } from "react";
2
+ import { MetricField } from "./propertyPanelPrimitives";
3
+ import { formatNumericValue, parseNumericValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
4
+
5
+ type Corner = "tl" | "tr" | "br" | "bl";
6
+
7
+ interface BorderRadiusEditorProps {
8
+ tl: number;
9
+ tr: number;
10
+ br: number;
11
+ bl: number;
12
+ disabled?: boolean;
13
+ onCommit: (corner: Corner | "all", value: number) => void;
14
+ }
15
+
16
+ const PREVIEW_W = 72;
17
+ const PREVIEW_H = 52;
18
+ const MAX_RADIUS = 26;
19
+
20
+ function clampRadius(v: number): number {
21
+ return Math.max(0, Math.min(MAX_RADIUS, v));
22
+ }
23
+
24
+ function scaleRadius(v: number, maxPx: number): number {
25
+ if (maxPx <= 0) return 0;
26
+ return clampRadius(Math.round((v / Math.max(maxPx, 1)) * MAX_RADIUS));
27
+ }
28
+
29
+ export function BorderRadiusEditor({
30
+ tl,
31
+ tr,
32
+ br,
33
+ bl,
34
+ disabled,
35
+ onCommit,
36
+ }: BorderRadiusEditorProps) {
37
+ const uniform = tl === tr && tr === br && br === bl;
38
+ const [linked, setLinked] = useState(uniform);
39
+
40
+ const maxVal = Math.max(tl, tr, br, bl, 1);
41
+ const sTL = scaleRadius(tl, maxVal);
42
+ const sTR = scaleRadius(tr, maxVal);
43
+ const sBR = scaleRadius(br, maxVal);
44
+ const sBL = scaleRadius(bl, maxVal);
45
+
46
+ const handleCornerCommit = useCallback(
47
+ (corner: Corner, raw: string) => {
48
+ const v = parseNumericValue(raw) ?? 0;
49
+ if (linked) {
50
+ onCommit("all", v);
51
+ } else {
52
+ onCommit(corner, v);
53
+ }
54
+ },
55
+ [linked, onCommit],
56
+ );
57
+
58
+ const handleToggleLinked = useCallback(() => {
59
+ if (!linked && !uniform) {
60
+ onCommit("all", tl);
61
+ }
62
+ setLinked((l) => !l);
63
+ }, [linked, uniform, tl, onCommit]);
64
+
65
+ const path = buildRoundedRectPath(PREVIEW_W, PREVIEW_H, sTL, sTR, sBR, sBL);
66
+
67
+ return (
68
+ <div className="space-y-3">
69
+ <div className="flex items-center gap-3">
70
+ <svg
71
+ width={PREVIEW_W}
72
+ height={PREVIEW_H}
73
+ viewBox={`0 0 ${PREVIEW_W} ${PREVIEW_H}`}
74
+ className="flex-shrink-0"
75
+ >
76
+ <path
77
+ d={path}
78
+ fill="rgba(255,255,255,0.06)"
79
+ stroke="rgba(255,255,255,0.24)"
80
+ strokeWidth={1.5}
81
+ />
82
+ <circle
83
+ cx={sTL}
84
+ cy={sTL}
85
+ r={3}
86
+ fill={linked ? "#3b82f6" : "#a78bfa"}
87
+ className="cursor-pointer"
88
+ />
89
+ <circle
90
+ cx={PREVIEW_W - sTR}
91
+ cy={sTR}
92
+ r={3}
93
+ fill={linked ? "#3b82f6" : "#a78bfa"}
94
+ className="cursor-pointer"
95
+ />
96
+ <circle
97
+ cx={PREVIEW_W - sBR}
98
+ cy={PREVIEW_H - sBR}
99
+ r={3}
100
+ fill={linked ? "#3b82f6" : "#a78bfa"}
101
+ className="cursor-pointer"
102
+ />
103
+ <circle
104
+ cx={sBL}
105
+ cy={PREVIEW_H - sBL}
106
+ r={3}
107
+ fill={linked ? "#3b82f6" : "#a78bfa"}
108
+ className="cursor-pointer"
109
+ />
110
+ </svg>
111
+
112
+ <button
113
+ type="button"
114
+ className="flex h-7 w-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
115
+ onClick={handleToggleLinked}
116
+ disabled={disabled}
117
+ title={linked ? "Unlink corners" : "Link all corners"}
118
+ >
119
+ {linked ? (
120
+ <svg
121
+ width={14}
122
+ height={14}
123
+ viewBox="0 0 16 16"
124
+ fill="none"
125
+ stroke="currentColor"
126
+ strokeWidth={1.5}
127
+ >
128
+ <path d="M6 12H4a4 4 0 010-8h2M10 4h2a4 4 0 010 8h-2M5 8h6" />
129
+ </svg>
130
+ ) : (
131
+ <svg
132
+ width={14}
133
+ height={14}
134
+ viewBox="0 0 16 16"
135
+ fill="none"
136
+ stroke="currentColor"
137
+ strokeWidth={1.5}
138
+ >
139
+ <path d="M6 12H4a4 4 0 010-8h2M10 4h2a4 4 0 010 8h-2" />
140
+ </svg>
141
+ )}
142
+ </button>
143
+ </div>
144
+
145
+ {linked ? (
146
+ <MetricField
147
+ label="All"
148
+ value={formatNumericValue(tl)}
149
+ disabled={disabled}
150
+ liveCommit
151
+ onCommit={(next) => handleCornerCommit("tl", next)}
152
+ />
153
+ ) : (
154
+ <div className={RESPONSIVE_GRID}>
155
+ <MetricField
156
+ label="TL"
157
+ value={formatNumericValue(tl)}
158
+ disabled={disabled}
159
+ liveCommit
160
+ onCommit={(next) => handleCornerCommit("tl", next)}
161
+ />
162
+ <MetricField
163
+ label="TR"
164
+ value={formatNumericValue(tr)}
165
+ disabled={disabled}
166
+ liveCommit
167
+ onCommit={(next) => handleCornerCommit("tr", next)}
168
+ />
169
+ <MetricField
170
+ label="BL"
171
+ value={formatNumericValue(bl)}
172
+ disabled={disabled}
173
+ liveCommit
174
+ onCommit={(next) => handleCornerCommit("bl", next)}
175
+ />
176
+ <MetricField
177
+ label="BR"
178
+ value={formatNumericValue(br)}
179
+ disabled={disabled}
180
+ liveCommit
181
+ onCommit={(next) => handleCornerCommit("br", next)}
182
+ />
183
+ </div>
184
+ )}
185
+ </div>
186
+ );
187
+ }
188
+
189
+ function buildRoundedRectPath(
190
+ w: number,
191
+ h: number,
192
+ tl: number,
193
+ tr: number,
194
+ br: number,
195
+ bl: number,
196
+ ): string {
197
+ return [
198
+ `M ${tl} 0`,
199
+ `L ${w - tr} 0`,
200
+ `Q ${w} 0 ${w} ${tr}`,
201
+ `L ${w} ${h - br}`,
202
+ `Q ${w} ${h} ${w - br} ${h}`,
203
+ `L ${bl} ${h}`,
204
+ `Q 0 ${h} 0 ${h - bl}`,
205
+ `L 0 ${tl}`,
206
+ `Q 0 0 ${tl} 0`,
207
+ "Z",
208
+ ].join(" ");
209
+ }