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 { useShallow } from "zustand/react/shallow";
|
||||||
import { useMachineStore } from "../stores/useMachineStore";
|
import { useMachineStore } from "../stores/useMachineStore";
|
||||||
import { useUIStore } from "../stores/useUIStore";
|
import { useUIStore } from "../stores/useUIStore";
|
||||||
import { usePrevious } from "../hooks/usePrevious";
|
import { useErrorPopoverState } from "@/hooks";
|
||||||
import { WorkflowStepper } from "./WorkflowStepper";
|
import { WorkflowStepper } from "./WorkflowStepper";
|
||||||
import { ErrorPopoverContent } from "./ErrorPopover";
|
import { ErrorPopoverContent } from "./ErrorPopover";
|
||||||
import {
|
import {
|
||||||
|
|
@ -61,17 +60,16 @@ export function AppHeader() {
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
// State management for error popover auto-open/close
|
// Error popover state management
|
||||||
const [errorPopoverOpen, setErrorPopoverOpen] = useState(false);
|
const {
|
||||||
const [dismissedErrorCode, setDismissedErrorCode] = useState<number | null>(
|
isOpen: errorPopoverOpen,
|
||||||
null,
|
handleOpenChange: handlePopoverOpenChange,
|
||||||
);
|
} = useErrorPopoverState({
|
||||||
const [wasManuallyDismissed, setWasManuallyDismissed] = useState(false);
|
machineError,
|
||||||
|
machineErrorMessage,
|
||||||
// Track previous values for comparison
|
pyodideError,
|
||||||
const prevMachineError = usePrevious(machineError);
|
hasError,
|
||||||
const prevErrorMessage = usePrevious(machineErrorMessage);
|
});
|
||||||
const prevPyodideError = usePrevious(pyodideError);
|
|
||||||
|
|
||||||
// Get state visual info for header status badge
|
// Get state visual info for header status badge
|
||||||
const stateVisual = getStateVisualInfo(machineStatus);
|
const stateVisual = getStateVisualInfo(machineStatus);
|
||||||
|
|
@ -90,67 +88,6 @@ export function AppHeader() {
|
||||||
? getStatusIndicatorState(machineStatus)
|
? getStatusIndicatorState(machineStatus)
|
||||||
: "idle";
|
: "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 (
|
return (
|
||||||
<TooltipProvider>
|
<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">
|
<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,
|
canUploadPattern,
|
||||||
getMachineStateCategory,
|
getMachineStateCategory,
|
||||||
} from "../utils/machineStateHelpers";
|
} from "../utils/machineStateHelpers";
|
||||||
import { useFileUpload } from "../hooks/useFileUpload";
|
import {
|
||||||
import { usePatternRotationUpload } from "../hooks/usePatternRotationUpload";
|
useFileUpload,
|
||||||
import { usePatternValidation } from "../hooks/usePatternValidation";
|
usePatternRotationUpload,
|
||||||
|
usePatternValidation,
|
||||||
|
} from "@/hooks";
|
||||||
import { PatternInfoSkeleton } from "./SkeletonLoader";
|
import { PatternInfoSkeleton } from "./SkeletonLoader";
|
||||||
import { PatternInfo } from "./PatternInfo";
|
import { PatternInfo } from "./PatternInfo";
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,7 @@ import { ThreadLegend } from "./ThreadLegend";
|
||||||
import { PatternPositionIndicator } from "./PatternPositionIndicator";
|
import { PatternPositionIndicator } from "./PatternPositionIndicator";
|
||||||
import { ZoomControls } from "./ZoomControls";
|
import { ZoomControls } from "./ZoomControls";
|
||||||
import { PatternLayer } from "./PatternLayer";
|
import { PatternLayer } from "./PatternLayer";
|
||||||
import { useCanvasViewport } from "../../hooks/useCanvasViewport";
|
import { useCanvasViewport, usePatternTransform } from "@/hooks";
|
||||||
import { usePatternTransform } from "../../hooks/usePatternTransform";
|
|
||||||
|
|
||||||
export function PatternCanvas() {
|
export function PatternCanvas() {
|
||||||
// Machine store
|
// 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 { useShallow } from "zustand/react/shallow";
|
||||||
import { useMachineStore } from "../stores/useMachineStore";
|
import { useMachineStore } from "../stores/useMachineStore";
|
||||||
import { usePatternStore } from "../stores/usePatternStore";
|
import { usePatternStore } from "../stores/usePatternStore";
|
||||||
|
|
@ -54,7 +55,6 @@ export function ProgressMonitor() {
|
||||||
const pesData = usePatternStore((state) => state.pesData);
|
const pesData = usePatternStore((state) => state.pesData);
|
||||||
const uploadedPesData = usePatternStore((state) => state.uploadedPesData);
|
const uploadedPesData = usePatternStore((state) => state.uploadedPesData);
|
||||||
const displayPattern = uploadedPesData || pesData;
|
const displayPattern = uploadedPesData || pesData;
|
||||||
const currentBlockRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// State indicators
|
// State indicators
|
||||||
const isMaskTraceComplete =
|
const isMaskTraceComplete =
|
||||||
|
|
@ -127,14 +127,7 @@ export function ProgressMonitor() {
|
||||||
}, [colorBlocks, currentStitch]);
|
}, [colorBlocks, currentStitch]);
|
||||||
|
|
||||||
// Auto-scroll to current block
|
// Auto-scroll to current block
|
||||||
useEffect(() => {
|
const currentBlockRef = useAutoScroll(currentBlockIndex);
|
||||||
if (currentBlockRef.current) {
|
|
||||||
currentBlockRef.current.scrollIntoView({
|
|
||||||
behavior: "smooth",
|
|
||||||
block: "nearest",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [currentBlockIndex]);
|
|
||||||
|
|
||||||
return (
|
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">
|
<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