import { useState, useCallback, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; import { processMediaAssets } from "@/media/processing"; import { toast } from "sonner"; import { showMediaUploadToast } from "@/media/upload-toast"; import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; import { mediaTimeFromSeconds, type MediaTime } from "@/wasm"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; import { roundToFrame } from "opencut-wasm"; import { buildTextElement, buildGraphicElement, buildStickerElement, buildElementFromMedia, buildEffectElement, } from "@/timeline/element-utils"; import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline"; import { BatchCommand } from "@/commands"; import { computeDropTarget } from "@/timeline/components/drop-target"; import { getDragData, hasDragData } from "@/timeline/drag-data"; import type { TrackType, DropTarget, ElementType } from "@/timeline"; import type { MediaDragData, GraphicDragData, StickerDragData, EffectDragData, } from "@/timeline/drag"; interface UseTimelineDragDropProps { containerRef: RefObject; headerRef?: RefObject; tracksScrollRef?: RefObject; zoomLevel: number; } export function useTimelineDragDrop({ containerRef, headerRef, tracksScrollRef, zoomLevel, }: UseTimelineDragDropProps) { const editor = useEditor(); const [isDragOver, setIsDragOver] = useState(false); const [dropTarget, setDropTarget] = useState(null); const [dragElementType, setElementType] = useState(null); const getSnappedTime = useCallback( ({ time }: { time: MediaTime }) => { const projectFps = editor.project.getActive().settings.fps; return (roundToFrame({ time, rate: projectFps }) ?? time) as MediaTime; }, [editor], ); const getElementType = useCallback( ({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => { const dragData = getDragData({ dataTransfer }); if (!dragData) return null; if (dragData.type === "text") return "text"; if (dragData.type === "graphic") return "graphic"; if (dragData.type === "sticker") return "sticker"; if (dragData.type === "effect") return "effect"; if (dragData.type === "media") { return dragData.mediaType; } return null; }, [], ); const getElementDuration = useCallback( ({ elementType, mediaId, }: { elementType: ElementType; mediaId?: string; }): MediaTime => { if ( elementType === "text" || elementType === "graphic" || elementType === "sticker" || elementType === "effect" ) { return DEFAULT_NEW_ELEMENT_DURATION; } if (mediaId) { const mediaAssets = editor.media.getAssets(); const media = mediaAssets.find((m) => m.id === mediaId); return media?.duration != null ? mediaTimeFromSeconds({ seconds: media.duration }) : DEFAULT_NEW_ELEMENT_DURATION; } return DEFAULT_NEW_ELEMENT_DURATION; }, [editor], ); const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); const hasAsset = hasDragData({ dataTransfer: e.dataTransfer }); const hasFiles = e.dataTransfer.types.includes("Files"); if (!hasAsset && !hasFiles) return; setIsDragOver(true); }, []); const handleDragOver = useCallback( (e: React.DragEvent) => { e.preventDefault(); const scrollContainer = tracksScrollRef?.current; const referenceRect = scrollContainer?.getBoundingClientRect() ?? containerRef.current?.getBoundingClientRect(); if (!referenceRect) return; const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0; const scrollLeft = scrollContainer?.scrollLeft ?? 0; const scrollTop = scrollContainer?.scrollTop ?? 0; const hasFiles = e.dataTransfer.types.includes("Files"); const isExternal = hasFiles && !hasDragData({ dataTransfer: e.dataTransfer }); const elementType = getElementType({ dataTransfer: e.dataTransfer }); if (!elementType && hasFiles && isExternal) { setDropTarget(null); setElementType(null); return; } if (!elementType) return; setElementType(elementType); const dragData = getDragData({ dataTransfer: e.dataTransfer }); const duration = getElementDuration({ elementType, mediaId: dragData?.type === "media" ? dragData.id : undefined, }); const mouseX = e.clientX - referenceRect.left + scrollLeft; const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight; const targetElementTypes = dragData?.type === "effect" ? (dragData as EffectDragData).targetElementTypes : dragData?.type === "media" ? (dragData as MediaDragData).targetElementTypes : undefined; const sceneTracks = editor.scenes.getActiveScene().tracks; const currentTime = editor.playback.getCurrentTime(); const target = computeDropTarget({ elementType, mouseX, mouseY, tracks: sceneTracks, playheadTime: currentTime, isExternalDrop: isExternal, elementDuration: duration, pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, zoomLevel, targetElementTypes, }); target.xPosition = getSnappedTime({ time: target.xPosition }); setDropTarget(target); e.dataTransfer.dropEffect = "copy"; }, [ containerRef, headerRef, tracksScrollRef, zoomLevel, getElementType, getElementDuration, getSnappedTime, editor, ], ); const handleDragLeave = useCallback( (e: React.DragEvent) => { e.preventDefault(); const rect = containerRef.current?.getBoundingClientRect(); if (rect) { const { clientX, clientY } = e; if ( clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom ) { setIsDragOver(false); setDropTarget(null); setElementType(null); } } }, [containerRef], ); const executeTextDrop = useCallback( ({ target, dragData, }: { target: DropTarget; dragData: { name?: string; content?: string }; }) => { const element = buildTextElement({ raw: { name: dragData.name ?? "", content: dragData.content ?? "", }, startTime: target.xPosition, }); if (target.isNewTrack) { const addTrackCmd = new AddTrackCommand("text", target.trackIndex); const insertCmd = new InsertElementCommand({ element, placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, }); editor.command.execute({ command: new BatchCommand([addTrackCmd, insertCmd]), }); return; } const tracks = [ ...editor.scenes.getActiveScene().tracks.overlay, editor.scenes.getActiveScene().tracks.main, ...editor.scenes.getActiveScene().tracks.audio, ]; const track = tracks[target.trackIndex]; if (!track) return; editor.timeline.insertElement({ placement: { mode: "explicit", trackId: track.id }, element, }); }, [editor], ); const executeStickerDrop = useCallback( ({ target, dragData, }: { target: DropTarget; dragData: StickerDragData; }) => { const element = buildStickerElement({ stickerId: dragData.stickerId, name: dragData.name, startTime: target.xPosition, }); if (target.isNewTrack) { const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); const insertCmd = new InsertElementCommand({ element, placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, }); editor.command.execute({ command: new BatchCommand([addTrackCmd, insertCmd]), }); return; } const tracks = [ ...editor.scenes.getActiveScene().tracks.overlay, editor.scenes.getActiveScene().tracks.main, ...editor.scenes.getActiveScene().tracks.audio, ]; const track = tracks[target.trackIndex]; if (!track) return; editor.timeline.insertElement({ placement: { mode: "explicit", trackId: track.id }, element, }); }, [editor], ); const executeGraphicDrop = useCallback( ({ target, dragData, }: { target: DropTarget; dragData: GraphicDragData; }) => { const element = buildGraphicElement({ definitionId: dragData.definitionId, name: dragData.name, startTime: target.xPosition, params: dragData.params, }); if (target.isNewTrack) { const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); const insertCmd = new InsertElementCommand({ element, placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, }); editor.command.execute({ command: new BatchCommand([addTrackCmd, insertCmd]), }); return; } const tracks = [ ...editor.scenes.getActiveScene().tracks.overlay, editor.scenes.getActiveScene().tracks.main, ...editor.scenes.getActiveScene().tracks.audio, ]; const track = tracks[target.trackIndex]; if (!track) return; editor.timeline.insertElement({ placement: { mode: "explicit", trackId: track.id }, element, }); }, [editor], ); const executeMediaDrop = useCallback( ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => { if (target.targetElement) { toast.info("Replace media source is coming soon!"); return; } const mediaAssets = editor.media.getAssets(); const mediaAsset = mediaAssets.find((m) => m.id === dragData.id); if (!mediaAsset) return; const trackType: TrackType = dragData.mediaType === "audio" ? "audio" : "video"; const duration = mediaAsset.duration != null ? mediaTimeFromSeconds({ seconds: mediaAsset.duration }) : DEFAULT_NEW_ELEMENT_DURATION; const element = buildElementFromMedia({ mediaId: mediaAsset.id, mediaType: mediaAsset.type, name: mediaAsset.name, duration, startTime: target.xPosition, }); if (target.isNewTrack) { const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); const insertCmd = new InsertElementCommand({ element, placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, }); editor.command.execute({ command: new BatchCommand([addTrackCmd, insertCmd]), }); return; } const tracks = [ ...editor.scenes.getActiveScene().tracks.overlay, editor.scenes.getActiveScene().tracks.main, ...editor.scenes.getActiveScene().tracks.audio, ]; const track = tracks[target.trackIndex]; if (!track) return; editor.timeline.insertElement({ placement: { mode: "explicit", trackId: track.id }, element, }); }, [editor], ); const executeEffectDrop = useCallback( ({ target, dragData, }: { target: DropTarget; dragData: EffectDragData; }) => { if (target.targetElement) { editor.timeline.addClipEffect({ trackId: target.targetElement.trackId, elementId: target.targetElement.elementId, effectType: dragData.effectType, }); return; } const tracks = [ ...editor.scenes.getActiveScene().tracks.overlay, editor.scenes.getActiveScene().tracks.main, ...editor.scenes.getActiveScene().tracks.audio, ]; const effectTrack = tracks.find((t) => t.type === "effect"); let trackId: string; if (effectTrack) { trackId = effectTrack.id; } else if (target.isNewTrack) { const addTrackCmd = new AddTrackCommand("effect", target.trackIndex); const insertCmd = new InsertElementCommand({ element: buildEffectElement({ effectType: dragData.effectType, startTime: target.xPosition, }), placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, }); editor.command.execute({ command: new BatchCommand([addTrackCmd, insertCmd]), }); return; } else { const track = tracks[target.trackIndex]; if (!track || track.type !== "effect") return; trackId = track.id; } const element = buildEffectElement({ effectType: dragData.effectType, startTime: target.xPosition, }); editor.timeline.insertElement({ placement: { mode: "explicit", trackId }, element, }); }, [editor], ); const executeFileDrop = useCallback( async ({ files, mouseX, mouseY, }: { files: File[]; mouseX: number; mouseY: number; }) => { const activeProject = editor.project.getActiveOrNull(); if (!activeProject) return; await showMediaUploadToast({ filesCount: files.length, promise: async () => { const processedAssets = await processMediaAssets({ files }); const projectId = activeProject.metadata.id; for (const asset of processedAssets) { const createdAsset = await editor.media.addMediaAsset({ projectId, asset, }); if (!createdAsset) continue; const duration = createdAsset.duration != null ? mediaTimeFromSeconds({ seconds: createdAsset.duration }) : DEFAULT_NEW_ELEMENT_DURATION; const sceneTracks = editor.scenes.getActiveScene().tracks; const currentTime = editor.playback.getCurrentTime(); const reuseMainTrackId = createdAsset.type !== "audio" && sceneTracks.overlay.length === 0 && sceneTracks.audio.length === 0 && sceneTracks.main.elements.length === 0 ? sceneTracks.main.id : null; const dropTarget = reuseMainTrackId ? null : computeDropTarget({ elementType: createdAsset.type, mouseX, mouseY, tracks: sceneTracks, playheadTime: currentTime, isExternalDrop: true, elementDuration: duration, pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, zoomLevel, }); const trackType: TrackType = createdAsset.type === "audio" ? "audio" : "video"; const startTime = dropTarget?.xPosition ?? currentTime; const element = buildElementFromMedia({ mediaId: createdAsset.id, mediaType: createdAsset.type, name: createdAsset.name, duration, startTime, }); if (reuseMainTrackId) { editor.command.execute({ command: new InsertElementCommand({ element, placement: { mode: "explicit", trackId: reuseMainTrackId }, }), }); } else { if (!dropTarget) continue; if (dropTarget.isNewTrack) { const addTrackCmd = new AddTrackCommand( trackType, dropTarget.trackIndex, ); editor.command.execute({ command: new BatchCommand([ addTrackCmd, new InsertElementCommand({ element, placement: { mode: "explicit", trackId: addTrackCmd.getTrackId(), }, }), ]), }); } else { const trackId = [ ...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio, ][dropTarget.trackIndex]?.id; if (!trackId) continue; editor.command.execute({ command: new InsertElementCommand({ element, placement: { mode: "explicit", trackId }, }), }); } } } return { uploadedCount: processedAssets.length, assetNames: processedAssets.map((asset) => asset.name), }; }, }); }, [editor, zoomLevel], ); const handleDrop = useCallback( async (e: React.DragEvent) => { e.preventDefault(); const hasAsset = hasDragData({ dataTransfer: e.dataTransfer }); const hasFiles = e.dataTransfer.files?.length > 0; if (!hasAsset && !hasFiles) return; const currentTarget = dropTarget; setIsDragOver(false); setDropTarget(null); setElementType(null); try { if (hasAsset) { if (!currentTarget) return; const dragData = getDragData({ dataTransfer: e.dataTransfer }); if (!dragData) return; if (dragData.type === "text") { executeTextDrop({ target: currentTarget, dragData }); } else if (dragData.type === "graphic") { executeGraphicDrop({ target: currentTarget, dragData: dragData as GraphicDragData, }); } else if (dragData.type === "sticker") { executeStickerDrop({ target: currentTarget, dragData }); } else if (dragData.type === "effect") { executeEffectDrop({ target: currentTarget, dragData: dragData as EffectDragData, }); } else { executeMediaDrop({ target: currentTarget, dragData }); } } else if (hasFiles) { const scrollContainer = tracksScrollRef?.current; const referenceRect = scrollContainer?.getBoundingClientRect() ?? containerRef.current?.getBoundingClientRect(); if (!referenceRect) return; const scrollLeft = scrollContainer?.scrollLeft ?? 0; const scrollTop = scrollContainer?.scrollTop ?? 0; const mouseX = e.clientX - referenceRect.left + scrollLeft; const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0; const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight; await executeFileDrop({ files: Array.from(e.dataTransfer.files), mouseX, mouseY, }); } } catch (err) { console.error("Failed to process drop:", err); } }, [ dropTarget, executeTextDrop, executeGraphicDrop, executeStickerDrop, executeMediaDrop, executeEffectDrop, executeFileDrop, containerRef, headerRef, tracksScrollRef, ], ); return { isDragOver, dropTarget, dragElementType, dragProps: { onDragEnter: handleDragEnter, onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop, }, }; }