import { useAppStore } from '../store'; import { invoke } from '@tauri-apps/api/core'; const MenuItem = ({ onClick, children, className = '' }: { onClick: () => void; children: React.ReactNode; className?: string }) => ( ); /** Real k-means palette extraction using canvas */ async function extractPaletteFromUrl(url: string, count = 5): Promise { return new Promise((resolve) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { try { const c = document.createElement('canvas'); const ctx = c.getContext('2d')!; c.width = 100; c.height = 100; ctx.drawImage(img, 0, 0, 100, 100); const data = ctx.getImageData(0, 0, 100, 100).data; // Collect pixels, skip near-black/near-white const pixels: number[][] = []; for (let i = 0; i < data.length; i += 12) { const r = data[i], g = data[i+1], b = data[i+2]; const sum = r + g + b; if (sum > 30 && sum < 720) pixels.push([r, g, b]); } if (pixels.length < count * 2) { resolve([]); return; } // Median cut quantization let buckets = [pixels]; while (buckets.length < count) { let bestIdx = -1, bestAxis = 0, bestRange = 0; buckets.forEach((b, i) => { if (b.length < 2) return; for (let a = 0; a < 3; a++) { const vals = b.map(p => p[a]); const range = Math.max(...vals) - Math.min(...vals); if (range > bestRange) { bestRange = range; bestIdx = i; bestAxis = a; } } }); if (bestIdx < 0) break; const b = buckets.splice(bestIdx, 1)[0].sort((x, y) => x[bestAxis] - y[bestAxis]); const mid = b.length >> 1; buckets.push(b.slice(0, mid), b.slice(mid)); } const colors = buckets.filter(b => b.length > 0).map(b => { const n = b.length; const avg = b.reduce((s, p) => [s[0]+p[0], s[1]+p[1], s[2]+p[2]], [0,0,0]); return '#' + avg.map(v => Math.round(v/n).toString(16).padStart(2, '0')).join(''); }); resolve(colors.slice(0, count)); } catch { resolve([]); } }; img.onerror = () => resolve([]); img.src = url; }); } export const ContextMenu = () => { const { contextMenu, setContextMenu, setImages, selectedNodeIds, setSelectedNodeIds, setZoom, setPan, zoom, pan, setPalettes, images, setIsAnnotationMode, setAnnotations, setTextNotes, isBrowserOpen, setIsBrowserOpen } = useAppStore(); if (!contextMenu) return null; const selectedImage = contextMenu.imageId ? images.find(i => i.id === contextMenu.imageId) : null; const handleDelete = () => { setImages(prev => prev.filter(i => !selectedNodeIds.includes(i.id))); setTextNotes(prev => prev.filter(n => !selectedNodeIds.includes(n.id))); setContextMenu(null); }; const handleDesaturate = () => { setImages(prev => prev.map(i => selectedNodeIds.includes(i.id) ? { ...i, isDesaturated: !i.isDesaturated } : i)); setContextMenu(null); }; const handleFlipH = () => { setImages(prev => prev.map(i => selectedNodeIds.includes(i.id) ? { ...i, isFlippedH: !i.isFlippedH } : i)); setContextMenu(null); }; const handleFlipV = () => { setImages(prev => prev.map(i => selectedNodeIds.includes(i.id) ? { ...i, isFlippedV: !i.isFlippedV } : i)); setContextMenu(null); }; const handleBringToFront = () => { setImages(prev => { const s = prev.filter(i => selectedNodeIds.includes(i.id)); const o = prev.filter(i => !selectedNodeIds.includes(i.id)); return [...o, ...s]; }); setContextMenu(null); }; const handleSendToBack = () => { setImages(prev => { const s = prev.filter(i => selectedNodeIds.includes(i.id)); const o = prev.filter(i => !selectedNodeIds.includes(i.id)); return [...s, ...o]; }); setContextMenu(null); }; const handleFitToCanvas = () => { if (selectedImage) { // Fit the selected image to fill the viewport const vw = window.innerWidth, vh = window.innerHeight; const scale = Math.min(vw / selectedImage.width, vh / selectedImage.height) * 0.85; setZoom(scale); setPan({ x: vw/2 - (selectedImage.x + selectedImage.width/2) * scale, y: vh/2 - (selectedImage.y + selectedImage.height/2) * scale }); } else { setZoom(1); setPan({ x: 0, y: 0 }); } setContextMenu(null); }; const handleExtractPalette = async () => { if (!selectedImage) return; setContextMenu(null); // Real extraction using canvas k-means const colors = await extractPaletteFromUrl(selectedImage.url); if (colors.length > 0) { setPalettes(prev => [...prev.filter(p => p.imageId !== selectedImage.id), { imageId: selectedImage.id, colors, x: selectedImage.x, y: selectedImage.y + selectedImage.height + 20 }]); } else { // Fallback: try via Rust backend (handles CORS/data URLs) try { const rustColors = await invoke('board_extract_palette_from_item', { id: selectedImage.id, count: 5 }); if (rustColors.length > 0) { setPalettes(prev => [...prev.filter(p => p.imageId !== selectedImage.id), { imageId: selectedImage.id, colors: rustColors, x: selectedImage.x, y: selectedImage.y + selectedImage.height + 20 }]); } } catch {} } }; const handleCopyImage = async () => { if (!selectedImage) return; try { const response = await fetch(selectedImage.url); const blob = await response.blob(); await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); } catch { // Fallback: copy URL navigator.clipboard.writeText(selectedImage.url); } setContextMenu(null); }; const handleOpenSourceUrl = () => { if (selectedImage) { // If it's a web URL, open in browser panel const url = selectedImage.url; if (url.startsWith('http://') || url.startsWith('https://')) { setIsBrowserOpen(true); // Navigate browser panel to this URL invoke('tab_navigate', { tabId: 'tab-1', url }).catch(() => {}); } } setContextMenu(null); }; const handleGroup = () => { const gid = crypto.randomUUID(); setImages(prev => prev.map(i => selectedNodeIds.includes(i.id) ? { ...i, groupId: gid } : i)); setTextNotes(prev => prev.map(n => selectedNodeIds.includes(n.id) ? { ...n, groupId: gid } : n)); setContextMenu(null); }; const handleUngroup = () => { setImages(prev => prev.map(i => selectedNodeIds.includes(i.id) ? { ...i, groupId: undefined } : i)); setTextNotes(prev => prev.map(n => selectedNodeIds.includes(n.id) ? { ...n, groupId: undefined } : n)); setContextMenu(null); }; const handleAddNote = () => { const x = (contextMenu.x - pan.x) / zoom, y = (contextMenu.y - pan.y) / zoom; setTextNotes(prev => [...prev, { id: crypto.randomUUID(), text: '', x, y, width: 200 }]); setContextMenu(null); }; return ( <>
{ e.stopPropagation(); if (e.button === 0) setContextMenu(null); }} onContextMenu={e => { e.preventDefault(); setContextMenu(null); }} />
{contextMenu.imageId ? (<> Fit to canvas Desaturate / Restore
Flip horizontal Flip vertical
Extract palette { setIsAnnotationMode(true); setContextMenu(null); }}>Add annotation
Copy image Open source URL
{selectedNodeIds.length > 1 && Group selection} Ungroup Bring to front Send to back
Delete ) : (<> Fit all to canvas { setAnnotations([]); setContextMenu(null); }}>Clear annotations
Add Text Note { setIsAnnotationMode(true); setContextMenu(null); }}>Annotate )}
); };