mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
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).
533 lines
14 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
}
|