@hyperframes/studio 0.7.46 → 0.7.48

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 (110) hide show
  1. package/dist/assets/{index-DO2MAFSG.js → index-CWHON2kh.js} +1 -1
  2. package/dist/assets/index-DX02Q1Yl.js +423 -0
  3. package/dist/assets/index-Dq7FEg0K.css +1 -0
  4. package/dist/assets/{index-BGVoOXdg.js → index-RTqYPUN7.js} +1 -1
  5. package/dist/index.d.ts +96 -29
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +8443 -4305
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +26 -32
  11. package/src/components/DesignPanelPromoteProvider.tsx +45 -0
  12. package/src/components/PanelTabButton.tsx +31 -0
  13. package/src/components/StudioPreviewArea.tsx +75 -3
  14. package/src/components/StudioRightPanel.tsx +111 -113
  15. package/src/components/editor/LayersPanel.tsx +2 -22
  16. package/src/components/editor/PromotableControl.tsx +105 -0
  17. package/src/components/editor/propertyPanelSections.tsx +42 -21
  18. package/src/components/panels/VariablesBindElement.tsx +266 -0
  19. package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
  20. package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
  21. package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
  22. package/src/components/panels/VariablesPanel.tsx +554 -0
  23. package/src/components/panels/VariablesRowAction.tsx +25 -0
  24. package/src/components/panels/VariablesValueControls.tsx +215 -0
  25. package/src/components/renders/useRenderQueue.ts +12 -0
  26. package/src/contexts/TimelineEditContext.tsx +7 -2
  27. package/src/contexts/VariablePromoteContext.tsx +138 -0
  28. package/src/contexts/variablePromoteHelpers.test.ts +124 -0
  29. package/src/contexts/variablePromoteHelpers.ts +53 -0
  30. package/src/contexts/variablePromoteIntegration.test.ts +80 -0
  31. package/src/hooks/previewVariablesStore.ts +34 -0
  32. package/src/hooks/timelineEditingHelpers.test.ts +104 -0
  33. package/src/hooks/timelineEditingHelpers.ts +386 -10
  34. package/src/hooks/useDomEditWiring.ts +9 -7
  35. package/src/hooks/useDomSelection.test.ts +36 -2
  36. package/src/hooks/useDomSelection.ts +56 -75
  37. package/src/hooks/useElementLifecycleOps.ts +57 -17
  38. package/src/hooks/usePreviewDocumentVersion.ts +27 -0
  39. package/src/hooks/useProjectCompositionVariables.ts +131 -0
  40. package/src/hooks/useStudioContextValue.ts +6 -1
  41. package/src/hooks/useStudioSdkSessions.ts +37 -0
  42. package/src/hooks/useTimelineEditing.test.tsx +963 -0
  43. package/src/hooks/useTimelineEditing.ts +122 -100
  44. package/src/hooks/useTimelineEditingTypes.ts +15 -0
  45. package/src/hooks/useTimelineGroupEditing.ts +370 -0
  46. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
  47. package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
  48. package/src/hooks/useVariablesPersist.ts +61 -0
  49. package/src/player/components/BeatStrip.tsx +19 -3
  50. package/src/player/components/Player.tsx +7 -1
  51. package/src/player/components/Timeline.test.ts +38 -0
  52. package/src/player/components/Timeline.tsx +102 -80
  53. package/src/player/components/TimelineCanvas.tsx +340 -296
  54. package/src/player/components/TimelineClip.test.tsx +11 -0
  55. package/src/player/components/TimelineClipDiamonds.tsx +40 -9
  56. package/src/player/components/TimelineDragGhost.tsx +59 -0
  57. package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
  58. package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
  59. package/src/player/components/TimelineLayerGutter.tsx +61 -0
  60. package/src/player/components/TimelineRuler.tsx +4 -2
  61. package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
  62. package/src/player/components/timelineCallbacks.ts +20 -2
  63. package/src/player/components/timelineClipDragPreview.ts +52 -0
  64. package/src/player/components/timelineDragDrop.ts +19 -3
  65. package/src/player/components/timelineDropIndicator.test.ts +48 -0
  66. package/src/player/components/timelineDropIndicator.ts +33 -0
  67. package/src/player/components/timelineEditing.test.ts +251 -1
  68. package/src/player/components/timelineEditing.ts +178 -19
  69. package/src/player/components/timelineGroupEditing.ts +159 -0
  70. package/src/player/components/timelineLayerDrag.test.ts +200 -0
  71. package/src/player/components/timelineLayerDrag.ts +383 -0
  72. package/src/player/components/timelineLayout.test.ts +34 -0
  73. package/src/player/components/timelineLayout.ts +47 -0
  74. package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
  75. package/src/player/components/timelineSnapTargets.test.ts +144 -0
  76. package/src/player/components/timelineSnapTargets.ts +164 -0
  77. package/src/player/components/timelineStacking.ts +62 -0
  78. package/src/player/components/timelineTrackOrder.test.ts +144 -0
  79. package/src/player/components/timelineTrackOrder.ts +173 -0
  80. package/src/player/components/useTimelineActiveClips.test.ts +20 -0
  81. package/src/player/components/useTimelineActiveClips.ts +15 -4
  82. package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
  83. package/src/player/components/useTimelineClipDrag.ts +172 -126
  84. package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
  85. package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
  86. package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
  87. package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
  88. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  89. package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -0
  91. package/src/player/lib/layerOrdering.test.ts +77 -0
  92. package/src/player/lib/layerOrdering.ts +98 -0
  93. package/src/player/lib/playbackTypes.ts +3 -0
  94. package/src/player/lib/timelineDOM.test.ts +103 -0
  95. package/src/player/lib/timelineDOM.ts +80 -0
  96. package/src/player/lib/timelineElementHelpers.ts +1 -1
  97. package/src/player/store/playerStore.test.ts +92 -0
  98. package/src/player/store/playerStore.ts +69 -9
  99. package/src/utils/blockInstaller.ts +2 -14
  100. package/src/utils/editHistory.test.ts +61 -0
  101. package/src/utils/editHistory.ts +7 -1
  102. package/src/utils/rootDuration.test.ts +34 -0
  103. package/src/utils/rootDuration.ts +17 -0
  104. package/src/utils/sdkCutover.ts +45 -0
  105. package/src/utils/studioHelpers.test.ts +31 -0
  106. package/src/utils/studioHelpers.ts +29 -1
  107. package/src/utils/studioUrlState.test.ts +29 -0
  108. package/src/utils/studioUrlState.ts +16 -1
  109. package/dist/assets/index-BsLQ2qGn.js +0 -420
  110. package/dist/assets/index-DoVLXke0.css +0 -1
