mirror of
https://github.com/jhbruhn/respira.git
synced 2026-04-27 17:45:45 +00:00
Merge pull request #89 from jhbruhn/positioning-presets
feature: Add pattern positioning presets and arrow key movement
This commit is contained in:
commit
ea68fa5bba
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