@hyperframes/studio 0.7.7 → 0.7.8

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 (39) hide show
  1. package/dist/assets/{index-B2YXvFxf.js → index--lOAjFJl.js} +1 -1
  2. package/dist/assets/{index-BeRh2hMe.js → index-BSyZKYmH.js} +194 -194
  3. package/dist/assets/{index-BoASKOeE.js → index-Dgeszckd.js} +1 -1
  4. package/dist/assets/index-svYFaNuq.css +1 -0
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +2699 -2052
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/components/StudioRightPanel.tsx +5 -1
  11. package/src/components/editor/DomEditOverlay.tsx +4 -12
  12. package/src/components/editor/MarqueeOverlay.tsx +40 -0
  13. package/src/components/editor/OffCanvasIndicators.tsx +2 -7
  14. package/src/components/editor/PropertyPanel.tsx +12 -0
  15. package/src/components/editor/Transform3DCube.tsx +313 -0
  16. package/src/components/editor/gsapAnimationConstants.ts +5 -0
  17. package/src/components/editor/marqueeCommit.ts +63 -20
  18. package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
  19. package/src/components/editor/propertyPanelHelpers.ts +43 -21
  20. package/src/components/editor/transform3dProjection.test.ts +99 -0
  21. package/src/components/editor/transform3dProjection.ts +172 -0
  22. package/src/components/sidebar/AssetsTab.tsx +23 -213
  23. package/src/components/sidebar/AudioRow.tsx +202 -0
  24. package/src/contexts/DomEditContext.tsx +4 -0
  25. package/src/hooks/gsapDragCommit.test.ts +9 -5
  26. package/src/hooks/gsapDragCommit.ts +28 -9
  27. package/src/hooks/gsapRuntimeKeyframes.ts +50 -5
  28. package/src/hooks/gsapRuntimePatch.ts +162 -35
  29. package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
  30. package/src/hooks/useDomEditCommits.ts +0 -2
  31. package/src/hooks/useDomEditSession.ts +4 -2
  32. package/src/hooks/useDomGeometryCommits.ts +3 -31
  33. package/src/hooks/useGsapAwareEditing.ts +31 -1
  34. package/src/hooks/useGsapKeyframeOps.ts +4 -1
  35. package/src/hooks/useGsapSelectionHandlers.ts +12 -9
  36. package/src/hooks/useGsapTweenCache.ts +6 -4
  37. package/src/utils/marqueeGeometry.test.ts +15 -98
  38. package/src/utils/marqueeGeometry.ts +1 -158
  39. package/dist/assets/index-BSkUuN8g.css +0 -1
@@ -3,7 +3,8 @@ import type { DomEditSelection } from "./domEditing";
3
3
  import { collectDomEditLayerItems, resolveDomEditSelection } from "./domEditingLayers";
4
4
  import { isElementComputedVisible } from "./domEditingElement";
5
5
  import { coversComposition } from "../../utils/studioPreviewHelpers";
6
- import { elementObbCorners, marqueeIntersectsObb } from "../../utils/marqueeGeometry";
6
+ import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
7
+ import { toOverlayRect } from "./domEditOverlayGeometry";
7
8
 
