Compare commits

..

No commits in common. "a253901fb4e6a7674c3264c5ab2750b647dbaa34" and "444e31af353c80e640851751607df9e31dfe4c61" have entirely different histories.

50 changed files with 3014 additions and 3740 deletions

View file

@ -1,19 +1,19 @@
import { contextBridge, ipcRenderer } from "electron"; import { contextBridge, ipcRenderer } from 'electron';
// Expose protected methods that allow the renderer process to use // Expose protected methods that allow the renderer process to use
// ipcRenderer without exposing the entire object // ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld("electronAPI", { contextBridge.exposeInMainWorld('electronAPI', {
invoke: (channel: string, ...args: unknown[]) => { invoke: (channel: string, ...args: unknown[]) => {
const validChannels = [ const validChannels = [
"storage:savePattern", 'storage:savePattern',
"storage:getPattern", 'storage:getPattern',
"storage:getLatest", 'storage:getLatest',
"storage:deletePattern", 'storage:deletePattern',
"storage:clear", 'storage:clear',
"dialog:openFile", 'dialog:openFile',
"dialog:saveFile", 'dialog:saveFile',
"fs:readFile", 'fs:readFile',
"fs:writeFile", 'fs:writeFile',
]; ];
if (validChannels.includes(channel)) { if (validChannels.includes(channel)) {
@ -23,21 +23,15 @@ contextBridge.exposeInMainWorld("electronAPI", {
throw new Error(`Invalid IPC channel: ${channel}`); throw new Error(`Invalid IPC channel: ${channel}`);
}, },
// Bluetooth device selection // Bluetooth device selection
onBluetoothDeviceList: ( onBluetoothDeviceList: (callback: (devices: Array<{ deviceId: string; deviceName: string }>) => void) => {
callback: ( ipcRenderer.on('bluetooth:device-list', (_event, devices) => callback(devices));
devices: Array<{ deviceId: string; deviceName: string }>,
) => void,
) => {
ipcRenderer.on("bluetooth:device-list", (_event, devices) =>
callback(devices),
);
}, },
selectBluetoothDevice: (deviceId: string) => { selectBluetoothDevice: (deviceId: string) => {
ipcRenderer.send("bluetooth:select-device", deviceId); ipcRenderer.send('bluetooth:select-device', deviceId);
}, },
}); });
// Also expose process type for platform detection // Also expose process type for platform detection
contextBridge.exposeInMainWorld("process", { contextBridge.exposeInMainWorld('process', {
type: "renderer", type: 'renderer',
}); });

View file

@ -1,25 +1,23 @@
import js from "@eslint/js"; import js from '@eslint/js'
import globals from "globals"; import globals from 'globals'
import reactHooks from "eslint-plugin-react-hooks"; import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from "eslint-plugin-react-refresh"; import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from "typescript-eslint"; import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from "eslint/config"; import { defineConfig, globalIgnores } from 'eslint/config'
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
export default defineConfig([ export default defineConfig([
globalIgnores(["dist", "dist-electron", ".vite"]), globalIgnores(['dist', 'dist-electron', '.vite']),
{ {
files: ["**/*.{ts,tsx}"], files: ['**/*.{ts,tsx}'],
extends: [ extends: [
js.configs.recommended, js.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommended,
reactHooks.configs.flat.recommended, reactHooks.configs.flat.recommended,
reactRefresh.configs.vite, reactRefresh.configs.vite,
eslintPluginPrettierRecommended,
], ],
languageOptions: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
}, },
]); ])

99
package-lock.json generated
View file

@ -43,12 +43,9 @@
"electron": "^39.2.6", "electron": "^39.2.6",
"electron-icon-builder": "^2.0.1", "electron-icon-builder": "^2.0.1",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"prettier": "3.7.4",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.4", "typescript-eslint": "^8.46.4",
"vite": "^7.2.4", "vite": "^7.2.4",
@ -2991,19 +2988,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@pkgr/core": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
"integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@polka/url": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@ -7058,53 +7042,6 @@
} }
} }
}, },
"node_modules/eslint-config-prettier": {
"version": "10.1.8",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
"funding": {
"url": "https://opencollective.com/eslint-config-prettier"
},
"peerDependencies": {
"eslint": ">=7.0.0"
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz",
"integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"prettier-linter-helpers": "^1.0.0",
"synckit": "^0.11.7"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint-plugin-prettier"
},
"peerDependencies": {
"@types/eslint": ">=8.0.0",
"eslint": ">=8.0.0",
"eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0",
"prettier": ">=3.0.0"
},
"peerDependenciesMeta": {
"@types/eslint": {
"optional": true
},
"eslint-config-prettier": {
"optional": true
}
}
},
"node_modules/eslint-plugin-react-hooks": { "node_modules/eslint-plugin-react-hooks": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
@ -7487,13 +7424,6 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@ -11127,19 +11057,6 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "url": "https://github.com/prettier/prettier?sponsor=1"
} }
}, },
"node_modules/prettier-linter-helpers": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
"integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-diff": "^1.1.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/pretty-ms": { "node_modules/pretty-ms": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-5.1.0.tgz", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-5.1.0.tgz",
@ -12814,22 +12731,6 @@
"camelcase": "^3.0.0" "camelcase": "^3.0.0"
} }
}, },
"node_modules/synckit": {
"version": "0.11.11",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
"integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pkgr/core": "^0.2.9"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/synckit"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",

View file

@ -56,12 +56,9 @@
"electron": "^39.2.6", "electron": "^39.2.6",
"electron-icon-builder": "^2.0.1", "electron-icon-builder": "^2.0.1",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"prettier": "3.7.4",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.4", "typescript-eslint": "^8.46.4",
"vite": "^7.2.4", "vite": "^7.2.4",

View file

@ -1,14 +1,14 @@
import { useEffect } from "react"; import { useEffect } from 'react';
import { useShallow } from "zustand/react/shallow"; import { useShallow } from 'zustand/react/shallow';
import { useMachineStore } from "./stores/useMachineStore"; import { useMachineStore } from './stores/useMachineStore';
import { usePatternStore } from "./stores/usePatternStore"; import { usePatternStore } from './stores/usePatternStore';
import { useUIStore } from "./stores/useUIStore"; import { useUIStore } from './stores/useUIStore';
import { AppHeader } from "./components/AppHeader"; import { AppHeader } from './components/AppHeader';
import { LeftSidebar } from "./components/LeftSidebar"; import { LeftSidebar } from './components/LeftSidebar';
import { PatternCanvas } from "./components/PatternCanvas"; import { PatternCanvas } from './components/PatternCanvas';
import { PatternPreviewPlaceholder } from "./components/PatternPreviewPlaceholder"; import { PatternPreviewPlaceholder } from './components/PatternPreviewPlaceholder';
import { BluetoothDevicePicker } from "./components/BluetoothDevicePicker"; import { BluetoothDevicePicker } from './components/BluetoothDevicePicker';
import "./App.css"; import './App.css';
function App() { function App() {
// Set page title with version // Set page title with version
@ -17,27 +17,36 @@ function App() {
}, []); }, []);
// Machine store - for auto-loading cached pattern // Machine store - for auto-loading cached pattern
const { resumedPattern, resumeFileName } = useMachineStore( const {
resumedPattern,
resumeFileName,
} = useMachineStore(
useShallow((state) => ({ useShallow((state) => ({
resumedPattern: state.resumedPattern, resumedPattern: state.resumedPattern,
resumeFileName: state.resumeFileName, resumeFileName: state.resumeFileName,
})), }))
); );
// Pattern store - for auto-loading cached pattern // Pattern store - for auto-loading cached pattern
const { pesData, setPattern, setPatternOffset } = usePatternStore( const {
pesData,
setPattern,
setPatternOffset,
} = usePatternStore(
useShallow((state) => ({ useShallow((state) => ({
pesData: state.pesData, pesData: state.pesData,
setPattern: state.setPattern, setPattern: state.setPattern,
setPatternOffset: state.setPatternOffset, setPatternOffset: state.setPatternOffset,
})), }))
); );
// UI store - for Pyodide initialization // UI store - for Pyodide initialization
const { initializePyodide } = useUIStore( const {
initializePyodide,
} = useUIStore(
useShallow((state) => ({ useShallow((state) => ({
initializePyodide: state.initializePyodide, initializePyodide: state.initializePyodide,
})), }))
); );
// Initialize Pyodide in background on mount (non-blocking thanks to worker) // Initialize Pyodide in background on mount (non-blocking thanks to worker)
@ -48,19 +57,11 @@ function App() {
// Auto-load cached pattern when available // Auto-load cached pattern when available
useEffect(() => { useEffect(() => {
if (resumedPattern && !pesData) { if (resumedPattern && !pesData) {
console.log( console.log('[App] Loading resumed pattern:', resumeFileName, 'Offset:', resumedPattern.patternOffset);
"[App] Loading resumed pattern:", setPattern(resumedPattern.pesData, resumeFileName || '');
resumeFileName,
"Offset:",
resumedPattern.patternOffset,
);
setPattern(resumedPattern.pesData, resumeFileName || "");
// Restore the cached pattern offset // Restore the cached pattern offset
if (resumedPattern.patternOffset) { if (resumedPattern.patternOffset) {
setPatternOffset( setPatternOffset(resumedPattern.patternOffset.x, resumedPattern.patternOffset.y);
resumedPattern.patternOffset.x,
resumedPattern.patternOffset.y,
);
} }
} }
}, [resumedPattern, resumeFileName, pesData, setPattern, setPatternOffset]); }, [resumedPattern, resumeFileName, pesData, setPattern, setPatternOffset]);

1
src/assets/react.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -1,10 +1,10 @@
import { useRef, useEffect } from "react"; import { useRef, useEffect } from 'react';
import { useShallow } from "zustand/react/shallow"; import { useShallow } from 'zustand/react/shallow';
import { useMachineStore } from "../stores/useMachineStore"; import { useMachineStore } from '../stores/useMachineStore';
import { useUIStore } from "../stores/useUIStore"; import { useUIStore } from '../stores/useUIStore';
import { WorkflowStepper } from "./WorkflowStepper"; import { WorkflowStepper } from './WorkflowStepper';
import { ErrorPopover } from "./ErrorPopover"; import { ErrorPopover } from './ErrorPopover';
import { getStateVisualInfo } from "../utils/machineStateHelpers"; import { getStateVisualInfo } from '../utils/machineStateHelpers';
import { import {
CheckCircleIcon, CheckCircleIcon,
BoltIcon, BoltIcon,
@ -12,7 +12,7 @@ import {
ExclamationTriangleIcon, ExclamationTriangleIcon,
ArrowPathIcon, ArrowPathIcon,
XMarkIcon, XMarkIcon,
} from "@heroicons/react/24/solid"; } from '@heroicons/react/24/solid';
export function AppHeader() { export function AppHeader() {
const { const {
@ -36,15 +36,19 @@ export function AppHeader() {
isPairingError: state.isPairingError, isPairingError: state.isPairingError,
isCommunicating: state.isCommunicating, isCommunicating: state.isCommunicating,
disconnect: state.disconnect, disconnect: state.disconnect,
})), }))
); );
const { pyodideError, showErrorPopover, setErrorPopover } = useUIStore( const {
pyodideError,
showErrorPopover,
setErrorPopover,
} = useUIStore(
useShallow((state) => ({ useShallow((state) => ({
pyodideError: state.pyodideError, pyodideError: state.pyodideError,
showErrorPopover: state.showErrorPopover, showErrorPopover: state.showErrorPopover,
setErrorPopover: state.setErrorPopover, setErrorPopover: state.setErrorPopover,
})), }))
); );
const errorPopoverRef = useRef<HTMLDivElement>(null); const errorPopoverRef = useRef<HTMLDivElement>(null);
@ -76,9 +80,8 @@ export function AppHeader() {
}; };
if (showErrorPopover) { if (showErrorPopover) {
document.addEventListener("mousedown", handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
return () => return () => document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener("mousedown", handleClickOutside);
} }
}, [showErrorPopover, setErrorPopover]); }, [showErrorPopover, setErrorPopover]);
@ -87,44 +90,33 @@ export function AppHeader() {
<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 <div className="w-2.5 h-2.5 bg-success-400 rounded-full animate-pulse shadow-lg shadow-success-400/50" style={{ visibility: isConnected ? 'visible' : 'hidden' }}></div>
className="w-2.5 h-2.5 bg-success-400 rounded-full animate-pulse shadow-lg shadow-success-400/50" <div className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5" style={{ visibility: !isConnected ? 'visible' : 'hidden' }}></div>
style={{ visibility: 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"> <h1 className="text-lg lg:text-xl font-bold text-white leading-tight">Respira</h1>
Respira
</h1>
{isConnected && machineInfo?.serialNumber && ( {isConnected && machineInfo?.serialNumber && (
<span <span
className="text-xs text-primary-200 cursor-help" className="text-xs text-primary-200 cursor-help"
title={`Serial: ${machineInfo.serialNumber}${ title={`Serial: ${machineInfo.serialNumber}${
machineInfo.macAddress machineInfo.macAddress
? `\nMAC: ${machineInfo.macAddress}` ? `\nMAC: ${machineInfo.macAddress}`
: "" : ''
}${ }${
machineInfo.totalCount !== undefined machineInfo.totalCount !== undefined
? `\nTotal stitches: ${machineInfo.totalCount.toLocaleString()}` ? `\nTotal stitches: ${machineInfo.totalCount.toLocaleString()}`
: "" : ''
}${ }${
machineInfo.serviceCount !== undefined machineInfo.serviceCount !== undefined
? `\nStitches since service: ${machineInfo.serviceCount.toLocaleString()}` ? `\nStitches since service: ${machineInfo.serviceCount.toLocaleString()}`
: "" : ''
}`} }`}
> >
{machineInfo.serialNumber} {machineInfo.serialNumber}
</span> </span>
)} )}
{isPolling && ( {isPolling && (
<ArrowPathIcon <ArrowPathIcon className="w-3.5 h-3.5 text-primary-200 animate-spin" title="Auto-refreshing status" />
className="w-3.5 h-3.5 text-primary-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]">
@ -154,9 +146,9 @@ export function AppHeader() {
ref={errorButtonRef} ref={errorButtonRef}
onClick={() => setErrorPopover(!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-danger-500/90 hover:bg-danger-600 text-white border border-danger-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-danger-500/90 hover:bg-danger-600 text-white border border-danger-400 transition-all flex-shrink-0 ${
machineErrorMessage || 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"
@ -165,30 +157,27 @@ export function AppHeader() {
<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 (isPairingError) return "Pairing Required"; if (isPairingError) return 'Pairing Required';
const errorMsg = machineErrorMessage || ""; const errorMsg = machineErrorMessage || '';
// Categorize by error message content // Categorize by error message content
if ( if (errorMsg.toLowerCase().includes('bluetooth') || errorMsg.toLowerCase().includes('connection')) {
errorMsg.toLowerCase().includes("bluetooth") || return 'Connection Error';
errorMsg.toLowerCase().includes("connection")
) {
return "Connection Error";
} }
if (errorMsg.toLowerCase().includes("upload")) { if (errorMsg.toLowerCase().includes('upload')) {
return "Upload Error"; return 'Upload Error';
} }
if (errorMsg.toLowerCase().includes("pattern")) { if (errorMsg.toLowerCase().includes('pattern')) {
return "Pattern Error"; return 'Pattern Error';
} }
if (machineError !== undefined) { if (machineError !== undefined) {
return `Machine Error`; return `Machine Error`;
} }
// Default fallback // Default fallback
return "Error"; return 'Error';
})()} })()}
</span> </span>
</button> </button>

View file

@ -1,5 +1,5 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from 'react';
import type { BluetoothDevice } from "../types/electron"; import type { BluetoothDevice } from '../types/electron';
export function BluetoothDevicePicker() { export function BluetoothDevicePicker() {
const [devices, setDevices] = useState<BluetoothDevice[]>([]); const [devices, setDevices] = useState<BluetoothDevice[]>([]);
@ -10,7 +10,7 @@ export function BluetoothDevicePicker() {
// Only set up listener in Electron // Only set up listener in Electron
if (window.electronAPI?.onBluetoothDeviceList) { if (window.electronAPI?.onBluetoothDeviceList) {
window.electronAPI.onBluetoothDeviceList((deviceList) => { window.electronAPI.onBluetoothDeviceList((deviceList) => {
console.log("[BluetoothPicker] Received device list:", deviceList); console.log('[BluetoothPicker] Received device list:', deviceList);
setDevices(deviceList); setDevices(deviceList);
// Open the picker when scan starts (even if empty at first) // Open the picker when scan starts (even if empty at first)
if (!isOpen) { if (!isOpen) {
@ -26,44 +26,38 @@ export function BluetoothDevicePicker() {
}, [isOpen]); }, [isOpen]);
const handleSelectDevice = useCallback((deviceId: string) => { const handleSelectDevice = useCallback((deviceId: string) => {
console.log("[BluetoothPicker] User selected device:", deviceId); console.log('[BluetoothPicker] User selected device:', deviceId);
window.electronAPI?.selectBluetoothDevice(deviceId); window.electronAPI?.selectBluetoothDevice(deviceId);
setIsOpen(false); setIsOpen(false);
setDevices([]); setDevices([]);
}, []); }, []);
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
console.log("[BluetoothPicker] User cancelled device selection"); console.log('[BluetoothPicker] User cancelled device selection');
window.electronAPI?.selectBluetoothDevice(""); window.electronAPI?.selectBluetoothDevice('');
setIsOpen(false); setIsOpen(false);
setDevices([]); setDevices([]);
setIsScanning(false); setIsScanning(false);
}, []); }, []);
// Handle escape key // Handle escape key
const handleEscape = useCallback( const handleEscape = useCallback((e: KeyboardEvent) => {
(e: KeyboardEvent) => { if (e.key === 'Escape') {
if (e.key === "Escape") { handleCancel();
handleCancel(); }
} }, [handleCancel]);
},
[handleCancel],
);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
document.addEventListener("keydown", handleEscape); document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener("keydown", handleEscape); return () => document.removeEventListener('keydown', handleEscape);
} }
}, [isOpen, handleEscape]); }, [isOpen, handleEscape]);
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div <div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]" onClick={handleCancel}>
className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]"
onClick={handleCancel}
>
<div <div
className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 border-t-4 border-primary-600 dark:border-primary-500" className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 border-t-4 border-primary-600 dark:border-primary-500"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@ -72,48 +66,23 @@ export function BluetoothDevicePicker() {
aria-describedby="bluetooth-picker-message" aria-describedby="bluetooth-picker-message"
> >
<div className="p-6 border-b border-gray-300 dark:border-gray-600"> <div className="p-6 border-b border-gray-300 dark:border-gray-600">
<h3 <h3 id="bluetooth-picker-title" className="m-0 text-base lg:text-lg font-semibold dark:text-white">
id="bluetooth-picker-title"
className="m-0 text-base lg:text-lg font-semibold dark:text-white"
>
Select Bluetooth Device Select Bluetooth Device
</h3> </h3>
</div> </div>
<div className="p-6"> <div className="p-6">
{isScanning && devices.length === 0 ? ( {isScanning && devices.length === 0 ? (
<div className="flex items-center gap-3 text-gray-700 dark:text-gray-300"> <div className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
<svg <svg className="animate-spin h-5 w-5 text-primary-600 dark:text-primary-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
className="animate-spin h-5 w-5 text-primary-600 dark:text-primary-400" <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
xmlns="http://www.w3.org/2000/svg" <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg> </svg>
<span id="bluetooth-picker-message"> <span id="bluetooth-picker-message">Scanning for Bluetooth devices...</span>
Scanning for Bluetooth devices...
</span>
</div> </div>
) : ( ) : (
<> <>
<p <p id="bluetooth-picker-message" className="mb-4 leading-relaxed text-gray-900 dark:text-gray-100">
id="bluetooth-picker-message" {devices.length} device{devices.length !== 1 ? 's' : ''} found. Select a device to connect:
className="mb-4 leading-relaxed text-gray-900 dark:text-gray-100"
>
{devices.length} device{devices.length !== 1 ? "s" : ""} found.
Select a device to connect:
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
{devices.map((device) => ( {devices.map((device) => (
@ -123,12 +92,8 @@ export function BluetoothDevicePicker() {
className="w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 text-left rounded-lg font-medium text-sm hover:bg-primary-100 dark:hover:bg-primary-900 hover:text-primary-900 dark:hover:text-primary-100 active:bg-primary-200 dark:active:bg-primary-800 transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" className="w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 text-left rounded-lg font-medium text-sm hover:bg-primary-100 dark:hover:bg-primary-900 hover:text-primary-900 dark:hover:text-primary-100 active:bg-primary-200 dark:active:bg-primary-800 transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label={`Connect to ${device.deviceName}`} aria-label={`Connect to ${device.deviceName}`}
> >
<div className="font-semibold text-gray-900 dark:text-white"> <div className="font-semibold text-gray-900 dark:text-white">{device.deviceName}</div>
{device.deviceName} <div className="text-xs text-gray-600 dark:text-gray-400 mt-1">{device.deviceId}</div>
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
{device.deviceId}
</div>
</button> </button>
))} ))}
</div> </div>

View file

@ -1,95 +1,79 @@
import { useEffect, useCallback } from "react"; import { useEffect, useCallback } from 'react';
interface ConfirmDialogProps { interface ConfirmDialogProps {
isOpen: boolean; isOpen: boolean;
title: string; title: string;
message: string; message: string;
confirmText?: string; confirmText?: string;
cancelText?: string; cancelText?: string;
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
variant?: "danger" | "warning"; variant?: 'danger' | 'warning';
} }
export function ConfirmDialog({ export function ConfirmDialog({
isOpen, isOpen,
title, title,
message, message,
confirmText = "Confirm", confirmText = 'Confirm',
cancelText = "Cancel", cancelText = 'Cancel',
onConfirm, onConfirm,
onCancel, onCancel,
variant = "warning", variant = 'warning',
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
// Handle escape key // Handle escape key
const handleEscape = useCallback( const handleEscape = useCallback((e: KeyboardEvent) => {
(e: KeyboardEvent) => { if (e.key === 'Escape') {
if (e.key === "Escape") { onCancel();
onCancel(); }
} }, [onCancel]);
},
[onCancel], useEffect(() => {
); if (isOpen) {
document.addEventListener('keydown', handleEscape);
useEffect(() => { return () => document.removeEventListener('keydown', handleEscape);
if (isOpen) { }
document.addEventListener("keydown", handleEscape); }, [isOpen, handleEscape]);
return () => document.removeEventListener("keydown", handleEscape);
} if (!isOpen) return null;
}, [isOpen, handleEscape]);
return (
if (!isOpen) return null; <div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]" onClick={onCancel}>
<div
return ( className={`bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 ${variant === 'danger' ? 'border-t-4 border-danger-600 dark:border-danger-500' : 'border-t-4 border-warning-500 dark:border-warning-600'}`}
<div onClick={(e) => e.stopPropagation()}
className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]" role="dialog"
onClick={onCancel} aria-labelledby="dialog-title"
> aria-describedby="dialog-message"
<div >
className={`bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 ${variant === "danger" ? "border-t-4 border-danger-600 dark:border-danger-500" : "border-t-4 border-warning-500 dark:border-warning-600"}`} <div className="p-6 border-b border-gray-300 dark:border-gray-600">
onClick={(e) => e.stopPropagation()} <h3 id="dialog-title" className="m-0 text-base lg:text-lg font-semibold dark:text-white">{title}</h3>
role="dialog" </div>
aria-labelledby="dialog-title" <div className="p-6">
aria-describedby="dialog-message" <p id="dialog-message" className="m-0 leading-relaxed text-gray-900 dark:text-gray-100">{message}</p>
> </div>
<div className="p-6 border-b border-gray-300 dark:border-gray-600"> <div className="p-4 px-6 flex gap-3 justify-end border-t border-gray-300 dark:border-gray-600">
<h3 <button
id="dialog-title" onClick={onCancel}
className="m-0 text-base lg:text-lg font-semibold dark:text-white" className="px-6 py-2.5 bg-gray-600 dark:bg-gray-700 text-white rounded-lg font-semibold text-sm hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-gray-300 dark:focus:ring-gray-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
> autoFocus
{title} aria-label="Cancel action"
</h3> >
</div> {cancelText}
<div className="p-6"> </button>
<p <button
id="dialog-message" onClick={onConfirm}
className="m-0 leading-relaxed text-gray-900 dark:text-gray-100" className={
> variant === 'danger'
{message} ? 'px-6 py-2.5 bg-danger-600 dark:bg-danger-700 text-white rounded-lg font-semibold text-sm hover:bg-danger-700 dark:hover:bg-danger-600 active:bg-danger-800 dark:active:bg-danger-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-danger-300 dark:focus:ring-danger-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900'
</p> : 'px-6 py-2.5 bg-primary-600 dark:bg-primary-700 text-white rounded-lg font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900'
</div> }
<div className="p-4 px-6 flex gap-3 justify-end border-t border-gray-300 dark:border-gray-600"> aria-label={`Confirm: ${confirmText}`}
<button >
onClick={onCancel} {confirmText}
className="px-6 py-2.5 bg-gray-600 dark:bg-gray-700 text-white rounded-lg font-semibold text-sm hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-gray-300 dark:focus:ring-gray-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" </button>
autoFocus </div>
aria-label="Cancel action" </div>
> </div>
{cancelText} );
</button> }
<button
onClick={onConfirm}
className={
variant === "danger"
? "px-6 py-2.5 bg-danger-600 dark:bg-danger-700 text-white rounded-lg font-semibold text-sm hover:bg-danger-700 dark:hover:bg-danger-600 active:bg-danger-800 dark:active:bg-danger-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-danger-300 dark:focus:ring-danger-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
: "px-6 py-2.5 bg-primary-600 dark:bg-primary-700 text-white rounded-lg font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
}
aria-label={`Confirm: ${confirmText}`}
>
{confirmText}
</button>
</div>
</div>
</div>
);
}

View file

@ -1,13 +1,13 @@
import { useShallow } from "zustand/react/shallow"; import { useShallow } from 'zustand/react/shallow';
import { useMachineStore } from "../stores/useMachineStore"; import { useMachineStore } from '../stores/useMachineStore';
import { isBluetoothSupported } from "../utils/bluetoothSupport"; import { isBluetoothSupported } from '../utils/bluetoothSupport';
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; import { ExclamationTriangleIcon } from '@heroicons/react/24/solid';
export function ConnectionPrompt() { export function ConnectionPrompt() {
const { connect } = useMachineStore( const { connect } = useMachineStore(
useShallow((state) => ({ useShallow((state) => ({
connect: state.connect, connect: state.connect,
})), }))
); );
if (isBluetoothSupported()) { if (isBluetoothSupported()) {
@ -15,27 +15,13 @@ export function ConnectionPrompt() {
<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">
<svg <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="w-6 h-6" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"
/>
</svg> </svg>
</div> </div>
<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"> <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Get Started</h3>
Get Started <p className="text-xs text-gray-600 dark:text-gray-400">Connect to your embroidery machine</p>
</h3>
<p className="text-xs text-gray-600 dark:text-gray-400">
Connect to your embroidery machine
</p>
</div> </div>
</div> </div>
<button <button
@ -53,21 +39,16 @@ export function ConnectionPrompt() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-6 h-6 text-warning-600 dark:text-warning-400 flex-shrink-0 mt-0.5" /> <ExclamationTriangleIcon className="w-6 h-6 text-warning-600 dark:text-warning-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-warning-900 dark:text-warning-100 mb-2"> <h3 className="text-base font-semibold text-warning-900 dark:text-warning-100 mb-2">Browser Not Supported</h3>
Browser Not Supported
</h3>
<p className="text-sm text-warning-800 dark:text-warning-200 mb-3"> <p className="text-sm text-warning-800 dark:text-warning-200 mb-3">
Your browser doesn't support Web Bluetooth, which is required to Your browser doesn't support Web Bluetooth, which is required to connect to your embroidery machine.
connect to your embroidery machine.
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm font-semibold text-warning-900 dark:text-warning-100"> <p className="text-sm font-semibold text-warning-900 dark:text-warning-100">Please try one of these options:</p>
Please try one of these options:
</p>
<ul className="text-sm text-warning-800 dark:text-warning-200 space-y-1.5 ml-4 list-disc"> <ul className="text-sm text-warning-800 dark:text-warning-200 space-y-1.5 ml-4 list-disc">
<li>Use a supported browser (Chrome, Edge, or Opera)</li> <li>Use a supported browser (Chrome, Edge, or Opera)</li>
<li> <li>
Download the Desktop app from{" "} Download the Desktop app from{' '}
<a <a
href="https://github.com/jhbruhn/respira/releases/latest" href="https://github.com/jhbruhn/respira/releases/latest"
target="_blank" target="_blank"

View file

@ -1,9 +1,6 @@
import { forwardRef } from "react"; import { forwardRef } from 'react';
import { import { ExclamationTriangleIcon, InformationCircleIcon } from '@heroicons/react/24/solid';
ExclamationTriangleIcon, import { getErrorDetails } from '../utils/errorCodeHelpers';
InformationCircleIcon,
} from "@heroicons/react/24/solid";
import { getErrorDetails } from "../utils/errorCodeHelpers";
interface ErrorPopoverProps { interface ErrorPopoverProps {
machineError?: number; machineError?: number;
@ -16,32 +13,31 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
({ machineError, isPairingError, errorMessage, pyodideError }, ref) => { ({ machineError, isPairingError, errorMessage, pyodideError }, ref) => {
const errorDetails = getErrorDetails(machineError); const errorDetails = getErrorDetails(machineError);
const isPairingErr = isPairingError; const isPairingErr = isPairingError;
const errorMsg = pyodideError || errorMessage || ""; const errorMsg = pyodideError || errorMessage || '';
const isInfo = isPairingErr || errorDetails?.isInformational; const isInfo = isPairingErr || errorDetails?.isInformational;
const bgColor = isInfo const bgColor = isInfo
? "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500" ? 'bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500'
: "bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500"; : 'bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500';
const iconColor = isInfo const iconColor = isInfo
? "text-info-600 dark:text-info-400" ? 'text-info-600 dark:text-info-400'
: "text-danger-600 dark:text-danger-400"; : 'text-danger-600 dark:text-danger-400';
const textColor = isInfo const textColor = isInfo
? "text-info-900 dark:text-info-200" ? 'text-info-900 dark:text-info-200'
: "text-danger-900 dark:text-danger-200"; : 'text-danger-900 dark:text-danger-200';
const descColor = isInfo const descColor = isInfo
? "text-info-800 dark:text-info-300" ? 'text-info-800 dark:text-info-300'
: "text-danger-800 dark:text-danger-300"; : 'text-danger-800 dark:text-danger-300';
const listColor = isInfo const listColor = isInfo
? "text-info-700 dark:text-info-300" ? 'text-info-700 dark:text-info-300'
: "text-danger-700 dark:text-danger-300"; : 'text-danger-700 dark:text-danger-300';
const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon; const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
const title = const title = errorDetails?.title || (isPairingErr ? 'Pairing Required' : 'Error');
errorDetails?.title || (isPairingErr ? "Pairing Required" : "Error");
return ( return (
<div <div
@ -50,9 +46,7 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
role="dialog" role="dialog"
aria-label="Error details" aria-label="Error details"
> >
<div <div className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}>
className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Icon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} /> <Icon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1"> <div className="flex-1">
@ -65,23 +59,18 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
{errorDetails?.solutions && errorDetails.solutions.length > 0 && ( {errorDetails?.solutions && errorDetails.solutions.length > 0 && (
<> <>
<h4 className={`text-sm font-semibold ${textColor} mb-2`}> <h4 className={`text-sm font-semibold ${textColor} mb-2`}>
{isInfo ? "Steps:" : "How to Fix:"} {isInfo ? 'Steps:' : 'How to Fix:'}
</h4> </h4>
<ol <ol className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`}>
className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`}
>
{errorDetails.solutions.map((solution, index) => ( {errorDetails.solutions.map((solution, index) => (
<li key={index} className="pl-2"> <li key={index} className="pl-2">{solution}</li>
{solution}
</li>
))} ))}
</ol> </ol>
</> </>
)} )}
{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 Error Code: 0x{machineError.toString(16).toUpperCase().padStart(2, '0')}
{machineError.toString(16).toUpperCase().padStart(2, "0")}
</p> </p>
)} )}
</div> </div>
@ -89,7 +78,7 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
</div> </div>
</div> </div>
); );
}, }
); );
ErrorPopover.displayName = "ErrorPopover"; ErrorPopover.displayName = 'ErrorPopover';

View file

@ -1,430 +1,336 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from 'react';
import { useShallow } from "zustand/react/shallow"; import { useShallow } from 'zustand/react/shallow';
import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore"; import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore';
import { usePatternStore } from "../stores/usePatternStore"; import { usePatternStore } from '../stores/usePatternStore';
import { useUIStore } from "../stores/useUIStore"; import { useUIStore } from '../stores/useUIStore';
import { import { convertPesToPen, type PesPatternData } from '../formats/import/pesImporter';
convertPesToPen, import { canUploadPattern, getMachineStateCategory } from '../utils/machineStateHelpers';
type PesPatternData, import { PatternInfoSkeleton } from './SkeletonLoader';
} from "../formats/import/pesImporter"; import { PatternInfo } from './PatternInfo';
import { import { ArrowUpTrayIcon, CheckCircleIcon, DocumentTextIcon, FolderOpenIcon } from '@heroicons/react/24/solid';
canUploadPattern, import { createFileService } from '../platform';
getMachineStateCategory, import type { IFileService } from '../platform/interfaces/IFileService';
} from "../utils/machineStateHelpers";
import { PatternInfoSkeleton } from "./SkeletonLoader"; export function FileUpload() {
import { PatternInfo } from "./PatternInfo"; // Machine store
import { const {
ArrowUpTrayIcon, isConnected,
CheckCircleIcon, machineStatus,
DocumentTextIcon, uploadProgress,
FolderOpenIcon, isUploading,
} from "@heroicons/react/24/solid"; machineInfo,
import { createFileService } from "../platform"; resumeAvailable,
import type { IFileService } from "../platform/interfaces/IFileService"; resumeFileName,
uploadPattern,
export function FileUpload() { } = useMachineStore(
// Machine store useShallow((state) => ({
const { isConnected: state.isConnected,
isConnected, machineStatus: state.machineStatus,
machineStatus, uploadProgress: state.uploadProgress,
uploadProgress, isUploading: state.isUploading,
isUploading, machineInfo: state.machineInfo,
machineInfo, resumeAvailable: state.resumeAvailable,
resumeAvailable, resumeFileName: state.resumeFileName,
resumeFileName, uploadPattern: state.uploadPattern,
uploadPattern, }))
} = useMachineStore( );
useShallow((state) => ({
isConnected: state.isConnected, // Pattern store
machineStatus: state.machineStatus, const {
uploadProgress: state.uploadProgress, pesData: pesDataProp,
isUploading: state.isUploading, currentFileName,
machineInfo: state.machineInfo, patternOffset,
resumeAvailable: state.resumeAvailable, setPattern,
resumeFileName: state.resumeFileName, } = usePatternStore(
uploadPattern: state.uploadPattern, useShallow((state) => ({
})), pesData: state.pesData,
); currentFileName: state.currentFileName,
patternOffset: state.patternOffset,
// Pattern store setPattern: state.setPattern,
const { }))
pesData: pesDataProp, );
currentFileName,
patternOffset, // Derived state: pattern is uploaded if machine has pattern info
setPattern, const patternUploaded = usePatternUploaded();
} = usePatternStore(
useShallow((state) => ({ // UI store
pesData: state.pesData, const {
currentFileName: state.currentFileName, pyodideReady,
patternOffset: state.patternOffset, pyodideProgress,
setPattern: state.setPattern, pyodideLoadingStep,
})), initializePyodide,
); } = useUIStore(
useShallow((state) => ({
// Derived state: pattern is uploaded if machine has pattern info pyodideReady: state.pyodideReady,
const patternUploaded = usePatternUploaded(); pyodideProgress: state.pyodideProgress,
pyodideLoadingStep: state.pyodideLoadingStep,
// UI store initializePyodide: state.initializePyodide,
const { }))
pyodideReady, );
pyodideProgress, const [localPesData, setLocalPesData] = useState<PesPatternData | null>(null);
pyodideLoadingStep, const [fileName, setFileName] = useState<string>('');
initializePyodide, const [fileService] = useState<IFileService>(() => createFileService());
} = useUIStore(
useShallow((state) => ({ // Use prop pesData if available (from cached pattern), otherwise use local state
pyodideReady: state.pyodideReady, const pesData = pesDataProp || localPesData;
pyodideProgress: state.pyodideProgress, // Use currentFileName from App state, or local fileName, or resumeFileName for display
pyodideLoadingStep: state.pyodideLoadingStep, const displayFileName = currentFileName || fileName || resumeFileName || '';
initializePyodide: state.initializePyodide, const [isLoading, setIsLoading] = useState(false);
})),
); const handleFileChange = useCallback(
const [localPesData, setLocalPesData] = useState<PesPatternData | null>(null); async (event?: React.ChangeEvent<HTMLInputElement>) => {
const [fileName, setFileName] = useState<string>(""); setIsLoading(true);
const [fileService] = useState<IFileService>(() => createFileService()); try {
// Wait for Pyodide if it's still loading
// Use prop pesData if available (from cached pattern), otherwise use local state if (!pyodideReady) {
const pesData = pesDataProp || localPesData; console.log('[FileUpload] Waiting for Pyodide to finish loading...');
// Use currentFileName from App state, or local fileName, or resumeFileName for display await initializePyodide();
const displayFileName = currentFileName || fileName || resumeFileName || ""; console.log('[FileUpload] Pyodide ready');
const [isLoading, setIsLoading] = useState(false); }
const handleFileChange = useCallback( let file: File | null = null;
async (event?: React.ChangeEvent<HTMLInputElement>) => {
setIsLoading(true); // In Electron, use native file dialogs
try { if (fileService.hasNativeDialogs()) {
// Wait for Pyodide if it's still loading file = await fileService.openFileDialog({ accept: '.pes' });
if (!pyodideReady) { } else {
console.log("[FileUpload] Waiting for Pyodide to finish loading..."); // In browser, use the input element
await initializePyodide(); file = event?.target.files?.[0] || null;
console.log("[FileUpload] Pyodide ready"); }
}
if (!file) {
let file: File | null = null; setIsLoading(false);
return;
// In Electron, use native file dialogs }
if (fileService.hasNativeDialogs()) {
file = await fileService.openFileDialog({ accept: ".pes" }); const data = await convertPesToPen(file);
} else { setLocalPesData(data);
// In browser, use the input element setFileName(file.name);
file = event?.target.files?.[0] || null; setPattern(data, file.name);
} } catch (err) {
alert(
if (!file) { `Failed to load PES file: ${
setIsLoading(false); err instanceof Error ? err.message : 'Unknown error'
return; }`
} );
} finally {
const data = await convertPesToPen(file); setIsLoading(false);
setLocalPesData(data); }
setFileName(file.name); },
setPattern(data, file.name); [fileService, setPattern, pyodideReady, initializePyodide]
} catch (err) { );
alert(
`Failed to load PES file: ${ const handleUpload = useCallback(() => {
err instanceof Error ? err.message : "Unknown error" if (pesData && displayFileName) {
}`, uploadPattern(pesData.penData, pesData, displayFileName, patternOffset);
); }
} finally { }, [pesData, displayFileName, uploadPattern, patternOffset]);
setIsLoading(false);
} // Check if pattern (with offset) fits within hoop bounds
}, const checkPatternFitsInHoop = useCallback(() => {
[fileService, setPattern, pyodideReady, initializePyodide], if (!pesData || !machineInfo) {
); return { fits: true, error: null };
}
const handleUpload = useCallback(() => {
if (pesData && displayFileName) { const { bounds } = pesData;
uploadPattern(pesData.penData, pesData, displayFileName, patternOffset); const { maxWidth, maxHeight } = machineInfo;
}
}, [pesData, displayFileName, uploadPattern, patternOffset]); // Calculate pattern bounds with offset applied
const patternMinX = bounds.minX + patternOffset.x;
// Check if pattern (with offset) fits within hoop bounds const patternMaxX = bounds.maxX + patternOffset.x;
const checkPatternFitsInHoop = useCallback(() => { const patternMinY = bounds.minY + patternOffset.y;
if (!pesData || !machineInfo) { const patternMaxY = bounds.maxY + patternOffset.y;
return { fits: true, error: null };
} // Hoop bounds (centered at origin)
const hoopMinX = -maxWidth / 2;
const { bounds } = pesData; const hoopMaxX = maxWidth / 2;
const { maxWidth, maxHeight } = machineInfo; const hoopMinY = -maxHeight / 2;
const hoopMaxY = maxHeight / 2;
// Calculate pattern bounds with offset applied
const patternMinX = bounds.minX + patternOffset.x; // Check if pattern exceeds hoop bounds
const patternMaxX = bounds.maxX + patternOffset.x; const exceedsLeft = patternMinX < hoopMinX;
const patternMinY = bounds.minY + patternOffset.y; const exceedsRight = patternMaxX > hoopMaxX;
const patternMaxY = bounds.maxY + patternOffset.y; const exceedsTop = patternMinY < hoopMinY;
const exceedsBottom = patternMaxY > hoopMaxY;
// Hoop bounds (centered at origin)
const hoopMinX = -maxWidth / 2; if (exceedsLeft || exceedsRight || exceedsTop || exceedsBottom) {
const hoopMaxX = maxWidth / 2; const directions = [];
const hoopMinY = -maxHeight / 2; if (exceedsLeft) directions.push(`left by ${((hoopMinX - patternMinX) / 10).toFixed(1)}mm`);
const hoopMaxY = maxHeight / 2; if (exceedsRight) directions.push(`right by ${((patternMaxX - hoopMaxX) / 10).toFixed(1)}mm`);
if (exceedsTop) directions.push(`top by ${((hoopMinY - patternMinY) / 10).toFixed(1)}mm`);
// Check if pattern exceeds hoop bounds if (exceedsBottom) directions.push(`bottom by ${((patternMaxY - hoopMaxY) / 10).toFixed(1)}mm`);
const exceedsLeft = patternMinX < hoopMinX;
const exceedsRight = patternMaxX > hoopMaxX; return {
const exceedsTop = patternMinY < hoopMinY; fits: false,
const exceedsBottom = patternMaxY > hoopMaxY; error: `Pattern exceeds hoop bounds: ${directions.join(', ')}. Adjust pattern position in preview.`
};
if (exceedsLeft || exceedsRight || exceedsTop || exceedsBottom) { }
const directions = [];
if (exceedsLeft) return { fits: true, error: null };
directions.push( }, [pesData, machineInfo, patternOffset]);
`left by ${((hoopMinX - patternMinX) / 10).toFixed(1)}mm`,
); const boundsCheck = checkPatternFitsInHoop();
if (exceedsRight)
directions.push( const borderColor = pesData ? 'border-secondary-600 dark:border-secondary-500' : 'border-gray-400 dark:border-gray-600';
`right by ${((patternMaxX - hoopMaxX) / 10).toFixed(1)}mm`, const iconColor = pesData ? 'text-secondary-600 dark:text-secondary-400' : 'text-gray-600 dark:text-gray-400';
);
if (exceedsTop) return (
directions.push( <div className={`bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor}`}>
`top by ${((hoopMinY - patternMinY) / 10).toFixed(1)}mm`, <div className="flex items-start gap-3 mb-3">
); <DocumentTextIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
if (exceedsBottom) <div className="flex-1 min-w-0">
directions.push( <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Pattern File</h3>
`bottom by ${((patternMaxY - hoopMaxY) / 10).toFixed(1)}mm`, {pesData && displayFileName ? (
); <p className="text-xs text-gray-600 dark:text-gray-400 truncate" title={displayFileName}>
{displayFileName}
return { </p>
fits: false, ) : (
error: `Pattern exceeds hoop bounds: ${directions.join(", ")}. Adjust pattern position in preview.`, <p className="text-xs text-gray-600 dark:text-gray-400">No pattern loaded</p>
}; )}
} </div>
</div>
return { fits: true, error: null };
}, [pesData, machineInfo, patternOffset]); {resumeAvailable && resumeFileName && (
<div className="bg-success-50 dark:bg-success-900/20 border border-success-200 dark:border-success-800 px-3 py-2 rounded mb-3">
const boundsCheck = checkPatternFitsInHoop(); <p className="text-xs text-success-800 dark:text-success-200">
<strong>Cached:</strong> "{resumeFileName}"
const borderColor = pesData </p>
? "border-secondary-600 dark:border-secondary-500" </div>
: "border-gray-400 dark:border-gray-600"; )}
const iconColor = pesData
? "text-secondary-600 dark:text-secondary-400" {isLoading && <PatternInfoSkeleton />}
: "text-gray-600 dark:text-gray-400";
{!isLoading && pesData && (
return ( <div className="mb-3">
<div <PatternInfo pesData={pesData} showThreadBlocks />
className={`bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor}`} </div>
> )}
<div className="flex items-start gap-3 mb-3">
<DocumentTextIcon <div className="flex gap-2 mb-3">
className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} <input
/> type="file"
<div className="flex-1 min-w-0"> accept=".pes"
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1"> onChange={handleFileChange}
Pattern File id="file-input"
</h3> className="hidden"
{pesData && displayFileName ? ( disabled={isLoading || patternUploaded || isUploading}
<p />
className="text-xs text-gray-600 dark:text-gray-400 truncate" <label
title={displayFileName} htmlFor={fileService.hasNativeDialogs() ? undefined : "file-input"}
> onClick={fileService.hasNativeDialogs() ? () => handleFileChange() : undefined}
{displayFileName} className={`flex-[2] flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 rounded font-semibold text-sm transition-all ${
</p> isLoading || patternUploaded || isUploading
) : ( ? 'opacity-50 cursor-not-allowed bg-gray-400 dark:bg-gray-600 text-white'
<p className="text-xs text-gray-600 dark:text-gray-400"> : 'cursor-pointer bg-gray-600 dark:bg-gray-700 text-white hover:bg-gray-700 dark:hover:bg-gray-600'
No pattern loaded }`}
</p> >
)} {isLoading ? (
</div> <>
</div> <svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
{resumeAvailable && resumeFileName && ( <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<div className="bg-success-50 dark:bg-success-900/20 border border-success-200 dark:border-success-800 px-3 py-2 rounded mb-3"> </svg>
<p className="text-xs text-success-800 dark:text-success-200"> <span>Loading...</span>
<strong>Cached:</strong> "{resumeFileName}" </>
</p> ) : patternUploaded ? (
</div> <>
)} <CheckCircleIcon className="w-3.5 h-3.5" />
<span>Locked</span>
{isLoading && <PatternInfoSkeleton />} </>
) : (
{!isLoading && pesData && ( <>
<div className="mb-3"> <FolderOpenIcon className="w-3.5 h-3.5" />
<PatternInfo pesData={pesData} showThreadBlocks /> <span>Choose PES File</span>
</div> </>
)} )}
</label>
<div className="flex gap-2 mb-3">
<input {pesData && canUploadPattern(machineStatus) && !patternUploaded && uploadProgress < 100 && (
type="file" <button
accept=".pes" onClick={handleUpload}
onChange={handleFileChange} disabled={!isConnected || isUploading || !boundsCheck.fits}
id="file-input" className="flex-1 px-3 py-2.5 sm:py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
className="hidden" aria-label={isUploading ? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete` : boundsCheck.error || 'Upload pattern to machine'}
disabled={isLoading || patternUploaded || isUploading} >
/> {isUploading ? (
<label <>
htmlFor={fileService.hasNativeDialogs() ? undefined : "file-input"} <svg className="w-3.5 h-3.5 animate-spin inline mr-1" fill="none" viewBox="0 0 24 24">
onClick={ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
fileService.hasNativeDialogs() <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
? () => handleFileChange() </svg>
: undefined {uploadProgress > 0 ? uploadProgress.toFixed(0) + '%' : 'Uploading'}
} </>
className={`flex-[2] flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 rounded font-semibold text-sm transition-all ${ ) : (
isLoading || patternUploaded || isUploading <>
? "opacity-50 cursor-not-allowed bg-gray-400 dark:bg-gray-600 text-white" <ArrowUpTrayIcon className="w-3.5 h-3.5 inline mr-1" />
: "cursor-pointer bg-gray-600 dark:bg-gray-700 text-white hover:bg-gray-700 dark:hover:bg-gray-600" Upload
}`} </>
> )}
{isLoading ? ( </button>
<> )}
<svg </div>
className="w-3.5 h-3.5 animate-spin"
fill="none" {/* Pyodide initialization progress indicator - shown when initializing or waiting */}
viewBox="0 0 24 24" {!pyodideReady && pyodideProgress > 0 && (
> <div className="mb-3">
<circle <div className="flex justify-between items-center mb-1.5">
className="opacity-25" <span className="text-xs font-medium text-gray-600 dark:text-gray-400">
cx="12" {isLoading && !pyodideReady
cy="12" ? 'Please wait - initializing Python environment...'
r="10" : pyodideLoadingStep || 'Initializing Python environment...'}
stroke="currentColor" </span>
strokeWidth="4" <span className="text-xs font-bold text-primary-600 dark:text-primary-400">
></circle> {pyodideProgress.toFixed(0)}%
<path </span>
className="opacity-75" </div>
fill="currentColor" <div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative">
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" <div
></path> className="h-full bg-gradient-to-r from-primary-500 via-primary-600 to-primary-700 dark:from-primary-600 dark:via-primary-700 dark:to-primary-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite] rounded-full"
</svg> style={{ width: `${pyodideProgress}%` }}
<span>Loading...</span> />
</> </div>
) : patternUploaded ? ( <p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 italic">
<> {isLoading && !pyodideReady
<CheckCircleIcon className="w-3.5 h-3.5" /> ? 'File dialog will open automatically when ready'
<span>Locked</span> : 'This only happens once on first use'}
</> </p>
) : ( </div>
<> )}
<FolderOpenIcon className="w-3.5 h-3.5" />
<span>Choose PES File</span> {/* Error/warning messages with smooth transition - placed after buttons */}
</> <div className="transition-all duration-200 ease-in-out overflow-hidden" style={{
)} maxHeight: (pesData && (boundsCheck.error || !canUploadPattern(machineStatus))) ? '200px' : '0px',
</label> marginTop: (pesData && (boundsCheck.error || !canUploadPattern(machineStatus))) ? '12px' : '0px'
}}>
{pesData && {pesData && !canUploadPattern(machineStatus) && (
canUploadPattern(machineStatus) && <div className="bg-warning-100 dark:bg-warning-900/20 text-warning-800 dark:text-warning-200 px-3 py-2 rounded border border-warning-200 dark:border-warning-800 text-sm">
!patternUploaded && Cannot upload while {getMachineStateCategory(machineStatus)}
uploadProgress < 100 && ( </div>
<button )}
onClick={handleUpload}
disabled={!isConnected || isUploading || !boundsCheck.fits} {pesData && boundsCheck.error && (
className="flex-1 px-3 py-2.5 sm:py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" <div className="bg-danger-100 dark:bg-danger-900/20 text-danger-800 dark:text-danger-200 px-3 py-2 rounded border border-danger-200 dark:border-danger-800 text-sm">
aria-label={ <strong>Pattern too large:</strong> {boundsCheck.error}
isUploading </div>
? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete` )}
: boundsCheck.error || "Upload pattern to machine" </div>
}
> {isUploading && uploadProgress < 100 && (
{isUploading ? ( <div className="mt-3">
<> <div className="flex justify-between items-center mb-1.5">
<svg <span className="text-xs font-medium text-gray-600 dark:text-gray-400">Uploading</span>
className="w-3.5 h-3.5 animate-spin inline mr-1" <span className="text-xs font-bold text-secondary-600 dark:text-secondary-400">
fill="none" {uploadProgress > 0 ? uploadProgress.toFixed(1) + '%' : 'Starting...'}
viewBox="0 0 24 24" </span>
> </div>
<circle <div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative">
className="opacity-25" <div
cx="12" className="h-full bg-gradient-to-r from-secondary-500 via-secondary-600 to-secondary-700 dark:from-secondary-600 dark:via-secondary-700 dark:to-secondary-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite] rounded-full"
cy="12" style={{ width: `${uploadProgress}%` }}
r="10" />
stroke="currentColor" </div>
strokeWidth="4" </div>
></circle> )}
<path </div>
className="opacity-75" );
fill="currentColor" }
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{uploadProgress > 0
? uploadProgress.toFixed(0) + "%"
: "Uploading"}
</>
) : (
<>
<ArrowUpTrayIcon className="w-3.5 h-3.5 inline mr-1" />
Upload
</>
)}
</button>
)}
</div>
{/* Pyodide initialization progress indicator - shown when initializing or waiting */}
{!pyodideReady && pyodideProgress > 0 && (
<div className="mb-3">
<div className="flex justify-between items-center mb-1.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
{isLoading && !pyodideReady
? "Please wait - initializing Python environment..."
: pyodideLoadingStep || "Initializing Python environment..."}
</span>
<span className="text-xs font-bold text-primary-600 dark:text-primary-400">
{pyodideProgress.toFixed(0)}%
</span>
</div>
<div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative">
<div
className="h-full bg-gradient-to-r from-primary-500 via-primary-600 to-primary-700 dark:from-primary-600 dark:via-primary-700 dark:to-primary-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite] rounded-full"
style={{ width: `${pyodideProgress}%` }}
/>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 italic">
{isLoading && !pyodideReady
? "File dialog will open automatically when ready"
: "This only happens once on first use"}
</p>
</div>
)}
{/* Error/warning messages with smooth transition - placed after buttons */}
<div
className="transition-all duration-200 ease-in-out overflow-hidden"
style={{
maxHeight:
pesData && (boundsCheck.error || !canUploadPattern(machineStatus))
? "200px"
: "0px",
marginTop:
pesData && (boundsCheck.error || !canUploadPattern(machineStatus))
? "12px"
: "0px",
}}
>
{pesData && !canUploadPattern(machineStatus) && (
<div className="bg-warning-100 dark:bg-warning-900/20 text-warning-800 dark:text-warning-200 px-3 py-2 rounded border border-warning-200 dark:border-warning-800 text-sm">
Cannot upload while {getMachineStateCategory(machineStatus)}
</div>
)}
{pesData && boundsCheck.error && (
<div className="bg-danger-100 dark:bg-danger-900/20 text-danger-800 dark:text-danger-200 px-3 py-2 rounded border border-danger-200 dark:border-danger-800 text-sm">
<strong>Pattern too large:</strong> {boundsCheck.error}
</div>
)}
</div>
{isUploading && uploadProgress < 100 && (
<div className="mt-3">
<div className="flex justify-between items-center mb-1.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
Uploading
</span>
<span className="text-xs font-bold text-secondary-600 dark:text-secondary-400">
{uploadProgress > 0
? uploadProgress.toFixed(1) + "%"
: "Starting..."}
</span>
</div>
<div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative">
<div
className="h-full bg-gradient-to-r from-secondary-500 via-secondary-600 to-secondary-700 dark:from-secondary-600 dark:via-secondary-700 dark:to-secondary-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite] rounded-full"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
</div>
);
}

