diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index c4da46f5e3..f06beb639b 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -245,19 +245,19 @@ 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, - onCompositionDrop: pinnedOnCompositionDrop, - }); + const assetDrop = useTimelineAssetDrop({ + scrollRef, + ppsRef, + durationRef, + trackOrderRef, + rowGeometryRef, + contentOrigin, + onFileDrop: pinnedOnFileDrop, + onAssetDrop: pinnedOnAssetDrop, + onBlockDrop: pinnedOnBlockDrop, + onCompositionDrop: pinnedOnCompositionDrop, + sessionEpoch, + }); const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry); const resizingElementIds = resizingClip?.groupPreview?.map((change) => change.key) ?? @@ -436,11 +436,11 @@ export const Timeline = memo(function Timeline({ if (!timelineReady || expandedElements.length === 0) { return ( clearDropPreview()} - onDrop={handleAssetDrop} + onDragOver={assetDrop.handleAssetDragOver} + onDragLeave={assetDrop.handleAssetDragLeave} + onDrop={assetDrop.handleAssetDrop} /> ); } @@ -450,7 +450,7 @@ export const Timeline = memo(function Timeline({ ref={setContainerRef} aria-label="Timeline" 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} onMouseLeave={clearRazorGuide} style={{ @@ -472,9 +472,9 @@ export const Timeline = memo(function Timeline({ syncScrollViewport(e.currentTarget, true); }} {...rowWindow.timelineFocusProps} - 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. diff --git a/packages/studio/src/player/components/timelineDragDrop.test.tsx b/packages/studio/src/player/components/timelineDragDrop.test.tsx new file mode 100644 index 0000000000..cde1a319c5 --- /dev/null +++ b/packages/studio/src/player/components/timelineDragDrop.test.tsx @@ -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, + sessionEpoch = 1, + options: { onBlockDrop?: ReturnType; 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 | 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 ? ( + + + + ) : ( + + ), + ); + 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()); + }); +}); diff --git a/packages/studio/src/player/components/timelineDragDrop.ts b/packages/studio/src/player/components/timelineDragDrop.ts index 8cd2bfbe78..389c8a84a1 100644 --- a/packages/studio/src/player/components/timelineDragDrop.ts +++ b/packages/studio/src/player/components/timelineDragDrop.ts @@ -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; @@ -15,6 +19,7 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks { trackOrderRef: RefObject; rowGeometryRef: RefObject; contentOrigin: number; + sessionEpoch: number; } type TimelinePlacement = { start: number; track: number }; @@ -29,12 +34,22 @@ function applyJsonDropPayload( pick: (parsed: Record) => string | undefined, apply: (value: string, placement: TimelinePlacement) => void, placement: TimelinePlacement, -): void { +): boolean { try { const value = pick(JSON.parse(raw) as Record); - if (value) apply(value, placement); + if (!value) return false; + apply(value, placement); + return true; + } catch { + return false; + } +} + +function invokeDropCallback(callback: () => Promise | void): void { + try { + void Promise.resolve(callback()).catch(() => undefined); } catch { - /* ignore malformed drag payloads */ + // 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) | 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(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, + }; }