Merge pull request #3 from jhbruhn/zustand

Use Zustand
This commit is contained in:
Jan-Henrik Bruhn 2025-12-12 21:47:37 +01:00 committed by GitHub
commit 2eb78329df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1010 additions and 280 deletions

View file

@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config' import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([ export default defineConfig([
globalIgnores(['dist']), globalIgnores(['dist', '.vite']),
{ {
files: ['**/*.{ts,tsx}'], files: ['**/*.{ts,tsx}'],
extends: [ extends: [

31
package-lock.json generated
View file

@ -20,7 +20,8 @@
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-konva": "^19.2.1", "react-konva": "^19.2.1",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"update-electron-app": "^3.1.2" "update-electron-app": "^3.1.2",
"zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.10.2", "@electron-forge/cli": "^7.10.2",
@ -15253,6 +15254,34 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "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
}
}
} }
} }
} }

View file

@ -30,7 +30,8 @@
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-konva": "^19.2.1", "react-konva": "^19.2.1",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"update-electron-app": "^3.1.2" "update-electron-app": "^3.1.2",
"zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.10.2", "@electron-forge/cli": "^7.10.2",

View file

@ -1,43 +1,92 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useBrotherMachine } from './hooks/useBrotherMachine'; 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 { FileUpload } from './components/FileUpload';
import { PatternCanvas } from './components/PatternCanvas'; import { PatternCanvas } from './components/PatternCanvas';
import { ProgressMonitor } from './components/ProgressMonitor'; import { ProgressMonitor } from './components/ProgressMonitor';
import { WorkflowStepper } from './components/WorkflowStepper'; import { WorkflowStepper } from './components/WorkflowStepper';
import { PatternSummaryCard } from './components/PatternSummaryCard'; import { PatternSummaryCard } from './components/PatternSummaryCard';
import { BluetoothDevicePicker } from './components/BluetoothDevicePicker'; import { BluetoothDevicePicker } from './components/BluetoothDevicePicker';
import type { PesPatternData } from './utils/pystitchConverter'; import { getErrorDetails } from './utils/errorCodeHelpers';
import { pyodideLoader } from './utils/pyodideLoader'; import { getStateVisualInfo } from './utils/machineStateHelpers';
import { hasError, getErrorDetails } from './utils/errorCodeHelpers';
import { canDeletePattern, getStateVisualInfo } from './utils/machineStateHelpers';
import { CheckCircleIcon, BoltIcon, PauseCircleIcon, ExclamationTriangleIcon, ArrowPathIcon, XMarkIcon, InformationCircleIcon } from '@heroicons/react/24/solid'; import { CheckCircleIcon, BoltIcon, PauseCircleIcon, ExclamationTriangleIcon, ArrowPathIcon, XMarkIcon, InformationCircleIcon } from '@heroicons/react/24/solid';
import './App.css'; import './App.css';
function App() { function App() {
const machine = useBrotherMachine(); // Machine store
const [pesData, setPesData] = useState<PesPatternData | null>(null); const {
const [pyodideReady, setPyodideReady] = useState(false); isConnected,
const [pyodideError, setPyodideError] = useState<string | null>(null); machineInfo,
const [patternOffset, setPatternOffset] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); machineStatus,
const [patternUploaded, setPatternUploaded] = useState(false); machineStatusName,
const [currentFileName, setCurrentFileName] = useState<string>(''); // Track current pattern filename machineError,
const [showErrorPopover, setShowErrorPopover] = useState(false); 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<HTMLDivElement>(null); const errorPopoverRef = useRef<HTMLDivElement>(null);
const errorButtonRef = useRef<HTMLButtonElement>(null); const errorButtonRef = useRef<HTMLButtonElement>(null);
// Initialize Pyodide on mount // Initialize Pyodide on mount
useEffect(() => { useEffect(() => {
pyodideLoader initializePyodide();
.initialize() }, [initializePyodide]);
.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);
});
}, []);
// Close error popover when clicking outside // Close error popover when clicking outside
useEffect(() => { useEffect(() => {
@ -48,7 +97,7 @@ function App() {
errorButtonRef.current && errorButtonRef.current &&
!errorButtonRef.current.contains(event.target as Node) !errorButtonRef.current.contains(event.target as Node)
) { ) {
setShowErrorPopover(false); setErrorPopover(false);
} }
}; };
@ -56,54 +105,20 @@ function App() {
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
} }
}, [showErrorPopover]); }, [showErrorPopover, setErrorPopover]);
// Auto-load cached pattern when available // Auto-load cached pattern when available
const resumedPattern = machine.resumedPattern;
const resumeFileName = machine.resumeFileName;
if (resumedPattern && !pesData) { if (resumedPattern && !pesData) {
console.log('[App] Loading resumed pattern:', resumeFileName, 'Offset:', resumedPattern.patternOffset); console.log('[App] Loading resumed pattern:', resumeFileName, 'Offset:', resumedPattern.patternOffset);
setPesData(resumedPattern.pesData); setPattern(resumedPattern.pesData, resumeFileName || '');
// Restore the cached pattern offset // Restore the cached pattern offset
if (resumedPattern.patternOffset) { if (resumedPattern.patternOffset) {
setPatternOffset(resumedPattern.patternOffset); setPatternOffset(resumedPattern.patternOffset.x, resumedPattern.patternOffset.y);
}
// Preserve the filename from cache
if (resumeFileName) {
setCurrentFileName(resumeFileName);
} }
} }
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 // Track pattern uploaded state based on machine status
const isConnected = machine.isConnected;
const patternInfo = machine.patternInfo;
if (!isConnected) { if (!isConnected) {
if (patternUploaded) { if (patternUploaded) {
setPatternUploaded(false); setPatternUploaded(false);
@ -117,7 +132,7 @@ function App() {
} }
// Get state visual info for header status badge // Get state visual info for header status badge
const stateVisual = getStateVisualInfo(machine.machineStatus); const stateVisual = getStateVisualInfo(machineStatus);
const stateIcons = { const stateIcons = {
ready: CheckCircleIcon, ready: CheckCircleIcon,
active: BoltIcon, active: BoltIcon,
@ -134,40 +149,40 @@ function App() {
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center"> <div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center">
{/* Machine Connection Status - Responsive width column */} {/* Machine Connection Status - Responsive width column */}
<div className="flex items-center gap-3 w-full lg:w-[280px]"> <div className="flex items-center gap-3 w-full lg:w-[280px]">
<div className="w-2.5 h-2.5 bg-green-400 rounded-full animate-pulse shadow-lg shadow-green-400/50" style={{ visibility: machine.isConnected ? 'visible' : 'hidden' }}></div> <div className="w-2.5 h-2.5 bg-green-400 rounded-full animate-pulse shadow-lg shadow-green-400/50" style={{ visibility: isConnected ? 'visible' : 'hidden' }}></div>
<div className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5" style={{ visibility: !machine.isConnected ? 'visible' : 'hidden' }}></div> <div className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5" style={{ visibility: !isConnected ? 'visible' : 'hidden' }}></div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-lg lg:text-xl font-bold text-white leading-tight">Respira</h1> <h1 className="text-lg lg:text-xl font-bold text-white leading-tight">Respira</h1>
{machine.isConnected && machine.machineInfo?.serialNumber && ( {isConnected && machineInfo?.serialNumber && (
<span <span
className="text-xs text-blue-200 cursor-help" className="text-xs text-blue-200 cursor-help"
title={`Serial: ${machine.machineInfo.serialNumber}${ title={`Serial: ${machineInfo.serialNumber}${
machine.machineInfo.macAddress machineInfo.macAddress
? `\nMAC: ${machine.machineInfo.macAddress}` ? `\nMAC: ${machineInfo.macAddress}`
: '' : ''
}${ }${
machine.machineInfo.totalCount !== undefined machineInfo.totalCount !== undefined
? `\nTotal stitches: ${machine.machineInfo.totalCount.toLocaleString()}` ? `\nTotal stitches: ${machineInfo.totalCount.toLocaleString()}`
: '' : ''
}${ }${
machine.machineInfo.serviceCount !== undefined machineInfo.serviceCount !== undefined
? `\nStitches since service: ${machine.machineInfo.serviceCount.toLocaleString()}` ? `\nStitches since service: ${machineInfo.serviceCount.toLocaleString()}`
: '' : ''
}`} }`}
> >
{machine.machineInfo.serialNumber} {machineInfo.serialNumber}
</span> </span>
)} )}
{machine.isPolling && ( {isPolling && (
<ArrowPathIcon className="w-3.5 h-3.5 text-blue-200 animate-spin" title="Auto-refreshing status" /> <ArrowPathIcon className="w-3.5 h-3.5 text-blue-200 animate-spin" title="Auto-refreshing status" />
)} )}
</div> </div>
<div className="flex items-center gap-2 mt-1 min-h-[32px]"> <div className="flex items-center gap-2 mt-1 min-h-[32px]">
{machine.isConnected ? ( {isConnected ? (
<> <>
<button <button
onClick={machine.disconnect} onClick={disconnect}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-white/10 hover:bg-red-600 text-blue-100 hover:text-white border border-white/20 hover:border-red-600 cursor-pointer transition-all flex-shrink-0" className="inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-white/10 hover:bg-red-600 text-blue-100 hover:text-white border border-white/20 hover:border-red-600 cursor-pointer transition-all flex-shrink-0"
title="Disconnect from machine" title="Disconnect from machine"
aria-label="Disconnect from machine" aria-label="Disconnect from machine"
@ -177,7 +192,7 @@ function App() {
</button> </button>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-semibold bg-white/20 text-white border border-white/30 flex-shrink-0"> <span className="inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-semibold bg-white/20 text-white border border-white/30 flex-shrink-0">
<StatusIcon className="w-3 h-3" /> <StatusIcon className="w-3 h-3" />
{machine.machineStatusName} {machineStatusName}
</span> </span>
</> </>
) : ( ) : (
@ -188,23 +203,23 @@ function App() {
<div className="relative"> <div className="relative">
<button <button
ref={errorButtonRef} ref={errorButtonRef}
onClick={() => setShowErrorPopover(!showErrorPopover)} 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-red-500/90 hover:bg-red-600 text-white border border-red-400 transition-all flex-shrink-0 ${ className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-red-500/90 hover:bg-red-600 text-white border border-red-400 transition-all flex-shrink-0 ${
(machine.error || pyodideError) (machineErrorMessage || pyodideError)
? 'cursor-pointer animate-pulse hover:animate-none' ? 'cursor-pointer animate-pulse hover:animate-none'
: 'invisible pointer-events-none' : 'invisible pointer-events-none'
}`} }`}
title="Click to view error details" title="Click to view error details"
aria-label="View error details" aria-label="View error details"
disabled={!(machine.error || pyodideError)} disabled={!(machineErrorMessage || pyodideError)}
> >
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" /> <ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
<span> <span>
{(() => { {(() => {
if (pyodideError) return 'Python Error'; if (pyodideError) return 'Python Error';
if (machine.isPairingError) return 'Pairing Required'; if (isPairingError) return 'Pairing Required';
const errorMsg = machine.error || ''; const errorMsg = machineErrorMessage || '';
// Categorize by error message content // Categorize by error message content
if (errorMsg.toLowerCase().includes('bluetooth') || errorMsg.toLowerCase().includes('connection')) { if (errorMsg.toLowerCase().includes('bluetooth') || errorMsg.toLowerCase().includes('connection')) {
@ -216,7 +231,7 @@ function App() {
if (errorMsg.toLowerCase().includes('pattern')) { if (errorMsg.toLowerCase().includes('pattern')) {
return 'Pattern Error'; return 'Pattern Error';
} }
if (machine.machineError !== undefined) { if (machineError !== undefined) {
return `Machine Error`; return `Machine Error`;
} }
@ -227,7 +242,7 @@ function App() {
</button> </button>
{/* Error popover */} {/* Error popover */}
{showErrorPopover && (machine.error || pyodideError) && ( {showErrorPopover && (machineErrorMessage || pyodideError) && (
<div <div
ref={errorPopoverRef} ref={errorPopoverRef}
className="absolute top-full mt-2 left-0 w-[600px] z-50 animate-fadeIn" className="absolute top-full mt-2 left-0 w-[600px] z-50 animate-fadeIn"
@ -235,10 +250,10 @@ function App() {
aria-label="Error details" aria-label="Error details"
> >
{(() => { {(() => {
const errorDetails = getErrorDetails(machine.machineError); const errorDetails = getErrorDetails(machineError);
const isPairingError = machine.isPairingError; const isPairingErr = isPairingError;
const errorMsg = pyodideError || machine.error || ''; const errorMsg = pyodideError || machineErrorMessage || '';
const isInfo = isPairingError || errorDetails?.isInformational; const isInfo = isPairingErr || errorDetails?.isInformational;
const bgColor = isInfo const bgColor = isInfo
? 'bg-blue-50 dark:bg-blue-900/95 border-blue-600 dark:border-blue-500' ? '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'; : 'text-red-700 dark:text-red-300';
const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon; const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
const title = errorDetails?.title || (isPairingError ? 'Pairing Required' : 'Error'); const title = errorDetails?.title || (isPairingErr ? 'Pairing Required' : 'Error');
return ( return (
<div className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}> <div className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}>
@ -286,9 +301,9 @@ function App() {
</ol> </ol>
</> </>
)} )}
{machine.machineError !== undefined && !errorDetails?.isInformational && ( {machineError !== undefined && !errorDetails?.isInformational && (
<p className={`text-xs ${descColor} mt-3 font-mono`}> <p className={`text-xs ${descColor} mt-3 font-mono`}>
Error Code: 0x{machine.machineError.toString(16).toUpperCase().padStart(2, '0')} Error Code: 0x{machineError.toString(16).toUpperCase().padStart(2, '0')}
</p> </p>
)} )}
</div> </div>
@ -305,15 +320,7 @@ function App() {
{/* Workflow Stepper - Flexible width column */} {/* Workflow Stepper - Flexible width column */}
<div> <div>
<WorkflowStepper <WorkflowStepper />
machineStatus={machine.machineStatus}
isConnected={machine.isConnected}
hasPattern={pesData !== null}
patternUploaded={patternUploaded}
hasError={hasError(machine.machineError)}
errorMessage={machine.error || undefined}
errorCode={machine.machineError}
/>
</div> </div>
</div> </div>
</header> </header>
@ -323,7 +330,7 @@ function App() {
{/* Left Column - Controls */} {/* Left Column - Controls */}
<div className="flex flex-col gap-4 md:gap-5 lg:gap-6 lg:overflow-hidden"> <div className="flex flex-col gap-4 md:gap-5 lg:gap-6 lg:overflow-hidden">
{/* Connect Button - Show when disconnected */} {/* Connect Button - Show when disconnected */}
{!machine.isConnected && ( {!isConnected && (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600"> <div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600">
<div className="flex items-start gap-3 mb-3"> <div className="flex items-start gap-3 mb-3">
<div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5"> <div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5">
@ -337,7 +344,7 @@ function App() {
</div> </div>
</div> </div>
<button <button
onClick={machine.connect} onClick={connect}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-sm hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer" className="w-full flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-sm hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer"
> >
Connect to Machine Connect to Machine
@ -346,50 +353,19 @@ function App() {
)} )}
{/* Pattern File - Show during upload stage (before pattern is uploaded) */} {/* Pattern File - Show during upload stage (before pattern is uploaded) */}
{machine.isConnected && !patternUploaded && ( {isConnected && !patternUploaded && (
<FileUpload <FileUpload />
isConnected={machine.isConnected}
machineStatus={machine.machineStatus}
uploadProgress={machine.uploadProgress}
onPatternLoaded={handlePatternLoaded}
onUpload={handleUpload}
pyodideReady={pyodideReady}
patternOffset={patternOffset}
patternUploaded={patternUploaded}
resumeAvailable={machine.resumeAvailable}
resumeFileName={machine.resumeFileName}
pesData={pesData}
currentFileName={currentFileName}
isUploading={machine.isUploading}
machineInfo={machine.machineInfo}
/>
)} )}
{/* Compact Pattern Summary - Show after upload (during sewing stages) */} {/* Compact Pattern Summary - Show after upload (during sewing stages) */}
{machine.isConnected && patternUploaded && pesData && ( {isConnected && patternUploaded && pesData && (
<PatternSummaryCard <PatternSummaryCard />
pesData={pesData}
fileName={currentFileName}
onDeletePattern={handleDeletePattern}
canDelete={canDeletePattern(machine.machineStatus)}
isDeleting={machine.isDeleting}
/>
)} )}
{/* Progress Monitor - Show when pattern is uploaded */} {/* Progress Monitor - Show when pattern is uploaded */}
{machine.isConnected && patternUploaded && ( {isConnected && patternUploaded && (
<div className="lg:flex-1 lg:min-h-0"> <div className="lg:flex-1 lg:min-h-0">
<ProgressMonitor <ProgressMonitor />
machineStatus={machine.machineStatus}
patternInfo={machine.patternInfo}
sewingProgress={machine.sewingProgress}
pesData={pesData}
onStartMaskTrace={machine.startMaskTrace}
onStartSewing={machine.startSewing}
onResumeSewing={machine.resumeSewing}
onDeletePattern={handleDeletePattern}
isDeleting={machine.isDeleting}
/>
</div> </div>
)} )}
</div> </div>
@ -397,15 +373,7 @@ function App() {
{/* Right Column - Pattern Preview */} {/* Right Column - Pattern Preview */}
<div className="flex flex-col lg:overflow-hidden lg:h-full"> <div className="flex flex-col lg:overflow-hidden lg:h-full">
{pesData ? ( {pesData ? (
<PatternCanvas <PatternCanvas />
pesData={pesData}
sewingProgress={machine.sewingProgress}
machineInfo={machine.machineInfo}
initialPatternOffset={patternOffset}
onPatternOffsetChange={handlePatternOffsetChange}
patternUploaded={patternUploaded}
isUploading={machine.uploadProgress > 0 && machine.uploadProgress < 100}
/>
) : ( ) : (
<div className="lg:h-full bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md animate-fadeIn flex flex-col"> <div className="lg:h-full bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md animate-fadeIn flex flex-col">
<h2 className="text-base lg:text-lg font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white flex-shrink-0">Pattern Preview</h2> <h2 className="text-base lg:text-lg font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white flex-shrink-0">Pattern Preview</h2>

View file

@ -1,45 +1,58 @@
import { useState, useCallback } from 'react'; 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 { convertPesToPen, type PesPatternData } from '../utils/pystitchConverter';
import { MachineStatus, type MachineInfo } from '../types/machine';
import { canUploadPattern, getMachineStateCategory } from '../utils/machineStateHelpers'; import { canUploadPattern, getMachineStateCategory } from '../utils/machineStateHelpers';
import { PatternInfoSkeleton } from './SkeletonLoader'; import { PatternInfoSkeleton } from './SkeletonLoader';
import { ArrowUpTrayIcon, CheckCircleIcon, DocumentTextIcon, FolderOpenIcon } from '@heroicons/react/24/solid'; import { ArrowUpTrayIcon, CheckCircleIcon, DocumentTextIcon, FolderOpenIcon } from '@heroicons/react/24/solid';
import { createFileService } from '../platform'; import { createFileService } from '../platform';
import type { IFileService } from '../platform/interfaces/IFileService'; import type { IFileService } from '../platform/interfaces/IFileService';
interface FileUploadProps { export function FileUpload() {
isConnected: boolean; // Machine store
machineStatus: MachineStatus; const {
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({
isConnected, isConnected,
machineStatus, machineStatus,
uploadProgress, uploadProgress,
onPatternLoaded, isUploading,
onUpload, machineInfo,
pyodideReady,
patternOffset,
patternUploaded,
resumeAvailable, resumeAvailable,
resumeFileName, 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, pesData: pesDataProp,
currentFileName, currentFileName,
isUploading = false, patternOffset,
machineInfo, patternUploaded,
}: FileUploadProps) { 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<PesPatternData | null>(null); const [localPesData, setLocalPesData] = useState<PesPatternData | null>(null);
const [fileName, setFileName] = useState<string>(''); const [fileName, setFileName] = useState<string>('');
const [fileService] = useState<IFileService>(() => createFileService()); const [fileService] = useState<IFileService>(() => createFileService());
@ -77,7 +90,7 @@ export function FileUpload({
const data = await convertPesToPen(file); const data = await convertPesToPen(file);
setLocalPesData(data); setLocalPesData(data);
setFileName(file.name); setFileName(file.name);
onPatternLoaded(data, file.name); setPattern(data, file.name);
} catch (err) { } catch (err) {
alert( alert(
`Failed to load PES file: ${ `Failed to load PES file: ${
@ -88,14 +101,14 @@ export function FileUpload({
setIsLoading(false); setIsLoading(false);
} }
}, },
[fileService, onPatternLoaded, pyodideReady] [fileService, setPattern, pyodideReady]
); );
const handleUpload = useCallback(() => { const handleUpload = useCallback(() => {
if (pesData && displayFileName) { 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 // Check if pattern (with offset) fits within hoop bounds
const checkPatternFitsInHoop = useCallback(() => { const checkPatternFitsInHoop = useCallback(() => {

View file

@ -1,39 +1,58 @@
import { useEffect, useRef, useState, useCallback } from 'react'; 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 { Stage, Layer, Group } from 'react-konva';
import Konva from 'konva'; import Konva from 'konva';
import { PlusIcon, MinusIcon, ArrowPathIcon, LockClosedIcon, PhotoIcon } from '@heroicons/react/24/solid'; import { PlusIcon, MinusIcon, ArrowPathIcon, LockClosedIcon, PhotoIcon } from '@heroicons/react/24/solid';
import type { PesPatternData } from '../utils/pystitchConverter'; import type { PesPatternData } from '../utils/pystitchConverter';
import type { SewingProgress, MachineInfo } from '../types/machine';
import { calculateInitialScale } from '../utils/konvaRenderers'; import { calculateInitialScale } from '../utils/konvaRenderers';
import { Grid, Origin, Hoop, Stitches, PatternBounds, CurrentPosition } from './KonvaComponents'; import { Grid, Origin, Hoop, Stitches, PatternBounds, CurrentPosition } from './KonvaComponents';
interface PatternCanvasProps { export function PatternCanvas() {
pesData: PesPatternData | null; // Machine store
sewingProgress: SewingProgress | null; const {
machineInfo: MachineInfo | null; sewingProgress,
initialPatternOffset?: { x: number; y: number }; machineInfo,
onPatternOffsetChange?: (offsetX: number, offsetY: number) => void; isUploading,
patternUploaded?: boolean; } = useMachineStore(
isUploading?: boolean; 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<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage | null>(null); const stageRef = useRef<Konva.Stage | null>(null);
const [stagePos, setStagePos] = useState({ x: 0, y: 0 }); const [stagePos, setStagePos] = useState({ x: 0, y: 0 });
const [stageScale, setStageScale] = useState(1); 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 [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const initialScaleRef = useRef<number>(1); const initialScaleRef = useRef<number>(1);
const prevPesDataRef = useRef<PesPatternData | null>(null); const prevPesDataRef = useRef<PesPatternData | null>(null);
// Update pattern offset when initialPatternOffset changes // Update pattern offset when initialPatternOffset changes
if (initialPatternOffset && ( if (initialPatternOffset && (
patternOffset.x !== initialPatternOffset.x || localPatternOffset.x !== initialPatternOffset.x ||
patternOffset.y !== initialPatternOffset.y localPatternOffset.y !== initialPatternOffset.y
)) { )) {
setPatternOffset(initialPatternOffset); setLocalPatternOffset(initialPatternOffset);
console.log('[PatternCanvas] Restored pattern offset:', initialPatternOffset); console.log('[PatternCanvas] Restored pattern offset:', initialPatternOffset);
} }
@ -178,12 +197,9 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPat
x: e.target.x(), x: e.target.x(),
y: e.target.y(), y: e.target.y(),
}; };
setPatternOffset(newOffset); setLocalPatternOffset(newOffset);
setPatternOffset(newOffset.x, newOffset.y);
if (onPatternOffsetChange) { }, [setPatternOffset]);
onPatternOffsetChange(newOffset.x, newOffset.y);
}
}, [onPatternOffsetChange]);
const borderColor = pesData ? 'border-teal-600 dark:border-teal-500' : 'border-gray-400 dark:border-gray-600'; 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'; 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
<Group <Group
name="pattern-group" name="pattern-group"
draggable={!patternUploaded && !isUploading} draggable={!patternUploaded && !isUploading}
x={patternOffset.x} x={localPatternOffset.x}
y={patternOffset.y} y={localPatternOffset.y}
onDragEnd={handlePatternDragEnd} onDragEnd={handlePatternDragEnd}
onMouseEnter={(e) => { onMouseEnter={(e) => {
const stage = e.target.getStage(); const stage = e.target.getStage();
@ -278,7 +294,7 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPat
{/* Current position layer */} {/* Current position layer */}
<Layer> <Layer>
{pesData && sewingProgress && sewingProgress.currentStitch > 0 && ( {pesData && sewingProgress && sewingProgress.currentStitch > 0 && (
<Group x={patternOffset.x} y={patternOffset.y}> <Group x={localPatternOffset.x} y={localPatternOffset.y}>
<CurrentPosition <CurrentPosition
currentStitchIndex={sewingProgress.currentStitch} currentStitchIndex={sewingProgress.currentStitch}
stitches={pesData.stitches} stitches={pesData.stitches}
@ -352,7 +368,7 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPat
)} )}
</div> </div>
<div className="text-sm font-semibold text-blue-600 dark:text-blue-400 mb-1"> <div className="text-sm font-semibold text-blue-600 dark:text-blue-400 mb-1">
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
</div> </div>
<div className="text-xs text-gray-600 dark:text-gray-400 italic"> <div className="text-xs text-gray-600 dark:text-gray-400 italic">
{patternUploaded ? 'Pattern locked • Drag background to pan' : 'Drag pattern to move • Drag background to pan'} {patternUploaded ? 'Pattern locked • Drag background to pan' : 'Drag pattern to move • Drag background to pan'}

View file

@ -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 { DocumentTextIcon, TrashIcon } from '@heroicons/react/24/solid';
import type { PesPatternData } from '../utils/pystitchConverter';
interface PatternSummaryCardProps { export function PatternSummaryCard() {
pesData: PesPatternData; // Machine store
fileName: string; const {
onDeletePattern: () => void; machineStatus,
canDelete: boolean; isDeleting,
isDeleting: boolean; deletePattern,
} } = useMachineStore(
useShallow((state) => ({
machineStatus: state.machineStatus,
isDeleting: state.isDeleting,
deletePattern: state.deletePattern,
}))
);
export function PatternSummaryCard({ // Pattern store
const {
pesData, pesData,
fileName, currentFileName,
onDeletePattern, } = usePatternStore(
canDelete, useShallow((state) => ({
isDeleting pesData: state.pesData,
}: PatternSummaryCardProps) { currentFileName: state.currentFileName,
}))
);
if (!pesData) return null;
const canDelete = canDeletePattern(machineStatus);
return ( return (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-blue-600 dark:border-blue-500"> <div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-blue-600 dark:border-blue-500">
<div className="flex items-start gap-3 mb-3"> <div className="flex items-start gap-3 mb-3">
<DocumentTextIcon className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" /> <DocumentTextIcon className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Active Pattern</h3> <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Active Pattern</h3>
<p className="text-xs text-gray-600 dark:text-gray-400 truncate" title={fileName}> <p className="text-xs text-gray-600 dark:text-gray-400 truncate" title={currentFileName}>
{fileName} {currentFileName}
</p> </p>
</div> </div>
</div> </div>
@ -93,7 +109,7 @@ export function PatternSummaryCard({
{canDelete && ( {canDelete && (
<button <button
onClick={onDeletePattern} onClick={deletePattern}
disabled={isDeleting} disabled={isDeleting}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 rounded border border-red-300 dark:border-red-700 hover:bg-red-100 dark:hover:bg-red-900/30 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer" className="w-full flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 rounded border border-red-300 dark:border-red-700 hover:bg-red-100 dark:hover:bg-red-900/30 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
> >

View file

@ -1,4 +1,7 @@
import { useRef, useEffect, useState, useMemo } from "react"; import { useRef, useEffect, useState, useMemo } from "react";
import { useShallow } from 'zustand/react/shallow';
import { useMachineStore } from '../stores/useMachineStore';
import { usePatternStore } from '../stores/usePatternStore';
import { import {
CheckCircleIcon, CheckCircleIcon,
ArrowRightIcon, ArrowRightIcon,
@ -11,9 +14,7 @@ import {
ChartBarIcon, ChartBarIcon,
ArrowPathIcon, ArrowPathIcon,
} from "@heroicons/react/24/solid"; } from "@heroicons/react/24/solid";
import type { PatternInfo, SewingProgress } from "../types/machine";
import { MachineStatus } from "../types/machine"; import { MachineStatus } from "../types/machine";
import type { PesPatternData } from "../utils/pystitchConverter";
import { import {
canStartSewing, canStartSewing,
canStartMaskTrace, canStartMaskTrace,
@ -21,28 +22,30 @@ import {
getStateVisualInfo, getStateVisualInfo,
} from "../utils/machineStateHelpers"; } from "../utils/machineStateHelpers";
interface ProgressMonitorProps { export function ProgressMonitor() {
machineStatus: MachineStatus; // Machine store
patternInfo: PatternInfo | null; const {
sewingProgress: SewingProgress | null;
pesData: PesPatternData | null;
onStartMaskTrace: () => void;
onStartSewing: () => void;
onResumeSewing: () => void;
onDeletePattern: () => void;
isDeleting?: boolean;
}
export function ProgressMonitor({
machineStatus, machineStatus,
patternInfo, patternInfo,
sewingProgress, sewingProgress,
pesData, isDeleting,
onStartMaskTrace, startMaskTrace,
onStartSewing, startSewing,
onResumeSewing, resumeSewing,
isDeleting = false, } = useMachineStore(
}: ProgressMonitorProps) { useShallow((state) => ({
machineStatus: state.machineStatus,
patternInfo: state.patternInfo,
sewingProgress: state.sewingProgress,
isDeleting: state.isDeleting,
startMaskTrace: state.startMaskTrace,
startSewing: state.startSewing,
resumeSewing: state.resumeSewing,
}))
);
// Pattern store
const pesData = usePatternStore((state) => state.pesData);
const currentBlockRef = useRef<HTMLDivElement>(null); const currentBlockRef = useRef<HTMLDivElement>(null);
const colorBlocksScrollRef = useRef<HTMLDivElement>(null); const colorBlocksScrollRef = useRef<HTMLDivElement>(null);
const [showGradient, setShowGradient] = useState(true); const [showGradient, setShowGradient] = useState(true);
@ -417,7 +420,7 @@ export function ProgressMonitor({
{/* Resume has highest priority when available */} {/* Resume has highest priority when available */}
{canResumeSewing(machineStatus) && ( {canResumeSewing(machineStatus) && (
<button <button
onClick={onResumeSewing} onClick={resumeSewing}
disabled={isDeleting} disabled={isDeleting}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 sm:py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 sm:py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Resume sewing the current pattern" aria-label="Resume sewing the current pattern"
@ -430,7 +433,7 @@ export function ProgressMonitor({
{/* Start Sewing - primary action, takes more space */} {/* Start Sewing - primary action, takes more space */}
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && ( {canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
<button <button
onClick={onStartSewing} onClick={startSewing}
disabled={isDeleting} disabled={isDeleting}
className="flex-[2] flex items-center justify-center gap-1.5 px-3 py-2.5 sm:py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" className="flex-[2] flex items-center justify-center gap-1.5 px-3 py-2.5 sm:py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Start sewing the pattern" aria-label="Start sewing the pattern"
@ -443,7 +446,7 @@ export function ProgressMonitor({
{/* Start Mask Trace - secondary action */} {/* Start Mask Trace - secondary action */}
{canStartMaskTrace(machineStatus) && ( {canStartMaskTrace(machineStatus) && (
<button <button
onClick={onStartMaskTrace} onClick={startMaskTrace}
disabled={isDeleting} disabled={isDeleting}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 sm:py-2 bg-gray-600 dark:bg-gray-700 text-white rounded font-semibold text-xs hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 sm:py-2 bg-gray-600 dark:bg-gray-700 text-white rounded font-semibold text-xs hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={ aria-label={

View file

@ -1,17 +1,10 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useMachineStore } from '../stores/useMachineStore';
import { usePatternStore } from '../stores/usePatternStore';
import { CheckCircleIcon, InformationCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/solid'; import { CheckCircleIcon, InformationCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/solid';
import { MachineStatus } from '../types/machine'; import { MachineStatus } from '../types/machine';
import { getErrorDetails } from '../utils/errorCodeHelpers'; import { getErrorDetails, hasError } from '../utils/errorCodeHelpers';
interface WorkflowStepperProps {
machineStatus: MachineStatus;
isConnected: boolean;
hasPattern: boolean;
patternUploaded: boolean;
hasError?: boolean;
errorMessage?: string;
errorCode?: number;
}
interface Step { interface Step {
id: number; id: number;
@ -256,15 +249,35 @@ function getCurrentStep(machineStatus: MachineStatus, isConnected: boolean, hasP
} }
} }
export function WorkflowStepper({ export function WorkflowStepper() {
// Machine store
const {
machineStatus, machineStatus,
isConnected, isConnected,
hasPattern, machineError,
error: errorMessage,
} = useMachineStore(
useShallow((state) => ({
machineStatus: state.machineStatus,
isConnected: state.isConnected,
machineError: state.machineError,
error: state.error,
}))
);
// Pattern store
const {
pesData,
patternUploaded, patternUploaded,
hasError = false, } = usePatternStore(
errorMessage, useShallow((state) => ({
errorCode pesData: state.pesData,
}: WorkflowStepperProps) { patternUploaded: state.patternUploaded,
}))
);
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 [showPopover, setShowPopover] = useState(false);
const [popoverStep, setPopoverStep] = useState<number | null>(null); const [popoverStep, setPopoverStep] = useState<number | null>(null);
@ -383,7 +396,7 @@ export function WorkflowStepper({
aria-label="Step guidance" aria-label="Step guidance"
> >
{(() => { {(() => {
const content = getGuideContent(popoverStep, machineStatus, hasError, errorCode, errorMessage); const content = getGuideContent(popoverStep, machineStatus, hasErrorFlag, machineError, errorMessage || undefined);
if (!content) return null; if (!content) return null;
const colorClasses = { const colorClasses = {

View file

@ -0,0 +1,554 @@
import { create } from 'zustand';
import { BrotherPP1Service, BluetoothPairingError } from '../services/BrotherPP1Service';
import type {
MachineInfo,
PatternInfo,
SewingProgress,
} from '../types/machine';
import { MachineStatus, MachineStatusNames } from '../types/machine';
import { SewingMachineError } from '../utils/errorCodeHelpers';
import { uuidToString } from '../services/PatternCacheService';
import { createStorageService } from '../platform';
import type { IStorageService } from '../platform/interfaces/IStorageService';
import type { PesPatternData } from '../utils/pystitchConverter';
interface MachineState {
// Service instances
service: BrotherPP1Service;
storageService: IStorageService;
// Connection state
isConnected: boolean;
machineInfo: MachineInfo | null;
// Machine status
machineStatus: MachineStatus;
machineStatusName: string;
machineError: number;
// Pattern state
patternInfo: PatternInfo | null;
sewingProgress: SewingProgress | null;
// Upload state
uploadProgress: number;
isUploading: boolean;
// Resume state
resumeAvailable: boolean;
resumeFileName: string | null;
resumedPattern: { pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null;
// Error state
error: string | null;
isPairingError: boolean;
// Communication state
isCommunicating: boolean;
isDeleting: boolean;
// Polling control
pollIntervalId: NodeJS.Timeout | null;
serviceCountIntervalId: NodeJS.Timeout | null;
// Actions
connect: () => Promise<void>;
disconnect: () => Promise<void>;
refreshStatus: () => Promise<void>;
refreshPatternInfo: () => Promise<void>;
refreshProgress: () => Promise<void>;
refreshServiceCount: () => Promise<void>;
uploadPattern: (
penData: Uint8Array,
pesData: PesPatternData,
fileName: string,
patternOffset?: { x: number; y: number }
) => Promise<void>;
startMaskTrace: () => Promise<void>;
startSewing: () => Promise<void>;
resumeSewing: () => Promise<void>;
deletePattern: () => Promise<void>;
checkResume: () => Promise<PesPatternData | null>;
loadCachedPattern: () => Promise<{ pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null>;
// Internal methods
_setupSubscriptions: () => void;
_startPolling: () => void;
_stopPolling: () => void;
}
export const useMachineStore = create<MachineState>((set, get) => ({
// Initial state
service: new BrotherPP1Service(),
storageService: createStorageService(),
isConnected: false,
machineInfo: null,
machineStatus: MachineStatus.None,
machineStatusName: MachineStatusNames[MachineStatus.None] || 'Unknown',
machineError: SewingMachineError.None,
patternInfo: null,
sewingProgress: null,
uploadProgress: 0,
isUploading: false,
resumeAvailable: false,
resumeFileName: null,
resumedPattern: null,
error: null,
isPairingError: false,
isCommunicating: false,
isDeleting: false,
pollIntervalId: null,
serviceCountIntervalId: null,
// Check for resumable pattern
checkResume: async (): Promise<PesPatternData | null> => {
try {
const { service, storageService } = get();
console.log('[Resume] Checking for cached pattern...');
const machineUuid = await service.getPatternUUID();
console.log(
'[Resume] Machine UUID:',
machineUuid ? uuidToString(machineUuid) : 'none',
);
if (!machineUuid) {
console.log('[Resume] No pattern loaded on machine');
set({ resumeAvailable: false, resumeFileName: null });
return null;
}
const uuidStr = uuidToString(machineUuid);
const cached = await storageService.getPatternByUUID(uuidStr);
if (cached) {
console.log('[Resume] Pattern found in cache:', cached.fileName, 'Offset:', cached.patternOffset);
console.log('[Resume] Auto-loading cached pattern...');
set({
resumeAvailable: true,
resumeFileName: cached.fileName,
resumedPattern: { pesData: cached.pesData, patternOffset: cached.patternOffset },
});
// Fetch pattern info from machine
try {
const info = await service.getPatternInfo();
set({ patternInfo: info });
console.log('[Resume] Pattern info loaded from machine');
} catch (err) {
console.error('[Resume] Failed to load pattern info:', err);
}
return cached.pesData;
} else {
console.log('[Resume] Pattern on machine not found in cache');
set({ resumeAvailable: false, resumeFileName: null });
return null;
}
} catch (err) {
console.error('[Resume] Failed to check resume:', err);
set({ resumeAvailable: false, resumeFileName: null });
return null;
}
},
// Connect to machine
connect: async () => {
try {
const { service, checkResume } = get();
set({ error: null, isPairingError: false });
await service.connect();
set({ isConnected: true });
// Fetch initial machine info and status
const info = await service.getMachineInfo();
const state = await service.getMachineState();
set({
machineInfo: info,
machineStatus: state.status,
machineStatusName: MachineStatusNames[state.status] || 'Unknown',
machineError: state.error,
});
// Check for resume possibility
await checkResume();
// Start polling
get()._startPolling();
} catch (err) {
console.log(err);
const isPairing = err instanceof BluetoothPairingError;
set({
isPairingError: isPairing,
error: err instanceof Error ? err.message : 'Failed to connect',
isConnected: false,
});
}
},
// Disconnect from machine
disconnect: async () => {
try {
const { service, _stopPolling } = get();
_stopPolling();
await service.disconnect();
set({
isConnected: false,
machineInfo: null,
machineStatus: MachineStatus.None,
machineStatusName: MachineStatusNames[MachineStatus.None] || 'Unknown',
patternInfo: null,
sewingProgress: null,
error: null,
machineError: SewingMachineError.None,
});
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to disconnect',
});
}
},
// Refresh machine status
refreshStatus: async () => {
const { isConnected, service } = get();
if (!isConnected) return;
try {
const state = await service.getMachineState();
set({
machineStatus: state.status,
machineStatusName: MachineStatusNames[state.status] || 'Unknown',
machineError: state.error,
});
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to get status',
});
}
},
// Refresh pattern info
refreshPatternInfo: async () => {
const { isConnected, service } = get();
if (!isConnected) return;
try {
const info = await service.getPatternInfo();
set({ patternInfo: info });
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to get pattern info',
});
}
},
// Refresh sewing progress
refreshProgress: async () => {
const { isConnected, service } = get();
if (!isConnected) return;
try {
const progress = await service.getSewingProgress();
set({ sewingProgress: progress });
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to get progress',
});
}
},
// Refresh service count
refreshServiceCount: async () => {
const { isConnected, machineInfo, service } = get();
if (!isConnected || !machineInfo) return;
try {
const counts = await service.getServiceCount();
set({
machineInfo: {
...machineInfo,
serviceCount: counts.serviceCount,
totalCount: counts.totalCount,
},
});
} catch (err) {
console.warn('Failed to get service count:', err);
}
},
// Upload pattern to machine
uploadPattern: async (
penData: Uint8Array,
pesData: PesPatternData,
fileName: string,
patternOffset?: { x: number; y: number }
) => {
const { isConnected, service, storageService, refreshStatus, refreshPatternInfo } = get();
if (!isConnected) {
set({ error: 'Not connected to machine' });
return;
}
try {
set({ error: null, uploadProgress: 0, isUploading: true });
const uuid = await service.uploadPattern(
penData,
(progress) => {
set({ uploadProgress: progress });
},
pesData.bounds,
patternOffset,
);
set({ uploadProgress: 100 });
// Cache the pattern with its UUID and offset
const uuidStr = uuidToString(uuid);
storageService.savePattern(uuidStr, pesData, fileName, patternOffset);
console.log('[Cache] Saved pattern:', fileName, 'with UUID:', uuidStr, 'Offset:', patternOffset);
// Clear resume state since we just uploaded
set({
resumeAvailable: false,
resumeFileName: null,
});
// Refresh status and pattern info after upload
await refreshStatus();
await refreshPatternInfo();
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to upload pattern',
});
} finally {
set({ isUploading: false });
}
},
// Start mask trace
startMaskTrace: async () => {
const { isConnected, service, refreshStatus } = get();
if (!isConnected) return;
try {
set({ error: null });
await service.startMaskTrace();
await refreshStatus();
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to start mask trace',
});
}
},
// Start sewing
startSewing: async () => {
const { isConnected, service, refreshStatus } = get();
if (!isConnected) return;
try {
set({ error: null });
await service.startSewing();
await refreshStatus();
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to start sewing',
});
}
},
// Resume sewing
resumeSewing: async () => {
const { isConnected, service, refreshStatus } = get();
if (!isConnected) return;
try {
set({ error: null });
await service.resumeSewing();
await refreshStatus();
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to resume sewing',
});
}
},
// Delete pattern from machine
deletePattern: async () => {
const { isConnected, service, storageService, refreshStatus } = get();
if (!isConnected) return;
try {
set({ error: null, isDeleting: true });
// Delete pattern from cache to prevent auto-resume
try {
const machineUuid = await service.getPatternUUID();
if (machineUuid) {
const uuidStr = uuidToString(machineUuid);
await storageService.deletePattern(uuidStr);
console.log('[Cache] Deleted pattern with UUID:', uuidStr);
}
} catch (err) {
console.warn('[Cache] Failed to get UUID for cache deletion:', err);
}
await service.deletePattern();
// Clear machine-related state
set({
patternInfo: null,
sewingProgress: null,
uploadProgress: 0,
resumeAvailable: false,
resumeFileName: null,
});
await refreshStatus();
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to delete pattern',
});
} finally {
set({ isDeleting: false });
}
},
// Load cached pattern
loadCachedPattern: async (): Promise<{ pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null> => {
const { resumeAvailable, service, storageService, refreshPatternInfo } = get();
if (!resumeAvailable) return null;
try {
const machineUuid = await service.getPatternUUID();
if (!machineUuid) return null;
const uuidStr = uuidToString(machineUuid);
const cached = await storageService.getPatternByUUID(uuidStr);
if (cached) {
console.log('[Resume] Loading cached pattern:', cached.fileName, 'Offset:', cached.patternOffset);
await refreshPatternInfo();
return { pesData: cached.pesData, patternOffset: cached.patternOffset };
}
return null;
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to load cached pattern',
});
return null;
}
},
// Setup service subscriptions
_setupSubscriptions: () => {
const { service } = get();
// Subscribe to communication state changes
service.onCommunicationChange((isCommunicating) => {
set({ isCommunicating });
});
// Subscribe to disconnect events
service.onDisconnect(() => {
console.log('[useMachineStore] Device disconnected');
get()._stopPolling();
set({
isConnected: false,
machineInfo: null,
machineStatus: MachineStatus.None,
machineStatusName: MachineStatusNames[MachineStatus.None] || 'Unknown',
machineError: SewingMachineError.None,
patternInfo: null,
sewingProgress: null,
error: 'Device disconnected',
isPairingError: false,
});
});
},
// Start polling for status updates
_startPolling: () => {
const { _stopPolling, refreshStatus, refreshProgress, refreshServiceCount } = get();
// Stop any existing polling
_stopPolling();
// Function to determine polling interval based on machine status
const getPollInterval = () => {
const status = get().machineStatus;
// Fast polling for active states
if (
status === MachineStatus.SEWING ||
status === MachineStatus.MASK_TRACING ||
status === MachineStatus.SEWING_DATA_RECEIVE
) {
return 500;
} else if (
status === MachineStatus.COLOR_CHANGE_WAIT ||
status === MachineStatus.MASK_TRACE_LOCK_WAIT ||
status === MachineStatus.SEWING_WAIT
) {
return 1000;
}
return 2000; // Default for idle states
};
// Main polling function
const poll = async () => {
await refreshStatus();
// Refresh progress during sewing
if (get().machineStatus === MachineStatus.SEWING) {
await refreshProgress();
}
// Schedule next poll with updated interval
const newInterval = getPollInterval();
const pollIntervalId = setTimeout(poll, newInterval);
set({ pollIntervalId });
};
// Start polling
const initialInterval = getPollInterval();
const pollIntervalId = setTimeout(poll, initialInterval);
// Service count polling (every 10 seconds)
const serviceCountIntervalId = setInterval(refreshServiceCount, 10000);
set({ pollIntervalId, serviceCountIntervalId });
},
// Stop polling
_stopPolling: () => {
const { pollIntervalId, serviceCountIntervalId } = get();
if (pollIntervalId) {
clearTimeout(pollIntervalId);
set({ pollIntervalId: null });
}
if (serviceCountIntervalId) {
clearInterval(serviceCountIntervalId);
set({ serviceCountIntervalId: null });
}
},
}));
// Initialize subscriptions when store is created
useMachineStore.getState()._setupSubscriptions();
// Selector hooks for common use cases
export const useIsConnected = () => useMachineStore((state) => state.isConnected);
export const useMachineInfo = () => useMachineStore((state) => state.machineInfo);
export const useMachineStatus = () => useMachineStore((state) => state.machineStatus);
export const useMachineError = () => useMachineStore((state) => state.machineError);
export const usePatternInfo = () => useMachineStore((state) => state.patternInfo);
export const useSewingProgress = () => useMachineStore((state) => state.sewingProgress);

View file

@ -0,0 +1,66 @@
import { create } from 'zustand';
import type { PesPatternData } from '../utils/pystitchConverter';
interface PatternState {
// Pattern data
pesData: PesPatternData | null;
currentFileName: string;
patternOffset: { x: number; y: number };
patternUploaded: boolean;
// Actions
setPattern: (data: PesPatternData, fileName: string) => void;
setPatternOffset: (x: number, y: number) => void;
setPatternUploaded: (uploaded: boolean) => void;
clearPattern: () => void;
resetPatternOffset: () => void;
}
export const usePatternStore = create<PatternState>((set) => ({
// Initial state
pesData: null,
currentFileName: '',
patternOffset: { x: 0, y: 0 },
patternUploaded: false,
// Set pattern data and filename
setPattern: (data: PesPatternData, fileName: string) => {
set({
pesData: data,
currentFileName: fileName,
patternOffset: { x: 0, y: 0 }, // Reset offset when new pattern is loaded
patternUploaded: false,
});
},
// Update pattern offset
setPatternOffset: (x: number, y: number) => {
set({ patternOffset: { x, y } });
console.log('[PatternStore] Pattern offset changed:', { x, y });
},
// Mark pattern as uploaded/not uploaded
setPatternUploaded: (uploaded: boolean) => {
set({ patternUploaded: uploaded });
},
// Clear pattern (but keep data visible for re-editing)
clearPattern: () => {
set({
patternUploaded: false,
// Note: We intentionally DON'T clear pesData or currentFileName
// so the pattern remains visible in the canvas for re-editing
});
},
// Reset pattern offset to default
resetPatternOffset: () => {
set({ patternOffset: { x: 0, y: 0 } });
},
}));
// Selector hooks for common use cases
export const usePesData = () => usePatternStore((state) => state.pesData);
export const usePatternFileName = () => usePatternStore((state) => state.currentFileName);
export const usePatternOffset = () => usePatternStore((state) => state.patternOffset);
export const usePatternUploaded = () => usePatternStore((state) => state.patternUploaded);

51
src/stores/useUIStore.ts Normal file
View file

@ -0,0 +1,51 @@
import { create } from 'zustand';
import { pyodideLoader } from '../utils/pyodideLoader';
interface UIState {
// Pyodide state
pyodideReady: boolean;
pyodideError: string | null;
// UI state
showErrorPopover: boolean;
// Actions
initializePyodide: () => Promise<void>;
toggleErrorPopover: () => void;
setErrorPopover: (show: boolean) => void;
}
export const useUIStore = create<UIState>((set) => ({
// Initial state
pyodideReady: false,
pyodideError: null,
showErrorPopover: false,
// Initialize Pyodide
initializePyodide: async () => {
try {
await pyodideLoader.initialize();
set({ pyodideReady: true });
console.log('[UIStore] Pyodide initialized successfully');
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to initialize Python environment';
set({ pyodideError: errorMessage });
console.error('[UIStore] Failed to initialize Pyodide:', err);
}
},
// Toggle error popover visibility
toggleErrorPopover: () => {
set((state) => ({ showErrorPopover: !state.showErrorPopover }));
},
// Set error popover visibility
setErrorPopover: (show: boolean) => {
set({ showErrorPopover: show });
},
}));
// Selector hooks for common use cases
export const usePyodideReady = () => useUIStore((state) => state.pyodideReady);
export const usePyodideError = () => useUIStore((state) => state.pyodideError);
export const useErrorPopover = () => useUIStore((state) => state.showErrorPopover);