musealpha / src /components /ContextMenu.tsx
asdf98's picture
Upload 112 files
3d7d9b5 verified
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>
</>
);
};