@hyperframes/studio 0.6.88 → 0.6.90

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 (49) hide show
  1. package/dist/assets/index-BKuDHMYl.js +146 -0
  2. package/dist/assets/index-D2NkPomd.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +33 -193
  6. package/src/components/StudioLeftSidebar.tsx +6 -0
  7. package/src/components/StudioRightPanel.tsx +8 -0
  8. package/src/components/TimelineToolbar.tsx +54 -31
  9. package/src/components/editor/AnimationCard.tsx +15 -3
  10. package/src/components/editor/DomEditOverlay.test.ts +34 -1
  11. package/src/components/editor/FileTree.tsx +5 -1
  12. package/src/components/editor/FileTreeNodes.tsx +17 -3
  13. package/src/components/editor/LayersPanel.tsx +19 -4
  14. package/src/components/editor/PropertyPanel.tsx +82 -170
  15. package/src/components/editor/domEditOverlayStartGesture.ts +1 -0
  16. package/src/components/editor/gsapAnimatesProperty.ts +52 -0
  17. package/src/components/editor/manualEditsDom.ts +11 -57
  18. package/src/components/editor/manualOffsetDrag.test.ts +18 -1
  19. package/src/components/editor/manualOffsetDrag.ts +16 -10
  20. package/src/components/editor/propertyPanel3dTransform.tsx +133 -0
  21. package/src/components/editor/propertyPanelHelpers.ts +76 -0
  22. package/src/components/editor/propertyPanelStyleSections.tsx +1 -9
  23. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  24. package/src/components/editor/useLayerDrag.ts +6 -3
  25. package/src/components/renders/RenderQueueItem.tsx +47 -46
  26. package/src/components/sidebar/CompositionsTab.tsx +15 -2
  27. package/src/components/sidebar/LeftSidebar.tsx +11 -0
  28. package/src/hooks/gsapDragCommit.ts +294 -0
  29. package/src/hooks/gsapKeyframeCacheHelpers.ts +88 -0
  30. package/src/hooks/gsapRuntimeBridge.ts +49 -402
  31. package/src/hooks/gsapRuntimeReaders.ts +201 -0
  32. package/src/hooks/timelineEditingHelpers.ts +148 -0
  33. package/src/hooks/useAnimatedPropertyCommit.ts +54 -12
  34. package/src/hooks/useBlockHandlers.ts +150 -0
  35. package/src/hooks/useClipboard.ts +1 -10
  36. package/src/hooks/useDomEditPreviewSync.ts +126 -0
  37. package/src/hooks/useDomEditSession.ts +11 -79
  38. package/src/hooks/useGestureCommit.ts +166 -0
  39. package/src/hooks/useGestureRecording.ts +271 -169
  40. package/src/hooks/useGsapScriptCommits.ts +7 -80
  41. package/src/hooks/useLintModal.ts +97 -25
  42. package/src/hooks/useTimelineEditing.ts +10 -132
  43. package/src/player/components/TimelineCanvas.tsx +24 -7
  44. package/src/player/components/useTimelinePlayhead.ts +2 -1
  45. package/src/player/store/playerStore.ts +12 -0
  46. package/src/utils/gsapSoftReload.ts +18 -1
  47. package/src/utils/studioUrlState.test.ts +9 -0
  48. package/dist/assets/index-B9_ctmee.js +0 -143
  49. package/dist/assets/index-CGlIm_-E.css +0 -1
@@ -61,6 +61,7 @@ vi.mock("./useDomEditOverlayRects", async () => {
61
61
  groupOverlayItems,
62
62
  groupOverlayItemsRef,
63
63
  setGroupOverlayItems,
64
+ childRects: [],
64
65
  };
65
66
  },
66
67
  };
@@ -96,7 +97,29 @@ describe("focusDomEditOverlayElement", () => {
96
97
  });
97
98
 
