File size: 9,583 Bytes
3d7d9b5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 | import { useAppStore } from '../store';
import { invoke } from '@tauri-apps/api/core';
const MenuItem = ({ onClick, children, className = '' }: { onClick: () => void; children: React.ReactNode; className?: string }) => (
<button className={`w-full text-left px-4 py-1.5 hover:bg-[#0A84FF] hover:text-white transition-colors ${className}`} onPointerUp={e => { e.stopPropagation(); if (e.button === 0 || e.button === 2) onClick(); }} onClick={e => e.preventDefault()} onContextMenu={e => e.preventDefault()}>{children}</button>
);
/** Real k-means palette extraction using canvas */
async function extractPaletteFromUrl(url: string, count = 5): Promise<string[]> {
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<string[]>('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 (
<>
<div className="fixed inset-0 z-[60]" onPointerDown={e => { e.stopPropagation(); if (e.button === 0) setContextMenu(null); }} onContextMenu={e => { e.preventDefault(); setContextMenu(null); }} />
<div className="fixed bg-[#1C1C1E] border border-[#3A3A3E] shadow-2xl rounded-md py-1.5 w-52 z-[70] text-[13px] text-[#E0E0E0]" style={{ top: contextMenu.y, left: contextMenu.x }}>
{contextMenu.imageId ? (<>
<MenuItem onClick={handleFitToCanvas}>Fit to canvas</MenuItem>
<MenuItem onClick={handleDesaturate}>Desaturate / Restore</MenuItem>
<div className="h-px bg-[#3A3A3E] my-1.5" />
<MenuItem onClick={handleFlipH}>Flip horizontal</MenuItem>
<MenuItem onClick={handleFlipV}>Flip vertical</MenuItem>
<div className="h-px bg-[#3A3A3E] my-1.5" />
<MenuItem onClick={handleExtractPalette}>Extract palette</MenuItem>
<MenuItem onClick={() => { setIsAnnotationMode(true); setContextMenu(null); }}>Add annotation</MenuItem>
<div className="h-px bg-[#3A3A3E] my-1.5" />
<MenuItem onClick={handleCopyImage}>Copy image</MenuItem>
<MenuItem onClick={handleOpenSourceUrl}>Open source URL</MenuItem>
<div className="h-px bg-[#3A3A3E] my-1.5" />
{selectedNodeIds.length > 1 && <MenuItem onClick={handleGroup}>Group selection</MenuItem>}
<MenuItem onClick={handleUngroup}>Ungroup</MenuItem>
<MenuItem onClick={handleBringToFront}>Bring to front</MenuItem>
<MenuItem onClick={handleSendToBack}>Send to back</MenuItem>
<div className="h-px bg-[#3A3A3E] my-1.5" />
<MenuItem className="text-red-400 hover:!bg-red-500" onClick={handleDelete}>Delete</MenuItem>
</>) : (<>
<MenuItem onClick={handleFitToCanvas}>Fit all to canvas</MenuItem>
<MenuItem onClick={() => { setAnnotations([]); setContextMenu(null); }}>Clear annotations</MenuItem>
<div className="h-px bg-[#3A3A3E] my-1.5" />
<MenuItem onClick={handleAddNote}>Add Text Note</MenuItem>
<MenuItem onClick={() => { setIsAnnotationMode(true); setContextMenu(null); }}>Annotate</MenuItem>
</>)}
</div>
</>
);
};
|