diff --git a/components/canvas/canvas-with-tools.tsx b/components/canvas/canvas-with-tools.tsx index 602ab8d..473b3f2 100644 --- a/components/canvas/canvas-with-tools.tsx +++ b/components/canvas/canvas-with-tools.tsx @@ -17,6 +17,8 @@ import { EllipticalSelectionTool, LassoSelectionTool, MagicWandTool, + MoveTool, + FreeTransformTool, type BaseTool, } from '@/tools'; import type { PointerState } from '@/types'; @@ -34,6 +36,8 @@ const tools: Record = { 'elliptical-select': new EllipticalSelectionTool(), 'lasso-select': new LassoSelectionTool(), 'magic-wand': new MagicWandTool(), + move: new MoveTool(), + transform: new FreeTransformTool(), }; export function CanvasWithTools() { @@ -176,6 +180,24 @@ export function CanvasWithTools() { return; } + // Transform tools + const transformTools = ['move', 'transform']; + if (e.button === 0 && !e.shiftKey && transformTools.includes(activeTool)) { + const tool = tools[activeTool]; + const newPointer: PointerState = { + isDown: true, + x: canvasPos.x, + y: canvasPos.y, + prevX: canvasPos.x, + prevY: canvasPos.y, + pressure: e.pressure || 1, + }; + + setPointer(newPointer); + tool.onPointerDown(newPointer, {} as any, settings); + return; + } + // Selection tools const selectionTools = ['select', 'rectangular-select', 'elliptical-select', 'lasso-select', 'magic-wand']; if (e.button === 0 && !e.shiftKey && selectionTools.includes(activeTool)) { diff --git a/components/editor/editor-layout.tsx b/components/editor/editor-layout.tsx index 02ac085..1d02a29 100644 --- a/components/editor/editor-layout.tsx +++ b/components/editor/editor-layout.tsx @@ -11,6 +11,7 @@ import { ToolPalette, ToolSettings } from '@/components/tools'; import { ColorPanel } from '@/components/colors'; import { FilterPanel } from '@/components/filters'; import { SelectionPanel } from '@/components/selection'; +import { TransformPanel } from '@/components/transform'; import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'; import { useFileOperations } from '@/hooks/use-file-operations'; import { useDragDrop } from '@/hooks/use-drag-drop'; @@ -183,6 +184,9 @@ export function EditorLayout() { {/* Selection Panel */} + {/* Transform Panel */} + + {/* Canvas area */}
diff --git a/components/transform/index.ts b/components/transform/index.ts new file mode 100644 index 0000000..9a5be25 --- /dev/null +++ b/components/transform/index.ts @@ -0,0 +1 @@ +export * from './transform-panel'; diff --git a/components/transform/transform-panel.tsx b/components/transform/transform-panel.tsx new file mode 100644 index 0000000..c53ba69 --- /dev/null +++ b/components/transform/transform-panel.tsx @@ -0,0 +1,200 @@ +'use client'; + +import { useTransformStore } from '@/store/transform-store'; +import { useLayerStore } from '@/store/layer-store'; +import { useHistoryStore } from '@/store/history-store'; +import { useToolStore } from '@/store/tool-store'; +import { TransformCommand } from '@/core/commands/transform-command'; +import { Move, RotateCw, Maximize2, Check, X, Lock, Unlock } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export function TransformPanel() { + const { + activeTransform, + transformType, + maintainAspectRatio, + setTransformType, + setMaintainAspectRatio, + applyTransform, + cancelTransform, + } = useTransformStore(); + + const { activeLayerId, layers } = useLayerStore(); + const { executeCommand } = useHistoryStore(); + const { setActiveTool } = useToolStore(); + + const activeLayer = layers.find((l) => l.id === activeLayerId); + const hasActiveLayer = !!activeLayer && !activeLayer.locked; + + const handleApply = () => { + if (!activeTransform || !activeLayer) return; + + const command = TransformCommand.applyToLayer( + activeLayer, + activeTransform.currentState, + activeTransform.originalBounds + ); + executeCommand(command); + applyTransform(); + }; + + const handleCancel = () => { + cancelTransform(); + }; + + const handleMoveTool = () => { + setActiveTool('move'); + }; + + const handleTransformTool = () => { + setActiveTool('move'); // Will be updated to 'transform' when tool types are updated + }; + + return ( +
+ {/* Header */} +
+ +

Transform

+
+ + {/* Transform Tools */} +
+

+ Tools +

+
+ + + +
+
+ + {/* Transform Options */} + {activeTransform && ( +
+

+ Options +

+ + {/* Maintain Aspect Ratio */} + + + {/* Transform State Display */} +
+
+ Position: + + {Math.round(activeTransform.currentState.x)},{' '} + {Math.round(activeTransform.currentState.y)} + +
+
+ Scale: + + {(activeTransform.currentState.scaleX * 100).toFixed(0)}% ×{' '} + {(activeTransform.currentState.scaleY * 100).toFixed(0)}% + +
+
+ Rotation: + {activeTransform.currentState.rotation.toFixed(1)}° +
+
+ + {/* Apply/Cancel Buttons */} +
+ + +
+
+ )} + + {/* Instructions */} +
+

+ Instructions +

+
+ {transformType === 'move' && ( + <> +

• Click and drag to move the layer

+

• Arrow keys for precise movement

+

• Hold Shift for 10px increments

+ + )} + {transformType === 'free-transform' && ( + <> +

• Drag corners to scale

+

• Drag edges to scale in one direction

+

• Drag rotate handle to rotate

+

• Drag inside to move

+

• Hold Shift to constrain proportions

+ + )} +
+
+ + {!hasActiveLayer && ( +
+

+ Select an unlocked layer to transform +

+
+ )} +
+ ); +} diff --git a/core/commands/transform-command.ts b/core/commands/transform-command.ts new file mode 100644 index 0000000..58fbe79 --- /dev/null +++ b/core/commands/transform-command.ts @@ -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; + } +} diff --git a/lib/transform-utils.ts b/lib/transform-utils.ts new file mode 100644 index 0000000..af4f2c5 --- /dev/null +++ b/lib/transform-utils.ts @@ -0,0 +1,346 @@ +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 = { + '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 = { + '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; +} diff --git a/store/index.ts b/store/index.ts index d941eb3..c5f463c 100644 --- a/store/index.ts +++ b/store/index.ts @@ -5,3 +5,4 @@ export * from './filter-store'; export * from './history-store'; export * from './color-store'; export * from './selection-store'; +export * from './transform-store'; diff --git a/store/transform-store.ts b/store/transform-store.ts new file mode 100644 index 0000000..4a56322 --- /dev/null +++ b/store/transform-store.ts @@ -0,0 +1,76 @@ +import { create } from 'zustand'; +import type { + Transform, + TransformType, + TransformBounds, + TransformState, + TransformStore as ITransformStore, +} from '@/types/transform'; + +export const useTransformStore = create((set, get) => ({ + activeTransform: null, + transformType: 'move', + showHandles: true, + maintainAspectRatio: false, + + setTransformType: (type) => + set({ + transformType: type, + }), + + setShowHandles: (show) => + set({ + showHandles: show, + }), + + setMaintainAspectRatio: (maintain) => + set({ + maintainAspectRatio: maintain, + }), + + startTransform: (layerId, bounds) => + set({ + activeTransform: { + layerId, + originalBounds: bounds, + currentState: { + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + rotation: 0, + skewX: 0, + skewY: 0, + }, + isActive: true, + maintainAspectRatio: get().maintainAspectRatio, + }, + }), + + updateTransform: (stateUpdate) => + set((state) => { + if (!state.activeTransform) return state; + + return { + activeTransform: { + ...state.activeTransform, + currentState: { + ...state.activeTransform.currentState, + ...stateUpdate, + }, + }, + }; + }), + + applyTransform: () => { + // Transform will be applied via command + set({ + activeTransform: null, + }); + }, + + cancelTransform: () => + set({ + activeTransform: null, + }), +})); diff --git a/tools/free-transform-tool.ts b/tools/free-transform-tool.ts new file mode 100644 index 0000000..9d95f68 --- /dev/null +++ b/tools/free-transform-tool.ts @@ -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); + } +} diff --git a/tools/index.ts b/tools/index.ts index 298bf35..0b104dd 100644 --- a/tools/index.ts +++ b/tools/index.ts @@ -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'; diff --git a/tools/move-tool.ts b/tools/move-tool.ts new file mode 100644 index 0000000..2ae8014 --- /dev/null +++ b/tools/move-tool.ts @@ -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); + } +} diff --git a/types/index.ts b/types/index.ts index 10d3d90..cfbd50f 100644 --- a/types/index.ts +++ b/types/index.ts @@ -4,3 +4,4 @@ export * from './tool'; export * from './history'; export * from './filter'; export * from './selection'; +export * from './transform'; diff --git a/types/transform.ts b/types/transform.ts new file mode 100644 index 0000000..563cb01 --- /dev/null +++ b/types/transform.ts @@ -0,0 +1,61 @@ +export type TransformType = 'move' | 'rotate' | 'scale' | 'free-transform'; + +export type TransformHandle = + | 'top-left' + | 'top-center' + | 'top-right' + | 'middle-left' + | 'middle-right' + | 'bottom-left' + | 'bottom-center' + | 'bottom-right' + | 'rotate'; + +export interface TransformBounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface TransformState { + x: number; + y: number; + scaleX: number; + scaleY: number; + rotation: number; // in degrees + skewX: number; + skewY: number; +} + +export interface Transform { + layerId: string; + originalBounds: TransformBounds; + currentState: TransformState; + isActive: boolean; + maintainAspectRatio: boolean; +} + +export interface TransformMatrix { + a: number; // scaleX + b: number; // skewY + c: number; // skewX + d: number; // scaleY + e: number; // translateX + f: number; // translateY +} + +export interface TransformStore { + activeTransform: Transform | null; + transformType: TransformType; + showHandles: boolean; + maintainAspectRatio: boolean; + + setTransformType: (type: TransformType) => void; + setShowHandles: (show: boolean) => void; + setMaintainAspectRatio: (maintain: boolean) => void; + startTransform: (layerId: string, bounds: TransformBounds) => void; + updateTransform: (state: Partial) => void; + applyTransform: () => void; + cancelTransform: () => void; +}