mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-12 20:47:30 +00:00
perf(studio): stabilize virtualized timeline drops
This commit is contained in:
@@ -252,18 +252,15 @@ export const Timeline = memo(function Timeline({
|
||||
sessionEpoch,
|
||||
});
|
||||
|
||||
const { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview } =
|
||||
useTimelineAssetDrop({
|
||||
scrollRef,
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
rowGeometryRef,
|
||||
contentOrigin,
|
||||
onFileDrop: pinnedOnFileDrop,
|
||||
onAssetDrop: pinnedOnAssetDrop,
|
||||
onBlockDrop: pinnedOnBlockDrop,
|
||||
});
|
||||
const assetDrop = useTimelineAssetDrop({
|
||||
scrollRef,
|
||||
trackOrderRef,
|
||||
rowGeometryRef,
|
||||
onFileDrop: pinnedOnFileDrop,
|
||||
onAssetDrop: pinnedOnAssetDrop,
|
||||
onBlockDrop: pinnedOnBlockDrop,
|
||||
sessionEpoch,
|
||||
});
|
||||
|
||||
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
|
||||
const { viewport, showShortcutHint, setScrollRef, syncScrollViewport } =
|
||||
@@ -423,11 +420,11 @@ export const Timeline = memo(function Timeline({
|
||||
if (!timelineReady || expandedElements.length === 0) {
|
||||
return (
|
||||
<TimelineEmptyState
|
||||
isDragOver={isDragOver}
|
||||
isDragOver={assetDrop.isDragOver}
|
||||
onFileDrop={!!onFileDrop}
|
||||
onDragOver={handleAssetDragOver}
|
||||
onDragLeave={() => clearDropPreview()}
|
||||
onDrop={handleAssetDrop}
|
||||
onDragOver={assetDrop.handleAssetDragOver}
|
||||
onDragLeave={assetDrop.handleAssetDragLeave}
|
||||
onDrop={assetDrop.handleAssetDrop}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -460,9 +457,9 @@ export const Timeline = memo(function Timeline({
|
||||
lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload
|
||||
syncScrollViewport(e.currentTarget, true);
|
||||
}}
|
||||
onDragOver={handleAssetDragOver}
|
||||
onDragLeave={() => clearDropPreview()}
|
||||
onDrop={handleAssetDrop}
|
||||
onDragOver={assetDrop.handleAssetDragOver}
|
||||
onDragLeave={assetDrop.handleAssetDragLeave}
|
||||
onDrop={assetDrop.handleAssetDrop}
|
||||
onPointerDown={(e) => {
|
||||
// Let interactive controls (keyframe nav/toggle, caret, inputs) handle
|
||||
// their own clicks — scrubbing here would preventDefault and eat them.
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// @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 } from "../../utils/timelineAssetDrop";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { createTimelineRowGeometry } from "./timelineLayout";
|
||||
import { useTimelineAssetDrop } from "./timelineDragDrop";
|
||||
|
||||
(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) {
|
||||
const tracks = Array.from({ length: 100 }, (_, index) => index);
|
||||
const geometry = createTimelineRowGeometry(
|
||||
tracks,
|
||||
tracks.map(() => 48),
|
||||
);
|
||||
const scroll = document.createElement("div");
|
||||
scroll.getBoundingClientRect = () =>
|
||||
({ left: 0, top: 0, right: 800, bottom: 240, width: 800, height: 240 }) as DOMRect;
|
||||
Object.defineProperties(scroll, {
|
||||
scrollLeft: { configurable: true, writable: true, value: 0 },
|
||||
scrollTop: { configurable: true, writable: true, value: 0 },
|
||||
scrollWidth: { configurable: true, value: 10_000 },
|
||||
scrollHeight: { configurable: true, value: geometry.canvasHeight },
|
||||
clientWidth: { configurable: true, value: 800 },
|
||||
clientHeight: { configurable: true, value: 240 },
|
||||
});
|
||||
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 },
|
||||
trackOrderRef: { current: tracks },
|
||||
rowGeometryRef: { current: geometry },
|
||||
sessionEpoch: epoch,
|
||||
onAssetDrop,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
act(() => root.render(<Probe epoch={sessionEpoch} />));
|
||||
return {
|
||||
scroll,
|
||||
root,
|
||||
get api() {
|
||||
if (!api) throw new Error("drop harness did not render");
|
||||
return api;
|
||||
},
|
||||
rerender(epoch: number) {
|
||||
act(() => root.render(<Probe epoch={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());
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,18 @@
|
||||
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 { usePlayerStore } from "../store/playerStore";
|
||||
import { resolveTimelineAssetDrop, type TimelineRowGeometry } from "./timelineLayout";
|
||||
import { getDefaultDroppedTrack, type TimelineRowGeometry } from "./timelineLayout";
|
||||
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
||||
import {
|
||||
applyTimelineAutoScrollStep,
|
||||
resolveTimelineAutoScrollLoopAction,
|
||||
} from "./timelineEditing";
|
||||
|
||||
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
ppsRef: RefObject<number>;
|
||||
durationRef: RefObject<number>;
|
||||
trackOrderRef: RefObject<number[]>;
|
||||
rowGeometryRef: RefObject<TimelineRowGeometry>;
|
||||
contentOrigin: number;
|
||||
sessionEpoch: number;
|
||||
}
|
||||
|
||||
type TimelinePlacement = { start: number; track: number };
|
||||
@@ -25,12 +27,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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,78 +56,153 @@ function applyJsonDropPayload(
|
||||
*/
|
||||
export function useTimelineAssetDrop({
|
||||
scrollRef,
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
rowGeometryRef,
|
||||
contentOrigin,
|
||||
onFileDrop,
|
||||
onAssetDrop,
|
||||
onBlockDrop,
|
||||
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);
|
||||
if (!hasFiles && !hasAsset && !hasBlock) 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 resolveDropPlacement = useCallback(
|
||||
(clientX: number, clientY: number): TimelinePlacement => {
|
||||
const stepAutoScroll = useCallback(
|
||||
function stepAutoScroll() {
|
||||
autoScrollRafRef.current = 0;
|
||||
const pointer = dragPointerRef.current;
|
||||
const scroll = scrollRef.current;
|
||||
const rect = scroll?.getBoundingClientRect();
|
||||
// Track comes from the vertical drop position; start is the playhead.
|
||||
const { track } = resolveTimelineAssetDrop(
|
||||
{
|
||||
rectLeft: rect?.left ?? 0,
|
||||
rectTop: rect?.top ?? 0,
|
||||
scrollLeft: scroll?.scrollLeft ?? 0,
|
||||
scrollTop: scroll?.scrollTop ?? 0,
|
||||
contentOrigin,
|
||||
pixelsPerSecond: ppsRef.current,
|
||||
duration: durationRef.current,
|
||||
rowHeights: rowGeometryRef.current.rowHeights,
|
||||
trackOrder: trackOrderRef.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);
|
||||
if (!hasFiles && !hasAsset && !hasBlock) 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): TimelinePlacement => {
|
||||
const scroll = scrollRef.current;
|
||||
const rect = scroll?.getBoundingClientRect();
|
||||
const contentY = clientY - (rect?.top ?? 0) + (scroll?.scrollTop ?? 0);
|
||||
const row = Math.floor(rowGeometryRef.current.getRowFromY(contentY));
|
||||
const track = getDefaultDroppedTrack(trackOrderRef.current, row);
|
||||
const start = Math.max(0, usePlayerStore.getState().currentTime);
|
||||
return { start, track };
|
||||
},
|
||||
[scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, contentOrigin],
|
||||
[scrollRef, trackOrderRef, rowGeometryRef],
|
||||
);
|
||||
|
||||
const handleAssetDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const canCommit = activeDropEpochRef.current === sessionEpoch;
|
||||
clearDropPreview();
|
||||
if (!canCommit) return;
|
||||
const placement = resolveDropPlacement(e.clientX, e.clientY);
|
||||
|
||||
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
||||
void onFileDrop(Array.from(e.dataTransfer.files), placement);
|
||||
invokeDropCallback(() => onFileDrop(Array.from(e.dataTransfer.files), placement));
|
||||
return;
|
||||
}
|
||||
const types = Array.from(e.dataTransfer.types);
|
||||
const assetPayload = e.dataTransfer.getData(TIMELINE_ASSET_MIME);
|
||||
if (assetPayload && onAssetDrop) {
|
||||
applyJsonDropPayload(assetPayload, (p) => p.path, onAssetDrop, placement);
|
||||
if (types.includes(TIMELINE_ASSET_MIME)) {
|
||||
if (assetPayload && onAssetDrop) {
|
||||
applyJsonDropPayload(
|
||||
assetPayload,
|
||||
(p) => p.path,
|
||||
(path, nextPlacement) => invokeDropCallback(() => onAssetDrop(path, nextPlacement)),
|
||||
placement,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const blockPayload = e.dataTransfer.getData(TIMELINE_BLOCK_MIME);
|
||||
if (blockPayload && onBlockDrop) {
|
||||
applyJsonDropPayload(blockPayload, (p) => p.name, onBlockDrop, placement);
|
||||
if (types.includes(TIMELINE_BLOCK_MIME)) {
|
||||
if (blockPayload && onBlockDrop) {
|
||||
applyJsonDropPayload(
|
||||
blockPayload,
|
||||
(p) => p.name,
|
||||
(name, nextPlacement) => invokeDropCallback(() => onBlockDrop(name, nextPlacement)),
|
||||
placement,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[resolveDropPlacement, onFileDrop, onAssetDrop, onBlockDrop],
|
||||
[clearDropPreview, onAssetDrop, onBlockDrop, onFileDrop, resolveDropPlacement, sessionEpoch],
|
||||
);
|
||||
|
||||
return { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview };
|
||||
useEffect(() => clearDropPreview, [clearDropPreview]);
|
||||
useEffect(() => {
|
||||
stopAutoScroll();
|
||||
setIsDragOver(false);
|
||||
}, [sessionEpoch, stopAutoScroll]);
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
handleAssetDragOver,
|
||||
handleAssetDragLeave,
|
||||
handleAssetDrop,
|
||||
clearDropPreview,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user