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:
200
tools/free-transform-tool.ts
Normal file
200
tools/free-transform-tool.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { BaseTool } from './base-tool';
|
||||
import type { PointerState, TransformHandle, TransformBounds } from '@/types';
|
||||
import { useLayerStore } from '@/store/layer-store';
|
||||
import { useTransformStore } from '@/store/transform-store';
|
||||
import {
|
||||
getHandlePosition,
|
||||
isNearHandle,
|
||||
getHandleCursor,
|
||||
calculateScaleFromHandle,
|
||||
calculateRotationFromHandle,
|
||||
} from '@/lib/transform-utils';
|
||||
|
||||
const HANDLES: TransformHandle[] = [
|
||||
'top-left',
|
||||
'top-center',
|
||||
'top-right',
|
||||
'middle-left',
|
||||
'middle-right',
|
||||
'bottom-left',
|
||||
'bottom-center',
|
||||
'bottom-right',
|
||||
'rotate',
|
||||
];
|
||||
|
||||
export class FreeTransformTool extends BaseTool {
|
||||
private activeHandle: TransformHandle | null = null;
|
||||
private dragStartX = 0;
|
||||
private dragStartY = 0;
|
||||
private originalState: any = null;
|
||||
|
||||
constructor() {
|
||||
super('Free Transform');
|
||||
}
|
||||
|
||||
onPointerDown(pointer: PointerState): void {
|
||||
const { activeTransform } = useTransformStore.getState();
|
||||
if (!activeTransform) {
|
||||
// Start new transform
|
||||
this.startTransform(pointer);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if clicking on a handle
|
||||
const handle = this.getHandleAtPoint(
|
||||
pointer.x,
|
||||
pointer.y,
|
||||
activeTransform.originalBounds,
|
||||
activeTransform.currentState
|
||||
);
|
||||
|
||||
if (handle) {
|
||||
this.isActive = true;
|
||||
this.isDrawing = true;
|
||||
this.activeHandle = handle;
|
||||
this.dragStartX = pointer.x;
|
||||
this.dragStartY = pointer.y;
|
||||
this.originalState = { ...activeTransform.currentState };
|
||||
} else {
|
||||
// Check if clicking inside bounds (move)
|
||||
const bounds = activeTransform.originalBounds;
|
||||
const state = activeTransform.currentState;
|
||||
|
||||
if (
|
||||
pointer.x >= bounds.x + state.x &&
|
||||
pointer.x <= bounds.x + state.x + bounds.width * state.scaleX &&
|
||||
pointer.y >= bounds.y + state.y &&
|
||||
pointer.y <= bounds.y + state.y + bounds.height * state.scaleY
|
||||
) {
|
||||
this.isActive = true;
|
||||
this.isDrawing = true;
|
||||
this.activeHandle = null;
|
||||
this.dragStartX = pointer.x;
|
||||
this.dragStartY = pointer.y;
|
||||
this.originalState = { ...activeTransform.currentState };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onPointerMove(pointer: PointerState): void {
|
||||
if (!this.isDrawing) {
|
||||
// Update cursor based on hover
|
||||
this.updateCursor(pointer);
|
||||
return;
|
||||
}
|
||||
|
||||
const { activeTransform, updateTransform, maintainAspectRatio } =
|
||||
useTransformStore.getState();
|
||||
if (!activeTransform || !this.originalState) return;
|
||||
|
||||
const dx = pointer.x - this.dragStartX;
|
||||
const dy = pointer.y - this.dragStartY;
|
||||
|
||||
if (this.activeHandle === 'rotate') {
|
||||
// Rotation
|
||||
const bounds = activeTransform.originalBounds;
|
||||
const centerX = bounds.x + bounds.width / 2 + activeTransform.currentState.x;
|
||||
const centerY = bounds.y + bounds.height / 2 + activeTransform.currentState.y;
|
||||
const rotation = calculateRotationFromHandle(centerX, centerY, pointer.x, pointer.y);
|
||||
updateTransform({ rotation });
|
||||
} else if (this.activeHandle) {
|
||||
// Scale
|
||||
const scale = calculateScaleFromHandle(
|
||||
this.activeHandle,
|
||||
this.dragStartX,
|
||||
this.dragStartY,
|
||||
pointer.x,
|
||||
pointer.y,
|
||||
activeTransform.originalBounds,
|
||||
maintainAspectRatio
|
||||
);
|
||||
updateTransform({
|
||||
scaleX: this.originalState.scaleX * scale.scaleX,
|
||||
scaleY: this.originalState.scaleY * scale.scaleY,
|
||||
});
|
||||
} else {
|
||||
// Move
|
||||
updateTransform({
|
||||
x: this.originalState.x + dx,
|
||||
y: this.originalState.y + dy,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onPointerUp(): void {
|
||||
this.isDrawing = false;
|
||||
this.isActive = false;
|
||||
this.activeHandle = null;
|
||||
this.originalState = null;
|
||||
}
|
||||
|
||||
getCursor(settings: any, pointer?: PointerState): string {
|
||||
const { activeTransform } = useTransformStore.getState();
|
||||
if (!activeTransform || !pointer) return 'default';
|
||||
|
||||
const handle = this.getHandleAtPoint(
|
||||
pointer.x,
|
||||
pointer.y,
|
||||
activeTransform.originalBounds,
|
||||
activeTransform.currentState
|
||||
);
|
||||
|
||||
if (handle) {
|
||||
return getHandleCursor(handle, activeTransform.currentState.rotation);
|
||||
}
|
||||
|
||||
// Check if inside bounds
|
||||
const bounds = activeTransform.originalBounds;
|
||||
const state = activeTransform.currentState;
|
||||
|
||||
if (
|
||||
pointer.x >= bounds.x + state.x &&
|
||||
pointer.x <= bounds.x + state.x + bounds.width * state.scaleX &&
|
||||
pointer.y >= bounds.y + state.y &&
|
||||
pointer.y <= bounds.y + state.y + bounds.height * state.scaleY
|
||||
) {
|
||||
return 'move';
|
||||
}
|
||||
|
||||
return 'default';
|
||||
}
|
||||
|
||||
private startTransform(pointer: PointerState): void {
|
||||
const layer = this.getActiveLayer();
|
||||
if (!layer?.canvas) return;
|
||||
|
||||
const bounds: TransformBounds = {
|
||||
x: layer.x,
|
||||
y: layer.y,
|
||||
width: layer.canvas.width,
|
||||
height: layer.canvas.height,
|
||||
};
|
||||
|
||||
const { startTransform } = useTransformStore.getState();
|
||||
startTransform(layer.id, bounds);
|
||||
}
|
||||
|
||||
private getHandleAtPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
bounds: TransformBounds,
|
||||
state: any
|
||||
): TransformHandle | null {
|
||||
for (const handle of HANDLES) {
|
||||
if (isNearHandle(x, y, handle, bounds, state, 10)) {
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private updateCursor(pointer: PointerState): void {
|
||||
// This would update the canvas cursor dynamically
|
||||
// Implementation depends on canvas component integration
|
||||
}
|
||||
|
||||
private getActiveLayer() {
|
||||
const { activeLayerId, layers } = useLayerStore.getState();
|
||||
return layers.find((l) => l.id === activeLayerId);
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,5 @@ export * from './rectangular-selection-tool';
|
||||
export * from './elliptical-selection-tool';
|
||||
export * from './lasso-selection-tool';
|
||||
export * from './magic-wand-tool';
|
||||
export * from './move-tool';
|
||||
export * from './free-transform-tool';
|
||||
|
||||
57
tools/move-tool.ts
Normal file
57
tools/move-tool.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { BaseTool } from './base-tool';
|
||||
import type { PointerState } from '@/types';
|
||||
import { useLayerStore } from '@/store/layer-store';
|
||||
|
||||
export class MoveTool extends BaseTool {
|
||||
private startX = 0;
|
||||
private startY = 0;
|
||||
private layerStartX = 0;
|
||||
private layerStartY = 0;
|
||||
|
||||
constructor() {
|
||||
super('Move');
|
||||
}
|
||||
|
||||
onPointerDown(pointer: PointerState): void {
|
||||
this.isActive = true;
|
||||
this.isDrawing = true;
|
||||
|
||||
const layer = this.getActiveLayer();
|
||||
if (!layer) return;
|
||||
|
||||
this.startX = pointer.x;
|
||||
this.startY = pointer.y;
|
||||
this.layerStartX = layer.x;
|
||||
this.layerStartY = layer.y;
|
||||
}
|
||||
|
||||
onPointerMove(pointer: PointerState): void {
|
||||
if (!this.isDrawing) return;
|
||||
|
||||
const layer = this.getActiveLayer();
|
||||
if (!layer) return;
|
||||
|
||||
const dx = pointer.x - this.startX;
|
||||
const dy = pointer.y - this.startY;
|
||||
|
||||
const { updateLayer } = useLayerStore.getState();
|
||||
updateLayer(layer.id, {
|
||||
x: this.layerStartX + dx,
|
||||
y: this.layerStartY + dy,
|
||||
});
|
||||
}
|
||||
|
||||
onPointerUp(): void {
|
||||
this.isDrawing = false;
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
getCursor(): string {
|
||||
return 'move';
|
||||
}
|
||||
|
||||
private getActiveLayer() {
|
||||
const { activeLayerId, layers } = useLayerStore.getState();
|
||||
return layers.find((l) => l.id === activeLayerId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user