feat(phase-9): implement comprehensive transform system with move and free transform
This commit completes Phase 9 of the paint-ui implementation, adding transform tools for moving, scaling, and rotating layers with real-time preview. **New Files:** - types/transform.ts: Transform types, state, and matrix interfaces - lib/transform-utils.ts: Transform matrix operations and calculations - store/transform-store.ts: Transform state management with Zustand - core/commands/transform-command.ts: Undo/redo support for transforms - tools/move-tool.ts: Simple layer move tool - tools/free-transform-tool.ts: Advanced transform with handles (scale/rotate/move) - components/transform/transform-panel.tsx: Transform UI panel - components/transform/index.ts: Transform components barrel export **Updated Files:** - components/canvas/canvas-with-tools.tsx: Added transform tools integration - components/editor/editor-layout.tsx: Integrated TransformPanel into layout - store/index.ts: Added transform-store export - tools/index.ts: Added transform tool exports - types/index.ts: Added transform types export **Transform Tools:** **Move Tool:** - ✨ Click-drag to move layers - 📐 Preserves layer dimensions - ⌨️ Arrow key support (planned) **Free Transform Tool:** - 🔲 8 scale handles (corners + edges) - 🔄 Rotate handle above center - 📏 Constrain proportions toggle - 🎯 Visual handle feedback - 🖱️ Cursor changes per handle **Transform Operations:** - **Move**: Translate layer position (X, Y offset) - **Scale**: Resize with handles (independent X/Y or constrained) - **Rotate**: Rotate around center point (degrees) - **Proportional Scaling**: Lock aspect ratio with toggle **Technical Features:** - Transform matrix operations (2D affine transformations) - Matrix multiplication for combined transforms - Handle position calculation with rotation - Transform bounds calculation (AABB of rotated corners) - Real-time transform preview on canvas - Non-destructive until apply - Undo/redo integration via TransformCommand - Apply/Cancel actions with state restoration **Matrix Mathematics:** - Identity matrix: [1 0 0 1 0 0] - Translation matrix: [1 0 0 1 tx ty] - Scale matrix: [sx 0 0 sy 0 0] - Rotation matrix: [cos -sin sin cos 0 0] - Matrix composition via multiplication - Point transformation: [x' y'] = M × [x y] **Transform Algorithm:** 1. Translate to origin (center of bounds) 2. Apply scale transformation 3. Apply rotation transformation 4. Translate back and apply position offset 5. Render transformed canvas to new canvas **Handle Types:** - **Corner handles** (4): Scale in both directions - **Edge handles** (4): Scale in single direction - **Rotate handle** (1): Rotate around center **Transform State:** ```typescript { x: number; // Translation X y: number; // Translation Y scaleX: number; // Scale factor X (1 = 100%) scaleY: number; // Scale factor Y (1 = 100%) rotation: number; // Rotation in degrees skewX: number; // Skew X (future) skewY: number; // Skew Y (future) } ``` **UI/UX Features:** - 264px wide transform panel with tool selection - Real-time transform state display (position, scale, rotation) - Constrain proportions toggle with lock/unlock icon - Apply/Cancel buttons with visual feedback - Tool-specific instructions - Disabled state when no unlocked layer selected - Keyboard shortcuts planned (Enter to apply, Esc to cancel) **Cursor Feedback:** - `move`: When dragging inside bounds - `nwse-resize`: Top-left/bottom-right corners - `nesw-resize`: Top-right/bottom-left corners - `ns-resize`: Top/bottom edges - `ew-resize`: Left/right edges - `crosshair`: Rotate handle - Cursor rotation adjustment (planned) Build verified: ✓ Compiled successfully in 1374ms 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
144
core/commands/transform-command.ts
Normal file
144
core/commands/transform-command.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { BaseCommand } from './base-command';
|
||||
import type { Layer, TransformState, TransformBounds } from '@/types';
|
||||
import { useLayerStore } from '@/store/layer-store';
|
||||
import { applyTransformToCanvas } from '@/lib/transform-utils';
|
||||
import { cloneCanvas } from '@/lib/canvas-utils';
|
||||
|
||||
export class TransformCommand extends BaseCommand {
|
||||
private layerId: string;
|
||||
private beforeCanvas: HTMLCanvasElement | null = null;
|
||||
private beforePosition: { x: number; y: number };
|
||||
private afterCanvas: HTMLCanvasElement | null = null;
|
||||
private afterPosition: { x: number; y: number } | null = null;
|
||||
private transformState: TransformState;
|
||||
private originalBounds: TransformBounds;
|
||||
|
||||
constructor(
|
||||
layer: Layer,
|
||||
transformState: TransformState,
|
||||
originalBounds: TransformBounds
|
||||
) {
|
||||
super('Transform Layer');
|
||||
this.layerId = layer.id;
|
||||
this.transformState = transformState;
|
||||
this.originalBounds = originalBounds;
|
||||
|
||||
// Store before state
|
||||
if (layer.canvas) {
|
||||
this.beforeCanvas = cloneCanvas(layer.canvas);
|
||||
}
|
||||
this.beforePosition = { x: layer.x, y: layer.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the state after applying the transform
|
||||
*/
|
||||
captureAfterState(layer: Layer): void {
|
||||
if (layer.canvas) {
|
||||
this.afterCanvas = cloneCanvas(layer.canvas);
|
||||
}
|
||||
this.afterPosition = { x: layer.x, y: layer.y };
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
if (!this.afterCanvas || !this.afterPosition) {
|
||||
// Apply transform for the first time
|
||||
this.applyTransform();
|
||||
} else {
|
||||
// Restore after state
|
||||
const { updateLayer } = useLayerStore.getState();
|
||||
const layer = this.getLayer();
|
||||
if (!layer?.canvas) return;
|
||||
|
||||
const ctx = layer.canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
ctx.drawImage(this.afterCanvas, 0, 0);
|
||||
|
||||
updateLayer(layer.id, {
|
||||
x: this.afterPosition.x,
|
||||
y: this.afterPosition.y,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.beforeCanvas) return;
|
||||
|
||||
const { updateLayer } = useLayerStore.getState();
|
||||
const layer = this.getLayer();
|
||||
if (!layer?.canvas) return;
|
||||
|
||||
const ctx = layer.canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
ctx.drawImage(this.beforeCanvas, 0, 0);
|
||||
|
||||
updateLayer(layer.id, {
|
||||
x: this.beforePosition.x,
|
||||
y: this.beforePosition.y,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private applyTransform(): void {
|
||||
const layer = this.getLayer();
|
||||
if (!layer?.canvas || !this.beforeCanvas) return;
|
||||
|
||||
// Apply transform to canvas
|
||||
const transformedCanvas = applyTransformToCanvas(
|
||||
this.beforeCanvas,
|
||||
this.transformState,
|
||||
this.originalBounds
|
||||
);
|
||||
|
||||
// Update layer canvas
|
||||
const ctx = layer.canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
// Resize canvas if needed
|
||||
if (
|
||||
layer.canvas.width !== transformedCanvas.width ||
|
||||
layer.canvas.height !== transformedCanvas.height
|
||||
) {
|
||||
layer.canvas.width = transformedCanvas.width;
|
||||
layer.canvas.height = transformedCanvas.height;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
ctx.drawImage(transformedCanvas, 0, 0);
|
||||
|
||||
// Update layer position and size
|
||||
const { updateLayer } = useLayerStore.getState();
|
||||
updateLayer(layer.id, {
|
||||
x: this.originalBounds.x + this.transformState.x,
|
||||
y: this.originalBounds.y + this.transformState.y,
|
||||
width: transformedCanvas.width,
|
||||
height: transformedCanvas.height,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
this.captureAfterState(layer);
|
||||
}
|
||||
|
||||
private getLayer(): Layer | undefined {
|
||||
const { layers } = useLayerStore.getState();
|
||||
return layers.find((l) => l.id === this.layerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static method to create and execute a transform command
|
||||
*/
|
||||
static applyToLayer(
|
||||
layer: Layer,
|
||||
transformState: TransformState,
|
||||
originalBounds: TransformBounds
|
||||
): TransformCommand {
|
||||
const command = new TransformCommand(layer, transformState, originalBounds);
|
||||
command.execute();
|
||||
return command;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user