mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 10:23:41 +00:00
commit
2eb78329df
12 changed files with 1010 additions and 280 deletions
|
|
@ -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
31
package-lock.json
generated
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
268
src/App.tsx
268
src/App.tsx
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
isConnected,
|
||||||
onPatternLoaded: (pesData: PesPatternData, fileName: string) => void;
|
machineStatus,
|
||||||
onUpload: (penData: Uint8Array, pesData: PesPatternData, fileName: string, patternOffset?: { x: number; y: number }) => void;
|
uploadProgress,
|
||||||
pyodideReady: boolean;
|
isUploading,
|
||||||
patternOffset: { x: number; y: number };
|
machineInfo,
|
||||||
patternUploaded: boolean;
|
resumeAvailable,
|
||||||
resumeAvailable: boolean;
|
resumeFileName,
|
||||||
resumeFileName: string | null;
|
uploadPattern,
|
||||||
pesData: PesPatternData | null;
|
} = useMachineStore(
|
||||||
currentFileName: string;
|
useShallow((state) => ({
|
||||||
isUploading?: boolean;
|
isConnected: state.isConnected,
|
||||||
machineInfo: MachineInfo | null;
|
machineStatus: state.machineStatus,
|
||||||
}
|
uploadProgress: state.uploadProgress,
|
||||||
|
isUploading: state.isUploading,
|
||||||
|
machineInfo: state.machineInfo,
|
||||||
|
resumeAvailable: state.resumeAvailable,
|
||||||
|
resumeFileName: state.resumeFileName,
|
||||||
|
uploadPattern: state.uploadPattern,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
export function FileUpload({
|
// Pattern store
|
||||||
isConnected,
|
const {
|
||||||
machineStatus,
|
pesData: pesDataProp,
|
||||||
uploadProgress,
|
currentFileName,
|
||||||
onPatternLoaded,
|
patternOffset,
|
||||||
onUpload,
|
patternUploaded,
|
||||||
pyodideReady,
|
setPattern,
|
||||||
patternOffset,
|
} = usePatternStore(
|
||||||
patternUploaded,
|
useShallow((state) => ({
|
||||||
resumeAvailable,
|
pesData: state.pesData,
|
||||||
resumeFileName,
|
currentFileName: state.currentFileName,
|
||||||
pesData: pesDataProp,
|
patternOffset: state.patternOffset,
|
||||||
currentFileName,
|
patternUploaded: state.patternUploaded,
|
||||||
isUploading = false,
|
setPattern: state.setPattern,
|
||||||
machineInfo,
|
}))
|
||||||
}: FileUploadProps) {
|
);
|
||||||
|
|
||||||
|
// 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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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'}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
pesData,
|
const {
|
||||||
fileName,
|
pesData,
|
||||||
onDeletePattern,
|
currentFileName,
|
||||||
canDelete,
|
} = usePatternStore(
|
||||||
isDeleting
|
useShallow((state) => ({
|
||||||
}: PatternSummaryCardProps) {
|
pesData: state.pesData,
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
machineStatus,
|
||||||
pesData: PesPatternData | null;
|
patternInfo,
|
||||||
onStartMaskTrace: () => void;
|
sewingProgress,
|
||||||
onStartSewing: () => void;
|
isDeleting,
|
||||||
onResumeSewing: () => void;
|
startMaskTrace,
|
||||||
onDeletePattern: () => void;
|
startSewing,
|
||||||
isDeleting?: boolean;
|
resumeSewing,
|
||||||
}
|
} = useMachineStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
machineStatus: state.machineStatus,
|
||||||
|
patternInfo: state.patternInfo,
|
||||||
|
sewingProgress: state.sewingProgress,
|
||||||
|
isDeleting: state.isDeleting,
|
||||||
|
startMaskTrace: state.startMaskTrace,
|
||||||
|
startSewing: state.startSewing,
|
||||||
|
resumeSewing: state.resumeSewing,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
export function ProgressMonitor({
|
// Pattern store
|
||||||
machineStatus,
|
const pesData = usePatternStore((state) => state.pesData);
|
||||||
patternInfo,
|
|
||||||
sewingProgress,
|
|
||||||
pesData,
|
|
||||||
onStartMaskTrace,
|
|
||||||
onStartSewing,
|
|
||||||
onResumeSewing,
|
|
||||||
isDeleting = false,
|
|
||||||
}: ProgressMonitorProps) {
|
|
||||||
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={
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
machineStatus,
|
// Machine store
|
||||||
isConnected,
|
const {
|
||||||
hasPattern,
|
machineStatus,
|
||||||
patternUploaded,
|
isConnected,
|
||||||
hasError = false,
|
machineError,
|
||||||
errorMessage,
|
error: errorMessage,
|
||||||
errorCode
|
} = useMachineStore(
|
||||||
}: WorkflowStepperProps) {
|
useShallow((state) => ({
|
||||||
|
machineStatus: state.machineStatus,
|
||||||
|
isConnected: state.isConnected,
|
||||||
|
machineError: state.machineError,
|
||||||
|
error: state.error,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pattern store
|
||||||
|
const {
|
||||||
|
pesData,
|
||||||
|
patternUploaded,
|
||||||
|
} = usePatternStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
pesData: state.pesData,
|
||||||
|
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 = {
|
||||||
|
|
|
||||||
554
src/stores/useMachineStore.ts
Normal file
554
src/stores/useMachineStore.ts
Normal 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);
|
||||||
66
src/stores/usePatternStore.ts
Normal file
66
src/stores/usePatternStore.ts
Normal 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
51
src/stores/useUIStore.ts
Normal 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);
|
||||||
Loading…
Reference in a new issue