import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop"; import { parseTimelineCompositionPayload, TIMELINE_COMPOSITION_MIME, } from "../../utils/timelineCompositionDrop"; 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; ppsRef: RefObject; durationRef: RefObject; trackOrderRef: RefObject; rowGeometryRef: RefObject; contentOrigin: number; sessionEpoch: number; } type TimelinePlacement = { start: number; track: number }; /** * Parse a JSON drag payload and, if it yields a value, forward it to the drop * callback. Malformed payloads are ignored. Shared by the asset + block paths so * the parse/guard/dispatch shape lives in one place. */ function applyJsonDropPayload( raw: string, pick: (parsed: Record) => string | undefined, apply: (value: string, placement: TimelinePlacement) => void, placement: TimelinePlacement, ): boolean { try { const value = pick(JSON.parse(raw) as Record); 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 { // A rejected external producer never keeps a timeline drop actor alive. } } function resolveDropStart(usePointerStart: boolean, pointerStart: number): number { if (usePointerStart) return pointerStart; 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. * Deliberate product choice (user preference, 2026-07-09): every add lands at * the playhead regardless of drop x, like CapCut's add-to-timeline. External * OS file drops and internal asset drops share this same placement path, so * both land identically. */ export function useTimelineAssetDrop({ scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, contentOrigin, onFileDrop, 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 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 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 => { const scroll = scrollRef.current; const rect = scroll?.getBoundingClientRect(); const pointer = resolveTimelineAssetDrop( { rectLeft: rect?.left ?? 0, rectTop: rect?.top ?? 0, scrollLeft: scroll?.scrollLeft ?? 0, scrollTop: scroll?.scrollTop ?? 0, contentOrigin, pixelsPerSecond: ppsRef.current, duration: durationRef.current, clampStartToDuration: !usePointerStart, rowHeights: rowGeometryRef.current.rowHeights, trackOrder: trackOrderRef.current, }, clientX, clientY, ); return { start: resolveDropStart(usePointerStart, pointer.start), track: pointer.track, }; }, [scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, contentOrigin], ); const handleAssetDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); 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); invokeDropCallback(() => onCompositionDrop(compositionPayload.sourcePath, placement)); return; } const placement = resolveDropPlacement(e.clientX, e.clientY); if (applyFileDrop(e.dataTransfer, onFileDrop, placement)) return; if (applyTypedJsonDrop(e.dataTransfer, TIMELINE_ASSET_MIME, "path", onAssetDrop, placement)) { return; } applyTypedJsonDrop(e.dataTransfer, TIMELINE_BLOCK_MIME, "name", onBlockDrop, placement); }, [ clearDropPreview, onAssetDrop, onBlockDrop, onCompositionDrop, onFileDrop, resolveDropPlacement, sessionEpoch, ], ); useEffect(() => { window.addEventListener("dragend", clearDropPreview); return () => { window.removeEventListener("dragend", clearDropPreview); clearDropPreview(); }; }, [clearDropPreview]); useEffect(() => clearDropPreview(), [clearDropPreview, sessionEpoch]); return { isDragOver, handleAssetDragOver, handleAssetDragLeave, handleAssetDrop, clearDropPreview, }; }