mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 02:13:41 +00:00
feature: Extract PatternCanvas display components into reusable modules
Extract three complex inline components into separate files: - ThreadLegend: Thread color display with metadata (52 lines extracted) - PatternPositionIndicator: Position/rotation display with locked state (49 lines extracted) - ZoomControls: Zoom and pan control buttons (41 lines extracted) Benefits: - Reduced PatternCanvas.tsx from 730 to 608 lines (-122 lines) - Cleaner component separation and reusability - Better testability for individual UI components - Removed unused icon imports (PlusIcon, MinusIcon, etc.) - Single responsibility per component Total refactoring impact (Phase 1+2): - Before: 786 lines in single file - After: 608 lines main + 3 focused components - Reduction: -178 lines of complex inline code 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
07275fa75a
commit
b008fd3aa8
4 changed files with 236 additions and 156 deletions
|
|
@ -8,14 +8,7 @@ import { usePatternStore } from "../../stores/usePatternStore";
|
||||||
import { Stage, Layer, Group, Transformer } from "react-konva";
|
import { Stage, Layer, Group, Transformer } from "react-konva";
|
||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import type { KonvaEventObject } from "konva/lib/Node";
|
import type { KonvaEventObject } from "konva/lib/Node";
|
||||||
import {
|
import { PhotoIcon } from "@heroicons/react/24/solid";
|
||||||
PlusIcon,
|
|
||||||
MinusIcon,
|
|
||||||
ArrowPathIcon,
|
|
||||||
LockClosedIcon,
|
|
||||||
PhotoIcon,
|
|
||||||
ArrowsPointingInIcon,
|
|
||||||
} from "@heroicons/react/24/solid";
|
|
||||||
import type { PesPatternData } from "../../formats/import/pesImporter";
|
import type { PesPatternData } from "../../formats/import/pesImporter";
|
||||||
import { calculateInitialScale } from "../../utils/konvaRenderers";
|
import { calculateInitialScale } from "../../utils/konvaRenderers";
|
||||||
import {
|
import {
|
||||||
|
|
@ -33,12 +26,14 @@ import {
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardContent,
|
CardContent,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
calculatePatternCenter,
|
calculatePatternCenter,
|
||||||
convertPenStitchesToPesFormat,
|
convertPenStitchesToPesFormat,
|
||||||
calculateZoomToPoint,
|
calculateZoomToPoint,
|
||||||
} from "./patternCanvasHelpers";
|
} from "./patternCanvasHelpers";
|
||||||
|
import { ThreadLegend } from "./ThreadLegend";
|
||||||
|
import { PatternPositionIndicator } from "./PatternPositionIndicator";
|
||||||
|
import { ZoomControls } from "./ZoomControls";
|
||||||
|
|
||||||
export function PatternCanvas() {
|
export function PatternCanvas() {
|
||||||
// Machine store
|
// Machine store
|
||||||
|
|
@ -579,156 +574,29 @@ export function PatternCanvas() {
|
||||||
return (
|
return (
|
||||||
displayPattern && (
|
displayPattern && (
|
||||||
<>
|
<>
|
||||||
{/* Thread Legend Overlay */}
|
<ThreadLegend colors={displayPattern.uniqueColors} />
|
||||||
<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>
|
|
||||||
{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
|
<PatternPositionIndicator
|
||||||
// Only show chart if it's different from catalogNumber
|
offset={
|
||||||
const secondaryMetadata = [
|
|
||||||
color.chart && color.chart !== color.catalogNumber
|
|
||||||
? color.chart
|
|
||||||
: null,
|
|
||||||
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 ${
|
|
||||||
isUploading || patternUploaded
|
isUploading || patternUploaded
|
||||||
? "bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600"
|
? initialUploadedPatternOffset
|
||||||
: "bg-white/95 dark:bg-gray-800/95"
|
: localPatternOffset
|
||||||
}`}
|
}
|
||||||
>
|
rotation={localPatternRotation}
|
||||||
<div className="flex items-center justify-between mb-1">
|
isLocked={patternUploaded}
|
||||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
isUploading={isUploading}
|
||||||
Pattern Position:
|
/>
|
||||||
</div>
|
|
||||||
{(isUploading || 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">
|
|
||||||
{isUploading ? "UPLOADING" : "LOCKED"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
|
|
||||||
{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
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!isUploading &&
|
|
||||||
!patternUploaded &&
|
|
||||||
localPatternRotation !== 0 && (
|
|
||||||
<div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
|
|
||||||
Rotation: {localPatternRotation.toFixed(1)}°
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400 italic">
|
|
||||||
{isUploading
|
|
||||||
? "Uploading pattern..."
|
|
||||||
: patternUploaded
|
|
||||||
? "Pattern locked • Drag background to pan"
|
|
||||||
: "Drag pattern to move • Drag background to pan"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Zoom Controls Overlay */}
|
<ZoomControls
|
||||||
<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">
|
scale={stageScale}
|
||||||
<Button
|
onZoomIn={handleZoomIn}
|
||||||
variant="outline"
|
onZoomOut={handleZoomOut}
|
||||||
size="icon"
|
onZoomReset={handleZoomReset}
|
||||||
className="w-7 h-7 sm:w-8 sm:h-8"
|
onCenterPattern={handleCenterPattern}
|
||||||
onClick={handleCenterPattern}
|
canCenterPattern={
|
||||||
disabled={!pesData || patternUploaded || isUploading}
|
!!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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
61
src/components/PatternCanvas/PatternPositionIndicator.tsx
Normal file
61
src/components/PatternCanvas/PatternPositionIndicator.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
/**
|
||||||
|
* PatternPositionIndicator Component
|
||||||
|
*
|
||||||
|
* Displays the current pattern position and rotation
|
||||||
|
* Shows locked state when pattern is uploaded or being uploaded
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { LockClosedIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
|
interface PatternPositionIndicatorProps {
|
||||||
|
offset: { x: number; y: number };
|
||||||
|
rotation?: number;
|
||||||
|
isLocked: boolean;
|
||||||
|
isUploading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PatternPositionIndicator({
|
||||||
|
offset,
|
||||||
|
rotation = 0,
|
||||||
|
isLocked,
|
||||||
|
isUploading,
|
||||||
|
}: PatternPositionIndicatorProps) {
|
||||||
|
return (
|
||||||
|
<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 ${
|
||||||
|
isUploading || isLocked
|
||||||
|
? "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>
|
||||||
|
{(isUploading || isLocked) && (
|
||||||
|
<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">
|
||||||
|
{isUploading ? "UPLOADING" : "LOCKED"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
|
||||||
|
X: {(offset.x / 10).toFixed(1)}mm, Y: {(offset.y / 10).toFixed(1)}mm
|
||||||
|
</div>
|
||||||
|
{!isUploading && !isLocked && rotation !== 0 && (
|
||||||
|
<div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
|
||||||
|
Rotation: {rotation.toFixed(1)}°
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 italic">
|
||||||
|
{isUploading
|
||||||
|
? "Uploading pattern..."
|
||||||
|
: isLocked
|
||||||
|
? "Pattern locked • Drag background to pan"
|
||||||
|
: "Drag pattern to move • Drag background to pan"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/components/PatternCanvas/ThreadLegend.tsx
Normal file
74
src/components/PatternCanvas/ThreadLegend.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
/**
|
||||||
|
* ThreadLegend Component
|
||||||
|
*
|
||||||
|
* Displays a legend of thread colors used in the embroidery pattern
|
||||||
|
* Shows color swatches with brand, catalog number, and description metadata
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ThreadColor {
|
||||||
|
hex: string;
|
||||||
|
brand?: string | null;
|
||||||
|
catalogNumber?: string | null;
|
||||||
|
chart?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThreadLegendProps {
|
||||||
|
colors: ThreadColor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThreadLegend({ colors }: ThreadLegendProps) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
{colors.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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/components/PatternCanvas/ZoomControls.tsx
Normal file
77
src/components/PatternCanvas/ZoomControls.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
/**
|
||||||
|
* ZoomControls Component
|
||||||
|
*
|
||||||
|
* Provides zoom and pan controls for the pattern canvas
|
||||||
|
* Includes zoom in/out, reset zoom, and center pattern buttons
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
PlusIcon,
|
||||||
|
MinusIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
ArrowsPointingInIcon,
|
||||||
|
} from "@heroicons/react/24/solid";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface ZoomControlsProps {
|
||||||
|
scale: number;
|
||||||
|
onZoomIn: () => void;
|
||||||
|
onZoomOut: () => void;
|
||||||
|
onZoomReset: () => void;
|
||||||
|
onCenterPattern: () => void;
|
||||||
|
canCenterPattern: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ZoomControls({
|
||||||
|
scale,
|
||||||
|
onZoomIn,
|
||||||
|
onZoomOut,
|
||||||
|
onZoomReset,
|
||||||
|
onCenterPattern,
|
||||||
|
canCenterPattern,
|
||||||
|
}: ZoomControlsProps) {
|
||||||
|
return (
|
||||||
|
<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={onCenterPattern}
|
||||||
|
disabled={!canCenterPattern}
|
||||||
|
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={onZoomIn}
|
||||||
|
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(scale * 100)}%
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="w-7 h-7 sm:w-8 sm:h-8"
|
||||||
|
onClick={onZoomOut}
|
||||||
|
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={onZoomReset}
|
||||||
|
title="Reset Zoom"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue