import { useEffect, useRef, useState, useCallback } from 'react'; import { Stage, Layer, Group } from 'react-konva'; import Konva from 'konva'; import { PlusIcon, MinusIcon, ArrowPathIcon, LockClosedIcon, PhotoIcon } 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(null); const stageRef = useRef(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(1); const prevPesDataRef = useRef(null); // Update pattern offset when initialPatternOffset changes if (initialPatternOffset && ( patternOffset.x !== initialPatternOffset.x || patternOffset.y !== initialPatternOffset.y )) { setPatternOffset(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]); // Pattern drag handlers const handlePatternDragEnd = useCallback((e: Konva.KonvaEventObject) => { const newOffset = { x: e.target.x(), y: e.target.y(), }; setPatternOffset(newOffset); if (onPatternOffsetChange) { onPatternOffsetChange(newOffset.x, newOffset.y); } }, [onPatternOffsetChange]); 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'; }} > )} {/* Current position layer */} {pesData && sewingProgress && sewingProgress.currentStitch > 0 && ( )} )} {/* 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: {(patternOffset.x / 10).toFixed(1)}mm, Y: {(patternOffset.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)}%
)}
); }