diff --git a/eslint.config.mjs b/eslint.config.mjs index 5e6b472..ff46326 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', '.vite']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/package-lock.json b/package-lock.json index e4e05e3..43c7c84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,8 @@ "react-dom": "^19.2.0", "react-konva": "^19.2.1", "tailwindcss": "^4.1.17", - "update-electron-app": "^3.1.2" + "update-electron-app": "^3.1.2", + "zustand": "^5.0.9" }, "devDependencies": { "@electron-forge/cli": "^7.10.2", @@ -15253,6 +15254,34 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 6a02677..676f159 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "react-dom": "^19.2.0", "react-konva": "^19.2.1", "tailwindcss": "^4.1.17", - "update-electron-app": "^3.1.2" + "update-electron-app": "^3.1.2", + "zustand": "^5.0.9" }, "devDependencies": { "@electron-forge/cli": "^7.10.2", diff --git a/src/App.tsx b/src/App.tsx index 55df094..98ea17f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,43 +1,92 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { useBrotherMachine } from './hooks/useBrotherMachine'; +import { useEffect, useRef } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { useMachineStore } from './stores/useMachineStore'; +import { usePatternStore } from './stores/usePatternStore'; +import { useUIStore } from './stores/useUIStore'; import { FileUpload } from './components/FileUpload'; import { PatternCanvas } from './components/PatternCanvas'; import { ProgressMonitor } from './components/ProgressMonitor'; import { WorkflowStepper } from './components/WorkflowStepper'; import { PatternSummaryCard } from './components/PatternSummaryCard'; import { BluetoothDevicePicker } from './components/BluetoothDevicePicker'; -import type { PesPatternData } from './utils/pystitchConverter'; -import { pyodideLoader } from './utils/pyodideLoader'; -import { hasError, getErrorDetails } from './utils/errorCodeHelpers'; -import { canDeletePattern, getStateVisualInfo } from './utils/machineStateHelpers'; +import { getErrorDetails } from './utils/errorCodeHelpers'; +import { getStateVisualInfo } from './utils/machineStateHelpers'; import { CheckCircleIcon, BoltIcon, PauseCircleIcon, ExclamationTriangleIcon, ArrowPathIcon, XMarkIcon, InformationCircleIcon } from '@heroicons/react/24/solid'; import './App.css'; function App() { - const machine = useBrotherMachine(); - const [pesData, setPesData] = useState(null); - const [pyodideReady, setPyodideReady] = useState(false); - const [pyodideError, setPyodideError] = useState(null); - const [patternOffset, setPatternOffset] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); - const [patternUploaded, setPatternUploaded] = useState(false); - const [currentFileName, setCurrentFileName] = useState(''); // Track current pattern filename - const [showErrorPopover, setShowErrorPopover] = useState(false); + // Machine store + const { + isConnected, + machineInfo, + machineStatus, + machineStatusName, + machineError, + patternInfo, + error: machineErrorMessage, + isPairingError, + isCommunicating: isPolling, + resumeFileName, + resumedPattern, + connect, + disconnect, + } = useMachineStore( + useShallow((state) => ({ + isConnected: state.isConnected, + machineInfo: state.machineInfo, + machineStatus: state.machineStatus, + machineStatusName: state.machineStatusName, + machineError: state.machineError, + patternInfo: state.patternInfo, + error: state.error, + isPairingError: state.isPairingError, + isCommunicating: state.isCommunicating, + resumeFileName: state.resumeFileName, + resumedPattern: state.resumedPattern, + connect: state.connect, + disconnect: state.disconnect, + })) + ); + + // Pattern store + const { + pesData, + patternUploaded, + setPattern, + setPatternOffset, + setPatternUploaded, + } = usePatternStore( + useShallow((state) => ({ + pesData: state.pesData, + patternUploaded: state.patternUploaded, + setPattern: state.setPattern, + setPatternOffset: state.setPatternOffset, + setPatternUploaded: state.setPatternUploaded, + })) + ); + + // UI store + const { + pyodideError, + showErrorPopover, + initializePyodide, + setErrorPopover, + } = useUIStore( + useShallow((state) => ({ + pyodideError: state.pyodideError, + showErrorPopover: state.showErrorPopover, + initializePyodide: state.initializePyodide, + setErrorPopover: state.setErrorPopover, + })) + ); + const errorPopoverRef = useRef(null); const errorButtonRef = useRef(null); // Initialize Pyodide on mount useEffect(() => { - pyodideLoader - .initialize() - .then(() => { - setPyodideReady(true); - console.log('[App] Pyodide initialized successfully'); - }) - .catch((err) => { - setPyodideError(err instanceof Error ? err.message : 'Failed to initialize Python environment'); - console.error('[App] Failed to initialize Pyodide:', err); - }); - }, []); + initializePyodide(); + }, [initializePyodide]); // Close error popover when clicking outside useEffect(() => { @@ -48,7 +97,7 @@ function App() { errorButtonRef.current && !errorButtonRef.current.contains(event.target as Node) ) { - setShowErrorPopover(false); + setErrorPopover(false); } }; @@ -56,54 +105,20 @@ function App() { document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); } - }, [showErrorPopover]); + }, [showErrorPopover, setErrorPopover]); // Auto-load cached pattern when available - const resumedPattern = machine.resumedPattern; - const resumeFileName = machine.resumeFileName; - if (resumedPattern && !pesData) { console.log('[App] Loading resumed pattern:', resumeFileName, 'Offset:', resumedPattern.patternOffset); - setPesData(resumedPattern.pesData); + setPattern(resumedPattern.pesData, resumeFileName || ''); // Restore the cached pattern offset if (resumedPattern.patternOffset) { - setPatternOffset(resumedPattern.patternOffset); - } - // Preserve the filename from cache - if (resumeFileName) { - setCurrentFileName(resumeFileName); + setPatternOffset(resumedPattern.patternOffset.x, resumedPattern.patternOffset.y); } } - const handlePatternLoaded = useCallback((data: PesPatternData, fileName: string) => { - setPesData(data); - setCurrentFileName(fileName); - // Reset pattern offset when new pattern is loaded - setPatternOffset({ x: 0, y: 0 }); - setPatternUploaded(false); - }, []); - - const handlePatternOffsetChange = useCallback((offsetX: number, offsetY: number) => { - setPatternOffset({ x: offsetX, y: offsetY }); - console.log('[App] Pattern offset changed:', { x: offsetX, y: offsetY }); - }, []); - - const handleUpload = useCallback(async (penData: Uint8Array, pesData: PesPatternData, fileName: string, patternOffset?: { x: number; y: number }) => { - await machine.uploadPattern(penData, pesData, fileName, patternOffset); - setPatternUploaded(true); - }, [machine]); - - const handleDeletePattern = useCallback(async () => { - await machine.deletePattern(); - setPatternUploaded(false); - // NOTE: We intentionally DON'T clear setPesData(null) here - // so the pattern remains visible in the canvas for re-editing and re-uploading - }, [machine]); // Track pattern uploaded state based on machine status - const isConnected = machine.isConnected; - const patternInfo = machine.patternInfo; - if (!isConnected) { if (patternUploaded) { setPatternUploaded(false); @@ -117,7 +132,7 @@ function App() { } // Get state visual info for header status badge - const stateVisual = getStateVisualInfo(machine.machineStatus); + const stateVisual = getStateVisualInfo(machineStatus); const stateIcons = { ready: CheckCircleIcon, active: BoltIcon, @@ -134,40 +149,40 @@ function App() {
{/* Machine Connection Status - Responsive width column */}
-
-
+
+

