Merge pull request #85 from jhbruhn/preview-bg

feature: Add light/dark background toggle to pattern preview
This commit is contained in:
Jan-Henrik Bruhn 2026-03-26 12:45:02 +01:00 committed by GitHub
commit 4cf2b09701
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 164 additions and 65 deletions

45
package-lock.json generated
View file

@ -17,6 +17,7 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.17",
"@types/web-bluetooth": "^0.0.21",
@ -4433,6 +4434,35 @@
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
@ -4570,6 +4600,21 @@
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",

View file

@ -30,6 +30,7 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.17",
"@types/web-bluetooth": "^0.0.21",

View file

@ -10,67 +10,70 @@ interface GridProps {
gridSize: number;
bounds: { minX: number; maxX: number; minY: number; maxY: number };
machineInfo: MachineInfo | null;
colorOverride?: string;
}
export const Grid = memo(({ gridSize, bounds, machineInfo }: GridProps) => {
const lines = useMemo(() => {
const gridMinX = machineInfo ? -machineInfo.maxWidth / 2 : bounds.minX;
const gridMaxX = machineInfo ? machineInfo.maxWidth / 2 : bounds.maxX;
const gridMinY = machineInfo ? -machineInfo.maxHeight / 2 : bounds.minY;
const gridMaxY = machineInfo ? machineInfo.maxHeight / 2 : bounds.maxY;
export const Grid = memo(
({ gridSize, bounds, machineInfo, colorOverride }: GridProps) => {
const lines = useMemo(() => {
const gridMinX = machineInfo ? -machineInfo.maxWidth / 2 : bounds.minX;
const gridMaxX = machineInfo ? machineInfo.maxWidth / 2 : bounds.maxX;
const gridMinY = machineInfo ? -machineInfo.maxHeight / 2 : bounds.minY;
const gridMaxY = machineInfo ? machineInfo.maxHeight / 2 : bounds.maxY;
const verticalLines: number[][] = [];
const horizontalLines: number[][] = [];
const verticalLines: number[][] = [];
const horizontalLines: number[][] = [];
// Vertical lines
for (
let x = Math.floor(gridMinX / gridSize) * gridSize;
x <= gridMaxX;
x += gridSize
) {
verticalLines.push([x, gridMinY, x, gridMaxY]);
}
// Vertical lines
for (
let x = Math.floor(gridMinX / gridSize) * gridSize;
x <= gridMaxX;
x += gridSize
) {
verticalLines.push([x, gridMinY, x, gridMaxY]);
}
// Horizontal lines
for (
let y = Math.floor(gridMinY / gridSize) * gridSize;
y <= gridMaxY;
y += gridSize
) {
horizontalLines.push([gridMinX, y, gridMaxX, y]);
}
// Horizontal lines
for (
let y = Math.floor(gridMinY / gridSize) * gridSize;
y <= gridMaxY;
y += gridSize
) {
horizontalLines.push([gridMinX, y, gridMaxX, y]);
}
return { verticalLines, horizontalLines };
}, [gridSize, bounds, machineInfo]);
return { verticalLines, horizontalLines };
}, [gridSize, bounds, machineInfo]);
const gridColor = canvasColors.grid();
const gridColor = colorOverride ?? canvasColors.grid();
return (
<Group name="grid" listening={false}>
{lines.verticalLines.map((points, i) => (
<Line
key={`v-${i}`}
points={points}
stroke={gridColor}
strokeWidth={1}
/>
))}
{lines.horizontalLines.map((points, i) => (
<Line
key={`h-${i}`}
points={points}
stroke={gridColor}
strokeWidth={1}
/>
))}
</Group>
);
});
return (
<Group name="grid" listening={false}>
{lines.verticalLines.map((points, i) => (
<Line
key={`v-${i}`}
points={points}
stroke={gridColor}
strokeWidth={1}
/>
))}
{lines.horizontalLines.map((points, i) => (
<Line
key={`h-${i}`}
points={points}
stroke={gridColor}
strokeWidth={1}
/>
))}
</Group>
);
},
);
Grid.displayName = "Grid";
export const Origin = memo(() => {
const originColor = canvasColors.origin();
export const Origin = memo(({ colorOverride }: { colorOverride?: string }) => {
const originColor = colorOverride ?? canvasColors.origin();
return (
<Group name="origin" listening={false}>

View file

@ -1,4 +1,4 @@
import { useRef, useMemo } from "react";
import { useRef, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import {
useMachineStore,
@ -8,7 +8,7 @@ import { useMachineUploadStore } from "../../stores/useMachineUploadStore";
import { usePatternStore } from "../../stores/usePatternStore";
import { Stage, Layer } from "react-konva";
import Konva from "konva";
import { PhotoIcon } from "@heroicons/react/24/solid";
import { PhotoIcon, SunIcon, MoonIcon } from "@heroicons/react/24/solid";
import { Grid, Origin, Hoop } from "./KonvaComponents";
import {
Card,
@ -21,6 +21,7 @@ import { ThreadLegend } from "./ThreadLegend";
import { PatternPositionIndicator } from "./PatternPositionIndicator";
import { ZoomControls } from "./ZoomControls";
import { PatternLayer } from "./PatternLayer";
import { Switch } from "@/components/ui/switch";
import { useCanvasViewport, usePatternTransform } from "@/hooks";
export function PatternCanvas() {
@ -103,6 +104,16 @@ export function PatternCanvas() {
isUploading,
});
const [previewDark, setPreviewDark] = useState(
() => window.matchMedia("(prefers-color-scheme: dark)").matches,
);
const canvasBg = previewDark
? "bg-gray-900 border-gray-600"
: "bg-gray-200 border-gray-300";
const canvasGridColor = previewDark ? "#404040" : "#e0e0e0";
const canvasOriginColor = previewDark ? "#999999" : "#888888";
const hasPattern = pesData || uploadedPesData;
const borderColor = hasPattern
? "border-tertiary-600 dark:border-tertiary-500"
@ -138,23 +149,34 @@ export function PatternCanvas() {
<CardHeader className="p-4 pb-3">
<div className="flex items-start gap-3">
<PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm">Pattern Preview</CardTitle>
{hasPattern ? (
<CardDescription className="text-xs">
{patternDimensions}
</CardDescription>
) : (
<CardDescription className="text-xs">
No pattern loaded
</CardDescription>
)}
<div className="flex-1 min-w-0 flex items-center justify-between">
<div>
<CardTitle className="text-sm">Pattern Preview</CardTitle>
{hasPattern ? (
<CardDescription className="text-xs">
{patternDimensions}
</CardDescription>
) : (
<CardDescription className="text-xs">
No pattern loaded
</CardDescription>
)}
</div>
<div className="flex items-center gap-1.5">
<SunIcon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
<Switch
checked={previewDark}
onCheckedChange={setPreviewDark}
aria-label="Toggle preview background"
/>
<MoonIcon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
</div>
</div>
</div>
</CardHeader>
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col min-h-0">
<div
className="relative w-full flex-1 min-h-0 border border-gray-300 dark:border-gray-600 rounded bg-gray-200 dark:bg-gray-900 overflow-hidden"
className={`relative w-full flex-1 min-h-0 border rounded overflow-hidden ${canvasBg}`}
ref={containerRef}
>
{containerSize.width > 0 && (
@ -184,8 +206,9 @@ export function PatternCanvas() {
gridSize={100}
bounds={displayPattern.bounds}
machineInfo={machineInfo}
colorOverride={canvasGridColor}
/>
<Origin />
<Origin colorOverride={canvasOriginColor} />
{machineInfo && <Hoop machineInfo={machineInfo} />}
</>
)}

View file

@ -0,0 +1,27 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };