respira/src/components/PatternCanvas.tsx
Jan-Henrik Bruhn 467eb9df95 refactor: Derive patternUploaded from patternInfo instead of syncing state
Removed redundant patternUploaded state from PatternStore and replaced
it with a derived selector usePatternUploaded() in MachineStore that
computes it from patternInfo !== null.

This eliminates duplicate state, removes the need for synchronization
logic, and ensures a single source of truth for pattern upload status.

Updated all components (App, LeftSidebar, FileUpload, PatternCanvas,
WorkflowStepper) to use the derived selector.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-17 11:25:26 +01:00

424 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(
useShallow((state) => ({
sewingProgress: state.sewingProgress,
machineInfo: state.machineInfo,
isUploading: state.isUploading,
}))
);
// Pattern store
const {
pesData,
patternOffset: initialPatternOffset,
setPatternOffset,
} = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
patternOffset: state.patternOffset,
setPatternOffset: state.setPatternOffset,
}))
);
// Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded();
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 [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
)) {
setLocalPatternOffset(initialPatternOffset);
console.log('[PatternCanvas] Restored pattern offset:', 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 and store initial scale when pattern or hoop changes
useEffect(() => {
if (!pesData || containerSize.width === 0) {
prevPesDataRef.current = null;
return;
}
// Only recalculate if pattern changed
if (prevPesDataRef.current !== pesData) {
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 initialScale = calculateInitialScale(containerSize.width, containerSize.height, viewWidth, viewHeight);
initialScaleRef.current = initialScale;
// Reset view when pattern changes
// eslint-disable-next-line react-hooks/set-state-in-effect
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 pointer = stage.getPointerPosition();
if (!pointer) return;
const scaleBy = 1.1;
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));
// Zoom towards pointer
setStagePos((prevPos) => {
const mousePointTo = {
x: (pointer.x - prevPos.x) / oldScale,
y: (pointer.y - prevPos.y) / oldScale,
};
return {
x: pointer.x - mousePointTo.x * newScale,
y: pointer.y - mousePointTo.y * newScale,
};
});
return newScale;
});
}, []);
// Zoom control handlers
const handleZoomIn = useCallback(() => {
setStageScale((oldScale) => {
const newScale = Math.max(0.1, Math.min(oldScale * 1.2, 2));
// Zoom towards center of viewport
setStagePos((prevPos) => {
const centerX = containerSize.width / 2;
const centerY = containerSize.height / 2;
const mousePointTo = {
x: (centerX - prevPos.x) / oldScale,
y: (centerY - prevPos.y) / oldScale,
};
return {
x: centerX - mousePointTo.x * newScale,
y: centerY - mousePointTo.y * newScale,
};
});
return newScale;
});
}, [containerSize]);
const handleZoomOut = useCallback(() => {
setStageScale((oldScale) => {
const newScale = Math.max(0.1, Math.min(oldScale / 1.2, 2));
// Zoom towards center of viewport
setStagePos((prevPos) => {
const centerX = containerSize.width / 2;
const centerY = containerSize.height / 2;
const mousePointTo = {
x: (centerX - prevPos.x) / oldScale,
y: (centerY - prevPos.y) / oldScale,
};
return {
x: centerX - mousePointTo.x * newScale,
y: centerY - mousePointTo.y * newScale,
};
});
return newScale;
});
}, [containerSize]);
const handleZoomReset = useCallback(() => {
const initialScale = initialScaleRef.current;
setStageScale(initialScale);
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
}, [containerSize]);
const handleCenterPattern = useCallback(() => {
if (!pesData) return;
const { bounds } = pesData;
const centerOffsetX = -(bounds.minX + bounds.maxX) / 2;
const centerOffsetY = -(bounds.minY + bounds.maxY) / 2;
setLocalPatternOffset({ x: centerOffsetX, y: centerOffsetY });
setPatternOffset(centerOffsetX, centerOffsetY);
}, [pesData, setPatternOffset]);
// Pattern drag handlers
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]);
const borderColor = pesData ? 'border-teal-600 dark:border-teal-500' : 'border-gray-400 dark:border-gray-600';
const iconColor = pesData ? 'text-teal-600 dark:text-teal-400' : 'text-gray-600 dark:text-gray-400';
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="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>
{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
</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-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={localPatternOffset.x}
y={localPatternOffset.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.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
)?.colorIndex ?? 0;
return [s.x, s.y, cmd, colorIndex];
})}
pesData={pesData}
currentStitchIndex={sewingProgress?.currentStitch || 0}
showProgress={patternUploaded || isUploading}
/>
<PatternBounds bounds={pesData.bounds} />
</Group>
)}
</Layer>
{/* Current position layer */}
<Layer>
{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] => {
const cmd = s.isJump ? 0x10 : 0;
const colorIndex = pesData.penStitches.colorBlocks.find(
(b) => i >= b.startStitch && i <= b.endStitch
)?.colorIndex ?? 0;
return [s.x, s.y, cmd, colorIndex];
})}
/>
</Group>
)}
</Layer>
</Stage>
)}
{/* Placeholder overlay when no pattern is loaded */}
{!pesData && (
<div className="flex items-center justify-center h-full 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 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>
{pesData.uniqueColors.map((color, idx) => {
// Primary metadata: brand and catalog number
const primaryMetadata = [
color.brand,
color.catalogNumber ? `#${color.catalogNumber}` : null
].filter(Boolean).join(" ");
// Secondary metadata: chart and description
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
className="w-3 h-3 sm:w-4 sm:h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5"
style={{ backgroundColor: color.hex }}
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-gray-900 dark:text-gray-100">
Color {idx + 1}
</div>
{(primaryMetadata || secondaryMetadata) && (
<div className="text-xs text-gray-600 dark:text-gray-400 leading-tight mt-0.5 break-words">
{primaryMetadata}
{primaryMetadata && secondaryMetadata && <span className="mx-1"></span>}
{secondaryMetadata}
</div>
)}
</div>
</div>
);
})}
</div>
{/* Pattern Offset Indicator */}
<div className={`absolute bottom-16 sm:bottom-20 right-2 sm:right-5 backdrop-blur-sm p-2 sm:p-2.5 px-2.5 sm:px-3.5 rounded-lg shadow-lg z-[11] min-w-[160px] sm:min-w-[180px] transition-colors ${
patternUploaded ? 'bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600' : 'bg-white/95 dark:bg-gray-800/95'
}`}>
<div className="flex items-center justify-between mb-1">
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Pattern Position:</div>
{patternUploaded && (
<div className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
<LockClosedIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs font-bold">LOCKED</span>
</div>
)}
</div>
<div className="text-sm font-semibold text-blue-600 dark:text-blue-400 mb-1">
X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y: {(localPatternOffset.y / 10).toFixed(1)}mm
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 italic">
{patternUploaded ? 'Pattern locked • Drag background to pan' : 'Drag pattern to move • Drag background to pan'}
</div>
</div>
{/* Zoom Controls Overlay */}
<div className="absolute bottom-2 sm:bottom-5 right-2 sm:right-5 flex gap-1.5 sm:gap-2 items-center bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg shadow-lg z-10">
<button className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-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={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-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-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-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-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-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-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
</button>
</div>
</>
)}
</div>
</div>
);
}