summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-01-31 00:20:04 +0100
committerGitHub <noreply@github.com>2026-01-31 00:20:04 +0100
commit7bf0984698e43b9aa2dd879f65951318ed410390 (patch)
treeccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
parent0173db99448d1c4c6415dcf15272e17551b48b04 (diff)
downloadopencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/hooks/timeline/use-timeline-drag-drop.ts')
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts460
1 files changed, 460 insertions, 0 deletions
diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
new file mode 100644
index 00000000..01af9362
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
@@ -0,0 +1,460 @@
+import { useState, useCallback, type RefObject } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { processMediaAssets } from "@/lib/media/processing";
+import { toast } from "sonner";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { snapTimeToFrame } from "@/lib/time";
+import {
+ buildTextElement,
+ buildStickerElement,
+ buildUploadAudioElement,
+ buildVideoElement,
+ buildImageElement,
+} from "@/lib/timeline/element-utils";
+import { computeDropTarget } from "@/lib/timeline/drop-utils";
+import { getDragData, hasDragData } from "@/lib/drag-data";
+import type { TrackType, DropTarget, ElementType } from "@/types/timeline";
+import type { MediaDragData, StickerDragData } from "@/types/drag";
+
+interface UseTimelineDragDropProps {
+ containerRef: RefObject<HTMLDivElement | null>;
+ headerRef?: RefObject<HTMLElement | null>;
+ zoomLevel: number;
+}
+
+export function useTimelineDragDrop({
+ containerRef,
+ headerRef,
+ 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 tracks = editor.timeline.getTracks();
+ const currentTime = editor.playback.getCurrentTime();
+ const mediaAssets = editor.media.getAssets();
+ const activeProject = editor.project.getActive();
+
+ const getSnappedTime = useCallback(
+ ({ time }: { time: number }) => {
+ const projectFps = activeProject.settings.fps;
+ return snapTimeToFrame({ time, fps: projectFps });
+ },
+ [activeProject.settings.fps],
+ );
+
+ 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 === "sticker") return "sticker";
+ if (dragData.type === "media") {
+ return dragData.mediaType;
+ }
+ return null;
+ },
+ [],
+ );
+
+ const getElementDuration = useCallback(
+ ({
+ elementType,
+ mediaId,
+ }: {
+ elementType: ElementType;
+ mediaId?: string;
+ }): number => {
+ if (elementType === "text" || elementType === "sticker") {
+ return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ }
+ if (mediaId) {
+ const media = mediaAssets.find((m) => m.id === mediaId);
+ return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ }
+ return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ },
+ [mediaAssets],
+ );
+
+ 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 rect = containerRef.current?.getBoundingClientRect();
+ if (!rect) return;
+
+ const headerHeight =
+ headerRef?.current?.getBoundingClientRect().height ?? 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 - rect.left;
+ const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
+
+ const target = computeDropTarget({
+ elementType,
+ mouseX,
+ mouseY,
+ tracks,
+ playheadTime: currentTime,
+ isExternalDrop: isExternal,
+ elementDuration: duration,
+ pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
+ zoomLevel,
+ });
+
+ target.xPosition = getSnappedTime({ time: target.xPosition });
+
+ setDropTarget(target);
+ e.dataTransfer.dropEffect = "copy";
+ },
+ [
+ containerRef,
+ headerRef,
+ tracks,
+ currentTime,
+ zoomLevel,
+ getElementType,
+ getElementDuration,
+ getSnappedTime,
+ ],
+ );
+
+ 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 };
+ }) => {
+ let trackId: string;
+
+ if (target.isNewTrack) {
+ trackId = editor.timeline.addTrack({
+ type: "text",
+ index: target.trackIndex,
+ });
+ } else {
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ trackId = track.id;
+ }
+
+ const element = buildTextElement({
+ raw: {
+ name: dragData.name ?? "",
+ content: dragData.content ?? "",
+ },
+ startTime: target.xPosition,
+ });
+
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element,
+ });
+ },
+ [editor.timeline, tracks],
+ );
+
+ const executeStickerDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: StickerDragData;
+ }) => {
+ let trackId: string;
+
+ if (target.isNewTrack) {
+ trackId = editor.timeline.addTrack({
+ type: "sticker",
+ index: target.trackIndex,
+ });
+ } else {
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ trackId = track.id;
+ }
+
+ const element = buildStickerElement({
+ iconName: dragData.iconName,
+ startTime: target.xPosition,
+ });
+
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element,
+ });
+ },
+ [editor.timeline, tracks],
+ );
+
+ const executeMediaDrop = useCallback(
+ ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
+ const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
+ if (!mediaAsset) return;
+
+ const trackType: TrackType =
+ dragData.mediaType === "audio" ? "audio" : "video";
+ let trackId: string;
+
+ if (target.isNewTrack) {
+ trackId = editor.timeline.addTrack({
+ type: trackType,
+ index: target.trackIndex,
+ });
+ } else {
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ trackId = track.id;
+ }
+
+ const duration =
+ mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+
+ if (dragData.mediaType === "audio") {
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element: buildUploadAudioElement({
+ mediaId: mediaAsset.id,
+ name: mediaAsset.name,
+ duration,
+ startTime: target.xPosition,
+ }),
+ });
+ } else if (dragData.mediaType === "video") {
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element: buildVideoElement({
+ mediaId: mediaAsset.id,
+ name: mediaAsset.name,
+ duration,
+ startTime: target.xPosition,
+ }),
+ });
+ } else {
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element: buildImageElement({
+ mediaId: mediaAsset.id,
+ name: mediaAsset.name,
+ duration,
+ startTime: target.xPosition,
+ }),
+ });
+ }
+ },
+ [editor.timeline, mediaAssets, tracks],
+ );
+
+ const executeFileDrop = useCallback(
+ async ({
+ files,
+ mouseX,
+ mouseY,
+ }: {
+ files: File[];
+ mouseX: number;
+ mouseY: number;
+ }) => {
+ if (!activeProject) return;
+
+ const processedAssets = await processMediaAssets({ files });
+
+ for (const asset of processedAssets) {
+ await editor.media.addMediaAsset({
+ projectId: activeProject.metadata.id,
+ asset,
+ });
+
+ const added = editor.media
+ .getAssets()
+ .find((m) => m.name === asset.name && m.url === asset.url);
+
+ if (added) {
+ const duration =
+ added.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const currentTracks = editor.timeline.getTracks();
+ const dropTarget = computeDropTarget({
+ elementType: added.type,
+ mouseX,
+ mouseY,
+ tracks: currentTracks,
+ playheadTime: currentTime,
+ isExternalDrop: true,
+ elementDuration: duration,
+ pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
+ zoomLevel,
+ });
+
+ const trackType: TrackType =
+ added.type === "audio" ? "audio" : "video";
+ const trackId = dropTarget.isNewTrack
+ ? editor.timeline.addTrack({
+ type: trackType,
+ index: dropTarget.trackIndex,
+ })
+ : currentTracks[dropTarget.trackIndex]?.id;
+
+ if (!trackId) return;
+
+ if (added.type === "audio") {
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element: buildUploadAudioElement({
+ mediaId: added.id,
+ name: added.name,
+ duration,
+ startTime: dropTarget.xPosition,
+ buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
+ }),
+ });
+ } else if (added.type === "video") {
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element: buildVideoElement({
+ mediaId: added.id,
+ name: added.name,
+ duration,
+ startTime: dropTarget.xPosition,
+ }),
+ });
+ } else {
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element: buildImageElement({
+ mediaId: added.id,
+ name: added.name,
+ duration,
+ startTime: dropTarget.xPosition,
+ }),
+ });
+ }
+ }
+ }
+ },
+ [activeProject, editor.media, editor.timeline, currentTime, 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 === "sticker") {
+ executeStickerDrop({ target: currentTarget, dragData });
+ } else {
+ executeMediaDrop({ target: currentTarget, dragData });
+ }
+ } else if (hasFiles) {
+ const rect = containerRef.current?.getBoundingClientRect();
+ if (!rect) return;
+ const mouseX = e.clientX - rect.left;
+ const headerHeight =
+ headerRef?.current?.getBoundingClientRect().height ?? 0;
+ const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
+ await executeFileDrop({
+ files: Array.from(e.dataTransfer.files),
+ mouseX,
+ mouseY,
+ });
+ }
+ } catch (err) {
+ console.error("Failed to process drop:", err);
+ toast.error("Failed to process drop");
+ }
+ },
+ [
+ dropTarget,
+ executeTextDrop,
+ executeStickerDrop,
+ executeMediaDrop,
+ executeFileDrop,
+ containerRef,
+ headerRef,
+ ],
+ );
+
+ return {
+ isDragOver,
+ dropTarget,
+ dragElementType,
+ dragProps: {
+ onDragEnter: handleDragEnter,
+ onDragOver: handleDragOver,
+ onDragLeave: handleDragLeave,
+ onDrop: handleDrop,
+ },
+ };
+}