@hyperframes/studio 0.6.72 → 0.6.74

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 (60) hide show
  1. package/dist/assets/index-BcJO6Ej5.js +140 -0
  2. package/dist/assets/index-C2gBZ2km.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +30 -24
  6. package/src/components/StudioPreviewArea.tsx +101 -26
  7. package/src/components/StudioRightPanel.tsx +3 -0
  8. package/src/components/StudioToast.tsx +18 -0
  9. package/src/components/TimelineToolbar.tsx +230 -4
  10. package/src/components/editor/AnimationCard.tsx +68 -4
  11. package/src/components/editor/DomEditOverlay.tsx +70 -1
  12. package/src/components/editor/GridOverlay.tsx +50 -0
  13. package/src/components/editor/KeyframeDiamond.tsx +49 -0
  14. package/src/components/editor/KeyframeNavigation.tsx +139 -0
  15. package/src/components/editor/PropertyPanel.tsx +293 -140
  16. package/src/components/editor/SnapGuideOverlay.tsx +166 -0
  17. package/src/components/editor/SnapToolbar.tsx +163 -0
  18. package/src/components/editor/SpringEaseEditor.tsx +256 -0
  19. package/src/components/editor/domEditOverlayGestures.ts +7 -0
  20. package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
  21. package/src/components/editor/gsapAnimationConstants.ts +42 -0
  22. package/src/components/editor/gsapAnimationHelpers.ts +2 -1
  23. package/src/components/editor/manualEditingAvailability.ts +6 -0
  24. package/src/components/editor/manualEditsDom.ts +56 -2
  25. package/src/components/editor/manualOffsetDrag.ts +19 -3
  26. package/src/components/editor/propertyPanelHelpers.ts +90 -0
  27. package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
  28. package/src/components/editor/snapEngine.test.ts +657 -0
  29. package/src/components/editor/snapEngine.ts +575 -0
  30. package/src/components/editor/snapTargetCollection.ts +147 -0
  31. package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
  32. package/src/components/nle/NLELayout.tsx +18 -0
  33. package/src/contexts/DomEditContext.tsx +24 -0
  34. package/src/hooks/gsapRuntimeBridge.ts +585 -0
  35. package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
  36. package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
  37. package/src/hooks/useAppHotkeys.ts +63 -1
  38. package/src/hooks/useDomEditCommits.ts +39 -4
  39. package/src/hooks/useDomEditSession.ts +177 -63
  40. package/src/hooks/useGsapScriptCommits.ts +144 -7
  41. package/src/hooks/useGsapSelectionHandlers.ts +202 -0
  42. package/src/hooks/useGsapTweenCache.ts +174 -3
  43. package/src/hooks/useTimelineEditing.ts +93 -0
  44. package/src/icons/SystemIcons.tsx +2 -0
  45. package/src/player/components/ClipContextMenu.tsx +99 -0
  46. package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
  47. package/src/player/components/Timeline.test.ts +2 -1
  48. package/src/player/components/Timeline.tsx +108 -68
  49. package/src/player/components/TimelineCanvas.tsx +47 -1
  50. package/src/player/components/TimelineClip.tsx +8 -3
  51. package/src/player/components/TimelineClipDiamonds.tsx +174 -0
  52. package/src/player/components/timelineDragDrop.ts +103 -0
  53. package/src/player/components/timelineLayout.ts +1 -1
  54. package/src/player/store/playerStore.ts +42 -0
  55. package/src/utils/editHistory.ts +1 -1
  56. package/src/utils/optimisticUpdate.test.ts +53 -0
  57. package/src/utils/optimisticUpdate.ts +18 -0
  58. package/src/utils/studioUiPreferences.ts +17 -0
  59. package/dist/assets/index-CrxThtSJ.css +0 -1
  60. package/dist/assets/index-CveQve6o.js +0 -140
