Compare commits

...

16 commits

Author SHA1 Message Date
Jan-Henrik Bruhn
212d21e065
Merge pull request #63 from jhbruhn/copilot/move-helper-functions-to-utils
Some checks are pending
Build, Test, and Lint / Build, Test, and Lint (push) Waiting to run
Draft Release / Draft Release (push) Waiting to run
Draft Release / Build Web App (push) Blocked by required conditions
Draft Release / Build Release - macos-latest (push) Blocked by required conditions
Draft Release / Build Release - ubuntu-latest (push) Blocked by required conditions
Draft Release / Build Release - windows-latest (push) Blocked by required conditions
Draft Release / Upload to GitHub Release (push) Blocked by required conditions
Verify helper function refactoring completion - no changes needed
2025-12-28 09:25:52 +01:00
copilot-swe-agent[bot]
7d8af87660 Initial plan 2025-12-28 08:20:38 +00:00
Jan-Henrik Bruhn
656f501a92
Merge pull request #61 from jhbruhn/copilot/add-computed-values-selectors
Add computed selectors to pattern store for memoized transformations
2025-12-28 09:19:40 +01:00
copilot-swe-agent[bot]
5296590a45 fix: Revert pattern validation to local hook to prevent infinite re-renders
The selectPatternValidation selector doesn't work well with Zustand because:
1. It requires machineInfo parameter from a different store
2. Passing machineInfo creates new object references on every render
3. This breaks Zustand's memoization and causes infinite loops

Solution:
- Reverted usePatternValidation.ts to original implementation with useMemo
- Removed selectPatternValidation and usePatternValidationFromStore
- Removed PatternValidationResult type (not needed)
- Updated tests to remove validation selector tests (12 tests remain)

The store selectors (selectPatternCenter, selectRotatedBounds, etc) are
still useful for components that only need those specific values, but
validation logic that depends on external state should stay local.

Co-authored-by: jhbruhn <1036566+jhbruhn@users.noreply.github.com>
2025-12-28 08:17:20 +00:00
copilot-swe-agent[bot]
bcb5ea1786 fix: Use shallow comparison in selector hooks to prevent infinite re-renders
The selectors were creating new object references on every call, causing
Zustand's subscription system to detect changes and trigger infinite
re-render loops. This was particularly evident in usePatternValidationFromStore.

Solution:
- Import useShallow from zustand/react/shallow
- Wrap all selector hooks (usePatternCenter, useUploadedPatternCenter,
  useRotatedBounds, usePatternValidationFromStore) with useShallow
- useShallow performs shallow comparison on returned objects, preventing
  re-renders when values haven't actually changed

This follows the established pattern in the codebase where useShallow is
already used extensively (App.tsx, FileUpload.tsx, etc).

Co-authored-by: jhbruhn <1036566+jhbruhn@users.noreply.github.com>
2025-12-28 08:10:40 +00:00
Jan-Henrik Bruhn
2a5fbb2232
Merge pull request #62 from jhbruhn/copilot/create-shared-infocard-component
Create shared InfoCard component for error/warning/success states
2025-12-28 09:01:26 +01:00
Jan-Henrik Bruhn
7c3f79ae7e fix: TypeScript error in InfoCard icon variant check
Fix TypeScript build error "Type 'null' cannot be used as an index type"
by adding explicit null check for variant before indexing defaultIcons.

The variant from VariantProps can be null even with a default value,
so TypeScript requires an explicit check.

Changes:
- Added null check: showDefaultIcon && variant
- Added 'as const' to defaultIcons for better type inference

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-28 09:00:03 +01:00
copilot-swe-agent[bot]
edb8fa9264 fix: Move InfoCard to components folder and remove tests
Co-authored-by: jhbruhn <1036566+jhbruhn@users.noreply.github.com>
2025-12-27 17:07:23 +00:00
copilot-swe-agent[bot]
df71f74396 fix: Address code review feedback for InfoCard component
Co-authored-by: jhbruhn <1036566+jhbruhn@users.noreply.github.com>
2025-12-27 17:01:20 +00:00
copilot-swe-agent[bot]
6fbb3ebf1a fix: Add missing bounds field to mock PenStitches in tests
Co-authored-by: jhbruhn <1036566+jhbruhn@users.noreply.github.com>
2025-12-27 17:00:05 +00:00
copilot-swe-agent[bot]
fb94591f78 feature: Document consistent use of pattern helpers across codebase
- Add documentation to usePatternRotationUpload about using same helpers as store
- Add documentation to App.tsx pattern resume logic
- Mark PatternLayer to continue using local memoization (receives props)
- All tests passing