8
9
  interface MarqueeState {
9
10
  startX: number;
@@ -16,13 +17,26 @@ interface MarqueeState {
16
17
 
17
18
  const MARQUEE_THRESHOLD_PX = 4;
18
19
 
20
+ interface MarqueeHit {
21
+ element: HTMLElement;
22
+ rect: Rect;
23
+ }
24
+
25
+ /**
26
+ * Synchronous core of the marquee: the elements whose overlay-space rect
27
+ * intersects the marquee rect. Uses the SAME `toOverlayRect` basis as the
28
+ * single-selection / group-selection boxes, so what the marquee highlights
29
+ * and selects is exactly the box the user sees when they click an element.
30
+ * Shared by the live candidate highlight (per pointer-move) and the mouse-up
31
+ * commit. No async source probe — that only happens once, on commit.
32
+ */
19
33
  // fallow-ignore-next-line complexity
20
- async function runMarqueeIntersection(
21
- rect: { left: number; top: number; width: number; height: number },
34
+ function collectMarqueeHits(
35
+ rect: Rect,
22
36
  iframe: HTMLIFrameElement,
23
37
  overlayEl: HTMLDivElement,
24
38
  activeCompositionPath: string,
25
- ): Promise<DomEditSelection[]> {
39
+ ): MarqueeHit[] {
26
40
  const doc = iframe.contentDocument;
27
41
  if (!doc) return [];
28
42
 
@@ -38,22 +52,42 @@ async function runMarqueeIntersection(
38
52
  height: declH > 0 ? declH : rootEl.getBoundingClientRect().height || 1,
39
53
  };
40
54
 
41
- const hits: DomEditSelection[] = [];
55
+ const hits: MarqueeHit[] = [];
42
56
  for (const item of items) {
43
57
  const el = item.element;
44
58
  if (!isElementComputedVisible(el)) continue;
45
59
  if (coversComposition(el.getBoundingClientRect(), viewport)) continue;
46
- const corners = elementObbCorners(el, overlayEl, iframe);
47
- if (!corners) continue;
48
- if (!marqueeIntersectsObb(rect, corners)) continue;
49
- const sel = await resolveDomEditSelection(el, {
60
+ const overlayRect = toOverlayRect(overlayEl, iframe, el);
61
+ if (!overlayRect) continue;
62
+ const r: Rect = {
63
+ left: overlayRect.left,
64
+ top: overlayRect.top,
65
+ width: overlayRect.width,
66
+ height: overlayRect.height,
67
+ };
68
+ if (!rectsOverlap(rect, r)) continue;
69
+ hits.push({ element: el, rect: r });
70
+ }
71
+
72
+ return hits;
73
+ }
74
+
75
+ async function runMarqueeIntersection(
76
+ rect: Rect,
77
+ iframe: HTMLIFrameElement,
78
+ overlayEl: HTMLDivElement,
79
+ activeCompositionPath: string,
80
+ ): Promise<DomEditSelection[]> {
81
+ const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
82
+ const hits: DomEditSelection[] = [];
83
+ for (const { element } of collectMarqueeHits(rect, iframe, overlayEl, activeCompositionPath)) {
84
+ const sel = await resolveDomEditSelection(element, {
50
85
  activeCompositionPath,
51
86
  isMasterView,
52
87
  skipSourceProbe: true,
53
88
  });
54
89
  if (sel) hits.push(sel);
55
90
  }
56
-
57
91
  return hits;
58
92
  }
59
93
 
@@ -75,12 +109,12 @@ interface MarqueeGesturesDeps {
75
109
  // fallow-ignore-next-line complexity
76
110
  export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
77
111
  const marqueeRef = useRef<MarqueeState | null>(null);
78
- const [marqueeRect, setMarqueeRect] = useState<{
79
- left: number;
80
- top: number;
81
- width: number;
82
- height: number;
83
- } | null>(null);
112
+ const [marqueeRect, setMarqueeRect] = useState<Rect | null>(null);
113
+ // Live "candidate" highlight: the elements the marquee currently touches,
114
+ // shown before mouse-up so you can see what you're about to select. The
115
+ // iframe DOM doesn't mutate during a drag, so a sync intersection per move
116
+ // is cheap (clean layout → no thrash).
117
+ const [candidateRects, setCandidateRects] = useState<Rect[]>([]);
84
118
 
85
119
  const commitMarquee = useCallback(
86
120
  async (
@@ -111,17 +145,24 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
111
145
  if (Math.hypot(dx, dy) < MARQUEE_THRESHOLD_PX) return;
112
146
  m.pastThreshold = true;
113
147
  }
114
- setMarqueeRect({
148
+ const rect: Rect = {
115
149
  left: Math.min(m.startX, m.currentX),
116
150
  top: Math.min(m.startY, m.currentY),
117
151
  width: Math.abs(m.currentX - m.startX),
118
152
  height: Math.abs(m.currentY - m.startY),
119
- });
153
+ };
154
+ setMarqueeRect(rect);
155
+ const iframe = deps.iframeRef.current;
156
+ const overlay = deps.overlayRef.current;
157
+ if (iframe && overlay) {
158
+ const acp = deps.activeCompositionPathRef.current ?? "index.html";
159
+ setCandidateRects(collectMarqueeHits(rect, iframe, overlay, acp).map((h) => h.rect));
160
+ }
120
161
  return;
121
162
  }
122
163
  deps.gestures.onPointerMove(event);
123
164
  },
124
- [deps.gestures, deps.overlayRef],
165
+ [deps.gestures, deps.overlayRef, deps.iframeRef, deps.activeCompositionPathRef],
125
166
  );
126
167
 
127
168
  const onPointerUp = useCallback(
@@ -148,6 +189,7 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
148
189
  deps.onMarqueeSelectRef.current?.([], false);
149
190
  }
150
191
  setMarqueeRect(null);
192
+ setCandidateRects([]);
151
193
  return;
152
194
  }
153
195
  deps.gestures.onPointerUp(event);
@@ -159,10 +201,11 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
159
201
  if (marqueeRef.current) {
160
202
  marqueeRef.current = null;
161
203
  setMarqueeRect(null);
204
+ setCandidateRects([]);
162
205
  return;
163
206
  }
164
207
  deps.gestures.clearPointerState(deps.selectionRef);
165
208
  }, [deps.gestures, deps.selectionRef]);
166
209
 
167
- return { marqueeRef, marqueeRect, onPointerMove, onPointerUp, onPointerCancel };
210
+ return { marqueeRef, marqueeRect, candidateRects, onPointerMove, onPointerUp, onPointerCancel };
168
211
  }
