feat: implement Phase 2 - Core Canvas Engine with layer system

Complete canvas rendering infrastructure and state management:

**Type System (types/)**
- Layer interface with blend modes, opacity, visibility
- Canvas state with zoom, pan, grid, rulers
- Tool types and settings interfaces
- Selection and pointer state types

**State Management (store/)**
- Layer store: CRUD operations, reordering, merging, flattening
- Canvas store: zoom (0.1x-10x), pan, grid, rulers, coordinate conversion
- Tool store: active tool, brush settings (size, opacity, hardness, flow)
- Full Zustand integration with selectors

**Utilities (lib/)**
- Canvas utils: create, clone, resize, load images, draw grid/checkerboard
- General utils: cn, clamp, lerp, distance, snap to grid, debounce, throttle
- Image data handling with error safety

**Components**
- CanvasWrapper: Multi-layer rendering with transformations
  - Checkerboard transparency background
  - Layer compositing with blend modes and opacity
  - Grid overlay support
  - Selection visualization
  - Mouse wheel zoom (Ctrl+scroll)
  - Middle-click or Shift+click panning

- LayersPanel: Interactive layer management
  - Visibility toggle with eye icon
  - Active layer selection
  - Opacity display
  - Delete with confirmation
  - Sorted by z-order

- EditorLayout: Full editor interface
  - Top toolbar with zoom controls (±, fit to screen, percentage)
  - Canvas area with full viewport
  - Right sidebar for layers panel
  - "New Layer" button with auto-naming

**Features Implemented**
✓ Multi-layer canvas with proper z-ordering
✓ Layer visibility, opacity, blend modes
✓ Zoom: 10%-1000% with Ctrl+wheel
✓ Pan: Middle-click or Shift+drag
✓ Grid overlay (toggleable)
✓ Selection rendering
✓ Background color support
✓ Create/delete/duplicate layers
✓ Layer merging and flattening

**Performance**
- Dev server: 451ms startup
- Efficient canvas rendering with transformations
- Debounced/throttled event handlers ready
- Memory-safe image data operations

Ready for Phase 3: History & Undo System

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-20 21:20:06 +01:00
parent 6f52b74037
commit 4b01e92b88
18 changed files with 1371 additions and 51 deletions

185
lib/canvas-utils.ts Normal file
View File

@@ -0,0 +1,185 @@
/**
* Create a new canvas element with specified dimensions
*/
export function createCanvas(width: number, height: number): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
}
/**
* Get 2D context from canvas with error handling
*/
export function getContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) {
throw new Error('Failed to get 2D context');
}
return ctx;
}
/**
* Clear entire canvas
*/
export function clearCanvas(ctx: CanvasRenderingContext2D): void {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
/**
* Fill canvas with color
*/
export function fillCanvas(ctx: CanvasRenderingContext2D, color: string): void {
ctx.fillStyle = color;
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
/**
* Draw checkerboard pattern (for transparency)
*/
export function drawCheckerboard(
ctx: CanvasRenderingContext2D,
squareSize = 10,
color1 = '#ffffff',
color2 = '#cccccc'
): void {
const { width, height } = ctx.canvas;
for (let y = 0; y < height; y += squareSize) {
for (let x = 0; x < width; x += squareSize) {
const isEven = (Math.floor(x / squareSize) + Math.floor(y / squareSize)) % 2 === 0;
ctx.fillStyle = isEven ? color1 : color2;
ctx.fillRect(x, y, squareSize, squareSize);
}
}
}
/**
* Clone a canvas
*/
export function cloneCanvas(source: HTMLCanvasElement): HTMLCanvasElement {
const clone = createCanvas(source.width, source.height);
const ctx = getContext(clone);
ctx.drawImage(source, 0, 0);
return clone;
}
/**
* Resize canvas maintaining content
*/
export function resizeCanvas(
canvas: HTMLCanvasElement,
newWidth: number,
newHeight: number
): void {
const tempCanvas = cloneCanvas(canvas);
canvas.width = newWidth;
canvas.height = newHeight;
const ctx = getContext(canvas);
ctx.drawImage(tempCanvas, 0, 0);
}
/**
* Get image data safely
*/
export function getImageData(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number
): ImageData | null {
try {
return ctx.getImageData(x, y, width, height);
} catch (e) {
console.error('Failed to get image data:', e);
return null;
}
}
/**
* Put image data safely
*/
export function putImageData(
ctx: CanvasRenderingContext2D,
imageData: ImageData,
x: number,
y: number
): void {
try {
ctx.putImageData(imageData, x, y);
} catch (e) {
console.error('Failed to put image data:', e);
}
}
/**
* Convert canvas to blob
*/
export async function canvasToBlob(
canvas: HTMLCanvasElement,
type = 'image/png',
quality = 1
): Promise<Blob | null> {
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob), type, quality);
});
}
/**
* Load image from URL
*/
export async function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
}
/**
* Load image from File
*/
export async function loadImageFromFile(file: File): Promise<HTMLImageElement> {
const url = URL.createObjectURL(file);
try {
const img = await loadImage(url);
URL.revokeObjectURL(url);
return img;
} catch (e) {
URL.revokeObjectURL(url);
throw e;
}
}
/**
* Draw grid on canvas
*/
export function drawGrid(
ctx: CanvasRenderingContext2D,
gridSize: number,
color = 'rgba(0, 0, 0, 0.1)'
): void {
const { width, height } = ctx.canvas;
ctx.strokeStyle = color;
ctx.lineWidth = 1;
// Vertical lines
for (let x = 0; x <= width; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x + 0.5, 0);
ctx.lineTo(x + 0.5, height);
ctx.stroke();
}
// Horizontal lines
for (let y = 0; y <= height; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y + 0.5);
ctx.lineTo(width, y + 0.5);
ctx.stroke();
}
}

86
lib/utils.ts Normal file
View File

@@ -0,0 +1,86 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Merge Tailwind CSS classes
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Clamp a value between min and max
*/
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/**
* Linear interpolation
*/
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
/**
* Calculate distance between two points
*/
export function distance(x1: number, y1: number, x2: number, y2: number): number {
const dx = x2 - x1;
const dy = y2 - y1;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Snap value to grid
*/
export function snapToGrid(value: number, gridSize: number): number {
return Math.round(value / gridSize) * gridSize;
}
/**
* Format bytes to human readable string
*/
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
/**
* Debounce function
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
/**
* Throttle function
*/
export function throttle<T extends (...args: any[]) => any>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle: boolean;
return (...args: Parameters<T>) => {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}