Co-authored-by: jhbruhn <1036566+jhbruhn@users.noreply.github.com>
2025-12-27 16:58:52 +00:00
copilot-swe-agent[bot]
b2f0455d4c feature: Refactor ErrorPopover and StepPopover to use InfoCard
Co-authored-by: jhbruhn <1036566+jhbruhn@users.noreply.github.com>
2025-12-27 16:58:19 +00:00
copilot-swe-agent[bot]
1d79ffb2a4 feature: Add computed selectors to usePatternStore for pattern transformations
- Add selectPatternCenter for pattern center calculation
- Add selectRotatedBounds for rotated bounds with memoization
- Add selectRotationCenterShift for center shift calculation
- Add selectPatternValidation for bounds checking against hoop
- Add comprehensive tests for all selectors
- Update usePatternValidation to use store selectors
- All tests passing and linter clean

Co-authored-by: jhbruhn <1036566+jhbruhn@users.noreply.github.com>
2025-12-27 16:56:54 +00:00
copilot-swe-agent[bot]
a828bf4c8f feature: Add InfoCard shared component with tests
Co-authored-by: jhbruhn <1036566+jhbruhn@users.noreply.github.com>
2025-12-27 16:55:24 +00:00
copilot-swe-agent[bot]
2114bacdae Initial plan 2025-12-27 16:49:39 +00:00
copilot-swe-agent[bot]
20fff4cdfb Initial plan 2025-12-27 16:47:35 +00:00
8 changed files with 593 additions and 156 deletions

View file

