mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
perf(studio): stabilize virtualized timeline drops (#2706)
This commit is contained in:
@@ -245,8 +245,7 @@ export const Timeline = memo(function Timeline({
|
|||||||
sessionEpoch,
|
sessionEpoch,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview } =
|
const assetDrop = useTimelineAssetDrop({
|
||||||
useTimelineAssetDrop({
|
|
||||||
scrollRef,
|
scrollRef,
|
||||||
ppsRef,
|
ppsRef,
|
||||||
durationRef,
|
durationRef,
|
||||||
@@ -257,6 +256,7 @@ export const Timeline = memo(function Timeline({
|
|||||||
onAssetDrop: pinnedOnAssetDrop,
|
onAssetDrop: pinnedOnAssetDrop,
|
||||||
onBlockDrop: pinnedOnBlockDrop,
|
onBlockDrop: pinnedOnBlockDrop,
|
||||||
onCompositionDrop: pinnedOnCompositionDrop,
|
onCompositionDrop: pinnedOnCompositionDrop,
|
||||||
|
sessionEpoch,
|
||||||
});
|
});
|
||||||
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
|
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
|
||||||
const resizingElementIds =
|
const resizingElementIds =
|
||||||
@@ -436,11 +436,11 @@ export const Timeline = memo(function Timeline({
|
|||||||
if (!timelineReady || expandedElements.length === 0) {
|
if (!timelineReady || expandedElements.length === 0) {
|
||||||
return (
|
return (
|
||||||
<TimelineEmptyState
|
<TimelineEmptyState
|
||||||
isDragOver={isDragOver}
|
isDragOver={assetDrop.isDragOver}
|
||||||
onFileDrop={!!onFileDrop}
|
onFileDrop={!!onFileDrop}
|
||||||
onDragOver={handleAssetDragOver}
|
onDragOver={assetDrop.handleAssetDragOver}
|
||||||
onDragLeave={() => clearDropPreview()}
|
onDragLeave={assetDrop.handleAssetDragLeave}
|
||||||
onDrop={handleAssetDrop}
|
onDrop={assetDrop.handleAssetDrop}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -450,7 +450,7 @@ export const Timeline = memo(function Timeline({
|
|||||||
ref={setContainerRef}
|
ref={setContainerRef}
|
||||||
aria-label="Timeline"
|
aria-label="Timeline"
|
||||||
data-timeline-element-count={expandedElements.length}
|
data-timeline-element-count={expandedElements.length}
|
||||||
className={`relative border-t select-none h-full overflow-hidden ${isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
className={`relative border-t select-none h-full overflow-hidden ${assetDrop.isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
||||||
onMouseMove={updateRazorGuide}
|
onMouseMove={updateRazorGuide}
|
||||||
onMouseLeave={clearRazorGuide}
|
onMouseLeave={clearRazorGuide}
|
||||||
style={{
|
style={{
|
||||||
@@ -472,9 +472,9 @@ export const Timeline = memo(function Timeline({
|
|||||||
syncScrollViewport(e.currentTarget, true);
|
syncScrollViewport(e.currentTarget, true);
|
||||||
}}
|
}}
|
||||||
{...rowWindow.timelineFocusProps}
|
{...rowWindow.timelineFocusProps}
|
||||||
onDragOver={handleAssetDragOver}
|
onDragOver={assetDrop.handleAssetDragOver}
|
||||||
onDragLeave={() => clearDropPreview()}
|
onDragLeave={assetDrop.handleAssetDragLeave}
|
||||||
onDrop={handleAssetDrop}
|
onDrop={assetDrop.handleAssetDrop}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
// Let interactive controls (keyframe nav/toggle, caret, inputs) handle
|
// Let interactive controls (keyframe nav/toggle, caret, inputs) handle
|
||||||
// their own clicks — scrubbing here would preventDefault and eat them.
|
// their own clicks — scrubbing here would preventDefault and eat them.
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
||||||
|
import { usePlayerStore } from "../store/playerStore";
|
||||||
|
import { createTimelineRowGeometry } from "./timelineLayout";
|
||||||
|
import { useTimelineAssetDrop } from "./timelineDragDrop";
|
||||||
|
import { configureTimelineTestViewport } from "./timelineTestViewport";
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
interface DropTransfer {
|
||||||
|
types: string[];
|
||||||
|
files: File[];
|
||||||
|
dropEffect: DataTransfer["dropEffect"];
|
||||||
|
getData: (type: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragEvent(transfer: DropTransfer, clientX: number, clientY: number): React.DragEvent {
|
||||||
|
return {
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
dataTransfer: transfer,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
} as unknown as React.DragEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetTransfer(payload: string): DropTransfer {
|
||||||
|
return {
|
||||||
|
types: [TIMELINE_ASSET_MIME],
|
||||||
|
files: [],
|
||||||
|
dropEffect: "none",
|
||||||
|
getData: (type) => (type === TIMELINE_ASSET_MIME ? payload : ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHarness(
|
||||||
|
onAssetDrop: ReturnType<typeof vi.fn>,
|
||||||
|
sessionEpoch = 1,
|
||||||
|
options: { onBlockDrop?: ReturnType<typeof vi.fn>; strict?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const tracks = Array.from({ length: 100 }, (_, index) => index);
|
||||||
|
const geometry = createTimelineRowGeometry(
|
||||||
|
tracks,
|
||||||
|
tracks.map(() => 48),
|
||||||
|
);
|
||||||
|
const scroll = document.createElement("div");
|
||||||
|
configureTimelineTestViewport(scroll, geometry.canvasHeight);
|
||||||
|
document.body.append(scroll);
|
||||||
|
const root = createRoot(document.createElement("div"));
|
||||||
|
let api: ReturnType<typeof useTimelineAssetDrop> | null = null;
|
||||||
|
|
||||||
|
function Probe({ epoch }: { epoch: number }) {
|
||||||
|
api = useTimelineAssetDrop({
|
||||||
|
scrollRef: { current: scroll },
|
||||||
|
ppsRef: { current: 40 },
|
||||||
|
durationRef: { current: 120 },
|
||||||
|
trackOrderRef: { current: tracks },
|
||||||
|
rowGeometryRef: { current: geometry },
|
||||||
|
contentOrigin: 0,
|
||||||
|
sessionEpoch: epoch,
|
||||||
|
onAssetDrop,
|
||||||
|
onBlockDrop: options.onBlockDrop,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderProbe = (epoch: number) =>
|
||||||
|
root.render(
|
||||||
|
options.strict ? (
|
||||||
|
<React.StrictMode>
|
||||||
|
<Probe epoch={epoch} />
|
||||||
|
</React.StrictMode>
|
||||||
|
) : (
|
||||||
|
<Probe epoch={epoch} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
act(() => renderProbe(sessionEpoch));
|
||||||
|
return {
|
||||||
|
scroll,
|
||||||
|
root,
|
||||||
|
get api() {
|
||||||
|
if (!api) throw new Error("drop harness did not render");
|
||||||
|
return api;
|
||||||
|
},
|
||||||
|
rerender(epoch: number) {
|
||||||
|
act(() => renderProbe(epoch));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
usePlayerStore.getState().reset();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useTimelineAssetDrop", () => {
|
||||||
|
it("edge-autoscrolls the sole timeline viewport while a supported asset is held", () => {
|
||||||
|
let frame: FrameRequestCallback | null = null;
|
||||||
|
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((callback) => {
|
||||||
|
frame = callback;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => undefined);
|
||||||
|
const view = renderHarness(vi.fn());
|
||||||
|
|
||||||
|
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 790, 120)));
|
||||||
|
expect(view.api.isDragOver).toBe(true);
|
||||||
|
expect(frame).not.toBeNull();
|
||||||
|
act(() => frame?.(0));
|
||||||
|
expect(view.scroll.scrollLeft).toBeGreaterThan(0);
|
||||||
|
expect(view.scroll.scrollTop).toBe(0);
|
||||||
|
|
||||||
|
act(() => view.api.clearDropPreview());
|
||||||
|
expect(view.api.isDragOver).toBe(false);
|
||||||
|
act(() => view.root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the drop actor while moving between descendants", () => {
|
||||||
|
const view = renderHarness(vi.fn());
|
||||||
|
const parent = document.createElement("div");
|
||||||
|
const child = document.createElement("div");
|
||||||
|
parent.append(child);
|
||||||
|
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
|
||||||
|
act(() =>
|
||||||
|
view.api.handleAssetDragLeave({
|
||||||
|
relatedTarget: child,
|
||||||
|
currentTarget: parent,
|
||||||
|
} as unknown as React.DragEvent),
|
||||||
|
);
|
||||||
|
expect(view.api.isDragOver).toBe(true);
|
||||||
|
act(() => view.root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops once on a model row outside the mounted window and appends below the last row", () => {
|
||||||
|
const onAssetDrop = vi.fn();
|
||||||
|
const view = renderHarness(onAssetDrop);
|
||||||
|
usePlayerStore.getState().setCurrentTime(12.5);
|
||||||
|
view.scroll.scrollTop = view.scroll.scrollHeight - view.scroll.clientHeight;
|
||||||
|
const transfer = assetTransfer(JSON.stringify({ path: "/media/hero.mp4" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
view.api.handleAssetDragOver(dragEvent(transfer, 400, 239));
|
||||||
|
view.api.handleAssetDrop(dragEvent(transfer, 400, 239));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onAssetDrop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onAssetDrop).toHaveBeenCalledWith("/media/hero.mp4", { start: 12.5, track: 100 });
|
||||||
|
expect(view.api.isDragOver).toBe(false);
|
||||||
|
act(() => view.root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores malformed payloads and clears the actor on project reset", () => {
|
||||||
|
const onAssetDrop = vi.fn();
|
||||||
|
const view = renderHarness(onAssetDrop, 1);
|
||||||
|
const transfer = assetTransfer("not-json");
|
||||||
|
|
||||||
|
act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, 100)));
|
||||||
|
expect(view.api.isDragOver).toBe(true);
|
||||||
|
view.rerender(2);
|
||||||
|
expect(view.api.isDragOver).toBe(false);
|
||||||
|
|
||||||
|
act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, 100)));
|
||||||
|
expect(onAssetDrop).not.toHaveBeenCalled();
|
||||||
|
act(() => view.root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls through a malformed asset payload to a valid block payload", () => {
|
||||||
|
const onAssetDrop = vi.fn();
|
||||||
|
const onBlockDrop = vi.fn();
|
||||||
|
const view = renderHarness(onAssetDrop, 1, { onBlockDrop });
|
||||||
|
const transfer: DropTransfer = {
|
||||||
|
types: [TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME],
|
||||||
|
files: [],
|
||||||
|
dropEffect: "none",
|
||||||
|
getData: (type) =>
|
||||||
|
type === TIMELINE_ASSET_MIME
|
||||||
|
? "not-json"
|
||||||
|
: type === TIMELINE_BLOCK_MIME
|
||||||
|
? JSON.stringify({ name: "title-card" })
|
||||||
|
: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
view.api.handleAssetDragOver(dragEvent(transfer, 400, 100));
|
||||||
|
view.api.handleAssetDrop(dragEvent(transfer, 400, 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onAssetDrop).not.toHaveBeenCalled();
|
||||||
|
expect(onBlockDrop).toHaveBeenCalledExactlyOnceWith("title-card", { start: 0, track: 0 });
|
||||||
|
act(() => view.root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears an escaped drag after StrictMode effect replay", () => {
|
||||||
|
const view = renderHarness(vi.fn(), 1, { strict: true });
|
||||||
|
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
|
||||||
|
expect(view.api.isDragOver).toBe(true);
|
||||||
|
|
||||||
|
act(() => window.dispatchEvent(new Event("dragend")));
|
||||||
|
expect(view.api.isDragOver).toBe(false);
|
||||||
|
act(() => view.root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState, type RefObject } from "react";
|
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||||
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
||||||
import {
|
import {
|
||||||
parseTimelineCompositionPayload,
|
parseTimelineCompositionPayload,
|
||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
import { usePlayerStore } from "../store/playerStore";
|
import { usePlayerStore } from "../store/playerStore";
|
||||||
import { resolveTimelineAssetDrop, type TimelineRowGeometry } from "./timelineLayout";
|
import { resolveTimelineAssetDrop, type TimelineRowGeometry } from "./timelineLayout";
|
||||||
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
||||||
|
import {
|
||||||
|
applyTimelineAutoScrollStep,
|
||||||
|
resolveTimelineAutoScrollLoopAction,
|
||||||
|
} from "./timelineEditing";
|
||||||
|
|
||||||
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
||||||
scrollRef: RefObject<HTMLDivElement | null>;
|
scrollRef: RefObject<HTMLDivElement | null>;
|
||||||
@@ -15,6 +19,7 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
|||||||
trackOrderRef: RefObject<number[]>;
|
trackOrderRef: RefObject<number[]>;
|
||||||
rowGeometryRef: RefObject<TimelineRowGeometry>;
|
rowGeometryRef: RefObject<TimelineRowGeometry>;
|
||||||
contentOrigin: number;
|
contentOrigin: number;
|
||||||
|
sessionEpoch: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TimelinePlacement = { start: number; track: number };
|
type TimelinePlacement = { start: number; track: number };
|
||||||
@@ -29,12 +34,22 @@ function applyJsonDropPayload(
|
|||||||
pick: (parsed: Record<string, string | undefined>) => string | undefined,
|
pick: (parsed: Record<string, string | undefined>) => string | undefined,
|
||||||
apply: (value: string, placement: TimelinePlacement) => void,
|
apply: (value: string, placement: TimelinePlacement) => void,
|
||||||
placement: TimelinePlacement,
|
placement: TimelinePlacement,
|
||||||
): void {
|
): boolean {
|
||||||
try {
|
try {
|
||||||
const value = pick(JSON.parse(raw) as Record<string, string | undefined>);
|
const value = pick(JSON.parse(raw) as Record<string, string | undefined>);
|
||||||
if (value) apply(value, placement);
|
if (!value) return false;
|
||||||
|
apply(value, placement);
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore malformed drag payloads */
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invokeDropCallback(callback: () => Promise<void> | void): void {
|
||||||
|
try {
|
||||||
|
void Promise.resolve(callback()).catch(() => undefined);
|
||||||
|
} catch {
|
||||||
|
// A rejected external producer never keeps a timeline drop actor alive.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +58,34 @@ function resolveDropStart(usePointerStart: boolean, pointerStart: number): numbe
|
|||||||
return Math.max(0, usePlayerStore.getState().currentTime);
|
return Math.max(0, usePlayerStore.getState().currentTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyFileDrop(
|
||||||
|
transfer: DataTransfer,
|
||||||
|
onFileDrop: TimelineDropCallbacks["onFileDrop"],
|
||||||
|
placement: TimelinePlacement,
|
||||||
|
): boolean {
|
||||||
|
if (!onFileDrop || transfer.files.length === 0) return false;
|
||||||
|
invokeDropCallback(() => onFileDrop(Array.from(transfer.files), placement));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTypedJsonDrop(
|
||||||
|
transfer: DataTransfer,
|
||||||
|
mime: string,
|
||||||
|
field: "name" | "path",
|
||||||
|
apply: ((value: string, placement: TimelinePlacement) => Promise<void> | void) | undefined,
|
||||||
|
placement: TimelinePlacement,
|
||||||
|
): boolean {
|
||||||
|
if (!apply || !Array.from(transfer.types).includes(mime)) return false;
|
||||||
|
const payload = transfer.getData(mime);
|
||||||
|
if (!payload) return false;
|
||||||
|
return applyJsonDropPayload(
|
||||||
|
payload,
|
||||||
|
(parsed) => parsed[field],
|
||||||
|
(value, nextPlacement) => invokeDropCallback(() => apply(value, nextPlacement)),
|
||||||
|
placement,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dropping an asset/file/block onto the timeline places it at the PLAYHEAD —
|
* Dropping an asset/file/block onto the timeline places it at the PLAYHEAD —
|
||||||
* start is the current playhead time, only the track comes from the drop y.
|
* start is the current playhead time, only the track comes from the drop y.
|
||||||
@@ -62,10 +105,55 @@ export function useTimelineAssetDrop({
|
|||||||
onAssetDrop,
|
onAssetDrop,
|
||||||
onBlockDrop,
|
onBlockDrop,
|
||||||
onCompositionDrop,
|
onCompositionDrop,
|
||||||
|
sessionEpoch,
|
||||||
}: UseTimelineAssetDropOptions) {
|
}: UseTimelineAssetDropOptions) {
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const dragPointerRef = useRef<{ clientX: number; clientY: number; sessionEpoch: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const autoScrollRafRef = useRef(0);
|
||||||
|
const activeDropEpochRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const handleAssetDragOver = useCallback((e: React.DragEvent) => {
|
const stopAutoScroll = useCallback(() => {
|
||||||
|
dragPointerRef.current = null;
|
||||||
|
if (autoScrollRafRef.current) cancelAnimationFrame(autoScrollRafRef.current);
|
||||||
|
autoScrollRafRef.current = 0;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stepAutoScroll = useCallback(
|
||||||
|
function stepAutoScroll() {
|
||||||
|
autoScrollRafRef.current = 0;
|
||||||
|
const pointer = dragPointerRef.current;
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
if (!pointer || pointer.sessionEpoch !== sessionEpoch || !scroll) return;
|
||||||
|
if (!applyTimelineAutoScrollStep(scroll, pointer.clientX, pointer.clientY)) return;
|
||||||
|
autoScrollRafRef.current = requestAnimationFrame(stepAutoScroll);
|
||||||
|
},
|
||||||
|
[scrollRef, sessionEpoch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncAutoScroll = useCallback(
|
||||||
|
(clientX: number, clientY: number) => {
|
||||||
|
dragPointerRef.current = { clientX, clientY, sessionEpoch };
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
const action = resolveTimelineAutoScrollLoopAction(
|
||||||
|
scroll,
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
autoScrollRafRef.current !== 0,
|
||||||
|
);
|
||||||
|
if (action === "stop") {
|
||||||
|
cancelAnimationFrame(autoScrollRafRef.current);
|
||||||
|
autoScrollRafRef.current = 0;
|
||||||
|
} else if (action === "start") {
|
||||||
|
autoScrollRafRef.current = requestAnimationFrame(stepAutoScroll);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollRef, sessionEpoch, stepAutoScroll],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAssetDragOver = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
const types = Array.from(e.dataTransfer.types);
|
const types = Array.from(e.dataTransfer.types);
|
||||||
const hasFiles = types.includes("Files");
|
const hasFiles = types.includes("Files");
|
||||||
const hasAsset = types.includes(TIMELINE_ASSET_MIME);
|
const hasAsset = types.includes(TIMELINE_ASSET_MIME);
|
||||||
@@ -74,10 +162,27 @@ export function useTimelineAssetDrop({
|
|||||||
if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return;
|
if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "copy";
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
activeDropEpochRef.current = sessionEpoch;
|
||||||
setIsDragOver(true);
|
setIsDragOver(true);
|
||||||
}, []);
|
syncAutoScroll(e.clientX, e.clientY);
|
||||||
|
},
|
||||||
|
[sessionEpoch, syncAutoScroll],
|
||||||
|
);
|
||||||
|
|
||||||
const clearDropPreview = useCallback(() => setIsDragOver(false), []);
|
const clearDropPreview = useCallback(() => {
|
||||||
|
activeDropEpochRef.current = null;
|
||||||
|
stopAutoScroll();
|
||||||
|
setIsDragOver(false);
|
||||||
|
}, [stopAutoScroll]);
|
||||||
|
|
||||||
|
const handleAssetDragLeave = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
const related = e.relatedTarget;
|
||||||
|
if (related instanceof Node && e.currentTarget.contains(related)) return;
|
||||||
|
clearDropPreview();
|
||||||
|
},
|
||||||
|
[clearDropPreview],
|
||||||
|
);
|
||||||
|
|
||||||
const resolveDropPlacement = useCallback(
|
const resolveDropPlacement = useCallback(
|
||||||
(clientX: number, clientY: number, usePointerStart = false): TimelinePlacement => {
|
(clientX: number, clientY: number, usePointerStart = false): TimelinePlacement => {
|
||||||
@@ -110,33 +215,50 @@ export function useTimelineAssetDrop({
|
|||||||
const handleAssetDrop = useCallback(
|
const handleAssetDrop = useCallback(
|
||||||
(e: React.DragEvent) => {
|
(e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragOver(false);
|
const canCommit = activeDropEpochRef.current === sessionEpoch;
|
||||||
|
clearDropPreview();
|
||||||
|
if (!canCommit) return;
|
||||||
const compositionPayload = parseTimelineCompositionPayload(
|
const compositionPayload = parseTimelineCompositionPayload(
|
||||||
e.dataTransfer.getData(TIMELINE_COMPOSITION_MIME),
|
e.dataTransfer.getData(TIMELINE_COMPOSITION_MIME),
|
||||||
);
|
);
|
||||||
if (compositionPayload && onCompositionDrop) {
|
if (compositionPayload && onCompositionDrop) {
|
||||||
const placement = resolveDropPlacement(e.clientX, e.clientY, true);
|
const placement = resolveDropPlacement(e.clientX, e.clientY, true);
|
||||||
void onCompositionDrop(compositionPayload.sourcePath, placement);
|
invokeDropCallback(() => onCompositionDrop(compositionPayload.sourcePath, placement));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const placement = resolveDropPlacement(e.clientX, e.clientY);
|
const placement = resolveDropPlacement(e.clientX, e.clientY);
|
||||||
|
|
||||||
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
if (applyFileDrop(e.dataTransfer, onFileDrop, placement)) return;
|
||||||
void onFileDrop(Array.from(e.dataTransfer.files), placement);
|
if (applyTypedJsonDrop(e.dataTransfer, TIMELINE_ASSET_MIME, "path", onAssetDrop, placement)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const assetPayload = e.dataTransfer.getData(TIMELINE_ASSET_MIME);
|
applyTypedJsonDrop(e.dataTransfer, TIMELINE_BLOCK_MIME, "name", onBlockDrop, placement);
|
||||||
if (assetPayload && onAssetDrop) {
|
|
||||||
applyJsonDropPayload(assetPayload, (p) => p.path, onAssetDrop, placement);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const blockPayload = e.dataTransfer.getData(TIMELINE_BLOCK_MIME);
|
|
||||||
if (blockPayload && onBlockDrop) {
|
|
||||||
applyJsonDropPayload(blockPayload, (p) => p.name, onBlockDrop, placement);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[resolveDropPlacement, onFileDrop, onAssetDrop, onBlockDrop, onCompositionDrop],
|
[
|
||||||
|
clearDropPreview,
|
||||||
|
onAssetDrop,
|
||||||
|
onBlockDrop,
|
||||||
|
onCompositionDrop,
|
||||||
|
onFileDrop,
|
||||||
|
resolveDropPlacement,
|
||||||
|
sessionEpoch,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview };
|
useEffect(() => {
|
||||||
|
window.addEventListener("dragend", clearDropPreview);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("dragend", clearDropPreview);
|
||||||
|
clearDropPreview();
|
||||||
|
};
|
||||||
|
}, [clearDropPreview]);
|
||||||
|
useEffect(() => clearDropPreview(), [clearDropPreview, sessionEpoch]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDragOver,
|
||||||
|
handleAssetDragOver,
|
||||||
|
handleAssetDragLeave,
|
||||||
|
handleAssetDrop,
|
||||||
|
clearDropPreview,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user