View file

@ -1,10 +1,10 @@
import { memo, useMemo } from "react"; import { memo, useMemo } from 'react';
import { Group, Line, Rect, Text, Circle } from "react-konva"; import { Group, Line, Rect, Text, Circle } from 'react-konva';
import type { PesPatternData } from "../formats/import/pesImporter"; import type { PesPatternData } from '../formats/import/pesImporter';
import { getThreadColor } from "../formats/import/pesImporter"; import { getThreadColor } from '../formats/import/pesImporter';
import type { MachineInfo } from "../types/machine"; import type { MachineInfo } from '../types/machine';
import { MOVE } from "../formats/import/constants"; import { MOVE } from '../formats/import/constants';
import { canvasColors } from "../utils/cssVariables"; import { canvasColors } from '../utils/cssVariables';
interface GridProps { interface GridProps {
gridSize: number; gridSize: number;
@ -23,20 +23,12 @@ export const Grid = memo(({ gridSize, bounds, machineInfo }: GridProps) => {
const horizontalLines: number[][] = []; const horizontalLines: number[][] = [];
// Vertical lines // Vertical lines
for ( for (let x = Math.floor(gridMinX / gridSize) * gridSize; x <= gridMaxX; x += gridSize) {
let x = Math.floor(gridMinX / gridSize) * gridSize;
x <= gridMaxX;
x += gridSize
) {
verticalLines.push([x, gridMinY, x, gridMaxY]); verticalLines.push([x, gridMinY, x, gridMaxY]);
} }
// Horizontal lines // Horizontal lines
for ( for (let y = Math.floor(gridMinY / gridSize) * gridSize; y <= gridMaxY; y += gridSize) {
let y = Math.floor(gridMinY / gridSize) * gridSize;
y <= gridMaxY;
y += gridSize
) {
horizontalLines.push([gridMinX, y, gridMaxX, y]); horizontalLines.push([gridMinX, y, gridMaxX, y]);
} }
@ -67,7 +59,7 @@ export const Grid = memo(({ gridSize, bounds, machineInfo }: GridProps) => {
); );
}); });
Grid.displayName = "Grid"; Grid.displayName = 'Grid';
export const Origin = memo(() => { export const Origin = memo(() => {
const originColor = canvasColors.origin(); const originColor = canvasColors.origin();
@ -80,7 +72,7 @@ export const Origin = memo(() => {
); );
}); });
Origin.displayName = "Origin"; Origin.displayName = 'Origin';
interface HoopProps { interface HoopProps {
machineInfo: MachineInfo; machineInfo: MachineInfo;
@ -116,7 +108,7 @@ export const Hoop = memo(({ machineInfo }: HoopProps) => {
); );
}); });
Hoop.displayName = "Hoop"; Hoop.displayName = 'Hoop';
interface PatternBoundsProps { interface PatternBoundsProps {
bounds: { minX: number; maxX: number; minY: number; maxY: number }; bounds: { minX: number; maxX: number; minY: number; maxY: number };
@ -141,7 +133,7 @@ export const PatternBounds = memo(({ bounds }: PatternBoundsProps) => {
); );
}); });
PatternBounds.displayName = "PatternBounds"; PatternBounds.displayName = 'PatternBounds';
interface StitchesProps { interface StitchesProps {
stitches: number[][]; stitches: number[][];
@ -150,146 +142,113 @@ interface StitchesProps {
showProgress?: boolean; showProgress?: boolean;
} }
export const Stitches = memo( export const Stitches = memo(({ stitches, pesData, currentStitchIndex, showProgress = false }: StitchesProps) => {
({ const stitchGroups = useMemo(() => {
stitches, interface StitchGroup {
pesData, color: string;
currentStitchIndex, points: number[];
showProgress = false, completed: boolean;
}: StitchesProps) => { isJump: boolean;
const stitchGroups = useMemo(() => { }
interface StitchGroup {
color: string;
points: number[];
completed: boolean;
isJump: boolean;
}
const groups: StitchGroup[] = []; const groups: StitchGroup[] = [];
let currentGroup: StitchGroup | null = null; let currentGroup: StitchGroup | null = null;
let prevX = 0; let prevX = 0;
let prevY = 0; let prevY = 0;
for (let i = 0; i < stitches.length; i++) { for (let i = 0; i < stitches.length; i++) {
const stitch = stitches[i]; const stitch = stitches[i];
const [x, y, cmd, colorIndex] = stitch; const [x, y, cmd, colorIndex] = stitch;
const isCompleted = i < currentStitchIndex; const isCompleted = i < currentStitchIndex;
const isJump = (cmd & MOVE) !== 0; const isJump = (cmd & MOVE) !== 0;
const color = getThreadColor(pesData, colorIndex); const color = getThreadColor(pesData, colorIndex);
// Start new group if color/status/type changes // Start new group if color/status/type changes
if ( if (
!currentGroup || !currentGroup ||
currentGroup.color !== color || currentGroup.color !== color ||
currentGroup.completed !== isCompleted || currentGroup.completed !== isCompleted ||
currentGroup.isJump !== isJump currentGroup.isJump !== isJump
) { ) {
// For jump stitches, we need to create a line from previous position to current position // For jump stitches, we need to create a line from previous position to current position
// So we include both the previous point and current point // So we include both the previous point and current point
if (isJump && i > 0) { if (isJump && i > 0) {
currentGroup = { currentGroup = {
color, color,
points: [prevX, prevY, x, y], points: [prevX, prevY, x, y],
completed: isCompleted, completed: isCompleted,
isJump, isJump,
}; };
} else {
currentGroup = {
color,
points: [x, y],
completed: isCompleted,
isJump,
};
}
groups.push(currentGroup);
} else { } else {
currentGroup.points.push(x, y); currentGroup = {
color,
points: [x, y],
completed: isCompleted,
isJump,
};
} }
groups.push(currentGroup);
prevX = x; } else {
prevY = y; currentGroup.points.push(x, y);
} }
return groups; prevX = x;
}, [stitches, pesData, currentStitchIndex]); prevY = y;
}
return ( return groups;
<Group name="stitches"> }, [stitches, pesData, currentStitchIndex]);
{stitchGroups.map((group, i) => (
<Line
key={i}
points={group.points}
stroke={group.color}
strokeWidth={group.isJump ? 1.5 : 1.5}
lineCap="round"
lineJoin="round"
dash={group.isJump ? [8, 4] : undefined}
opacity={
group.isJump
? group.completed
? 0.8
: 0.5
: showProgress && !group.completed
? 0.3
: 1.0
}
/>
))}
</Group>
);
},
);
Stitches.displayName = "Stitches"; return (
<Group name="stitches">
{stitchGroups.map((group, i) => (
<Line
key={i}
points={group.points}
stroke={group.color}
strokeWidth={group.isJump ? 1.5 : 1.5}
lineCap="round"
lineJoin="round"
dash={group.isJump ? [8, 4] : undefined}
opacity={group.isJump ? (group.completed ? 0.8 : 0.5) : (showProgress && !group.completed ? 0.3 : 1.0)}
/>
))}
</Group>
);
});
Stitches.displayName = 'Stitches';
interface CurrentPositionProps { interface CurrentPositionProps {
currentStitchIndex: number; currentStitchIndex: number;
stitches: number[][]; stitches: number[][];
} }
export const CurrentPosition = memo( export const CurrentPosition = memo(({ currentStitchIndex, stitches }: CurrentPositionProps) => {
({ currentStitchIndex, stitches }: CurrentPositionProps) => { if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) {
if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) { return null;
return null; }
}
const [x, y] = stitches[currentStitchIndex]; const [x, y] = stitches[currentStitchIndex];
const positionColor = canvasColors.position(); const positionColor = canvasColors.position();
return ( return (
<Group name="currentPosition"> <Group name="currentPosition">
<Circle <Circle
x={x} x={x}
y={y} y={y}
radius={8} radius={8}
fill={`${positionColor}4d`} fill={`${positionColor}4d`}
stroke={positionColor} stroke={positionColor}
strokeWidth={3} strokeWidth={3}
/> />
<Line <Line points={[x - 12, y, x - 3, y]} stroke={positionColor} strokeWidth={2} />
points={[x - 12, y, x - 3, y]} <Line points={[x + 12, y, x + 3, y]} stroke={positionColor} strokeWidth={2} />
stroke={positionColor} <Line points={[x, y - 12, x, y - 3]} stroke={positionColor} strokeWidth={2} />
strokeWidth={2} <Line points={[x, y + 12, x, y + 3]} stroke={positionColor} strokeWidth={2} />
/> </Group>
<Line );
points={[x + 12, y, x + 3, y]} });
stroke={positionColor}
strokeWidth={2}
/>
<Line
points={[x, y - 12, x, y - 3]}
stroke={positionColor}
strokeWidth={2}
/>
<Line
points={[x, y + 12, x, y + 3]}
stroke={positionColor}
strokeWidth={2}
/>
</Group>
);
},
);
CurrentPosition.displayName = "CurrentPosition"; CurrentPosition.displayName = 'CurrentPosition';

View file

