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:
Jan-Henrik 2026-03-26 13:38:40 +01:00
parent 9177dc9273
commit 2dedfc5513
5 changed files with 257 additions and 33 deletions

View file

@ -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
}
/> />
</> </>
)} )}

View 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>
);
}

View file

@ -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"

View file

@ -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;

View file

@ -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)