From fe020be6cea00691d6ce66e21843915defa5e944 Mon Sep 17 00:00:00 2001 From: Hyteq Date: Sun, 20 Jul 2025 10:50:17 +0300 Subject: Revert "format, cleanup, deps" This reverts commit eab8ec3ba81b86fdd4ade920d999c28fb34785b1. --- 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-disable-browser-zoom.ts | 88 +-- apps/web/src/hooks/use-drag-drop.ts | 170 +++--- apps/web/src/hooks/use-keyboard-shortcuts.ts | 676 +++++++++++----------- 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 | 308 +++++----- apps/web/src/hooks/use-timeline-playhead.ts | 250 ++++---- apps/web/src/hooks/use-timeline-snapping.ts | 324 +++++------ apps/web/src/hooks/use-timeline-zoom.ts | 186 +++--- apps/web/src/hooks/use-toast.ts | 278 ++++----- 14 files changed, 1651 insertions(+), 1653 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 a3942506..12eddcbf 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 fae7c967..4cd2850a 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, - }; -} + return { + name, + setName, + email, + setEmail, + password, + setPassword, + error, + isEmailLoading, + isGoogleLoading, + isAnyLoading, + handleSignUp, + handleGoogleSignUp, + }; +} \ No newline at end of file diff --git a/apps/web/src/hooks/use-aspect-ratio.ts b/apps/web/src/hooks/use-aspect-ratio.ts index b297f6b5..40de8454 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-disable-browser-zoom.ts b/apps/web/src/hooks/use-disable-browser-zoom.ts index 53f86507..9467cb71 100644 --- a/apps/web/src/hooks/use-disable-browser-zoom.ts +++ b/apps/web/src/hooks/use-disable-browser-zoom.ts @@ -12,52 +12,52 @@ import { useEffect } from "react"; * Use with caution, especially for public-facing applications. */ export const useDisableBrowserZoom = () => { - useEffect(() => { - /** - * Prevents browser zoom when user holds Ctrl (or Cmd on Mac) and scrolls the mouse wheel. - */ - const handleWheel = (e: WheelEvent) => { - if (e.ctrlKey) { - e.preventDefault(); - } - }; + useEffect(() => { + /** + * Prevents browser zoom when user holds Ctrl (or Cmd on Mac) and scrolls the mouse wheel. + */ + const handleWheel = (e: WheelEvent) => { + if (e.ctrlKey) { + e.preventDefault(); + } + }; - /** - * Prevents browser zoom via keyboard shortcuts: - * Ctrl/Cmd + '+', '-', '=', or '0' (reset zoom) - * Some keyboards emit '=' for '+' (without Shift). - */ - const handleKeyDown = (e: KeyboardEvent) => { - if (e.ctrlKey) { - const key = e.key; - if (key === "+" || key === "-" || key === "0" || key === "=") { - e.preventDefault(); - } - } - }; + /** + * Prevents browser zoom via keyboard shortcuts: + * Ctrl/Cmd + '+', '-', '=', or '0' (reset zoom) + * Some keyboards emit '=' for '+' (without Shift). + */ + const handleKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey) { + const key = e.key; + if (key === "+" || key === "-" || key === "0" || key === "=") { + e.preventDefault(); + } + } + }; - /** - * Prevents pinch-zoom gestures on supported browsers (mainly Safari/macOS/iOS). - * gesturestart, gesturechange, and gestureend are non-standard and not supported everywhere. - */ - const handleGesture = (e: Event) => { - e.preventDefault(); - }; + /** + * Prevents pinch-zoom gestures on supported browsers (mainly Safari/macOS/iOS). + * gesturestart, gesturechange, and gestureend are non-standard and not supported everywhere. + */ + const handleGesture = (e: Event) => { + e.preventDefault(); + }; - // Attach event listeners (passive: false is required for preventDefault to work) - window.addEventListener("wheel", handleWheel, { passive: false }); - window.addEventListener("keydown", handleKeyDown, { passive: false }); - window.addEventListener("gesturestart", handleGesture, { passive: false }); - window.addEventListener("gesturechange", handleGesture, { passive: false }); - window.addEventListener("gestureend", handleGesture, { passive: false }); + // Attach event listeners (passive: false is required for preventDefault to work) + window.addEventListener("wheel", handleWheel, { passive: false }); + window.addEventListener("keydown", handleKeyDown, { passive: false }); + window.addEventListener("gesturestart", handleGesture, { passive: false }); + window.addEventListener("gesturechange", handleGesture, { passive: false }); + window.addEventListener("gestureend", handleGesture, { passive: false }); - // Cleanup listeners on unmount to avoid memory leaks - return () => { - window.removeEventListener("wheel", handleWheel); - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("gesturestart", handleGesture); - window.removeEventListener("gesturechange", handleGesture); - window.removeEventListener("gestureend", handleGesture); - }; - }, []); + // Cleanup listeners on unmount to avoid memory leaks + return () => { + window.removeEventListener("wheel", handleWheel); + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("gesturestart", handleGesture); + window.removeEventListener("gesturechange", handleGesture); + window.removeEventListener("gestureend", handleGesture); + }; + }, []); }; diff --git a/apps/web/src/hooks/use-drag-drop.ts b/apps/web/src/hooks/use-drag-drop.ts index 768014ec..eaab89d8 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-keyboard-shortcuts.ts b/apps/web/src/hooks/use-keyboard-shortcuts.ts index cd1790fd..feb8f409 100644 --- a/apps/web/src/hooks/use-keyboard-shortcuts.ts +++ b/apps/web/src/hooks/use-keyboard-shortcuts.ts @@ -1,338 +1,338 @@ -"use client"; - -import { useEffect, useCallback } from "react"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { usePlaybackStore } from "@/stores/playback-store"; -import { useProjectStore } from "@/stores/project-store"; -import { toast } from "sonner"; - -export interface KeyboardShortcut { - id: string; - keys: string[]; - description: string; - category: string; - action: () => void; - enabled?: boolean; - requiresSelection?: boolean; - icon?: React.ReactNode; -} - -interface UseKeyboardShortcutsOptions { - enabled?: boolean; - context?: "global" | "timeline" | "editor"; -} - -export const useKeyboardShortcuts = ( - options: UseKeyboardShortcutsOptions = {}, -) => { - const { enabled = true, context = "editor" } = options; - - const { - tracks, - selectedElements, - clearSelectedElements, - setSelectedElements, - removeElementFromTrack, - splitElement, - addElementToTrack, - snappingEnabled, - toggleSnapping, - undo, - redo, - } = useTimelineStore(); - - const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); - - const { activeProject } = useProjectStore(); - - // Check if user is typing in an input field - const isInputFocused = useCallback(() => { - const activeElement = document.activeElement as HTMLElement; - return ( - activeElement && - (activeElement.tagName === "INPUT" || - activeElement.tagName === "TEXTAREA" || - activeElement.contentEditable === "true") - ); - }, []); - - // Define all shortcuts in one place - const shortcuts: KeyboardShortcut[] = [ - // Playback Controls - { - id: "play-pause", - keys: ["Space"], - description: "Play/Pause", - category: "Playback", - action: () => { - toggle(); - }, - }, - { - id: "rewind", - keys: ["J"], - description: "Rewind 1 second", - category: "Playback", - action: () => { - seek(Math.max(0, currentTime - 1)); - }, - }, - { - id: "play-pause-alt", - keys: ["K"], - description: "Play/Pause (alternative)", - category: "Playback", - action: () => { - toggle(); - }, - }, - { - id: "fast-forward", - keys: ["L"], - description: "Fast forward 1 second", - category: "Playback", - action: () => { - seek(Math.min(duration, currentTime + 1)); - }, - }, - - // Navigation - { - id: "frame-backward", - keys: ["ArrowLeft"], - description: "Frame step backward", - category: "Navigation", - action: () => { - const projectFps = activeProject?.fps || 30; - seek(Math.max(0, currentTime - 1 / projectFps)); - }, - }, - { - id: "frame-forward", - keys: ["ArrowRight"], - description: "Frame step forward", - category: "Navigation", - action: () => { - const projectFps = activeProject?.fps || 30; - seek(Math.min(duration, currentTime + 1 / projectFps)); - }, - }, - { - id: "jump-backward", - keys: ["Shift+ArrowLeft"], - description: "Jump back 5 seconds", - category: "Navigation", - action: () => { - seek(Math.max(0, currentTime - 5)); - }, - }, - { - id: "jump-forward", - keys: ["Shift+ArrowRight"], - description: "Jump forward 5 seconds", - category: "Navigation", - action: () => { - seek(Math.min(duration, currentTime + 5)); - }, - }, - { - id: "goto-start", - keys: ["Home"], - description: "Go to timeline start", - category: "Navigation", - action: () => { - seek(0); - }, - }, - { - id: "goto-end", - keys: ["End"], - description: "Go to timeline end", - category: "Navigation", - action: () => { - seek(duration); - }, - }, - - // Editing - { - id: "split-element", - keys: ["S"], - description: "Split element at playhead", - category: "Editing", - requiresSelection: true, - action: () => { - 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"); - } - } - }, - }, - { - id: "delete-elements", - keys: ["Delete", "Backspace"], - description: "Delete selected elements", - category: "Editing", - requiresSelection: true, - action: () => { - if (selectedElements.length === 0) { - toast.error("No elements selected"); - return; - } - selectedElements.forEach( - ({ trackId, elementId }: { trackId: string; elementId: string }) => { - removeElementFromTrack(trackId, elementId); - }, - ); - clearSelectedElements(); - }, - }, - { - id: "toggle-snapping", - keys: ["N"], - description: "Toggle snapping", - category: "Editing", - action: () => { - toggleSnapping(); - }, - }, - - // Selection & Organization - { - id: "select-all", - keys: ["Cmd+A", "Ctrl+A"], - description: "Select all elements", - category: "Selection", - action: () => { - const allElements = tracks.flatMap((track: any) => - track.elements.map((element: any) => ({ - trackId: track.id, - elementId: element.id, - })), - ); - setSelectedElements(allElements); - }, - }, - { - id: "duplicate-element", - keys: ["Cmd+D", "Ctrl+D"], - description: "Duplicate selected element", - category: "Selection", - requiresSelection: true, - action: () => { - 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, - }); - } - }, - }, - - // History - { - id: "undo", - keys: ["Cmd+Z", "Ctrl+Z"], - description: "Undo", - category: "History", - action: () => { - undo(); - }, - }, - { - id: "redo", - keys: ["Cmd+Shift+Z", "Ctrl+Shift+Z", "Cmd+Y", "Ctrl+Y"], - description: "Redo", - category: "History", - action: () => { - redo(); - }, - }, - ]; - - // Parse keyboard event to match against shortcuts - const parseKeyboardEvent = useCallback((e: KeyboardEvent): string => { - const parts: string[] = []; - - if (e.metaKey || e.ctrlKey) parts.push(e.metaKey ? "Cmd" : "Ctrl"); - if (e.shiftKey) parts.push("Shift"); - if (e.altKey) parts.push("Alt"); - - parts.push(e.key); - - return parts.join("+").toLowerCase(); - }, []); - - // Handle keyboard events - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (!enabled || isInputFocused()) return; - - const keyCombo = parseKeyboardEvent(e); - const shortcut = shortcuts.find((s) => - s.keys.some( - (key) => - key.toLowerCase() === keyCombo || - key.toLowerCase() === e.key.toLowerCase(), - ), - ); - - if (shortcut) { - // Check if shortcut requires selection - if (shortcut.requiresSelection && selectedElements.length === 0) { - return; - } - - e.preventDefault(); - shortcut.action(); - } - }, - [enabled, shortcuts, selectedElements, parseKeyboardEvent, isInputFocused], - ); - - // Set up event listener - useEffect(() => { - if (!enabled) return; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [enabled, handleKeyDown]); - - // Return shortcuts for help component - return { - shortcuts: shortcuts.filter((s) => s.enabled !== false), - enabled, - }; -}; +"use client"; + +import { useEffect, useCallback } from "react"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { useProjectStore } from "@/stores/project-store"; +import { toast } from "sonner"; + +export interface KeyboardShortcut { + id: string; + keys: string[]; + description: string; + category: string; + action: () => void; + enabled?: boolean; + requiresSelection?: boolean; + icon?: React.ReactNode; +} + +interface UseKeyboardShortcutsOptions { + enabled?: boolean; + context?: "global" | "timeline" | "editor"; +} + +export const useKeyboardShortcuts = ( + options: UseKeyboardShortcutsOptions = {} +) => { + const { enabled = true, context = "editor" } = options; + + const { + tracks, + selectedElements, + clearSelectedElements, + setSelectedElements, + removeElementFromTrack, + splitElement, + addElementToTrack, + snappingEnabled, + toggleSnapping, + undo, + redo, + } = useTimelineStore(); + + const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); + + const { activeProject } = useProjectStore(); + + // Check if user is typing in an input field + const isInputFocused = useCallback(() => { + const activeElement = document.activeElement as HTMLElement; + return ( + activeElement && + (activeElement.tagName === "INPUT" || + activeElement.tagName === "TEXTAREA" || + activeElement.contentEditable === "true") + ); + }, []); + + // Define all shortcuts in one place + const shortcuts: KeyboardShortcut[] = [ + // Playback Controls + { + id: "play-pause", + keys: ["Space"], + description: "Play/Pause", + category: "Playback", + action: () => { + toggle(); + }, + }, + { + id: "rewind", + keys: ["J"], + description: "Rewind 1 second", + category: "Playback", + action: () => { + seek(Math.max(0, currentTime - 1)); + }, + }, + { + id: "play-pause-alt", + keys: ["K"], + description: "Play/Pause (alternative)", + category: "Playback", + action: () => { + toggle(); + }, + }, + { + id: "fast-forward", + keys: ["L"], + description: "Fast forward 1 second", + category: "Playback", + action: () => { + seek(Math.min(duration, currentTime + 1)); + }, + }, + + // Navigation + { + id: "frame-backward", + keys: ["ArrowLeft"], + description: "Frame step backward", + category: "Navigation", + action: () => { + const projectFps = activeProject?.fps || 30; + seek(Math.max(0, currentTime - 1 / projectFps)); + }, + }, + { + id: "frame-forward", + keys: ["ArrowRight"], + description: "Frame step forward", + category: "Navigation", + action: () => { + const projectFps = activeProject?.fps || 30; + seek(Math.min(duration, currentTime + 1 / projectFps)); + }, + }, + { + id: "jump-backward", + keys: ["Shift+ArrowLeft"], + description: "Jump back 5 seconds", + category: "Navigation", + action: () => { + seek(Math.max(0, currentTime - 5)); + }, + }, + { + id: "jump-forward", + keys: ["Shift+ArrowRight"], + description: "Jump forward 5 seconds", + category: "Navigation", + action: () => { + seek(Math.min(duration, currentTime + 5)); + }, + }, + { + id: "goto-start", + keys: ["Home"], + description: "Go to timeline start", + category: "Navigation", + action: () => { + seek(0); + }, + }, + { + id: "goto-end", + keys: ["End"], + description: "Go to timeline end", + category: "Navigation", + action: () => { + seek(duration); + }, + }, + + // Editing + { + id: "split-element", + keys: ["S"], + description: "Split element at playhead", + category: "Editing", + requiresSelection: true, + action: () => { + 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"); + } + } + }, + }, + { + id: "delete-elements", + keys: ["Delete", "Backspace"], + description: "Delete selected elements", + category: "Editing", + requiresSelection: true, + action: () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + selectedElements.forEach( + ({ trackId, elementId }: { trackId: string; elementId: string }) => { + removeElementFromTrack(trackId, elementId); + } + ); + clearSelectedElements(); + }, + }, + { + id: "toggle-snapping", + keys: ["N"], + description: "Toggle snapping", + category: "Editing", + action: () => { + toggleSnapping(); + }, + }, + + // Selection & Organization + { + id: "select-all", + keys: ["Cmd+A", "Ctrl+A"], + description: "Select all elements", + category: "Selection", + action: () => { + const allElements = tracks.flatMap((track: any) => + track.elements.map((element: any) => ({ + trackId: track.id, + elementId: element.id, + })) + ); + setSelectedElements(allElements); + }, + }, + { + id: "duplicate-element", + keys: ["Cmd+D", "Ctrl+D"], + description: "Duplicate selected element", + category: "Selection", + requiresSelection: true, + action: () => { + 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, + }); + } + }, + }, + + // History + { + id: "undo", + keys: ["Cmd+Z", "Ctrl+Z"], + description: "Undo", + category: "History", + action: () => { + undo(); + }, + }, + { + id: "redo", + keys: ["Cmd+Shift+Z", "Ctrl+Shift+Z", "Cmd+Y", "Ctrl+Y"], + description: "Redo", + category: "History", + action: () => { + redo(); + }, + }, + ]; + + // Parse keyboard event to match against shortcuts + const parseKeyboardEvent = useCallback((e: KeyboardEvent): string => { + const parts: string[] = []; + + if (e.metaKey || e.ctrlKey) parts.push(e.metaKey ? "Cmd" : "Ctrl"); + if (e.shiftKey) parts.push("Shift"); + if (e.altKey) parts.push("Alt"); + + parts.push(e.key); + + return parts.join("+").toLowerCase(); + }, []); + + // Handle keyboard events + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!enabled || isInputFocused()) return; + + const keyCombo = parseKeyboardEvent(e); + const shortcut = shortcuts.find((s) => + s.keys.some( + (key) => + key.toLowerCase() === keyCombo || + key.toLowerCase() === e.key.toLowerCase() + ) + ); + + if (shortcut) { + // Check if shortcut requires selection + if (shortcut.requiresSelection && selectedElements.length === 0) { + return; + } + + e.preventDefault(); + shortcut.action(); + } + }, + [enabled, shortcuts, selectedElements, parseKeyboardEvent, isInputFocused] + ); + + // Set up event listener + useEffect(() => { + if (!enabled) return; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [enabled, handleKeyDown]); + + // Return shortcuts for help component + return { + shortcuts: shortcuts.filter((s) => s.enabled !== false), + enabled, + }; +}; diff --git a/apps/web/src/hooks/use-mobile.tsx b/apps/web/src/hooks/use-mobile.tsx index 0a892310..2b0fe1df 100644 --- a/apps/web/src/hooks/use-mobile.tsx +++ b/apps/web/src/hooks/use-mobile.tsx @@ -1,21 +1,19 @@ -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 39b5c841..ecbd1232 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 0abf8707..c40617c1 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 f7737346..0e409977 100644 --- a/apps/web/src/hooks/use-timeline-element-resize.ts +++ b/apps/web/src/hooks/use-timeline-element-resize.ts @@ -3,162 +3,162 @@ import { ResizeState, TimelineElement, TimelineTrack } from "@/types/timeline"; import { useMediaStore } from "@/stores/media-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(); - - // 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 - only trim within original duration - const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1; - const calculated = resizing.initialTrimStart + deltaTime; - const newTrimStart = Math.max(0, Math.min(maxAllowed, calculated)); - - onUpdateTrim(track.id, element.id, newTrimStart, resizing.initialTrimEnd); - } 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 handleResizeMove = (e: React.MouseEvent) => { - updateTrimFromMouseMove(e); - }; - - 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(); + + // 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 - only trim within original duration + const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1; + const calculated = resizing.initialTrimStart + deltaTime; + const newTrimStart = Math.max(0, Math.min(maxAllowed, calculated)); + + onUpdateTrim(track.id, element.id, newTrimStart, resizing.initialTrimEnd); + } 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 handleResizeMove = (e: React.MouseEvent) => { + updateTrimFromMouseMove(e); + }; + + 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 73dca7a5..9eb06485 100644 --- a/apps/web/src/hooks/use-timeline-playhead.ts +++ b/apps/web/src/hooks/use-timeline-playhead.ts @@ -1,125 +1,125 @@ -import { snapTimeToFrame } from "@/constants/timeline-constants"; -import { useProjectStore } from "@/stores/project-store"; -import { useRef, useState, useCallback } from "react"; - -interface UseTimelinePlayheadProps { - currentTime: number; - duration: number; - zoomLevel: number; - seek: (time: number) => void; - rulerRef: React.RefObject; - rulerScrollRef: React.RefObject; -} - -/** - * useTimelinePlayhead - * - * Custom hook to manage playhead (scrubbing) logic for a timeline editor. - * Handles mouse interaction for timeline ruler, calculates time from mouse, - * and enables smooth scrubbing without unnecessary re-renders. - */ -export function useTimelinePlayhead({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, -}: UseTimelinePlayheadProps) { - // Get current project info (especially FPS) from global store - const { activeProject } = useProjectStore(); - - // Ref to track if currently scrubbing - const isScrubbingRef = useRef(false); - - // Ref to hold the playhead time while scrubbing (does not trigger re-render) - const scrubTimeRef = useRef(null); - - // State to force re-render when scrubbing ends - const [_, forceRerender] = useState(0); - - /** - * Determines the actual playhead position: - * - If scrubbing, use the value in scrubTimeRef - * - Otherwise, use the currentTime (controlled by external state) - */ - const playheadPosition = - isScrubbingRef.current && scrubTimeRef.current !== null - ? scrubTimeRef.current - : currentTime; - - /** - * Calculate timeline time (in seconds) based on mouse X position. - * - Gets bounding rect of the ruler - * - Adjusts for scroll position if ruler is scrollable - * - Converts X pixel offset to seconds using current zoom level - * - Snaps to nearest frame using FPS - */ - const getTimeFromMouse = useCallback( - (e: MouseEvent | React.MouseEvent) => { - const ruler = rulerRef.current; - const scrollArea = rulerScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]", - ) as HTMLElement; - - if (!ruler || !scrollArea) return 0; - - const rect = ruler.getBoundingClientRect(); - const scrollLeft = scrollArea.scrollLeft; - const x = e.clientX - rect.left + scrollLeft; - - // Calculate how many pixels represent one second, depending on zoom - const pixelsPerSecond = 50 * zoomLevel; - const rawTime = Math.max(0, Math.min(duration, x / pixelsPerSecond)); - const time = snapTimeToFrame(rawTime, activeProject?.fps || 30); - return time; - }, - [rulerRef, rulerScrollRef, duration, zoomLevel, activeProject?.fps], - ); - - /** - * Handle mouse down event on the ruler: - * - Starts scrubbing and updates playhead immediately - * - Registers mousemove/mouseup events to allow scrubbing - * - Updates time as mouse moves and seeks to new time - * - Cleans up listeners and triggers re-render when scrubbing ends - */ - const handleRulerMouseDown = useCallback( - (e: React.MouseEvent) => { - if (e.button !== 0) return; - e.preventDefault(); - const time = getTimeFromMouse(e); - - isScrubbingRef.current = true; - scrubTimeRef.current = time; - seek(time); - - // Mouse move handler: update playhead and seek to new time - const onMouseMove = (e: MouseEvent) => { - const t = getTimeFromMouse(e); - if (t !== scrubTimeRef.current) { - scrubTimeRef.current = t; - seek(t); - } - }; - - // Mouse up handler: stop scrubbing, cleanup, and force re-render - const onMouseUp = () => { - isScrubbingRef.current = false; - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - forceRerender((v) => v + 1); - }; - - // Attach listeners to window for drag outside the ruler area - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - }, - [getTimeFromMouse, seek], - ); - - return { - playheadPosition, // Current playhead position (real-time while scrubbing) - handleRulerMouseDown, // Attach to the ruler's onMouseDown - }; -} +import { snapTimeToFrame } from "@/constants/timeline-constants"; +import { useProjectStore } from "@/stores/project-store"; +import { useRef, useState, useCallback } from "react"; + +interface UseTimelinePlayheadProps { + currentTime: number; + duration: number; + zoomLevel: number; + seek: (time: number) => void; + rulerRef: React.RefObject; + rulerScrollRef: React.RefObject; +} + +/** + * useTimelinePlayhead + * + * Custom hook to manage playhead (scrubbing) logic for a timeline editor. + * Handles mouse interaction for timeline ruler, calculates time from mouse, + * and enables smooth scrubbing without unnecessary re-renders. + */ +export function useTimelinePlayhead({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, +}: UseTimelinePlayheadProps) { + // Get current project info (especially FPS) from global store + const { activeProject } = useProjectStore(); + + // Ref to track if currently scrubbing + const isScrubbingRef = useRef(false); + + // Ref to hold the playhead time while scrubbing (does not trigger re-render) + const scrubTimeRef = useRef(null); + + // State to force re-render when scrubbing ends + const [_, forceRerender] = useState(0); + + /** + * Determines the actual playhead position: + * - If scrubbing, use the value in scrubTimeRef + * - Otherwise, use the currentTime (controlled by external state) + */ + const playheadPosition = + isScrubbingRef.current && scrubTimeRef.current !== null + ? scrubTimeRef.current + : currentTime; + + /** + * Calculate timeline time (in seconds) based on mouse X position. + * - Gets bounding rect of the ruler + * - Adjusts for scroll position if ruler is scrollable + * - Converts X pixel offset to seconds using current zoom level + * - Snaps to nearest frame using FPS + */ + const getTimeFromMouse = useCallback( + (e: MouseEvent | React.MouseEvent) => { + const ruler = rulerRef.current; + const scrollArea = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + + if (!ruler || !scrollArea) return 0; + + const rect = ruler.getBoundingClientRect(); + const scrollLeft = scrollArea.scrollLeft; + const x = e.clientX - rect.left + scrollLeft; + + // Calculate how many pixels represent one second, depending on zoom + const pixelsPerSecond = 50 * zoomLevel; + const rawTime = Math.max(0, Math.min(duration, x / pixelsPerSecond)); + const time = snapTimeToFrame(rawTime, activeProject?.fps || 30); + return time; + }, + [rulerRef, rulerScrollRef, duration, zoomLevel, activeProject?.fps] + ); + + /** + * Handle mouse down event on the ruler: + * - Starts scrubbing and updates playhead immediately + * - Registers mousemove/mouseup events to allow scrubbing + * - Updates time as mouse moves and seeks to new time + * - Cleans up listeners and triggers re-render when scrubbing ends + */ + const handleRulerMouseDown = useCallback( + (e: React.MouseEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + const time = getTimeFromMouse(e); + + isScrubbingRef.current = true; + scrubTimeRef.current = time; + seek(time); + + // Mouse move handler: update playhead and seek to new time + const onMouseMove = (e: MouseEvent) => { + const t = getTimeFromMouse(e); + if (t !== scrubTimeRef.current) { + scrubTimeRef.current = t; + seek(t); + } + }; + + // Mouse up handler: stop scrubbing, cleanup, and force re-render + const onMouseUp = () => { + isScrubbingRef.current = false; + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + forceRerender((v) => v + 1); + }; + + // Attach listeners to window for drag outside the ruler area + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + }, + [getTimeFromMouse, seek] + ); + + return { + playheadPosition, // Current playhead position (real-time while scrubbing) + handleRulerMouseDown, // Attach to the ruler's onMouseDown + }; +} diff --git a/apps/web/src/hooks/use-timeline-snapping.ts b/apps/web/src/hooks/use-timeline-snapping.ts index e51f6a22..f7f96392 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 8da2f718..c8780f9a 100644 --- a/apps/web/src/hooks/use-timeline-zoom.ts +++ b/apps/web/src/hooks/use-timeline-zoom.ts @@ -1,93 +1,93 @@ -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { useState, useCallback, RefObject, useMemo } from "react"; - -interface UseTimelineZoomProps { - containerRef: RefObject; - isInTimeline?: boolean; -} - -interface UseTimelineZoomReturn { - zoomLevel: number; - zoomStep: number; - setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void; - handleChangeZoomStep: (zoomStep: number) => void; - handleChangeZoomLevel: (zoomStep: number) => void; - handleWheel: (e: React.WheelEvent) => void; -} - -/** - * useTimelineZoom - * - * Custom hook to manage zoom logic for a timeline component. - * Handles zoom state, step calculation, level changes, and mouse wheel zooming (with ctrl/meta). - */ -export function useTimelineZoom(): UseTimelineZoomReturn { - // Current zoom level (1 = default) - const [zoomLevel, setZoomLevel] = useState(1); - - /** - * Calculate the current zoom step based on the zoom level and a base step constant. - * Ensures minimum step is 1 (prevents zero or negative step). - */ - const zoomStep = useMemo( - () => - Math.max(1, Math.round(zoomLevel / TIMELINE_CONSTANTS.ZOOM_STEP_BASE)), - [zoomLevel], - ); - - /** - * Update the zoom level using a given step. - * The zoom level is clamped to a minimum value defined in constants. - */ - const handleChangeZoomStep = useCallback((newStep: number) => { - setZoomLevel( - Math.max( - TIMELINE_CONSTANTS.ZOOM_LEVEL_MIN, - newStep * TIMELINE_CONSTANTS.ZOOM_STEP_BASE, - ), - ); - }, []); - - /** - * Update the zoom level directly. - * The new value is clamped between the defined minimum and maximum levels. - */ - const handleChangeZoomLevel = useCallback((newLevel: number) => { - setZoomLevel( - Math.max( - TIMELINE_CONSTANTS.ZOOM_LEVEL_MIN, - Math.min(TIMELINE_CONSTANTS.ZOOM_LEVEL_MAX, newLevel), - ), - ); - }, []); - - /** - * Handle zooming using mouse wheel + ctrl/meta key (like browser zoom). - * Prevents default browser behavior and updates zoom level accordingly. - */ - const handleWheel = useCallback((e: React.WheelEvent) => { - if (e.ctrlKey || e.metaKey) { - e.preventDefault?.(); - const delta = - e.deltaY > 0 - ? -TIMELINE_CONSTANTS.ZOOM_STEP_BASE - : TIMELINE_CONSTANTS.ZOOM_STEP_BASE; - setZoomLevel((prev) => - Math.max( - TIMELINE_CONSTANTS.ZOOM_LEVEL_MIN, - Math.min(TIMELINE_CONSTANTS.ZOOM_LEVEL_MAX, prev + delta), - ), - ); - } - }, []); - - // Return all handlers and state values for use in timeline components - return { - zoomLevel, // Current zoom level - zoomStep, // Current zoom step (calculated from zoomLevel) - setZoomLevel, // Directly set the zoom level (accepts value or updater function) - handleChangeZoomStep, // Change zoom by step value - handleChangeZoomLevel, // Change zoom by level value - handleWheel, // Handler for mouse wheel zooming - }; -} +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useState, useCallback, RefObject, useMemo } from "react"; + +interface UseTimelineZoomProps { + containerRef: RefObject; + isInTimeline?: boolean; +} + +interface UseTimelineZoomReturn { + zoomLevel: number; + zoomStep: number; + setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void; + handleChangeZoomStep: (zoomStep: number) => void; + handleChangeZoomLevel: (zoomStep: number) => void; + handleWheel: (e: React.WheelEvent) => void; +} + +/** + * useTimelineZoom + * + * Custom hook to manage zoom logic for a timeline component. + * Handles zoom state, step calculation, level changes, and mouse wheel zooming (with ctrl/meta). + */ +export function useTimelineZoom(): UseTimelineZoomReturn { + // Current zoom level (1 = default) + const [zoomLevel, setZoomLevel] = useState(1); + + /** + * Calculate the current zoom step based on the zoom level and a base step constant. + * Ensures minimum step is 1 (prevents zero or negative step). + */ + const zoomStep = useMemo( + () => + Math.max(1, Math.round(zoomLevel / TIMELINE_CONSTANTS.ZOOM_STEP_BASE)), + [zoomLevel] + ); + + /** + * Update the zoom level using a given step. + * The zoom level is clamped to a minimum value defined in constants. + */ + const handleChangeZoomStep = useCallback((newStep: number) => { + setZoomLevel( + Math.max( + TIMELINE_CONSTANTS.ZOOM_LEVEL_MIN, + newStep * TIMELINE_CONSTANTS.ZOOM_STEP_BASE + ) + ); + }, []); + + /** + * Update the zoom level directly. + * The new value is clamped between the defined minimum and maximum levels. + */ + const handleChangeZoomLevel = useCallback((newLevel: number) => { + setZoomLevel( + Math.max( + TIMELINE_CONSTANTS.ZOOM_LEVEL_MIN, + Math.min(TIMELINE_CONSTANTS.ZOOM_LEVEL_MAX, newLevel) + ) + ); + }, []); + + /** + * Handle zooming using mouse wheel + ctrl/meta key (like browser zoom). + * Prevents default browser behavior and updates zoom level accordingly. + */ + const handleWheel = useCallback((e: React.WheelEvent) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault?.(); + const delta = + e.deltaY > 0 + ? -TIMELINE_CONSTANTS.ZOOM_STEP_BASE + : TIMELINE_CONSTANTS.ZOOM_STEP_BASE; + setZoomLevel((prev) => + Math.max( + TIMELINE_CONSTANTS.ZOOM_LEVEL_MIN, + Math.min(TIMELINE_CONSTANTS.ZOOM_LEVEL_MAX, prev + delta) + ) + ); + } + }, []); + + // Return all handlers and state values for use in timeline components + return { + zoomLevel, // Current zoom level + zoomStep, // Current zoom step (calculated from zoomLevel) + setZoomLevel, // Directly set the zoom level (accepts value or updater function) + handleChangeZoomStep, // Change zoom by step value + handleChangeZoomLevel, // Change zoom by level value + handleWheel, // Handler for mouse wheel zooming + }; +} diff --git a/apps/web/src/hooks/use-toast.ts b/apps/web/src/hooks/use-toast.ts index 78cc46e8..c682cd1f 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