@ -1,22 +1,22 @@
import { useShallow } from "zustand/react/shallow"; import { useShallow } from 'zustand/react/shallow';
import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore"; import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore';
import { usePatternStore } from "../stores/usePatternStore"; import { usePatternStore } from '../stores/usePatternStore';
import { ConnectionPrompt } from "./ConnectionPrompt"; import { ConnectionPrompt } from './ConnectionPrompt';
import { FileUpload } from "./FileUpload"; import { FileUpload } from './FileUpload';
import { PatternSummaryCard } from "./PatternSummaryCard"; import { PatternSummaryCard } from './PatternSummaryCard';
import { ProgressMonitor } from "./ProgressMonitor"; import { ProgressMonitor } from './ProgressMonitor';
export function LeftSidebar() { export function LeftSidebar() {
const { isConnected } = useMachineStore( const { isConnected } = useMachineStore(
useShallow((state) => ({ useShallow((state) => ({
isConnected: state.isConnected, isConnected: state.isConnected,
})), }))
); );
const { pesData } = usePatternStore( const { pesData } = usePatternStore(
useShallow((state) => ({ useShallow((state) => ({
pesData: state.pesData, pesData: state.pesData,
})), }))
); );
// Derived state: pattern is uploaded if machine has pattern info // Derived state: pattern is uploaded if machine has pattern info

View file

@ -1,224 +1,189 @@
import { useState } from "react"; import { useState } from 'react';
import { import {
InformationCircleIcon, InformationCircleIcon,
CheckCircleIcon, CheckCircleIcon,
BoltIcon, BoltIcon,
PauseCircleIcon, PauseCircleIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
WifiIcon, WifiIcon,
} from "@heroicons/react/24/solid"; } from '@heroicons/react/24/solid';
import type { MachineInfo } from "../types/machine"; import type { MachineInfo } from '../types/machine';
import { MachineStatus } from "../types/machine"; import { MachineStatus } from '../types/machine';
import { ConfirmDialog } from "./ConfirmDialog"; import { ConfirmDialog } from './ConfirmDialog';
import { import { shouldConfirmDisconnect, getStateVisualInfo } from '../utils/machineStateHelpers';
shouldConfirmDisconnect, import { hasError, getErrorDetails } from '../utils/errorCodeHelpers';
getStateVisualInfo,
} from "../utils/machineStateHelpers"; interface MachineConnectionProps {
import { hasError, getErrorDetails } from "../utils/errorCodeHelpers"; isConnected: boolean;
machineInfo: MachineInfo | null;
interface MachineConnectionProps { machineStatus: MachineStatus;
isConnected: boolean; machineStatusName: string;
machineInfo: MachineInfo | null; machineError: number;
machineStatus: MachineStatus; onConnect: () => void;
machineStatusName: string; onDisconnect: () => void;
machineError: number; onRefresh: () => void;
onConnect: () => void; }
onDisconnect: () => void;
onRefresh: () => void; export function MachineConnection({
} isConnected,
machineInfo,
export function MachineConnection({ machineStatus,
isConnected, machineStatusName,
machineInfo, machineError,
machineStatus, onConnect,
machineStatusName, onDisconnect,
machineError, }: MachineConnectionProps) {
onConnect, const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
onDisconnect,
}: MachineConnectionProps) { const handleDisconnectClick = () => {
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false); if (shouldConfirmDisconnect(machineStatus)) {
setShowDisconnectConfirm(true);
const handleDisconnectClick = () => { } else {
if (shouldConfirmDisconnect(machineStatus)) { onDisconnect();
setShowDisconnectConfirm(true); }
} else { };
onDisconnect();
} const handleConfirmDisconnect = () => {
}; setShowDisconnectConfirm(false);
onDisconnect();
const handleConfirmDisconnect = () => { };
setShowDisconnectConfirm(false);
onDisconnect(); const stateVisual = getStateVisualInfo(machineStatus);
};
// Map icon names to Heroicons
const stateVisual = getStateVisualInfo(machineStatus); const stateIcons = {
ready: CheckCircleIcon,
// Map icon names to Heroicons active: BoltIcon,
const stateIcons = { waiting: PauseCircleIcon,
ready: CheckCircleIcon, complete: CheckCircleIcon,
active: BoltIcon, interrupted: PauseCircleIcon,
waiting: PauseCircleIcon, error: ExclamationTriangleIcon,
complete: CheckCircleIcon, };
interrupted: PauseCircleIcon,
error: ExclamationTriangleIcon, const statusBadgeColors = {
}; idle: 'bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300',
info: 'bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300',
const statusBadgeColors = { active: 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300',
idle: "bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300", waiting: 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300',
info: "bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300", warning: 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300',
active: complete: 'bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300',
"bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300", success: 'bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300',
waiting: interrupted: 'bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300',
"bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300", error: 'bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300',
warning: danger: 'bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300',
"bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300", };
complete:
"bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300", // Only show error info when connected AND there's an actual error
success: const errorInfo = (isConnected && hasError(machineError)) ? getErrorDetails(machineError) : null;
"bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300",
interrupted: return (
"bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300", <>
error: {!isConnected ? (
"bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300", <div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600">
danger: <div className="flex items-start gap-3 mb-3">
"bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300", <WifiIcon className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
}; <div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Machine</h3>
// Only show error info when connected AND there's an actual error <p className="text-xs text-gray-600 dark:text-gray-400">Ready to connect</p>
const errorInfo = </div>
isConnected && hasError(machineError) </div>
? getErrorDetails(machineError)
: null; <button
onClick={onConnect}
return ( className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-xs hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer"
<> >
{!isConnected ? ( Connect to Machine
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600"> </button>
<div className="flex items-start gap-3 mb-3"> </div>
<WifiIcon className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" /> ) : (
<div className="flex-1 min-w-0"> <div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-success-600 dark:border-success-500">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1"> <div className="flex items-start gap-3 mb-3">
Machine <WifiIcon className="w-6 h-6 text-success-600 dark:text-success-400 flex-shrink-0 mt-0.5" />
</h3> <div className="flex-1 min-w-0">
<p className="text-xs text-gray-600 dark:text-gray-400"> <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Machine Info</h3>
Ready to connect <p className="text-xs text-gray-600 dark:text-gray-400">
</p> {machineInfo?.modelNumber || 'Brother Embroidery Machine'}
</div> </p>
</div> </div>
</div>
<button
onClick={onConnect} {/* Error/Info Display */}
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-xs hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer" {errorInfo && (
> errorInfo.isInformational ? (
Connect to Machine <div className="mb-3 p-3 bg-info-50 dark:bg-info-900/20 border border-info-200 dark:border-info-800 rounded-lg">
</button> <div className="flex items-start gap-2">
</div> <InformationCircleIcon className="w-4 h-4 text-info-600 dark:text-info-400 flex-shrink-0" />
) : ( <div className="flex-1 min-w-0">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-success-600 dark:border-success-500"> <div className="font-semibold text-info-900 dark:text-info-200 text-xs">{errorInfo.title}</div>
<div className="flex items-start gap-3 mb-3"> </div>
<WifiIcon className="w-6 h-6 text-success-600 dark:text-success-400 flex-shrink-0 mt-0.5" /> </div>
<div className="flex-1 min-w-0"> </div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1"> ) : (
Machine Info <div className="mb-3 p-3 bg-danger-50 dark:bg-danger-900/20 border border-danger-200 dark:border-danger-800 rounded-lg">
</h3> <div className="flex items-start gap-2">
<p className="text-xs text-gray-600 dark:text-gray-400"> <span className="text-danger-600 dark:text-danger-400 flex-shrink-0"></span>
{machineInfo?.modelNumber || "Brother Embroidery Machine"} <div className="flex-1 min-w-0">
</p> <div className="font-semibold text-danger-900 dark:text-danger-200 text-xs mb-1">{errorInfo.title}</div>
</div> <div className="text-xs text-danger-700 dark:text-danger-300 font-mono">
</div> Error Code: 0x{machineError.toString(16).toUpperCase().padStart(2, '0')}
</div>
{/* Error/Info Display */} </div>
{errorInfo && </div>
(errorInfo.isInformational ? ( </div>
<div className="mb-3 p-3 bg-info-50 dark:bg-info-900/20 border border-info-200 dark:border-info-800 rounded-lg"> )
<div className="flex items-start gap-2"> )}
<InformationCircleIcon className="w-4 h-4 text-info-600 dark:text-info-400 flex-shrink-0" />
<div className="flex-1 min-w-0"> {/* Status Badge */}
<div className="font-semibold text-info-900 dark:text-info-200 text-xs"> <div className="mb-3">
{errorInfo.title} <span className="text-xs text-gray-600 dark:text-gray-400 block mb-1">Status:</span>
</div> <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg font-semibold text-xs ${statusBadgeColors[stateVisual.color as keyof typeof statusBadgeColors] || statusBadgeColors.info}`}>
</div> {(() => {
</div> const Icon = stateIcons[stateVisual.iconName];
</div> return <Icon className="w-3.5 h-3.5" />;
) : ( })()}
<div className="mb-3 p-3 bg-danger-50 dark:bg-danger-900/20 border border-danger-200 dark:border-danger-800 rounded-lg"> <span>{machineStatusName}</span>
<div className="flex items-start gap-2"> </span>
<span className="text-danger-600 dark:text-danger-400 flex-shrink-0"> </div>
</span> {/* Machine Info */}
<div className="flex-1 min-w-0"> {machineInfo && (
<div className="font-semibold text-danger-900 dark:text-danger-200 text-xs mb-1"> <div className="grid grid-cols-2 gap-2 text-xs mb-3">
{errorInfo.title} <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
</div> <span className="text-gray-600 dark:text-gray-400 block">Max Area</span>
<div className="text-xs text-danger-700 dark:text-danger-300 font-mono"> <span className="font-semibold text-gray-900 dark:text-gray-100">
Error Code: 0x {(machineInfo.maxWidth / 10).toFixed(1)} × {(machineInfo.maxHeight / 10).toFixed(1)} mm
{machineError.toString(16).toUpperCase().padStart(2, "0")} </span>
</div> </div>
</div> {machineInfo.totalCount !== undefined && (
</div> <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
</div> <span className="text-gray-600 dark:text-gray-400 block">Total Stitches</span>
))} <span className="font-semibold text-gray-900 dark:text-gray-100">
{machineInfo.totalCount.toLocaleString()}
{/* Status Badge */} </span>
<div className="mb-3"> </div>
<span className="text-xs text-gray-600 dark:text-gray-400 block mb-1"> )}
Status: </div>
</span> )}
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg font-semibold text-xs ${statusBadgeColors[stateVisual.color as keyof typeof statusBadgeColors] || statusBadgeColors.info}`} <button
> onClick={handleDisconnectClick}
{(() => { className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600 text-xs font-medium transition-colors cursor-pointer"
const Icon = stateIcons[stateVisual.iconName]; >
return <Icon className="w-3.5 h-3.5" />; Disconnect
})()} </button>
<span>{machineStatusName}</span> </div>
</span> )}
</div>
<ConfirmDialog
{/* Machine Info */} isOpen={showDisconnectConfirm}
{machineInfo && ( title="Confirm Disconnect"
<div className="grid grid-cols-2 gap-2 text-xs mb-3"> message={`The machine is currently ${machineStatusName.toLowerCase()}. Disconnecting may interrupt the operation. Are you sure you want to disconnect?`}
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded"> confirmText="Disconnect Anyway"
<span className="text-gray-600 dark:text-gray-400 block"> cancelText="Cancel"
Max Area onConfirm={handleConfirmDisconnect}
</span> onCancel={() => setShowDisconnectConfirm(false)}
<span className="font-semibold text-gray-900 dark:text-gray-100"> variant="danger"
{(machineInfo.maxWidth / 10).toFixed(1)} ×{" "} />
{(machineInfo.maxHeight / 10).toFixed(1)} mm </>
</span> );
</div> }
{machineInfo.totalCount !== undefined && (
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
Total Stitches
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{machineInfo.totalCount.toLocaleString()}
</span>
</div>
)}
</div>
)}
<button
onClick={handleDisconnectClick}
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600 text-xs font-medium transition-colors cursor-pointer"
>
Disconnect
</button>
</div>
)}
<ConfirmDialog
isOpen={showDisconnectConfirm}
title="Confirm Disconnect"
message={`The machine is currently ${machineStatusName.toLowerCase()}. Disconnecting may interrupt the operation. Are you sure you want to disconnect?`}
confirmText="Disconnect Anyway"
cancelText="Cancel"
onConfirm={handleConfirmDisconnect}
onCancel={() => setShowDisconnectConfirm(false)}
variant="danger"
/>
</>
);
}

View file

@ -1,516 +1,424 @@
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState, useCallback } from 'react';
import { useShallow } from "zustand/react/shallow"; import { useShallow } from 'zustand/react/shallow';
import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore"; import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore';
import { usePatternStore } from "../stores/usePatternStore"; 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 { import { PlusIcon, MinusIcon, ArrowPathIcon, LockClosedIcon, PhotoIcon, ArrowsPointingInIcon } from '@heroicons/react/24/solid';
PlusIcon, import type { PesPatternData } from '../formats/import/pesImporter';
MinusIcon, import { calculateInitialScale } from '../utils/konvaRenderers';
ArrowPathIcon, import { Grid, Origin, Hoop, Stitches, PatternBounds, CurrentPosition } from './KonvaComponents';
LockClosedIcon,
PhotoIcon, export function PatternCanvas() {
ArrowsPointingInIcon, // Machine store
} from "@heroicons/react/24/solid"; const {
import type { PesPatternData } from "../formats/import/pesImporter"; sewingProgress,
import { calculateInitialScale } from "../utils/konvaRenderers"; machineInfo,
import { isUploading,
Grid, } = useMachineStore(
Origin, useShallow((state) => ({
Hoop, sewingProgress: state.sewingProgress,
Stitches, machineInfo: state.machineInfo,
PatternBounds, isUploading: state.isUploading,
CurrentPosition, }))
} from "./KonvaComponents"; );
export function PatternCanvas() { // Pattern store
// Machine store const {
const { sewingProgress, machineInfo, isUploading } = useMachineStore( pesData,
useShallow((state) => ({ patternOffset: initialPatternOffset,
sewingProgress: state.sewingProgress, setPatternOffset,
machineInfo: state.machineInfo, } = usePatternStore(
isUploading: state.isUploading, useShallow((state) => ({
})), pesData: state.pesData,
); patternOffset: state.patternOffset,
setPatternOffset: state.setPatternOffset,
// Pattern store }))
const { );
pesData,
patternOffset: initialPatternOffset, // Derived state: pattern is uploaded if machine has pattern info
setPatternOffset, const patternUploaded = usePatternUploaded();
} = usePatternStore( const containerRef = useRef<HTMLDivElement>(null);
useShallow((state) => ({ const stageRef = useRef<Konva.Stage | null>(null);
pesData: state.pesData,
patternOffset: state.patternOffset, const [stagePos, setStagePos] = useState({ x: 0, y: 0 });
setPatternOffset: state.setPatternOffset, const [stageScale, setStageScale] = useState(1);
})), const [localPatternOffset, setLocalPatternOffset] = useState(initialPatternOffset || { x: 0, y: 0 });
); const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const initialScaleRef = useRef<number>(1);
// Derived state: pattern is uploaded if machine has pattern info const prevPesDataRef = useRef<PesPatternData | null>(null);
const patternUploaded = usePatternUploaded();
const containerRef = useRef<HTMLDivElement>(null); // Update pattern offset when initialPatternOffset changes
const stageRef = useRef<Konva.Stage | null>(null); if (initialPatternOffset && (
localPatternOffset.x !== initialPatternOffset.x ||
const [stagePos, setStagePos] = useState({ x: 0, y: 0 }); localPatternOffset.y !== initialPatternOffset.y
const [stageScale, setStageScale] = useState(1); )) {
const [localPatternOffset, setLocalPatternOffset] = useState( setLocalPatternOffset(initialPatternOffset);
initialPatternOffset || { x: 0, y: 0 }, console.log('[PatternCanvas] Restored pattern offset:', initialPatternOffset);
); }
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const initialScaleRef = useRef<number>(1); // Track container size
const prevPesDataRef = useRef<PesPatternData | null>(null); useEffect(() => {
if (!containerRef.current) return;
// Update pattern offset when initialPatternOffset changes
if ( const updateSize = () => {
initialPatternOffset && if (containerRef.current) {
(localPatternOffset.x !== initialPatternOffset.x || const width = containerRef.current.clientWidth;
localPatternOffset.y !== initialPatternOffset.y) const height = containerRef.current.clientHeight;
) { setContainerSize({ width, height });
setLocalPatternOffset(initialPatternOffset); }
console.log( };
"[PatternCanvas] Restored pattern offset:",
initialPatternOffset, // Initial size
); updateSize();
}
// Watch for resize
// Track container size const resizeObserver = new ResizeObserver(updateSize);
useEffect(() => { resizeObserver.observe(containerRef.current);
if (!containerRef.current) return;
return () => resizeObserver.disconnect();
const updateSize = () => { }, []);
if (containerRef.current) {
const width = containerRef.current.clientWidth; // Calculate and store initial scale when pattern or hoop changes
const height = containerRef.current.clientHeight; useEffect(() => {
setContainerSize({ width, height }); if (!pesData || containerSize.width === 0) {
} prevPesDataRef.current = null;
}; return;
}
// Initial size
updateSize(); // Only recalculate if pattern changed
if (prevPesDataRef.current !== pesData) {
// Watch for resize prevPesDataRef.current = pesData;
const resizeObserver = new ResizeObserver(updateSize);
resizeObserver.observe(containerRef.current); const { bounds } = pesData;
const viewWidth = machineInfo ? machineInfo.maxWidth : bounds.maxX - bounds.minX;
return () => resizeObserver.disconnect(); const viewHeight = machineInfo ? machineInfo.maxHeight : bounds.maxY - bounds.minY;
}, []);
const initialScale = calculateInitialScale(containerSize.width, containerSize.height, viewWidth, viewHeight);
// Calculate and store initial scale when pattern or hoop changes initialScaleRef.current = initialScale;
useEffect(() => {
if (!pesData || containerSize.width === 0) { // Reset view when pattern changes
prevPesDataRef.current = null; // eslint-disable-next-line react-hooks/set-state-in-effect
return; setStageScale(initialScale);
} setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
}
// Only recalculate if pattern changed }, [pesData, machineInfo, containerSize]);
if (prevPesDataRef.current !== pesData) {
prevPesDataRef.current = pesData; // Wheel zoom handler
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
const { bounds } = pesData; e.evt.preventDefault();
const viewWidth = machineInfo
? machineInfo.maxWidth const stage = e.target.getStage();
: bounds.maxX - bounds.minX; if (!stage) return;
const viewHeight = machineInfo
? machineInfo.maxHeight const pointer = stage.getPointerPosition();
: bounds.maxY - bounds.minY; if (!pointer) return;
const initialScale = calculateInitialScale( const scaleBy = 1.1;
containerSize.width, const direction = e.evt.deltaY > 0 ? -1 : 1;
containerSize.height,
viewWidth, setStageScale((oldScale) => {
viewHeight, const newScale = Math.max(0.1, Math.min(direction > 0 ? oldScale * scaleBy : oldScale / scaleBy, 2));
);
initialScaleRef.current = initialScale; // Zoom towards pointer
setStagePos((prevPos) => {
// Reset view when pattern changes const mousePointTo = {
// eslint-disable-next-line react-hooks/set-state-in-effect x: (pointer.x - prevPos.x) / oldScale,
setStageScale(initialScale); y: (pointer.y - prevPos.y) / oldScale,
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 }); };
}
}, [pesData, machineInfo, containerSize]); return {
x: pointer.x - mousePointTo.x * newScale,
// Wheel zoom handler y: pointer.y - mousePointTo.y * newScale,
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => { };
e.evt.preventDefault(); });
const stage = e.target.getStage(); return newScale;
if (!stage) return; });
}, []);
const pointer = stage.getPointerPosition();
if (!pointer) return; // Zoom control handlers
const handleZoomIn = useCallback(() => {
const scaleBy = 1.1; setStageScale((oldScale) => {
const direction = e.evt.deltaY > 0 ? -1 : 1; const newScale = Math.max(0.1, Math.min(oldScale * 1.2, 2));
setStageScale((oldScale) => { // Zoom towards center of viewport
const newScale = Math.max( setStagePos((prevPos) => {
0.1, const centerX = containerSize.width / 2;
Math.min(direction > 0 ? oldScale * scaleBy : oldScale / scaleBy, 2), const centerY = containerSize.height / 2;
);
const mousePointTo = {
// Zoom towards pointer x: (centerX - prevPos.x) / oldScale,
setStagePos((prevPos) => { y: (centerY - prevPos.y) / oldScale,
const mousePointTo = { };
x: (pointer.x - prevPos.x) / oldScale,
y: (pointer.y - prevPos.y) / oldScale, return {
}; x: centerX - mousePointTo.x * newScale,
y: centerY - mousePointTo.y * newScale,
return { };
x: pointer.x - mousePointTo.x * newScale, });
y: pointer.y - mousePointTo.y * newScale,
}; return newScale;
}); });
}, [containerSize]);
return newScale;
}); const handleZoomOut = useCallback(() => {
}, []); setStageScale((oldScale) => {
const newScale = Math.max(0.1, Math.min(oldScale / 1.2, 2));
// Zoom control handlers
const handleZoomIn = useCallback(() => { // Zoom towards center of viewport
setStageScale((oldScale) => { setStagePos((prevPos) => {
const newScale = Math.max(0.1, Math.min(oldScale * 1.2, 2)); const centerX = containerSize.width / 2;
const centerY = containerSize.height / 2;
// Zoom towards center of viewport
setStagePos((prevPos) => { const mousePointTo = {
const centerX = containerSize.width / 2; x: (centerX - prevPos.x) / oldScale,
const centerY = containerSize.height / 2; y: (centerY - prevPos.y) / oldScale,
};
const mousePointTo = {
x: (centerX - prevPos.x) / oldScale, return {
y: (centerY - prevPos.y) / oldScale, x: centerX - mousePointTo.x * newScale,
}; y: centerY - mousePointTo.y * newScale,
};
return { });
x: centerX - mousePointTo.x * newScale,
y: centerY - mousePointTo.y * newScale, return newScale;
}; });
}); }, [containerSize]);
return newScale; const handleZoomReset = useCallback(() => {
}); const initialScale = initialScaleRef.current;
}, [containerSize]); setStageScale(initialScale);
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
const handleZoomOut = useCallback(() => { }, [containerSize]);
setStageScale((oldScale) => {
const newScale = Math.max(0.1, Math.min(oldScale / 1.2, 2)); const handleCenterPattern = useCallback(() => {
if (!pesData) return;
// Zoom towards center of viewport
setStagePos((prevPos) => { const { bounds } = pesData;
const centerX = containerSize.width / 2; const centerOffsetX = -(bounds.minX + bounds.maxX) / 2;
const centerY = containerSize.height / 2; const centerOffsetY = -(bounds.minY + bounds.maxY) / 2;
const mousePointTo = { setLocalPatternOffset({ x: centerOffsetX, y: centerOffsetY });
x: (centerX - prevPos.x) / oldScale, setPatternOffset(centerOffsetX, centerOffsetY);
y: (centerY - prevPos.y) / oldScale, }, [pesData, setPatternOffset]);
};
// Pattern drag handlers
return { const handlePatternDragEnd = useCallback((e: Konva.KonvaEventObject<DragEvent>) => {
x: centerX - mousePointTo.x * newScale, const newOffset = {
y: centerY - mousePointTo.y * newScale, x: e.target.x(),
}; y: e.target.y(),
}); };
setLocalPatternOffset(newOffset);
return newScale; setPatternOffset(newOffset.x, newOffset.y);
}); }, [setPatternOffset]);
}, [containerSize]);
const borderColor = pesData ? 'border-tertiary-600 dark:border-tertiary-500' : 'border-gray-400 dark:border-gray-600';
const handleZoomReset = useCallback(() => { const iconColor = pesData ? 'text-tertiary-600 dark:text-tertiary-400' : 'text-gray-600 dark:text-gray-400';
const initialScale = initialScaleRef.current;
setStageScale(initialScale); return (
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 }); <div className={`lg:h-full bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor} flex flex-col`}>
}, [containerSize]); <div className="flex items-start gap-3 mb-3 flex-shrink-0">
<PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
const handleCenterPattern = useCallback(() => { <div className="flex-1 min-w-0">
if (!pesData) return; <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Pattern Preview</h3>
{pesData ? (
const { bounds } = pesData; <p className="text-xs text-gray-600 dark:text-gray-400">
const centerOffsetX = -(bounds.minX + bounds.maxX) / 2; {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} × {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
const centerOffsetY = -(bounds.minY + bounds.maxY) / 2; </p>
) : (
setLocalPatternOffset({ x: centerOffsetX, y: centerOffsetY }); <p className="text-xs text-gray-600 dark:text-gray-400">No pattern loaded</p>
setPatternOffset(centerOffsetX, centerOffsetY); )}
}, [pesData, setPatternOffset]); </div>
</div>
// Pattern drag handlers <div className="relative w-full h-[400px] sm:h-[500px] lg:flex-1 lg:min-h-0 border border-gray-300 dark:border-gray-600 rounded bg-gray-200 dark:bg-gray-900 overflow-hidden" ref={containerRef}>
const handlePatternDragEnd = useCallback( {containerSize.width > 0 && (
(e: Konva.KonvaEventObject<DragEvent>) => { <Stage
const newOffset = { width={containerSize.width}
x: e.target.x(), height={containerSize.height}
y: e.target.y(), x={stagePos.x}
}; y={stagePos.y}
setLocalPatternOffset(newOffset); scaleX={stageScale}
setPatternOffset(newOffset.x, newOffset.y); scaleY={stageScale}
}, draggable
[setPatternOffset], onWheel={handleWheel}
); onDragStart={() => {
if (stageRef.current) {
const borderColor = pesData stageRef.current.container().style.cursor = 'grabbing';
? "border-tertiary-600 dark:border-tertiary-500" }
: "border-gray-400 dark:border-gray-600"; }}
const iconColor = pesData onDragEnd={() => {
? "text-tertiary-600 dark:text-tertiary-400" if (stageRef.current) {
: "text-gray-600 dark:text-gray-400"; stageRef.current.container().style.cursor = 'grab';
}
return ( }}
<div ref={(node) => {
className={`lg:h-full bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor} flex flex-col`} stageRef.current = node;
> if (node) {
<div className="flex items-start gap-3 mb-3 flex-shrink-0"> node.container().style.cursor = 'grab';
<PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} /> }
<div className="flex-1 min-w-0"> }}
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1"> >
Pattern Preview {/* Background layer: grid, origin, hoop */}
</h3> <Layer>
{pesData ? ( {pesData && (
<p className="text-xs text-gray-600 dark:text-gray-400"> <>
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} ×{" "} <Grid
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm gridSize={100}
</p> bounds={pesData.bounds}
) : ( machineInfo={machineInfo}
<p className="text-xs text-gray-600 dark:text-gray-400"> />
No pattern loaded <Origin />
</p> {machineInfo && <Hoop machineInfo={machineInfo} />}
)} </>
</div> )}
</div> </Layer>
<div
className="relative w-full h-[400px] sm:h-[500px] lg:flex-1 lg:min-h-0 border border-gray-300 dark:border-gray-600 rounded bg-gray-200 dark:bg-gray-900 overflow-hidden" {/* Pattern layer: draggable stitches and bounds */}
ref={containerRef} <Layer>
> {pesData && (
{containerSize.width > 0 && ( <Group
<Stage name="pattern-group"
width={containerSize.width} draggable={!patternUploaded && !isUploading}
height={containerSize.height} x={localPatternOffset.x}
x={stagePos.x} y={localPatternOffset.y}
y={stagePos.y} onDragEnd={handlePatternDragEnd}
scaleX={stageScale} onMouseEnter={(e) => {
scaleY={stageScale} const stage = e.target.getStage();
draggable if (stage && !patternUploaded && !isUploading) stage.container().style.cursor = 'move';
onWheel={handleWheel} }}
onDragStart={() => { onMouseLeave={(e) => {
if (stageRef.current) { const stage = e.target.getStage();
stageRef.current.container().style.cursor = "grabbing"; if (stage && !patternUploaded && !isUploading) stage.container().style.cursor = 'grab';
} }}
}} >
onDragEnd={() => { <Stitches
if (stageRef.current) { stitches={pesData.penStitches.stitches.map((s, i): [number, number, number, number] => {
stageRef.current.container().style.cursor = "grab"; // Convert PEN stitch format {x, y, flags, isJump} to PES format [x, y, cmd, colorIndex]
} const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump
}} const colorIndex = pesData.penStitches.colorBlocks.find(
ref={(node) => { (b) => i >= b.startStitch && i <= b.endStitch
stageRef.current = node; )?.colorIndex ?? 0;
if (node) { return [s.x, s.y, cmd, colorIndex];
node.container().style.cursor = "grab"; })}
} pesData={pesData}
}} currentStitchIndex={sewingProgress?.currentStitch || 0}
> showProgress={patternUploaded || isUploading}
{/* Background layer: grid, origin, hoop */} />
<Layer> <PatternBounds bounds={pesData.bounds} />
{pesData && ( </Group>
<> )}
<Grid </Layer>
gridSize={100}
bounds={pesData.bounds} {/* Current position layer */}
machineInfo={machineInfo} <Layer>
/> {pesData && pesData.penStitches && sewingProgress && sewingProgress.currentStitch > 0 && (
<Origin /> <Group x={localPatternOffset.x} y={localPatternOffset.y}>
{machineInfo && <Hoop machineInfo={machineInfo} />} <CurrentPosition
</> currentStitchIndex={sewingProgress.currentStitch}
)} stitches={pesData.penStitches.stitches.map((s, i): [number, number, number, number] => {
</Layer> const cmd = s.isJump ? 0x10 : 0;
const colorIndex = pesData.penStitches.colorBlocks.find(
{/* Pattern layer: draggable stitches and bounds */} (b) => i >= b.startStitch && i <= b.endStitch
<Layer> )?.colorIndex ?? 0;
{pesData && ( return [s.x, s.y, cmd, colorIndex];
<Group })}
name="pattern-group" />
draggable={!patternUploaded && !isUploading} </Group>
x={localPatternOffset.x} )}
y={localPatternOffset.y} </Layer>
onDragEnd={handlePatternDragEnd} </Stage>
onMouseEnter={(e) => { )}
const stage = e.target.getStage();
if (stage && !patternUploaded && !isUploading) {/* Placeholder overlay when no pattern is loaded */}
stage.container().style.cursor = "move"; {!pesData && (
}} <div className="flex items-center justify-center h-full text-gray-600 dark:text-gray-400 italic">
onMouseLeave={(e) => { Load a PES file to preview the pattern
const stage = e.target.getStage(); </div>
if (stage && !patternUploaded && !isUploading) )}
stage.container().style.cursor = "grab";
}} {/* Pattern info overlays */}
> {pesData && (
<Stitches <>
stitches={pesData.penStitches.stitches.map( {/* Thread Legend Overlay */}
(s, i): [number, number, number, number] => { <div className="absolute top-2 sm:top-2.5 left-2 sm:left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2 sm:p-2.5 rounded-lg shadow-lg z-10 max-w-[150px] sm:max-w-[180px] lg:max-w-[200px]">
// Convert PEN stitch format {x, y, flags, isJump} to PES format [x, y, cmd, colorIndex] <h4 className="m-0 mb-1.5 sm:mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1 sm:pb-1.5">Colors</h4>
const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump {pesData.uniqueColors.map((color, idx) => {
const colorIndex = // Primary metadata: brand and catalog number
pesData.penStitches.colorBlocks.find( const primaryMetadata = [
(b) => i >= b.startStitch && i <= b.endStitch, color.brand,
)?.colorIndex ?? 0; color.catalogNumber ? `#${color.catalogNumber}` : null
return [s.x, s.y, cmd, colorIndex]; ].filter(Boolean).join(" ");
},
)} // Secondary metadata: chart and description
pesData={pesData} const secondaryMetadata = [
currentStitchIndex={sewingProgress?.currentStitch || 0} color.chart,
showProgress={patternUploaded || isUploading} color.description
/> ].filter(Boolean).join(" ");
<PatternBounds bounds={pesData.bounds} />
</Group> return (
)} <div key={idx} className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0">
</Layer> <div
className="w-3 h-3 sm:w-4 sm:h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5"
{/* Current position layer */} style={{ backgroundColor: color.hex }}
<Layer> />
{pesData && <div className="flex-1 min-w-0">
pesData.penStitches && <div className="text-xs font-semibold text-gray-900 dark:text-gray-100">
sewingProgress && Color {idx + 1}
sewingProgress.currentStitch > 0 && ( </div>
<Group x={localPatternOffset.x} y={localPatternOffset.y}> {(primaryMetadata || secondaryMetadata) && (
<CurrentPosition <div className="text-xs text-gray-600 dark:text-gray-400 leading-tight mt-0.5 break-words">
currentStitchIndex={sewingProgress.currentStitch} {primaryMetadata}
stitches={pesData.penStitches.stitches.map( {primaryMetadata && secondaryMetadata && <span className="mx-1"></span>}
(s, i): [number, number, number, number] => { {secondaryMetadata}
const cmd = s.isJump ? 0x10 : 0; </div>
const colorIndex = )}
pesData.penStitches.colorBlocks.find( </div>
(b) => i >= b.startStitch && i <= b.endStitch, </div>
)?.colorIndex ?? 0; );
return [s.x, s.y, cmd, colorIndex]; })}
}, </div>
)}
/> {/* Pattern Offset Indicator */}
</Group> <div className={`absolute bottom-16 sm:bottom-20 right-2 sm:right-5 backdrop-blur-sm p-2 sm:p-2.5 px-2.5 sm:px-3.5 rounded-lg shadow-lg z-[11] min-w-[160px] sm:min-w-[180px] transition-colors ${
)} patternUploaded ? 'bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600' : 'bg-white/95 dark:bg-gray-800/95'
</Layer> }`}>
</Stage> <div className="flex items-center justify-between mb-1">
)} <div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Pattern Position:</div>
{patternUploaded && (
{/* Placeholder overlay when no pattern is loaded */} <div className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
{!pesData && ( <LockClosedIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<div className="flex items-center justify-center h-full text-gray-600 dark:text-gray-400 italic"> <span className="text-xs font-bold">LOCKED</span>
Load a PES file to preview the pattern </div>
</div> )}
)} </div>
<div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
{/* Pattern info overlays */} X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y: {(localPatternOffset.y / 10).toFixed(1)}mm
{pesData && ( </div>
<> <div className="text-xs text-gray-600 dark:text-gray-400 italic">
{/* Thread Legend Overlay */} {patternUploaded ? 'Pattern locked • Drag background to pan' : 'Drag pattern to move • Drag background to pan'}
<div className="absolute top-2 sm:top-2.5 left-2 sm:left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2 sm:p-2.5 rounded-lg shadow-lg z-10 max-w-[150px] sm:max-w-[180px] lg:max-w-[200px]"> </div>
<h4 className="m-0 mb-1.5 sm:mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1 sm:pb-1.5"> </div>
Colors
</h4> {/* Zoom Controls Overlay */}
{pesData.uniqueColors.map((color, idx) => { <div className="absolute bottom-2 sm:bottom-5 right-2 sm:right-5 flex gap-1.5 sm:gap-2 items-center bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg shadow-lg z-10">
// Primary metadata: brand and catalog number <button className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed" onClick={handleCenterPattern} disabled={!pesData || patternUploaded || isUploading} title="Center Pattern in Hoop">
const primaryMetadata = [ <ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
color.brand, </button>
color.catalogNumber ? `#${color.catalogNumber}` : null, <button className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed" onClick={handleZoomIn} title="Zoom In">
] <PlusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
.filter(Boolean) </button>
.join(" "); <span className="min-w-[40px] sm:min-w-[50px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 select-none">{Math.round(stageScale * 100)}%</span>
<button className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed" onClick={handleZoomOut} title="Zoom Out">
// Secondary metadata: chart and description <MinusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
const secondaryMetadata = [color.chart, color.description] </button>
.filter(Boolean) <button className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed ml-1" onClick={handleZoomReset} title="Reset Zoom">
.join(" "); <ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
</button>
return ( </div>
<div </>
key={idx} )}
className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0" </div>
> </div>
<div );
className="w-3 h-3 sm:w-4 sm:h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5" }
style={{ backgroundColor: color.hex }}
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-gray-900 dark:text-gray-100">
Color {idx + 1}
</div>
{(primaryMetadata || secondaryMetadata) && (
<div className="text-xs text-gray-600 dark:text-gray-400 leading-tight mt-0.5 break-words">
{primaryMetadata}
{primaryMetadata && secondaryMetadata && (
<span className="mx-1"></span>
)}
{secondaryMetadata}
</div>
)}
</div>
</div>
);
})}
</div>
{/* Pattern Offset Indicator */}
<div
className={`absolute bottom-16 sm:bottom-20 right-2 sm:right-5 backdrop-blur-sm p-2 sm:p-2.5 px-2.5 sm:px-3.5 rounded-lg shadow-lg z-[11] min-w-[160px] sm:min-w-[180px] transition-colors ${
patternUploaded
? "bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600"
: "bg-white/95 dark:bg-gray-800/95"
}`}
>
<div className="flex items-center justify-between mb-1">
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
Pattern Position:
</div>
{patternUploaded && (
<div className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
<LockClosedIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs font-bold">LOCKED</span>
</div>
)}
</div>
<div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y:{" "}
{(localPatternOffset.y / 10).toFixed(1)}mm
</div>
<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"}
</div>
</div>
{/* Zoom Controls Overlay */}
<div className="absolute bottom-2 sm:bottom-5 right-2 sm:right-5 flex gap-1.5 sm:gap-2 items-center bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg shadow-lg z-10">
<button
className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleCenterPattern}
disabled={!pesData || patternUploaded || isUploading}
title="Center Pattern in Hoop"
>
<ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
</button>
<button
className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleZoomIn}
title="Zoom In"
>
<PlusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
</button>
<span className="min-w-[40px] sm:min-w-[50px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 select-none">
{Math.round(stageScale * 100)}%
</span>
<button
className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleZoomOut}
title="Zoom Out"
>
<MinusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
</button>
<button
className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed ml-1"
onClick={handleZoomReset}
title="Reset Zoom"
>
<ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
</button>
</div>
</>
)}
</div>
</div>
);
}

View file