@ -112,8 +112,9 @@ function App() {
// Use cached uploadedPesData if available, otherwise recalculate
if (cachedUploadedPesData) {
// Use the exact uploaded data from cache
// Calculate the adjusted offset (same logic as upload)
// Calculate the adjusted offset (same logic as upload hook)
if (rotation !== 0) {
// Calculate center shift using the same helper as store selectors
const originalCenter = calculatePatternCenter(originalPesData.bounds);
const rotatedCenter = calculatePatternCenter(
cachedUploadedPesData.bounds,
@ -140,6 +141,7 @@ function App() {
}
} else if (rotation !== 0) {
// Fallback: recalculate if no cached uploaded data (shouldn't happen for new uploads)
// This uses the same transformation logic as usePatternRotationUpload hook
console.warn("[App] No cached uploaded data, recalculating rotation");
const rotatedStitches = transformStitchesRotation(
originalPesData.stitches,
@ -152,6 +154,7 @@ function App() {
const decoded = decodePenData(penData);
const rotatedBounds = calculateBoundsFromDecodedStitches(decoded);
// Calculate center shift using the same helper as store selectors
const originalCenter = calculatePatternCenter(originalPesData.bounds);
const rotatedCenter = calculatePatternCenter(rotatedBounds);
const centerShiftX = rotatedCenter.x - originalCenter.x;

View file

@ -1,10 +1,13 @@
import {
ExclamationTriangleIcon,
InformationCircleIcon,
} from "@heroicons/react/24/solid";
import { getErrorDetails } from "../utils/errorCodeHelpers";
import { PopoverContent } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import {
InfoCard,
InfoCardTitle,
InfoCardDescription,
InfoCardList,
InfoCardListItem,
} from "./InfoCard";
interface ErrorPopoverContentProps {
machineError?: number;
@ -24,71 +27,50 @@ export function ErrorPopoverContent({
const errorMsg = pyodideError || errorMessage || "";
const isInfo = isPairingErr || errorDetails?.isInformational;
const bgColor = isInfo
? "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500"
: "bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500";
const iconColor = isInfo
? "text-info-600 dark:text-info-400"
: "text-danger-600 dark:text-danger-400";
const textColor = isInfo
? "text-info-900 dark:text-info-200"
: "text-danger-900 dark:text-danger-200";
const descColor = isInfo
? "text-info-800 dark:text-info-300"
: "text-danger-800 dark:text-danger-300";
const listColor = isInfo
? "text-info-700 dark:text-info-300"
: "text-danger-700 dark:text-danger-300";
const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
const variant = isInfo ? "info" : "error";
const title =
errorDetails?.title || (isPairingErr ? "Pairing Required" : "Error");
return (
<PopoverContent
className={cn("w-[600px] border-l-4 p-4 backdrop-blur-sm", bgColor)}
align="start"
>
<div className="flex items-start gap-3">
<Icon className={cn("w-6 h-6 flex-shrink-0 mt-0.5", iconColor)} />
<div className="flex-1">
<h3 className={cn("text-base font-semibold mb-2", textColor)}>
{title}
</h3>
<p className={cn("text-sm mb-3", descColor)}>
{errorDetails?.description || errorMsg}
<PopoverContent className="w-[600px] p-0" align="start">
<InfoCard variant={variant} className="rounded-lg shadow-none border-0">
<InfoCardTitle variant={variant}>{title}</InfoCardTitle>
<InfoCardDescription variant={variant}>
{errorDetails?.description || errorMsg}
</InfoCardDescription>
{errorDetails?.solutions && errorDetails.solutions.length > 0 && (
<>
<h4
className={cn(
"text-sm font-semibold mb-2",
variant === "info"
? "text-info-900 dark:text-info-200"
: "text-danger-900 dark:text-danger-200",
)}
>
{isInfo ? "Steps:" : "How to Fix:"}
</h4>
<InfoCardList variant={variant} ordered>
{errorDetails.solutions.map((solution, index) => (
<InfoCardListItem key={index}>{solution}</InfoCardListItem>
))}
</InfoCardList>
</>
)}
{machineError !== undefined && !errorDetails?.isInformational && (
<p
className={cn(
"text-xs mt-3 font-mono",
variant === "info"
? "text-info-800 dark:text-info-300"
: "text-danger-800 dark:text-danger-300",
)}
>
Error Code: 0x
{machineError.toString(16).toUpperCase().padStart(2, "0")}
</p>
{errorDetails?.solutions && errorDetails.solutions.length > 0 && (
<>
<h4 className={cn("text-sm font-semibold mb-2", textColor)}>
{isInfo ? "Steps:" : "How to Fix:"}
</h4>
<ol
className={cn(
"list-decimal list-inside text-sm space-y-1.5",
listColor,
)}
>
{errorDetails.solutions.map((solution, index) => (
<li key={index} className="pl-2">
{solution}
</li>
))}
</ol>
</>
)}
{machineError !== undefined && !errorDetails?.isInformational && (
<p className={cn("text-xs mt-3 font-mono", descColor)}>
Error Code: 0x
{machineError.toString(16).toUpperCase().padStart(2, "0")}
</p>
)}
</div>
</div>
)}
</InfoCard>
</PopoverContent>
);
}

191
src/components/InfoCard.tsx Normal file
View file

@ -0,0 +1,191 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import {
InformationCircleIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
} from "@heroicons/react/24/solid";
import { cn } from "@/lib/utils";
const infoCardVariants = cva("border-l-4 p-4 rounded-lg backdrop-blur-sm", {
variants: {
variant: {
info: "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
warning:
"bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500",
error:
"bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500",
success:
"bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500",
},
},
defaultVariants: {
variant: "info",
},
});
const iconColorVariants = cva("w-6 h-6 flex-shrink-0 mt-0.5", {
variants: {
variant: {
info: "text-info-600 dark:text-info-400",
warning: "text-warning-600 dark:text-warning-400",
error: "text-danger-600 dark:text-danger-400",
success: "text-success-600 dark:text-success-400",
},
},
defaultVariants: {
variant: "info",
},
});
const titleColorVariants = cva("text-base font-semibold mb-2", {
variants: {
variant: {
info: "text-info-900 dark:text-info-200",
warning: "text-warning-900 dark:text-warning-200",
error: "text-danger-900 dark:text-danger-200",
success: "text-success-900 dark:text-success-200",
},
},
defaultVariants: {
variant: "info",
},
});
const descriptionColorVariants = cva("text-sm mb-3", {
variants: {
variant: {
info: "text-info-800 dark:text-info-300",
warning: "text-warning-800 dark:text-warning-300",
error: "text-danger-800 dark:text-danger-300",
success: "text-success-800 dark:text-success-300",
},
},
defaultVariants: {
variant: "info",
},
});
const listColorVariants = cva("text-sm", {
variants: {
variant: {
info: "text-info-700 dark:text-info-300",
warning: "text-warning-700 dark:text-warning-300",
error: "text-danger-700 dark:text-danger-300",
success: "text-success-700 dark:text-success-300",
},
},
defaultVariants: {
variant: "info",
},
});
interface InfoCardProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof infoCardVariants> {
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
showDefaultIcon?: boolean;
}
function InfoCard({
className,
variant = "info",
icon: CustomIcon,
showDefaultIcon = true,
children,
...props
}: InfoCardProps) {
// Default icons based on variant
const defaultIcons = {
info: InformationCircleIcon,
warning: ExclamationTriangleIcon,
error: ExclamationTriangleIcon,
success: CheckCircleIcon,
} as const;
const Icon =
CustomIcon || (showDefaultIcon && variant ? defaultIcons[variant] : null);
return (
<div className={cn(infoCardVariants({ variant }), className)} {...props}>
<div className="flex items-start gap-3">
{Icon && <Icon className={iconColorVariants({ variant })} />}
<div className="flex-1">{children}</div>
</div>
</div>
);
}
function InfoCardTitle({
className,
variant = "info",
...props
}: React.HTMLAttributes<HTMLHeadingElement> &
VariantProps<typeof titleColorVariants>) {
return (
<h3 className={cn(titleColorVariants({ variant }), className)} {...props} />
);
}
function InfoCardDescription({
className,
variant = "info",
...props
}: React.HTMLAttributes<HTMLParagraphElement> &
VariantProps<typeof descriptionColorVariants>) {
return (
<p
className={cn(descriptionColorVariants({ variant }), className)}
{...props}
/>
);
}
interface InfoCardListProps
extends
React.HTMLAttributes<HTMLOListElement | HTMLUListElement>,
VariantProps<typeof listColorVariants> {
ordered?: boolean;
}
function InfoCardList({
className,
variant = "info",
ordered = false,
children,
...props
}: InfoCardListProps) {
const ListComponent = ordered ? "ol" : "ul";
const listClass = ordered ? "list-decimal" : "list-disc";
return (
<ListComponent
className={cn(
listClass,
"list-inside space-y-1.5",
listColorVariants({ variant }),
className,
)}
{...props}
>
{children}
</ListComponent>
);
}
function InfoCardListItem({
className,
...props
}: React.HTMLAttributes<HTMLLIElement>) {
return <li className={cn("pl-2", className)} {...props} />;
}
export {
InfoCard,
InfoCardTitle,
InfoCardDescription,
InfoCardList,
InfoCardListItem,
};

View file

@ -43,6 +43,9 @@ export const PatternLayer = memo(function PatternLayer({
onTransformEnd,
attachTransformer,
}: PatternLayerProps) {
// Memoize center calculation - this is the pattern center calculation
// Note: We keep this local calculation here since the component receives
// pesData as a prop which could be either original or uploaded pattern
const center = useMemo(
() => calculatePatternCenter(pesData.bounds),
[pesData.bounds],

View file

@ -5,12 +5,15 @@
*/
import { forwardRef } from "react";
import {
InformationCircleIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/solid";
import { MachineStatus } from "../../types/machine";
import { getGuideContent } from "../../utils/workflowGuideContent";
import {
InfoCard,
InfoCardTitle,
InfoCardDescription,
InfoCardList,
InfoCardListItem,
} from "../InfoCard";
export interface StepPopoverProps {
stepId: number;
@ -22,54 +25,16 @@ export const StepPopover = forwardRef<HTMLDivElement, StepPopoverProps>(
const content = getGuideContent(stepId, machineStatus);
if (!content) return null;
const colorClasses = {
info: "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
success:
"bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500",
warning:
"bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500",
error:
"bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500",
progress:
"bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
};
// Map content type to InfoCard variant
const variantMap = {
info: "info",
success: "success",
warning: "warning",
error: "error",
progress: "info",
} as const;
const iconColorClasses = {
info: "text-info-600 dark:text-info-400",
success: "text-success-600 dark:text-success-400",
warning: "text-warning-600 dark:text-warning-400",
error: "text-danger-600 dark:text-danger-400",
progress: "text-info-600 dark:text-info-400",
};
const textColorClasses = {
info: "text-info-900 dark:text-info-200",
success: "text-success-900 dark:text-success-200",
warning: "text-warning-900 dark:text-warning-200",
error: "text-danger-900 dark:text-danger-200",
progress: "text-info-900 dark:text-info-200",
};
const descColorClasses = {
info: "text-info-800 dark:text-info-300",
success: "text-success-800 dark:text-success-300",
warning: "text-warning-800 dark:text-warning-300",
error: "text-danger-800 dark:text-danger-300",
progress: "text-info-800 dark:text-info-300",
};
const listColorClasses = {
info: "text-blue-700 dark:text-blue-300",
success: "text-green-700 dark:text-green-300",
warning: "text-yellow-700 dark:text-yellow-300",
error: "text-red-700 dark:text-red-300",
progress: "text-cyan-700 dark:text-cyan-300",
};
const Icon =
content.type === "warning"
? ExclamationTriangleIcon
: InformationCircleIcon;
const variant = variantMap[content.type];
return (
<div
@ -78,45 +43,30 @@ export const StepPopover = forwardRef<HTMLDivElement, StepPopoverProps>(
role="dialog"
aria-label="Step guidance"
>
<div
className={`${colorClasses[content.type]} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
>
<div className="flex items-start gap-3">
<Icon
className={`w-6 h-6 ${iconColorClasses[content.type]} flex-shrink-0 mt-0.5`}
/>
<div className="flex-1">
<h3
className={`text-base font-semibold ${textColorClasses[content.type]} mb-2`}
>
{content.title}
</h3>
<p className={`text-sm ${descColorClasses[content.type]} mb-3`}>
{content.description}
</p>
{content.items && content.items.length > 0 && (
<ul
className={`list-disc list-inside text-sm ${listColorClasses[content.type]} space-y-1`}
>
{content.items.map((item, index) => {
// Parse **text** markdown syntax into React elements safely
const parts = item.split(/(\*\*.*?\*\*)/);
return (
<li key={index} className="pl-2">
{parts.map((part, i) => {
if (part.startsWith("**") && part.endsWith("**")) {
return <strong key={i}>{part.slice(2, -2)}</strong>;
}
return part;
})}
</li>
);
})}
</ul>
)}
</div>
</div>
</div>
<InfoCard variant={variant} className="shadow-xl">
<InfoCardTitle variant={variant}>{content.title}</InfoCardTitle>
<InfoCardDescription variant={variant}>
{content.description}
</InfoCardDescription>
{content.items && content.items.length > 0 && (
<InfoCardList variant={variant}>
{content.items.map((item, index) => {
// Parse **text** markdown syntax into React elements safely
const parts = item.split(/(\*\*.*?\*\*)/);
return (
<InfoCardListItem key={index}>
{parts.map((part, i) => {
if (part.startsWith("**") && part.endsWith("**")) {
return <strong key={i}>{part.slice(2, -2)}</strong>;
}
return part;
})}
</InfoCardListItem>
);
})}
</InfoCardList>
)}
</InfoCard>
</div>
);
},

View file

@ -42,6 +42,10 @@ export interface UsePatternRotationUploadReturn {
* - Center shift calculation to maintain visual position
* - Upload orchestration with proper caching
*
* Note: This hook operates on passed parameters rather than store state,
* allowing it to be used as a callback handler. The center calculations
* use the same helpers as the store selectors for consistency.
*
* @param params - Upload and store functions
* @returns Upload handler function
*/
@ -78,7 +82,8 @@ export function usePatternRotationUpload({
// Calculate bounds from the DECODED stitches (the actual data that will be rendered)
const rotatedBounds = calculateBoundsFromDecodedStitches(decoded);
// Calculate the center of the rotated pattern
// Calculate the center shift caused by rotation
// Uses the same calculatePatternCenter helper as store selectors
const originalCenter = calculatePatternCenter(pesData.bounds);
const rotatedCenter = calculatePatternCenter(rotatedBounds);
const centerShiftX = rotatedCenter.x - originalCenter.x;

View file

@ -0,0 +1,200 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
usePatternStore,
selectPatternCenter,
selectUploadedPatternCenter,
selectRotatedBounds,
selectRotationCenterShift,
} from "./usePatternStore";
import type { PesPatternData } from "../formats/import/pesImporter";
// Mock pattern data for testing
const createMockPesData = (
bounds = {
minX: -100,
maxX: 100,
minY: -50,
maxY: 50,
},
): PesPatternData => ({
stitches: [[0, 0, 0, 0]],
threads: [],
uniqueColors: [],
penData: new Uint8Array(),
penStitches: {
stitches: [],
colorBlocks: [],
bounds: { minX: 0, maxX: 0, minY: 0, maxY: 0 },
},
colorCount: 1,
stitchCount: 1,
bounds,
});
describe("usePatternStore selectors", () => {
beforeEach(() => {
// Reset store state before each test
const state = usePatternStore.getState();
state.setPattern(createMockPesData(), "test.pes");
state.resetPatternOffset();
state.resetRotation();
state.clearUploadedPattern();
});
describe("selectPatternCenter", () => {
it("should return null when no pattern is loaded", () => {
// Clear the pattern
usePatternStore.setState({ pesData: null });
const center = selectPatternCenter(usePatternStore.getState());
expect(center).toBeNull();
});
it("should calculate center correctly for symmetric bounds", () => {
const state = usePatternStore.getState();
const center = selectPatternCenter(state);
expect(center).not.toBeNull();
expect(center!.x).toBe(0); // (minX + maxX) / 2 = (-100 + 100) / 2 = 0
expect(center!.y).toBe(0); // (minY + maxY) / 2 = (-50 + 50) / 2 = 0
});
it("should calculate center correctly for asymmetric bounds", () => {
const pesData = createMockPesData({
minX: 0,
maxX: 200,
minY: 0,
maxY: 100,
});
usePatternStore.setState({ pesData });
const state = usePatternStore.getState();
const center = selectPatternCenter(state);
expect(center).not.toBeNull();
expect(center!.x).toBe(100); // (0 + 200) / 2
expect(center!.y).toBe(50); // (0 + 100) / 2
});
});
describe("selectUploadedPatternCenter", () => {
it("should return null when no uploaded pattern", () => {
const state = usePatternStore.getState();
const center = selectUploadedPatternCenter(state);
expect(center).toBeNull();
});
it("should calculate center of uploaded pattern", () => {
const uploadedData = createMockPesData({
minX: 50,
maxX: 150,
minY: 25,
maxY: 75,
});
usePatternStore
.getState()
.setUploadedPattern(uploadedData, { x: 0, y: 0 });
const state = usePatternStore.getState();
const center = selectUploadedPatternCenter(state);
expect(center).not.toBeNull();
expect(center!.x).toBe(100); // (50 + 150) / 2
expect(center!.y).toBe(50); // (25 + 75) / 2
});
});
describe("selectRotatedBounds", () => {
it("should return original bounds when no rotation", () => {
const state = usePatternStore.getState();
const result = selectRotatedBounds(state);
expect(result).not.toBeNull();
expect(result!.bounds).toEqual({
minX: -100,
maxX: 100,
minY: -50,
maxY: 50,
});
expect(result!.center).toEqual({ x: 0, y: 0 });
});
it("should return null when no pattern", () => {
usePatternStore.setState({ pesData: null });
const state = usePatternStore.getState();
const result = selectRotatedBounds(state);
expect(result).toBeNull();
});
it("should calculate rotated bounds for 90 degree rotation", () => {
usePatternStore.getState().setPatternRotation(90);
const state = usePatternStore.getState();
const result = selectRotatedBounds(state);
expect(result).not.toBeNull();
// After 90° rotation, X and Y bounds should swap
expect(result!.bounds.minX).toBeCloseTo(-50, 0);
expect(result!.bounds.maxX).toBeCloseTo(50, 0);
expect(result!.bounds.minY).toBeCloseTo(-100, 0);
expect(result!.bounds.maxY).toBeCloseTo(100, 0);
});
it("should expand bounds for 45 degree rotation", () => {
usePatternStore.getState().setPatternRotation(45);
const state = usePatternStore.getState();
const result = selectRotatedBounds(state);
expect(result).not.toBeNull();
// After 45° rotation, bounds should expand
expect(Math.abs(result!.bounds.minX)).toBeGreaterThan(100);
expect(Math.abs(result!.bounds.minY)).toBeGreaterThan(50);
});
});
describe("selectRotationCenterShift", () => {
it("should return zero shift when no rotation", () => {
const state = usePatternStore.getState();
const rotatedBounds = state.pesData!.bounds;
const shift = selectRotationCenterShift(state, rotatedBounds);
expect(shift).toEqual({ x: 0, y: 0 });
});
it("should return null when no pattern", () => {
usePatternStore.setState({ pesData: null });
const state = usePatternStore.getState();
const shift = selectRotationCenterShift(state, {
minX: 0,
maxX: 100,
minY: 0,
maxY: 100,
});
expect(shift).toBeNull();
});
it("should calculate center shift for asymmetric pattern", () => {
const pesData = createMockPesData({
minX: 0,
maxX: 200,
minY: 0,
maxY: 100,
});
usePatternStore.setState({ pesData });
usePatternStore.getState().setPatternRotation(90);
const state = usePatternStore.getState();
const rotatedBounds = selectRotatedBounds(state)!.bounds;
const shift = selectRotationCenterShift(state, rotatedBounds);
expect(shift).not.toBeNull();
// Original center: (100, 50)
// After 90° rotation around center, new center should be slightly different
// due to the asymmetric bounds
expect(shift!.x).toBeCloseTo(0, 0);
expect(shift!.y).toBeCloseTo(0, 0);
});
});
});

View file

@ -1,6 +1,9 @@
import { create } from "zustand";
import { useShallow } from "zustand/react/shallow";
import type { PesPatternData } from "../formats/import/pesImporter";
import { onPatternDeleted } from "./storeEvents";
import { calculatePatternCenter } from "../components/PatternCanvas/patternCanvasHelpers";
import { calculateRotatedBounds } from "../utils/rotationUtils";
interface PatternState {
// Original pattern (pre-upload)
@ -29,6 +32,24 @@ interface PatternState {
resetRotation: () => void;
}
// Computed value types
export interface PatternCenter {
x: number;
y: number;
}
export interface PatternBounds {
minX: number;
maxX: number;
minY: number;
maxY: number;
}
export interface TransformedBounds {
bounds: PatternBounds;
center: PatternCenter;
}
export const usePatternStore = create<PatternState>((set) => ({
// Initial state - original pattern
pesData: null,
@ -123,6 +144,88 @@ export const useUploadedPatternOffset = () =>
export const usePatternRotation = () =>
usePatternStore((state) => state.patternRotation);
// Computed selectors (memoized by Zustand)
// These provide single source of truth for derived state
/**
* Select the geometric center of the pattern's bounds
*/
export const selectPatternCenter = (
state: PatternState,
): PatternCenter | null => {
if (!state.pesData) return null;
return calculatePatternCenter(state.pesData.bounds);
};
/**
* Select the center of the uploaded pattern's bounds
*/
export const selectUploadedPatternCenter = (
state: PatternState,
): PatternCenter | null => {
if (!state.uploadedPesData) return null;
return calculatePatternCenter(state.uploadedPesData.bounds);
};
/**
* Select the rotated bounds of the current pattern
* Returns original bounds if no rotation or no pattern
*/
export const selectRotatedBounds = (
state: PatternState,
): TransformedBounds | null => {
if (!state.pesData) return null;
const bounds =
state.patternRotation && state.patternRotation !== 0
? calculateRotatedBounds(state.pesData.bounds, state.patternRotation)
: state.pesData.bounds;
const center = calculatePatternCenter(bounds);
return { bounds, center };
};
/**
* Select the center shift caused by rotation
* This is used to adjust the offset when rotation is applied
* Returns null if no pattern or no rotation
*/
export const selectRotationCenterShift = (
state: PatternState,
rotatedBounds: PatternBounds,
): { x: number; y: number } | null => {
if (!state.pesData) return null;
if (!state.patternRotation || state.patternRotation === 0)
return { x: 0, y: 0 };
const originalCenter = calculatePatternCenter(state.pesData.bounds);
const rotatedCenter = calculatePatternCenter(rotatedBounds);
return {
x: rotatedCenter.x - originalCenter.x,
y: rotatedCenter.y - originalCenter.y,
};
};
/**
* Hook to get pattern center (memoized with shallow comparison)
*/
export const usePatternCenter = () =>
usePatternStore(useShallow(selectPatternCenter));
/**
* Hook to get uploaded pattern center (memoized with shallow comparison)
*/
export const useUploadedPatternCenter = () =>
usePatternStore(useShallow(selectUploadedPatternCenter));
/**
* Hook to get rotated bounds (memoized with shallow comparison)
*/
export const useRotatedBounds = () =>
usePatternStore(useShallow(selectRotatedBounds));
// Subscribe to pattern deleted event.
// This subscription is intended to persist for the lifetime of the application,
// so the unsubscribe function returned by `onPatternDeleted` is intentionally