@hyperframes/studio 0.7.90 → 0.7.92

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 (86) hide show
  1. package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-9qOa08Tv.js} +1 -1
  2. package/dist/assets/{index-Bi-CwQDX.js → index-Bw4lNdtO.js} +1 -1
  3. package/dist/assets/{index-CquRJgm-.js → index-CDTN2ZVa.js} +1 -1
  4. package/dist/assets/index-DIntkrQl.js +428 -0
  5. package/dist/index.d.ts +20 -16
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +6495 -5429
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/StudioRightPanel.tsx +1 -1
  11. package/src/components/editor/GsapAnimationList.tsx +84 -0
  12. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  13. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  14. package/src/components/editor/PropertyPanel.tsx +1 -1
  15. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  16. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  17. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  18. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  19. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  20. package/src/components/editor/propertyPanelTypes.ts +1 -1
  21. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  22. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  23. package/src/components/renders/useRenderQueue.ts +16 -3
  24. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  25. package/src/contexts/DomEditContext.tsx +1 -1
  26. package/src/hooks/useDomEditSession.test.tsx +1 -1
  27. package/src/hooks/useDomEditSession.ts +1 -1
  28. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  29. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  30. package/src/player/components/BeatStrip.test.tsx +374 -0
  31. package/src/player/components/BeatStrip.tsx +295 -37
  32. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  33. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  34. package/src/player/components/Timeline.test.ts +2 -3
  35. package/src/player/components/Timeline.tsx +47 -48
  36. package/src/player/components/TimelineCanvas.tsx +14 -71
  37. package/src/player/components/TimelineClip.tsx +6 -3
  38. package/src/player/components/TimelineClipDiamonds.test.tsx +442 -18
  39. package/src/player/components/TimelineClipDiamonds.tsx +66 -211
  40. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  41. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  42. package/src/player/components/TimelineLanes.test.tsx +0 -1
  43. package/src/player/components/TimelineLanes.tsx +3 -2
  44. package/src/player/components/TimelineOverlays.test.ts +161 -0
  45. package/src/player/components/TimelineOverlays.tsx +104 -11
  46. package/src/player/components/timelineCallbacks.ts +1 -1
  47. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  48. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  49. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  50. package/src/player/components/timelineClipDragPreview.ts +18 -12
  51. package/src/player/components/timelineClipDragTypes.ts +12 -0
  52. package/src/player/components/timelineDiamondTypes.ts +5 -15
  53. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  54. package/src/player/components/timelineDragDrop.ts +152 -30
  55. package/src/player/components/timelineLaneProps.ts +0 -1
  56. package/src/player/components/timelineMarquee.test.ts +73 -25
  57. package/src/player/components/timelineMarquee.ts +44 -16
  58. package/src/player/components/timelineTestViewport.ts +13 -0
  59. package/src/player/components/timelineViewModel.ts +4 -4
  60. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  61. package/src/player/components/useTimelineClipDrag.ts +112 -70
  62. package/src/player/components/useTimelineClipRenderWindow.ts +4 -4
  63. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  64. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  65. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  66. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  67. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  68. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  69. package/src/player/components/useTimelineRowVirtualization.ts +11 -4
  70. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  71. package/src/player/components/useTrackGapMenu.ts +32 -9
  72. package/src/player/store/keyframeSlice.ts +59 -18
  73. package/src/player/store/playerStore.test.ts +76 -0
  74. package/src/player/store/playerStore.ts +19 -11
  75. package/src/telemetry/canary.test.ts +325 -0
  76. package/src/telemetry/canary.ts +268 -0
  77. package/src/telemetry/client.test.ts +8 -3
  78. package/src/telemetry/client.ts +13 -43
  79. package/src/telemetry/config.ts +16 -2
  80. package/src/telemetry/distinctId.test.ts +35 -0
  81. package/src/telemetry/distinctId.ts +11 -2
  82. package/src/telemetry/policy.test.ts +81 -0
  83. package/src/telemetry/policy.ts +104 -0
  84. package/src/utils/studioTelemetry.test.ts +76 -0
  85. package/src/utils/studioTelemetry.ts +14 -6
  86. package/dist/assets/index-Cql15Yur.js +0 -428