98
99
  describe("DomEditOverlay", () => {
99
- it("renders selected bounds right after clicking a movable selection", () => {
100
+ it("renders selected bounds right after clicking a movable selection", async () => {
101
+ // The overlay's compRect updates via a RAF loop reading iframe + overlay
102
+ // getBoundingClientRect. happy-dom returns all zeros for newly-created
103
+ // elements with no layout, so without stubs the RAF early-returns
104
+ // (iRect.width <= 0) and compRect.width stays 0 — gating the selection
105
+ // box (and other bounded UI) behind `compRect.width > 0` (added in the
106
+ // keyframes PR a468550f). Stub element-level getBoundingClientRect for
107
+ // the test so the RAF compRect update produces a real width.
108
+ const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
109
+ Element.prototype.getBoundingClientRect = function (): DOMRect {
110
+ return {
111
+ left: 0,
112
+ top: 0,
113
+ right: 800,
114
+ bottom: 450,
115
+ width: 800,
116
+ height: 450,
117
+ x: 0,
118
+ y: 0,
119
+ toJSON: () => ({}),
120
+ };
121
+ };
122
+
100
123
  const host = document.createElement("div");
101
124
  document.body.append(host);
102
125
  const root = createRoot(host);
@@ -162,6 +185,15 @@ describe("DomEditOverlay", () => {
162
185
  root.render(React.createElement(Harness));
163
186
  });
164
187
 
188
+ // Flush the mount's RAF tick so the compRect update lands before the
189
+ // pointer-down. Two animation-frame ticks: the first scheduled by
190
+ // useMountEffect's update(), the second by update()'s tail recursion.
191
+ await act(async () => {
192
+ await new Promise<void>((resolve) => {
193
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
194
+ });
195
+ });
196
+
165
197
  const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement;
166
198
  expect(overlay).toBeTruthy();
167
199
 
@@ -183,6 +215,7 @@ describe("DomEditOverlay", () => {
183
215
  root.unmount();
184
216
  });
185
217
  HTMLDivElement.prototype.setPointerCapture = originalPointerCapture;
218
+ Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
186
219
  host.remove();
187
220
  });
188
221
  });
@@ -15,7 +15,7 @@ import {
15
15
 
16
16
  // ── Types ──
17
17
 
18
- export interface FileTreeProps {
18
+ interface FileTreeProps {
19
19
  files: string[];
20
20
  activeFile: string | null;
21
21
  onSelectFile: (path: string) => void;
@@ -26,6 +26,7 @@ export interface FileTreeProps {
26
26
  onDuplicateFile?: (path: string) => void;
27
27
  onMoveFile?: (oldPath: string, newPath: string) => void;
28
28
  onImportFiles?: (files: FileList, dir?: string) => void;
29
+ lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
29
30
  }
30
31
 
31
32
  // ── Main FileTree Component ──
@@ -41,6 +42,7 @@ export const FileTree = memo(function FileTree({
41
42
  onDuplicateFile,
42
43
  onMoveFile,
43
44
  onImportFiles,
45
+ lintFindingsByFile,
44
46
  }: FileTreeProps) {
45
47
  const tree = useMemo(() => buildTree(files), [files]);
46
48
  const children = useMemo(() => sortChildren(tree.children), [tree]);
@@ -283,6 +285,7 @@ export const FileTree = memo(function FileTree({
283
285
  onContextMenu={handleContextMenu}
284
286
  inlineInput={inlineInput}
285
287
  onDragStart={handleDragStart}
288
+ lintInfo={lintFindingsByFile?.get(child.fullPath)}
286
289
  />
287
290
  ) : (
288
291
  <TreeFolder
@@ -299,6 +302,7 @@ export const FileTree = memo(function FileTree({
299
302
  onDrop={handleDrop}
300
303
  onDragLeave={handleDragLeave}
301
304
  dragOverFolder={dragOverFolder}
305
+ lintFindingsByFile={lintFindingsByFile}
302
306
  />
303
307
  ),
304
308
  )}
@@ -18,8 +18,7 @@ import {
18
18
  type InlineInputState,
19
19
  } from "./FileTreeIcons";
20
20
 
21
- // Re-export for FileTree.tsx consumers
22
- export type { TreeNode, ContextMenuState, InlineInputState };
21
+ export type { ContextMenuState, InlineInputState };
23
22
  export { buildTree, sortChildren, isActiveInSubtree } from "./FileTreeIcons";
24
23
 
25
24
  const SZ_ICON = 14;
@@ -300,6 +299,7 @@ export const TreeFile = memo(function TreeFile({
300
299
  onContextMenu,
301
300
  inlineInput,
302
301
  onDragStart,
302
+ lintInfo,
303
303
  }: {
304
304
  node: TreeNode;
305
305
  depth: number;
@@ -308,6 +308,7 @@ export const TreeFile = memo(function TreeFile({
308
308
  onContextMenu: (e: React.MouseEvent, path: string, isFolder: boolean) => void;
309
309
  inlineInput: InlineInputState | null;
310
310
  onDragStart: (e: React.DragEvent, path: string) => void;
311
+ lintInfo?: { count: number; messages: string[] };
311
312
  }) {
312
313
  const isActive = node.fullPath === activeFile;
313
314
  const isRenaming = inlineInput?.mode === "rename" && inlineInput.originalPath === node.fullPath;
@@ -345,7 +346,15 @@ export const TreeFile = memo(function TreeFile({
345
346
  style={{ paddingLeft: `${8 + depth * 12 + 14}px` }}
346
347
  >
347
348
  <FileIcon path={node.name} />
348
- <span className="truncate">{node.name}</span>
349
+ <span className="truncate flex-1">{node.name}</span>
350
+ {lintInfo && lintInfo.count > 0 && (
351
+ <span
352
+ className="flex-shrink-0 min-w-[16px] rounded-full bg-amber-500/20 px-1 text-[8px] font-bold text-amber-400 text-center mr-1"
353
+ title={lintInfo.messages.join("\n")}
354
+ >
355
+ {lintInfo.count}
356
+ </span>
357
+ )}
349
358
  </button>
350
359
  );
351
360
  });
@@ -365,6 +374,7 @@ export const TreeFolder = memo(function TreeFolder({
365
374
  onDrop,
366
375
  onDragLeave,
367
376
  dragOverFolder,
377
+ lintFindingsByFile,
368
378
  }: {
369
379
  node: TreeNode;
370
380
  depth: number;
@@ -378,6 +388,7 @@ export const TreeFolder = memo(function TreeFolder({
378
388
  onDrop: (e: React.DragEvent, folderPath: string) => void;
379
389
  onDragLeave: () => void;
380
390
  dragOverFolder: string | null;
391
+ lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
381
392
  }) {
382
393
  const [isOpen, setIsOpen] = useState(defaultOpen);
383
394
  const toggle = useCallback(() => setIsOpen((v) => !v), []);
@@ -459,6 +470,7 @@ export const TreeFolder = memo(function TreeFolder({
459
470
  onContextMenu={onContextMenu}
460
471
  inlineInput={inlineInput}
461
472
  onDragStart={onDragStart}
473
+ lintInfo={lintFindingsByFile?.get(child.fullPath)}
462
474
  />
463
475
  ) : child.children.size > 0 ? (
464
476
  <TreeFolder
@@ -475,6 +487,7 @@ export const TreeFolder = memo(function TreeFolder({
475
487
  onDrop={onDrop}
476
488
  onDragLeave={onDragLeave}
477
489
  dragOverFolder={dragOverFolder}
490
+ lintFindingsByFile={lintFindingsByFile}
478
491
  />
479
492
  ) : (
480
493
  <TreeFile
@@ -486,6 +499,7 @@ export const TreeFolder = memo(function TreeFolder({
486
499
  onContextMenu={onContextMenu}
487
500
  inlineInput={inlineInput}
488
501
  onDragStart={onDragStart}
502
+ lintInfo={lintFindingsByFile?.get(child.fullPath)}
489
503
  />
490
504
  ),
491
505
  )}
@@ -122,13 +122,28 @@ export const LayersPanel = memo(function LayersPanel() {
122
122
  }, [compositionLoading, collectLayers]);
123
123
 
124
124
  const resolveSelection = useCallback(
125
- (layer: DomEditLayerItem) =>
126
- resolveDomEditSelection(layer.element, {
125
+ (layer: DomEditLayerItem) => {
126
+ // Re-find the element from the live DOM — layer.element may be stale
127
+ // after soft reload (which replaces scripts without reloading the iframe).
128
+ let el = layer.element;
129
+ if (!el.isConnected) {
130
+ const iframe = previewIframeRef.current;
131
+ const doc = iframe?.contentDocument;
132
+ if (doc) {
133
+ const found =
134
+ (layer.id ? doc.getElementById(layer.id) : null) ??
135
+ (layer.hfId ? doc.querySelector(`[data-hf-id="${layer.hfId}"]`) : null) ??
136
+ doc.getElementById(layer.key);
137
+ if (found instanceof HTMLElement) el = found;
138
+ }
139
+ }
140
+ return resolveDomEditSelection(el, {
127
141
  activeCompositionPath: activeCompPath,
128
142
  isMasterView,
129
143
  preferClipAncestor: false,
130
- }),
131
- [activeCompPath, isMasterView],
144
+ });
145
+ },
146
+ [activeCompPath, isMasterView, previewIframeRef],
132
147
  );
133
148
 
134
149
  const seekToLayer = useCallback(
@@ -1,4 +1,4 @@
1
- import { memo, useRef, useState } from "react";
1
+ import { memo, useEffect, useRef, useState } from "react";
2
2
  import { Eye, Layers, Move, X } from "../../icons/SystemIcons";
3
3
  import { useStudioContext } from "../../contexts/StudioContext";
4
4
  import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
@@ -7,14 +7,17 @@ import {
7
7
  formatPxMetricValue,
8
8
  parsePxMetricValue,
9
9
  RESPONSIVE_GRID,
10
+ readGsapRuntimeValuesForPanel,
11
+ readGsapBorderRadiusForPanel,
10
12
  } from "./propertyPanelHelpers";
11
13
  import { MetricField, Section } from "./propertyPanelPrimitives";
12
14
  import { isMediaElement, MediaSection } from "./propertyPanelMediaSection";
13
15
  import { TextSection, StyleSections } from "./propertyPanelSections";
14
16
  import { GsapAnimationSection } from "./GsapAnimationSection";
17
+ import { PropertyPanel3dTransform } from "./propertyPanel3dTransform";
15
18
  import { KeyframeNavigation } from "./KeyframeNavigation";
16
19
  import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
17
- import { usePlayerStore } from "../../player";
20
+ import { usePlayerStore, liveTime } from "../../player";
18
21
  import { TimingSection } from "./propertyPanelTimingSection";
19
22
  import { type PropertyPanelProps } from "./propertyPanelHelpers";
20
23
 
@@ -85,7 +88,29 @@ export const PropertyPanel = memo(function PropertyPanel({
85
88
  const { showToast } = useStudioContext();
86
89
  const [clipboardCopied, setClipboardCopied] = useState(false);
87
90
  const clipboardTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
88
- const currentTime = usePlayerStore((s) => s.currentTime);
91
+ const storeTime = usePlayerStore((s) => s.currentTime);
92
+ const isPlaying = usePlayerStore((s) => s.isPlaying);
93
+ const liveTimeRef = useRef(storeTime);
94
+ const [, forceRender] = useState(0);
95
+ useEffect(() => {
96
+ if (!isPlaying) return;
97
+ let timerId: ReturnType<typeof setTimeout> | 0 = 0;
98
+ const unsub = liveTime.subscribe((t) => {
99
+ liveTimeRef.current = t;
100
+ if (!timerId)
101
+ timerId = setTimeout(() => {
102
+ timerId = 0;
103
+ forceRender((v) => v + 1);
104
+ }, 33);
105
+ });
106
+ return () => {
107
+ unsub();
108
+ if (timerId) clearTimeout(timerId);
109
+ };
110
+ }, [isPlaying]);
111
+ const currentTime = isPlaying ? liveTimeRef.current : storeTime;
112
+ const cacheElementKey = element?.id ?? element?.selector ?? "";
113
+ const cacheEntry = usePlayerStore((s) => s.keyframeCache.get(cacheElementKey));
89
114
 
90
115
  if (!element) {
91
116
  return (
@@ -137,7 +162,7 @@ export const PropertyPanel = memo(function PropertyPanel({
137
162
  const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
138
163
  const parsed = parsePxMetricValue(nextValue);
139
164
  if (parsed == null) return;
140
- if (onCommitAnimatedProperty && (gsapAnimId || gsapAnimations.length > 0)) {
165
+ if (onCommitAnimatedProperty && hasGsapAnimation) {
141
166
  void onCommitAnimatedProperty(element, axis, parsed);
142
167
  return;
143
168
  }
@@ -146,6 +171,10 @@ export const PropertyPanel = memo(function PropertyPanel({
146
171
  onAddKeyframe(gsapAnimId, pct, axis, parsed);
147
172
  return;
148
173
  }
174
+ if (hasGsapAnimation) {
175
+ showToast?.("Cannot edit position — animation callbacks not available");
176
+ return;
177
+ }
149
178
  const current = readStudioPathOffset(element.element);
150
179
  onSetManualOffset(element, {
151
180
  x: axis === "x" ? parsed : current.x,
@@ -157,6 +186,14 @@ export const PropertyPanel = memo(function PropertyPanel({
157
186
  const commitManualSize = (axis: "width" | "height", nextValue: string) => {
158
187
  const parsed = parsePxMetricValue(nextValue);
159
188
  if (parsed == null || parsed <= 0) return;
189
+ if (onCommitAnimatedProperty && hasGsapAnimation) {
190
+ void onCommitAnimatedProperty(element, axis, parsed);
191
+ return;
192
+ }
193
+ if (hasGsapAnimation) {
194
+ showToast?.("Cannot edit size — animation callbacks not available");
195
+ return;
196
+ }
160
197
  const current = readStudioBoxSize(element.element);
161
198
  const width =
162
199
  current.width > 0
@@ -183,74 +220,27 @@ export const PropertyPanel = memo(function PropertyPanel({
183
220
  const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0;
184
221
  const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0;
185
222
 
186
- const gsapKeyframes = gsapAnimations?.find((a) => a.keyframes)?.keyframes?.keyframes ?? null;
187
- const gsapAnimId =
188
- gsapAnimations?.find((a) => a.keyframes)?.id ?? gsapAnimations?.[0]?.id ?? null;
223
+ const gsapKfAnim = gsapAnimations?.find((a) => a.keyframes) ?? null;
224
+ const gsapKeyframes = gsapKfAnim?.keyframes?.keyframes ?? null;
225
+ const gsapAnimId = gsapKfAnim?.id ?? gsapAnimations?.[0]?.id ?? null;
226
+ const hasGsapAnimation = !!(gsapAnimId || gsapAnimations.length > 0);
227
+ const navKeyframes = cacheEntry?.keyframes ?? gsapKeyframes;
228
+ const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration);
189
229
 
190
230
  // Read ALL GSAP-interpolated values at the current seek time.
191
- // Discovers animated properties from the animation's keyframes/tween vars.
192
- const gsapRuntimeValues: Record<string, number> | null = (() => {
193
- if (!gsapAnimId || gsapAnimations.length === 0) return null;
194
- const iframe = previewIframeRef?.current;
195
- if (!iframe?.contentWindow) return null;
196
- const selector = element.id ? `#${element.id}` : element.selector;
197
- if (!selector) return null;
198
- try {
199
- const gsap = (
200
- iframe.contentWindow as unknown as {
201
- gsap?: { getProperty: (el: Element, prop: string) => number | string };
202
- }
203
- ).gsap;
204
- if (!gsap?.getProperty) return null;
205
- const el = iframe.contentDocument?.querySelector(selector);
206
- if (!el) return null;
207
- const propKeys = new Set<string>();
208
- for (const anim of gsapAnimations) {
209
- if (anim.keyframes) {
210
- for (const kf of anim.keyframes.keyframes) {
211
- for (const p of Object.keys(kf.properties)) propKeys.add(p);
212
- }
213
- }
214
- for (const p of Object.keys(anim.properties)) propKeys.add(p);
215
- }
216
- const result: Record<string, number> = {};
217
- for (const prop of propKeys) {
218
- const v = Number(gsap.getProperty(el, prop));
219
- if (Number.isFinite(v)) result[prop] = Math.round(v * 100) / 100;
220
- }
221
- return Object.keys(result).length > 0 ? result : null;
222
- } catch {
223
- return null;
224
- }
225
- })();
231
+ const gsapRuntimeValues = readGsapRuntimeValuesForPanel(
232
+ gsapAnimId,
233
+ gsapAnimations,
234
+ element,
235
+ previewIframeRef ?? { current: null },
236
+ );
226
237
 
227
- const gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null = (() => {
228
- if (!gsapRuntimeValues || !("borderRadius" in gsapRuntimeValues)) {
229
- const hasBRProp = gsapAnimations.some(
230
- (a) =>
231
- "borderRadius" in a.properties ||
232
- a.keyframes?.keyframes.some((kf) => "borderRadius" in kf.properties),
233
- );
234
- if (!hasBRProp) return null;
235
- }
236
- const iframe = previewIframeRef?.current;
237
- const selector = element.id ? `#${element.id}` : element.selector;
238
- if (!iframe?.contentDocument || !selector) return null;
239
- try {
240
- const el = iframe.contentDocument.querySelector(selector);
241
- if (!el) return null;
242
- const cs = iframe.contentWindow!.getComputedStyle(el);
243
- const parse = (v: string) => Number.parseFloat(v) || 0;
244
- return {
245
- tl: parse(cs.borderTopLeftRadius),
246
- tr: parse(cs.borderTopRightRadius),
247
- br: parse(cs.borderBottomRightRadius),
248
- bl: parse(cs.borderBottomLeftRadius),
249
- };
250
- } catch {
251
- return null;
252
- }
253
- })();
238
+ const gsapBorderRadius = readGsapBorderRadiusForPanel(
239
+ gsapRuntimeValues,
240
+ gsapAnimations,
241
+ element,
242
+ previewIframeRef ?? { current: null },
243
+ );
254
244
 
255
245
  const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
256
246
  const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
@@ -398,9 +388,9 @@ export const PropertyPanel = memo(function PropertyPanel({
398
388
  {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
399
389
  <KeyframeNavigation
400
390
  property="x"
401
- keyframes={gsapKeyframes}
391
+ keyframes={navKeyframes}
402
392
  currentPercentage={currentPct}
403
- onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
393
+ onSeek={seekFromKfPct}
404
394
  onAddKeyframe={() =>
405
395
  onCommitAnimatedProperty &&
406
396
  void onCommitAnimatedProperty(element, "x", displayX)
@@ -423,9 +413,9 @@ export const PropertyPanel = memo(function PropertyPanel({
423
413
  {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
424
414
  <KeyframeNavigation
425
415
  property="y"
426
- keyframes={gsapKeyframes}
416
+ keyframes={navKeyframes}
427
417
  currentPercentage={currentPct}
428
- onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
418
+ onSeek={seekFromKfPct}
429
419
  onAddKeyframe={() =>
430
420
  onCommitAnimatedProperty &&
431
421
  void onCommitAnimatedProperty(element, "y", displayY)
@@ -448,9 +438,9 @@ export const PropertyPanel = memo(function PropertyPanel({
448
438
  {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
449
439
  <KeyframeNavigation
450
440
  property="width"
451
- keyframes={gsapKeyframes}
441
+ keyframes={navKeyframes}
452
442
  currentPercentage={currentPct}
453
- onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
443
+ onSeek={seekFromKfPct}
454
444
  onAddKeyframe={() =>
455
445
  onCommitAnimatedProperty &&
456
446
  void onCommitAnimatedProperty(element, "width", displayW)
@@ -473,9 +463,9 @@ export const PropertyPanel = memo(function PropertyPanel({
473
463
  {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
474
464
  <KeyframeNavigation
475
465
  property="height"
476
- keyframes={gsapKeyframes}
466
+ keyframes={navKeyframes}
477
467
  currentPercentage={currentPct}
478
- onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
468
+ onSeek={seekFromKfPct}
479
469
  onAddKeyframe={() =>
480
470
  onCommitAnimatedProperty &&
481
471
  void onCommitAnimatedProperty(element, "height", displayH)
@@ -496,9 +486,9 @@ export const PropertyPanel = memo(function PropertyPanel({
496
486
  {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
497
487
  <KeyframeNavigation
498
488
  property="rotation"
499
- keyframes={gsapKeyframes}
489
+ keyframes={navKeyframes}
500
490
  currentPercentage={currentPct}
501
- onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
491
+ onSeek={seekFromKfPct}
502
492
  onAddKeyframe={() =>
503
493
  onCommitAnimatedProperty &&
504
494
  void onCommitAnimatedProperty(element, "rotation", displayR)
@@ -510,97 +500,19 @@ export const PropertyPanel = memo(function PropertyPanel({
510
500
  </div>
511
501
  </div>
512
502
  {gsapRuntimeValues && (
513
- <div className="mt-3 border-t border-neutral-800/40 pt-3">
514
- <div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
515
- 3D Transform
516
- </div>
517
- <div className={RESPONSIVE_GRID}>
518
- <div className="flex items-center gap-1">
519
- <div className="flex-1">
520
- <MetricField
521
- label="Z"
522
- value={formatPxMetricValue(gsapRuntimeValues.z ?? 0)}
523
- scrub
524
- onCommit={(next) => {
525
- const v = parsePxMetricValue(next);
526
- if (v != null && onCommitAnimatedProperty) {
527
- void onCommitAnimatedProperty(element, "z", v);
528
- }
529
- }}
530
- />
531
- </div>
532
- {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
533
- <KeyframeNavigation
534
- property="z"
535
- keyframes={gsapKeyframes}
536
- currentPercentage={currentPct}
537
- onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
538
- onAddKeyframe={() => {
539
- if (onCommitAnimatedProperty) {
540
- void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0);
541
- }
542
- }}
543
- onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
544
- onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
545
- />
546
- )}
547
- </div>
548
- <div className="flex items-center gap-1">
549
- <div className="flex-1">
550
- <MetricField
551
- label="Scale"
552
- value={String(gsapRuntimeValues.scale ?? 1)}
553
- scrub
554
- onCommit={(next) => {
555
- const v = Number.parseFloat(next);
556
- if (Number.isFinite(v) && onCommitAnimatedProperty) {
557
- void onCommitAnimatedProperty(element, "scale", v);
558
- }
559
- }}
560
- />
561
- </div>
562
- {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
563
- <KeyframeNavigation
564
- property="scale"
565
- keyframes={gsapKeyframes}
566
- currentPercentage={currentPct}
567
- onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
568
- onAddKeyframe={() => {
569
- if (onCommitAnimatedProperty) {
570
- void onCommitAnimatedProperty(
571
- element,
572
- "scale",
573
- gsapRuntimeValues?.scale ?? 1,
574
- );
575
- }
576
- }}
577
- onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
578
- onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
579
- />
580
- )}
581
- </div>
582
- <MetricField
583
- label="RotX"
584
- value={`${gsapRuntimeValues.rotationX ?? 0}°`}
585
- onCommit={(next) => {
586
- const v = Number.parseFloat(next.replace("°", ""));
587
- if (Number.isFinite(v) && onCommitAnimatedProperty) {
588
- void onCommitAnimatedProperty(element, "rotationX", v);
589
- }
590
- }}
591
- />
592
- <MetricField
593
- label="RotY"
594
- value={`${gsapRuntimeValues.rotationY ?? 0}°`}
595
- onCommit={(next) => {
596
- const v = Number.parseFloat(next.replace("°", ""));
597
- if (Number.isFinite(v) && onCommitAnimatedProperty) {
598
- void onCommitAnimatedProperty(element, "rotationY", v);
599
- }
600
- }}
601
- />
602
- </div>
603
- </div>
503
+ <PropertyPanel3dTransform
504
+ gsapRuntimeValues={gsapRuntimeValues}
505
+ gsapAnimId={gsapAnimId}
506
+ gsapKeyframes={navKeyframes}
507
+ currentPct={currentPct}
508
+ elStart={elStart}
509
+ elDuration={elDuration}
510
+ element={element}
511
+ onCommitAnimatedProperty={onCommitAnimatedProperty}
512
+ onSeekToTime={onSeekToTime}
513
+ onRemoveKeyframe={onRemoveKeyframe}
514
+ onConvertToKeyframes={onConvertToKeyframes}
515
+ />
604
516
  )}
605
517
  <div className="mt-3">
606
518
  <div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
@@ -121,6 +121,7 @@ export function startGesture(
121
121
 
122
122
  if (kind === "drag") {
123
123
  opts.onManualDragStartRef.current?.();
124
+ opts.rafPausedRef.current = true;
124
125
  const result = createManualOffsetDragMember({
125
126
  key: selectionCacheKey(sel),
126
127
  selection: sel,
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Checks whether GSAP actively animates one or more CSS/GSAP properties on
3
+ * the given element by inspecting all registered `__timelines`.
4
+ */
5
+ export function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean {
6
+ const win = el.ownerDocument.defaultView as
7
+ | (Window & {
8
+ __timelines?: Record<
9
+ string,
10
+ {
11
+ getChildren?: (
12
+ deep: boolean,
13
+ ) => Array<{ targets?: () => Element[]; vars?: Record<string, unknown> }>;
14
+ }
15
+ >;
16
+ })
17
+ | null;
18
+ if (!win?.__timelines) return false;
19
+ const propSet = new Set(props);
20
+ for (const tl of Object.values(win.__timelines)) {
21
+ if (!tl?.getChildren) continue;
22
+ try {
23
+ for (const child of tl.getChildren(true)) {
24
+ if (!child.targets || !child.vars) continue;
25
+ let targetsEl = false;
26
+ for (const t of child.targets()) {
27
+ if (t === el || (el.id && t.id === el.id)) {
28
+ targetsEl = true;
29
+ break;
30
+ }
31
+ }
32
+ if (!targetsEl) continue;
33
+ const vars = child.vars;
34
+ for (const p of propSet) {
35
+ if (p in vars) return true;
36
+ }
37
+ if (vars.keyframes && typeof vars.keyframes === "object") {
38
+ for (const kfVal of Object.values(vars.keyframes as Record<string, unknown>)) {
39
+ if (kfVal && typeof kfVal === "object") {
40
+ for (const p of propSet) {
41
+ if (p in (kfVal as Record<string, unknown>)) return true;
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }
47
+ } catch {
48
+ /* */
49
+ }
50
+ }
51
+ return false;
52
+ }