musealpha / uiprototype2 /src /components /ContextMenu.tsx
asdf98's picture
Upload 112 files
3d7d9b5 verified
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) {
// Mock palette extraction with aesthetic colors
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>
</>
);
};