@hyperframes/studio 0.7.90 → 0.7.93

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
  3. package/dist/assets/index-D78KEjgB.css +1 -0
  4. package/dist/assets/index-Dp1zVb9X.js +428 -0
  5. package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +40 -36
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +9598 -6798
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/App.tsx +14 -14
  12. package/src/components/DesignPanelPromoteProvider.tsx +1 -1
  13. package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
  14. package/src/components/ExternalFileConflictBanner.tsx +255 -0
  15. package/src/components/StudioRightPanel.tsx +1 -1
  16. package/src/components/editor/GsapAnimationList.tsx +84 -0
  17. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  18. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  19. package/src/components/editor/PropertyPanel.tsx +1 -1
  20. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  21. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  22. package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
  23. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  24. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  27. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  29. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  30. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  31. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  32. package/src/components/editor/propertyPanelTypes.ts +10 -4
  33. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  34. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  36. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  37. package/src/components/renders/useRenderQueue.ts +16 -3
  38. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  39. package/src/components/sidebar/AssetCard.tsx +4 -3
  40. package/src/components/sidebar/AudioRow.tsx +4 -3
  41. package/src/contexts/DomEditContext.tsx +1 -1
  42. package/src/contexts/FileManagerContext.tsx +12 -0
  43. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  44. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  45. package/src/hooks/externalFileReloadBus.ts +18 -0
  46. package/src/hooks/gsapDragCommit.test.ts +39 -1
  47. package/src/hooks/gsapDragCommit.ts +12 -40
  48. package/src/hooks/gsapEditOutcome.ts +72 -0
  49. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  50. package/src/hooks/gsapResizeIntercept.ts +44 -16
  51. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  52. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  53. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  54. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  55. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  56. package/src/hooks/useDomEditSession.test.tsx +1 -1
  57. package/src/hooks/useDomEditSession.ts +1 -1
  58. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  59. package/src/hooks/useDomGeometryCommits.ts +20 -0
  60. package/src/hooks/useEditorSave.test.tsx +131 -0
  61. package/src/hooks/useEditorSave.ts +113 -39
  62. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  63. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  64. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  65. package/src/hooks/useFileManager.ts +32 -3
  66. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  67. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  68. package/src/hooks/useGsapAwareEditing.ts +88 -27
  69. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  70. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  71. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  72. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  73. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  74. package/src/hooks/usePreviewPersistence.ts +22 -58
  75. package/src/hooks/useSdkSession.ts +8 -52
  76. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  77. package/src/hooks/useStudioSdkSessions.ts +2 -7
  78. package/src/hooks/useStudioSessionStart.ts +15 -0
  79. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  80. package/src/player/components/BeatStrip.test.tsx +374 -0
  81. package/src/player/components/BeatStrip.tsx +295 -37
  82. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  83. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  84. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  85. package/src/player/components/Timeline.test.ts +26 -8
  86. package/src/player/components/Timeline.tsx +87 -88
  87. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  88. package/src/player/components/TimelineCanvas.tsx +14 -71
  89. package/src/player/components/TimelineClip.test.tsx +17 -2
  90. package/src/player/components/TimelineClip.tsx +20 -5
  91. package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
  92. package/src/player/components/TimelineClipDiamonds.tsx +118 -223
  93. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  94. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  95. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  96. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  97. package/src/player/components/TimelineLanes.test.tsx +64 -7
  98. package/src/player/components/TimelineLanes.tsx +84 -66
  99. package/src/player/components/TimelineOverlays.test.ts +161 -0
  100. package/src/player/components/TimelineOverlays.tsx +104 -11
  101. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  102. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  103. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  104. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  105. package/src/player/components/TimelineTrackRow.tsx +58 -7
  106. package/src/player/components/timelineCallbacks.ts +1 -1
  107. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  108. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  109. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  110. package/src/player/components/timelineClipDragPreview.ts +18 -12
  111. package/src/player/components/timelineClipDragTypes.ts +12 -0
  112. package/src/player/components/timelineDiamondTypes.ts +18 -21
  113. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  114. package/src/player/components/timelineDragDrop.ts +152 -30
  115. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  116. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  117. package/src/player/components/timelineLaneProps.ts +3 -1
  118. package/src/player/components/timelineMarquee.test.ts +73 -25
  119. package/src/player/components/timelineMarquee.ts +44 -16
  120. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  121. package/src/player/components/timelineTestViewport.ts +13 -0
  122. package/src/player/components/timelineViewModel.ts +4 -4
  123. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  124. package/src/player/components/useTimelineClipDrag.ts +112 -70
  125. package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
  126. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  127. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  128. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  129. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  130. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  131. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  132. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  133. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  134. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  135. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  136. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  137. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  138. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  139. package/src/player/components/useTimelineRowVirtualization.ts +26 -29
  140. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  141. package/src/player/components/useTrackGapMenu.ts +32 -9
  142. package/src/player/store/keyframeSlice.ts +59 -18
  143. package/src/player/store/liveTime.ts +11 -0
  144. package/src/player/store/playerStore.test.ts +106 -23
  145. package/src/player/store/playerStore.ts +43 -40
  146. package/src/player/store/timelineFocusState.ts +15 -0
  147. package/src/telemetry/canary.test.ts +325 -0
  148. package/src/telemetry/canary.ts +268 -0
  149. package/src/telemetry/client.test.ts +8 -3
  150. package/src/telemetry/client.ts +13 -43
  151. package/src/telemetry/config.ts +16 -2
  152. package/src/telemetry/distinctId.test.ts +35 -0
  153. package/src/telemetry/distinctId.ts +11 -2
  154. package/src/telemetry/policy.test.ts +81 -0
  155. package/src/telemetry/policy.ts +104 -0
  156. package/src/utils/domEditSaveQueue.test.ts +37 -1
  157. package/src/utils/domEditSaveQueue.ts +22 -4
  158. package/src/utils/externalConflictStorage.test.ts +95 -0
  159. package/src/utils/externalConflictStorage.ts +172 -0
  160. package/src/utils/studioFileVersion.test.ts +28 -1
  161. package/src/utils/studioFileVersion.ts +30 -0
  162. package/src/utils/studioPendingEdits.test.ts +108 -1
  163. package/src/utils/studioPendingEdits.ts +39 -2
  164. package/src/utils/studioSaveDiagnostics.ts +5 -0
  165. package/src/utils/studioTelemetry.test.ts +76 -0
  166. package/src/utils/studioTelemetry.ts +14 -6
  167. package/dist/assets/index-BXkzp_OU.css +0 -1
  168. package/dist/assets/index-Cql15Yur.js +0 -428
  169. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  170. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -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
  }