summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-02 13:13:07 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-02 13:13:07 +0100
commite7dcb586c0408255a0bf788e355fdb6d50d9b923 (patch)
tree74ff0fc0f1ba501a075342db9b4e454450926154 /apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
parent93bea01c9ed3fcbebc64c9cef382a51d24c925ed (diff)
downloadopencut-e7dcb586c0408255a0bf788e355fdb6d50d9b923.tar.gz
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).
Diffstat (limited to 'apps/web/src/hooks/timeline/use-timeline-drag-drop.ts')
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts163
1 files changed, 100 insertions, 63 deletions
diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
index ee739298..8a30785e 100644
--- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
@@ -26,12 +26,14 @@ import type {
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();
@@ -104,11 +106,16 @@ export function useTimelineDragDrop({
(e: React.DragEvent) => {
e.preventDefault();
- const rect = containerRef.current?.getBoundingClientRect();
- if (!rect) return;
+ 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 });
@@ -131,8 +138,8 @@ export function useTimelineDragDrop({
mediaId: dragData?.type === "media" ? dragData.id : undefined,
});
- const mouseX = e.clientX - rect.left;
- const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
+ const mouseX = e.clientX - referenceRect.left + scrollLeft;
+ const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight;
const targetElementTypes =
dragData?.type === "effect"
@@ -162,6 +169,7 @@ export function useTimelineDragDrop({
[
containerRef,
headerRef,
+ tracksScrollRef,
tracks,
currentTime,
zoomLevel,
@@ -200,19 +208,6 @@ export function useTimelineDragDrop({
target: DropTarget;
dragData: { name?: string; content?: string };
}) => {
- let trackId: string;
-
- if (target.isNewTrack) {
- trackId = editor.timeline.addTrack({
- type: "text",
- index: target.trackIndex,
- });
- } else {
- const track = tracks[target.trackIndex];
- if (!track) return;
- trackId = track.id;
- }
-
const element = buildTextElement({
raw: {
name: dragData.name ?? "",
@@ -221,12 +216,26 @@ export function useTimelineDragDrop({
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 },
+ placement: { mode: "explicit", trackId: track.id },
element,
});
},
- [editor.timeline, tracks],
+ [editor.command, editor.timeline, tracks],
);
const executeStickerDrop = useCallback(
@@ -237,31 +246,32 @@ export function useTimelineDragDrop({
target: DropTarget;
dragData: StickerDragData;
}) => {
- let trackId: string;
-
- if (target.isNewTrack) {
- trackId = editor.timeline.addTrack({
- type: "sticker",
- index: target.trackIndex,
- });
- } else {
- const track = tracks[target.trackIndex];
- if (!track) return;
- trackId = track.id;
- }
-
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 },
+ placement: { mode: "explicit", trackId: track.id },
element,
});
},
- [editor.timeline, tracks],
+ [editor.command, editor.timeline, tracks],
);
const executeMediaDrop = useCallback(
@@ -276,18 +286,6 @@ export function useTimelineDragDrop({
const trackType: TrackType =
dragData.mediaType === "audio" ? "audio" : "video";
- let trackId: string;
-
- if (target.isNewTrack) {
- trackId = editor.timeline.addTrack({
- type: trackType,
- index: target.trackIndex,
- });
- } else {
- const track = tracks[target.trackIndex];
- if (!track) return;
- trackId = track.id;
- }
const duration =
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
@@ -299,31 +297,63 @@ export function useTimelineDragDrop({
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 },
+ placement: { mode: "explicit", trackId: track.id },
element,
});
},
- [editor.timeline, mediaAssets, tracks],
+ [editor.command, editor.timeline, mediaAssets, tracks],
);
const executeEffectDrop = useCallback(
- ({ target, dragData }: { target: DropTarget; dragData: EffectDragData }) => {
+ ({
+ 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 && !target.targetElement) {
+ if (effectTrack) {
trackId = effectTrack.id;
- } else if (target.targetElement) {
- trackId = effectTrack?.id ?? editor.timeline.addTrack({
- type: "effect",
- index: 0,
- });
} else if (target.isNewTrack) {
- trackId = editor.timeline.addTrack({
- type: "effect",
- index: target.trackIndex,
+ 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;
@@ -340,7 +370,7 @@ export function useTimelineDragDrop({
element,
});
},
- [editor.timeline, tracks],
+ [editor.command, editor.timeline, tracks],
);
const executeFileDrop = useCallback(
@@ -452,12 +482,18 @@ export function useTimelineDragDrop({
executeMediaDrop({ target: currentTarget, dragData });
}
} else if (hasFiles) {
- const rect = containerRef.current?.getBoundingClientRect();
- if (!rect) return;
- const mouseX = e.clientX - rect.left;
+ 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 = Math.max(0, e.clientY - rect.top - headerHeight);
+ const mouseY =
+ e.clientY - referenceRect.top + scrollTop - headerHeight;
await executeFileDrop({
files: Array.from(e.dataTransfer.files),
mouseX,
@@ -478,6 +514,7 @@ export function useTimelineDragDrop({
executeFileDrop,
containerRef,
headerRef,
+ tracksScrollRef,
],
);