Files
paint-ui/lib/transform-utils.ts
Sebastian Krüger 1d82f60182 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>
2025-11-21 02:36:50 +01:00

347 lines
7.8 KiB
TypeScript

import type {
TransformMatrix,
TransformState,
TransformBounds,
TransformHandle,
} from '@/types/transform';
/**
* Create an identity transform matrix
*/
export function createIdentityMatrix(): TransformMatrix {
return {
a: 1,
b: 0,
c: 0,
d: 1,
e: 0,
f: 0,
};
}
/**
* Create a translation matrix
*/
export function createTranslationMatrix(x: number, y: number): TransformMatrix {
return {
a: 1,
b: 0,
c: 0,
d: 1,
e: x,
f: y,
};
}
/**
* Create a scale matrix
*/
export function createScaleMatrix(
scaleX: number,
scaleY: number
): TransformMatrix {
return {
a: scaleX,
b: 0,
c: 0,
d: scaleY,
e: 0,
f: 0,
};
}
/**
* Create a rotation matrix
*/
export function createRotationMatrix(degrees: number): TransformMatrix {
const radians = (degrees * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
return {
a: cos,
b: sin,
c: -sin,
d: cos,
e: 0,
f: 0,
};
}
/**
* Multiply two transform matrices
*/
export function multiplyMatrices(
m1: TransformMatrix,
m2: TransformMatrix
): TransformMatrix {
return {
a: m1.a * m2.a + m1.c * m2.b,
b: m1.b * m2.a + m1.d * m2.b,
c: m1.a * m2.c + m1.c * m2.d,
d: m1.b * m2.c + m1.d * m2.d,
e: m1.a * m2.e + m1.c * m2.f + m1.e,
f: m1.b * m2.e + m1.d * m2.f + m1.f,
};
}
/**
* Create a transform matrix from transform state
*/
export function createTransformMatrix(
state: TransformState,
bounds: TransformBounds
): TransformMatrix {
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
// Translate to origin
let matrix = createTranslationMatrix(-centerX, -centerY);
// Apply scale
matrix = multiplyMatrices(
matrix,
createScaleMatrix(state.scaleX, state.scaleY)
);
// Apply rotation
if (state.rotation !== 0) {
matrix = multiplyMatrices(matrix, createRotationMatrix(state.rotation));
}
// Translate back and apply position offset
matrix = multiplyMatrices(
matrix,
createTranslationMatrix(centerX + state.x, centerY + state.y)
);
return matrix;
}
/**
* Apply transform matrix to a point
*/
export function transformPoint(
x: number,
y: number,
matrix: TransformMatrix
): { x: number; y: number } {
return {
x: matrix.a * x + matrix.c * y + matrix.e,
y: matrix.b * x + matrix.d * y + matrix.f,
};
}
/**
* Calculate transformed bounds
*/
export function getTransformedBounds(
bounds: TransformBounds,
state: TransformState
): TransformBounds {
const matrix = createTransformMatrix(state, bounds);
const corners = [
{ x: bounds.x, y: bounds.y },
{ x: bounds.x + bounds.width, y: bounds.y },
{ x: bounds.x + bounds.width, y: bounds.y + bounds.height },
{ x: bounds.x, y: bounds.y + bounds.height },
];
const transformedCorners = corners.map((corner) =>
transformPoint(corner.x, corner.y, matrix)
);
const xs = transformedCorners.map((c) => c.x);
const ys = transformedCorners.map((c) => c.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
/**
* Get handle position for transform bounds
*/
export function getHandlePosition(
handle: TransformHandle,
bounds: TransformBounds,
state: TransformState
): { x: number; y: number } {
const matrix = createTransformMatrix(state, bounds);
const handleMap: Record<TransformHandle, { x: number; y: number }> = {
'top-left': { x: bounds.x, y: bounds.y },
'top-center': { x: bounds.x + bounds.width / 2, y: bounds.y },
'top-right': { x: bounds.x + bounds.width, y: bounds.y },
'middle-left': { x: bounds.x, y: bounds.y + bounds.height / 2 },
'middle-right': {
x: bounds.x + bounds.width,
y: bounds.y + bounds.height / 2,
},
'bottom-left': { x: bounds.x, y: bounds.y + bounds.height },
'bottom-center': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height },
'bottom-right': { x: bounds.x + bounds.width, y: bounds.y + bounds.height },
rotate: {
x: bounds.x + bounds.width / 2,
y: bounds.y - 30,
},
};
const point = handleMap[handle];
return transformPoint(point.x, point.y, matrix);
}
/**
* Check if a point is near a handle
*/
export function isNearHandle(
x: number,
y: number,
handle: TransformHandle,
bounds: TransformBounds,
state: TransformState,
threshold: number = 10
): boolean {
const handlePos = getHandlePosition(handle, bounds, state);
const dx = x - handlePos.x;
const dy = y - handlePos.y;
const distance = Math.sqrt(dx * dx + dy * dy);
return distance <= threshold;
}
/**
* Get cursor for transform handle
*/
export function getHandleCursor(handle: TransformHandle, rotation: number): string {
const cursors: Record<TransformHandle, string> = {
'top-left': 'nwse-resize',
'top-center': 'ns-resize',
'top-right': 'nesw-resize',
'middle-left': 'ew-resize',
'middle-right': 'ew-resize',
'bottom-left': 'nesw-resize',
'bottom-center': 'ns-resize',
'bottom-right': 'nwse-resize',
rotate: 'crosshair',
};
// TODO: Adjust cursor based on rotation
return cursors[handle];
}
/**
* Calculate scale from handle drag
*/
export function calculateScaleFromHandle(
handle: TransformHandle,
dragStartX: number,
dragStartY: number,
dragCurrentX: number,
dragCurrentY: number,
bounds: TransformBounds,
maintainAspectRatio: boolean
): { scaleX: number; scaleY: number } {
const dx = dragCurrentX - dragStartX;
const dy = dragCurrentY - dragStartY;
let scaleX = 1;
let scaleY = 1;
switch (handle) {
case 'top-left':
scaleX = 1 - dx / bounds.width;
scaleY = 1 - dy / bounds.height;
break;
case 'top-center':
scaleY = 1 - dy / bounds.height;
break;
case 'top-right':
scaleX = 1 + dx / bounds.width;
scaleY = 1 - dy / bounds.height;
break;
case 'middle-left':
scaleX = 1 - dx / bounds.width;
break;
case 'middle-right':
scaleX = 1 + dx / bounds.width;
break;
case 'bottom-left':
scaleX = 1 - dx / bounds.width;
scaleY = 1 + dy / bounds.height;
break;
case 'bottom-center':
scaleY = 1 + dy / bounds.height;
break;
case 'bottom-right':
scaleX = 1 + dx / bounds.width;
scaleY = 1 + dy / bounds.height;
break;
}
if (maintainAspectRatio) {
const scale = Math.max(Math.abs(scaleX), Math.abs(scaleY));
scaleX = scaleX < 0 ? -scale : scale;
scaleY = scaleY < 0 ? -scale : scale;
}
return { scaleX, scaleY };
}
/**
* Calculate rotation from handle drag
*/
export function calculateRotationFromHandle(
centerX: number,
centerY: number,
currentX: number,
currentY: number
): number {
const dx = currentX - centerX;
const dy = currentY - centerY;
const radians = Math.atan2(dy, dx);
return (radians * 180) / Math.PI + 90;
}
/**
* Apply transform to canvas
*/
export function applyTransformToCanvas(
sourceCanvas: HTMLCanvasElement,
state: TransformState,
bounds: TransformBounds
): HTMLCanvasElement {
const transformedBounds = getTransformedBounds(bounds, state);
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(transformedBounds.width);
canvas.height = Math.ceil(transformedBounds.height);
const ctx = canvas.getContext('2d');
if (!ctx) return canvas;
ctx.save();
// Translate to account for new bounds
ctx.translate(-transformedBounds.x, -transformedBounds.y);
// Apply transform matrix
const matrix = createTransformMatrix(state, bounds);
ctx.transform(matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f);
// Draw source canvas
ctx.drawImage(sourceCanvas, 0, 0);
ctx.restore();
return canvas;
}