@hyperframes/studio 0.7.89 → 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.
- package/dist/assets/{hyperframes-player-DVTnBvSO.js → hyperframes-player-9qOa08Tv.js} +1 -1
- package/dist/assets/{index-Qvx7boWH.js → index-Bw4lNdtO.js} +1 -1
- package/dist/assets/{index-ulQEdWKo.js → index-CDTN2ZVa.js} +1 -1
- package/dist/assets/index-DIntkrQl.js +428 -0
- package/dist/index.d.ts +20 -16
- package/dist/index.html +1 -1
- package/dist/index.js +6495 -5429
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +1 -1
- package/src/components/editor/GsapAnimationList.tsx +84 -0
- package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
- package/src/components/editor/GsapAnimationSection.tsx +11 -41
- package/src/components/editor/PropertyPanel.tsx +1 -1
- package/src/components/editor/PropertyPanelFlat.tsx +29 -26
- package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
- package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
- package/src/components/editor/propertyPanelTypes.ts +1 -1
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
- package/src/components/renders/useRenderQueue.ts +16 -3
- package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/hooks/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
- package/src/hooks/useGsapKeyframeOps.ts +20 -9
- package/src/player/components/BeatStrip.test.tsx +374 -0
- package/src/player/components/BeatStrip.tsx +295 -37
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
- package/src/player/components/Timeline.test.ts +2 -3
- package/src/player/components/Timeline.tsx +47 -48
- package/src/player/components/TimelineCanvas.tsx +14 -71
- package/src/player/components/TimelineClip.tsx +6 -3
- package/src/player/components/TimelineClipDiamonds.test.tsx +442 -18
- package/src/player/components/TimelineClipDiamonds.tsx +66 -211
- package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
- package/src/player/components/TimelineGestureOverlay.tsx +96 -0
- package/src/player/components/TimelineLanes.test.tsx +0 -1
- package/src/player/components/TimelineLanes.tsx +3 -2
- package/src/player/components/TimelineOverlays.test.ts +161 -0
- package/src/player/components/TimelineOverlays.tsx +104 -11
- package/src/player/components/timelineCallbacks.ts +1 -1
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
- package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
- package/src/player/components/timelineClipDragPreview.test.ts +33 -0
- package/src/player/components/timelineClipDragPreview.ts +18 -12
- package/src/player/components/timelineClipDragTypes.ts +12 -0
- package/src/player/components/timelineDiamondTypes.ts +5 -15
- package/src/player/components/timelineDragDrop.test.tsx +206 -0
- package/src/player/components/timelineDragDrop.ts +152 -30
- package/src/player/components/timelineLaneProps.ts +0 -1
- package/src/player/components/timelineMarquee.test.ts +73 -25
- package/src/player/components/timelineMarquee.ts +44 -16
- package/src/player/components/timelineTestViewport.ts +13 -0
- package/src/player/components/timelineViewModel.ts +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
- package/src/player/components/useTimelineClipDrag.ts +112 -70
- package/src/player/components/useTimelineClipRenderWindow.ts +4 -4
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
- package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
- package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
- package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
- package/src/player/components/useTimelineRangeSelection.ts +202 -69
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRowVirtualization.ts +11 -4
- package/src/player/components/useTrackGapMenu.test.tsx +64 -0
- package/src/player/components/useTrackGapMenu.ts +32 -9
- package/src/player/store/keyframeSlice.ts +59 -18
- package/src/player/store/playerStore.test.ts +76 -0
- package/src/player/store/playerStore.ts +19 -11
- package/src/telemetry/canary.test.ts +325 -0
- package/src/telemetry/canary.ts +268 -0
- package/src/telemetry/client.test.ts +8 -3
- package/src/telemetry/client.ts +13 -43
- package/src/telemetry/config.ts +16 -2
- package/src/telemetry/distinctId.test.ts +35 -0
- package/src/telemetry/distinctId.ts +11 -2
- package/src/telemetry/policy.test.ts +81 -0
- package/src/telemetry/policy.ts +104 -0
- package/src/utils/studioTelemetry.test.ts +76 -0
- package/src/utils/studioTelemetry.ts +14 -6
- package/dist/assets/index-CddKwfQ1.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
|
-
):
|
|
37
|
+
): boolean {
|
|
33
38
|
try {
|
|
34
39
|
const value = pick(JSON.parse(raw) as Record<string, string | undefined>);
|
|
35
|
-
if (value)
|
|
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
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
226
|
+
invokeDropCallback(() => onCompositionDrop(compositionPayload.sourcePath, placement));
|
|
120
227
|
return;
|
|
121
228
|
}
|
|
122
229
|
const placement = resolveDropPlacement(e.clientX, e.clientY);
|
|
123
230
|
|
|
124
|
-
if (
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
237
|
+
[
|
|
238
|
+
clearDropPreview,
|
|
239
|
+
onAssetDrop,
|
|
240
|
+
onBlockDrop,
|
|
241
|
+
onCompositionDrop,
|
|
242
|
+
onFileDrop,
|
|
243
|
+
resolveDropPlacement,
|
|
244
|
+
sessionEpoch,
|
|
245
|
+
],
|
|
139
246
|
);
|
|
140
247
|
|
|
141
|
-
|
|
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 },
|
|
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 },
|
|
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 },
|
|
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
|
-
|
|
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:
|
|
139
|
+
height: TRACK_H - CLIP_Y * 2,
|
|
132
140
|
});
|
|
133
141
|
expect(
|
|
134
|
-
getTimelineClipRect({ start: 0, duration: 1, track: 2 },
|
|
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 },
|
|
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 },
|
|
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 },
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
249
|
+
computeMarqueeSelection({ clips, rowGeometry, pps, contentOrigin: ORIGIN, marquee: wide })
|
|
250
|
+
.ids,
|
|
244
251
|
).toEqual(new Set(["a", "b"]));
|
|
245
252
|
expect(
|
|
246
|
-
computeMarqueeSelection({ clips,
|
|
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
|
-
|
|
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
|
+
});
|