Files
MajoorWaldi-ComfyUI-Majoor-…/js/components/ContextMenu.js
T

320 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Context Menu Component
* Right-click menu for asset cards.
*
* Notes:
* - Idempotent: binds once per grid container.
* - Safe defaults: only uses existing backend routes.
* - Uses PrimeIcons (`pi pi-*`) for icons.
*/
import { getViewerInstance } from "./Viewer.js";
import { buildAssetViewURL } from "../api/endpoints.js";
import { comfyAlert } from "../app/dialogs.js";
import { openInFolder } from "../api/client.js";
import { showAddToCollectionMenu } from "../features/collections/contextmenu/addToCollectionMenu.js";
// NOTE: Keep this menu isolated from the newer grid context menu
// (`.mjr-grid-context-menu`) to avoid selector collisions.
const MENU_SELECTOR = ".mjr-legacy-context-menu";
export function createContextMenu() {
const existing = document.querySelector(MENU_SELECTOR);
if (existing) return existing;
const menu = document.createElement("div");
menu.className = "mjr-context-menu mjr-legacy-context-menu";
menu.style.cssText = `
position: fixed;
background: var(--comfy-menu-bg);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
min-width: 200px;
z-index: 9999;
display: none;
padding: 4px 0;
`;
const hide = () => {
menu.style.display = "none";
};
const ac = new AbortController();
menu._mjrAbortController = ac;
document.addEventListener(
"click",
(e) => {
if (!menu.contains(e.target)) hide();
},
{ signal: ac.signal }
);
document.addEventListener(
"keydown",
(e) => {
if (e.key === "Escape") hide();
},
{ signal: ac.signal }
);
document.addEventListener("scroll", hide, { capture: true, signal: ac.signal });
document.body.appendChild(menu);
return menu;
}
function createMenuItem(label, iconClass, shortcut, onClick) {
const item = document.createElement("div");
item.className = "mjr-context-menu-item";
item.style.cssText = `
padding: 8px 16px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
color: var(--fg-color);
font-size: 13px;
transition: background 0.15s;
user-select: none;
`;
const leftSide = document.createElement("div");
leftSide.style.cssText = "display: flex; align-items: center; gap: 10px;";
if (iconClass) {
const iconEl = document.createElement("i");
iconEl.className = iconClass;
iconEl.style.cssText = "width: 16px; text-align: center; opacity: 0.8;";
leftSide.appendChild(iconEl);
}
const labelEl = document.createElement("span");
labelEl.textContent = label;
leftSide.appendChild(labelEl);
item.appendChild(leftSide);
if (shortcut) {
const shortcutEl = document.createElement("span");
shortcutEl.textContent = shortcut;
shortcutEl.style.cssText = "font-size: 11px; opacity: 0.5; margin-left: 20px;";
item.appendChild(shortcutEl);
}
item.addEventListener("mouseenter", () => {
item.style.background = "var(--comfy-input-bg)";
});
item.addEventListener("mouseleave", () => {
item.style.background = "transparent";
});
item.addEventListener("click", (e) => {
e.stopPropagation();
try {
onClick?.();
} catch {}
});
return item;
}
function createMenuSeparator() {
const separator = document.createElement("div");
separator.className = "mjr-context-menu-separator";
separator.style.cssText = `
height: 1px;
background: var(--border-color);
margin: 4px 0;
`;
return separator;
}
export function showContextMenu(x, y, asset, allAssets, currentIndex, selectedAssets = [], { onDetails } = {}) {
const menu = createContextMenu();
menu.innerHTML = "";
const selectedCount = selectedAssets.length;
const hide = () => {
try {
menu.style.display = "none";
} catch {}
};
const openViewer = createMenuItem("Open in Viewer", "pi pi-eye", "Double-click", () => {
const viewer = getViewerInstance();
if (selectedCount >= 2 && selectedCount <= 4) {
viewer.open(selectedAssets, 0);
} else {
viewer.open(allAssets, currentIndex);
}
});
menu.appendChild(openViewer);
const details = createMenuItem("Details", "pi pi-info-circle", "D", () => {
hide();
try {
onDetails?.(asset);
} catch {}
});
menu.appendChild(details);
const openNewTab = createMenuItem("Open in New Tab", "pi pi-external-link", null, () => {
hide();
const url = buildAssetViewURL(asset);
window.open(url, "_blank");
});
menu.appendChild(openNewTab);
menu.appendChild(createMenuSeparator());
if (selectedCount === 2) {
const ab = createMenuItem("Compare A/B (2 selected)", "pi pi-clone", null, () => {
const viewer = getViewerInstance();
viewer.open(selectedAssets, 0);
viewer.setMode("ab");
});
menu.appendChild(ab);
}
if (selectedCount >= 2 && selectedCount <= 4) {
const side = createMenuItem(`Side-by-side (2×2) (${selectedCount} selected)`, "pi pi-table", null, () => {
const viewer = getViewerInstance();
viewer.open(selectedAssets, 0);
viewer.setMode("sidebyside");
});
menu.appendChild(side);
}
const copyPath = createMenuItem("Copy file path", "pi pi-copy", null, async () => {
try {
const path = asset?.filepath ? String(asset.filepath) : "";
if (!path) {
await comfyAlert("No file path available for this asset.");
return;
}
await navigator.clipboard.writeText(path);
} catch (error) {
console.error("Copy path failed:", error);
}
hide();
});
menu.appendChild(copyPath);
const addToCollection = createMenuItem(
selectedCount > 1 ? `Add to collection... (${selectedCount})` : "Add to collection...",
"pi pi-bookmark",
null,
async () => {
hide();
try {
const list = selectedAssets?.length ? selectedAssets : [asset];
await showAddToCollectionMenu({ x, y, assets: list });
} catch (err) {
console.error("Add to collection failed:", err);
}
}
);
menu.appendChild(addToCollection);
const openFolder = createMenuItem("Open in Folder", "pi pi-folder-open", null, async () => {
try {
const assetId = asset?.id;
if (!assetId) {
await comfyAlert("No asset id available for this item.");
return;
}
const res = await openInFolder(assetId);
if (!res?.ok) {
await comfyAlert(res?.error || "Failed to open folder.");
}
} catch (error) {
console.error("Open in folder failed:", error);
}
hide();
});
menu.appendChild(openFolder);
menu.style.display = "block";
const rect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let finalX = x;
let finalY = y;
if (x + rect.width > viewportWidth) {
finalX = viewportWidth - rect.width - 10;
}
if (y + rect.height > viewportHeight) {
finalY = viewportHeight - rect.height - 10;
}
menu.style.left = `${finalX}px`;
menu.style.top = `${finalY}px`;
}
export function bindContextMenu(gridContainer, assets) {
try {
gridContainer._mjrContextMenuAssets = Array.isArray(assets) ? assets : [];
} catch {}
// If the panel has the newer grid context menu bound, don't bind the legacy one.
try {
if (gridContainer?._mjrGridContextMenuBound) return;
} catch {}
if (gridContainer._mjrContextMenuBound) return;
gridContainer._mjrContextMenuBound = true;
gridContainer.addEventListener("contextmenu", (e) => {
const card = e.target.closest(".mjr-asset-card");
if (!card) return;
e.preventDefault();
e.stopPropagation();
// If right-clicking on a non-selected card, treat it as a single selection.
try {
if (!card.classList.contains("is-selected")) {
for (const el of gridContainer.querySelectorAll(".mjr-asset-card.is-selected")) {
el.classList.remove("is-selected");
el.setAttribute?.("aria-selected", "false");
}
card.classList.add("is-selected");
card.setAttribute?.("aria-selected", "true");
try {
const id = card?.dataset?.mjrAssetId;
if (id) {
gridContainer.dataset.mjrSelectedAssetId = String(id);
gridContainer.dataset.mjrSelectedAssetIds = JSON.stringify([String(id)]);
}
} catch {}
}
} catch {}
const currentAsset = card._mjrAsset;
if (!currentAsset) return;
const list = gridContainer._mjrContextMenuAssets || [];
const idx = Array.isArray(list) ? list.findIndex((a) => a.id === currentAsset.id) : -1;
const selected = Array.from(gridContainer.querySelectorAll(".mjr-asset-card.is-selected"))
.map((c) => c?._mjrAsset)
.filter(Boolean);
// Keep dataset selection in sync so selection survives grid re-renders.
try {
const ids = Array.from(gridContainer.querySelectorAll(".mjr-asset-card.is-selected"))
.map((c) => c?.dataset?.mjrAssetId)
.filter(Boolean)
.map(String);
gridContainer.dataset.mjrSelectedAssetIds = JSON.stringify(ids);
if (card?.dataset?.mjrAssetId) gridContainer.dataset.mjrSelectedAssetId = String(card.dataset.mjrAssetId);
} catch {}
showContextMenu(e.clientX, e.clientY, currentAsset, list, idx, selected, {
onDetails: typeof gridContainer._mjrOpenDetails === "function" ? gridContainer._mjrOpenDetails : null,
});
});
}