feature: Create unified PatternLayer component to eliminate duplication

Unified three separate layer implementations into single PatternLayer component:

**PatternLayer.tsx (156 lines)**
- Handles both interactive (original) and locked (uploaded) pattern rendering
- Props-driven configuration for dragging, rotation, transformer
- Includes CurrentPosition indicator for uploaded patterns
- Memoized stitches and center calculations for performance
- Single source of truth for pattern rendering logic

**Benefits:**
- Eliminated ~140 lines of duplicated layer code
- Reduced PatternCanvas from 388 to 271 lines (-117 lines, -30%)
- Consistent rendering behavior across pattern states
- Easier to maintain and test pattern rendering
- Cleaner component composition in PatternCanvas

**Removed from PatternCanvas:**
- Original pattern layer implementation (74 lines)
- Uploaded pattern layer implementation (33 lines)
- Current position layer implementation (26 lines)
- Duplicate Group, Transformer, Stitches setup
- Redundant center calculations and stitch conversions

**Complete refactoring impact (All Phases):**
- Original: 786 lines in single monolithic file
- After Phase 4: 271 lines main + hooks + components
- **Total reduction: -515 lines (-65%)**

File structure now:
- PatternCanvas.tsx: 271 lines (orchestration)
- PatternLayer.tsx: 156 lines (rendering)
- useCanvasViewport.ts: 166 lines (viewport)
- usePatternTransform.ts: 179 lines (transform)
- 3 display components + 3 utilities

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-12-25 21:29:24 +01:00
parent 99d32f9029
commit 469c08d45b
2 changed files with 183 additions and 144 deletions

View file

@ -5,17 +5,10 @@ import {
usePatternUploaded,
} from "../../stores/useMachineStore";
import { usePatternStore } from "../../stores/usePatternStore";
import { Stage, Layer, Group, Transformer } from "react-konva";
import { Stage, Layer } from "react-konva";
import Konva from "konva";
import { PhotoIcon } from "@heroicons/react/24/solid";
import {
Grid,
Origin,
Hoop,
Stitches,
PatternBounds,
CurrentPosition,
} from "../KonvaComponents";
import { Grid, Origin, Hoop } from "../KonvaComponents";
import {
Card,
CardHeader,
@ -23,13 +16,10 @@ import {
CardDescription,
CardContent,
} from "@/components/ui/card";
import {
calculatePatternCenter,
convertPenStitchesToPesFormat,
} from "./patternCanvasHelpers";
import { ThreadLegend } from "./ThreadLegend";
import { PatternPositionIndicator } from "./PatternPositionIndicator";
import { ZoomControls } from "./ZoomControls";
import { PatternLayer } from "./PatternLayer";
import { useCanvasViewport } from "../../hooks/useCanvasViewport";
import { usePatternTransform } from "../../hooks/usePatternTransform";
@ -201,141 +191,34 @@ export function PatternCanvas() {
{/* Original pattern layer: draggable with transformer (shown before upload starts) */}
<Layer visible={!isUploading && !patternUploaded}>
{pesData &&
(() => {
const originalCenter = calculatePatternCenter(
pesData.bounds,
);
console.log("[Canvas] Rendering original pattern:", {
position: localPatternOffset,
rotation: localPatternRotation,
center: originalCenter,
bounds: pesData.bounds,
});
return (
<>
<Group
name="pattern-group"
ref={(node) => {
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}
{pesData && (
<PatternLayer
pesData={pesData}
offset={localPatternOffset}
rotation={localPatternRotation}
isInteractive={true}
showProgress={false}
currentStitchIndex={0}
patternGroupRef={patternGroupRef}
transformerRef={transformerRef}
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";
}}
>
<Stitches
stitches={convertPenStitchesToPesFormat(
pesData.penStitches,
attachTransformer={attachTransformer}
/>
)}
pesData={pesData}
currentStitchIndex={0}
showProgress={false}
/>
<PatternBounds bounds={pesData.bounds} />
</Group>
<Transformer
ref={(node) => {
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}
/>
</>
);
})()}
</Layer>
{/* Uploaded pattern layer: locked, rotation baked in (shown during and after upload) */}
<Layer visible={isUploading || patternUploaded}>
{uploadedPesData &&
(() => {
const uploadedCenter = calculatePatternCenter(
uploadedPesData.bounds,
);
console.log("[Canvas] Rendering uploaded pattern:", {
position: initialUploadedPatternOffset,
center: uploadedCenter,
bounds: uploadedPesData.bounds,
});
return (
<Group
name="uploaded-pattern-group"
x={initialUploadedPatternOffset.x}
y={initialUploadedPatternOffset.y}
offsetX={uploadedCenter.x}
offsetY={uploadedCenter.y}
>
<Stitches
stitches={convertPenStitchesToPesFormat(
uploadedPesData.penStitches,
)}
{uploadedPesData && (
<PatternLayer
pesData={uploadedPesData}
currentStitchIndex={
sewingProgress?.currentStitch || 0
}
offset={initialUploadedPatternOffset}
isInteractive={false}
showProgress={true}
currentStitchIndex={sewingProgress?.currentStitch || 0}
/>
<PatternBounds bounds={uploadedPesData.bounds} />
</Group>
);
})()}
</Layer>
{/* Current position layer (for uploaded pattern during sewing) */}
<Layer visible={isUploading || patternUploaded}>
{uploadedPesData &&
sewingProgress &&
sewingProgress.currentStitch > 0 &&
(() => {
const center = calculatePatternCenter(
uploadedPesData.bounds,
);
return (
<Group
x={initialUploadedPatternOffset.x}
y={initialUploadedPatternOffset.y}
offsetX={center.x}
offsetY={center.y}
>
<CurrentPosition
currentStitchIndex={sewingProgress.currentStitch}
stitches={convertPenStitchesToPesFormat(
uploadedPesData.penStitches,
)}
/>
</Group>
);
})()}
</Layer>
</Stage>
)}

View file

@ -0,0 +1,156 @@
/**
* PatternLayer Component
*
* Unified component for rendering pattern layers (both original and uploaded)
* Handles both interactive (draggable/rotatable) and locked states
*/
import { useMemo, type RefObject } from "react";
import { Group, Transformer } from "react-konva";
import type Konva from "konva";
import type { KonvaEventObject } from "konva/lib/Node";
import type { PesPatternData } from "../../formats/import/pesImporter";
import {
calculatePatternCenter,
convertPenStitchesToPesFormat,
} from "./patternCanvasHelpers";
import { Stitches, PatternBounds, CurrentPosition } from "../KonvaComponents";
interface PatternLayerProps {
pesData: PesPatternData;
offset: { x: number; y: number };
rotation?: number;
isInteractive: boolean;
showProgress?: boolean;
currentStitchIndex?: number;
patternGroupRef?: RefObject<Konva.Group | null>;
transformerRef?: RefObject<Konva.Transformer | null>;
onDragEnd?: (e: Konva.KonvaEventObject<DragEvent>) => void;
onTransformEnd?: (e: KonvaEventObject<Event>) => void;
attachTransformer?: () => void;
}
export function PatternLayer({
pesData,
offset,
rotation = 0,
isInteractive,
showProgress = false,
currentStitchIndex = 0,
patternGroupRef,
transformerRef,
onDragEnd,
onTransformEnd,
attachTransformer,
}: PatternLayerProps) {
const center = useMemo(
() => calculatePatternCenter(pesData.bounds),
[pesData.bounds],
);
const stitches = useMemo(
() => convertPenStitchesToPesFormat(pesData.penStitches),
[pesData.penStitches],
);
const groupName = isInteractive ? "pattern-group" : "uploaded-pattern-group";
console.log(
`[PatternLayer] Rendering ${isInteractive ? "original" : "uploaded"} pattern:`,
{
position: offset,
rotation: isInteractive ? rotation : "n/a",
center,
bounds: pesData.bounds,
},
);
return (
<>
<Group
name={groupName}
ref={
isInteractive
? (node) => {
if (patternGroupRef) {
patternGroupRef.current = node;
}
// Set initial rotation from state
if (node && isInteractive) {
node.rotation(rotation);
// Try to attach transformer when group is mounted
if (attachTransformer) {
attachTransformer();
}
}
}
: undefined
}
draggable={isInteractive}
x={offset.x}
y={offset.y}
offsetX={center.x}
offsetY={center.y}
onDragEnd={isInteractive ? onDragEnd : undefined}
onTransformEnd={isInteractive ? onTransformEnd : undefined}
onMouseEnter={
isInteractive
? (e) => {
const stage = e.target.getStage();
if (stage) stage.container().style.cursor = "move";
}
: undefined
}
onMouseLeave={
isInteractive
? (e) => {
const stage = e.target.getStage();
if (stage) stage.container().style.cursor = "grab";
}
: undefined
}
>
<Stitches
stitches={stitches}
pesData={pesData}
currentStitchIndex={currentStitchIndex}
showProgress={showProgress}
/>
<PatternBounds bounds={pesData.bounds} />
</Group>
{/* Transformer only for interactive layer */}
{isInteractive && transformerRef && (
<Transformer
ref={(node) => {
if (transformerRef) {
transformerRef.current = node;
}
// Try to attach transformer when transformer is mounted
if (node && attachTransformer) {
attachTransformer();
}
}}
enabledAnchors={[]}
rotateEnabled={true}
borderEnabled={true}
borderStroke="#FF6B6B"
borderStrokeWidth={2}
rotationSnaps={[0, 45, 90, 135, 180, 225, 270, 315]}
ignoreStroke={true}
rotateAnchorOffset={20}
/>
)}
{/* Current position indicator (only for uploaded pattern with progress) */}
{!isInteractive && showProgress && currentStitchIndex > 0 && (
<Group x={offset.x} y={offset.y} offsetX={center.x} offsetY={center.y}>
<CurrentPosition
currentStitchIndex={currentStitchIndex}
stitches={stitches}
/>
</Group>
)}
</>
);
}