mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 02:13:41 +00:00
Dark Mode Implementation: - Add Tailwind config with darkMode: 'media' for system preference detection - Update all 9 components with 200+ dark mode variants - Semantic color backgrounds with semi-transparent overlays in dark mode - Proper text contrast (gray-900/gray-100) for readability - Enhanced borders, shadows, and focus rings for dark backgrounds Component Dark Mode Updates: - App.tsx: Header gradient, error banners, empty states - MachineConnection: Status badges with proper dark variants for all states - FileUpload: Pattern info cards, thread swatches, upload progress - PatternCanvas: Canvas background, overlays, zoom controls - ProgressMonitor: Color blocks, progress bars, state indicators with colored icons - NextStepGuide: All status boxes (blue/yellow/cyan/green/red) - WorkflowStepper: Progress indicators and step states - ConfirmDialog: Modal overlays and dialog backgrounds - KonvaComponents: Grid lines and origin markers Pattern Visibility Improvements: - Pattern shows full opacity (1.0) when unlocked for easy positioning - Pattern shows reduced opacity (0.75) for unstitched areas when locked/uploading - Helps distinguish completed vs pending stitches during sewing - Pattern locks during upload to prevent accidental repositioning - Canvas dragging disabled when pattern is uploading or uploaded Status Indicator Enhancements: - Machine status badges: All states (idle/active/waiting/complete/error) have dark variants - Progress monitor state icons: Colored icons (blue/yellow/green/red) in both modes - Color blocks: Proper backgrounds and borders for completed/current/pending states - All semantic colors maintain visibility and meaning in dark mode Canvas Lock Behavior: - Pattern locked during upload (uploadProgress > 0 && < 100) - Pattern locked after upload (patternUploaded = true) - Lock indicator shows amber background with lock icon - Cursor changes prevent confusion about draggability - Full opacity during positioning, transparency during progress tracking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
331 lines
14 KiB
TypeScript
331 lines
14 KiB
TypeScript
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
import { Stage, Layer, Group } from 'react-konva';
|
|
import Konva from 'konva';
|
|
import { PlusIcon, MinusIcon, ArrowPathIcon, LockClosedIcon } from '@heroicons/react/24/solid';
|
|
import type { PesPatternData } from '../utils/pystitchConverter';
|
|
import type { SewingProgress, MachineInfo } from '../types/machine';
|
|
import { calculateInitialScale } from '../utils/konvaRenderers';
|
|
import { Grid, Origin, Hoop, Stitches, PatternBounds, CurrentPosition } from './KonvaComponents';
|
|
|
|
interface PatternCanvasProps {
|
|
pesData: PesPatternData | null;
|
|
sewingProgress: SewingProgress | null;
|
|
machineInfo: MachineInfo | null;
|
|
initialPatternOffset?: { x: number; y: number };
|
|
onPatternOffsetChange?: (offsetX: number, offsetY: number) => void;
|
|
patternUploaded?: boolean;
|
|
isUploading?: boolean;
|
|
}
|
|
|
|
export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPatternOffset, onPatternOffsetChange, patternUploaded = false, isUploading = false }: PatternCanvasProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const stageRef = useRef<Konva.Stage | null>(null);
|
|
|
|
const [stagePos, setStagePos] = useState({ x: 0, y: 0 });
|
|
const [stageScale, setStageScale] = useState(1);
|
|
const [patternOffset, setPatternOffset] = useState(initialPatternOffset || { x: 0, y: 0 });
|
|
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
|
const initialScaleRef = useRef<number>(1);
|
|
|
|
// Update pattern offset when initialPatternOffset changes
|
|
useEffect(() => {
|
|
if (initialPatternOffset) {
|
|
setPatternOffset(initialPatternOffset);
|
|
console.log('[PatternCanvas] Restored pattern offset:', initialPatternOffset);
|
|
}
|
|
}, [initialPatternOffset]);
|
|
|
|
// Track container size
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
|
|
const updateSize = () => {
|
|
if (containerRef.current) {
|
|
const width = containerRef.current.clientWidth;
|
|
const height = containerRef.current.clientHeight;
|
|
setContainerSize({ width, height });
|
|
}
|
|
};
|
|
|
|
// Initial size
|
|
updateSize();
|
|
|
|
// Watch for resize
|
|
const resizeObserver = new ResizeObserver(updateSize);
|
|
resizeObserver.observe(containerRef.current);
|
|
|
|
return () => resizeObserver.disconnect();
|
|
}, []);
|
|
|
|
// Calculate initial scale when pattern or hoop changes
|
|
useEffect(() => {
|
|
if (!pesData || containerSize.width === 0) return;
|
|
|
|
const { bounds } = pesData;
|
|
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);
|
|
initialScaleRef.current = initialScale;
|
|
|
|
// Set initial scale and center position when pattern loads
|
|
setStageScale(initialScale);
|
|
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
|
|
}, [pesData, machineInfo, containerSize]);
|
|
|
|
// Wheel zoom handler
|
|
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
|
|
e.evt.preventDefault();
|
|
|
|
const stage = e.target.getStage();
|
|
if (!stage) return;
|
|
|
|
const oldScale = stage.scaleX();
|
|
const pointer = stage.getPointerPosition();
|
|
if (!pointer) return;
|
|
|
|
const scaleBy = 1.1;
|
|
const direction = e.evt.deltaY > 0 ? -1 : 1;
|
|
let newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy;
|
|
|
|
// Apply constraints
|
|
newScale = Math.max(0.1, Math.min(10, newScale));
|
|
|
|
// Zoom towards pointer
|
|
const mousePointTo = {
|
|
x: (pointer.x - stage.x()) / oldScale,
|
|
y: (pointer.y - stage.y()) / oldScale,
|
|
};
|
|
|
|
const newPos = {
|
|
x: pointer.x - mousePointTo.x * newScale,
|
|
y: pointer.y - mousePointTo.y * newScale,
|
|
};
|
|
|
|
setStageScale(newScale);
|
|
setStagePos(newPos);
|
|
}, []);
|
|
|
|
// Zoom control handlers
|
|
const handleZoomIn = useCallback(() => {
|
|
const oldScale = stageScale;
|
|
const newScale = Math.min(oldScale * 1.2, 10);
|
|
|
|
// Zoom towards center of viewport
|
|
const centerX = containerSize.width / 2;
|
|
const centerY = containerSize.height / 2;
|
|
|
|
const mousePointTo = {
|
|
x: (centerX - stagePos.x) / oldScale,
|
|
y: (centerY - stagePos.y) / oldScale,
|
|
};
|
|
|
|
const newPos = {
|
|
x: centerX - mousePointTo.x * newScale,
|
|
y: centerY - mousePointTo.y * newScale,
|
|
};
|
|
|
|
setStageScale(newScale);
|
|
setStagePos(newPos);
|
|
}, [stageScale, stagePos, containerSize]);
|
|
|
|
const handleZoomOut = useCallback(() => {
|
|
const oldScale = stageScale;
|
|
const newScale = Math.max(oldScale / 1.2, 0.1);
|
|
|
|
// Zoom towards center of viewport
|
|
const centerX = containerSize.width / 2;
|
|
const centerY = containerSize.height / 2;
|
|
|
|
const mousePointTo = {
|
|
x: (centerX - stagePos.x) / oldScale,
|
|
y: (centerY - stagePos.y) / oldScale,
|
|
};
|
|
|
|
const newPos = {
|
|
x: centerX - mousePointTo.x * newScale,
|
|
y: centerY - mousePointTo.y * newScale,
|
|
};
|
|
|
|
setStageScale(newScale);
|
|
setStagePos(newPos);
|
|
}, [stageScale, stagePos, containerSize]);
|
|
|
|
const handleZoomReset = useCallback(() => {
|
|
const initialScale = initialScaleRef.current;
|
|
setStageScale(initialScale);
|
|
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
|
|
}, [containerSize]);
|
|
|
|
// Pattern drag handlers
|
|
const handlePatternDragEnd = useCallback((e: Konva.KonvaEventObject<DragEvent>) => {
|
|
const newOffset = {
|
|
x: e.target.x(),
|
|
y: e.target.y(),
|
|
};
|
|
setPatternOffset(newOffset);
|
|
|
|
if (onPatternOffsetChange) {
|
|
onPatternOffsetChange(newOffset.x, newOffset.y);
|
|
}
|
|
}, [onPatternOffsetChange]);
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200">
|
|
<h2 className="text-xl font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white">Pattern Preview</h2>
|
|
<div className="relative w-full h-[600px] border border-gray-300 dark:border-gray-600 rounded bg-gray-50 dark:bg-gray-900 overflow-hidden" ref={containerRef}>
|
|
{containerSize.width > 0 && (
|
|
<Stage
|
|
width={containerSize.width}
|
|
height={containerSize.height}
|
|
x={stagePos.x}
|
|
y={stagePos.y}
|
|
scaleX={stageScale}
|
|
scaleY={stageScale}
|
|
draggable
|
|
onWheel={handleWheel}
|
|
onDragStart={() => {
|
|
if (stageRef.current) {
|
|
stageRef.current.container().style.cursor = 'grabbing';
|
|
}
|
|
}}
|
|
onDragEnd={() => {
|
|
if (stageRef.current) {
|
|
stageRef.current.container().style.cursor = 'grab';
|
|
}
|
|
}}
|
|
ref={(node) => {
|
|
stageRef.current = node;
|
|
if (node) {
|
|
node.container().style.cursor = 'grab';
|
|
}
|
|
}}
|
|
>
|
|
{/* Background layer: grid, origin, hoop */}
|
|
<Layer>
|
|
{pesData && (
|
|
<>
|
|
<Grid
|
|
gridSize={100}
|
|
bounds={pesData.bounds}
|
|
machineInfo={machineInfo}
|
|
/>
|
|
<Origin />
|
|
{machineInfo && <Hoop machineInfo={machineInfo} />}
|
|
</>
|
|
)}
|
|
</Layer>
|
|
|
|
{/* Pattern layer: draggable stitches and bounds */}
|
|
<Layer>
|
|
{pesData && (
|
|
<Group
|
|
name="pattern-group"
|
|
draggable={!patternUploaded && !isUploading}
|
|
x={patternOffset.x}
|
|
y={patternOffset.y}
|
|
onDragEnd={handlePatternDragEnd}
|
|
onMouseEnter={(e) => {
|
|
const stage = e.target.getStage();
|
|
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';
|
|
}}
|
|
>
|
|
<Stitches
|
|
stitches={pesData.stitches}
|
|
pesData={pesData}
|
|
currentStitchIndex={sewingProgress?.currentStitch || 0}
|
|
showProgress={patternUploaded || isUploading}
|
|
/>
|
|
<PatternBounds bounds={pesData.bounds} />
|
|
</Group>
|
|
)}
|
|
</Layer>
|
|
|
|
{/* Current position layer */}
|
|
<Layer>
|
|
{pesData && sewingProgress && sewingProgress.currentStitch > 0 && (
|
|
<Group x={patternOffset.x} y={patternOffset.y}>
|
|
<CurrentPosition
|
|
currentStitchIndex={sewingProgress.currentStitch}
|
|
stitches={pesData.stitches}
|
|
/>
|
|
</Group>
|
|
)}
|
|
</Layer>
|
|
</Stage>
|
|
)}
|
|
|
|
{/* Placeholder overlay when no pattern is loaded */}
|
|
{!pesData && (
|
|
<div className="flex items-center justify-center h-[600px] text-gray-600 dark:text-gray-400 italic">
|
|
Load a PES file to preview the pattern
|
|
</div>
|
|
)}
|
|
|
|
{/* Pattern info overlays */}
|
|
{pesData && (
|
|
<>
|
|
{/* Thread Legend Overlay */}
|
|
<div className="absolute top-2.5 left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-3 rounded-lg shadow-lg z-10 max-w-[150px]">
|
|
<h4 className="m-0 mb-2 text-[13px] font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1.5">Threads</h4>
|
|
{pesData.threads.map((thread, index) => (
|
|
<div key={index} className="flex items-center gap-2 mb-1.5 last:mb-0">
|
|
<div
|
|
className="w-5 h-5 rounded border border-black dark:border-gray-300 flex-shrink-0"
|
|
style={{ backgroundColor: thread.hex }}
|
|
/>
|
|
<span className="text-xs text-gray-900 dark:text-gray-100">Thread {index + 1}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Pattern Dimensions Overlay */}
|
|
<div className="absolute bottom-[165px] right-5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-4 py-2 rounded-lg shadow-lg z-[11] text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '}
|
|
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
|
|
</div>
|
|
|
|
{/* Pattern Offset Indicator */}
|
|
<div className={`absolute bottom-20 right-5 backdrop-blur-sm p-2.5 px-3.5 rounded-lg shadow-lg z-[11] 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-[11px] 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.5 h-3.5" />
|
|
<span className="text-[10px] font-bold">LOCKED</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="text-[13px] font-semibold text-blue-600 dark:text-blue-400 mb-1">
|
|
X: {(patternOffset.x / 10).toFixed(1)}mm, Y: {(patternOffset.y / 10).toFixed(1)}mm
|
|
</div>
|
|
<div className="text-[10px] text-gray-600 dark:text-gray-400 italic">
|
|
{patternUploaded ? 'Pattern locked • Drag background to pan' : 'Drag pattern to move • Drag background to pan'}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Zoom Controls Overlay */}
|
|
<div className="absolute bottom-5 right-5 flex gap-2 items-center bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-3 py-2 rounded-lg shadow-lg z-10">
|
|
<button className="w-8 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-blue-600 hover:text-white hover:border-blue-600 dark:hover:border-blue-600 hover:shadow-md hover:shadow-blue-600/30 disabled:opacity-50 disabled:cursor-not-allowed" onClick={handleZoomIn} title="Zoom In">
|
|
<PlusIcon className="w-5 h-5 dark:text-gray-200" />
|
|
</button>
|
|
<span className="min-w-[50px] text-center text-[13px] font-semibold text-gray-900 dark:text-gray-100 select-none">{Math.round(stageScale * 100)}%</span>
|
|
<button className="w-8 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-blue-600 hover:text-white hover:border-blue-600 dark:hover:border-blue-600 hover:shadow-md hover:shadow-blue-600/30 disabled:opacity-50 disabled:cursor-not-allowed" onClick={handleZoomOut} title="Zoom Out">
|
|
<MinusIcon className="w-5 h-5 dark:text-gray-200" />
|
|
</button>
|
|
<button className="w-8 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-blue-600 hover:text-white hover:border-blue-600 dark:hover:border-blue-600 hover:shadow-md hover:shadow-blue-600/30 disabled:opacity-50 disabled:cursor-not-allowed ml-1" onClick={handleZoomReset} title="Reset Zoom">
|
|
<ArrowPathIcon className="w-5 h-5 dark:text-gray-200" />
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|