Merge pull request #89 from jhbruhn/positioning-presets

feature: Add pattern positioning presets and arrow key movement
This commit is contained in:
Jan-Henrik Bruhn 2026-03-26 13:40:28 +01:00 committed by GitHub
commit ea68fa5bba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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 {
useMachineStore,
@ -19,6 +19,7 @@ import {
} from "@/components/ui/card";
import { ThreadLegend } from "./ThreadLegend";
import { PatternPositionIndicator } from "./PatternPositionIndicator";
import { PositionPresets } from "./PositionPresets";
import { ZoomControls } from "./ZoomControls";
import { PatternLayer } from "./PatternLayer";
import { Switch } from "@/components/ui/switch";
@ -84,6 +85,14 @@ export function PatternCanvas() {
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)
const {
localPatternOffset,
@ -91,7 +100,6 @@ export function PatternCanvas() {
patternGroupRef,
transformerRef,
attachTransformer,
handleCenterPattern,
handlePatternDragEnd,
handleTransformEnd,
} = usePatternTransform({
@ -264,6 +272,20 @@ export function PatternCanvas() {
<>
<ThreadLegend colors={displayPattern.uniqueColors} />
{pesData &&
machineInfo &&
!patternUploaded &&
!isUploading &&
!uploadedPesData && (
<PositionPresets
pesData={pesData}
patternRotation={localPatternRotation}
machineInfo={machineInfo}
onPositionSelect={handlePositionPreset}
disabled={false}
/>
)}
<PatternPositionIndicator
offset={
isUploading || patternUploaded || uploadedPesData
@ -280,13 +302,6 @@ export function PatternCanvas() {
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
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
*
* 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 {
PlusIcon,
MinusIcon,
ArrowPathIcon,
ArrowsPointingInIcon,
} from "@heroicons/react/24/solid";
import { PlusIcon, MinusIcon, ArrowPathIcon } from "@heroicons/react/24/solid";
import { Button } from "@/components/ui/button";
interface ZoomControlsProps {
@ -18,8 +13,6 @@ interface ZoomControlsProps {
onZoomIn: () => void;
onZoomOut: () => void;
onZoomReset: () => void;
onCenterPattern: () => void;
canCenterPattern: boolean;
}
export function ZoomControls({
@ -27,21 +20,9 @@ export function ZoomControls({
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"

View file

@ -101,6 +101,45 @@ export function usePatternTransform({
}
}, [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
const handleCenterPattern = useCallback(() => {
if (!pesData) return;

View file

@ -82,9 +82,6 @@ export const usePatternStore = create<PatternState>((set) => ({
// Update pattern offset (for original pattern only)
setPatternOffset: (x: number, y: number) => {
set({ patternOffset: { x, y } });
if (isDev) {
console.log("[PatternStore] Pattern offset changed:", { x, y });
}
},
// Set pattern rotation (for original pattern only)