Files
paint-ui/components/canvas/canvas-wrapper.tsx
Sebastian Krüger cd59f0606b feat: implement UI state persistence and theme toggle
Major improvements to UI state management and user preferences:

- Add theme toggle with dark/light mode support
- Implement Zustand persist middleware for UI state
- Add ui-store for panel layout preferences (dock width, heights, tabs)
- Persist tool settings (active tool, size, opacity, hardness, etc.)
- Persist canvas view preferences (grid, rulers, snap-to-grid)
- Persist shape tool settings
- Persist collapsible section states
- Fix canvas coordinate transformation for centered rendering
- Constrain checkerboard and grid to canvas bounds
- Add icons to all tab buttons and collapsible sections
- Restructure panel-dock to use persisted state

Storage impact: ~3.5KB total across all preferences
Storage keys: tool-storage, canvas-view-storage, shape-storage, ui-storage

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 09:03:14 +01:00

151 lines
4.0 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
import { useCanvasStore, useLayerStore } from '@/store';
import { getContext, drawGrid, drawCheckerboard } from '@/lib/canvas-utils';
import { cn } from '@/lib/utils';
export function CanvasWrapper() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const {
width,
height,
zoom,
offsetX,
offsetY,
showGrid,
gridSize,
backgroundColor,
selection,
} = useCanvasStore();
const { layers } = useLayerStore();
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
// Render canvas
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = getContext(canvas);
const container = containerRef.current;
if (!container) return;
// Set canvas size to match container
const rect = container.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Save context state
ctx.save();
// Apply transformations
ctx.translate(offsetX + canvas.width / 2, offsetY + canvas.height / 2);
ctx.scale(zoom, zoom);
ctx.translate(-width / 2, -height / 2);
// Draw checkerboard background (only within canvas bounds)
drawCheckerboard(ctx, 10, '#ffffff', '#e0e0e0', width, height);
// Draw background color if not transparent
if (backgroundColor && backgroundColor !== 'transparent') {
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, width, height);
}
// Draw all visible layers
layers
.filter((layer) => layer.visible && layer.canvas)
.sort((a, b) => a.order - b.order)
.forEach((layer) => {
if (!layer.canvas) return;
ctx.globalAlpha = layer.opacity;
ctx.globalCompositeOperation = layer.blendMode as GlobalCompositeOperation;
ctx.drawImage(layer.canvas, layer.x, layer.y);
});
// Reset composite operation
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
// Draw grid if enabled (only within canvas bounds)
if (showGrid) {
drawGrid(ctx, gridSize, 'rgba(0, 0, 0, 0.15)', width, height);
}
// Draw selection if active
if (selection.active) {
ctx.strokeStyle = '#0066ff';
ctx.lineWidth = 1 / zoom;
ctx.setLineDash([4 / zoom, 4 / zoom]);
ctx.strokeRect(selection.x, selection.y, selection.width, selection.height);
ctx.setLineDash([]);
}
// Restore context state
ctx.restore();
}, [layers, width, height, zoom, offsetX, offsetY, showGrid, gridSize, backgroundColor, selection]);
// Handle mouse wheel for zooming
const handleWheel = (e: React.WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const { zoomIn, zoomOut } = useCanvasStore.getState();
if (e.deltaY < 0) {
zoomIn();
} else {
zoomOut();
}
}
};
// Handle panning
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
// Middle mouse or Shift + Left mouse for panning
setIsPanning(true);
setPanStart({ x: e.clientX - offsetX, y: e.clientY - offsetY });
e.preventDefault();
}
};
const handleMouseMove = (e: React.MouseEvent) => {
if (isPanning) {
const { setPanOffset } = useCanvasStore.getState();
setPanOffset(e.clientX - panStart.x, e.clientY - panStart.y);
}
};
const handleMouseUp = () => {
setIsPanning(false);
};
return (
<div
ref={containerRef}
className={cn(
'relative h-full w-full overflow-hidden bg-canvas-bg',
isPanning && 'cursor-grabbing'
)}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<canvas
ref={canvasRef}
className="absolute inset-0"
/>
</div>
);
}