612 lines
16 KiB
TypeScript
612 lines
16 KiB
TypeScript
import { useState, useCallback, type RefObject } from "react";
|
|
import { useEditor } from "@/hooks/use-editor";
|
|
import { processMediaAssets } from "@/lib/media/processing";
|
|
import { toast } from "sonner";
|
|
import { showMediaUploadToast } from "@/lib/media/upload-toast";
|
|
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
|
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
|
import { roundToFrame } from "opencut-wasm";
|
|
import {
|
|
buildTextElement,
|
|
buildGraphicElement,
|
|
buildStickerElement,
|
|
buildElementFromMedia,
|
|
buildEffectElement,
|
|
} from "@/lib/timeline/element-utils";
|
|
import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
|
|
import { BatchCommand } from "@/lib/commands";
|
|
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
|
|
import { getDragData, hasDragData } from "@/lib/drag-data";
|
|
import { isMainTrack } from "@/lib/timeline/placement";
|
|
import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
|
|
import type {
|
|
MediaDragData,
|
|
GraphicDragData,
|
|
StickerDragData,
|
|
EffectDragData,
|
|
} from "@/lib/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 ?? 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 tracks = editor.timeline.getTracks();
|
|
const currentTime = editor.playback.getCurrentTime();
|
|
const target = computeDropTarget({
|
|
elementType,
|
|
mouseX,
|
|
mouseY,
|
|
tracks,
|
|
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.timeline.getTracks();
|
|
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.timeline.getTracks();
|
|
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.timeline.getTracks();
|
|
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 ?? 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.timeline.getTracks();
|
|
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.timeline.getTracks();
|
|
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 ??
|
|
DEFAULT_NEW_ELEMENT_DURATION;
|
|
const currentTracks = editor.timeline.getTracks();
|
|
const currentTime = editor.playback.getCurrentTime();
|
|
const onlyTrack = currentTracks[0];
|
|
const reuseMainTrackId =
|
|
createdAsset.type !== "audio" &&
|
|
currentTracks.length === 1 &&
|
|
!!onlyTrack &&
|
|
isMainTrack(onlyTrack) &&
|
|
onlyTrack.elements.length === 0
|
|
? onlyTrack.id
|
|
: null;
|
|
const dropTarget = reuseMainTrackId
|
|
? null
|
|
: computeDropTarget({
|
|
elementType: createdAsset.type,
|
|
mouseX,
|
|
mouseY,
|
|
tracks: currentTracks,
|
|
playheadTime: currentTime,
|
|
isExternalDrop: true,
|
|
elementDuration: duration,
|
|
pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
|
|
zoomLevel,
|
|
});
|
|
|
|
const trackType: TrackType =
|
|
createdAsset.type === "audio" ? "audio" : "video";
|
|
|
|
let trackId: string | undefined;
|
|
if (reuseMainTrackId) {
|
|
trackId = reuseMainTrackId;
|
|
} else {
|
|
if (!dropTarget) continue;
|
|
if (dropTarget.isNewTrack) {
|
|
const addTrackCmd = new AddTrackCommand(
|
|
trackType,
|
|
dropTarget.trackIndex,
|
|
);
|
|
trackId = addTrackCmd.getTrackId();
|
|
editor.command.execute({ command: addTrackCmd });
|
|
} else {
|
|
trackId = currentTracks[dropTarget.trackIndex]?.id;
|
|
}
|
|
}
|
|
|
|
if (!trackId) continue;
|
|
|
|
const element = buildElementFromMedia({
|
|
mediaId: createdAsset.id,
|
|
mediaType: createdAsset.type,
|
|
name: createdAsset.name,
|
|
duration,
|
|
startTime: dropTarget?.xPosition ?? currentTime,
|
|
buffer:
|
|
createdAsset.type === "audio"
|
|
? new AudioBuffer({ length: 1, sampleRate: 44100 })
|
|
: undefined,
|
|
});
|
|
|
|
const insertCmd = new InsertElementCommand({
|
|
element,
|
|
placement: { mode: "explicit", trackId },
|
|
});
|
|
editor.command.execute({ command: insertCmd });
|
|
}
|
|
|
|
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,
|
|
},
|
|
};
|
|
}
|