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 { Card, CardHeader, CardTitle, CardDescription, CardContent, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; 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-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 (
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)}%
)}
); }