mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 02:13:41 +00:00
feature: Create comprehensive custom hooks library (WIP)
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
2372278081
commit
e1aadc9e1f
20 changed files with 594 additions and 89 deletions
|
|
@ -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<number | null>(
|
||||
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 (
|
||||
<TooltipProvider>
|
||||
<header className="bg-gradient-to-r from-primary-600 via-primary-700 to-primary-800 dark:from-primary-700 dark:via-primary-800 dark:to-primary-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-primary-900/20 dark:border-primary-800/30 flex-shrink-0">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<Card className="p-0 gap-0 lg:h-full border-l-4 border-accent-600 dark:border-accent-500 flex flex-col lg:overflow-hidden">
|
||||
|
|
|
|||
12
src/hooks/domain/index.ts
Normal file
12
src/hooks/domain/index.ts
Normal file
|
|
@ -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";
|
||||
137
src/hooks/domain/useErrorPopoverState.ts
Normal file
137
src/hooks/domain/useErrorPopoverState.ts
Normal file
|
|
@ -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 (
|
||||
* <Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||
* <PopoverContent>{errorMessage}</PopoverContent>
|
||||
* </Popover>
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
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<number | null>(
|
||||
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,
|
||||
};
|
||||
}
|
||||
174
src/hooks/domain/useMachinePolling.ts
Normal file
174
src/hooks/domain/useMachinePolling.ts
Normal file
|
|
@ -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<void>;
|
||||
onProgressRefresh: () => Promise<void>;
|
||||
onServiceCountRefresh: () => Promise<void>;
|
||||
onPatternInfoRefresh: () => Promise<void>;
|
||||
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<NodeJS.Timeout | null>(null);
|
||||
const serviceCountIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const pollFunctionRef = useRef<() => Promise<void>>();
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
11
src/hooks/index.ts
Normal file
11
src/hooks/index.ts
Normal file
|
|
@ -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";
|
||||
3
src/hooks/platform/index.ts
Normal file
3
src/hooks/platform/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { useFileUpload } from "./useFileUpload";
|
||||
export { useBluetoothDeviceListener } from "./useBluetoothDeviceListener";
|
||||
export type { UseBluetoothDeviceListenerReturn } from "./useBluetoothDeviceListener";
|
||||
90
src/hooks/platform/useBluetoothDeviceListener.ts
Normal file
90
src/hooks/platform/useBluetoothDeviceListener.ts
Normal file
|
|
@ -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 <div>Bluetooth pairing only available in Electron app</div>;
|
||||
* }
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {isScanning && <p>Scanning...</p>}
|
||||
* {devices.map(device => <div key={device.id}>{device.name}</div>)}
|
||||
* </div>
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
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<BluetoothDevice[]>([]);
|
||||
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,
|
||||
};
|
||||
}
|
||||
2
src/hooks/ui/index.ts
Normal file
2
src/hooks/ui/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { useCanvasViewport } from "./useCanvasViewport";
|
||||
export { usePatternTransform } from "./usePatternTransform";
|
||||
5
src/hooks/utility/index.ts
Normal file
5
src/hooks/utility/index.ts
Normal file
|
|
@ -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";
|
||||
51
src/hooks/utility/useAutoScroll.ts
Normal file
51
src/hooks/utility/useAutoScroll.ts
Normal file
|
|
@ -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 (
|
||||
* <div ref={isCurrent ? currentItemRef : null}>
|
||||
* Current Item
|
||||
* </div>
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
|
||||
export interface UseAutoScrollOptions {
|
||||
behavior?: ScrollBehavior;
|
||||
block?: ScrollLogicalPosition;
|
||||
inline?: ScrollLogicalPosition;
|
||||
}
|
||||
|
||||
export function useAutoScroll<T extends HTMLElement>(
|
||||
dependency: unknown,
|
||||
options?: UseAutoScrollOptions,
|
||||
): RefObject<T> {
|
||||
const ref = useRef<T>(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;
|
||||
}
|
||||
89
src/hooks/utility/useClickOutside.ts
Normal file
89
src/hooks/utility/useClickOutside.ts
Normal file
|
|
@ -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<HTMLDivElement>(null);
|
||||
* const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
*
|
||||
* useClickOutside(
|
||||
* dropdownRef,
|
||||
* () => setIsOpen(false),
|
||||
* {
|
||||
* enabled: isOpen,
|
||||
* excludeRefs: [buttonRef] // Don't close when clicking the button
|
||||
* }
|
||||
* );
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <button ref={buttonRef}>Toggle</button>
|
||||
* {isOpen && <div ref={dropdownRef}>Content</div>}
|
||||
* </>
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useEffect, type RefObject } from "react";
|
||||
|
||||
export interface UseClickOutsideOptions {
|
||||
enabled?: boolean;
|
||||
excludeRefs?: (
|
||||
| RefObject<HTMLElement>
|
||||
| { current: Record<string, HTMLElement | null> }
|
||||
)[];
|
||||
}
|
||||
|
||||
export function useClickOutside<T extends HTMLElement>(
|
||||
ref: RefObject<T>,
|
||||
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]);
|
||||
}
|
||||
Loading…
Reference in a new issue