Files
OpenCut/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
T
Maze Winther e7dcb586c0 feat: Clip effects, asset sorting, and timeline improvements
Major features and improvements:

* **Clip Effects**:
  * Added UI in Properties Panel to manage effects on video/image clips (add, remove, toggle, reorder).
  * Implemented dynamic parameter fields for effects.
  * Added support for keyframing effect parameters.

* **Assets Panel**:
  * Added sorting options: Name, Type, Duration, and File Size.
  * Persisted view preferences (grid/list mode, sort order) to local storage.
  * Refactored media item rendering and drag interactions.

* **Timeline & Interaction**:
  * **Keyframe Dragging**: Added ability to drag keyframes directly on the timeline element.
  * **Resizing**: Improved resize logic to respect neighboring clips (prevents overlaps).
  * **Visuals**: Implemented tiled background rendering for video/image clips on the timeline.
  * **Shortcuts**: Added "Deselect All" action bound to the `Escape` key.
  * **Fixes**: Corrected drag-and-drop coordinate calculations when the timeline track area is scrolled.

* **Text Elements**:
  * Refactored text background storage to use an explicit `enabled` flag.
  * Added `V8toV9` storage migration to update existing projects.

* **Architecture**:
  * Moved export state management to `ProjectManager` for better lifecycle handling.
  * Refactored `PropertiesPanel` sections to be more composable (custom headers, borders).
2026-03-02 13:13:07 +01:00

533 lines
14 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 { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import {
buildTextElement,
buildStickerElement,
buildElementFromMedia,
buildEffectElement,
} from "@/lib/timeline/element-utils";
import type { Command } from "@/lib/commands/base-command";
import { AddMediaAssetCommand } from "@/lib/commands/media";
import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
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,
EffectDragData,
} from "@/types/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 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 === "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 === "sticker" ||
elementType === "effect"
) {
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 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 target = computeDropTarget({
elementType,
mouseX,
mouseY,
tracks,
playheadTime: currentTime,
isExternalDrop: isExternal,
elementDuration: duration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
zoomLevel,
targetElementTypes,
});
target.xPosition = getSnappedTime({ time: target.xPosition });
setDropTarget(target);
e.dataTransfer.dropEffect = "copy";
},
[
containerRef,
headerRef,
tracksScrollRef,
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 };
}) => {
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 track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
placement: { mode: "explicit", trackId: track.id },
element,
});
},
[editor.command, editor.timeline, tracks],
);
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("sticker", target.trackIndex);
const insertCmd = new InsertElementCommand({
element,
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
});
editor.command.execute({
command: new BatchCommand([addTrackCmd, insertCmd]),
});
return;
}
const track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
placement: { mode: "explicit", trackId: track.id },
element,
});
},
[editor.command, editor.timeline, tracks],
);
const executeMediaDrop = useCallback(
({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
if (target.targetElement) {
toast.info("Replace media source is coming soon!");
return;
}
const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
if (!mediaAsset) return;
const trackType: TrackType =
dragData.mediaType === "audio" ? "audio" : "video";
const duration =
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_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 track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
placement: { mode: "explicit", trackId: track.id },
element,
});
},
[editor.command, editor.timeline, mediaAssets, tracks],
);
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 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.command, editor.timeline, tracks],
);
const executeFileDrop = useCallback(
async ({
files,
mouseX,
mouseY,
}: {
files: File[];
mouseX: number;
mouseY: number;
}) => {
if (!activeProject) return;
const processedAssets = await processMediaAssets({ files });
const projectId = activeProject.metadata.id;
for (const asset of processedAssets) {
const duration =
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
const currentTracks = editor.timeline.getTracks();
const dropTarget = computeDropTarget({
elementType: asset.type,
mouseX,
mouseY,
tracks: currentTracks,
playheadTime: currentTime,
isExternalDrop: true,
elementDuration: duration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
zoomLevel,
});
const trackType: TrackType = asset.type === "audio" ? "audio" : "video";
const addMediaCmd = new AddMediaAssetCommand(projectId, asset);
const assetId = addMediaCmd.getAssetId();
const commands: Command[] = [addMediaCmd];
let trackId: string | undefined;
if (dropTarget.isNewTrack) {
const addTrackCmd = new AddTrackCommand(
trackType,
dropTarget.trackIndex,
);
trackId = addTrackCmd.getTrackId();
commands.unshift(addTrackCmd);
} else {
trackId = currentTracks[dropTarget.trackIndex]?.id;
}
if (!trackId) return;
const element = buildElementFromMedia({
mediaId: assetId,
mediaType: asset.type,
name: asset.name,
duration,
startTime: dropTarget.xPosition,
buffer:
asset.type === "audio"
? new AudioBuffer({ length: 1, sampleRate: 44100 })
: undefined,
});
const insertCmd = new InsertElementCommand({
element,
placement: { mode: "explicit", trackId },
});
commands.push(insertCmd);
const batchCmd = new BatchCommand(commands);
editor.command.execute({ command: batchCmd });
}
},
[activeProject, editor.command, 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 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);
toast.error("Failed to process drop");
}
},
[
dropTarget,
executeTextDrop,
executeStickerDrop,
executeMediaDrop,
executeEffectDrop,
executeFileDrop,
containerRef,
headerRef,
tracksScrollRef,
],
);
return {
isDragOver,
dropTarget,
dragElementType,
dragProps: {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
},
};
}