@@ -28,13 +28,17 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({
28
28
  beatTimes: number[] | undefined;
29
29
  beatStrengths: number[] | undefined;
30
30
  pps: number;
31
- /** Beat time a dragged clip will snap to drawn as a bright neon line. */
31
+ /** Snap guide time drawn as a bright line even when it is not a beat. */
32
32
  highlightTime?: number | null;
33
33
  }) {
34
- if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
34
+ const visibleBeatTimes = beatTimes && !beatsTooDense(beatTimes, pps) ? beatTimes : null;
35
+ const highlightIsBeat =
36
+ highlightTime != null &&
37
+ visibleBeatTimes?.some((t) => Math.abs(t - highlightTime) < 1e-3) === true;
38
+ if (!visibleBeatTimes && highlightTime == null) return null;
35
39
  return (
36
40
  <div className="absolute inset-0 pointer-events-none" style={{ zIndex: 0 }}>
37
- {beatTimes.map((t, i) => {
41
+ {visibleBeatTimes?.map((t, i) => {
38
42
  const isHighlight = highlightTime != null && Math.abs(t - highlightTime) < 1e-3;
39
43
  const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
40
44
  const opacity = isHighlight ? 1 : 0.06 + strength * 0.16;
@@ -52,6 +56,18 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({
52
56
  />
53
57
  );
54
58
  })}
59
+ {highlightTime != null && !highlightIsBeat && (
60
+ <div
61
+ className="absolute top-0 bottom-0"
62
+ style={{
63
+ left: highlightTime * pps,
64
+ width: 2,
65
+ background: "rgba(34,197,94,1)",
66
+ boxShadow: "0 0 6px rgba(34,197,94,0.9)",
67
+ zIndex: 1,
68
+ }}
69
+ />
70
+ )}
55
71
  </div>
56
72
  );
57
73
  });
@@ -1,6 +1,7 @@
1
1
  import { forwardRef, useEffect, useRef, useState } from "react";
2
2
  import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness";
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
+ import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
4
5
  import { HyperframesLoader } from "../../components/ui";
5
6
  // NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
6
7
  // at module load, which throws under SSR. Defer the import to the mount effect
@@ -154,7 +155,12 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
154
155
 
155
156
  // Create the web component imperatively to avoid JSX custom-element typing.
156
157
  const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
157
- const src = directUrl || `/api/projects/${projectId}/preview`;
158
+ const srcUrl = new URL(
159
+ directUrl || `/api/projects/${projectId}/preview`,
160
+ window.location.origin,
161
+ );
162
+ applyPreviewVariablesToUrl(srcUrl);
163
+ const src = srcUrl.pathname + srcUrl.search;
158
164
  player.setAttribute("shader-capture-scale", "1");
159
165
  player.setAttribute("shader-loading", "player");
160
166
  player.setAttribute("src", src);
@@ -117,6 +117,10 @@ describe("Timeline provider boundary", () => {
117
117
  );
118
118
  });
119
119
 
120
+ // Flush passive effects (ResizeObserver-driven layout) so the gutter row is
121
+ // mounted before we query it.
122
+ act(() => {});
123
+
120
124
  const button = host.querySelector<HTMLButtonElement>('button[aria-label="Show track 0"]');
121
125
  expect(button).not.toBeNull();
122
126
  if (!button) throw new Error("Expected a track visibility toggle");
@@ -199,6 +203,40 @@ describe("Timeline provider boundary", () => {
199
203
  expect(onSeek).not.toHaveBeenCalled();
200
204
  act(() => root.unmount());
201
205
  });
206
+
207
+ it("marks every clip in selectedElementIds as selected", () => {
208
+ const host = document.createElement("div");
209
+ document.body.append(host);
210
+ Object.defineProperty(host, "clientWidth", {
211
+ configurable: true,
212
+ value: 720,
213
+ });
214
+
215
+ usePlayerStore.setState({
216
+ duration: 6,
217
+ timelineReady: true,
218
+ selectedElementId: "clip-2",
219
+ selectedElementIds: new Set(["clip-1", "clip-2"]),
220
+ elements: [
221
+ { id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
222
+ { id: "clip-2", tag: "div", start: 1.5, duration: 1, track: 1 },
223
+ { id: "clip-3", tag: "div", start: 3, duration: 1, track: 2 },
224
+ ],
225
+ });
226
+
227
+ const root = createRoot(host);
228
+ act(() => {
229
+ root.render(React.createElement(Timeline));
230
+ });
231
+
232
+ const selectedClips = host.querySelectorAll(".timeline-clip.is-selected");
233
+ expect(selectedClips).toHaveLength(2);
234
+ expect(host.querySelector('[data-el-id="clip-3"]')?.classList.contains("is-selected")).toBe(
235
+ false,
236
+ );
237
+
238
+ act(() => root.unmount());
239
+ });
202
240
  });
203
241
 
204
242
  describe("generateTicks", () => {
@@ -8,6 +8,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
8
8
  import { EditPopover } from "./EditModal";
9
9
  import { defaultTimelineTheme } from "./timelineTheme";
10
10
  import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
11
+ import { useTimelineMarqueeSelection } from "./useTimelineMarqueeSelection";
11
12
  import { useTimelinePlayhead } from "./useTimelinePlayhead";
12
13
  import { useTimelineActiveClips } from "./useTimelineActiveClips";
13
14
  import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons";
@@ -21,18 +22,22 @@ import {
21
22
  type KeyframeDiamondContextMenuState,
22
23
  } from "./KeyframeDiamondContextMenu";
23
24
  import { useTimelineClipDrag } from "./useTimelineClipDrag";
25
+ import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
24
26
  import { ClipContextMenu } from "./ClipContextMenu";
25
27
  import { TimelineShortcutHint } from "./TimelineShortcutHint";
28
+ import { buildStackingTimelineLayers, insertPreviewTrackOrder } from "./timelineTrackOrder";
29
+ import { getTimelineLayerGroupHeaderTotalHeight } from "./TimelineLayerGroupHeader";
26
30
  import {
27
31
  GUTTER,
28
- generateTicks,
32
+ generateVisibleTicks,
29
33
  getTimelineCanvasHeight,
30
34
  shouldShowTimelineShortcutHint,
35
+ computeTimelineBasisDuration,
36
+ computeTimelineEffectiveDuration,
31
37
  } from "./timelineLayout";
32
38
  import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
33
39
  import type { TimelineProps } from "./TimelineTypes";
34
40
 
35
- // Re-export pure utilities so existing imports from "./Timeline" still resolve.
36
41
  export {
37
42
  generateTicks,
38
43
  formatTimelineTickLabel,
@@ -66,6 +71,10 @@ export const Timeline = memo(function Timeline({
66
71
  const {
67
72
  onMoveElement,
68
73
  onResizeElement,
74
+ onMoveElements,
75
+ onResizeElements,
76
+ onPreviewMoveElements,
77
+ onPreviewResizeElements,
69
78
  onBlockedEditAttempt,
70
79
  onSplitElement,
71
80
  onRazorSplitAll,
@@ -99,14 +108,12 @@ export const Timeline = memo(function Timeline({
99
108
  const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
100
109
 
101
110
  const playheadRef = useRef<HTMLDivElement>(null);
102
- const containerRef = useRef<HTMLDivElement>(null);
103
111
  const scrollRef = useRef<HTMLDivElement>(null);
104
112
  const activeTool = usePlayerStore((s) => s.activeTool);
105
113
  const [hoveredClip, setHoveredClip] = useState<string | null>(null);
106
114
  const isDragging = useRef(false);
107
115
  const [shiftHeld, setShiftHeld] = useState(false);
108
116
  const [razorGuideX, setRazorGuideX] = useState<number | null>(null);
109
-
110
117
  useMountEffect(() => {
111
118
  const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true);
112
119
  const up = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(false);
@@ -132,7 +139,6 @@ export const Timeline = memo(function Timeline({
132
139
  const [viewportWidth, setViewportWidth] = useState(0);
133
140
  const roRef = useRef<ResizeObserver | null>(null);
134
141
  const shortcutHintRafRef = useRef(0);
135
-
136
142
  const syncShortcutHintVisibility = useCallback(() => {
137
143
  const scroll = scrollRef.current;
138
144
  setShowShortcutHint(
@@ -148,10 +154,6 @@ export const Timeline = memo(function Timeline({
148
154
  });
149
155
  }, [syncShortcutHintVisibility]);
150
156
 
151
- const setContainerRef = useCallback((el: HTMLDivElement | null) => {
152
- containerRef.current = el;
153
- }, []);
154
-
155
157
  const setScrollRef = useCallback(
156
158
  (el: HTMLDivElement | null) => {
157
159
  if (roRef.current) {
@@ -178,41 +180,30 @@ export const Timeline = memo(function Timeline({
178
180
  if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
179
181
  });
180
182
 
181
- const effectiveDuration = useMemo(() => {
182
- const safeDur = Number.isFinite(duration) ? duration : 0;
183
- if (rawElements.length === 0) return safeDur;
184
- const maxEnd = Math.max(...rawElements.map((el) => el.start + el.duration));
185
- const result = Math.max(safeDur, maxEnd);
186
- return Number.isFinite(result) ? result : safeDur;
187
- }, [rawElements, duration]);
188
-
189
- const tracks = useMemo(() => {
190
- const map = new Map<number, typeof expandedElements>();
191
- for (const el of expandedElements) {
192
- const list = map.get(el.track) ?? [];
193
- list.push(el);
194
- map.set(el.track, list);
195
- }
196
- return Array.from(map.entries()).sort(([a], [b]) => a - b);
197
- }, [expandedElements]);
183
+ const tracks = useMemo(
184
+ () => buildStackingTimelineLayers(expandedElements).rows,
185
+ [expandedElements],
186
+ );
198
187
 
199
188
  const trackStyles = useMemo(() => {
200
- const map = new Map<number, TrackVisualStyle>();
201
- for (const [trackNum, els] of tracks) {
202
- map.set(trackNum, getTrackStyle(els[0]?.tag ?? ""));
189
+ const map = new Map<string, TrackVisualStyle>();
190
+ for (const layer of tracks) {
191
+ map.set(layer.id, getTrackStyle(layer.elements[0]?.tag ?? ""));
203
192
  }
204
193
  return map;
205
194
  }, [tracks]);
206
195
 
207
- const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
196
+ const trackOrder = useMemo(() => tracks.map((layer) => layer.id), [tracks]);
208
197
  const trackOrderRef = useRef(trackOrder);
209
198
  trackOrderRef.current = trackOrder;
199
+ const timelineLayersRef = useRef(tracks);
200
+ timelineLayersRef.current = tracks;
201
+ const expandedElementsRef = useRef(expandedElements);
202
+ expandedElementsRef.current = expandedElements;
210
203
 
211
204
  const ppsRef = useRef(100);
212
- const durationRef = useRef(effectiveDuration);
213
- durationRef.current = effectiveDuration;
205
+ const durationRef = useRef(Number.isFinite(duration) ? duration : 0);
214
206
 
215
- // Stable ref so useTimelineClipDrag can clear rangeSelection without circular dep
216
207
  const setRangeSelectionRef = useRef<((sel: null) => void) | null>(null);
217
208
 
218
209
  const {
@@ -226,29 +217,67 @@ export const Timeline = memo(function Timeline({
226
217
  } = useTimelineClipDrag({
227
218
  scrollRef,
228
219
  ppsRef,
229
- durationRef,
230
220
  trackOrderRef,
221
+ timelineLayersRef,
222
+ timelineElementsRef: expandedElementsRef,
231
223
  onMoveElement,
232
224
  onResizeElement,
225
+ onMoveElements,
226
+ onResizeElements,
227
+ onPreviewMoveElements,
228
+ onPreviewResizeElements,
233
229
  onBlockedEditAttempt,
234
230
  setShowPopover,
235
231
  setRangeSelectionRef,
236
232
  });
237
233
 
234
+ const basisDuration = useMemo(
235
+ () =>
236
+ computeTimelineBasisDuration(
237
+ duration,
238
+ rawElements.map((el) => el.start + el.duration),
239
+ ),
240
+ [rawElements, duration],
241
+ );
242
+ const effectiveDuration = useMemo(
243
+ () =>
244
+ computeTimelineEffectiveDuration(basisDuration, [
245
+ draggedClip?.started ? draggedClip.previewStart + draggedClip.element.duration : null,
246
+ resizingClip?.started ? resizingClip.previewStart + resizingClip.previewDuration : null,
247
+ ]),
248
+ [basisDuration, draggedClip, resizingClip],
249
+ );
250
+ durationRef.current = effectiveDuration;
251
+
238
252
  const displayTrackOrder = useMemo(() => {
239
253
  if (
240
254
  !draggedClip?.started ||
241
255
  trackOrder.length === 0 ||
242
- trackOrder.includes(draggedClip.previewTrack)
256
+ trackOrder.includes(draggedClip.previewLayerId)
243
257
  )
244
258
  return trackOrder;
245
- return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
259
+ return insertPreviewTrackOrder(
260
+ trackOrder,
261
+ draggedClip.previewLayerId,
262
+ draggedClip.previewLayerIndex,
263
+ );
246
264
  }, [draggedClip, trackOrder]);
247
265
 
248
- const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
266
+ const totalH =
267
+ getTimelineCanvasHeight(displayTrackOrder.length) +
268
+ getTimelineLayerGroupHeaderTotalHeight(displayTrackOrder, tracks);
249
269
  const keyframeCache = usePlayerStore((s) => s.keyframeCache);
250
270
  const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
251
271
  const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
272
+ const keyframeHandlers = useTimelineKeyframeHandlers({
273
+ expandedElements,
274
+ keyframeCache,
275
+ onSelectElement,
276
+ onSeek,
277
+ setSelectedElementId,
278
+ setKfContextMenu,
279
+ toggleSelectedKeyframe,
280
+ });
252
281
 
253
282
  const selectedElement = useMemo(
254
283
  () =>
@@ -259,8 +288,8 @@ export const Timeline = memo(function Timeline({
259
288
  selectedElementRef.current = selectedElement;
260
289
 
261
290
  const fitPps =
262
- viewportWidth > GUTTER && effectiveDuration > 0
263
- ? (viewportWidth - GUTTER - 2) / effectiveDuration
291
+ viewportWidth > GUTTER && basisDuration > 0
292
+ ? (viewportWidth - GUTTER - 2) / basisDuration
264
293
  : 100;
265
294
  const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
266
295
  ppsRef.current = pps;
@@ -325,8 +354,27 @@ export const Timeline = memo(function Timeline({
325
354
  isDragging,
326
355
  setShowPopover,
327
356
  });
328
- // Wire setRangeSelection into the stable ref consumed by useTimelineClipDrag
357
+ const {
358
+ marqueeRect,
359
+ handlePointerDown: handleMarqueePointerDown,
360
+ handlePointerMove: handleMarqueePointerMove,
361
+ handlePointerUp: handleMarqueePointerUp,
362
+ } = useTimelineMarqueeSelection({
363
+ scrollRef,
364
+ ppsRef,
365
+ trackOrderRef,
366
+ timelineLayersRef,
367
+ disabled: activeTool === "razor",
368
+ setShowPopover,
369
+ setRangeSelectionRef,
370
+ seekFromX,
371
+ });
329
372
  setRangeSelectionRef.current = setRangeSelection;
373
+ // Pointer-up and lost-capture end a gesture identically (marquee-claims-first).
374
+ const releasePointer = (event: Parameters<typeof handleMarqueePointerUp>[0]) => {
375
+ if (handleMarqueePointerUp(event)) return;
376
+ handlePointerUp();
377
+ };
330
378
 
331
379
  const prevSelectedRef = useRef(selectedElementRef.current);
332
380
  // eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps
@@ -341,8 +389,8 @@ export const Timeline = memo(function Timeline({
341
389
  });
342
390
 
343
391
  const { major, minor } = useMemo(
344
- () => generateTicks(effectiveDuration, pps),
345
- [effectiveDuration, pps],
392
+ () => generateVisibleTicks(effectiveDuration, pps, viewportWidth, GUTTER),
393
+ [effectiveDuration, pps, viewportWidth],
346
394
  );
347
395
  const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
348
396
 
@@ -373,6 +421,7 @@ export const Timeline = memo(function Timeline({
373
421
  ppsRef,
374
422
  durationRef,
375
423
  trackOrderRef,
424
+ timelineLayersRef,
376
425
  onFileDrop,
377
426
  onAssetDrop,
378
427
  onBlockDrop,
@@ -392,7 +441,6 @@ export const Timeline = memo(function Timeline({
392
441
 
393
442
  return (
394
443
  <div
395
- ref={setContainerRef}
396
444
  aria-label="Timeline"
397
445
  className={`relative border-t select-none h-full overflow-hidden ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
398
446
  onMouseMove={(e) => {
@@ -423,11 +471,15 @@ export const Timeline = memo(function Timeline({
423
471
  onRazorSplitAll?.(splitTime);
424
472
  return;
425
473
  }
474
+ if (handleMarqueePointerDown(e)) return;
426
475
  handlePointerDown(e);
427
476
  }}
428
- onPointerMove={handlePointerMove}
429
- onPointerUp={handlePointerUp}
430
- onLostPointerCapture={handlePointerUp}
477
+ onPointerMove={(e) => {
478
+ if (handleMarqueePointerMove(e)) return;
479
+ handlePointerMove(e);
480
+ }}
481
+ onPointerUp={releasePointer}
482
+ onLostPointerCapture={releasePointer}
431
483
  >
432
484
  <TimelineCanvas
433
485
  major={major}
@@ -438,6 +490,7 @@ export const Timeline = memo(function Timeline({
438
490
  effectiveDuration={effectiveDuration}
439
491
  majorTickInterval={majorTickInterval}
440
492
  rangeSelection={rangeSelection}
493
+ marqueeRect={marqueeRect}
441
494
  theme={theme}
442
495
  displayTrackOrder={displayTrackOrder}
443
496
  trackOrder={trackOrder}
@@ -469,41 +522,10 @@ export const Timeline = memo(function Timeline({
469
522
  selectedKeyframes={selectedKeyframes}
470
523
  currentTime={currentTime}
471
524
  beatAnalysis={adjustedBeatAnalysis}
472
- onClickKeyframe={(el, pct) => {
473
- usePlayerStore.getState().clearSelectedKeyframes();
474
- const elKey = el.key ?? el.id;
475
- setSelectedElementId(elKey);
476
- onSelectElement?.(el);
477
- // Visually select the clicked diamond (matches shift-click / motion-path
478
- // selection); cleared above so this single-selects it.
479
- toggleSelectedKeyframe(`${elKey}:${pct}`);
480
- const absTime = el.start + (pct / 100) * el.duration;
481
- onSeek?.(absTime);
482
- const kfData = keyframeCache?.get(elKey);
483
- const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.5);
484
- usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null);
485
- }}
486
- onShiftClickKeyframe={(elId, pct) => {
487
- toggleSelectedKeyframe(`${elId}:${pct}`);
488
- }}
525
+ onClickKeyframe={keyframeHandlers.onClickKeyframe}
526
+ onShiftClickKeyframe={keyframeHandlers.onShiftClickKeyframe}
489
527
  onMoveKeyframe={onMoveKeyframe}
490
- onContextMenuKeyframe={(e, elId, pct) => {
491
- const el = expandedElements.find((x) => (x.key ?? x.id) === elId);
492
- if (el) {
493
- setSelectedElementId(elId);
494
- onSelectElement?.(el);
495
- }
496
- const kfData = keyframeCache.get(elId);
497
- const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
498
- setKfContextMenu({
499
- x: e.clientX + 4,
500
- y: e.clientY + 2,
501
- elementId: elId,
502
- percentage: pct,
503
- tweenPercentage: kf?.tweenPercentage,
504
- currentEase: kf?.ease ?? kfData?.ease,
505
- });
506
- }}
528
+ onContextMenuKeyframe={keyframeHandlers.onContextMenuKeyframe}
507
529
  onContextMenuClip={(e, el) => {
508
530
  e.preventDefault();
509
531
  setSelectedElementId(el.key ?? el.id);