Compare commits

..

7 commits

Author SHA1 Message Date
Jan-Henrik Bruhn
2372278081
Merge pull request #54 from jhbruhn/refactor/36-improve-useeffect-with-useprevious
Some checks are pending
Build, Test, and Lint / Build, Test, and Lint (push) Waiting to run
Draft Release / Draft Release (push) Waiting to run
Draft Release / Build Web App (push) Blocked by required conditions
Draft Release / Build Release - macos-latest (push) Blocked by required conditions
Draft Release / Build Release - ubuntu-latest (push) Blocked by required conditions
Draft Release / Build Release - windows-latest (push) Blocked by required conditions
Draft Release / Upload to GitHub Release (push) Blocked by required conditions
refactor: Improve useEffect patterns in AppHeader with usePrevious hook
2025-12-27 12:00:58 +01:00
705815a8fc fix: Provide initial value to useRef in usePrevious hook
TypeScript requires an initial value argument when calling useRef.
Changed useRef<T>() to useRef<T | undefined>(undefined) to fix
build error and properly type the ref for the first render.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 11:59:42 +01:00
757e0cdd73 fix: Address PR review feedback for usePrevious implementation
- Update usePrevious hook to use useEffect pattern instead of mutating
  refs during render (addresses Concurrent Mode compatibility)
- Add wasManuallyDismissed flag to properly track dismissal of all error
  types (machineError, machineErrorMessage, and pyodideError)
- Add proper eslint-disable comment with explanation for ref access
- Update handlePopoverOpenChange to handle dismissal of all error types

These changes address all feedback from PR review #54

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 11:55:32 +01:00
7baf03e4f7 refactor: Improve useEffect patterns in AppHeader with usePrevious hook
Replace manual ref tracking with usePrevious custom hook for cleaner,
more maintainable code. Simplifies error state change detection by
eliminating manual ref updates and reducing complexity.

Resolves #36

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 11:46:45 +01:00
Jan-Henrik Bruhn
03ba6c77e9
Merge pull request #53 from jhbruhn/refactor/39-separate-fileupload-business-logic
refactor: Extract business logic from FileUpload into custom hooks
2025-12-27 11:41:09 +01:00
e63a96b024 fix: Address PR review comments
- Remove redundant useMemo wrapper in usePatternValidation
  Inline the calculation logic directly in useMemo instead of
  useCallback + useMemo pattern for better clarity and efficiency

- Remove unnecessary 'Early return' comment in usePatternRotationUpload
  The return statement is self-explanatory
2025-12-27 11:39:48 +01:00
c905c4f5f7 refactor: Extract business logic from FileUpload into custom hooks
**Problem:**
FileUpload component mixed UI and business logic making it:
- Hard to test business logic independently
- Difficult to reuse logic elsewhere
- Component had too many responsibilities (550+ lines)
- Harder to understand and maintain

**Solution:**
Extracted business logic into three focused custom hooks:

1. **useFileUpload** (84 lines)
   - File selection and conversion
   - Pyodide initialization handling
   - Error handling

2. **usePatternRotationUpload** (145 lines)
   - Rotation transformation logic
   - PEN encoding/decoding
   - Center shift calculation
   - Upload orchestration

3. **usePatternValidation** (105 lines)
   - Bounds checking logic
   - Rotated pattern validation
   - Error message generation

**Impact:**
- FileUpload component reduced from 550 → 350 lines (36% smaller)
- Business logic now testable in isolation
- Clear separation of concerns
- Logic can be reused in other components
- Improved maintainability

**Technical Details:**
- All hooks fully typed with TypeScript
- Proper dependency management with useCallback/useMemo
- No behavioral changes
- Build tested successfully
- Linter passed

Fixes #39

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 11:36:04 +01:00
6 changed files with 425 additions and 232 deletions

View file

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

View file

@ -5,24 +5,14 @@ import { useMachineUploadStore } from "../stores/useMachineUploadStore";
import { useMachineCacheStore } from "../stores/useMachineCacheStore";
import { usePatternStore } from "../stores/usePatternStore";
import { useUIStore } from "../stores/useUIStore";
import {
convertPesToPen,
type PesPatternData,
} from "../formats/import/pesImporter";
import type { PesPatternData } from "../formats/import/pesImporter";
import {
canUploadPattern,
getMachineStateCategory,
} from "../utils/machineStateHelpers";
import {
transformStitchesRotation,
calculateRotatedBounds,
} from "../utils/rotationUtils";
import { encodeStitchesToPen } from "../formats/pen/encoder";
import { decodePenData } from "../formats/pen/decoder";
import {
calculatePatternCenter,
calculateBoundsFromDecodedStitches,
} from "./PatternCanvas/patternCanvasHelpers";
import { useFileUpload } from "../hooks/useFileUpload";
import { usePatternRotationUpload } from "../hooks/usePatternRotationUpload";
import { usePatternValidation } from "../hooks/usePatternValidation";
import { PatternInfoSkeleton } from "./SkeletonLoader";
import { PatternInfo } from "./PatternInfo";
import {
@ -111,207 +101,53 @@ export function FileUpload() {
const pesData = pesDataProp || localPesData;
// Use currentFileName from App state, or local fileName, or resumeFileName for display
const displayFileName = currentFileName || fileName || resumeFileName || "";
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);
// File upload hook - handles file selection and conversion
const { isLoading, handleFileChange } = useFileUpload({
fileService,
pyodideReady,
initializePyodide,
onFileLoaded: useCallback(
(data: PesPatternData, name: string) => {
setLocalPesData(data);
setFileName(file.name);
setPattern(data, file.name);
} catch (err) {
alert(
`Failed to load PES file: ${
err instanceof Error ? err.message : "Unknown error"
}`,
);
} finally {
setIsLoading(false);
}
},
[fileService, setPattern, pyodideReady, initializePyodide],
);
setFileName(name);
setPattern(data, name);
},
[setPattern],
),
});
// 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 () => {
if (pesData && displayFileName) {
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
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,
await handlePatternUpload(
pesData,
displayFileName,
patternOffset,
0, // No rotation
// No need to pass originalPesData since it's the same as pesDataForUpload
patternRotation,
);
}
}, [
pesData,
displayFileName,
uploadPattern,
patternOffset,
patternRotation,
setUploadedPattern,
handlePatternUpload,
]);
// Check if pattern (with offset and rotation) fits within hoop bounds
const checkPatternFitsInHoop = useCallback(() => {
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]);
const boundsCheck = checkPatternFitsInHoop();
// Pattern validation hook - checks if pattern fits in hoop
const boundsCheck = usePatternValidation({
pesData,
machineInfo,
patternOffset,
patternRotation,
});
const borderColor = pesData
? "border-secondary-600 dark:border-secondary-500"

View file

@ -0,0 +1,84 @@
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

@ -0,0 +1,145 @@
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

@ -0,0 +1,97 @@
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]);
}

26
src/hooks/usePrevious.ts Normal file
View file

@ -0,0 +1,26 @@
/**
* 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;
}