@@ -0,0 +1,206 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
7
+ import { usePlayerStore } from "../store/playerStore";
8
+ import { createTimelineRowGeometry } from "./timelineLayout";
9
+ import { useTimelineAssetDrop } from "./timelineDragDrop";
10
+ import { configureTimelineTestViewport } from "./timelineTestViewport";
11
+
12
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
+
14
+ interface DropTransfer {
15
+ types: string[];
16
+ files: File[];
17
+ dropEffect: DataTransfer["dropEffect"];
18
+ getData: (type: string) => string;
19
+ }
20
+
21
+ function dragEvent(transfer: DropTransfer, clientX: number, clientY: number): React.DragEvent {
22
+ return {
23
+ clientX,
24
+ clientY,
25
+ dataTransfer: transfer,
26
+ preventDefault: vi.fn(),
27
+ } as unknown as React.DragEvent;
28
+ }
29
+
30
+ function assetTransfer(payload: string): DropTransfer {
31
+ return {
32
+ types: [TIMELINE_ASSET_MIME],
33
+ files: [],
34
+ dropEffect: "none",
35
+ getData: (type) => (type === TIMELINE_ASSET_MIME ? payload : ""),
36
+ };
37
+ }
38
+
39
+ function renderHarness(
40
+ onAssetDrop: ReturnType<typeof vi.fn>,
41
+ sessionEpoch = 1,
42
+ options: { onBlockDrop?: ReturnType<typeof vi.fn>; strict?: boolean } = {},
43
+ ) {
44
+ const tracks = Array.from({ length: 100 }, (_, index) => index);
45
+ const geometry = createTimelineRowGeometry(
46
+ tracks,
47
+ tracks.map(() => 48),
48
+ );
49
+ const scroll = document.createElement("div");
50
+ configureTimelineTestViewport(scroll, geometry.canvasHeight);
51
+ document.body.append(scroll);
52
+ const root = createRoot(document.createElement("div"));
53
+ let api: ReturnType<typeof useTimelineAssetDrop> | null = null;
54
+
55
+ function Probe({ epoch }: { epoch: number }) {
56
+ api = useTimelineAssetDrop({
57
+ scrollRef: { current: scroll },
58
+ ppsRef: { current: 40 },
59
+ durationRef: { current: 120 },
60
+ trackOrderRef: { current: tracks },
61
+ rowGeometryRef: { current: geometry },
62
+ contentOrigin: 0,
63
+ sessionEpoch: epoch,
64
+ onAssetDrop,
65
+ onBlockDrop: options.onBlockDrop,
66
+ });
67
+ return null;
68
+ }
69
+
70
+ const renderProbe = (epoch: number) =>
71
+ root.render(
72
+ options.strict ? (
73
+ <React.StrictMode>
74
+ <Probe epoch={epoch} />
75
+ </React.StrictMode>
76
+ ) : (
77
+ <Probe epoch={epoch} />
78
+ ),
79
+ );
80
+ act(() => renderProbe(sessionEpoch));
81
+ return {
82
+ scroll,
83
+ root,
84
+ get api() {
85
+ if (!api) throw new Error("drop harness did not render");
86
+ return api;
87
+ },
88
+ rerender(epoch: number) {
89
+ act(() => renderProbe(epoch));
90
+ },
91
+ };
92
+ }
93
+
94
+ afterEach(() => {
95
+ vi.restoreAllMocks();
96
+ usePlayerStore.getState().reset();
97
+ document.body.innerHTML = "";
98
+ });
99
+
100
+ describe("useTimelineAssetDrop", () => {
101
+ it("edge-autoscrolls the sole timeline viewport while a supported asset is held", () => {
102
+ let frame: FrameRequestCallback | null = null;
103
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((callback) => {
104
+ frame = callback;
105
+ return 1;
106
+ });
107
+ vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => undefined);
108
+ const view = renderHarness(vi.fn());
109
+
110
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 790, 120)));
111
+ expect(view.api.isDragOver).toBe(true);
112
+ expect(frame).not.toBeNull();
113
+ act(() => frame?.(0));
114
+ expect(view.scroll.scrollLeft).toBeGreaterThan(0);
115
+ expect(view.scroll.scrollTop).toBe(0);
116
+
117
+ act(() => view.api.clearDropPreview());
118
+ expect(view.api.isDragOver).toBe(false);
119
+ act(() => view.root.unmount());
120
+ });
121
+
122
+ it("keeps the drop actor while moving between descendants", () => {
123
+ const view = renderHarness(vi.fn());
124
+ const parent = document.createElement("div");
125
+ const child = document.createElement("div");
126
+ parent.append(child);
127
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
128
+ act(() =>
129
+ view.api.handleAssetDragLeave({
130
+ relatedTarget: child,
131
+ currentTarget: parent,
132
+ } as unknown as React.DragEvent),
133
+ );
134
+ expect(view.api.isDragOver).toBe(true);
135
+ act(() => view.root.unmount());
136
+ });
137
+
138
+ it("drops once on a model row outside the mounted window and appends below the last row", () => {
139
+ const onAssetDrop = vi.fn();
140
+ const view = renderHarness(onAssetDrop);
141
+ usePlayerStore.getState().setCurrentTime(12.5);
142
+ view.scroll.scrollTop = view.scroll.scrollHeight - view.scroll.clientHeight;
143
+ const transfer = assetTransfer(JSON.stringify({ path: "/media/hero.mp4" }));
144
+
145
+ act(() => {
146
+ view.api.handleAssetDragOver(dragEvent(transfer, 400, 239));
147
+ view.api.handleAssetDrop(dragEvent(transfer, 400, 239));
148
+ });
149
+
150
+ expect(onAssetDrop).toHaveBeenCalledTimes(1);
151
+ expect(onAssetDrop).toHaveBeenCalledWith("/media/hero.mp4", { start: 12.5, track: 100 });
152
+ expect(view.api.isDragOver).toBe(false);
153
+ act(() => view.root.unmount());
154
+ });
155
+
156
+ it("ignores malformed payloads and clears the actor on project reset", () => {
157
+ const onAssetDrop = vi.fn();
158
+ const view = renderHarness(onAssetDrop, 1);
159
+ const transfer = assetTransfer("not-json");
160
+
161
+ act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, 100)));
162
+ expect(view.api.isDragOver).toBe(true);
163
+ view.rerender(2);
164
+ expect(view.api.isDragOver).toBe(false);
165
+
166
+ act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, 100)));
167
+ expect(onAssetDrop).not.toHaveBeenCalled();
168
+ act(() => view.root.unmount());
169
+ });
170
+
171
+ it("falls through a malformed asset payload to a valid block payload", () => {
172
+ const onAssetDrop = vi.fn();
173
+ const onBlockDrop = vi.fn();
174
+ const view = renderHarness(onAssetDrop, 1, { onBlockDrop });
175
+ const transfer: DropTransfer = {
176
+ types: [TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME],
177
+ files: [],
178
+ dropEffect: "none",
179
+ getData: (type) =>
180
+ type === TIMELINE_ASSET_MIME
181
+ ? "not-json"
182
+ : type === TIMELINE_BLOCK_MIME
183
+ ? JSON.stringify({ name: "title-card" })
184
+ : "",
185
+ };
186
+
187
+ act(() => {
188
+ view.api.handleAssetDragOver(dragEvent(transfer, 400, 100));
189
+ view.api.handleAssetDrop(dragEvent(transfer, 400, 100));
190
+ });
191
+
192
+ expect(onAssetDrop).not.toHaveBeenCalled();
193
+ expect(onBlockDrop).toHaveBeenCalledExactlyOnceWith("title-card", { start: 0, track: 0 });
194
+ act(() => view.root.unmount());
195
+ });
196
+
197
+ it("clears an escaped drag after StrictMode effect replay", () => {
198
+ const view = renderHarness(vi.fn(), 1, { strict: true });
199
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
200
+ expect(view.api.isDragOver).toBe(true);
201
+
202
+ act(() => window.dispatchEvent(new Event("dragend")));
203
+ expect(view.api.isDragOver).toBe(false);
204
+ act(() => view.root.unmount());
205
+ });
206
+ });
@@ -1,4 +1,4 @@
1
- import { useCallback, useState, type RefObject } from "react";
1
+ import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
2
2
  import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
