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:
2025-11-21 02:36:50 +01:00
parent 7f1e69559f
commit 1d82f60182
13 changed files with 1115 additions and 0 deletions

View 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;
}
}