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 {
|
||||
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
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
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
|
||||
*
|
||||
* 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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue