respira/src/components/PatternCanvas.tsx
Jan-Henrik Bruhn a0cb6d1822 fix: Prevent PatternCanvas overflow on small screens
Replace fixed height values with flexbox sizing to prevent canvas overflow
when the window height is constrained.

Changes:
- Replace h-[400px] sm:h-[500px] lg:flex-1 with flex-1 on all screen sizes
- Add min-h-0 to CardContent and canvas container for proper flex shrinking
- Canvas now properly respects card boundaries on all screen sizes

The flex-1 min-h-0 pattern ensures the canvas takes available space without
overflowing its parent card, regardless of viewport size.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-21 13:32:13 +01:00

536 lines
19 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 { 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<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage | null>(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<number>(1);
const prevPesDataRef = useRef<PesPatternData | null>(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<WheelEvent>) => {
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<DragEvent>) => {
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 (
<Card
className={`p-0 gap-0 lg:h-full flex flex-col border-l-4 ${borderColor}`}
>
<CardHeader className="p-4 pb-3">
<div className="flex items-start gap-3">
<PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm">Pattern Preview</CardTitle>
{pesData ? (
<CardDescription className="text-xs">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)}{" "}
×{" "}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)}{" "}
mm
</CardDescription>
) : (
<CardDescription className="text-xs">
No pattern loaded
</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col min-h-0">
<div
className="relative w-full flex-1 min-h-0 border border-gray-300 dark:border-gray-600 rounded bg-gray-200 dark:bg-gray-900 overflow-hidden"
ref={containerRef}
>
{containerSize.width > 0 && (
<Stage
width={containerSize.width}
height={containerSize.height}
x={stagePos.x}
y={stagePos.y}
scaleX={stageScale}
scaleY={stageScale}
draggable
onWheel={handleWheel}
onDragStart={() => {
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 */}
<Layer>
{pesData && (
<>
<Grid
gridSize={100}
bounds={pesData.bounds}
machineInfo={machineInfo}
/>
<Origin />
{machineInfo && <Hoop machineInfo={machineInfo} />}
</>
)}
</Layer>
{/* Pattern layer: draggable stitches and bounds */}
<Layer>
{pesData && (
<Group
name="pattern-group"
draggable={!patternUploaded && !isUploading}
x={localPatternOffset.x}
y={localPatternOffset.y}
onDragEnd={handlePatternDragEnd}
onMouseEnter={(e) => {
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";
}}
>
<Stitches
stitches={pesData.penStitches.stitches.map(
(s, i): [number, number, number, number] => {
// 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}
/>
<PatternBounds bounds={pesData.bounds} />
</Group>
)}
</Layer>
{/* Current position layer */}
<Layer>
{pesData &&
pesData.penStitches &&
sewingProgress &&
sewingProgress.currentStitch > 0 && (
<Group x={localPatternOffset.x} y={localPatternOffset.y}>
<CurrentPosition
currentStitchIndex={sewingProgress.currentStitch}
stitches={pesData.penStitches.stitches.map(
(s, i): [number, number, number, number] => {
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];
},
)}
/>
</Group>
)}
</Layer>
</Stage>
)}
{/* Placeholder overlay when no pattern is loaded */}
{!pesData && (
<div className="flex items-center justify-center h-full text-gray-600 dark:text-gray-400 italic">
Load a PES file to preview the pattern
</div>
)}
{/* Pattern info overlays */}
{pesData && (
<>
{/* Thread Legend Overlay */}
<div className="absolute top-2 sm:top-2.5 left-2 sm:left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2 sm:p-2.5 rounded-lg shadow-lg z-10 max-w-[150px] sm:max-w-[180px] lg:max-w-[200px]">
<h4 className="m-0 mb-1.5 sm:mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1 sm:pb-1.5">
Colors
</h4>
{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 (
<div
key={idx}
className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0"
>
<div
className="w-3 h-3 sm:w-4 sm:h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5"
style={{ backgroundColor: color.hex }}
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-gray-900 dark:text-gray-100">
Color {idx + 1}
</div>
{(primaryMetadata || secondaryMetadata) && (
<div className="text-xs text-gray-600 dark:text-gray-400 leading-tight mt-0.5 break-words">
{primaryMetadata}
{primaryMetadata && secondaryMetadata && (
<span className="mx-1"></span>
)}
{secondaryMetadata}
</div>
)}
</div>
</div>
);
})}
</div>
{/* Pattern Offset Indicator */}
<div
className={`absolute bottom-16 sm:bottom-20 right-2 sm:right-5 backdrop-blur-sm p-2 sm:p-2.5 px-2.5 sm:px-3.5 rounded-lg shadow-lg z-[11] min-w-[160px] sm:min-w-[180px] transition-colors ${
patternUploaded
? "bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600"
: "bg-white/95 dark:bg-gray-800/95"
}`}
>
<div className="flex items-center justify-between mb-1">
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
Pattern Position:
</div>
{patternUploaded && (
<div className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
<LockClosedIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs font-bold">LOCKED</span>
</div>
)}
</div>
<div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y:{" "}
{(localPatternOffset.y / 10).toFixed(1)}mm
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 italic">
{patternUploaded
? "Pattern locked • Drag background to pan"
: "Drag pattern to move • Drag background to pan"}
</div>
</div>
{/* Zoom Controls Overlay */}
<div className="absolute bottom-2 sm:bottom-5 right-2 sm:right-5 flex gap-1.5 sm:gap-2 items-center bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg shadow-lg z-10">
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8"
onClick={handleCenterPattern}
disabled={!pesData || patternUploaded || isUploading}
title="Center Pattern in Hoop"
>
<ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8"
onClick={handleZoomIn}
title="Zoom In"
>
<PlusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
<span className="min-w-[40px] sm:min-w-[50px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 select-none">
{Math.round(stageScale * 100)}%
</span>
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8"
onClick={handleZoomOut}
title="Zoom Out"
>
<MinusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8 ml-1"
onClick={handleZoomReset}
title="Reset Zoom"
>
<ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
</div>
</>
)}
</div>
</CardContent>
</Card>
);
}