| 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>
|
| );
|
|
|
|
|
| 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;
|
|
|
| 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; }
|
|
|
| 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) {
|
|
|
| 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);
|
|
|
| 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 {
|
|
|
| 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 {
|
|
|
| navigator.clipboard.writeText(selectedImage.url);
|
| }
|
| setContextMenu(null);
|
| };
|
|
|
| const handleOpenSourceUrl = () => {
|
| if (selectedImage) {
|
|
|
| const url = selectedImage.url;
|
| if (url.startsWith('http://') || url.startsWith('https://')) {
|
| setIsBrowserOpen(true);
|
|
|
| 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>
|
| </>
|
| );
|
| };
|
|
|