'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 { drawMarchingAnts } from '@/lib/selection-utils'; import { getContext, drawGrid, drawCheckerboard } from '@/lib/canvas-utils'; import { renderText } from '@/lib/text-utils'; 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 { Scissors, Copy, Clipboard, Undo2, Redo2, Layers, SquareDashedMousePointer, RotateCw, FlipHorizontal, FlipVertical, } from 'lucide-react'; export function CanvasWithTools() { const canvasRef = useRef(null); const containerRef = useRef(null); const drawCommandRef = useRef(null); const toolsCache = useRef>({}); 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 [marchingOffset, setMarchingOffset] = useState(0); const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); const [pointer, setPointer] = useState({ isDown: false, x: 0, y: 0, prevX: 0, prevY: 0, pressure: 1, }); // 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 => { 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; ctx.globalAlpha = layer.opacity; ctx.globalCompositeOperation = layer.blendMode as GlobalCompositeOperation; ctx.drawImage(layer.canvas, 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(); }, [layers, width, height, zoom, offsetX, offsetY, showGrid, gridSize, backgroundColor, selection, pointer, activeSelection, isMarching, marchingOffset, textObjects, editingTextId]); // 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; } // 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); // Panning if (isPanning) { const { setPanOffset } = useCanvasStore.getState(); setPanOffset(e.clientX - panStart.x, e.clientY - panStart.y); 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; } if (pointer.isDown && ['pencil', 'brush', 'eraser', 'fill', 'eyedropper', 'shape'].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 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: , onClick: async () => { const { cutSelection } = await import('@/lib/clipboard-operations'); cutSelection(); }, disabled: !hasSelection, }, { label: 'Copy', icon: , onClick: async () => { const { copySelection } = await import('@/lib/clipboard-operations'); copySelection(); }, disabled: !hasSelection, }, { label: 'Paste', icon: , onClick: async () => { const { pasteFromClipboard } = await import('@/lib/clipboard-operations'); await pasteFromClipboard(); }, }, { separator: true, label: '', onClick: () => {}, }, // Selection operations { label: 'Select All', icon: , onClick: () => selectAll(), disabled: !activeLayer, }, { label: 'Deselect', icon: , onClick: () => clearSelection(), disabled: !hasSelection, }, { separator: true, label: '', onClick: () => {}, }, // Layer operations { label: 'New Layer', icon: , onClick: async () => { const { createLayerWithHistory } = await import('@/lib/layer-operations'); createLayerWithHistory({ name: `Layer ${layers.length + 1}`, width, height, }); }, }, { label: 'Duplicate Layer', icon: , onClick: async () => { if (!activeLayer) return; const { duplicateLayerWithHistory } = await import('@/lib/layer-operations'); duplicateLayerWithHistory(activeLayer.id); }, disabled: !activeLayer, }, { label: 'Merge Down', icon: , 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: , onClick: async () => { if (!activeLayer) return; const { rotateLayerWithHistory } = await import('@/lib/canvas-operations'); rotateLayerWithHistory(activeLayer.id, 90); }, disabled: !activeLayer, }, { label: 'Flip Horizontal', icon: , onClick: async () => { if (!activeLayer) return; const { flipLayerWithHistory } = await import('@/lib/canvas-operations'); flipLayerWithHistory(activeLayer.id, 'horizontal'); }, disabled: !activeLayer, }, { label: 'Flip Vertical', icon: , 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: , onClick: () => undo(), disabled: !canUndo, }, { label: 'Redo', icon: , onClick: () => redo(), disabled: !canRedo, }, ]); }; return (
{/* On-canvas text editor */}
); }