diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-25 03:56:22 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-25 03:56:22 +0200 |
| commit | 6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692 (patch) | |
| tree | 976d60dbe1ac8e44d6a9259f9d6e20fe6530c698 /apps/web/src/timeline/hooks/use-timeline-drag-drop.ts | |
| parent | 0dffefdd59d8c70fece63436e8556f8ec1e43a49 (diff) | |
| download | opencut-6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692.tar.gz | |
refactor: centralize editor interaction state
Move timeline and preview gestures into dedicated controllers so hooks stay thin around editor state and DOM events.
This also routes drag and playback synchronization through manager APIs and reuses persistent wasm surfaces so scrubbing, seeking, and preview rendering stay in sync.
Made-with: Cursor
Diffstat (limited to 'apps/web/src/timeline/hooks/use-timeline-drag-drop.ts')
| -rw-r--r-- | apps/web/src/timeline/hooks/use-timeline-drag-drop.ts | 659 |
1 files changed, 38 insertions, 621 deletions
diff --git a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts index 775b7337..4ec2b80b 100644 --- a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts +++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts @@ -1,30 +1,9 @@ -import { useState, useCallback, type RefObject } from "react"; +import { useEffect, useReducer, useRef, 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 { TICKS_PER_SECOND } 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"; + DragDropController, + type DragDropConfig, +} from "@/timeline/controllers/drag-drop-controller"; interface UseTimelineDragDropProps { containerRef: RefObject<HTMLDivElement | null>; @@ -40,609 +19,47 @@ export function useTimelineDragDrop({ zoomLevel, }: UseTimelineDragDropProps) { const editor = useEditor(); - const [isDragOver, setIsDragOver] = useState(false); - const [dropTarget, setDropTarget] = useState<DropTarget | null>(null); - const [dragElementType, setElementType] = useState<ElementType | null>(null); - const getSnappedTime = useCallback( - ({ time }: { time: number }) => { - const projectFps = editor.project.getActive().settings.fps; - return roundToFrame({ time, rate: projectFps }) ?? time; - }, - [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; - }): number => { - 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 - ? Math.round(media.duration * TICKS_PER_SECOND) - : 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 - ? Math.round(mediaAsset.duration * TICKS_PER_SECOND) - : 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 - ? Math.round(createdAsset.duration * TICKS_PER_SECOND) - : 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 config: DragDropConfig = { + zoomLevel, + getContainerEl: () => containerRef.current, + getHeaderEl: () => headerRef?.current ?? null, + getTracksScrollEl: () => tracksScrollRef?.current ?? null, + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + getActiveProjectId: () => + editor.project.getActiveOrNull()?.metadata.id ?? null, + getSceneTracks: () => editor.scenes.getActiveScene().tracks, + getCurrentPlayheadTime: () => editor.playback.getCurrentTime(), + getMediaAssets: () => editor.media.getAssets(), + dragSource: editor.timeline.dragSource, + addMediaAsset: (args) => editor.media.addMediaAsset(args), + executeCommand: (command) => editor.command.execute({ command }), + insertElement: (args) => editor.timeline.insertElement(args), + addClipEffect: (args) => editor.timeline.addClipEffect(args), + }; - const currentTarget = dropTarget; - setIsDragOver(false); - setDropTarget(null); - setElementType(null); + const configRef = useRef<DragDropConfig>(config); + configRef.current = config; - try { - if (hasAsset) { - if (!currentTarget) return; - const dragData = getDragData({ dataTransfer: e.dataTransfer }); - if (!dragData) return; + const controllerRef = useRef<DragDropController | null>(null); + if (!controllerRef.current) { + controllerRef.current = new DragDropController({ configRef }); + } + const controller = controllerRef.current; - 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, - ], - ); + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); + useEffect(() => () => controller.destroy(), [controller]); return { - isDragOver, - dropTarget, - dragElementType, + isDragOver: controller.isDragOver, + dropTarget: controller.dropTarget, + dragElementType: controller.dragElementType, dragProps: { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, + onDragEnter: controller.onDragEnter, + onDragOver: controller.onDragOver, + onDragLeave: controller.onDragLeave, + onDrop: controller.onDrop, }, }; } |