@@ -0,0 +1,139 @@
1
+ import { memo } from "react";
2
+ import { KeyframeDiamond, type DiamondState } from "./KeyframeDiamond";
3
+
4
+ interface KeyframeNavigationProps {
5
+ property: string;
6
+ /** All keyframes for this element's tween, or null if no keyframes exist */
7
+ keyframes: Array<{
8
+ percentage: number;
9
+ properties: Record<string, number | string>;
10
+ ease?: string;
11
+ }> | null;
12
+ /** Current playhead percentage within the element's lifetime (0-100) */
13
+ currentPercentage: number;
14
+ onSeek: (percentage: number) => void;
15
+ onAddKeyframe: (percentage: number) => void;
16
+ onRemoveKeyframe: (percentage: number) => void;
17
+ onConvertToKeyframes: () => void;
18
+ }
19
+
20
+ const TOLERANCE = 0.5;
21
+
22
+ function ArrowLeft({ disabled }: { disabled: boolean }) {
23
+ return (
24
+ <svg
25
+ width="6"
26
+ height="10"
27
+ viewBox="0 0 6 10"
28
+ fill="none"
29
+ style={{ opacity: disabled ? 0.25 : 1 }}
30
+ >
31
+ <path
32
+ d="M5 1L1 5L5 9"
33
+ stroke="#a3a3a3"
34
+ strokeWidth="1.4"
35
+ strokeLinecap="round"
36
+ strokeLinejoin="round"
37
+ />
38
+ </svg>
39
+ );
40
+ }
41
+
42
+ function ArrowRight({ disabled }: { disabled: boolean }) {
43
+ return (
44
+ <svg
45
+ width="6"
46
+ height="10"
47
+ viewBox="0 0 6 10"
48
+ fill="none"
49
+ style={{ opacity: disabled ? 0.25 : 1 }}
50
+ >
51
+ <path
52
+ d="M1 1L5 5L1 9"
53
+ stroke="#a3a3a3"
54
+ strokeWidth="1.4"
55
+ strokeLinecap="round"
56
+ strokeLinejoin="round"
57
+ />
58
+ </svg>
59
+ );
60
+ }
61
+
62
+ // fallow-ignore-next-line complexity
63
+ export const KeyframeNavigation = memo(function KeyframeNavigation({
64
+ property,
65
+ keyframes,
66
+ currentPercentage,
67
+ onSeek,
68
+ onAddKeyframe,
69
+ onRemoveKeyframe,
70
+ onConvertToKeyframes,
71
+ }: KeyframeNavigationProps) {
72
+ // Find keyframes that contain this property
73
+ const propertyKeyframes = keyframes?.filter((kf) => property in kf.properties) ?? [];
74
+
75
+ const prevKf =
76
+ propertyKeyframes.filter((kf) => kf.percentage < currentPercentage - TOLERANCE).at(-1) ?? null;
77
+
78
+ const nextKf =
79
+ propertyKeyframes.find((kf) => kf.percentage > currentPercentage + TOLERANCE) ?? null;
80
+
81
+ const atCurrent =
82
+ propertyKeyframes.find((kf) => Math.abs(kf.percentage - currentPercentage) <= TOLERANCE) ??
83
+ null;
84
+
85
+ // Diamond state
86
+ let diamondState: DiamondState;
87
+ if (!keyframes || keyframes.length === 0) {
88
+ diamondState = "ghost";
89
+ } else if (atCurrent) {
90
+ diamondState = "active";
91
+ } else if (propertyKeyframes.length > 0) {
92
+ diamondState = "inactive";
93
+ } else {
94
+ diamondState = "ghost";
95
+ }
96
+
97
+ const handleDiamondClick = () => {
98
+ if (diamondState === "ghost") {
99
+ onConvertToKeyframes();
100
+ } else if (diamondState === "active") {
101
+ onRemoveKeyframe(currentPercentage);
102
+ } else {
103
+ onAddKeyframe(currentPercentage);
104
+ }
105
+ };
106
+
107
+ return (
108
+ <div className="flex h-5 items-center gap-0.5">
109
+ <button
110
+ type="button"
111
+ disabled={!prevKf}
112
+ onClick={() => prevKf && onSeek(prevKf.percentage)}
113
+ className="flex h-5 w-3 items-center justify-center disabled:cursor-default"
114
+ >
115
+ <ArrowLeft disabled={!prevKf} />
116
+ </button>
117
+ <KeyframeDiamond
118
+ state={diamondState}
119
+ onClick={handleDiamondClick}
120
+ size={9}
121
+ title={
122
+ diamondState === "ghost"
123
+ ? `Convert ${property} to keyframes`
124
+ : diamondState === "active"
125
+ ? `Remove ${property} keyframe`
126
+ : `Add ${property} keyframe`
127
+ }
128
+ />
129
+ <button
130
+ type="button"
131
+ disabled={!nextKf}
132
+ onClick={() => nextKf && onSeek(nextKf.percentage)}
133
+ className="flex h-5 w-3 items-center justify-center disabled:cursor-default"
134
+ >
135
+ <ArrowRight disabled={!nextKf} />
136
+ </button>
137
+ </div>
138
+ );
139
+ });
@@ -1,8 +1,6 @@
1
1
  import { memo } from "react";
