diff --git a/components/canvas/canvas-with-tools.tsx b/components/canvas/canvas-with-tools.tsx index 35cc9f3..d106d87 100644 --- a/components/canvas/canvas-with-tools.tsx +++ b/components/canvas/canvas-with-tools.tsx @@ -5,6 +5,7 @@ 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'; @@ -15,6 +16,18 @@ 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); @@ -37,9 +50,10 @@ export function CanvasWithTools() { const { layers, getActiveLayer } = useLayerStore(); const { activeTool, settings } = useToolStore(); - const { executeCommand } = useHistoryStore(); - const { activeSelection, selectionType, isMarching } = useSelectionStore(); + 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); @@ -421,6 +435,155 @@ export function CanvasWithTools() { } }; + // 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 (
0) { + const pixelIndex = i * 4 + 3; + imageData.data[pixelIndex] = 0; + } + } + + // Put the modified image data back + ctx.putImageData(imageData, bounds.x, bounds.y); + + // Capture after state and execute command + command.captureAfterState(); + executeCommand(command); + + addToast('Selection cut', 'success'); +} + +/** + * Paste from clipboard + */ +export async function pasteFromClipboard(): Promise { + const { width, height } = useCanvasStore.getState(); + const { addToast } = useToastStore.getState(); + + // Try to paste from browser clipboard first + try { + const items = await navigator.clipboard.read(); + + for (const item of items) { + for (const type of item.types) { + if (type.startsWith('image/')) { + const blob = await item.getType(type); + const img = new Image(); + const url = URL.createObjectURL(blob); + + img.onload = () => { + // Create new layer with pasted image + createLayerWithHistory({ + name: 'Pasted Layer', + width: img.width, + height: img.height, + }); + + // Draw the image onto the new layer + const { getActiveLayer } = useLayerStore.getState(); + const layer = getActiveLayer(); + if (layer && layer.canvas) { + const ctx = layer.canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(img, 0, 0); + addToast('Image pasted', 'success'); + } + } + + URL.revokeObjectURL(url); + }; + + img.src = url; + return; + } + } + } + } catch (error) { + // Clipboard API not available or no image in clipboard + console.warn('Clipboard API failed:', error); + } + + // Fallback to internal clipboard + if (clipboardCanvas) { + createLayerWithHistory({ + name: 'Pasted Layer', + width: clipboardCanvas.width, + height: clipboardCanvas.height, + }); + + // Draw the clipboard canvas onto the new layer + const { getActiveLayer } = useLayerStore.getState(); + const layer = getActiveLayer(); + if (layer && layer.canvas) { + const ctx = layer.canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(clipboardCanvas, 0, 0); + addToast('Selection pasted', 'success'); + } + } + } else { + addToast('Nothing to paste', 'warning'); + } +} + +/** + * Check if clipboard has content + */ +export function hasClipboardContent(): boolean { + return clipboardCanvas !== null; +} diff --git a/store/selection-store.ts b/store/selection-store.ts index 8208c0c..8aaf8b6 100644 --- a/store/selection-store.ts +++ b/store/selection-store.ts @@ -14,6 +14,7 @@ interface SelectionStore extends SelectionState { setTolerance: (tolerance: number) => void; setMarching: (isMarching: boolean) => void; clearSelection: () => void; + selectAll: () => void; invertSelection: () => void; } @@ -60,6 +61,40 @@ export const useSelectionStore = create((set) => ({ activeSelection: null, }), + selectAll: () => + set(() => { + const { useCanvasStore, useLayerStore } = require('@/store'); + const { width, height } = useCanvasStore.getState(); + const { getActiveLayer } = useLayerStore.getState(); + const activeLayer = getActiveLayer(); + + if (!activeLayer) return {}; + + // Create a mask that covers the entire canvas + const maskData = new Uint8Array(width * height).fill(255); + + return { + activeSelection: { + id: `selection-${Date.now()}`, + layerId: activeLayer.id, + mask: { + width, + height, + data: maskData, + bounds: { + x: 0, + y: 0, + width, + height, + }, + }, + inverted: false, + feather: 0, + createdAt: Date.now(), + }, + }; + }), + invertSelection: () => set((state) => { if (state.activeSelection) {