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.ts659
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,
},
};
}