2
- import { Clock, Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
3
- import { type DomEditSelection } from "./domEditing";
2
+ import { Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
4
3
  import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
5
- import type { ImportedFontAsset } from "./fontAssets";
6
4
  import {
7
5
  EMPTY_STYLES,
8
6
  formatPxMetricValue,
@@ -14,7 +12,11 @@ import { MetricField, Section } from "./propertyPanelPrimitives";
14
12
  import { isMediaElement, MediaSection } from "./propertyPanelMediaSection";
15
13
  import { TextSection, StyleSections } from "./propertyPanelSections";
16
14
  import { GsapAnimationSection } from "./GsapAnimationSection";
17
- import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
15
+ import { KeyframeNavigation } from "./KeyframeNavigation";
16
+ import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
17
+ import { usePlayerStore } from "../../player";
18
+ import { TimingSection } from "./propertyPanelTimingSection";
19
+ import { computeFitToChildrenSize, type PropertyPanelProps } from "./propertyPanelHelpers";
18
20
 
19
21
  // Re-export helpers that external consumers import from this module
20
22
  export {
@@ -28,109 +30,6 @@ export {
28
30
  setCssFilterFunctionPx,
29
31
  } from "./propertyPanelHelpers";
30
32
 
31
- interface PropertyPanelProps {
32
- projectId: string;
33
- projectDir: string | null;
34
- assets: string[];
35
- element: DomEditSelection | null;
36
- multiSelectCount?: number;
37
- copiedAgentPrompt: boolean;
38
- onClearSelection: () => void;
39
- onSetStyle: (prop: string, value: string) => void | Promise<void>;
40
- onSetAttribute: (attr: string, value: string) => void | Promise<void>;
41
- onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
42
- onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
43
- onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
44
- onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
45
- onSetText: (value: string, fieldKey?: string) => void;
46
- onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
47
- onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
48
- onRemoveTextField: (fieldKey: string) => void;
49
- onAskAgent: () => void;
50
- onImportAssets?: (files: FileList) => Promise<string[]>;
51
- fontAssets?: ImportedFontAsset[];
52
- onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
53
- gsapAnimations?: import("@hyperframes/core/gsap-parser").GsapAnimation[];
54
- gsapMultipleTimelines?: boolean;
55
- gsapUnsupportedTimelinePattern?: boolean;
56
- onUpdateGsapProperty?: (animId: string, prop: string, value: number | string) => void;
57
- onUpdateGsapMeta?: (
58
- animId: string,
59
- updates: { duration?: number; ease?: string; position?: number },
60
- ) => void;
61
- onDeleteGsapAnimation?: (animId: string) => void;
62
- onAddGsapProperty?: (animId: string, prop: string) => void;
63
- onRemoveGsapProperty?: (animId: string, prop: string) => void;
64
- onUpdateGsapFromProperty?: (animId: string, prop: string, value: number | string) => void;
65
- onAddGsapFromProperty?: (animId: string, prop: string) => void;
66
- onRemoveGsapFromProperty?: (animId: string, prop: string) => void;
67
- onAddGsapAnimation?: (method: "to" | "from" | "set" | "fromTo") => void;
68
- }
69
-
70
- /* ------------------------------------------------------------------ */
71
- /* TimingSection */
72
- /* ------------------------------------------------------------------ */
73
-
74
- function formatTimingValue(seconds: number): string {
75
- if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
76
- return `${seconds.toFixed(2)}s`;
77
- }
78
-
79
- function parseTimingValue(input: string): number | null {
80
- const cleaned = input.replace(/s$/i, "").trim();
81
- const parsed = Number.parseFloat(cleaned);
82
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
83
- }
84
-
85
- function TimingSection({
86
- element,
87
- onSetAttribute,
88
- }: {
89
- element: DomEditSelection;
90
- onSetAttribute: (attr: string, value: string) => void | Promise<void>;
91
- }) {
92
- const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
93
- const duration =
94
- Number.parseFloat(
95
- element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0",
96
- ) || 0;
97
- const end = start + duration;
98
-
99
- const commitStart = (nextValue: string) => {
100
- const parsed = parseTimingValue(nextValue);
101
- if (parsed == null) return;
102
- void onSetAttribute("start", parsed.toFixed(2));
103
- };
104
-
105
- const commitDuration = (nextValue: string) => {
106
- const parsed = parseTimingValue(nextValue);
107
- if (parsed == null || parsed <= 0) return;
108
- void onSetAttribute("duration", parsed.toFixed(2));
109
- };
110
-
111
- const commitEnd = (nextValue: string) => {
112
- const parsed = parseTimingValue(nextValue);
113
- if (parsed == null || parsed <= start) return;
114
- void onSetAttribute("duration", (parsed - start).toFixed(2));
115
- };
116
-
117
- return (
118
- <Section title="Timing" icon={<Clock size={15} />}>
119
- <div className={RESPONSIVE_GRID}>
120
- <MetricField label="Start" value={formatTimingValue(start)} onCommit={commitStart} />
121
- <MetricField label="End" value={formatTimingValue(end)} onCommit={commitEnd} />
122
- </div>
123
- <div className="mt-3">
124
- <MetricField
125
- label="Duration"
126
- value={formatTimingValue(duration)}
127
- onCommit={commitDuration}
128
- />
129
- </div>
130
- </Section>
131
- );
132
- }
133
-
134
33
  /* ------------------------------------------------------------------ */
135
34
  /* PropertyPanel */
136
35
  /* ------------------------------------------------------------------ */
@@ -158,6 +57,7 @@ export const PropertyPanel = memo(function PropertyPanel({
158
57
  onImportAssets,
159
58
  fontAssets = [],
160
59
  onImportFonts,
60
+ previewIframeRef,
161
61
  gsapAnimations = [],
162
62
  gsapMultipleTimelines,
163
63
  gsapUnsupportedTimelinePattern,
@@ -170,6 +70,11 @@ export const PropertyPanel = memo(function PropertyPanel({
170
70
  onAddGsapFromProperty,
171
71
  onRemoveGsapFromProperty,
172
72
  onAddGsapAnimation,
73
+ onAddKeyframe,
74
+ onRemoveKeyframe,
75
+ onConvertToKeyframes,
76
+ onCommitAnimatedProperty,
77
+ onSeekToTime,
173
78
  }: PropertyPanelProps) {
174
79
  const styles = element?.computedStyles ?? EMPTY_STYLES;
175
80
 
@@ -223,6 +128,15 @@ export const PropertyPanel = memo(function PropertyPanel({
223
128
  const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
224
129
  const parsed = parsePxMetricValue(nextValue);
225
130
  if (parsed == null) return;
131
+ if (onCommitAnimatedProperty && (gsapAnimId || gsapAnimations.length > 0)) {
132
+ void onCommitAnimatedProperty(element, axis, parsed);
133
+ return;
134
+ }
135
+ if (gsapKeyframes && gsapAnimId && onAddKeyframe) {
136
+ const pct = Math.max(0, Math.min(100, Math.round(currentPct * 10) / 10));
137
+ onAddKeyframe(gsapAnimId, pct, axis, parsed);
138
+ return;
139
+ }
226
140
  const current = readStudioPathOffset(element.element);
227
141
  onSetManualOffset(element, {
228
142
  x: axis === "x" ? parsed : current.x,
@@ -256,6 +170,59 @@ export const PropertyPanel = memo(function PropertyPanel({
256
170
  onSetManualRotation(element, { angle: parsed });
257
171
  };
258
172
 
173
+ // Keyframe navigation state
174
+ const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
175
+ const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0;
176
+ const currentTime = usePlayerStore((s) => s.currentTime);
177
+ const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0;
178
+
179
+ const gsapKeyframes = gsapAnimations?.find((a) => a.keyframes)?.keyframes?.keyframes ?? null;
180
+ const gsapAnimId =
181
+ gsapAnimations?.find((a) => a.keyframes)?.id ?? gsapAnimations?.[0]?.id ?? null;
182
+
183
+ // Read ALL GSAP-interpolated values at the current seek time.
184
+ // Discovers animated properties from the animation's keyframes/tween vars.
185
+ const gsapRuntimeValues: Record<string, number> | null = (() => {
186
+ if (!gsapAnimId || gsapAnimations.length === 0) return null;
187
+ const iframe = previewIframeRef?.current;
188
+ if (!iframe?.contentWindow) return null;
189
+ const selector = element.id ? `#${element.id}` : element.selector;
190
+ if (!selector) return null;
191
+ try {
192
+ const gsap = (
193
+ iframe.contentWindow as unknown as {
194
+ gsap?: { getProperty: (el: Element, prop: string) => number | string };
195
+ }
196
+ ).gsap;
197
+ if (!gsap?.getProperty) return null;
198
+ const el = iframe.contentDocument?.querySelector(selector);
199
+ if (!el) return null;
200
+ const propKeys = new Set<string>();
201
+ for (const anim of gsapAnimations) {
202
+ if (anim.keyframes) {
203
+ for (const kf of anim.keyframes.keyframes) {
204
+ for (const p of Object.keys(kf.properties)) propKeys.add(p);
205
+ }
206
+ }
207
+ for (const p of Object.keys(anim.properties)) propKeys.add(p);
208
+ }
209
+ const result: Record<string, number> = {};
210
+ for (const prop of propKeys) {
211
+ const v = Number(gsap.getProperty(el, prop));
212
+ if (Number.isFinite(v)) result[prop] = Math.round(v * 100) / 100;
213
+ }
214
+ return Object.keys(result).length > 0 ? result : null;
215
+ } catch {
216
+ return null;
217
+ }
218
+ })();
219
+
220
+ const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
221
+ const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
222
+ const displayW = gsapRuntimeValues?.width ?? resolvedWidth;
223
+ const displayH = gsapRuntimeValues?.height ?? resolvedHeight;
224
+ const displayR = gsapRuntimeValues?.rotation ?? manualRotation.angle;
225
+
259
226
  return (
260
227
  <div className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-900 text-neutral-100">
261
228
  <div className="border-b border-neutral-800 px-4 py-5">
@@ -317,41 +284,227 @@ export const PropertyPanel = memo(function PropertyPanel({
317
284
 
318
285
  <Section title="Layout" icon={<Move size={15} />}>
319
286
  <div className={RESPONSIVE_GRID}>
320
- <MetricField
321
- label="X"
322
- value={formatPxMetricValue(manualOffset.x)}
323
- disabled={manualOffsetEditingDisabled}
324
- scrub
325
- onCommit={(next) => commitManualOffset("x", next)}
326
- />
327
- <MetricField
328
- label="Y"
329
- value={formatPxMetricValue(manualOffset.y)}
330
- disabled={manualOffsetEditingDisabled}
331
- scrub
332
- onCommit={(next) => commitManualOffset("y", next)}
333
- />
334
- <MetricField
335
- label="W"
336
- value={formatPxMetricValue(resolvedWidth)}
337
- disabled={manualSizeEditingDisabled}
338
- scrub
339
- onCommit={(next) => commitManualSize("width", next)}
340
- />
341
- <MetricField
342
- label="H"
343
- value={formatPxMetricValue(resolvedHeight)}
344
- disabled={manualSizeEditingDisabled}
345
- scrub
346
- onCommit={(next) => commitManualSize("height", next)}
347
- />
348
- <MetricField
349
- label="R"
350
- value={`${manualRotation.angle}°`}
351
- onCommit={(next) => commitManualRotation(next.replace("°", ""))}
352
- />
287
+ <div className="flex items-center gap-1">
288
+ <div className="flex-1">
289
+ <MetricField
290
+ label="X"
291
+ value={formatPxMetricValue(displayX)}
292
+ disabled={manualOffsetEditingDisabled}
293
+ scrub
294
+ onCommit={(next) => commitManualOffset("x", next)}
295
+ />
296
+ </div>
297
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
298
+ <KeyframeNavigation
299
+ property="x"
300
+ keyframes={gsapKeyframes}
301
+ currentPercentage={currentPct}
302
+ onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
303
+ onAddKeyframe={() =>
304
+ onCommitAnimatedProperty &&
305
+ void onCommitAnimatedProperty(element, "x", displayX)
306
+ }
307
+ onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
308
+ onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
309
+ />
310
+ )}
311
+ </div>
312
+ <div className="flex items-center gap-1">
313
+ <div className="flex-1">
314
+ <MetricField
315
+ label="Y"
316
+ value={formatPxMetricValue(displayY)}
317
+ disabled={manualOffsetEditingDisabled}
318
+ scrub
319
+ onCommit={(next) => commitManualOffset("y", next)}
320
+ />
321
+ </div>
322
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
323
+ <KeyframeNavigation
324
+ property="y"
325
+ keyframes={gsapKeyframes}
326
+ currentPercentage={currentPct}
327
+ onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
328
+ onAddKeyframe={() =>
329
+ onCommitAnimatedProperty &&
330
+ void onCommitAnimatedProperty(element, "y", displayY)
331
+ }
332
+ onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
333
+ onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
334
+ />
335
+ )}
336
+ </div>
337
+ <div className="flex items-center gap-1">
338
+ <div className="flex-1">
339
+ <MetricField
340
+ label="W"
341
+ value={formatPxMetricValue(displayW)}
342
+ disabled={manualSizeEditingDisabled}
343
+ scrub
344
+ onCommit={(next) => commitManualSize("width", next)}
345
+ />
346
+ </div>
347
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
348
+ <KeyframeNavigation
349
+ property="width"
350
+ keyframes={gsapKeyframes}
351
+ currentPercentage={currentPct}
352
+ onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
353
+ onAddKeyframe={() =>
354
+ onCommitAnimatedProperty &&
355
+ void onCommitAnimatedProperty(element, "width", displayW)
356
+ }
357
+ onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
358
+ onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
359
+ />
360
+ )}
361
+ </div>
362
+ <div className="flex items-center gap-1">
363
+ <div className="flex-1">
364
+ <MetricField
365
+ label="H"
366
+ value={formatPxMetricValue(displayH)}
367
+ disabled={manualSizeEditingDisabled}
368
+ scrub
369
+ onCommit={(next) => commitManualSize("height", next)}
370
+ />
371
+ </div>
372
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
373
+ <KeyframeNavigation
374
+ property="height"
375
+ keyframes={gsapKeyframes}
376
+ currentPercentage={currentPct}
377
+ onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
378
+ onAddKeyframe={() =>
379
+ onCommitAnimatedProperty &&
380
+ void onCommitAnimatedProperty(element, "height", displayH)
381
+ }
382
+ onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
383
+ onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
384
+ />
385
+ )}
386
+ </div>
387
+ {element.capabilities.canApplyManualSize && (
388
+ <button
389
+ type="button"
390
+ className="flex-shrink-0 rounded p-1 text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
391
+ title="Fit to children"
392
+ onClick={() => {
393
+ const size = computeFitToChildrenSize(element);
394
+ if (size) onSetManualSize(element, size);
395
+ }}
396
+ >
397
+ <svg
398
+ width="14"
399
+ height="14"
400
+ viewBox="0 0 14 14"
401
+ fill="none"
402
+ stroke="currentColor"
403
+ strokeWidth="1.2"
404
+ >
405
+ <rect x="2" y="2" width="10" height="10" strokeDasharray="2 1.5" rx="1" />
406
+ <path d="M2 4.5h1m-1 5h1m8-5h1m-1 5h1M4.5 2v1m5-1v1M4.5 11v1m5-1v1" />
407
+ </svg>
408
+ </button>
409
+ )}
410
+ <div className="flex items-center gap-1">
411
+ <div className="flex-1">
412
+ <MetricField
413
+ label="R"
414
+ value={`${displayR}°`}
415
+ onCommit={(next) => commitManualRotation(next.replace("°", ""))}
416
+ />
417
+ </div>
418
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
419
+ <KeyframeNavigation
420
+ property="rotation"
421
+ keyframes={gsapKeyframes}
422
+ currentPercentage={currentPct}
423
+ onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
424
+ onAddKeyframe={() =>
425
+ onCommitAnimatedProperty &&
426
+ void onCommitAnimatedProperty(element, "rotation", displayR)
427
+ }
428
+ onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
429
+ onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
430
+ />
431
+ )}
432
+ </div>
353
433
  </div>
434
+ {gsapRuntimeValues && (
435
+ <div className="mt-3 border-t border-neutral-800/40 pt-3">
436
+ <div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
437
+ 3D Transform
438
+ </div>
439
+ <div className={RESPONSIVE_GRID}>
440
+ <div className="flex items-center gap-1">
441
+ <div className="flex-1">
442
+ <MetricField
443
+ label="Z"
444
+ value={formatPxMetricValue(gsapRuntimeValues.z ?? 0)}
445
+ scrub
446
+ onCommit={(next) => {
447
+ const v = parsePxMetricValue(next);
448
+ if (v != null && onCommitAnimatedProperty) {
449
+ void onCommitAnimatedProperty(element, "z", v);
450
+ }
451
+ }}
452
+ />
453
+ </div>
454
+ {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
455
+ <KeyframeNavigation
456
+ property="z"
457
+ keyframes={gsapKeyframes}
458
+ currentPercentage={currentPct}
459
+ onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
460
+ onAddKeyframe={() => {
461
+ if (onCommitAnimatedProperty) {
462
+ void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0);
463
+ }
464
+ }}
465
+ onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
466
+ onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
467
+ />
468
+ )}
469
+ </div>
470
+ <MetricField
471
+ label="Scale"
472
+ value={String(gsapRuntimeValues.scale ?? 1)}
473
+ scrub
474
+ onCommit={(next) => {
475
+ const v = Number.parseFloat(next);
476
+ if (Number.isFinite(v) && onCommitAnimatedProperty) {
477
+ void onCommitAnimatedProperty(element, "scale", v);
478
+ }
479
+ }}
480
+ />
481
+ <MetricField
482
+ label="RotX"
483
+ value={`${gsapRuntimeValues.rotationX ?? 0}°`}
484
+ onCommit={(next) => {
485
+ const v = Number.parseFloat(next.replace("°", ""));
486
+ if (Number.isFinite(v) && onCommitAnimatedProperty) {
487
+ void onCommitAnimatedProperty(element, "rotationX", v);
488
+ }
489
+ }}
490
+ />
491
+ <MetricField
492
+ label="RotY"
493
+ value={`${gsapRuntimeValues.rotationY ?? 0}°`}
494
+ onCommit={(next) => {
495
+ const v = Number.parseFloat(next.replace("°", ""));
496
+ if (Number.isFinite(v) && onCommitAnimatedProperty) {
497
+ void onCommitAnimatedProperty(element, "rotationY", v);
498
+ }
499
+ }}
500
+ />
501
+ </div>
502
+ </div>
503
+ )}
354
504
  <div className="mt-3">
505
+ <div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
506
+ Stacking
507
+ </div>
355
508
  <MetricField
356
509
  label="Z-index"
357
510
  value={String(parseInt(styles["z-index"] || "auto", 10) || 0)}