From 07275fa75a671bf1d2f06481af0dab989cc5aaa8 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Thu, 25 Dec 2025 21:15:05 +0100 Subject: [PATCH] feature: Refactor PatternCanvas with utility functions and folder structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract duplicate code into reusable utilities: - calculatePatternCenter(): Eliminates 6x duplication of center calculations - convertPenStitchesToPesFormat(): Eliminates 3x duplication of stitch conversion - calculateZoomToPoint(): Eliminates 2x duplication of zoom math Reorganize into subfolder: - Created src/components/PatternCanvas/ directory - Moved PatternCanvas.tsx into subfolder - Added index.ts for clean imports - All utilities in patternCanvasHelpers.ts Benefits: - Reduced ~50+ lines of duplicated code - Single source of truth for common operations - Easier to test and maintain - Better code organization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../PatternCanvas/PatternCanvas.tsx | 740 ++++++++++++++++++ src/components/PatternCanvas/index.ts | 1 + .../PatternCanvas/patternCanvasHelpers.ts | 59 ++ 3 files changed, 800 insertions(+) create mode 100644 src/components/PatternCanvas/PatternCanvas.tsx create mode 100644 src/components/PatternCanvas/index.ts create mode 100644 src/components/PatternCanvas/patternCanvasHelpers.ts diff --git a/src/components/PatternCanvas/PatternCanvas.tsx b/src/components/PatternCanvas/PatternCanvas.tsx new file mode 100644 index 0000000..8b0d213 --- /dev/null +++ b/src/components/PatternCanvas/PatternCanvas.tsx @@ -0,0 +1,740 @@ +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, Transformer } from "react-konva"; +import Konva from "konva"; +import type { KonvaEventObject } from "konva/lib/Node"; +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"; +import { + calculatePatternCenter, + convertPenStitchesToPesFormat, + calculateZoomToPoint, +} from "./patternCanvasHelpers"; + +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, + patternRotation: initialPatternRotation, + uploadedPesData, + uploadedPatternOffset: initialUploadedPatternOffset, + setPatternOffset, + setPatternRotation, + } = usePatternStore( + useShallow((state) => ({ + pesData: state.pesData, + patternOffset: state.patternOffset, + patternRotation: state.patternRotation, + uploadedPesData: state.uploadedPesData, + uploadedPatternOffset: state.uploadedPatternOffset, + setPatternOffset: state.setPatternOffset, + setPatternRotation: state.setPatternRotation, + })), + ); + + // Derived state: pattern is uploaded if machine has pattern info + const patternUploaded = usePatternUploaded(); + const containerRef = useRef(null); + const stageRef = useRef(null); + const patternGroupRef = useRef(null); + const transformerRef = 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 [localPatternRotation, setLocalPatternRotation] = useState( + initialPatternRotation || 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, + ); + } + + // Update pattern rotation when initialPatternRotation changes + if ( + initialPatternRotation !== undefined && + localPatternRotation !== initialPatternRotation + ) { + setLocalPatternRotation(initialPatternRotation); + } + + // 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(() => { + // Use whichever pattern is available (uploaded or original) + const currentPattern = uploadedPesData || pesData; + if (!currentPattern || containerSize.width === 0) { + prevPesDataRef.current = null; + return; + } + + // Only recalculate if pattern changed + if (prevPesDataRef.current !== currentPattern) { + prevPesDataRef.current = currentPattern; + + const { bounds } = currentPattern; + 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, uploadedPesData, 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) => + calculateZoomToPoint(oldScale, newScale, pointer, prevPos), + ); + + 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 + const center = { + x: containerSize.width / 2, + y: containerSize.height / 2, + }; + setStagePos((prevPos) => + calculateZoomToPoint(oldScale, newScale, center, prevPos), + ); + + 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 + const center = { + x: containerSize.width / 2, + y: containerSize.height / 2, + }; + setStagePos((prevPos) => + calculateZoomToPoint(oldScale, newScale, center, prevPos), + ); + + 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], + ); + + // Attach/detach transformer based on state + const attachTransformer = useCallback(() => { + if (!transformerRef.current || !patternGroupRef.current) { + console.log( + "[PatternCanvas] Cannot attach transformer - refs not ready", + { + hasTransformer: !!transformerRef.current, + hasPatternGroup: !!patternGroupRef.current, + }, + ); + return; + } + + if (!patternUploaded && !isUploading) { + console.log("[PatternCanvas] Attaching transformer"); + transformerRef.current.nodes([patternGroupRef.current]); + transformerRef.current.getLayer()?.batchDraw(); + } else { + console.log("[PatternCanvas] Detaching transformer"); + transformerRef.current.nodes([]); + } + }, [patternUploaded, isUploading]); + + // Call attachTransformer when conditions change + useEffect(() => { + attachTransformer(); + }, [attachTransformer, pesData]); + + // Sync node rotation with state (important for when rotation is reset to 0 after upload) + useEffect(() => { + if (patternGroupRef.current) { + patternGroupRef.current.rotation(localPatternRotation); + } + }, [localPatternRotation]); + + // Handle transformer rotation - just store the angle, apply at upload time + const handleTransformEnd = useCallback( + (e: KonvaEventObject) => { + if (!pesData) return; + + const node = e.target; + // Read rotation from the node + const totalRotation = node.rotation(); + const normalizedRotation = ((totalRotation % 360) + 360) % 360; + + setLocalPatternRotation(normalizedRotation); + + // Also read position in case the Transformer affected it + const newOffset = { + x: node.x(), + y: node.y(), + }; + setLocalPatternOffset(newOffset); + + // Store rotation angle and position + setPatternRotation(normalizedRotation); + setPatternOffset(newOffset.x, newOffset.y); + + console.log( + "[Canvas] Transform end - rotation:", + normalizedRotation, + "degrees, position:", + newOffset, + ); + }, + [setPatternRotation, setPatternOffset, pesData], + ); + + const hasPattern = pesData || uploadedPesData; + const borderColor = hasPattern + ? "border-tertiary-600 dark:border-tertiary-500" + : "border-gray-400 dark:border-gray-600"; + const iconColor = hasPattern + ? "text-tertiary-600 dark:text-tertiary-400" + : "text-gray-600 dark:text-gray-400"; + + return ( + + +
+ +
+ Pattern Preview + {hasPattern ? ( + + {(() => { + const displayPattern = uploadedPesData || pesData; + return displayPattern ? ( + <> + {( + (displayPattern.bounds.maxX - + displayPattern.bounds.minX) / + 10 + ).toFixed(1)}{" "} + ×{" "} + {( + (displayPattern.bounds.maxY - + displayPattern.bounds.minY) / + 10 + ).toFixed(1)}{" "} + mm + + ) : null; + })()} + + ) : ( + + 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 */} + + {hasPattern && ( + <> + + + {machineInfo && } + + )} + + + {/* Original pattern layer: draggable with transformer (shown before upload starts) */} + + {pesData && + (() => { + const originalCenter = calculatePatternCenter( + pesData.bounds, + ); + console.log("[Canvas] Rendering original pattern:", { + position: localPatternOffset, + rotation: localPatternRotation, + center: originalCenter, + bounds: pesData.bounds, + }); + return ( + <> + { + patternGroupRef.current = node; + // Set initial rotation from state + if (node) { + node.rotation(localPatternRotation); + // Try to attach transformer when group is mounted + attachTransformer(); + } + }} + draggable={!isUploading} + x={localPatternOffset.x} + y={localPatternOffset.y} + offsetX={originalCenter.x} + offsetY={originalCenter.y} + onDragEnd={handlePatternDragEnd} + onTransformEnd={handleTransformEnd} + onMouseEnter={(e) => { + const stage = e.target.getStage(); + if (stage && !isUploading) + stage.container().style.cursor = "move"; + }} + onMouseLeave={(e) => { + const stage = e.target.getStage(); + if (stage && !isUploading) + stage.container().style.cursor = "grab"; + }} + > + + + + { + transformerRef.current = node; + // Try to attach transformer when transformer is mounted + if (node) { + attachTransformer(); + } + }} + enabledAnchors={[]} + rotateEnabled={true} + borderEnabled={true} + borderStroke="#FF6B6B" + borderStrokeWidth={2} + rotationSnaps={[0, 45, 90, 135, 180, 225, 270, 315]} + ignoreStroke={true} + rotateAnchorOffset={20} + /> + + ); + })()} + + + {/* Uploaded pattern layer: locked, rotation baked in (shown during and after upload) */} + + {uploadedPesData && + (() => { + const uploadedCenter = calculatePatternCenter( + uploadedPesData.bounds, + ); + console.log("[Canvas] Rendering uploaded pattern:", { + position: initialUploadedPatternOffset, + center: uploadedCenter, + bounds: uploadedPesData.bounds, + }); + return ( + + + + + ); + })()} + + + {/* Current position layer (for uploaded pattern during sewing) */} + + {uploadedPesData && + sewingProgress && + sewingProgress.currentStitch > 0 && + (() => { + const center = calculatePatternCenter( + uploadedPesData.bounds, + ); + return ( + + + + ); + })()} + + + )} + + {/* Placeholder overlay when no pattern is loaded */} + {!hasPattern && ( +
+ Load a PES file to preview the pattern +
+ )} + + {/* Pattern info overlays */} + {hasPattern && + (() => { + const displayPattern = uploadedPesData || pesData; + return ( + displayPattern && ( + <> + {/* Thread Legend Overlay */} +
+

+ Colors +

+ {displayPattern.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 + // Only show chart if it's different from catalogNumber + const secondaryMetadata = [ + color.chart && color.chart !== color.catalogNumber + ? color.chart + : null, + color.description, + ] + .filter(Boolean) + .join(" "); + + return ( +
+
+
+
+ Color {idx + 1} +
+ {(primaryMetadata || secondaryMetadata) && ( +
+ {primaryMetadata} + {primaryMetadata && secondaryMetadata && ( + • + )} + {secondaryMetadata} +
+ )} +
+
+ ); + })} +
+ + {/* Pattern Offset Indicator */} +
+
+
+ Pattern Position: +
+ {(isUploading || patternUploaded) && ( +
+ + + {isUploading ? "UPLOADING" : "LOCKED"} + +
+ )} +
+
+ {isUploading || patternUploaded ? ( + <> + X:{" "} + {(initialUploadedPatternOffset.x / 10).toFixed(1)} + mm, Y:{" "} + {(initialUploadedPatternOffset.y / 10).toFixed(1)}mm + + ) : ( + <> + X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y:{" "} + {(localPatternOffset.y / 10).toFixed(1)}mm + + )} +
+ {!isUploading && + !patternUploaded && + localPatternRotation !== 0 && ( +
+ Rotation: {localPatternRotation.toFixed(1)}° +
+ )} +
+ {isUploading + ? "Uploading pattern..." + : patternUploaded + ? "Pattern locked • Drag background to pan" + : "Drag pattern to move • Drag background to pan"} +
+
+ + {/* Zoom Controls Overlay */} +
+ + + + {Math.round(stageScale * 100)}% + + + +
+ + ) + ); + })()} +
+ + + ); +} diff --git a/src/components/PatternCanvas/index.ts b/src/components/PatternCanvas/index.ts new file mode 100644 index 0000000..ff58a71 --- /dev/null +++ b/src/components/PatternCanvas/index.ts @@ -0,0 +1 @@ +export { PatternCanvas } from "./PatternCanvas"; diff --git a/src/components/PatternCanvas/patternCanvasHelpers.ts b/src/components/PatternCanvas/patternCanvasHelpers.ts new file mode 100644 index 0000000..2bda0b9 --- /dev/null +++ b/src/components/PatternCanvas/patternCanvasHelpers.ts @@ -0,0 +1,59 @@ +/** + * Utility functions for PatternCanvas operations + */ + +import type { DecodedPenData } from "../../formats/pen/types"; + +/** + * Calculate the geometric center of a pattern's bounds + */ +export function calculatePatternCenter(bounds: { + minX: number; + maxX: number; + minY: number; + maxY: number; +}): { x: number; y: number } { + return { + x: (bounds.minX + bounds.maxX) / 2, + y: (bounds.minY + bounds.maxY) / 2, + }; +} + +/** + * Convert PEN stitch format to PES stitch format + * PEN: {x, y, flags, isJump} + * PES: [x, y, cmd, colorIndex] + */ +export function convertPenStitchesToPesFormat( + penStitches: DecodedPenData, +): [number, number, number, number][] { + return penStitches.stitches.map((s, i) => { + const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump + const colorIndex = + penStitches.colorBlocks.find( + (b) => i >= b.startStitch && i <= b.endStitch, + )?.colorIndex ?? 0; + return [s.x, s.y, cmd, colorIndex]; + }); +} + +/** + * Calculate new stage position for zooming towards a specific point + * Used for both wheel zoom and button zoom operations + */ +export function calculateZoomToPoint( + oldScale: number, + newScale: number, + targetPoint: { x: number; y: number }, + currentPos: { x: number; y: number }, +): { x: number; y: number } { + const mousePointTo = { + x: (targetPoint.x - currentPos.x) / oldScale, + y: (targetPoint.y - currentPos.y) / oldScale, + }; + + return { + x: targetPoint.x - mousePointTo.x * newScale, + y: targetPoint.y - mousePointTo.y * newScale, + }; +}