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:
185
lib/canvas-utils.ts
Normal file
185
lib/canvas-utils.ts
Normal 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
86
lib/utils.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user