@@ -1,8 +1,10 @@
1
+ import { useState } from "react";
1
2
  import type { DomEditSelection } from "./domEditingTypes";
2
3
  import { STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
3
4
  import { MetricField } from "./propertyPanelPrimitives";
4
5
  import { KeyframeNavigation } from "./KeyframeNavigation";
5
6
  import { formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
7
+ import { Transform3DCube, type CubePose } from "./Transform3DCube";
6
8
 
7
9
  type KeyframeEntry = Array<{
8
10
  percentage: number;
@@ -24,9 +26,216 @@ interface PropertyPanel3dTransformProps {
24
26
  property: string,
25
27
  value: number,
26
28
  ) => Promise<void>;
29
+ /** Batched commit — several props into one keyframe (the cube's rotationX/Y/Z). */
30
+ onCommitAnimatedProperties?: (
31
+ element: DomEditSelection,
32
+ props: Record<string, number | string>,
33
+ ) => Promise<void>;
27
34
  onSeekToTime?: (time: number) => void;
28
35
  onRemoveKeyframe?: (animId: string, pct: number) => void;
29
- onConvertToKeyframes?: (animId: string) => void;
36
+ onConvertToKeyframes?: (animId: string, duration?: number) => void;
37
+ /** Live-set props on the preview element during a cube drag (no source write). */
38
+ onLivePreviewProps?: (element: DomEditSelection, props: Record<string, number>) => void;
39
+ }
40
+
41
+ type CommitAnimatedProperty = (
42
+ element: DomEditSelection,
43
+ property: string,
44
+ value: number,
45
+ ) => Promise<void>;
46
+
47
+ /** The draggable cube + its commit/recenter/live-preview wiring. */
48
+ function Cube3dControl({
49
+ element,
50
+ gsapRuntimeValues,
51
+ onCommitAnimatedProperty,
52
+ onCommitAnimatedProperties,
53
+ onLivePreviewProps,
54
+ onKeyframe,
55
+ keyframed,
56
+ }: {
57
+ element: DomEditSelection;
58
+ gsapRuntimeValues: Record<string, number>;
59
+ onCommitAnimatedProperty: CommitAnimatedProperty;
60
+ onCommitAnimatedProperties?: (
61
+ element: DomEditSelection,
62
+ props: Record<string, number | string>,
63
+ ) => Promise<void>;
64
+ onLivePreviewProps?: (element: DomEditSelection, props: Record<string, number>) => void;
65
+ onKeyframe?: () => void;
66
+ keyframed?: boolean;
67
+ }) {
68
+ const pose: CubePose = {
69
+ rotationX: gsapRuntimeValues.rotationX ?? 0,
70
+ rotationY: gsapRuntimeValues.rotationY ?? 0,
71
+ rotationZ: gsapRuntimeValues.rotationZ ?? 0,
72
+ };
73
+ // Commit only the rotation axes the drag actually changed (each rounded to a
74
+ // whole degree). Reuses the keyframe-aware animated-property commit, so a drag
75
+ // at the playhead writes/updates a keyframe just like the numeric fields.
76
+ const commitPose = (next: CubePose) => {
77
+ const changedProps: Record<string, number> = {};
78
+ for (const axis of ["rotationX", "rotationY", "rotationZ"] as const) {
79
+ const rounded = Math.round(next[axis]);
80
+ if (rounded !== Math.round(pose[axis])) changedProps[axis] = rounded;
81
+ }
82
+ const axes = Object.keys(changedProps);
83
+ if (axes.length === 0) return;
84
+ // ONE keyframe for the whole pose change — avoids per-axis commits racing into
85
+ // adjacent duplicate keyframes. Fall back to per-axis if no batched commit.
86
+ if (onCommitAnimatedProperties) {
87
+ void onCommitAnimatedProperties(element, changedProps);
88
+ } else {
89
+ for (const [axis, v] of Object.entries(changedProps))
90
+ onCommitAnimatedProperty(element, axis, v);
91
+ }
92
+ };
93
+ const recenter = () => {
94
+ // ONE commit for the whole reset — six per-axis commits meant six soft-reloads
95
+ // (six flashes) for a single click. Batch like commitPose does.
96
+ const identity = {
97
+ rotationX: 0,
98
+ rotationY: 0,
99
+ rotationZ: 0,
100
+ z: 0,
101
+ scale: 1,
102
+ transformPerspective: 0,
103
+ };
104
+ if (onCommitAnimatedProperties) {
105
+ void onCommitAnimatedProperties(element, identity);
106
+ } else {
107
+ for (const [prop, v] of Object.entries(identity))
108
+ void onCommitAnimatedProperty(element, prop, v);
109
+ }
110
+ };
111
+ // Immediate element feedback while dragging — set the live transform without a
112
+ // source write; the release commits via onCommitAnimatedProperty.
113
+ const livePreview = (next: CubePose) =>
114
+ onLivePreviewProps?.(element, {
115
+ rotationX: next.rotationX,
116
+ rotationY: next.rotationY,
117
+ rotationZ: next.rotationZ,
118
+ });
119
+
120
+ return (
121
+ <div className="mb-2 px-2">
122
+ <div className="mx-auto max-w-[184px]">
123
+ <Transform3DCube
124
+ pose={pose}
125
+ perspective={gsapRuntimeValues.transformPerspective ?? 0}
126
+ onPoseDraft={livePreview}
127
+ onPoseCommit={commitPose}
128
+ onPerspectiveDraft={(px) => onLivePreviewProps?.(element, { transformPerspective: px })}
129
+ onPerspectiveCommit={(px) =>
130
+ void onCommitAnimatedProperty(element, "transformPerspective", px)
131
+ }
132
+ onRecenter={recenter}
133
+ onKeyframe={onKeyframe}
134
+ keyframed={keyframed}
135
+ />
136
+ <p className="mt-1 text-center text-[9px] leading-snug text-neutral-600">
137
+ Drag to tilt · Shift-drag to roll
138
+ </p>
139
+ </div>
140
+ </div>
141
+ );
142
+ }
143
+
144
+ interface FieldCtx {
145
+ element: DomEditSelection;
146
+ gsapRuntimeValues: Record<string, number>;
147
+ gsapKeyframes: KeyframeEntry;
148
+ gsapAnimId: string | null;
149
+ currentPct: number;
150
+ elStart: number;
151
+ elDuration: number;
152
+ resolveAnimIdForProp?: (prop: string) => string | null;
153
+ onCommitAnimatedProperty?: (
154
+ element: DomEditSelection,
155
+ property: string,
156
+ value: number,
157
+ ) => Promise<void>;
158
+ onSeekToTime?: (time: number) => void;
159
+ onRemoveKeyframe?: (animId: string, pct: number) => void;
160
+ onConvertToKeyframes?: (animId: string, duration?: number) => void;
161
+ }
162
+
163
+ const parseDeg = (s: string): number | null => {
164
+ const n = Number.parseFloat(s.replace("°", ""));
165
+ return Number.isFinite(n) ? n : null;
166
+ };
167
+ const parseScale = (s: string): number | null => {
168
+ const n = Number.parseFloat(s);
169
+ return Number.isFinite(n) ? n : null;
170
+ };
171
+ const parsePxNonNeg = (s: string): number | null => {
172
+ const v = parsePxMetricValue(s);
173
+ return v != null && v >= 0 ? v : null;
174
+ };
175
+
176
+ /**
177
+ * One 3D-transform field: a number/scrub input plus its keyframe diamond, so
178
+ * rotation / perspective / Z / scale can each be keyframed just like Layout's
179
+ * X / Y — the diamond was previously missing on the rotation + perspective rows.
180
+ */
181
+ function Transform3dField({
182
+ label,
183
+ prop,
184
+ scrub,
185
+ format,
186
+ parse,
187
+ defaultValue,
188
+ ctx,
189
+ }: {
190
+ label: string;
191
+ prop: string;
192
+ scrub?: boolean;
193
+ format: (v: number) => string;
194
+ parse: (s: string) => number | null;
195
+ defaultValue: number;
196
+ ctx: FieldCtx;
197
+ }) {
198
+ const { gsapAnimId, onCommitAnimatedProperty } = ctx;
199
+ const idFor = (p: string) => ctx.resolveAnimIdForProp?.(p) ?? gsapAnimId;
200
+ const current = ctx.gsapRuntimeValues[prop] ?? defaultValue;
201
+ return (
202
+ <div className="flex items-center gap-1">
203
+ <div className="flex-1">
204
+ <MetricField
205
+ label={label}
206
+ value={format(current)}
207
+ scrub={scrub}
208
+ onCommit={(next) => {
209
+ const v = parse(next);
210
+ if (v != null && onCommitAnimatedProperty) {
211
+ void onCommitAnimatedProperty(ctx.element, prop, v);
212
+ }
213
+ }}
214
+ />
215
+ </div>
216
+ {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
217
+ <KeyframeNavigation
218
+ property={prop}
219
+ keyframes={ctx.gsapKeyframes}
220
+ currentPercentage={ctx.currentPct}
221
+ onSeek={(pct) => ctx.onSeekToTime?.(ctx.elStart + (pct / 100) * ctx.elDuration)}
222
+ onAddKeyframe={() => {
223
+ if (onCommitAnimatedProperty) void onCommitAnimatedProperty(ctx.element, prop, current);
224
+ }}
225
+ onRemoveKeyframe={(pct) => {
226
+ const id = idFor(prop);
227
+ if (id) ctx.onRemoveKeyframe?.(id, pct);
228
+ }}
229
+ onConvertToKeyframes={() => {
230
+ const id = idFor(prop);
231
+ // Pass the element's clip duration so a converted static 3D `set`
232
+ // spans the whole clip (keyframes land in range at any playhead).
233
+ if (id) ctx.onConvertToKeyframes?.(id, ctx.elDuration);
234
+ }}
235
+ />
236
+ )}
237
+ </div>
238
+ );
30
239
  }
31
240
 
32
241
  export function PropertyPanel3dTransform({
@@ -39,110 +248,120 @@ export function PropertyPanel3dTransform({
39
248
  elDuration,
40
249
  element,
41
250
  onCommitAnimatedProperty,
251
+ onCommitAnimatedProperties,
42
252
  onSeekToTime,
43
253
  onRemoveKeyframe,
44
254
  onConvertToKeyframes,
255
+ onLivePreviewProps,
45
256
  }: PropertyPanel3dTransformProps) {
46
- const idFor = (prop: string) => resolveAnimIdForProp?.(prop) ?? gsapAnimId;
257
+ // Expanded by default the cube gizmo is the headline of this panel, so show
258
+ // it up front rather than hiding it behind a collapsed header.
259
+ const [collapsed, setCollapsed] = useState(false);
260
+ const ctx: FieldCtx = {
261
+ element,
262
+ gsapRuntimeValues,
263
+ gsapKeyframes,
264
+ gsapAnimId,
265
+ currentPct,
266
+ elStart,
267
+ elDuration,
268
+ resolveAnimIdForProp,
269
+ onCommitAnimatedProperty,
270
+ onSeekToTime,
271
+ onRemoveKeyframe,
272
+ onConvertToKeyframes,
273
+ };
274
+
47
275
  return (
48
276
  <div className="mt-3 border-t border-neutral-800/40 pt-3">
49
- <div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
50
- 3D Transform
51
- </div>
52
- <div className={RESPONSIVE_GRID}>
53
- <div className="flex items-center gap-1">
54
- <div className="flex-1">
55
- <MetricField
56
- label="Z"
57
- value={formatPxMetricValue(gsapRuntimeValues.z ?? 0)}
58
- scrub
59
- onCommit={(next) => {
60
- const v = parsePxMetricValue(next);
61
- if (v != null && onCommitAnimatedProperty) {
62
- void onCommitAnimatedProperty(element, "z", v);
63
- }
64
- }}
65
- />
66
- </div>
67
- {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
68
- <KeyframeNavigation
69
- property="z"
70
- keyframes={gsapKeyframes}
71
- currentPercentage={currentPct}
72
- onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
73
- onAddKeyframe={() => {
74
- if (onCommitAnimatedProperty) {
75
- void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0);
76
- }
77
- }}
78
- onRemoveKeyframe={(pct) => {
79
- const id = idFor("z");
80
- if (id) onRemoveKeyframe?.(id, pct);
81
- }}
82
- onConvertToKeyframes={() => {
83
- const id = idFor("z");
84
- if (id) onConvertToKeyframes?.(id);
277
+ <button
278
+ type="button"
279
+ onClick={() => setCollapsed((v) => !v)}
280
+ className="mb-2 flex w-full items-center justify-between text-[10px] font-medium uppercase tracking-wider text-neutral-600 hover:text-neutral-400"
281
+ >
282
+ <span>3D Transform</span>
283
+ <svg width="9" height="9" viewBox="0 0 10 10" fill="currentColor" aria-hidden>
284
+ {collapsed ? <path d="M3 2l4 3-4 3z" /> : <path d="M2 3l3 4 3-4z" />}
285
+ </svg>
286
+ </button>
287
+ {collapsed ? null : (
288
+ <>
289
+ {onCommitAnimatedProperty && (
290
+ <Cube3dControl
291
+ element={element}
292
+ gsapRuntimeValues={gsapRuntimeValues}
293
+ onCommitAnimatedProperty={onCommitAnimatedProperty}
294
+ onCommitAnimatedProperties={onCommitAnimatedProperties}
295
+ onLivePreviewProps={onLivePreviewProps}
296
+ keyframed={(gsapKeyframes ?? []).some(
297
+ (kf) =>
298
+ "rotationX" in kf.properties ||
299
+ "rotationY" in kf.properties ||
300
+ "rotationZ" in kf.properties,
301
+ )}
302
+ onKeyframe={() => {
303
+ // Convert the 3D ("other"-group) static set to keyframes so the
304
+ // cube can animate; spans the element's clip via elDuration.
305
+ const id = resolveAnimIdForProp?.("rotationX") ?? gsapAnimId;
306
+ if (id) onConvertToKeyframes?.(id, elDuration);
85
307
  }}
86
308
  />
87
309
  )}
88
- </div>
89
- <div className="flex items-center gap-1">
90
- <div className="flex-1">
91
- <MetricField
310
+ <div className={RESPONSIVE_GRID}>
311
+ <Transform3dField
312
+ ctx={ctx}
313
+ label="Z"
314
+ prop="z"
315
+ scrub
316
+ format={formatPxMetricValue}
317
+ parse={parsePxMetricValue}
318
+ defaultValue={0}
319
+ />
320
+ <Transform3dField
321
+ ctx={ctx}
92
322
  label="Scale"
93
- value={String(gsapRuntimeValues.scale ?? 1)}
323
+ prop="scale"
94
324
  scrub
95
- onCommit={(next) => {
96
- const v = Number.parseFloat(next);
97
- if (Number.isFinite(v) && onCommitAnimatedProperty) {
98
- void onCommitAnimatedProperty(element, "scale", v);
99
- }
100
- }}
325
+ format={(v) => String(v)}
326
+ parse={parseScale}
327
+ defaultValue={1}
101
328
  />
102
- </div>
103
- {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
104
- <KeyframeNavigation
105
- property="scale"
106
- keyframes={gsapKeyframes}
107
- currentPercentage={currentPct}
108
- onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
109
- onAddKeyframe={() => {
110
- if (onCommitAnimatedProperty) {
111
- void onCommitAnimatedProperty(element, "scale", gsapRuntimeValues?.scale ?? 1);
112
- }
113
- }}
114
- onRemoveKeyframe={(pct) => {
115
- const id = idFor("scale");
116
- if (id) onRemoveKeyframe?.(id, pct);
117
- }}
118
- onConvertToKeyframes={() => {
119
- const id = idFor("scale");
120
- if (id) onConvertToKeyframes?.(id);
121
- }}
329
+ <Transform3dField
330
+ ctx={ctx}
331
+ label="RotX"
332
+ prop="rotationX"
333
+ format={(v) => `${v}°`}
334
+ parse={parseDeg}
335
+ defaultValue={0}
122
336
  />
123
- )}
124
- </div>
125
- <MetricField
126
- label="RotX"
127
- value={`${gsapRuntimeValues.rotationX ?? 0}°`}
128
- onCommit={(next) => {
129
- const v = Number.parseFloat(next.replace("°", ""));
130
- if (Number.isFinite(v) && onCommitAnimatedProperty) {
131
- void onCommitAnimatedProperty(element, "rotationX", v);
132
- }
133
- }}
134
- />
135
- <MetricField
136
- label="RotY"
137
- value={`${gsapRuntimeValues.rotationY ?? 0}°`}
138
- onCommit={(next) => {
139
- const v = Number.parseFloat(next.replace("°", ""));
140
- if (Number.isFinite(v) && onCommitAnimatedProperty) {
141
- void onCommitAnimatedProperty(element, "rotationY", v);
142
- }
143
- }}
144
- />
145
- </div>
337
+ <Transform3dField
338
+ ctx={ctx}
339
+ label="RotY"
340
+ prop="rotationY"
341
+ format={(v) => `${v}°`}
342
+ parse={parseDeg}
343
+ defaultValue={0}
344
+ />
345
+ <Transform3dField
346
+ ctx={ctx}
347
+ label="RotZ"
348
+ prop="rotationZ"
349
+ format={(v) => `${v}°`}
350
+ parse={parseDeg}
351
+ defaultValue={0}
352
+ />
353
+ <Transform3dField
354
+ ctx={ctx}
355
+ label="Perspective"
356
+ prop="transformPerspective"
357
+ scrub
358
+ format={formatPxMetricValue}
359
+ parse={parsePxNonNeg}
360
+ defaultValue={0}
361
+ />
362
+ </div>
363
+ </>
364
+ )}
146
365
  </div>
147
366
  );
148
367
  }