diff --git a/electron/preload.ts b/electron/preload.ts index b24fb26..84ce05d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,19 +1,19 @@ -import { contextBridge, ipcRenderer } from 'electron'; +import { contextBridge, ipcRenderer } from "electron"; // Expose protected methods that allow the renderer process to use // ipcRenderer without exposing the entire object -contextBridge.exposeInMainWorld('electronAPI', { +contextBridge.exposeInMainWorld("electronAPI", { invoke: (channel: string, ...args: unknown[]) => { const validChannels = [ - 'storage:savePattern', - 'storage:getPattern', - 'storage:getLatest', - 'storage:deletePattern', - 'storage:clear', - 'dialog:openFile', - 'dialog:saveFile', - 'fs:readFile', - 'fs:writeFile', + "storage:savePattern", + "storage:getPattern", + "storage:getLatest", + "storage:deletePattern", + "storage:clear", + "dialog:openFile", + "dialog:saveFile", + "fs:readFile", + "fs:writeFile", ]; if (validChannels.includes(channel)) { @@ -23,15 +23,21 @@ contextBridge.exposeInMainWorld('electronAPI', { throw new Error(`Invalid IPC channel: ${channel}`); }, // Bluetooth device selection - onBluetoothDeviceList: (callback: (devices: Array<{ deviceId: string; deviceName: string }>) => void) => { - ipcRenderer.on('bluetooth:device-list', (_event, devices) => callback(devices)); + onBluetoothDeviceList: ( + callback: ( + devices: Array<{ deviceId: string; deviceName: string }>, + ) => void, + ) => { + ipcRenderer.on("bluetooth:device-list", (_event, devices) => + callback(devices), + ); }, selectBluetoothDevice: (deviceId: string) => { - ipcRenderer.send('bluetooth:select-device', deviceId); + ipcRenderer.send("bluetooth:select-device", deviceId); }, }); // Also expose process type for platform detection -contextBridge.exposeInMainWorld('process', { - type: 'renderer', +contextBridge.exposeInMainWorld("process", { + type: "renderer", }); diff --git a/src/App.tsx b/src/App.tsx index 9a6f49c..4c94540 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,14 @@ -import { useEffect } from 'react'; -import { useShallow } from 'zustand/react/shallow'; -import { useMachineStore } from './stores/useMachineStore'; -import { usePatternStore } from './stores/usePatternStore'; -import { useUIStore } from './stores/useUIStore'; -import { AppHeader } from './components/AppHeader'; -import { LeftSidebar } from './components/LeftSidebar'; -import { PatternCanvas } from './components/PatternCanvas'; -import { PatternPreviewPlaceholder } from './components/PatternPreviewPlaceholder'; -import { BluetoothDevicePicker } from './components/BluetoothDevicePicker'; -import './App.css'; +import { useEffect } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useMachineStore } from "./stores/useMachineStore"; +import { usePatternStore } from "./stores/usePatternStore"; +import { useUIStore } from "./stores/useUIStore"; +import { AppHeader } from "./components/AppHeader"; +import { LeftSidebar } from "./components/LeftSidebar"; +import { PatternCanvas } from "./components/PatternCanvas"; +import { PatternPreviewPlaceholder } from "./components/PatternPreviewPlaceholder"; +import { BluetoothDevicePicker } from "./components/BluetoothDevicePicker"; +import "./App.css"; function App() { // Set page title with version @@ -17,36 +17,27 @@ function App() { }, []); // Machine store - for auto-loading cached pattern - const { - resumedPattern, - resumeFileName, - } = useMachineStore( + const { resumedPattern, resumeFileName } = useMachineStore( useShallow((state) => ({ resumedPattern: state.resumedPattern, resumeFileName: state.resumeFileName, - })) + })), ); // Pattern store - for auto-loading cached pattern - const { - pesData, - setPattern, - setPatternOffset, - } = usePatternStore( + const { pesData, setPattern, setPatternOffset } = usePatternStore( useShallow((state) => ({ pesData: state.pesData, setPattern: state.setPattern, setPatternOffset: state.setPatternOffset, - })) + })), ); // UI store - for Pyodide initialization - const { - initializePyodide, - } = useUIStore( + const { initializePyodide } = useUIStore( useShallow((state) => ({ initializePyodide: state.initializePyodide, - })) + })), ); // Initialize Pyodide in background on mount (non-blocking thanks to worker) @@ -57,11 +48,19 @@ function App() { // Auto-load cached pattern when available useEffect(() => { if (resumedPattern && !pesData) { - console.log('[App] Loading resumed pattern:', resumeFileName, 'Offset:', resumedPattern.patternOffset); - setPattern(resumedPattern.pesData, resumeFileName || ''); + console.log( + "[App] Loading resumed pattern:", + resumeFileName, + "Offset:", + resumedPattern.patternOffset, + ); + setPattern(resumedPattern.pesData, resumeFileName || ""); // Restore the cached pattern offset if (resumedPattern.patternOffset) { - setPatternOffset(resumedPattern.patternOffset.x, resumedPattern.patternOffset.y); + setPatternOffset( + resumedPattern.patternOffset.x, + resumedPattern.patternOffset.y, + ); } } }, [resumedPattern, resumeFileName, pesData, setPattern, setPatternOffset]); diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 1ffacad..c5f736d 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -1,10 +1,10 @@ -import { useRef, useEffect } from 'react'; -import { useShallow } from 'zustand/react/shallow'; -import { useMachineStore } from '../stores/useMachineStore'; -import { useUIStore } from '../stores/useUIStore'; -import { WorkflowStepper } from './WorkflowStepper'; -import { ErrorPopover } from './ErrorPopover'; -import { getStateVisualInfo } from '../utils/machineStateHelpers'; +import { useRef, useEffect } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useMachineStore } from "../stores/useMachineStore"; +import { useUIStore } from "../stores/useUIStore"; +import { WorkflowStepper } from "./WorkflowStepper"; +import { ErrorPopover } from "./ErrorPopover"; +import { getStateVisualInfo } from "../utils/machineStateHelpers"; import { CheckCircleIcon, BoltIcon, @@ -12,7 +12,7 @@ import { ExclamationTriangleIcon, ArrowPathIcon, XMarkIcon, -} from '@heroicons/react/24/solid'; +} from "@heroicons/react/24/solid"; export function AppHeader() { const { @@ -36,19 +36,15 @@ export function AppHeader() { isPairingError: state.isPairingError, isCommunicating: state.isCommunicating, disconnect: state.disconnect, - })) + })), ); - const { - pyodideError, - showErrorPopover, - setErrorPopover, - } = useUIStore( + const { pyodideError, showErrorPopover, setErrorPopover } = useUIStore( useShallow((state) => ({ pyodideError: state.pyodideError, showErrorPopover: state.showErrorPopover, setErrorPopover: state.setErrorPopover, - })) + })), ); const errorPopoverRef = useRef(null); @@ -80,8 +76,9 @@ export function AppHeader() { }; if (showErrorPopover) { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); } }, [showErrorPopover, setErrorPopover]); @@ -90,33 +87,44 @@ export function AppHeader() {
{/* Machine Connection Status - Responsive width column */}
-
-
+
+
-

Respira

+

+ Respira +

