mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor not done
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { useState, useRef } from "react";
|
||||
|
||||
interface UseDragDropOptions {
|
||||
onDrop?: (files: FileList) => void;
|
||||
}
|
||||
|
||||
// Helper function to check if drag contains files from external sources (not internal app drags)
|
||||
const containsFiles = (dataTransfer: DataTransfer): boolean => {
|
||||
// Check if this is an internal app drag (media item)
|
||||
if (dataTransfer.types.includes("application/x-media-item")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only show overlay for external file drags
|
||||
return dataTransfer.types.includes("Files");
|
||||
};
|
||||
|
||||
export function useDragDrop(options: UseDragDropOptions = {}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Only handle external file drags, not internal app element drags
|
||||
if (!containsFiles(e.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragCounterRef.current += 1;
|
||||
if (!isDragOver) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Only handle file drags
|
||||
if (!containsFiles(e.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Only handle file drags
|
||||
if (!containsFiles(e.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragCounterRef.current -= 1;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
dragCounterRef.current = 0;
|
||||
|
||||
// Only handle file drops
|
||||
if (
|
||||
options.onDrop &&
|
||||
e.dataTransfer.files &&
|
||||
containsFiles(e.dataTransfer)
|
||||
) {
|
||||
options.onDrop(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const dragProps = {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
};
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
dragProps,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface UseEdgeAutoScrollParams {
|
||||
isActive: boolean;
|
||||
getMouseClientX: () => number;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
contentWidth: number;
|
||||
edgeThreshold?: number;
|
||||
maxScrollSpeed?: number;
|
||||
}
|
||||
|
||||
// Provides smooth edge auto-scrolling for horizontal timeline interactions.
|
||||
export function useEdgeAutoScroll({
|
||||
isActive,
|
||||
getMouseClientX,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth,
|
||||
edgeThreshold = 100,
|
||||
maxScrollSpeed = 15,
|
||||
}: UseEdgeAutoScrollParams): void {
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const step = () => {
|
||||
const rulerViewport = rulerScrollRef.current;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
if (!rulerViewport || !tracksViewport) {
|
||||
rafRef.current = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportRect = rulerViewport.getBoundingClientRect();
|
||||
const mouseX = getMouseClientX();
|
||||
const mouseXRelative = mouseX - viewportRect.left;
|
||||
|
||||
const viewportWidth = rulerViewport.clientWidth;
|
||||
const intrinsicContentWidth = rulerViewport.scrollWidth;
|
||||
const effectiveContentWidth = Math.max(
|
||||
contentWidth,
|
||||
intrinsicContentWidth
|
||||
);
|
||||
const scrollMax = Math.max(0, effectiveContentWidth - viewportWidth);
|
||||
|
||||
let scrollSpeed = 0;
|
||||
|
||||
if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) {
|
||||
const edgeDistance = Math.max(0, mouseXRelative);
|
||||
const intensity = 1 - edgeDistance / edgeThreshold;
|
||||
scrollSpeed = -maxScrollSpeed * intensity;
|
||||
} else if (
|
||||
mouseXRelative > viewportWidth - edgeThreshold &&
|
||||
rulerViewport.scrollLeft < scrollMax
|
||||
) {
|
||||
const edgeDistance = Math.max(
|
||||
0,
|
||||
viewportWidth - edgeThreshold - mouseXRelative
|
||||
);
|
||||
const intensity = 1 - edgeDistance / edgeThreshold;
|
||||
scrollSpeed = maxScrollSpeed * intensity;
|
||||
}
|
||||
|
||||
if (scrollSpeed !== 0) {
|
||||
const newScrollLeft = Math.max(
|
||||
0,
|
||||
Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed)
|
||||
);
|
||||
rulerViewport.scrollLeft = newScrollLeft;
|
||||
tracksViewport.scrollLeft = newScrollLeft;
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(step);
|
||||
|
||||
return () => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
isActive,
|
||||
getMouseClientX,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth,
|
||||
edgeThreshold,
|
||||
maxScrollSpeed,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { toast } from "sonner";
|
||||
import { useActionHandler } from "@/constants/action-constants";
|
||||
|
||||
export function useEditorActions() {
|
||||
const {
|
||||
tracks,
|
||||
selectedElements,
|
||||
setSelectedElements,
|
||||
deleteSelected,
|
||||
splitSelected,
|
||||
addElementToTrack,
|
||||
toggleSnapping,
|
||||
undo,
|
||||
redo,
|
||||
} = useTimelineStore();
|
||||
|
||||
const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
|
||||
// Playback actions
|
||||
useActionHandler(
|
||||
"toggle-play",
|
||||
() => {
|
||||
toggle();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"stop-playback",
|
||||
() => {
|
||||
if (isPlaying) {
|
||||
toggle();
|
||||
}
|
||||
seek(0);
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"seek-forward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 1;
|
||||
seek(Math.min(duration, currentTime + seconds));
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"seek-backward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 1;
|
||||
seek(Math.max(0, currentTime - seconds));
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"frame-step-forward",
|
||||
() => {
|
||||
const projectFps = activeProject?.fps || DEFAULT_FPS;
|
||||
seek(Math.min(duration, currentTime + 1 / projectFps));
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"frame-step-backward",
|
||||
() => {
|
||||
const projectFps = activeProject?.fps || DEFAULT_FPS;
|
||||
seek(Math.max(0, currentTime - 1 / projectFps));
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"jump-forward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 5;
|
||||
seek(Math.min(duration, currentTime + seconds));
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"jump-backward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 5;
|
||||
seek(Math.max(0, currentTime - seconds));
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"goto-start",
|
||||
() => {
|
||||
seek(0);
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"goto-end",
|
||||
() => {
|
||||
seek(duration);
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Timeline editing actions
|
||||
useActionHandler(
|
||||
"split-element",
|
||||
() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element to split");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t: any) => t.id === trackId);
|
||||
const element = track?.elements.find((el: any) => el.id === elementId);
|
||||
|
||||
if (element) {
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
|
||||
splitSelected(currentTime, trackId, elementId);
|
||||
} else {
|
||||
toast.error("Playhead must be within selected element");
|
||||
}
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"delete-selected",
|
||||
() => {
|
||||
if (selectedElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
deleteSelected();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"select-all",
|
||||
() => {
|
||||
const allElements = tracks.flatMap((track: any) =>
|
||||
track.elements.map((element: any) => ({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
})),
|
||||
);
|
||||
setSelectedElements(allElements);
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"duplicate-selected",
|
||||
() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element to duplicate");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t: any) => t.id === trackId);
|
||||
const element = track?.elements.find((el: any) => el.id === elementId);
|
||||
|
||||
if (element) {
|
||||
const newStartTime =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd) +
|
||||
0.1;
|
||||
const { id, ...elementWithoutId } = element;
|
||||
|
||||
addElementToTrack(trackId, {
|
||||
...elementWithoutId,
|
||||
startTime: newStartTime,
|
||||
});
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"copy-selected",
|
||||
() => {
|
||||
if (selectedElements.length === 0) return;
|
||||
useTimelineStore.getState().copySelected();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"paste-selected",
|
||||
() => {
|
||||
useTimelineStore.getState().pasteAtTime(currentTime);
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"toggle-snapping",
|
||||
() => {
|
||||
toggleSnapping();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
// History actions
|
||||
useActionHandler(
|
||||
"undo",
|
||||
() => {
|
||||
undo();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"redo",
|
||||
() => {
|
||||
redo();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
export function useHighlightScroll(
|
||||
highlightId: string | null,
|
||||
onClearHighlight: () => void,
|
||||
highlightDuration = 1000
|
||||
) {
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
|
||||
const registerElement = (id: string, element: HTMLElement | null) => {
|
||||
if (element) {
|
||||
elementRefs.current.set(id, element);
|
||||
} else {
|
||||
elementRefs.current.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightId) return;
|
||||
|
||||
setHighlightedId(highlightId);
|
||||
|
||||
const target = elementRefs.current.get(highlightId);
|
||||
target?.scrollIntoView({ block: "center" });
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setHighlightedId(null);
|
||||
onClearHighlight();
|
||||
}, highlightDuration);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [highlightId, onClearHighlight, highlightDuration]);
|
||||
|
||||
return { highlightedId, registerElement };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useRef, useCallback } from "react";
|
||||
|
||||
interface UseInfiniteScrollOptions {
|
||||
onLoadMore: () => void;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
threshold?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useInfiniteScroll({
|
||||
onLoadMore,
|
||||
hasMore,
|
||||
isLoading,
|
||||
threshold = 200,
|
||||
enabled = true,
|
||||
}: UseInfiniteScrollOptions) {
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!enabled) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||||
const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
|
||||
|
||||
if (nearBottom && hasMore && !isLoading) {
|
||||
onLoadMore();
|
||||
}
|
||||
},
|
||||
[onLoadMore, hasMore, isLoading, threshold, enabled]
|
||||
);
|
||||
|
||||
return { scrollAreaRef, handleScroll };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { ActionWithOptionalArgs } from "@/constants/actions-constants";
|
||||
|
||||
export interface KeybindingConflictInfo {
|
||||
key: string;
|
||||
actions: ActionWithOptionalArgs[];
|
||||
isConflict: boolean;
|
||||
}
|
||||
|
||||
export const useKeybindingConflicts = () => {
|
||||
const { keybindings } = useKeybindingsStore();
|
||||
|
||||
const conflicts = useMemo(() => {
|
||||
const keyToActions: Record<string, ActionWithOptionalArgs[]> = {};
|
||||
const conflictList: KeybindingConflictInfo[] = [];
|
||||
|
||||
// Group actions by key
|
||||
Object.entries(keybindings).forEach(([key, action]) => {
|
||||
if (!keyToActions[key]) {
|
||||
keyToActions[key] = [];
|
||||
}
|
||||
keyToActions[key].push(action);
|
||||
});
|
||||
|
||||
// Find conflicts
|
||||
Object.entries(keyToActions).forEach(([key, actions]) => {
|
||||
const uniqueActions = [...new Set(actions)];
|
||||
conflictList.push({
|
||||
key,
|
||||
actions: uniqueActions,
|
||||
isConflict: uniqueActions.length > 1,
|
||||
});
|
||||
});
|
||||
|
||||
return conflictList.filter((item) => item.isConflict);
|
||||
}, [keybindings]);
|
||||
|
||||
const hasConflicts = conflicts.length > 0;
|
||||
|
||||
const getConflictsForKey = (key: string): KeybindingConflictInfo | null => {
|
||||
return conflicts.find((conflict) => conflict.key === key) || null;
|
||||
};
|
||||
|
||||
const getConflictsForAction = (
|
||||
action: ActionWithOptionalArgs,
|
||||
): KeybindingConflictInfo[] => {
|
||||
return conflicts.filter((conflict) => conflict.actions.includes(action));
|
||||
};
|
||||
|
||||
return {
|
||||
conflicts,
|
||||
hasConflicts,
|
||||
getConflictsForKey,
|
||||
getConflictsForAction,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect } from "react";
|
||||
import { invokeAction } from "../constants/actions-constants";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
|
||||
/**
|
||||
* A composable that hooks to the caller component's
|
||||
* lifecycle and hooks to the keyboard events to fire
|
||||
* the appropriate actions based on keybindings
|
||||
*/
|
||||
export function useKeybindingsListener() {
|
||||
const { keybindings, getKeybindingString, keybindingsEnabled, isRecording } =
|
||||
useKeybindingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
// Do not check keybinds if the mode is disabled
|
||||
if (!keybindingsEnabled) return;
|
||||
// ignore key events if user is changing keybindings
|
||||
if (isRecording) return;
|
||||
|
||||
const binding = getKeybindingString(ev);
|
||||
if (!binding) return;
|
||||
|
||||
const boundAction = keybindings[binding];
|
||||
if (!boundAction) return;
|
||||
|
||||
const activeElement = document.activeElement;
|
||||
const isTextInput =
|
||||
activeElement &&
|
||||
(activeElement.tagName === "INPUT" ||
|
||||
activeElement.tagName === "TEXTAREA" ||
|
||||
(activeElement as HTMLElement).isContentEditable);
|
||||
|
||||
if (isTextInput) return;
|
||||
|
||||
ev.preventDefault();
|
||||
|
||||
// Handle actions with default arguments
|
||||
let actionArgs: any;
|
||||
|
||||
if (boundAction === "seek-forward") {
|
||||
actionArgs = { seconds: 1 };
|
||||
} else if (boundAction === "seek-backward") {
|
||||
actionArgs = { seconds: 1 };
|
||||
} else if (boundAction === "jump-forward") {
|
||||
actionArgs = { seconds: 5 };
|
||||
} else if (boundAction === "jump-backward") {
|
||||
actionArgs = { seconds: 5 };
|
||||
}
|
||||
|
||||
invokeAction(boundAction, actionArgs, "keypress");
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]);
|
||||
}
|
||||
|
||||
/**
|
||||
* This composable allows for the UI component to be disabled if the component in question is mounted
|
||||
*/
|
||||
export function useKeybindingDisabler() {
|
||||
const { disableKeybindings, enableKeybindings } = useKeybindingsStore();
|
||||
|
||||
return {
|
||||
disableKeybindings,
|
||||
enableKeybindings,
|
||||
};
|
||||
}
|
||||
|
||||
// Export the bindings for backward compatibility
|
||||
export const bindings = {};
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { Action } from "@/constants/action-constants";
|
||||
import { getPlatformAlternateKey, getPlatformSpecialKey } from "@/lib/keyboard-utils";
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
id: string;
|
||||
keys: string[];
|
||||
description: string;
|
||||
category: string;
|
||||
action: Action;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Map actions to their descriptions and categories
|
||||
const actionDescriptions: Record<
|
||||
Action,
|
||||
{ description: string; category: string }
|
||||
> = {
|
||||
"toggle-play": { description: "Play/Pause", category: "Playback" },
|
||||
"stop-playback": { description: "Stop playback", category: "Playback" },
|
||||
"seek-forward": {
|
||||
description: "Seek forward 1 second",
|
||||
category: "Playback",
|
||||
},
|
||||
"seek-backward": {
|
||||
description: "Seek backward 1 second",
|
||||
category: "Playback",
|
||||
},
|
||||
"frame-step-forward": {
|
||||
description: "Frame step forward",
|
||||
category: "Navigation",
|
||||
},
|
||||
"frame-step-backward": {
|
||||
description: "Frame step backward",
|
||||
category: "Navigation",
|
||||
},
|
||||
"jump-forward": {
|
||||
description: "Jump forward 5 seconds",
|
||||
category: "Navigation",
|
||||
},
|
||||
"jump-backward": {
|
||||
description: "Jump backward 5 seconds",
|
||||
category: "Navigation",
|
||||
},
|
||||
"goto-start": { description: "Go to timeline start", category: "Navigation" },
|
||||
"goto-end": { description: "Go to timeline end", category: "Navigation" },
|
||||
"split-element": {
|
||||
description: "Split element at playhead",
|
||||
category: "Editing",
|
||||
},
|
||||
"delete-selected": {
|
||||
description: "Delete selected elements",
|
||||
category: "Editing",
|
||||
},
|
||||
"select-all": { description: "Select all elements", category: "Selection" },
|
||||
"duplicate-selected": {
|
||||
description: "Duplicate selected element",
|
||||
category: "Selection",
|
||||
},
|
||||
"toggle-snapping": { description: "Toggle snapping", category: "Editing" },
|
||||
undo: { description: "Undo", category: "History" },
|
||||
redo: { description: "Redo", category: "History" },
|
||||
"copy-selected": {
|
||||
description: "Copy selected elements",
|
||||
category: "Editing",
|
||||
},
|
||||
"paste-selected": {
|
||||
description: "Paste elements at playhead",
|
||||
category: "Editing",
|
||||
},
|
||||
};
|
||||
|
||||
// Convert key binding format to display format
|
||||
const formatKey = (key: string): string => {
|
||||
return key
|
||||
.replace("ctrl", getPlatformSpecialKey())
|
||||
.replace("alt", getPlatformAlternateKey())
|
||||
.replace("shift", "Shift")
|
||||
.replace("left", "←")
|
||||
.replace("right", "→")
|
||||
.replace("up", "↑")
|
||||
.replace("down", "↓")
|
||||
.replace("space", "Space")
|
||||
.replace("home", "Home")
|
||||
.replace("enter", "Enter")
|
||||
.replace("end", "End")
|
||||
.replace("delete", "Delete")
|
||||
.replace("backspace", "Backspace")
|
||||
.replace("-", "+");
|
||||
};
|
||||
|
||||
export const useKeyboardShortcutsHelp = () => {
|
||||
const { keybindings } = useKeybindingsStore();
|
||||
|
||||
const shortcuts = useMemo(() => {
|
||||
const result: KeyboardShortcut[] = [];
|
||||
|
||||
// Group keybindings by action
|
||||
const actionToKeys: Record<Action, string[]> = {} as any;
|
||||
|
||||
Object.entries(keybindings).forEach(([key, action]) => {
|
||||
if (action) {
|
||||
if (!actionToKeys[action]) {
|
||||
actionToKeys[action] = [];
|
||||
}
|
||||
actionToKeys[action].push(formatKey(key));
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to shortcuts format
|
||||
Object.entries(actionToKeys).forEach(([action, keys]) => {
|
||||
const actionInfo = actionDescriptions[action as Action];
|
||||
if (actionInfo) {
|
||||
result.push({
|
||||
id: action,
|
||||
keys,
|
||||
description: actionInfo.description,
|
||||
category: actionInfo.category,
|
||||
action: action as Action,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort shortcuts by category first, then by description to ensure consistent ordering
|
||||
return result.sort((a, b) => {
|
||||
if (a.category !== b.category) {
|
||||
return a.category.localeCompare(b.category);
|
||||
}
|
||||
return a.description.localeCompare(b.description);
|
||||
});
|
||||
}, [keybindings]);
|
||||
|
||||
return {
|
||||
shortcuts,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const usePlaybackControls = () => {
|
||||
const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore();
|
||||
|
||||
const {
|
||||
selectedElements,
|
||||
tracks,
|
||||
splitSelected,
|
||||
splitAndKeepLeft,
|
||||
splitAndKeepRight,
|
||||
separateAudio,
|
||||
} = useTimelineStore();
|
||||
|
||||
const handleSplitSelectedElement = useCallback(() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element to split");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
|
||||
if (!element) return;
|
||||
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within selected element");
|
||||
return;
|
||||
}
|
||||
|
||||
splitSelected(currentTime, trackId, elementId);
|
||||
}, [selectedElements, tracks, currentTime, splitSelected]);
|
||||
|
||||
const handleSplitAndKeepLeftCallback = useCallback(() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
|
||||
if (!element) return;
|
||||
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within selected element");
|
||||
return;
|
||||
}
|
||||
|
||||
splitAndKeepLeft(trackId, elementId, currentTime);
|
||||
}, [selectedElements, tracks, currentTime, splitAndKeepLeft]);
|
||||
|
||||
const handleSplitAndKeepRightCallback = useCallback(() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
|
||||
if (!element) return;
|
||||
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within selected element");
|
||||
return;
|
||||
}
|
||||
|
||||
splitAndKeepRight(trackId, elementId, currentTime);
|
||||
}, [selectedElements, tracks, currentTime, splitAndKeepRight]);
|
||||
|
||||
const handleSeparateAudioCallback = useCallback(() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one media element to separate audio");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
|
||||
if (!track || track.type !== "media") {
|
||||
toast.error("Select a media element to separate audio");
|
||||
return;
|
||||
}
|
||||
|
||||
separateAudio(trackId, elementId);
|
||||
}, [selectedElements, tracks, separateAudio]);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface UsePreventScrollOptions {
|
||||
enabled?: boolean;
|
||||
element?: HTMLElement;
|
||||
}
|
||||
|
||||
export function usePreventScroll({ enabled = true, element }: UsePreventScrollOptions = {}) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const targetElement = element || document.body;
|
||||
const originalOverflow = targetElement.style.overflow;
|
||||
const originalPaddingRight = targetElement.style.paddingRight;
|
||||
|
||||
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||
|
||||
targetElement.style.overflow = 'hidden';
|
||||
if (scrollbarWidth > 0) {
|
||||
targetElement.style.paddingRight = `${scrollbarWidth}px`;
|
||||
}
|
||||
|
||||
return () => {
|
||||
targetElement.style.overflow = originalOverflow;
|
||||
targetElement.style.paddingRight = originalPaddingRight;
|
||||
};
|
||||
}, [enabled, element]);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useRafLoop(callback: ({ time }: { time: number }) => void) {
|
||||
const requestRef = useRef<number>(0);
|
||||
const previousTimeRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const loop = ({ time }: { time: number }) => {
|
||||
if (previousTimeRef.current !== undefined) {
|
||||
const deltaTime = time - previousTimeRef.current;
|
||||
callback({ time: deltaTime });
|
||||
}
|
||||
previousTimeRef.current = time;
|
||||
requestRef.current = requestAnimationFrame((time) => loop({ time }));
|
||||
};
|
||||
|
||||
requestRef.current = requestAnimationFrame((time) => loop({ time }));
|
||||
return () => cancelAnimationFrame(requestRef.current);
|
||||
}, [callback]);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface UseScrollSyncProps {
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
trackLabelsScrollRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function useScrollSync({
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
trackLabelsScrollRef,
|
||||
}: UseScrollSyncProps) {
|
||||
const isUpdatingRef = useRef(false);
|
||||
const lastRulerSync = useRef(0);
|
||||
const lastTracksSync = useRef(0);
|
||||
const lastVerticalSync = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const rulerViewport = rulerScrollRef.current;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
const trackLabelsViewport = trackLabelsScrollRef?.current;
|
||||
|
||||
if (!rulerViewport || !tracksViewport) return;
|
||||
|
||||
const handleRulerScroll = () => {
|
||||
const now = Date.now();
|
||||
if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
|
||||
lastRulerSync.current = now;
|
||||
isUpdatingRef.current = true;
|
||||
tracksViewport.scrollLeft = rulerViewport.scrollLeft;
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
|
||||
const handleTracksScroll = () => {
|
||||
const now = Date.now();
|
||||
if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
|
||||
lastTracksSync.current = now;
|
||||
isUpdatingRef.current = true;
|
||||
rulerViewport.scrollLeft = tracksViewport.scrollLeft;
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
|
||||
rulerViewport.addEventListener("scroll", handleRulerScroll);
|
||||
tracksViewport.addEventListener("scroll", handleTracksScroll);
|
||||
|
||||
if (trackLabelsViewport) {
|
||||
const handleTrackLabelsScroll = () => {
|
||||
const now = Date.now();
|
||||
if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
|
||||
return;
|
||||
lastVerticalSync.current = now;
|
||||
isUpdatingRef.current = true;
|
||||
tracksViewport.scrollTop = trackLabelsViewport.scrollTop;
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
|
||||
const handleTracksVerticalScroll = () => {
|
||||
const now = Date.now();
|
||||
if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
|
||||
return;
|
||||
lastVerticalSync.current = now;
|
||||
isUpdatingRef.current = true;
|
||||
trackLabelsViewport.scrollTop = tracksViewport.scrollTop;
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
|
||||
trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
|
||||
tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
|
||||
|
||||
return () => {
|
||||
rulerViewport.removeEventListener("scroll", handleRulerScroll);
|
||||
tracksViewport.removeEventListener("scroll", handleTracksScroll);
|
||||
trackLabelsViewport.removeEventListener(
|
||||
"scroll",
|
||||
handleTrackLabelsScroll,
|
||||
);
|
||||
tracksViewport.removeEventListener(
|
||||
"scroll",
|
||||
handleTracksVerticalScroll,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
rulerViewport.removeEventListener("scroll", handleRulerScroll);
|
||||
tracksViewport.removeEventListener("scroll", handleTracksScroll);
|
||||
};
|
||||
}, [rulerScrollRef, tracksScrollRef, trackLabelsScrollRef]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface UseSelectionBoxProps {
|
||||
containerRef: React.RefObject<HTMLElement>;
|
||||
playheadRef?: React.RefObject<HTMLElement>;
|
||||
onSelectionComplete: (
|
||||
elements: { trackId: string; elementId: string }[]
|
||||
) => void;
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface SelectionBoxState {
|
||||
startPos: { x: number; y: number };
|
||||
currentPos: { x: number; y: number };
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function useSelectionBox({
|
||||
containerRef,
|
||||
playheadRef,
|
||||
onSelectionComplete,
|
||||
isEnabled = true,
|
||||
}: UseSelectionBoxProps) {
|
||||
const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
|
||||
null
|
||||
);
|
||||
const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
|
||||
|
||||
// Mouse down handler to start selection
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
// Only start selection on empty space clicks
|
||||
if ((e.target as HTMLElement).closest(".timeline-element")) {
|
||||
return;
|
||||
}
|
||||
if (playheadRef?.current?.contains(e.target as Node)) {
|
||||
return;
|
||||
}
|
||||
if ((e.target as HTMLElement).closest("[data-track-labels]")) {
|
||||
return;
|
||||
}
|
||||
// Don't start selection when clicking in the ruler area - this interferes with playhead dragging
|
||||
if ((e.target as HTMLElement).closest("[data-ruler-area]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectionBox({
|
||||
startPos: { x: e.clientX, y: e.clientY },
|
||||
currentPos: { x: e.clientX, y: e.clientY },
|
||||
isActive: false, // Will become active when mouse moves
|
||||
});
|
||||
},
|
||||
[isEnabled, playheadRef]
|
||||
);
|
||||
|
||||
// Function to select elements within the selection box
|
||||
const selectElementsInBox = useCallback(
|
||||
(startPos: { x: number; y: number }, endPos: { x: number; y: number }) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// Calculate selection rectangle in container coordinates
|
||||
const startX = startPos.x - containerRect.left;
|
||||
const startY = startPos.y - containerRect.top;
|
||||
const endX = endPos.x - containerRect.left;
|
||||
const endY = endPos.y - containerRect.top;
|
||||
|
||||
const selectionRect = {
|
||||
left: Math.min(startX, endX),
|
||||
top: Math.min(startY, endY),
|
||||
right: Math.max(startX, endX),
|
||||
bottom: Math.max(startY, endY),
|
||||
};
|
||||
|
||||
// Find all timeline elements within the selection rectangle
|
||||
const timelineElements = container.querySelectorAll(".timeline-element");
|
||||
|
||||
const selectedElements: { trackId: string; elementId: string }[] = [];
|
||||
|
||||
timelineElements.forEach((element) => {
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
// Use absolute coordinates for more accurate intersection detection
|
||||
const elementAbsolute = {
|
||||
left: elementRect.left,
|
||||
top: elementRect.top,
|
||||
right: elementRect.right,
|
||||
bottom: elementRect.bottom,
|
||||
};
|
||||
|
||||
const selectionAbsolute = {
|
||||
left: startPos.x,
|
||||
top: startPos.y,
|
||||
right: endPos.x,
|
||||
bottom: endPos.y,
|
||||
};
|
||||
|
||||
// Normalize selection rectangle (handle dragging in any direction)
|
||||
const normalizedSelection = {
|
||||
left: Math.min(selectionAbsolute.left, selectionAbsolute.right),
|
||||
top: Math.min(selectionAbsolute.top, selectionAbsolute.bottom),
|
||||
right: Math.max(selectionAbsolute.left, selectionAbsolute.right),
|
||||
bottom: Math.max(selectionAbsolute.top, selectionAbsolute.bottom),
|
||||
};
|
||||
|
||||
const elementId = element.getAttribute("data-element-id");
|
||||
const trackId = element.getAttribute("data-track-id");
|
||||
|
||||
// Check if element intersects with selection rectangle (any overlap)
|
||||
// Using absolute coordinates for more precise detection
|
||||
const intersects = !(
|
||||
elementAbsolute.right < normalizedSelection.left ||
|
||||
elementAbsolute.left > normalizedSelection.right ||
|
||||
elementAbsolute.bottom < normalizedSelection.top ||
|
||||
elementAbsolute.top > normalizedSelection.bottom
|
||||
);
|
||||
|
||||
if (intersects && elementId && trackId) {
|
||||
selectedElements.push({ trackId, elementId });
|
||||
}
|
||||
});
|
||||
|
||||
// Always call the callback - with elements or empty array to clear selection
|
||||
console.log(
|
||||
JSON.stringify({ selectElementsInBox: selectedElements.length })
|
||||
);
|
||||
onSelectionComplete(selectedElements);
|
||||
},
|
||||
[containerRef, onSelectionComplete]
|
||||
);
|
||||
|
||||
// Effect to track selection box movement
|
||||
useEffect(() => {
|
||||
if (!selectionBox) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = Math.abs(e.clientX - selectionBox.startPos.x);
|
||||
const deltaY = Math.abs(e.clientY - selectionBox.startPos.y);
|
||||
|
||||
// Start selection if mouse moved more than 5px
|
||||
const shouldActivate = deltaX > 5 || deltaY > 5;
|
||||
|
||||
const newSelectionBox = {
|
||||
...selectionBox,
|
||||
currentPos: { x: e.clientX, y: e.clientY },
|
||||
isActive: shouldActivate || selectionBox.isActive,
|
||||
};
|
||||
|
||||
setSelectionBox(newSelectionBox);
|
||||
|
||||
// Real-time visual feedback: update selection as we drag
|
||||
if (newSelectionBox.isActive) {
|
||||
selectElementsInBox(
|
||||
newSelectionBox.startPos,
|
||||
newSelectionBox.currentPos
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
console.log(
|
||||
JSON.stringify({ mouseUp: { wasActive: selectionBox?.isActive } })
|
||||
);
|
||||
|
||||
// If we had an active selection, mark that we just finished selecting
|
||||
if (selectionBox?.isActive) {
|
||||
console.log(JSON.stringify({ settingJustFinishedSelecting: true }));
|
||||
setJustFinishedSelecting(true);
|
||||
// Clear the flag after a short delay to allow click events to check it
|
||||
setTimeout(() => {
|
||||
console.log(JSON.stringify({ clearingJustFinishedSelecting: true }));
|
||||
setJustFinishedSelecting(false);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// Don't call selectElementsInBox again - real-time selection already handled it
|
||||
// Just clean up the selection box visual
|
||||
setSelectionBox(null);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [selectionBox, selectElementsInBox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectionBox?.isActive) return;
|
||||
|
||||
const previousBodyUserSelect = document.body.style.userSelect;
|
||||
const container = containerRef.current;
|
||||
const previousContainerUserSelect = container?.style.userSelect ?? "";
|
||||
|
||||
document.body.style.userSelect = "none";
|
||||
if (container) container.style.userSelect = "none";
|
||||
|
||||
return () => {
|
||||
document.body.style.userSelect = previousBodyUserSelect;
|
||||
if (container) container.style.userSelect = previousContainerUserSelect;
|
||||
};
|
||||
}, [selectionBox?.isActive, containerRef]);
|
||||
|
||||
return {
|
||||
selectionBox,
|
||||
handleMouseDown,
|
||||
isSelecting: selectionBox?.isActive || false,
|
||||
justFinishedSelecting,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSoundsStore } from "@/stores/sounds-store";
|
||||
|
||||
/**
|
||||
* Custom hook for searching sound effects with race condition protection.
|
||||
* Uses global Zustand store to persist search state across tab switches.
|
||||
* - Debounced search (300ms)
|
||||
* - Race condition protection with cleanup
|
||||
* - Proper error handling
|
||||
*/
|
||||
|
||||
export function useSoundSearch(query: string, commercialOnly: boolean) {
|
||||
const {
|
||||
searchResults,
|
||||
isSearching,
|
||||
searchError,
|
||||
lastSearchQuery,
|
||||
currentPage,
|
||||
hasNextPage,
|
||||
isLoadingMore,
|
||||
totalCount,
|
||||
setSearchResults,
|
||||
setSearching,
|
||||
setSearchError,
|
||||
setLastSearchQuery,
|
||||
setCurrentPage,
|
||||
setHasNextPage,
|
||||
setTotalCount,
|
||||
setLoadingMore,
|
||||
appendSearchResults,
|
||||
appendTopSounds,
|
||||
resetPagination,
|
||||
} = useSoundsStore();
|
||||
|
||||
// Load more function for infinite scroll
|
||||
const loadMore = async () => {
|
||||
if (isLoadingMore || !hasNextPage) return;
|
||||
|
||||
try {
|
||||
setLoadingMore(true);
|
||||
const nextPage = currentPage + 1;
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
page: nextPage.toString(),
|
||||
type: "effects",
|
||||
});
|
||||
|
||||
if (query.trim()) {
|
||||
searchParams.set("q", query);
|
||||
}
|
||||
|
||||
searchParams.set("commercial_only", commercialOnly.toString());
|
||||
const response = await fetch(
|
||||
`/api/sounds/search?${searchParams.toString()}`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Append to appropriate array based on whether we have a query
|
||||
if (query.trim()) {
|
||||
appendSearchResults(data.results);
|
||||
} else {
|
||||
appendTopSounds(data.results);
|
||||
}
|
||||
|
||||
setCurrentPage(nextPage);
|
||||
setHasNextPage(!!data.next);
|
||||
setTotalCount(data.count);
|
||||
} else {
|
||||
setSearchError(`Load more failed: ${response.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setSearchError(err instanceof Error ? err.message : "Load more failed");
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([]);
|
||||
setSearchError(null);
|
||||
setLastSearchQuery("");
|
||||
// Don't reset pagination here - top sounds pagination is managed by prefetcher
|
||||
return;
|
||||
}
|
||||
|
||||
// If we already searched for this query and have results, don't search again
|
||||
if (query === lastSearchQuery && searchResults.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let ignore = false;
|
||||
|
||||
const timeoutId = setTimeout(async () => {
|
||||
try {
|
||||
setSearching(true);
|
||||
setSearchError(null);
|
||||
resetPagination();
|
||||
|
||||
const response = await fetch(
|
||||
`/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`
|
||||
);
|
||||
|
||||
if (!ignore) {
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSearchResults(data.results);
|
||||
setLastSearchQuery(query);
|
||||
setHasNextPage(!!data.next);
|
||||
setTotalCount(data.count);
|
||||
setCurrentPage(1);
|
||||
} else {
|
||||
setSearchError(`Search failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!ignore) {
|
||||
setSearchError(err instanceof Error ? err.message : "Search failed");
|
||||
}
|
||||
} finally {
|
||||
if (!ignore) {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
ignore = true;
|
||||
};
|
||||
}, [
|
||||
query,
|
||||
lastSearchQuery,
|
||||
searchResults.length,
|
||||
setSearchResults,
|
||||
setSearching,
|
||||
setSearchError,
|
||||
setLastSearchQuery,
|
||||
setCurrentPage,
|
||||
setHasNextPage,
|
||||
setTotalCount,
|
||||
resetPagination,
|
||||
]);
|
||||
|
||||
return {
|
||||
results: searchResults,
|
||||
isLoading: isSearching,
|
||||
error: searchError,
|
||||
loadMore,
|
||||
hasNextPage,
|
||||
isLoadingMore,
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { processMediaFiles } from "@/lib/media-processing-utils";
|
||||
import { toast } from "sonner";
|
||||
import type { DragData } from "@/types/timeline";
|
||||
|
||||
interface UseTimelineDragDropProps {
|
||||
addElementToNewTrack: (data: any) => void;
|
||||
}
|
||||
|
||||
export function useTimelineDragDrop({ addElementToNewTrack }: UseTimelineDragDropProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const { mediaFiles, addMediaFile } = useMediaStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
const dragCounterRef = useRef(0);
|
||||
|
||||
const handleInternalMediaDrop = useCallback(async (dragData: DragData) => {
|
||||
if (dragData.type === "text") {
|
||||
addElementToNewTrack(dragData);
|
||||
} else {
|
||||
const mediaItem = mediaFiles.find((item: any) => item.id === dragData.id);
|
||||
if (!mediaItem) {
|
||||
toast.error("Media item not found");
|
||||
return;
|
||||
}
|
||||
|
||||
addElementToNewTrack(mediaItem);
|
||||
}
|
||||
}, [mediaFiles, addElementToNewTrack]);
|
||||
|
||||
const handleExternalFileDrop = useCallback(async (files: FileList) => {
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const processedItems = await processMediaFiles({
|
||||
files,
|
||||
});
|
||||
|
||||
for (const processedItem of processedItems) {
|
||||
await addMediaFile(activeProject.id, processedItem);
|
||||
|
||||
const addedItem = mediaFiles.find(
|
||||
(item) =>
|
||||
item.name === processedItem.name && item.url === processedItem.url,
|
||||
);
|
||||
|
||||
if (addedItem) {
|
||||
const trackType: "audio" | "media" =
|
||||
addedItem.type === "audio" ? "audio" : "media";
|
||||
const targetTrackId = useTimelineStore
|
||||
.getState()
|
||||
.insertTrackAt(trackType, 0);
|
||||
|
||||
useTimelineStore.getState().addElementToTrack(targetTrackId, {
|
||||
type: "media",
|
||||
mediaId: addedItem.id,
|
||||
name: addedItem.name,
|
||||
duration: addedItem.duration || 5,
|
||||
startTime: currentTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing external files:", error);
|
||||
toast.error("Failed to process dropped files");
|
||||
}
|
||||
}, [activeProject, mediaFiles, addMediaFile, currentTime]);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.dataTransfer.types.includes("application/x-timeline-element")) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragCounterRef.current++;
|
||||
if (!isDragOver) setIsDragOver(true);
|
||||
}, [isDragOver]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.dataTransfer.types.includes("application/x-timeline-element")) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragCounterRef.current--;
|
||||
if (dragCounterRef.current === 0) setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
dragCounterRef.current = 0;
|
||||
|
||||
if (e.dataTransfer.types.includes("application/x-timeline-element")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const itemData = e.dataTransfer.getData("application/x-media-item");
|
||||
if (itemData) {
|
||||
const dragData: DragData = JSON.parse(itemData);
|
||||
await handleInternalMediaDrop(dragData);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.dataTransfer.files?.length > 0) {
|
||||
await handleExternalFileDrop(e.dataTransfer.files);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing dropped item data:", error);
|
||||
toast.error("Failed to add item to timeline");
|
||||
}
|
||||
}, [handleInternalMediaDrop, handleExternalFileDrop]);
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
dragProps: {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { ResizeState, TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
|
||||
interface UseTimelineElementResizeProps {
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
}
|
||||
|
||||
export function useTimelineElementResize({
|
||||
element,
|
||||
track,
|
||||
zoomLevel,
|
||||
}: UseTimelineElementResizeProps) {
|
||||
const [resizing, setResizing] = useState<ResizeState | null>(null);
|
||||
const { mediaFiles } = useMediaStore();
|
||||
const {
|
||||
updateElementStartTime,
|
||||
updateElementTrim,
|
||||
updateElementDuration,
|
||||
pushHistory,
|
||||
} = useTimelineStore();
|
||||
|
||||
// Set up document-level mouse listeners during resize (like proper drag behavior)
|
||||
useEffect(() => {
|
||||
if (!resizing) return;
|
||||
|
||||
const handleDocumentMouseMove = (e: MouseEvent) => {
|
||||
updateTrimFromMouseMove({ clientX: e.clientX });
|
||||
};
|
||||
|
||||
const handleDocumentMouseUp = () => {
|
||||
handleResizeEnd();
|
||||
};
|
||||
|
||||
// Add document-level listeners for proper drag behavior
|
||||
document.addEventListener("mousemove", handleDocumentMouseMove);
|
||||
document.addEventListener("mouseup", handleDocumentMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDocumentMouseMove);
|
||||
document.removeEventListener("mouseup", handleDocumentMouseUp);
|
||||
};
|
||||
}, [resizing]); // Re-run when resizing state changes
|
||||
|
||||
const handleResizeStart = (
|
||||
e: React.MouseEvent,
|
||||
elementId: string,
|
||||
side: "left" | "right",
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
// Push history once at the start of the resize operation
|
||||
pushHistory();
|
||||
|
||||
setResizing({
|
||||
elementId,
|
||||
side,
|
||||
startX: e.clientX,
|
||||
initialTrimStart: element.trimStart,
|
||||
initialTrimEnd: element.trimEnd,
|
||||
});
|
||||
};
|
||||
|
||||
const canExtendElementDuration = () => {
|
||||
// Text elements can always be extended
|
||||
if (element.type === "text") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Media elements - check the media type
|
||||
if (element.type === "media") {
|
||||
const mediaFile = mediaFiles.find((file) => file.id === element.mediaId);
|
||||
if (!mediaFile) return false;
|
||||
|
||||
// Images can be extended (static content)
|
||||
if (mediaFile.type === "image") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Videos and audio cannot be extended beyond their natural duration
|
||||
// (no additional content exists)
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const updateTrimFromMouseMove = (e: { clientX: number }) => {
|
||||
if (!resizing) return;
|
||||
|
||||
const deltaX = e.clientX - resizing.startX;
|
||||
// Reasonable sensitivity for resize operations - similar to timeline scale
|
||||
const deltaTime = deltaX / (50 * zoomLevel);
|
||||
|
||||
// Get project FPS for frame snapping
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
|
||||
|
||||
if (resizing.side === "left") {
|
||||
// Left resize - different behavior for media vs text/image elements
|
||||
const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1;
|
||||
const calculated = resizing.initialTrimStart + deltaTime;
|
||||
|
||||
if (calculated >= 0) {
|
||||
// Normal trimming within available content
|
||||
const newTrimStart = snapTimeToFrame({
|
||||
time: Math.min(maxAllowed, calculated),
|
||||
fps: projectFps,
|
||||
});
|
||||
const trimDelta = newTrimStart - resizing.initialTrimStart;
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: element.startTime + trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
newTrimStart,
|
||||
resizing.initialTrimEnd,
|
||||
false,
|
||||
);
|
||||
updateElementStartTime(track.id, element.id, newStartTime, false);
|
||||
} else {
|
||||
// Trying to extend beyond trimStart = 0
|
||||
if (canExtendElementDuration()) {
|
||||
// Text/Image: extend element to the left by moving startTime and increasing duration
|
||||
const extensionAmount = Math.abs(calculated);
|
||||
const maxExtension = element.startTime;
|
||||
const actualExtension = Math.min(extensionAmount, maxExtension);
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: element.startTime - actualExtension,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: element.duration + actualExtension,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
// Keep trimStart at 0 and extend the element
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
0,
|
||||
resizing.initialTrimEnd,
|
||||
false,
|
||||
);
|
||||
updateElementDuration(track.id, element.id, newDuration, false);
|
||||
updateElementStartTime(track.id, element.id, newStartTime, false);
|
||||
} else {
|
||||
// Video/Audio: can't extend beyond original content - limit to trimStart = 0
|
||||
const newTrimStart = 0;
|
||||
const trimDelta = newTrimStart - resizing.initialTrimStart;
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: element.startTime + trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
newTrimStart,
|
||||
resizing.initialTrimEnd,
|
||||
false,
|
||||
);
|
||||
updateElementStartTime(track.id, element.id, newStartTime, false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Right resize - can extend duration for supported element types
|
||||
const calculated = resizing.initialTrimEnd - deltaTime;
|
||||
|
||||
if (calculated < 0) {
|
||||
// We're trying to extend beyond original duration
|
||||
if (canExtendElementDuration()) {
|
||||
// Extend the duration instead of reducing trimEnd further
|
||||
const extensionNeeded = Math.abs(calculated);
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: element.duration + extensionNeeded,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending
|
||||
|
||||
// Update duration first, then trim
|
||||
updateElementDuration(track.id, element.id, newDuration, false);
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
resizing.initialTrimStart,
|
||||
newTrimEnd,
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
// Can't extend - just set trimEnd to 0 (maximum possible extension)
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
resizing.initialTrimStart,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Normal trimming within original duration
|
||||
// Calculate the desired end time based on mouse movement
|
||||
const currentEndTime =
|
||||
element.startTime +
|
||||
element.duration -
|
||||
element.trimStart -
|
||||
element.trimEnd;
|
||||
const desiredEndTime = currentEndTime + deltaTime;
|
||||
|
||||
// Snap the desired end time to frame
|
||||
const snappedEndTime = snapTimeToFrame({
|
||||
time: desiredEndTime,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
// Calculate what trimEnd should be to achieve this snapped end time
|
||||
const newTrimEnd = Math.max(
|
||||
0,
|
||||
element.duration -
|
||||
element.trimStart -
|
||||
(snappedEndTime - element.startTime),
|
||||
);
|
||||
|
||||
// Ensure we don't trim more than available content (leave at least 0.1s visible)
|
||||
const maxTrimEnd = element.duration - element.trimStart - 0.1;
|
||||
const finalTrimEnd = Math.min(maxTrimEnd, newTrimEnd);
|
||||
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
element.trimStart,
|
||||
finalTrimEnd,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeEnd = () => {
|
||||
setResizing(null);
|
||||
};
|
||||
|
||||
return {
|
||||
resizing,
|
||||
isResizing: resizing !== null,
|
||||
handleResizeStart,
|
||||
// Return empty handlers since we use document listeners now
|
||||
handleResizeMove: () => {}, // Not used anymore
|
||||
handleResizeEnd: () => {}, // Not used anymore
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import type { RefObject } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
|
||||
interface UseTimelineInteractionsProps {
|
||||
playheadRef: RefObject<HTMLDivElement>;
|
||||
tracksContainerRef: RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: RefObject<HTMLDivElement>;
|
||||
zoomLevel: number;
|
||||
duration: number;
|
||||
isSelecting: boolean;
|
||||
justFinishedSelecting: boolean;
|
||||
clearSelectedElements: () => void;
|
||||
seek: (time: number) => void;
|
||||
}
|
||||
|
||||
export function useTimelineInteractions({
|
||||
playheadRef,
|
||||
tracksContainerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
duration,
|
||||
isSelecting,
|
||||
justFinishedSelecting,
|
||||
clearSelectedElements,
|
||||
seek,
|
||||
}: UseTimelineInteractionsProps) {
|
||||
const { activeProject } = useProjectStore();
|
||||
const mouseTrackingRef = useRef({
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
});
|
||||
|
||||
const handleTimelineMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
const isTimelineBackground =
|
||||
!target.closest(".timeline-element") &&
|
||||
!playheadRef.current?.contains(target) &&
|
||||
!target.closest("[data-track-labels]");
|
||||
|
||||
if (isTimelineBackground) {
|
||||
mouseTrackingRef.current = {
|
||||
isMouseDown: true,
|
||||
downX: e.clientX,
|
||||
downY: e.clientY,
|
||||
downTime: e.timeStamp,
|
||||
};
|
||||
}
|
||||
},
|
||||
[playheadRef],
|
||||
);
|
||||
|
||||
const shouldProcessTimelineClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
|
||||
|
||||
if (!isMouseDown) return false;
|
||||
|
||||
const deltaX = Math.abs(e.clientX - downX);
|
||||
const deltaY = Math.abs(e.clientY - downY);
|
||||
const deltaTime = e.timeStamp - downTime;
|
||||
|
||||
if (deltaX > 5 || deltaY > 5 || deltaTime > 500) return false;
|
||||
|
||||
if (isSelecting || justFinishedSelecting) return false;
|
||||
|
||||
if (target.closest(".timeline-element")) return false;
|
||||
|
||||
if (playheadRef.current?.contains(target)) return false;
|
||||
|
||||
if (target.closest("[data-track-labels]")) {
|
||||
clearSelectedElements();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[isSelecting, justFinishedSelecting, clearSelectedElements, playheadRef],
|
||||
);
|
||||
|
||||
const handleTimelineSeek = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const isRulerClick = (e.target as HTMLElement).closest(
|
||||
"[data-ruler-area]",
|
||||
);
|
||||
const scrollContainer = isRulerClick
|
||||
? rulerScrollRef.current
|
||||
: tracksScrollRef.current;
|
||||
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const rect = scrollContainer.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
|
||||
const rawTime = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
duration,
|
||||
(mouseX + scrollLeft) /
|
||||
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
|
||||
const projectFps = activeProject?.fps || 30;
|
||||
const time = snapTimeToFrame({ time: rawTime, fps: projectFps });
|
||||
seek(time);
|
||||
},
|
||||
[
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
activeProject?.fps,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTimelineContentClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
mouseTrackingRef.current = {
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
};
|
||||
|
||||
if (shouldProcessTimelineClick(e)) {
|
||||
clearSelectedElements();
|
||||
handleTimelineSeek(e);
|
||||
}
|
||||
},
|
||||
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
|
||||
);
|
||||
|
||||
return {
|
||||
handleTimelineMouseDown,
|
||||
handleTimelineContentClick,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll";
|
||||
|
||||
interface UseTimelinePlayheadProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
zoomLevel: number;
|
||||
seek: (time: number) => void;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
playheadRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function useTimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
}: UseTimelinePlayheadProps) {
|
||||
// Playhead scrubbing state
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
const [scrubTime, setScrubTime] = useState<number | null>(null);
|
||||
|
||||
// Ruler drag detection state
|
||||
const [isDraggingRuler, setIsDraggingRuler] = useState(false);
|
||||
const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
|
||||
const lastMouseXRef = useRef<number>(0);
|
||||
|
||||
const playheadPosition =
|
||||
isScrubbing && scrubTime !== null ? scrubTime : currentTime;
|
||||
|
||||
// --- Playhead Scrubbing Handlers ---
|
||||
const handlePlayheadMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent ruler drag from triggering
|
||||
setIsScrubbing(true);
|
||||
handleScrub(e);
|
||||
},
|
||||
[duration, zoomLevel],
|
||||
);
|
||||
|
||||
// Ruler mouse down handler
|
||||
const handleRulerMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only handle left mouse button
|
||||
if (e.button !== 0) return;
|
||||
|
||||
// Don't interfere if clicking on the playhead itself
|
||||
if (playheadRef?.current?.contains(e.target as Node)) return;
|
||||
|
||||
e.preventDefault();
|
||||
setIsDraggingRuler(true);
|
||||
setHasDraggedRuler(false);
|
||||
|
||||
// Start scrubbing immediately
|
||||
setIsScrubbing(true);
|
||||
handleScrub(e);
|
||||
},
|
||||
[duration, zoomLevel],
|
||||
);
|
||||
|
||||
const handleScrub = useCallback(
|
||||
(e: MouseEvent | React.MouseEvent) => {
|
||||
const ruler = rulerRef.current;
|
||||
if (!ruler) return;
|
||||
const rect = ruler.getBoundingClientRect();
|
||||
const rawX = e.clientX - rect.left;
|
||||
|
||||
// Get the timeline content width based on duration and zoom
|
||||
const timelineContentWidth = duration * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
|
||||
|
||||
// Constrain x to be within the timeline content bounds
|
||||
const x = Math.max(0, Math.min(timelineContentWidth, rawX));
|
||||
|
||||
const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
|
||||
// Use frame snapping for playhead scrubbing
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
|
||||
const time = snapTimeToFrame({ time: rawTime, fps: projectFps });
|
||||
|
||||
// Debug logging
|
||||
if (rawX < 0 || x !== rawX) {
|
||||
console.log(
|
||||
"PLAYHEAD DEBUG:",
|
||||
JSON.stringify({
|
||||
mouseX: e.clientX,
|
||||
rulerLeft: rect.left,
|
||||
rawX,
|
||||
constrainedX: x,
|
||||
timelineContentWidth,
|
||||
rawTime,
|
||||
finalTime: time,
|
||||
duration,
|
||||
zoomLevel,
|
||||
playheadPx: time * 50 * zoomLevel,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setScrubTime(time);
|
||||
seek(time); // update video preview in real time
|
||||
|
||||
// Store mouse position for auto-scrolling
|
||||
lastMouseXRef.current = e.clientX;
|
||||
},
|
||||
[duration, zoomLevel, seek, rulerRef],
|
||||
);
|
||||
|
||||
useEdgeAutoScroll({
|
||||
isActive: isScrubbing,
|
||||
getMouseClientX: () => lastMouseXRef.current,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: duration * 50 * zoomLevel,
|
||||
});
|
||||
|
||||
// Mouse move/up event handlers
|
||||
useEffect(() => {
|
||||
if (!isScrubbing) return;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
handleScrub(e);
|
||||
// Mark that we've dragged if ruler drag is active
|
||||
if (isDraggingRuler) {
|
||||
setHasDraggedRuler(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = (e: MouseEvent) => {
|
||||
setIsScrubbing(false);
|
||||
if (scrubTime !== null) seek(scrubTime); // finalize seek
|
||||
setScrubTime(null);
|
||||
|
||||
// Handle ruler click vs drag
|
||||
if (isDraggingRuler) {
|
||||
setIsDraggingRuler(false);
|
||||
// If we didn't drag, treat it as a click-to-seek
|
||||
if (!hasDraggedRuler) {
|
||||
handleScrub(e);
|
||||
}
|
||||
setHasDraggedRuler(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
|
||||
// Edge auto-scroll is handled by useEdgeAutoScroll
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
// nothing to cleanup for edge auto scroll
|
||||
};
|
||||
}, [
|
||||
isScrubbing,
|
||||
scrubTime,
|
||||
seek,
|
||||
handleScrub,
|
||||
isDraggingRuler,
|
||||
hasDraggedRuler,
|
||||
// edge auto scroll hook is independent
|
||||
]);
|
||||
|
||||
// --- Playhead auto-scroll effect (only during playback) ---
|
||||
useEffect(() => {
|
||||
const { isPlaying } = usePlaybackStore.getState();
|
||||
|
||||
// Only auto-scroll during playback, not during manual interactions
|
||||
if (!isPlaying || isScrubbing) return;
|
||||
|
||||
const rulerViewport = rulerScrollRef.current;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
if (!rulerViewport || !tracksViewport) return;
|
||||
|
||||
const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
|
||||
const viewportWidth = rulerViewport.clientWidth;
|
||||
const scrollMin = 0;
|
||||
const scrollMax = rulerViewport.scrollWidth - viewportWidth;
|
||||
|
||||
// Only auto-scroll if playhead is completely out of view (no buffer)
|
||||
const needsScroll =
|
||||
playheadPx < rulerViewport.scrollLeft ||
|
||||
playheadPx > rulerViewport.scrollLeft + viewportWidth;
|
||||
|
||||
if (needsScroll) {
|
||||
// Center the playhead in the viewport
|
||||
const desiredScroll = Math.max(
|
||||
scrollMin,
|
||||
Math.min(scrollMax, playheadPx - viewportWidth / 2),
|
||||
);
|
||||
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
|
||||
}
|
||||
}, [
|
||||
playheadPosition,
|
||||
duration,
|
||||
zoomLevel,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
isScrubbing,
|
||||
]);
|
||||
|
||||
return {
|
||||
playheadPosition,
|
||||
handlePlayheadMouseDown,
|
||||
handleRulerMouseDown,
|
||||
isDraggingRuler,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useCallback } from "react";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
export interface SnapPoint {
|
||||
time: number;
|
||||
type: "element-start" | "element-end" | "playhead";
|
||||
elementId?: string;
|
||||
trackId?: string;
|
||||
}
|
||||
|
||||
export interface SnapResult {
|
||||
snappedTime: number;
|
||||
snapPoint: SnapPoint | null;
|
||||
snapDistance: number;
|
||||
}
|
||||
|
||||
export interface UseTimelineSnappingOptions {
|
||||
snapThreshold?: number; // Distance in pixels to trigger snapping
|
||||
enableElementSnapping?: boolean;
|
||||
enablePlayheadSnapping?: boolean;
|
||||
}
|
||||
|
||||
export function useTimelineSnapping({
|
||||
snapThreshold = 10,
|
||||
enableElementSnapping = true,
|
||||
enablePlayheadSnapping = true,
|
||||
}: UseTimelineSnappingOptions = {}) {
|
||||
const findSnapPoints = useCallback(
|
||||
(
|
||||
tracks: TimelineTrack[],
|
||||
currentTime: number,
|
||||
playheadTime: number,
|
||||
zoomLevel: number,
|
||||
excludeElementId?: string,
|
||||
): SnapPoint[] => {
|
||||
const snapPoints: SnapPoint[] = [];
|
||||
|
||||
// Add element snap points
|
||||
if (enableElementSnapping) {
|
||||
tracks.forEach((track) => {
|
||||
track.elements.forEach((element) => {
|
||||
// Skip the element being dragged
|
||||
if (element.id === excludeElementId) return;
|
||||
|
||||
const elementStart = element.startTime;
|
||||
const elementEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
snapPoints.push(
|
||||
{
|
||||
time: elementStart,
|
||||
type: "element-start",
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
},
|
||||
{
|
||||
time: elementEnd,
|
||||
type: "element-end",
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add playhead snap point
|
||||
if (enablePlayheadSnapping) {
|
||||
snapPoints.push({
|
||||
time: playheadTime,
|
||||
type: "playhead",
|
||||
});
|
||||
}
|
||||
|
||||
return snapPoints;
|
||||
},
|
||||
[enableElementSnapping, enablePlayheadSnapping],
|
||||
);
|
||||
|
||||
const snapToNearestPoint = useCallback(
|
||||
(
|
||||
targetTime: number,
|
||||
snapPoints: SnapPoint[],
|
||||
zoomLevel: number,
|
||||
): SnapResult => {
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const thresholdInSeconds = snapThreshold / pixelsPerSecond;
|
||||
|
||||
let closestSnapPoint: SnapPoint | null = null;
|
||||
let closestDistance = Infinity;
|
||||
|
||||
snapPoints.forEach((snapPoint) => {
|
||||
const distance = Math.abs(targetTime - snapPoint.time);
|
||||
if (distance < thresholdInSeconds && distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestSnapPoint = snapPoint;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
snappedTime: closestSnapPoint
|
||||
? (closestSnapPoint as SnapPoint).time
|
||||
: targetTime,
|
||||
snapPoint: closestSnapPoint,
|
||||
snapDistance: closestDistance,
|
||||
};
|
||||
},
|
||||
[snapThreshold],
|
||||
);
|
||||
|
||||
const snapElementEdge = useCallback(
|
||||
(
|
||||
targetTime: number,
|
||||
elementDuration: number,
|
||||
tracks: TimelineTrack[],
|
||||
playheadTime: number,
|
||||
zoomLevel: number,
|
||||
excludeElementId?: string,
|
||||
snapToStart = true, // true for start edge, false for end edge
|
||||
): SnapResult => {
|
||||
const snapPoints = findSnapPoints(
|
||||
tracks,
|
||||
targetTime,
|
||||
playheadTime,
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
);
|
||||
|
||||
// For end edge snapping, we need to account for element duration
|
||||
const effectiveTargetTime = snapToStart
|
||||
? targetTime
|
||||
: targetTime + elementDuration;
|
||||
const snapResult = snapToNearestPoint(
|
||||
effectiveTargetTime,
|
||||
snapPoints,
|
||||
zoomLevel,
|
||||
);
|
||||
|
||||
// Adjust the snapped time back for end edge
|
||||
if (!snapToStart && snapResult.snapPoint) {
|
||||
snapResult.snappedTime = snapResult.snappedTime - elementDuration;
|
||||
}
|
||||
|
||||
return snapResult;
|
||||
},
|
||||
[findSnapPoints, snapToNearestPoint],
|
||||
);
|
||||
|
||||
return {
|
||||
snapElementEdge,
|
||||
findSnapPoints,
|
||||
snapToNearestPoint,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState, useCallback, useEffect, RefObject } from "react";
|
||||
|
||||
interface UseTimelineZoomProps {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
isInTimeline?: boolean;
|
||||
}
|
||||
|
||||
interface UseTimelineZoomReturn {
|
||||
zoomLevel: number;
|
||||
setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
|
||||
handleWheel: (e: React.WheelEvent) => void;
|
||||
}
|
||||
|
||||
export function useTimelineZoom({
|
||||
containerRef,
|
||||
isInTimeline = false,
|
||||
}: UseTimelineZoomProps): UseTimelineZoomReturn {
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
// Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.15 : 0.15;
|
||||
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
|
||||
}
|
||||
// For horizontal scrolling (when shift is held or horizontal wheel movement),
|
||||
// let the event bubble up to allow ScrollArea to handle it
|
||||
else if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
// Don't prevent default - let ScrollArea handle horizontal scrolling
|
||||
return;
|
||||
}
|
||||
// Otherwise, allow normal scrolling
|
||||
}, []);
|
||||
|
||||
// Prevent browser zooming in/out when in timeline
|
||||
useEffect(() => {
|
||||
const preventZoom = (e: WheelEvent) => {
|
||||
if (
|
||||
isInTimeline &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
containerRef.current?.contains(e.target as Node)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("wheel", preventZoom, { passive: false });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("wheel", preventZoom);
|
||||
};
|
||||
}, [isInTimeline, containerRef]);
|
||||
|
||||
return {
|
||||
zoomLevel,
|
||||
setZoomLevel,
|
||||
handleWheel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react";
|
||||
|
||||
import type { ToastActionElement, ToastProps } from "../components/ui/toast";
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1_000_000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const;
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
};
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action;
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
};
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { useToast, toast };
|
||||
Reference in New Issue
Block a user