| import { useAppStore } from "../store";
|
|
|
| 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-accent-blue 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>
|
| );
|
|
|
| export const ContextMenu = () => {
|
| const {
|
| contextMenu,
|
| setContextMenu,
|
| setImages,
|
| selectedNodeIds,
|
| setZoom,
|
| setPan,
|
| zoom,
|
| pan,
|
| setPalettes,
|
| images,
|
| setIsAnnotationMode,
|
| setAnnotations,
|
| setTextNotes,
|
| } = useAppStore();
|
|
|
| if (!contextMenu) return null;
|
|
|
| const handleDelete = () => {
|
| setImages((prev) =>
|
| prev.filter((img) => !selectedNodeIds.includes(img.id)),
|
| );
|
| setTextNotes((prev) =>
|
| prev.filter((note) => !selectedNodeIds.includes(note.id)),
|
| );
|
| setAnnotations((prev) =>
|
| prev.filter((ann) => !selectedNodeIds.includes(ann.id)),
|
| );
|
| setPalettes((prev) =>
|
| prev.filter((p) => !selectedNodeIds.includes(p.imageId)),
|
| );
|
| setContextMenu(null);
|
| };
|
|
|
| const handleDesaturate = () => {
|
| setImages((prev) =>
|
| prev.map((img) =>
|
| selectedNodeIds.includes(img.id)
|
| ? { ...img, isDesaturated: !img.isDesaturated }
|
| : img,
|
| ),
|
| );
|
| setContextMenu(null);
|
| };
|
|
|
| const handleFlipH = () => {
|
| setImages((prev) =>
|
| prev.map((img) =>
|
| selectedNodeIds.includes(img.id)
|
| ? { ...img, isFlippedH: !img.isFlippedH }
|
| : img,
|
| ),
|
| );
|
| setContextMenu(null);
|
| };
|
|
|
| const handleFlipV = () => {
|
| setImages((prev) =>
|
| prev.map((img) =>
|
| selectedNodeIds.includes(img.id)
|
| ? { ...img, isFlippedV: !img.isFlippedV }
|
| : img,
|
| ),
|
| );
|
| setContextMenu(null);
|
| };
|
|
|
| const handleBringToFront = () => {
|
| setImages((prev) => {
|
| const selected = prev.filter((img) => selectedNodeIds.includes(img.id));
|
| const others = prev.filter((img) => !selectedNodeIds.includes(img.id));
|
| return [...others, ...selected];
|
| });
|
| setTextNotes((prev) => {
|
| const selected = prev.filter((n) => selectedNodeIds.includes(n.id));
|
| const others = prev.filter((n) => !selectedNodeIds.includes(n.id));
|
| return [...others, ...selected];
|
| });
|
| setContextMenu(null);
|
| };
|
|
|
| const handleSendToBack = () => {
|
| setImages((prev) => {
|
| const selected = prev.filter((img) => selectedNodeIds.includes(img.id));
|
| const others = prev.filter((img) => !selectedNodeIds.includes(img.id));
|
| return [...selected, ...others];
|
| });
|
| setTextNotes((prev) => {
|
| const selected = prev.filter((n) => selectedNodeIds.includes(n.id));
|
| const others = prev.filter((n) => !selectedNodeIds.includes(n.id));
|
| return [...selected, ...others];
|
| });
|
| setContextMenu(null);
|
| };
|
|
|
| const handleFit = () => {
|
| setZoom(1);
|
| setPan({ x: 0, y: 0 });
|
| setContextMenu(null);
|
| };
|
|
|
| const handleExtractPalette = () => {
|
| if (contextMenu.imageId) {
|
| const img = images.find((i) => i.id === contextMenu.imageId);
|
| if (img) {
|
|
|
| const aestheticPalettes = [
|
| ["#2A363B", "#E84A5F", "#FF847C", "#FECEAB", "#99B898"],
|
| ["#F8B195", "#F67280", "#C06C84", "#6C5B7B", "#355C7D"],
|
| ["#1A1A1D", "#4E4E50", "#6F2232", "#950740", "#C3073F"],
|
| ];
|
| setPalettes((prev) => {
|
| const existing = prev.filter((p) => p.imageId !== img.id);
|
| return [
|
| ...existing,
|
| {
|
| imageId: img.id,
|
| colors:
|
| aestheticPalettes[
|
| Math.floor(Math.random() * aestheticPalettes.length)
|
| ],
|
| x: img.x,
|
| y: img.y + img.height + 20,
|
| },
|
| ];
|
| });
|
| }
|
| }
|
| setContextMenu(null);
|
| };
|
|
|
| const handleGroup = () => {
|
| const groupId = Math.random().toString(36).substr(2, 9);
|
| setImages((prev) =>
|
| prev.map((img) =>
|
| selectedNodeIds.includes(img.id) ? { ...img, groupId } : img,
|
| ),
|
| );
|
| setTextNotes((prev) =>
|
| prev.map((n) =>
|
| selectedNodeIds.includes(n.id) ? { ...n, groupId } : n,
|
| ),
|
| );
|
| setAnnotations((prev) =>
|
| prev.map((ann) =>
|
| selectedNodeIds.includes(ann.id) ? { ...ann, groupId } : ann,
|
| ),
|
| );
|
| setContextMenu(null);
|
| };
|
|
|
| const handleUngroup = () => {
|
| setImages((prev) =>
|
| prev.map((img) =>
|
| selectedNodeIds.includes(img.id)
|
| ? { ...img, groupId: undefined }
|
| : img,
|
| ),
|
| );
|
| setTextNotes((prev) =>
|
| prev.map((n) =>
|
| selectedNodeIds.includes(n.id)
|
| ? { ...n, groupId: undefined }
|
| : n,
|
| ),
|
| );
|
| setAnnotations((prev) =>
|
| prev.map((ann) =>
|
| selectedNodeIds.includes(ann.id)
|
| ? { ...ann, groupId: undefined }
|
| : ann,
|
| ),
|
| );
|
| setContextMenu(null);
|
| };
|
|
|
| const handleAddTextNote = () => {
|
| const newX = (contextMenu.x - pan.x) / zoom;
|
| const newY = (contextMenu.y - pan.y) / zoom;
|
| setTextNotes((prev) => [
|
| ...prev,
|
| {
|
| id: Math.random().toString(36).substr(2, 9),
|
| text: "New Note",
|
| x: newX,
|
| y: newY,
|
| 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-panel-bg border border-panel-border shadow-2xl rounded-md py-1.5 w-48 z-[70] text-[13px] text-ui-primary"
|
| style={{ top: contextMenu.y, left: contextMenu.x }}
|
| >
|
| {contextMenu.imageId ? (
|
| <>
|
| <MenuItem onClick={handleFit}>Fit to canvas</MenuItem>
|
| <MenuItem onClick={handleDesaturate}>Desaturate / Restore</MenuItem>
|
| <div className="h-px bg-panel-border my-1.5" />
|
| <MenuItem onClick={handleFlipH}>Flip horizontal</MenuItem>
|
| <MenuItem onClick={handleFlipV}>Flip vertical</MenuItem>
|
| <div className="h-px bg-panel-border my-1.5" />
|
| <MenuItem onClick={handleExtractPalette}>Extract palette</MenuItem>
|
| <MenuItem
|
| onClick={() => {
|
| setIsAnnotationMode(true);
|
| setContextMenu(null);
|
| }}
|
| >
|
| Add annotation
|
| </MenuItem>
|
| <MenuItem
|
| onClick={async () => {
|
| if (contextMenu.imageId) {
|
| const img = images.find((i) => i.id === contextMenu.imageId);
|
| if (img) {
|
| try {
|
| const response = await fetch(img.url);
|
| const blob = await response.blob();
|
| await navigator.clipboard.write([
|
| new ClipboardItem({
|
| [blob.type]: blob,
|
| }),
|
| ]);
|
| } catch (err) {
|
| // fallback to URL if image is cross-origin restricted
|
| navigator.clipboard.writeText(img.url);
|
| }
|
| }
|
| }
|
| setContextMenu(null);
|
| }}
|
| >
|
| Copy image
|
| </MenuItem>
|
| <MenuItem
|
| onClick={() => {
|
| window.open(
|
| images.find((i) => i.id === contextMenu.imageId)?.url,
|
| "_blank",
|
| );
|
| setContextMenu(null);
|
| }}
|
| >
|
| Open source URL
|
| </MenuItem>
|
| <div className="h-px bg-panel-border my-1.5" />
|
| {selectedNodeIds.length > 1 && (
|
| <>
|
| <MenuItem
|
| onClick={() => {
|
| // Layout in Row
|
| const selected = images.filter((img) =>
|
| selectedNodeIds.includes(img.id),
|
| );
|
| if (selected.length < 2) return;
|
|
|
| // Sort by current X position
|
| selected.sort((a, b) => a.x - b.x);
|
|
|
| const startX = selected[0].x;
|
| const startY = selected[0].y;
|
| const targetHeight = selected[0].height;
|
|
|
| let currentX = startX;
|
| const gap = 20;
|
|
|
| const newImages = images.map((img) => {
|
| if (selectedNodeIds.includes(img.id)) {
|
| const sortedIndex = selected.findIndex(
|
| (s) => s.id === img.id,
|
| );
|
| // Scale to match height
|
| const scale = targetHeight / img.height;
|
| const newWidth = img.width * scale;
|
|
|
| // we have to calculate the X position based on all previous items' widths
|
| let curX = startX;
|
| for (let i = 0; i < sortedIndex; i++) {
|
| curX +=
|
| selected[i].width *
|
| (targetHeight / selected[i].height) +
|
| gap;
|
| }
|
|
|
| return {
|
| ...img,
|
| x: curX,
|
| y: startY,
|
| width: newWidth,
|
| height: targetHeight,
|
| };
|
| }
|
| return img;
|
| });
|
| setImages(newImages);
|
| setContextMenu(null);
|
| }}
|
| >
|
| Organize in Row
|
| </MenuItem>
|
| <MenuItem onClick={handleGroup}>Group selection</MenuItem>
|
| </>
|
| )}
|
| <MenuItem onClick={handleUngroup}>Ungroup</MenuItem>
|
| <div className="h-px bg-panel-border my-1.5" />
|
| <MenuItem onClick={handleBringToFront}>Bring to front</MenuItem>
|
| <MenuItem onClick={handleSendToBack}>Send to back</MenuItem>
|
| <div className="h-px bg-panel-border my-1.5" />
|
| <MenuItem
|
| className="text-[#FF453A] hover:bg-[#FF453A]"
|
| onClick={handleDelete}
|
| >
|
| Delete selection
|
| </MenuItem>
|
| </>
|
| ) : (
|
| <>
|
| <MenuItem onClick={handleFit}>Fit to canvas (Reset View)</MenuItem>
|
| <MenuItem
|
| onClick={() => {
|
| setAnnotations([]);
|
| setContextMenu(null);
|
| }}
|
| >
|
| Clear all annotations
|
| </MenuItem>
|
| <div className="h-px bg-panel-border my-1.5" />
|
| <MenuItem onClick={handleAddTextNote}>Add Text Note</MenuItem>
|
| <MenuItem
|
| onClick={() => {
|
| setIsAnnotationMode(true);
|
| setContextMenu(null);
|
| }}
|
| >
|
| Add annotation
|
| </MenuItem>
|
| </>
|
| )}
|
| </div>
|
| </>
|
| );
|
| };
|
|
|