{isConnected && machineInfo?.serialNumber && ( • {machineInfo.serialNumber} )} {isPolling && ( - + )}
@@ -146,9 +154,9 @@ export function AppHeader() { ref={errorButtonRef} onClick={() => setErrorPopover(!showErrorPopover)} className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-danger-500/90 hover:bg-danger-600 text-white border border-danger-400 transition-all flex-shrink-0 ${ - (machineErrorMessage || pyodideError) - ? 'cursor-pointer animate-pulse hover:animate-none' - : 'invisible pointer-events-none' + machineErrorMessage || pyodideError + ? "cursor-pointer animate-pulse hover:animate-none" + : "invisible pointer-events-none" }`} title="Click to view error details" aria-label="View error details" @@ -157,27 +165,30 @@ export function AppHeader() { {(() => { - if (pyodideError) return 'Python Error'; - if (isPairingError) return 'Pairing Required'; + if (pyodideError) return "Python Error"; + if (isPairingError) return "Pairing Required"; - const errorMsg = machineErrorMessage || ''; + const errorMsg = machineErrorMessage || ""; // Categorize by error message content - if (errorMsg.toLowerCase().includes('bluetooth') || errorMsg.toLowerCase().includes('connection')) { - return 'Connection Error'; + if ( + errorMsg.toLowerCase().includes("bluetooth") || + errorMsg.toLowerCase().includes("connection") + ) { + return "Connection Error"; } - if (errorMsg.toLowerCase().includes('upload')) { - return 'Upload Error'; + if (errorMsg.toLowerCase().includes("upload")) { + return "Upload Error"; } - if (errorMsg.toLowerCase().includes('pattern')) { - return 'Pattern Error'; + if (errorMsg.toLowerCase().includes("pattern")) { + return "Pattern Error"; } if (machineError !== undefined) { return `Machine Error`; } // Default fallback - return 'Error'; + return "Error"; })()} diff --git a/src/components/BluetoothDevicePicker.tsx b/src/components/BluetoothDevicePicker.tsx index c993423..b828d03 100644 --- a/src/components/BluetoothDevicePicker.tsx +++ b/src/components/BluetoothDevicePicker.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState, useCallback } from 'react'; -import type { BluetoothDevice } from '../types/electron'; +import { useEffect, useState, useCallback } from "react"; +import type { BluetoothDevice } from "../types/electron"; export function BluetoothDevicePicker() { const [devices, setDevices] = useState([]); @@ -10,7 +10,7 @@ export function BluetoothDevicePicker() { // Only set up listener in Electron if (window.electronAPI?.onBluetoothDeviceList) { window.electronAPI.onBluetoothDeviceList((deviceList) => { - console.log('[BluetoothPicker] Received device list:', deviceList); + console.log("[BluetoothPicker] Received device list:", deviceList); setDevices(deviceList); // Open the picker when scan starts (even if empty at first) if (!isOpen) { @@ -26,38 +26,44 @@ export function BluetoothDevicePicker() { }, [isOpen]); const handleSelectDevice = useCallback((deviceId: string) => { - console.log('[BluetoothPicker] User selected device:', deviceId); + console.log("[BluetoothPicker] User selected device:", deviceId); window.electronAPI?.selectBluetoothDevice(deviceId); setIsOpen(false); setDevices([]); }, []); const handleCancel = useCallback(() => { - console.log('[BluetoothPicker] User cancelled device selection'); - window.electronAPI?.selectBluetoothDevice(''); + console.log("[BluetoothPicker] User cancelled device selection"); + window.electronAPI?.selectBluetoothDevice(""); setIsOpen(false); setDevices([]); setIsScanning(false); }, []); // Handle escape key - const handleEscape = useCallback((e: KeyboardEvent) => { - if (e.key === 'Escape') { - handleCancel(); - } - }, [handleCancel]); + const handleEscape = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") { + handleCancel(); + } + }, + [handleCancel], + ); useEffect(() => { if (isOpen) { - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); } }, [isOpen, handleEscape]); if (!isOpen) return null; return ( -
+
e.stopPropagation()} @@ -66,23 +72,48 @@ export function BluetoothDevicePicker() { aria-describedby="bluetooth-picker-message" >
-

+

Select Bluetooth Device

{isScanning && devices.length === 0 ? (
- - - + + + - Scanning for Bluetooth devices... + + Scanning for Bluetooth devices... +
) : ( <> -

- {devices.length} device{devices.length !== 1 ? 's' : ''} found. Select a device to connect: +

+ {devices.length} device{devices.length !== 1 ? "s" : ""} found. + Select a device to connect:

{devices.map((device) => ( @@ -92,8 +123,12 @@ export function BluetoothDevicePicker() { className="w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 text-left rounded-lg font-medium text-sm hover:bg-primary-100 dark:hover:bg-primary-900 hover:text-primary-900 dark:hover:text-primary-100 active:bg-primary-200 dark:active:bg-primary-800 transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" aria-label={`Connect to ${device.deviceName}`} > -
{device.deviceName}
-
{device.deviceId}
+
+ {device.deviceName} +
+
+ {device.deviceId} +
))}
diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index 68cea07..d984cdc 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -1,79 +1,95 @@ -import { useEffect, useCallback } from 'react'; - -interface ConfirmDialogProps { - isOpen: boolean; - title: string; - message: string; - confirmText?: string; - cancelText?: string; - onConfirm: () => void; - onCancel: () => void; - variant?: 'danger' | 'warning'; -} - -export function ConfirmDialog({ - isOpen, - title, - message, - confirmText = 'Confirm', - cancelText = 'Cancel', - onConfirm, - onCancel, - variant = 'warning', -}: ConfirmDialogProps) { - // Handle escape key - const handleEscape = useCallback((e: KeyboardEvent) => { - if (e.key === 'Escape') { - onCancel(); - } - }, [onCancel]); - - useEffect(() => { - if (isOpen) { - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - } - }, [isOpen, handleEscape]); - - if (!isOpen) return null; - - return ( -
-
e.stopPropagation()} - role="dialog" - aria-labelledby="dialog-title" - aria-describedby="dialog-message" - > -
-

{title}

-
-
-

{message}

-
-
- - -
-
-
- ); -} +import { useEffect, useCallback } from "react"; + +interface ConfirmDialogProps { + isOpen: boolean; + title: string; + message: string; + confirmText?: string; + cancelText?: string; + onConfirm: () => void; + onCancel: () => void; + variant?: "danger" | "warning"; +} + +export function ConfirmDialog({ + isOpen, + title, + message, + confirmText = "Confirm", + cancelText = "Cancel", + onConfirm, + onCancel, + variant = "warning", +}: ConfirmDialogProps) { + // Handle escape key + const handleEscape = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") { + onCancel(); + } + }, + [onCancel], + ); + + useEffect(() => { + if (isOpen) { + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + } + }, [isOpen, handleEscape]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-labelledby="dialog-title" + aria-describedby="dialog-message" + > +
+

+ {title} +

+
+
+

+ {message} +

+
+
+ + +
+
+
+ ); +} diff --git a/src/components/ConnectionPrompt.tsx b/src/components/ConnectionPrompt.tsx index 77bfcb9..2c99ef3 100644 --- a/src/components/ConnectionPrompt.tsx +++ b/src/components/ConnectionPrompt.tsx @@ -1,13 +1,13 @@ -import { useShallow } from 'zustand/react/shallow'; -import { useMachineStore } from '../stores/useMachineStore'; -import { isBluetoothSupported } from '../utils/bluetoothSupport'; -import { ExclamationTriangleIcon } from '@heroicons/react/24/solid'; +import { useShallow } from "zustand/react/shallow"; +import { useMachineStore } from "../stores/useMachineStore"; +import { isBluetoothSupported } from "../utils/bluetoothSupport"; +import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; export function ConnectionPrompt() { const { connect } = useMachineStore( useShallow((state) => ({ connect: state.connect, - })) + })), ); if (isBluetoothSupported()) { @@ -15,13 +15,27 @@ export function ConnectionPrompt() {
- - + +
-

Get Started

-

Connect to your embroidery machine

+

+ Get Started +

+

+ Connect to your embroidery machine +

- )} -
- - {/* Pyodide initialization progress indicator - shown when initializing or waiting */} - {!pyodideReady && pyodideProgress > 0 && ( -
-
- - {isLoading && !pyodideReady - ? 'Please wait - initializing Python environment...' - : pyodideLoadingStep || 'Initializing Python environment...'} - - - {pyodideProgress.toFixed(0)}% - -
-
-
-
-

- {isLoading && !pyodideReady - ? 'File dialog will open automatically when ready' - : 'This only happens once on first use'} -

-
- )} - - {/* Error/warning messages with smooth transition - placed after buttons */} -
- {pesData && !canUploadPattern(machineStatus) && ( -
- Cannot upload while {getMachineStateCategory(machineStatus)} -
- )} - - {pesData && boundsCheck.error && ( -
- Pattern too large: {boundsCheck.error} -
- )} -
- - {isUploading && uploadProgress < 100 && ( -
-
- Uploading - - {uploadProgress > 0 ? uploadProgress.toFixed(1) + '%' : 'Starting...'} - -
-
-
-
-
- )} -
- ); -} +import { useState, useCallback } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore"; +import { usePatternStore } from "../stores/usePatternStore"; +import { useUIStore } from "../stores/useUIStore"; +import { + convertPesToPen, + type PesPatternData, +} from "../formats/import/pesImporter"; +import { + canUploadPattern, + getMachineStateCategory, +} from "../utils/machineStateHelpers"; +import { PatternInfoSkeleton } from "./SkeletonLoader"; +import { PatternInfo } from "./PatternInfo"; +import { + ArrowUpTrayIcon, + CheckCircleIcon, + DocumentTextIcon, + FolderOpenIcon, +} from "@heroicons/react/24/solid"; +import { createFileService } from "../platform"; +import type { IFileService } from "../platform/interfaces/IFileService"; + +export function FileUpload() { + // Machine store + const { + isConnected, + machineStatus, + uploadProgress, + isUploading, + machineInfo, + resumeAvailable, + resumeFileName, + uploadPattern, + } = useMachineStore( + useShallow((state) => ({ + isConnected: state.isConnected, + machineStatus: state.machineStatus, + uploadProgress: state.uploadProgress, + isUploading: state.isUploading, + machineInfo: state.machineInfo, + resumeAvailable: state.resumeAvailable, + resumeFileName: state.resumeFileName, + uploadPattern: state.uploadPattern, + })), + ); + + // Pattern store + const { + pesData: pesDataProp, + currentFileName, + patternOffset, + setPattern, + } = usePatternStore( + useShallow((state) => ({ + pesData: state.pesData, + currentFileName: state.currentFileName, + patternOffset: state.patternOffset, + setPattern: state.setPattern, + })), + ); + + // Derived state: pattern is uploaded if machine has pattern info + const patternUploaded = usePatternUploaded(); + + // UI store + const { + pyodideReady, + pyodideProgress, + pyodideLoadingStep, + initializePyodide, + } = useUIStore( + useShallow((state) => ({ + pyodideReady: state.pyodideReady, + pyodideProgress: state.pyodideProgress, + pyodideLoadingStep: state.pyodideLoadingStep, + initializePyodide: state.initializePyodide, + })), + ); + const [localPesData, setLocalPesData] = useState(null); + const [fileName, setFileName] = useState(""); + const [fileService] = useState(() => createFileService()); + + // Use prop pesData if available (from cached pattern), otherwise use local state + 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) => { + 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); + 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], + ); + + const handleUpload = useCallback(() => { + if (pesData && displayFileName) { + uploadPattern(pesData.penData, pesData, displayFileName, patternOffset); + } + }, [pesData, displayFileName, uploadPattern, patternOffset]); + + // Check if pattern (with offset) fits within hoop bounds + const checkPatternFitsInHoop = useCallback(() => { + if (!pesData || !machineInfo) { + return { fits: true, error: null }; + } + + const { bounds } = pesData; + const { maxWidth, maxHeight } = machineInfo; + + // Calculate pattern bounds with offset applied + const patternMinX = bounds.minX + patternOffset.x; + const patternMaxX = bounds.maxX + patternOffset.x; + const patternMinY = bounds.minY + patternOffset.y; + const patternMaxY = bounds.maxY + patternOffset.y; + + // 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]); + + const boundsCheck = checkPatternFitsInHoop(); + + const borderColor = pesData + ? "border-secondary-600 dark:border-secondary-500" + : "border-gray-400 dark:border-gray-600"; + const iconColor = pesData + ? "text-secondary-600 dark:text-secondary-400" + : "text-gray-600 dark:text-gray-400"; + + return ( +
+
+ +
+

+ Pattern File +

+ {pesData && displayFileName ? ( +

+ {displayFileName} +

+ ) : ( +

+ No pattern loaded +

+ )} +
+
+ + {resumeAvailable && resumeFileName && ( +
+

+ Cached: "{resumeFileName}" +

+
+ )} + + {isLoading && } + + {!isLoading && pesData && ( +
+ +
+ )} + +
+ + + + {pesData && + canUploadPattern(machineStatus) && + !patternUploaded && + uploadProgress < 100 && ( + + )} +
+ + {/* Pyodide initialization progress indicator - shown when initializing or waiting */} + {!pyodideReady && pyodideProgress > 0 && ( +
+
+ + {isLoading && !pyodideReady + ? "Please wait - initializing Python environment..." + : pyodideLoadingStep || "Initializing Python environment..."} + + + {pyodideProgress.toFixed(0)}% + +
+
+
+
+

+ {isLoading && !pyodideReady + ? "File dialog will open automatically when ready" + : "This only happens once on first use"} +

+
+ )} + + {/* Error/warning messages with smooth transition - placed after buttons */} +
+ {pesData && !canUploadPattern(machineStatus) && ( +
+ Cannot upload while {getMachineStateCategory(machineStatus)} +
+ )} + + {pesData && boundsCheck.error && ( +
+ Pattern too large: {boundsCheck.error} +
+ )} +
+ + {isUploading && uploadProgress < 100 && ( +
+
+ + Uploading + + + {uploadProgress > 0 + ? uploadProgress.toFixed(1) + "%" + : "Starting..."} + +
+
+
+
+
+ )} +
+ ); +} diff --git a/src/components/KonvaComponents.tsx b/src/components/KonvaComponents.tsx index 146471d..a8e15cb 100644 --- a/src/components/KonvaComponents.tsx +++ b/src/components/KonvaComponents.tsx @@ -1,10 +1,10 @@ -import { memo, useMemo } from 'react'; -import { Group, Line, Rect, Text, Circle } from 'react-konva'; -import type { PesPatternData } from '../formats/import/pesImporter'; -import { getThreadColor } from '../formats/import/pesImporter'; -import type { MachineInfo } from '../types/machine'; -import { MOVE } from '../formats/import/constants'; -import { canvasColors } from '../utils/cssVariables'; +import { memo, useMemo } from "react"; +import { Group, Line, Rect, Text, Circle } from "react-konva"; +import type { PesPatternData } from "../formats/import/pesImporter"; +import { getThreadColor } from "../formats/import/pesImporter"; +import type { MachineInfo } from "../types/machine"; +import { MOVE } from "../formats/import/constants"; +import { canvasColors } from "../utils/cssVariables"; interface GridProps { gridSize: number; @@ -23,12 +23,20 @@ export const Grid = memo(({ gridSize, bounds, machineInfo }: GridProps) => { const horizontalLines: number[][] = []; // Vertical lines - for (let x = Math.floor(gridMinX / gridSize) * gridSize; x <= gridMaxX; x += gridSize) { + for ( + let x = Math.floor(gridMinX / gridSize) * gridSize; + x <= gridMaxX; + x += gridSize + ) { verticalLines.push([x, gridMinY, x, gridMaxY]); } // Horizontal lines - for (let y = Math.floor(gridMinY / gridSize) * gridSize; y <= gridMaxY; y += gridSize) { + for ( + let y = Math.floor(gridMinY / gridSize) * gridSize; + y <= gridMaxY; + y += gridSize + ) { horizontalLines.push([gridMinX, y, gridMaxX, y]); } @@ -59,7 +67,7 @@ export const Grid = memo(({ gridSize, bounds, machineInfo }: GridProps) => { ); }); -Grid.displayName = 'Grid'; +Grid.displayName = "Grid"; export const Origin = memo(() => { const originColor = canvasColors.origin(); @@ -72,7 +80,7 @@ export const Origin = memo(() => { ); }); -Origin.displayName = 'Origin'; +Origin.displayName = "Origin"; interface HoopProps { machineInfo: MachineInfo; @@ -108,7 +116,7 @@ export const Hoop = memo(({ machineInfo }: HoopProps) => { ); }); -Hoop.displayName = 'Hoop'; +Hoop.displayName = "Hoop"; interface PatternBoundsProps { bounds: { minX: number; maxX: number; minY: number; maxY: number }; @@ -133,7 +141,7 @@ export const PatternBounds = memo(({ bounds }: PatternBoundsProps) => { ); }); -PatternBounds.displayName = 'PatternBounds'; +PatternBounds.displayName = "PatternBounds"; interface StitchesProps { stitches: number[][]; @@ -142,113 +150,146 @@ interface StitchesProps { showProgress?: boolean; } -export const Stitches = memo(({ stitches, pesData, currentStitchIndex, showProgress = false }: StitchesProps) => { - const stitchGroups = useMemo(() => { - interface StitchGroup { - color: string; - points: number[]; - completed: boolean; - isJump: boolean; - } - - const groups: StitchGroup[] = []; - let currentGroup: StitchGroup | null = null; - - let prevX = 0; - let prevY = 0; - - for (let i = 0; i < stitches.length; i++) { - const stitch = stitches[i]; - const [x, y, cmd, colorIndex] = stitch; - const isCompleted = i < currentStitchIndex; - const isJump = (cmd & MOVE) !== 0; - const color = getThreadColor(pesData, colorIndex); - - // Start new group if color/status/type changes - if ( - !currentGroup || - currentGroup.color !== color || - currentGroup.completed !== isCompleted || - currentGroup.isJump !== isJump - ) { - // For jump stitches, we need to create a line from previous position to current position - // So we include both the previous point and current point - if (isJump && i > 0) { - currentGroup = { - color, - points: [prevX, prevY, x, y], - completed: isCompleted, - isJump, - }; - } else { - currentGroup = { - color, - points: [x, y], - completed: isCompleted, - isJump, - }; - } - groups.push(currentGroup); - } else { - currentGroup.points.push(x, y); +export const Stitches = memo( + ({ + stitches, + pesData, + currentStitchIndex, + showProgress = false, + }: StitchesProps) => { + const stitchGroups = useMemo(() => { + interface StitchGroup { + color: string; + points: number[]; + completed: boolean; + isJump: boolean; } - prevX = x; - prevY = y; - } + const groups: StitchGroup[] = []; + let currentGroup: StitchGroup | null = null; - return groups; - }, [stitches, pesData, currentStitchIndex]); + let prevX = 0; + let prevY = 0; - return ( - - {stitchGroups.map((group, i) => ( - - ))} - - ); -}); + for (let i = 0; i < stitches.length; i++) { + const stitch = stitches[i]; + const [x, y, cmd, colorIndex] = stitch; + const isCompleted = i < currentStitchIndex; + const isJump = (cmd & MOVE) !== 0; + const color = getThreadColor(pesData, colorIndex); -Stitches.displayName = 'Stitches'; + // Start new group if color/status/type changes + if ( + !currentGroup || + currentGroup.color !== color || + currentGroup.completed !== isCompleted || + currentGroup.isJump !== isJump + ) { + // For jump stitches, we need to create a line from previous position to current position + // So we include both the previous point and current point + if (isJump && i > 0) { + currentGroup = { + color, + points: [prevX, prevY, x, y], + completed: isCompleted, + isJump, + }; + } else { + currentGroup = { + color, + points: [x, y], + completed: isCompleted, + isJump, + }; + } + groups.push(currentGroup); + } else { + currentGroup.points.push(x, y); + } + + prevX = x; + prevY = y; + } + + return groups; + }, [stitches, pesData, currentStitchIndex]); + + return ( + + {stitchGroups.map((group, i) => ( + + ))} + + ); + }, +); + +Stitches.displayName = "Stitches"; interface CurrentPositionProps { currentStitchIndex: number; stitches: number[][]; } -export const CurrentPosition = memo(({ currentStitchIndex, stitches }: CurrentPositionProps) => { - if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) { - return null; - } +export const CurrentPosition = memo( + ({ currentStitchIndex, stitches }: CurrentPositionProps) => { + if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) { + return null; + } - const [x, y] = stitches[currentStitchIndex]; - const positionColor = canvasColors.position(); + const [x, y] = stitches[currentStitchIndex]; + const positionColor = canvasColors.position(); - return ( - - - - - - - - ); -}); + return ( + + + + + + + + ); + }, +); -CurrentPosition.displayName = 'CurrentPosition'; +CurrentPosition.displayName = "CurrentPosition"; diff --git a/src/components/LeftSidebar.tsx b/src/components/LeftSidebar.tsx index 29b4ae9..adddb0e 100644 --- a/src/components/LeftSidebar.tsx +++ b/src/components/LeftSidebar.tsx @@ -1,22 +1,22 @@ -import { useShallow } from 'zustand/react/shallow'; -import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore'; -import { usePatternStore } from '../stores/usePatternStore'; -import { ConnectionPrompt } from './ConnectionPrompt'; -import { FileUpload } from './FileUpload'; -import { PatternSummaryCard } from './PatternSummaryCard'; -import { ProgressMonitor } from './ProgressMonitor'; +import { useShallow } from "zustand/react/shallow"; +import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore"; +import { usePatternStore } from "../stores/usePatternStore"; +import { ConnectionPrompt } from "./ConnectionPrompt"; +import { FileUpload } from "./FileUpload"; +import { PatternSummaryCard } from "./PatternSummaryCard"; +import { ProgressMonitor } from "./ProgressMonitor"; export function LeftSidebar() { const { isConnected } = useMachineStore( useShallow((state) => ({ isConnected: state.isConnected, - })) + })), ); const { pesData } = usePatternStore( useShallow((state) => ({ pesData: state.pesData, - })) + })), ); // Derived state: pattern is uploaded if machine has pattern info diff --git a/src/components/MachineConnection.tsx b/src/components/MachineConnection.tsx index dee49b5..ac85da1 100644 --- a/src/components/MachineConnection.tsx +++ b/src/components/MachineConnection.tsx @@ -1,189 +1,224 @@ -import { useState } from 'react'; -import { - InformationCircleIcon, - CheckCircleIcon, - BoltIcon, - PauseCircleIcon, - ExclamationTriangleIcon, - WifiIcon, -} from '@heroicons/react/24/solid'; -import type { MachineInfo } from '../types/machine'; -import { MachineStatus } from '../types/machine'; -import { ConfirmDialog } from './ConfirmDialog'; -import { shouldConfirmDisconnect, getStateVisualInfo } from '../utils/machineStateHelpers'; -import { hasError, getErrorDetails } from '../utils/errorCodeHelpers'; - -interface MachineConnectionProps { - isConnected: boolean; - machineInfo: MachineInfo | null; - machineStatus: MachineStatus; - machineStatusName: string; - machineError: number; - onConnect: () => void; - onDisconnect: () => void; - onRefresh: () => void; -} - -export function MachineConnection({ - isConnected, - machineInfo, - machineStatus, - machineStatusName, - machineError, - onConnect, - onDisconnect, -}: MachineConnectionProps) { - const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false); - - const handleDisconnectClick = () => { - if (shouldConfirmDisconnect(machineStatus)) { - setShowDisconnectConfirm(true); - } else { - onDisconnect(); - } - }; - - const handleConfirmDisconnect = () => { - setShowDisconnectConfirm(false); - onDisconnect(); - }; - - const stateVisual = getStateVisualInfo(machineStatus); - - // Map icon names to Heroicons - const stateIcons = { - ready: CheckCircleIcon, - active: BoltIcon, - waiting: PauseCircleIcon, - complete: CheckCircleIcon, - interrupted: PauseCircleIcon, - error: ExclamationTriangleIcon, - }; - - const statusBadgeColors = { - idle: 'bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300', - info: 'bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300', - active: 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300', - waiting: 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300', - warning: 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300', - complete: 'bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300', - success: 'bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300', - interrupted: 'bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300', - error: 'bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300', - danger: 'bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300', - }; - - // Only show error info when connected AND there's an actual error - const errorInfo = (isConnected && hasError(machineError)) ? getErrorDetails(machineError) : null; - - return ( - <> - {!isConnected ? ( -
-
- -
-

Machine

-

Ready to connect

-
-
- - -
- ) : ( -
-
- -
-

Machine Info

-

- {machineInfo?.modelNumber || 'Brother Embroidery Machine'} -

-
-
- - {/* Error/Info Display */} - {errorInfo && ( - errorInfo.isInformational ? ( -
-
- -
-
{errorInfo.title}
-
-
-
- ) : ( -
-
- ⚠️ -
-
{errorInfo.title}
-
- Error Code: 0x{machineError.toString(16).toUpperCase().padStart(2, '0')} -
-
-
-
- ) - )} - - {/* Status Badge */} -
- Status: - - {(() => { - const Icon = stateIcons[stateVisual.iconName]; - return ; - })()} - {machineStatusName} - -
- - {/* Machine Info */} - {machineInfo && ( -
-
- Max Area - - {(machineInfo.maxWidth / 10).toFixed(1)} × {(machineInfo.maxHeight / 10).toFixed(1)} mm - -
- {machineInfo.totalCount !== undefined && ( -
- Total Stitches - - {machineInfo.totalCount.toLocaleString()} - -
- )} -
- )} - - -
- )} - - setShowDisconnectConfirm(false)} - variant="danger" - /> - - ); -} +import { useState } from "react"; +import { + InformationCircleIcon, + CheckCircleIcon, + BoltIcon, + PauseCircleIcon, + ExclamationTriangleIcon, + WifiIcon, +} from "@heroicons/react/24/solid"; +import type { MachineInfo } from "../types/machine"; +import { MachineStatus } from "../types/machine"; +import { ConfirmDialog } from "./ConfirmDialog"; +import { + shouldConfirmDisconnect, + getStateVisualInfo, +} from "../utils/machineStateHelpers"; +import { hasError, getErrorDetails } from "../utils/errorCodeHelpers"; + +interface MachineConnectionProps { + isConnected: boolean; + machineInfo: MachineInfo | null; + machineStatus: MachineStatus; + machineStatusName: string; + machineError: number; + onConnect: () => void; + onDisconnect: () => void; + onRefresh: () => void; +} + +export function MachineConnection({ + isConnected, + machineInfo, + machineStatus, + machineStatusName, + machineError, + onConnect, + onDisconnect, +}: MachineConnectionProps) { + const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false); + + const handleDisconnectClick = () => { + if (shouldConfirmDisconnect(machineStatus)) { + setShowDisconnectConfirm(true); + } else { + onDisconnect(); + } + }; + + const handleConfirmDisconnect = () => { + setShowDisconnectConfirm(false); + onDisconnect(); + }; + + const stateVisual = getStateVisualInfo(machineStatus); + + // Map icon names to Heroicons + const stateIcons = { + ready: CheckCircleIcon, + active: BoltIcon, + waiting: PauseCircleIcon, + complete: CheckCircleIcon, + interrupted: PauseCircleIcon, + error: ExclamationTriangleIcon, + }; + + const statusBadgeColors = { + idle: "bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300", + info: "bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300", + active: + "bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300", + waiting: + "bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300", + warning: + "bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300", + complete: + "bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300", + success: + "bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300", + interrupted: + "bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300", + error: + "bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300", + danger: + "bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300", + }; + + // Only show error info when connected AND there's an actual error + const errorInfo = + isConnected && hasError(machineError) + ? getErrorDetails(machineError) + : null; + + return ( + <> + {!isConnected ? ( +
+
+ +
+

+ Machine +

+

+ Ready to connect +

+
+
+ + +
+ ) : ( +
+
+ +
+

+ Machine Info +

+

+ {machineInfo?.modelNumber || "Brother Embroidery Machine"} +

+
+
+ + {/* Error/Info Display */} + {errorInfo && + (errorInfo.isInformational ? ( +
+
+ +
+
+ {errorInfo.title} +
+
+
+
+ ) : ( +
+
+ + ⚠️ + +
+
+ {errorInfo.title} +
+
+ Error Code: 0x + {machineError.toString(16).toUpperCase().padStart(2, "0")} +
+
+
+
+ ))} + + {/* Status Badge */} +
+ + Status: + + + {(() => { + const Icon = stateIcons[stateVisual.iconName]; + return ; + })()} + {machineStatusName} + +
+ + {/* Machine Info */} + {machineInfo && ( +
+
+ + Max Area + + + {(machineInfo.maxWidth / 10).toFixed(1)} ×{" "} + {(machineInfo.maxHeight / 10).toFixed(1)} mm + +
+ {machineInfo.totalCount !== undefined && ( +
+ + Total Stitches + + + {machineInfo.totalCount.toLocaleString()} + +
+ )} +
+ )} + + +
+ )} + + setShowDisconnectConfirm(false)} + variant="danger" + /> + + ); +} diff --git a/src/components/PatternCanvas.tsx b/src/components/PatternCanvas.tsx index 2b14a8d..b9099b8 100644 --- a/src/components/PatternCanvas.tsx +++ b/src/components/PatternCanvas.tsx @@ -1,424 +1,516 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; -import { useShallow } from 'zustand/react/shallow'; -import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore'; -import { usePatternStore } from '../stores/usePatternStore'; -import { Stage, Layer, Group } from 'react-konva'; -import Konva from 'konva'; -import { PlusIcon, MinusIcon, ArrowPathIcon, LockClosedIcon, PhotoIcon, ArrowsPointingInIcon } from '@heroicons/react/24/solid'; -import type { PesPatternData } from '../formats/import/pesImporter'; -import { calculateInitialScale } from '../utils/konvaRenderers'; -import { Grid, Origin, Hoop, Stitches, PatternBounds, CurrentPosition } from './KonvaComponents'; - -export function PatternCanvas() { - // Machine store - const { - sewingProgress, - machineInfo, - isUploading, - } = useMachineStore( - useShallow((state) => ({ - sewingProgress: state.sewingProgress, - machineInfo: state.machineInfo, - isUploading: state.isUploading, - })) - ); - - // Pattern store - const { - pesData, - patternOffset: initialPatternOffset, - setPatternOffset, - } = usePatternStore( - useShallow((state) => ({ - pesData: state.pesData, - patternOffset: state.patternOffset, - setPatternOffset: state.setPatternOffset, - })) - ); - - // Derived state: pattern is uploaded if machine has pattern info - const patternUploaded = usePatternUploaded(); - const containerRef = useRef(null); - const stageRef = useRef(null); - - const [stagePos, setStagePos] = useState({ x: 0, y: 0 }); - const [stageScale, setStageScale] = useState(1); - const [localPatternOffset, setLocalPatternOffset] = useState(initialPatternOffset || { x: 0, y: 0 }); - const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); - const initialScaleRef = useRef(1); - const prevPesDataRef = useRef(null); - - // Update pattern offset when initialPatternOffset changes - if (initialPatternOffset && ( - localPatternOffset.x !== initialPatternOffset.x || - localPatternOffset.y !== initialPatternOffset.y - )) { - setLocalPatternOffset(initialPatternOffset); - console.log('[PatternCanvas] Restored pattern offset:', initialPatternOffset); - } - - // Track container size - useEffect(() => { - if (!containerRef.current) return; - - const updateSize = () => { - if (containerRef.current) { - const width = containerRef.current.clientWidth; - const height = containerRef.current.clientHeight; - setContainerSize({ width, height }); - } - }; - - // Initial size - updateSize(); - - // Watch for resize - const resizeObserver = new ResizeObserver(updateSize); - resizeObserver.observe(containerRef.current); - - return () => resizeObserver.disconnect(); - }, []); - - // Calculate and store initial scale when pattern or hoop changes - useEffect(() => { - if (!pesData || containerSize.width === 0) { - prevPesDataRef.current = null; - return; - } - - // Only recalculate if pattern changed - if (prevPesDataRef.current !== pesData) { - prevPesDataRef.current = pesData; - - const { bounds } = pesData; - const viewWidth = machineInfo ? machineInfo.maxWidth : bounds.maxX - bounds.minX; - const viewHeight = machineInfo ? machineInfo.maxHeight : bounds.maxY - bounds.minY; - - const initialScale = calculateInitialScale(containerSize.width, containerSize.height, viewWidth, viewHeight); - initialScaleRef.current = initialScale; - - // Reset view when pattern changes - // eslint-disable-next-line react-hooks/set-state-in-effect - setStageScale(initialScale); - setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 }); - } - }, [pesData, machineInfo, containerSize]); - - // Wheel zoom handler - const handleWheel = useCallback((e: Konva.KonvaEventObject) => { - e.evt.preventDefault(); - - const stage = e.target.getStage(); - if (!stage) return; - - const pointer = stage.getPointerPosition(); - if (!pointer) return; - - const scaleBy = 1.1; - const direction = e.evt.deltaY > 0 ? -1 : 1; - - setStageScale((oldScale) => { - const newScale = Math.max(0.1, Math.min(direction > 0 ? oldScale * scaleBy : oldScale / scaleBy, 2)); - - // Zoom towards pointer - setStagePos((prevPos) => { - const mousePointTo = { - x: (pointer.x - prevPos.x) / oldScale, - y: (pointer.y - prevPos.y) / oldScale, - }; - - return { - x: pointer.x - mousePointTo.x * newScale, - y: pointer.y - mousePointTo.y * newScale, - }; - }); - - return newScale; - }); - }, []); - - // Zoom control handlers - const handleZoomIn = useCallback(() => { - setStageScale((oldScale) => { - const newScale = Math.max(0.1, Math.min(oldScale * 1.2, 2)); - - // Zoom towards center of viewport - setStagePos((prevPos) => { - const centerX = containerSize.width / 2; - const centerY = containerSize.height / 2; - - const mousePointTo = { - x: (centerX - prevPos.x) / oldScale, - y: (centerY - prevPos.y) / oldScale, - }; - - return { - x: centerX - mousePointTo.x * newScale, - y: centerY - mousePointTo.y * newScale, - }; - }); - - return newScale; - }); - }, [containerSize]); - - const handleZoomOut = useCallback(() => { - setStageScale((oldScale) => { - const newScale = Math.max(0.1, Math.min(oldScale / 1.2, 2)); - - // Zoom towards center of viewport - setStagePos((prevPos) => { - const centerX = containerSize.width / 2; - const centerY = containerSize.height / 2; - - const mousePointTo = { - x: (centerX - prevPos.x) / oldScale, - y: (centerY - prevPos.y) / oldScale, - }; - - return { - x: centerX - mousePointTo.x * newScale, - y: centerY - mousePointTo.y * newScale, - }; - }); - - return newScale; - }); - }, [containerSize]); - - const handleZoomReset = useCallback(() => { - const initialScale = initialScaleRef.current; - setStageScale(initialScale); - setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 }); - }, [containerSize]); - - const handleCenterPattern = useCallback(() => { - if (!pesData) return; - - const { bounds } = pesData; - const centerOffsetX = -(bounds.minX + bounds.maxX) / 2; - const centerOffsetY = -(bounds.minY + bounds.maxY) / 2; - - setLocalPatternOffset({ x: centerOffsetX, y: centerOffsetY }); - setPatternOffset(centerOffsetX, centerOffsetY); - }, [pesData, setPatternOffset]); - - // Pattern drag handlers - const handlePatternDragEnd = useCallback((e: Konva.KonvaEventObject) => { - const newOffset = { - x: e.target.x(), - y: e.target.y(), - }; - setLocalPatternOffset(newOffset); - setPatternOffset(newOffset.x, newOffset.y); - }, [setPatternOffset]); - - const borderColor = pesData ? 'border-tertiary-600 dark:border-tertiary-500' : 'border-gray-400 dark:border-gray-600'; - const iconColor = pesData ? 'text-tertiary-600 dark:text-tertiary-400' : 'text-gray-600 dark:text-gray-400'; - - return ( -
-
- -
-

Pattern Preview

- {pesData ? ( -

- {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} × {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm -

- ) : ( -

No pattern loaded

- )} -
-
-
- {containerSize.width > 0 && ( - { - if (stageRef.current) { - stageRef.current.container().style.cursor = 'grabbing'; - } - }} - onDragEnd={() => { - if (stageRef.current) { - stageRef.current.container().style.cursor = 'grab'; - } - }} - ref={(node) => { - stageRef.current = node; - if (node) { - node.container().style.cursor = 'grab'; - } - }} - > - {/* Background layer: grid, origin, hoop */} - - {pesData && ( - <> - - - {machineInfo && } - - )} - - - {/* Pattern layer: draggable stitches and bounds */} - - {pesData && ( - { - const stage = e.target.getStage(); - if (stage && !patternUploaded && !isUploading) stage.container().style.cursor = 'move'; - }} - onMouseLeave={(e) => { - const stage = e.target.getStage(); - if (stage && !patternUploaded && !isUploading) stage.container().style.cursor = 'grab'; - }} - > - { - // Convert PEN stitch format {x, y, flags, isJump} to PES format [x, y, cmd, colorIndex] - const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump - const colorIndex = pesData.penStitches.colorBlocks.find( - (b) => i >= b.startStitch && i <= b.endStitch - )?.colorIndex ?? 0; - return [s.x, s.y, cmd, colorIndex]; - })} - pesData={pesData} - currentStitchIndex={sewingProgress?.currentStitch || 0} - showProgress={patternUploaded || isUploading} - /> - - - )} - - - {/* Current position layer */} - - {pesData && pesData.penStitches && sewingProgress && sewingProgress.currentStitch > 0 && ( - - { - const cmd = s.isJump ? 0x10 : 0; - const colorIndex = pesData.penStitches.colorBlocks.find( - (b) => i >= b.startStitch && i <= b.endStitch - )?.colorIndex ?? 0; - return [s.x, s.y, cmd, colorIndex]; - })} - /> - - )} - - - )} - - {/* Placeholder overlay when no pattern is loaded */} - {!pesData && ( -
- Load a PES file to preview the pattern -
- )} - - {/* Pattern info overlays */} - {pesData && ( - <> - {/* Thread Legend Overlay */} -
-

Colors

- {pesData.uniqueColors.map((color, idx) => { - // Primary metadata: brand and catalog number - const primaryMetadata = [ - color.brand, - color.catalogNumber ? `#${color.catalogNumber}` : null - ].filter(Boolean).join(" "); - - // Secondary metadata: chart and description - const secondaryMetadata = [ - color.chart, - color.description - ].filter(Boolean).join(" "); - - return ( -
-
-
-
- Color {idx + 1} -
- {(primaryMetadata || secondaryMetadata) && ( -
- {primaryMetadata} - {primaryMetadata && secondaryMetadata && } - {secondaryMetadata} -
- )} -
-
- ); - })} -
- - {/* Pattern Offset Indicator */} -
-
-
Pattern Position:
- {patternUploaded && ( -
- - LOCKED -
- )} -
-
- X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y: {(localPatternOffset.y / 10).toFixed(1)}mm -
-
- {patternUploaded ? 'Pattern locked • Drag background to pan' : 'Drag pattern to move • Drag background to pan'} -
-
- - {/* Zoom Controls Overlay */} -
- - - {Math.round(stageScale * 100)}% - - -
- - )} -
-
- ); -} +import { useEffect, useRef, useState, useCallback } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore"; +import { usePatternStore } from "../stores/usePatternStore"; +import { Stage, Layer, Group } from "react-konva"; +import Konva from "konva"; +import { + PlusIcon, + MinusIcon, + ArrowPathIcon, + LockClosedIcon, + PhotoIcon, + ArrowsPointingInIcon, +} from "@heroicons/react/24/solid"; +import type { PesPatternData } from "../formats/import/pesImporter"; +import { calculateInitialScale } from "../utils/konvaRenderers"; +import { + Grid, + Origin, + Hoop, + Stitches, + PatternBounds, + CurrentPosition, +} from "./KonvaComponents"; + +export function PatternCanvas() { + // Machine store + const { sewingProgress, machineInfo, isUploading } = useMachineStore( + useShallow((state) => ({ + sewingProgress: state.sewingProgress, + machineInfo: state.machineInfo, + isUploading: state.isUploading, + })), + ); + + // Pattern store + const { + pesData, + patternOffset: initialPatternOffset, + setPatternOffset, + } = usePatternStore( + useShallow((state) => ({ + pesData: state.pesData, + patternOffset: state.patternOffset, + setPatternOffset: state.setPatternOffset, + })), + ); + + // Derived state: pattern is uploaded if machine has pattern info + const patternUploaded = usePatternUploaded(); + const containerRef = useRef(null); + const stageRef = useRef(null); + + const [stagePos, setStagePos] = useState({ x: 0, y: 0 }); + const [stageScale, setStageScale] = useState(1); + const [localPatternOffset, setLocalPatternOffset] = useState( + initialPatternOffset || { x: 0, y: 0 }, + ); + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + const initialScaleRef = useRef(1); + const prevPesDataRef = useRef(null); + + // Update pattern offset when initialPatternOffset changes + if ( + initialPatternOffset && + (localPatternOffset.x !== initialPatternOffset.x || + localPatternOffset.y !== initialPatternOffset.y) + ) { + setLocalPatternOffset(initialPatternOffset); + console.log( + "[PatternCanvas] Restored pattern offset:", + initialPatternOffset, + ); + } + + // Track container size + useEffect(() => { + if (!containerRef.current) return; + + const updateSize = () => { + if (containerRef.current) { + const width = containerRef.current.clientWidth; + const height = containerRef.current.clientHeight; + setContainerSize({ width, height }); + } + }; + + // Initial size + updateSize(); + + // Watch for resize + const resizeObserver = new ResizeObserver(updateSize); + resizeObserver.observe(containerRef.current); + + return () => resizeObserver.disconnect(); + }, []); + + // Calculate and store initial scale when pattern or hoop changes + useEffect(() => { + if (!pesData || containerSize.width === 0) { + prevPesDataRef.current = null; + return; + } + + // Only recalculate if pattern changed + if (prevPesDataRef.current !== pesData) { + prevPesDataRef.current = pesData; + + const { bounds } = pesData; + const viewWidth = machineInfo + ? machineInfo.maxWidth + : bounds.maxX - bounds.minX; + const viewHeight = machineInfo + ? machineInfo.maxHeight + : bounds.maxY - bounds.minY; + + const initialScale = calculateInitialScale( + containerSize.width, + containerSize.height, + viewWidth, + viewHeight, + ); + initialScaleRef.current = initialScale; + + // Reset view when pattern changes + // eslint-disable-next-line react-hooks/set-state-in-effect + setStageScale(initialScale); + setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 }); + } + }, [pesData, machineInfo, containerSize]); + + // Wheel zoom handler + const handleWheel = useCallback((e: Konva.KonvaEventObject) => { + e.evt.preventDefault(); + + const stage = e.target.getStage(); + if (!stage) return; + + const pointer = stage.getPointerPosition(); + if (!pointer) return; + + const scaleBy = 1.1; + const direction = e.evt.deltaY > 0 ? -1 : 1; + + setStageScale((oldScale) => { + const newScale = Math.max( + 0.1, + Math.min(direction > 0 ? oldScale * scaleBy : oldScale / scaleBy, 2), + ); + + // Zoom towards pointer + setStagePos((prevPos) => { + const mousePointTo = { + x: (pointer.x - prevPos.x) / oldScale, + y: (pointer.y - prevPos.y) / oldScale, + }; + + return { + x: pointer.x - mousePointTo.x * newScale, + y: pointer.y - mousePointTo.y * newScale, + }; + }); + + return newScale; + }); + }, []); + + // Zoom control handlers + const handleZoomIn = useCallback(() => { + setStageScale((oldScale) => { + const newScale = Math.max(0.1, Math.min(oldScale * 1.2, 2)); + + // Zoom towards center of viewport + setStagePos((prevPos) => { + const centerX = containerSize.width / 2; + const centerY = containerSize.height / 2; + + const mousePointTo = { + x: (centerX - prevPos.x) / oldScale, + y: (centerY - prevPos.y) / oldScale, + }; + + return { + x: centerX - mousePointTo.x * newScale, + y: centerY - mousePointTo.y * newScale, + }; + }); + + return newScale; + }); + }, [containerSize]); + + const handleZoomOut = useCallback(() => { + setStageScale((oldScale) => { + const newScale = Math.max(0.1, Math.min(oldScale / 1.2, 2)); + + // Zoom towards center of viewport + setStagePos((prevPos) => { + const centerX = containerSize.width / 2; + const centerY = containerSize.height / 2; + + const mousePointTo = { + x: (centerX - prevPos.x) / oldScale, + y: (centerY - prevPos.y) / oldScale, + }; + + return { + x: centerX - mousePointTo.x * newScale, + y: centerY - mousePointTo.y * newScale, + }; + }); + + return newScale; + }); + }, [containerSize]); + + const handleZoomReset = useCallback(() => { + const initialScale = initialScaleRef.current; + setStageScale(initialScale); + setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 }); + }, [containerSize]); + + const handleCenterPattern = useCallback(() => { + if (!pesData) return; + + const { bounds } = pesData; + const centerOffsetX = -(bounds.minX + bounds.maxX) / 2; + const centerOffsetY = -(bounds.minY + bounds.maxY) / 2; + + setLocalPatternOffset({ x: centerOffsetX, y: centerOffsetY }); + setPatternOffset(centerOffsetX, centerOffsetY); + }, [pesData, setPatternOffset]); + + // Pattern drag handlers + const handlePatternDragEnd = useCallback( + (e: Konva.KonvaEventObject) => { + const newOffset = { + x: e.target.x(), + y: e.target.y(), + }; + setLocalPatternOffset(newOffset); + setPatternOffset(newOffset.x, newOffset.y); + }, + [setPatternOffset], + ); + + const borderColor = pesData + ? "border-tertiary-600 dark:border-tertiary-500" + : "border-gray-400 dark:border-gray-600"; + const iconColor = pesData + ? "text-tertiary-600 dark:text-tertiary-400" + : "text-gray-600 dark:text-gray-400"; + + return ( +
+
+ +
+

