Compare commits

..

No commits in common. "2372278081bd6e3c3ceeec67f0305e88e5a3d05d" and "91bc0285e0367bb11f9dbcddf6e217d3366fcea3" have entirely different histories.

6 changed files with 232 additions and 425 deletions

View file

@ -1,8 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } 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 { WorkflowStepper } from "./WorkflowStepper"; import { WorkflowStepper } from "./WorkflowStepper";
import { ErrorPopoverContent } from "./ErrorPopover"; import { ErrorPopoverContent } from "./ErrorPopover";
import { import {
@ -66,12 +65,9 @@ export function AppHeader() {
const [dismissedErrorCode, setDismissedErrorCode] = useState<number | null>( const [dismissedErrorCode, setDismissedErrorCode] = useState<number | null>(
null, null,
); );
const [wasManuallyDismissed, setWasManuallyDismissed] = useState(false); const prevMachineErrorRef = useRef<number | undefined>(undefined);
const prevErrorMessageRef = useRef<string | null>(null);
// Track previous values for comparison const prevPyodideErrorRef = useRef<string | null>(null);
const prevMachineError = usePrevious(machineError);
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);
@ -93,26 +89,31 @@ export function AppHeader() {
// Auto-open/close error popover based on error state changes // Auto-open/close error popover based on error state changes
/* eslint-disable react-hooks/set-state-in-effect */ /* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => { useEffect(() => {
const currentError = machineError;
const prevError = prevMachineErrorRef.current;
const currentErrorMessage = machineErrorMessage;
const prevErrorMessage = prevErrorMessageRef.current;
const currentPyodideError = pyodideError;
const prevPyodideError = prevPyodideErrorRef.current;
// Check if there's any error now // Check if there's any error now
const hasAnyError = const hasAnyError =
machineErrorMessage || pyodideError || hasError(machineError); machineErrorMessage || pyodideError || hasError(currentError);
// Check if there was any error before // Check if there was any error before
const hadAnyError = const hadAnyError =
prevErrorMessage || prevPyodideError || hasError(prevMachineError); prevErrorMessage || prevPyodideError || hasError(prevError);
// Auto-open popover when new error appears (but not if user manually dismissed) // Auto-open popover when new error appears
const isNewMachineError = const isNewMachineError =
hasError(machineError) && hasError(currentError) &&
machineError !== prevMachineError && currentError !== prevError &&
machineError !== dismissedErrorCode; currentError !== dismissedErrorCode;
const isNewErrorMessage = const isNewErrorMessage =
machineErrorMessage && machineErrorMessage !== prevErrorMessage; currentErrorMessage && currentErrorMessage !== prevErrorMessage;
const isNewPyodideError = pyodideError && pyodideError !== prevPyodideError; const isNewPyodideError =
currentPyodideError && currentPyodideError !== prevPyodideError;
if ( if (isNewMachineError || isNewErrorMessage || isNewPyodideError) {
!wasManuallyDismissed &&
(isNewMachineError || isNewErrorMessage || isNewPyodideError)
) {
setErrorPopoverOpen(true); setErrorPopoverOpen(true);
} }
@ -120,34 +121,28 @@ export function AppHeader() {
if (!hasAnyError && hadAnyError) { if (!hasAnyError && hadAnyError) {
setErrorPopoverOpen(false); setErrorPopoverOpen(false);
setDismissedErrorCode(null); // Reset dismissed tracking setDismissedErrorCode(null); // Reset dismissed tracking
setWasManuallyDismissed(false); // Reset manual dismissal flag
} }
}, [
machineError, // Update refs for next comparison
machineErrorMessage, prevMachineErrorRef.current = currentError;
pyodideError, prevErrorMessageRef.current = currentErrorMessage;
dismissedErrorCode, prevPyodideErrorRef.current = currentPyodideError;
wasManuallyDismissed, }, [machineError, machineErrorMessage, pyodideError, dismissedErrorCode]);
prevMachineError,
prevErrorMessage,
prevPyodideError,
]);
/* eslint-enable react-hooks/set-state-in-effect */ /* eslint-enable react-hooks/set-state-in-effect */
// Handle manual popover dismiss // Handle manual popover dismiss
const handlePopoverOpenChange = (open: boolean) => { const handlePopoverOpenChange = (open: boolean) => {
setErrorPopoverOpen(open); setErrorPopoverOpen(open);
// If user manually closes it while any error is present, remember this to prevent reopening // If user manually closes it, remember the current error state to prevent reopening
if ( if (!open) {
!open && // For machine errors, track the error code
(hasError(machineError) || machineErrorMessage || pyodideError)
) {
setWasManuallyDismissed(true);
// Also track the specific machine error code if present
if (hasError(machineError)) { if (hasError(machineError)) {
setDismissedErrorCode(machineError); setDismissedErrorCode(machineError);
} }
// Update refs so we don't reopen for the same error message/pyodide error
prevErrorMessageRef.current = machineErrorMessage;
prevPyodideErrorRef.current = pyodideError;
} }
}; };

View file

@ -5,14 +5,24 @@ import { useMachineUploadStore } from "../stores/useMachineUploadStore";
import { useMachineCacheStore } from "../stores/useMachineCacheStore"; import { useMachineCacheStore } from "../stores/useMachineCacheStore";
import { usePatternStore } from "../stores/usePatternStore"; import { usePatternStore } from "../stores/usePatternStore";
import { useUIStore } from "../stores/useUIStore"; import { useUIStore } from "../stores/useUIStore";
import type { PesPatternData } from "../formats/import/pesImporter"; import {
convertPesToPen,
type PesPatternData,
} from "../formats/import/pesImporter";
import { import {
canUploadPattern, canUploadPattern,
getMachineStateCategory, getMachineStateCategory,
} from "../utils/machineStateHelpers"; } from "../utils/machineStateHelpers";
import { useFileUpload } from "../hooks/useFileUpload"; import {
import { usePatternRotationUpload } from "../hooks/usePatternRotationUpload"; transformStitchesRotation,
import { usePatternValidation } from "../hooks/usePatternValidation"; calculateRotatedBounds,
} from "../utils/rotationUtils";
import { encodeStitchesToPen } from "../formats/pen/encoder";
import { decodePenData } from "../formats/pen/decoder";
import {
calculatePatternCenter,
calculateBoundsFromDecodedStitches,
} from "./PatternCanvas/patternCanvasHelpers";
import { PatternInfoSkeleton } from "./SkeletonLoader"; import { PatternInfoSkeleton } from "./SkeletonLoader";
import { PatternInfo } from "./PatternInfo"; import { PatternInfo } from "./PatternInfo";
import { import {
@ -101,53 +111,207 @@ export function FileUpload() {
const pesData = pesDataProp || localPesData; const pesData = pesDataProp || localPesData;
// Use currentFileName from App state, or local fileName, or resumeFileName for display // Use currentFileName from App state, or local fileName, or resumeFileName for display
const displayFileName = currentFileName || fileName || resumeFileName || ""; const displayFileName = currentFileName || fileName || resumeFileName || "";
const [isLoading, setIsLoading] = useState(false);
// File upload hook - handles file selection and conversion const handleFileChange = useCallback(
const { isLoading, handleFileChange } = useFileUpload({ async (event?: React.ChangeEvent<HTMLInputElement>) => {
fileService, setIsLoading(true);
pyodideReady, try {
initializePyodide, // Wait for Pyodide if it's still loading
onFileLoaded: useCallback( if (!pyodideReady) {
(data: PesPatternData, name: string) => { console.log("[FileUpload] Waiting for Pyodide to finish loading...");
await initializePyodide();
console.log("[FileUpload] Pyodide ready");
}
let file: File | null = null;
// In Electron, use native file dialogs
if (fileService.hasNativeDialogs()) {
file = await fileService.openFileDialog({ accept: ".pes" });
} else {
// In browser, use the input element
file = event?.target.files?.[0] || null;
}
if (!file) {
setIsLoading(false);
return;
}
const data = await convertPesToPen(file);
setLocalPesData(data); setLocalPesData(data);
setFileName(name); setFileName(file.name);
setPattern(data, name); setPattern(data, file.name);
} catch (err) {
alert(
`Failed to load PES file: ${
err instanceof Error ? err.message : "Unknown error"
}`,
);
} finally {
setIsLoading(false);
}
}, },
[setPattern], [fileService, setPattern, pyodideReady, initializePyodide],
), );
});
// Pattern rotation and upload hook - handles rotation transformation
const { handleUpload: handlePatternUpload } = usePatternRotationUpload({
uploadPattern,
setUploadedPattern,
});
// Wrapper to call upload with current pattern data
const handleUpload = useCallback(async () => { const handleUpload = useCallback(async () => {
if (pesData && displayFileName) { if (pesData && displayFileName) {
await handlePatternUpload( let penDataToUpload = pesData.penData;
pesData, let pesDataForUpload = pesData;
// Apply rotation if needed
if (patternRotation && patternRotation !== 0) {
// Transform stitches
const rotatedStitches = transformStitchesRotation(
pesData.stitches,
patternRotation,
pesData.bounds,
);
// Encode to PEN (this will round coordinates)
const penResult = encodeStitchesToPen(rotatedStitches);
penDataToUpload = new Uint8Array(penResult.penBytes);
// Decode back to get the ACTUAL pattern (after PEN rounding)
const decoded = decodePenData(penDataToUpload);
// Calculate bounds from the DECODED stitches (the actual data that will be rendered)
const rotatedBounds = calculateBoundsFromDecodedStitches(decoded);
// Calculate the center of the rotated pattern
const originalCenter = calculatePatternCenter(pesData.bounds);
const rotatedCenter = calculatePatternCenter(rotatedBounds);
const centerShiftX = rotatedCenter.x - originalCenter.x;
const centerShiftY = rotatedCenter.y - originalCenter.y;
// CRITICAL: Adjust position to compensate for the center shift!
// In Konva, visual position = (x - offsetX, y - offsetY).
// Original visual pos: (x - originalCenter.x, y - originalCenter.y)
// New visual pos: (newX - rotatedCenter.x, newY - rotatedCenter.y)
// For same visual position: newX = x + (rotatedCenter.x - originalCenter.x)
// So we need to add (rotatedCenter - originalCenter) to the position.
const adjustedOffset = {
x: patternOffset.x + centerShiftX,
y: patternOffset.y + centerShiftY,
};
// Create rotated PesPatternData for upload
pesDataForUpload = {
...pesData,
stitches: rotatedStitches,
penData: penDataToUpload,
penStitches: decoded,
bounds: rotatedBounds,
};
// Save uploaded pattern to store for preview BEFORE starting upload
// This allows the preview to show immediately when isUploading becomes true
setUploadedPattern(pesDataForUpload, adjustedOffset);
// Upload the pattern with offset
// IMPORTANT: Pass original unrotated pesData for caching, rotated pesData for upload
uploadPattern(
penDataToUpload,
pesDataForUpload,
displayFileName,
adjustedOffset,
patternRotation,
pesData, // Original unrotated pattern for caching
);
return; // Early return to skip the upload below
}
// Save uploaded pattern to store BEFORE starting upload
// (same as original since no rotation)
setUploadedPattern(pesDataForUpload, patternOffset);
// Upload the pattern (no rotation case)
uploadPattern(
penDataToUpload,
pesDataForUpload,
displayFileName, displayFileName,
patternOffset, patternOffset,
patternRotation, 0, // No rotation
// No need to pass originalPesData since it's the same as pesDataForUpload
); );
} }
}, [ }, [
pesData, pesData,
displayFileName, displayFileName,
uploadPattern,
patternOffset, patternOffset,
patternRotation, patternRotation,
handlePatternUpload, setUploadedPattern,
]); ]);
// Pattern validation hook - checks if pattern fits in hoop // Check if pattern (with offset and rotation) fits within hoop bounds
const boundsCheck = usePatternValidation({ const checkPatternFitsInHoop = useCallback(() => {
pesData, if (!pesData || !machineInfo) {
machineInfo, return { fits: true, error: null };
patternOffset, }
patternRotation,
}); // Calculate rotated bounds if rotation is applied
let bounds = pesData.bounds;
if (patternRotation && patternRotation !== 0) {
bounds = calculateRotatedBounds(pesData.bounds, patternRotation);
}
const { maxWidth, maxHeight } = machineInfo;
// The patternOffset represents the pattern's CENTER position (due to offsetX/offsetY in canvas)
// So we need to calculate bounds relative to the center
const center = calculatePatternCenter(bounds);
// Calculate actual bounds in world coordinates
const patternMinX = patternOffset.x - center.x + bounds.minX;
const patternMaxX = patternOffset.x - center.x + bounds.maxX;
const patternMinY = patternOffset.y - center.y + bounds.minY;
const patternMaxY = patternOffset.y - center.y + bounds.maxY;
// Hoop bounds (centered at origin)
const hoopMinX = -maxWidth / 2;
const hoopMaxX = maxWidth / 2;
const hoopMinY = -maxHeight / 2;
const hoopMaxY = maxHeight / 2;
// Check if pattern exceeds hoop bounds
const exceedsLeft = patternMinX < hoopMinX;
const exceedsRight = patternMaxX > hoopMaxX;
const exceedsTop = patternMinY < hoopMinY;
const exceedsBottom = patternMaxY > hoopMaxY;
if (exceedsLeft || exceedsRight || exceedsTop || exceedsBottom) {
const directions = [];
if (exceedsLeft)
directions.push(
`left by ${((hoopMinX - patternMinX) / 10).toFixed(1)}mm`,
);
if (exceedsRight)
directions.push(
`right by ${((patternMaxX - hoopMaxX) / 10).toFixed(1)}mm`,
);
if (exceedsTop)
directions.push(
`top by ${((hoopMinY - patternMinY) / 10).toFixed(1)}mm`,
);
if (exceedsBottom)
directions.push(
`bottom by ${((patternMaxY - hoopMaxY) / 10).toFixed(1)}mm`,
);
return {
fits: false,
error: `Pattern exceeds hoop bounds: ${directions.join(", ")}. Adjust pattern position in preview.`,
};
}
return { fits: true, error: null };
}, [pesData, machineInfo, patternOffset, patternRotation]);
const boundsCheck = checkPatternFitsInHoop();
const borderColor = pesData const borderColor = pesData
? "border-secondary-600 dark:border-secondary-500" ? "border-secondary-600 dark:border-secondary-500"

View file

@ -1,84 +0,0 @@
import { useState, useCallback } from "react";
import {
convertPesToPen,
type PesPatternData,
} from "../formats/import/pesImporter";
import type { IFileService } from "../platform/interfaces/IFileService";
export interface UseFileUploadParams {
fileService: IFileService;
pyodideReady: boolean;
initializePyodide: () => Promise<void>;
onFileLoaded: (data: PesPatternData, fileName: string) => void;
}
export interface UseFileUploadReturn {
isLoading: boolean;
handleFileChange: (
event?: React.ChangeEvent<HTMLInputElement>,
) => Promise<void>;
}
/**
* Custom hook for handling file upload and PES to PEN conversion
*
* Manages file selection (native dialog or browser input), Pyodide initialization,
* PES file conversion, and error handling.
*
* @param params - File service, Pyodide state, and callback
* @returns Loading state and file change handler
*/
export function useFileUpload({
fileService,
pyodideReady,
initializePyodide,
onFileLoaded,
}: UseFileUploadParams): UseFileUploadReturn {
const [isLoading, setIsLoading] = useState(false);
const handleFileChange = useCallback(
async (event?: React.ChangeEvent<HTMLInputElement>) => {
setIsLoading(true);
try {
// Wait for Pyodide if it's still loading
if (!pyodideReady) {
console.log("[FileUpload] Waiting for Pyodide to finish loading...");
await initializePyodide();
console.log("[FileUpload] Pyodide ready");
}
let file: File | null = null;
// In Electron, use native file dialogs
if (fileService.hasNativeDialogs()) {
file = await fileService.openFileDialog({ accept: ".pes" });
} else {
// In browser, use the input element
file = event?.target.files?.[0] || null;
}
if (!file) {
setIsLoading(false);
return;
}
const data = await convertPesToPen(file);
onFileLoaded(data, file.name);
} catch (err) {
alert(
`Failed to load PES file: ${
err instanceof Error ? err.message : "Unknown error"
}`,
);
} finally {
setIsLoading(false);
}
},
[fileService, pyodideReady, initializePyodide, onFileLoaded],
);
return {
isLoading,
handleFileChange,
};
}

View file

@ -1,145 +0,0 @@
import { useCallback } from "react";
import type { PesPatternData } from "../formats/import/pesImporter";
import { transformStitchesRotation } from "../utils/rotationUtils";
import { encodeStitchesToPen } from "../formats/pen/encoder";
import { decodePenData } from "../formats/pen/decoder";
import {
calculatePatternCenter,
calculateBoundsFromDecodedStitches,
} from "../components/PatternCanvas/patternCanvasHelpers";
export interface UsePatternRotationUploadParams {
uploadPattern: (
penData: Uint8Array,
uploadedPesData: PesPatternData,
fileName: string,
patternOffset?: { x: number; y: number },
patternRotation?: number,
originalPesData?: PesPatternData,
) => Promise<void>;
setUploadedPattern: (
pesData: PesPatternData,
offset: { x: number; y: number },
fileName?: string,
) => void;
}
export interface UsePatternRotationUploadReturn {
handleUpload: (
pesData: PesPatternData,
displayFileName: string,
patternOffset: { x: number; y: number },
patternRotation: number,
) => Promise<void>;
}
/**
* Custom hook for handling pattern rotation transformation and upload
*
* Manages the complex rotation logic including:
* - Stitch transformation with rotation
* - PEN encoding/decoding for coordinate rounding
* - Center shift calculation to maintain visual position
* - Upload orchestration with proper caching
*
* @param params - Upload and store functions
* @returns Upload handler function
*/
export function usePatternRotationUpload({
uploadPattern,
setUploadedPattern,
}: UsePatternRotationUploadParams): UsePatternRotationUploadReturn {
const handleUpload = useCallback(
async (
pesData: PesPatternData,
displayFileName: string,
patternOffset: { x: number; y: number },
patternRotation: number,
) => {
let penDataToUpload = pesData.penData;
let pesDataForUpload = pesData;
// Apply rotation if needed
if (patternRotation && patternRotation !== 0) {
// Transform stitches
const rotatedStitches = transformStitchesRotation(
pesData.stitches,
patternRotation,
pesData.bounds,
);
// Encode to PEN (this will round coordinates)
const penResult = encodeStitchesToPen(rotatedStitches);
penDataToUpload = new Uint8Array(penResult.penBytes);
// Decode back to get the ACTUAL pattern (after PEN rounding)
const decoded = decodePenData(penDataToUpload);
// Calculate bounds from the DECODED stitches (the actual data that will be rendered)
const rotatedBounds = calculateBoundsFromDecodedStitches(decoded);
// Calculate the center of the rotated pattern
const originalCenter = calculatePatternCenter(pesData.bounds);
const rotatedCenter = calculatePatternCenter(rotatedBounds);
const centerShiftX = rotatedCenter.x - originalCenter.x;
const centerShiftY = rotatedCenter.y - originalCenter.y;
// CRITICAL: Adjust position to compensate for the center shift!
// In Konva, visual position = (x - offsetX, y - offsetY).
// Original visual pos: (x - originalCenter.x, y - originalCenter.y)
// New visual pos: (newX - rotatedCenter.x, newY - rotatedCenter.y)
// For same visual position: newX = x + (rotatedCenter.x - originalCenter.x)
// So we need to add (rotatedCenter - originalCenter) to the position.
const adjustedOffset = {
x: patternOffset.x + centerShiftX,
y: patternOffset.y + centerShiftY,
};
// Create rotated PesPatternData for upload
pesDataForUpload = {
...pesData,
stitches: rotatedStitches,
penData: penDataToUpload,
penStitches: decoded,
bounds: rotatedBounds,
};
// Save uploaded pattern to store for preview BEFORE starting upload
// This allows the preview to show immediately when isUploading becomes true
setUploadedPattern(pesDataForUpload, adjustedOffset);
// Upload the pattern with offset
// IMPORTANT: Pass original unrotated pesData for caching, rotated pesData for upload
await uploadPattern(
penDataToUpload,
pesDataForUpload,
displayFileName,
adjustedOffset,
patternRotation,
pesData, // Original unrotated pattern for caching
);
return;
}
// No rotation case
// Save uploaded pattern to store BEFORE starting upload
setUploadedPattern(pesDataForUpload, patternOffset);
// Upload the pattern
await uploadPattern(
penDataToUpload,
pesDataForUpload,
displayFileName,
patternOffset,
0, // No rotation
// No need to pass originalPesData since it's the same as pesDataForUpload
);
},
[uploadPattern, setUploadedPattern],
);
return {
handleUpload,
};
}

View file

@ -1,97 +0,0 @@
import { useMemo } from "react";
import type { PesPatternData } from "../formats/import/pesImporter";
import type { MachineInfo } from "../types/machine";
import { calculateRotatedBounds } from "../utils/rotationUtils";
import { calculatePatternCenter } from "../components/PatternCanvas/patternCanvasHelpers";
export interface PatternBoundsCheckResult {
fits: boolean;
error: string | null;
}
export interface UsePatternValidationParams {
pesData: PesPatternData | null;
machineInfo: MachineInfo | null;
patternOffset: { x: number; y: number };
patternRotation: number;
}
/**
* Custom hook for validating pattern bounds against hoop size
*
* Checks if the pattern (with rotation and offset applied) fits within
* the machine's hoop bounds and provides detailed error messages if not.
*
* @param params - Pattern and machine configuration
* @returns Bounds check result with fit status and error message
*/
export function usePatternValidation({
pesData,
machineInfo,
patternOffset,
patternRotation,
}: UsePatternValidationParams): PatternBoundsCheckResult {
// Memoize the bounds check calculation to avoid unnecessary recalculations
return useMemo((): PatternBoundsCheckResult => {
if (!pesData || !machineInfo) {
return { fits: true, error: null };
}
// Calculate rotated bounds if rotation is applied
let bounds = pesData.bounds;
if (patternRotation && patternRotation !== 0) {
bounds = calculateRotatedBounds(pesData.bounds, patternRotation);
}
const { maxWidth, maxHeight } = machineInfo;
// The patternOffset represents the pattern's CENTER position (due to offsetX/offsetY in canvas)
// So we need to calculate bounds relative to the center
const center = calculatePatternCenter(bounds);
// Calculate actual bounds in world coordinates
const patternMinX = patternOffset.x - center.x + bounds.minX;
const patternMaxX = patternOffset.x - center.x + bounds.maxX;
const patternMinY = patternOffset.y - center.y + bounds.minY;
const patternMaxY = patternOffset.y - center.y + bounds.maxY;
// Hoop bounds (centered at origin)
const hoopMinX = -maxWidth / 2;
const hoopMaxX = maxWidth / 2;
const hoopMinY = -maxHeight / 2;
const hoopMaxY = maxHeight / 2;
// Check if pattern exceeds hoop bounds
const exceedsLeft = patternMinX < hoopMinX;
const exceedsRight = patternMaxX > hoopMaxX;
const exceedsTop = patternMinY < hoopMinY;
const exceedsBottom = patternMaxY > hoopMaxY;
if (exceedsLeft || exceedsRight || exceedsTop || exceedsBottom) {
const directions = [];
if (exceedsLeft)
directions.push(
`left by ${((hoopMinX - patternMinX) / 10).toFixed(1)}mm`,
);
if (exceedsRight)
directions.push(
`right by ${((patternMaxX - hoopMaxX) / 10).toFixed(1)}mm`,
);
if (exceedsTop)
directions.push(
`top by ${((hoopMinY - patternMinY) / 10).toFixed(1)}mm`,
);
if (exceedsBottom)
directions.push(
`bottom by ${((patternMaxY - hoopMaxY) / 10).toFixed(1)}mm`,
);
return {
fits: false,
error: `Pattern exceeds hoop bounds: ${directions.join(", ")}. Adjust pattern position in preview.`,
};
}
return { fits: true, error: null };
}, [pesData, machineInfo, patternOffset, patternRotation]);
}

View file

@ -1,26 +0,0 @@
/**
* usePrevious Hook
*
* Returns the previous value of a state or prop
* Useful for comparing current vs previous values in effects
*
* This implementation updates the ref in an effect instead of during render,
* which is compatible with React Concurrent Mode and Strict Mode.
*
* Note: The ref read on return is safe because it's only returning the previous value
* (from the last render), not the current value being passed in. This is the standard
* pattern for usePrevious hooks.
*/
import { useEffect, useRef } from "react";
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}, [value]);
// eslint-disable-next-line react-hooks/refs
return ref.current;
}