mirror of
https://github.com/jhbruhn/respira.git
synced 2026-04-27 17:45:45 +00:00
feature: Add pattern positioning presets and arrow key movement
Add a 3x3 alignment grid to position the pattern at corners, edge centers, or center of the hoop. Support arrow key movement (1mm per press, 0.1mm with Shift held). Remove redundant center button from zoom controls. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9177dc9273
commit
2dedfc5513
5 changed files with 257 additions and 33 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import { useRef, useMemo, useState } from "react";
|
import { useRef, useMemo, useState, useCallback } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import {
|
import {
|
||||||
useMachineStore,
|
useMachineStore,
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { ThreadLegend } from "./ThreadLegend";
|
import { ThreadLegend } from "./ThreadLegend";
|
||||||
import { PatternPositionIndicator } from "./PatternPositionIndicator";
|
import { PatternPositionIndicator } from "./PatternPositionIndicator";
|
||||||
|
import { PositionPresets } from "./PositionPresets";
|
||||||
import { ZoomControls } from "./ZoomControls";
|
import { ZoomControls } from "./ZoomControls";
|
||||||
import { PatternLayer } from "./PatternLayer";
|
import { PatternLayer } from "./PatternLayer";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
@ -84,6 +85,14 @@ export function PatternCanvas() {
|
||||||
machineInfo,
|
machineInfo,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handler for position preset selection
|
||||||
|
const handlePositionPreset = useCallback(
|
||||||
|
(offset: { x: number; y: number }) => {
|
||||||
|
setPatternOffset(offset.x, offset.y);
|
||||||
|
},
|
||||||
|
[setPatternOffset],
|
||||||
|
);
|
||||||
|
|
||||||
// Pattern transform (position, rotation, drag/transform)
|
// Pattern transform (position, rotation, drag/transform)
|
||||||
const {
|
const {
|
||||||
localPatternOffset,
|
localPatternOffset,
|
||||||
|
|
@ -91,7 +100,6 @@ export function PatternCanvas() {
|
||||||
patternGroupRef,
|
patternGroupRef,
|
||||||
transformerRef,
|
transformerRef,
|
||||||
attachTransformer,
|
attachTransformer,
|
||||||
handleCenterPattern,
|
|
||||||
handlePatternDragEnd,
|
handlePatternDragEnd,
|
||||||
handleTransformEnd,
|
handleTransformEnd,
|
||||||
} = usePatternTransform({
|
} = usePatternTransform({
|
||||||
|
|
@ -264,6 +272,20 @@ export function PatternCanvas() {
|
||||||
<>
|
<>
|
||||||
<ThreadLegend colors={displayPattern.uniqueColors} />
|
<ThreadLegend colors={displayPattern.uniqueColors} />
|
||||||
|
|
||||||
|
{pesData &&
|
||||||
|
machineInfo &&
|
||||||
|
!patternUploaded &&
|
||||||
|
!isUploading &&
|
||||||
|
!uploadedPesData && (
|
||||||
|
<PositionPresets
|
||||||
|
pesData={pesData}
|
||||||
|
patternRotation={localPatternRotation}
|
||||||
|
machineInfo={machineInfo}
|
||||||
|
onPositionSelect={handlePositionPreset}
|
||||||
|
disabled={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<PatternPositionIndicator
|
<PatternPositionIndicator
|
||||||
offset={
|
offset={
|
||||||
isUploading || patternUploaded || uploadedPesData
|
isUploading || patternUploaded || uploadedPesData
|
||||||
|
|
@ -280,13 +302,6 @@ export function PatternCanvas() {
|
||||||
onZoomIn={handleZoomIn}
|
onZoomIn={handleZoomIn}
|
||||||
onZoomOut={handleZoomOut}
|
onZoomOut={handleZoomOut}
|
||||||
onZoomReset={handleZoomReset}
|
onZoomReset={handleZoomReset}
|
||||||
onCenterPattern={handleCenterPattern}
|
|
||||||
canCenterPattern={
|
|
||||||
!!pesData &&
|
|
||||||
!patternUploaded &&
|
|
||||||
!isUploading &&
|
|
||||||
!uploadedPesData
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
192
src/components/PatternCanvas/PositionPresets.tsx
Normal file
192
src/components/PatternCanvas/PositionPresets.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
/**
|
||||||
|
* PositionPresets Component
|
||||||
|
*
|
||||||
|
* Provides a 3x3 grid of buttons to quickly position the pattern
|
||||||
|
* at predefined locations within the hoop (corners, edge centers, and center)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MachineInfo } from "../../types/machine";
|
||||||
|
import type { PesPatternData } from "../../formats/import/pesImporter";
|
||||||
|
import { calculatePatternCenter } from "./patternCanvasHelpers";
|
||||||
|
import { calculateRotatedBounds } from "../../utils/rotationUtils";
|
||||||
|
|
||||||
|
export type PositionPreset =
|
||||||
|
| "top-left"
|
||||||
|
| "top-center"
|
||||||
|
| "top-right"
|
||||||
|
| "center-left"
|
||||||
|
| "center"
|
||||||
|
| "center-right"
|
||||||
|
| "bottom-left"
|
||||||
|
| "bottom-center"
|
||||||
|
| "bottom-right";
|
||||||
|
|
||||||
|
interface PositionPresetsProps {
|
||||||
|
pesData: PesPatternData;
|
||||||
|
patternRotation: number;
|
||||||
|
machineInfo: MachineInfo;
|
||||||
|
onPositionSelect: (offset: { x: number; y: number }) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the offset needed to position the pattern at a given preset location.
|
||||||
|
*
|
||||||
|
* The offset represents the pattern center's position in world coordinates.
|
||||||
|
* When offset is {0,0}, the pattern center is at the hoop center.
|
||||||
|
*/
|
||||||
|
function calculatePresetOffset(
|
||||||
|
preset: PositionPreset,
|
||||||
|
pesData: PesPatternData,
|
||||||
|
patternRotation: number,
|
||||||
|
machineInfo: MachineInfo,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
// Use rotated bounds if rotation is applied
|
||||||
|
const bounds =
|
||||||
|
patternRotation !== 0
|
||||||
|
? calculateRotatedBounds(pesData.bounds, patternRotation)
|
||||||
|
: pesData.bounds;
|
||||||
|
|
||||||
|
const center = calculatePatternCenter(bounds);
|
||||||
|
const hoopHalfW = machineInfo.maxWidth / 2;
|
||||||
|
const hoopHalfH = machineInfo.maxHeight / 2;
|
||||||
|
|
||||||
|
// Distance from pattern center to each edge
|
||||||
|
const leftHalf = center.x - bounds.minX;
|
||||||
|
const rightHalf = bounds.maxX - center.x;
|
||||||
|
const topHalf = center.y - bounds.minY;
|
||||||
|
const bottomHalf = bounds.maxY - center.y;
|
||||||
|
|
||||||
|
const xPositions = {
|
||||||
|
left: -hoopHalfW + leftHalf,
|
||||||
|
center: 0,
|
||||||
|
right: hoopHalfW - rightHalf,
|
||||||
|
};
|
||||||
|
|
||||||
|
const yPositions = {
|
||||||
|
top: -hoopHalfH + topHalf,
|
||||||
|
center: 0,
|
||||||
|
bottom: hoopHalfH - bottomHalf,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (preset) {
|
||||||
|
case "top-left":
|
||||||
|
return { x: xPositions.left, y: yPositions.top };
|
||||||
|
case "top-center":
|
||||||
|
return { x: xPositions.center, y: yPositions.top };
|
||||||
|
case "top-right":
|
||||||
|
return { x: xPositions.right, y: yPositions.top };
|
||||||
|
case "center-left":
|
||||||
|
return { x: xPositions.left, y: yPositions.center };
|
||||||
|
case "center":
|
||||||
|
return { x: xPositions.center, y: yPositions.center };
|
||||||
|
case "center-right":
|
||||||
|
return { x: xPositions.right, y: yPositions.center };
|
||||||
|
case "bottom-left":
|
||||||
|
return { x: xPositions.left, y: yPositions.bottom };
|
||||||
|
case "bottom-center":
|
||||||
|
return { x: xPositions.center, y: yPositions.bottom };
|
||||||
|
case "bottom-right":
|
||||||
|
return { x: xPositions.right, y: yPositions.bottom };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const presetGrid: { preset: PositionPreset; label: string }[][] = [
|
||||||
|
[
|
||||||
|
{ preset: "top-left", label: "Top Left" },
|
||||||
|
{ preset: "top-center", label: "Top Center" },
|
||||||
|
{ preset: "top-right", label: "Top Right" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ preset: "center-left", label: "Center Left" },
|
||||||
|
{ preset: "center", label: "Center" },
|
||||||
|
{ preset: "center-right", label: "Center Right" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ preset: "bottom-left", label: "Bottom Left" },
|
||||||
|
{ preset: "bottom-center", label: "Bottom Center" },
|
||||||
|
{ preset: "bottom-right", label: "Bottom Right" },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PositionPresets({
|
||||||
|
pesData,
|
||||||
|
patternRotation,
|
||||||
|
machineInfo,
|
||||||
|
onPositionSelect,
|
||||||
|
disabled,
|
||||||
|
}: PositionPresetsProps) {
|
||||||
|
const handleClick = (preset: PositionPreset) => {
|
||||||
|
const offset = calculatePresetOffset(
|
||||||
|
preset,
|
||||||
|
pesData,
|
||||||
|
patternRotation,
|
||||||
|
machineInfo,
|
||||||
|
);
|
||||||
|
onPositionSelect(offset);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-2 sm:top-2.5 right-2 sm:right-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">
|
||||||
|
<h4 className="m-0 mb-1.5 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1">
|
||||||
|
Align
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-3 gap-0.5">
|
||||||
|
{presetGrid.map((row, rowIdx) =>
|
||||||
|
row.map(({ preset, label }) => (
|
||||||
|
<button
|
||||||
|
key={preset}
|
||||||
|
onClick={() => handleClick(preset)}
|
||||||
|
disabled={disabled}
|
||||||
|
title={label}
|
||||||
|
className={`w-6 h-6 sm:w-7 sm:h-7 rounded border border-gray-300 dark:border-gray-600
|
||||||
|
flex items-center justify-center
|
||||||
|
hover:bg-primary-100 dark:hover:bg-primary-900 hover:border-primary-400 dark:hover:border-primary-500
|
||||||
|
disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent
|
||||||
|
transition-colors cursor-pointer`}
|
||||||
|
>
|
||||||
|
<DotIndicator row={rowIdx} preset={preset} />
|
||||||
|
</button>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual indicator showing the position within a rectangle.
|
||||||
|
* Renders a small rectangle outline with a dot at the corresponding position.
|
||||||
|
*/
|
||||||
|
function DotIndicator({
|
||||||
|
row,
|
||||||
|
preset,
|
||||||
|
}: {
|
||||||
|
row: number;
|
||||||
|
preset: PositionPreset;
|
||||||
|
}) {
|
||||||
|
// Map preset to dot position within a 12x12 viewBox
|
||||||
|
const dotX = preset.includes("left") ? 2 : preset.includes("right") ? 10 : 6;
|
||||||
|
const dotY = row === 0 ? 2 : row === 2 ? 10 : 6;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
className="text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="0.5"
|
||||||
|
y="0.5"
|
||||||
|
width="11"
|
||||||
|
height="11"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
rx="1"
|
||||||
|
/>
|
||||||
|
<circle cx={dotX} cy={dotY} r="1.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,15 +2,10 @@
|
||||||
* ZoomControls Component
|
* ZoomControls Component
|
||||||
*
|
*
|
||||||
* Provides zoom and pan controls for the pattern canvas
|
* Provides zoom and pan controls for the pattern canvas
|
||||||
* Includes zoom in/out, reset zoom, and center pattern buttons
|
* Includes zoom in/out and reset zoom buttons
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { PlusIcon, MinusIcon, ArrowPathIcon } from "@heroicons/react/24/solid";
|
||||||
PlusIcon,
|
|
||||||
MinusIcon,
|
|
||||||
ArrowPathIcon,
|
|
||||||
ArrowsPointingInIcon,
|
|
||||||
} from "@heroicons/react/24/solid";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface ZoomControlsProps {
|
interface ZoomControlsProps {
|
||||||
|
|
@ -18,8 +13,6 @@ interface ZoomControlsProps {
|
||||||
onZoomIn: () => void;
|
onZoomIn: () => void;
|
||||||
onZoomOut: () => void;
|
onZoomOut: () => void;
|
||||||
onZoomReset: () => void;
|
onZoomReset: () => void;
|
||||||
onCenterPattern: () => void;
|
|
||||||
canCenterPattern: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ZoomControls({
|
export function ZoomControls({
|
||||||
|
|
@ -27,21 +20,9 @@ export function ZoomControls({
|
||||||
onZoomIn,
|
onZoomIn,
|
||||||
onZoomOut,
|
onZoomOut,
|
||||||
onZoomReset,
|
onZoomReset,
|
||||||
onCenterPattern,
|
|
||||||
canCenterPattern,
|
|
||||||
}: ZoomControlsProps) {
|
}: ZoomControlsProps) {
|
||||||
return (
|
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">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,45 @@ export function usePatternTransform({
|
||||||
}
|
}
|
||||||
}, [localPatternRotation]);
|
}, [localPatternRotation]);
|
||||||
|
|
||||||
|
// Arrow key movement: 1mm per press, 0.1mm with Shift
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pesData || patternUploaded || isUploading) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const step = e.shiftKey ? 1 : 10; // 0.1mm or 1mm in 0.1mm units
|
||||||
|
let dx = 0;
|
||||||
|
let dy = 0;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowLeft":
|
||||||
|
dx = -step;
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
dx = step;
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
dy = -step;
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
dy = step;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
setLocalPatternOffset((prev) => {
|
||||||
|
const newOffset = { x: prev.x + dx, y: prev.y + dy };
|
||||||
|
setPatternOffset(newOffset.x, newOffset.y);
|
||||||
|
return newOffset;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [pesData, patternUploaded, isUploading, setPatternOffset]);
|
||||||
|
|
||||||
// Center pattern in hoop
|
// Center pattern in hoop
|
||||||
const handleCenterPattern = useCallback(() => {
|
const handleCenterPattern = useCallback(() => {
|
||||||
if (!pesData) return;
|
if (!pesData) return;
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,6 @@ export const usePatternStore = create<PatternState>((set) => ({
|
||||||
// Update pattern offset (for original pattern only)
|
// Update pattern offset (for original pattern only)
|
||||||
setPatternOffset: (x: number, y: number) => {
|
setPatternOffset: (x: number, y: number) => {
|
||||||
set({ patternOffset: { x, y } });
|
set({ patternOffset: { x, y } });
|
||||||
if (isDev) {
|
|
||||||
console.log("[PatternStore] Pattern offset changed:", { x, y });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Set pattern rotation (for original pattern only)
|
// Set pattern rotation (for original pattern only)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue