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(null); const stageRef = useRef(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(1); const prevPesDataRef = useRef(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) => { 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) => { 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 (

Pattern Preview

{pesData ? (

{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} × {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm

) : (

No pattern loaded

)}
{containerSize.width > 0 && ( { 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 */} {pesData && ( <> {machineInfo && } )} {/* Pattern layer: draggable stitches and bounds */} {pesData && ( { 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'; }} > { // 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} /> )} {/* Current position layer */} {pesData && pesData.penStitches && sewingProgress && sewingProgress.currentStitch > 0 && ( { 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]; })} /> )} )} {/* Placeholder overlay when no pattern is loaded */} {!pesData && (
Load a PES file to preview the pattern
)} {/* Pattern info overlays */} {pesData && ( <> {/* Thread Legend Overlay */}

Colors

{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 (
Color {idx + 1}
{(primaryMetadata || secondaryMetadata) && (
{primaryMetadata} {primaryMetadata && secondaryMetadata && } {secondaryMetadata}
)}
); })}
{/* Pattern Offset Indicator */}
Pattern Position:
{patternUploaded && (
LOCKED
)}
X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y: {(localPatternOffset.y / 10).toFixed(1)}mm
{patternUploaded ? 'Pattern locked • Drag background to pan' : 'Drag pattern to move • Drag background to pan'}
{/* Zoom Controls Overlay */}
{Math.round(stageScale * 100)}%
)}
); }