mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 02:13:41 +00:00
fix: run linter
Some checks are pending
Build, Test, and Lint / Build, Test, and Lint (push) Waiting to run
Draft Release / Draft Release (push) Waiting to run
Draft Release / Build Web App (push) Blocked by required conditions
Draft Release / Build Release - macos-latest (push) Blocked by required conditions
Draft Release / Build Release - ubuntu-latest (push) Blocked by required conditions
Draft Release / Build Release - windows-latest (push) Blocked by required conditions
Draft Release / Upload to GitHub Release (push) Blocked by required conditions
Some checks are pending
Build, Test, and Lint / Build, Test, and Lint (push) Waiting to run
Draft Release / Draft Release (push) Waiting to run
Draft Release / Build Web App (push) Blocked by required conditions
Draft Release / Build Release - macos-latest (push) Blocked by required conditions
Draft Release / Build Release - ubuntu-latest (push) Blocked by required conditions
Draft Release / Build Release - windows-latest (push) Blocked by required conditions
Draft Release / Upload to GitHub Release (push) Blocked by required conditions
This commit is contained in:
parent
20b8d47b6a
commit
a253901fb4
44 changed files with 3246 additions and 2523 deletions
|
|
@ -1,19 +1,19 @@
|
|||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
// Expose protected methods that allow the renderer process to use
|
||||
// ipcRenderer without exposing the entire object
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
invoke: (channel: string, ...args: unknown[]) => {
|
||||
const validChannels = [
|
||||
'storage:savePattern',
|
||||
'storage:getPattern',
|
||||
'storage:getLatest',
|
||||
'storage:deletePattern',
|
||||
'storage:clear',
|
||||
'dialog:openFile',
|
||||
'dialog:saveFile',
|
||||
'fs:readFile',
|
||||
'fs:writeFile',
|
||||
"storage:savePattern",
|
||||
"storage:getPattern",
|
||||
"storage:getLatest",
|
||||
"storage:deletePattern",
|
||||
"storage:clear",
|
||||
"dialog:openFile",
|
||||
"dialog:saveFile",
|
||||
"fs:readFile",
|
||||
"fs:writeFile",
|
||||
];
|
||||
|
||||
if (validChannels.includes(channel)) {
|
||||
|
|
@ -23,15 +23,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
throw new Error(`Invalid IPC channel: ${channel}`);
|
||||
},
|
||||
// Bluetooth device selection
|
||||
onBluetoothDeviceList: (callback: (devices: Array<{ deviceId: string; deviceName: string }>) => void) => {
|
||||
ipcRenderer.on('bluetooth:device-list', (_event, devices) => callback(devices));
|
||||
onBluetoothDeviceList: (
|
||||
callback: (
|
||||
devices: Array<{ deviceId: string; deviceName: string }>,
|
||||
) => void,
|
||||
) => {
|
||||
ipcRenderer.on("bluetooth:device-list", (_event, devices) =>
|
||||
callback(devices),
|
||||
);
|
||||
},
|
||||
selectBluetoothDevice: (deviceId: string) => {
|
||||
ipcRenderer.send('bluetooth:select-device', deviceId);
|
||||
ipcRenderer.send("bluetooth:select-device", deviceId);
|
||||
},
|
||||
});
|
||||
|
||||
// Also expose process type for platform detection
|
||||
contextBridge.exposeInMainWorld('process', {
|
||||
type: 'renderer',
|
||||
contextBridge.exposeInMainWorld("process", {
|
||||
type: "renderer",
|
||||
});
|
||||
|
|
|
|||
57
src/App.tsx
57
src/App.tsx
|
|
@ -1,14 +1,14 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useMachineStore } from './stores/useMachineStore';
|
||||
import { usePatternStore } from './stores/usePatternStore';
|
||||
import { useUIStore } from './stores/useUIStore';
|
||||
import { AppHeader } from './components/AppHeader';
|
||||
import { LeftSidebar } from './components/LeftSidebar';
|
||||
import { PatternCanvas } from './components/PatternCanvas';
|
||||
import { PatternPreviewPlaceholder } from './components/PatternPreviewPlaceholder';
|
||||
import { BluetoothDevicePicker } from './components/BluetoothDevicePicker';
|
||||
import './App.css';
|
||||
import { useEffect } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMachineStore } from "./stores/useMachineStore";
|
||||
import { usePatternStore } from "./stores/usePatternStore";
|
||||
import { useUIStore } from "./stores/useUIStore";
|
||||
import { AppHeader } from "./components/AppHeader";
|
||||
import { LeftSidebar } from "./components/LeftSidebar";
|
||||
import { PatternCanvas } from "./components/PatternCanvas";
|
||||
import { PatternPreviewPlaceholder } from "./components/PatternPreviewPlaceholder";
|
||||
import { BluetoothDevicePicker } from "./components/BluetoothDevicePicker";
|
||||
import "./App.css";
|
||||
|
||||
function App() {
|
||||
// Set page title with version
|
||||
|
|
@ -17,36 +17,27 @@ function App() {
|
|||
}, []);
|
||||
|
||||
// Machine store - for auto-loading cached pattern
|
||||
const {
|
||||
resumedPattern,
|
||||
resumeFileName,
|
||||
} = useMachineStore(
|
||||
const { resumedPattern, resumeFileName } = useMachineStore(
|
||||
useShallow((state) => ({
|
||||
resumedPattern: state.resumedPattern,
|
||||
resumeFileName: state.resumeFileName,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
// Pattern store - for auto-loading cached pattern
|
||||
const {
|
||||
pesData,
|
||||
setPattern,
|
||||
setPatternOffset,
|
||||
} = usePatternStore(
|
||||
const { pesData, setPattern, setPatternOffset } = usePatternStore(
|
||||
useShallow((state) => ({
|
||||
pesData: state.pesData,
|
||||
setPattern: state.setPattern,
|
||||
setPatternOffset: state.setPatternOffset,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
// UI store - for Pyodide initialization
|
||||
const {
|
||||
initializePyodide,
|
||||
} = useUIStore(
|
||||
const { initializePyodide } = useUIStore(
|
||||
useShallow((state) => ({
|
||||
initializePyodide: state.initializePyodide,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
// Initialize Pyodide in background on mount (non-blocking thanks to worker)
|
||||
|
|
@ -57,11 +48,19 @@ function App() {
|
|||
// Auto-load cached pattern when available
|
||||
useEffect(() => {
|
||||
if (resumedPattern && !pesData) {
|
||||
console.log('[App] Loading resumed pattern:', resumeFileName, 'Offset:', resumedPattern.patternOffset);
|
||||
setPattern(resumedPattern.pesData, resumeFileName || '');
|
||||
console.log(
|
||||
"[App] Loading resumed pattern:",
|
||||
resumeFileName,
|
||||
"Offset:",
|
||||
resumedPattern.patternOffset,
|
||||
);
|
||||
setPattern(resumedPattern.pesData, resumeFileName || "");
|
||||
// Restore the cached pattern offset
|
||||
if (resumedPattern.patternOffset) {
|
||||
setPatternOffset(resumedPattern.patternOffset.x, resumedPattern.patternOffset.y);
|
||||
setPatternOffset(
|
||||
resumedPattern.patternOffset.x,
|
||||
resumedPattern.patternOffset.y,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [resumedPattern, resumeFileName, pesData, setPattern, setPatternOffset]);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { useRef, useEffect } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useMachineStore } from '../stores/useMachineStore';
|
||||
import { useUIStore } from '../stores/useUIStore';
|
||||
import { WorkflowStepper } from './WorkflowStepper';
|
||||
import { ErrorPopover } from './ErrorPopover';
|
||||
import { getStateVisualInfo } from '../utils/machineStateHelpers';
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMachineStore } from "../stores/useMachineStore";
|
||||
import { useUIStore } from "../stores/useUIStore";
|
||||
import { WorkflowStepper } from "./WorkflowStepper";
|
||||
import { ErrorPopover } from "./ErrorPopover";
|
||||
import { getStateVisualInfo } from "../utils/machineStateHelpers";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
BoltIcon,
|
||||
|
|
@ -12,7 +12,7 @@ import {
|
|||
ExclamationTriangleIcon,
|
||||
ArrowPathIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
} from "@heroicons/react/24/solid";
|
||||
|
||||
export function AppHeader() {
|
||||
const {
|
||||
|
|
@ -36,19 +36,15 @@ export function AppHeader() {
|
|||
isPairingError: state.isPairingError,
|
||||
isCommunicating: state.isCommunicating,
|
||||
disconnect: state.disconnect,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
const {
|
||||
pyodideError,
|
||||
showErrorPopover,
|
||||
setErrorPopover,
|
||||
} = useUIStore(
|
||||
const { pyodideError, showErrorPopover, setErrorPopover } = useUIStore(
|
||||
useShallow((state) => ({
|
||||
pyodideError: state.pyodideError,
|
||||
showErrorPopover: state.showErrorPopover,
|
||||
setErrorPopover: state.setErrorPopover,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
const errorPopoverRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -80,8 +76,9 @@ export function AppHeader() {
|
|||
};
|
||||
|
||||
if (showErrorPopover) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
}, [showErrorPopover, setErrorPopover]);
|
||||
|
||||
|
|
@ -90,33 +87,44 @@ export function AppHeader() {
|
|||
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center">
|
||||
{/* Machine Connection Status - Responsive width column */}
|
||||
<div className="flex items-center gap-3 w-full lg:w-[280px]">
|
||||
<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>
|
||||
<div className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5" style={{ visibility: !isConnected ? 'visible' : 'hidden' }}></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>
|
||||
<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 items-center gap-2">
|
||||
<h1 className="text-lg lg:text-xl font-bold text-white leading-tight">Respira</h1>
|
||||
<h1 className="text-lg lg:text-xl font-bold text-white leading-tight">
|
||||
Respira
|
||||
</h1>
|
||||
{isConnected && machineInfo?.serialNumber && (
|
||||
<span
|
||||
className="text-xs text-primary-200 cursor-help"
|
||||
title={`Serial: ${machineInfo.serialNumber}${
|
||||
machineInfo.macAddress
|
||||
? `\nMAC: ${machineInfo.macAddress}`
|
||||
: ''
|
||||
: ""
|
||||
}${
|
||||
machineInfo.totalCount !== undefined
|
||||
? `\nTotal stitches: ${machineInfo.totalCount.toLocaleString()}`
|
||||
: ''
|
||||
: ""
|
||||
}${
|
||||
machineInfo.serviceCount !== undefined
|
||||
? `\nStitches since service: ${machineInfo.serviceCount.toLocaleString()}`
|
||||
: ''
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
• {machineInfo.serialNumber}
|
||||
</span>
|
||||
)}
|
||||
{isPolling && (
|
||||
<ArrowPathIcon className="w-3.5 h-3.5 text-primary-200 animate-spin" title="Auto-refreshing status" />
|
||||
<ArrowPathIcon
|
||||
className="w-3.5 h-3.5 text-primary-200 animate-spin"
|
||||
title="Auto-refreshing status"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 min-h-[32px]">
|
||||
|
|
@ -146,9 +154,9 @@ export function AppHeader() {
|
|||
ref={errorButtonRef}
|
||||
onClick={() => setErrorPopover(!showErrorPopover)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-danger-500/90 hover:bg-danger-600 text-white border border-danger-400 transition-all flex-shrink-0 ${
|
||||
(machineErrorMessage || pyodideError)
|
||||
? 'cursor-pointer animate-pulse hover:animate-none'
|
||||
: 'invisible pointer-events-none'
|
||||
machineErrorMessage || pyodideError
|
||||
? "cursor-pointer animate-pulse hover:animate-none"
|
||||
: "invisible pointer-events-none"
|
||||
}`}
|
||||
title="Click to view error details"
|
||||
aria-label="View error details"
|
||||
|
|
@ -157,27 +165,30 @@ export function AppHeader() {
|
|||
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>
|
||||
{(() => {
|
||||
if (pyodideError) return 'Python Error';
|
||||
if (isPairingError) return 'Pairing Required';
|
||||
if (pyodideError) return "Python Error";
|
||||
if (isPairingError) return "Pairing Required";
|
||||
|
||||
const errorMsg = machineErrorMessage || '';
|
||||
const errorMsg = machineErrorMessage || "";
|
||||
|
||||
// Categorize by error message content
|
||||
if (errorMsg.toLowerCase().includes('bluetooth') || errorMsg.toLowerCase().includes('connection')) {
|
||||
return 'Connection Error';
|
||||
if (
|
||||
errorMsg.toLowerCase().includes("bluetooth") ||
|
||||
errorMsg.toLowerCase().includes("connection")
|
||||
) {
|
||||
return "Connection Error";
|
||||
}
|
||||
if (errorMsg.toLowerCase().includes('upload')) {
|
||||
return 'Upload Error';
|
||||
if (errorMsg.toLowerCase().includes("upload")) {
|
||||
return "Upload Error";
|
||||
}
|
||||
if (errorMsg.toLowerCase().includes('pattern')) {
|
||||
return 'Pattern Error';
|
||||
if (errorMsg.toLowerCase().includes("pattern")) {
|
||||
return "Pattern Error";
|
||||
}
|
||||
if (machineError !== undefined) {
|
||||
return `Machine Error`;
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return 'Error';
|
||||
return "Error";
|
||||
})()}
|
||||
</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState, useCallback } from 'react';
|
||||
import type { BluetoothDevice } from '../types/electron';
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import type { BluetoothDevice } from "../types/electron";
|
||||
|
||||
export function BluetoothDevicePicker() {
|
||||
const [devices, setDevices] = useState<BluetoothDevice[]>([]);
|
||||
|
|
@ -10,7 +10,7 @@ export function BluetoothDevicePicker() {
|
|||
// Only set up listener in Electron
|
||||
if (window.electronAPI?.onBluetoothDeviceList) {
|
||||
window.electronAPI.onBluetoothDeviceList((deviceList) => {
|
||||
console.log('[BluetoothPicker] Received device list:', deviceList);
|
||||
console.log("[BluetoothPicker] Received device list:", deviceList);
|
||||
setDevices(deviceList);
|
||||
// Open the picker when scan starts (even if empty at first)
|
||||
if (!isOpen) {
|
||||
|
|
@ -26,38 +26,44 @@ export function BluetoothDevicePicker() {
|
|||
}, [isOpen]);
|
||||
|
||||
const handleSelectDevice = useCallback((deviceId: string) => {
|
||||
console.log('[BluetoothPicker] User selected device:', deviceId);
|
||||
console.log("[BluetoothPicker] User selected device:", deviceId);
|
||||
window.electronAPI?.selectBluetoothDevice(deviceId);
|
||||
setIsOpen(false);
|
||||
setDevices([]);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
console.log('[BluetoothPicker] User cancelled device selection');
|
||||
window.electronAPI?.selectBluetoothDevice('');
|
||||
console.log("[BluetoothPicker] User cancelled device selection");
|
||||
window.electronAPI?.selectBluetoothDevice("");
|
||||
setIsOpen(false);
|
||||
setDevices([]);
|
||||
setIsScanning(false);
|
||||
}, []);
|
||||
|
||||
// Handle escape key
|
||||
const handleEscape = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
const handleEscape = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
handleCancel();
|
||||
}
|
||||
}, [handleCancel]);
|
||||
},
|
||||
[handleCancel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}
|
||||
}, [isOpen, handleEscape]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]" onClick={handleCancel}>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
|
@ -66,23 +72,48 @@ export function BluetoothDevicePicker() {
|
|||
aria-describedby="bluetooth-picker-message"
|
||||
>
|
||||
<div className="p-6 border-b border-gray-300 dark:border-gray-600">
|
||||
<h3 id="bluetooth-picker-title" className="m-0 text-base lg:text-lg font-semibold dark:text-white">
|
||||
<h3
|
||||
id="bluetooth-picker-title"
|
||||
className="m-0 text-base lg:text-lg font-semibold dark:text-white"
|
||||
>
|
||||
Select Bluetooth Device
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{isScanning && devices.length === 0 ? (
|
||||
<div className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
|
||||
<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">
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<span id="bluetooth-picker-message">Scanning for Bluetooth devices...</span>
|
||||
<span id="bluetooth-picker-message">
|
||||
Scanning for Bluetooth devices...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p id="bluetooth-picker-message" 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
|
||||
id="bluetooth-picker-message"
|
||||
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>
|
||||
<div className="space-y-2">
|
||||
{devices.map((device) => (
|
||||
|
|
@ -92,8 +123,12 @@ export function BluetoothDevicePicker() {
|
|||
className="w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 text-left rounded-lg font-medium text-sm hover:bg-primary-100 dark:hover:bg-primary-900 hover:text-primary-900 dark:hover:text-primary-100 active:bg-primary-200 dark:active:bg-primary-800 transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
aria-label={`Connect to ${device.deviceName}`}
|
||||
>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">{device.deviceName}</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">{device.deviceId}</div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">
|
||||
{device.deviceName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
{device.deviceId}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useCallback } from 'react';
|
||||
import { useEffect, useCallback } from "react";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -8,49 +8,65 @@ interface ConfirmDialogProps {
|
|||
cancelText?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
variant?: 'danger' | 'warning';
|
||||
variant?: "danger" | "warning";
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
variant = 'warning',
|
||||
variant = "warning",
|
||||
}: ConfirmDialogProps) {
|
||||
// Handle escape key
|
||||
const handleEscape = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
const handleEscape = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onCancel();
|
||||
}
|
||||
}, [onCancel]);
|
||||
},
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}
|
||||
}, [isOpen, handleEscape]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]" onClick={onCancel}>
|
||||
<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'}`}
|
||||
className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<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"}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="dialog-title"
|
||||
aria-describedby="dialog-message"
|
||||
>
|
||||
<div className="p-6 border-b border-gray-300 dark:border-gray-600">
|
||||
<h3 id="dialog-title" className="m-0 text-base lg:text-lg font-semibold dark:text-white">{title}</h3>
|
||||
<h3
|
||||
id="dialog-title"
|
||||
className="m-0 text-base lg:text-lg font-semibold dark:text-white"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p id="dialog-message" className="m-0 leading-relaxed text-gray-900 dark:text-gray-100">{message}</p>
|
||||
<p
|
||||
id="dialog-message"
|
||||
className="m-0 leading-relaxed text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 px-6 flex gap-3 justify-end border-t border-gray-300 dark:border-gray-600">
|
||||
<button
|
||||
|
|
@ -64,9 +80,9 @@ export function ConfirmDialog({
|
|||
<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'
|
||||
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}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useMachineStore } from '../stores/useMachineStore';
|
||||
import { isBluetoothSupported } from '../utils/bluetoothSupport';
|
||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/solid';
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMachineStore } from "../stores/useMachineStore";
|
||||
import { isBluetoothSupported } from "../utils/bluetoothSupport";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
export function ConnectionPrompt() {
|
||||
const { connect } = useMachineStore(
|
||||
useShallow((state) => ({
|
||||
connect: state.connect,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
if (isBluetoothSupported()) {
|
||||
|
|
@ -15,13 +15,27 @@ export function ConnectionPrompt() {
|
|||
<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="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5">
|
||||
<svg className="w-6 h-6" 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
|
||||
className="w-6 h-6"
|
||||
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>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Get Started</h3>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">Connect to your embroidery machine</p>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Get Started
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Connect to your embroidery machine
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -39,16 +53,21 @@ export function ConnectionPrompt() {
|
|||
<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" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-warning-900 dark:text-warning-100 mb-2">Browser Not Supported</h3>
|
||||
<h3 className="text-base font-semibold text-warning-900 dark:text-warning-100 mb-2">
|
||||
Browser Not Supported
|
||||
</h3>
|
||||
<p className="text-sm text-warning-800 dark:text-warning-200 mb-3">
|
||||
Your browser doesn't support Web Bluetooth, which is required to connect to your embroidery machine.
|
||||
Your browser doesn't support Web Bluetooth, which is required to
|
||||
connect to your embroidery machine.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-warning-900 dark:text-warning-100">Please try one of these options:</p>
|
||||
<p className="text-sm font-semibold text-warning-900 dark:text-warning-100">
|
||||
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">
|
||||
<li>Use a supported browser (Chrome, Edge, or Opera)</li>
|
||||
<li>
|
||||
Download the Desktop app from{' '}
|
||||
Download the Desktop app from{" "}
|
||||
<a
|
||||
href="https://github.com/jhbruhn/respira/releases/latest"
|
||||
target="_blank"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { forwardRef } from 'react';
|
||||
import { ExclamationTriangleIcon, InformationCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { getErrorDetails } from '../utils/errorCodeHelpers';
|
||||
import { forwardRef } from "react";
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { getErrorDetails } from "../utils/errorCodeHelpers";
|
||||
|
||||
interface ErrorPopoverProps {
|
||||
machineError?: number;
|
||||
|
|
@ -13,31 +16,32 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
|
|||
({ machineError, isPairingError, errorMessage, pyodideError }, ref) => {
|
||||
const errorDetails = getErrorDetails(machineError);
|
||||
const isPairingErr = isPairingError;
|
||||
const errorMsg = pyodideError || errorMessage || '';
|
||||
const errorMsg = pyodideError || errorMessage || "";
|
||||
const isInfo = isPairingErr || errorDetails?.isInformational;
|
||||
|
||||
const bgColor = isInfo
|
||||
? '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-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";
|
||||
|
||||
const iconColor = isInfo
|
||||
? 'text-info-600 dark:text-info-400'
|
||||
: 'text-danger-600 dark:text-danger-400';
|
||||
? "text-info-600 dark:text-info-400"
|
||||
: "text-danger-600 dark:text-danger-400";
|
||||
|
||||
const textColor = isInfo
|
||||
? 'text-info-900 dark:text-info-200'
|
||||
: 'text-danger-900 dark:text-danger-200';
|
||||
? "text-info-900 dark:text-info-200"
|
||||
: "text-danger-900 dark:text-danger-200";
|
||||
|
||||
const descColor = isInfo
|
||||
? 'text-info-800 dark:text-info-300'
|
||||
: 'text-danger-800 dark:text-danger-300';
|
||||
? "text-info-800 dark:text-info-300"
|
||||
: "text-danger-800 dark:text-danger-300";
|
||||
|
||||
const listColor = isInfo
|
||||
? 'text-info-700 dark:text-info-300'
|
||||
: 'text-danger-700 dark:text-danger-300';
|
||||
? "text-info-700 dark:text-info-300"
|
||||
: "text-danger-700 dark:text-danger-300";
|
||||
|
||||
const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
|
||||
const title = errorDetails?.title || (isPairingErr ? 'Pairing Required' : 'Error');
|
||||
const title =
|
||||
errorDetails?.title || (isPairingErr ? "Pairing Required" : "Error");
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -46,7 +50,9 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
|
|||
role="dialog"
|
||||
aria-label="Error details"
|
||||
>
|
||||
<div className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}>
|
||||
<div
|
||||
className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
|
||||
<div className="flex-1">
|
||||
|
|
@ -59,18 +65,23 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
|
|||
{errorDetails?.solutions && errorDetails.solutions.length > 0 && (
|
||||
<>
|
||||
<h4 className={`text-sm font-semibold ${textColor} mb-2`}>
|
||||
{isInfo ? 'Steps:' : 'How to Fix:'}
|
||||
{isInfo ? "Steps:" : "How to Fix:"}
|
||||
</h4>
|
||||
<ol className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`}>
|
||||
<ol
|
||||
className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`}
|
||||
>
|
||||
{errorDetails.solutions.map((solution, index) => (
|
||||
<li key={index} className="pl-2">{solution}</li>
|
||||
<li key={index} className="pl-2">
|
||||
{solution}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
{machineError !== undefined && !errorDetails?.isInformational && (
|
||||
<p className={`text-xs ${descColor} mt-3 font-mono`}>
|
||||
Error Code: 0x{machineError.toString(16).toUpperCase().padStart(2, '0')}
|
||||
Error Code: 0x
|
||||
{machineError.toString(16).toUpperCase().padStart(2, "0")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -78,7 +89,7 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ErrorPopover.displayName = 'ErrorPopover';
|
||||
ErrorPopover.displayName = "ErrorPopover";
|
||||
|
|
|
|||
|
|
@ -1,15 +1,26 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore';
|
||||
import { usePatternStore } from '../stores/usePatternStore';
|
||||
import { useUIStore } from '../stores/useUIStore';
|
||||
import { convertPesToPen, type PesPatternData } from '../formats/import/pesImporter';
|
||||
import { canUploadPattern, getMachineStateCategory } from '../utils/machineStateHelpers';
|
||||
import { PatternInfoSkeleton } from './SkeletonLoader';
|
||||
import { PatternInfo } from './PatternInfo';
|
||||
import { ArrowUpTrayIcon, CheckCircleIcon, DocumentTextIcon, FolderOpenIcon } from '@heroicons/react/24/solid';
|
||||
import { createFileService } from '../platform';
|
||||
import type { IFileService } from '../platform/interfaces/IFileService';
|
||||
import { useState, useCallback } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
|
||||
import { usePatternStore } from "../stores/usePatternStore";
|
||||
import { useUIStore } from "../stores/useUIStore";
|
||||
import {
|
||||
convertPesToPen,
|
||||
type PesPatternData,
|
||||
} from "../formats/import/pesImporter";
|
||||
import {
|
||||
canUploadPattern,
|
||||
getMachineStateCategory,
|
||||
} from "../utils/machineStateHelpers";
|
||||
import { PatternInfoSkeleton } from "./SkeletonLoader";
|
||||
import { PatternInfo } from "./PatternInfo";
|
||||
import {
|
||||
ArrowUpTrayIcon,
|
||||
CheckCircleIcon,
|
||||
DocumentTextIcon,
|
||||
FolderOpenIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { createFileService } from "../platform";
|
||||
import type { IFileService } from "../platform/interfaces/IFileService";
|
||||
|
||||
export function FileUpload() {
|
||||
// Machine store
|
||||
|
|
@ -32,7 +43,7 @@ export function FileUpload() {
|
|||
resumeAvailable: state.resumeAvailable,
|
||||
resumeFileName: state.resumeFileName,
|
||||
uploadPattern: state.uploadPattern,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
// Pattern store
|
||||
|
|
@ -47,7 +58,7 @@ export function FileUpload() {
|
|||
currentFileName: state.currentFileName,
|
||||
patternOffset: state.patternOffset,
|
||||
setPattern: state.setPattern,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
// Derived state: pattern is uploaded if machine has pattern info
|
||||
|
|
@ -65,16 +76,16 @@ export function FileUpload() {
|
|||
pyodideProgress: state.pyodideProgress,
|
||||
pyodideLoadingStep: state.pyodideLoadingStep,
|
||||
initializePyodide: state.initializePyodide,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
const [localPesData, setLocalPesData] = useState<PesPatternData | null>(null);
|
||||
const [fileName, setFileName] = useState<string>('');
|
||||
const [fileName, setFileName] = useState<string>("");
|
||||
const [fileService] = useState<IFileService>(() => createFileService());
|
||||
|
||||
// Use prop pesData if available (from cached pattern), otherwise use local state
|
||||
const pesData = pesDataProp || localPesData;
|
||||
// Use currentFileName from App state, or local fileName, or resumeFileName for display
|
||||
const displayFileName = currentFileName || fileName || resumeFileName || '';
|
||||
const displayFileName = currentFileName || fileName || resumeFileName || "";
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
|
|
@ -83,16 +94,16 @@ export function FileUpload() {
|
|||
try {
|
||||
// Wait for Pyodide if it's still loading
|
||||
if (!pyodideReady) {
|
||||
console.log('[FileUpload] Waiting for Pyodide to finish loading...');
|
||||
console.log("[FileUpload] Waiting for Pyodide to finish loading...");
|
||||
await initializePyodide();
|
||||
console.log('[FileUpload] Pyodide ready');
|
||||
console.log("[FileUpload] Pyodide ready");
|
||||
}
|
||||
|
||||
let file: File | null = null;
|
||||
|
||||
// In Electron, use native file dialogs
|
||||
if (fileService.hasNativeDialogs()) {
|
||||
file = await fileService.openFileDialog({ accept: '.pes' });
|
||||
file = await fileService.openFileDialog({ accept: ".pes" });
|
||||
} else {
|
||||
// In browser, use the input element
|
||||
file = event?.target.files?.[0] || null;
|
||||
|
|
@ -110,14 +121,14 @@ export function FileUpload() {
|
|||
} catch (err) {
|
||||
alert(
|
||||
`Failed to load PES file: ${
|
||||
err instanceof Error ? err.message : 'Unknown error'
|
||||
}`
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[fileService, setPattern, pyodideReady, initializePyodide]
|
||||
[fileService, setPattern, pyodideReady, initializePyodide],
|
||||
);
|
||||
|
||||
const handleUpload = useCallback(() => {
|
||||
|
|
@ -155,14 +166,26 @@ export function FileUpload() {
|
|||
|
||||
if (exceedsLeft || exceedsRight || exceedsTop || exceedsBottom) {
|
||||
const directions = [];
|
||||
if (exceedsLeft) directions.push(`left by ${((hoopMinX - patternMinX) / 10).toFixed(1)}mm`);
|
||||
if (exceedsRight) directions.push(`right by ${((patternMaxX - hoopMaxX) / 10).toFixed(1)}mm`);
|
||||
if (exceedsTop) directions.push(`top by ${((hoopMinY - patternMinY) / 10).toFixed(1)}mm`);
|
||||
if (exceedsBottom) directions.push(`bottom by ${((patternMaxY - hoopMaxY) / 10).toFixed(1)}mm`);
|
||||
if (exceedsLeft)
|
||||
directions.push(
|
||||
`left by ${((hoopMinX - patternMinX) / 10).toFixed(1)}mm`,
|
||||
);
|
||||
if (exceedsRight)
|
||||
directions.push(
|
||||
`right by ${((patternMaxX - hoopMaxX) / 10).toFixed(1)}mm`,
|
||||
);
|
||||
if (exceedsTop)
|
||||
directions.push(
|
||||
`top by ${((hoopMinY - patternMinY) / 10).toFixed(1)}mm`,
|
||||
);
|
||||
if (exceedsBottom)
|
||||
directions.push(
|
||||
`bottom by ${((patternMaxY - hoopMaxY) / 10).toFixed(1)}mm`,
|
||||
);
|
||||
|
||||
return {
|
||||
fits: false,
|
||||
error: `Pattern exceeds hoop bounds: ${directions.join(', ')}. Adjust pattern position in preview.`
|
||||
error: `Pattern exceeds hoop bounds: ${directions.join(", ")}. Adjust pattern position in preview.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -171,21 +194,36 @@ export function FileUpload() {
|
|||
|
||||
const boundsCheck = checkPatternFitsInHoop();
|
||||
|
||||
const borderColor = pesData ? 'border-secondary-600 dark:border-secondary-500' : 'border-gray-400 dark:border-gray-600';
|
||||
const iconColor = pesData ? 'text-secondary-600 dark:text-secondary-400' : 'text-gray-600 dark:text-gray-400';
|
||||
const borderColor = pesData
|
||||
? "border-secondary-600 dark:border-secondary-500"
|
||||
: "border-gray-400 dark:border-gray-600";
|
||||
const iconColor = pesData
|
||||
? "text-secondary-600 dark:text-secondary-400"
|
||||
: "text-gray-600 dark:text-gray-400";
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor}`}>
|
||||
<div
|
||||
className={`bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<DocumentTextIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
|
||||
<DocumentTextIcon
|
||||
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 File</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Pattern File
|
||||
</h3>
|
||||
{pesData && displayFileName ? (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 truncate" title={displayFileName}>
|
||||
<p
|
||||
className="text-xs text-gray-600 dark:text-gray-400 truncate"
|
||||
title={displayFileName}
|
||||
>
|
||||
{displayFileName}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">No pattern loaded</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
No pattern loaded
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -217,18 +255,37 @@ export function FileUpload() {
|
|||
/>
|
||||
<label
|
||||
htmlFor={fileService.hasNativeDialogs() ? undefined : "file-input"}
|
||||
onClick={fileService.hasNativeDialogs() ? () => handleFileChange() : undefined}
|
||||
onClick={
|
||||
fileService.hasNativeDialogs()
|
||||
? () => handleFileChange()
|
||||
: undefined
|
||||
}
|
||||
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'
|
||||
: 'cursor-pointer bg-gray-600 dark:bg-gray-700 text-white hover:bg-gray-700 dark:hover:bg-gray-600'
|
||||
? "opacity-50 cursor-not-allowed bg-gray-400 dark:bg-gray-600 text-white"
|
||||
: "cursor-pointer bg-gray-600 dark:bg-gray-700 text-white hover:bg-gray-700 dark:hover:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<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>
|
||||
<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
|
||||
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>
|
||||
<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>
|
||||
<span>Loading...</span>
|
||||
</>
|
||||
|
|
@ -245,20 +302,44 @@ export function FileUpload() {
|
|||
)}
|
||||
</label>
|
||||
|
||||
{pesData && canUploadPattern(machineStatus) && !patternUploaded && uploadProgress < 100 && (
|
||||
{pesData &&
|
||||
canUploadPattern(machineStatus) &&
|
||||
!patternUploaded &&
|
||||
uploadProgress < 100 && (
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!isConnected || isUploading || !boundsCheck.fits}
|
||||
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"
|
||||
aria-label={isUploading ? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete` : boundsCheck.error || 'Upload pattern to machine'}
|
||||
aria-label={
|
||||
isUploading
|
||||
? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete`
|
||||
: boundsCheck.error || "Upload pattern to machine"
|
||||
}
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 animate-spin inline mr-1" 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
|
||||
className="w-3.5 h-3.5 animate-spin inline mr-1"
|
||||
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>
|
||||
{uploadProgress > 0 ? uploadProgress.toFixed(0) + '%' : 'Uploading'}
|
||||
{uploadProgress > 0
|
||||
? uploadProgress.toFixed(0) + "%"
|
||||
: "Uploading"}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -276,8 +357,8 @@ export function FileUpload() {
|
|||
<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...'}
|
||||
? "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)}%
|
||||
|
|
@ -291,17 +372,26 @@ export function FileUpload() {
|
|||
</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'}
|
||||
? "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'
|
||||
}}>
|
||||
<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)}
|
||||
|
|
@ -318,9 +408,13 @@ export function FileUpload() {
|
|||
{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-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...'}
|
||||
{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">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { memo, useMemo } from 'react';
|
||||
import { Group, Line, Rect, Text, Circle } from 'react-konva';
|
||||
import type { PesPatternData } from '../formats/import/pesImporter';
|
||||
import { getThreadColor } from '../formats/import/pesImporter';
|
||||
import type { MachineInfo } from '../types/machine';
|
||||
import { MOVE } from '../formats/import/constants';
|
||||
import { canvasColors } from '../utils/cssVariables';
|
||||
import { memo, useMemo } from "react";
|
||||
import { Group, Line, Rect, Text, Circle } from "react-konva";
|
||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||
import { getThreadColor } from "../formats/import/pesImporter";
|
||||
import type { MachineInfo } from "../types/machine";
|
||||
import { MOVE } from "../formats/import/constants";
|
||||
import { canvasColors } from "../utils/cssVariables";
|
||||
|
||||
interface GridProps {
|
||||
gridSize: number;
|
||||
|
|
@ -23,12 +23,20 @@ export const Grid = memo(({ gridSize, bounds, machineInfo }: GridProps) => {
|
|||
const horizontalLines: number[][] = [];
|
||||
|
||||
// Vertical lines
|
||||
for (let x = Math.floor(gridMinX / gridSize) * gridSize; x <= gridMaxX; x += gridSize) {
|
||||
for (
|
||||
let x = Math.floor(gridMinX / gridSize) * gridSize;
|
||||
x <= gridMaxX;
|
||||
x += gridSize
|
||||
) {
|
||||
verticalLines.push([x, gridMinY, x, gridMaxY]);
|
||||
}
|
||||
|
||||
// Horizontal lines
|
||||
for (let y = Math.floor(gridMinY / gridSize) * gridSize; y <= gridMaxY; y += gridSize) {
|
||||
for (
|
||||
let y = Math.floor(gridMinY / gridSize) * gridSize;
|
||||
y <= gridMaxY;
|
||||
y += gridSize
|
||||
) {
|
||||
horizontalLines.push([gridMinX, y, gridMaxX, y]);
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +67,7 @@ export const Grid = memo(({ gridSize, bounds, machineInfo }: GridProps) => {
|
|||
);
|
||||
});
|
||||
|
||||
Grid.displayName = 'Grid';
|
||||
Grid.displayName = "Grid";
|
||||
|
||||
export const Origin = memo(() => {
|
||||
const originColor = canvasColors.origin();
|
||||
|
|
@ -72,7 +80,7 @@ export const Origin = memo(() => {
|
|||
);
|
||||
});
|
||||
|
||||
Origin.displayName = 'Origin';
|
||||
Origin.displayName = "Origin";
|
||||
|
||||
interface HoopProps {
|
||||
machineInfo: MachineInfo;
|
||||
|
|
@ -108,7 +116,7 @@ export const Hoop = memo(({ machineInfo }: HoopProps) => {
|
|||
);
|
||||
});
|
||||
|
||||
Hoop.displayName = 'Hoop';
|
||||
Hoop.displayName = "Hoop";
|
||||
|
||||
interface PatternBoundsProps {
|
||||
bounds: { minX: number; maxX: number; minY: number; maxY: number };
|
||||
|
|
@ -133,7 +141,7 @@ export const PatternBounds = memo(({ bounds }: PatternBoundsProps) => {
|
|||
);
|
||||
});
|
||||
|
||||
PatternBounds.displayName = 'PatternBounds';
|
||||
PatternBounds.displayName = "PatternBounds";
|
||||
|
||||
interface StitchesProps {
|
||||
stitches: number[][];
|
||||
|
|
@ -142,7 +150,13 @@ interface StitchesProps {
|
|||
showProgress?: boolean;
|
||||
}
|
||||
|
||||
export const Stitches = memo(({ stitches, pesData, currentStitchIndex, showProgress = false }: StitchesProps) => {
|
||||
export const Stitches = memo(
|
||||
({
|
||||
stitches,
|
||||
pesData,
|
||||
currentStitchIndex,
|
||||
showProgress = false,
|
||||
}: StitchesProps) => {
|
||||
const stitchGroups = useMemo(() => {
|
||||
interface StitchGroup {
|
||||
color: string;
|
||||
|
|
@ -211,21 +225,31 @@ export const Stitches = memo(({ stitches, pesData, currentStitchIndex, showProgr
|
|||
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)}
|
||||
opacity={
|
||||
group.isJump
|
||||
? group.completed
|
||||
? 0.8
|
||||
: 0.5
|
||||
: showProgress && !group.completed
|
||||
? 0.3
|
||||
: 1.0
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
Stitches.displayName = 'Stitches';
|
||||
Stitches.displayName = "Stitches";
|
||||
|
||||
interface CurrentPositionProps {
|
||||
currentStitchIndex: number;
|
||||
stitches: number[][];
|
||||
}
|
||||
|
||||
export const CurrentPosition = memo(({ currentStitchIndex, stitches }: CurrentPositionProps) => {
|
||||
export const CurrentPosition = memo(
|
||||
({ currentStitchIndex, stitches }: CurrentPositionProps) => {
|
||||
if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -243,12 +267,29 @@ export const CurrentPosition = memo(({ currentStitchIndex, stitches }: CurrentPo
|
|||
stroke={positionColor}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
<Line points={[x - 12, y, x - 3, y]} stroke={positionColor} strokeWidth={2} />
|
||||
<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} />
|
||||
<Line
|
||||
points={[x - 12, y, x - 3, y]}
|
||||
stroke={positionColor}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<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";
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore';
|
||||
import { usePatternStore } from '../stores/usePatternStore';
|
||||
import { ConnectionPrompt } from './ConnectionPrompt';
|
||||
import { FileUpload } from './FileUpload';
|
||||
import { PatternSummaryCard } from './PatternSummaryCard';
|
||||
import { ProgressMonitor } from './ProgressMonitor';
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
|
||||
import { usePatternStore } from "../stores/usePatternStore";
|
||||
import { ConnectionPrompt } from "./ConnectionPrompt";
|
||||
import { FileUpload } from "./FileUpload";
|
||||
import { PatternSummaryCard } from "./PatternSummaryCard";
|
||||
import { ProgressMonitor } from "./ProgressMonitor";
|
||||
|
||||
export function LeftSidebar() {
|
||||
const { isConnected } = useMachineStore(
|
||||
useShallow((state) => ({
|
||||
isConnected: state.isConnected,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
const { pesData } = usePatternStore(
|
||||
useShallow((state) => ({
|
||||
pesData: state.pesData,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
// Derived state: pattern is uploaded if machine has pattern info
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useState } from "react";
|
||||
import {
|
||||
InformationCircleIcon,
|
||||
CheckCircleIcon,
|
||||
|
|
@ -6,12 +6,15 @@ import {
|
|||
PauseCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
WifiIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import type { MachineInfo } from '../types/machine';
|
||||
import { MachineStatus } from '../types/machine';
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { shouldConfirmDisconnect, getStateVisualInfo } from '../utils/machineStateHelpers';
|
||||
import { hasError, getErrorDetails } from '../utils/errorCodeHelpers';
|
||||
} from "@heroicons/react/24/solid";
|
||||
import type { MachineInfo } from "../types/machine";
|
||||
import { MachineStatus } from "../types/machine";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import {
|
||||
shouldConfirmDisconnect,
|
||||
getStateVisualInfo,
|
||||
} from "../utils/machineStateHelpers";
|
||||
import { hasError, getErrorDetails } from "../utils/errorCodeHelpers";
|
||||
|
||||
interface MachineConnectionProps {
|
||||
isConnected: boolean;
|
||||
|
|
@ -61,20 +64,31 @@ export function MachineConnection({
|
|||
};
|
||||
|
||||
const statusBadgeColors = {
|
||||
idle: 'bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300',
|
||||
info: 'bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300',
|
||||
active: 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300',
|
||||
waiting: 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300',
|
||||
warning: 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300',
|
||||
complete: 'bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300',
|
||||
success: 'bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300',
|
||||
interrupted: 'bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300',
|
||||
error: 'bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300',
|
||||
danger: 'bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300',
|
||||
idle: "bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300",
|
||||
info: "bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300",
|
||||
active:
|
||||
"bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300",
|
||||
waiting:
|
||||
"bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300",
|
||||
warning:
|
||||
"bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300",
|
||||
complete:
|
||||
"bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300",
|
||||
success:
|
||||
"bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300",
|
||||
interrupted:
|
||||
"bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300",
|
||||
error:
|
||||
"bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300",
|
||||
danger:
|
||||
"bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300",
|
||||
};
|
||||
|
||||
// Only show error info when connected AND there's an actual error
|
||||
const errorInfo = (isConnected && hasError(machineError)) ? getErrorDetails(machineError) : null;
|
||||
const errorInfo =
|
||||
isConnected && hasError(machineError)
|
||||
? getErrorDetails(machineError)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -83,8 +97,12 @@ export function MachineConnection({
|
|||
<div className="flex items-start gap-3 mb-3">
|
||||
<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>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">Ready to connect</p>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Machine
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Ready to connect
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -100,43 +118,55 @@ export function MachineConnection({
|
|||
<div className="flex items-start gap-3 mb-3">
|
||||
<WifiIcon className="w-6 h-6 text-success-600 dark:text-success-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 Info</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Machine Info
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{machineInfo?.modelNumber || 'Brother Embroidery Machine'}
|
||||
{machineInfo?.modelNumber || "Brother Embroidery Machine"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error/Info Display */}
|
||||
{errorInfo && (
|
||||
errorInfo.isInformational ? (
|
||||
{errorInfo &&
|
||||
(errorInfo.isInformational ? (
|
||||
<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">
|
||||
<div className="font-semibold text-info-900 dark:text-info-200 text-xs">{errorInfo.title}</div>
|
||||
<div className="font-semibold text-info-900 dark:text-info-200 text-xs">
|
||||
{errorInfo.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-3 p-3 bg-danger-50 dark:bg-danger-900/20 border border-danger-200 dark:border-danger-800 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-danger-600 dark:text-danger-400 flex-shrink-0">⚠️</span>
|
||||
<span className="text-danger-600 dark:text-danger-400 flex-shrink-0">
|
||||
⚠️
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-danger-900 dark:text-danger-200 text-xs mb-1">{errorInfo.title}</div>
|
||||
<div className="font-semibold text-danger-900 dark:text-danger-200 text-xs mb-1">
|
||||
{errorInfo.title}
|
||||
</div>
|
||||
<div className="text-xs text-danger-700 dark:text-danger-300 font-mono">
|
||||
Error Code: 0x{machineError.toString(16).toUpperCase().padStart(2, '0')}
|
||||
Error Code: 0x
|
||||
{machineError.toString(16).toUpperCase().padStart(2, "0")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="mb-3">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400 block mb-1">Status:</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}`}>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400 block mb-1">
|
||||
Status:
|
||||
</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}`}
|
||||
>
|
||||
{(() => {
|
||||
const Icon = stateIcons[stateVisual.iconName];
|
||||
return <Icon className="w-3.5 h-3.5" />;
|
||||
|
|
@ -149,14 +179,19 @@ export function MachineConnection({
|
|||
{machineInfo && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
|
||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
||||
<span className="text-gray-600 dark:text-gray-400 block">Max Area</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 block">
|
||||
Max Area
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{(machineInfo.maxWidth / 10).toFixed(1)} × {(machineInfo.maxHeight / 10).toFixed(1)} mm
|
||||
{(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="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>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,36 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore';
|
||||
import { usePatternStore } from '../stores/usePatternStore';
|
||||
import { Stage, Layer, Group } from 'react-konva';
|
||||
import Konva from 'konva';
|
||||
import { PlusIcon, MinusIcon, ArrowPathIcon, LockClosedIcon, PhotoIcon, ArrowsPointingInIcon } from '@heroicons/react/24/solid';
|
||||
import type { PesPatternData } from '../formats/import/pesImporter';
|
||||
import { calculateInitialScale } from '../utils/konvaRenderers';
|
||||
import { Grid, Origin, Hoop, Stitches, PatternBounds, CurrentPosition } from './KonvaComponents';
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
|
||||
import { usePatternStore } from "../stores/usePatternStore";
|
||||
import { Stage, Layer, Group } from "react-konva";
|
||||
import Konva from "konva";
|
||||
import {
|
||||
PlusIcon,
|
||||
MinusIcon,
|
||||
ArrowPathIcon,
|
||||
LockClosedIcon,
|
||||
PhotoIcon,
|
||||
ArrowsPointingInIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||
import { calculateInitialScale } from "../utils/konvaRenderers";
|
||||
import {
|
||||
Grid,
|
||||
Origin,
|
||||
Hoop,
|
||||
Stitches,
|
||||
PatternBounds,
|
||||
CurrentPosition,
|
||||
} from "./KonvaComponents";
|
||||
|
||||
export function PatternCanvas() {
|
||||
// Machine store
|
||||
const {
|
||||
sewingProgress,
|
||||
machineInfo,
|
||||
isUploading,
|
||||
} = useMachineStore(
|
||||
const { sewingProgress, machineInfo, isUploading } = useMachineStore(
|
||||
useShallow((state) => ({
|
||||
sewingProgress: state.sewingProgress,
|
||||
machineInfo: state.machineInfo,
|
||||
isUploading: state.isUploading,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
// Pattern store
|
||||
|
|
@ -33,7 +43,7 @@ export function PatternCanvas() {
|
|||
pesData: state.pesData,
|
||||
patternOffset: state.patternOffset,
|
||||
setPatternOffset: state.setPatternOffset,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
// Derived state: pattern is uploaded if machine has pattern info
|
||||
|
|
@ -43,18 +53,24 @@ export function PatternCanvas() {
|
|||
|
||||
const [stagePos, setStagePos] = useState({ x: 0, y: 0 });
|
||||
const [stageScale, setStageScale] = useState(1);
|
||||
const [localPatternOffset, setLocalPatternOffset] = useState(initialPatternOffset || { x: 0, y: 0 });
|
||||
const [localPatternOffset, setLocalPatternOffset] = useState(
|
||||
initialPatternOffset || { x: 0, y: 0 },
|
||||
);
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||
const initialScaleRef = useRef<number>(1);
|
||||
const prevPesDataRef = useRef<PesPatternData | null>(null);
|
||||
|
||||
// Update pattern offset when initialPatternOffset changes
|
||||
if (initialPatternOffset && (
|
||||
localPatternOffset.x !== initialPatternOffset.x ||
|
||||
localPatternOffset.y !== initialPatternOffset.y
|
||||
)) {
|
||||
if (
|
||||
initialPatternOffset &&
|
||||
(localPatternOffset.x !== initialPatternOffset.x ||
|
||||
localPatternOffset.y !== initialPatternOffset.y)
|
||||
) {
|
||||
setLocalPatternOffset(initialPatternOffset);
|
||||
console.log('[PatternCanvas] Restored pattern offset:', initialPatternOffset);
|
||||
console.log(
|
||||
"[PatternCanvas] Restored pattern offset:",
|
||||
initialPatternOffset,
|
||||
);
|
||||
}
|
||||
|
||||
// Track container size
|
||||
|
|
@ -91,10 +107,19 @@ export function PatternCanvas() {
|
|||
prevPesDataRef.current = pesData;
|
||||
|
||||
const { bounds } = pesData;
|
||||
const viewWidth = machineInfo ? machineInfo.maxWidth : bounds.maxX - bounds.minX;
|
||||
const viewHeight = machineInfo ? machineInfo.maxHeight : bounds.maxY - bounds.minY;
|
||||
const viewWidth = machineInfo
|
||||
? machineInfo.maxWidth
|
||||
: bounds.maxX - bounds.minX;
|
||||
const viewHeight = machineInfo
|
||||
? machineInfo.maxHeight
|
||||
: bounds.maxY - bounds.minY;
|
||||
|
||||
const initialScale = calculateInitialScale(containerSize.width, containerSize.height, viewWidth, viewHeight);
|
||||
const initialScale = calculateInitialScale(
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
viewWidth,
|
||||
viewHeight,
|
||||
);
|
||||
initialScaleRef.current = initialScale;
|
||||
|
||||
// Reset view when pattern changes
|
||||
|
|
@ -118,7 +143,10 @@ export function PatternCanvas() {
|
|||
const direction = e.evt.deltaY > 0 ? -1 : 1;
|
||||
|
||||
setStageScale((oldScale) => {
|
||||
const newScale = Math.max(0.1, Math.min(direction > 0 ? oldScale * scaleBy : oldScale / scaleBy, 2));
|
||||
const newScale = Math.max(
|
||||
0.1,
|
||||
Math.min(direction > 0 ? oldScale * scaleBy : oldScale / scaleBy, 2),
|
||||
);
|
||||
|
||||
// Zoom towards pointer
|
||||
setStagePos((prevPos) => {
|
||||
|
|
@ -204,34 +232,51 @@ export function PatternCanvas() {
|
|||
}, [pesData, setPatternOffset]);
|
||||
|
||||
// Pattern drag handlers
|
||||
const handlePatternDragEnd = useCallback((e: Konva.KonvaEventObject<DragEvent>) => {
|
||||
const handlePatternDragEnd = useCallback(
|
||||
(e: Konva.KonvaEventObject<DragEvent>) => {
|
||||
const newOffset = {
|
||||
x: e.target.x(),
|
||||
y: e.target.y(),
|
||||
};
|
||||
setLocalPatternOffset(newOffset);
|
||||
setPatternOffset(newOffset.x, newOffset.y);
|
||||
}, [setPatternOffset]);
|
||||
},
|
||||
[setPatternOffset],
|
||||
);
|
||||
|
||||
const borderColor = pesData ? 'border-tertiary-600 dark:border-tertiary-500' : 'border-gray-400 dark:border-gray-600';
|
||||
const iconColor = pesData ? 'text-tertiary-600 dark:text-tertiary-400' : 'text-gray-600 dark:text-gray-400';
|
||||
const borderColor = pesData
|
||||
? "border-tertiary-600 dark:border-tertiary-500"
|
||||
: "border-gray-400 dark:border-gray-600";
|
||||
const iconColor = pesData
|
||||
? "text-tertiary-600 dark:text-tertiary-400"
|
||||
: "text-gray-600 dark:text-gray-400";
|
||||
|
||||
return (
|
||||
<div className={`lg:h-full bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor} flex flex-col`}>
|
||||
<div
|
||||
className={`lg:h-full bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor} flex flex-col`}
|
||||
>
|
||||
<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`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Pattern Preview</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Pattern Preview
|
||||
</h3>
|
||||
{pesData ? (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} × {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
|
||||
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} ×{" "}
|
||||
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">No pattern loaded</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
No pattern loaded
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<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}>
|
||||
<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}
|
||||
>
|
||||
{containerSize.width > 0 && (
|
||||
<Stage
|
||||
width={containerSize.width}
|
||||
|
|
@ -244,18 +289,18 @@ export function PatternCanvas() {
|
|||
onWheel={handleWheel}
|
||||
onDragStart={() => {
|
||||
if (stageRef.current) {
|
||||
stageRef.current.container().style.cursor = 'grabbing';
|
||||
stageRef.current.container().style.cursor = "grabbing";
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
if (stageRef.current) {
|
||||
stageRef.current.container().style.cursor = 'grab';
|
||||
stageRef.current.container().style.cursor = "grab";
|
||||
}
|
||||
}}
|
||||
ref={(node) => {
|
||||
stageRef.current = node;
|
||||
if (node) {
|
||||
node.container().style.cursor = 'grab';
|
||||
node.container().style.cursor = "grab";
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
@ -285,22 +330,27 @@ export function PatternCanvas() {
|
|||
onDragEnd={handlePatternDragEnd}
|
||||
onMouseEnter={(e) => {
|
||||
const stage = e.target.getStage();
|
||||
if (stage && !patternUploaded && !isUploading) stage.container().style.cursor = 'move';
|
||||
if (stage && !patternUploaded && !isUploading)
|
||||
stage.container().style.cursor = "move";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const stage = e.target.getStage();
|
||||
if (stage && !patternUploaded && !isUploading) stage.container().style.cursor = 'grab';
|
||||
if (stage && !patternUploaded && !isUploading)
|
||||
stage.container().style.cursor = "grab";
|
||||
}}
|
||||
>
|
||||
<Stitches
|
||||
stitches={pesData.penStitches.stitches.map((s, i): [number, number, number, number] => {
|
||||
stitches={pesData.penStitches.stitches.map(
|
||||
(s, i): [number, number, number, number] => {
|
||||
// Convert PEN stitch format {x, y, flags, isJump} to PES format [x, y, cmd, colorIndex]
|
||||
const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump
|
||||
const colorIndex = pesData.penStitches.colorBlocks.find(
|
||||
(b) => i >= b.startStitch && i <= b.endStitch
|
||||
const colorIndex =
|
||||
pesData.penStitches.colorBlocks.find(
|
||||
(b) => i >= b.startStitch && i <= b.endStitch,
|
||||
)?.colorIndex ?? 0;
|
||||
return [s.x, s.y, cmd, colorIndex];
|
||||
})}
|
||||
},
|
||||
)}
|
||||
pesData={pesData}
|
||||
currentStitchIndex={sewingProgress?.currentStitch || 0}
|
||||
showProgress={patternUploaded || isUploading}
|
||||
|
|
@ -312,17 +362,23 @@ export function PatternCanvas() {
|
|||
|
||||
{/* Current position layer */}
|
||||
<Layer>
|
||||
{pesData && pesData.penStitches && sewingProgress && sewingProgress.currentStitch > 0 && (
|
||||
{pesData &&
|
||||
pesData.penStitches &&
|
||||
sewingProgress &&
|
||||
sewingProgress.currentStitch > 0 && (
|
||||
<Group x={localPatternOffset.x} y={localPatternOffset.y}>
|
||||
<CurrentPosition
|
||||
currentStitchIndex={sewingProgress.currentStitch}
|
||||
stitches={pesData.penStitches.stitches.map((s, i): [number, number, number, number] => {
|
||||
stitches={pesData.penStitches.stitches.map(
|
||||
(s, i): [number, number, number, number] => {
|
||||
const cmd = s.isJump ? 0x10 : 0;
|
||||
const colorIndex = pesData.penStitches.colorBlocks.find(
|
||||
(b) => i >= b.startStitch && i <= b.endStitch
|
||||
const colorIndex =
|
||||
pesData.penStitches.colorBlocks.find(
|
||||
(b) => i >= b.startStitch && i <= b.endStitch,
|
||||
)?.colorIndex ?? 0;
|
||||
return [s.x, s.y, cmd, colorIndex];
|
||||
})}
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
|
@ -342,22 +398,28 @@ export function PatternCanvas() {
|
|||
<>
|
||||
{/* Thread Legend Overlay */}
|
||||
<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]">
|
||||
<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>
|
||||
<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>
|
||||
{pesData.uniqueColors.map((color, idx) => {
|
||||
// Primary metadata: brand and catalog number
|
||||
const primaryMetadata = [
|
||||
color.brand,
|
||||
color.catalogNumber ? `#${color.catalogNumber}` : null
|
||||
].filter(Boolean).join(" ");
|
||||
color.catalogNumber ? `#${color.catalogNumber}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
// Secondary metadata: chart and description
|
||||
const secondaryMetadata = [
|
||||
color.chart,
|
||||
color.description
|
||||
].filter(Boolean).join(" ");
|
||||
const secondaryMetadata = [color.chart, color.description]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0">
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0"
|
||||
>
|
||||
<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 }}
|
||||
|
|
@ -369,7 +431,9 @@ export function PatternCanvas() {
|
|||
{(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>}
|
||||
{primaryMetadata && secondaryMetadata && (
|
||||
<span className="mx-1">•</span>
|
||||
)}
|
||||
{secondaryMetadata}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -380,11 +444,17 @@ export function PatternCanvas() {
|
|||
</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={`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>
|
||||
<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" />
|
||||
|
|
@ -393,26 +463,48 @@ export function PatternCanvas() {
|
|||
)}
|
||||
</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
|
||||
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'}
|
||||
{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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,33 @@
|
|||
import type { PesPatternData } from '../formats/import/pesImporter';
|
||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||
|
||||
interface PatternInfoProps {
|
||||
pesData: PesPatternData;
|
||||
showThreadBlocks?: boolean;
|
||||
}
|
||||
|
||||
export function PatternInfo({ pesData, showThreadBlocks = false }: PatternInfoProps) {
|
||||
export function PatternInfo({
|
||||
pesData,
|
||||
showThreadBlocks = false,
|
||||
}: PatternInfoProps) {
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<span className="text-gray-600 dark:text-gray-400 block">Size</span>
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
||||
<span className="text-gray-600 dark:text-gray-400 block">Stitches</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 block">
|
||||
Stitches
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{pesData.penStitches?.stitches.length.toLocaleString() || pesData.stitchCount.toLocaleString()}
|
||||
{pesData.penStitches && pesData.penStitches.stitches.length !== pesData.stitchCount && (
|
||||
{pesData.penStitches?.stitches.length.toLocaleString() ||
|
||||
pesData.stitchCount.toLocaleString()}
|
||||
{pesData.penStitches &&
|
||||
pesData.penStitches.stitches.length !== pesData.stitchCount && (
|
||||
<span
|
||||
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)"
|
||||
|
|
@ -32,44 +39,50 @@ export function PatternInfo({ pesData, showThreadBlocks = false }: PatternInfoPr
|
|||
</div>
|
||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
||||
<span className="text-gray-600 dark:text-gray-400 block">
|
||||
{showThreadBlocks ? 'Colors / Blocks' : 'Colors'}
|
||||
{showThreadBlocks ? "Colors / Blocks" : "Colors"}
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{showThreadBlocks
|
||||
? `${pesData.uniqueColors.length} / ${pesData.threads.length}`
|
||||
: pesData.uniqueColors.length
|
||||
}
|
||||
: pesData.uniqueColors.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Colors:</span>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Colors:
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{pesData.uniqueColors.slice(0, 8).map((color, idx) => {
|
||||
// Primary metadata: brand and catalog number
|
||||
const primaryMetadata = [
|
||||
color.brand,
|
||||
color.catalogNumber ? `#${color.catalogNumber}` : null
|
||||
].filter(Boolean).join(" ");
|
||||
color.catalogNumber ? `#${color.catalogNumber}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
// Secondary metadata: chart and description
|
||||
const secondaryMetadata = [
|
||||
color.chart,
|
||||
color.description
|
||||
].filter(Boolean).join(" ");
|
||||
const secondaryMetadata = [color.chart, color.description]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const metadata = [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
|
||||
const metadata = [primaryMetadata, secondaryMetadata]
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
|
||||
// Show which thread blocks use this color in PatternSummaryCard
|
||||
const threadNumbers = color.threadIndices.map(i => i + 1).join(", ");
|
||||
const threadNumbers = color.threadIndices
|
||||
.map((i) => i + 1)
|
||||
.join(", ");
|
||||
const tooltipText = showThreadBlocks
|
||||
? (metadata
|
||||
? metadata
|
||||
? `Color ${idx + 1}: ${color.hex} - ${metadata}`
|
||||
: `Color ${idx + 1}: ${color.hex}`)
|
||||
: (metadata
|
||||
: `Color ${idx + 1}: ${color.hex}`
|
||||
: metadata
|
||||
? `Color ${idx + 1}: ${color.hex}\n${metadata}\nUsed in thread blocks: ${threadNumbers}`
|
||||
: `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`);
|
||||
: `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
export function PatternPreviewPlaceholder() {
|
||||
return (
|
||||
<div className="lg:h-full bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md animate-fadeIn flex flex-col">
|
||||
<h2 className="text-base lg:text-lg font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white flex-shrink-0">Pattern Preview</h2>
|
||||
<h2 className="text-base lg:text-lg font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white flex-shrink-0">
|
||||
Pattern Preview
|
||||
</h2>
|
||||
<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 */}
|
||||
<div className="absolute inset-0 opacity-5 dark:opacity-10">
|
||||
|
|
@ -12,18 +14,41 @@ export function PatternPreviewPlaceholder() {
|
|||
|
||||
<div className="text-center relative z-10">
|
||||
<div className="relative inline-block mb-6">
|
||||
<svg className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600" 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
|
||||
className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600"
|
||||
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>
|
||||
<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 className="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
<svg
|
||||
className="w-5 h-5 text-primary-600 dark:text-primary-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2">No Pattern Loaded</h3>
|
||||
<h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2">
|
||||
No Pattern Loaded
|
||||
</h3>
|
||||
<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 design preview
|
||||
Connect to your machine and choose a PES embroidery file to see your
|
||||
design preview
|
||||
</p>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -1,33 +1,26 @@
|
|||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useMachineStore } from '../stores/useMachineStore';
|
||||
import { usePatternStore } from '../stores/usePatternStore';
|
||||
import { canDeletePattern } from '../utils/machineStateHelpers';
|
||||
import { PatternInfo } from './PatternInfo';
|
||||
import { DocumentTextIcon, TrashIcon } from '@heroicons/react/24/solid';
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMachineStore } from "../stores/useMachineStore";
|
||||
import { usePatternStore } from "../stores/usePatternStore";
|
||||
import { canDeletePattern } from "../utils/machineStateHelpers";
|
||||
import { PatternInfo } from "./PatternInfo";
|
||||
import { DocumentTextIcon, TrashIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
export function PatternSummaryCard() {
|
||||
// Machine store
|
||||
const {
|
||||
machineStatus,
|
||||
isDeleting,
|
||||
deletePattern,
|
||||
} = useMachineStore(
|
||||
const { machineStatus, isDeleting, deletePattern } = useMachineStore(
|
||||
useShallow((state) => ({
|
||||
machineStatus: state.machineStatus,
|
||||
isDeleting: state.isDeleting,
|
||||
deletePattern: state.deletePattern,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
// Pattern store
|
||||
const {
|
||||
pesData,
|
||||
currentFileName,
|
||||
} = usePatternStore(
|
||||
const { pesData, currentFileName } = usePatternStore(
|
||||
useShallow((state) => ({
|
||||
pesData: state.pesData,
|
||||
currentFileName: state.currentFileName,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
if (!pesData) return null;
|
||||
|
|
@ -38,8 +31,13 @@ export function PatternSummaryCard() {
|
|||
<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" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Active Pattern</h3>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 truncate" title={currentFileName}>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Active Pattern
|
||||
</h3>
|
||||
<p
|
||||
className="text-xs text-gray-600 dark:text-gray-400 truncate"
|
||||
title={currentFileName}
|
||||
>
|
||||
{currentFileName}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -55,9 +53,24 @@ export function PatternSummaryCard() {
|
|||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<svg className="w-3 h-3 animate-spin" 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
|
||||
className="w-3 h-3 animate-spin"
|
||||
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>
|
||||
Deleting...
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useRef, useEffect, useState, useMemo } from "react";
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useMachineStore } from '../stores/useMachineStore';
|
||||
import { usePatternStore } from '../stores/usePatternStore';
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMachineStore } from "../stores/useMachineStore";
|
||||
import { usePatternStore } from "../stores/usePatternStore";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ArrowRightIcon,
|
||||
|
|
@ -42,7 +42,7 @@ export function ProgressMonitor() {
|
|||
startMaskTrace: state.startMaskTrace,
|
||||
startSewing: state.startSewing,
|
||||
resumeSewing: state.resumeSewing,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
// Pattern store
|
||||
|
|
@ -59,12 +59,13 @@ export function ProgressMonitor() {
|
|||
|
||||
// Use PEN stitch count as fallback when machine reports 0 total stitches
|
||||
const totalStitches = patternInfo
|
||||
? (patternInfo.totalStitches === 0 && pesData?.penStitches
|
||||
? patternInfo.totalStitches === 0 && pesData?.penStitches
|
||||
? pesData.penStitches.stitches.length
|
||||
: patternInfo.totalStitches)
|
||||
: patternInfo.totalStitches
|
||||
: 0;
|
||||
|
||||
const progressPercent = totalStitches > 0
|
||||
const progressPercent =
|
||||
totalStitches > 0
|
||||
? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100
|
||||
: 0;
|
||||
|
||||
|
|
@ -116,7 +117,10 @@ export function ProgressMonitor() {
|
|||
return { totalMinutes: 0, elapsedMinutes: 0 };
|
||||
}
|
||||
const result = calculatePatternTime(colorBlocks, currentStitch);
|
||||
return { totalMinutes: result.totalMinutes, elapsedMinutes: result.elapsedMinutes };
|
||||
return {
|
||||
totalMinutes: result.totalMinutes,
|
||||
elapsedMinutes: result.elapsedMinutes,
|
||||
};
|
||||
}, [colorBlocks, currentStitch]);
|
||||
|
||||
// Auto-scroll to current block
|
||||
|
|
@ -132,7 +136,8 @@ export function ProgressMonitor() {
|
|||
// Handle scroll to detect if at bottom
|
||||
const handleColorBlocksScroll = () => {
|
||||
if (colorBlocksScrollRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = colorBlocksScrollRef.current;
|
||||
const { scrollTop, scrollHeight, clientHeight } =
|
||||
colorBlocksScrollRef.current;
|
||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; // 5px threshold
|
||||
setShowGradient(!isAtBottom);
|
||||
}
|
||||
|
|
@ -149,8 +154,8 @@ export function ProgressMonitor() {
|
|||
};
|
||||
|
||||
checkScrollable();
|
||||
window.addEventListener('resize', checkScrollable);
|
||||
return () => window.removeEventListener('resize', checkScrollable);
|
||||
window.addEventListener("resize", checkScrollable);
|
||||
return () => window.removeEventListener("resize", checkScrollable);
|
||||
}, [colorBlocks]);
|
||||
|
||||
const stateIndicatorColors = {
|
||||
|
|
@ -343,7 +348,10 @@ export function ProgressMonitor() {
|
|||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
|
||||
Thread {block.colorIndex + 1}
|
||||
{(block.threadBrand || block.threadChart || block.threadDescription || block.threadCatalogNumber) && (
|
||||
{(block.threadBrand ||
|
||||
block.threadChart ||
|
||||
block.threadDescription ||
|
||||
block.threadCatalogNumber) && (
|
||||
<span className="font-normal text-gray-600 dark:text-gray-400">
|
||||
{" "}
|
||||
(
|
||||
|
|
@ -351,16 +359,24 @@ export function ProgressMonitor() {
|
|||
// Primary metadata: brand and catalog number
|
||||
const primaryMetadata = [
|
||||
block.threadBrand,
|
||||
block.threadCatalogNumber ? `#${block.threadCatalogNumber}` : null
|
||||
].filter(Boolean).join(" ");
|
||||
block.threadCatalogNumber
|
||||
? `#${block.threadCatalogNumber}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
// Secondary metadata: chart and description
|
||||
const secondaryMetadata = [
|
||||
block.threadChart,
|
||||
block.threadDescription
|
||||
].filter(Boolean).join(" ");
|
||||
block.threadDescription,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
|
||||
return [primaryMetadata, secondaryMetadata]
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
})()}
|
||||
)
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
interface SkeletonLoaderProps {
|
||||
className?: string;
|
||||
variant?: 'text' | 'rect' | 'circle';
|
||||
variant?: "text" | "rect" | "circle";
|
||||
}
|
||||
|
||||
export function SkeletonLoader({ className = '', variant = 'rect' }: SkeletonLoaderProps) {
|
||||
const baseClasses = 'animate-pulse bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 bg-[length:200%_100%]';
|
||||
export function SkeletonLoader({
|
||||
className = "",
|
||||
variant = "rect",
|
||||
}: SkeletonLoaderProps) {
|
||||
const baseClasses =
|
||||
"animate-pulse bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 bg-[length:200%_100%]";
|
||||
|
||||
const variantClasses = {
|
||||
text: 'h-4 rounded',
|
||||
rect: 'rounded-lg',
|
||||
circle: 'rounded-full'
|
||||
text: "h-4 rounded",
|
||||
rect: "rounded-lg",
|
||||
circle: "rounded-full",
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -29,9 +33,24 @@ export function PatternCanvasSkeleton() {
|
|||
<div className="relative w-24 h-24 mx-auto">
|
||||
<SkeletonLoader className="w-24 h-24" variant="circle" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="w-12 h-12 text-gray-400 dark:text-gray-500 animate-spin" 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
|
||||
className="w-12 h-12 text-gray-400 dark:text-gray-500 animate-spin"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore';
|
||||
import { usePatternStore } from '../stores/usePatternStore';
|
||||
import { CheckCircleIcon, InformationCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/solid';
|
||||
import { MachineStatus } from '../types/machine';
|
||||
import { getErrorDetails, hasError } from '../utils/errorCodeHelpers';
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
|
||||
import { usePatternStore } from "../stores/usePatternStore";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
InformationCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { MachineStatus } from "../types/machine";
|
||||
import { getErrorDetails, hasError } from "../utils/errorCodeHelpers";
|
||||
|
||||
interface Step {
|
||||
id: number;
|
||||
|
|
@ -13,14 +17,14 @@ interface Step {
|
|||
}
|
||||
|
||||
const steps: Step[] = [
|
||||
{ id: 1, label: 'Connect', description: 'Connect to machine' },
|
||||
{ id: 2, label: 'Home Machine', description: 'Initialize hoop position' },
|
||||
{ id: 3, label: 'Load Pattern', description: 'Choose PES file' },
|
||||
{ id: 4, label: 'Upload', description: 'Upload to machine' },
|
||||
{ id: 5, label: 'Mask Trace', description: 'Trace pattern area' },
|
||||
{ id: 6, label: 'Start Sewing', description: 'Begin embroidery' },
|
||||
{ id: 7, label: 'Monitor', description: 'Watch progress' },
|
||||
{ id: 8, label: 'Complete', description: 'Finish and remove' },
|
||||
{ id: 1, label: "Connect", description: "Connect to machine" },
|
||||
{ id: 2, label: "Home Machine", description: "Initialize hoop position" },
|
||||
{ id: 3, label: "Load Pattern", description: "Choose PES file" },
|
||||
{ id: 4, label: "Upload", description: "Upload to machine" },
|
||||
{ id: 5, label: "Mask Trace", description: "Trace pattern area" },
|
||||
{ id: 6, label: "Start Sewing", description: "Begin embroidery" },
|
||||
{ id: 7, label: "Monitor", description: "Watch progress" },
|
||||
{ id: 8, label: "Complete", description: "Finish and remove" },
|
||||
];
|
||||
|
||||
// Helper function to get guide content for a step
|
||||
|
|
@ -29,7 +33,7 @@ function getGuideContent(
|
|||
machineStatus: MachineStatus,
|
||||
hasError: boolean,
|
||||
errorCode?: number,
|
||||
errorMessage?: string
|
||||
errorMessage?: string,
|
||||
) {
|
||||
// Check for errors first
|
||||
if (hasError) {
|
||||
|
|
@ -37,19 +41,22 @@ function getGuideContent(
|
|||
|
||||
if (errorDetails?.isInformational) {
|
||||
return {
|
||||
type: 'info' as const,
|
||||
type: "info" as const,
|
||||
title: errorDetails.title,
|
||||
description: errorDetails.description,
|
||||
items: errorDetails.solutions || []
|
||||
items: errorDetails.solutions || [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'error' as const,
|
||||
title: errorDetails?.title || 'Error Occurred',
|
||||
description: errorDetails?.description || errorMessage || 'An error occurred. Please check the machine and try again.',
|
||||
type: "error" as const,
|
||||
title: errorDetails?.title || "Error Occurred",
|
||||
description:
|
||||
errorDetails?.description ||
|
||||
errorMessage ||
|
||||
"An error occurred. Please check the machine and try again.",
|
||||
items: errorDetails?.solutions || [],
|
||||
errorCode
|
||||
errorCode,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -57,156 +64,166 @@ function getGuideContent(
|
|||
switch (stepId) {
|
||||
case 1:
|
||||
return {
|
||||
type: 'info' as const,
|
||||
title: 'Step 1: Connect to Machine',
|
||||
description: 'To get started, connect to your Brother embroidery machine via Bluetooth.',
|
||||
type: "info" as const,
|
||||
title: "Step 1: Connect to Machine",
|
||||
description:
|
||||
"To get started, connect to your Brother embroidery machine via Bluetooth.",
|
||||
items: [
|
||||
'Make sure your machine is powered on',
|
||||
'Enable Bluetooth on your machine',
|
||||
'Click the "Connect to Machine" button below'
|
||||
]
|
||||
"Make sure your machine is powered on",
|
||||
"Enable Bluetooth on your machine",
|
||||
'Click the "Connect to Machine" button below',
|
||||
],
|
||||
};
|
||||
|
||||
case 2:
|
||||
return {
|
||||
type: 'info' as const,
|
||||
title: 'Step 2: Home Machine',
|
||||
description: 'The hoop needs to be removed and an initial homing procedure must be performed.',
|
||||
type: "info" as const,
|
||||
title: "Step 2: Home Machine",
|
||||
description:
|
||||
"The hoop needs to be removed and an initial homing procedure must be performed.",
|
||||
items: [
|
||||
'Remove the embroidery hoop from the machine completely',
|
||||
'Press the Accept button on the machine',
|
||||
'Wait for the machine to complete its initialization (homing)',
|
||||
'Once initialization is complete, reattach the hoop',
|
||||
'The machine should now recognize the hoop correctly'
|
||||
]
|
||||
"Remove the embroidery hoop from the machine completely",
|
||||
"Press the Accept button on the machine",
|
||||
"Wait for the machine to complete its initialization (homing)",
|
||||
"Once initialization is complete, reattach the hoop",
|
||||
"The machine should now recognize the hoop correctly",
|
||||
],
|
||||
};
|
||||
|
||||
case 3:
|
||||
return {
|
||||
type: 'info' as const,
|
||||
title: 'Step 3: Load Your Pattern',
|
||||
description: 'Choose a PES embroidery file from your computer to preview and upload.',
|
||||
type: "info" as const,
|
||||
title: "Step 3: Load Your Pattern",
|
||||
description:
|
||||
"Choose a PES embroidery file from your computer to preview and upload.",
|
||||
items: [
|
||||
'Click "Choose PES File" in the Pattern File section',
|
||||
'Select your embroidery design (.pes file)',
|
||||
'Review the pattern preview on the right',
|
||||
'You can drag the pattern to adjust its position'
|
||||
]
|
||||
"Select your embroidery design (.pes file)",
|
||||
"Review the pattern preview on the right",
|
||||
"You can drag the pattern to adjust its position",
|
||||
],
|
||||
};
|
||||
|
||||
case 4:
|
||||
return {
|
||||
type: 'info' as const,
|
||||
title: 'Step 4: Upload Pattern to Machine',
|
||||
description: 'Send your pattern to the embroidery machine to prepare for sewing.',
|
||||
type: "info" as const,
|
||||
title: "Step 4: Upload Pattern to Machine",
|
||||
description:
|
||||
"Send your pattern to the embroidery machine to prepare for sewing.",
|
||||
items: [
|
||||
'Review the pattern preview to ensure it\'s positioned correctly',
|
||||
'Check the pattern size matches your hoop',
|
||||
"Review the pattern preview to ensure it's positioned correctly",
|
||||
"Check the pattern size matches your hoop",
|
||||
'Click "Upload to Machine" when ready',
|
||||
'Wait for the upload to complete (this may take a minute)'
|
||||
]
|
||||
"Wait for the upload to complete (this may take a minute)",
|
||||
],
|
||||
};
|
||||
|
||||
case 5:
|
||||
// Check machine status for substates
|
||||
if (machineStatus === MachineStatus.MASK_TRACE_LOCK_WAIT) {
|
||||
return {
|
||||
type: 'warning' as const,
|
||||
title: 'Machine Action Required',
|
||||
description: 'The machine is ready to trace the pattern outline.',
|
||||
type: "warning" as const,
|
||||
title: "Machine Action Required",
|
||||
description: "The machine is ready to trace the pattern outline.",
|
||||
items: [
|
||||
'Press the button on your machine to confirm and start the mask trace',
|
||||
'Ensure the hoop is properly attached',
|
||||
'Make sure the needle area is clear'
|
||||
]
|
||||
"Press the button on your machine to confirm and start the mask trace",
|
||||
"Ensure the hoop is properly attached",
|
||||
"Make sure the needle area is clear",
|
||||
],
|
||||
};
|
||||
}
|
||||
if (machineStatus === MachineStatus.MASK_TRACING) {
|
||||
return {
|
||||
type: 'progress' as const,
|
||||
title: 'Mask Trace In Progress',
|
||||
description: 'The machine is tracing the pattern boundary. Please wait...',
|
||||
type: "progress" as const,
|
||||
title: "Mask Trace In Progress",
|
||||
description:
|
||||
"The machine is tracing the pattern boundary. Please wait...",
|
||||
items: [
|
||||
'Watch the machine trace the outline',
|
||||
'Verify the pattern fits within your hoop',
|
||||
'Do not interrupt the machine'
|
||||
]
|
||||
"Watch the machine trace the outline",
|
||||
"Verify the pattern fits within your hoop",
|
||||
"Do not interrupt the machine",
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'info' as const,
|
||||
title: 'Step 5: Start Mask Trace',
|
||||
description: 'The mask trace helps the machine understand the pattern boundaries.',
|
||||
type: "info" as const,
|
||||
title: "Step 5: Start Mask Trace",
|
||||
description:
|
||||
"The mask trace helps the machine understand the pattern boundaries.",
|
||||
items: [
|
||||
'Click "Start Mask Trace" button in the Sewing Progress section',
|
||||
'The machine will trace the pattern outline',
|
||||
'This ensures the hoop is positioned correctly'
|
||||
]
|
||||
"The machine will trace the pattern outline",
|
||||
"This ensures the hoop is positioned correctly",
|
||||
],
|
||||
};
|
||||
|
||||
case 6:
|
||||
return {
|
||||
type: 'success' as const,
|
||||
title: 'Step 6: Ready to Sew!',
|
||||
description: 'The machine is ready to begin embroidering your pattern.',
|
||||
type: "success" as const,
|
||||
title: "Step 6: Ready to Sew!",
|
||||
description: "The machine is ready to begin embroidering your pattern.",
|
||||
items: [
|
||||
'Verify your thread colors are correct',
|
||||
'Ensure the fabric is properly hooped',
|
||||
'Click "Start Sewing" when ready'
|
||||
]
|
||||
"Verify your thread colors are correct",
|
||||
"Ensure the fabric is properly hooped",
|
||||
'Click "Start Sewing" when ready',
|
||||
],
|
||||
};
|
||||
|
||||
case 7:
|
||||
// Check for substates
|
||||
if (machineStatus === MachineStatus.COLOR_CHANGE_WAIT) {
|
||||
return {
|
||||
type: 'warning' as const,
|
||||
title: 'Thread Change Required',
|
||||
description: 'The machine needs a different thread color to continue.',
|
||||
type: "warning" as const,
|
||||
title: "Thread Change Required",
|
||||
description:
|
||||
"The machine needs a different thread color to continue.",
|
||||
items: [
|
||||
'Check the color blocks section to see which thread is needed',
|
||||
'Change to the correct thread color',
|
||||
'Press the button on your machine to resume sewing'
|
||||
]
|
||||
"Check the color blocks section to see which thread is needed",
|
||||
"Change to the correct thread color",
|
||||
"Press the button on your machine to resume sewing",
|
||||
],
|
||||
};
|
||||
}
|
||||
if (machineStatus === MachineStatus.PAUSE ||
|
||||
if (
|
||||
machineStatus === MachineStatus.PAUSE ||
|
||||
machineStatus === MachineStatus.STOP ||
|
||||
machineStatus === MachineStatus.SEWING_INTERRUPTION) {
|
||||
machineStatus === MachineStatus.SEWING_INTERRUPTION
|
||||
) {
|
||||
return {
|
||||
type: 'warning' as const,
|
||||
title: 'Sewing Paused',
|
||||
description: 'The embroidery has been paused or interrupted.',
|
||||
type: "warning" as const,
|
||||
title: "Sewing Paused",
|
||||
description: "The embroidery has been paused or interrupted.",
|
||||
items: [
|
||||
'Check if everything is okay with the machine',
|
||||
"Check if everything is okay with the machine",
|
||||
'Click "Resume Sewing" when ready to continue',
|
||||
'The machine will pick up where it left off'
|
||||
]
|
||||
"The machine will pick up where it left off",
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'progress' as const,
|
||||
title: 'Step 7: Sewing In Progress',
|
||||
description: 'Your embroidery is being stitched. Monitor the progress below.',
|
||||
type: "progress" as const,
|
||||
title: "Step 7: Sewing In Progress",
|
||||
description:
|
||||
"Your embroidery is being stitched. Monitor the progress below.",
|
||||
items: [
|
||||
'Watch the progress bar and current stitch count',
|
||||
'The machine will pause when a color change is needed',
|
||||
'Do not leave the machine unattended'
|
||||
]
|
||||
"Watch the progress bar and current stitch count",
|
||||
"The machine will pause when a color change is needed",
|
||||
"Do not leave the machine unattended",
|
||||
],
|
||||
};
|
||||
|
||||
case 8:
|
||||
return {
|
||||
type: 'success' as const,
|
||||
title: 'Step 8: Embroidery Complete!',
|
||||
description: 'Your embroidery is finished. Great work!',
|
||||
type: "success" as const,
|
||||
title: "Step 8: Embroidery Complete!",
|
||||
description: "Your embroidery is finished. Great work!",
|
||||
items: [
|
||||
'Remove the hoop from the machine',
|
||||
'Press the Accept button on the machine',
|
||||
'Carefully remove your finished embroidery',
|
||||
'Trim any jump stitches or loose threads',
|
||||
'Click "Delete Pattern" to start a new project'
|
||||
]
|
||||
"Remove the hoop from the machine",
|
||||
"Press the Accept button on the machine",
|
||||
"Carefully remove your finished embroidery",
|
||||
"Trim any jump stitches or loose threads",
|
||||
'Click "Delete Pattern" to start a new project',
|
||||
],
|
||||
};
|
||||
|
||||
default:
|
||||
|
|
@ -214,7 +231,12 @@ function getGuideContent(
|
|||
}
|
||||
}
|
||||
|
||||
function getCurrentStep(machineStatus: MachineStatus, isConnected: boolean, hasPattern: boolean, patternUploaded: boolean): number {
|
||||
function getCurrentStep(
|
||||
machineStatus: MachineStatus,
|
||||
isConnected: boolean,
|
||||
hasPattern: boolean,
|
||||
patternUploaded: boolean,
|
||||
): number {
|
||||
if (!isConnected) return 1;
|
||||
|
||||
// Check if machine needs homing (Initial state)
|
||||
|
|
@ -262,23 +284,26 @@ export function WorkflowStepper() {
|
|||
isConnected: state.isConnected,
|
||||
machineError: state.machineError,
|
||||
error: state.error,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
// Pattern store
|
||||
const {
|
||||
pesData,
|
||||
} = usePatternStore(
|
||||
const { pesData } = usePatternStore(
|
||||
useShallow((state) => ({
|
||||
pesData: state.pesData,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
// Derived state: pattern is uploaded if machine has pattern info
|
||||
const patternUploaded = usePatternUploaded();
|
||||
const hasPattern = pesData !== null;
|
||||
const hasErrorFlag = hasError(machineError);
|
||||
const currentStep = getCurrentStep(machineStatus, isConnected, hasPattern, patternUploaded);
|
||||
const currentStep = getCurrentStep(
|
||||
machineStatus,
|
||||
isConnected,
|
||||
hasPattern,
|
||||
patternUploaded,
|
||||
);
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const [popoverStep, setPopoverStep] = useState<number | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -287,10 +312,13 @@ export function WorkflowStepper() {
|
|||
// Close popover when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||
if (
|
||||
popoverRef.current &&
|
||||
!popoverRef.current.contains(event.target as Node)
|
||||
) {
|
||||
// Check if click was on a step circle
|
||||
const clickedStep = Object.values(stepRefs.current).find(ref =>
|
||||
ref?.contains(event.target as Node)
|
||||
const clickedStep = Object.values(stepRefs.current).find((ref) =>
|
||||
ref?.contains(event.target as Node),
|
||||
);
|
||||
if (!clickedStep) {
|
||||
setShowPopover(false);
|
||||
|
|
@ -299,8 +327,9 @@ export function WorkflowStepper() {
|
|||
};
|
||||
|
||||
if (showPopover) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
}, [showPopover]);
|
||||
|
||||
|
|
@ -318,16 +347,23 @@ export function WorkflowStepper() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="relative max-w-5xl mx-auto mt-2 lg:mt-4" role="navigation" aria-label="Workflow progress">
|
||||
<div
|
||||
className="relative max-w-5xl mx-auto mt-2 lg:mt-4"
|
||||
role="navigation"
|
||||
aria-label="Workflow progress"
|
||||
>
|
||||
{/* Progress bar background */}
|
||||
<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' }} />
|
||||
<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" }}
|
||||
/>
|
||||
|
||||
{/* Progress bar fill */}
|
||||
<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"
|
||||
style={{
|
||||
left: '16px',
|
||||
width: `calc(${((currentStep - 1) / (steps.length - 1)) * 100}% - 16px)`
|
||||
left: "16px",
|
||||
width: `calc(${((currentStep - 1) / (steps.length - 1)) * 100}% - 16px)`,
|
||||
}}
|
||||
role="progressbar"
|
||||
aria-valuenow={currentStep}
|
||||
|
|
@ -349,26 +385,31 @@ export function WorkflowStepper() {
|
|||
className="flex flex-col items-center"
|
||||
style={{ flex: 1 }}
|
||||
role="listitem"
|
||||
aria-current={isCurrent ? 'step' : undefined}
|
||||
aria-current={isCurrent ? "step" : undefined}
|
||||
>
|
||||
{/* Step circle */}
|
||||
<div
|
||||
ref={(el) => { stepRefs.current[step.id] = el; }}
|
||||
ref={(el) => {
|
||||
stepRefs.current[step.id] = el;
|
||||
}}
|
||||
onClick={() => handleStepClick(step.id)}
|
||||
className={`
|
||||
w-8 h-8 lg:w-10 lg:h-10 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-300 border-2 shadow-md
|
||||
${step.id <= currentStep ? 'cursor-pointer hover:scale-110' : 'cursor-not-allowed'}
|
||||
${isComplete ? 'bg-success-500 dark:bg-success-600 border-success-400 dark:border-success-500 text-white shadow-success-500/30 dark:shadow-success-600/30' : ''}
|
||||
${isCurrent ? 'bg-primary-600 dark:bg-primary-700 border-primary-500 dark:border-primary-600 text-white scale-105 lg:scale-110 shadow-primary-600/40 dark:shadow-primary-700/40 ring-2 ring-primary-300 dark:ring-primary-500 ring-offset-2 dark:ring-offset-gray-900' : ''}
|
||||
${isUpcoming ? 'bg-primary-700 dark:bg-primary-800 border-primary-500/30 dark:border-primary-600/30 text-primary-200/70 dark:text-primary-300/70' : ''}
|
||||
${showPopover && popoverStep === step.id ? 'ring-4 ring-white dark:ring-gray-800' : ''}
|
||||
${step.id <= currentStep ? "cursor-pointer hover:scale-110" : "cursor-not-allowed"}
|
||||
${isComplete ? "bg-success-500 dark:bg-success-600 border-success-400 dark:border-success-500 text-white shadow-success-500/30 dark:shadow-success-600/30" : ""}
|
||||
${isCurrent ? "bg-primary-600 dark:bg-primary-700 border-primary-500 dark:border-primary-600 text-white scale-105 lg:scale-110 shadow-primary-600/40 dark:shadow-primary-700/40 ring-2 ring-primary-300 dark:ring-primary-500 ring-offset-2 dark:ring-offset-gray-900" : ""}
|
||||
${isUpcoming ? "bg-primary-700 dark:bg-primary-800 border-primary-500/30 dark:border-primary-600/30 text-primary-200/70 dark:text-primary-300/70" : ""}
|
||||
${showPopover && popoverStep === step.id ? "ring-4 ring-white dark:ring-gray-800" : ""}
|
||||
`}
|
||||
aria-label={`${step.label}: ${isComplete ? 'completed' : isCurrent ? 'current' : 'upcoming'}. Click for details.`}
|
||||
aria-label={`${step.label}: ${isComplete ? "completed" : isCurrent ? "current" : "upcoming"}. Click for details.`}
|
||||
role="button"
|
||||
tabIndex={step.id <= currentStep ? 0 : -1}
|
||||
>
|
||||
{isComplete ? (
|
||||
<CheckCircleIcon className="w-5 h-5 lg:w-6 lg:h-6" aria-hidden="true" />
|
||||
<CheckCircleIcon
|
||||
className="w-5 h-5 lg:w-6 lg:h-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
step.id
|
||||
)}
|
||||
|
|
@ -376,9 +417,15 @@ export function WorkflowStepper() {
|
|||
|
||||
{/* Step label */}
|
||||
<div className="mt-1 lg:mt-2 text-center">
|
||||
<div 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'
|
||||
}`}>
|
||||
<div
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -396,72 +443,111 @@ export function WorkflowStepper() {
|
|||
aria-label="Step guidance"
|
||||
>
|
||||
{(() => {
|
||||
const content = getGuideContent(popoverStep, machineStatus, hasErrorFlag, machineError, errorMessage || undefined);
|
||||
const content = getGuideContent(
|
||||
popoverStep,
|
||||
machineStatus,
|
||||
hasErrorFlag,
|
||||
machineError,
|
||||
errorMessage || undefined,
|
||||
);
|
||||
if (!content) return null;
|
||||
|
||||
const colorClasses = {
|
||||
info: 'bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500',
|
||||
success: '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',
|
||||
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'
|
||||
info: "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
|
||||
success:
|
||||
"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",
|
||||
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 = {
|
||||
info: 'text-info-600 dark:text-info-400',
|
||||
success: 'text-success-600 dark:text-success-400',
|
||||
warning: 'text-warning-600 dark:text-warning-400',
|
||||
error: 'text-danger-600 dark:text-danger-400',
|
||||
progress: 'text-info-600 dark:text-info-400'
|
||||
info: "text-info-600 dark:text-info-400",
|
||||
success: "text-success-600 dark:text-success-400",
|
||||
warning: "text-warning-600 dark:text-warning-400",
|
||||
error: "text-danger-600 dark:text-danger-400",
|
||||
progress: "text-info-600 dark:text-info-400",
|
||||
};
|
||||
|
||||
const textColorClasses = {
|
||||
info: 'text-info-900 dark:text-info-200',
|
||||
success: 'text-success-900 dark:text-success-200',
|
||||
warning: 'text-warning-900 dark:text-warning-200',
|
||||
error: 'text-danger-900 dark:text-danger-200',
|
||||
progress: 'text-info-900 dark:text-info-200'
|
||||
info: "text-info-900 dark:text-info-200",
|
||||
success: "text-success-900 dark:text-success-200",
|
||||
warning: "text-warning-900 dark:text-warning-200",
|
||||
error: "text-danger-900 dark:text-danger-200",
|
||||
progress: "text-info-900 dark:text-info-200",
|
||||
};
|
||||
|
||||
const descColorClasses = {
|
||||
info: 'text-info-800 dark:text-info-300',
|
||||
success: 'text-success-800 dark:text-success-300',
|
||||
warning: 'text-warning-800 dark:text-warning-300',
|
||||
error: 'text-danger-800 dark:text-danger-300',
|
||||
progress: 'text-info-800 dark:text-info-300'
|
||||
info: "text-info-800 dark:text-info-300",
|
||||
success: "text-success-800 dark:text-success-300",
|
||||
warning: "text-warning-800 dark:text-warning-300",
|
||||
error: "text-danger-800 dark:text-danger-300",
|
||||
progress: "text-info-800 dark:text-info-300",
|
||||
};
|
||||
|
||||
const listColorClasses = {
|
||||
info: 'text-blue-700 dark:text-blue-300',
|
||||
success: 'text-green-700 dark:text-green-300',
|
||||
warning: 'text-yellow-700 dark:text-yellow-300',
|
||||
error: 'text-red-700 dark:text-red-300',
|
||||
progress: 'text-cyan-700 dark:text-cyan-300'
|
||||
info: "text-blue-700 dark:text-blue-300",
|
||||
success: "text-green-700 dark:text-green-300",
|
||||
warning: "text-yellow-700 dark:text-yellow-300",
|
||||
error: "text-red-700 dark:text-red-300",
|
||||
progress: "text-cyan-700 dark:text-cyan-300",
|
||||
};
|
||||
|
||||
const Icon = content.type === 'error' ? ExclamationTriangleIcon : InformationCircleIcon;
|
||||
const Icon =
|
||||
content.type === "error"
|
||||
? ExclamationTriangleIcon
|
||||
: InformationCircleIcon;
|
||||
|
||||
return (
|
||||
<div className={`${colorClasses[content.type]} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}>
|
||||
<div
|
||||
className={`${colorClasses[content.type]} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className={`w-6 h-6 ${iconColorClasses[content.type]} flex-shrink-0 mt-0.5`} />
|
||||
<Icon
|
||||
className={`w-6 h-6 ${iconColorClasses[content.type]} flex-shrink-0 mt-0.5`}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h3 className={`text-base font-semibold ${textColorClasses[content.type]} mb-2`}>
|
||||
<h3
|
||||
className={`text-base font-semibold ${textColorClasses[content.type]} mb-2`}
|
||||
>
|
||||
{content.title}
|
||||
</h3>
|
||||
<p className={`text-sm ${descColorClasses[content.type]} mb-3`}>
|
||||
<p
|
||||
className={`text-sm ${descColorClasses[content.type]} mb-3`}
|
||||
>
|
||||
{content.description}
|
||||
</p>
|
||||
{content.items && content.items.length > 0 && (
|
||||
<ul className={`list-disc list-inside text-sm ${listColorClasses[content.type]} space-y-1`}>
|
||||
<ul
|
||||
className={`list-disc list-inside text-sm ${listColorClasses[content.type]} space-y-1`}
|
||||
>
|
||||
{content.items.map((item, index) => (
|
||||
<li key={index} className="pl-2" dangerouslySetInnerHTML={{ __html: item.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') }} />
|
||||
<li
|
||||
key={index}
|
||||
className="pl-2"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: item.replace(
|
||||
/\*\*(.*?)\*\*/g,
|
||||
"<strong>$1</strong>",
|
||||
),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{content.type === 'error' && content.errorCode !== undefined && (
|
||||
<p className={`text-xs ${descColorClasses[content.type]} mt-3 font-mono`}>
|
||||
Error Code: 0x{content.errorCode.toString(16).toUpperCase().padStart(2, '0')}
|
||||
{content.type === "error" &&
|
||||
content.errorCode !== undefined && (
|
||||
<p
|
||||
className={`text-xs ${descColorClasses[content.type]} mt-3 font-mono`}
|
||||
>
|
||||
Error Code: 0x
|
||||
{content.errorCode
|
||||
.toString(16)
|
||||
.toUpperCase()
|
||||
.padStart(2, "0")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import type { WorkerMessage, WorkerResponse } from './worker';
|
||||
import PatternConverterWorker from './worker?worker';
|
||||
import { decodePenData } from '../pen/decoder';
|
||||
import type { DecodedPenData } from '../pen/types';
|
||||
import type { WorkerMessage, WorkerResponse } from "./worker";
|
||||
import PatternConverterWorker from "./worker?worker";
|
||||
import { decodePenData } from "../pen/decoder";
|
||||
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 {
|
||||
stitches: number[][]; // Original PES stitches (for reference)
|
||||
|
|
@ -40,7 +40,7 @@ export type ProgressCallback = (progress: number, step: string) => void;
|
|||
|
||||
class PatternConverterClient {
|
||||
private worker: Worker | null = null;
|
||||
private state: PyodideState = 'not_loaded';
|
||||
private state: PyodideState = "not_loaded";
|
||||
private error: string | null = null;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
private progressCallbacks: Set<ProgressCallback> = new Set();
|
||||
|
|
@ -64,7 +64,7 @@ class PatternConverterClient {
|
|||
*/
|
||||
async initialize(onProgress?: ProgressCallback): Promise<void> {
|
||||
// If already ready, return immediately
|
||||
if (this.state === 'ready') {
|
||||
if (this.state === "ready") {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -78,13 +78,13 @@ class PatternConverterClient {
|
|||
|
||||
// Create worker if it doesn't exist
|
||||
if (!this.worker) {
|
||||
console.log('[PatternConverterClient] Creating worker...');
|
||||
console.log("[PatternConverterClient] Creating worker...");
|
||||
try {
|
||||
this.worker = new PatternConverterWorker();
|
||||
console.log('[PatternConverterClient] Worker created successfully');
|
||||
console.log("[PatternConverterClient] Worker created successfully");
|
||||
this.setupWorkerListeners();
|
||||
} catch (err) {
|
||||
console.error('[PatternConverterClient] Failed to create worker:', err);
|
||||
console.error("[PatternConverterClient] Failed to create worker:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
@ -95,7 +95,7 @@ class PatternConverterClient {
|
|||
}
|
||||
|
||||
// Start initialization
|
||||
this.state = 'loading';
|
||||
this.state = "loading";
|
||||
this.error = null;
|
||||
|
||||
this.initPromise = new Promise<void>((resolve, reject) => {
|
||||
|
|
@ -103,44 +103,55 @@ class PatternConverterClient {
|
|||
const message = event.data;
|
||||
|
||||
switch (message.type) {
|
||||
case 'INIT_PROGRESS':
|
||||
case "INIT_PROGRESS":
|
||||
// Notify all progress callbacks
|
||||
this.progressCallbacks.forEach((callback) => {
|
||||
callback(message.progress, message.step);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'INIT_COMPLETE':
|
||||
this.state = 'ready';
|
||||
case "INIT_COMPLETE":
|
||||
this.state = "ready";
|
||||
this.progressCallbacks.clear();
|
||||
this.worker?.removeEventListener('message', handleMessage);
|
||||
this.worker?.removeEventListener("message", handleMessage);
|
||||
resolve();
|
||||
break;
|
||||
|
||||
case 'INIT_ERROR':
|
||||
this.state = 'error';
|
||||
case "INIT_ERROR":
|
||||
this.state = "error";
|
||||
this.error = message.error;
|
||||
this.progressCallbacks.clear();
|
||||
this.worker?.removeEventListener('message', handleMessage);
|
||||
this.worker?.removeEventListener("message", handleMessage);
|
||||
reject(new Error(message.error));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
this.worker?.addEventListener('message', handleMessage);
|
||||
this.worker?.addEventListener("message", handleMessage);
|
||||
|
||||
// Send initialization message with asset URLs
|
||||
// Resolve URLs relative to the current page location
|
||||
const baseURL = window.location.origin + window.location.pathname.replace(/\/[^/]*$/, '/');
|
||||
const pyodideIndexURL = new URL('assets/', baseURL).href;
|
||||
const pystitchWheelURL = new URL('pystitch-1.0.0-py3-none-any.whl', baseURL).href;
|
||||
const baseURL =
|
||||
window.location.origin +
|
||||
window.location.pathname.replace(/\/[^/]*$/, "/");
|
||||
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] Pyodide index URL:', pyodideIndexURL);
|
||||
console.log('[PatternConverterClient] Pystitch wheel URL:', pystitchWheelURL);
|
||||
console.log("[PatternConverterClient] Base URL:", baseURL);
|
||||
console.log(
|
||||
"[PatternConverterClient] Pyodide index URL:",
|
||||
pyodideIndexURL,
|
||||
);
|
||||
console.log(
|
||||
"[PatternConverterClient] Pystitch wheel URL:",
|
||||
pystitchWheelURL,
|
||||
);
|
||||
|
||||
const initMessage: WorkerMessage = {
|
||||
type: 'INITIALIZE',
|
||||
type: "INITIALIZE",
|
||||
pyodideIndexURL,
|
||||
pystitchWheelURL,
|
||||
};
|
||||
|
|
@ -155,19 +166,21 @@ class PatternConverterClient {
|
|||
*/
|
||||
async convertPesToPen(file: File): Promise<PesPatternData> {
|
||||
// Ensure worker is initialized
|
||||
if (this.state !== 'ready') {
|
||||
throw new Error('Pyodide worker not initialized. Call initialize() first.');
|
||||
if (this.state !== "ready") {
|
||||
throw new Error(
|
||||
"Pyodide worker not initialized. Call initialize() first.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.worker) {
|
||||
throw new Error('Worker not available');
|
||||
throw new Error("Worker not available");
|
||||
}
|
||||
|
||||
return new Promise<PesPatternData>((resolve, reject) => {
|
||||
// Store reference to worker for TypeScript null checking
|
||||
const worker = this.worker;
|
||||
if (!worker) {
|
||||
reject(new Error('Worker not available'));
|
||||
reject(new Error("Worker not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -175,14 +188,20 @@ class PatternConverterClient {
|
|||
const message = event.data;
|
||||
|
||||
switch (message.type) {
|
||||
case 'CONVERT_COMPLETE': {
|
||||
worker.removeEventListener('message', handleMessage);
|
||||
case "CONVERT_COMPLETE": {
|
||||
worker.removeEventListener("message", handleMessage);
|
||||
// Convert penData array back to Uint8Array
|
||||
const penData = new Uint8Array(message.data.penData);
|
||||
|
||||
// Decode the PEN data to get stitches for rendering
|
||||
const penStitches = decodePenData(penData);
|
||||
console.log('[PatternConverter] Decoded PEN data:', penStitches.stitches.length, 'stitches,', penStitches.colorBlocks.length, 'colors');
|
||||
console.log(
|
||||
"[PatternConverter] Decoded PEN data:",
|
||||
penStitches.stitches.length,
|
||||
"stitches,",
|
||||
penStitches.colorBlocks.length,
|
||||
"colors",
|
||||
);
|
||||
|
||||
const result: PesPatternData = {
|
||||
...message.data,
|
||||
|
|
@ -193,28 +212,28 @@ class PatternConverterClient {
|
|||
break;
|
||||
}
|
||||
|
||||
case 'CONVERT_ERROR':
|
||||
worker.removeEventListener('message', handleMessage);
|
||||
case "CONVERT_ERROR":
|
||||
worker.removeEventListener("message", handleMessage);
|
||||
reject(new Error(message.error));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
worker.addEventListener('message', handleMessage);
|
||||
worker.addEventListener("message", handleMessage);
|
||||
|
||||
// Read file as ArrayBuffer and send to worker
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const convertMessage: WorkerMessage = {
|
||||
type: 'CONVERT_PES',
|
||||
type: "CONVERT_PES",
|
||||
fileData: reader.result as ArrayBuffer,
|
||||
fileName: file.name,
|
||||
};
|
||||
worker.postMessage(convertMessage);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
worker.removeEventListener('message', handleMessage);
|
||||
reject(new Error('Failed to read file'));
|
||||
worker.removeEventListener("message", handleMessage);
|
||||
reject(new Error("Failed to read file"));
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
|
|
@ -226,16 +245,16 @@ class PatternConverterClient {
|
|||
private setupWorkerListeners() {
|
||||
if (!this.worker) return;
|
||||
|
||||
this.worker.addEventListener('error', (event) => {
|
||||
console.error('[PyodideWorkerClient] Worker error:', event);
|
||||
this.state = 'error';
|
||||
this.error = event.message || 'Worker error';
|
||||
this.worker.addEventListener("error", (event) => {
|
||||
console.error("[PyodideWorkerClient] Worker error:", event);
|
||||
this.state = "error";
|
||||
this.error = event.message || "Worker error";
|
||||
});
|
||||
|
||||
this.worker.addEventListener('messageerror', (event) => {
|
||||
console.error('[PyodideWorkerClient] Worker message error:', event);
|
||||
this.state = 'error';
|
||||
this.error = 'Failed to deserialize worker message';
|
||||
this.worker.addEventListener("messageerror", (event) => {
|
||||
console.error("[PyodideWorkerClient] Worker message error:", event);
|
||||
this.state = "error";
|
||||
this.error = "Failed to deserialize worker message";
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -247,7 +266,7 @@ class PatternConverterClient {
|
|||
this.worker.terminate();
|
||||
this.worker = null;
|
||||
}
|
||||
this.state = 'not_loaded';
|
||||
this.state = "not_loaded";
|
||||
this.error = null;
|
||||
this.initPromise = null;
|
||||
this.progressCallbacks.clear();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export async function convertPesToPen(file: File): Promise<PesPatternData> {
|
|||
return await patternConverterClient.convertPesToPen(file);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get thread color from pattern data
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,24 +1,19 @@
|
|||
import { loadPyodide, type PyodideInterface } from 'pyodide';
|
||||
import {
|
||||
STITCH,
|
||||
MOVE,
|
||||
TRIM,
|
||||
END,
|
||||
} from './constants';
|
||||
import { encodeStitchesToPen } from '../pen/encoder';
|
||||
import { loadPyodide, type PyodideInterface } from "pyodide";
|
||||
import { STITCH, MOVE, TRIM, END } from "./constants";
|
||||
import { encodeStitchesToPen } from "../pen/encoder";
|
||||
|
||||
// Message types from main thread
|
||||
export type WorkerMessage =
|
||||
| { type: 'INITIALIZE'; pyodideIndexURL?: string; pystitchWheelURL?: string }
|
||||
| { type: 'CONVERT_PES'; fileData: ArrayBuffer; fileName: string };
|
||||
| { type: "INITIALIZE"; pyodideIndexURL?: string; pystitchWheelURL?: string }
|
||||
| { type: "CONVERT_PES"; fileData: ArrayBuffer; fileName: string };
|
||||
|
||||
// Response types to main thread
|
||||
export type WorkerResponse =
|
||||
| { type: 'INIT_PROGRESS'; progress: number; step: string }
|
||||
| { type: 'INIT_COMPLETE' }
|
||||
| { type: 'INIT_ERROR'; error: string }
|
||||
| { type: "INIT_PROGRESS"; progress: number; step: string }
|
||||
| { type: "INIT_COMPLETE" }
|
||||
| { type: "INIT_ERROR"; error: string }
|
||||
| {
|
||||
type: 'CONVERT_COMPLETE';
|
||||
type: "CONVERT_COMPLETE";
|
||||
data: {
|
||||
stitches: number[][];
|
||||
threads: Array<{
|
||||
|
|
@ -49,9 +44,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 isInitializing = false;
|
||||
|
|
@ -67,79 +62,82 @@ const jsEmbConstants = {
|
|||
/**
|
||||
* Initialize Pyodide with progress tracking
|
||||
*/
|
||||
async function initializePyodide(pyodideIndexURL?: string, pystitchWheelURL?: string) {
|
||||
async function initializePyodide(
|
||||
pyodideIndexURL?: string,
|
||||
pystitchWheelURL?: string,
|
||||
) {
|
||||
if (pyodide) {
|
||||
return; // Already initialized
|
||||
}
|
||||
|
||||
if (isInitializing) {
|
||||
throw new Error('Initialization already in progress');
|
||||
throw new Error("Initialization already in progress");
|
||||
}
|
||||
|
||||
isInitializing = true;
|
||||
|
||||
try {
|
||||
self.postMessage({
|
||||
type: 'INIT_PROGRESS',
|
||||
type: "INIT_PROGRESS",
|
||||
progress: 0,
|
||||
step: 'Starting initialization...',
|
||||
step: "Starting initialization...",
|
||||
} as WorkerResponse);
|
||||
|
||||
console.log('[PyodideWorker] Loading Pyodide runtime...');
|
||||
console.log("[PyodideWorker] Loading Pyodide runtime...");
|
||||
|
||||
self.postMessage({
|
||||
type: 'INIT_PROGRESS',
|
||||
type: "INIT_PROGRESS",
|
||||
progress: 10,
|
||||
step: 'Loading Python runtime...',
|
||||
step: "Loading Python runtime...",
|
||||
} as WorkerResponse);
|
||||
|
||||
// Load Pyodide runtime
|
||||
// Use provided URL or default to /assets/
|
||||
const indexURL = pyodideIndexURL || '/assets/';
|
||||
console.log('[PyodideWorker] Pyodide index URL:', indexURL);
|
||||
const indexURL = pyodideIndexURL || "/assets/";
|
||||
console.log("[PyodideWorker] Pyodide index URL:", indexURL);
|
||||
|
||||
pyodide = await loadPyodide({
|
||||
indexURL: indexURL,
|
||||
});
|
||||
|
||||
console.log('[PyodideWorker] Pyodide runtime loaded');
|
||||
console.log("[PyodideWorker] Pyodide runtime loaded");
|
||||
|
||||
self.postMessage({
|
||||
type: 'INIT_PROGRESS',
|
||||
type: "INIT_PROGRESS",
|
||||
progress: 70,
|
||||
step: 'Python runtime loaded',
|
||||
step: "Python runtime loaded",
|
||||
} as WorkerResponse);
|
||||
|
||||
self.postMessage({
|
||||
type: 'INIT_PROGRESS',
|
||||
type: "INIT_PROGRESS",
|
||||
progress: 75,
|
||||
step: 'Loading pystitch library...',
|
||||
step: "Loading pystitch library...",
|
||||
} as WorkerResponse);
|
||||
|
||||
// Load pystitch wheel
|
||||
// Use provided URL or default
|
||||
const wheelURL = pystitchWheelURL || '/pystitch-1.0.0-py3-none-any.whl';
|
||||
console.log('[PyodideWorker] Pystitch wheel URL:', wheelURL);
|
||||
const wheelURL = pystitchWheelURL || "/pystitch-1.0.0-py3-none-any.whl";
|
||||
console.log("[PyodideWorker] Pystitch wheel URL:", wheelURL);
|
||||
|
||||
await pyodide.loadPackage(wheelURL);
|
||||
|
||||
console.log('[PyodideWorker] pystitch library loaded');
|
||||
console.log("[PyodideWorker] pystitch library loaded");
|
||||
|
||||
self.postMessage({
|
||||
type: 'INIT_PROGRESS',
|
||||
type: "INIT_PROGRESS",
|
||||
progress: 100,
|
||||
step: 'Ready!',
|
||||
step: "Ready!",
|
||||
} as WorkerResponse);
|
||||
|
||||
self.postMessage({
|
||||
type: 'INIT_COMPLETE',
|
||||
type: "INIT_COMPLETE",
|
||||
} as WorkerResponse);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error('[PyodideWorker] Initialization error:', err);
|
||||
const errorMsg = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error("[PyodideWorker] Initialization error:", err);
|
||||
|
||||
self.postMessage({
|
||||
type: 'INIT_ERROR',
|
||||
type: "INIT_ERROR",
|
||||
error: errorMsg,
|
||||
} as WorkerResponse);
|
||||
|
||||
|
|
@ -154,18 +152,18 @@ async function initializePyodide(pyodideIndexURL?: string, pystitchWheelURL?: st
|
|||
*/
|
||||
async function convertPesToPen(fileData: ArrayBuffer) {
|
||||
if (!pyodide) {
|
||||
throw new Error('Pyodide not initialized');
|
||||
throw new Error("Pyodide not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
// Register our JavaScript constants module for Python to import
|
||||
pyodide.registerJsModule('js_emb_constants', jsEmbConstants);
|
||||
pyodide.registerJsModule("js_emb_constants", jsEmbConstants);
|
||||
|
||||
// Convert to Uint8Array
|
||||
const uint8Array = new Uint8Array(fileData);
|
||||
|
||||
// Write file to Pyodide virtual filesystem
|
||||
const tempFileName = '/tmp/pattern.pes';
|
||||
const tempFileName = "/tmp/pattern.pes";
|
||||
pyodide.FS.writeFile(tempFileName, uint8Array);
|
||||
|
||||
// Read the pattern using PyStitch (same logic as original converter)
|
||||
|
|
@ -277,11 +275,11 @@ for i, stitch in enumerate(pattern.stitches):
|
|||
|
||||
// Extract stitches and validate
|
||||
const stitches: number[][] = Array.from(
|
||||
data.stitches as ArrayLike<ArrayLike<number>>
|
||||
data.stitches as ArrayLike<ArrayLike<number>>,
|
||||
).map((stitch) => Array.from(stitch));
|
||||
|
||||
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
|
||||
|
|
@ -301,27 +299,27 @@ for i, stitch in enumerate(pattern.stitches):
|
|||
catalogNum !== undefined &&
|
||||
catalogNum !== null &&
|
||||
catalogNum !== -1 &&
|
||||
catalogNum !== '-1' &&
|
||||
catalogNum !== ''
|
||||
catalogNum !== "-1" &&
|
||||
catalogNum !== ""
|
||||
? String(catalogNum)
|
||||
: null;
|
||||
|
||||
return {
|
||||
color: thread.color ?? 0,
|
||||
hex: thread.hex || '#000000',
|
||||
hex: thread.hex || "#000000",
|
||||
catalogNumber: normalizedCatalog,
|
||||
brand: thread.brand && thread.brand !== '' ? thread.brand : null,
|
||||
brand: thread.brand && thread.brand !== "" ? thread.brand : null,
|
||||
description:
|
||||
thread.description && thread.description !== ''
|
||||
thread.description && thread.description !== ""
|
||||
? thread.description
|
||||
: 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
|
||||
console.log('[patternConverter] Encoding stitches to PEN format...');
|
||||
console.log(' - Input stitches:', stitches);
|
||||
console.log("[patternConverter] Encoding stitches to PEN format...");
|
||||
console.log(" - Input stitches:", stitches);
|
||||
const { penBytes: penStitches, bounds } = encodeStitchesToPen(stitches);
|
||||
const { minX, maxX, minY, maxY } = bounds;
|
||||
|
||||
|
|
@ -352,13 +350,13 @@ for i, stitch in enumerate(pattern.stitches):
|
|||
description: string | null;
|
||||
chart: string | null;
|
||||
threadIndices: number[];
|
||||
}>
|
||||
}>,
|
||||
);
|
||||
|
||||
// Calculate PEN stitch count (should match what machine will count)
|
||||
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(` - PEN bytes: ${penStitches.length}`);
|
||||
console.log(` - PEN stitches (bytes/4): ${penStitchCount}`);
|
||||
|
|
@ -366,7 +364,7 @@ for i, stitch in enumerate(pattern.stitches):
|
|||
|
||||
// Post result back to main thread
|
||||
self.postMessage({
|
||||
type: 'CONVERT_COMPLETE',
|
||||
type: "CONVERT_COMPLETE",
|
||||
data: {
|
||||
stitches,
|
||||
threads,
|
||||
|
|
@ -383,11 +381,11 @@ for i, stitch in enumerate(pattern.stitches):
|
|||
},
|
||||
} as WorkerResponse);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error('[PyodideWorker] Conversion error:', err);
|
||||
const errorMsg = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error("[PyodideWorker] Conversion error:", err);
|
||||
|
||||
self.postMessage({
|
||||
type: 'CONVERT_ERROR',
|
||||
type: "CONVERT_ERROR",
|
||||
error: errorMsg,
|
||||
} as WorkerResponse);
|
||||
|
||||
|
|
@ -398,26 +396,32 @@ for i, stitch in enumerate(pattern.stitches):
|
|||
// Handle messages from main thread
|
||||
self.onmessage = async (event: MessageEvent<WorkerMessage>) => {
|
||||
const message = event.data;
|
||||
console.log('[PatternConverterWorker] Received message:', message.type);
|
||||
console.log("[PatternConverterWorker] Received message:", message.type);
|
||||
|
||||
try {
|
||||
switch (message.type) {
|
||||
case 'INITIALIZE':
|
||||
console.log('[PatternConverterWorker] Starting initialization...');
|
||||
await initializePyodide(message.pyodideIndexURL, message.pystitchWheelURL);
|
||||
case "INITIALIZE":
|
||||
console.log("[PatternConverterWorker] Starting initialization...");
|
||||
await initializePyodide(
|
||||
message.pyodideIndexURL,
|
||||
message.pystitchWheelURL,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'CONVERT_PES':
|
||||
console.log('[PatternConverterWorker] Starting PES conversion...');
|
||||
case "CONVERT_PES":
|
||||
console.log("[PatternConverterWorker] Starting PES conversion...");
|
||||
await convertPesToPen(message.fileData);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error('[PatternConverterWorker] Unknown message type:', message);
|
||||
console.error(
|
||||
"[PatternConverterWorker] Unknown message type:",
|
||||
message,
|
||||
);
|
||||
}
|
||||
} 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");
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* 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
|
||||
const PEN_FEED_DATA = 0x01; // Bit 0: Jump stitch (move without stitching)
|
||||
|
|
@ -22,7 +22,7 @@ const PEN_DATA_END = 0x05; // Last stitch of entire pattern
|
|||
*/
|
||||
export function decodePenStitch(
|
||||
bytes: Uint8Array | number[],
|
||||
offset: number
|
||||
offset: number,
|
||||
): DecodedPenStitch {
|
||||
const xLow = bytes[offset];
|
||||
const xHigh = bytes[offset + 1];
|
||||
|
|
@ -37,14 +37,14 @@ export function decodePenStitch(
|
|||
const yFlags = yRaw & 0x07;
|
||||
|
||||
// Clear flags and shift right to get actual coordinates
|
||||
const xClean = xRaw & 0xFFF8;
|
||||
const yClean = yRaw & 0xFFF8;
|
||||
const xClean = xRaw & 0xfff8;
|
||||
const yClean = yRaw & 0xfff8;
|
||||
|
||||
// Convert to signed 16-bit
|
||||
let xSigned = xClean;
|
||||
let ySigned = yClean;
|
||||
if (xSigned > 0x7FFF) xSigned = xSigned - 0x10000;
|
||||
if (ySigned > 0x7FFF) ySigned = ySigned - 0x10000;
|
||||
if (xSigned > 0x7fff) xSigned = xSigned - 0x10000;
|
||||
if (ySigned > 0x7fff) ySigned = ySigned - 0x10000;
|
||||
|
||||
// Shift right by 3 to get actual coordinates
|
||||
const x = xSigned >> 3;
|
||||
|
|
@ -76,9 +76,13 @@ export function decodePenStitch(
|
|||
* @param bytes PEN format byte array
|
||||
* @returns Array of decoded stitches
|
||||
*/
|
||||
export function decodeAllPenStitches(bytes: Uint8Array | number[]): DecodedPenStitch[] {
|
||||
export function decodeAllPenStitches(
|
||||
bytes: Uint8Array | number[],
|
||||
): DecodedPenStitch[] {
|
||||
if (bytes.length < 4 || bytes.length % 4 !== 0) {
|
||||
throw new Error(`Invalid PEN data size: ${bytes.length} bytes (must be multiple of 4)`);
|
||||
throw new Error(
|
||||
`Invalid PEN data size: ${bytes.length} bytes (must be multiple of 4)`,
|
||||
);
|
||||
}
|
||||
|
||||
const stitches: DecodedPenStitch[] = [];
|
||||
|
|
@ -169,9 +173,15 @@ export function decodePenData(data: Uint8Array): DecodedPenData {
|
|||
* @param stitchIndex Index of the stitch
|
||||
* @returns Color index, or -1 if not found
|
||||
*/
|
||||
export function getStitchColor(penData: DecodedPenData, stitchIndex: number): number {
|
||||
export function getStitchColor(
|
||||
penData: DecodedPenData,
|
||||
stitchIndex: number,
|
||||
): number {
|
||||
for (const block of penData.colorBlocks) {
|
||||
if (stitchIndex >= block.startStitchIndex && stitchIndex <= block.endStitchIndex) {
|
||||
if (
|
||||
stitchIndex >= block.startStitchIndex &&
|
||||
stitchIndex <= block.endStitchIndex
|
||||
) {
|
||||
return block.colorIndex;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,51 +1,51 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
encodeStitchPosition,
|
||||
calculateLockDirection,
|
||||
generateLockStitches,
|
||||
encodeStitchesToPen,
|
||||
LOCK_STITCH_JUMP_SIZE
|
||||
} from './encoder';
|
||||
import { decodeAllPenStitches } from './decoder';
|
||||
import { STITCH, MOVE, TRIM, END } from '../import/constants';
|
||||
LOCK_STITCH_JUMP_SIZE,
|
||||
} from "./encoder";
|
||||
import { decodeAllPenStitches } from "./decoder";
|
||||
import { STITCH, MOVE, TRIM, END } from "../import/constants";
|
||||
|
||||
// PEN format flag constants for testing
|
||||
const PEN_FEED_DATA = 0x01;
|
||||
const PEN_CUT_DATA = 0x02;
|
||||
|
||||
describe('encodeStitchPosition', () => {
|
||||
it('should encode position (0, 0) correctly', () => {
|
||||
describe("encodeStitchPosition", () => {
|
||||
it("should encode position (0, 0) correctly", () => {
|
||||
const result = encodeStitchPosition(0, 0);
|
||||
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
|
||||
const result = encodeStitchPosition(1, 1);
|
||||
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
|
||||
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
|
||||
const result = encodeStitchPosition(128, 0);
|
||||
expect(result).toEqual([0x00, 0x04, 0x00, 0x00]);
|
||||
});
|
||||
|
||||
it('should round fractional coordinates', () => {
|
||||
it("should round fractional coordinates", () => {
|
||||
const result = encodeStitchPosition(1.5, 2.4);
|
||||
// 2 << 3 = 16, 2 << 3 = 16
|
||||
expect(result).toEqual([0x10, 0x00, 0x10, 0x00]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateLockDirection', () => {
|
||||
it('should look ahead for forward direction', () => {
|
||||
describe("calculateLockDirection", () => {
|
||||
it("should look ahead for forward direction", () => {
|
||||
const stitches = [
|
||||
[0, 0, STITCH, 0],
|
||||
[10, 0, STITCH, 0],
|
||||
|
|
@ -62,7 +62,7 @@ describe('calculateLockDirection', () => {
|
|||
expect(magnitude).toBeCloseTo(8.0, 1);
|
||||
});
|
||||
|
||||
it('should look backward for backward direction', () => {
|
||||
it("should look backward for backward direction", () => {
|
||||
const stitches = [
|
||||
[0, 0, STITCH, 0],
|
||||
[10, 0, STITCH, 0],
|
||||
|
|
@ -79,7 +79,7 @@ describe('calculateLockDirection', () => {
|
|||
expect(magnitude).toBeCloseTo(8.0, 1);
|
||||
});
|
||||
|
||||
it('should skip MOVE stitches when accumulating', () => {
|
||||
it("should skip MOVE stitches when accumulating", () => {
|
||||
const stitches = [
|
||||
[0, 0, STITCH, 0],
|
||||
[5, 0, MOVE, 0], // Should be skipped
|
||||
|
|
@ -93,10 +93,8 @@ describe('calculateLockDirection', () => {
|
|||
expect(result.dirX).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return fallback diagonal for empty or short stitch sequences', () => {
|
||||
const stitches = [
|
||||
[0, 0, STITCH, 0],
|
||||
];
|
||||
it("should return fallback diagonal for empty or short stitch sequences", () => {
|
||||
const stitches = [[0, 0, STITCH, 0]];
|
||||
|
||||
const result = calculateLockDirection(stitches, 0, true);
|
||||
|
||||
|
|
@ -106,7 +104,7 @@ describe('calculateLockDirection', () => {
|
|||
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 = [
|
||||
[0, 0, STITCH, 0],
|
||||
[3, 4, STITCH, 0], // Distance = 5
|
||||
|
|
@ -124,7 +122,7 @@ describe('calculateLockDirection', () => {
|
|||
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
|
||||
const stitches = [
|
||||
[0, 0, STITCH, 0],
|
||||
|
|
@ -144,13 +142,13 @@ describe('calculateLockDirection', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('generateLockStitches', () => {
|
||||
it('should generate 8 lock stitches (32 bytes)', () => {
|
||||
describe("generateLockStitches", () => {
|
||||
it("should generate 8 lock stitches (32 bytes)", () => {
|
||||
const result = generateLockStitches(0, 0, 8.0, 0);
|
||||
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);
|
||||
expect(result.length).toBe(32); // 8 stitches * 4 bytes
|
||||
|
||||
|
|
@ -159,7 +157,7 @@ describe('generateLockStitches', () => {
|
|||
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)
|
||||
const result = generateLockStitches(0, 0, 8.0, 0);
|
||||
|
||||
|
|
@ -176,8 +174,8 @@ describe('generateLockStitches', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('encodeStitchesToPen', () => {
|
||||
it('should encode a simple stitch sequence', () => {
|
||||
describe("encodeStitchesToPen", () => {
|
||||
it("should encode a simple stitch sequence", () => {
|
||||
const stitches = [
|
||||
[0, 0, STITCH, 0],
|
||||
[10, 0, STITCH, 0],
|
||||
|
|
@ -192,7 +190,7 @@ describe('encodeStitchesToPen', () => {
|
|||
expect(result.bounds.maxX).toBe(20);
|
||||
});
|
||||
|
||||
it('should track bounds correctly', () => {
|
||||
it("should track bounds correctly", () => {
|
||||
const stitches = [
|
||||
[10, 20, STITCH, 0],
|
||||
[-5, 30, STITCH, 0],
|
||||
|
|
@ -208,7 +206,7 @@ describe('encodeStitchesToPen', () => {
|
|||
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 = [
|
||||
[0, 0, STITCH, 0],
|
||||
[10, 0, END, 0],
|
||||
|
|
@ -222,7 +220,7 @@ describe('encodeStitchesToPen', () => {
|
|||
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 = [
|
||||
[0, 0, STITCH, 0], // Color 0
|
||||
[10, 0, STITCH, 0], // Color 0
|
||||
|
|
@ -246,7 +244,7 @@ describe('encodeStitchesToPen', () => {
|
|||
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
|
||||
const stitches = [
|
||||
[0, 0, STITCH, 0], // Color 0
|
||||
|
|
@ -325,7 +323,7 @@ describe('encodeStitchesToPen', () => {
|
|||
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
|
||||
const stitches = [
|
||||
[0, 0, STITCH, 0], // Color 0
|
||||
|
|
@ -372,7 +370,7 @@ describe('encodeStitchesToPen', () => {
|
|||
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
|
||||
// This is a common pattern: color change, then jump to new location
|
||||
const stitches = [
|
||||
|
|
@ -443,7 +441,7 @@ describe('encodeStitchesToPen', () => {
|
|||
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)
|
||||
const stitches = [
|
||||
[0, 0, STITCH, 0],
|
||||
|
|
@ -508,7 +506,7 @@ describe('encodeStitchesToPen', () => {
|
|||
expect(decoded[idx].isDataEnd).toBe(true);
|
||||
});
|
||||
|
||||
it('should encode MOVE flag for jump stitches', () => {
|
||||
it("should encode MOVE flag for jump stitches", () => {
|
||||
const stitches = [
|
||||
[0, 0, STITCH, 0],
|
||||
[10, 0, MOVE, 0], // Short jump (no lock stitches)
|
||||
|
|
@ -525,7 +523,7 @@ describe('encodeStitchesToPen', () => {
|
|||
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 = [
|
||||
[0, 0, STITCH, 0],
|
||||
[100, 100, MOVE, 0], // Jump - should not affect bounds
|
||||
|
|
@ -542,7 +540,7 @@ describe('encodeStitchesToPen', () => {
|
|||
expect(result.bounds.maxY).toBe(20);
|
||||
});
|
||||
|
||||
it('should handle TRIM flag correctly', () => {
|
||||
it("should handle TRIM flag correctly", () => {
|
||||
const stitches = [
|
||||
[0, 0, STITCH, 0],
|
||||
[10, 0, TRIM, 0],
|
||||
|
|
@ -574,7 +572,7 @@ describe('encodeStitchesToPen', () => {
|
|||
expect(decoded[idx].isDataEnd).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty stitch array', () => {
|
||||
it("should handle empty stitch array", () => {
|
||||
const stitches: number[][] = [];
|
||||
|
||||
const result = encodeStitchesToPen(stitches);
|
||||
|
|
@ -586,10 +584,8 @@ describe('encodeStitchesToPen', () => {
|
|||
expect(result.bounds.maxY).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle single stitch', () => {
|
||||
const stitches = [
|
||||
[5, 10, END, 0],
|
||||
];
|
||||
it("should handle single stitch", () => {
|
||||
const stitches = [[5, 10, END, 0]];
|
||||
|
||||
const result = encodeStitchesToPen(stitches);
|
||||
|
||||
|
|
@ -601,7 +597,7 @@ describe('encodeStitchesToPen', () => {
|
|||
// 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
|
||||
// even if the input stitches don't have an END flag
|
||||
const stitches = [
|
||||
|
|
@ -623,7 +619,7 @@ describe('encodeStitchesToPen', () => {
|
|||
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
|
||||
const stitches = [
|
||||
[0, 0, STITCH, 0],
|
||||
|
|
@ -640,7 +636,7 @@ describe('encodeStitchesToPen', () => {
|
|||
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
|
||||
// This adds starting lock stitches to secure the thread at pattern start
|
||||
const stitches = [
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* 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
|
||||
const PEN_FEED_DATA = 0x01; // Bit 0: Jump stitch (move without stitching)
|
||||
|
|
@ -45,12 +45,7 @@ export function encodeStitchPosition(x: number, y: number): number[] {
|
|||
const xEnc = (Math.round(x) << 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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -70,7 +65,7 @@ export function encodeStitchPosition(x: number, y: number): number[] {
|
|||
export function calculateLockDirection(
|
||||
stitches: number[][],
|
||||
currentIndex: number,
|
||||
lookAhead: boolean
|
||||
lookAhead: boolean,
|
||||
): { dirX: number; dirY: number } {
|
||||
let accumulatedX = 0;
|
||||
let accumulatedY = 0;
|
||||
|
|
@ -84,7 +79,7 @@ export function calculateLockDirection(
|
|||
: Math.min(MAX_POINTS, currentIndex);
|
||||
|
||||
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;
|
||||
|
||||
const stitch = stitches[idx];
|
||||
|
|
@ -94,13 +89,17 @@ export function calculateLockDirection(
|
|||
if ((cmd & MOVE) !== 0) continue;
|
||||
|
||||
// Accumulate relative coordinates
|
||||
const deltaX = Math.round(stitch[0]) - Math.round(stitches[currentIndex][0]);
|
||||
const deltaY = Math.round(stitch[1]) - Math.round(stitches[currentIndex][1]);
|
||||
const deltaX =
|
||||
Math.round(stitch[0]) - Math.round(stitches[currentIndex][0]);
|
||||
const deltaY =
|
||||
Math.round(stitch[1]) - Math.round(stitches[currentIndex][1]);
|
||||
|
||||
accumulatedX += deltaX;
|
||||
accumulatedY += deltaY;
|
||||
|
||||
const length = Math.sqrt(accumulatedX * accumulatedX + accumulatedY * accumulatedY);
|
||||
const length = Math.sqrt(
|
||||
accumulatedX * accumulatedX + accumulatedY * accumulatedY,
|
||||
);
|
||||
|
||||
// Track the maximum length vector seen so far
|
||||
if (length > maxLength) {
|
||||
|
|
@ -113,7 +112,7 @@ export function calculateLockDirection(
|
|||
if (length >= TARGET_LENGTH) {
|
||||
return {
|
||||
dirX: (accumulatedX * 8.0) / length,
|
||||
dirY: (accumulatedY * 8.0) / length
|
||||
dirY: (accumulatedY * 8.0) / length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +121,7 @@ export function calculateLockDirection(
|
|||
if (maxLength > 0.1) {
|
||||
return {
|
||||
dirX: (bestX * 8.0) / maxLength,
|
||||
dirY: (bestY * 8.0) / maxLength
|
||||
dirY: (bestY * 8.0) / maxLength,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -140,7 +139,12 @@ export function calculateLockDirection(
|
|||
* @param dirY Direction Y component (magnitude ~8.0)
|
||||
* @returns Array of PEN bytes for lock stitches (32 bytes = 8 stitches * 4 bytes)
|
||||
*/
|
||||
export function generateLockStitches(x: number, y: number, dirX: number, dirY: number): number[] {
|
||||
export function generateLockStitches(
|
||||
x: number,
|
||||
y: number,
|
||||
dirX: number,
|
||||
dirY: number,
|
||||
): number[] {
|
||||
const lockBytes: number[] = [];
|
||||
|
||||
// Generate 8 lock stitches in alternating pattern
|
||||
|
|
@ -153,7 +157,7 @@ export function generateLockStitches(x: number, y: number, dirX: number, dirY: n
|
|||
// Generate 8 stitches alternating between forward and backward
|
||||
for (let i = 0; i < 8; i++) {
|
||||
// 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 yAdd = scaledDirY * sign;
|
||||
lockBytes.push(...encodeStitchPosition(x + xAdd, y + yAdd));
|
||||
|
|
@ -181,7 +185,6 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
|
|||
let prevX = 0;
|
||||
let prevY = 0;
|
||||
|
||||
|
||||
for (let i = 0; i < stitches.length; i++) {
|
||||
const stitch = stitches[i];
|
||||
const absX = Math.round(stitch[0]);
|
||||
|
|
@ -209,7 +212,9 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
|
|||
// Loop B: End/Cut Vector - Look BACKWARD at previous stitches
|
||||
// This hides the knot inside the embroidery we just finished
|
||||
const finishDir = calculateLockDirection(stitches, i - 1, false);
|
||||
penStitches.push(...generateLockStitches(prevX, prevY, finishDir.dirX, finishDir.dirY));
|
||||
penStitches.push(
|
||||
...generateLockStitches(prevX, prevY, finishDir.dirX, finishDir.dirY),
|
||||
);
|
||||
|
||||
// Encode jump with both FEED and CUT flags
|
||||
const xEncoded = (absX << 3) & 0xffff;
|
||||
|
|
@ -221,14 +226,16 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
|
|||
xEncoded & 0xff,
|
||||
(xEncoded >> 8) & 0xff,
|
||||
yEncoded & 0xff,
|
||||
(yEncoded >> 8) & 0xff
|
||||
(yEncoded >> 8) & 0xff,
|
||||
);
|
||||
|
||||
// Add starting lock stitches at new position
|
||||
// Loop A: Jump/Entry Vector - Look FORWARD at upcoming stitches
|
||||
// This hides the knot under the stitches we're about to make
|
||||
const startDir = calculateLockDirection(stitches, i, true);
|
||||
penStitches.push(...generateLockStitches(absX, absY, startDir.dirX, startDir.dirY));
|
||||
penStitches.push(
|
||||
...generateLockStitches(absX, absY, startDir.dirX, startDir.dirY),
|
||||
);
|
||||
|
||||
// Update position and continue
|
||||
prevX = absX;
|
||||
|
|
@ -258,7 +265,10 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
|
|||
// Check for color change by comparing stitch color index
|
||||
const nextStitch = stitches[i + 1];
|
||||
const nextStitchColor = nextStitch?.[3];
|
||||
const isColorChange = !isLastStitch && nextStitchColor !== undefined && nextStitchColor !== stitchColor;
|
||||
const isColorChange =
|
||||
!isLastStitch &&
|
||||
nextStitchColor !== undefined &&
|
||||
nextStitchColor !== stitchColor;
|
||||
|
||||
// Mark the very last stitch of the pattern with DATA_END
|
||||
if (isLastStitch) {
|
||||
|
|
@ -270,7 +280,7 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
|
|||
xEncoded & 0xff,
|
||||
(xEncoded >> 8) & 0xff,
|
||||
yEncoded & 0xff,
|
||||
(yEncoded >> 8) & 0xff
|
||||
(yEncoded >> 8) & 0xff,
|
||||
);
|
||||
|
||||
// Update position for next iteration
|
||||
|
|
@ -283,7 +293,9 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
|
|||
|
||||
// Calculate direction for starting locks (look forward into the pattern)
|
||||
const startDir = calculateLockDirection(stitches, i, true);
|
||||
penStitches.push(...generateLockStitches(absX, absY, startDir.dirX, startDir.dirY));
|
||||
penStitches.push(
|
||||
...generateLockStitches(absX, absY, startDir.dirX, startDir.dirY),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle color change: finishing lock, COLOR_END+CUT, jump, starting lock
|
||||
|
|
@ -297,7 +309,9 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
|
|||
// 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
|
||||
const finishDir = calculateLockDirection(stitches, i, true);
|
||||
penStitches.push(...generateLockStitches(absX, absY, finishDir.dirX, finishDir.dirY));
|
||||
penStitches.push(
|
||||
...generateLockStitches(absX, absY, finishDir.dirX, finishDir.dirY),
|
||||
);
|
||||
|
||||
// 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
|
||||
|
|
@ -313,7 +327,7 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
|
|||
colorEndCutXEncoded & 0xff,
|
||||
(colorEndCutXEncoded >> 8) & 0xff,
|
||||
colorEndCutYEncoded & 0xff,
|
||||
(colorEndCutYEncoded >> 8) & 0xff
|
||||
(colorEndCutYEncoded >> 8) & 0xff,
|
||||
);
|
||||
|
||||
// Machine pauses here for color change
|
||||
|
|
@ -339,7 +353,7 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
|
|||
jumpXEncoded & 0xff,
|
||||
(jumpXEncoded >> 8) & 0xff,
|
||||
jumpYEncoded & 0xff,
|
||||
(jumpYEncoded >> 8) & 0xff
|
||||
(jumpYEncoded >> 8) & 0xff,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -347,8 +361,14 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
|
|||
// 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
|
||||
const nextStitchIdx = nextIsJump ? i + 2 : i + 1;
|
||||
const startDir = calculateLockDirection(stitches, nextStitchIdx < stitches.length ? nextStitchIdx : i, true);
|
||||
penStitches.push(...generateLockStitches(jumpToX, jumpToY, startDir.dirX, startDir.dirY));
|
||||
const startDir = calculateLockDirection(
|
||||
stitches,
|
||||
nextStitchIdx < stitches.length ? nextStitchIdx : i,
|
||||
true,
|
||||
);
|
||||
penStitches.push(
|
||||
...generateLockStitches(jumpToX, jumpToY, startDir.dirX, startDir.dirY),
|
||||
);
|
||||
|
||||
// Update position
|
||||
prevX = jumpToX;
|
||||
|
|
|
|||
12
src/main.tsx
12
src/main.tsx
|
|
@ -1,10 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -6,8 +6,8 @@ import type { IFileService } from '../interfaces/IFileService';
|
|||
export class BrowserFileService implements IFileService {
|
||||
async openFileDialog(options: { accept: string }): Promise<File | null> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = options.accept;
|
||||
|
||||
input.onchange = (e) => {
|
||||
|
|
@ -25,7 +25,7 @@ export class BrowserFileService implements IFileService {
|
|||
|
||||
async saveFileDialog(): Promise<void> {
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { PatternCacheService } from '../../services/PatternCacheService';
|
||||
import type { IStorageService, ICachedPattern } from '../interfaces/IStorageService';
|
||||
import type { PesPatternData } from '../../formats/import/pesImporter';
|
||||
import { PatternCacheService } from "../../services/PatternCacheService";
|
||||
import type {
|
||||
IStorageService,
|
||||
ICachedPattern,
|
||||
} from "../interfaces/IStorageService";
|
||||
import type { PesPatternData } from "../../formats/import/pesImporter";
|
||||
|
||||
/**
|
||||
* Browser implementation of storage service using localStorage
|
||||
|
|
@ -11,7 +14,7 @@ export class BrowserStorageService implements IStorageService {
|
|||
uuid: string,
|
||||
pesData: PesPatternData,
|
||||
fileName: string,
|
||||
patternOffset?: { x: number; y: number }
|
||||
patternOffset?: { x: number; y: number },
|
||||
): Promise<void> {
|
||||
PatternCacheService.savePattern(uuid, pesData, fileName, patternOffset);
|
||||
}
|
||||
|
|
@ -36,7 +39,12 @@ export class BrowserStorageService implements IStorageService {
|
|||
PatternCacheService.clearCache();
|
||||
}
|
||||
|
||||
async getCacheInfo(): Promise<{ hasCache: boolean; fileName?: string; uuid?: string; age?: number }> {
|
||||
async getCacheInfo(): Promise<{
|
||||
hasCache: boolean;
|
||||
fileName?: string;
|
||||
uuid?: string;
|
||||
age?: number;
|
||||
}> {
|
||||
return PatternCacheService.getCacheInfo();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -6,14 +6,17 @@ import type { IFileService } from '../interfaces/IFileService';
|
|||
export class ElectronFileService implements IFileService {
|
||||
async openFileDialog(): Promise<File | null> {
|
||||
if (!window.electronAPI) {
|
||||
throw new Error('Electron API not available');
|
||||
throw new Error("Electron API not available");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.invoke<{ filePath: string; fileName: string } | null>('dialog:openFile', {
|
||||
const result = await window.electronAPI.invoke<{
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
} | null>("dialog:openFile", {
|
||||
filters: [
|
||||
{ name: 'PES Files', extensions: ['pes'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
{ name: "PES Files", extensions: ["pes"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
});
|
||||
|
||||
|
|
@ -22,34 +25,46 @@ export class ElectronFileService implements IFileService {
|
|||
}
|
||||
|
||||
// Read the file content
|
||||
const buffer = await window.electronAPI.invoke<ArrayBuffer>('fs:readFile', result.filePath);
|
||||
const buffer = await window.electronAPI.invoke<ArrayBuffer>(
|
||||
"fs:readFile",
|
||||
result.filePath,
|
||||
);
|
||||
const blob = new Blob([buffer]);
|
||||
return new File([blob], result.fileName, { type: 'application/octet-stream' });
|
||||
return new File([blob], result.fileName, {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ElectronFileService] Failed to open file:', err);
|
||||
console.error("[ElectronFileService] Failed to open file:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async saveFileDialog(data: Uint8Array, defaultName: string): Promise<void> {
|
||||
if (!window.electronAPI) {
|
||||
throw new Error('Electron API not available');
|
||||
throw new Error("Electron API not available");
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = await window.electronAPI.invoke<string | null>('dialog:saveFile', {
|
||||
const filePath = await window.electronAPI.invoke<string | null>(
|
||||
"dialog:saveFile",
|
||||
{
|
||||
defaultPath: defaultName,
|
||||
filters: [
|
||||
{ name: 'PEN Files', extensions: ['pen'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
{ name: "PEN Files", extensions: ["pen"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (filePath) {
|
||||
await window.electronAPI.invoke('fs:writeFile', filePath, Array.from(data));
|
||||
await window.electronAPI.invoke(
|
||||
"fs:writeFile",
|
||||
filePath,
|
||||
Array.from(data),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ElectronFileService] Failed to save file:', err);
|
||||
console.error("[ElectronFileService] Failed to save file:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import type { IStorageService, ICachedPattern } from '../interfaces/IStorageService';
|
||||
import type { PesPatternData } from '../../formats/import/pesImporter';
|
||||
import type {
|
||||
IStorageService,
|
||||
ICachedPattern,
|
||||
} from "../interfaces/IStorageService";
|
||||
import type { PesPatternData } from "../../formats/import/pesImporter";
|
||||
|
||||
/**
|
||||
* Electron implementation of storage service using electron-store via IPC
|
||||
|
|
@ -7,7 +10,7 @@ import type { PesPatternData } from '../../formats/import/pesImporter';
|
|||
export class ElectronStorageService implements IStorageService {
|
||||
private async invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
|
||||
if (!window.electronAPI) {
|
||||
throw new Error('Electron API not available');
|
||||
throw new Error("Electron API not available");
|
||||
}
|
||||
return window.electronAPI.invoke(channel, ...args);
|
||||
}
|
||||
|
|
@ -16,7 +19,7 @@ export class ElectronStorageService implements IStorageService {
|
|||
uuid: string,
|
||||
pesData: PesPatternData,
|
||||
fileName: string,
|
||||
patternOffset?: { x: number; y: number }
|
||||
patternOffset?: { x: number; y: number },
|
||||
): Promise<void> {
|
||||
// Convert Uint8Array to array for JSON serialization over IPC
|
||||
const serializable = {
|
||||
|
|
@ -31,14 +34,17 @@ export class ElectronStorageService implements IStorageService {
|
|||
};
|
||||
|
||||
// Fire and forget (sync-like behavior to match interface)
|
||||
this.invoke('storage:savePattern', serializable).catch(err => {
|
||||
console.error('[ElectronStorage] Failed to save pattern:', err);
|
||||
this.invoke("storage:savePattern", serializable).catch((err) => {
|
||||
console.error("[ElectronStorage] Failed to save pattern:", err);
|
||||
});
|
||||
}
|
||||
|
||||
async getPatternByUUID(uuid: string): Promise<ICachedPattern | null> {
|
||||
try {
|
||||
const pattern = await this.invoke<ICachedPattern | null>('storage:getPattern', uuid);
|
||||
const pattern = await this.invoke<ICachedPattern | null>(
|
||||
"storage:getPattern",
|
||||
uuid,
|
||||
);
|
||||
|
||||
if (pattern && Array.isArray(pattern.pesData.penData)) {
|
||||
// Restore Uint8Array from array
|
||||
|
|
@ -47,14 +53,16 @@ export class ElectronStorageService implements IStorageService {
|
|||
|
||||
return pattern;
|
||||
} catch (err) {
|
||||
console.error('[ElectronStorage] Failed to get pattern:', err);
|
||||
console.error("[ElectronStorage] Failed to get pattern:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getMostRecentPattern(): Promise<ICachedPattern | null> {
|
||||
try {
|
||||
const pattern = await this.invoke<ICachedPattern | null>('storage:getLatest');
|
||||
const pattern = await this.invoke<ICachedPattern | null>(
|
||||
"storage:getLatest",
|
||||
);
|
||||
|
||||
if (pattern && Array.isArray(pattern.pesData.penData)) {
|
||||
// Restore Uint8Array from array
|
||||
|
|
@ -63,7 +71,7 @@ export class ElectronStorageService implements IStorageService {
|
|||
|
||||
return pattern;
|
||||
} catch (err) {
|
||||
console.error('[ElectronStorage] Failed to get latest pattern:', err);
|
||||
console.error("[ElectronStorage] Failed to get latest pattern:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -71,29 +79,38 @@ export class ElectronStorageService implements IStorageService {
|
|||
async hasPattern(): Promise<boolean> {
|
||||
// Since this is async in Electron, we can't truly implement this synchronously
|
||||
// Returning false as a safe default
|
||||
console.warn('[ElectronStorage] hasPattern called synchronously, returning false');
|
||||
console.warn(
|
||||
"[ElectronStorage] hasPattern called synchronously, returning false",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
async deletePattern(uuid: string): Promise<void> {
|
||||
try {
|
||||
await this.invoke('storage:deletePattern', uuid);
|
||||
await this.invoke("storage:deletePattern", uuid);
|
||||
} catch (err) {
|
||||
console.error('[ElectronStorage] Failed to delete pattern:', err);
|
||||
console.error("[ElectronStorage] Failed to delete pattern:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async clearCache(): Promise<void> {
|
||||
try {
|
||||
await this.invoke('storage:clear');
|
||||
await this.invoke("storage:clear");
|
||||
} catch (err) {
|
||||
console.error('[ElectronStorage] Failed to clear cache:', err);
|
||||
console.error("[ElectronStorage] Failed to clear cache:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async getCacheInfo(): Promise<{ hasCache: boolean; fileName?: string; uuid?: string; age?: number }> {
|
||||
async getCacheInfo(): Promise<{
|
||||
hasCache: boolean;
|
||||
fileName?: string;
|
||||
uuid?: string;
|
||||
age?: number;
|
||||
}> {
|
||||
// This needs to be async in Electron, return empty info synchronously
|
||||
console.warn('[ElectronStorage] getCacheInfo called synchronously, returning empty');
|
||||
console.warn(
|
||||
"[ElectronStorage] getCacheInfo called synchronously, returning empty",
|
||||
);
|
||||
return { hasCache: false };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import type { IStorageService } from './interfaces/IStorageService';
|
||||
import type { IFileService } from './interfaces/IFileService';
|
||||
import { BrowserStorageService } from './browser/BrowserStorageService';
|
||||
import { BrowserFileService } from './browser/BrowserFileService';
|
||||
import { ElectronStorageService } from './electron/ElectronStorageService';
|
||||
import { ElectronFileService } from './electron/ElectronFileService';
|
||||
import type { IStorageService } from "./interfaces/IStorageService";
|
||||
import type { IFileService } from "./interfaces/IFileService";
|
||||
import { BrowserStorageService } from "./browser/BrowserStorageService";
|
||||
import { BrowserFileService } from "./browser/BrowserFileService";
|
||||
import { ElectronStorageService } from "./electron/ElectronStorageService";
|
||||
import { ElectronFileService } from "./electron/ElectronFileService";
|
||||
|
||||
/**
|
||||
* Detect if running in Electron
|
||||
*/
|
||||
export function isElectron(): boolean {
|
||||
return !!(typeof window !== 'undefined' && window.process && window.process.type === 'renderer');
|
||||
return !!(
|
||||
typeof window !== "undefined" &&
|
||||
window.process &&
|
||||
window.process.type === "renderer"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { PesPatternData } from '../../formats/import/pesImporter';
|
||||
import type { PesPatternData } from "../../formats/import/pesImporter";
|
||||
|
||||
export interface ICachedPattern {
|
||||
uuid: string;
|
||||
|
|
@ -13,7 +13,7 @@ export interface IStorageService {
|
|||
uuid: string,
|
||||
pesData: PesPatternData,
|
||||
fileName: string,
|
||||
patternOffset?: { x: number; y: number }
|
||||
patternOffset?: { x: number; y: number },
|
||||
): Promise<void>;
|
||||
|
||||
getPatternByUUID(uuid: string): Promise<ICachedPattern | null>;
|
||||
|
|
@ -21,5 +21,10 @@ export interface IStorageService {
|
|||
hasPattern(uuid: string): Promise<boolean>;
|
||||
deletePattern(uuid: string): Promise<void>;
|
||||
clearCache(): Promise<void>;
|
||||
getCacheInfo(): Promise<{ hasCache: boolean; fileName?: string; uuid?: string; age?: number }>;
|
||||
getCacheInfo(): Promise<{
|
||||
hasCache: boolean;
|
||||
fileName?: string;
|
||||
uuid?: string;
|
||||
age?: number;
|
||||
}>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { MachineStatus } from "../types/machine";
|
|||
export class BluetoothPairingError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'BluetoothPairingError';
|
||||
this.name = "BluetoothPairingError";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +57,8 @@ export class BrotherPP1Service {
|
|||
private isProcessingQueue = false;
|
||||
private isCommunicating = false;
|
||||
private isInitialConnection = false;
|
||||
private communicationCallbacks: Set<(isCommunicating: boolean) => void> = new Set();
|
||||
private communicationCallbacks: Set<(isCommunicating: boolean) => void> =
|
||||
new Set();
|
||||
private disconnectCallbacks: Set<() => void> = new Set();
|
||||
|
||||
/**
|
||||
|
|
@ -65,7 +66,9 @@ export class BrotherPP1Service {
|
|||
* @param callback Function called when communication state changes
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
onCommunicationChange(callback: (isCommunicating: boolean) => void): () => void {
|
||||
onCommunicationChange(
|
||||
callback: (isCommunicating: boolean) => void,
|
||||
): () => void {
|
||||
this.communicationCallbacks.add(callback);
|
||||
// Immediately call with current state
|
||||
callback(this.isCommunicating);
|
||||
|
|
@ -89,19 +92,19 @@ export class BrotherPP1Service {
|
|||
private setCommunicating(value: boolean) {
|
||||
if (this.isCommunicating !== value) {
|
||||
this.isCommunicating = value;
|
||||
this.communicationCallbacks.forEach(callback => callback(value));
|
||||
this.communicationCallbacks.forEach((callback) => callback(value));
|
||||
}
|
||||
}
|
||||
|
||||
private handleDisconnect() {
|
||||
console.log('[BrotherPP1Service] Device disconnected');
|
||||
console.log("[BrotherPP1Service] Device disconnected");
|
||||
this.server = null;
|
||||
this.writeCharacteristic = null;
|
||||
this.readCharacteristic = null;
|
||||
this.commandQueue = [];
|
||||
this.isProcessingQueue = false;
|
||||
this.setCommunicating(false);
|
||||
this.disconnectCallbacks.forEach(callback => callback());
|
||||
this.disconnectCallbacks.forEach((callback) => callback());
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
|
|
@ -116,7 +119,7 @@ export class BrotherPP1Service {
|
|||
}
|
||||
|
||||
// Listen for disconnection events
|
||||
this.device.addEventListener('gattserverdisconnected', () => {
|
||||
this.device.addEventListener("gattserverdisconnected", () => {
|
||||
this.handleDisconnect();
|
||||
});
|
||||
|
||||
|
|
@ -126,7 +129,8 @@ export class BrotherPP1Service {
|
|||
const service = await this.server.getPrimaryService(SERVICE_UUID);
|
||||
console.log("Got primary service");
|
||||
|
||||
this.writeCharacteristic = await service.getCharacteristic(WRITE_CHAR_UUID);
|
||||
this.writeCharacteristic =
|
||||
await service.getCharacteristic(WRITE_CHAR_UUID);
|
||||
this.readCharacteristic = await service.getCharacteristic(READ_CHAR_UUID);
|
||||
|
||||
console.log("Connected to Brother PP1 machine");
|
||||
|
|
@ -136,7 +140,9 @@ export class BrotherPP1Service {
|
|||
console.log("Validating connection with test command...");
|
||||
try {
|
||||
await this.getMachineState();
|
||||
console.log("Connection validation successful - device is properly paired");
|
||||
console.log(
|
||||
"Connection validation successful - device is properly paired",
|
||||
);
|
||||
} catch (e) {
|
||||
console.log("Connection validation failed:", e);
|
||||
// Disconnect to clean up
|
||||
|
|
@ -289,16 +295,21 @@ export class BrotherPP1Service {
|
|||
// Detect pairing issues during initial connection - empty or invalid response
|
||||
if (this.isInitialConnection) {
|
||||
if (response.length === 0) {
|
||||
console.log('[BrotherPP1] Empty response received - device likely not paired');
|
||||
console.log(
|
||||
"[BrotherPP1] Empty response received - device likely not paired",
|
||||
);
|
||||
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)
|
||||
if (response.length < 3) {
|
||||
console.log('[BrotherPP1] Invalid response length:', response.length);
|
||||
console.log(
|
||||
"[BrotherPP1] Invalid response length:",
|
||||
response.length,
|
||||
);
|
||||
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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -322,11 +333,12 @@ export class BrotherPP1Service {
|
|||
if (this.isInitialConnection && error instanceof Error) {
|
||||
const errorMsg = error.message.toLowerCase();
|
||||
if (
|
||||
errorMsg.includes('gatt server is disconnected') ||
|
||||
(errorMsg.includes('writevaluewithresponse') && errorMsg.includes('gatt server is disconnected'))
|
||||
errorMsg.includes("gatt server is disconnected") ||
|
||||
(errorMsg.includes("writevaluewithresponse") &&
|
||||
errorMsg.includes("gatt server is disconnected"))
|
||||
) {
|
||||
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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -369,7 +381,7 @@ export class BrotherPP1Service {
|
|||
serviceCount = serviceData.serviceCount;
|
||||
totalCount = serviceData.totalCount;
|
||||
} catch (err) {
|
||||
console.warn('[BrotherPP1] Failed to fetch service count:', err);
|
||||
console.warn("[BrotherPP1] Failed to fetch service count:", err);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -385,13 +397,19 @@ export class BrotherPP1Service {
|
|||
};
|
||||
}
|
||||
|
||||
async getServiceCount(): Promise<{ serviceCount: number; totalCount: number }> {
|
||||
async getServiceCount(): Promise<{
|
||||
serviceCount: number;
|
||||
totalCount: number;
|
||||
}> {
|
||||
const response = await this.sendCommand(Commands.SERVICE_COUNT);
|
||||
const data = response.slice(2);
|
||||
|
||||
// Read uint32 values in little-endian format
|
||||
const readUInt32LE = (offset: number) =>
|
||||
data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24);
|
||||
data[offset] |
|
||||
(data[offset + 1] << 8) |
|
||||
(data[offset + 2] << 16) |
|
||||
(data[offset + 3] << 24);
|
||||
|
||||
return {
|
||||
serviceCount: readUInt32LE(0), // Bytes 0-3
|
||||
|
|
@ -427,8 +445,10 @@ export class BrotherPP1Service {
|
|||
speed: readUInt16LE(12),
|
||||
};
|
||||
|
||||
console.log('[BrotherPP1] Pattern Info Response:', {
|
||||
rawData: Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(' '),
|
||||
console.log("[BrotherPP1] Pattern Info Response:", {
|
||||
rawData: Array.from(data)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join(" "),
|
||||
parsed: patternInfo,
|
||||
});
|
||||
|
||||
|
|
@ -580,7 +600,7 @@ export class BrotherPP1Service {
|
|||
payload[24] = flip;
|
||||
payload[25] = frame;
|
||||
|
||||
console.log('[DEBUG] Layout bounds:', {
|
||||
console.log("[DEBUG] Layout bounds:", {
|
||||
boundLeft,
|
||||
boundTop,
|
||||
boundRight,
|
||||
|
|
@ -675,7 +695,7 @@ export class BrotherPP1Service {
|
|||
moveX = patternOffset.x - patternCenterX;
|
||||
moveY = patternOffset.y - patternCenterY;
|
||||
|
||||
console.log('[LAYOUT] Using user-defined offset:', {
|
||||
console.log("[LAYOUT] Using user-defined offset:", {
|
||||
patternOffset,
|
||||
patternCenter: { x: patternCenterX, y: patternCenterY },
|
||||
moveX,
|
||||
|
|
@ -688,7 +708,7 @@ export class BrotherPP1Service {
|
|||
moveX = -patternCenterX;
|
||||
moveY = -patternCenterY;
|
||||
|
||||
console.log('[LAYOUT] Auto-centering pattern:', { moveX, moveY });
|
||||
console.log("[LAYOUT] Auto-centering pattern:", { moveX, moveY });
|
||||
}
|
||||
|
||||
// Send layout with actual pattern bounds
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { PesPatternData } from '../formats/import/pesImporter';
|
||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||
|
||||
interface CachedPattern {
|
||||
uuid: string;
|
||||
|
|
@ -8,13 +8,15 @@ interface CachedPattern {
|
|||
patternOffset?: { x: number; y: number };
|
||||
}
|
||||
|
||||
const CACHE_KEY = 'brother_pattern_cache';
|
||||
const CACHE_KEY = "brother_pattern_cache";
|
||||
|
||||
/**
|
||||
* Convert UUID Uint8Array to hex string
|
||||
*/
|
||||
export function uuidToString(uuid: Uint8Array): string {
|
||||
return Array.from(uuid).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
return Array.from(uuid)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -36,7 +38,7 @@ export class PatternCacheService {
|
|||
uuid: string,
|
||||
pesData: PesPatternData,
|
||||
fileName: string,
|
||||
patternOffset?: { x: number; y: number }
|
||||
patternOffset?: { x: number; y: number },
|
||||
): void {
|
||||
try {
|
||||
// Convert penData Uint8Array to array for JSON serialization
|
||||
|
|
@ -54,11 +56,18 @@ export class PatternCacheService {
|
|||
};
|
||||
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cached));
|
||||
console.log('[PatternCache] Saved pattern:', fileName, 'UUID:', uuid, 'Offset:', patternOffset);
|
||||
console.log(
|
||||
"[PatternCache] Saved pattern:",
|
||||
fileName,
|
||||
"UUID:",
|
||||
uuid,
|
||||
"Offset:",
|
||||
patternOffset,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[PatternCache] Failed to save pattern:', err);
|
||||
console.error("[PatternCache] Failed to save pattern:", err);
|
||||
// If quota exceeded, clear and try again
|
||||
if (err instanceof Error && err.name === 'QuotaExceededError') {
|
||||
if (err instanceof Error && err.name === "QuotaExceededError") {
|
||||
this.clearCache();
|
||||
}
|
||||
}
|
||||
|
|
@ -78,7 +87,12 @@ export class PatternCacheService {
|
|||
|
||||
// Check if UUID matches
|
||||
if (pattern.uuid !== uuid) {
|
||||
console.log('[PatternCache] UUID mismatch. Cached:', pattern.uuid, 'Requested:', uuid);
|
||||
console.log(
|
||||
"[PatternCache] UUID mismatch. Cached:",
|
||||
pattern.uuid,
|
||||
"Requested:",
|
||||
uuid,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -87,10 +101,15 @@ export class PatternCacheService {
|
|||
pattern.pesData.penData = new Uint8Array(pattern.pesData.penData);
|
||||
}
|
||||
|
||||
console.log('[PatternCache] Found cached pattern:', pattern.fileName, 'UUID:', uuid);
|
||||
console.log(
|
||||
"[PatternCache] Found cached pattern:",
|
||||
pattern.fileName,
|
||||
"UUID:",
|
||||
uuid,
|
||||
);
|
||||
return pattern;
|
||||
} catch (err) {
|
||||
console.error('[PatternCache] Failed to retrieve pattern:', err);
|
||||
console.error("[PatternCache] Failed to retrieve pattern:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -114,7 +133,7 @@ export class PatternCacheService {
|
|||
|
||||
return pattern;
|
||||
} catch (err) {
|
||||
console.error('[PatternCache] Failed to retrieve pattern:', err);
|
||||
console.error("[PatternCache] Failed to retrieve pattern:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -135,10 +154,10 @@ export class PatternCacheService {
|
|||
const cached = this.getPatternByUUID(uuid);
|
||||
if (cached) {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
console.log('[PatternCache] Deleted pattern with UUID:', uuid);
|
||||
console.log("[PatternCache] Deleted pattern with UUID:", uuid);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[PatternCache] Failed to delete pattern:', err);
|
||||
console.error("[PatternCache] Failed to delete pattern:", err);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -148,16 +167,21 @@ export class PatternCacheService {
|
|||
static clearCache(): void {
|
||||
try {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
console.log('[PatternCache] Cache cleared');
|
||||
console.log("[PatternCache] Cache cleared");
|
||||
} catch (err) {
|
||||
console.error('[PatternCache] Failed to clear cache:', err);
|
||||
console.error("[PatternCache] Failed to clear cache:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache info for debugging
|
||||
*/
|
||||
static getCacheInfo(): { hasCache: boolean; fileName?: string; uuid?: string; age?: number } {
|
||||
static getCacheInfo(): {
|
||||
hasCache: boolean;
|
||||
fileName?: string;
|
||||
uuid?: string;
|
||||
age?: number;
|
||||
} {
|
||||
const pattern = this.getMostRecentPattern();
|
||||
if (!pattern) {
|
||||
return { hasCache: false };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { create } from 'zustand';
|
||||
import type { PesPatternData } from '../formats/import/pesImporter';
|
||||
import { create } from "zustand";
|
||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||
|
||||
interface PatternState {
|
||||
// Pattern data
|
||||
|
|
@ -19,7 +19,7 @@ interface PatternState {
|
|||
export const usePatternStore = create<PatternState>((set) => ({
|
||||
// Initial state
|
||||
pesData: null,
|
||||
currentFileName: '',
|
||||
currentFileName: "",
|
||||
patternOffset: { x: 0, y: 0 },
|
||||
patternUploaded: false,
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ export const usePatternStore = create<PatternState>((set) => ({
|
|||
// Update pattern offset
|
||||
setPatternOffset: (x: number, y: number) => {
|
||||
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
|
||||
|
|
@ -61,6 +61,9 @@ export const usePatternStore = create<PatternState>((set) => ({
|
|||
|
||||
// Selector hooks for common use cases
|
||||
export const usePesData = () => usePatternStore((state) => state.pesData);
|
||||
export const usePatternFileName = () => usePatternStore((state) => state.currentFileName);
|
||||
export const usePatternOffset = () => usePatternStore((state) => state.patternOffset);
|
||||
export const usePatternUploaded = () => usePatternStore((state) => state.patternUploaded);
|
||||
export const usePatternFileName = () =>
|
||||
usePatternStore((state) => state.currentFileName);
|
||||
export const usePatternOffset = () =>
|
||||
usePatternStore((state) => state.patternOffset);
|
||||
export const usePatternUploaded = () =>
|
||||
usePatternStore((state) => state.patternUploaded);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { create } from 'zustand';
|
||||
import { patternConverterClient } from '../formats/import/client';
|
||||
import { create } from "zustand";
|
||||
import { patternConverterClient } from "../formats/import/client";
|
||||
|
||||
interface UIState {
|
||||
// Pyodide state
|
||||
|
|
@ -23,26 +23,41 @@ export const useUIStore = create<UIState>((set) => ({
|
|||
pyodideReady: false,
|
||||
pyodideError: null,
|
||||
pyodideProgress: 0,
|
||||
pyodideLoadingStep: '',
|
||||
pyodideLoadingStep: "",
|
||||
showErrorPopover: false,
|
||||
|
||||
// Initialize Pyodide with progress tracking
|
||||
initializePyodide: async () => {
|
||||
try {
|
||||
// Reset progress
|
||||
set({ pyodideProgress: 0, pyodideLoadingStep: 'Starting...', pyodideError: null });
|
||||
set({
|
||||
pyodideProgress: 0,
|
||||
pyodideLoadingStep: "Starting...",
|
||||
pyodideError: null,
|
||||
});
|
||||
|
||||
// Initialize with progress callback
|
||||
await patternConverterClient.initialize((progress, step) => {
|
||||
set({ pyodideProgress: progress, pyodideLoadingStep: step });
|
||||
});
|
||||
|
||||
set({ pyodideReady: true, pyodideProgress: 100, pyodideLoadingStep: 'Ready!' });
|
||||
console.log('[UIStore] Pyodide initialized successfully');
|
||||
set({
|
||||
pyodideReady: true,
|
||||
pyodideProgress: 100,
|
||||
pyodideLoadingStep: "Ready!",
|
||||
});
|
||||
console.log("[UIStore] Pyodide initialized successfully");
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to initialize Python environment';
|
||||
set({ pyodideError: errorMessage, pyodideProgress: 0, pyodideLoadingStep: '' });
|
||||
console.error('[UIStore] Failed to initialize Pyodide:', err);
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to initialize Python environment";
|
||||
set({
|
||||
pyodideError: errorMessage,
|
||||
pyodideProgress: 0,
|
||||
pyodideLoadingStep: "",
|
||||
});
|
||||
console.error("[UIStore] Failed to initialize Pyodide:", err);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -65,6 +80,9 @@ export const useUIStore = create<UIState>((set) => ({
|
|||
// Selector hooks for common use cases
|
||||
export const usePyodideReady = () => useUIStore((state) => state.pyodideReady);
|
||||
export const usePyodideError = () => useUIStore((state) => state.pyodideError);
|
||||
export const usePyodideProgress = () => useUIStore((state) => state.pyodideProgress);
|
||||
export const usePyodideLoadingStep = () => useUIStore((state) => state.pyodideLoadingStep);
|
||||
export const useErrorPopover = () => useUIStore((state) => state.showErrorPopover);
|
||||
export const usePyodideProgress = () =>
|
||||
useUIStore((state) => state.pyodideProgress);
|
||||
export const usePyodideLoadingStep = () =>
|
||||
useUIStore((state) => state.pyodideLoadingStep);
|
||||
export const useErrorPopover = () =>
|
||||
useUIStore((state) => state.showErrorPopover);
|
||||
|
|
|
|||
4
src/types/electron.d.ts
vendored
4
src/types/electron.d.ts
vendored
|
|
@ -5,7 +5,9 @@ export interface BluetoothDevice {
|
|||
|
||||
export interface ElectronAPI {
|
||||
invoke<T = unknown>(channel: string, ...args: unknown[]): Promise<T>;
|
||||
onBluetoothDeviceList: (callback: (devices: BluetoothDevice[]) => void) => void;
|
||||
onBluetoothDeviceList: (
|
||||
callback: (devices: BluetoothDevice[]) => void,
|
||||
) => void;
|
||||
selectBluetoothDevice: (deviceId: string) => void;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,33 +19,33 @@ export const MachineStatus = {
|
|||
HOOP_AVOIDANCEING: 0x51,
|
||||
RL_RECEIVING: 0x60,
|
||||
RL_RECEIVED: 0x61,
|
||||
None: 0xDD,
|
||||
TryConnecting: 0xFF,
|
||||
None: 0xdd,
|
||||
TryConnecting: 0xff,
|
||||
} as const;
|
||||
|
||||
export type MachineStatus = typeof MachineStatus[keyof typeof MachineStatus];
|
||||
export type MachineStatus = (typeof MachineStatus)[keyof typeof MachineStatus];
|
||||
|
||||
export const MachineStatusNames: Record<MachineStatus, string> = {
|
||||
[MachineStatus.Initial]: 'Initial',
|
||||
[MachineStatus.LowerThread]: 'Lower Thread',
|
||||
[MachineStatus.IDLE]: 'Idle',
|
||||
[MachineStatus.SEWING_WAIT]: 'Ready to Sew',
|
||||
[MachineStatus.SEWING_DATA_RECEIVE]: 'Receiving Data',
|
||||
[MachineStatus.MASK_TRACE_LOCK_WAIT]: 'Waiting for Mask Trace',
|
||||
[MachineStatus.MASK_TRACING]: 'Mask Tracing',
|
||||
[MachineStatus.MASK_TRACE_COMPLETE]: 'Mask Trace Complete',
|
||||
[MachineStatus.SEWING]: 'Sewing',
|
||||
[MachineStatus.SEWING_COMPLETE]: 'Complete',
|
||||
[MachineStatus.SEWING_INTERRUPTION]: 'Interrupted',
|
||||
[MachineStatus.COLOR_CHANGE_WAIT]: 'Waiting for Color Change',
|
||||
[MachineStatus.PAUSE]: 'Paused',
|
||||
[MachineStatus.STOP]: 'Stopped',
|
||||
[MachineStatus.HOOP_AVOIDANCE]: 'Hoop Avoidance',
|
||||
[MachineStatus.HOOP_AVOIDANCEING]: 'Hoop Avoidance In Progress',
|
||||
[MachineStatus.RL_RECEIVING]: 'RL Receiving',
|
||||
[MachineStatus.RL_RECEIVED]: 'RL Received',
|
||||
[MachineStatus.None]: 'None',
|
||||
[MachineStatus.TryConnecting]: 'Connecting',
|
||||
[MachineStatus.Initial]: "Initial",
|
||||
[MachineStatus.LowerThread]: "Lower Thread",
|
||||
[MachineStatus.IDLE]: "Idle",
|
||||
[MachineStatus.SEWING_WAIT]: "Ready to Sew",
|
||||
[MachineStatus.SEWING_DATA_RECEIVE]: "Receiving Data",
|
||||
[MachineStatus.MASK_TRACE_LOCK_WAIT]: "Waiting for Mask Trace",
|
||||
[MachineStatus.MASK_TRACING]: "Mask Tracing",
|
||||
[MachineStatus.MASK_TRACE_COMPLETE]: "Mask Trace Complete",
|
||||
[MachineStatus.SEWING]: "Sewing",
|
||||
[MachineStatus.SEWING_COMPLETE]: "Complete",
|
||||
[MachineStatus.SEWING_INTERRUPTION]: "Interrupted",
|
||||
[MachineStatus.COLOR_CHANGE_WAIT]: "Waiting for Color Change",
|
||||
[MachineStatus.PAUSE]: "Paused",
|
||||
[MachineStatus.STOP]: "Stopped",
|
||||
[MachineStatus.HOOP_AVOIDANCE]: "Hoop Avoidance",
|
||||
[MachineStatus.HOOP_AVOIDANCEING]: "Hoop Avoidance In Progress",
|
||||
[MachineStatus.RL_RECEIVING]: "RL Receiving",
|
||||
[MachineStatus.RL_RECEIVED]: "RL Received",
|
||||
[MachineStatus.None]: "None",
|
||||
[MachineStatus.TryConnecting]: "Connecting",
|
||||
};
|
||||
|
||||
export interface MachineInfo {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ export function getCSSVariable(name: string): string {
|
|||
* Canvas color helpers
|
||||
*/
|
||||
export const canvasColors = {
|
||||
grid: () => getCSSVariable('--color-canvas-grid'),
|
||||
origin: () => getCSSVariable('--color-canvas-origin'),
|
||||
hoop: () => getCSSVariable('--color-canvas-hoop'),
|
||||
bounds: () => getCSSVariable('--color-canvas-bounds'),
|
||||
position: () => getCSSVariable('--color-canvas-position'),
|
||||
grid: () => getCSSVariable("--color-canvas-grid"),
|
||||
origin: () => getCSSVariable("--color-canvas-origin"),
|
||||
hoop: () => getCSSVariable("--color-canvas-hoop"),
|
||||
bounds: () => getCSSVariable("--color-canvas-bounds"),
|
||||
position: () => getCSSVariable("--color-canvas-position"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import Konva from 'konva';
|
||||
import type { PesPatternData } from '../formats/import/pesImporter';
|
||||
import { getThreadColor } from '../formats/import/pesImporter';
|
||||
import type { MachineInfo } from '../types/machine';
|
||||
import { MOVE } from '../formats/import/constants';
|
||||
import Konva from "konva";
|
||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||
import { getThreadColor } from "../formats/import/pesImporter";
|
||||
import type { MachineInfo } from "../types/machine";
|
||||
import { MOVE } from "../formats/import/constants";
|
||||
|
||||
/**
|
||||
* Renders a grid with specified spacing
|
||||
|
|
@ -11,9 +11,9 @@ export function renderGrid(
|
|||
layer: Konva.Layer,
|
||||
gridSize: number,
|
||||
bounds: { minX: number; maxX: number; minY: number; maxY: number },
|
||||
machineInfo: MachineInfo | null
|
||||
machineInfo: MachineInfo | null,
|
||||
): void {
|
||||
const gridGroup = new Konva.Group({ name: 'grid' });
|
||||
const gridGroup = new Konva.Group({ name: "grid" });
|
||||
|
||||
// Determine grid bounds based on hoop or pattern
|
||||
const gridMinX = machineInfo ? -machineInfo.maxWidth / 2 : bounds.minX;
|
||||
|
|
@ -22,20 +22,28 @@ export function renderGrid(
|
|||
const gridMaxY = machineInfo ? machineInfo.maxHeight / 2 : bounds.maxY;
|
||||
|
||||
// Vertical lines
|
||||
for (let x = Math.floor(gridMinX / gridSize) * gridSize; x <= gridMaxX; x += gridSize) {
|
||||
for (
|
||||
let x = Math.floor(gridMinX / gridSize) * gridSize;
|
||||
x <= gridMaxX;
|
||||
x += gridSize
|
||||
) {
|
||||
const line = new Konva.Line({
|
||||
points: [x, gridMinY, x, gridMaxY],
|
||||
stroke: '#e0e0e0',
|
||||
stroke: "#e0e0e0",
|
||||
strokeWidth: 1,
|
||||
});
|
||||
gridGroup.add(line);
|
||||
}
|
||||
|
||||
// Horizontal lines
|
||||
for (let y = Math.floor(gridMinY / gridSize) * gridSize; y <= gridMaxY; y += gridSize) {
|
||||
for (
|
||||
let y = Math.floor(gridMinY / gridSize) * gridSize;
|
||||
y <= gridMaxY;
|
||||
y += gridSize
|
||||
) {
|
||||
const line = new Konva.Line({
|
||||
points: [gridMinX, y, gridMaxX, y],
|
||||
stroke: '#e0e0e0',
|
||||
stroke: "#e0e0e0",
|
||||
strokeWidth: 1,
|
||||
});
|
||||
gridGroup.add(line);
|
||||
|
|
@ -48,19 +56,19 @@ export function renderGrid(
|
|||
* Renders the origin crosshair at (0,0)
|
||||
*/
|
||||
export function renderOrigin(layer: Konva.Layer): void {
|
||||
const originGroup = new Konva.Group({ name: 'origin' });
|
||||
const originGroup = new Konva.Group({ name: "origin" });
|
||||
|
||||
// Horizontal line
|
||||
const hLine = new Konva.Line({
|
||||
points: [-10, 0, 10, 0],
|
||||
stroke: '#888',
|
||||
stroke: "#888",
|
||||
strokeWidth: 2,
|
||||
});
|
||||
|
||||
// Vertical line
|
||||
const vLine = new Konva.Line({
|
||||
points: [0, -10, 0, 10],
|
||||
stroke: '#888',
|
||||
stroke: "#888",
|
||||
strokeWidth: 2,
|
||||
});
|
||||
|
||||
|
|
@ -72,7 +80,7 @@ export function renderOrigin(layer: Konva.Layer): void {
|
|||
* Renders the hoop boundary and label
|
||||
*/
|
||||
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 hoopHeight = machineInfo.maxHeight;
|
||||
|
|
@ -87,7 +95,7 @@ export function renderHoop(layer: Konva.Layer, machineInfo: MachineInfo): void {
|
|||
y: hoopTop,
|
||||
width: hoopWidth,
|
||||
height: hoopHeight,
|
||||
stroke: '#2196F3',
|
||||
stroke: "#2196F3",
|
||||
strokeWidth: 3,
|
||||
dash: [10, 5],
|
||||
});
|
||||
|
|
@ -98,9 +106,9 @@ export function renderHoop(layer: Konva.Layer, machineInfo: MachineInfo): void {
|
|||
y: hoopTop + 10,
|
||||
text: `Hoop: ${(hoopWidth / 10).toFixed(0)} x ${(hoopHeight / 10).toFixed(0)} mm`,
|
||||
fontSize: 14,
|
||||
fontFamily: 'sans-serif',
|
||||
fontStyle: 'bold',
|
||||
fill: '#2196F3',
|
||||
fontFamily: "sans-serif",
|
||||
fontStyle: "bold",
|
||||
fill: "#2196F3",
|
||||
});
|
||||
|
||||
hoopGroup.add(rect, label);
|
||||
|
|
@ -114,9 +122,9 @@ export function renderStitches(
|
|||
container: Konva.Layer | Konva.Group,
|
||||
stitches: number[][],
|
||||
pesData: PesPatternData,
|
||||
currentStitchIndex: number
|
||||
currentStitchIndex: number,
|
||||
): 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)
|
||||
interface StitchGroup {
|
||||
|
|
@ -164,8 +172,8 @@ export function renderStitches(
|
|||
points: group.points,
|
||||
stroke: group.color,
|
||||
strokeWidth: 1.0,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
dash: [5, 5],
|
||||
opacity: group.completed ? 0.6 : 0.25,
|
||||
});
|
||||
|
|
@ -176,8 +184,8 @@ export function renderStitches(
|
|||
points: group.points,
|
||||
stroke: group.color,
|
||||
strokeWidth: 1.5,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
opacity: group.completed ? 1.0 : 0.3,
|
||||
});
|
||||
stitchesGroup.add(line);
|
||||
|
|
@ -192,7 +200,7 @@ export function renderStitches(
|
|||
*/
|
||||
export function renderPatternBounds(
|
||||
container: Konva.Layer | Konva.Group,
|
||||
bounds: { minX: number; maxX: number; minY: number; maxY: number }
|
||||
bounds: { minX: number; maxX: number; minY: number; maxY: number },
|
||||
): void {
|
||||
const { minX, maxX, minY, maxY } = bounds;
|
||||
const patternWidth = maxX - minX;
|
||||
|
|
@ -203,7 +211,7 @@ export function renderPatternBounds(
|
|||
y: minY,
|
||||
width: patternWidth,
|
||||
height: patternHeight,
|
||||
stroke: '#ff0000',
|
||||
stroke: "#ff0000",
|
||||
strokeWidth: 2,
|
||||
dash: [5, 5],
|
||||
});
|
||||
|
|
@ -217,47 +225,47 @@ export function renderPatternBounds(
|
|||
export function renderCurrentPosition(
|
||||
container: Konva.Layer | Konva.Group,
|
||||
currentStitchIndex: number,
|
||||
stitches: number[][]
|
||||
stitches: number[][],
|
||||
): void {
|
||||
if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) return;
|
||||
|
||||
const stitch = stitches[currentStitchIndex];
|
||||
const [x, y] = stitch;
|
||||
|
||||
const posGroup = new Konva.Group({ name: 'currentPosition' });
|
||||
const posGroup = new Konva.Group({ name: "currentPosition" });
|
||||
|
||||
// Circle with fill
|
||||
const circle = new Konva.Circle({
|
||||
x,
|
||||
y,
|
||||
radius: 8,
|
||||
fill: 'rgba(255, 0, 0, 0.3)',
|
||||
stroke: '#ff0000',
|
||||
fill: "rgba(255, 0, 0, 0.3)",
|
||||
stroke: "#ff0000",
|
||||
strokeWidth: 3,
|
||||
});
|
||||
|
||||
// Crosshair lines
|
||||
const hLine1 = new Konva.Line({
|
||||
points: [x - 12, y, x - 3, y],
|
||||
stroke: '#ff0000',
|
||||
stroke: "#ff0000",
|
||||
strokeWidth: 2,
|
||||
});
|
||||
|
||||
const hLine2 = new Konva.Line({
|
||||
points: [x + 12, y, x + 3, y],
|
||||
stroke: '#ff0000',
|
||||
stroke: "#ff0000",
|
||||
strokeWidth: 2,
|
||||
});
|
||||
|
||||
const vLine1 = new Konva.Line({
|
||||
points: [x, y - 12, x, y - 3],
|
||||
stroke: '#ff0000',
|
||||
stroke: "#ff0000",
|
||||
strokeWidth: 2,
|
||||
});
|
||||
|
||||
const vLine2 = new Konva.Line({
|
||||
points: [x, y + 12, x, y + 3],
|
||||
stroke: '#ff0000',
|
||||
stroke: "#ff0000",
|
||||
strokeWidth: 2,
|
||||
});
|
||||
|
||||
|
|
@ -270,9 +278,9 @@ export function renderCurrentPosition(
|
|||
*/
|
||||
export function renderLegend(
|
||||
layer: Konva.Layer,
|
||||
pesData: PesPatternData
|
||||
pesData: PesPatternData,
|
||||
): void {
|
||||
const legendGroup = new Konva.Group({ name: 'legend' });
|
||||
const legendGroup = new Konva.Group({ name: "legend" });
|
||||
|
||||
// Semi-transparent background for better readability
|
||||
const bgPadding = 8;
|
||||
|
|
@ -284,9 +292,9 @@ export function renderLegend(
|
|||
y: 10,
|
||||
width: 100,
|
||||
height: legendHeight,
|
||||
fill: 'rgba(255, 255, 255, 0.9)',
|
||||
fill: "rgba(255, 255, 255, 0.9)",
|
||||
cornerRadius: 4,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.2)',
|
||||
shadowColor: "rgba(0, 0, 0, 0.2)",
|
||||
shadowBlur: 4,
|
||||
shadowOffset: { x: 0, y: 2 },
|
||||
});
|
||||
|
|
@ -305,7 +313,7 @@ export function renderLegend(
|
|||
width: 20,
|
||||
height: 20,
|
||||
fill: color,
|
||||
stroke: '#000',
|
||||
stroke: "#000",
|
||||
strokeWidth: 1,
|
||||
});
|
||||
|
||||
|
|
@ -315,8 +323,8 @@ export function renderLegend(
|
|||
y: legendY + 5,
|
||||
text: `Thread ${i + 1}`,
|
||||
fontSize: 12,
|
||||
fontFamily: 'sans-serif',
|
||||
fill: '#000',
|
||||
fontFamily: "sans-serif",
|
||||
fill: "#000",
|
||||
});
|
||||
|
||||
legendGroup.add(swatch, label);
|
||||
|
|
@ -334,7 +342,7 @@ export function renderDimensions(
|
|||
patternWidth: number,
|
||||
patternHeight: number,
|
||||
stageWidth: number,
|
||||
stageHeight: number
|
||||
stageHeight: number,
|
||||
): void {
|
||||
const dimensionText = `${(patternWidth / 10).toFixed(1)} x ${(patternHeight / 10).toFixed(1)} mm`;
|
||||
|
||||
|
|
@ -348,9 +356,9 @@ export function renderDimensions(
|
|||
y: stageHeight - textHeight - padding - 80, // Above zoom controls
|
||||
width: textWidth,
|
||||
height: textHeight,
|
||||
fill: 'rgba(255, 255, 255, 0.9)',
|
||||
fill: "rgba(255, 255, 255, 0.9)",
|
||||
cornerRadius: 4,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.2)',
|
||||
shadowColor: "rgba(0, 0, 0, 0.2)",
|
||||
shadowBlur: 4,
|
||||
shadowOffset: { x: 0, y: 2 },
|
||||
});
|
||||
|
|
@ -362,10 +370,10 @@ export function renderDimensions(
|
|||
height: textHeight,
|
||||
text: dimensionText,
|
||||
fontSize: 14,
|
||||
fontFamily: 'sans-serif',
|
||||
fill: '#000',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
fontFamily: "sans-serif",
|
||||
fill: "#000",
|
||||
align: "center",
|
||||
verticalAlign: "middle",
|
||||
});
|
||||
|
||||
layer.add(background, text);
|
||||
|
|
@ -379,7 +387,7 @@ export function calculateInitialScale(
|
|||
stageHeight: number,
|
||||
viewWidth: number,
|
||||
viewHeight: number,
|
||||
padding: number = 40
|
||||
padding: number = 40,
|
||||
): number {
|
||||
const scaleX = (stageWidth - 2 * padding) / viewWidth;
|
||||
const scaleY = (stageHeight - 2 * padding) / viewHeight;
|
||||
|
|
|
|||
|
|
@ -1,23 +1,26 @@
|
|||
import { MachineStatus } from '../types/machine';
|
||||
import { MachineStatus } from "../types/machine";
|
||||
|
||||
/**
|
||||
* Machine state categories for safety logic
|
||||
*/
|
||||
export const MachineStateCategory = {
|
||||
IDLE: 'idle',
|
||||
ACTIVE: 'active',
|
||||
WAITING: 'waiting',
|
||||
COMPLETE: 'complete',
|
||||
INTERRUPTED: 'interrupted',
|
||||
ERROR: 'error',
|
||||
IDLE: "idle",
|
||||
ACTIVE: "active",
|
||||
WAITING: "waiting",
|
||||
COMPLETE: "complete",
|
||||
INTERRUPTED: "interrupted",
|
||||
ERROR: "error",
|
||||
} as const;
|
||||
|
||||
export type MachineStateCategoryType = typeof MachineStateCategory[keyof typeof MachineStateCategory];
|
||||
export type MachineStateCategoryType =
|
||||
(typeof MachineStateCategory)[keyof typeof MachineStateCategory];
|
||||
|
||||
/**
|
||||
* Categorize a machine status into a semantic safety category
|
||||
*/
|
||||
export function getMachineStateCategory(status: MachineStatus): MachineStateCategoryType {
|
||||
export function getMachineStateCategory(
|
||||
status: MachineStatus,
|
||||
): MachineStateCategoryType {
|
||||
switch (status) {
|
||||
// IDLE states - safe to perform any action
|
||||
case MachineStatus.IDLE:
|
||||
|
|
@ -67,9 +70,11 @@ export function getMachineStateCategory(status: MachineStatus): MachineStateCate
|
|||
export function canDeletePattern(status: MachineStatus): boolean {
|
||||
const category = getMachineStateCategory(status);
|
||||
// Can delete in IDLE, WAITING, or COMPLETE states, never during ACTIVE operations
|
||||
return category === MachineStateCategory.IDLE ||
|
||||
return (
|
||||
category === MachineStateCategory.IDLE ||
|
||||
category === MachineStateCategory.WAITING ||
|
||||
category === MachineStateCategory.COMPLETE;
|
||||
category === MachineStateCategory.COMPLETE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -79,8 +84,10 @@ export function canDeletePattern(status: MachineStatus): boolean {
|
|||
export function canUploadPattern(status: MachineStatus): boolean {
|
||||
const category = getMachineStateCategory(status);
|
||||
// Can upload in IDLE or COMPLETE states (includes MASK_TRACE_COMPLETE)
|
||||
return category === MachineStateCategory.IDLE ||
|
||||
category === MachineStateCategory.COMPLETE;
|
||||
return (
|
||||
category === MachineStateCategory.IDLE ||
|
||||
category === MachineStateCategory.COMPLETE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -89,11 +96,13 @@ export function canUploadPattern(status: MachineStatus): boolean {
|
|||
*/
|
||||
export function canStartSewing(status: MachineStatus): boolean {
|
||||
// Only in specific ready states
|
||||
return status === MachineStatus.SEWING_WAIT ||
|
||||
return (
|
||||
status === MachineStatus.SEWING_WAIT ||
|
||||
status === MachineStatus.MASK_TRACE_COMPLETE ||
|
||||
status === MachineStatus.PAUSE ||
|
||||
status === MachineStatus.STOP ||
|
||||
status === MachineStatus.SEWING_INTERRUPTION;
|
||||
status === MachineStatus.SEWING_INTERRUPTION
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -101,9 +110,11 @@ export function canStartSewing(status: MachineStatus): boolean {
|
|||
*/
|
||||
export function canStartMaskTrace(status: MachineStatus): boolean {
|
||||
// Can start mask trace when IDLE (after upload), SEWING_WAIT, or after previous trace
|
||||
return status === MachineStatus.IDLE ||
|
||||
return (
|
||||
status === MachineStatus.IDLE ||
|
||||
status === MachineStatus.SEWING_WAIT ||
|
||||
status === MachineStatus.MASK_TRACE_COMPLETE;
|
||||
status === MachineStatus.MASK_TRACE_COMPLETE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -123,8 +134,10 @@ export function canResumeSewing(status: MachineStatus): boolean {
|
|||
export function shouldConfirmDisconnect(status: MachineStatus): boolean {
|
||||
const category = getMachineStateCategory(status);
|
||||
// Confirm if disconnecting during active operation or waiting for action
|
||||
return category === MachineStateCategory.ACTIVE ||
|
||||
category === MachineStateCategory.WAITING;
|
||||
return (
|
||||
category === MachineStateCategory.ACTIVE ||
|
||||
category === MachineStateCategory.WAITING
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -132,7 +145,13 @@ export function shouldConfirmDisconnect(status: MachineStatus): boolean {
|
|||
*/
|
||||
export interface StateVisualInfo {
|
||||
color: string;
|
||||
iconName: 'ready' | 'active' | 'waiting' | 'complete' | 'interrupted' | 'error';
|
||||
iconName:
|
||||
| "ready"
|
||||
| "active"
|
||||
| "waiting"
|
||||
| "complete"
|
||||
| "interrupted"
|
||||
| "error";
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
|
@ -147,41 +166,41 @@ export function getStateVisualInfo(status: MachineStatus): StateVisualInfo {
|
|||
// Map state category to visual properties
|
||||
const visualMap: Record<MachineStateCategoryType, StateVisualInfo> = {
|
||||
[MachineStateCategory.IDLE]: {
|
||||
color: 'info',
|
||||
iconName: 'ready',
|
||||
label: 'Ready',
|
||||
description: 'Machine is idle and ready for operations'
|
||||
color: "info",
|
||||
iconName: "ready",
|
||||
label: "Ready",
|
||||
description: "Machine is idle and ready for operations",
|
||||
},
|
||||
[MachineStateCategory.ACTIVE]: {
|
||||
color: 'warning',
|
||||
iconName: 'active',
|
||||
label: 'Active',
|
||||
description: 'Operation in progress - do not interrupt'
|
||||
color: "warning",
|
||||
iconName: "active",
|
||||
label: "Active",
|
||||
description: "Operation in progress - do not interrupt",
|
||||
},
|
||||
[MachineStateCategory.WAITING]: {
|
||||
color: 'warning',
|
||||
iconName: 'waiting',
|
||||
label: 'Waiting',
|
||||
description: 'Waiting for user or machine action'
|
||||
color: "warning",
|
||||
iconName: "waiting",
|
||||
label: "Waiting",
|
||||
description: "Waiting for user or machine action",
|
||||
},
|
||||
[MachineStateCategory.COMPLETE]: {
|
||||
color: 'success',
|
||||
iconName: 'complete',
|
||||
label: 'Complete',
|
||||
description: 'Operation completed successfully'
|
||||
color: "success",
|
||||
iconName: "complete",
|
||||
label: "Complete",
|
||||
description: "Operation completed successfully",
|
||||
},
|
||||
[MachineStateCategory.INTERRUPTED]: {
|
||||
color: 'danger',
|
||||
iconName: 'interrupted',
|
||||
label: 'Interrupted',
|
||||
description: 'Operation paused or stopped'
|
||||
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'
|
||||
}
|
||||
color: "danger",
|
||||
iconName: "error",
|
||||
label: "Error",
|
||||
description: "Machine in error or unknown state",
|
||||
},
|
||||
};
|
||||
|
||||
return visualMap[category];
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function convertStitchesToMinutes(stitchCount: number): number {
|
|||
*/
|
||||
export function calculatePatternTime(
|
||||
colorBlocks: Array<{ stitchCount: number }>,
|
||||
currentStitch: number
|
||||
currentStitch: number,
|
||||
): {
|
||||
totalMinutes: number;
|
||||
elapsedMinutes: number;
|
||||
|
|
@ -44,7 +44,8 @@ export function calculatePatternTime(
|
|||
break;
|
||||
} else {
|
||||
// We're partway through this block
|
||||
const stitchesInBlock = currentStitch - (cumulativeStitches - block.stitchCount);
|
||||
const stitchesInBlock =
|
||||
currentStitch - (cumulativeStitches - block.stitchCount);
|
||||
elapsedMinutes += convertStitchesToMinutes(stitchesInBlock);
|
||||
break;
|
||||
}
|
||||
|
|
@ -63,5 +64,5 @@ export function calculatePatternTime(
|
|||
export function formatMinutes(minutes: number): string {
|
||||
const mins = Math.floor(minutes);
|
||||
const secs = Math.round((minutes - mins) * 60);
|
||||
return `${mins}:${String(secs).padStart(2, '0')}`;
|
||||
return `${mins}:${String(secs).padStart(2, "0")}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
environment: "node",
|
||||
include: ["src/**/*.{test,spec}.{js,ts}"],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue