respira/src/components/PatternCanvas.tsx
Jan-Henrik Bruhn 3c8c2d49fd Fix React/Konva DOM conflicts and zoom reset issues
- Separate Konva container from React-managed overlays to prevent removeChild errors
- Move wheel zoom handler into stage initialization to maintain stable reference
- Remove zoomLevel from background effect dependencies to prevent zoom reset
- Add double-initialization guard for stage creation
- Clear refs before stage destruction to prevent race conditions

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 22:34:10 +01:00

449 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState, useCallback } from 'react';
import Konva from 'konva';
import type { PesPatternData } from '../utils/pystitchConverter';
import type { SewingProgress, MachineInfo } from '../types/machine';
import {
renderGrid,
renderOrigin,
renderHoop,
renderStitches,
renderPatternBounds,
renderCurrentPosition,
calculateInitialScale,
} from '../utils/konvaRenderers';
interface PatternCanvasProps {
pesData: PesPatternData | null;
sewingProgress: SewingProgress | null;
machineInfo: MachineInfo | null;
onPatternOffsetChange?: (offsetX: number, offsetY: number) => void;
}
export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternOffsetChange }: PatternCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage | null>(null);
const backgroundLayerRef = useRef<Konva.Layer | null>(null);
const patternLayerRef = useRef<Konva.Layer | null>(null);
const currentPosLayerRef = useRef<Konva.Layer | null>(null);
const patternGroupRef = useRef<Konva.Group | null>(null);
const [zoomLevel, setZoomLevel] = useState(1);
const [patternOffset, setPatternOffset] = useState({ x: 0, y: 0 });
const initialScaleRef = useRef<number>(1);
const isDraggingRef = useRef(false);
// Initialize Konva stage and layers
useEffect(() => {
if (!containerRef.current) return;
// Prevent double initialization
if (stageRef.current) return;
const container = containerRef.current;
// Create stage
const stage = new Konva.Stage({
container,
width: container.offsetWidth,
height: container.offsetHeight,
draggable: false, // Stage itself is not draggable
});
// Configure stage to center on embroidery origin (0,0)
// Simply position the stage so that (0,0) appears at the center
stage.position({ x: stage.width() / 2, y: stage.height() / 2 });
// Create layers
const backgroundLayer = new Konva.Layer();
const patternLayer = new Konva.Layer();
const currentPosLayer = new Konva.Layer();
stage.add(backgroundLayer, patternLayer, currentPosLayer);
// Store refs
stageRef.current = stage;
backgroundLayerRef.current = backgroundLayer;
patternLayerRef.current = patternLayer;
currentPosLayerRef.current = currentPosLayer;
// Set initial cursor - grab for panning
stage.container().style.cursor = 'grab';
// Make stage draggable for panning
stage.draggable(true);
// Update cursor on drag
stage.on('dragstart', () => {
stage.container().style.cursor = 'grabbing';
});
stage.on('dragend', () => {
stage.container().style.cursor = 'grab';
});
// Mouse wheel zoom handler
const handleWheel = (e: Konva.KonvaEventObject<WheelEvent>) => {
e.evt.preventDefault();
const oldScale = stage.scaleX();
const pointer = stage.getPointerPosition();
if (!pointer) return;
const scaleBy = 1.1;
const direction = e.evt.deltaY > 0 ? -1 : 1;
let newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy;
// Apply constraints
newScale = Math.max(0.1, Math.min(10, newScale));
// Zoom towards pointer
const mousePointTo = {
x: (pointer.x - stage.x()) / oldScale,
y: (pointer.y - stage.y()) / oldScale,
};
const newPos = {
x: pointer.x - mousePointTo.x * newScale,
y: pointer.y - mousePointTo.y * newScale,
};
stage.scale({ x: newScale, y: newScale });
stage.position(newPos);
setZoomLevel(newScale);
stage.batchDraw();
};
// Attach wheel event
stage.on('wheel', handleWheel);
return () => {
// Clear refs before destroying to prevent race conditions
stageRef.current = null;
backgroundLayerRef.current = null;
patternLayerRef.current = null;
currentPosLayerRef.current = null;
patternGroupRef.current = null;
// Destroy the stage (this removes the canvas from DOM)
stage.destroy();
};
}, []);
// Handle responsive resizing
useEffect(() => {
if (!containerRef.current || !stageRef.current) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
const stage = stageRef.current;
if (stage) {
// Keep the current pan/zoom, just update size
const oldWidth = stage.width();
const oldHeight = stage.height();
const oldPos = stage.position();
stage.width(width);
stage.height(height);
// Adjust position to maintain center point
stage.position({
x: oldPos.x + (width - oldWidth) / 2,
y: oldPos.y + (height - oldHeight) / 2,
});
stage.batchDraw();
}
}
});
resizeObserver.observe(containerRef.current);
return () => resizeObserver.disconnect();
}, []);
// Helper function to zoom to a specific point
const zoomToPoint = useCallback(
(point: { x: number; y: number }, newScale: number) => {
const stage = stageRef.current;
if (!stage) return;
const oldScale = stage.scaleX();
const mousePointTo = {
x: (point.x - stage.x()) / oldScale,
y: (point.y - stage.y()) / oldScale,
};
const newPos = {
x: point.x - mousePointTo.x * newScale,
y: point.y - mousePointTo.y * newScale,
};
stage.scale({ x: newScale, y: newScale });
stage.position(newPos);
setZoomLevel(newScale);
stage.batchDraw();
},
[]
);
// Zoom control handlers
const handleZoomIn = useCallback(() => {
const stage = stageRef.current;
if (!stage) return;
const newScale = Math.min(stage.scaleX() * 1.2, 10);
const center = {
x: stage.width() / 2,
y: stage.height() / 2,
};
zoomToPoint(center, newScale);
}, [zoomToPoint]);
const handleZoomOut = useCallback(() => {
const stage = stageRef.current;
if (!stage) return;
const newScale = Math.max(stage.scaleX() / 1.2, 0.1);
const center = {
x: stage.width() / 2,
y: stage.height() / 2,
};
zoomToPoint(center, newScale);
}, [zoomToPoint]);
const handleZoomReset = useCallback(() => {
const stage = stageRef.current;
if (!stage) return;
const initialScale = initialScaleRef.current;
stage.scale({ x: initialScale, y: initialScale });
stage.position({ x: stage.width() / 2, y: stage.height() / 2 });
setZoomLevel(initialScale);
stage.batchDraw();
}, []);
// Render background layer (grid, origin, hoop)
useEffect(() => {
const layer = backgroundLayerRef.current;
const stage = stageRef.current;
if (!layer || !stage || !pesData) return;
layer.destroyChildren();
const { bounds } = pesData;
// Determine view dimensions - always fit to hoop if available, otherwise fit to pattern
const viewWidth = machineInfo ? machineInfo.maxWidth : bounds.maxX - bounds.minX;
const viewHeight = machineInfo ? machineInfo.maxHeight : bounds.maxY - bounds.minY;
// Calculate and store initial scale
const initialScale = calculateInitialScale(stage.width(), stage.height(), viewWidth, viewHeight);
initialScaleRef.current = initialScale;
// Only set initial scale if this is the first render (zoom level is still 1)
if (zoomLevel === 1) {
stage.scale({ x: initialScale, y: initialScale });
stage.position({ x: stage.width() / 2, y: stage.height() / 2 });
setZoomLevel(initialScale);
}
// Render background elements
const gridSize = 100; // 10mm grid (100 units in 0.1mm)
renderGrid(layer, gridSize, bounds, machineInfo);
renderOrigin(layer);
if (machineInfo) {
renderHoop(layer, machineInfo);
}
// Cache the background layer for performance
layer.cache();
layer.batchDraw();
}, [machineInfo, pesData]);
// Render pattern layer (stitches and bounds in a draggable group)
// This effect only runs when the pattern changes, NOT when sewing progress changes
useEffect(() => {
const layer = patternLayerRef.current;
if (!layer || !pesData) return;
layer.destroyChildren();
const { stitches, bounds } = pesData;
// Create a draggable group for the pattern
const patternGroup = new Konva.Group({
draggable: true,
x: patternOffset.x,
y: patternOffset.y,
});
// Store ref
patternGroupRef.current = patternGroup;
// Render pattern elements into the group (initial render with currentStitch = 0)
const currentStitch = sewingProgress?.currentStitch || 0;
renderStitches(patternGroup, stitches, pesData, currentStitch);
renderPatternBounds(patternGroup, bounds);
// Handle drag events
patternGroup.on('dragstart', () => {
isDraggingRef.current = true;
});
patternGroup.on('dragend', () => {
isDraggingRef.current = false;
const newOffset = {
x: patternGroup.x(),
y: patternGroup.y(),
};
setPatternOffset(newOffset);
// Notify parent component of offset change
if (onPatternOffsetChange) {
onPatternOffsetChange(newOffset.x, newOffset.y);
}
});
// Add visual feedback on hover
patternGroup.on('mouseenter', () => {
const stage = stageRef.current;
if (stage) stage.container().style.cursor = 'move';
});
patternGroup.on('mouseleave', () => {
if (!isDraggingRef.current) {
const stage = stageRef.current;
if (stage) stage.container().style.cursor = 'grab';
}
});
layer.add(patternGroup);
layer.batchDraw();
}, [pesData, onPatternOffsetChange]); // Removed sewingProgress from dependencies
// Separate effect to update stitches when sewing progress changes
// This only updates the stitch rendering, not the entire group
useEffect(() => {
const patternGroup = patternGroupRef.current;
if (!patternGroup || !pesData || isDraggingRef.current) return;
const currentStitch = sewingProgress?.currentStitch || 0;
const { stitches } = pesData;
// Remove old stitches group and re-render
const oldStitchesGroup = patternGroup.findOne('.stitches');
if (oldStitchesGroup) {
oldStitchesGroup.destroy();
}
// Re-render stitches with updated progress
renderStitches(patternGroup, stitches, pesData, currentStitch);
patternGroup.getLayer()?.batchDraw();
}, [sewingProgress, pesData]);
// Separate effect to update pattern position when offset changes externally (not during drag)
useEffect(() => {
const patternGroup = patternGroupRef.current;
if (patternGroup && !isDraggingRef.current) {
patternGroup.position({ x: patternOffset.x, y: patternOffset.y });
patternGroup.getLayer()?.batchDraw();
}
}, [patternOffset.x, patternOffset.y]);
// Render current position layer (updates frequently, follows pattern offset)
useEffect(() => {
const layer = currentPosLayerRef.current;
if (!layer || !pesData) return;
layer.destroyChildren();
const currentStitch = sewingProgress?.currentStitch || 0;
const { stitches } = pesData;
if (currentStitch > 0 && currentStitch < stitches.length) {
// Create group at pattern offset
const posGroup = new Konva.Group({
x: patternOffset.x,
y: patternOffset.y,
});
renderCurrentPosition(posGroup, currentStitch, stitches);
layer.add(posGroup);
}
layer.batchDraw();
}, [pesData, sewingProgress, patternOffset.x, patternOffset.y]);
return (
<div className="canvas-panel">
<h2>Pattern Preview</h2>
<div className="canvas-container">
{/* Konva container - separate from React-managed overlays */}
<div ref={containerRef} style={{ width: '100%', height: '100%', position: 'absolute' }} />
{/* Placeholder overlay when no pattern is loaded */}
{!pesData && (
<div className="canvas-placeholder">
Load a PES file to preview the pattern
</div>
)}
{/* Pattern info overlays */}
{pesData && (
<>
{/* Thread Legend Overlay */}
<div className="canvas-legend">
<h4>Threads</h4>
{pesData.threads.map((thread, index) => (
<div key={index} className="legend-item">
<div
className="legend-swatch"
style={{ backgroundColor: thread.hex }}
/>
<span className="legend-label">Thread {index + 1}</span>
</div>
))}
</div>
{/* Pattern Dimensions Overlay */}
<div className="canvas-dimensions">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
</div>
{/* Pattern Offset Indicator */}
<div className="canvas-offset-info">
<div className="offset-label">Pattern Position:</div>
<div className="offset-value">
X: {(patternOffset.x / 10).toFixed(1)}mm, Y: {(patternOffset.y / 10).toFixed(1)}mm
</div>
<div className="offset-hint">
Drag pattern to move Drag background to pan
</div>
</div>
{/* Zoom Controls Overlay */}
<div className="zoom-controls">
<button className="zoom-btn" onClick={handleZoomIn} title="Zoom In">
+
</button>
<span className="zoom-level">{Math.round(zoomLevel * 100)}%</span>
<button className="zoom-btn" onClick={handleZoomOut} title="Zoom Out">
</button>
<button className="zoom-btn zoom-reset" onClick={handleZoomReset} title="Reset Zoom">
</button>
</div>
</>
)}
</div>
</div>
);
}