From be1ae20d9089e81cb84a8294be76fe680a24b60f Mon Sep 17 00:00:00 2001 From: Hyteq Date: Sun, 20 Jul 2025 11:00:22 +0300 Subject: format --- apps/web/src/hooks/auth/useLogin.ts | 108 +++--- apps/web/src/hooks/auth/useSignUp.ts | 102 +++--- apps/web/src/hooks/use-aspect-ratio.ts | 184 +++++----- apps/web/src/hooks/use-drag-drop.ts | 170 ++++----- apps/web/src/hooks/use-editor-actions.ts | 302 ++++++++-------- apps/web/src/hooks/use-keybinding-conflicts.ts | 96 +++--- apps/web/src/hooks/use-keybindings.ts | 68 ++-- apps/web/src/hooks/use-keyboard-shortcuts-help.ts | 206 +++++------ apps/web/src/hooks/use-mobile.tsx | 28 +- apps/web/src/hooks/use-playback-controls.ts | 204 +++++------ apps/web/src/hooks/use-selection-box.ts | 398 +++++++++++----------- apps/web/src/hooks/use-timeline-element-resize.ts | 378 ++++++++++---------- apps/web/src/hooks/use-timeline-playhead.ts | 314 ++++++++--------- apps/web/src/hooks/use-timeline-snapping.ts | 324 +++++++++--------- apps/web/src/hooks/use-timeline-zoom.ts | 108 +++--- apps/web/src/hooks/use-toast.ts | 278 +++++++-------- 16 files changed, 1635 insertions(+), 1633 deletions(-) (limited to 'apps/web/src/hooks') diff --git a/apps/web/src/hooks/auth/useLogin.ts b/apps/web/src/hooks/auth/useLogin.ts index 12eddcbf..a3942506 100644 --- a/apps/web/src/hooks/auth/useLogin.ts +++ b/apps/web/src/hooks/auth/useLogin.ts @@ -3,58 +3,58 @@ import { useRouter } from "next/navigation"; import { signIn } from "@opencut/auth/client"; export function useLogin() { - const router = useRouter(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [isEmailLoading, setIsEmailLoading] = useState(false); - const [isGoogleLoading, setIsGoogleLoading] = useState(false); - - const handleLogin = useCallback(async () => { - setError(null); - setIsEmailLoading(true); - - const { error } = await signIn.email({ - email, - password, - }); - - if (error) { - setError(error.message || "An unexpected error occurred."); - setIsEmailLoading(false); - return; - } - - router.push("/projects"); - }, [router, email, password]); - - const handleGoogleLogin = async () => { - setError(null); - setIsGoogleLoading(true); - - try { - await signIn.social({ - provider: "google", - callbackURL: "/projects", - }); - } catch (error) { - setError("Failed to sign in with Google. Please try again."); - setIsGoogleLoading(false); - } - }; - - const isAnyLoading = isEmailLoading || isGoogleLoading; - - return { - email, - setEmail, - password, - setPassword, - error, - isEmailLoading, - isGoogleLoading, - isAnyLoading, - handleLogin, - handleGoogleLogin, - }; + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isEmailLoading, setIsEmailLoading] = useState(false); + const [isGoogleLoading, setIsGoogleLoading] = useState(false); + + const handleLogin = useCallback(async () => { + setError(null); + setIsEmailLoading(true); + + const { error } = await signIn.email({ + email, + password, + }); + + if (error) { + setError(error.message || "An unexpected error occurred."); + setIsEmailLoading(false); + return; + } + + router.push("/projects"); + }, [router, email, password]); + + const handleGoogleLogin = async () => { + setError(null); + setIsGoogleLoading(true); + + try { + await signIn.social({ + provider: "google", + callbackURL: "/projects", + }); + } catch (error) { + setError("Failed to sign in with Google. Please try again."); + setIsGoogleLoading(false); + } + }; + + const isAnyLoading = isEmailLoading || isGoogleLoading; + + return { + email, + setEmail, + password, + setPassword, + error, + isEmailLoading, + isGoogleLoading, + isAnyLoading, + handleLogin, + handleGoogleLogin, + }; } diff --git a/apps/web/src/hooks/auth/useSignUp.ts b/apps/web/src/hooks/auth/useSignUp.ts index 4cd2850a..fae7c967 100644 --- a/apps/web/src/hooks/auth/useSignUp.ts +++ b/apps/web/src/hooks/auth/useSignUp.ts @@ -3,63 +3,63 @@ import { useRouter } from "next/navigation"; import { signUp, signIn } from "@opencut/auth/client"; export function useSignUp() { - const router = useRouter(); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [isEmailLoading, setIsEmailLoading] = useState(false); - const [isGoogleLoading, setIsGoogleLoading] = useState(false); + const router = useRouter(); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isEmailLoading, setIsEmailLoading] = useState(false); + const [isGoogleLoading, setIsGoogleLoading] = useState(false); - const handleSignUp = useCallback(async () => { - setError(null); - setIsEmailLoading(true); + const handleSignUp = useCallback(async () => { + setError(null); + setIsEmailLoading(true); - const { error } = await signUp.email({ - name, - email, - password, - }); + const { error } = await signUp.email({ + name, + email, + password, + }); - if (error) { - setError(error.message || "An unexpected error occurred."); - setIsEmailLoading(false); - return; - } + if (error) { + setError(error.message || "An unexpected error occurred."); + setIsEmailLoading(false); + return; + } - router.push("/login"); - }, [name, email, password, router]); + router.push("/login"); + }, [name, email, password, router]); - const handleGoogleSignUp = useCallback(async () => { - setError(null); - setIsGoogleLoading(true); + const handleGoogleSignUp = useCallback(async () => { + setError(null); + setIsGoogleLoading(true); - try { - await signIn.social({ - provider: "google", - }); + try { + await signIn.social({ + provider: "google", + }); - router.push("/editor"); - } catch (error) { - setError("Failed to sign up with Google. Please try again."); - setIsGoogleLoading(false); - } - }, [router]); + router.push("/editor"); + } catch (error) { + setError("Failed to sign up with Google. Please try again."); + setIsGoogleLoading(false); + } + }, [router]); - const isAnyLoading = isEmailLoading || isGoogleLoading; + const isAnyLoading = isEmailLoading || isGoogleLoading; - return { - name, - setName, - email, - setEmail, - password, - setPassword, - error, - isEmailLoading, - isGoogleLoading, - isAnyLoading, - handleSignUp, - handleGoogleSignUp, - }; -} \ No newline at end of file + return { + name, + setName, + email, + setEmail, + password, + setPassword, + error, + isEmailLoading, + isGoogleLoading, + isAnyLoading, + handleSignUp, + handleGoogleSignUp, + }; +} diff --git a/apps/web/src/hooks/use-aspect-ratio.ts b/apps/web/src/hooks/use-aspect-ratio.ts index 40de8454..b297f6b5 100644 --- a/apps/web/src/hooks/use-aspect-ratio.ts +++ b/apps/web/src/hooks/use-aspect-ratio.ts @@ -1,92 +1,92 @@ -import { useEditorStore } from "@/stores/editor-store"; -import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store"; -import { useTimelineStore } from "@/stores/timeline-store"; - -export function useAspectRatio() { - const { canvasSize, canvasMode, canvasPresets } = useEditorStore(); - const { mediaItems } = useMediaStore(); - const { tracks } = useTimelineStore(); - - // Find the current preset based on canvas size - const currentPreset = canvasPresets.find( - (preset) => - preset.width === canvasSize.width && preset.height === canvasSize.height - ); - - // Get the original aspect ratio from the first video/image in timeline - const getOriginalAspectRatio = (): number => { - // Find first video or image in timeline - for (const track of tracks) { - for (const element of track.elements) { - if (element.type === "media") { - const mediaItem = mediaItems.find( - (item) => item.id === element.mediaId - ); - if ( - mediaItem && - (mediaItem.type === "video" || mediaItem.type === "image") - ) { - return getMediaAspectRatio(mediaItem); - } - } - } - } - return 16 / 9; // Default aspect ratio - }; - - // Get current aspect ratio - const getCurrentAspectRatio = (): number => { - return canvasSize.width / canvasSize.height; - }; - - // Format aspect ratio as a readable string - const formatAspectRatio = (aspectRatio: number): string => { - // Check if it matches a common aspect ratio - const ratios = [ - { ratio: 16 / 9, label: "16:9" }, - { ratio: 9 / 16, label: "9:16" }, - { ratio: 1, label: "1:1" }, - { ratio: 4 / 3, label: "4:3" }, - { ratio: 3 / 4, label: "3:4" }, - { ratio: 21 / 9, label: "21:9" }, - ]; - - for (const { ratio, label } of ratios) { - if (Math.abs(aspectRatio - ratio) < 0.01) { - return label; - } - } - - // If not a common ratio, format as decimal - return aspectRatio.toFixed(2); - }; - - // Check if current mode is "Original" - const isOriginal = canvasMode === "original"; - - // Get display name for current aspect ratio - const getDisplayName = (): string => { - // If explicitly set to original mode, always show "Original" - if (canvasMode === "original") { - return "Original"; - } - - if (currentPreset) { - return currentPreset.name; - } - - return formatAspectRatio(getCurrentAspectRatio()); - }; - - return { - currentPreset, - canvasMode, - isOriginal, - getCurrentAspectRatio, - getOriginalAspectRatio, - formatAspectRatio, - getDisplayName, - canvasSize, - canvasPresets, - }; -} +import { useEditorStore } from "@/stores/editor-store"; +import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store"; +import { useTimelineStore } from "@/stores/timeline-store"; + +export function useAspectRatio() { + const { canvasSize, canvasMode, canvasPresets } = useEditorStore(); + const { mediaItems } = useMediaStore(); + const { tracks } = useTimelineStore(); + + // Find the current preset based on canvas size + const currentPreset = canvasPresets.find( + (preset) => + preset.width === canvasSize.width && preset.height === canvasSize.height, + ); + + // Get the original aspect ratio from the first video/image in timeline + const getOriginalAspectRatio = (): number => { + // Find first video or image in timeline + for (const track of tracks) { + for (const element of track.elements) { + if (element.type === "media") { + const mediaItem = mediaItems.find( + (item) => item.id === element.mediaId, + ); + if ( + mediaItem && + (mediaItem.type === "video" || mediaItem.type === "image") + ) { + return getMediaAspectRatio(mediaItem); + } + } + } + } + return 16 / 9; // Default aspect ratio + }; + + // Get current aspect ratio + const getCurrentAspectRatio = (): number => { + return canvasSize.width / canvasSize.height; + }; + + // Format aspect ratio as a readable string + const formatAspectRatio = (aspectRatio: number): string => { + // Check if it matches a common aspect ratio + const ratios = [ + { ratio: 16 / 9, label: "16:9" }, + { ratio: 9 / 16, label: "9:16" }, + { ratio: 1, label: "1:1" }, + { ratio: 4 / 3, label: "4:3" }, + { ratio: 3 / 4, label: "3:4" }, + { ratio: 21 / 9, label: "21:9" }, + ]; + + for (const { ratio, label } of ratios) { + if (Math.abs(aspectRatio - ratio) < 0.01) { + return label; + } + } + + // If not a common ratio, format as decimal + return aspectRatio.toFixed(2); + }; + + // Check if current mode is "Original" + const isOriginal = canvasMode === "original"; + + // Get display name for current aspect ratio + const getDisplayName = (): string => { + // If explicitly set to original mode, always show "Original" + if (canvasMode === "original") { + return "Original"; + } + + if (currentPreset) { + return currentPreset.name; + } + + return formatAspectRatio(getCurrentAspectRatio()); + }; + + return { + currentPreset, + canvasMode, + isOriginal, + getCurrentAspectRatio, + getOriginalAspectRatio, + formatAspectRatio, + getDisplayName, + canvasSize, + canvasPresets, + }; +} diff --git a/apps/web/src/hooks/use-drag-drop.ts b/apps/web/src/hooks/use-drag-drop.ts index eaab89d8..768014ec 100644 --- a/apps/web/src/hooks/use-drag-drop.ts +++ b/apps/web/src/hooks/use-drag-drop.ts @@ -1,85 +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, - }; -} +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, + }; +} diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts index e0d9b9eb..3a47dcee 100644 --- a/apps/web/src/hooks/use-editor-actions.ts +++ b/apps/web/src/hooks/use-editor-actions.ts @@ -8,155 +8,155 @@ import { useProjectStore } from "@/stores/project-store"; import { toast } from "sonner"; export function useEditorActions() { - const { - tracks, - selectedElements, - clearSelectedElements, - setSelectedElements, - removeElementFromTrack, - splitElement, - addElementToTrack, - snappingEnabled, - toggleSnapping, - undo, - redo, - } = useTimelineStore(); - - const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); - const { activeProject } = useProjectStore(); - - // Playback actions - useActionHandler("toggle-play", () => { - toggle(); - }); - - useActionHandler("stop-playback", () => { - if (isPlaying) { - toggle(); - } - seek(0); - }); - - useActionHandler("seek-forward", (args) => { - const seconds = args?.seconds ?? 1; - seek(Math.min(duration, currentTime + seconds)); - }); - - useActionHandler("seek-backward", (args) => { - const seconds = args?.seconds ?? 1; - seek(Math.max(0, currentTime - seconds)); - }); - - useActionHandler("frame-step-forward", () => { - const projectFps = activeProject?.fps || 30; - seek(Math.min(duration, currentTime + 1 / projectFps)); - }); - - useActionHandler("frame-step-backward", () => { - const projectFps = activeProject?.fps || 30; - seek(Math.max(0, currentTime - 1 / projectFps)); - }); - - useActionHandler("jump-forward", (args) => { - const seconds = args?.seconds ?? 5; - seek(Math.min(duration, currentTime + seconds)); - }); - - useActionHandler("jump-backward", (args) => { - const seconds = args?.seconds ?? 5; - seek(Math.max(0, currentTime - seconds)); - }); - - useActionHandler("goto-start", () => { - seek(0); - }); - - useActionHandler("goto-end", () => { - seek(duration); - }); - - // 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) { - splitElement(trackId, elementId, currentTime); - } else { - toast.error("Playhead must be within selected element"); - } - } - }); - - useActionHandler("delete-selected", () => { - if (selectedElements.length === 0) { - toast.error("No elements selected"); - return; - } - selectedElements.forEach( - ({ trackId, elementId }: { trackId: string; elementId: string }) => { - removeElementFromTrack(trackId, elementId); - } - ); - clearSelectedElements(); - }); - - useActionHandler("select-all", () => { - const allElements = tracks.flatMap((track: any) => - track.elements.map((element: any) => ({ - trackId: track.id, - elementId: element.id, - })) - ); - setSelectedElements(allElements); - }); - - 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, - }); - } - }); - - useActionHandler("toggle-snapping", () => { - toggleSnapping(); - }); - - // History actions - useActionHandler("undo", () => { - undo(); - }); - - useActionHandler("redo", () => { - redo(); - }); + const { + tracks, + selectedElements, + clearSelectedElements, + setSelectedElements, + removeElementFromTrack, + splitElement, + addElementToTrack, + snappingEnabled, + toggleSnapping, + undo, + redo, + } = useTimelineStore(); + + const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); + const { activeProject } = useProjectStore(); + + // Playback actions + useActionHandler("toggle-play", () => { + toggle(); + }); + + useActionHandler("stop-playback", () => { + if (isPlaying) { + toggle(); + } + seek(0); + }); + + useActionHandler("seek-forward", (args) => { + const seconds = args?.seconds ?? 1; + seek(Math.min(duration, currentTime + seconds)); + }); + + useActionHandler("seek-backward", (args) => { + const seconds = args?.seconds ?? 1; + seek(Math.max(0, currentTime - seconds)); + }); + + useActionHandler("frame-step-forward", () => { + const projectFps = activeProject?.fps || 30; + seek(Math.min(duration, currentTime + 1 / projectFps)); + }); + + useActionHandler("frame-step-backward", () => { + const projectFps = activeProject?.fps || 30; + seek(Math.max(0, currentTime - 1 / projectFps)); + }); + + useActionHandler("jump-forward", (args) => { + const seconds = args?.seconds ?? 5; + seek(Math.min(duration, currentTime + seconds)); + }); + + useActionHandler("jump-backward", (args) => { + const seconds = args?.seconds ?? 5; + seek(Math.max(0, currentTime - seconds)); + }); + + useActionHandler("goto-start", () => { + seek(0); + }); + + useActionHandler("goto-end", () => { + seek(duration); + }); + + // 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) { + splitElement(trackId, elementId, currentTime); + } else { + toast.error("Playhead must be within selected element"); + } + } + }); + + useActionHandler("delete-selected", () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + selectedElements.forEach( + ({ trackId, elementId }: { trackId: string; elementId: string }) => { + removeElementFromTrack(trackId, elementId); + }, + ); + clearSelectedElements(); + }); + + useActionHandler("select-all", () => { + const allElements = tracks.flatMap((track: any) => + track.elements.map((element: any) => ({ + trackId: track.id, + elementId: element.id, + })), + ); + setSelectedElements(allElements); + }); + + 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, + }); + } + }); + + useActionHandler("toggle-snapping", () => { + toggleSnapping(); + }); + + // History actions + useActionHandler("undo", () => { + undo(); + }); + + useActionHandler("redo", () => { + redo(); + }); } diff --git a/apps/web/src/hooks/use-keybinding-conflicts.ts b/apps/web/src/hooks/use-keybinding-conflicts.ts index cc5dd340..0720982e 100644 --- a/apps/web/src/hooks/use-keybinding-conflicts.ts +++ b/apps/web/src/hooks/use-keybinding-conflicts.ts @@ -5,55 +5,55 @@ import { useKeybindingsStore } from "@/stores/keybindings-store"; import { ActionWithOptionalArgs } from "@/constants/actions"; export interface KeybindingConflictInfo { - key: string; - actions: ActionWithOptionalArgs[]; - isConflict: boolean; + key: string; + actions: ActionWithOptionalArgs[]; + isConflict: boolean; } export const useKeybindingConflicts = () => { - const { keybindings } = useKeybindingsStore(); - - const conflicts = useMemo(() => { - const keyToActions: Record = {}; - 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, - }; + const { keybindings } = useKeybindingsStore(); + + const conflicts = useMemo(() => { + const keyToActions: Record = {}; + 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, + }; }; diff --git a/apps/web/src/hooks/use-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts index 7887f3f1..1c001820 100644 --- a/apps/web/src/hooks/use-keybindings.ts +++ b/apps/web/src/hooks/use-keybindings.ts @@ -8,56 +8,56 @@ import { useKeybindingsStore } from "@/stores/keybindings-store"; * the appropriate actions based on keybindings */ export function useKeybindingsListener() { - const { keybindings, getKeybindingString, keybindingsEnabled } = - useKeybindingsStore(); + const { keybindings, getKeybindingString, keybindingsEnabled } = + useKeybindingsStore(); - useEffect(() => { - const handleKeyDown = (ev: KeyboardEvent) => { - // Do not check keybinds if the mode is disabled - if (!keybindingsEnabled) return; + useEffect(() => { + const handleKeyDown = (ev: KeyboardEvent) => { + // Do not check keybinds if the mode is disabled + if (!keybindingsEnabled) return; - const binding = getKeybindingString(ev); - if (!binding) return; + const binding = getKeybindingString(ev); + if (!binding) return; - const boundAction = keybindings[binding]; - if (!boundAction) return; + const boundAction = keybindings[binding]; + if (!boundAction) return; - ev.preventDefault(); + ev.preventDefault(); - // Handle actions with default arguments - let actionArgs: any = undefined; + // Handle actions with default arguments + let actionArgs: any = undefined; - 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 }; - } + 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"); - }; + invokeAction(boundAction, actionArgs, "keypress"); + }; - document.addEventListener("keydown", handleKeyDown); + document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("keydown", handleKeyDown); - }; - }, [keybindings, getKeybindingString, keybindingsEnabled]); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [keybindings, getKeybindingString, keybindingsEnabled]); } /** * This composable allows for the UI component to be disabled if the component in question is mounted */ export function useKeybindingDisabler() { - const { disableKeybindings, enableKeybindings } = useKeybindingsStore(); + const { disableKeybindings, enableKeybindings } = useKeybindingsStore(); - return { - disableKeybindings, - enableKeybindings, - }; + return { + disableKeybindings, + enableKeybindings, + }; } // Export the bindings for backward compatibility diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts index c7de8466..dd52f00b 100644 --- a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts +++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts @@ -5,125 +5,125 @@ import { useKeybindingsStore } from "@/stores/keybindings-store"; import { Action } from "@/constants/actions"; export interface KeyboardShortcut { - id: string; - keys: string[]; - description: string; - category: string; - action: Action; - icon?: React.ReactNode; + 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 } + 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" }, + "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" }, }; // Convert key binding format to display format const formatKey = (key: string): string => { - return key - .replace("ctrl", "Cmd") - .replace("alt", "Alt") - .replace("shift", "Shift") - .replace("left", "ArrowLeft") - .replace("right", "ArrowRight") - .replace("up", "ArrowUp") - .replace("down", "ArrowDown") - .replace("space", "Space") - .replace("home", "Home") - .replace("end", "End") - .replace("delete", "Delete") - .replace("backspace", "Backspace") - .replace("-", "+"); + return key + .replace("ctrl", "Cmd") + .replace("alt", "Alt") + .replace("shift", "Shift") + .replace("left", "ArrowLeft") + .replace("right", "ArrowRight") + .replace("up", "ArrowUp") + .replace("down", "ArrowDown") + .replace("space", "Space") + .replace("home", "Home") + .replace("end", "End") + .replace("delete", "Delete") + .replace("backspace", "Backspace") + .replace("-", "+"); }; export const useKeyboardShortcutsHelp = () => { - const { keybindings } = useKeybindingsStore(); + const { keybindings } = useKeybindingsStore(); - const shortcuts = useMemo(() => { - const result: KeyboardShortcut[] = []; + const shortcuts = useMemo(() => { + const result: KeyboardShortcut[] = []; - // Group keybindings by action - const actionToKeys: Record = {} as any; + // Group keybindings by action + const actionToKeys: Record = {} as any; - Object.entries(keybindings).forEach(([key, action]) => { - if (action) { - if (!actionToKeys[action]) { - actionToKeys[action] = []; - } - actionToKeys[action].push(formatKey(key)); - } - }); + 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, - }); - } - }); + // 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]); + // 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, - }; + return { + shortcuts, + }; }; diff --git a/apps/web/src/hooks/use-mobile.tsx b/apps/web/src/hooks/use-mobile.tsx index 2b0fe1df..0a892310 100644 --- a/apps/web/src/hooks/use-mobile.tsx +++ b/apps/web/src/hooks/use-mobile.tsx @@ -1,19 +1,21 @@ -import * as React from "react" +import * as React from "react"; -const MOBILE_BREAKPOINT = 768 +const MOBILE_BREAKPOINT = 768; export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(undefined) + const [isMobile, setIsMobile] = React.useState( + 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) - }, []) + 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 + return !!isMobile; } diff --git a/apps/web/src/hooks/use-playback-controls.ts b/apps/web/src/hooks/use-playback-controls.ts index ecbd1232..39b5c841 100644 --- a/apps/web/src/hooks/use-playback-controls.ts +++ b/apps/web/src/hooks/use-playback-controls.ts @@ -4,106 +4,106 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { toast } from "sonner"; export const usePlaybackControls = () => { - const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore(); - - const { - selectedElements, - tracks, - splitElement, - 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; - } - - splitElement(trackId, elementId, currentTime); - }, [selectedElements, tracks, currentTime, splitElement]); - - 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]); + const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore(); + + const { + selectedElements, + tracks, + splitElement, + 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; + } + + splitElement(trackId, elementId, currentTime); + }, [selectedElements, tracks, currentTime, splitElement]); + + 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]); }; diff --git a/apps/web/src/hooks/use-selection-box.ts b/apps/web/src/hooks/use-selection-box.ts index c40617c1..0abf8707 100644 --- a/apps/web/src/hooks/use-selection-box.ts +++ b/apps/web/src/hooks/use-selection-box.ts @@ -1,199 +1,199 @@ -import { useState, useEffect, useCallback } from "react"; - -interface UseSelectionBoxProps { - containerRef: React.RefObject; - playheadRef?: React.RefObject; - 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( - 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]); - - return { - selectionBox, - handleMouseDown, - isSelecting: selectionBox?.isActive || false, - justFinishedSelecting, - }; -} +import { useState, useEffect, useCallback } from "react"; + +interface UseSelectionBoxProps { + containerRef: React.RefObject; + playheadRef?: React.RefObject; + 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( + 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]); + + return { + selectionBox, + handleMouseDown, + isSelecting: selectionBox?.isActive || false, + justFinishedSelecting, + }; +} diff --git a/apps/web/src/hooks/use-timeline-element-resize.ts b/apps/web/src/hooks/use-timeline-element-resize.ts index f16ca8e2..9b664af4 100644 --- a/apps/web/src/hooks/use-timeline-element-resize.ts +++ b/apps/web/src/hooks/use-timeline-element-resize.ts @@ -4,197 +4,197 @@ import { useMediaStore } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; interface UseTimelineElementResizeProps { - element: TimelineElement; - track: TimelineTrack; - zoomLevel: number; - onUpdateTrim: ( - trackId: string, - elementId: string, - trimStart: number, - trimEnd: number - ) => void; - onUpdateDuration: ( - trackId: string, - elementId: string, - duration: number - ) => void; + element: TimelineElement; + track: TimelineTrack; + zoomLevel: number; + onUpdateTrim: ( + trackId: string, + elementId: string, + trimStart: number, + trimEnd: number, + ) => void; + onUpdateDuration: ( + trackId: string, + elementId: string, + duration: number, + ) => void; } export function useTimelineElementResize({ - element, - track, - zoomLevel, - onUpdateTrim, - onUpdateDuration, + element, + track, + zoomLevel, + onUpdateTrim, + onUpdateDuration, }: UseTimelineElementResizeProps) { - const [resizing, setResizing] = useState(null); - const { mediaItems } = useMediaStore(); - const { updateElementStartTime } = 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(); - - 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 mediaItem = mediaItems.find((item) => item.id === element.mediaId); - if (!mediaItem) return false; - - // Images can be extended (static content) - if (mediaItem.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); - - 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 = Math.min(maxAllowed, calculated); - const trimDelta = newTrimStart - resizing.initialTrimStart; - const newStartTime = element.startTime + trimDelta; - - onUpdateTrim( - track.id, - element.id, - newTrimStart, - resizing.initialTrimEnd - ); - updateElementStartTime(track.id, element.id, newStartTime); - } 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 newStartTime = element.startTime - extensionAmount; - const newDuration = element.duration + extensionAmount; - - // Keep trimStart at 0 and extend the element - onUpdateTrim(track.id, element.id, 0, resizing.initialTrimEnd); - onUpdateDuration(track.id, element.id, newDuration); - updateElementStartTime(track.id, element.id, newStartTime); - } else { - // Video/Audio: can't extend beyond original content - limit to trimStart = 0 - const newTrimStart = 0; - const trimDelta = newTrimStart - resizing.initialTrimStart; - const newStartTime = element.startTime + trimDelta; - - onUpdateTrim( - track.id, - element.id, - newTrimStart, - resizing.initialTrimEnd - ); - updateElementStartTime(track.id, element.id, newStartTime); - } - } - } 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 = element.duration + extensionNeeded; - const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending - - // Update duration first, then trim - onUpdateDuration(track.id, element.id, newDuration); - onUpdateTrim( - track.id, - element.id, - resizing.initialTrimStart, - newTrimEnd - ); - } else { - // Can't extend - just set trimEnd to 0 (maximum possible extension) - onUpdateTrim(track.id, element.id, resizing.initialTrimStart, 0); - } - } else { - // Normal trimming within original duration - const maxTrimEnd = element.duration - resizing.initialTrimStart - 0.1; // Leave at least 0.1s visible - const newTrimEnd = Math.max(0, Math.min(maxTrimEnd, calculated)); - - onUpdateTrim( - track.id, - element.id, - resizing.initialTrimStart, - newTrimEnd - ); - } - } - }; - - 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 - }; + const [resizing, setResizing] = useState(null); + const { mediaItems } = useMediaStore(); + const { updateElementStartTime } = 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(); + + 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 mediaItem = mediaItems.find((item) => item.id === element.mediaId); + if (!mediaItem) return false; + + // Images can be extended (static content) + if (mediaItem.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); + + 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 = Math.min(maxAllowed, calculated); + const trimDelta = newTrimStart - resizing.initialTrimStart; + const newStartTime = element.startTime + trimDelta; + + onUpdateTrim( + track.id, + element.id, + newTrimStart, + resizing.initialTrimEnd, + ); + updateElementStartTime(track.id, element.id, newStartTime); + } 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 newStartTime = element.startTime - extensionAmount; + const newDuration = element.duration + extensionAmount; + + // Keep trimStart at 0 and extend the element + onUpdateTrim(track.id, element.id, 0, resizing.initialTrimEnd); + onUpdateDuration(track.id, element.id, newDuration); + updateElementStartTime(track.id, element.id, newStartTime); + } else { + // Video/Audio: can't extend beyond original content - limit to trimStart = 0 + const newTrimStart = 0; + const trimDelta = newTrimStart - resizing.initialTrimStart; + const newStartTime = element.startTime + trimDelta; + + onUpdateTrim( + track.id, + element.id, + newTrimStart, + resizing.initialTrimEnd, + ); + updateElementStartTime(track.id, element.id, newStartTime); + } + } + } 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 = element.duration + extensionNeeded; + const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending + + // Update duration first, then trim + onUpdateDuration(track.id, element.id, newDuration); + onUpdateTrim( + track.id, + element.id, + resizing.initialTrimStart, + newTrimEnd, + ); + } else { + // Can't extend - just set trimEnd to 0 (maximum possible extension) + onUpdateTrim(track.id, element.id, resizing.initialTrimStart, 0); + } + } else { + // Normal trimming within original duration + const maxTrimEnd = element.duration - resizing.initialTrimStart - 0.1; // Leave at least 0.1s visible + const newTrimEnd = Math.max(0, Math.min(maxTrimEnd, calculated)); + + onUpdateTrim( + track.id, + element.id, + resizing.initialTrimStart, + newTrimEnd, + ); + } + } + }; + + 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 + }; } diff --git a/apps/web/src/hooks/use-timeline-playhead.ts b/apps/web/src/hooks/use-timeline-playhead.ts index 9a82c23f..cd2fc76e 100644 --- a/apps/web/src/hooks/use-timeline-playhead.ts +++ b/apps/web/src/hooks/use-timeline-playhead.ts @@ -1,157 +1,157 @@ -import { snapTimeToFrame } from "@/constants/timeline-constants"; -import { useProjectStore } from "@/stores/project-store"; -import { useState, useEffect, useCallback } from "react"; - -interface UseTimelinePlayheadProps { - currentTime: number; - duration: number; - zoomLevel: number; - seek: (time: number) => void; - rulerRef: React.RefObject; - rulerScrollRef: React.RefObject; - tracksScrollRef: React.RefObject; - playheadRef?: React.RefObject; -} - -export function useTimelinePlayhead({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, -}: UseTimelinePlayheadProps) { - // Playhead scrubbing state - const [isScrubbing, setIsScrubbing] = useState(false); - const [scrubTime, setScrubTime] = useState(null); - - // Ruler drag detection state - const [isDraggingRuler, setIsDraggingRuler] = useState(false); - const [hasDraggedRuler, setHasDraggedRuler] = useState(false); - - 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 x = e.clientX - rect.left; - 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 || 30; - const time = snapTimeToFrame(rawTime, projectFps); - setScrubTime(time); - seek(time); // update video preview in real time - }, - [duration, zoomLevel, seek, rulerRef] - ); - - // 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); - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - }; - }, [ - isScrubbing, - scrubTime, - seek, - handleScrub, - isDraggingRuler, - hasDraggedRuler, - ]); - - // --- Playhead auto-scroll effect --- - useEffect(() => { - const rulerViewport = rulerScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - const tracksViewport = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - 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; - // Center the playhead if it's not visible (100px buffer) - const desiredScroll = Math.max( - scrollMin, - Math.min(scrollMax, playheadPx - viewportWidth / 2) - ); - if ( - playheadPx < rulerViewport.scrollLeft + 100 || - playheadPx > rulerViewport.scrollLeft + viewportWidth - 100 - ) { - rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; - } - }, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]); - - return { - playheadPosition, - handlePlayheadMouseDown, - handleRulerMouseDown, - isDraggingRuler, - }; -} +import { snapTimeToFrame } from "@/constants/timeline-constants"; +import { useProjectStore } from "@/stores/project-store"; +import { useState, useEffect, useCallback } from "react"; + +interface UseTimelinePlayheadProps { + currentTime: number; + duration: number; + zoomLevel: number; + seek: (time: number) => void; + rulerRef: React.RefObject; + rulerScrollRef: React.RefObject; + tracksScrollRef: React.RefObject; + playheadRef?: React.RefObject; +} + +export function useTimelinePlayhead({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, +}: UseTimelinePlayheadProps) { + // Playhead scrubbing state + const [isScrubbing, setIsScrubbing] = useState(false); + const [scrubTime, setScrubTime] = useState(null); + + // Ruler drag detection state + const [isDraggingRuler, setIsDraggingRuler] = useState(false); + const [hasDraggedRuler, setHasDraggedRuler] = useState(false); + + 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 x = e.clientX - rect.left; + 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 || 30; + const time = snapTimeToFrame(rawTime, projectFps); + setScrubTime(time); + seek(time); // update video preview in real time + }, + [duration, zoomLevel, seek, rulerRef], + ); + + // 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); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, [ + isScrubbing, + scrubTime, + seek, + handleScrub, + isDraggingRuler, + hasDraggedRuler, + ]); + + // --- Playhead auto-scroll effect --- + useEffect(() => { + const rulerViewport = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; + const tracksViewport = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; + 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; + // Center the playhead if it's not visible (100px buffer) + const desiredScroll = Math.max( + scrollMin, + Math.min(scrollMax, playheadPx - viewportWidth / 2), + ); + if ( + playheadPx < rulerViewport.scrollLeft + 100 || + playheadPx > rulerViewport.scrollLeft + viewportWidth - 100 + ) { + rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; + } + }, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]); + + return { + playheadPosition, + handlePlayheadMouseDown, + handleRulerMouseDown, + isDraggingRuler, + }; +} diff --git a/apps/web/src/hooks/use-timeline-snapping.ts b/apps/web/src/hooks/use-timeline-snapping.ts index f7f96392..e51f6a22 100644 --- a/apps/web/src/hooks/use-timeline-snapping.ts +++ b/apps/web/src/hooks/use-timeline-snapping.ts @@ -3,176 +3,176 @@ 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; + time: number; + type: "element-start" | "element-end" | "playhead"; + elementId?: string; + trackId?: string; } export interface SnapResult { - snappedTime: number; - snapPoint: SnapPoint | null; - snapDistance: number; + snappedTime: number; + snapPoint: SnapPoint | null; + snapDistance: number; } export interface UseTimelineSnappingOptions { - snapThreshold?: number; // Distance in pixels to trigger snapping - enableElementSnapping?: boolean; - enablePlayheadSnapping?: boolean; + snapThreshold?: number; // Distance in pixels to trigger snapping + enableElementSnapping?: boolean; + enablePlayheadSnapping?: boolean; } export function useTimelineSnapping({ - snapThreshold = 10, - enableElementSnapping = true, - enablePlayheadSnapping = true, + 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 snapElementPosition = useCallback( - ( - targetTime: number, - tracks: TimelineTrack[], - playheadTime: number, - zoomLevel: number, - excludeElementId?: string - ): SnapResult => { - const snapPoints = findSnapPoints( - tracks, - targetTime, - playheadTime, - zoomLevel, - excludeElementId - ); - - return snapToNearestPoint(targetTime, snapPoints, zoomLevel); - }, - [findSnapPoints, snapToNearestPoint] - ); - - 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 { - snapElementPosition, - snapElementEdge, - findSnapPoints, - snapToNearestPoint, - }; + 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 snapElementPosition = useCallback( + ( + targetTime: number, + tracks: TimelineTrack[], + playheadTime: number, + zoomLevel: number, + excludeElementId?: string, + ): SnapResult => { + const snapPoints = findSnapPoints( + tracks, + targetTime, + playheadTime, + zoomLevel, + excludeElementId, + ); + + return snapToNearestPoint(targetTime, snapPoints, zoomLevel); + }, + [findSnapPoints, snapToNearestPoint], + ); + + 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 { + snapElementPosition, + snapElementEdge, + findSnapPoints, + snapToNearestPoint, + }; } diff --git a/apps/web/src/hooks/use-timeline-zoom.ts b/apps/web/src/hooks/use-timeline-zoom.ts index ce32c9f0..00511290 100644 --- a/apps/web/src/hooks/use-timeline-zoom.ts +++ b/apps/web/src/hooks/use-timeline-zoom.ts @@ -1,54 +1,54 @@ -import { useState, useCallback, useEffect, RefObject } from "react"; - -interface UseTimelineZoomProps { - containerRef: RefObject; - 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))); - } - // 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, - }; -} +import { useState, useCallback, useEffect, RefObject } from "react"; + +interface UseTimelineZoomProps { + containerRef: RefObject; + 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))); + } + // 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, + }; +} diff --git a/apps/web/src/hooks/use-toast.ts b/apps/web/src/hooks/use-toast.ts index c682cd1f..78cc46e8 100644 --- a/apps/web/src/hooks/use-toast.ts +++ b/apps/web/src/hooks/use-toast.ts @@ -9,121 +9,121 @@ const TOAST_LIMIT = 1; const TOAST_REMOVE_DELAY = 1000000; type ToasterToast = ToastProps & { - id: string; - title?: React.ReactNode; - description?: React.ReactNode; - action?: ToastActionElement; + 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", + 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(); + 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; - } - | { - type: ActionType["DISMISS_TOAST"]; - toastId?: ToasterToast["id"]; - } - | { - type: ActionType["REMOVE_TOAST"]; - toastId?: ToasterToast["id"]; - }; + | { + type: ActionType["ADD_TOAST"]; + toast: ToasterToast; + } + | { + type: ActionType["UPDATE_TOAST"]; + toast: Partial; + } + | { + type: ActionType["DISMISS_TOAST"]; + toastId?: ToasterToast["id"]; + } + | { + type: ActionType["REMOVE_TOAST"]; + toastId?: ToasterToast["id"]; + }; interface State { - toasts: ToasterToast[]; + toasts: ToasterToast[]; } const toastTimeouts = new Map>(); const addToRemoveQueue = (toastId: string) => { - if (toastTimeouts.has(toastId)) { - return; - } - - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId); - dispatch({ - type: "REMOVE_TOAST", - toastId: toastId, - }); - }, TOAST_REMOVE_DELAY); - - toastTimeouts.set(toastId, timeout); + if (toastTimeouts.has(toastId)) { + return; + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: "REMOVE_TOAST", + toastId: 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), - }; - } + 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> = []; @@ -131,61 +131,61 @@ const listeners: Array<(state: State) => void> = []; let memoryState: State = { toasts: [] }; function dispatch(action: Action) { - memoryState = reducer(memoryState, action); - listeners.forEach((listener) => { - listener(memoryState); - }); + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); } type Toast = Omit; 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: id, - dismiss, - update, - }; + 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: id, + dismiss, + update, + }; } function useToast() { - const [state, setState] = React.useState(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 }), - }; + const [state, setState] = React.useState(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 }; -- cgit v1.3.1