From 5931ddb77aac23ffe1b82d9d7c03afde3e671109 Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Thu, 24 Jul 2025 14:41:34 -0700 Subject: feat: add ultracite (#452) * Init ultracite * Update scripts and biome.jsonc * Update biome.jsonc * Update biome.jsonc * Update biome.jsonc * Run format --- apps/web/src/hooks/auth/useLogin.ts | 108 ++++---- apps/web/src/hooks/auth/useSignUp.ts | 102 +++---- apps/web/src/hooks/use-aspect-ratio.ts | 184 ++++++------- apps/web/src/hooks/use-drag-drop.ts | 170 ++++++------ apps/web/src/hooks/use-keybindings.ts | 2 +- apps/web/src/hooks/use-mobile.tsx | 24 +- apps/web/src/hooks/use-selection-box.ts | 398 ++++++++++++++-------------- apps/web/src/hooks/use-timeline-playhead.ts | 314 +++++++++++----------- apps/web/src/hooks/use-timeline-zoom.ts | 120 ++++----- apps/web/src/hooks/use-toast.ts | 6 +- 10 files changed, 715 insertions(+), 713 deletions(-) (limited to 'apps/web/src/hooks') diff --git a/apps/web/src/hooks/auth/useLogin.ts b/apps/web/src/hooks/auth/useLogin.ts index 12eddcbf..b2c5ef98 100644 --- a/apps/web/src/hooks/auth/useLogin.ts +++ b/apps/web/src/hooks/auth/useLogin.ts @@ -3,58 +3,58 @@ import { useRouter } from "next/navigation"; import { signIn } from "@opencut/auth/client"; export function useLogin() { - const router = useRouter(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [isEmailLoading, setIsEmailLoading] = useState(false); - const [isGoogleLoading, setIsGoogleLoading] = useState(false); - - const handleLogin = useCallback(async () => { - setError(null); - setIsEmailLoading(true); - - const { error } = await signIn.email({ - email, - password, - }); - - if (error) { - setError(error.message || "An unexpected error occurred."); - setIsEmailLoading(false); - return; - } - - router.push("/projects"); - }, [router, email, password]); - - const handleGoogleLogin = async () => { - setError(null); - setIsGoogleLoading(true); - - try { - await signIn.social({ - provider: "google", - callbackURL: "/projects", - }); - } catch (error) { - setError("Failed to sign in with Google. Please try again."); - setIsGoogleLoading(false); - } - }; - - const isAnyLoading = isEmailLoading || isGoogleLoading; - - return { - email, - setEmail, - password, - setPassword, - error, - isEmailLoading, - isGoogleLoading, - isAnyLoading, - handleLogin, - handleGoogleLogin, - }; + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isEmailLoading, setIsEmailLoading] = useState(false); + const [isGoogleLoading, setIsGoogleLoading] = useState(false); + + const handleLogin = useCallback(async () => { + setError(null); + setIsEmailLoading(true); + + const { error } = await signIn.email({ + email, + password, + }); + + if (error) { + setError(error.message || "An unexpected error occurred."); + setIsEmailLoading(false); + return; + } + + router.push("/projects"); + }, [router, email, password]); + + const handleGoogleLogin = async () => { + setError(null); + setIsGoogleLoading(true); + + try { + await signIn.social({ + provider: "google", + callbackURL: "/projects", + }); + } catch (error) { + setError("Failed to sign in with Google. Please try again."); + setIsGoogleLoading(false); + } + }; + + const isAnyLoading = isEmailLoading || isGoogleLoading; + + return { + email, + setEmail, + password, + setPassword, + error, + isEmailLoading, + isGoogleLoading, + isAnyLoading, + handleLogin, + handleGoogleLogin, + }; } diff --git a/apps/web/src/hooks/auth/useSignUp.ts b/apps/web/src/hooks/auth/useSignUp.ts index 4cd2850a..b6039f85 100644 --- a/apps/web/src/hooks/auth/useSignUp.ts +++ b/apps/web/src/hooks/auth/useSignUp.ts @@ -3,63 +3,63 @@ import { useRouter } from "next/navigation"; import { signUp, signIn } from "@opencut/auth/client"; export function useSignUp() { - const router = useRouter(); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [isEmailLoading, setIsEmailLoading] = useState(false); - const [isGoogleLoading, setIsGoogleLoading] = useState(false); + const router = useRouter(); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isEmailLoading, setIsEmailLoading] = useState(false); + const [isGoogleLoading, setIsGoogleLoading] = useState(false); - const handleSignUp = useCallback(async () => { - setError(null); - setIsEmailLoading(true); + const handleSignUp = useCallback(async () => { + setError(null); + setIsEmailLoading(true); - const { error } = await signUp.email({ - name, - email, - password, - }); + const { error } = await signUp.email({ + name, + email, + password, + }); - if (error) { - setError(error.message || "An unexpected error occurred."); - setIsEmailLoading(false); - return; - } + if (error) { + setError(error.message || "An unexpected error occurred."); + setIsEmailLoading(false); + return; + } - router.push("/login"); - }, [name, email, password, router]); + router.push("/login"); + }, [name, email, password, router]); - const handleGoogleSignUp = useCallback(async () => { - setError(null); - setIsGoogleLoading(true); + const handleGoogleSignUp = useCallback(async () => { + setError(null); + setIsGoogleLoading(true); - try { - await signIn.social({ - provider: "google", - }); + try { + await signIn.social({ + provider: "google", + }); - router.push("/editor"); - } catch (error) { - setError("Failed to sign up with Google. Please try again."); - setIsGoogleLoading(false); - } - }, [router]); + router.push("/editor"); + } catch (error) { + setError("Failed to sign up with Google. Please try again."); + setIsGoogleLoading(false); + } + }, [router]); - const isAnyLoading = isEmailLoading || isGoogleLoading; + const isAnyLoading = isEmailLoading || isGoogleLoading; - return { - name, - setName, - email, - setEmail, - password, - setPassword, - error, - isEmailLoading, - isGoogleLoading, - isAnyLoading, - handleSignUp, - handleGoogleSignUp, - }; -} \ No newline at end of file + return { + name, + setName, + email, + setEmail, + password, + setPassword, + error, + isEmailLoading, + isGoogleLoading, + isAnyLoading, + handleSignUp, + handleGoogleSignUp, + }; +} diff --git a/apps/web/src/hooks/use-aspect-ratio.ts b/apps/web/src/hooks/use-aspect-ratio.ts index 40de8454..e3e85141 100644 --- a/apps/web/src/hooks/use-aspect-ratio.ts +++ b/apps/web/src/hooks/use-aspect-ratio.ts @@ -1,92 +1,92 @@ -import { useEditorStore } from "@/stores/editor-store"; -import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store"; -import { useTimelineStore } from "@/stores/timeline-store"; - -export function useAspectRatio() { - const { canvasSize, canvasMode, canvasPresets } = useEditorStore(); - const { mediaItems } = useMediaStore(); - const { tracks } = useTimelineStore(); - - // Find the current preset based on canvas size - const currentPreset = canvasPresets.find( - (preset) => - preset.width === canvasSize.width && preset.height === canvasSize.height - ); - - // Get the original aspect ratio from the first video/image in timeline - const getOriginalAspectRatio = (): number => { - // Find first video or image in timeline - for (const track of tracks) { - for (const element of track.elements) { - if (element.type === "media") { - const mediaItem = mediaItems.find( - (item) => item.id === element.mediaId - ); - if ( - mediaItem && - (mediaItem.type === "video" || mediaItem.type === "image") - ) { - return getMediaAspectRatio(mediaItem); - } - } - } - } - return 16 / 9; // Default aspect ratio - }; - - // Get current aspect ratio - const getCurrentAspectRatio = (): number => { - return canvasSize.width / canvasSize.height; - }; - - // Format aspect ratio as a readable string - const formatAspectRatio = (aspectRatio: number): string => { - // Check if it matches a common aspect ratio - const ratios = [ - { ratio: 16 / 9, label: "16:9" }, - { ratio: 9 / 16, label: "9:16" }, - { ratio: 1, label: "1:1" }, - { ratio: 4 / 3, label: "4:3" }, - { ratio: 3 / 4, label: "3:4" }, - { ratio: 21 / 9, label: "21:9" }, - ]; - - for (const { ratio, label } of ratios) { - if (Math.abs(aspectRatio - ratio) < 0.01) { - return label; - } - } - - // If not a common ratio, format as decimal - return aspectRatio.toFixed(2); - }; - - // Check if current mode is "Original" - const isOriginal = canvasMode === "original"; - - // Get display name for current aspect ratio - const getDisplayName = (): string => { - // If explicitly set to original mode, always show "Original" - if (canvasMode === "original") { - return "Original"; - } - - if (currentPreset) { - return currentPreset.name; - } - - return formatAspectRatio(getCurrentAspectRatio()); - }; - - return { - currentPreset, - canvasMode, - isOriginal, - getCurrentAspectRatio, - getOriginalAspectRatio, - formatAspectRatio, - getDisplayName, - canvasSize, - canvasPresets, - }; -} +import { useEditorStore } from "@/stores/editor-store"; +import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store"; +import { useTimelineStore } from "@/stores/timeline-store"; + +export function useAspectRatio() { + const { canvasSize, canvasMode, canvasPresets } = useEditorStore(); + const { mediaItems } = useMediaStore(); + const { tracks } = useTimelineStore(); + + // Find the current preset based on canvas size + const currentPreset = canvasPresets.find( + (preset) => + preset.width === canvasSize.width && preset.height === canvasSize.height + ); + + // Get the original aspect ratio from the first video/image in timeline + const getOriginalAspectRatio = (): number => { + // Find first video or image in timeline + for (const track of tracks) { + for (const element of track.elements) { + if (element.type === "media") { + const mediaItem = mediaItems.find( + (item) => item.id === element.mediaId + ); + if ( + mediaItem && + (mediaItem.type === "video" || mediaItem.type === "image") + ) { + return getMediaAspectRatio(mediaItem); + } + } + } + } + return 16 / 9; // Default aspect ratio + }; + + // Get current aspect ratio + const getCurrentAspectRatio = (): number => { + return canvasSize.width / canvasSize.height; + }; + + // Format aspect ratio as a readable string + const formatAspectRatio = (aspectRatio: number): string => { + // Check if it matches a common aspect ratio + const ratios = [ + { ratio: 16 / 9, label: "16:9" }, + { ratio: 9 / 16, label: "9:16" }, + { ratio: 1, label: "1:1" }, + { ratio: 4 / 3, label: "4:3" }, + { ratio: 3 / 4, label: "3:4" }, + { ratio: 21 / 9, label: "21:9" }, + ]; + + for (const { ratio, label } of ratios) { + if (Math.abs(aspectRatio - ratio) < 0.01) { + return label; + } + } + + // If not a common ratio, format as decimal + return aspectRatio.toFixed(2); + }; + + // Check if current mode is "Original" + const isOriginal = canvasMode === "original"; + + // Get display name for current aspect ratio + const getDisplayName = (): string => { + // If explicitly set to original mode, always show "Original" + if (canvasMode === "original") { + return "Original"; + } + + if (currentPreset) { + return currentPreset.name; + } + + return formatAspectRatio(getCurrentAspectRatio()); + }; + + return { + currentPreset, + canvasMode, + isOriginal, + getCurrentAspectRatio, + getOriginalAspectRatio, + formatAspectRatio, + getDisplayName, + canvasSize, + canvasPresets, + }; +} diff --git a/apps/web/src/hooks/use-drag-drop.ts b/apps/web/src/hooks/use-drag-drop.ts index eaab89d8..0b1d4008 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-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts index 7887f3f1..7224c1c8 100644 --- a/apps/web/src/hooks/use-keybindings.ts +++ b/apps/web/src/hooks/use-keybindings.ts @@ -25,7 +25,7 @@ export function useKeybindingsListener() { ev.preventDefault(); // Handle actions with default arguments - let actionArgs: any = undefined; + let actionArgs: any; if (boundAction === "seek-forward") { actionArgs = { seconds: 1 }; diff --git a/apps/web/src/hooks/use-mobile.tsx b/apps/web/src/hooks/use-mobile.tsx index 2b0fe1df..48fab93c 100644 --- a/apps/web/src/hooks/use-mobile.tsx +++ b/apps/web/src/hooks/use-mobile.tsx @@ -1,19 +1,21 @@ -import * as React from "react" +import * as React from "react"; -const MOBILE_BREAKPOINT = 768 +const MOBILE_BREAKPOINT = 768; export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(undefined) + const [isMobile, setIsMobile] = React.useState( + undefined + ); React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const 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) - }, []) + 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-selection-box.ts b/apps/web/src/hooks/use-selection-box.ts index c40617c1..46e77fa9 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-playhead.ts b/apps/web/src/hooks/use-timeline-playhead.ts index 9a82c23f..008f61a4 100644 --- a/apps/web/src/hooks/use-timeline-playhead.ts +++ b/apps/web/src/hooks/use-timeline-playhead.ts @@ -1,157 +1,157 @@ -import { snapTimeToFrame } from "@/constants/timeline-constants"; -import { useProjectStore } from "@/stores/project-store"; -import { useState, useEffect, useCallback } from "react"; - -interface UseTimelinePlayheadProps { - currentTime: number; - duration: number; - zoomLevel: number; - seek: (time: number) => void; - rulerRef: React.RefObject; - rulerScrollRef: React.RefObject; - tracksScrollRef: React.RefObject; - playheadRef?: React.RefObject; -} - -export function useTimelinePlayhead({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, -}: UseTimelinePlayheadProps) { - // Playhead scrubbing state - const [isScrubbing, setIsScrubbing] = useState(false); - const [scrubTime, setScrubTime] = useState(null); - - // Ruler drag detection state - const [isDraggingRuler, setIsDraggingRuler] = useState(false); - const [hasDraggedRuler, setHasDraggedRuler] = useState(false); - - const playheadPosition = - isScrubbing && scrubTime !== null ? scrubTime : currentTime; - - // --- Playhead Scrubbing Handlers --- - const handlePlayheadMouseDown = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); // Prevent ruler drag from triggering - setIsScrubbing(true); - handleScrub(e); - }, - [duration, zoomLevel] - ); - - // Ruler mouse down handler - const handleRulerMouseDown = useCallback( - (e: React.MouseEvent) => { - // Only handle left mouse button - if (e.button !== 0) return; - - // Don't interfere if clicking on the playhead itself - if (playheadRef?.current?.contains(e.target as Node)) return; - - e.preventDefault(); - setIsDraggingRuler(true); - setHasDraggedRuler(false); - - // Start scrubbing immediately - setIsScrubbing(true); - handleScrub(e); - }, - [duration, zoomLevel] - ); - - const handleScrub = useCallback( - (e: MouseEvent | React.MouseEvent) => { - const ruler = rulerRef.current; - if (!ruler) return; - const rect = ruler.getBoundingClientRect(); - const x = e.clientX - rect.left; - const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel))); - // Use frame snapping for playhead scrubbing - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - const time = snapTimeToFrame(rawTime, projectFps); - setScrubTime(time); - seek(time); // update video preview in real time - }, - [duration, zoomLevel, seek, rulerRef] - ); - - // Mouse move/up event handlers - useEffect(() => { - if (!isScrubbing) return; - const onMouseMove = (e: MouseEvent) => { - handleScrub(e); - // Mark that we've dragged if ruler drag is active - if (isDraggingRuler) { - setHasDraggedRuler(true); - } - }; - const onMouseUp = (e: MouseEvent) => { - setIsScrubbing(false); - if (scrubTime !== null) seek(scrubTime); // finalize seek - setScrubTime(null); - - // Handle ruler click vs drag - if (isDraggingRuler) { - setIsDraggingRuler(false); - // If we didn't drag, treat it as a click-to-seek - if (!hasDraggedRuler) { - handleScrub(e); - } - setHasDraggedRuler(false); - } - }; - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - }; - }, [ - isScrubbing, - scrubTime, - seek, - handleScrub, - isDraggingRuler, - hasDraggedRuler, - ]); - - // --- Playhead auto-scroll effect --- - useEffect(() => { - const rulerViewport = rulerScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - const tracksViewport = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - if (!rulerViewport || !tracksViewport) return; - const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50 - const viewportWidth = rulerViewport.clientWidth; - const scrollMin = 0; - const scrollMax = rulerViewport.scrollWidth - viewportWidth; - // Center the playhead if it's not visible (100px buffer) - const desiredScroll = Math.max( - scrollMin, - Math.min(scrollMax, playheadPx - viewportWidth / 2) - ); - if ( - playheadPx < rulerViewport.scrollLeft + 100 || - playheadPx > rulerViewport.scrollLeft + viewportWidth - 100 - ) { - rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; - } - }, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]); - - return { - playheadPosition, - handlePlayheadMouseDown, - handleRulerMouseDown, - isDraggingRuler, - }; -} +import { snapTimeToFrame } from "@/constants/timeline-constants"; +import { useProjectStore } from "@/stores/project-store"; +import { useState, useEffect, useCallback } from "react"; + +interface UseTimelinePlayheadProps { + currentTime: number; + duration: number; + zoomLevel: number; + seek: (time: number) => void; + rulerRef: React.RefObject; + rulerScrollRef: React.RefObject; + tracksScrollRef: React.RefObject; + playheadRef?: React.RefObject; +} + +export function useTimelinePlayhead({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, +}: UseTimelinePlayheadProps) { + // Playhead scrubbing state + const [isScrubbing, setIsScrubbing] = useState(false); + const [scrubTime, setScrubTime] = useState(null); + + // Ruler drag detection state + const [isDraggingRuler, setIsDraggingRuler] = useState(false); + const [hasDraggedRuler, setHasDraggedRuler] = useState(false); + + const playheadPosition = + isScrubbing && scrubTime !== null ? scrubTime : currentTime; + + // --- Playhead Scrubbing Handlers --- + const handlePlayheadMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); // Prevent ruler drag from triggering + setIsScrubbing(true); + handleScrub(e); + }, + [duration, zoomLevel] + ); + + // Ruler mouse down handler + const handleRulerMouseDown = useCallback( + (e: React.MouseEvent) => { + // Only handle left mouse button + if (e.button !== 0) return; + + // Don't interfere if clicking on the playhead itself + if (playheadRef?.current?.contains(e.target as Node)) return; + + e.preventDefault(); + setIsDraggingRuler(true); + setHasDraggedRuler(false); + + // Start scrubbing immediately + setIsScrubbing(true); + handleScrub(e); + }, + [duration, zoomLevel] + ); + + const handleScrub = useCallback( + (e: MouseEvent | React.MouseEvent) => { + const ruler = rulerRef.current; + if (!ruler) return; + const rect = ruler.getBoundingClientRect(); + const x = e.clientX - rect.left; + const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel))); + // Use frame snapping for playhead scrubbing + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + const time = snapTimeToFrame(rawTime, projectFps); + setScrubTime(time); + seek(time); // update video preview in real time + }, + [duration, zoomLevel, seek, rulerRef] + ); + + // Mouse move/up event handlers + useEffect(() => { + if (!isScrubbing) return; + const onMouseMove = (e: MouseEvent) => { + handleScrub(e); + // Mark that we've dragged if ruler drag is active + if (isDraggingRuler) { + setHasDraggedRuler(true); + } + }; + const onMouseUp = (e: MouseEvent) => { + setIsScrubbing(false); + if (scrubTime !== null) seek(scrubTime); // finalize seek + setScrubTime(null); + + // Handle ruler click vs drag + if (isDraggingRuler) { + setIsDraggingRuler(false); + // If we didn't drag, treat it as a click-to-seek + if (!hasDraggedRuler) { + handleScrub(e); + } + setHasDraggedRuler(false); + } + }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, [ + isScrubbing, + scrubTime, + seek, + handleScrub, + isDraggingRuler, + hasDraggedRuler, + ]); + + // --- Playhead auto-scroll effect --- + useEffect(() => { + const rulerViewport = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + const tracksViewport = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + if (!rulerViewport || !tracksViewport) return; + const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50 + const viewportWidth = rulerViewport.clientWidth; + const scrollMin = 0; + const scrollMax = rulerViewport.scrollWidth - viewportWidth; + // Center the playhead if it's not visible (100px buffer) + const desiredScroll = Math.max( + scrollMin, + Math.min(scrollMax, playheadPx - viewportWidth / 2) + ); + if ( + playheadPx < rulerViewport.scrollLeft + 100 || + playheadPx > rulerViewport.scrollLeft + viewportWidth - 100 + ) { + rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; + } + }, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]); + + return { + playheadPosition, + handlePlayheadMouseDown, + handleRulerMouseDown, + isDraggingRuler, + }; +} diff --git a/apps/web/src/hooks/use-timeline-zoom.ts b/apps/web/src/hooks/use-timeline-zoom.ts index 4f5e0af0..e764c550 100644 --- a/apps/web/src/hooks/use-timeline-zoom.ts +++ b/apps/web/src/hooks/use-timeline-zoom.ts @@ -1,60 +1,60 @@ -import { useState, useCallback, useEffect, RefObject } from "react"; - -interface UseTimelineZoomProps { - containerRef: RefObject; - isInTimeline?: boolean; -} - -interface UseTimelineZoomReturn { - zoomLevel: number; - setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void; - handleWheel: (e: React.WheelEvent) => void; -} - -export function useTimelineZoom({ - containerRef, - isInTimeline = false, -}: UseTimelineZoomProps): UseTimelineZoomReturn { - const [zoomLevel, setZoomLevel] = useState(1); - - const handleWheel = useCallback((e: React.WheelEvent) => { - // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true) - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - const delta = e.deltaY > 0 ? -0.15 : 0.15; - setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta))); - } - // For horizontal scrolling (when shift is held or horizontal wheel movement), - // let the event bubble up to allow ScrollArea to handle it - else if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { - // Don't prevent default - let ScrollArea handle horizontal scrolling - return; - } - // Otherwise, allow normal scrolling - }, []); - - // Prevent browser zooming in/out when in timeline - useEffect(() => { - const preventZoom = (e: WheelEvent) => { - if ( - isInTimeline && - (e.ctrlKey || e.metaKey) && - containerRef.current?.contains(e.target as Node) - ) { - e.preventDefault(); - } - }; - - document.addEventListener("wheel", preventZoom, { passive: false }); - - return () => { - document.removeEventListener("wheel", preventZoom); - }; - }, [isInTimeline, containerRef]); - - return { - zoomLevel, - setZoomLevel, - handleWheel, - }; -} +import { useState, useCallback, useEffect, RefObject } from "react"; + +interface UseTimelineZoomProps { + containerRef: RefObject; + isInTimeline?: boolean; +} + +interface UseTimelineZoomReturn { + zoomLevel: number; + setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void; + handleWheel: (e: React.WheelEvent) => void; +} + +export function useTimelineZoom({ + containerRef, + isInTimeline = false, +}: UseTimelineZoomProps): UseTimelineZoomReturn { + const [zoomLevel, setZoomLevel] = useState(1); + + const handleWheel = useCallback((e: React.WheelEvent) => { + // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true) + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.15 : 0.15; + setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta))); + } + // For horizontal scrolling (when shift is held or horizontal wheel movement), + // let the event bubble up to allow ScrollArea to handle it + else if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + // Don't prevent default - let ScrollArea handle horizontal scrolling + return; + } + // Otherwise, allow normal scrolling + }, []); + + // Prevent browser zooming in/out when in timeline + useEffect(() => { + const preventZoom = (e: WheelEvent) => { + if ( + isInTimeline && + (e.ctrlKey || e.metaKey) && + containerRef.current?.contains(e.target as Node) + ) { + e.preventDefault(); + } + }; + + document.addEventListener("wheel", preventZoom, { passive: false }); + + return () => { + document.removeEventListener("wheel", preventZoom); + }; + }, [isInTimeline, containerRef]); + + return { + zoomLevel, + setZoomLevel, + handleWheel, + }; +} diff --git a/apps/web/src/hooks/use-toast.ts b/apps/web/src/hooks/use-toast.ts index c682cd1f..dcc02dcd 100644 --- a/apps/web/src/hooks/use-toast.ts +++ b/apps/web/src/hooks/use-toast.ts @@ -6,7 +6,7 @@ import * as React from "react"; import type { ToastActionElement, ToastProps } from "../components/ui/toast"; const TOAST_LIMIT = 1; -const TOAST_REMOVE_DELAY = 1000000; +const TOAST_REMOVE_DELAY = 1_000_000; type ToasterToast = ToastProps & { id: string; @@ -64,7 +64,7 @@ const addToRemoveQueue = (toastId: string) => { toastTimeouts.delete(toastId); dispatch({ type: "REMOVE_TOAST", - toastId: toastId, + toastId, }); }, TOAST_REMOVE_DELAY); @@ -162,7 +162,7 @@ function toast({ ...props }: Toast) { }); return { - id: id, + id, dismiss, update, }; -- cgit v1.3.1