Files
MajoorWaldi-ComfyUI-Majoor-…/js/features/contextmenu/GridContextMenu.js
T
MajoorWaldi 2fa41a0e76 feat: add useGridState and useVirtualGrid composables for asset management
- Implemented useGridState to manage grid state, including asset selection and loading states.
- Created useVirtualGrid to centralize virtualization helpers for improved performance.
- Added createVueApp for isolated Vue 3 + Pinia application instances.
- Introduced Vite configuration for building the ComfyUI-Majoor-AssetsManager with external dependencies.
2026-04-03 11:04:55 +02:00

1209 lines
43 KiB
JavaScript

/**
* GridContextMenu.js
*
* Vue now renders the menu DOM. This module only:
* - detects right-click context
* - computes the menu model
* - executes menu actions
*/
import {
browserFolderOp,
deleteAsset,
getAssetMetadata,
openInFolder,
post,
removeFilepathsFromCollection,
renameAsset,
updateAssetRating,
} from "../../api/client.js";
import { ENDPOINTS, buildDownloadURL } from "../../api/endpoints.js";
import { ASSET_RATING_CHANGED_EVENT } from "../../app/events.js";
import { t } from "../../app/i18n.js";
import { comfyConfirm, comfyPrompt } from "../../app/dialogs.js";
import { comfyToast } from "../../app/toast.js";
import { APP_CONFIG } from "../../app/config.js";
import { createWorkflowDot } from "../../components/Badges.js";
import { normalizeRenameFilename, validateFilename } from "../../utils/filenames.js";
import { reportError } from "../../utils/logging.js";
import { safeClosest, safeEscapeSelector } from "../../utils/dom.js";
import { safeDispatchCustomEvent } from "../../utils/events.js";
import { confirmDeletion } from "../../utils/deleteGuard.js";
import { showAddToCollectionMenu } from "../collections/contextmenu/addToCollectionMenu.js";
import { removeAssetsFromGrid } from "../grid/gridApi.js";
import { getShortcutDisplay } from "../grid/GridKeyboard.js";
import {
closeAllGridContextMenus,
openGridContextMenu,
openGridTagsPopover,
} from "./gridContextMenuState.js";
import { cancelAllRatingUpdates, scheduleRatingUpdate } from "./ratingUpdater.js";
import { requestViewerOpen } from "../viewer/viewerOpenRequest.js";
let _nextMenuItemId = 1;
function createItem(
label,
iconClass,
rightHint,
action,
{ disabled = false, closeOnSelect = true, submenu = null } = {},
) {
return {
id: `mjr-grid-menu-item-${_nextMenuItemId++}`,
type: "item",
label: String(label || ""),
iconClass: iconClass ? String(iconClass) : "",
rightHint: rightHint ? String(rightHint) : "",
disabled: !!disabled,
closeOnSelect,
submenu: Array.isArray(submenu) && submenu.length ? submenu : null,
action: typeof action === "function" ? action : null,
};
}
function createSeparator() {
return {
id: `mjr-grid-menu-separator-${_nextMenuItemId++}`,
type: "separator",
};
}
const getSelectedAssetIds = (gridContainer) => {
const selected = new Set();
if (!gridContainer) return selected;
try {
const raw = gridContainer.dataset?.mjrSelectedAssetIds;
if (raw) {
const list = JSON.parse(raw);
if (Array.isArray(list)) {
list.forEach((id) => selected.add(String(id)));
return selected;
}
}
} catch (e) {
console.debug?.(e);
}
try {
const cards = gridContainer.querySelectorAll(".mjr-asset-card.is-selected");
for (const card of cards) {
const id = card.dataset?.mjrAssetId;
if (id) selected.add(String(id));
}
} catch (e) {
console.debug?.(e);
}
return selected;
};
const getSelectedAssets = (gridContainer) => {
const assets = [];
if (!gridContainer) return assets;
try {
const raw = gridContainer.dataset?.mjrSelectedAssetIds;
if (raw) {
const ids = JSON.parse(raw);
if (Array.isArray(ids) && ids.length) {
const all =
typeof gridContainer?._mjrGetAssets === "function"
? gridContainer._mjrGetAssets()
: [];
const idSet = new Set(ids.map(String));
for (const asset of all || []) {
const id = String(asset?.id || "");
if (id && idSet.has(id)) assets.push(asset);
}
if (assets.length) return assets;
}
}
} catch (e) {
console.debug?.(e);
}
try {
const cards = gridContainer.querySelectorAll(".mjr-asset-card.is-selected");
for (const card of cards) {
const asset = card?._mjrAsset;
if (asset) assets.push(asset);
}
} catch (e) {
console.debug?.(e);
}
return assets;
};
const getAssetFilepath = (asset) => {
try {
const filepath = asset?.filepath || asset?.path || asset?.file_info?.filepath || "";
return filepath ? String(filepath) : "";
} catch {
return "";
}
};
const clearSelection = (gridContainer) => {
if (!gridContainer) return;
try {
if (typeof gridContainer?._mjrSetSelection === "function") {
gridContainer._mjrSetSelection([], "");
} else {
const cards = gridContainer.querySelectorAll(".mjr-asset-card.is-selected");
for (const card of cards) {
card.classList.remove("is-selected");
card.setAttribute("aria-selected", "false");
}
delete gridContainer.dataset.mjrSelectedAssetIds;
delete gridContainer.dataset.mjrSelectedAssetId;
}
} catch (e) {
console.debug?.(e);
}
safeDispatchCustomEvent(
"mjr:selection-changed",
{ selectedIds: [] },
{ target: gridContainer, warnPrefix: "[GridContextMenu]" },
);
};
const selectCardForDetails = (gridContainer, card, asset, state) => {
if (!gridContainer) return;
try {
clearSelection(gridContainer);
} catch (e) {
console.debug?.(e);
}
try {
card?.classList?.add?.("is-selected");
card?.setAttribute?.("aria-selected", "true");
} catch (e) {
console.debug?.(e);
}
const id = asset?.id != null ? String(asset.id) : "";
if (id) {
try {
if (typeof gridContainer?._mjrSetSelection === "function") {
gridContainer._mjrSetSelection([id], id);
} else {
gridContainer.dataset.mjrSelectedAssetIds = JSON.stringify([id]);
gridContainer.dataset.mjrSelectedAssetId = id;
}
} catch (e) {
console.debug?.(e);
}
}
if (state && typeof state === "object") {
try {
state.selectedAssetIds = id ? [id] : [];
state.activeAssetId = id;
} catch (e) {
console.debug?.(e);
}
}
};
const getAllAssetsInGrid = (gridContainer) => {
const assets = [];
if (!gridContainer) return assets;
try {
const getter = gridContainer._mjrGetAssets;
if (typeof getter === "function") {
const list = getter();
if (Array.isArray(list) && list.length) return list;
}
} catch (e) {
console.debug?.(e);
}
try {
const cards = gridContainer.querySelectorAll(".mjr-asset-card");
for (const card of cards) {
const asset = card?._mjrAsset;
if (asset) assets.push(asset);
}
} catch (e) {
console.debug?.(e);
}
return assets;
};
const findIndexById = (assets, assetId) => {
try {
const id = String(assetId ?? "");
return (assets || []).findIndex((asset) => String(asset?.id ?? "") === id);
} catch {
return -1;
}
};
function findRenderedCard(assetId) {
if (assetId == null) return null;
try {
return document.querySelector(`[data-mjr-asset-id="${safeEscapeSelector(assetId)}"]`);
} catch (e) {
console.debug?.(e);
return null;
}
}
function syncRenderedCardAsset(asset, patch = null) {
const card = findRenderedCard(asset?.id);
if (!card || !card._mjrAsset || typeof card._mjrAsset !== "object") return card;
try {
if (patch && typeof patch === "object") Object.assign(card._mjrAsset, patch);
else if (asset && typeof asset === "object") Object.assign(card._mjrAsset, asset);
} catch (e) {
console.debug?.(e);
}
return card;
}
const triggerBrowserGridReload = (gridContainer) => {
try {
const reason = gridContainer ? "grid-contextmenu" : "grid-contextmenu-global";
window?.dispatchEvent?.(new CustomEvent("mjr:reload-grid", { detail: { reason } }));
} catch (e) {
console.debug?.(e);
}
};
async function resetIndexForSingleAsset(asset, gridContainer) {
if (!asset) return { ok: false, error: "Missing asset" };
const fileEntry = {
filename: String(asset.filename || "").trim(),
subfolder: String(asset.subfolder || "").trim(),
type:
String(asset.type || asset.source || "output")
.trim()
.toLowerCase() || "output",
root_id:
String(asset.root_id || asset.rootId || asset?.file_info?.root_id || "").trim() ||
undefined,
};
if (!fileEntry.filename) return { ok: false, error: "Missing filename" };
const indexRes = await post(ENDPOINTS.INDEX_FILES, { files: [fileEntry], incremental: false });
if (!indexRes?.ok) return indexRes || { ok: false, error: "Index refresh failed" };
if (asset?.id != null) {
try {
const fresh = await getAssetMetadata(asset.id);
if (fresh?.ok && fresh?.data && typeof fresh.data === "object") {
Object.assign(asset, fresh.data);
const card = syncRenderedCardAsset(asset, fresh.data);
const oldDot = card?.querySelector?.(".mjr-workflow-dot");
if (oldDot) {
const nextDot = createWorkflowDot(card._mjrAsset);
if (nextDot) oldDot.replaceWith(nextDot);
}
}
} catch (e) {
console.debug?.(e);
}
}
triggerBrowserGridReload(gridContainer);
return { ok: true, data: { refreshed: true } };
}
function setRating(asset, rating, onChanged) {
const assetId = asset?.id;
try {
asset.rating = rating;
} catch (e) {
console.debug?.(e);
}
try {
onChanged?.();
} catch (e) {
console.debug?.(e);
}
if (assetId) {
scheduleRatingUpdate(String(assetId), rating, {
successMessage: rating > 0 ? `Rating set to ${rating} stars` : "Rating cleared",
errorMessage: "Failed to update rating",
warnPrefix: "[GridContextMenu]",
onSuccess: () => {
safeDispatchCustomEvent(
ASSET_RATING_CHANGED_EVENT,
{ assetId: String(assetId), rating },
{ warnPrefix: "[GridContextMenu]" },
);
},
onFailure: (error) => {
reportError(error, "[GridContextMenu] Rating update", {
showToast: APP_CONFIG.DEBUG_VERBOSE_ERRORS,
});
},
});
return;
}
updateAssetRating(asset, rating).catch((error) => {
reportError(error, "[GridContextMenu] Rating update", {
showToast: APP_CONFIG.DEBUG_VERBOSE_ERRORS,
});
});
}
function _isBrowserScope(panelState) {
return String(panelState?.scope || "").toLowerCase() === "custom";
}
function _isFolderAsset(asset) {
return String(asset?.kind || "").toLowerCase() === "folder";
}
function _getSelectionSnapshot(gridContainer) {
const selectedIds = getSelectedAssetIds(gridContainer);
const selectedAssetsNow = getSelectedAssets(gridContainer);
const effectiveSelectionCount = selectedAssetsNow.length || selectedIds.size;
return {
selectedIds,
selectedAssetsNow,
effectiveSelectionCount,
isMultiSelected: effectiveSelectionCount > 1,
hasSelection: effectiveSelectionCount > 0,
};
}
function _ensureRightClickSelection({ asset, card, gridContainer }) {
let selectedIds = getSelectedAssetIds(gridContainer);
const cardSelected = !!card?.classList?.contains?.("is-selected");
if (!asset || cardSelected) return selectedIds;
const nextId = asset?.id != null ? String(asset.id) : "";
const newSelection = nextId ? [nextId] : [];
try {
if (nextId && typeof gridContainer?._mjrSetSelection === "function") {
gridContainer._mjrSetSelection(newSelection, newSelection[0]);
} else if (nextId) {
gridContainer.dataset.mjrSelectedAssetIds = JSON.stringify(newSelection);
gridContainer.dataset.mjrSelectedAssetId = newSelection[0];
} else {
delete gridContainer.dataset.mjrSelectedAssetIds;
delete gridContainer.dataset.mjrSelectedAssetId;
}
} catch (e) {
console.debug?.(e);
}
safeDispatchCustomEvent(
"mjr:selection-changed",
{
selectedIds: newSelection,
selectedAssets: [asset],
activeId: nextId,
},
{ target: gridContainer, warnPrefix: "[GridContextMenu]" },
);
selectedIds = new Set(newSelection);
try {
gridContainer.querySelectorAll(".mjr-asset-card.is-selected").forEach((el) => {
el.classList.remove("is-selected");
el.setAttribute?.("aria-selected", "false");
});
card?.classList?.add?.("is-selected");
card?.setAttribute?.("aria-selected", "true");
} catch (e) {
console.debug?.(e);
}
return selectedIds;
}
function _buildRatingSubmenu(asset, canRate) {
const stars = (n) => "*".repeat(n) + "o".repeat(Math.max(0, 5 - n));
const updateRenderedRating = (value) => {
syncRenderedCardAsset(asset, { rating: value });
};
return [
createItem(stars(5), "pi pi-star", null, async () => {
setRating(asset, 5, () => updateRenderedRating(5));
}, { disabled: !canRate }),
createItem(stars(4), "pi pi-star", null, async () => {
setRating(asset, 4, () => updateRenderedRating(4));
}, { disabled: !canRate }),
createItem(stars(3), "pi pi-star", null, async () => {
setRating(asset, 3, () => updateRenderedRating(3));
}, { disabled: !canRate }),
createItem(stars(2), "pi pi-star", null, async () => {
setRating(asset, 2, () => updateRenderedRating(2));
}, { disabled: !canRate }),
createItem(stars(1), "pi pi-star", null, async () => {
setRating(asset, 1, () => updateRenderedRating(1));
}, { disabled: !canRate }),
createSeparator(),
createItem(
t("ctx.resetRating", "Reset rating"),
"pi pi-star",
"0",
async () => {
setRating(asset, 0, () => updateRenderedRating(0));
},
{ disabled: !canRate },
),
];
}
function _buildEmptyGridItems({ currentPath, gridContainer }) {
return [
createItem(
t("ctx.createFolderHere", "Create folder here..."),
"pi pi-plus",
null,
async () => {
try {
const name = await comfyPrompt(t("dialog.newFolderName", "New folder name"), "");
const next = String(name || "").trim();
if (!next) return;
const res = await browserFolderOp({
op: "create",
path: currentPath,
name: next,
});
if (!res?.ok) {
comfyToast(res?.error || "Failed to create folder", "error");
return;
}
triggerBrowserGridReload(gridContainer);
comfyToast(
t("toast.folderCreated", "Folder created: {name}", { name: next }),
"success",
);
} catch (error) {
comfyToast(
t("toast.createFolderFailedDetail", "Create folder failed: {error}", {
error: error?.message || String(error || ""),
}),
"error",
);
}
},
),
];
}
function _buildFolderItems({ asset, gridContainer, panelState }) {
const folderPath = String(asset?.filepath || "").trim();
const items = [
createItem(t("ctx.openFolder", "Open folder"), "pi pi-folder-open", null, () => {
try {
gridContainer.dispatchEvent(
new CustomEvent("mjr:open-folder-asset", {
bubbles: true,
detail: { asset },
}),
);
} catch (e) {
console.debug?.(e);
}
}),
createItem(t("ctx.pinAsBrowserRoot"), "pi pi-bookmark", null, async () => {
if (!folderPath) {
comfyToast(t("toast.unableResolveFolderPath"), "error");
return;
}
const label = await comfyPrompt(
t("dialog.browserRootLabelOptional"),
String(asset?.filename || ""),
);
const res = await post(ENDPOINTS.CUSTOM_ROOTS, {
path: folderPath,
label: String(label || "").trim() || undefined,
});
if (!res?.ok) {
comfyToast(res?.error || t("toast.pinFolderFailed"), "error");
return;
}
comfyToast(t("toast.folderPinnedAsBrowserRoot"), "success");
try {
window.dispatchEvent(
new CustomEvent("mjr:custom-roots-changed", {
detail: { preferredId: String(res?.data?.id || "") },
}),
);
} catch (e) {
console.debug?.(e);
}
}),
];
if (_isBrowserScope(panelState) && folderPath) {
items.push(
createSeparator(),
createItem(t("ctx.createFolderHere"), "pi pi-plus", null, async () => {
try {
const name = await comfyPrompt(t("dialog.newFolderName"), "");
const next = String(name || "").trim();
if (!next) return;
const res = await browserFolderOp({
op: "create",
path: folderPath,
name: next,
});
if (!res?.ok) {
comfyToast(res?.error || t("toast.createFolderFailed"), "error");
return;
}
triggerBrowserGridReload(gridContainer);
comfyToast(t("toast.folderCreated", { name: next }), "success");
} catch (error) {
comfyToast(
t("toast.createFolderFailedDetail", {
error: error?.message || String(error || ""),
}),
"error",
);
}
}),
createItem(t("ctx.renameFolder"), "pi pi-pencil", null, async () => {
try {
const current = String(asset?.filename || "").trim();
const name = await comfyPrompt(t("dialog.renameFolder"), current);
const next = String(name || "").trim();
if (!next || next === current) return;
const res = await browserFolderOp({
op: "rename",
path: folderPath,
name: next,
});
if (!res?.ok) {
comfyToast(res?.error || t("toast.renameFolderFailed"), "error");
return;
}
triggerBrowserGridReload(gridContainer);
comfyToast(t("toast.folderRenamed"), "success");
} catch (error) {
comfyToast(
t("toast.renameFolderFailedDetail", {
error: error?.message || String(error || ""),
}),
"error",
);
}
}),
createItem(t("ctx.moveFolder"), "pi pi-arrow-right", null, async () => {
try {
const destination = await comfyPrompt(
t("dialog.destinationDirectoryPath"),
"",
);
const dest = String(destination || "").trim();
if (!dest) return;
const res = await browserFolderOp({
op: "move",
path: folderPath,
destination: dest,
});
if (!res?.ok) {
comfyToast(res?.error || t("toast.moveFolderFailed"), "error");
return;
}
triggerBrowserGridReload(gridContainer);
comfyToast(t("toast.folderMoved"), "success");
} catch (error) {
comfyToast(
t("toast.moveFolderFailedDetail", {
error: error?.message || String(error || ""),
}),
"error",
);
}
}),
createItem(t("ctx.deleteFolder"), "pi pi-trash", null, async () => {
try {
const folderName = String(
asset?.filename || t("label.thisFolder", "this folder"),
);
const ok = await comfyConfirm(
t("dialog.deleteFolderRecursive", { name: folderName }),
);
if (!ok) return;
const res = await browserFolderOp({
op: "delete",
path: folderPath,
recursive: true,
});
if (!res?.ok) {
comfyToast(res?.error || t("toast.deleteFolderFailed"), "error");
return;
}
triggerBrowserGridReload(gridContainer);
comfyToast(t("toast.folderDeleted"), "success");
} catch (error) {
comfyToast(
t("toast.deleteFolderFailedDetail", {
error: error?.message || String(error || ""),
}),
"error",
);
}
}),
);
}
return items;
}
function _buildAssetItems({
asset,
card,
gridContainer,
panelState,
pointerX,
pointerY,
selection,
}) {
const {
selectedAssetsNow,
effectiveSelectionCount,
isMultiSelected,
hasSelection,
} = selection;
const items = [];
const openInViewer = ({ assets = [], index = 0, mode = "" } = {}) =>
requestViewerOpen({ assets, index, mode });
items.push(
createItem("Open Viewer", "pi pi-image", getShortcutDisplay("OPEN_VIEWER"), () => {
try {
const selectedAssets = hasSelection ? getSelectedAssets(gridContainer) : [];
const mediaSelectedAssets = selectedAssets.filter((entry) => !_isFolderAsset(entry));
const selectedCount = mediaSelectedAssets.length;
if (selectedCount >= 2 && selectedCount <= 4) {
openInViewer({ assets: mediaSelectedAssets, index: 0 });
return;
}
const allAssets = getAllAssetsInGrid(gridContainer).filter(
(entry) => !_isFolderAsset(entry),
);
const index = findIndexById(allAssets, asset?.id);
if (!allAssets.length || index < 0) return;
openInViewer({ assets: allAssets, index: Math.max(0, index) });
} catch (e) {
console.debug?.(e);
}
}),
createItem(
t("ctx.showMetadataPanel", "Show metadata panel"),
"pi pi-info-circle",
getShortcutDisplay("METADATA_PANEL"),
() => {
try {
selectCardForDetails(gridContainer, card, asset, panelState);
const openDetails = gridContainer?._mjrOpenDetails;
if (typeof openDetails === "function") openDetails();
} catch (e) {
console.debug?.(e);
}
},
),
);
const mediaSelectedAssets = (hasSelection ? selectedAssetsNow : []).filter(
(entry) => !_isFolderAsset(entry),
);
const selectedCount = mediaSelectedAssets.length;
if (selectedCount === 2) {
items.push(
createItem(
"Compare A/B (2 selected)",
"pi pi-clone",
getShortcutDisplay("COMPARE_AB"),
() => {
try {
openInViewer({ assets: mediaSelectedAssets, index: 0, mode: "ab" });
} catch (e) {
console.debug?.(e);
}
},
),
);
}
if (selectedCount >= 2 && selectedCount <= 4) {
items.push(
createItem(
`Side-by-side (2x2) (${selectedCount} selected)`,
"pi pi-table",
getShortcutDisplay("SIDE_BY_SIDE"),
() => {
try {
openInViewer({
assets: mediaSelectedAssets,
index: 0,
mode: "sidebyside",
});
} catch (e) {
console.debug?.(e);
}
},
),
);
}
items.push(
createItem(
t("ctx.openInFolder", "Open in folder"),
"pi pi-folder-open",
getShortcutDisplay("OPEN_IN_FOLDER"),
async () => {
const res = await openInFolder(asset);
if (!res?.ok) {
comfyToast(res?.error || t("toast.openFolderFailed"), "error");
} else {
comfyToast(t("toast.openedInFolder"), "info", 2000);
}
},
{ disabled: !(asset?.id || getAssetFilepath(asset)) },
),
createItem(
t("ctx.copyPath", "Copy path"),
"pi pi-copy",
getShortcutDisplay("COPY_PATH"),
async () => {
const filepath = asset?.filepath ? String(asset.filepath) : "";
if (!filepath) {
comfyToast(t("toast.noFilePath"), "error");
return;
}
try {
await navigator.clipboard.writeText(filepath);
comfyToast(t("toast.pathCopied"), "success", 2000);
} catch (err) {
console.warn(t("log.clipboardCopyFailed", "Clipboard copy failed"), err);
comfyToast(t("toast.pathCopyFailed"), "error");
}
},
),
createItem(
t("ctx.download", "Download"),
"pi pi-download",
getShortcutDisplay("DOWNLOAD"),
() => {
if (!asset?.filepath) return;
const url = buildDownloadURL(asset.filepath);
const filename = asset.filename || "download";
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
comfyToast(
t("toast.downloadingFile", "Downloading {filename}...", { filename }),
"info",
3000,
);
},
{ disabled: !asset?.filepath },
),
createItem(
"Add to collection...",
"pi pi-bookmark",
getShortcutDisplay("ADD_TO_COLLECTION"),
async () => {
const selectedAssets = getSelectedAssets(gridContainer);
const list = selectedAssets.length ? selectedAssets : [asset];
await showAddToCollectionMenu({ x: pointerX, y: pointerY, assets: list });
},
),
);
const collectionId = String(panelState?.collectionId || "").trim();
if (collectionId) {
const selectedAssets = getSelectedAssets(gridContainer);
const list = selectedAssets.length ? selectedAssets : [asset];
const filepaths = list.map(getAssetFilepath).filter(Boolean);
const label =
filepaths.length > 1
? `Remove from collection (${filepaths.length})`
: "Remove from collection";
items.push(
createItem(label, "pi pi-bookmark", null, async () => {
if (!filepaths.length) {
comfyToast(t("toast.noFilePath"), "error");
return;
}
const res = await removeFilepathsFromCollection(collectionId, filepaths);
if (!res?.ok) {
comfyToast(res?.error || t("toast.removeFromCollectionFailed"), "error");
return;
}
triggerBrowserGridReload(gridContainer);
}),
);
}
const canRate = !!(asset?.id || getAssetFilepath(asset));
items.push(
createSeparator(),
createItem(
t("ctx.editTags", "Edit tags"),
"pi pi-tags",
getShortcutDisplay("EDIT_TAGS"),
() => {
showTagsPopover(pointerX + 6, pointerY + 6, asset, () => {
syncRenderedCardAsset(asset, {
tags: Array.isArray(asset?.tags) ? [...asset.tags] : [],
});
});
},
{ closeOnSelect: false },
),
createSeparator(),
createItem(
t("ctx.setRating", "Set rating"),
"pi pi-star",
"1-5 >",
null,
{
disabled: !canRate,
closeOnSelect: false,
submenu: _buildRatingSubmenu(asset, canRate),
},
),
createSeparator(),
createItem(
t("ctx.resetIndexFile", "Reset index (this file)"),
"pi pi-refresh",
null,
async () => {
if (!(asset?.filename || asset?.id)) return;
comfyToast(t("toast.rescanningFile", "Rescanning file..."), "info", 1600);
try {
const res = await resetIndexForSingleAsset(asset, gridContainer);
if (res?.ok) {
comfyToast(
t("toast.metadataRefreshed", "Metadata refreshed{suffix}", {
suffix: "",
}),
"success",
1800,
);
} else {
comfyToast(
res?.error || t("toast.resetFailed", "Failed to reset index"),
"error",
);
}
} catch (error) {
comfyToast(
`${t("toast.resetFailed", "Failed to reset index")}: ${error?.message || String(error || "")}`,
"error",
);
}
},
{ disabled: !(asset?.filename || asset?.id) },
),
createItem(
"Rename...",
"pi pi-pencil",
getShortcutDisplay("RENAME"),
async () => {
if (!(asset?.id || getAssetFilepath(asset))) return;
const currentName = asset.filename || "";
const rawInput = await comfyPrompt(
t("dialog.rename.title", "Rename file"),
currentName,
);
const newName = normalizeRenameFilename(rawInput, currentName);
if (!newName || newName === currentName) return;
const validation = validateFilename(newName);
if (!validation.valid) {
comfyToast(validation.reason, "error");
return;
}
try {
const result = await renameAsset(asset, newName);
if (!result?.ok) {
comfyToast(result?.error || t("toast.fileRenameFailed"), "error");
return;
}
const fresh = result?.data?.asset;
if (fresh && typeof fresh === "object") {
Object.assign(asset, fresh);
syncRenderedCardAsset(asset, fresh);
} else {
asset.filename = newName;
if (asset.filepath) {
asset.filepath = String(asset.filepath).replace(/[^\\/]+$/, newName);
}
if (asset.path) {
asset.path = String(asset.path).replace(/[^\\/]+$/, newName);
}
if (asset.file_info && typeof asset.file_info === "object") {
asset.file_info.filename = newName;
if (asset.file_info.filepath) {
asset.file_info.filepath = String(asset.file_info.filepath).replace(
/[^\\/]+$/,
newName,
);
}
if (asset.file_info.path) {
asset.file_info.path = String(asset.file_info.path).replace(
/[^\\/]+$/,
newName,
);
}
}
syncRenderedCardAsset(asset, {
filename: asset.filename,
filepath: asset.filepath,
path: asset.path,
file_info: asset.file_info,
});
}
const rendered = findRenderedCard(asset.id);
const filenameEl = rendered?.querySelector?.(".mjr-filename");
if (filenameEl) filenameEl.textContent = asset.filename || newName;
comfyToast(t("toast.fileRenamedSuccess"), "success");
triggerBrowserGridReload(gridContainer);
} catch (error) {
comfyToast(
t("toast.errorRenaming", "Error renaming file: {error}", {
error: error?.message || String(error || ""),
}),
"error",
);
}
},
{ disabled: !(asset?.id || getAssetFilepath(asset)) },
),
);
if (isMultiSelected) {
items.push(
createItem(
`Delete ${effectiveSelectionCount} files...`,
"pi pi-trash",
getShortcutDisplay("DELETE"),
async () => {
const ok = await confirmDeletion(Number(effectiveSelectionCount) || 0);
if (!ok) return;
try {
let successCount = 0;
let errorCount = 0;
const deletedIds = [];
let deletedByFilepath = 0;
const selectionList = selectedAssetsNow.length ? selectedAssetsNow : [asset];
for (const selectedAsset of selectionList) {
const result = await deleteAsset(selectedAsset);
if (result?.ok) {
successCount += 1;
if (selectedAsset?.id != null) {
deletedIds.push(String(selectedAsset.id));
} else {
deletedByFilepath += 1;
}
} else {
errorCount += 1;
}
}
if (deletedIds.length) {
const removal = removeAssetsFromGrid(gridContainer, deletedIds);
if (panelState && Array.isArray(removal?.selectedIds)) {
panelState.selectedAssetIds = removal.selectedIds;
panelState.activeAssetId = removal.selectedIds[0] || "";
}
}
if (deletedByFilepath > 0) {
triggerBrowserGridReload(gridContainer);
}
if (errorCount === 0) {
comfyToast(
t("toast.filesDeletedSuccessN", "{n} files deleted successfully!", {
n: successCount,
}),
"success",
);
} else {
comfyToast(
t(
"toast.filesDeletedPartial",
"{success} files deleted, {failed} failed.",
{ success: successCount, failed: errorCount },
),
"warning",
);
}
} catch (error) {
comfyToast(
t("toast.errorDeleting", "Error deleting file: {error}", {
error: error?.message || String(error || ""),
}),
"error",
);
}
},
),
);
} else {
items.push(
createItem(
t("ctx.delete", "Delete"),
"pi pi-trash",
getShortcutDisplay("DELETE"),
async () => {
const ok = await confirmDeletion(1, asset?.filename);
if (!ok) return;
try {
const result = await deleteAsset(asset);
if (!result?.ok) {
comfyToast(result?.error || t("toast.fileDeleteFailed"), "error");
return;
}
if (asset?.id) {
const removal = removeAssetsFromGrid(gridContainer, [String(asset.id)]);
if (panelState && Array.isArray(removal?.selectedIds)) {
panelState.selectedAssetIds = removal.selectedIds;
panelState.activeAssetId = removal.selectedIds[0] || "";
}
} else {
triggerBrowserGridReload(gridContainer);
}
comfyToast(t("toast.fileDeletedSuccess"), "success");
} catch (error) {
comfyToast(
t("toast.errorDeleting", "Error deleting file: {error}", {
error: error?.message || String(error || ""),
}),
"error",
);
}
},
{ disabled: !(asset?.id || getAssetFilepath(asset)) },
),
);
}
return items;
}
export function showTagsPopover(x, y, asset, onChanged) {
if (!asset) return;
openGridTagsPopover({
x,
y,
asset,
onChanged,
});
}
export function bindGridContextMenu({
gridContainer,
getState = () => ({}),
} = {}) {
if (!gridContainer) return;
if (
gridContainer._mjrGridContextMenuBound &&
typeof gridContainer._mjrGridContextMenuUnbind === "function"
) {
return gridContainer._mjrGridContextMenuUnbind;
}
const handler = async (event) => {
const panelState = (() => {
try {
return getState?.() || {};
} catch {
return {};
}
})();
const card = safeClosest(event.target, ".mjr-asset-card");
if (!card) {
if (!_isBrowserScope(panelState)) return;
const currentPath = String(gridContainer?.dataset?.mjrSubfolder || "").trim();
if (!currentPath) return;
event.preventDefault();
event.stopPropagation();
openGridContextMenu({
x: event.clientX,
y: event.clientY,
items: _buildEmptyGridItems({ currentPath, gridContainer }),
});
return;
}
event.preventDefault();
event.stopPropagation();
const asset = card._mjrAsset;
if (!asset) return;
const selection = _getSelectionSnapshot(gridContainer);
const items = _isFolderAsset(asset)
? _buildFolderItems({ asset, gridContainer, panelState })
: _buildAssetItems({
asset,
card,
gridContainer,
panelState,
pointerX: event.clientX,
pointerY: event.clientY,
selection,
});
openGridContextMenu({
x: event.clientX,
y: event.clientY,
items,
});
};
try {
gridContainer.addEventListener("contextmenu", handler);
} catch (err) {
console.error("[GridContextMenu] Failed to bind:", err);
}
gridContainer._mjrGridContextMenuBound = true;
const unbind = () => {
try {
gridContainer.removeEventListener("contextmenu", handler);
} catch (e) {
console.debug?.(e);
}
try {
gridContainer._mjrGridContextMenuBound = false;
} catch (e) {
console.debug?.(e);
}
try {
gridContainer._mjrGridContextMenuUnbind = null;
} catch (e) {
console.debug?.(e);
}
try {
cancelAllRatingUpdates();
} catch (e) {
console.debug?.(e);
}
try {
closeAllGridContextMenus();
} catch (e) {
console.debug?.(e);
}
};
gridContainer._mjrGridContextMenuUnbind = unbind;
return unbind;
}