Respira

- {machine.isConnected && machine.machineInfo?.serialNumber && ( + {isConnected && machineInfo?.serialNumber && ( - • {machine.machineInfo.serialNumber} + • {machineInfo.serialNumber} )} - {machine.isPolling && ( + {isPolling && ( )}
- {machine.isConnected ? ( + {isConnected ? ( <> {/* Error popover */} - {showErrorPopover && (machine.error || pyodideError) && ( + {showErrorPopover && (machineErrorMessage || pyodideError) && (
{(() => { - const errorDetails = getErrorDetails(machine.machineError); - const isPairingError = machine.isPairingError; - const errorMsg = pyodideError || machine.error || ''; - const isInfo = isPairingError || errorDetails?.isInformational; + const errorDetails = getErrorDetails(machineError); + const isPairingErr = isPairingError; + const errorMsg = pyodideError || machineErrorMessage || ''; + const isInfo = isPairingErr || errorDetails?.isInformational; const bgColor = isInfo ? 'bg-blue-50 dark:bg-blue-900/95 border-blue-600 dark:border-blue-500' @@ -261,7 +276,7 @@ function App() { : 'text-red-700 dark:text-red-300'; const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon; - const title = errorDetails?.title || (isPairingError ? 'Pairing Required' : 'Error'); + const title = errorDetails?.title || (isPairingErr ? 'Pairing Required' : 'Error'); return (
@@ -286,9 +301,9 @@ function App() { )} - {machine.machineError !== undefined && !errorDetails?.isInformational && ( + {machineError !== undefined && !errorDetails?.isInformational && (

- Error Code: 0x{machine.machineError.toString(16).toUpperCase().padStart(2, '0')} + Error Code: 0x{machineError.toString(16).toUpperCase().padStart(2, '0')}

)}
@@ -305,15 +320,7 @@ function App() { {/* Workflow Stepper - Flexible width column */}
- +
@@ -323,7 +330,7 @@ function App() { {/* Left Column - Controls */}
{/* Connect Button - Show when disconnected */} - {!machine.isConnected && ( + {!isConnected && (
@@ -337,7 +344,7 @@ function App() {
@@ -397,15 +373,7 @@ function App() { {/* Right Column - Pattern Preview */}
{pesData ? ( - 0 && machine.uploadProgress < 100} - /> + ) : (

Pattern Preview

diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index 505366f..36fcf84 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -1,45 +1,58 @@ import { useState, useCallback } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { useMachineStore } from '../stores/useMachineStore'; +import { usePatternStore } from '../stores/usePatternStore'; +import { useUIStore } from '../stores/useUIStore'; import { convertPesToPen, type PesPatternData } from '../utils/pystitchConverter'; -import { MachineStatus, type MachineInfo } from '../types/machine'; import { canUploadPattern, getMachineStateCategory } from '../utils/machineStateHelpers'; import { PatternInfoSkeleton } from './SkeletonLoader'; import { ArrowUpTrayIcon, CheckCircleIcon, DocumentTextIcon, FolderOpenIcon } from '@heroicons/react/24/solid'; import { createFileService } from '../platform'; import type { IFileService } from '../platform/interfaces/IFileService'; -interface FileUploadProps { - isConnected: boolean; - machineStatus: MachineStatus; - uploadProgress: number; - onPatternLoaded: (pesData: PesPatternData, fileName: string) => void; - onUpload: (penData: Uint8Array, pesData: PesPatternData, fileName: string, patternOffset?: { x: number; y: number }) => void; - pyodideReady: boolean; - patternOffset: { x: number; y: number }; - patternUploaded: boolean; - resumeAvailable: boolean; - resumeFileName: string | null; - pesData: PesPatternData | null; - currentFileName: string; - isUploading?: boolean; - machineInfo: MachineInfo | null; -} +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, + })) + ); -export function FileUpload({ - isConnected, - machineStatus, - uploadProgress, - onPatternLoaded, - onUpload, - pyodideReady, - patternOffset, - patternUploaded, - resumeAvailable, - resumeFileName, - pesData: pesDataProp, - currentFileName, - isUploading = false, - machineInfo, -}: FileUploadProps) { + // Pattern store + const { + pesData: pesDataProp, + currentFileName, + patternOffset, + patternUploaded, + setPattern, + } = usePatternStore( + useShallow((state) => ({ + pesData: state.pesData, + currentFileName: state.currentFileName, + patternOffset: state.patternOffset, + patternUploaded: state.patternUploaded, + setPattern: state.setPattern, + })) + ); + + // UI store + const pyodideReady = useUIStore((state) => state.pyodideReady); const [localPesData, setLocalPesData] = useState(null); const [fileName, setFileName] = useState(''); const [fileService] = useState(() => createFileService()); @@ -77,7 +90,7 @@ export function FileUpload({ const data = await convertPesToPen(file); setLocalPesData(data); setFileName(file.name); - onPatternLoaded(data, file.name); + setPattern(data, file.name); } catch (err) { alert( `Failed to load PES file: ${ @@ -88,14 +101,14 @@ export function FileUpload({ setIsLoading(false); } }, - [fileService, onPatternLoaded, pyodideReady] + [fileService, setPattern, pyodideReady] ); const handleUpload = useCallback(() => { if (pesData && displayFileName) { - onUpload(pesData.penData, pesData, displayFileName, patternOffset); + uploadPattern(pesData.penData, pesData, displayFileName, patternOffset); } - }, [pesData, displayFileName, onUpload, patternOffset]); + }, [pesData, displayFileName, uploadPattern, patternOffset]); // Check if pattern (with offset) fits within hoop bounds const checkPatternFitsInHoop = useCallback(() => { diff --git a/src/components/PatternCanvas.tsx b/src/components/PatternCanvas.tsx index 86eaf57..e7367ed 100644 --- a/src/components/PatternCanvas.tsx +++ b/src/components/PatternCanvas.tsx @@ -1,39 +1,58 @@ import { useEffect, useRef, useState, useCallback } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { useMachineStore } 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 } from '@heroicons/react/24/solid'; import type { PesPatternData } from '../utils/pystitchConverter'; -import type { SewingProgress, MachineInfo } from '../types/machine'; import { calculateInitialScale } from '../utils/konvaRenderers'; import { Grid, Origin, Hoop, Stitches, PatternBounds, CurrentPosition } from './KonvaComponents'; -interface PatternCanvasProps { - pesData: PesPatternData | null; - sewingProgress: SewingProgress | null; - machineInfo: MachineInfo | null; - initialPatternOffset?: { x: number; y: number }; - onPatternOffsetChange?: (offsetX: number, offsetY: number) => void; - patternUploaded?: boolean; - isUploading?: boolean; -} +export function PatternCanvas() { + // Machine store + const { + sewingProgress, + machineInfo, + isUploading, + } = useMachineStore( + useShallow((state) => ({ + sewingProgress: state.sewingProgress, + machineInfo: state.machineInfo, + isUploading: state.isUploading, + })) + ); -export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPatternOffset, onPatternOffsetChange, patternUploaded = false, isUploading = false }: PatternCanvasProps) { + // Pattern store + const { + pesData, + patternOffset: initialPatternOffset, + patternUploaded, + setPatternOffset, + } = usePatternStore( + useShallow((state) => ({ + pesData: state.pesData, + patternOffset: state.patternOffset, + patternUploaded: state.patternUploaded, + setPatternOffset: state.setPatternOffset, + })) + ); const containerRef = useRef(null); const stageRef = useRef(null); const [stagePos, setStagePos] = useState({ x: 0, y: 0 }); const [stageScale, setStageScale] = useState(1); - const [patternOffset, setPatternOffset] = useState(initialPatternOffset || { x: 0, y: 0 }); + 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 && ( - patternOffset.x !== initialPatternOffset.x || - patternOffset.y !== initialPatternOffset.y + localPatternOffset.x !== initialPatternOffset.x || + localPatternOffset.y !== initialPatternOffset.y )) { - setPatternOffset(initialPatternOffset); + setLocalPatternOffset(initialPatternOffset); console.log('[PatternCanvas] Restored pattern offset:', initialPatternOffset); } @@ -178,12 +197,9 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPat x: e.target.x(), y: e.target.y(), }; - setPatternOffset(newOffset); - - if (onPatternOffsetChange) { - onPatternOffsetChange(newOffset.x, newOffset.y); - } - }, [onPatternOffsetChange]); + setLocalPatternOffset(newOffset); + setPatternOffset(newOffset.x, newOffset.y); + }, [setPatternOffset]); const borderColor = pesData ? 'border-teal-600 dark:border-teal-500' : 'border-gray-400 dark:border-gray-600'; const iconColor = pesData ? 'text-teal-600 dark:text-teal-400' : 'text-gray-600 dark:text-gray-400'; @@ -252,8 +268,8 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPat { const stage = e.target.getStage(); @@ -278,7 +294,7 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPat {/* Current position layer */} {pesData && sewingProgress && sewingProgress.currentStitch > 0 && ( - +
- X: {(patternOffset.x / 10).toFixed(1)}mm, Y: {(patternOffset.y / 10).toFixed(1)}mm + 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'} diff --git a/src/components/PatternSummaryCard.tsx b/src/components/PatternSummaryCard.tsx index e42e5e7..9abc0ca 100644 --- a/src/components/PatternSummaryCard.tsx +++ b/src/components/PatternSummaryCard.tsx @@ -1,29 +1,45 @@ +import { useShallow } from 'zustand/react/shallow'; +import { useMachineStore } from '../stores/useMachineStore'; +import { usePatternStore } from '../stores/usePatternStore'; +import { canDeletePattern } from '../utils/machineStateHelpers'; import { DocumentTextIcon, TrashIcon } from '@heroicons/react/24/solid'; -import type { PesPatternData } from '../utils/pystitchConverter'; -interface PatternSummaryCardProps { - pesData: PesPatternData; - fileName: string; - onDeletePattern: () => void; - canDelete: boolean; - isDeleting: boolean; -} +export function PatternSummaryCard() { + // Machine store + const { + machineStatus, + isDeleting, + deletePattern, + } = useMachineStore( + useShallow((state) => ({ + machineStatus: state.machineStatus, + isDeleting: state.isDeleting, + deletePattern: state.deletePattern, + })) + ); -export function PatternSummaryCard({ - pesData, - fileName, - onDeletePattern, - canDelete, - isDeleting -}: PatternSummaryCardProps) { + // Pattern store + const { + pesData, + currentFileName, + } = usePatternStore( + useShallow((state) => ({ + pesData: state.pesData, + currentFileName: state.currentFileName, + })) + ); + + if (!pesData) return null; + + const canDelete = canDeletePattern(machineStatus); return (

Active Pattern

-

- {fileName} +

+ {currentFileName}

@@ -93,7 +109,7 @@ export function PatternSummaryCard({ {canDelete && (