- 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.
1209 lines
43 KiB
JavaScript
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;
|
|
}
|