Files
paint-ui/components/canvas/canvas-with-tools.tsx
Sebastian Krüger f9eefcc7cd fix: add missing pointer event handlers for selection and drawing tools
Fixed multiple tools that were not working due to missing pointer event handlers:

**Selection Tools (rectangular, elliptical, lasso, magic-wand):**
- Added pointer move handler to draw selection preview while dragging
- Added pointer up handler to finalize selection and update selection store
- Selection tools now show live preview and create selections correctly

**Drawing Tools (clone, smudge, dodge/burn):**
- Added clone, smudge, and dodge to pointer up handler
- These tools now properly capture state changes in history
- Canvas re-renders correctly after using these tools

All affected tools now have complete pointer event handling:
- onPointerDown: Initialize tool state
- onPointerMove: Update preview/drawing while pointer is down
- onPointerUp: Finalize changes and update history

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 21:13:01 +01:00

787 lines
23 KiB
TypeScript

'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useCanvasStore, useLayerStore, useToolStore } from '@/store';
import { useHistoryStore } from '@/store/history-store';
import { useSelectionStore } from '@/store/selection-store';
import { useTextStore } from '@/store/text-store';
import { useContextMenuStore } from '@/store/context-menu-store';
import { useLayerEffectsStore } from '@/store/layer-effects-store';
import { drawMarchingAnts } from '@/lib/selection-utils';
import { getContext, drawGrid, drawCheckerboard } from '@/lib/canvas-utils';
import { renderText } from '@/lib/text-utils';
import { applyLayerEffects } from '@/lib/layer-effects-renderer';
import { DrawCommand } from '@/core/commands';
import { getTool, preloadCommonTools } from '@/lib/tool-loader';
import { useTouchGestures } from '@/hooks/use-touch-gestures';
import type { BaseTool } from '@/tools';
import type { PointerState } from '@/types';
import { cn } from '@/lib/utils';
import { OnCanvasTextEditor } from './on-canvas-text-editor';
import { MiniMap } from './mini-map';
import {
Scissors,
Copy,
Clipboard,
Undo2,
Redo2,
Layers,
SquareDashedMousePointer,
RotateCw,
FlipHorizontal,
FlipVertical,
} from 'lucide-react';
interface CanvasWithToolsProps {
onCursorMove?: (pos: { x: number; y: number } | undefined) => void;
}
export function CanvasWithTools({ onCursorMove }: CanvasWithToolsProps = {}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const drawCommandRef = useRef<DrawCommand | null>(null);
const toolsCache = useRef<Record<string, BaseTool>>({});
const {
width,
height,
zoom,
offsetX,
offsetY,
showGrid,
gridSize,
backgroundColor,
selection,
screenToCanvas,
} = useCanvasStore();
const { layers, getActiveLayer } = useLayerStore();
const { activeTool, settings } = useToolStore();
const { executeCommand, canUndo, canRedo, undo, redo } = useHistoryStore();
const { activeSelection, selectionType, isMarching, clearSelection, selectAll } = useSelectionStore();
const { textObjects, editingTextId, isOnCanvasEditorActive } = useTextStore();
const { showContextMenu } = useContextMenuStore();
const { getLayerEffects } = useLayerEffectsStore();
const [marchingOffset, setMarchingOffset] = useState(0);
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
const [pointer, setPointer] = useState<PointerState>({
isDown: false,
x: 0,
y: 0,
prevX: 0,
prevY: 0,
pressure: 1,
});
const [cropOverlayNeedsUpdate, setCropOverlayNeedsUpdate] = useState(0);
// Touch gesture support for mobile
useTouchGestures(containerRef, {
minScale: 0.1,
maxScale: 32,
});
// Helper to get tool (lazy load if not in cache)
const getToolInstance = useCallback(async (toolKey: string): Promise<BaseTool | null> => {
if (toolsCache.current[toolKey]) {
return toolsCache.current[toolKey];
}
try {
const tool = await getTool(toolKey as any);
toolsCache.current[toolKey] = tool;
return tool;
} catch (error) {
console.error(`Failed to load tool ${toolKey}:`, error);
return null;
}
}, []);
// Preload common tools on mount
useEffect(() => {
preloadCommonTools();
// Preload tools into cache
const initTools = async () => {
const commonTools = ['pencil', 'brush', 'eraser'];
for (const toolKey of commonTools) {
await getToolInstance(toolKey);
}
};
initTools();
}, [getToolInstance]);
// Eagerly load active tool when it changes
useEffect(() => {
const loadActiveTool = async () => {
// Load the active tool
await getToolInstance(activeTool);
// For selection tools, also load the specific selection type
if (activeTool === 'select') {
await getToolInstance(`${selectionType}-select`);
}
};
loadActiveTool();
}, [activeTool, selectionType, getToolInstance]);
// Render canvas
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = getContext(canvas);
const container = containerRef.current;
if (!container) return;
// Set canvas size to match container
const rect = container.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Save context state
ctx.save();
// Apply transformations
ctx.translate(offsetX + canvas.width / 2, offsetY + canvas.height / 2);
ctx.scale(zoom, zoom);
ctx.translate(-width / 2, -height / 2);
// Draw checkerboard background (only within canvas bounds)
drawCheckerboard(ctx, 10, '#ffffff', '#e0e0e0', width, height);
// Draw background color if not transparent
if (backgroundColor && backgroundColor !== 'transparent') {
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, width, height);
}
// Draw all visible layers
layers
.filter((layer) => layer.visible && layer.canvas)
.sort((a, b) => a.order - b.order)
.forEach((layer) => {
if (!layer.canvas) return;
// Get layer effects configuration
const effectsConfig = getLayerEffects(layer.id);
// Apply effects to layer if any exist
const layerWithEffects = applyLayerEffects(layer.canvas, effectsConfig);
ctx.globalAlpha = layer.opacity;
ctx.globalCompositeOperation = layer.blendMode as GlobalCompositeOperation;
ctx.drawImage(layerWithEffects, layer.x, layer.y);
});
// Reset composite operation
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
// Draw text objects (skip the one being edited)
textObjects.forEach((textObj) => {
// Don't render text that's currently being edited
if (editingTextId && textObj.id === editingTextId) {
return;
}
renderText(ctx, textObj.x, textObj.y, {
text: textObj.text,
fontFamily: textObj.fontFamily,
fontSize: textObj.fontSize,
fontStyle: textObj.fontStyle,
fontWeight: textObj.fontWeight,
color: textObj.color,
align: textObj.align,
baseline: textObj.baseline,
lineHeight: textObj.lineHeight,
letterSpacing: textObj.letterSpacing,
});
});
// Draw grid if enabled (only within canvas bounds)
if (showGrid) {
drawGrid(ctx, gridSize, 'rgba(0, 0, 0, 0.15)', width, height);
}
// Draw selection if active (marching ants)
if (activeSelection && isMarching) {
drawMarchingAnts(ctx, activeSelection.mask, marchingOffset);
}
// Restore context state
ctx.restore();
// Draw crop tool overlay if crop tool is active
if (activeTool === 'crop') {
const cropTool = toolsCache.current['crop'];
if (cropTool && typeof (cropTool as any).drawCropOverlay === 'function') {
ctx.save();
ctx.translate(offsetX + canvas.width / 2, offsetY + canvas.height / 2);
ctx.scale(zoom, zoom);
ctx.translate(-width / 2, -height / 2);
// Create a temporary canvas context that matches the full canvas size
const tempCanvas = document.createElement('canvas');
tempCanvas.width = width;
tempCanvas.height = height;
const tempCtx = tempCanvas.getContext('2d');
if (tempCtx) {
(cropTool as any).drawCropOverlay(tempCtx);
ctx.drawImage(tempCanvas, 0, 0);
}
ctx.restore();
}
}
}, [layers, width, height, zoom, offsetX, offsetY, showGrid, gridSize, backgroundColor, selection, pointer, activeSelection, isMarching, marchingOffset, textObjects, editingTextId, activeTool, cropOverlayNeedsUpdate]);
// Marching ants animation
useEffect(() => {
if (!activeSelection || !isMarching) return;
const interval = setInterval(() => {
setMarchingOffset((prev) => (prev + 1) % 8);
}, 50);
return () => clearInterval(interval);
}, [activeSelection, isMarching]);
// Handle mouse wheel for zooming
const handleWheel = (e: React.WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const { zoomIn, zoomOut } = useCanvasStore.getState();
if (e.deltaY < 0) {
zoomIn();
} else {
zoomOut();
}
}
};
// Handle pointer down
const handlePointerDown = (e: React.PointerEvent) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
const canvasPos = screenToCanvas(screenX, screenY, rect.width, rect.height);
// Check for panning
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
setIsPanning(true);
setPanStart({ x: e.clientX - offsetX, y: e.clientY - offsetY });
e.preventDefault();
return;
}
// Transform tools
const transformTools = ['move', 'transform'];
if (e.button === 0 && !e.shiftKey && transformTools.includes(activeTool)) {
const tool = toolsCache.current[activeTool];
if (!tool) return; // Tool not loaded yet
const newPointer: PointerState = {
isDown: true,
x: canvasPos.x,
y: canvasPos.y,
prevX: canvasPos.x,
prevY: canvasPos.y,
pressure: e.pressure || 1,
altKey: e.altKey,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
metaKey: e.metaKey,
};
setPointer(newPointer);
tool.onPointerDown(newPointer, {} as any, settings);
return;
}
// Crop tool
if (e.button === 0 && !e.shiftKey && activeTool === 'crop') {
const cropTool = toolsCache.current['crop'];
if (!cropTool) return; // Tool not loaded yet
const newPointer: PointerState = {
isDown: true,
x: canvasPos.x,
y: canvasPos.y,
prevX: canvasPos.x,
prevY: canvasPos.y,
pressure: e.pressure || 1,
altKey: e.altKey,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
metaKey: e.metaKey,
};
setPointer(newPointer);
// Create a temporary canvas for the crop tool
const tempCanvas = document.createElement('canvas');
tempCanvas.width = width;
tempCanvas.height = height;
const tempCtx = tempCanvas.getContext('2d');
if (tempCtx) {
cropTool.onPointerDown(newPointer, tempCtx, settings);
setCropOverlayNeedsUpdate(prev => prev + 1);
}
return;
}
// Text tool - only handle if editor is not already active
if (activeTool === 'text') {
// If editor is active, let it handle its own events (selection, dragging, click-outside)
if (isOnCanvasEditorActive) return;
const activeLayer = getActiveLayer();
if (!activeLayer || !activeLayer.canvas || activeLayer.locked) return;
const newPointer: PointerState = {
isDown: true,
x: canvasPos.x,
y: canvasPos.y,
prevX: canvasPos.x,
prevY: canvasPos.y,
pressure: e.pressure || 1,
altKey: e.altKey,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
metaKey: e.metaKey,
};
const textTool = toolsCache.current['text'];
if (textTool) {
textTool.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)) {
const activeLayer = getActiveLayer();
if (!activeLayer || !activeLayer.canvas) return;
const tool = toolsCache.current[`${selectionType}-select`] || toolsCache.current['select'];
if (!tool) return; // Tool not loaded yet
const newPointer: PointerState = {
isDown: true,
x: canvasPos.x,
y: canvasPos.y,
prevX: canvasPos.x,
prevY: canvasPos.y,
pressure: e.pressure || 1,
altKey: e.altKey,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
metaKey: e.metaKey,
};
setPointer(newPointer);
const ctx = activeLayer.canvas.getContext('2d');
if (ctx) {
tool.onPointerDown(newPointer, ctx, settings);
}
return;
}
// Drawing tools
if (e.button === 0 && !e.shiftKey && ['pencil', 'brush', 'eraser', 'fill', 'eyedropper', 'shape', 'clone', 'smudge', 'dodge'].includes(activeTool)) {
const activeLayer = getActiveLayer();
if (!activeLayer || !activeLayer.canvas || activeLayer.locked) return;
const newPointer: PointerState = {
isDown: true,
x: canvasPos.x,
y: canvasPos.y,
prevX: canvasPos.x,
prevY: canvasPos.y,
pressure: e.pressure || 1,
altKey: e.altKey,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
metaKey: e.metaKey,
};
setPointer(newPointer);
const tool = toolsCache.current[activeTool];
if (!tool) return; // Tool not loaded yet
// Create draw command for history
drawCommandRef.current = new DrawCommand(activeLayer.id, tool.name);
// Call tool's onPointerDown
const ctx = activeLayer.canvas.getContext('2d');
if (ctx) {
tool.onPointerDown(newPointer, ctx, settings);
}
}
};
// Handle pointer move
const handlePointerMove = (e: React.PointerEvent) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
const canvasPos = screenToCanvas(screenX, screenY, rect.width, rect.height);
// Report cursor position to parent
onCursorMove?.(canvasPos);
// Panning
if (isPanning) {
const { setPanOffset } = useCanvasStore.getState();
setPanOffset(e.clientX - panStart.x, e.clientY - panStart.y);
return;
}
// Crop tool
if (pointer.isDown && activeTool === 'crop') {
const cropTool = toolsCache.current['crop'];
if (!cropTool) return;
const newPointer: PointerState = {
...pointer,
x: canvasPos.x,
y: canvasPos.y,
pressure: e.pressure || 1,
altKey: e.altKey,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
metaKey: e.metaKey,
};
setPointer(newPointer);
const tempCanvas = document.createElement('canvas');
tempCanvas.width = width;
tempCanvas.height = height;
const tempCtx = tempCanvas.getContext('2d');
if (tempCtx) {
cropTool.onPointerMove(newPointer, tempCtx, settings);
setCropOverlayNeedsUpdate(prev => prev + 1);
}
return;
}
// Selection tools
const selectionTools = ['select', 'rectangular-select', 'elliptical-select', 'lasso-select', 'magic-wand'];
if (pointer.isDown && selectionTools.includes(activeTool)) {
const activeLayer = getActiveLayer();
if (!activeLayer || !activeLayer.canvas) return;
const tool = toolsCache.current[`${selectionType}-select`] || toolsCache.current['select'];
if (!tool) return;
const newPointer: PointerState = {
...pointer,
x: canvasPos.x,
y: canvasPos.y,
pressure: e.pressure || 1,
altKey: e.altKey,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
metaKey: e.metaKey,
};
setPointer(newPointer);
const ctx = activeLayer.canvas.getContext('2d');
if (ctx) {
tool.onPointerMove(newPointer, ctx, settings);
}
return;
}
// Drawing
if (pointer.isDown && ['pencil', 'brush', 'eraser', 'eyedropper', 'shape', 'clone', 'smudge', 'dodge'].includes(activeTool)) {
const activeLayer = getActiveLayer();
if (!activeLayer || !activeLayer.canvas) return;
const newPointer: PointerState = {
...pointer,
x: canvasPos.x,
y: canvasPos.y,
pressure: e.pressure || 1,
altKey: e.altKey,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
metaKey: e.metaKey,
};
setPointer(newPointer);
const tool = toolsCache.current[activeTool];
if (!tool) return; // Tool not loaded yet
const ctx = activeLayer.canvas.getContext('2d');
if (ctx) {
tool.onPointerMove(newPointer, ctx, settings);
}
}
};
// Handle pointer up
const handlePointerUp = (e: React.PointerEvent) => {
if (isPanning) {
setIsPanning(false);
return;
}
// Crop tool
if (pointer.isDown && activeTool === 'crop') {
const cropTool = toolsCache.current['crop'];
if (cropTool) {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = width;
tempCanvas.height = height;
const tempCtx = tempCanvas.getContext('2d');
if (tempCtx) {
cropTool.onPointerUp(pointer, tempCtx, settings);
setCropOverlayNeedsUpdate(prev => prev + 1);
}
}
setPointer({ ...pointer, isDown: false });
return;
}
// Selection tools
const selectionTools = ['select', 'rectangular-select', 'elliptical-select', 'lasso-select', 'magic-wand'];
if (pointer.isDown && selectionTools.includes(activeTool)) {
const activeLayer = getActiveLayer();
if (!activeLayer || !activeLayer.canvas) return;
const tool = toolsCache.current[`${selectionType}-select`] || toolsCache.current['select'];
if (tool) {
const ctx = activeLayer.canvas.getContext('2d');
if (ctx) {
tool.onPointerUp(pointer, ctx, settings);
}
}
setPointer({ ...pointer, isDown: false });
return;
}
if (pointer.isDown && ['pencil', 'brush', 'eraser', 'fill', 'eyedropper', 'shape', 'clone', 'smudge', 'dodge'].includes(activeTool)) {
const activeLayer = getActiveLayer();
if (!activeLayer || !activeLayer.canvas) return;
const tool = toolsCache.current[activeTool];
if (!tool) return; // Tool not loaded yet
const ctx = activeLayer.canvas.getContext('2d');
if (ctx) {
tool.onPointerUp(pointer, ctx, settings);
}
// Capture after state and add to history
if (drawCommandRef.current) {
drawCommandRef.current.captureAfterState();
executeCommand(drawCommandRef.current);
drawCommandRef.current = null;
}
setPointer({ ...pointer, isDown: false });
}
};
// Handle pointer leave
const handlePointerLeave = (e: React.PointerEvent) => {
handlePointerUp(e);
onCursorMove?.(undefined);
};
// Handle context menu
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
const hasSelection = !!activeSelection;
const activeLayer = getActiveLayer();
const canMergeDown = activeLayer ? layers.findIndex((l) => l.id === activeLayer.id) < layers.length - 1 : false;
showContextMenu(e.clientX, e.clientY, [
// Clipboard operations
{
label: 'Cut',
icon: <Scissors className="h-4 w-4" />,
onClick: async () => {
const { cutSelection } = await import('@/lib/clipboard-operations');
cutSelection();
},
disabled: !hasSelection,
},
{
label: 'Copy',
icon: <Copy className="h-4 w-4" />,
onClick: async () => {
const { copySelection } = await import('@/lib/clipboard-operations');
copySelection();
},
disabled: !hasSelection,
},
{
label: 'Paste',
icon: <Clipboard className="h-4 w-4" />,
onClick: async () => {
const { pasteFromClipboard } = await import('@/lib/clipboard-operations');
await pasteFromClipboard();
},
},
{
separator: true,
label: '',
onClick: () => {},
},
// Selection operations
{
label: 'Select All',
icon: <SquareDashedMousePointer className="h-4 w-4" />,
onClick: () => selectAll(),
disabled: !activeLayer,
},
{
label: 'Deselect',
icon: <SquareDashedMousePointer className="h-4 w-4" />,
onClick: () => clearSelection(),
disabled: !hasSelection,
},
{
separator: true,
label: '',
onClick: () => {},
},
// Layer operations
{
label: 'New Layer',
icon: <Layers className="h-4 w-4" />,
onClick: async () => {
const { createLayerWithHistory } = await import('@/lib/layer-operations');
createLayerWithHistory({
name: `Layer ${layers.length + 1}`,
width,
height,
});
},
},
{
label: 'Duplicate Layer',
icon: <Copy className="h-4 w-4" />,
onClick: async () => {
if (!activeLayer) return;
const { duplicateLayerWithHistory } = await import('@/lib/layer-operations');
duplicateLayerWithHistory(activeLayer.id);
},
disabled: !activeLayer,
},
{
label: 'Merge Down',
icon: <Layers className="h-4 w-4" />,
onClick: async () => {
if (!activeLayer) return;
const { mergeLayerDownWithHistory } = await import('@/lib/layer-operations');
mergeLayerDownWithHistory(activeLayer.id);
},
disabled: !canMergeDown,
},
{
separator: true,
label: '',
onClick: () => {},
},
// Transform operations
{
label: 'Rotate 90° CW',
icon: <RotateCw className="h-4 w-4" />,
onClick: async () => {
if (!activeLayer) return;
const { rotateLayerWithHistory } = await import('@/lib/canvas-operations');
rotateLayerWithHistory(activeLayer.id, 90);
},
disabled: !activeLayer,
},
{
label: 'Flip Horizontal',
icon: <FlipHorizontal className="h-4 w-4" />,
onClick: async () => {
if (!activeLayer) return;
const { flipLayerWithHistory } = await import('@/lib/canvas-operations');
flipLayerWithHistory(activeLayer.id, 'horizontal');
},
disabled: !activeLayer,
},
{
label: 'Flip Vertical',
icon: <FlipVertical className="h-4 w-4" />,
onClick: async () => {
if (!activeLayer) return;
const { flipLayerWithHistory } = await import('@/lib/canvas-operations');
flipLayerWithHistory(activeLayer.id, 'vertical');
},
disabled: !activeLayer,
},
{
separator: true,
label: '',
onClick: () => {},
},
// Edit operations
{
label: 'Undo',
icon: <Undo2 className="h-4 w-4" />,
onClick: () => undo(),
disabled: !canUndo,
},
{
label: 'Redo',
icon: <Redo2 className="h-4 w-4" />,
onClick: () => redo(),
disabled: !canRedo,
},
]);
};
return (
<div
ref={containerRef}
className={cn(
'relative h-full w-full overflow-hidden bg-canvas-bg touch-none',
isPanning ? 'cursor-grabbing' : `cursor-${toolsCache.current[activeTool]?.getCursor(settings) || 'default'}`
)}
style={{ touchAction: 'none' }}
onWheel={handleWheel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
onContextMenu={handleContextMenu}
>
<canvas
ref={canvasRef}
className="absolute inset-0"
/>
{/* On-canvas text editor */}
<OnCanvasTextEditor />
{/* Mini-map navigator */}
<MiniMap />
</div>
);
}