@ -1,88 +1,75 @@
import type { PesPatternData } from "../formats/import/pesImporter"; import type { PesPatternData } from '../formats/import/pesImporter';
interface PatternInfoProps { interface PatternInfoProps {
pesData: PesPatternData; pesData: PesPatternData;
showThreadBlocks?: boolean; showThreadBlocks?: boolean;
} }
export function PatternInfo({ export function PatternInfo({ pesData, showThreadBlocks = false }: PatternInfoProps) {
pesData,
showThreadBlocks = false,
}: PatternInfoProps) {
return ( return (
<> <>
<div className="grid grid-cols-3 gap-2 text-xs mb-2"> <div className="grid grid-cols-3 gap-2 text-xs mb-2">
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Size</span> <span className="text-gray-600 dark:text-gray-400 block">Size</span>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{" "} {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
</span> </span>
</div> </div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block"> <span className="text-gray-600 dark:text-gray-400 block">Stitches</span>
Stitches
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{pesData.penStitches?.stitches.length.toLocaleString() || {pesData.penStitches?.stitches.length.toLocaleString() || pesData.stitchCount.toLocaleString()}
pesData.stitchCount.toLocaleString()} {pesData.penStitches && pesData.penStitches.stitches.length !== pesData.stitchCount && (
{pesData.penStitches && <span
pesData.penStitches.stitches.length !== pesData.stitchCount && ( className="text-gray-500 dark:text-gray-500 font-normal ml-1"
<span title="Input stitch count from PES file (lock stitches were added for machine compatibility)"
className="text-gray-500 dark:text-gray-500 font-normal ml-1" >
title="Input stitch count from PES file (lock stitches were added for machine compatibility)" ({pesData.stitchCount.toLocaleString()})
> </span>
({pesData.stitchCount.toLocaleString()}) )}
</span>
)}
</span> </span>
</div> </div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block"> <span className="text-gray-600 dark:text-gray-400 block">
{showThreadBlocks ? "Colors / Blocks" : "Colors"} {showThreadBlocks ? 'Colors / Blocks' : 'Colors'}
</span> </span>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{showThreadBlocks {showThreadBlocks
? `${pesData.uniqueColors.length} / ${pesData.threads.length}` ? `${pesData.uniqueColors.length} / ${pesData.threads.length}`
: pesData.uniqueColors.length} : pesData.uniqueColors.length
}
</span> </span>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-600 dark:text-gray-400"> <span className="text-xs text-gray-600 dark:text-gray-400">Colors:</span>
Colors:
</span>
<div className="flex gap-1"> <div className="flex gap-1">
{pesData.uniqueColors.slice(0, 8).map((color, idx) => { {pesData.uniqueColors.slice(0, 8).map((color, idx) => {
// Primary metadata: brand and catalog number // Primary metadata: brand and catalog number
const primaryMetadata = [ const primaryMetadata = [
color.brand, color.brand,
color.catalogNumber ? `#${color.catalogNumber}` : null, color.catalogNumber ? `#${color.catalogNumber}` : null
] ].filter(Boolean).join(" ");
.filter(Boolean)
.join(" ");
// Secondary metadata: chart and description // Secondary metadata: chart and description
const secondaryMetadata = [color.chart, color.description] const secondaryMetadata = [
.filter(Boolean) color.chart,
.join(" "); color.description
].filter(Boolean).join(" ");
const metadata = [primaryMetadata, secondaryMetadata] const metadata = [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
.filter(Boolean)
.join(" • ");
// Show which thread blocks use this color in PatternSummaryCard // Show which thread blocks use this color in PatternSummaryCard
const threadNumbers = color.threadIndices const threadNumbers = color.threadIndices.map(i => i + 1).join(", ");
.map((i) => i + 1)
.join(", ");
const tooltipText = showThreadBlocks const tooltipText = showThreadBlocks
? metadata ? (metadata
? `Color ${idx + 1}: ${color.hex} - ${metadata}` ? `Color ${idx + 1}: ${color.hex} - ${metadata}`
: `Color ${idx + 1}: ${color.hex}` : `Color ${idx + 1}: ${color.hex}`)
: metadata : (metadata
? `Color ${idx + 1}: ${color.hex}\n${metadata}\nUsed in thread blocks: ${threadNumbers}` ? `Color ${idx + 1}: ${color.hex}\n${metadata}\nUsed in thread blocks: ${threadNumbers}`
: `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`; : `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`);
return ( return (
<div <div

View file

@ -1,9 +1,7 @@
export function PatternPreviewPlaceholder() { export function PatternPreviewPlaceholder() {
return ( return (
<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"> <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>
Pattern Preview
</h2>
<div className="h-[400px] sm:h-[500px] lg:flex-1 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 relative overflow-hidden"> <div className="h-[400px] sm:h-[500px] lg:flex-1 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 relative overflow-hidden">
{/* Decorative background pattern */} {/* Decorative background pattern */}
<div className="absolute inset-0 opacity-5 dark:opacity-10"> <div className="absolute inset-0 opacity-5 dark:opacity-10">
@ -14,41 +12,18 @@ export function PatternPreviewPlaceholder() {
<div className="text-center relative z-10"> <div className="text-center relative z-10">
<div className="relative inline-block mb-6"> <div className="relative inline-block mb-6">
<svg <svg className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg> </svg>
<div className="absolute -top-2 -right-2 w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center"> <div className="absolute -top-2 -right-2 w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
<svg <svg className="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="w-5 h-5 text-primary-600 dark:text-primary-400" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg> </svg>
</div> </div>
</div> </div>
<h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2"> <h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2">No Pattern Loaded</h3>
No Pattern Loaded
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm mb-4 max-w-sm mx-auto"> <p className="text-gray-500 dark:text-gray-400 text-sm mb-4 max-w-sm mx-auto">
Connect to your machine and choose a PES embroidery file to see your Connect to your machine and choose a PES embroidery file to see your design preview
design preview
</p> </p>
<div className="flex items-center justify-center gap-6 text-xs text-gray-400 dark:text-gray-500"> <div className="flex items-center justify-center gap-6 text-xs text-gray-400 dark:text-gray-500">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View file

@ -1,26 +1,33 @@
import { useShallow } from "zustand/react/shallow"; import { useShallow } from 'zustand/react/shallow';
import { useMachineStore } from "../stores/useMachineStore"; import { useMachineStore } from '../stores/useMachineStore';
import { usePatternStore } from "../stores/usePatternStore"; import { usePatternStore } from '../stores/usePatternStore';
import { canDeletePattern } from "../utils/machineStateHelpers"; import { canDeletePattern } from '../utils/machineStateHelpers';
import { PatternInfo } from "./PatternInfo"; import { PatternInfo } from './PatternInfo';
import { DocumentTextIcon, TrashIcon } from "@heroicons/react/24/solid"; import { DocumentTextIcon, TrashIcon } from '@heroicons/react/24/solid';
export function PatternSummaryCard() { export function PatternSummaryCard() {
// Machine store // Machine store
const { machineStatus, isDeleting, deletePattern } = useMachineStore( const {
machineStatus,
isDeleting,
deletePattern,
} = useMachineStore(
useShallow((state) => ({ useShallow((state) => ({
machineStatus: state.machineStatus, machineStatus: state.machineStatus,
isDeleting: state.isDeleting, isDeleting: state.isDeleting,
deletePattern: state.deletePattern, deletePattern: state.deletePattern,
})), }))
); );
// Pattern store // Pattern store
const { pesData, currentFileName } = usePatternStore( const {
pesData,
currentFileName,
} = usePatternStore(
useShallow((state) => ({ useShallow((state) => ({
pesData: state.pesData, pesData: state.pesData,
currentFileName: state.currentFileName, currentFileName: state.currentFileName,
})), }))
); );
if (!pesData) return null; if (!pesData) return null;
@ -31,13 +38,8 @@ export function PatternSummaryCard() {
<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-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" /> <DocumentTextIcon className="w-6 h-6 text-primary-600 dark:text-primary-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"> <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Active Pattern</h3>
Active Pattern <p className="text-xs text-gray-600 dark:text-gray-400 truncate" title={currentFileName}>
</h3>
<p
className="text-xs text-gray-600 dark:text-gray-400 truncate"
title={currentFileName}
>
{currentFileName} {currentFileName}
</p> </p>
</div> </div>
@ -53,24 +55,9 @@ export function PatternSummaryCard() {
> >
{isDeleting ? ( {isDeleting ? (
<> <>
<svg <svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
className="w-3 h-3 animate-spin" <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
fill="none" <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg> </svg>
Deleting... Deleting...
</> </>

View file

@ -1,7 +1,7 @@
import { useRef, useEffect, useState, useMemo } from "react"; import { useRef, useEffect, useState, useMemo } from "react";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from 'zustand/react/shallow';
import { useMachineStore } from "../stores/useMachineStore"; import { useMachineStore } from '../stores/useMachineStore';
import { usePatternStore } from "../stores/usePatternStore"; import { usePatternStore } from '../stores/usePatternStore';
import { import {
CheckCircleIcon, CheckCircleIcon,
ArrowRightIcon, ArrowRightIcon,
@ -42,7 +42,7 @@ export function ProgressMonitor() {
startMaskTrace: state.startMaskTrace, startMaskTrace: state.startMaskTrace,
startSewing: state.startSewing, startSewing: state.startSewing,
resumeSewing: state.resumeSewing, resumeSewing: state.resumeSewing,
})), }))
); );
// Pattern store // Pattern store
@ -59,15 +59,14 @@ export function ProgressMonitor() {
// Use PEN stitch count as fallback when machine reports 0 total stitches // Use PEN stitch count as fallback when machine reports 0 total stitches
const totalStitches = patternInfo const totalStitches = patternInfo
? patternInfo.totalStitches === 0 && pesData?.penStitches ? (patternInfo.totalStitches === 0 && pesData?.penStitches
? pesData.penStitches.stitches.length ? pesData.penStitches.stitches.length
: patternInfo.totalStitches : patternInfo.totalStitches)
: 0; : 0;
const progressPercent = const progressPercent = totalStitches > 0
totalStitches > 0 ? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100
? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100 : 0;
: 0;
// Calculate color block information from decoded penStitches // Calculate color block information from decoded penStitches
const colorBlocks = useMemo(() => { const colorBlocks = useMemo(() => {
@ -117,10 +116,7 @@ export function ProgressMonitor() {
return { totalMinutes: 0, elapsedMinutes: 0 }; return { totalMinutes: 0, elapsedMinutes: 0 };
} }
const result = calculatePatternTime(colorBlocks, currentStitch); const result = calculatePatternTime(colorBlocks, currentStitch);
return { return { totalMinutes: result.totalMinutes, elapsedMinutes: result.elapsedMinutes };
totalMinutes: result.totalMinutes,
elapsedMinutes: result.elapsedMinutes,
};
}, [colorBlocks, currentStitch]); }, [colorBlocks, currentStitch]);
// Auto-scroll to current block // Auto-scroll to current block
@ -136,8 +132,7 @@ export function ProgressMonitor() {
// Handle scroll to detect if at bottom // Handle scroll to detect if at bottom
const handleColorBlocksScroll = () => { const handleColorBlocksScroll = () => {
if (colorBlocksScrollRef.current) { if (colorBlocksScrollRef.current) {
const { scrollTop, scrollHeight, clientHeight } = const { scrollTop, scrollHeight, clientHeight } = colorBlocksScrollRef.current;
colorBlocksScrollRef.current;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; // 5px threshold const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; // 5px threshold
setShowGradient(!isAtBottom); setShowGradient(!isAtBottom);
} }
@ -154,8 +149,8 @@ export function ProgressMonitor() {
}; };
checkScrollable(); checkScrollable();
window.addEventListener("resize", checkScrollable); window.addEventListener('resize', checkScrollable);
return () => window.removeEventListener("resize", checkScrollable); return () => window.removeEventListener('resize', checkScrollable);
}, [colorBlocks]); }, [colorBlocks]);
const stateIndicatorColors = { const stateIndicatorColors = {
@ -305,124 +300,113 @@ export function ProgressMonitor() {
className="lg:absolute lg:inset-0 flex flex-col gap-2 lg:overflow-y-auto scroll-smooth pr-1 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-gray-100 dark:[&::-webkit-scrollbar-track]:bg-gray-700 [&::-webkit-scrollbar-thumb]:bg-primary-600 dark:[&::-webkit-scrollbar-thumb]:bg-primary-500 [&::-webkit-scrollbar-thumb]:rounded-full" className="lg:absolute lg:inset-0 flex flex-col gap-2 lg:overflow-y-auto scroll-smooth pr-1 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-gray-100 dark:[&::-webkit-scrollbar-track]:bg-gray-700 [&::-webkit-scrollbar-thumb]:bg-primary-600 dark:[&::-webkit-scrollbar-thumb]:bg-primary-500 [&::-webkit-scrollbar-thumb]:rounded-full"
> >
{colorBlocks.map((block, index) => { {colorBlocks.map((block, index) => {
const isCompleted = currentStitch >= block.endStitch; const isCompleted = currentStitch >= block.endStitch;
const isCurrent = index === currentBlockIndex; const isCurrent = index === currentBlockIndex;
// Calculate progress within current block // Calculate progress within current block
let blockProgress = 0; let blockProgress = 0;
if (isCurrent) { if (isCurrent) {
blockProgress = blockProgress =
((currentStitch - block.startStitch) / block.stitchCount) * ((currentStitch - block.startStitch) / block.stitchCount) *
100; 100;
} else if (isCompleted) { } else if (isCompleted) {
blockProgress = 100; blockProgress = 100;
} }
return ( return (
<div <div
key={index} key={index}
ref={isCurrent ? currentBlockRef : null} ref={isCurrent ? currentBlockRef : null}
className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${ className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
isCompleted isCompleted
? "border-success-600 bg-success-50 dark:bg-success-900/20" ? "border-success-600 bg-success-50 dark:bg-success-900/20"
: isCurrent : isCurrent
? "border-accent-600 bg-accent-50 dark:bg-accent-900/20 shadow-lg shadow-accent-600/20 animate-pulseGlow" ? "border-accent-600 bg-accent-50 dark:bg-accent-900/20 shadow-lg shadow-accent-600/20 animate-pulseGlow"
: "border-gray-200 dark:border-gray-600 bg-gray-300 dark:bg-gray-800/50 opacity-70" : "border-gray-200 dark:border-gray-600 bg-gray-300 dark:bg-gray-800/50 opacity-70"
}`} }`}
role="listitem" role="listitem"
aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? "completed" : isCurrent ? "in progress" : "pending"}`} aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? "completed" : isCurrent ? "in progress" : "pending"}`}
> >
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
{/* Color swatch */} {/* Color swatch */}
<div <div
className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0" className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
style={{ style={{
backgroundColor: block.threadHex, backgroundColor: block.threadHex,
...(isCurrent && { borderColor: "#9333ea" }), ...(isCurrent && { borderColor: "#9333ea" }),
}} }}
title={`Thread color: ${block.threadHex}`} title={`Thread color: ${block.threadHex}`}
aria-label={`Thread color ${block.threadHex}`} aria-label={`Thread color ${block.threadHex}`}
/> />
{/* Thread info */} {/* Thread info */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-semibold text-xs text-gray-900 dark:text-gray-100"> <div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
Thread {block.colorIndex + 1} Thread {block.colorIndex + 1}
{(block.threadBrand || {(block.threadBrand || block.threadChart || block.threadDescription || block.threadCatalogNumber) && (
block.threadChart || <span className="font-normal text-gray-600 dark:text-gray-400">
block.threadDescription || {" "}
block.threadCatalogNumber) && ( (
<span className="font-normal text-gray-600 dark:text-gray-400"> {(() => {
{" "} // Primary metadata: brand and catalog number
( const primaryMetadata = [
{(() => { block.threadBrand,
// Primary metadata: brand and catalog number block.threadCatalogNumber ? `#${block.threadCatalogNumber}` : null
const primaryMetadata = [ ].filter(Boolean).join(" ");
block.threadBrand,
block.threadCatalogNumber
? `#${block.threadCatalogNumber}`
: null,
]
.filter(Boolean)
.join(" ");
// Secondary metadata: chart and description // Secondary metadata: chart and description
const secondaryMetadata = [ const secondaryMetadata = [
block.threadChart, block.threadChart,
block.threadDescription, block.threadDescription
] ].filter(Boolean).join(" ");
.filter(Boolean)
.join(" ");
return [primaryMetadata, secondaryMetadata] return [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
.filter(Boolean) })()}
.join(" • "); )
})()} </span>
) )}
</span> </div>
)} <div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
</div> {block.stitchCount.toLocaleString()} stitches
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
{block.stitchCount.toLocaleString()} stitches
</div>
</div> </div>
{/* Status icon */}
{isCompleted ? (
<CheckCircleIcon
className="w-5 h-5 text-success-600 flex-shrink-0"
aria-label="Completed"
/>
) : isCurrent ? (
<ArrowRightIcon
className="w-5 h-5 text-accent-600 flex-shrink-0 animate-pulse"
aria-label="In progress"
/>
) : (
<CircleStackIcon
className="w-5 h-5 text-gray-400 flex-shrink-0"
aria-label="Pending"
/>
)}
</div> </div>
{/* Progress bar for current block */} {/* Status icon */}
{isCurrent && ( {isCompleted ? (
<div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden"> <CheckCircleIcon
<div className="w-5 h-5 text-success-600 flex-shrink-0"
className="h-full bg-accent-600 dark:bg-accent-500 transition-all duration-300 rounded-full" aria-label="Completed"
style={{ width: `${blockProgress}%` }} />
role="progressbar" ) : isCurrent ? (
aria-valuenow={Math.round(blockProgress)} <ArrowRightIcon
aria-valuemin={0} className="w-5 h-5 text-accent-600 flex-shrink-0 animate-pulse"
aria-valuemax={100} aria-label="In progress"
aria-label={`${Math.round(blockProgress)}% complete`} />
/> ) : (
</div> <CircleStackIcon
className="w-5 h-5 text-gray-400 flex-shrink-0"
aria-label="Pending"
/>
)} )}
</div> </div>
);
})} {/* Progress bar for current block */}
{isCurrent && (
<div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-accent-600 dark:bg-accent-500 transition-all duration-300 rounded-full"
style={{ width: `${blockProgress}%` }}
role="progressbar"
aria-valuenow={Math.round(blockProgress)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${Math.round(blockProgress)}% complete`}
/>
</div>
)}
</div>
);
})}
</div> </div>
{/* Gradient overlay to indicate more content below - only on desktop and when not at bottom */} {/* Gradient overlay to indicate more content below - only on desktop and when not at bottom */}
{showGradient && ( {showGradient && (

View file

@ -1,19 +1,15 @@
interface SkeletonLoaderProps { interface SkeletonLoaderProps {
className?: string; className?: string;
variant?: "text" | "rect" | "circle"; variant?: 'text' | 'rect' | 'circle';
} }
export function SkeletonLoader({ export function SkeletonLoader({ className = '', variant = 'rect' }: SkeletonLoaderProps) {
className = "", const baseClasses = 'animate-pulse bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 bg-[length:200%_100%]';
variant = "rect",
}: SkeletonLoaderProps) {
const baseClasses =
"animate-pulse bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 bg-[length:200%_100%]";
const variantClasses = { const variantClasses = {
text: "h-4 rounded", text: 'h-4 rounded',
rect: "rounded-lg", rect: 'rounded-lg',
circle: "rounded-full", circle: 'rounded-full'
}; };
return ( return (
@ -33,24 +29,9 @@ export function PatternCanvasSkeleton() {
<div className="relative w-24 h-24 mx-auto"> <div className="relative w-24 h-24 mx-auto">
<SkeletonLoader className="w-24 h-24" variant="circle" /> <SkeletonLoader className="w-24 h-24" variant="circle" />
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<svg <svg className="w-12 h-12 text-gray-400 dark:text-gray-500 animate-spin" fill="none" viewBox="0 0 24 24">
className="w-12 h-12 text-gray-400 dark:text-gray-500 animate-spin" <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
fill="none" <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg> </svg>
</div> </div>
</div> </div>

View file

@ -1,14 +1,10 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from 'react';
import { useShallow } from "zustand/react/shallow"; import { useShallow } from 'zustand/react/shallow';
import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore"; import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore';
import { usePatternStore } from "../stores/usePatternStore"; import { usePatternStore } from '../stores/usePatternStore';
import { import { CheckCircleIcon, InformationCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/solid';
CheckCircleIcon, import { MachineStatus } from '../types/machine';
InformationCircleIcon, import { getErrorDetails, hasError } from '../utils/errorCodeHelpers';
ExclamationTriangleIcon,
} from "@heroicons/react/24/solid";
import { MachineStatus } from "../types/machine";
import { getErrorDetails, hasError } from "../utils/errorCodeHelpers";
interface Step { interface Step {
id: number; id: number;
@ -17,14 +13,14 @@ interface Step {
} }
const steps: Step[] = [ const steps: Step[] = [
{ id: 1, label: "Connect", description: "Connect to machine" }, { id: 1, label: 'Connect', description: 'Connect to machine' },
{ id: 2, label: "Home Machine", description: "Initialize hoop position" }, { id: 2, label: 'Home Machine', description: 'Initialize hoop position' },
{ id: 3, label: "Load Pattern", description: "Choose PES file" }, { id: 3, label: 'Load Pattern', description: 'Choose PES file' },
{ id: 4, label: "Upload", description: "Upload to machine" }, { id: 4, label: 'Upload', description: 'Upload to machine' },
{ id: 5, label: "Mask Trace", description: "Trace pattern area" }, { id: 5, label: 'Mask Trace', description: 'Trace pattern area' },
{ id: 6, label: "Start Sewing", description: "Begin embroidery" }, { id: 6, label: 'Start Sewing', description: 'Begin embroidery' },
{ id: 7, label: "Monitor", description: "Watch progress" }, { id: 7, label: 'Monitor', description: 'Watch progress' },
{ id: 8, label: "Complete", description: "Finish and remove" }, { id: 8, label: 'Complete', description: 'Finish and remove' },
]; ];
// Helper function to get guide content for a step // Helper function to get guide content for a step
@ -33,7 +29,7 @@ function getGuideContent(
machineStatus: MachineStatus, machineStatus: MachineStatus,
hasError: boolean, hasError: boolean,
errorCode?: number, errorCode?: number,
errorMessage?: string, errorMessage?: string
) { ) {
// Check for errors first // Check for errors first
if (hasError) { if (hasError) {
@ -41,22 +37,19 @@ function getGuideContent(
if (errorDetails?.isInformational) { if (errorDetails?.isInformational) {
return { return {
type: "info" as const, type: 'info' as const,
title: errorDetails.title, title: errorDetails.title,
description: errorDetails.description, description: errorDetails.description,
items: errorDetails.solutions || [], items: errorDetails.solutions || []
}; };
} }
return { return {
type: "error" as const, type: 'error' as const,
title: errorDetails?.title || "Error Occurred", title: errorDetails?.title || 'Error Occurred',
description: description: errorDetails?.description || errorMessage || 'An error occurred. Please check the machine and try again.',
errorDetails?.description ||
errorMessage ||
"An error occurred. Please check the machine and try again.",
items: errorDetails?.solutions || [], items: errorDetails?.solutions || [],
errorCode, errorCode
}; };
} }
@ -64,166 +57,156 @@ function getGuideContent(
switch (stepId) { switch (stepId) {
case 1: case 1:
return { return {
type: "info" as const, type: 'info' as const,
title: "Step 1: Connect to Machine", title: 'Step 1: Connect to Machine',
description: description: 'To get started, connect to your Brother embroidery machine via Bluetooth.',
"To get started, connect to your Brother embroidery machine via Bluetooth.",
items: [ items: [
"Make sure your machine is powered on", 'Make sure your machine is powered on',
"Enable Bluetooth on your machine", 'Enable Bluetooth on your machine',
'Click the "Connect to Machine" button below', 'Click the "Connect to Machine" button below'
], ]
}; };
case 2: case 2:
return { return {
type: "info" as const, type: 'info' as const,
title: "Step 2: Home Machine", title: 'Step 2: Home Machine',
description: description: 'The hoop needs to be removed and an initial homing procedure must be performed.',
"The hoop needs to be removed and an initial homing procedure must be performed.",
items: [ items: [
"Remove the embroidery hoop from the machine completely", 'Remove the embroidery hoop from the machine completely',
"Press the Accept button on the machine", 'Press the Accept button on the machine',
"Wait for the machine to complete its initialization (homing)", 'Wait for the machine to complete its initialization (homing)',
"Once initialization is complete, reattach the hoop", 'Once initialization is complete, reattach the hoop',
"The machine should now recognize the hoop correctly", 'The machine should now recognize the hoop correctly'
], ]
}; };
case 3: case 3:
return { return {
type: "info" as const, type: 'info' as const,
title: "Step 3: Load Your Pattern", title: 'Step 3: Load Your Pattern',
description: description: 'Choose a PES embroidery file from your computer to preview and upload.',
"Choose a PES embroidery file from your computer to preview and upload.",
items: [ items: [
'Click "Choose PES File" in the Pattern File section', 'Click "Choose PES File" in the Pattern File section',
"Select your embroidery design (.pes file)", 'Select your embroidery design (.pes file)',
"Review the pattern preview on the right", 'Review the pattern preview on the right',
"You can drag the pattern to adjust its position", 'You can drag the pattern to adjust its position'
], ]
}; };
case 4: case 4:
return { return {
type: "info" as const, type: 'info' as const,
title: "Step 4: Upload Pattern to Machine", title: 'Step 4: Upload Pattern to Machine',
description: description: 'Send your pattern to the embroidery machine to prepare for sewing.',
"Send your pattern to the embroidery machine to prepare for sewing.",
items: [ items: [
"Review the pattern preview to ensure it's positioned correctly", 'Review the pattern preview to ensure it\'s positioned correctly',
"Check the pattern size matches your hoop", 'Check the pattern size matches your hoop',
'Click "Upload to Machine" when ready', 'Click "Upload to Machine" when ready',
"Wait for the upload to complete (this may take a minute)", 'Wait for the upload to complete (this may take a minute)'
], ]
}; };
case 5: case 5:
// Check machine status for substates // Check machine status for substates
if (machineStatus === MachineStatus.MASK_TRACE_LOCK_WAIT) { if (machineStatus === MachineStatus.MASK_TRACE_LOCK_WAIT) {
return { return {
type: "warning" as const, type: 'warning' as const,
title: "Machine Action Required", title: 'Machine Action Required',
description: "The machine is ready to trace the pattern outline.", description: 'The machine is ready to trace the pattern outline.',
items: [ items: [
"Press the button on your machine to confirm and start the mask trace", 'Press the button on your machine to confirm and start the mask trace',
"Ensure the hoop is properly attached", 'Ensure the hoop is properly attached',
"Make sure the needle area is clear", 'Make sure the needle area is clear'
], ]
}; };
} }
if (machineStatus === MachineStatus.MASK_TRACING) { if (machineStatus === MachineStatus.MASK_TRACING) {
return { return {
type: "progress" as const, type: 'progress' as const,
title: "Mask Trace In Progress", title: 'Mask Trace In Progress',
description: description: 'The machine is tracing the pattern boundary. Please wait...',
"The machine is tracing the pattern boundary. Please wait...",
items: [ items: [
"Watch the machine trace the outline", 'Watch the machine trace the outline',
"Verify the pattern fits within your hoop", 'Verify the pattern fits within your hoop',
"Do not interrupt the machine", 'Do not interrupt the machine'
], ]
}; };
} }
return { return {
type: "info" as const, type: 'info' as const,
title: "Step 5: Start Mask Trace", title: 'Step 5: Start Mask Trace',
description: description: 'The mask trace helps the machine understand the pattern boundaries.',
"The mask trace helps the machine understand the pattern boundaries.",
items: [ items: [
'Click "Start Mask Trace" button in the Sewing Progress section', 'Click "Start Mask Trace" button in the Sewing Progress section',
"The machine will trace the pattern outline", 'The machine will trace the pattern outline',
"This ensures the hoop is positioned correctly", 'This ensures the hoop is positioned correctly'
], ]
}; };
case 6: case 6:
return { return {
type: "success" as const, type: 'success' as const,
title: "Step 6: Ready to Sew!", title: 'Step 6: Ready to Sew!',
description: "The machine is ready to begin embroidering your pattern.", description: 'The machine is ready to begin embroidering your pattern.',
items: [ items: [
"Verify your thread colors are correct", 'Verify your thread colors are correct',
"Ensure the fabric is properly hooped", 'Ensure the fabric is properly hooped',
'Click "Start Sewing" when ready', 'Click "Start Sewing" when ready'
], ]
}; };
case 7: case 7:
// Check for substates // Check for substates
if (machineStatus === MachineStatus.COLOR_CHANGE_WAIT) { if (machineStatus === MachineStatus.COLOR_CHANGE_WAIT) {
return { return {
type: "warning" as const, type: 'warning' as const,
title: "Thread Change Required", title: 'Thread Change Required',
description: description: 'The machine needs a different thread color to continue.',
"The machine needs a different thread color to continue.",
items: [ items: [
"Check the color blocks section to see which thread is needed", 'Check the color blocks section to see which thread is needed',
"Change to the correct thread color", 'Change to the correct thread color',
"Press the button on your machine to resume sewing", 'Press the button on your machine to resume sewing'
], ]
}; };
} }
if ( if (machineStatus === MachineStatus.PAUSE ||
machineStatus === MachineStatus.PAUSE || machineStatus === MachineStatus.STOP ||
machineStatus === MachineStatus.STOP || machineStatus === MachineStatus.SEWING_INTERRUPTION) {
machineStatus === MachineStatus.SEWING_INTERRUPTION
) {
return { return {
type: "warning" as const, type: 'warning' as const,
title: "Sewing Paused", title: 'Sewing Paused',
description: "The embroidery has been paused or interrupted.", description: 'The embroidery has been paused or interrupted.',
items: [ items: [
"Check if everything is okay with the machine", 'Check if everything is okay with the machine',
'Click "Resume Sewing" when ready to continue', 'Click "Resume Sewing" when ready to continue',
"The machine will pick up where it left off", 'The machine will pick up where it left off'
], ]
}; };
} }
return { return {
type: "progress" as const, type: 'progress' as const,
title: "Step 7: Sewing In Progress", title: 'Step 7: Sewing In Progress',
description: description: 'Your embroidery is being stitched. Monitor the progress below.',
"Your embroidery is being stitched. Monitor the progress below.",
items: [ items: [
"Watch the progress bar and current stitch count", 'Watch the progress bar and current stitch count',
"The machine will pause when a color change is needed", 'The machine will pause when a color change is needed',
"Do not leave the machine unattended", 'Do not leave the machine unattended'
], ]
}; };
case 8: case 8:
return { return {
type: "success" as const, type: 'success' as const,
title: "Step 8: Embroidery Complete!", title: 'Step 8: Embroidery Complete!',
description: "Your embroidery is finished. Great work!", description: 'Your embroidery is finished. Great work!',
items: [ items: [
"Remove the hoop from the machine", 'Remove the hoop from the machine',
"Press the Accept button on the machine", 'Press the Accept button on the machine',
"Carefully remove your finished embroidery", 'Carefully remove your finished embroidery',
"Trim any jump stitches or loose threads", 'Trim any jump stitches or loose threads',
'Click "Delete Pattern" to start a new project', 'Click "Delete Pattern" to start a new project'
], ]
}; };
default: default:
@ -231,12 +214,7 @@ function getGuideContent(
} }
} }
function getCurrentStep( function getCurrentStep(machineStatus: MachineStatus, isConnected: boolean, hasPattern: boolean, patternUploaded: boolean): number {
machineStatus: MachineStatus,
isConnected: boolean,
hasPattern: boolean,
patternUploaded: boolean,
): number {
if (!isConnected) return 1; if (!isConnected) return 1;
// Check if machine needs homing (Initial state) // Check if machine needs homing (Initial state)
@ -284,26 +262,23 @@ export function WorkflowStepper() {
isConnected: state.isConnected, isConnected: state.isConnected,
machineError: state.machineError, machineError: state.machineError,
error: state.error, error: state.error,
})), }))
); );
// Pattern store // Pattern store
const { pesData } = usePatternStore( const {
pesData,
} = usePatternStore(
useShallow((state) => ({ useShallow((state) => ({
pesData: state.pesData, pesData: state.pesData,
})), }))
); );
// Derived state: pattern is uploaded if machine has pattern info // Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded(); const patternUploaded = usePatternUploaded();
const hasPattern = pesData !== null; const hasPattern = pesData !== null;
const hasErrorFlag = hasError(machineError); const hasErrorFlag = hasError(machineError);
const currentStep = getCurrentStep( const currentStep = getCurrentStep(machineStatus, isConnected, hasPattern, patternUploaded);
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);
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
@ -312,13 +287,10 @@ export function WorkflowStepper() {
// Close popover when clicking outside // Close popover when clicking outside
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if ( if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
popoverRef.current &&
!popoverRef.current.contains(event.target as Node)
) {
// Check if click was on a step circle // Check if click was on a step circle
const clickedStep = Object.values(stepRefs.current).find((ref) => const clickedStep = Object.values(stepRefs.current).find(ref =>
ref?.contains(event.target as Node), ref?.contains(event.target as Node)
); );
if (!clickedStep) { if (!clickedStep) {
setShowPopover(false); setShowPopover(false);
@ -327,9 +299,8 @@ export function WorkflowStepper() {
}; };
if (showPopover) { if (showPopover) {
document.addEventListener("mousedown", handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
return () => return () => document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener("mousedown", handleClickOutside);
} }
}, [showPopover]); }, [showPopover]);
@ -347,23 +318,16 @@ export function WorkflowStepper() {
}; };
return ( return (
<div <div className="relative max-w-5xl mx-auto mt-2 lg:mt-4" role="navigation" aria-label="Workflow progress">
className="relative max-w-5xl mx-auto mt-2 lg:mt-4"
role="navigation"
aria-label="Workflow progress"
>
{/* Progress bar background */} {/* Progress bar background */}
<div <div className="absolute top-4 lg:top-5 left-0 right-0 h-0.5 lg:h-1 bg-primary-400/20 dark:bg-primary-600/20 rounded-full" style={{ left: '16px', right: '16px' }} />
className="absolute top-4 lg:top-5 left-0 right-0 h-0.5 lg:h-1 bg-primary-400/20 dark:bg-primary-600/20 rounded-full"
style={{ left: "16px", right: "16px" }}
/>
{/* Progress bar fill */} {/* Progress bar fill */}
<div <div
className="absolute top-4 lg:top-5 left-0 h-0.5 lg:h-1 bg-gradient-to-r from-success-500 to-primary-500 dark:from-success-600 dark:to-primary-600 transition-all duration-500 rounded-full" className="absolute top-4 lg:top-5 left-0 h-0.5 lg:h-1 bg-gradient-to-r from-success-500 to-primary-500 dark:from-success-600 dark:to-primary-600 transition-all duration-500 rounded-full"
style={{ style={{
left: "16px", left: '16px',
width: `calc(${((currentStep - 1) / (steps.length - 1)) * 100}% - 16px)`, width: `calc(${((currentStep - 1) / (steps.length - 1)) * 100}% - 16px)`
}} }}
role="progressbar" role="progressbar"
aria-valuenow={currentStep} aria-valuenow={currentStep}
@ -385,31 +349,26 @@ export function WorkflowStepper() {
className="flex flex-col items-center" className="flex flex-col items-center"
style={{ flex: 1 }} style={{ flex: 1 }}
role="listitem" role="listitem"
aria-current={isCurrent ? "step" : undefined} aria-current={isCurrent ? 'step' : undefined}
> >
{/* Step circle */} {/* Step circle */}
<div <div
ref={(el) => { ref={(el) => { stepRefs.current[step.id] = el; }}
stepRefs.current[step.id] = el;
}}
onClick={() => handleStepClick(step.id)} onClick={() => handleStepClick(step.id)}
className={` className={`
w-8 h-8 lg:w-10 lg:h-10 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-300 border-2 shadow-md w-8 h-8 lg:w-10 lg:h-10 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-300 border-2 shadow-md
${step.id <= currentStep ? "cursor-pointer hover:scale-110" : "cursor-not-allowed"} ${step.id <= currentStep ? 'cursor-pointer hover:scale-110' : 'cursor-not-allowed'}
${isComplete ? "bg-success-500 dark:bg-success-600 border-success-400 dark:border-success-500 text-white shadow-success-500/30 dark:shadow-success-600/30" : ""} ${isComplete ? 'bg-success-500 dark:bg-success-600 border-success-400 dark:border-success-500 text-white shadow-success-500/30 dark:shadow-success-600/30' : ''}
${isCurrent ? "bg-primary-600 dark:bg-primary-700 border-primary-500 dark:border-primary-600 text-white scale-105 lg:scale-110 shadow-primary-600/40 dark:shadow-primary-700/40 ring-2 ring-primary-300 dark:ring-primary-500 ring-offset-2 dark:ring-offset-gray-900" : ""} ${isCurrent ? 'bg-primary-600 dark:bg-primary-700 border-primary-500 dark:border-primary-600 text-white scale-105 lg:scale-110 shadow-primary-600/40 dark:shadow-primary-700/40 ring-2 ring-primary-300 dark:ring-primary-500 ring-offset-2 dark:ring-offset-gray-900' : ''}
${isUpcoming ? "bg-primary-700 dark:bg-primary-800 border-primary-500/30 dark:border-primary-600/30 text-primary-200/70 dark:text-primary-300/70" : ""} ${isUpcoming ? 'bg-primary-700 dark:bg-primary-800 border-primary-500/30 dark:border-primary-600/30 text-primary-200/70 dark:text-primary-300/70' : ''}
${showPopover && popoverStep === step.id ? "ring-4 ring-white dark:ring-gray-800" : ""} ${showPopover && popoverStep === step.id ? 'ring-4 ring-white dark:ring-gray-800' : ''}
`} `}
aria-label={`${step.label}: ${isComplete ? "completed" : isCurrent ? "current" : "upcoming"}. Click for details.`} aria-label={`${step.label}: ${isComplete ? 'completed' : isCurrent ? 'current' : 'upcoming'}. Click for details.`}
role="button" role="button"
tabIndex={step.id <= currentStep ? 0 : -1} tabIndex={step.id <= currentStep ? 0 : -1}
> >
{isComplete ? ( {isComplete ? (
<CheckCircleIcon <CheckCircleIcon className="w-5 h-5 lg:w-6 lg:h-6" aria-hidden="true" />
className="w-5 h-5 lg:w-6 lg:h-6"
aria-hidden="true"
/>
) : ( ) : (
step.id step.id
)} )}
@ -417,15 +376,9 @@ export function WorkflowStepper() {
{/* Step label */} {/* Step label */}
<div className="mt-1 lg:mt-2 text-center"> <div className="mt-1 lg:mt-2 text-center">
<div <div className={`text-xs font-semibold leading-tight ${
className={`text-xs font-semibold leading-tight ${ isCurrent ? 'text-white' : isComplete ? 'text-success-200 dark:text-success-300' : 'text-primary-300/70 dark:text-primary-400/70'
isCurrent }`}>
? "text-white"
: isComplete
? "text-success-200 dark:text-success-300"
: "text-primary-300/70 dark:text-primary-400/70"
}`}
>
{step.label} {step.label}
</div> </div>
</div> </div>
@ -443,113 +396,74 @@ export function WorkflowStepper() {
aria-label="Step guidance" aria-label="Step guidance"
> >
{(() => { {(() => {
const content = getGuideContent( const content = getGuideContent(popoverStep, machineStatus, hasErrorFlag, machineError, errorMessage || undefined);
popoverStep,
machineStatus,
hasErrorFlag,
machineError,
errorMessage || undefined,
);
if (!content) return null; if (!content) return null;
const colorClasses = { const colorClasses = {
info: "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500", info: 'bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500',
success: success: 'bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500',
"bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500", warning: 'bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500',
warning: error: 'bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500',
"bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500", progress: 'bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500'
error:
"bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500",
progress:
"bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
}; };
const iconColorClasses = { const iconColorClasses = {
info: "text-info-600 dark:text-info-400", info: 'text-info-600 dark:text-info-400',
success: "text-success-600 dark:text-success-400", success: 'text-success-600 dark:text-success-400',
warning: "text-warning-600 dark:text-warning-400", warning: 'text-warning-600 dark:text-warning-400',
error: "text-danger-600 dark:text-danger-400", error: 'text-danger-600 dark:text-danger-400',
progress: "text-info-600 dark:text-info-400", progress: 'text-info-600 dark:text-info-400'
}; };
const textColorClasses = { const textColorClasses = {
info: "text-info-900 dark:text-info-200", info: 'text-info-900 dark:text-info-200',
success: "text-success-900 dark:text-success-200", success: 'text-success-900 dark:text-success-200',
warning: "text-warning-900 dark:text-warning-200", warning: 'text-warning-900 dark:text-warning-200',
error: "text-danger-900 dark:text-danger-200", error: 'text-danger-900 dark:text-danger-200',
progress: "text-info-900 dark:text-info-200", progress: 'text-info-900 dark:text-info-200'
}; };
const descColorClasses = { const descColorClasses = {
info: "text-info-800 dark:text-info-300", info: 'text-info-800 dark:text-info-300',
success: "text-success-800 dark:text-success-300", success: 'text-success-800 dark:text-success-300',
warning: "text-warning-800 dark:text-warning-300", warning: 'text-warning-800 dark:text-warning-300',
error: "text-danger-800 dark:text-danger-300", error: 'text-danger-800 dark:text-danger-300',
progress: "text-info-800 dark:text-info-300", progress: 'text-info-800 dark:text-info-300'
}; };
const listColorClasses = { const listColorClasses = {
info: "text-blue-700 dark:text-blue-300", info: 'text-blue-700 dark:text-blue-300',
success: "text-green-700 dark:text-green-300", success: 'text-green-700 dark:text-green-300',
warning: "text-yellow-700 dark:text-yellow-300", warning: 'text-yellow-700 dark:text-yellow-300',
error: "text-red-700 dark:text-red-300", error: 'text-red-700 dark:text-red-300',
progress: "text-cyan-700 dark:text-cyan-300", progress: 'text-cyan-700 dark:text-cyan-300'
}; };
const Icon = const Icon = content.type === 'error' ? ExclamationTriangleIcon : InformationCircleIcon;
content.type === "error"
? ExclamationTriangleIcon
: InformationCircleIcon;
return ( return (
<div <div className={`${colorClasses[content.type]} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}>
className={`${colorClasses[content.type]} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Icon <Icon className={`w-6 h-6 ${iconColorClasses[content.type]} flex-shrink-0 mt-0.5`} />
className={`w-6 h-6 ${iconColorClasses[content.type]} flex-shrink-0 mt-0.5`}
/>
<div className="flex-1"> <div className="flex-1">
<h3 <h3 className={`text-base font-semibold ${textColorClasses[content.type]} mb-2`}>
className={`text-base font-semibold ${textColorClasses[content.type]} mb-2`}
>
{content.title} {content.title}
</h3> </h3>
<p <p className={`text-sm ${descColorClasses[content.type]} mb-3`}>
className={`text-sm ${descColorClasses[content.type]} mb-3`}
>
{content.description} {content.description}
</p> </p>
{content.items && content.items.length > 0 && ( {content.items && content.items.length > 0 && (
<ul <ul className={`list-disc list-inside text-sm ${listColorClasses[content.type]} space-y-1`}>
className={`list-disc list-inside text-sm ${listColorClasses[content.type]} space-y-1`}
>
{content.items.map((item, index) => ( {content.items.map((item, index) => (
<li <li key={index} className="pl-2" dangerouslySetInnerHTML={{ __html: item.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') }} />
key={index}
className="pl-2"
dangerouslySetInnerHTML={{
__html: item.replace(
/\*\*(.*?)\*\*/g,
"<strong>$1</strong>",
),
}}
/>
))} ))}
</ul> </ul>
)} )}
{content.type === "error" && {content.type === 'error' && content.errorCode !== undefined && (
content.errorCode !== undefined && ( <p className={`text-xs ${descColorClasses[content.type]} mt-3 font-mono`}>
<p Error Code: 0x{content.errorCode.toString(16).toUpperCase().padStart(2, '0')}
className={`text-xs ${descColorClasses[content.type]} mt-3 font-mono`} </p>
> )}
Error Code: 0x
{content.errorCode
.toString(16)
.toUpperCase()
.padStart(2, "0")}
</p>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,12 +1,12 @@
import type { WorkerMessage, WorkerResponse } from "./worker"; import type { WorkerMessage, WorkerResponse } from './worker';
import PatternConverterWorker from "./worker?worker"; import PatternConverterWorker from './worker?worker';
import { decodePenData } from "../pen/decoder"; import { decodePenData } from '../pen/decoder';
import type { DecodedPenData } from "../pen/types"; import type { DecodedPenData } from '../pen/types';
export type PyodideState = "not_loaded" | "loading" | "ready" | "error"; export type PyodideState = 'not_loaded' | 'loading' | 'ready' | 'error';
export interface PesPatternData { export interface PesPatternData {
stitches: number[][]; // Original PES stitches (for reference) stitches: number[][]; // Original PES stitches (for reference)
threads: Array<{ threads: Array<{
color: number; color: number;
hex: string; hex: string;
@ -24,7 +24,7 @@ export interface PesPatternData {
chart: string | null; chart: string | null;
threadIndices: number[]; threadIndices: number[];
}>; }>;
penData: Uint8Array; // Raw PEN bytes sent to machine penData: Uint8Array; // Raw PEN bytes sent to machine
penStitches: DecodedPenData; // Decoded PEN stitches (for rendering) penStitches: DecodedPenData; // Decoded PEN stitches (for rendering)
colorCount: number; colorCount: number;
stitchCount: number; stitchCount: number;
@ -40,7 +40,7 @@ export type ProgressCallback = (progress: number, step: string) => void;
class PatternConverterClient { class PatternConverterClient {
private worker: Worker | null = null; private worker: Worker | null = null;
private state: PyodideState = "not_loaded"; private state: PyodideState = 'not_loaded';
private error: string | null = null; private error: string | null = null;
private initPromise: Promise<void> | null = null; private initPromise: Promise<void> | null = null;
private progressCallbacks: Set<ProgressCallback> = new Set(); private progressCallbacks: Set<ProgressCallback> = new Set();
@ -64,7 +64,7 @@ class PatternConverterClient {
*/ */
async initialize(onProgress?: ProgressCallback): Promise<void> { async initialize(onProgress?: ProgressCallback): Promise<void> {
// If already ready, return immediately // If already ready, return immediately
if (this.state === "ready") { if (this.state === 'ready') {
return; return;
} }
@ -78,13 +78,13 @@ class PatternConverterClient {
// Create worker if it doesn't exist // Create worker if it doesn't exist
if (!this.worker) { if (!this.worker) {
console.log("[PatternConverterClient] Creating worker..."); console.log('[PatternConverterClient] Creating worker...');
try { try {
this.worker = new PatternConverterWorker(); this.worker = new PatternConverterWorker();
console.log("[PatternConverterClient] Worker created successfully"); console.log('[PatternConverterClient] Worker created successfully');
this.setupWorkerListeners(); this.setupWorkerListeners();
} catch (err) { } catch (err) {
console.error("[PatternConverterClient] Failed to create worker:", err); console.error('[PatternConverterClient] Failed to create worker:', err);
throw err; throw err;
} }
} }
@ -95,7 +95,7 @@ class PatternConverterClient {
} }
// Start initialization // Start initialization
this.state = "loading"; this.state = 'loading';
this.error = null; this.error = null;
this.initPromise = new Promise<void>((resolve, reject) => { this.initPromise = new Promise<void>((resolve, reject) => {
@ -103,55 +103,44 @@ class PatternConverterClient {
const message = event.data; const message = event.data;
switch (message.type) { switch (message.type) {
case "INIT_PROGRESS": case 'INIT_PROGRESS':
// Notify all progress callbacks // Notify all progress callbacks
this.progressCallbacks.forEach((callback) => { this.progressCallbacks.forEach((callback) => {
callback(message.progress, message.step); callback(message.progress, message.step);
}); });
break; break;
case "INIT_COMPLETE": case 'INIT_COMPLETE':
this.state = "ready"; this.state = 'ready';
this.progressCallbacks.clear(); this.progressCallbacks.clear();
this.worker?.removeEventListener("message", handleMessage); this.worker?.removeEventListener('message', handleMessage);
resolve(); resolve();
break; break;
case "INIT_ERROR": case 'INIT_ERROR':
this.state = "error"; this.state = 'error';
this.error = message.error; this.error = message.error;
this.progressCallbacks.clear(); this.progressCallbacks.clear();
this.worker?.removeEventListener("message", handleMessage); this.worker?.removeEventListener('message', handleMessage);
reject(new Error(message.error)); reject(new Error(message.error));
break; break;
} }
}; };
this.worker?.addEventListener("message", handleMessage); this.worker?.addEventListener('message', handleMessage);
// Send initialization message with asset URLs // Send initialization message with asset URLs
// Resolve URLs relative to the current page location // Resolve URLs relative to the current page location
const baseURL = const baseURL = window.location.origin + window.location.pathname.replace(/\/[^/]*$/, '/');
window.location.origin + const pyodideIndexURL = new URL('assets/', baseURL).href;
window.location.pathname.replace(/\/[^/]*$/, "/"); const pystitchWheelURL = new URL('pystitch-1.0.0-py3-none-any.whl', baseURL).href;
const pyodideIndexURL = new URL("assets/", baseURL).href;
const pystitchWheelURL = new URL(
"pystitch-1.0.0-py3-none-any.whl",
baseURL,
).href;
console.log("[PatternConverterClient] Base URL:", baseURL); console.log('[PatternConverterClient] Base URL:', baseURL);
console.log( console.log('[PatternConverterClient] Pyodide index URL:', pyodideIndexURL);
"[PatternConverterClient] Pyodide index URL:", console.log('[PatternConverterClient] Pystitch wheel URL:', pystitchWheelURL);
pyodideIndexURL,
);
console.log(
"[PatternConverterClient] Pystitch wheel URL:",
pystitchWheelURL,
);
const initMessage: WorkerMessage = { const initMessage: WorkerMessage = {
type: "INITIALIZE", type: 'INITIALIZE',
pyodideIndexURL, pyodideIndexURL,
pystitchWheelURL, pystitchWheelURL,
}; };
@ -166,21 +155,19 @@ class PatternConverterClient {
*/ */
async convertPesToPen(file: File): Promise<PesPatternData> { async convertPesToPen(file: File): Promise<PesPatternData> {
// Ensure worker is initialized // Ensure worker is initialized
if (this.state !== "ready") { if (this.state !== 'ready') {
throw new Error( throw new Error('Pyodide worker not initialized. Call initialize() first.');
"Pyodide worker not initialized. Call initialize() first.",
);
} }
if (!this.worker) { if (!this.worker) {
throw new Error("Worker not available"); throw new Error('Worker not available');
} }
return new Promise<PesPatternData>((resolve, reject) => { return new Promise<PesPatternData>((resolve, reject) => {
// Store reference to worker for TypeScript null checking // Store reference to worker for TypeScript null checking
const worker = this.worker; const worker = this.worker;
if (!worker) { if (!worker) {
reject(new Error("Worker not available")); reject(new Error('Worker not available'));
return; return;
} }
@ -188,20 +175,14 @@ class PatternConverterClient {
const message = event.data; const message = event.data;
switch (message.type) { switch (message.type) {
case "CONVERT_COMPLETE": { case 'CONVERT_COMPLETE': {
worker.removeEventListener("message", handleMessage); worker.removeEventListener('message', handleMessage);
// Convert penData array back to Uint8Array // Convert penData array back to Uint8Array
const penData = new Uint8Array(message.data.penData); const penData = new Uint8Array(message.data.penData);
// Decode the PEN data to get stitches for rendering // Decode the PEN data to get stitches for rendering
const penStitches = decodePenData(penData); const penStitches = decodePenData(penData);
console.log( console.log('[PatternConverter] Decoded PEN data:', penStitches.stitches.length, 'stitches,', penStitches.colorBlocks.length, 'colors');
"[PatternConverter] Decoded PEN data:",
penStitches.stitches.length,
"stitches,",
penStitches.colorBlocks.length,
"colors",
);
const result: PesPatternData = { const result: PesPatternData = {
...message.data, ...message.data,
@ -212,28 +193,28 @@ class PatternConverterClient {
break; break;
} }
case "CONVERT_ERROR": case 'CONVERT_ERROR':
worker.removeEventListener("message", handleMessage); worker.removeEventListener('message', handleMessage);
reject(new Error(message.error)); reject(new Error(message.error));
break; break;
} }
}; };
worker.addEventListener("message", handleMessage); worker.addEventListener('message', handleMessage);
// Read file as ArrayBuffer and send to worker // Read file as ArrayBuffer and send to worker
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
const convertMessage: WorkerMessage = { const convertMessage: WorkerMessage = {
type: "CONVERT_PES", type: 'CONVERT_PES',
fileData: reader.result as ArrayBuffer, fileData: reader.result as ArrayBuffer,
fileName: file.name, fileName: file.name,
}; };
worker.postMessage(convertMessage); worker.postMessage(convertMessage);
}; };
reader.onerror = () => { reader.onerror = () => {
worker.removeEventListener("message", handleMessage); worker.removeEventListener('message', handleMessage);
reject(new Error("Failed to read file")); reject(new Error('Failed to read file'));
}; };
reader.readAsArrayBuffer(file); reader.readAsArrayBuffer(file);
}); });
@ -245,16 +226,16 @@ class PatternConverterClient {
private setupWorkerListeners() { private setupWorkerListeners() {
if (!this.worker) return; if (!this.worker) return;
this.worker.addEventListener("error", (event) => { this.worker.addEventListener('error', (event) => {
console.error("[PyodideWorkerClient] Worker error:", event); console.error('[PyodideWorkerClient] Worker error:', event);
this.state = "error"; this.state = 'error';
this.error = event.message || "Worker error"; this.error = event.message || 'Worker error';
}); });
this.worker.addEventListener("messageerror", (event) => { this.worker.addEventListener('messageerror', (event) => {
console.error("[PyodideWorkerClient] Worker message error:", event); console.error('[PyodideWorkerClient] Worker message error:', event);
this.state = "error"; this.state = 'error';
this.error = "Failed to deserialize worker message"; this.error = 'Failed to deserialize worker message';
}); });
} }
@ -266,7 +247,7 @@ class PatternConverterClient {
this.worker.terminate(); this.worker.terminate();
this.worker = null; this.worker = null;
} }
this.state = "not_loaded"; this.state = 'not_loaded';
this.error = null; this.error = null;
this.initPromise = null; this.initPromise = null;
this.progressCallbacks.clear(); this.progressCallbacks.clear();

View file

@ -8,10 +8,10 @@
*/ */
// Stitch type flags (bitmasks - can be combined) // Stitch type flags (bitmasks - can be combined)
export const STITCH = 0x00; // Regular stitch (no flags) export const STITCH = 0x00; // Regular stitch (no flags)
export const MOVE = 0x10; // Jump/move stitch (move without stitching) export const MOVE = 0x10; // Jump/move stitch (move without stitching)
export const JUMP = MOVE; // Alias: JUMP is the same as MOVE export const JUMP = MOVE; // Alias: JUMP is the same as MOVE
export const TRIM = 0x20; // Trim thread command export const TRIM = 0x20; // Trim thread command
export const COLOR_CHANGE = 0x40; // Color change command export const COLOR_CHANGE = 0x40; // Color change command
export const STOP = 0x80; // Stop command export const STOP = 0x80; // Stop command
export const END = 0x100; // End of pattern export const END = 0x100; // End of pattern

View file

@ -11,6 +11,7 @@ export async function convertPesToPen(file: File): Promise<PesPatternData> {
return await patternConverterClient.convertPesToPen(file); return await patternConverterClient.convertPesToPen(file);
} }
/** /**
* Get thread color from pattern data * Get thread color from pattern data
*/ */

View file

@ -0,0 +1,90 @@
import { loadPyodide, type PyodideInterface } from "pyodide";
export type PyodideState = "not_loaded" | "loading" | "ready" | "error";
class PyodideLoader {
private pyodide: PyodideInterface | null = null;
private state: PyodideState = "not_loaded";
private error: string | null = null;
private loadPromise: Promise<PyodideInterface> | null = null;
/**
* Get the current Pyodide state
*/
getState(): PyodideState {
return this.state;
}
/**
* Get the error message if state is 'error'
*/
getError(): string | null {
return this.error;
}
/**
* Initialize Pyodide and install PyStitch
*/
async initialize(): Promise<PyodideInterface> {
// If already ready, return immediately
if (this.state === "ready" && this.pyodide) {
return this.pyodide;
}
// If currently loading, wait for the existing promise
if (this.loadPromise) {
return this.loadPromise;
}
// Start loading
this.state = "loading";
this.error = null;
this.loadPromise = (async () => {
try {
console.log("[PyodideLoader] Loading Pyodide...");
// Load Pyodide with CDN indexURL for packages
// The core files will be loaded from our bundle, but packages will come from CDN
this.pyodide = await loadPyodide();
console.log("[PyodideLoader] Pyodide loaded, loading micropip...");
// Load micropip package
/*await this.pyodide.loadPackage('micropip');
console.log('[PyodideLoader] Installing PyStitch...');
// Install PyStitch using micropip
await this.pyodide.runPythonAsync(`
import micropip
await micropip.install('pystitch')
`);*/
await this.pyodide.loadPackage("pystitch-1.0.0-py3-none-any.whl");
console.log("[PyodideLoader] PyStitch installed successfully");
this.state = "ready";
return this.pyodide;
} catch (err) {
this.state = "error";
this.error =
err instanceof Error ? err.message : "Unknown error loading Pyodide";
console.error("[PyodideLoader] Error:", this.error);
throw err;
}
})();
return this.loadPromise;
}
/**
* Get the Pyodide instance (must be initialized first)
*/
getInstance(): PyodideInterface | null {
return this.pyodide;
}
}
// Export singleton instance
export const pyodideLoader = new PyodideLoader();

View file

@ -1,19 +1,24 @@
import { loadPyodide, type PyodideInterface } from "pyodide"; import { loadPyodide, type PyodideInterface } from 'pyodide';
import { STITCH, MOVE, TRIM, END } from "./constants"; import {
import { encodeStitchesToPen } from "../pen/encoder"; STITCH,
MOVE,
TRIM,
END,
} from './constants';
import { encodeStitchesToPen } from '../pen/encoder';
// Message types from main thread // Message types from main thread
export type WorkerMessage = export type WorkerMessage =
| { type: "INITIALIZE"; pyodideIndexURL?: string; pystitchWheelURL?: string } | { type: 'INITIALIZE'; pyodideIndexURL?: string; pystitchWheelURL?: string }
| { type: "CONVERT_PES"; fileData: ArrayBuffer; fileName: string }; | { type: 'CONVERT_PES'; fileData: ArrayBuffer; fileName: string };
// Response types to main thread // Response types to main thread
export type WorkerResponse = export type WorkerResponse =
| { type: "INIT_PROGRESS"; progress: number; step: string } | { type: 'INIT_PROGRESS'; progress: number; step: string }
| { type: "INIT_COMPLETE" } | { type: 'INIT_COMPLETE' }
| { type: "INIT_ERROR"; error: string } | { type: 'INIT_ERROR'; error: string }
| { | {
type: "CONVERT_COMPLETE"; type: 'CONVERT_COMPLETE';
data: { data: {
stitches: number[][]; stitches: number[][];
threads: Array<{ threads: Array<{
@ -44,9 +49,9 @@ export type WorkerResponse =
}; };
}; };
} }
| { type: "CONVERT_ERROR"; error: string }; | { type: 'CONVERT_ERROR'; error: string };
console.log("[PatternConverterWorker] Worker script loaded"); console.log('[PatternConverterWorker] Worker script loaded');
let pyodide: PyodideInterface | null = null; let pyodide: PyodideInterface | null = null;
let isInitializing = false; let isInitializing = false;
@ -62,82 +67,79 @@ const jsEmbConstants = {
/** /**
* Initialize Pyodide with progress tracking * Initialize Pyodide with progress tracking
*/ */
async function initializePyodide( async function initializePyodide(pyodideIndexURL?: string, pystitchWheelURL?: string) {
pyodideIndexURL?: string,
pystitchWheelURL?: string,
) {
if (pyodide) { if (pyodide) {
return; // Already initialized return; // Already initialized
} }
if (isInitializing) { if (isInitializing) {
throw new Error("Initialization already in progress"); throw new Error('Initialization already in progress');
} }
isInitializing = true; isInitializing = true;
try { try {
self.postMessage({ self.postMessage({
type: "INIT_PROGRESS", type: 'INIT_PROGRESS',
progress: 0, progress: 0,
step: "Starting initialization...", step: 'Starting initialization...',
} as WorkerResponse); } as WorkerResponse);
console.log("[PyodideWorker] Loading Pyodide runtime..."); console.log('[PyodideWorker] Loading Pyodide runtime...');
self.postMessage({ self.postMessage({
type: "INIT_PROGRESS", type: 'INIT_PROGRESS',
progress: 10, progress: 10,
step: "Loading Python runtime...", step: 'Loading Python runtime...',
} as WorkerResponse); } as WorkerResponse);
// Load Pyodide runtime // Load Pyodide runtime
// Use provided URL or default to /assets/ // Use provided URL or default to /assets/
const indexURL = pyodideIndexURL || "/assets/"; const indexURL = pyodideIndexURL || '/assets/';
console.log("[PyodideWorker] Pyodide index URL:", indexURL); console.log('[PyodideWorker] Pyodide index URL:', indexURL);
pyodide = await loadPyodide({ pyodide = await loadPyodide({
indexURL: indexURL, indexURL: indexURL,
}); });
console.log("[PyodideWorker] Pyodide runtime loaded"); console.log('[PyodideWorker] Pyodide runtime loaded');
self.postMessage({ self.postMessage({
type: "INIT_PROGRESS", type: 'INIT_PROGRESS',
progress: 70, progress: 70,
step: "Python runtime loaded", step: 'Python runtime loaded',
} as WorkerResponse); } as WorkerResponse);
self.postMessage({ self.postMessage({
type: "INIT_PROGRESS", type: 'INIT_PROGRESS',
progress: 75, progress: 75,
step: "Loading pystitch library...", step: 'Loading pystitch library...',
} as WorkerResponse); } as WorkerResponse);
// Load pystitch wheel // Load pystitch wheel
// Use provided URL or default // Use provided URL or default
const wheelURL = pystitchWheelURL || "/pystitch-1.0.0-py3-none-any.whl"; const wheelURL = pystitchWheelURL || '/pystitch-1.0.0-py3-none-any.whl';
console.log("[PyodideWorker] Pystitch wheel URL:", wheelURL); console.log('[PyodideWorker] Pystitch wheel URL:', wheelURL);
await pyodide.loadPackage(wheelURL); await pyodide.loadPackage(wheelURL);
console.log("[PyodideWorker] pystitch library loaded"); console.log('[PyodideWorker] pystitch library loaded');
self.postMessage({ self.postMessage({
type: "INIT_PROGRESS", type: 'INIT_PROGRESS',
progress: 100, progress: 100,
step: "Ready!", step: 'Ready!',
} as WorkerResponse); } as WorkerResponse);
self.postMessage({ self.postMessage({
type: "INIT_COMPLETE", type: 'INIT_COMPLETE',
} as WorkerResponse); } as WorkerResponse);
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : "Unknown error"; const errorMsg = err instanceof Error ? err.message : 'Unknown error';
console.error("[PyodideWorker] Initialization error:", err); console.error('[PyodideWorker] Initialization error:', err);
self.postMessage({ self.postMessage({
type: "INIT_ERROR", type: 'INIT_ERROR',
error: errorMsg, error: errorMsg,
} as WorkerResponse); } as WorkerResponse);
@ -152,18 +154,18 @@ async function initializePyodide(
*/ */
async function convertPesToPen(fileData: ArrayBuffer) { async function convertPesToPen(fileData: ArrayBuffer) {
if (!pyodide) { if (!pyodide) {
throw new Error("Pyodide not initialized"); throw new Error('Pyodide not initialized');
} }
try { try {
// Register our JavaScript constants module for Python to import // Register our JavaScript constants module for Python to import
pyodide.registerJsModule("js_emb_constants", jsEmbConstants); pyodide.registerJsModule('js_emb_constants', jsEmbConstants);
// Convert to Uint8Array // Convert to Uint8Array
const uint8Array = new Uint8Array(fileData); const uint8Array = new Uint8Array(fileData);
// Write file to Pyodide virtual filesystem // Write file to Pyodide virtual filesystem
const tempFileName = "/tmp/pattern.pes"; const tempFileName = '/tmp/pattern.pes';
pyodide.FS.writeFile(tempFileName, uint8Array); pyodide.FS.writeFile(tempFileName, uint8Array);
// Read the pattern using PyStitch (same logic as original converter) // Read the pattern using PyStitch (same logic as original converter)
@ -275,11 +277,11 @@ for i, stitch in enumerate(pattern.stitches):
// Extract stitches and validate // Extract stitches and validate
const stitches: number[][] = Array.from( const stitches: number[][] = Array.from(
data.stitches as ArrayLike<ArrayLike<number>>, data.stitches as ArrayLike<ArrayLike<number>>
).map((stitch) => Array.from(stitch)); ).map((stitch) => Array.from(stitch));
if (!stitches || stitches.length === 0) { if (!stitches || stitches.length === 0) {
throw new Error("Invalid PES file or no stitches found"); throw new Error('Invalid PES file or no stitches found');
} }
// Extract thread data - preserve null values for unavailable metadata // Extract thread data - preserve null values for unavailable metadata
@ -299,27 +301,27 @@ for i, stitch in enumerate(pattern.stitches):
catalogNum !== undefined && catalogNum !== undefined &&
catalogNum !== null && catalogNum !== null &&
catalogNum !== -1 && catalogNum !== -1 &&
catalogNum !== "-1" && catalogNum !== '-1' &&
catalogNum !== "" catalogNum !== ''
? String(catalogNum) ? String(catalogNum)
: null; : null;
return { return {
color: thread.color ?? 0, color: thread.color ?? 0,
hex: thread.hex || "#000000", hex: thread.hex || '#000000',
catalogNumber: normalizedCatalog, catalogNumber: normalizedCatalog,
brand: thread.brand && thread.brand !== "" ? thread.brand : null, brand: thread.brand && thread.brand !== '' ? thread.brand : null,
description: description:
thread.description && thread.description !== "" thread.description && thread.description !== ''
? thread.description ? thread.description
: null, : null,
chart: thread.chart && thread.chart !== "" ? thread.chart : null, chart: thread.chart && thread.chart !== '' ? thread.chart : null,
}; };
}); });
// Encode stitches to PEN format using the extracted encoder // Encode stitches to PEN format using the extracted encoder
console.log("[patternConverter] Encoding stitches to PEN format..."); console.log('[patternConverter] Encoding stitches to PEN format...');
console.log(" - Input stitches:", stitches); console.log(' - Input stitches:', stitches);
const { penBytes: penStitches, bounds } = encodeStitchesToPen(stitches); const { penBytes: penStitches, bounds } = encodeStitchesToPen(stitches);
const { minX, maxX, minY, maxY } = bounds; const { minX, maxX, minY, maxY } = bounds;
@ -350,13 +352,13 @@ for i, stitch in enumerate(pattern.stitches):
description: string | null; description: string | null;
chart: string | null; chart: string | null;
threadIndices: number[]; threadIndices: number[];
}>, }>
); );
// Calculate PEN stitch count (should match what machine will count) // Calculate PEN stitch count (should match what machine will count)
const penStitchCount = penStitches.length / 4; const penStitchCount = penStitches.length / 4;
console.log("[patternConverter] PEN encoding complete:"); console.log('[patternConverter] PEN encoding complete:');
console.log(` - PyStitch stitches: ${stitches.length}`); console.log(` - PyStitch stitches: ${stitches.length}`);
console.log(` - PEN bytes: ${penStitches.length}`); console.log(` - PEN bytes: ${penStitches.length}`);
console.log(` - PEN stitches (bytes/4): ${penStitchCount}`); console.log(` - PEN stitches (bytes/4): ${penStitchCount}`);
@ -364,7 +366,7 @@ for i, stitch in enumerate(pattern.stitches):
// Post result back to main thread // Post result back to main thread
self.postMessage({ self.postMessage({
type: "CONVERT_COMPLETE", type: 'CONVERT_COMPLETE',
data: { data: {
stitches, stitches,
threads, threads,
@ -381,11 +383,11 @@ for i, stitch in enumerate(pattern.stitches):
}, },
} as WorkerResponse); } as WorkerResponse);
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : "Unknown error"; const errorMsg = err instanceof Error ? err.message : 'Unknown error';
console.error("[PyodideWorker] Conversion error:", err); console.error('[PyodideWorker] Conversion error:', err);
self.postMessage({ self.postMessage({
type: "CONVERT_ERROR", type: 'CONVERT_ERROR',
error: errorMsg, error: errorMsg,
} as WorkerResponse); } as WorkerResponse);
@ -396,32 +398,26 @@ for i, stitch in enumerate(pattern.stitches):
// Handle messages from main thread // Handle messages from main thread
self.onmessage = async (event: MessageEvent<WorkerMessage>) => { self.onmessage = async (event: MessageEvent<WorkerMessage>) => {
const message = event.data; const message = event.data;
console.log("[PatternConverterWorker] Received message:", message.type); console.log('[PatternConverterWorker] Received message:', message.type);
try { try {
switch (message.type) { switch (message.type) {
case "INITIALIZE": case 'INITIALIZE':
console.log("[PatternConverterWorker] Starting initialization..."); console.log('[PatternConverterWorker] Starting initialization...');
await initializePyodide( await initializePyodide(message.pyodideIndexURL, message.pystitchWheelURL);
message.pyodideIndexURL,
message.pystitchWheelURL,
);
break; break;
case "CONVERT_PES": case 'CONVERT_PES':
console.log("[PatternConverterWorker] Starting PES conversion..."); console.log('[PatternConverterWorker] Starting PES conversion...');
await convertPesToPen(message.fileData); await convertPesToPen(message.fileData);
break; break;
default: default:
console.error( console.error('[PatternConverterWorker] Unknown message type:', message);
"[PatternConverterWorker] Unknown message type:",
message,
);
} }
} catch (err) { } catch (err) {
console.error("[PatternConverterWorker] Error handling message:", err); console.error('[PatternConverterWorker] Error handling message:', err);
} }
}; };
console.log("[PatternConverterWorker] Message handler registered"); console.log('[PatternConverterWorker] Message handler registered');

View file

@ -5,13 +5,13 @@
* The PEN format uses absolute coordinates shifted left by 3 bits, with flags in the low 3 bits. * The PEN format uses absolute coordinates shifted left by 3 bits, with flags in the low 3 bits.
*/ */
import type { DecodedPenStitch, DecodedPenData, PenColorBlock } from "./types"; import type { DecodedPenStitch, DecodedPenData, PenColorBlock } from './types';
// PEN format flags // PEN format flags
const PEN_FEED_DATA = 0x01; // Bit 0: Jump stitch (move without stitching) const PEN_FEED_DATA = 0x01; // Bit 0: Jump stitch (move without stitching)
const PEN_CUT_DATA = 0x02; // Bit 1: Trim/cut thread command const PEN_CUT_DATA = 0x02; // Bit 1: Trim/cut thread command
const PEN_COLOR_END = 0x03; // Last stitch before color change const PEN_COLOR_END = 0x03; // Last stitch before color change
const PEN_DATA_END = 0x05; // Last stitch of entire pattern const PEN_DATA_END = 0x05; // Last stitch of entire pattern
/** /**
* Decode a single PEN stitch (4 bytes) into coordinates and flags * Decode a single PEN stitch (4 bytes) into coordinates and flags
@ -22,7 +22,7 @@ const PEN_DATA_END = 0x05; // Last stitch of entire pattern
*/ */
export function decodePenStitch( export function decodePenStitch(
bytes: Uint8Array | number[], bytes: Uint8Array | number[],
offset: number, offset: number
): DecodedPenStitch { ): DecodedPenStitch {
const xLow = bytes[offset]; const xLow = bytes[offset];
const xHigh = bytes[offset + 1]; const xHigh = bytes[offset + 1];
@ -37,14 +37,14 @@ export function decodePenStitch(
const yFlags = yRaw & 0x07; const yFlags = yRaw & 0x07;
// Clear flags and shift right to get actual coordinates // Clear flags and shift right to get actual coordinates
const xClean = xRaw & 0xfff8; const xClean = xRaw & 0xFFF8;
const yClean = yRaw & 0xfff8; const yClean = yRaw & 0xFFF8;
// Convert to signed 16-bit // Convert to signed 16-bit
let xSigned = xClean; let xSigned = xClean;
let ySigned = yClean; let ySigned = yClean;
if (xSigned > 0x7fff) xSigned = xSigned - 0x10000; if (xSigned > 0x7FFF) xSigned = xSigned - 0x10000;
if (ySigned > 0x7fff) ySigned = ySigned - 0x10000; if (ySigned > 0x7FFF) ySigned = ySigned - 0x10000;
// Shift right by 3 to get actual coordinates // Shift right by 3 to get actual coordinates
const x = xSigned >> 3; const x = xSigned >> 3;
@ -76,13 +76,9 @@ export function decodePenStitch(
* @param bytes PEN format byte array * @param bytes PEN format byte array
* @returns Array of decoded stitches * @returns Array of decoded stitches
*/ */
export function decodeAllPenStitches( export function decodeAllPenStitches(bytes: Uint8Array | number[]): DecodedPenStitch[] {
bytes: Uint8Array | number[],
): DecodedPenStitch[] {
if (bytes.length < 4 || bytes.length % 4 !== 0) { if (bytes.length < 4 || bytes.length % 4 !== 0) {
throw new Error( throw new Error(`Invalid PEN data size: ${bytes.length} bytes (must be multiple of 4)`);
`Invalid PEN data size: ${bytes.length} bytes (must be multiple of 4)`,
);
} }
const stitches: DecodedPenStitch[] = []; const stitches: DecodedPenStitch[] = [];
@ -173,15 +169,9 @@ export function decodePenData(data: Uint8Array): DecodedPenData {
* @param stitchIndex Index of the stitch * @param stitchIndex Index of the stitch
* @returns Color index, or -1 if not found * @returns Color index, or -1 if not found
*/ */
export function getStitchColor( export function getStitchColor(penData: DecodedPenData, stitchIndex: number): number {
penData: DecodedPenData,
stitchIndex: number,
): number {
for (const block of penData.colorBlocks) { for (const block of penData.colorBlocks) {
if ( if (stitchIndex >= block.startStitchIndex && stitchIndex <= block.endStitchIndex) {
stitchIndex >= block.startStitchIndex &&
stitchIndex <= block.endStitchIndex
) {
return block.colorIndex; return block.colorIndex;
} }
} }

View file

@ -1,51 +1,51 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from 'vitest';
import { import {
encodeStitchPosition, encodeStitchPosition,
calculateLockDirection, calculateLockDirection,
generateLockStitches, generateLockStitches,
encodeStitchesToPen, encodeStitchesToPen,
LOCK_STITCH_JUMP_SIZE, LOCK_STITCH_JUMP_SIZE
} from "./encoder"; } from './encoder';
import { decodeAllPenStitches } from "./decoder"; import { decodeAllPenStitches } from './decoder';
import { STITCH, MOVE, TRIM, END } from "../import/constants"; import { STITCH, MOVE, TRIM, END } from '../import/constants';
// PEN format flag constants for testing // PEN format flag constants for testing
const PEN_FEED_DATA = 0x01; const PEN_FEED_DATA = 0x01;
const PEN_CUT_DATA = 0x02; const PEN_CUT_DATA = 0x02;
describe("encodeStitchPosition", () => { describe('encodeStitchPosition', () => {
it("should encode position (0, 0) correctly", () => { it('should encode position (0, 0) correctly', () => {
const result = encodeStitchPosition(0, 0); const result = encodeStitchPosition(0, 0);
expect(result).toEqual([0x00, 0x00, 0x00, 0x00]); expect(result).toEqual([0x00, 0x00, 0x00, 0x00]);
}); });
it("should shift coordinates left by 3 bits", () => { it('should shift coordinates left by 3 bits', () => {
// Position (1, 1) should become (8, 8) after shifting // Position (1, 1) should become (8, 8) after shifting
const result = encodeStitchPosition(1, 1); const result = encodeStitchPosition(1, 1);
expect(result).toEqual([0x08, 0x00, 0x08, 0x00]); expect(result).toEqual([0x08, 0x00, 0x08, 0x00]);
}); });
it("should handle negative coordinates", () => { it('should handle negative coordinates', () => {
// -1 in 16-bit signed = 0xFFFF, shifted left 3 = 0xFFF8 // -1 in 16-bit signed = 0xFFFF, shifted left 3 = 0xFFF8
const result = encodeStitchPosition(-1, -1); const result = encodeStitchPosition(-1, -1);
expect(result).toEqual([0xf8, 0xff, 0xf8, 0xff]); expect(result).toEqual([0xF8, 0xFF, 0xF8, 0xFF]);
}); });
it("should encode multi-byte coordinates correctly", () => { it('should encode multi-byte coordinates correctly', () => {
// Position (128, 0) -> shifted = 1024 = 0x0400 // Position (128, 0) -> shifted = 1024 = 0x0400
const result = encodeStitchPosition(128, 0); const result = encodeStitchPosition(128, 0);
expect(result).toEqual([0x00, 0x04, 0x00, 0x00]); expect(result).toEqual([0x00, 0x04, 0x00, 0x00]);
}); });
it("should round fractional coordinates", () => { it('should round fractional coordinates', () => {
const result = encodeStitchPosition(1.5, 2.4); const result = encodeStitchPosition(1.5, 2.4);
// 2 << 3 = 16, 2 << 3 = 16 // 2 << 3 = 16, 2 << 3 = 16
expect(result).toEqual([0x10, 0x00, 0x10, 0x00]); expect(result).toEqual([0x10, 0x00, 0x10, 0x00]);
}); });
}); });
describe("calculateLockDirection", () => { describe('calculateLockDirection', () => {
it("should look ahead for forward direction", () => { it('should look ahead for forward direction', () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[10, 0, STITCH, 0], [10, 0, STITCH, 0],
@ -62,7 +62,7 @@ describe("calculateLockDirection", () => {
expect(magnitude).toBeCloseTo(8.0, 1); expect(magnitude).toBeCloseTo(8.0, 1);
}); });
it("should look backward for backward direction", () => { it('should look backward for backward direction', () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[10, 0, STITCH, 0], [10, 0, STITCH, 0],
@ -79,10 +79,10 @@ describe("calculateLockDirection", () => {
expect(magnitude).toBeCloseTo(8.0, 1); expect(magnitude).toBeCloseTo(8.0, 1);
}); });
it("should skip MOVE stitches when accumulating", () => { it('should skip MOVE stitches when accumulating', () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[5, 0, MOVE, 0], // Should be skipped [5, 0, MOVE, 0], // Should be skipped
[10, 0, STITCH, 0], [10, 0, STITCH, 0],
[15, 0, STITCH, 0], [15, 0, STITCH, 0],
]; ];
@ -93,8 +93,10 @@ describe("calculateLockDirection", () => {
expect(result.dirX).toBeGreaterThan(0); expect(result.dirX).toBeGreaterThan(0);
}); });
it("should return fallback diagonal for empty or short stitch sequences", () => { it('should return fallback diagonal for empty or short stitch sequences', () => {
const stitches = [[0, 0, STITCH, 0]]; const stitches = [
[0, 0, STITCH, 0],
];
const result = calculateLockDirection(stitches, 0, true); const result = calculateLockDirection(stitches, 0, true);
@ -104,7 +106,7 @@ describe("calculateLockDirection", () => {
expect(result.dirY).toBeCloseTo(expectedMag, 1); expect(result.dirY).toBeCloseTo(expectedMag, 1);
}); });
it("should normalize accumulated vector to magnitude 8.0", () => { it('should normalize accumulated vector to magnitude 8.0', () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[3, 4, STITCH, 0], // Distance = 5 [3, 4, STITCH, 0], // Distance = 5
@ -122,7 +124,7 @@ describe("calculateLockDirection", () => {
expect(magnitude).toBeCloseTo(8.0, 1); expect(magnitude).toBeCloseTo(8.0, 1);
}); });
it("should stop accumulating after reaching target length", () => { it('should stop accumulating after reaching target length', () => {
// Create a long chain of stitches // Create a long chain of stitches
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
@ -142,13 +144,13 @@ describe("calculateLockDirection", () => {
}); });
}); });
describe("generateLockStitches", () => { describe('generateLockStitches', () => {
it("should generate 8 lock stitches (32 bytes)", () => { it('should generate 8 lock stitches (32 bytes)', () => {
const result = generateLockStitches(0, 0, 8.0, 0); const result = generateLockStitches(0, 0, 8.0, 0);
expect(result.length).toBe(32); // 8 stitches * 4 bytes each expect(result.length).toBe(32); // 8 stitches * 4 bytes each
}); });
it("should alternate between +dir and -dir", () => { it('should alternate between +dir and -dir', () => {
const result = generateLockStitches(0, 0, 8.0, 0); const result = generateLockStitches(0, 0, 8.0, 0);
expect(result.length).toBe(32); // 8 stitches * 4 bytes expect(result.length).toBe(32); // 8 stitches * 4 bytes
@ -157,7 +159,7 @@ describe("generateLockStitches", () => {
expect(result2.length).toBe(32); expect(result2.length).toBe(32);
}); });
it("should rotate stitches in the given direction", () => { it('should rotate stitches in the given direction', () => {
// Direction pointing right (8, 0) // Direction pointing right (8, 0)
const result = generateLockStitches(0, 0, 8.0, 0); const result = generateLockStitches(0, 0, 8.0, 0);
@ -174,8 +176,8 @@ describe("generateLockStitches", () => {
}); });
}); });
describe("encodeStitchesToPen", () => { describe('encodeStitchesToPen', () => {
it("should encode a simple stitch sequence", () => { it('should encode a simple stitch sequence', () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[10, 0, STITCH, 0], [10, 0, STITCH, 0],
@ -190,7 +192,7 @@ describe("encodeStitchesToPen", () => {
expect(result.bounds.maxX).toBe(20); expect(result.bounds.maxX).toBe(20);
}); });
it("should track bounds correctly", () => { it('should track bounds correctly', () => {
const stitches = [ const stitches = [
[10, 20, STITCH, 0], [10, 20, STITCH, 0],
[-5, 30, STITCH, 0], [-5, 30, STITCH, 0],
@ -206,7 +208,7 @@ describe("encodeStitchesToPen", () => {
expect(result.bounds.maxY).toBe(30); expect(result.bounds.maxY).toBe(30);
}); });
it("should mark the last stitch with DATA_END flag", () => { it('should mark the last stitch with DATA_END flag', () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[10, 0, END, 0], [10, 0, END, 0],
@ -220,14 +222,14 @@ describe("encodeStitchesToPen", () => {
expect(xLow & 0x07).toBe(0x05); // DATA_END flag expect(xLow & 0x07).toBe(0x05); // DATA_END flag
}); });
it("should handle color changes with lock stitches", () => { it('should handle color changes with lock stitches', () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], // Color 0 [0, 0, STITCH, 0], // Color 0
[10, 0, STITCH, 0], // Color 0 [10, 0, STITCH, 0], // Color 0
[20, 0, STITCH, 0], // Color 0 - last stitch before color change [20, 0, STITCH, 0], // Color 0 - last stitch before color change
[20, 0, STITCH, 1], // Color 1 - first stitch of new color [20, 0, STITCH, 1], // Color 1 - first stitch of new color
[30, 0, STITCH, 1], // Color 1 [30, 0, STITCH, 1], // Color 1
[40, 0, END, 1], // Color 1 - last stitch [40, 0, END, 1], // Color 1 - last stitch
]; ];
const result = encodeStitchesToPen(stitches); const result = encodeStitchesToPen(stitches);
@ -244,13 +246,13 @@ describe("encodeStitchesToPen", () => {
expect(result.penBytes.length).toBeGreaterThan(90); // Should have many bytes from lock stitches expect(result.penBytes.length).toBeGreaterThan(90); // Should have many bytes from lock stitches
}); });
it("should encode color change sequence in correct order", () => { it('should encode color change sequence in correct order', () => {
// Test the exact sequence of operations for a color change // Test the exact sequence of operations for a color change
const stitches = [ const stitches = [
[0, 0, STITCH, 0], // Color 0 [0, 0, STITCH, 0], // Color 0
[10, 0, STITCH, 0], // Color 0 - last stitch before color change [10, 0, STITCH, 0], // Color 0 - last stitch before color change
[10, 0, STITCH, 1], // Color 1 - first stitch (same position) [10, 0, STITCH, 1], // Color 1 - first stitch (same position)
[20, 0, STITCH | END, 1], // Color 1 - last stitch [20, 0, STITCH | END, 1], // Color 1 - last stitch
]; ];
const result = encodeStitchesToPen(stitches); const result = encodeStitchesToPen(stitches);
@ -323,11 +325,11 @@ describe("encodeStitchesToPen", () => {
expect(decoded[idx].isDataEnd).toBe(true); expect(decoded[idx].isDataEnd).toBe(true);
}); });
it("should encode color change with jump in correct order", () => { it('should encode color change with jump in correct order', () => {
// Test color change when next color is at a different position // Test color change when next color is at a different position
const stitches = [ const stitches = [
[0, 0, STITCH, 0], // Color 0 [0, 0, STITCH, 0], // Color 0
[10, 0, STITCH, 0], // Color 0 - last before change [10, 0, STITCH, 0], // Color 0 - last before change
[30, 10, STITCH, 1], // Color 1 - different position, requires jump [30, 10, STITCH, 1], // Color 1 - different position, requires jump
[40, 10, STITCH | END, 1], [40, 10, STITCH | END, 1],
]; ];
@ -370,13 +372,13 @@ describe("encodeStitchesToPen", () => {
expect(decoded[idx].y).toBe(10); expect(decoded[idx].y).toBe(10);
}); });
it("should encode color change followed by explicit JUMP in correct order", () => { it('should encode color change followed by explicit JUMP in correct order', () => {
// Test when PES data has a JUMP stitch immediately after color change // Test when PES data has a JUMP stitch immediately after color change
// This is a common pattern: color change, then jump to new location // This is a common pattern: color change, then jump to new location
const stitches = [ const stitches = [
[0, 0, STITCH, 0], // Color 0 [0, 0, STITCH, 0], // Color 0
[10, 0, STITCH, 0], // Color 0 - last before change [10, 0, STITCH, 0], // Color 0 - last before change
[50, 20, MOVE, 1], // Color 1 - JUMP to new location (50, 20) [50, 20, MOVE, 1], // Color 1 - JUMP to new location (50, 20)
[50, 20, STITCH, 1], // Color 1 - first actual stitch at new location [50, 20, STITCH, 1], // Color 1 - first actual stitch at new location
[60, 20, STITCH | END, 1], [60, 20, STITCH | END, 1],
]; ];
@ -441,12 +443,12 @@ describe("encodeStitchesToPen", () => {
expect(decoded[idx].isDataEnd).toBe(true); expect(decoded[idx].isDataEnd).toBe(true);
}); });
it("should handle long jumps with lock stitches and cut in correct order", () => { it('should handle long jumps with lock stitches and cut in correct order', () => {
// Test the exact sequence for a long jump (distance > 50) // Test the exact sequence for a long jump (distance > 50)
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[10, 0, STITCH, 0], [10, 0, STITCH, 0],
[100, 0, MOVE, 0], // Long jump (distance = 90 > 50) [100, 0, MOVE, 0], // Long jump (distance = 90 > 50)
[110, 0, STITCH, 0], [110, 0, STITCH, 0],
[120, 0, STITCH | END, 0], [120, 0, STITCH | END, 0],
]; ];
@ -463,7 +465,7 @@ describe("encodeStitchesToPen", () => {
// 6. Stitch at (110, 0) // 6. Stitch at (110, 0)
// 7. Stitch at (120, 0) with END flag // 7. Stitch at (120, 0) with END flag
let idx = 0; let idx = 0;
// 1-2. First two stitches // 1-2. First two stitches
expect(decoded[idx++].x).toBe(0); expect(decoded[idx++].x).toBe(0);
@ -506,10 +508,10 @@ describe("encodeStitchesToPen", () => {
expect(decoded[idx].isDataEnd).toBe(true); expect(decoded[idx].isDataEnd).toBe(true);
}); });
it("should encode MOVE flag for jump stitches", () => { it('should encode MOVE flag for jump stitches', () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[10, 0, MOVE, 0], // Short jump (no lock stitches) [10, 0, MOVE, 0], // Short jump (no lock stitches)
[20, 0, END, 0], [20, 0, END, 0],
]; ];
@ -523,10 +525,10 @@ describe("encodeStitchesToPen", () => {
expect(yLow & 0x01).toBe(0x01); // FEED_DATA flag expect(yLow & 0x01).toBe(0x01); // FEED_DATA flag
}); });
it("should not include MOVE stitches in bounds calculation", () => { it('should not include MOVE stitches in bounds calculation', () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[100, 100, MOVE, 0], // Jump - should not affect bounds [100, 100, MOVE, 0], // Jump - should not affect bounds
[10, 10, STITCH, 0], [10, 10, STITCH, 0],
[20, 20, STITCH | END, 0], // Last stitch with both STITCH and END flags [20, 20, STITCH | END, 0], // Last stitch with both STITCH and END flags
]; ];
@ -540,7 +542,7 @@ describe("encodeStitchesToPen", () => {
expect(result.bounds.maxY).toBe(20); expect(result.bounds.maxY).toBe(20);
}); });
it("should handle TRIM flag correctly", () => { it('should handle TRIM flag correctly', () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[10, 0, TRIM, 0], [10, 0, TRIM, 0],
@ -550,7 +552,7 @@ describe("encodeStitchesToPen", () => {
const result = encodeStitchesToPen(stitches); const result = encodeStitchesToPen(stitches);
const decoded = decodeAllPenStitches(result.penBytes); const decoded = decodeAllPenStitches(result.penBytes);
let idx = 0; let idx = 0;
// Verify sequence: // Verify sequence:
// 1. Regular stitch at (0, 0) // 1. Regular stitch at (0, 0)
@ -572,7 +574,7 @@ describe("encodeStitchesToPen", () => {
expect(decoded[idx].isDataEnd).toBe(true); expect(decoded[idx].isDataEnd).toBe(true);
}); });
it("should handle empty stitch array", () => { it('should handle empty stitch array', () => {
const stitches: number[][] = []; const stitches: number[][] = [];
const result = encodeStitchesToPen(stitches); const result = encodeStitchesToPen(stitches);
@ -584,8 +586,10 @@ describe("encodeStitchesToPen", () => {
expect(result.bounds.maxY).toBe(0); expect(result.bounds.maxY).toBe(0);
}); });
it("should handle single stitch", () => { it('should handle single stitch', () => {
const stitches = [[5, 10, END, 0]]; const stitches = [
[5, 10, END, 0],
];
const result = encodeStitchesToPen(stitches); const result = encodeStitchesToPen(stitches);
@ -597,7 +601,7 @@ describe("encodeStitchesToPen", () => {
// END stitches update bounds (they're not MOVE stitches) // END stitches update bounds (they're not MOVE stitches)
}); });
it("should add DATA_END flag to last stitch even without END flag in input", () => { it('should add DATA_END flag to last stitch even without END flag in input', () => {
// Test that the encoder automatically marks the last stitch with DATA_END // Test that the encoder automatically marks the last stitch with DATA_END
// even if the input stitches don't have an END flag // even if the input stitches don't have an END flag
const stitches = [ const stitches = [
@ -619,7 +623,7 @@ describe("encodeStitchesToPen", () => {
expect(decoded[10].y).toBe(0); expect(decoded[10].y).toBe(0);
}); });
it("should add DATA_END flag when input has explicit END flag", () => { it('should add DATA_END flag when input has explicit END flag', () => {
// Verify that END flag in input also results in DATA_END flag in output // Verify that END flag in input also results in DATA_END flag in output
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
@ -636,7 +640,7 @@ describe("encodeStitchesToPen", () => {
expect(decoded[10].y).toBe(0); expect(decoded[10].y).toBe(0);
}); });
it("should add lock stitches at the very start of the pattern", () => { it('should add lock stitches at the very start of the pattern', () => {
// Matching C# behavior: Nuihajime_TomeDataPlus is called when counter <= 2 // Matching C# behavior: Nuihajime_TomeDataPlus is called when counter <= 2
// This adds starting lock stitches to secure the thread at pattern start // This adds starting lock stitches to secure the thread at pattern start
const stitches = [ const stitches = [

View file

@ -5,18 +5,18 @@
* The PEN format uses absolute coordinates shifted left by 3 bits, with flags in the low 3 bits. * The PEN format uses absolute coordinates shifted left by 3 bits, with flags in the low 3 bits.
*/ */
import { MOVE, TRIM, END } from "../import/constants"; import { MOVE, TRIM, END } from '../import/constants';
// PEN format flags for Brother machines // PEN format flags for Brother machines
const PEN_FEED_DATA = 0x01; // Bit 0: Jump stitch (move without stitching) const PEN_FEED_DATA = 0x01; // Bit 0: Jump stitch (move without stitching)
const PEN_CUT_DATA = 0x02; // Bit 1: Trim/cut thread command const PEN_CUT_DATA = 0x02; // Bit 1: Trim/cut thread command
const PEN_COLOR_END = 0x03; // Last stitch before color change const PEN_COLOR_END = 0x03; // Last stitch before color change
const PEN_DATA_END = 0x05; // Last stitch of entire pattern const PEN_DATA_END = 0x05; // Last stitch of entire pattern
// Constants from PesxToPen.cs // Constants from PesxToPen.cs
const FEED_LENGTH = 50; // Long jump threshold requiring lock stitches and cut const FEED_LENGTH = 50; // Long jump threshold requiring lock stitches and cut
const TARGET_LENGTH = 8.0; // Target accumulated length for lock stitch direction const TARGET_LENGTH = 8.0; // Target accumulated length for lock stitch direction
const MAX_POINTS = 5; // Maximum points to accumulate for lock stitch direction const MAX_POINTS = 5; // Maximum points to accumulate for lock stitch direction
export const LOCK_STITCH_JUMP_SIZE = 2.0; export const LOCK_STITCH_JUMP_SIZE = 2.0;
const LOCK_STITCH_SCALE = LOCK_STITCH_JUMP_SIZE / 8.0; // Scale the magnitude-8 vector down to 4 const LOCK_STITCH_SCALE = LOCK_STITCH_JUMP_SIZE / 8.0; // Scale the magnitude-8 vector down to 4
@ -45,7 +45,12 @@ export function encodeStitchPosition(x: number, y: number): number[] {
const xEnc = (Math.round(x) << 3) & 0xffff; const xEnc = (Math.round(x) << 3) & 0xffff;
const yEnc = (Math.round(y) << 3) & 0xffff; const yEnc = (Math.round(y) << 3) & 0xffff;
return [xEnc & 0xff, (xEnc >> 8) & 0xff, yEnc & 0xff, (yEnc >> 8) & 0xff]; return [
xEnc & 0xff,
(xEnc >> 8) & 0xff,
yEnc & 0xff,
(yEnc >> 8) & 0xff
];
} }
/** /**
@ -65,7 +70,7 @@ export function encodeStitchPosition(x: number, y: number): number[] {
export function calculateLockDirection( export function calculateLockDirection(
stitches: number[][], stitches: number[][],
currentIndex: number, currentIndex: number,
lookAhead: boolean, lookAhead: boolean
): { dirX: number; dirY: number } { ): { dirX: number; dirY: number } {
let accumulatedX = 0; let accumulatedX = 0;
let accumulatedY = 0; let accumulatedY = 0;
@ -79,7 +84,7 @@ export function calculateLockDirection(
: Math.min(MAX_POINTS, currentIndex); : Math.min(MAX_POINTS, currentIndex);
for (let i = 0; i < maxIterations; i++) { for (let i = 0; i < maxIterations; i++) {
const idx = currentIndex + step * (i + 1); const idx = currentIndex + (step * (i + 1));
if (idx < 0 || idx >= stitches.length) break; if (idx < 0 || idx >= stitches.length) break;
const stitch = stitches[idx]; const stitch = stitches[idx];
@ -89,17 +94,13 @@ export function calculateLockDirection(
if ((cmd & MOVE) !== 0) continue; if ((cmd & MOVE) !== 0) continue;
// Accumulate relative coordinates // Accumulate relative coordinates
const deltaX = const deltaX = Math.round(stitch[0]) - Math.round(stitches[currentIndex][0]);
Math.round(stitch[0]) - Math.round(stitches[currentIndex][0]); const deltaY = Math.round(stitch[1]) - Math.round(stitches[currentIndex][1]);
const deltaY =
Math.round(stitch[1]) - Math.round(stitches[currentIndex][1]);
accumulatedX += deltaX; accumulatedX += deltaX;
accumulatedY += deltaY; accumulatedY += deltaY;
const length = Math.sqrt( const length = Math.sqrt(accumulatedX * accumulatedX + accumulatedY * accumulatedY);
accumulatedX * accumulatedX + accumulatedY * accumulatedY,
);
// Track the maximum length vector seen so far // Track the maximum length vector seen so far
if (length > maxLength) { if (length > maxLength) {
@ -112,7 +113,7 @@ export function calculateLockDirection(
if (length >= TARGET_LENGTH) { if (length >= TARGET_LENGTH) {
return { return {
dirX: (accumulatedX * 8.0) / length, dirX: (accumulatedX * 8.0) / length,
dirY: (accumulatedY * 8.0) / length, dirY: (accumulatedY * 8.0) / length
}; };
} }
} }
@ -121,7 +122,7 @@ export function calculateLockDirection(
if (maxLength > 0.1) { if (maxLength > 0.1) {
return { return {
dirX: (bestX * 8.0) / maxLength, dirX: (bestX * 8.0) / maxLength,
dirY: (bestY * 8.0) / maxLength, dirY: (bestY * 8.0) / maxLength
}; };
} }
@ -139,12 +140,7 @@ export function calculateLockDirection(
* @param dirY Direction Y component (magnitude ~8.0) * @param dirY Direction Y component (magnitude ~8.0)
* @returns Array of PEN bytes for lock stitches (32 bytes = 8 stitches * 4 bytes) * @returns Array of PEN bytes for lock stitches (32 bytes = 8 stitches * 4 bytes)
*/ */
export function generateLockStitches( export function generateLockStitches(x: number, y: number, dirX: number, dirY: number): number[] {
x: number,
y: number,
dirX: number,
dirY: number,
): number[] {
const lockBytes: number[] = []; const lockBytes: number[] = [];
// Generate 8 lock stitches in alternating pattern // Generate 8 lock stitches in alternating pattern
@ -157,7 +153,7 @@ export function generateLockStitches(
// Generate 8 stitches alternating between forward and backward // Generate 8 stitches alternating between forward and backward
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
// Alternate between forward (+) and backward (-) direction // Alternate between forward (+) and backward (-) direction
const sign = i % 2 === 0 ? 1 : -1; const sign = (i % 2 === 0) ? 1 : -1;
const xAdd = scaledDirX * sign; const xAdd = scaledDirX * sign;
const yAdd = scaledDirY * sign; const yAdd = scaledDirY * sign;
lockBytes.push(...encodeStitchPosition(x + xAdd, y + yAdd)); lockBytes.push(...encodeStitchPosition(x + xAdd, y + yAdd));
@ -184,6 +180,7 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
// Track position for calculating jump distances // Track position for calculating jump distances
let prevX = 0; let prevX = 0;
let prevY = 0; let prevY = 0;
for (let i = 0; i < stitches.length; i++) { for (let i = 0; i < stitches.length; i++) {
const stitch = stitches[i]; const stitch = stitches[i];
@ -212,30 +209,26 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
// Loop B: End/Cut Vector - Look BACKWARD at previous stitches // Loop B: End/Cut Vector - Look BACKWARD at previous stitches
// This hides the knot inside the embroidery we just finished // This hides the knot inside the embroidery we just finished
const finishDir = calculateLockDirection(stitches, i - 1, false); const finishDir = calculateLockDirection(stitches, i - 1, false);
penStitches.push( penStitches.push(...generateLockStitches(prevX, prevY, finishDir.dirX, finishDir.dirY));
...generateLockStitches(prevX, prevY, finishDir.dirX, finishDir.dirY),
);
// Encode jump with both FEED and CUT flags // Encode jump with both FEED and CUT flags
const xEncoded = (absX << 3) & 0xffff; const xEncoded = (absX << 3) & 0xffff;
let yEncoded = (absY << 3) & 0xffff; let yEncoded = (absY << 3) & 0xffff;
yEncoded |= PEN_FEED_DATA; // Jump flag yEncoded |= PEN_FEED_DATA; // Jump flag
yEncoded |= PEN_CUT_DATA; // Cut flag for long jumps yEncoded |= PEN_CUT_DATA; // Cut flag for long jumps
penStitches.push( penStitches.push(
xEncoded & 0xff, xEncoded & 0xff,
(xEncoded >> 8) & 0xff, (xEncoded >> 8) & 0xff,
yEncoded & 0xff, yEncoded & 0xff,
(yEncoded >> 8) & 0xff, (yEncoded >> 8) & 0xff
); );
// Add starting lock stitches at new position // Add starting lock stitches at new position
// Loop A: Jump/Entry Vector - Look FORWARD at upcoming stitches // Loop A: Jump/Entry Vector - Look FORWARD at upcoming stitches
// This hides the knot under the stitches we're about to make // This hides the knot under the stitches we're about to make
const startDir = calculateLockDirection(stitches, i, true); const startDir = calculateLockDirection(stitches, i, true);
penStitches.push( penStitches.push(...generateLockStitches(absX, absY, startDir.dirX, startDir.dirY));
...generateLockStitches(absX, absY, startDir.dirX, startDir.dirY),
);
// Update position and continue // Update position and continue
prevX = absX; prevX = absX;
@ -265,10 +258,7 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
// Check for color change by comparing stitch color index // Check for color change by comparing stitch color index
const nextStitch = stitches[i + 1]; const nextStitch = stitches[i + 1];
const nextStitchColor = nextStitch?.[3]; const nextStitchColor = nextStitch?.[3];
const isColorChange = const isColorChange = !isLastStitch && nextStitchColor !== undefined && nextStitchColor !== stitchColor;
!isLastStitch &&
nextStitchColor !== undefined &&
nextStitchColor !== stitchColor;
// Mark the very last stitch of the pattern with DATA_END // Mark the very last stitch of the pattern with DATA_END
if (isLastStitch) { if (isLastStitch) {
@ -280,7 +270,7 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
xEncoded & 0xff, xEncoded & 0xff,
(xEncoded >> 8) & 0xff, (xEncoded >> 8) & 0xff,
yEncoded & 0xff, yEncoded & 0xff,
(yEncoded >> 8) & 0xff, (yEncoded >> 8) & 0xff
); );
// Update position for next iteration // Update position for next iteration
@ -293,9 +283,7 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
// Calculate direction for starting locks (look forward into the pattern) // Calculate direction for starting locks (look forward into the pattern)
const startDir = calculateLockDirection(stitches, i, true); const startDir = calculateLockDirection(stitches, i, true);
penStitches.push( penStitches.push(...generateLockStitches(absX, absY, startDir.dirX, startDir.dirY));
...generateLockStitches(absX, absY, startDir.dirX, startDir.dirY),
);
} }
// Handle color change: finishing lock, COLOR_END+CUT, jump, starting lock // Handle color change: finishing lock, COLOR_END+CUT, jump, starting lock
@ -309,9 +297,7 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
// Loop C: Color Change Vector - Look FORWARD at the stop event data // Loop C: Color Change Vector - Look FORWARD at the stop event data
// This aligns the knot with the stop command's data block for correct tension // This aligns the knot with the stop command's data block for correct tension
const finishDir = calculateLockDirection(stitches, i, true); const finishDir = calculateLockDirection(stitches, i, true);
penStitches.push( penStitches.push(...generateLockStitches(absX, absY, finishDir.dirX, finishDir.dirY));
...generateLockStitches(absX, absY, finishDir.dirX, finishDir.dirY),
);
// Step 2: Add COLOR_END + CUT command at CURRENT position (same stitch!) // Step 2: Add COLOR_END + CUT command at CURRENT position (same stitch!)
// This is where the machine pauses and waits for the user to change thread color // This is where the machine pauses and waits for the user to change thread color
@ -327,7 +313,7 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
colorEndCutXEncoded & 0xff, colorEndCutXEncoded & 0xff,
(colorEndCutXEncoded >> 8) & 0xff, (colorEndCutXEncoded >> 8) & 0xff,
colorEndCutYEncoded & 0xff, colorEndCutYEncoded & 0xff,
(colorEndCutYEncoded >> 8) & 0xff, (colorEndCutYEncoded >> 8) & 0xff
); );
// Machine pauses here for color change // Machine pauses here for color change
@ -353,7 +339,7 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
jumpXEncoded & 0xff, jumpXEncoded & 0xff,
(jumpXEncoded >> 8) & 0xff, (jumpXEncoded >> 8) & 0xff,
jumpYEncoded & 0xff, jumpYEncoded & 0xff,
(jumpYEncoded >> 8) & 0xff, (jumpYEncoded >> 8) & 0xff
); );
} }
@ -361,14 +347,8 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
// Loop A: Jump/Entry Vector - Look FORWARD at upcoming stitches in new color // Loop A: Jump/Entry Vector - Look FORWARD at upcoming stitches in new color
// This hides the knot under the stitches we're about to make // This hides the knot under the stitches we're about to make
const nextStitchIdx = nextIsJump ? i + 2 : i + 1; const nextStitchIdx = nextIsJump ? i + 2 : i + 1;
const startDir = calculateLockDirection( const startDir = calculateLockDirection(stitches, nextStitchIdx < stitches.length ? nextStitchIdx : i, true);
stitches, penStitches.push(...generateLockStitches(jumpToX, jumpToY, startDir.dirX, startDir.dirY));
nextStitchIdx < stitches.length ? nextStitchIdx : i,
true,
);
penStitches.push(
...generateLockStitches(jumpToX, jumpToY, startDir.dirX, startDir.dirY),
);
// Update position // Update position
prevX = jumpToX; prevX = jumpToX;

View file

@ -9,18 +9,18 @@
* A single decoded PEN stitch with coordinates and flags * A single decoded PEN stitch with coordinates and flags
*/ */
export interface DecodedPenStitch { export interface DecodedPenStitch {
x: number; // X coordinate (already shifted right by 3) x: number; // X coordinate (already shifted right by 3)
y: number; // Y coordinate (already shifted right by 3) y: number; // Y coordinate (already shifted right by 3)
xFlags: number; // Flags from X coordinate low 3 bits xFlags: number; // Flags from X coordinate low 3 bits
yFlags: number; // Flags from Y coordinate low 3 bits yFlags: number; // Flags from Y coordinate low 3 bits
isFeed: boolean; // Jump/move without stitching (Y-bit 0) isFeed: boolean; // Jump/move without stitching (Y-bit 0)
isCut: boolean; // Trim/cut thread (Y-bit 1) isCut: boolean; // Trim/cut thread (Y-bit 1)
isColorEnd: boolean; // Color change marker (X-bits 0-2 = 0x03) isColorEnd: boolean; // Color change marker (X-bits 0-2 = 0x03)
isDataEnd: boolean; // Pattern end marker (X-bits 0-2 = 0x05) isDataEnd: boolean; // Pattern end marker (X-bits 0-2 = 0x05)
// Compatibility aliases // Compatibility aliases
isJump: boolean; // Alias for isFeed (backward compatibility) isJump: boolean; // Alias for isFeed (backward compatibility)
flags: number; // Combined flags (backward compatibility) flags: number; // Combined flags (backward compatibility)
} }
/** /**
@ -28,12 +28,12 @@ export interface DecodedPenStitch {
*/ */
export interface PenColorBlock { export interface PenColorBlock {
startStitchIndex: number; // Index of first stitch in this color startStitchIndex: number; // Index of first stitch in this color
endStitchIndex: number; // Index of last stitch in this color endStitchIndex: number; // Index of last stitch in this color
colorIndex: number; // Color number (0-based) colorIndex: number; // Color number (0-based)
// Compatibility aliases // Compatibility aliases
startStitch: number; // Alias for startStitchIndex (backward compatibility) startStitch: number; // Alias for startStitchIndex (backward compatibility)
endStitch: number; // Alias for endStitchIndex (backward compatibility) endStitch: number; // Alias for endStitchIndex (backward compatibility)
} }
/** /**

View file

@ -1,10 +1,10 @@
import { StrictMode } from "react"; import { StrictMode } from 'react'
import { createRoot } from "react-dom/client"; import { createRoot } from 'react-dom/client'
import "./index.css"; import './index.css'
import App from "./App.tsx"; import App from './App.tsx'
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>,
); )

View file

@ -1,4 +1,4 @@
import type { IFileService } from "../interfaces/IFileService"; import type { IFileService } from '../interfaces/IFileService';
/** /**
* Browser implementation of file service using HTML input elements * Browser implementation of file service using HTML input elements
@ -6,8 +6,8 @@ import type { IFileService } from "../interfaces/IFileService";
export class BrowserFileService implements IFileService { export class BrowserFileService implements IFileService {
async openFileDialog(options: { accept: string }): Promise<File | null> { async openFileDialog(options: { accept: string }): Promise<File | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
const input = document.createElement("input"); const input = document.createElement('input');
input.type = "file"; input.type = 'file';
input.accept = options.accept; input.accept = options.accept;
input.onchange = (e) => { input.onchange = (e) => {
@ -25,7 +25,7 @@ export class BrowserFileService implements IFileService {
async saveFileDialog(): Promise<void> { async saveFileDialog(): Promise<void> {
// No-op in browser - could implement download if needed in the future // No-op in browser - could implement download if needed in the future
console.warn("saveFileDialog not implemented in browser"); console.warn('saveFileDialog not implemented in browser');
} }
hasNativeDialogs(): boolean { hasNativeDialogs(): boolean {

View file

@ -1,9 +1,6 @@
import { PatternCacheService } from "../../services/PatternCacheService"; import { PatternCacheService } from '../../services/PatternCacheService';
import type { import type { IStorageService, ICachedPattern } from '../interfaces/IStorageService';
IStorageService, import type { PesPatternData } from '../../formats/import/pesImporter';
ICachedPattern,
} from "../interfaces/IStorageService";
import type { PesPatternData } from "../../formats/import/pesImporter";
/** /**
* Browser implementation of storage service using localStorage * Browser implementation of storage service using localStorage
@ -14,7 +11,7 @@ export class BrowserStorageService implements IStorageService {
uuid: string, uuid: string,
pesData: PesPatternData, pesData: PesPatternData,
fileName: string, fileName: string,
patternOffset?: { x: number; y: number }, patternOffset?: { x: number; y: number }
): Promise<void> { ): Promise<void> {
PatternCacheService.savePattern(uuid, pesData, fileName, patternOffset); PatternCacheService.savePattern(uuid, pesData, fileName, patternOffset);
} }
@ -39,12 +36,7 @@ export class BrowserStorageService implements IStorageService {
PatternCacheService.clearCache(); PatternCacheService.clearCache();
} }
async getCacheInfo(): Promise<{ async getCacheInfo(): Promise<{ hasCache: boolean; fileName?: string; uuid?: string; age?: number }> {
hasCache: boolean;
fileName?: string;
uuid?: string;
age?: number;
}> {
return PatternCacheService.getCacheInfo(); return PatternCacheService.getCacheInfo();
} }
} }

View file

@ -1,4 +1,4 @@
import type { IFileService } from "../interfaces/IFileService"; import type { IFileService } from '../interfaces/IFileService';
/** /**
* Electron implementation of file service using native dialogs via IPC * Electron implementation of file service using native dialogs via IPC
@ -6,17 +6,14 @@ import type { IFileService } from "../interfaces/IFileService";
export class ElectronFileService implements IFileService { export class ElectronFileService implements IFileService {
async openFileDialog(): Promise<File | null> { async openFileDialog(): Promise<File | null> {
if (!window.electronAPI) { if (!window.electronAPI) {
throw new Error("Electron API not available"); throw new Error('Electron API not available');
} }
try { try {
const result = await window.electronAPI.invoke<{ const result = await window.electronAPI.invoke<{ filePath: string; fileName: string } | null>('dialog:openFile', {
filePath: string;
fileName: string;
} | null>("dialog:openFile", {
filters: [ filters: [
{ name: "PES Files", extensions: ["pes"] }, { name: 'PES Files', extensions: ['pes'] },
{ name: "All Files", extensions: ["*"] }, { name: 'All Files', extensions: ['*'] },
], ],
}); });
@ -25,46 +22,34 @@ export class ElectronFileService implements IFileService {
} }
// Read the file content // Read the file content
const buffer = await window.electronAPI.invoke<ArrayBuffer>( const buffer = await window.electronAPI.invoke<ArrayBuffer>('fs:readFile', result.filePath);
"fs:readFile",
result.filePath,
);
const blob = new Blob([buffer]); const blob = new Blob([buffer]);
return new File([blob], result.fileName, { return new File([blob], result.fileName, { type: 'application/octet-stream' });
type: "application/octet-stream",
});
} catch (err) { } catch (err) {
console.error("[ElectronFileService] Failed to open file:", err); console.error('[ElectronFileService] Failed to open file:', err);
return null; return null;
} }
} }
async saveFileDialog(data: Uint8Array, defaultName: string): Promise<void> { async saveFileDialog(data: Uint8Array, defaultName: string): Promise<void> {
if (!window.electronAPI) { if (!window.electronAPI) {
throw new Error("Electron API not available"); throw new Error('Electron API not available');
} }
try { try {
const filePath = await window.electronAPI.invoke<string | null>( const filePath = await window.electronAPI.invoke<string | null>('dialog:saveFile', {
"dialog:saveFile", defaultPath: defaultName,
{ filters: [
defaultPath: defaultName, { name: 'PEN Files', extensions: ['pen'] },
filters: [ { name: 'All Files', extensions: ['*'] },
{ name: "PEN Files", extensions: ["pen"] }, ],
{ name: "All Files", extensions: ["*"] }, });
],
},
);
if (filePath) { if (filePath) {
await window.electronAPI.invoke( await window.electronAPI.invoke('fs:writeFile', filePath, Array.from(data));
"fs:writeFile",
filePath,
Array.from(data),
);
} }
} catch (err) { } catch (err) {
console.error("[ElectronFileService] Failed to save file:", err); console.error('[ElectronFileService] Failed to save file:', err);
throw err; throw err;
} }
} }

View file

@ -1,8 +1,5 @@
import type { import type { IStorageService, ICachedPattern } from '../interfaces/IStorageService';
IStorageService, import type { PesPatternData } from '../../formats/import/pesImporter';
ICachedPattern,
} from "../interfaces/IStorageService";
import type { PesPatternData } from "../../formats/import/pesImporter";
/** /**
* Electron implementation of storage service using electron-store via IPC * Electron implementation of storage service using electron-store via IPC
@ -10,7 +7,7 @@ import type { PesPatternData } from "../../formats/import/pesImporter";
export class ElectronStorageService implements IStorageService { export class ElectronStorageService implements IStorageService {
private async invoke<T>(channel: string, ...args: unknown[]): Promise<T> { private async invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
if (!window.electronAPI) { if (!window.electronAPI) {
throw new Error("Electron API not available"); throw new Error('Electron API not available');
} }
return window.electronAPI.invoke(channel, ...args); return window.electronAPI.invoke(channel, ...args);
} }
@ -19,7 +16,7 @@ export class ElectronStorageService implements IStorageService {
uuid: string, uuid: string,
pesData: PesPatternData, pesData: PesPatternData,
fileName: string, fileName: string,
patternOffset?: { x: number; y: number }, patternOffset?: { x: number; y: number }
): Promise<void> { ): Promise<void> {
// Convert Uint8Array to array for JSON serialization over IPC // Convert Uint8Array to array for JSON serialization over IPC
const serializable = { const serializable = {
@ -34,17 +31,14 @@ export class ElectronStorageService implements IStorageService {
}; };
// Fire and forget (sync-like behavior to match interface) // Fire and forget (sync-like behavior to match interface)
this.invoke("storage:savePattern", serializable).catch((err) => { this.invoke('storage:savePattern', serializable).catch(err => {
console.error("[ElectronStorage] Failed to save pattern:", err); console.error('[ElectronStorage] Failed to save pattern:', err);
}); });
} }
async getPatternByUUID(uuid: string): Promise<ICachedPattern | null> { async getPatternByUUID(uuid: string): Promise<ICachedPattern | null> {
try { try {
const pattern = await this.invoke<ICachedPattern | null>( const pattern = await this.invoke<ICachedPattern | null>('storage:getPattern', uuid);
"storage:getPattern",
uuid,
);
if (pattern && Array.isArray(pattern.pesData.penData)) { if (pattern && Array.isArray(pattern.pesData.penData)) {
// Restore Uint8Array from array // Restore Uint8Array from array
@ -53,16 +47,14 @@ export class ElectronStorageService implements IStorageService {
return pattern; return pattern;
} catch (err) { } catch (err) {
console.error("[ElectronStorage] Failed to get pattern:", err); console.error('[ElectronStorage] Failed to get pattern:', err);
return null; return null;
} }
} }
async getMostRecentPattern(): Promise<ICachedPattern | null> { async getMostRecentPattern(): Promise<ICachedPattern | null> {
try { try {
const pattern = await this.invoke<ICachedPattern | null>( const pattern = await this.invoke<ICachedPattern | null>('storage:getLatest');
"storage:getLatest",
);
if (pattern && Array.isArray(pattern.pesData.penData)) { if (pattern && Array.isArray(pattern.pesData.penData)) {
// Restore Uint8Array from array // Restore Uint8Array from array
@ -71,7 +63,7 @@ export class ElectronStorageService implements IStorageService {
return pattern; return pattern;
} catch (err) { } catch (err) {
console.error("[ElectronStorage] Failed to get latest pattern:", err); console.error('[ElectronStorage] Failed to get latest pattern:', err);
return null; return null;
} }
} }
@ -79,38 +71,29 @@ export class ElectronStorageService implements IStorageService {
async hasPattern(): Promise<boolean> { async hasPattern(): Promise<boolean> {
// Since this is async in Electron, we can't truly implement this synchronously // Since this is async in Electron, we can't truly implement this synchronously
// Returning false as a safe default // Returning false as a safe default
console.warn( console.warn('[ElectronStorage] hasPattern called synchronously, returning false');
"[ElectronStorage] hasPattern called synchronously, returning false",
);
return false; return false;
} }
async deletePattern(uuid: string): Promise<void> { async deletePattern(uuid: string): Promise<void> {
try { try {
await this.invoke("storage:deletePattern", uuid); await this.invoke('storage:deletePattern', uuid);
} catch (err) { } catch (err) {
console.error("[ElectronStorage] Failed to delete pattern:", err); console.error('[ElectronStorage] Failed to delete pattern:', err);
} }
} }
async clearCache(): Promise<void> { async clearCache(): Promise<void> {
try { try {
await this.invoke("storage:clear"); await this.invoke('storage:clear');
} catch (err) { } catch (err) {
console.error("[ElectronStorage] Failed to clear cache:", err); console.error('[ElectronStorage] Failed to clear cache:', err);
} }
} }
async getCacheInfo(): Promise<{ async getCacheInfo(): Promise<{ hasCache: boolean; fileName?: string; uuid?: string; age?: number }> {
hasCache: boolean;
fileName?: string;
uuid?: string;
age?: number;
}> {
// This needs to be async in Electron, return empty info synchronously // This needs to be async in Electron, return empty info synchronously
console.warn( console.warn('[ElectronStorage] getCacheInfo called synchronously, returning empty');
"[ElectronStorage] getCacheInfo called synchronously, returning empty",
);
return { hasCache: false }; return { hasCache: false };
} }
} }

View file

@ -1,19 +1,15 @@
import type { IStorageService } from "./interfaces/IStorageService"; import type { IStorageService } from './interfaces/IStorageService';
import type { IFileService } from "./interfaces/IFileService"; import type { IFileService } from './interfaces/IFileService';
import { BrowserStorageService } from "./browser/BrowserStorageService"; import { BrowserStorageService } from './browser/BrowserStorageService';
import { BrowserFileService } from "./browser/BrowserFileService"; import { BrowserFileService } from './browser/BrowserFileService';
import { ElectronStorageService } from "./electron/ElectronStorageService"; import { ElectronStorageService } from './electron/ElectronStorageService';
import { ElectronFileService } from "./electron/ElectronFileService"; import { ElectronFileService } from './electron/ElectronFileService';
/** /**
* Detect if running in Electron * Detect if running in Electron
*/ */
export function isElectron(): boolean { export function isElectron(): boolean {
return !!( return !!(typeof window !== 'undefined' && window.process && window.process.type === 'renderer');
typeof window !== "undefined" &&
window.process &&
window.process.type === "renderer"
);
} }
/** /**

View file

@ -1,4 +1,4 @@
import type { PesPatternData } from "../../formats/import/pesImporter"; import type { PesPatternData } from '../../formats/import/pesImporter';
export interface ICachedPattern { export interface ICachedPattern {
uuid: string; uuid: string;
@ -13,7 +13,7 @@ export interface IStorageService {
uuid: string, uuid: string,
pesData: PesPatternData, pesData: PesPatternData,
fileName: string, fileName: string,
patternOffset?: { x: number; y: number }, patternOffset?: { x: number; y: number }
): Promise<void>; ): Promise<void>;
getPatternByUUID(uuid: string): Promise<ICachedPattern | null>; getPatternByUUID(uuid: string): Promise<ICachedPattern | null>;
@ -21,10 +21,5 @@ export interface IStorageService {
hasPattern(uuid: string): Promise<boolean>; hasPattern(uuid: string): Promise<boolean>;
deletePattern(uuid: string): Promise<void>; deletePattern(uuid: string): Promise<void>;
clearCache(): Promise<void>; clearCache(): Promise<void>;
getCacheInfo(): Promise<{ getCacheInfo(): Promise<{ hasCache: boolean; fileName?: string; uuid?: string; age?: number }>;
hasCache: boolean;
fileName?: string;
uuid?: string;
age?: number;
}>;
} }

View file

@ -9,7 +9,7 @@ import { MachineStatus } from "../types/machine";
export class BluetoothPairingError extends Error { export class BluetoothPairingError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
this.name = "BluetoothPairingError"; this.name = 'BluetoothPairingError';
} }
} }
@ -57,8 +57,7 @@ export class BrotherPP1Service {
private isProcessingQueue = false; private isProcessingQueue = false;
private isCommunicating = false; private isCommunicating = false;
private isInitialConnection = false; private isInitialConnection = false;
private communicationCallbacks: Set<(isCommunicating: boolean) => void> = private communicationCallbacks: Set<(isCommunicating: boolean) => void> = new Set();
new Set();
private disconnectCallbacks: Set<() => void> = new Set(); private disconnectCallbacks: Set<() => void> = new Set();
/** /**
@ -66,9 +65,7 @@ export class BrotherPP1Service {
* @param callback Function called when communication state changes * @param callback Function called when communication state changes
* @returns Unsubscribe function * @returns Unsubscribe function
*/ */
onCommunicationChange( onCommunicationChange(callback: (isCommunicating: boolean) => void): () => void {
callback: (isCommunicating: boolean) => void,
): () => void {
this.communicationCallbacks.add(callback); this.communicationCallbacks.add(callback);
// Immediately call with current state // Immediately call with current state
callback(this.isCommunicating); callback(this.isCommunicating);
@ -92,19 +89,19 @@ export class BrotherPP1Service {
private setCommunicating(value: boolean) { private setCommunicating(value: boolean) {
if (this.isCommunicating !== value) { if (this.isCommunicating !== value) {
this.isCommunicating = value; this.isCommunicating = value;
this.communicationCallbacks.forEach((callback) => callback(value)); this.communicationCallbacks.forEach(callback => callback(value));
} }
} }
private handleDisconnect() { private handleDisconnect() {
console.log("[BrotherPP1Service] Device disconnected"); console.log('[BrotherPP1Service] Device disconnected');
this.server = null; this.server = null;
this.writeCharacteristic = null; this.writeCharacteristic = null;
this.readCharacteristic = null; this.readCharacteristic = null;
this.commandQueue = []; this.commandQueue = [];
this.isProcessingQueue = false; this.isProcessingQueue = false;
this.setCommunicating(false); this.setCommunicating(false);
this.disconnectCallbacks.forEach((callback) => callback()); this.disconnectCallbacks.forEach(callback => callback());
} }
async connect(): Promise<void> { async connect(): Promise<void> {
@ -119,7 +116,7 @@ export class BrotherPP1Service {
} }
// Listen for disconnection events // Listen for disconnection events
this.device.addEventListener("gattserverdisconnected", () => { this.device.addEventListener('gattserverdisconnected', () => {
this.handleDisconnect(); this.handleDisconnect();
}); });
@ -129,8 +126,7 @@ export class BrotherPP1Service {
const service = await this.server.getPrimaryService(SERVICE_UUID); const service = await this.server.getPrimaryService(SERVICE_UUID);
console.log("Got primary service"); console.log("Got primary service");
this.writeCharacteristic = this.writeCharacteristic = await service.getCharacteristic(WRITE_CHAR_UUID);
await service.getCharacteristic(WRITE_CHAR_UUID);
this.readCharacteristic = await service.getCharacteristic(READ_CHAR_UUID); this.readCharacteristic = await service.getCharacteristic(READ_CHAR_UUID);
console.log("Connected to Brother PP1 machine"); console.log("Connected to Brother PP1 machine");
@ -140,9 +136,7 @@ export class BrotherPP1Service {
console.log("Validating connection with test command..."); console.log("Validating connection with test command...");
try { try {
await this.getMachineState(); await this.getMachineState();
console.log( console.log("Connection validation successful - device is properly paired");
"Connection validation successful - device is properly paired",
);
} catch (e) { } catch (e) {
console.log("Connection validation failed:", e); console.log("Connection validation failed:", e);
// Disconnect to clean up // Disconnect to clean up
@ -295,21 +289,16 @@ export class BrotherPP1Service {
// Detect pairing issues during initial connection - empty or invalid response // Detect pairing issues during initial connection - empty or invalid response
if (this.isInitialConnection) { if (this.isInitialConnection) {
if (response.length === 0) { if (response.length === 0) {
console.log( console.log('[BrotherPP1] Empty response received - device likely not paired');
"[BrotherPP1] Empty response received - device likely not paired",
);
throw new BluetoothPairingError( throw new BluetoothPairingError(
"Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system's Bluetooth settings. After pairing, try connecting again.", 'Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system\'s Bluetooth settings. After pairing, try connecting again.'
); );
} }
// Check for invalid response (less than 3 bytes means no proper command response) // Check for invalid response (less than 3 bytes means no proper command response)
if (response.length < 3) { if (response.length < 3) {
console.log( console.log('[BrotherPP1] Invalid response length:', response.length);
"[BrotherPP1] Invalid response length:",
response.length,
);
throw new BluetoothPairingError( throw new BluetoothPairingError(
"Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system's Bluetooth settings. After pairing, try connecting again.", 'Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system\'s Bluetooth settings. After pairing, try connecting again.'
); );
} }
} }
@ -333,12 +322,11 @@ export class BrotherPP1Service {
if (this.isInitialConnection && error instanceof Error) { if (this.isInitialConnection && error instanceof Error) {
const errorMsg = error.message.toLowerCase(); const errorMsg = error.message.toLowerCase();
if ( if (
errorMsg.includes("gatt server is disconnected") || errorMsg.includes('gatt server is disconnected') ||
(errorMsg.includes("writevaluewithresponse") && (errorMsg.includes('writevaluewithresponse') && errorMsg.includes('gatt server is disconnected'))
errorMsg.includes("gatt server is disconnected"))
) { ) {
throw new BluetoothPairingError( throw new BluetoothPairingError(
"Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system's Bluetooth settings. After pairing, try connecting again.", 'Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system\'s Bluetooth settings. After pairing, try connecting again.'
); );
} }
} }
@ -381,7 +369,7 @@ export class BrotherPP1Service {
serviceCount = serviceData.serviceCount; serviceCount = serviceData.serviceCount;
totalCount = serviceData.totalCount; totalCount = serviceData.totalCount;
} catch (err) { } catch (err) {
console.warn("[BrotherPP1] Failed to fetch service count:", err); console.warn('[BrotherPP1] Failed to fetch service count:', err);
} }
return { return {
@ -397,23 +385,17 @@ export class BrotherPP1Service {
}; };
} }
async getServiceCount(): Promise<{ async getServiceCount(): Promise<{ serviceCount: number; totalCount: number }> {
serviceCount: number;
totalCount: number;
}> {
const response = await this.sendCommand(Commands.SERVICE_COUNT); const response = await this.sendCommand(Commands.SERVICE_COUNT);
const data = response.slice(2); const data = response.slice(2);
// Read uint32 values in little-endian format // Read uint32 values in little-endian format
const readUInt32LE = (offset: number) => const readUInt32LE = (offset: number) =>
data[offset] | data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24);
(data[offset + 1] << 8) |
(data[offset + 2] << 16) |
(data[offset + 3] << 24);
return { return {
serviceCount: readUInt32LE(0), // Bytes 0-3 serviceCount: readUInt32LE(0), // Bytes 0-3
totalCount: readUInt32LE(4), // Bytes 4-7 totalCount: readUInt32LE(4), // Bytes 4-7
}; };
} }
@ -445,10 +427,8 @@ export class BrotherPP1Service {
speed: readUInt16LE(12), speed: readUInt16LE(12),
}; };
console.log("[BrotherPP1] Pattern Info Response:", { console.log('[BrotherPP1] Pattern Info Response:', {
rawData: Array.from(data) rawData: Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(' '),
.map((b) => b.toString(16).padStart(2, "0"))
.join(" "),
parsed: patternInfo, parsed: patternInfo,
}); });
@ -600,7 +580,7 @@ export class BrotherPP1Service {
payload[24] = flip; payload[24] = flip;
payload[25] = frame; payload[25] = frame;
console.log("[DEBUG] Layout bounds:", { console.log('[DEBUG] Layout bounds:', {
boundLeft, boundLeft,
boundTop, boundTop,
boundRight, boundRight,
@ -695,7 +675,7 @@ export class BrotherPP1Service {
moveX = patternOffset.x - patternCenterX; moveX = patternOffset.x - patternCenterX;
moveY = patternOffset.y - patternCenterY; moveY = patternOffset.y - patternCenterY;
console.log("[LAYOUT] Using user-defined offset:", { console.log('[LAYOUT] Using user-defined offset:', {
patternOffset, patternOffset,
patternCenter: { x: patternCenterX, y: patternCenterY }, patternCenter: { x: patternCenterX, y: patternCenterY },
moveX, moveX,
@ -708,7 +688,7 @@ export class BrotherPP1Service {
moveX = -patternCenterX; moveX = -patternCenterX;
moveY = -patternCenterY; moveY = -patternCenterY;
console.log("[LAYOUT] Auto-centering pattern:", { moveX, moveY }); console.log('[LAYOUT] Auto-centering pattern:', { moveX, moveY });
} }
// Send layout with actual pattern bounds // Send layout with actual pattern bounds

View file

@ -1,197 +1,173 @@
import type { PesPatternData } from "../formats/import/pesImporter"; import type { PesPatternData } from '../formats/import/pesImporter';
interface CachedPattern { interface CachedPattern {
uuid: string; uuid: string;
pesData: PesPatternData; pesData: PesPatternData;
fileName: string; fileName: string;
timestamp: number; timestamp: number;
patternOffset?: { x: number; y: number }; patternOffset?: { x: number; y: number };
} }
const CACHE_KEY = "brother_pattern_cache"; const CACHE_KEY = 'brother_pattern_cache';
/** /**
* Convert UUID Uint8Array to hex string * Convert UUID Uint8Array to hex string
*/ */
export function uuidToString(uuid: Uint8Array): string { export function uuidToString(uuid: Uint8Array): string {
return Array.from(uuid) return Array.from(uuid).map(b => b.toString(16).padStart(2, '0')).join('');
.map((b) => b.toString(16).padStart(2, "0")) }
.join("");
} /**
* Convert hex string to UUID Uint8Array
/** */
* Convert hex string to UUID Uint8Array export function stringToUuid(str: string): Uint8Array {
*/ const bytes = new Uint8Array(16);
export function stringToUuid(str: string): Uint8Array { for (let i = 0; i < 16; i++) {
const bytes = new Uint8Array(16); bytes[i] = parseInt(str.substr(i * 2, 2), 16);
for (let i = 0; i < 16; i++) { }
bytes[i] = parseInt(str.substr(i * 2, 2), 16); return bytes;
} }
return bytes;
} export class PatternCacheService {
/**
export class PatternCacheService { * Save pattern to local storage with its UUID
/** */
* Save pattern to local storage with its UUID static savePattern(
*/ uuid: string,
static savePattern( pesData: PesPatternData,
uuid: string, fileName: string,
pesData: PesPatternData, patternOffset?: { x: number; y: number }
fileName: string, ): void {
patternOffset?: { x: number; y: number }, try {
): void { // Convert penData Uint8Array to array for JSON serialization
try { const pesDataWithArrayPenData = {
// Convert penData Uint8Array to array for JSON serialization ...pesData,
const pesDataWithArrayPenData = { penData: Array.from(pesData.penData) as unknown as Uint8Array,
...pesData, };
penData: Array.from(pesData.penData) as unknown as Uint8Array,
}; const cached: CachedPattern = {
uuid,
const cached: CachedPattern = { pesData: pesDataWithArrayPenData,
uuid, fileName,
pesData: pesDataWithArrayPenData, timestamp: Date.now(),
fileName, patternOffset,
timestamp: Date.now(), };
patternOffset,
}; localStorage.setItem(CACHE_KEY, JSON.stringify(cached));
console.log('[PatternCache] Saved pattern:', fileName, 'UUID:', uuid, 'Offset:', patternOffset);
localStorage.setItem(CACHE_KEY, JSON.stringify(cached)); } catch (err) {
console.log( console.error('[PatternCache] Failed to save pattern:', err);
"[PatternCache] Saved pattern:", // If quota exceeded, clear and try again
fileName, if (err instanceof Error && err.name === 'QuotaExceededError') {
"UUID:", this.clearCache();
uuid, }
"Offset:", }
patternOffset, }
);
} catch (err) { /**
console.error("[PatternCache] Failed to save pattern:", err); * Get cached pattern by UUID
// If quota exceeded, clear and try again */
if (err instanceof Error && err.name === "QuotaExceededError") { static getPatternByUUID(uuid: string): CachedPattern | null {
this.clearCache(); try {
} const cached = localStorage.getItem(CACHE_KEY);
} if (!cached) {
} return null;
}
/**
* Get cached pattern by UUID const pattern: CachedPattern = JSON.parse(cached);
*/
static getPatternByUUID(uuid: string): CachedPattern | null { // Check if UUID matches
try { if (pattern.uuid !== uuid) {
const cached = localStorage.getItem(CACHE_KEY); console.log('[PatternCache] UUID mismatch. Cached:', pattern.uuid, 'Requested:', uuid);
if (!cached) { return null;
return null; }
}
// Restore Uint8Array from array inside pesData
const pattern: CachedPattern = JSON.parse(cached); if (Array.isArray(pattern.pesData.penData)) {
pattern.pesData.penData = new Uint8Array(pattern.pesData.penData);
// Check if UUID matches }
if (pattern.uuid !== uuid) {
console.log( console.log('[PatternCache] Found cached pattern:', pattern.fileName, 'UUID:', uuid);
"[PatternCache] UUID mismatch. Cached:", return pattern;
pattern.uuid, } catch (err) {
"Requested:", console.error('[PatternCache] Failed to retrieve pattern:', err);
uuid, return null;
); }
return null; }
}
/**
// Restore Uint8Array from array inside pesData * Get the most recent cached pattern (regardless of UUID)
if (Array.isArray(pattern.pesData.penData)) { */
pattern.pesData.penData = new Uint8Array(pattern.pesData.penData); static getMostRecentPattern(): CachedPattern | null {
} try {
const cached = localStorage.getItem(CACHE_KEY);
console.log( if (!cached) {
"[PatternCache] Found cached pattern:", return null;
pattern.fileName, }
"UUID:",
uuid, const pattern: CachedPattern = JSON.parse(cached);
);
return pattern; // Restore Uint8Array from array inside pesData
} catch (err) { if (Array.isArray(pattern.pesData.penData)) {
console.error("[PatternCache] Failed to retrieve pattern:", err); pattern.pesData.penData = new Uint8Array(pattern.pesData.penData);
return null; }
}
} return pattern;
} catch (err) {
/** console.error('[PatternCache] Failed to retrieve pattern:', err);
* Get the most recent cached pattern (regardless of UUID) return null;
*/ }
static getMostRecentPattern(): CachedPattern | null { }
try {
const cached = localStorage.getItem(CACHE_KEY); /**
if (!cached) { * Check if a pattern with the given UUID exists in cache
return null; */
} static hasPattern(uuid: string): boolean {
const pattern = this.getMostRecentPattern();
const pattern: CachedPattern = JSON.parse(cached); return pattern?.uuid === uuid;
}
// Restore Uint8Array from array inside pesData
if (Array.isArray(pattern.pesData.penData)) { /**
pattern.pesData.penData = new Uint8Array(pattern.pesData.penData); * Delete a specific pattern by UUID
} */
static deletePattern(uuid: string): void {
return pattern; try {
} catch (err) { const cached = this.getPatternByUUID(uuid);
console.error("[PatternCache] Failed to retrieve pattern:", err); if (cached) {
return null; localStorage.removeItem(CACHE_KEY);
} console.log('[PatternCache] Deleted pattern with UUID:', uuid);
} }
} catch (err) {
/** console.error('[PatternCache] Failed to delete pattern:', err);
* Check if a pattern with the given UUID exists in cache }
*/ }
static hasPattern(uuid: string): boolean {
const pattern = this.getMostRecentPattern(); /**
return pattern?.uuid === uuid; * Clear the pattern cache
} */
static clearCache(): void {
/** try {
* Delete a specific pattern by UUID localStorage.removeItem(CACHE_KEY);
*/ console.log('[PatternCache] Cache cleared');
static deletePattern(uuid: string): void { } catch (err) {
try { console.error('[PatternCache] Failed to clear cache:', err);
const cached = this.getPatternByUUID(uuid); }
if (cached) { }
localStorage.removeItem(CACHE_KEY);
console.log("[PatternCache] Deleted pattern with UUID:", uuid); /**
} * Get cache info for debugging
} catch (err) { */
console.error("[PatternCache] Failed to delete pattern:", err); static getCacheInfo(): { hasCache: boolean; fileName?: string; uuid?: string; age?: number } {
} const pattern = this.getMostRecentPattern();
} if (!pattern) {
return { hasCache: false };
/** }
* Clear the pattern cache
*/ return {
static clearCache(): void { hasCache: true,
try { fileName: pattern.fileName,
localStorage.removeItem(CACHE_KEY); uuid: pattern.uuid,
console.log("[PatternCache] Cache cleared"); age: Date.now() - pattern.timestamp,
} catch (err) { };
console.error("[PatternCache] Failed to clear cache:", err); }
} }
}
/**
* Get cache info for debugging
*/
static getCacheInfo(): {
hasCache: boolean;
fileName?: string;
uuid?: string;
age?: number;
} {
const pattern = this.getMostRecentPattern();
if (!pattern) {
return { hasCache: false };
}
return {
hasCache: true,
fileName: pattern.fileName,
uuid: pattern.uuid,
age: Date.now() - pattern.timestamp,
};
}
}

View file

@ -1,5 +1,5 @@
import { create } from "zustand"; import { create } from 'zustand';
import type { PesPatternData } from "../formats/import/pesImporter"; import type { PesPatternData } from '../formats/import/pesImporter';
interface PatternState { interface PatternState {
// Pattern data // Pattern data
@ -19,7 +19,7 @@ interface PatternState {
export const usePatternStore = create<PatternState>((set) => ({ export const usePatternStore = create<PatternState>((set) => ({
// Initial state // Initial state
pesData: null, pesData: null,
currentFileName: "", currentFileName: '',
patternOffset: { x: 0, y: 0 }, patternOffset: { x: 0, y: 0 },
patternUploaded: false, patternUploaded: false,
@ -36,7 +36,7 @@ export const usePatternStore = create<PatternState>((set) => ({
// Update pattern offset // Update pattern offset
setPatternOffset: (x: number, y: number) => { setPatternOffset: (x: number, y: number) => {
set({ patternOffset: { x, y } }); set({ patternOffset: { x, y } });
console.log("[PatternStore] Pattern offset changed:", { x, y }); console.log('[PatternStore] Pattern offset changed:', { x, y });
}, },
// Mark pattern as uploaded/not uploaded // Mark pattern as uploaded/not uploaded
@ -61,9 +61,6 @@ export const usePatternStore = create<PatternState>((set) => ({
// Selector hooks for common use cases // Selector hooks for common use cases
export const usePesData = () => usePatternStore((state) => state.pesData); export const usePesData = () => usePatternStore((state) => state.pesData);
export const usePatternFileName = () => export const usePatternFileName = () => usePatternStore((state) => state.currentFileName);
usePatternStore((state) => state.currentFileName); export const usePatternOffset = () => usePatternStore((state) => state.patternOffset);
export const usePatternOffset = () => export const usePatternUploaded = () => usePatternStore((state) => state.patternUploaded);
usePatternStore((state) => state.patternOffset);
export const usePatternUploaded = () =>
usePatternStore((state) => state.patternUploaded);

View file

@ -1,5 +1,5 @@
import { create } from "zustand"; import { create } from 'zustand';
import { patternConverterClient } from "../formats/import/client"; import { patternConverterClient } from '../formats/import/client';
interface UIState { interface UIState {
// Pyodide state // Pyodide state
@ -23,41 +23,26 @@ export const useUIStore = create<UIState>((set) => ({
pyodideReady: false, pyodideReady: false,
pyodideError: null, pyodideError: null,
pyodideProgress: 0, pyodideProgress: 0,
pyodideLoadingStep: "", pyodideLoadingStep: '',
showErrorPopover: false, showErrorPopover: false,
// Initialize Pyodide with progress tracking // Initialize Pyodide with progress tracking
initializePyodide: async () => { initializePyodide: async () => {
try { try {
// Reset progress // Reset progress
set({ set({ pyodideProgress: 0, pyodideLoadingStep: 'Starting...', pyodideError: null });
pyodideProgress: 0,
pyodideLoadingStep: "Starting...",
pyodideError: null,
});
// Initialize with progress callback // Initialize with progress callback
await patternConverterClient.initialize((progress, step) => { await patternConverterClient.initialize((progress, step) => {
set({ pyodideProgress: progress, pyodideLoadingStep: step }); set({ pyodideProgress: progress, pyodideLoadingStep: step });
}); });
set({ set({ pyodideReady: true, pyodideProgress: 100, pyodideLoadingStep: 'Ready!' });
pyodideReady: true, console.log('[UIStore] Pyodide initialized successfully');
pyodideProgress: 100,
pyodideLoadingStep: "Ready!",
});
console.log("[UIStore] Pyodide initialized successfully");
} catch (err) { } catch (err) {
const errorMessage = const errorMessage = err instanceof Error ? err.message : 'Failed to initialize Python environment';
err instanceof Error set({ pyodideError: errorMessage, pyodideProgress: 0, pyodideLoadingStep: '' });
? err.message console.error('[UIStore] Failed to initialize Pyodide:', err);
: "Failed to initialize Python environment";
set({
pyodideError: errorMessage,
pyodideProgress: 0,
pyodideLoadingStep: "",
});
console.error("[UIStore] Failed to initialize Pyodide:", err);
} }
}, },
@ -80,9 +65,6 @@ export const useUIStore = create<UIState>((set) => ({
// Selector hooks for common use cases // Selector hooks for common use cases
export const usePyodideReady = () => useUIStore((state) => state.pyodideReady); export const usePyodideReady = () => useUIStore((state) => state.pyodideReady);
export const usePyodideError = () => useUIStore((state) => state.pyodideError); export const usePyodideError = () => useUIStore((state) => state.pyodideError);
export const usePyodideProgress = () => export const usePyodideProgress = () => useUIStore((state) => state.pyodideProgress);
useUIStore((state) => state.pyodideProgress); export const usePyodideLoadingStep = () => useUIStore((state) => state.pyodideLoadingStep);
export const usePyodideLoadingStep = () => export const useErrorPopover = () => useUIStore((state) => state.showErrorPopover);
useUIStore((state) => state.pyodideLoadingStep);
export const useErrorPopover = () =>
useUIStore((state) => state.showErrorPopover);

View file

@ -5,9 +5,7 @@ export interface BluetoothDevice {
export interface ElectronAPI { export interface ElectronAPI {
invoke<T = unknown>(channel: string, ...args: unknown[]): Promise<T>; invoke<T = unknown>(channel: string, ...args: unknown[]): Promise<T>;
onBluetoothDeviceList: ( onBluetoothDeviceList: (callback: (devices: BluetoothDevice[]) => void) => void;
callback: (devices: BluetoothDevice[]) => void,
) => void;
selectBluetoothDevice: (deviceId: string) => void; selectBluetoothDevice: (deviceId: string) => void;
} }

View file

@ -1,105 +1,105 @@
// Brother PP1 Machine Types // Brother PP1 Machine Types
export const MachineStatus = { export const MachineStatus = {
Initial: 0x00, Initial: 0x00,
LowerThread: 0x01, LowerThread: 0x01,
IDLE: 0x10, IDLE: 0x10,
SEWING_WAIT: 0x11, SEWING_WAIT: 0x11,
SEWING_DATA_RECEIVE: 0x12, SEWING_DATA_RECEIVE: 0x12,
MASK_TRACE_LOCK_WAIT: 0x20, MASK_TRACE_LOCK_WAIT: 0x20,
MASK_TRACING: 0x21, MASK_TRACING: 0x21,
MASK_TRACE_COMPLETE: 0x22, MASK_TRACE_COMPLETE: 0x22,
SEWING: 0x30, SEWING: 0x30,
SEWING_COMPLETE: 0x31, SEWING_COMPLETE: 0x31,
SEWING_INTERRUPTION: 0x32, SEWING_INTERRUPTION: 0x32,
COLOR_CHANGE_WAIT: 0x40, COLOR_CHANGE_WAIT: 0x40,
PAUSE: 0x41, PAUSE: 0x41,
STOP: 0x42, STOP: 0x42,
HOOP_AVOIDANCE: 0x50, HOOP_AVOIDANCE: 0x50,
HOOP_AVOIDANCEING: 0x51, HOOP_AVOIDANCEING: 0x51,
RL_RECEIVING: 0x60, RL_RECEIVING: 0x60,
RL_RECEIVED: 0x61, RL_RECEIVED: 0x61,
None: 0xdd, None: 0xDD,
TryConnecting: 0xff, TryConnecting: 0xFF,
} as const; } as const;
export type MachineStatus = (typeof MachineStatus)[keyof typeof MachineStatus]; export type MachineStatus = typeof MachineStatus[keyof typeof MachineStatus];
export const MachineStatusNames: Record<MachineStatus, string> = { export const MachineStatusNames: Record<MachineStatus, string> = {
[MachineStatus.Initial]: "Initial", [MachineStatus.Initial]: 'Initial',
[MachineStatus.LowerThread]: "Lower Thread", [MachineStatus.LowerThread]: 'Lower Thread',
[MachineStatus.IDLE]: "Idle", [MachineStatus.IDLE]: 'Idle',
[MachineStatus.SEWING_WAIT]: "Ready to Sew", [MachineStatus.SEWING_WAIT]: 'Ready to Sew',
[MachineStatus.SEWING_DATA_RECEIVE]: "Receiving Data", [MachineStatus.SEWING_DATA_RECEIVE]: 'Receiving Data',
[MachineStatus.MASK_TRACE_LOCK_WAIT]: "Waiting for Mask Trace", [MachineStatus.MASK_TRACE_LOCK_WAIT]: 'Waiting for Mask Trace',
[MachineStatus.MASK_TRACING]: "Mask Tracing", [MachineStatus.MASK_TRACING]: 'Mask Tracing',
[MachineStatus.MASK_TRACE_COMPLETE]: "Mask Trace Complete", [MachineStatus.MASK_TRACE_COMPLETE]: 'Mask Trace Complete',
[MachineStatus.SEWING]: "Sewing", [MachineStatus.SEWING]: 'Sewing',
[MachineStatus.SEWING_COMPLETE]: "Complete", [MachineStatus.SEWING_COMPLETE]: 'Complete',
[MachineStatus.SEWING_INTERRUPTION]: "Interrupted", [MachineStatus.SEWING_INTERRUPTION]: 'Interrupted',
[MachineStatus.COLOR_CHANGE_WAIT]: "Waiting for Color Change", [MachineStatus.COLOR_CHANGE_WAIT]: 'Waiting for Color Change',
[MachineStatus.PAUSE]: "Paused", [MachineStatus.PAUSE]: 'Paused',
[MachineStatus.STOP]: "Stopped", [MachineStatus.STOP]: 'Stopped',
[MachineStatus.HOOP_AVOIDANCE]: "Hoop Avoidance", [MachineStatus.HOOP_AVOIDANCE]: 'Hoop Avoidance',
[MachineStatus.HOOP_AVOIDANCEING]: "Hoop Avoidance In Progress", [MachineStatus.HOOP_AVOIDANCEING]: 'Hoop Avoidance In Progress',
[MachineStatus.RL_RECEIVING]: "RL Receiving", [MachineStatus.RL_RECEIVING]: 'RL Receiving',
[MachineStatus.RL_RECEIVED]: "RL Received", [MachineStatus.RL_RECEIVED]: 'RL Received',
[MachineStatus.None]: "None", [MachineStatus.None]: 'None',
[MachineStatus.TryConnecting]: "Connecting", [MachineStatus.TryConnecting]: 'Connecting',
}; };
export interface MachineInfo { export interface MachineInfo {
serialNumber: string; serialNumber: string;
modelNumber: string; modelNumber: string;
softwareVersion: string; softwareVersion: string;
bluetoothVersion: number; bluetoothVersion: number;
maxWidth: number; // in 0.1mm units maxWidth: number; // in 0.1mm units
maxHeight: number; // in 0.1mm units maxHeight: number; // in 0.1mm units
macAddress: string; macAddress: string;
serviceCount?: number; // Cumulative service counter serviceCount?: number; // Cumulative service counter
totalCount?: number; // Total stitches sewn by machine totalCount?: number; // Total stitches sewn by machine
} }
export interface PatternInfo { export interface PatternInfo {
totalStitches: number; totalStitches: number;
totalTime: number; // seconds totalTime: number; // seconds
speed: number; // stitches per minute speed: number; // stitches per minute
boundLeft: number; boundLeft: number;
boundTop: number; boundTop: number;
boundRight: number; boundRight: number;
boundBottom: number; boundBottom: number;
} }
export interface SewingProgress { export interface SewingProgress {
currentStitch: number; currentStitch: number;
currentTime: number; // seconds currentTime: number; // seconds
stopTime: number; stopTime: number;
positionX: number; // in 0.1mm units positionX: number; // in 0.1mm units
positionY: number; // in 0.1mm units positionY: number; // in 0.1mm units
} }
export interface PenStitch { export interface PenStitch {
x: number; x: number;
y: number; y: number;
flags: number; flags: number;
isJump: boolean; isJump: boolean;
} }
export interface PenColorBlock { export interface PenColorBlock {
startStitch: number; startStitch: number;
endStitch: number; endStitch: number;
colorIndex: number; colorIndex: number;
} }
export interface PenData { export interface PenData {
stitches: PenStitch[]; stitches: PenStitch[];
colorBlocks: PenColorBlock[]; colorBlocks: PenColorBlock[];
totalStitches: number; totalStitches: number;
colorCount: number; colorCount: number;
bounds: { bounds: {
minX: number; minX: number;
maxX: number; maxX: number;
minY: number; minY: number;
maxY: number; maxY: number;
}; };
} }

View file

@ -11,9 +11,9 @@ export function getCSSVariable(name: string): string {
* Canvas color helpers * Canvas color helpers
*/ */
export const canvasColors = { export const canvasColors = {
grid: () => getCSSVariable("--color-canvas-grid"), grid: () => getCSSVariable('--color-canvas-grid'),
origin: () => getCSSVariable("--color-canvas-origin"), origin: () => getCSSVariable('--color-canvas-origin'),
hoop: () => getCSSVariable("--color-canvas-hoop"), hoop: () => getCSSVariable('--color-canvas-hoop'),
bounds: () => getCSSVariable("--color-canvas-bounds"), bounds: () => getCSSVariable('--color-canvas-bounds'),
position: () => getCSSVariable("--color-canvas-position"), position: () => getCSSVariable('--color-canvas-position'),
}; };

View file

@ -1,386 +1,396 @@
/** /**
* Brother PP1 Protocol Error Codes * Brother PP1 Protocol Error Codes
* Based on App/Asura.Core/Models/SewingMachineError.cs * Based on App/Asura.Core/Models/SewingMachineError.cs
*/ */
export const SewingMachineError = { export const SewingMachineError = {
NeedlePositionError: 0x00, NeedlePositionError: 0x00,
SafetyError: 0x01, SafetyError: 0x01,
LowerThreadSafetyError: 0x02, LowerThreadSafetyError: 0x02,
LowerThreadFreeError: 0x03, LowerThreadFreeError: 0x03,
RestartError10: 0x10, RestartError10: 0x10,
RestartError11: 0x11, RestartError11: 0x11,
RestartError12: 0x12, RestartError12: 0x12,
RestartError13: 0x13, RestartError13: 0x13,
RestartError14: 0x14, RestartError14: 0x14,
RestartError15: 0x15, RestartError15: 0x15,
RestartError16: 0x16, RestartError16: 0x16,
RestartError17: 0x17, RestartError17: 0x17,
RestartError18: 0x18, RestartError18: 0x18,
RestartError19: 0x19, RestartError19: 0x19,
RestartError1A: 0x1a, RestartError1A: 0x1A,
RestartError1B: 0x1b, RestartError1B: 0x1B,
RestartError1C: 0x1c, RestartError1C: 0x1C,
NeedlePlateError: 0x20, NeedlePlateError: 0x20,
ThreadLeverError: 0x21, ThreadLeverError: 0x21,
UpperThreadError: 0x60, UpperThreadError: 0x60,
LowerThreadError: 0x61, LowerThreadError: 0x61,
UpperThreadSewingStartError: 0x62, UpperThreadSewingStartError: 0x62,
PRWiperError: 0x63, PRWiperError: 0x63,
HoopError: 0x70, HoopError: 0x70,
NoHoopError: 0x71, NoHoopError: 0x71,
InitialHoopError: 0x72, InitialHoopError: 0x72,
RegularInspectionError: 0x80, RegularInspectionError: 0x80,
Setting: 0x98, Setting: 0x98,
None: 0xdd, None: 0xDD,
Unknown: 0xee, Unknown: 0xEE,
OtherError: 0xff, OtherError: 0xFF,
} as const; } as const;
/** /**
* Detailed error information with title, description, and solution steps * Detailed error information with title, description, and solution steps
*/ */
interface ErrorInfo { interface ErrorInfo {
title: string; title: string;
description: string; description: string;
solutions: string[]; solutions: string[];
/** If true, this "error" is really just an informational step, not a real error */ /** If true, this "error" is really just an informational step, not a real error */
isInformational?: boolean; isInformational?: boolean;
} }
/** /**
* Detailed error messages with actionable solutions * Detailed error messages with actionable solutions
* Only errors with verified solutions are included here * Only errors with verified solutions are included here
*/ */
const ERROR_DETAILS: Record<number, ErrorInfo> = { const ERROR_DETAILS: Record<number, ErrorInfo> = {
[SewingMachineError.NeedlePositionError]: { [SewingMachineError.NeedlePositionError]: {
title: "The Needle is Down", title: 'The Needle is Down',
description: description: 'The needle is in the down position and needs to be raised before continuing.',
"The needle is in the down position and needs to be raised before continuing.", solutions: [
solutions: ["Press the needle position switch to raise the needle"], 'Press the needle position switch to raise the needle',
}, ],
[SewingMachineError.SafetyError]: { },
title: "Safety Error", [SewingMachineError.SafetyError]: {
description: "The machine is sensing an operational issue.", title: 'Safety Error',
solutions: [ description: 'The machine is sensing an operational issue.',
"Remove the thread on the top of the fabric and then remove the needle", solutions: [
"Remove the thread on the underside of the fabric and clean the bobbin case of all threads", 'Remove the thread on the top of the fabric and then remove the needle',
"Check the bobbin case for scratches or contamination", 'Remove the thread on the underside of the fabric and clean the bobbin case of all threads',
"Insert the embroidery needle", 'Check the bobbin case for scratches or contamination',
"Check that the bobbin is inserted correctly", 'Insert the embroidery needle',
], 'Check that the bobbin is inserted correctly',
}, ],
[SewingMachineError.LowerThreadSafetyError]: { },
title: "Lower Thread Safety Error", [SewingMachineError.LowerThreadSafetyError]: {
description: "The bobbin winder safety device is activated.", title: 'Lower Thread Safety Error',
solutions: ["Check if the thread is tangled"], description: 'The bobbin winder safety device is activated.',
}, solutions: [
[SewingMachineError.LowerThreadFreeError]: { 'Check if the thread is tangled',
title: "Lower Thread Free Error", ],
description: "Problem with lower thread.", },
solutions: ["Slide the bobbin winder shaft toward the front"], [SewingMachineError.LowerThreadFreeError]: {
}, title: 'Lower Thread Free Error',
[SewingMachineError.RestartError10]: { description: 'Problem with lower thread.',
title: "Restart Required", solutions: [
description: "A malfunction occurred.", 'Slide the bobbin winder shaft toward the front',
solutions: ["Turn the machine off, then on again"], ],
}, },
[SewingMachineError.RestartError11]: { [SewingMachineError.RestartError10]: {
title: "Restart Required (M519411)", title: 'Restart Required',
description: "A malfunction occurred. Error code: M519411", description: 'A malfunction occurred.',
solutions: [ solutions: [
"Turn the machine off, then on again", 'Turn the machine off, then on again',
"If the problem persists, note error code M519411 and contact technical support", ],
], },
}, [SewingMachineError.RestartError11]: {
[SewingMachineError.RestartError12]: { title: 'Restart Required (M519411)',
title: "Restart Required (M519412)", description: 'A malfunction occurred. Error code: M519411',
description: "A malfunction occurred. Error code: M519412", solutions: [
solutions: [ 'Turn the machine off, then on again',
"Turn the machine off, then on again", 'If the problem persists, note error code M519411 and contact technical support',
"If the problem persists, note error code M519412 and contact technical support", ],
], },
}, [SewingMachineError.RestartError12]: {
[SewingMachineError.RestartError13]: { title: 'Restart Required (M519412)',
title: "Restart Required (M519413)", description: 'A malfunction occurred. Error code: M519412',
description: "A malfunction occurred. Error code: M519413", solutions: [
solutions: [ 'Turn the machine off, then on again',
"Turn the machine off, then on again", 'If the problem persists, note error code M519412 and contact technical support',
"If the problem persists, note error code M519413 and contact technical support", ],
], },
}, [SewingMachineError.RestartError13]: {
[SewingMachineError.RestartError14]: { title: 'Restart Required (M519413)',
title: "Restart Required (M519414)", description: 'A malfunction occurred. Error code: M519413',
description: "A malfunction occurred. Error code: M519414", solutions: [
solutions: [ 'Turn the machine off, then on again',
"Turn the machine off, then on again", 'If the problem persists, note error code M519413 and contact technical support',
"If the problem persists, note error code M519414 and contact technical support", ],
], },
}, [SewingMachineError.RestartError14]: {
[SewingMachineError.RestartError15]: { title: 'Restart Required (M519414)',
title: "Restart Required (M519415)", description: 'A malfunction occurred. Error code: M519414',
description: "A malfunction occurred. Error code: M519415", solutions: [
solutions: [ 'Turn the machine off, then on again',
"Turn the machine off, then on again", 'If the problem persists, note error code M519414 and contact technical support',
"If the problem persists, note error code M519415 and contact technical support", ],
], },
}, [SewingMachineError.RestartError15]: {
[SewingMachineError.RestartError16]: { title: 'Restart Required (M519415)',
title: "Restart Required (M519416)", description: 'A malfunction occurred. Error code: M519415',
description: "A malfunction occurred. Error code: M519416", solutions: [
solutions: [ 'Turn the machine off, then on again',
"Turn the machine off, then on again", 'If the problem persists, note error code M519415 and contact technical support',
"If the problem persists, note error code M519416 and contact technical support", ],
], },
}, [SewingMachineError.RestartError16]: {
[SewingMachineError.RestartError17]: { title: 'Restart Required (M519416)',
title: "Restart Required (M519417)", description: 'A malfunction occurred. Error code: M519416',
description: "A malfunction occurred. Error code: M519417", solutions: [
solutions: [ 'Turn the machine off, then on again',
"Turn the machine off, then on again", 'If the problem persists, note error code M519416 and contact technical support',
"If the problem persists, note error code M519417 and contact technical support", ],
], },
}, [SewingMachineError.RestartError17]: {
[SewingMachineError.RestartError18]: { title: 'Restart Required (M519417)',
title: "Restart Required (M519418)", description: 'A malfunction occurred. Error code: M519417',
description: "A malfunction occurred. Error code: M519418", solutions: [
solutions: [ 'Turn the machine off, then on again',
"Turn the machine off, then on again", 'If the problem persists, note error code M519417 and contact technical support',
"If the problem persists, note error code M519418 and contact technical support", ],
], },
}, [SewingMachineError.RestartError18]: {
[SewingMachineError.RestartError19]: { title: 'Restart Required (M519418)',
title: "Restart Required (M519419)", description: 'A malfunction occurred. Error code: M519418',
description: "A malfunction occurred. Error code: M519419", solutions: [
solutions: [ 'Turn the machine off, then on again',
"Turn the machine off, then on again", 'If the problem persists, note error code M519418 and contact technical support',
"If the problem persists, note error code M519419 and contact technical support", ],
], },
}, [SewingMachineError.RestartError19]: {
[SewingMachineError.RestartError1A]: { title: 'Restart Required (M519419)',
title: "Restart Required (M51941A)", description: 'A malfunction occurred. Error code: M519419',
description: "A malfunction occurred. Error code: M51941A", solutions: [
solutions: [ 'Turn the machine off, then on again',
"Turn the machine off, then on again", 'If the problem persists, note error code M519419 and contact technical support',
"If the problem persists, note error code M51941A and contact technical support", ],
], },
}, [SewingMachineError.RestartError1A]: {
[SewingMachineError.RestartError1B]: { title: 'Restart Required (M51941A)',
title: "Restart Required (M51941B)", description: 'A malfunction occurred. Error code: M51941A',
description: "A malfunction occurred. Error code: M51941B", solutions: [
solutions: [ 'Turn the machine off, then on again',
"Turn the machine off, then on again", 'If the problem persists, note error code M51941A and contact technical support',
"If the problem persists, note error code M51941B and contact technical support", ],
], },
}, [SewingMachineError.RestartError1B]: {
[SewingMachineError.RestartError1C]: { title: 'Restart Required (M51941B)',
title: "Restart Required (M51941C)", description: 'A malfunction occurred. Error code: M51941B',
description: "A malfunction occurred. Error code: M51941C", solutions: [
solutions: [ 'Turn the machine off, then on again',
"Turn the machine off, then on again", 'If the problem persists, note error code M51941B and contact technical support',
"If the problem persists, note error code M51941C and contact technical support", ],
], },
}, [SewingMachineError.RestartError1C]: {
[SewingMachineError.NeedlePlateError]: { title: 'Restart Required (M51941C)',
title: "Needle Plate Error", description: 'A malfunction occurred. Error code: M51941C',
description: "Check the needle plate cover.", solutions: [
solutions: [ 'Turn the machine off, then on again',
"Reattach the needle plate cover", 'If the problem persists, note error code M51941C and contact technical support',
"Check the bobbin case (for misalignment, scratches, etc.) and then reattach the needle plate cover", ],
], },
}, [SewingMachineError.NeedlePlateError]: {
[SewingMachineError.ThreadLeverError]: { title: 'Needle Plate Error',
title: "Thread Lever Error", description: 'Check the needle plate cover.',
description: "The needle threading lever is not in its original position.", solutions: [
solutions: ["Return the needle threading lever to its original position"], 'Reattach the needle plate cover',
}, 'Check the bobbin case (for misalignment, scratches, etc.) and then reattach the needle plate cover',
[SewingMachineError.UpperThreadError]: { ],
title: "Upper Thread Error", },
description: "Check and rethread the upper thread.", [SewingMachineError.ThreadLeverError]: {
solutions: [ title: 'Thread Lever Error',
"Check the upper thread and rethread it", description: 'The needle threading lever is not in its original position.',
"If the problem persists, replace the embroidery needle, then check the upper thread and rethread it", solutions: [
], 'Return the needle threading lever to its original position',
}, ],
[SewingMachineError.LowerThreadError]: { },
title: "Lower Thread Error", [SewingMachineError.UpperThreadError]: {
description: "The bobbin thread is almost empty.", title: 'Upper Thread Error',
solutions: [ description: 'Check and rethread the upper thread.',
"Replace the bobbin thread", solutions: [
"Wind the thread onto the empty bobbin in the correct way, then insert the bobbin", 'Check the upper thread and rethread it',
], 'If the problem persists, replace the embroidery needle, then check the upper thread and rethread it',
}, ],
[SewingMachineError.UpperThreadSewingStartError]: { },
title: "Upper Thread Error at Sewing Start", [SewingMachineError.LowerThreadError]: {
description: "Check and rethread the upper thread.", title: 'Lower Thread Error',
solutions: [ description: 'The bobbin thread is almost empty.',
"Press the Accept button to resolve the error", solutions: [
"Check the upper thread and rethread it", 'Replace the bobbin thread',
"If the problem persists, replace the embroidery needle, then check the upper thread and rethread it", 'Wind the thread onto the empty bobbin in the correct way, then insert the bobbin',
], ],
}, },
[SewingMachineError.PRWiperError]: { [SewingMachineError.UpperThreadSewingStartError]: {
title: "PR Wiper Error", title: 'Upper Thread Error at Sewing Start',
description: "PR Wiper Error.", description: 'Check and rethread the upper thread.',
solutions: ["Press the Accept button to resolve the error"], solutions: [
}, 'Press the Accept button to resolve the error',
[SewingMachineError.HoopError]: { 'Check the upper thread and rethread it',
title: "Hoop Error", 'If the problem persists, replace the embroidery needle, then check the upper thread and rethread it',
description: "This embroidery frame cannot be used.", ],
solutions: ["Use another frame that fits the pattern"], },
}, [SewingMachineError.PRWiperError]: {
[SewingMachineError.NoHoopError]: { title: 'PR Wiper Error',
title: "No Hoop Detected", description: 'PR Wiper Error.',
description: "No hoop attached.", solutions: [
solutions: ["Attach the embroidery hoop"], 'Press the Accept button to resolve the error',
}, ],
[SewingMachineError.InitialHoopError]: { },
title: "Machine Initialization Required", [SewingMachineError.HoopError]: {
description: "An initial homing procedure must be performed.", title: 'Hoop Error',
solutions: [ description: 'This embroidery frame cannot be used.',
"Remove the embroidery hoop from the machine completely", solutions: [
"Press the Accept button", 'Use another frame that fits the pattern',
"Wait for the machine to complete its initialization (homing)", ],
"Once initialization is complete, reattach the hoop", },
"The machine should now recognize the hoop correctly", [SewingMachineError.NoHoopError]: {
], title: 'No Hoop Detected',
isInformational: true, // This is a normal initialization step, not an error description: 'No hoop attached.',
}, solutions: [
[SewingMachineError.RegularInspectionError]: { 'Attach the embroidery hoop',
title: "Regular Inspection Required", ],
description: },
"Preventive maintenance is recommended. This message is displayed when maintenance is due.", [SewingMachineError.InitialHoopError]: {
solutions: ["Please contact the service center"], title: 'Machine Initialization Required',
}, description: 'The hoop needs to be removed and an initial homing procedure must be performed.',
[SewingMachineError.Setting]: { solutions: [
title: "Settings Error", 'Remove the embroidery hoop from the machine completely',
description: "Stitch count cannot be changed.", 'Press the Accept button',
solutions: ["This setting cannot be modified at this time"], 'Wait for the machine to complete its initialization (homing)',
}, 'Once initialization is complete, reattach the hoop',
}; 'The machine should now recognize the hoop correctly',
],
/** isInformational: true, // This is a normal initialization step, not an error
* Simple error titles for all error codes },
*/ [SewingMachineError.RegularInspectionError]: {
const ERROR_MESSAGES: Record<number, string> = { title: 'Regular Inspection Required',
[SewingMachineError.NeedlePositionError]: "Needle Position Error", description: 'Preventive maintenance is recommended. This message is displayed when maintenance is due.',
[SewingMachineError.SafetyError]: "Safety Error", solutions: [
[SewingMachineError.LowerThreadSafetyError]: "Lower Thread Safety Error", 'Please contact the service center',
[SewingMachineError.LowerThreadFreeError]: "Lower Thread Free Error", ],
[SewingMachineError.RestartError10]: "Restart Required (0x10)", },
[SewingMachineError.RestartError11]: "Restart Required (0x11)", [SewingMachineError.Setting]: {
[SewingMachineError.RestartError12]: "Restart Required (0x12)", title: 'Settings Error',
[SewingMachineError.RestartError13]: "Restart Required (0x13)", description: 'Stitch count cannot be changed.',
[SewingMachineError.RestartError14]: "Restart Required (0x14)", solutions: [
[SewingMachineError.RestartError15]: "Restart Required (0x15)", 'This setting cannot be modified at this time',
[SewingMachineError.RestartError16]: "Restart Required (0x16)", ],
[SewingMachineError.RestartError17]: "Restart Required (0x17)", },
[SewingMachineError.RestartError18]: "Restart Required (0x18)", };
[SewingMachineError.RestartError19]: "Restart Required (0x19)",
[SewingMachineError.RestartError1A]: "Restart Required (0x1A)", /**
[SewingMachineError.RestartError1B]: "Restart Required (0x1B)", * Simple error titles for all error codes
[SewingMachineError.RestartError1C]: "Restart Required (0x1C)", */
[SewingMachineError.NeedlePlateError]: "Needle Plate Error", const ERROR_MESSAGES: Record<number, string> = {
[SewingMachineError.ThreadLeverError]: "Thread Lever Error", [SewingMachineError.NeedlePositionError]: 'Needle Position Error',
[SewingMachineError.UpperThreadError]: "Upper Thread Error", [SewingMachineError.SafetyError]: 'Safety Error',
[SewingMachineError.LowerThreadError]: "Lower Thread Error", [SewingMachineError.LowerThreadSafetyError]: 'Lower Thread Safety Error',
[SewingMachineError.UpperThreadSewingStartError]: [SewingMachineError.LowerThreadFreeError]: 'Lower Thread Free Error',
"Upper Thread Error at Sewing Start", [SewingMachineError.RestartError10]: 'Restart Required (0x10)',
[SewingMachineError.PRWiperError]: "PR Wiper Error", [SewingMachineError.RestartError11]: 'Restart Required (0x11)',
[SewingMachineError.HoopError]: "Hoop Error", [SewingMachineError.RestartError12]: 'Restart Required (0x12)',
[SewingMachineError.NoHoopError]: "No Hoop Detected", [SewingMachineError.RestartError13]: 'Restart Required (0x13)',
[SewingMachineError.InitialHoopError]: "Initial Hoop Position Error", [SewingMachineError.RestartError14]: 'Restart Required (0x14)',
[SewingMachineError.RegularInspectionError]: "Regular Inspection Required", [SewingMachineError.RestartError15]: 'Restart Required (0x15)',
[SewingMachineError.Setting]: "Settings Error", [SewingMachineError.RestartError16]: 'Restart Required (0x16)',
[SewingMachineError.Unknown]: "Unknown Error", [SewingMachineError.RestartError17]: 'Restart Required (0x17)',
[SewingMachineError.OtherError]: "Other Error", [SewingMachineError.RestartError18]: 'Restart Required (0x18)',
}; [SewingMachineError.RestartError19]: 'Restart Required (0x19)',
[SewingMachineError.RestartError1A]: 'Restart Required (0x1A)',
/** [SewingMachineError.RestartError1B]: 'Restart Required (0x1B)',
* Get human-readable error message for an error code [SewingMachineError.RestartError1C]: 'Restart Required (0x1C)',
*/ [SewingMachineError.NeedlePlateError]: 'Needle Plate Error',
export function getErrorMessage(errorCode: number | undefined): string | null { [SewingMachineError.ThreadLeverError]: 'Thread Lever Error',
// Handle undefined or null [SewingMachineError.UpperThreadError]: 'Upper Thread Error',
if (errorCode === undefined || errorCode === null) { [SewingMachineError.LowerThreadError]: 'Lower Thread Error',
return null; [SewingMachineError.UpperThreadSewingStartError]: 'Upper Thread Error at Sewing Start',
} [SewingMachineError.PRWiperError]: 'PR Wiper Error',
[SewingMachineError.HoopError]: 'Hoop Error',
// 0xDD (221) is the default "no error" value [SewingMachineError.NoHoopError]: 'No Hoop Detected',
if (errorCode === SewingMachineError.None) { [SewingMachineError.InitialHoopError]: 'Initial Hoop Position Error',
return null; // No error to display [SewingMachineError.RegularInspectionError]: 'Regular Inspection Required',
} [SewingMachineError.Setting]: 'Settings Error',
[SewingMachineError.Unknown]: 'Unknown Error',
// Look up known error message [SewingMachineError.OtherError]: 'Other Error',
const message = ERROR_MESSAGES[errorCode]; };
if (message) {
return message; /**
} * Get human-readable error message for an error code
*/
// Unknown error code export function getErrorMessage(errorCode: number | undefined): string | null {
return `Machine Error ${errorCode} (0x${errorCode.toString(16).toUpperCase().padStart(2, "0")})`; // Handle undefined or null
} if (errorCode === undefined || errorCode === null) {
return null;
/** }
* Check if error code represents an actual error condition
*/ // 0xDD (221) is the default "no error" value
export function hasError(errorCode: number | undefined): boolean { if (errorCode === SewingMachineError.None) {
return ( return null; // No error to display
errorCode !== undefined && }
errorCode !== null &&
errorCode !== SewingMachineError.None // Look up known error message
); const message = ERROR_MESSAGES[errorCode];
} if (message) {
return message;
/** }
* Get detailed error information including title, description, and solutions
*/ // Unknown error code
export function getErrorDetails( return `Machine Error ${errorCode} (0x${errorCode.toString(16).toUpperCase().padStart(2, '0')})`;
errorCode: number | undefined, }
): ErrorInfo | null {
// Handle undefined or null /**
if (errorCode === undefined || errorCode === null) { * Check if error code represents an actual error condition
return null; */
} export function hasError(errorCode: number | undefined): boolean {
return errorCode !== undefined && errorCode !== null && errorCode !== SewingMachineError.None;
// 0xDD (221) is the default "no error" value }
if (errorCode === SewingMachineError.None) {
return null; /**
} * Get detailed error information including title, description, and solutions
*/
// Look up known error details with solutions export function getErrorDetails(errorCode: number | undefined): ErrorInfo | null {
const details = ERROR_DETAILS[errorCode]; // Handle undefined or null
if (details) { if (errorCode === undefined || errorCode === null) {
return details; return null;
} }
// For errors without detailed solutions, return basic info // 0xDD (221) is the default "no error" value
const errorTitle = ERROR_MESSAGES[errorCode]; if (errorCode === SewingMachineError.None) {
if (errorTitle) { return null;
return { }
title: errorTitle,
description: "Please check the machine display for more information.", // Look up known error details with solutions
solutions: [ const details = ERROR_DETAILS[errorCode];
"Consult your machine manual for specific troubleshooting steps", if (details) {
"Check the error code on the machine display", return details;
"Contact technical support if the problem persists", }
],
}; // For errors without detailed solutions, return basic info
} const errorTitle = ERROR_MESSAGES[errorCode];
if (errorTitle) {
// Unknown error code return {
return { title: errorTitle,
title: `Machine Error 0x${errorCode.toString(16).toUpperCase().padStart(2, "0")}`, description: 'Please check the machine display for more information.',
description: solutions: [
"The machine has reported an error code that is not recognized.", 'Consult your machine manual for specific troubleshooting steps',
solutions: [ 'Check the error code on the machine display',
"Note the error code and consult your machine manual", 'Contact technical support if the problem persists',
"Turn the machine off and on again", ],
"If error persists, contact technical support with this error code", };
], }
};
} // Unknown error code
return {
/** title: `Machine Error 0x${errorCode.toString(16).toUpperCase().padStart(2, '0')}`,
* Export ErrorInfo type for use in other files description: 'The machine has reported an error code that is not recognized.',
*/ solutions: [
export type { ErrorInfo }; 'Note the error code and consult your machine manual',
'Turn the machine off and on again',
'If error persists, contact technical support with this error code',
],
};
}
/**
* Export ErrorInfo type for use in other files
*/
export type { ErrorInfo };

View file

@ -1,8 +1,8 @@
import Konva from "konva"; import Konva from 'konva';
import type { PesPatternData } from "../formats/import/pesImporter"; import type { PesPatternData } from '../formats/import/pesImporter';
import { getThreadColor } from "../formats/import/pesImporter"; import { getThreadColor } from '../formats/import/pesImporter';
import type { MachineInfo } from "../types/machine"; import type { MachineInfo } from '../types/machine';
import { MOVE } from "../formats/import/constants"; import { MOVE } from '../formats/import/constants';
/** /**
* Renders a grid with specified spacing * Renders a grid with specified spacing
@ -11,9 +11,9 @@ export function renderGrid(
layer: Konva.Layer, layer: Konva.Layer,
gridSize: number, gridSize: number,
bounds: { minX: number; maxX: number; minY: number; maxY: number }, bounds: { minX: number; maxX: number; minY: number; maxY: number },
machineInfo: MachineInfo | null, machineInfo: MachineInfo | null
): void { ): void {
const gridGroup = new Konva.Group({ name: "grid" }); const gridGroup = new Konva.Group({ name: 'grid' });
// Determine grid bounds based on hoop or pattern // Determine grid bounds based on hoop or pattern
const gridMinX = machineInfo ? -machineInfo.maxWidth / 2 : bounds.minX; const gridMinX = machineInfo ? -machineInfo.maxWidth / 2 : bounds.minX;
@ -22,28 +22,20 @@ export function renderGrid(
const gridMaxY = machineInfo ? machineInfo.maxHeight / 2 : bounds.maxY; const gridMaxY = machineInfo ? machineInfo.maxHeight / 2 : bounds.maxY;
// Vertical lines // Vertical lines
for ( for (let x = Math.floor(gridMinX / gridSize) * gridSize; x <= gridMaxX; x += gridSize) {
let x = Math.floor(gridMinX / gridSize) * gridSize;
x <= gridMaxX;
x += gridSize
) {
const line = new Konva.Line({ const line = new Konva.Line({
points: [x, gridMinY, x, gridMaxY], points: [x, gridMinY, x, gridMaxY],
stroke: "#e0e0e0", stroke: '#e0e0e0',
strokeWidth: 1, strokeWidth: 1,
}); });
gridGroup.add(line); gridGroup.add(line);
} }
// Horizontal lines // Horizontal lines
for ( for (let y = Math.floor(gridMinY / gridSize) * gridSize; y <= gridMaxY; y += gridSize) {
let y = Math.floor(gridMinY / gridSize) * gridSize;
y <= gridMaxY;
y += gridSize
) {
const line = new Konva.Line({ const line = new Konva.Line({
points: [gridMinX, y, gridMaxX, y], points: [gridMinX, y, gridMaxX, y],
stroke: "#e0e0e0", stroke: '#e0e0e0',
strokeWidth: 1, strokeWidth: 1,
}); });
gridGroup.add(line); gridGroup.add(line);
@ -56,19 +48,19 @@ export function renderGrid(
* Renders the origin crosshair at (0,0) * Renders the origin crosshair at (0,0)
*/ */
export function renderOrigin(layer: Konva.Layer): void { export function renderOrigin(layer: Konva.Layer): void {
const originGroup = new Konva.Group({ name: "origin" }); const originGroup = new Konva.Group({ name: 'origin' });
// Horizontal line // Horizontal line
const hLine = new Konva.Line({ const hLine = new Konva.Line({
points: [-10, 0, 10, 0], points: [-10, 0, 10, 0],
stroke: "#888", stroke: '#888',
strokeWidth: 2, strokeWidth: 2,
}); });
// Vertical line // Vertical line
const vLine = new Konva.Line({ const vLine = new Konva.Line({
points: [0, -10, 0, 10], points: [0, -10, 0, 10],
stroke: "#888", stroke: '#888',
strokeWidth: 2, strokeWidth: 2,
}); });
@ -80,7 +72,7 @@ export function renderOrigin(layer: Konva.Layer): void {
* Renders the hoop boundary and label * Renders the hoop boundary and label
*/ */
export function renderHoop(layer: Konva.Layer, machineInfo: MachineInfo): void { export function renderHoop(layer: Konva.Layer, machineInfo: MachineInfo): void {
const hoopGroup = new Konva.Group({ name: "hoop" }); const hoopGroup = new Konva.Group({ name: 'hoop' });
const hoopWidth = machineInfo.maxWidth; const hoopWidth = machineInfo.maxWidth;
const hoopHeight = machineInfo.maxHeight; const hoopHeight = machineInfo.maxHeight;
@ -95,7 +87,7 @@ export function renderHoop(layer: Konva.Layer, machineInfo: MachineInfo): void {
y: hoopTop, y: hoopTop,
width: hoopWidth, width: hoopWidth,
height: hoopHeight, height: hoopHeight,
stroke: "#2196F3", stroke: '#2196F3',
strokeWidth: 3, strokeWidth: 3,
dash: [10, 5], dash: [10, 5],
}); });
@ -106,9 +98,9 @@ export function renderHoop(layer: Konva.Layer, machineInfo: MachineInfo): void {
y: hoopTop + 10, y: hoopTop + 10,
text: `Hoop: ${(hoopWidth / 10).toFixed(0)} x ${(hoopHeight / 10).toFixed(0)} mm`, text: `Hoop: ${(hoopWidth / 10).toFixed(0)} x ${(hoopHeight / 10).toFixed(0)} mm`,
fontSize: 14, fontSize: 14,
fontFamily: "sans-serif", fontFamily: 'sans-serif',
fontStyle: "bold", fontStyle: 'bold',
fill: "#2196F3", fill: '#2196F3',
}); });
hoopGroup.add(rect, label); hoopGroup.add(rect, label);
@ -122,9 +114,9 @@ export function renderStitches(
container: Konva.Layer | Konva.Group, container: Konva.Layer | Konva.Group,
stitches: number[][], stitches: number[][],
pesData: PesPatternData, pesData: PesPatternData,
currentStitchIndex: number, currentStitchIndex: number
): void { ): void {
const stitchesGroup = new Konva.Group({ name: "stitches" }); const stitchesGroup = new Konva.Group({ name: 'stitches' });
// Group stitches by color, completion status, and type (stitch vs jump) // Group stitches by color, completion status, and type (stitch vs jump)
interface StitchGroup { interface StitchGroup {
@ -172,8 +164,8 @@ export function renderStitches(
points: group.points, points: group.points,
stroke: group.color, stroke: group.color,
strokeWidth: 1.0, strokeWidth: 1.0,
lineCap: "round", lineCap: 'round',
lineJoin: "round", lineJoin: 'round',
dash: [5, 5], dash: [5, 5],
opacity: group.completed ? 0.6 : 0.25, opacity: group.completed ? 0.6 : 0.25,
}); });
@ -184,8 +176,8 @@ export function renderStitches(
points: group.points, points: group.points,
stroke: group.color, stroke: group.color,
strokeWidth: 1.5, strokeWidth: 1.5,
lineCap: "round", lineCap: 'round',
lineJoin: "round", lineJoin: 'round',
opacity: group.completed ? 1.0 : 0.3, opacity: group.completed ? 1.0 : 0.3,
}); });
stitchesGroup.add(line); stitchesGroup.add(line);
@ -200,7 +192,7 @@ export function renderStitches(
*/ */
export function renderPatternBounds( export function renderPatternBounds(
container: Konva.Layer | Konva.Group, container: Konva.Layer | Konva.Group,
bounds: { minX: number; maxX: number; minY: number; maxY: number }, bounds: { minX: number; maxX: number; minY: number; maxY: number }
): void { ): void {
const { minX, maxX, minY, maxY } = bounds; const { minX, maxX, minY, maxY } = bounds;
const patternWidth = maxX - minX; const patternWidth = maxX - minX;
@ -211,7 +203,7 @@ export function renderPatternBounds(
y: minY, y: minY,
width: patternWidth, width: patternWidth,
height: patternHeight, height: patternHeight,
stroke: "#ff0000", stroke: '#ff0000',
strokeWidth: 2, strokeWidth: 2,
dash: [5, 5], dash: [5, 5],
}); });
@ -225,47 +217,47 @@ export function renderPatternBounds(
export function renderCurrentPosition( export function renderCurrentPosition(
container: Konva.Layer | Konva.Group, container: Konva.Layer | Konva.Group,
currentStitchIndex: number, currentStitchIndex: number,
stitches: number[][], stitches: number[][]
): void { ): void {
if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) return; if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) return;
const stitch = stitches[currentStitchIndex]; const stitch = stitches[currentStitchIndex];
const [x, y] = stitch; const [x, y] = stitch;
const posGroup = new Konva.Group({ name: "currentPosition" }); const posGroup = new Konva.Group({ name: 'currentPosition' });
// Circle with fill // Circle with fill
const circle = new Konva.Circle({ const circle = new Konva.Circle({
x, x,
y, y,
radius: 8, radius: 8,
fill: "rgba(255, 0, 0, 0.3)", fill: 'rgba(255, 0, 0, 0.3)',
stroke: "#ff0000", stroke: '#ff0000',
strokeWidth: 3, strokeWidth: 3,
}); });
// Crosshair lines // Crosshair lines
const hLine1 = new Konva.Line({ const hLine1 = new Konva.Line({
points: [x - 12, y, x - 3, y], points: [x - 12, y, x - 3, y],
stroke: "#ff0000", stroke: '#ff0000',
strokeWidth: 2, strokeWidth: 2,
}); });
const hLine2 = new Konva.Line({ const hLine2 = new Konva.Line({
points: [x + 12, y, x + 3, y], points: [x + 12, y, x + 3, y],
stroke: "#ff0000", stroke: '#ff0000',
strokeWidth: 2, strokeWidth: 2,
}); });
const vLine1 = new Konva.Line({ const vLine1 = new Konva.Line({
points: [x, y - 12, x, y - 3], points: [x, y - 12, x, y - 3],
stroke: "#ff0000", stroke: '#ff0000',
strokeWidth: 2, strokeWidth: 2,
}); });
const vLine2 = new Konva.Line({ const vLine2 = new Konva.Line({
points: [x, y + 12, x, y + 3], points: [x, y + 12, x, y + 3],
stroke: "#ff0000", stroke: '#ff0000',
strokeWidth: 2, strokeWidth: 2,
}); });
@ -278,9 +270,9 @@ export function renderCurrentPosition(
*/ */
export function renderLegend( export function renderLegend(
layer: Konva.Layer, layer: Konva.Layer,
pesData: PesPatternData, pesData: PesPatternData
): void { ): void {
const legendGroup = new Konva.Group({ name: "legend" }); const legendGroup = new Konva.Group({ name: 'legend' });
// Semi-transparent background for better readability // Semi-transparent background for better readability
const bgPadding = 8; const bgPadding = 8;
@ -292,9 +284,9 @@ export function renderLegend(
y: 10, y: 10,
width: 100, width: 100,
height: legendHeight, height: legendHeight,
fill: "rgba(255, 255, 255, 0.9)", fill: 'rgba(255, 255, 255, 0.9)',
cornerRadius: 4, cornerRadius: 4,
shadowColor: "rgba(0, 0, 0, 0.2)", shadowColor: 'rgba(0, 0, 0, 0.2)',
shadowBlur: 4, shadowBlur: 4,
shadowOffset: { x: 0, y: 2 }, shadowOffset: { x: 0, y: 2 },
}); });
@ -313,7 +305,7 @@ export function renderLegend(
width: 20, width: 20,
height: 20, height: 20,
fill: color, fill: color,
stroke: "#000", stroke: '#000',
strokeWidth: 1, strokeWidth: 1,
}); });
@ -323,8 +315,8 @@ export function renderLegend(
y: legendY + 5, y: legendY + 5,
text: `Thread ${i + 1}`, text: `Thread ${i + 1}`,
fontSize: 12, fontSize: 12,
fontFamily: "sans-serif", fontFamily: 'sans-serif',
fill: "#000", fill: '#000',
}); });
legendGroup.add(swatch, label); legendGroup.add(swatch, label);
@ -342,7 +334,7 @@ export function renderDimensions(
patternWidth: number, patternWidth: number,
patternHeight: number, patternHeight: number,
stageWidth: number, stageWidth: number,
stageHeight: number, stageHeight: number
): void { ): void {
const dimensionText = `${(patternWidth / 10).toFixed(1)} x ${(patternHeight / 10).toFixed(1)} mm`; const dimensionText = `${(patternWidth / 10).toFixed(1)} x ${(patternHeight / 10).toFixed(1)} mm`;
@ -356,9 +348,9 @@ export function renderDimensions(
y: stageHeight - textHeight - padding - 80, // Above zoom controls y: stageHeight - textHeight - padding - 80, // Above zoom controls
width: textWidth, width: textWidth,
height: textHeight, height: textHeight,
fill: "rgba(255, 255, 255, 0.9)", fill: 'rgba(255, 255, 255, 0.9)',
cornerRadius: 4, cornerRadius: 4,
shadowColor: "rgba(0, 0, 0, 0.2)", shadowColor: 'rgba(0, 0, 0, 0.2)',
shadowBlur: 4, shadowBlur: 4,
shadowOffset: { x: 0, y: 2 }, shadowOffset: { x: 0, y: 2 },
}); });
@ -370,10 +362,10 @@ export function renderDimensions(
height: textHeight, height: textHeight,
text: dimensionText, text: dimensionText,
fontSize: 14, fontSize: 14,
fontFamily: "sans-serif", fontFamily: 'sans-serif',
fill: "#000", fill: '#000',
align: "center", align: 'center',
verticalAlign: "middle", verticalAlign: 'middle',
}); });
layer.add(background, text); layer.add(background, text);
@ -387,7 +379,7 @@ export function calculateInitialScale(
stageHeight: number, stageHeight: number,
viewWidth: number, viewWidth: number,
viewHeight: number, viewHeight: number,
padding: number = 40, padding: number = 40
): number { ): number {
const scaleX = (stageWidth - 2 * padding) / viewWidth; const scaleX = (stageWidth - 2 * padding) / viewWidth;
const scaleY = (stageHeight - 2 * padding) / viewHeight; const scaleY = (stageHeight - 2 * padding) / viewHeight;

View file

@ -1,207 +1,188 @@
import { MachineStatus } from "../types/machine"; import { MachineStatus } from '../types/machine';
/** /**
* Machine state categories for safety logic * Machine state categories for safety logic
*/ */
export const MachineStateCategory = { export const MachineStateCategory = {
IDLE: "idle", IDLE: 'idle',
ACTIVE: "active", ACTIVE: 'active',
WAITING: "waiting", WAITING: 'waiting',
COMPLETE: "complete", COMPLETE: 'complete',
INTERRUPTED: "interrupted", INTERRUPTED: 'interrupted',
ERROR: "error", ERROR: 'error',
} as const; } as const;
export type MachineStateCategoryType = export type MachineStateCategoryType = typeof MachineStateCategory[keyof typeof MachineStateCategory];
(typeof MachineStateCategory)[keyof typeof MachineStateCategory];
/**
/** * Categorize a machine status into a semantic safety category
* Categorize a machine status into a semantic safety category */
*/ export function getMachineStateCategory(status: MachineStatus): MachineStateCategoryType {
export function getMachineStateCategory( switch (status) {
status: MachineStatus, // IDLE states - safe to perform any action
): MachineStateCategoryType { case MachineStatus.IDLE:
switch (status) { case MachineStatus.SEWING_WAIT:
// IDLE states - safe to perform any action case MachineStatus.Initial:
case MachineStatus.IDLE: case MachineStatus.LowerThread:
case MachineStatus.SEWING_WAIT: return MachineStateCategory.IDLE;
case MachineStatus.Initial:
case MachineStatus.LowerThread: // ACTIVE states - operation in progress, dangerous to interrupt
return MachineStateCategory.IDLE; case MachineStatus.SEWING:
case MachineStatus.MASK_TRACING:
// ACTIVE states - operation in progress, dangerous to interrupt case MachineStatus.SEWING_DATA_RECEIVE:
case MachineStatus.SEWING: case MachineStatus.HOOP_AVOIDANCEING:
case MachineStatus.MASK_TRACING: return MachineStateCategory.ACTIVE;
case MachineStatus.SEWING_DATA_RECEIVE:
case MachineStatus.HOOP_AVOIDANCEING: // WAITING states - waiting for user/machine action
return MachineStateCategory.ACTIVE; case MachineStatus.COLOR_CHANGE_WAIT:
case MachineStatus.MASK_TRACE_LOCK_WAIT:
// WAITING states - waiting for user/machine action case MachineStatus.HOOP_AVOIDANCE:
case MachineStatus.COLOR_CHANGE_WAIT: return MachineStateCategory.WAITING;
case MachineStatus.MASK_TRACE_LOCK_WAIT:
case MachineStatus.HOOP_AVOIDANCE: // COMPLETE states - operation finished
return MachineStateCategory.WAITING; case MachineStatus.SEWING_COMPLETE:
case MachineStatus.MASK_TRACE_COMPLETE:
// COMPLETE states - operation finished case MachineStatus.RL_RECEIVED:
case MachineStatus.SEWING_COMPLETE: return MachineStateCategory.COMPLETE;
case MachineStatus.MASK_TRACE_COMPLETE:
case MachineStatus.RL_RECEIVED: // INTERRUPTED states - operation paused/stopped
return MachineStateCategory.COMPLETE; case MachineStatus.PAUSE:
case MachineStatus.STOP:
// INTERRUPTED states - operation paused/stopped case MachineStatus.SEWING_INTERRUPTION:
case MachineStatus.PAUSE: return MachineStateCategory.INTERRUPTED;
case MachineStatus.STOP:
case MachineStatus.SEWING_INTERRUPTION: // ERROR/UNKNOWN states
return MachineStateCategory.INTERRUPTED; case MachineStatus.None:
case MachineStatus.TryConnecting:
// ERROR/UNKNOWN states case MachineStatus.RL_RECEIVING:
case MachineStatus.None: default:
case MachineStatus.TryConnecting: return MachineStateCategory.ERROR;
case MachineStatus.RL_RECEIVING: }
default: }
return MachineStateCategory.ERROR;
} /**
} * Determines if the pattern can be safely deleted in the current state.
* Prevents deletion during active operations (SEWING, MASK_TRACING, etc.)
/** */
* Determines if the pattern can be safely deleted in the current state. export function canDeletePattern(status: MachineStatus): boolean {
* Prevents deletion during active operations (SEWING, MASK_TRACING, etc.) const category = getMachineStateCategory(status);
*/ // Can delete in IDLE, WAITING, or COMPLETE states, never during ACTIVE operations
export function canDeletePattern(status: MachineStatus): boolean { return category === MachineStateCategory.IDLE ||
const category = getMachineStateCategory(status); category === MachineStateCategory.WAITING ||
// Can delete in IDLE, WAITING, or COMPLETE states, never during ACTIVE operations category === MachineStateCategory.COMPLETE;
return ( }
category === MachineStateCategory.IDLE ||
category === MachineStateCategory.WAITING || /**
category === MachineStateCategory.COMPLETE * Determines if a pattern can be safely uploaded in the current state.
); * Only allow uploads when machine is idle or in a complete state.
} */
export function canUploadPattern(status: MachineStatus): boolean {
/** const category = getMachineStateCategory(status);
* Determines if a pattern can be safely uploaded in the current state. // Can upload in IDLE or COMPLETE states (includes MASK_TRACE_COMPLETE)
* Only allow uploads when machine is idle or in a complete state. return category === MachineStateCategory.IDLE ||
*/ category === MachineStateCategory.COMPLETE;
export function canUploadPattern(status: MachineStatus): boolean { }
const category = getMachineStateCategory(status);
// Can upload in IDLE or COMPLETE states (includes MASK_TRACE_COMPLETE) /**
return ( * Determines if sewing can be started in the current state.
category === MachineStateCategory.IDLE || * Allows starting from ready state or resuming from interrupted states.
category === MachineStateCategory.COMPLETE */
); export function canStartSewing(status: MachineStatus): boolean {
} // Only in specific ready states
return status === MachineStatus.SEWING_WAIT ||
/** status === MachineStatus.MASK_TRACE_COMPLETE ||
* Determines if sewing can be started in the current state. status === MachineStatus.PAUSE ||
* Allows starting from ready state or resuming from interrupted states. status === MachineStatus.STOP ||
*/ status === MachineStatus.SEWING_INTERRUPTION;
export function canStartSewing(status: MachineStatus): boolean { }
// Only in specific ready states
return ( /**
status === MachineStatus.SEWING_WAIT || * Determines if mask trace can be started in the current state.
status === MachineStatus.MASK_TRACE_COMPLETE || */
status === MachineStatus.PAUSE || export function canStartMaskTrace(status: MachineStatus): boolean {
status === MachineStatus.STOP || // Can start mask trace when IDLE (after upload), SEWING_WAIT, or after previous trace
status === MachineStatus.SEWING_INTERRUPTION return status === MachineStatus.IDLE ||
); status === MachineStatus.SEWING_WAIT ||
} status === MachineStatus.MASK_TRACE_COMPLETE;
}
/**
* Determines if mask trace can be started in the current state. /**
*/ * Determines if sewing can be resumed in the current state.
export function canStartMaskTrace(status: MachineStatus): boolean { * Only for interrupted operations (PAUSE, STOP, SEWING_INTERRUPTION).
// Can start mask trace when IDLE (after upload), SEWING_WAIT, or after previous trace */
return ( export function canResumeSewing(status: MachineStatus): boolean {
status === MachineStatus.IDLE || // Only in interrupted states
status === MachineStatus.SEWING_WAIT || const category = getMachineStateCategory(status);
status === MachineStatus.MASK_TRACE_COMPLETE return category === MachineStateCategory.INTERRUPTED;
); }
}
/**
/** * Determines if disconnect should show a confirmation dialog.
* Determines if sewing can be resumed in the current state. * Confirms if disconnecting during active operation or while waiting.
* Only for interrupted operations (PAUSE, STOP, SEWING_INTERRUPTION). */
*/ export function shouldConfirmDisconnect(status: MachineStatus): boolean {
export function canResumeSewing(status: MachineStatus): boolean { const category = getMachineStateCategory(status);
// Only in interrupted states // Confirm if disconnecting during active operation or waiting for action
const category = getMachineStateCategory(status); return category === MachineStateCategory.ACTIVE ||
return category === MachineStateCategory.INTERRUPTED; category === MachineStateCategory.WAITING;
} }
/** /**
* Determines if disconnect should show a confirmation dialog. * Visual information for a machine state
* Confirms if disconnecting during active operation or while waiting. */
*/ export interface StateVisualInfo {
export function shouldConfirmDisconnect(status: MachineStatus): boolean { color: string;
const category = getMachineStateCategory(status); iconName: 'ready' | 'active' | 'waiting' | 'complete' | 'interrupted' | 'error';
// Confirm if disconnecting during active operation or waiting for action label: string;
return ( description: string;
category === MachineStateCategory.ACTIVE || }
category === MachineStateCategory.WAITING
); /**
} * Get visual styling information for a machine state.
* Returns color, icon, label, and description for UI display.
/** */
* Visual information for a machine state export function getStateVisualInfo(status: MachineStatus): StateVisualInfo {
*/ const category = getMachineStateCategory(status);
export interface StateVisualInfo {
color: string; // Map state category to visual properties
iconName: const visualMap: Record<MachineStateCategoryType, StateVisualInfo> = {
| "ready" [MachineStateCategory.IDLE]: {
| "active" color: 'info',
| "waiting" iconName: 'ready',
| "complete" label: 'Ready',
| "interrupted" description: 'Machine is idle and ready for operations'
| "error"; },
label: string; [MachineStateCategory.ACTIVE]: {
description: string; color: 'warning',
} iconName: 'active',
label: 'Active',
/** description: 'Operation in progress - do not interrupt'
* Get visual styling information for a machine state. },
* Returns color, icon, label, and description for UI display. [MachineStateCategory.WAITING]: {
*/ color: 'warning',
export function getStateVisualInfo(status: MachineStatus): StateVisualInfo { iconName: 'waiting',
const category = getMachineStateCategory(status); label: 'Waiting',
description: 'Waiting for user or machine action'
// Map state category to visual properties },
const visualMap: Record<MachineStateCategoryType, StateVisualInfo> = { [MachineStateCategory.COMPLETE]: {
[MachineStateCategory.IDLE]: { color: 'success',
color: "info", iconName: 'complete',
iconName: "ready", label: 'Complete',
label: "Ready", description: 'Operation completed successfully'
description: "Machine is idle and ready for operations", },
}, [MachineStateCategory.INTERRUPTED]: {
[MachineStateCategory.ACTIVE]: { color: 'danger',
color: "warning", iconName: 'interrupted',
iconName: "active", label: 'Interrupted',
label: "Active", description: 'Operation paused or stopped'
description: "Operation in progress - do not interrupt", },
}, [MachineStateCategory.ERROR]: {
[MachineStateCategory.WAITING]: { color: 'danger',
color: "warning", iconName: 'error',
iconName: "waiting", label: 'Error',
label: "Waiting", description: 'Machine in error or unknown state'
description: "Waiting for user or machine action", }
}, };
[MachineStateCategory.COMPLETE]: {
color: "success", return visualMap[category];
iconName: "complete", }
label: "Complete",
description: "Operation completed successfully",
},
[MachineStateCategory.INTERRUPTED]: {
color: "danger",
iconName: "interrupted",
label: "Interrupted",
description: "Operation paused or stopped",
},
[MachineStateCategory.ERROR]: {
color: "danger",
iconName: "error",
label: "Error",
description: "Machine in error or unknown state",
},
};
return visualMap[category];
}

View file

@ -20,7 +20,7 @@ export function convertStitchesToMinutes(stitchCount: number): number {
*/ */
export function calculatePatternTime( export function calculatePatternTime(
colorBlocks: Array<{ stitchCount: number }>, colorBlocks: Array<{ stitchCount: number }>,
currentStitch: number, currentStitch: number
): { ): {
totalMinutes: number; totalMinutes: number;
elapsedMinutes: number; elapsedMinutes: number;
@ -44,8 +44,7 @@ export function calculatePatternTime(
break; break;
} else { } else {
// We're partway through this block // We're partway through this block
const stitchesInBlock = const stitchesInBlock = currentStitch - (cumulativeStitches - block.stitchCount);
currentStitch - (cumulativeStitches - block.stitchCount);
elapsedMinutes += convertStitchesToMinutes(stitchesInBlock); elapsedMinutes += convertStitchesToMinutes(stitchesInBlock);
break; break;
} }
@ -64,5 +63,5 @@ export function calculatePatternTime(
export function formatMinutes(minutes: number): string { export function formatMinutes(minutes: number): string {
const mins = Math.floor(minutes); const mins = Math.floor(minutes);
const secs = Math.round((minutes - mins) * 60); const secs = Math.round((minutes - mins) * 60);
return `${mins}:${String(secs).padStart(2, "0")}`; return `${mins}:${String(secs).padStart(2, '0')}`;
} }

View file

@ -1,9 +1,9 @@
import { defineConfig } from "vitest/config"; import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
test: { test: {
globals: true, globals: true,
environment: "node", environment: 'node',
include: ["src/**/*.{test,spec}.{js,ts}"], include: ['src/**/*.{test,spec}.{js,ts}'],
}, },
}); });