+ Pattern Preview +

+ {pesData ? ( +

+ {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} ×{" "} + {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm +

+ ) : ( +

+ No pattern loaded +

+ )} +
+
+
+ {containerSize.width > 0 && ( + { + if (stageRef.current) { + stageRef.current.container().style.cursor = "grabbing"; + } + }} + onDragEnd={() => { + if (stageRef.current) { + stageRef.current.container().style.cursor = "grab"; + } + }} + ref={(node) => { + stageRef.current = node; + if (node) { + node.container().style.cursor = "grab"; + } + }} + > + {/* Background layer: grid, origin, hoop */} + + {pesData && ( + <> + + + {machineInfo && } + + )} + + + {/* Pattern layer: draggable stitches and bounds */} + + {pesData && ( + { + const stage = e.target.getStage(); + if (stage && !patternUploaded && !isUploading) + stage.container().style.cursor = "move"; + }} + onMouseLeave={(e) => { + const stage = e.target.getStage(); + if (stage && !patternUploaded && !isUploading) + stage.container().style.cursor = "grab"; + }} + > + { + // Convert PEN stitch format {x, y, flags, isJump} to PES format [x, y, cmd, colorIndex] + const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump + const colorIndex = + pesData.penStitches.colorBlocks.find( + (b) => i >= b.startStitch && i <= b.endStitch, + )?.colorIndex ?? 0; + return [s.x, s.y, cmd, colorIndex]; + }, + )} + pesData={pesData} + currentStitchIndex={sewingProgress?.currentStitch || 0} + showProgress={patternUploaded || isUploading} + /> + + + )} + + + {/* Current position layer */} + + {pesData && + pesData.penStitches && + sewingProgress && + sewingProgress.currentStitch > 0 && ( + + { + const cmd = s.isJump ? 0x10 : 0; + const colorIndex = + pesData.penStitches.colorBlocks.find( + (b) => i >= b.startStitch && i <= b.endStitch, + )?.colorIndex ?? 0; + return [s.x, s.y, cmd, colorIndex]; + }, + )} + /> + + )} + + + )} + + {/* Placeholder overlay when no pattern is loaded */} + {!pesData && ( +
+ Load a PES file to preview the pattern +
+ )} + + {/* Pattern info overlays */} + {pesData && ( + <> + {/* Thread Legend Overlay */} +
+

+ Colors +

+ {pesData.uniqueColors.map((color, idx) => { + // Primary metadata: brand and catalog number + const primaryMetadata = [ + color.brand, + color.catalogNumber ? `#${color.catalogNumber}` : null, + ] + .filter(Boolean) + .join(" "); + + // Secondary metadata: chart and description + const secondaryMetadata = [color.chart, color.description] + .filter(Boolean) + .join(" "); + + return ( +
+
+
+
+ Color {idx + 1} +
+ {(primaryMetadata || secondaryMetadata) && ( +
+ {primaryMetadata} + {primaryMetadata && secondaryMetadata && ( + + )} + {secondaryMetadata} +
+ )} +
+
+ ); + })} +
+ + {/* Pattern Offset Indicator */} +
+
+
+ Pattern Position: +
+ {patternUploaded && ( +
+ + LOCKED +
+ )} +
+
+ X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y:{" "} + {(localPatternOffset.y / 10).toFixed(1)}mm +
+
+ {patternUploaded + ? "Pattern locked • Drag background to pan" + : "Drag pattern to move • Drag background to pan"} +
+
+ + {/* Zoom Controls Overlay */} +
+ + + + {Math.round(stageScale * 100)}% + + + +
+ + )} +
+
+ ); +} diff --git a/src/components/PatternInfo.tsx b/src/components/PatternInfo.tsx index cbb3d17..94ec215 100644 --- a/src/components/PatternInfo.tsx +++ b/src/components/PatternInfo.tsx @@ -1,75 +1,88 @@ -import type { PesPatternData } from '../formats/import/pesImporter'; +import type { PesPatternData } from "../formats/import/pesImporter"; interface PatternInfoProps { pesData: PesPatternData; showThreadBlocks?: boolean; } -export function PatternInfo({ pesData, showThreadBlocks = false }: PatternInfoProps) { +export function PatternInfo({ + pesData, + showThreadBlocks = false, +}: PatternInfoProps) { return ( <>
Size - {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '} + {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{" "} {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
- Stitches + + Stitches + - {pesData.penStitches?.stitches.length.toLocaleString() || pesData.stitchCount.toLocaleString()} - {pesData.penStitches && pesData.penStitches.stitches.length !== pesData.stitchCount && ( - - ({pesData.stitchCount.toLocaleString()}) - - )} + {pesData.penStitches?.stitches.length.toLocaleString() || + pesData.stitchCount.toLocaleString()} + {pesData.penStitches && + pesData.penStitches.stitches.length !== pesData.stitchCount && ( + + ({pesData.stitchCount.toLocaleString()}) + + )}
- {showThreadBlocks ? 'Colors / Blocks' : 'Colors'} + {showThreadBlocks ? "Colors / Blocks" : "Colors"} {showThreadBlocks ? `${pesData.uniqueColors.length} / ${pesData.threads.length}` - : pesData.uniqueColors.length - } + : pesData.uniqueColors.length}
- Colors: + + Colors: +
{pesData.uniqueColors.slice(0, 8).map((color, idx) => { // Primary metadata: brand and catalog number const primaryMetadata = [ color.brand, - color.catalogNumber ? `#${color.catalogNumber}` : null - ].filter(Boolean).join(" "); + color.catalogNumber ? `#${color.catalogNumber}` : null, + ] + .filter(Boolean) + .join(" "); // Secondary metadata: chart and description - const secondaryMetadata = [ - color.chart, - color.description - ].filter(Boolean).join(" "); + const secondaryMetadata = [color.chart, color.description] + .filter(Boolean) + .join(" "); - const metadata = [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • "); + const metadata = [primaryMetadata, secondaryMetadata] + .filter(Boolean) + .join(" • "); // Show which thread blocks use this color in PatternSummaryCard - const threadNumbers = color.threadIndices.map(i => i + 1).join(", "); + const threadNumbers = color.threadIndices + .map((i) => i + 1) + .join(", "); const tooltipText = showThreadBlocks - ? (metadata - ? `Color ${idx + 1}: ${color.hex} - ${metadata}` - : `Color ${idx + 1}: ${color.hex}`) - : (metadata - ? `Color ${idx + 1}: ${color.hex}\n${metadata}\nUsed in thread blocks: ${threadNumbers}` - : `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`); + ? metadata + ? `Color ${idx + 1}: ${color.hex} - ${metadata}` + : `Color ${idx + 1}: ${color.hex}` + : metadata + ? `Color ${idx + 1}: ${color.hex}\n${metadata}\nUsed in thread blocks: ${threadNumbers}` + : `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`; return (
-

Pattern Preview

+

+ Pattern Preview +

{/* Decorative background pattern */}
@@ -12,18 +14,41 @@ export function PatternPreviewPlaceholder() {
- - + +
- - + +
-

No Pattern Loaded

+

+ No Pattern Loaded +

- Connect to your machine and choose a PES embroidery file to see your design preview + Connect to your machine and choose a PES embroidery file to see your + design preview

diff --git a/src/components/PatternSummaryCard.tsx b/src/components/PatternSummaryCard.tsx index cd809e0..d06cf5e 100644 --- a/src/components/PatternSummaryCard.tsx +++ b/src/components/PatternSummaryCard.tsx @@ -1,33 +1,26 @@ -import { useShallow } from 'zustand/react/shallow'; -import { useMachineStore } from '../stores/useMachineStore'; -import { usePatternStore } from '../stores/usePatternStore'; -import { canDeletePattern } from '../utils/machineStateHelpers'; -import { PatternInfo } from './PatternInfo'; -import { DocumentTextIcon, TrashIcon } from '@heroicons/react/24/solid'; +import { useShallow } from "zustand/react/shallow"; +import { useMachineStore } from "../stores/useMachineStore"; +import { usePatternStore } from "../stores/usePatternStore"; +import { canDeletePattern } from "../utils/machineStateHelpers"; +import { PatternInfo } from "./PatternInfo"; +import { DocumentTextIcon, TrashIcon } from "@heroicons/react/24/solid"; export function PatternSummaryCard() { // Machine store - const { - machineStatus, - isDeleting, - deletePattern, - } = useMachineStore( + const { machineStatus, isDeleting, deletePattern } = useMachineStore( useShallow((state) => ({ machineStatus: state.machineStatus, isDeleting: state.isDeleting, deletePattern: state.deletePattern, - })) + })), ); // Pattern store - const { - pesData, - currentFileName, - } = usePatternStore( + const { pesData, currentFileName } = usePatternStore( useShallow((state) => ({ pesData: state.pesData, currentFileName: state.currentFileName, - })) + })), ); if (!pesData) return null; @@ -38,8 +31,13 @@ export function PatternSummaryCard() {
-

Active Pattern

-

+

+ Active Pattern +

+

{currentFileName}

@@ -55,9 +53,24 @@ export function PatternSummaryCard() { > {isDeleting ? ( <> - - - + + + Deleting... diff --git a/src/components/ProgressMonitor.tsx b/src/components/ProgressMonitor.tsx index 11c3692..5a82a40 100644 --- a/src/components/ProgressMonitor.tsx +++ b/src/components/ProgressMonitor.tsx @@ -1,7 +1,7 @@ import { useRef, useEffect, useState, useMemo } from "react"; -import { useShallow } from 'zustand/react/shallow'; -import { useMachineStore } from '../stores/useMachineStore'; -import { usePatternStore } from '../stores/usePatternStore'; +import { useShallow } from "zustand/react/shallow"; +import { useMachineStore } from "../stores/useMachineStore"; +import { usePatternStore } from "../stores/usePatternStore"; import { CheckCircleIcon, ArrowRightIcon, @@ -42,7 +42,7 @@ export function ProgressMonitor() { startMaskTrace: state.startMaskTrace, startSewing: state.startSewing, resumeSewing: state.resumeSewing, - })) + })), ); // Pattern store @@ -59,14 +59,15 @@ export function ProgressMonitor() { // Use PEN stitch count as fallback when machine reports 0 total stitches const totalStitches = patternInfo - ? (patternInfo.totalStitches === 0 && pesData?.penStitches - ? pesData.penStitches.stitches.length - : patternInfo.totalStitches) + ? patternInfo.totalStitches === 0 && pesData?.penStitches + ? pesData.penStitches.stitches.length + : patternInfo.totalStitches : 0; - const progressPercent = totalStitches > 0 - ? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100 - : 0; + const progressPercent = + totalStitches > 0 + ? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100 + : 0; // Calculate color block information from decoded penStitches const colorBlocks = useMemo(() => { @@ -116,7 +117,10 @@ export function ProgressMonitor() { return { totalMinutes: 0, elapsedMinutes: 0 }; } const result = calculatePatternTime(colorBlocks, currentStitch); - return { totalMinutes: result.totalMinutes, elapsedMinutes: result.elapsedMinutes }; + return { + totalMinutes: result.totalMinutes, + elapsedMinutes: result.elapsedMinutes, + }; }, [colorBlocks, currentStitch]); // Auto-scroll to current block @@ -132,7 +136,8 @@ export function ProgressMonitor() { // Handle scroll to detect if at bottom const handleColorBlocksScroll = () => { if (colorBlocksScrollRef.current) { - const { scrollTop, scrollHeight, clientHeight } = colorBlocksScrollRef.current; + const { scrollTop, scrollHeight, clientHeight } = + colorBlocksScrollRef.current; const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; // 5px threshold setShowGradient(!isAtBottom); } @@ -149,8 +154,8 @@ export function ProgressMonitor() { }; checkScrollable(); - window.addEventListener('resize', checkScrollable); - return () => window.removeEventListener('resize', checkScrollable); + window.addEventListener("resize", checkScrollable); + return () => window.removeEventListener("resize", checkScrollable); }, [colorBlocks]); const stateIndicatorColors = { @@ -300,113 +305,124 @@ export function ProgressMonitor() { className="lg:absolute lg:inset-0 flex flex-col gap-2 lg:overflow-y-auto scroll-smooth pr-1 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-gray-100 dark:[&::-webkit-scrollbar-track]:bg-gray-700 [&::-webkit-scrollbar-thumb]:bg-primary-600 dark:[&::-webkit-scrollbar-thumb]:bg-primary-500 [&::-webkit-scrollbar-thumb]:rounded-full" > {colorBlocks.map((block, index) => { - const isCompleted = currentStitch >= block.endStitch; - const isCurrent = index === currentBlockIndex; + const isCompleted = currentStitch >= block.endStitch; + const isCurrent = index === currentBlockIndex; - // Calculate progress within current block - let blockProgress = 0; - if (isCurrent) { - blockProgress = - ((currentStitch - block.startStitch) / block.stitchCount) * - 100; - } else if (isCompleted) { - blockProgress = 100; - } + // Calculate progress within current block + let blockProgress = 0; + if (isCurrent) { + blockProgress = + ((currentStitch - block.startStitch) / block.stitchCount) * + 100; + } else if (isCompleted) { + blockProgress = 100; + } - return ( -
-
- {/* Color swatch */} -
+ return ( +
+
+ {/* Color swatch */} +
- {/* Thread info */} -
-
- Thread {block.colorIndex + 1} - {(block.threadBrand || block.threadChart || block.threadDescription || block.threadCatalogNumber) && ( - - {" "} - ( - {(() => { - // Primary metadata: brand and catalog number - const primaryMetadata = [ - block.threadBrand, - block.threadCatalogNumber ? `#${block.threadCatalogNumber}` : null - ].filter(Boolean).join(" "); + {/* Thread info */} +
+
+ Thread {block.colorIndex + 1} + {(block.threadBrand || + block.threadChart || + block.threadDescription || + block.threadCatalogNumber) && ( + + {" "} + ( + {(() => { + // Primary metadata: brand and catalog number + const primaryMetadata = [ + block.threadBrand, + block.threadCatalogNumber + ? `#${block.threadCatalogNumber}` + : null, + ] + .filter(Boolean) + .join(" "); - // Secondary metadata: chart and description - const secondaryMetadata = [ - block.threadChart, - block.threadDescription - ].filter(Boolean).join(" "); + // Secondary metadata: chart and description + const secondaryMetadata = [ + block.threadChart, + block.threadDescription, + ] + .filter(Boolean) + .join(" "); - return [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • "); - })()} - ) - - )} -
-
- {block.stitchCount.toLocaleString()} stitches + return [primaryMetadata, secondaryMetadata] + .filter(Boolean) + .join(" • "); + })()} + ) + + )} +
+
+ {block.stitchCount.toLocaleString()} stitches +
+ + {/* Status icon */} + {isCompleted ? ( + + ) : isCurrent ? ( + + ) : ( + + )}
- {/* Status icon */} - {isCompleted ? ( - - ) : isCurrent ? ( - - ) : ( - + {/* Progress bar for current block */} + {isCurrent && ( +
+
+
)}
- - {/* Progress bar for current block */} - {isCurrent && ( -
-
-
- )} -
- ); - })} + ); + })}
{/* Gradient overlay to indicate more content below - only on desktop and when not at bottom */} {showGradient && ( diff --git a/src/components/SkeletonLoader.tsx b/src/components/SkeletonLoader.tsx index a4582e7..a7447c9 100644 --- a/src/components/SkeletonLoader.tsx +++ b/src/components/SkeletonLoader.tsx @@ -1,15 +1,19 @@ interface SkeletonLoaderProps { className?: string; - variant?: 'text' | 'rect' | 'circle'; + variant?: "text" | "rect" | "circle"; } -export function SkeletonLoader({ className = '', variant = 'rect' }: SkeletonLoaderProps) { - const baseClasses = 'animate-pulse bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 bg-[length:200%_100%]'; +export function SkeletonLoader({ + className = "", + variant = "rect", +}: SkeletonLoaderProps) { + const baseClasses = + "animate-pulse bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 bg-[length:200%_100%]"; const variantClasses = { - text: 'h-4 rounded', - rect: 'rounded-lg', - circle: 'rounded-full' + text: "h-4 rounded", + rect: "rounded-lg", + circle: "rounded-full", }; return ( @@ -29,9 +33,24 @@ export function PatternCanvasSkeleton() {
- - - + + +
diff --git a/src/components/WorkflowStepper.tsx b/src/components/WorkflowStepper.tsx index da0d176..150c0ec 100644 --- a/src/components/WorkflowStepper.tsx +++ b/src/components/WorkflowStepper.tsx @@ -1,10 +1,14 @@ -import { useState, useRef, useEffect } from 'react'; -import { useShallow } from 'zustand/react/shallow'; -import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore'; -import { usePatternStore } from '../stores/usePatternStore'; -import { CheckCircleIcon, InformationCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/solid'; -import { MachineStatus } from '../types/machine'; -import { getErrorDetails, hasError } from '../utils/errorCodeHelpers'; +import { useState, useRef, useEffect } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore"; +import { usePatternStore } from "../stores/usePatternStore"; +import { + CheckCircleIcon, + InformationCircleIcon, + ExclamationTriangleIcon, +} from "@heroicons/react/24/solid"; +import { MachineStatus } from "../types/machine"; +import { getErrorDetails, hasError } from "../utils/errorCodeHelpers"; interface Step { id: number; @@ -13,14 +17,14 @@ interface Step { } const steps: Step[] = [ - { id: 1, label: 'Connect', description: 'Connect to machine' }, - { id: 2, label: 'Home Machine', description: 'Initialize hoop position' }, - { id: 3, label: 'Load Pattern', description: 'Choose PES file' }, - { id: 4, label: 'Upload', description: 'Upload to machine' }, - { id: 5, label: 'Mask Trace', description: 'Trace pattern area' }, - { id: 6, label: 'Start Sewing', description: 'Begin embroidery' }, - { id: 7, label: 'Monitor', description: 'Watch progress' }, - { id: 8, label: 'Complete', description: 'Finish and remove' }, + { id: 1, label: "Connect", description: "Connect to machine" }, + { id: 2, label: "Home Machine", description: "Initialize hoop position" }, + { id: 3, label: "Load Pattern", description: "Choose PES file" }, + { id: 4, label: "Upload", description: "Upload to machine" }, + { id: 5, label: "Mask Trace", description: "Trace pattern area" }, + { id: 6, label: "Start Sewing", description: "Begin embroidery" }, + { id: 7, label: "Monitor", description: "Watch progress" }, + { id: 8, label: "Complete", description: "Finish and remove" }, ]; // Helper function to get guide content for a step @@ -29,7 +33,7 @@ function getGuideContent( machineStatus: MachineStatus, hasError: boolean, errorCode?: number, - errorMessage?: string + errorMessage?: string, ) { // Check for errors first if (hasError) { @@ -37,19 +41,22 @@ function getGuideContent( if (errorDetails?.isInformational) { return { - type: 'info' as const, + type: "info" as const, title: errorDetails.title, description: errorDetails.description, - items: errorDetails.solutions || [] + items: errorDetails.solutions || [], }; } return { - type: 'error' as const, - title: errorDetails?.title || 'Error Occurred', - description: errorDetails?.description || errorMessage || 'An error occurred. Please check the machine and try again.', + type: "error" as const, + title: errorDetails?.title || "Error Occurred", + description: + errorDetails?.description || + errorMessage || + "An error occurred. Please check the machine and try again.", items: errorDetails?.solutions || [], - errorCode + errorCode, }; } @@ -57,156 +64,166 @@ function getGuideContent( switch (stepId) { case 1: return { - type: 'info' as const, - title: 'Step 1: Connect to Machine', - description: 'To get started, connect to your Brother embroidery machine via Bluetooth.', + type: "info" as const, + title: "Step 1: Connect to Machine", + description: + "To get started, connect to your Brother embroidery machine via Bluetooth.", items: [ - 'Make sure your machine is powered on', - 'Enable Bluetooth on your machine', - 'Click the "Connect to Machine" button below' - ] + "Make sure your machine is powered on", + "Enable Bluetooth on your machine", + 'Click the "Connect to Machine" button below', + ], }; case 2: return { - type: 'info' as const, - title: 'Step 2: Home Machine', - description: 'The hoop needs to be removed and an initial homing procedure must be performed.', + type: "info" as const, + title: "Step 2: Home Machine", + description: + "The hoop needs to be removed and an initial homing procedure must be performed.", items: [ - 'Remove the embroidery hoop from the machine completely', - 'Press the Accept button on the machine', - 'Wait for the machine to complete its initialization (homing)', - 'Once initialization is complete, reattach the hoop', - 'The machine should now recognize the hoop correctly' - ] + "Remove the embroidery hoop from the machine completely", + "Press the Accept button on the machine", + "Wait for the machine to complete its initialization (homing)", + "Once initialization is complete, reattach the hoop", + "The machine should now recognize the hoop correctly", + ], }; case 3: return { - type: 'info' as const, - title: 'Step 3: Load Your Pattern', - description: 'Choose a PES embroidery file from your computer to preview and upload.', + type: "info" as const, + title: "Step 3: Load Your Pattern", + description: + "Choose a PES embroidery file from your computer to preview and upload.", items: [ 'Click "Choose PES File" in the Pattern File section', - 'Select your embroidery design (.pes file)', - 'Review the pattern preview on the right', - 'You can drag the pattern to adjust its position' - ] + "Select your embroidery design (.pes file)", + "Review the pattern preview on the right", + "You can drag the pattern to adjust its position", + ], }; case 4: return { - type: 'info' as const, - title: 'Step 4: Upload Pattern to Machine', - description: 'Send your pattern to the embroidery machine to prepare for sewing.', + type: "info" as const, + title: "Step 4: Upload Pattern to Machine", + description: + "Send your pattern to the embroidery machine to prepare for sewing.", items: [ - 'Review the pattern preview to ensure it\'s positioned correctly', - 'Check the pattern size matches your hoop', + "Review the pattern preview to ensure it's positioned correctly", + "Check the pattern size matches your hoop", 'Click "Upload to Machine" when ready', - 'Wait for the upload to complete (this may take a minute)' - ] + "Wait for the upload to complete (this may take a minute)", + ], }; case 5: // Check machine status for substates if (machineStatus === MachineStatus.MASK_TRACE_LOCK_WAIT) { return { - type: 'warning' as const, - title: 'Machine Action Required', - description: 'The machine is ready to trace the pattern outline.', + type: "warning" as const, + title: "Machine Action Required", + description: "The machine is ready to trace the pattern outline.", items: [ - 'Press the button on your machine to confirm and start the mask trace', - 'Ensure the hoop is properly attached', - 'Make sure the needle area is clear' - ] + "Press the button on your machine to confirm and start the mask trace", + "Ensure the hoop is properly attached", + "Make sure the needle area is clear", + ], }; } if (machineStatus === MachineStatus.MASK_TRACING) { return { - type: 'progress' as const, - title: 'Mask Trace In Progress', - description: 'The machine is tracing the pattern boundary. Please wait...', + type: "progress" as const, + title: "Mask Trace In Progress", + description: + "The machine is tracing the pattern boundary. Please wait...", items: [ - 'Watch the machine trace the outline', - 'Verify the pattern fits within your hoop', - 'Do not interrupt the machine' - ] + "Watch the machine trace the outline", + "Verify the pattern fits within your hoop", + "Do not interrupt the machine", + ], }; } return { - type: 'info' as const, - title: 'Step 5: Start Mask Trace', - description: 'The mask trace helps the machine understand the pattern boundaries.', + type: "info" as const, + title: "Step 5: Start Mask Trace", + description: + "The mask trace helps the machine understand the pattern boundaries.", items: [ 'Click "Start Mask Trace" button in the Sewing Progress section', - 'The machine will trace the pattern outline', - 'This ensures the hoop is positioned correctly' - ] + "The machine will trace the pattern outline", + "This ensures the hoop is positioned correctly", + ], }; case 6: return { - type: 'success' as const, - title: 'Step 6: Ready to Sew!', - description: 'The machine is ready to begin embroidering your pattern.', + type: "success" as const, + title: "Step 6: Ready to Sew!", + description: "The machine is ready to begin embroidering your pattern.", items: [ - 'Verify your thread colors are correct', - 'Ensure the fabric is properly hooped', - 'Click "Start Sewing" when ready' - ] + "Verify your thread colors are correct", + "Ensure the fabric is properly hooped", + 'Click "Start Sewing" when ready', + ], }; case 7: // Check for substates if (machineStatus === MachineStatus.COLOR_CHANGE_WAIT) { return { - type: 'warning' as const, - title: 'Thread Change Required', - description: 'The machine needs a different thread color to continue.', + type: "warning" as const, + title: "Thread Change Required", + description: + "The machine needs a different thread color to continue.", items: [ - 'Check the color blocks section to see which thread is needed', - 'Change to the correct thread color', - 'Press the button on your machine to resume sewing' - ] + "Check the color blocks section to see which thread is needed", + "Change to the correct thread color", + "Press the button on your machine to resume sewing", + ], }; } - if (machineStatus === MachineStatus.PAUSE || - machineStatus === MachineStatus.STOP || - machineStatus === MachineStatus.SEWING_INTERRUPTION) { + if ( + machineStatus === MachineStatus.PAUSE || + machineStatus === MachineStatus.STOP || + machineStatus === MachineStatus.SEWING_INTERRUPTION + ) { return { - type: 'warning' as const, - title: 'Sewing Paused', - description: 'The embroidery has been paused or interrupted.', + type: "warning" as const, + title: "Sewing Paused", + description: "The embroidery has been paused or interrupted.", items: [ - 'Check if everything is okay with the machine', + "Check if everything is okay with the machine", 'Click "Resume Sewing" when ready to continue', - 'The machine will pick up where it left off' - ] + "The machine will pick up where it left off", + ], }; } return { - type: 'progress' as const, - title: 'Step 7: Sewing In Progress', - description: 'Your embroidery is being stitched. Monitor the progress below.', + type: "progress" as const, + title: "Step 7: Sewing In Progress", + description: + "Your embroidery is being stitched. Monitor the progress below.", items: [ - 'Watch the progress bar and current stitch count', - 'The machine will pause when a color change is needed', - 'Do not leave the machine unattended' - ] + "Watch the progress bar and current stitch count", + "The machine will pause when a color change is needed", + "Do not leave the machine unattended", + ], }; case 8: return { - type: 'success' as const, - title: 'Step 8: Embroidery Complete!', - description: 'Your embroidery is finished. Great work!', + type: "success" as const, + title: "Step 8: Embroidery Complete!", + description: "Your embroidery is finished. Great work!", items: [ - 'Remove the hoop from the machine', - 'Press the Accept button on the machine', - 'Carefully remove your finished embroidery', - 'Trim any jump stitches or loose threads', - 'Click "Delete Pattern" to start a new project' - ] + "Remove the hoop from the machine", + "Press the Accept button on the machine", + "Carefully remove your finished embroidery", + "Trim any jump stitches or loose threads", + 'Click "Delete Pattern" to start a new project', + ], }; default: @@ -214,7 +231,12 @@ function getGuideContent( } } -function getCurrentStep(machineStatus: MachineStatus, isConnected: boolean, hasPattern: boolean, patternUploaded: boolean): number { +function getCurrentStep( + machineStatus: MachineStatus, + isConnected: boolean, + hasPattern: boolean, + patternUploaded: boolean, +): number { if (!isConnected) return 1; // Check if machine needs homing (Initial state) @@ -262,23 +284,26 @@ export function WorkflowStepper() { isConnected: state.isConnected, machineError: state.machineError, error: state.error, - })) + })), ); // Pattern store - const { - pesData, - } = usePatternStore( + const { pesData } = usePatternStore( useShallow((state) => ({ pesData: state.pesData, - })) + })), ); // Derived state: pattern is uploaded if machine has pattern info const patternUploaded = usePatternUploaded(); const hasPattern = pesData !== null; const hasErrorFlag = hasError(machineError); - const currentStep = getCurrentStep(machineStatus, isConnected, hasPattern, patternUploaded); + const currentStep = getCurrentStep( + machineStatus, + isConnected, + hasPattern, + patternUploaded, + ); const [showPopover, setShowPopover] = useState(false); const [popoverStep, setPopoverStep] = useState(null); const popoverRef = useRef(null); @@ -287,10 +312,13 @@ export function WorkflowStepper() { // Close popover when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) { + if ( + popoverRef.current && + !popoverRef.current.contains(event.target as Node) + ) { // Check if click was on a step circle - const clickedStep = Object.values(stepRefs.current).find(ref => - ref?.contains(event.target as Node) + const clickedStep = Object.values(stepRefs.current).find((ref) => + ref?.contains(event.target as Node), ); if (!clickedStep) { setShowPopover(false); @@ -299,8 +327,9 @@ export function WorkflowStepper() { }; if (showPopover) { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); } }, [showPopover]); @@ -318,16 +347,23 @@ export function WorkflowStepper() { }; return ( -
+
{/* Progress bar background */} -
+
{/* Progress bar fill */}
{/* Step circle */}
{ stepRefs.current[step.id] = el; }} + ref={(el) => { + stepRefs.current[step.id] = el; + }} onClick={() => handleStepClick(step.id)} className={` w-8 h-8 lg:w-10 lg:h-10 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-300 border-2 shadow-md - ${step.id <= currentStep ? 'cursor-pointer hover:scale-110' : 'cursor-not-allowed'} - ${isComplete ? 'bg-success-500 dark:bg-success-600 border-success-400 dark:border-success-500 text-white shadow-success-500/30 dark:shadow-success-600/30' : ''} - ${isCurrent ? 'bg-primary-600 dark:bg-primary-700 border-primary-500 dark:border-primary-600 text-white scale-105 lg:scale-110 shadow-primary-600/40 dark:shadow-primary-700/40 ring-2 ring-primary-300 dark:ring-primary-500 ring-offset-2 dark:ring-offset-gray-900' : ''} - ${isUpcoming ? 'bg-primary-700 dark:bg-primary-800 border-primary-500/30 dark:border-primary-600/30 text-primary-200/70 dark:text-primary-300/70' : ''} - ${showPopover && popoverStep === step.id ? 'ring-4 ring-white dark:ring-gray-800' : ''} + ${step.id <= currentStep ? "cursor-pointer hover:scale-110" : "cursor-not-allowed"} + ${isComplete ? "bg-success-500 dark:bg-success-600 border-success-400 dark:border-success-500 text-white shadow-success-500/30 dark:shadow-success-600/30" : ""} + ${isCurrent ? "bg-primary-600 dark:bg-primary-700 border-primary-500 dark:border-primary-600 text-white scale-105 lg:scale-110 shadow-primary-600/40 dark:shadow-primary-700/40 ring-2 ring-primary-300 dark:ring-primary-500 ring-offset-2 dark:ring-offset-gray-900" : ""} + ${isUpcoming ? "bg-primary-700 dark:bg-primary-800 border-primary-500/30 dark:border-primary-600/30 text-primary-200/70 dark:text-primary-300/70" : ""} + ${showPopover && popoverStep === step.id ? "ring-4 ring-white dark:ring-gray-800" : ""} `} - aria-label={`${step.label}: ${isComplete ? 'completed' : isCurrent ? 'current' : 'upcoming'}. Click for details.`} + aria-label={`${step.label}: ${isComplete ? "completed" : isCurrent ? "current" : "upcoming"}. Click for details.`} role="button" tabIndex={step.id <= currentStep ? 0 : -1} > {isComplete ? ( -