3
3
  import {
4
4
  parseTimelineCompositionPayload,
@@ -7,6 +7,10 @@ import {
7
7
  import { usePlayerStore } from "../store/playerStore";
8
8
  import { resolveTimelineAssetDrop, type TimelineRowGeometry } from "./timelineLayout";
9
9
  import type { TimelineDropCallbacks } from "./timelineCallbacks";
10
+ import {
11
+ applyTimelineAutoScrollStep,
12
+ resolveTimelineAutoScrollLoopAction,
13
+ } from "./timelineEditing";
10
14
 
11
15
  interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
12
16
  scrollRef: RefObject<HTMLDivElement | null>;
@@ -15,6 +19,7 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
15
19
  trackOrderRef: RefObject<number[]>;
16
20
  rowGeometryRef: RefObject<TimelineRowGeometry>;
17
21
  contentOrigin: number;
22
+ sessionEpoch: number;
18
23
  }
19
24
 
20
25
  type TimelinePlacement = { start: number; track: number };
@@ -29,12 +34,22 @@ function applyJsonDropPayload(
29
34
  pick: (parsed: Record<string, string | undefined>) => string | undefined,
30
35
  apply: (value: string, placement: TimelinePlacement) => void,
31
36
  placement: TimelinePlacement,
32
- ): void {
37
+ ): boolean {
33
38
  try {
34
39
  const value = pick(JSON.parse(raw) as Record<string, string | undefined>);
35
- if (value) apply(value, placement);
40
+ if (!value) return false;
41
+ apply(value, placement);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ function invokeDropCallback(callback: () => Promise<void> | void): void {
49
+ try {
50
+ void Promise.resolve(callback()).catch(() => undefined);
36
51
  } catch {
37
- /* ignore malformed drag payloads */
52
+ // A rejected external producer never keeps a timeline drop actor alive.
38
53
  }
39
54
  }
40
55
 
@@ -43,6 +58,34 @@ function resolveDropStart(usePointerStart: boolean, pointerStart: number): numbe
43
58
  return Math.max(0, usePlayerStore.getState().currentTime);
44
59
  }
45
60
 
61
+ function applyFileDrop(
62
+ transfer: DataTransfer,
63
+ onFileDrop: TimelineDropCallbacks["onFileDrop"],
64
+ placement: TimelinePlacement,
65
+ ): boolean {
66
+ if (!onFileDrop || transfer.files.length === 0) return false;
67
+ invokeDropCallback(() => onFileDrop(Array.from(transfer.files), placement));
68
+ return true;
69
+ }
70
+
71
+ function applyTypedJsonDrop(
72
+ transfer: DataTransfer,
73
+ mime: string,
74
+ field: "name" | "path",
75
+ apply: ((value: string, placement: TimelinePlacement) => Promise<void> | void) | undefined,
76
+ placement: TimelinePlacement,
77
+ ): boolean {
78
+ if (!apply || !Array.from(transfer.types).includes(mime)) return false;
79
+ const payload = transfer.getData(mime);
80
+ if (!payload) return false;
81
+ return applyJsonDropPayload(
82
+ payload,
83
+ (parsed) => parsed[field],
84
+ (value, nextPlacement) => invokeDropCallback(() => apply(value, nextPlacement)),
85
+ placement,
86
+ );
87
+ }
88
+
46
89
  /**
47
90
  * Dropping an asset/file/block onto the timeline places it at the PLAYHEAD —
48
91
  * start is the current playhead time, only the track comes from the drop y.
@@ -62,22 +105,84 @@ export function useTimelineAssetDrop({
62
105
  onAssetDrop,
63
106
  onBlockDrop,
64
107
  onCompositionDrop,
108
+ sessionEpoch,
65
109
  }: UseTimelineAssetDropOptions) {
66
110
  const [isDragOver, setIsDragOver] = useState(false);
111
+ const dragPointerRef = useRef<{ clientX: number; clientY: number; sessionEpoch: number } | null>(
112
+ null,
113
+ );
114
+ const autoScrollRafRef = useRef(0);
115
+ const activeDropEpochRef = useRef<number | null>(null);
67
116
 
68
- const handleAssetDragOver = useCallback((e: React.DragEvent) => {
69
- const types = Array.from(e.dataTransfer.types);
70
- const hasFiles = types.includes("Files");
71
- const hasAsset = types.includes(TIMELINE_ASSET_MIME);
72
- const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
73
- const hasComposition = types.includes(TIMELINE_COMPOSITION_MIME);
74
- if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return;
75
- e.preventDefault();
76
- e.dataTransfer.dropEffect = "copy";
77
- setIsDragOver(true);
117
+ const stopAutoScroll = useCallback(() => {
118
+ dragPointerRef.current = null;
119
+ if (autoScrollRafRef.current) cancelAnimationFrame(autoScrollRafRef.current);
120
+ autoScrollRafRef.current = 0;
78
121
  }, []);
79
122
 
80
- const clearDropPreview = useCallback(() => setIsDragOver(false), []);
123
+ const stepAutoScroll = useCallback(
124
+ function stepAutoScroll() {
125
+ autoScrollRafRef.current = 0;
126
+ const pointer = dragPointerRef.current;
127
+ const scroll = scrollRef.current;
128
+ if (!pointer || pointer.sessionEpoch !== sessionEpoch || !scroll) return;
129
+ if (!applyTimelineAutoScrollStep(scroll, pointer.clientX, pointer.clientY)) return;
130
+ autoScrollRafRef.current = requestAnimationFrame(stepAutoScroll);
131
+ },
132
+ [scrollRef, sessionEpoch],
133
+ );
134
+
135
+ const syncAutoScroll = useCallback(
136
+ (clientX: number, clientY: number) => {
137
+ dragPointerRef.current = { clientX, clientY, sessionEpoch };
138
+ const scroll = scrollRef.current;
139
+ const action = resolveTimelineAutoScrollLoopAction(
140
+ scroll,
141
+ clientX,
142
+ clientY,
143
+ autoScrollRafRef.current !== 0,
144
+ );
145
+ if (action === "stop") {
146
+ cancelAnimationFrame(autoScrollRafRef.current);
147
+ autoScrollRafRef.current = 0;
148
+ } else if (action === "start") {
149
+ autoScrollRafRef.current = requestAnimationFrame(stepAutoScroll);
150
+ }
151
+ },
152
+ [scrollRef, sessionEpoch, stepAutoScroll],
153
+ );
154
+
155
+ const handleAssetDragOver = useCallback(
156
+ (e: React.DragEvent) => {
157
+ const types = Array.from(e.dataTransfer.types);
158
+ const hasFiles = types.includes("Files");
159
+ const hasAsset = types.includes(TIMELINE_ASSET_MIME);
160
+ const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
161
+ const hasComposition = types.includes(TIMELINE_COMPOSITION_MIME);
162
+ if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return;
163
+ e.preventDefault();
164
+ e.dataTransfer.dropEffect = "copy";
165
+ activeDropEpochRef.current = sessionEpoch;
166
+ setIsDragOver(true);
167
+ syncAutoScroll(e.clientX, e.clientY);
168
+ },
169
+ [sessionEpoch, syncAutoScroll],
170
+ );
171
+
172
+ const clearDropPreview = useCallback(() => {
173
+ activeDropEpochRef.current = null;
174
+ stopAutoScroll();
175
+ setIsDragOver(false);
176
+ }, [stopAutoScroll]);
177
+
178
+ const handleAssetDragLeave = useCallback(
179
+ (e: React.DragEvent) => {
180
+ const related = e.relatedTarget;
181
+ if (related instanceof Node && e.currentTarget.contains(related)) return;
182
+ clearDropPreview();
183
+ },
184
+ [clearDropPreview],
185
+ );
81
186
 
82
187
  const resolveDropPlacement = useCallback(
83
188
  (clientX: number, clientY: number, usePointerStart = false): TimelinePlacement => {
@@ -110,33 +215,50 @@ export function useTimelineAssetDrop({
110
215
  const handleAssetDrop = useCallback(
111
216
  (e: React.DragEvent) => {
112
217
  e.preventDefault();
113
- setIsDragOver(false);
218
+ const canCommit = activeDropEpochRef.current === sessionEpoch;
219
+ clearDropPreview();
220
+ if (!canCommit) return;
114
221
  const compositionPayload = parseTimelineCompositionPayload(
115
222
  e.dataTransfer.getData(TIMELINE_COMPOSITION_MIME),
116
223
  );
117
224
  if (compositionPayload && onCompositionDrop) {
118
225
  const placement = resolveDropPlacement(e.clientX, e.clientY, true);
119
- void onCompositionDrop(compositionPayload.sourcePath, placement);
226
+ invokeDropCallback(() => onCompositionDrop(compositionPayload.sourcePath, placement));
120
227
  return;
121
228
  }
122
229
  const placement = resolveDropPlacement(e.clientX, e.clientY);
123
230
 
124
- if (onFileDrop && e.dataTransfer.files.length > 0) {
125
- void onFileDrop(Array.from(e.dataTransfer.files), placement);
231
+ if (applyFileDrop(e.dataTransfer, onFileDrop, placement)) return;
232
+ if (applyTypedJsonDrop(e.dataTransfer, TIMELINE_ASSET_MIME, "path", onAssetDrop, placement)) {
126
233
  return;
127
234
  }
128
- const assetPayload = e.dataTransfer.getData(TIMELINE_ASSET_MIME);
129
- if (assetPayload && onAssetDrop) {
130
- applyJsonDropPayload(assetPayload, (p) => p.path, onAssetDrop, placement);
131
- return;
132
- }
133
- const blockPayload = e.dataTransfer.getData(TIMELINE_BLOCK_MIME);
134
- if (blockPayload && onBlockDrop) {
135
- applyJsonDropPayload(blockPayload, (p) => p.name, onBlockDrop, placement);
136
- }
235
+ applyTypedJsonDrop(e.dataTransfer, TIMELINE_BLOCK_MIME, "name", onBlockDrop, placement);
137
236
  },
138
- [resolveDropPlacement, onFileDrop, onAssetDrop, onBlockDrop, onCompositionDrop],
237
+ [
238
+ clearDropPreview,
239
+ onAssetDrop,
240
+ onBlockDrop,
241
+ onCompositionDrop,
242
+ onFileDrop,
243
+ resolveDropPlacement,
244
+ sessionEpoch,
245
+ ],
139
246
  );
140
247
 
141
- return { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview };
248
+ useEffect(() => {
249
+ window.addEventListener("dragend", clearDropPreview);
250
+ return () => {
251
+ window.removeEventListener("dragend", clearDropPreview);
252
+ clearDropPreview();
253
+ };
254
+ }, [clearDropPreview]);
255
+ useEffect(() => clearDropPreview(), [clearDropPreview, sessionEpoch]);
256
+
257
+ return {
258
+ isDragOver,
259
+ handleAssetDragOver,
260
+ handleAssetDragLeave,
261
+ handleAssetDrop,
262
+ clearDropPreview,
263
+ };
142
264
  }
@@ -55,7 +55,6 @@ export interface TimelineLaneBaseProps {
55
55
  setResizingClip: (v: ResizingClipState | null) => void;
56
56
  setDraggedClip: (v: DraggedClipState | null) => void;
57
57
  setSelectedElementId: (id: string | null) => void;
58
- syncClipDragAutoScroll: (x: number, y: number) => void;
59
58
  shiftClickClipRef: React.RefObject<{
60
59
  element: TimelineElement;
61
60
  anchorX: number;
@@ -5,8 +5,11 @@ import {
5
5
  isTimelineRulerPress,
6
6
  getMarqueeRect,
7
7
  getTimelineClipRect,
8
+ getMarqueeClipCandidates,
8
9
  computeMarqueeSelection,
9
10
  } from "./timelineMarquee";
11
+ import { createTimelineClipIndex } from "../lib/timelineClipIndex";
12
+ import type { TimelineElement } from "../store/playerStore";
10
13
  import {
11
14
  GUTTER,
12
15
  LANE_H,
@@ -14,6 +17,7 @@ import {
14
17
  RULER_H,
15
18
  CLIP_Y,
16
19
  TRACKS_LEFT_PAD,
20
+ createTimelineRowGeometry,
17
21
  getTimelineRowTop,
18
22
  } from "./timelineLayout";
19
23
 
@@ -95,9 +99,13 @@ describe("getMarqueeRect", () => {
95
99
 
96
100
  describe("getTimelineClipRect", () => {
97
101
  const trackOrder = [0, 2, 5];
102
+ const geometry = createTimelineRowGeometry(
103
+ trackOrder,
104
+ trackOrder.map(() => TRACK_H),
105
+ );
98
106
 
99
107
  it("maps start/duration to x via pps and the track row to y via the shared row→y helper", () => {
100
- const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, trackOrder, 100, GUTTER);
108
+ const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, geometry, 100, GUTTER);
101
109
  expect(rect).toEqual({
102
110
  left: GUTTER + 200,
103
111
  top: getTimelineRowTop(1) + CLIP_Y,
@@ -107,54 +115,48 @@ describe("getTimelineClipRect", () => {
107
115
  });
108
116
 
109
117
  it("places the first visible track below the ruler + top breathing pad", () => {
110
- const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 50, GUTTER);
118
+ const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, geometry, 50, GUTTER);
111
119
  expect(rect?.top).toBe(getTimelineRowTop(0) + CLIP_Y);
112
120
  expect(rect?.left).toBe(GUTTER);
113
121
  });
114
122
 
115
123
  it("uses the row index in trackOrder, not the raw track number", () => {
116
- const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, trackOrder, 50, GUTTER);
124
+ const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, geometry, 50, GUTTER);
117
125
  expect(rect?.top).toBe(getTimelineRowTop(2) + CLIP_Y);
118
126
  });
119
127
 
120
128
  it("uses cumulative tops and the resolved height for an expanded row", () => {
121
129
  const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H];
130
+ const expandedGeometry = createTimelineRowGeometry(trackOrder, rowHeights);
122
131
  const rect = getTimelineClipRect(
123
132
  { start: 0, duration: 1, track: 0 },
124
- trackOrder,
133
+ expandedGeometry,
125
134
  50,
126
135
  GUTTER,
127
- rowHeights,
128
136
  );
129
137
  expect(rect).toMatchObject({
130
138
  top: getTimelineRowTop(0, rowHeights) + CLIP_Y,
131
- height: rowHeights[0] - CLIP_Y * 2,
139
+ height: TRACK_H - CLIP_Y * 2,
132
140
  });
133
141
  expect(
134
- getTimelineClipRect({ start: 0, duration: 1, track: 2 }, trackOrder, 50, GUTTER, rowHeights)
135
- ?.top,
142
+ getTimelineClipRect({ start: 0, duration: 1, track: 2 }, expandedGeometry, 50, GUTTER)?.top,
136
143
  ).toBe(getTimelineRowTop(1, rowHeights) + CLIP_Y);
137
144
  });
138
145
 
139
146
  it("enforces the 4px minimum rendered width", () => {
140
- const rect = getTimelineClipRect(
141
- { start: 0, duration: 0.01, track: 0 },
142
- trackOrder,
143
- 10,
144
- GUTTER,
145
- );
147
+ const rect = getTimelineClipRect({ start: 0, duration: 0.01, track: 0 }, geometry, 10, GUTTER);
146
148
  expect(rect?.width).toBe(4);
147
149
  });
148
150
 
149
151
  it("returns null for a track that is not displayed or an invalid pps", () => {
150
152
  expect(
151
- getTimelineClipRect({ start: 0, duration: 1, track: 9 }, trackOrder, 100, GUTTER),
153
+ getTimelineClipRect({ start: 0, duration: 1, track: 9 }, geometry, 100, GUTTER),
152
154
  ).toBeNull();
153
155
  expect(
154
- getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 0, GUTTER),
156
+ getTimelineClipRect({ start: 0, duration: 1, track: 0 }, geometry, 0, GUTTER),
155
157
  ).toBeNull();
156
158
  expect(
157
- getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, NaN, GUTTER),
159
+ getTimelineClipRect({ start: 0, duration: 1, track: 0 }, geometry, NaN, GUTTER),
158
160
  ).toBeNull();
159
161
  });
160
162
  });
@@ -162,6 +164,10 @@ describe("getTimelineClipRect", () => {
162
164
  describe("computeMarqueeSelection", () => {
163
165
  // Two visible tracks: row 0 = track 0, row 1 = track 1. pps 100.
164
166
  const trackOrder = [0, 1];
167
+ const rowGeometry = createTimelineRowGeometry(
168
+ trackOrder,
169
+ trackOrder.map(() => TRACK_H),
170
+ );
165
171
  const pps = 100;
166
172
  const clips = [
167
173
  { id: "a", start: 0, duration: 1, track: 0 }, // x [32,132], row 0
@@ -175,7 +181,7 @@ describe("computeMarqueeSelection", () => {
175
181
  const marquee = { left: ORIGIN, top: row0Top, width: 50, height: 10 };
176
182
  const { ids, primaryId } = computeMarqueeSelection({
177
183
  clips,
178
- trackOrder,
184
+ rowGeometry,
179
185
  pps,
180
186
  contentOrigin: ORIGIN,
181
187
  marquee,
@@ -188,7 +194,7 @@ describe("computeMarqueeSelection", () => {
188
194
  const marquee = { left: ORIGIN, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
189
195
  const { ids } = computeMarqueeSelection({
190
196
  clips,
191
- trackOrder,
197
+ rowGeometry,
192
198
  pps,
193
199
  contentOrigin: ORIGIN,
194
200
  marquee,
@@ -200,7 +206,7 @@ describe("computeMarqueeSelection", () => {
200
206
  const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
201
207
  const { ids } = computeMarqueeSelection({
202
208
  clips,
203
- trackOrder,
209
+ rowGeometry,
204
210
  pps,
205
211
  contentOrigin: ORIGIN,
206
212
  marquee,
@@ -212,7 +218,7 @@ describe("computeMarqueeSelection", () => {
212
218
  const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 };
213
219
  const { ids, primaryId } = computeMarqueeSelection({
214
220
  clips,
215
- trackOrder,
221
+ rowGeometry,
216
222
  pps,
217
223
  contentOrigin: GUTTER,
218
224
  marquee,
@@ -226,7 +232,7 @@ describe("computeMarqueeSelection", () => {
226
232
  const marquee = { left: GUTTER, top: row1Top, width: 100, height: 10 };
227
233
  const { ids, primaryId } = computeMarqueeSelection({
228
234
  clips,
229
- trackOrder,
235
+ rowGeometry,
230
236
  pps,
231
237
  contentOrigin: GUTTER,
232
238
  marquee,
@@ -240,10 +246,11 @@ describe("computeMarqueeSelection", () => {
240
246
  const wide = { left: ORIGIN, top: row0Top, width: 320, height: 10 };
241
247
  const narrow = { left: ORIGIN, top: row0Top, width: 80, height: 10 };
242
248
  expect(
243
- computeMarqueeSelection({ clips, trackOrder, pps, contentOrigin: ORIGIN, marquee: wide }).ids,
249
+ computeMarqueeSelection({ clips, rowGeometry, pps, contentOrigin: ORIGIN, marquee: wide })
250
+ .ids,
244
251
  ).toEqual(new Set(["a", "b"]));
245
252
  expect(
246
- computeMarqueeSelection({ clips, trackOrder, pps, contentOrigin: ORIGIN, marquee: narrow })
253
+ computeMarqueeSelection({ clips, rowGeometry, pps, contentOrigin: ORIGIN, marquee: narrow })
247
254
  .ids,
248
255
  ).toEqual(new Set(["a"]));
249
256
  });
@@ -252,7 +259,7 @@ describe("computeMarqueeSelection", () => {
252
259
  const marquee = { left: 0, top: 0, width: 10000, height: 10000 };
253
260
  const { ids } = computeMarqueeSelection({
254
261
  clips: [{ id: "x", start: 0, duration: 1, track: 7 }],
255
- trackOrder,
262
+ rowGeometry,
256
263
  pps,
257
264
  contentOrigin: GUTTER,
258
265
  marquee,
@@ -260,3 +267,44 @@ describe("computeMarqueeSelection", () => {
260
267
  expect(ids).toEqual(new Set());
261
268
  });
262
269
  });
270
+
271
+ describe("getMarqueeClipCandidates", () => {
272
+ it("queries only the intersecting rows and time span", () => {
273
+ const rowGeometry = createTimelineRowGeometry([0, 1, 2], [TRACK_H, TRACK_H, TRACK_H]);
274
+ const near: TimelineElement = { id: "near", tag: "div", start: 1, duration: 1, track: 1 };
275
+ const wrongTime: TimelineElement = {
276
+ id: "wrong-time",
277
+ tag: "div",
278
+ start: 20,
279
+ duration: 1,
280
+ track: 1,
281
+ };
282
+ const wrongRow: TimelineElement = {
283
+ id: "wrong-row",
284
+ tag: "div",
285
+ start: 1,
286
+ duration: 1,
287
+ track: 2,
288
+ };
289
+ const clipIndex = createTimelineClipIndex([
290
+ [0, []],
291
+ [1, [near, wrongTime]],
292
+ [2, [wrongRow]],
293
+ ]);
294
+
295
+ expect(
296
+ getMarqueeClipCandidates({
297
+ clipIndex,
298
+ rowGeometry,
299
+ marquee: {
300
+ left: ORIGIN + 100,
301
+ top: getTimelineRowTop(1),
302
+ width: 100,
303
+ height: TRACK_H - 1,
304
+ },
305
+ pps: 100,
306
+ contentOrigin: ORIGIN,
307
+ }),
308
+ ).toEqual([near]);
309
+ });
310
+ });