summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts
diff options
context:
space:
mode:
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.ts648
1 files changed, 648 insertions, 0 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
new file mode 100644
index 00000000..775b7337
--- /dev/null
+++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts
@@ -0,0 +1,648 @@
+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 { 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";
+
+interface UseTimelineDragDropProps {
+ containerRef: RefObject<HTMLDivElement | null>;
+ headerRef?: RefObject<HTMLElement | null>;
+ tracksScrollRef?: RefObject<HTMLDivElement | null>;
+ zoomLevel: number;
+}
+
+export function useTimelineDragDrop({
+ containerRef,
+ headerRef,
+ tracksScrollRef,
+ 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 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,
+ },
+ };
+}