mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 02:13:41 +00:00
- 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>
449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|