mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
perf(studio): stabilize virtualized timeline drops (#2706)
This commit is contained in:
@@ -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 {
|
||||
parseTimelineCompositionPayload,
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { resolveTimelineAssetDrop, type TimelineRowGeometry } from "./timelineLayout";
|
||||
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
||||
import {
|
||||
applyTimelineAutoScrollStep,
|
||||
resolveTimelineAutoScrollLoopAction,
|
||||
} from "./timelineEditing";
|
||||
|
||||
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
@@ -15,6 +19,7 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
||||
trackOrderRef: RefObject<number[]>;
|
||||
rowGeometryRef: RefObject<TimelineRowGeometry>;
|
||||
contentOrigin: number;
|
||||
sessionEpoch: number;
|
||||
}
|
||||
|
||||
type TimelinePlacement = { start: number; track: number };
|
||||
@@ -29,12 +34,22 @@ function applyJsonDropPayload(
|
||||
pick: (parsed: Record<string, string | undefined>) => string | undefined,
|
||||
apply: (value: string, placement: TimelinePlacement) => void,
|
||||
placement: TimelinePlacement,
|
||||
): void {
|
||||
): boolean {
|
||||
try {
|
||||
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 {
|
||||
/* 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);
|
||||
}
|
||||
|
||||
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 —
|
||||
* start is the current playhead time, only the track comes from the drop y.
|
||||
@@ -62,22 +105,84 @@ export function useTimelineAssetDrop({
|
||||
onAssetDrop,
|
||||
onBlockDrop,
|
||||
onCompositionDrop,
|
||||
sessionEpoch,
|
||||
}: UseTimelineAssetDropOptions) {
|
||||
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 types = Array.from(e.dataTransfer.types);
|
||||
const hasFiles = types.includes("Files");
|
||||
const hasAsset = types.includes(TIMELINE_ASSET_MIME);
|
||||
const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
|
||||
const hasComposition = types.includes(TIMELINE_COMPOSITION_MIME);
|
||||
if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOver(true);
|
||||
const stopAutoScroll = useCallback(() => {
|
||||
dragPointerRef.current = null;
|
||||
if (autoScrollRafRef.current) cancelAnimationFrame(autoScrollRafRef.current);
|
||||
autoScrollRafRef.current = 0;
|
||||
}, []);
|
||||
|
||||
const clearDropPreview = useCallback(() => setIsDragOver(false), []);
|
||||
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 hasFiles = types.includes("Files");
|
||||
const hasAsset = types.includes(TIMELINE_ASSET_MIME);
|
||||
const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
|
||||
const hasComposition = types.includes(TIMELINE_COMPOSITION_MIME);
|
||||
if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
activeDropEpochRef.current = sessionEpoch;
|
||||
setIsDragOver(true);
|
||||
syncAutoScroll(e.clientX, e.clientY);
|
||||
},
|
||||
[sessionEpoch, syncAutoScroll],
|
||||
);
|
||||
|
||||
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(
|
||||
(clientX: number, clientY: number, usePointerStart = false): TimelinePlacement => {
|
||||
@@ -110,33 +215,50 @@ export function useTimelineAssetDrop({
|
||||
const handleAssetDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const canCommit = activeDropEpochRef.current === sessionEpoch;
|
||||
clearDropPreview();
|
||||
if (!canCommit) return;
|
||||
const compositionPayload = parseTimelineCompositionPayload(
|
||||
e.dataTransfer.getData(TIMELINE_COMPOSITION_MIME),
|
||||
);
|
||||
if (compositionPayload && onCompositionDrop) {
|
||||
const placement = resolveDropPlacement(e.clientX, e.clientY, true);
|
||||
void onCompositionDrop(compositionPayload.sourcePath, placement);
|
||||
invokeDropCallback(() => onCompositionDrop(compositionPayload.sourcePath, placement));
|
||||
return;
|
||||
}
|
||||
const placement = resolveDropPlacement(e.clientX, e.clientY);
|
||||
|
||||
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
||||
void onFileDrop(Array.from(e.dataTransfer.files), placement);
|
||||
if (applyFileDrop(e.dataTransfer, onFileDrop, placement)) return;
|
||||
if (applyTypedJsonDrop(e.dataTransfer, TIMELINE_ASSET_MIME, "path", onAssetDrop, placement)) {
|
||||
return;
|
||||
}
|
||||
const assetPayload = e.dataTransfer.getData(TIMELINE_ASSET_MIME);
|
||||
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);
|
||||
}
|
||||
applyTypedJsonDrop(e.dataTransfer, TIMELINE_BLOCK_MIME, "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