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 Konva from "konva";
|
||||
import type { KonvaEventObject } from "konva/lib/Node";
|
||||
import {
|
||||
PlusIcon,
|
||||
MinusIcon,
|
||||
ArrowPathIcon,
|
||||
LockClosedIcon,
|
||||
PhotoIcon,
|
||||
ArrowsPointingInIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { PhotoIcon } from "@heroicons/react/24/solid";
|
||||
import type { PesPatternData } from "../../formats/import/pesImporter";
|
||||
import { calculateInitialScale } from "../../utils/konvaRenderers";
|
||||
import {
|
||||
|
|
@ -33,12 +26,14 @@ import {
|
|||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
calculatePatternCenter,
|
||||
convertPenStitchesToPesFormat,
|
||||
calculateZoomToPoint,
|
||||
} from "./patternCanvasHelpers";
|
||||
import { ThreadLegend } from "./ThreadLegend";
|
||||
import { PatternPositionIndicator } from "./PatternPositionIndicator";
|
||||
import { ZoomControls } from "./ZoomControls";
|
||||
|
||||
export function PatternCanvas() {
|
||||
// Machine store
|
||||
|
|
@ -579,156 +574,29 @@ export function PatternCanvas() {
|
|||
return (
|
||||
displayPattern && (
|
||||
<>
|
||||
{/* 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>
|
||||
{displayPattern.uniqueColors.map((color, idx) => {
|
||||
// Primary metadata: brand and catalog number
|
||||
const primaryMetadata = [
|
||||
color.brand,
|
||||
color.catalogNumber
|
||||
? `#${color.catalogNumber}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
<ThreadLegend colors={displayPattern.uniqueColors} />
|
||||
|
||||
// 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>
|
||||
|
||||
{/* 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 ${
|
||||
<PatternPositionIndicator
|
||||
offset={
|
||||
isUploading || 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>
|
||||
{(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>
|
||||
? initialUploadedPatternOffset
|
||||
: localPatternOffset
|
||||
}
|
||||
rotation={localPatternRotation}
|
||||
isLocked={patternUploaded}
|
||||
isUploading={isUploading}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<ZoomControls
|
||||
scale={stageScale}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
onZoomReset={handleZoomReset}
|
||||
onCenterPattern={handleCenterPattern}
|
||||
canCenterPattern={
|
||||
!!pesData && !patternUploaded && !isUploading
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
|
|
|
|||
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