From e1aadc9e1fd726d5c7dcd48f51284e21c9fd4c6c Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sat, 27 Dec 2025 12:19:12 +0100 Subject: [PATCH] feature: Create comprehensive custom hooks library (WIP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract 5 new custom hooks: * useAutoScroll - Auto-scroll element into view * useClickOutside - Detect outside clicks with exclusions * useMachinePolling - Dynamic machine status polling * useErrorPopoverState - Error popover state management * useBluetoothDeviceListener - Bluetooth device discovery - Reorganize all hooks into categorized folders: * utility/ - Generic reusable patterns * domain/ - Business logic for embroidery/patterns * ui/ - Library integration (Konva) * platform/ - Electron/Pyodide specific - Create barrel exports for clean imports (@/hooks) - Update components to use new hooks: * AppHeader uses useErrorPopoverState * ProgressMonitor uses useAutoScroll * FileUpload, PatternCanvas use barrel exports Part 1: Hooks extraction and reorganization Still TODO: Update remaining components, add tests, add documentation Related to #40 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/components/AppHeader.tsx | 85 ++------- src/components/FileUpload.tsx | 8 +- .../PatternCanvas/PatternCanvas.tsx | 3 +- src/components/ProgressMonitor.tsx | 13 +- src/hooks/domain/index.ts | 12 ++ src/hooks/domain/useErrorPopoverState.ts | 137 ++++++++++++++ src/hooks/domain/useMachinePolling.ts | 174 ++++++++++++++++++ .../{ => domain}/usePatternRotationUpload.ts | 0 .../{ => domain}/usePatternValidation.ts | 0 src/hooks/index.ts | 11 ++ src/hooks/platform/index.ts | 3 + .../platform/useBluetoothDeviceListener.ts | 90 +++++++++ src/hooks/{ => platform}/useFileUpload.ts | 0 src/hooks/ui/index.ts | 2 + src/hooks/{ => ui}/useCanvasViewport.ts | 0 src/hooks/{ => ui}/usePatternTransform.ts | 0 src/hooks/utility/index.ts | 5 + src/hooks/utility/useAutoScroll.ts | 51 +++++ src/hooks/utility/useClickOutside.ts | 89 +++++++++ src/hooks/{ => utility}/usePrevious.ts | 0 20 files changed, 594 insertions(+), 89 deletions(-) create mode 100644 src/hooks/domain/index.ts create mode 100644 src/hooks/domain/useErrorPopoverState.ts create mode 100644 src/hooks/domain/useMachinePolling.ts rename src/hooks/{ => domain}/usePatternRotationUpload.ts (100%) rename src/hooks/{ => domain}/usePatternValidation.ts (100%) create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/platform/index.ts create mode 100644 src/hooks/platform/useBluetoothDeviceListener.ts rename src/hooks/{ => platform}/useFileUpload.ts (100%) create mode 100644 src/hooks/ui/index.ts rename src/hooks/{ => ui}/useCanvasViewport.ts (100%) rename src/hooks/{ => ui}/usePatternTransform.ts (100%) create mode 100644 src/hooks/utility/index.ts create mode 100644 src/hooks/utility/useAutoScroll.ts create mode 100644 src/hooks/utility/useClickOutside.ts rename src/hooks/{ => utility}/usePrevious.ts (100%) diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index c100d06..51af7f1 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -1,8 +1,7 @@ -import { useState, useEffect } from "react"; import { useShallow } from "zustand/react/shallow"; import { useMachineStore } from "../stores/useMachineStore"; import { useUIStore } from "../stores/useUIStore"; -import { usePrevious } from "../hooks/usePrevious"; +import { useErrorPopoverState } from "@/hooks"; import { WorkflowStepper } from "./WorkflowStepper"; import { ErrorPopoverContent } from "./ErrorPopover"; import { @@ -61,17 +60,16 @@ export function AppHeader() { })), ); - // State management for error popover auto-open/close - const [errorPopoverOpen, setErrorPopoverOpen] = useState(false); - const [dismissedErrorCode, setDismissedErrorCode] = useState( - null, - ); - const [wasManuallyDismissed, setWasManuallyDismissed] = useState(false); - - // Track previous values for comparison - const prevMachineError = usePrevious(machineError); - const prevErrorMessage = usePrevious(machineErrorMessage); - const prevPyodideError = usePrevious(pyodideError); + // Error popover state management + const { + isOpen: errorPopoverOpen, + handleOpenChange: handlePopoverOpenChange, + } = useErrorPopoverState({ + machineError, + machineErrorMessage, + pyodideError, + hasError, + }); // Get state visual info for header status badge const stateVisual = getStateVisualInfo(machineStatus); @@ -90,67 +88,6 @@ export function AppHeader() { ? getStatusIndicatorState(machineStatus) : "idle"; - // Auto-open/close error popover based on error state changes - /* eslint-disable react-hooks/set-state-in-effect */ - useEffect(() => { - // Check if there's any error now - const hasAnyError = - machineErrorMessage || pyodideError || hasError(machineError); - // Check if there was any error before - const hadAnyError = - prevErrorMessage || prevPyodideError || hasError(prevMachineError); - - // Auto-open popover when new error appears (but not if user manually dismissed) - const isNewMachineError = - hasError(machineError) && - machineError !== prevMachineError && - machineError !== dismissedErrorCode; - const isNewErrorMessage = - machineErrorMessage && machineErrorMessage !== prevErrorMessage; - const isNewPyodideError = pyodideError && pyodideError !== prevPyodideError; - - if ( - !wasManuallyDismissed && - (isNewMachineError || isNewErrorMessage || isNewPyodideError) - ) { - setErrorPopoverOpen(true); - } - - // Auto-close popover when all errors are cleared - if (!hasAnyError && hadAnyError) { - setErrorPopoverOpen(false); - setDismissedErrorCode(null); // Reset dismissed tracking - setWasManuallyDismissed(false); // Reset manual dismissal flag - } - }, [ - machineError, - machineErrorMessage, - pyodideError, - dismissedErrorCode, - wasManuallyDismissed, - prevMachineError, - prevErrorMessage, - prevPyodideError, - ]); - /* eslint-enable react-hooks/set-state-in-effect */ - - // Handle manual popover dismiss - const handlePopoverOpenChange = (open: boolean) => { - setErrorPopoverOpen(open); - - // If user manually closes it while any error is present, remember this to prevent reopening - if ( - !open && - (hasError(machineError) || machineErrorMessage || pyodideError) - ) { - setWasManuallyDismissed(true); - // Also track the specific machine error code if present - if (hasError(machineError)) { - setDismissedErrorCode(machineError); - } - } - }; - return (
diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index 394df09..f89990d 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -10,9 +10,11 @@ import { canUploadPattern, getMachineStateCategory, } from "../utils/machineStateHelpers"; -import { useFileUpload } from "../hooks/useFileUpload"; -import { usePatternRotationUpload } from "../hooks/usePatternRotationUpload"; -import { usePatternValidation } from "../hooks/usePatternValidation"; +import { + useFileUpload, + usePatternRotationUpload, + usePatternValidation, +} from "@/hooks"; import { PatternInfoSkeleton } from "./SkeletonLoader"; import { PatternInfo } from "./PatternInfo"; import { diff --git a/src/components/PatternCanvas/PatternCanvas.tsx b/src/components/PatternCanvas/PatternCanvas.tsx index e7cda97..f791876 100644 --- a/src/components/PatternCanvas/PatternCanvas.tsx +++ b/src/components/PatternCanvas/PatternCanvas.tsx @@ -21,8 +21,7 @@ import { ThreadLegend } from "./ThreadLegend"; import { PatternPositionIndicator } from "./PatternPositionIndicator"; import { ZoomControls } from "./ZoomControls"; import { PatternLayer } from "./PatternLayer"; -import { useCanvasViewport } from "../../hooks/useCanvasViewport"; -import { usePatternTransform } from "../../hooks/usePatternTransform"; +import { useCanvasViewport, usePatternTransform } from "@/hooks"; export function PatternCanvas() { // Machine store diff --git a/src/components/ProgressMonitor.tsx b/src/components/ProgressMonitor.tsx index 26fea1f..bebb05a 100644 --- a/src/components/ProgressMonitor.tsx +++ b/src/components/ProgressMonitor.tsx @@ -1,4 +1,5 @@ -import { useRef, useEffect, useMemo } from "react"; +import { useMemo } from "react"; +import { useAutoScroll } from "@/hooks"; import { useShallow } from "zustand/react/shallow"; import { useMachineStore } from "../stores/useMachineStore"; import { usePatternStore } from "../stores/usePatternStore"; @@ -54,7 +55,6 @@ export function ProgressMonitor() { const pesData = usePatternStore((state) => state.pesData); const uploadedPesData = usePatternStore((state) => state.uploadedPesData); const displayPattern = uploadedPesData || pesData; - const currentBlockRef = useRef(null); // State indicators const isMaskTraceComplete = @@ -127,14 +127,7 @@ export function ProgressMonitor() { }, [colorBlocks, currentStitch]); // Auto-scroll to current block - useEffect(() => { - if (currentBlockRef.current) { - currentBlockRef.current.scrollIntoView({ - behavior: "smooth", - block: "nearest", - }); - } - }, [currentBlockIndex]); + const currentBlockRef = useAutoScroll(currentBlockIndex); return ( diff --git a/src/hooks/domain/index.ts b/src/hooks/domain/index.ts new file mode 100644 index 0000000..200ebd1 --- /dev/null +++ b/src/hooks/domain/index.ts @@ -0,0 +1,12 @@ +export { usePatternValidation } from "./usePatternValidation"; +export { usePatternRotationUpload } from "./usePatternRotationUpload"; +export { useMachinePolling } from "./useMachinePolling"; +export { useErrorPopoverState } from "./useErrorPopoverState"; +export type { + UseMachinePollingOptions, + UseMachinePollingReturn, +} from "./useMachinePolling"; +export type { + UseErrorPopoverStateOptions, + UseErrorPopoverStateReturn, +} from "./useErrorPopoverState"; diff --git a/src/hooks/domain/useErrorPopoverState.ts b/src/hooks/domain/useErrorPopoverState.ts new file mode 100644 index 0000000..7e836b7 --- /dev/null +++ b/src/hooks/domain/useErrorPopoverState.ts @@ -0,0 +1,137 @@ +/** + * useErrorPopoverState Hook + * + * Manages error popover state with sophisticated auto-open/close behavior. + * Automatically opens when new errors appear and closes when all errors are cleared. + * Tracks manual dismissal to prevent reopening for the same error. + * + * This hook is designed for multi-source error handling (e.g., machine errors, + * pyodide errors, error messages) and provides a consistent UX for error notification. + * + * @param options - Configuration options + * @param options.machineError - Current machine error code + * @param options.machineErrorMessage - Current machine error message + * @param options.pyodideError - Current Pyodide error message + * @param options.hasError - Function to check if an error code represents an error + * @returns Object containing popover state and control functions + * + * @example + * ```tsx + * const { isOpen, handleOpenChange } = useErrorPopoverState({ + * machineError, + * machineErrorMessage, + * pyodideError, + * hasError: (code) => code !== 0 && code !== undefined + * }); + * + * return ( + * + * {errorMessage} + * + * ); + * ``` + */ + +import { useState, useEffect } from "react"; +import { usePrevious } from "../utility/usePrevious"; + +export interface UseErrorPopoverStateOptions { + machineError: number | undefined; + machineErrorMessage: string | null; + pyodideError: string | null; + hasError: (error: number | undefined) => boolean; +} + +export interface UseErrorPopoverStateReturn { + isOpen: boolean; + handleOpenChange: (open: boolean) => void; + dismissedErrorCode: number | null; + wasManuallyDismissed: boolean; +} + +export function useErrorPopoverState( + options: UseErrorPopoverStateOptions, +): UseErrorPopoverStateReturn { + const { machineError, machineErrorMessage, pyodideError, hasError } = options; + + // Internal state + const [isOpen, setIsOpen] = useState(false); + const [dismissedErrorCode, setDismissedErrorCode] = useState( + null, + ); + const [wasManuallyDismissed, setWasManuallyDismissed] = useState(false); + + // Track previous values for comparison + const prevMachineError = usePrevious(machineError); + const prevErrorMessage = usePrevious(machineErrorMessage); + const prevPyodideError = usePrevious(pyodideError); + + // Auto-open/close logic + /* eslint-disable react-hooks/set-state-in-effect */ + useEffect(() => { + // Check if there's any error now + const hasAnyError = + machineErrorMessage || pyodideError || hasError(machineError); + // Check if there was any error before + const hadAnyError = + prevErrorMessage || prevPyodideError || hasError(prevMachineError); + + // Auto-open popover when new error appears (but not if user manually dismissed) + const isNewMachineError = + hasError(machineError) && + machineError !== prevMachineError && + machineError !== dismissedErrorCode; + const isNewErrorMessage = + machineErrorMessage && machineErrorMessage !== prevErrorMessage; + const isNewPyodideError = pyodideError && pyodideError !== prevPyodideError; + + if ( + !wasManuallyDismissed && + (isNewMachineError || isNewErrorMessage || isNewPyodideError) + ) { + setIsOpen(true); + } + + // Auto-close popover when all errors are cleared + if (!hasAnyError && hadAnyError) { + setIsOpen(false); + setDismissedErrorCode(null); // Reset dismissed tracking + setWasManuallyDismissed(false); // Reset manual dismissal flag + } + }, [ + machineError, + machineErrorMessage, + pyodideError, + dismissedErrorCode, + wasManuallyDismissed, + prevMachineError, + prevErrorMessage, + prevPyodideError, + hasError, + ]); + /* eslint-enable react-hooks/set-state-in-effect */ + + // Handle manual popover dismiss + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + + // If user manually closes it while any error is present, remember this to prevent reopening + if ( + !open && + (hasError(machineError) || machineErrorMessage || pyodideError) + ) { + setWasManuallyDismissed(true); + // Also track the specific machine error code if present + if (hasError(machineError)) { + setDismissedErrorCode(machineError); + } + } + }; + + return { + isOpen, + handleOpenChange, + dismissedErrorCode, + wasManuallyDismissed, + }; +} diff --git a/src/hooks/domain/useMachinePolling.ts b/src/hooks/domain/useMachinePolling.ts new file mode 100644 index 0000000..477b9ba --- /dev/null +++ b/src/hooks/domain/useMachinePolling.ts @@ -0,0 +1,174 @@ +/** + * useMachinePolling Hook + * + * Implements dynamic polling for machine status based on machine state. + * Uses adaptive polling intervals and conditional progress polling during sewing. + * + * Polling intervals: + * - 500ms for active states (SEWING, MASK_TRACING, SEWING_DATA_RECEIVE) + * - 1000ms for waiting states (COLOR_CHANGE_WAIT, MASK_TRACE_LOCK_WAIT, SEWING_WAIT) + * - 2000ms for idle/other states + * + * Additionally polls service count every 10 seconds. + * + * @param options - Configuration options + * @param options.machineStatus - Current machine status to determine polling interval + * @param options.patternInfo - Current pattern info for resumable pattern check + * @param options.onStatusRefresh - Callback to refresh machine status + * @param options.onProgressRefresh - Callback to refresh sewing progress + * @param options.onServiceCountRefresh - Callback to refresh service count + * @param options.onPatternInfoRefresh - Callback to refresh pattern info + * @param options.shouldCheckResumablePattern - Function to check if resumable pattern exists + * @returns Object containing start/stop functions and polling state + * + * @example + * ```tsx + * const { startPolling, stopPolling, isPolling } = useMachinePolling({ + * machineStatus, + * patternInfo, + * onStatusRefresh: async () => { ... }, + * onProgressRefresh: async () => { ... }, + * onServiceCountRefresh: async () => { ... }, + * onPatternInfoRefresh: async () => { ... }, + * shouldCheckResumablePattern: () => resumeAvailable + * }); + * + * useEffect(() => { + * startPolling(); + * return () => stopPolling(); + * }, []); + * ``` + */ + +import { useState, useCallback, useRef, useEffect } from "react"; +import { MachineStatus } from "../../types/machine"; +import type { PatternInfo } from "../../types/machine"; + +export interface UseMachinePollingOptions { + machineStatus: MachineStatus; + patternInfo: PatternInfo | null; + onStatusRefresh: () => Promise; + onProgressRefresh: () => Promise; + onServiceCountRefresh: () => Promise; + onPatternInfoRefresh: () => Promise; + shouldCheckResumablePattern: () => boolean; +} + +export interface UseMachinePollingReturn { + startPolling: () => void; + stopPolling: () => void; + isPolling: boolean; +} + +export function useMachinePolling( + options: UseMachinePollingOptions, +): UseMachinePollingReturn { + const { + machineStatus, + patternInfo, + onStatusRefresh, + onProgressRefresh, + onServiceCountRefresh, + onPatternInfoRefresh, + shouldCheckResumablePattern, + } = options; + + const [isPolling, setIsPolling] = useState(false); + const pollTimeoutRef = useRef(null); + const serviceCountIntervalRef = useRef(null); + const pollFunctionRef = useRef<() => Promise>(); + + // Function to determine polling interval based on machine status + const getPollInterval = useCallback((status: MachineStatus) => { + // Fast polling for active states + if ( + status === MachineStatus.SEWING || + status === MachineStatus.MASK_TRACING || + status === MachineStatus.SEWING_DATA_RECEIVE + ) { + return 500; + } else if ( + status === MachineStatus.COLOR_CHANGE_WAIT || + status === MachineStatus.MASK_TRACE_LOCK_WAIT || + status === MachineStatus.SEWING_WAIT + ) { + return 1000; + } + return 2000; // Default for idle states + }, []); + + // Main polling function + const poll = useCallback(async () => { + await onStatusRefresh(); + + // Refresh progress during sewing + if (machineStatus === MachineStatus.SEWING) { + await onProgressRefresh(); + } + + // Check if we have a cached pattern and pattern info needs refreshing + // This follows the app's logic for resumable patterns + if (shouldCheckResumablePattern() && patternInfo?.totalStitches === 0) { + await onPatternInfoRefresh(); + } + + // Schedule next poll with updated interval + const newInterval = getPollInterval(machineStatus); + if (pollFunctionRef.current) { + pollTimeoutRef.current = setTimeout(pollFunctionRef.current, newInterval); + } + }, [ + machineStatus, + patternInfo, + onStatusRefresh, + onProgressRefresh, + onPatternInfoRefresh, + shouldCheckResumablePattern, + getPollInterval, + ]); + + // Store poll function in ref for recursive setTimeout + useEffect(() => { + pollFunctionRef.current = poll; + }, [poll]); + + const stopPolling = useCallback(() => { + if (pollTimeoutRef.current) { + clearTimeout(pollTimeoutRef.current); + pollTimeoutRef.current = null; + } + + if (serviceCountIntervalRef.current) { + clearInterval(serviceCountIntervalRef.current); + serviceCountIntervalRef.current = null; + } + + setIsPolling(false); + }, []); + + const startPolling = useCallback(() => { + // Stop any existing polling + stopPolling(); + + // Start main polling + const initialInterval = getPollInterval(machineStatus); + pollTimeoutRef.current = setTimeout(poll, initialInterval); + + // Start service count polling (every 10 seconds) + serviceCountIntervalRef.current = setInterval(onServiceCountRefresh, 10000); + + setIsPolling(true); + }, [ + machineStatus, + poll, + stopPolling, + getPollInterval, + onServiceCountRefresh, + ]); + + return { + startPolling, + stopPolling, + isPolling, + }; +} diff --git a/src/hooks/usePatternRotationUpload.ts b/src/hooks/domain/usePatternRotationUpload.ts similarity index 100% rename from src/hooks/usePatternRotationUpload.ts rename to src/hooks/domain/usePatternRotationUpload.ts diff --git a/src/hooks/usePatternValidation.ts b/src/hooks/domain/usePatternValidation.ts similarity index 100% rename from src/hooks/usePatternValidation.ts rename to src/hooks/domain/usePatternValidation.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..7226d00 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,11 @@ +// Utility Hooks - Generic, reusable patterns +export * from "./utility"; + +// Domain Hooks - Business logic for embroidery/pattern operations +export * from "./domain"; + +// UI Hooks - Library/framework integration (Konva, etc.) +export * from "./ui"; + +// Platform Hooks - Electron/Pyodide specific functionality +export * from "./platform"; diff --git a/src/hooks/platform/index.ts b/src/hooks/platform/index.ts new file mode 100644 index 0000000..426914c --- /dev/null +++ b/src/hooks/platform/index.ts @@ -0,0 +1,3 @@ +export { useFileUpload } from "./useFileUpload"; +export { useBluetoothDeviceListener } from "./useBluetoothDeviceListener"; +export type { UseBluetoothDeviceListenerReturn } from "./useBluetoothDeviceListener"; diff --git a/src/hooks/platform/useBluetoothDeviceListener.ts b/src/hooks/platform/useBluetoothDeviceListener.ts new file mode 100644 index 0000000..850cadf --- /dev/null +++ b/src/hooks/platform/useBluetoothDeviceListener.ts @@ -0,0 +1,90 @@ +/** + * useBluetoothDeviceListener Hook + * + * Listens for Bluetooth device discovery events from Electron IPC. + * Automatically manages device list state and provides platform detection. + * + * This hook is Electron-specific and will gracefully handle browser environments + * by returning empty state. + * + * @param onDevicesChanged - Optional callback when device list changes + * @returns Object containing devices array, scanning state, and platform support flag + * + * @example + * ```tsx + * const { devices, isScanning, isSupported } = useBluetoothDeviceListener( + * (devices) => { + * if (devices.length > 0) { + * console.log('Devices found:', devices); + * } + * } + * ); + * + * if (!isSupported) { + * return
Bluetooth pairing only available in Electron app
; + * } + * + * return ( + *
+ * {isScanning &&

Scanning...

} + * {devices.map(device =>
{device.name}
)} + *
+ * ); + * ``` + */ + +import { useEffect, useState } from "react"; +import type { BluetoothDevice } from "../../types/electron"; + +export interface UseBluetoothDeviceListenerReturn { + devices: BluetoothDevice[]; + isScanning: boolean; + isSupported: boolean; +} + +export function useBluetoothDeviceListener( + onDevicesChanged?: (devices: BluetoothDevice[]) => void, +): UseBluetoothDeviceListenerReturn { + const [devices, setDevices] = useState([]); + const [isScanning, setIsScanning] = useState(false); + + // Check if Electron API is available + const isSupported = + typeof window !== "undefined" && + !!window.electronAPI?.onBluetoothDeviceList; + + useEffect(() => { + // Only set up listener in Electron + if (!isSupported) { + return; + } + + const handleDeviceList = (deviceList: BluetoothDevice[]) => { + setDevices(deviceList); + + // Start scanning when first update received + if (deviceList.length === 0) { + setIsScanning(true); + } else { + // Stop showing scanning state once we have devices + setIsScanning(false); + } + + // Call optional callback + onDevicesChanged?.(deviceList); + }; + + // Register listener + window.electronAPI!.onBluetoothDeviceList(handleDeviceList); + + // Note: Electron IPC listeners are typically not cleaned up individually + // as they're meant to persist. If cleanup is needed, the Electron main + // process should handle it. + }, [isSupported, onDevicesChanged]); + + return { + devices, + isScanning, + isSupported, + }; +} diff --git a/src/hooks/useFileUpload.ts b/src/hooks/platform/useFileUpload.ts similarity index 100% rename from src/hooks/useFileUpload.ts rename to src/hooks/platform/useFileUpload.ts diff --git a/src/hooks/ui/index.ts b/src/hooks/ui/index.ts new file mode 100644 index 0000000..91bc173 --- /dev/null +++ b/src/hooks/ui/index.ts @@ -0,0 +1,2 @@ +export { useCanvasViewport } from "./useCanvasViewport"; +export { usePatternTransform } from "./usePatternTransform"; diff --git a/src/hooks/useCanvasViewport.ts b/src/hooks/ui/useCanvasViewport.ts similarity index 100% rename from src/hooks/useCanvasViewport.ts rename to src/hooks/ui/useCanvasViewport.ts diff --git a/src/hooks/usePatternTransform.ts b/src/hooks/ui/usePatternTransform.ts similarity index 100% rename from src/hooks/usePatternTransform.ts rename to src/hooks/ui/usePatternTransform.ts diff --git a/src/hooks/utility/index.ts b/src/hooks/utility/index.ts new file mode 100644 index 0000000..a8e2ede --- /dev/null +++ b/src/hooks/utility/index.ts @@ -0,0 +1,5 @@ +export { usePrevious } from "./usePrevious"; +export { useAutoScroll } from "./useAutoScroll"; +export { useClickOutside } from "./useClickOutside"; +export type { UseAutoScrollOptions } from "./useAutoScroll"; +export type { UseClickOutsideOptions } from "./useClickOutside"; diff --git a/src/hooks/utility/useAutoScroll.ts b/src/hooks/utility/useAutoScroll.ts new file mode 100644 index 0000000..41a9946 --- /dev/null +++ b/src/hooks/utility/useAutoScroll.ts @@ -0,0 +1,51 @@ +/** + * useAutoScroll Hook + * + * Automatically scrolls an element into view when a dependency changes. + * Useful for keeping the current item visible in scrollable lists. + * + * @param dependency - The value to watch for changes (e.g., current index) + * @param options - Scroll behavior options + * @returns RefObject to attach to the element that should be scrolled into view + * + * @example + * ```tsx + * const currentItemRef = useAutoScroll(currentIndex, { + * behavior: "smooth", + * block: "nearest" + * }); + * + * return ( + *
+ * Current Item + *
+ * ); + * ``` + */ + +import { useEffect, useRef, type RefObject } from "react"; + +export interface UseAutoScrollOptions { + behavior?: ScrollBehavior; + block?: ScrollLogicalPosition; + inline?: ScrollLogicalPosition; +} + +export function useAutoScroll( + dependency: unknown, + options?: UseAutoScrollOptions, +): RefObject { + const ref = useRef(null); + + useEffect(() => { + if (ref.current) { + ref.current.scrollIntoView({ + behavior: options?.behavior || "smooth", + block: options?.block || "nearest", + inline: options?.inline, + }); + } + }, [dependency, options?.behavior, options?.block, options?.inline]); + + return ref; +} diff --git a/src/hooks/utility/useClickOutside.ts b/src/hooks/utility/useClickOutside.ts new file mode 100644 index 0000000..f1a234b --- /dev/null +++ b/src/hooks/utility/useClickOutside.ts @@ -0,0 +1,89 @@ +/** + * useClickOutside Hook + * + * Detects clicks outside a referenced element and executes a handler function. + * Useful for closing dropdown menus, popovers, modals, and other overlay UI elements. + * + * @param ref - Reference to the element to monitor for outside clicks + * @param handler - Callback function to execute when outside click is detected + * @param options - Configuration options + * @param options.enabled - Whether the listener is active (default: true) + * @param options.excludeRefs - Array of refs that should not trigger the handler when clicked + * + * @example + * ```tsx + * const dropdownRef = useRef(null); + * const buttonRef = useRef(null); + * + * useClickOutside( + * dropdownRef, + * () => setIsOpen(false), + * { + * enabled: isOpen, + * excludeRefs: [buttonRef] // Don't close when clicking the button + * } + * ); + * + * return ( + * <> + * + * {isOpen &&
Content
} + * + * ); + * ``` + */ + +import { useEffect, type RefObject } from "react"; + +export interface UseClickOutsideOptions { + enabled?: boolean; + excludeRefs?: ( + | RefObject + | { current: Record } + )[]; +} + +export function useClickOutside( + ref: RefObject, + handler: (event: MouseEvent) => void, + options?: UseClickOutsideOptions, +): void { + const { enabled = true, excludeRefs = [] } = options || {}; + + useEffect(() => { + if (!enabled) return; + + const handleClickOutside = (event: MouseEvent) => { + // Check if click is outside the main ref + if (ref.current && !ref.current.contains(event.target as Node)) { + // Check if click is on any excluded refs + const clickedExcluded = excludeRefs.some((excludeRef) => { + if (!excludeRef.current) return false; + + // Handle object of refs (e.g., { [key: number]: HTMLElement | null }) + if ( + typeof excludeRef.current === "object" && + !("nodeType" in excludeRef.current) + ) { + return Object.values(excludeRef.current).some((element) => + element?.contains(event.target as Node), + ); + } + + // Handle single ref + return (excludeRef.current as HTMLElement).contains( + event.target as Node, + ); + }); + + // Only call handler if click was not on excluded elements + if (!clickedExcluded) { + handler(event); + } + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [ref, handler, enabled, excludeRefs]); +} diff --git a/src/hooks/usePrevious.ts b/src/hooks/utility/usePrevious.ts similarity index 100% rename from src/hooks/usePrevious.ts rename to src/hooks/utility/usePrevious.ts