- Default 'Show folders in Input / Output panels' (browser.showFolders) to disabled: fresh installs keep the classic flat grid until the user opts in (backend settings service, frontend defaults, settings UI toggle). - Gate the root-level 'Create folder here' context menu on the setting. - Kick off a throttled incremental background scan when browse mode serves the first page, so unindexed files get indexed/enriched and asset status dots resolve instead of staying pending (blue). - Document the feature and setting in README, CHANGELOG, and docs/SETTINGS_CONFIGURATION.md.
1945 lines
71 KiB
TypeScript
1945 lines
71 KiB
TypeScript
/**
|
|
* GridContextMenu.js
|
|
*
|
|
* Vue now renders the menu DOM. This module only:
|
|
* - detects right-click context
|
|
* - computes the menu model
|
|
* - executes menu actions
|
|
*/
|
|
|
|
import {
|
|
browserFolderOp,
|
|
collectFiles,
|
|
deleteAsset,
|
|
deleteWorkflow,
|
|
duplicateWorkflow,
|
|
getAssetMetadata,
|
|
getWorkflowContent,
|
|
listWorkflowThumbnailCandidates,
|
|
markWorkflowLoaded,
|
|
moveWorkflow,
|
|
openInFolder,
|
|
post,
|
|
removeFilepathsFromCollection,
|
|
renameAsset,
|
|
setWorkflowThumbnail,
|
|
updateAssetRating,
|
|
} from "../../api/client.js";
|
|
import { ENDPOINTS, buildBatchZipDownloadURL, buildDownloadURL } from "../../api/endpoints.js";
|
|
import { ASSET_RATING_CHANGED_EVENT } from "../../app/events.js";
|
|
import { t } from "../../app/i18n.js";
|
|
import { loadMajoorSettings } from "../../app/settings/settingsCore.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 {
|
|
getRawHostApp,
|
|
importWorkflowPreferHostTab,
|
|
isHostWorkflowDirty,
|
|
} from "../../app/hostAdapter.js";
|
|
import { createSecureToken, pickRootId } from "../../utils/ids.js";
|
|
import { getShortcutDisplay } from "../grid/GridKeyboard.js";
|
|
import {
|
|
closeAllGridContextMenus,
|
|
openGridContextMenu,
|
|
openGridTagsPopover,
|
|
} from "./gridContextMenuState.js";
|
|
import { cancelAllRatingUpdates, scheduleRatingUpdate } from "./ratingUpdater.js";
|
|
import { createCanvasLoaderNodes } from "../dnd/canvasLoaderNode.js";
|
|
import { stageToInputDetailed } from "../dnd/staging/stageToInput.js";
|
|
import { resolveAssetWorkflow } from "../viewer/workflowGraphMap/workflowGraphMapData.js";
|
|
import { openWorkflowAssetPicker, openWorkflowPicker } from "../workflows/workflowPickerState.js";
|
|
import { openWorkflowInfoDialog } from "../workflows/workflowInfoState.js";
|
|
|
|
let _nextMenuItemId = 1;
|
|
let viewerOpenRequestModulePromise: Promise<typeof import("../viewer/viewerOpenRequest.js")> | null = null;
|
|
let floatingViewerManagerModulePromise: Promise<typeof import("../viewer/floatingViewerManager.js")> | null = null;
|
|
|
|
function loadViewerOpenRequestModule() {
|
|
if (!viewerOpenRequestModulePromise) {
|
|
viewerOpenRequestModulePromise = import("../viewer/viewerOpenRequest.js");
|
|
}
|
|
return viewerOpenRequestModulePromise;
|
|
}
|
|
|
|
function loadFloatingViewerManagerModule() {
|
|
if (!floatingViewerManagerModulePromise) {
|
|
floatingViewerManagerModulePromise = import("../viewer/floatingViewerManager.js");
|
|
}
|
|
return floatingViewerManagerModulePromise;
|
|
}
|
|
|
|
function inferItemTone(label: any) {
|
|
const normalized = String(label || "").trim().toLowerCase();
|
|
if (!normalized) return "";
|
|
if (normalized.includes("open graph map")) return "graph-map";
|
|
if (normalized.includes("open in floating viewer") || normalized.includes("open floating viewer")) {
|
|
return "floating-viewer";
|
|
}
|
|
if (normalized.includes("rename")) return "rename";
|
|
if (normalized.includes("delete")) return "delete";
|
|
return "";
|
|
}
|
|
|
|
function createItem(
|
|
label: any,
|
|
iconClass: any,
|
|
rightHint: any,
|
|
action: any,
|
|
{ disabled = false, closeOnSelect = true, submenu = null }: { disabled?: boolean; closeOnSelect?: boolean; submenu?: any[] | null } = {},
|
|
) {
|
|
return {
|
|
id: `mjr-grid-menu-item-${_nextMenuItemId++}`,
|
|
type: "item",
|
|
label: String(label || ""),
|
|
iconClass: iconClass ? String(iconClass) : "",
|
|
rightHint: rightHint ? String(rightHint) : "",
|
|
tone: inferItemTone(label),
|
|
disabled: !!disabled,
|
|
closeOnSelect,
|
|
submenu: Array.isArray(submenu) && submenu.length ? submenu : null,
|
|
action: typeof action === "function" ? action : null,
|
|
};
|
|
}
|
|
|
|
function createSeparator(): any {
|
|
return {
|
|
id: `mjr-grid-menu-separator-${_nextMenuItemId++}`,
|
|
type: "separator",
|
|
};
|
|
}
|
|
|
|
const getSelectedAssetIds = (gridContainer: any) => {
|
|
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: any) => selected.add(String(id)));
|
|
return selected;
|
|
}
|
|
}
|
|
} catch (e: any) {
|
|
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: any) {
|
|
console.debug?.(e);
|
|
}
|
|
return selected;
|
|
};
|
|
|
|
const getSelectedAssets = (gridContainer: any) => {
|
|
const assets: any[] = [];
|
|
if (!gridContainer) return assets;
|
|
try {
|
|
const selected =
|
|
typeof gridContainer?._mjrGetSelectedAssets === "function"
|
|
? gridContainer._mjrGetSelectedAssets()
|
|
: [];
|
|
if (Array.isArray(selected) && selected.length) {
|
|
return selected.filter((asset: any) => asset && typeof asset === "object");
|
|
}
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
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: any) {
|
|
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: any) {
|
|
console.debug?.(e);
|
|
}
|
|
return assets;
|
|
};
|
|
|
|
async function enrichWorkflowGraphAsset(asset: any) {
|
|
const filepath = String(asset?.filepath || asset?.path || asset?.full_path || "").trim();
|
|
if (!filepath) return asset;
|
|
try {
|
|
const result = await getWorkflowContent(filepath, { timeoutMs: 25_000 });
|
|
const workflow = result?.data?.workflow || result?.workflow || null;
|
|
const prompt = result?.data?.prompt || result?.prompt || null;
|
|
if (!workflow && !prompt) return asset;
|
|
return {
|
|
...asset,
|
|
workflow: workflow || asset?.workflow,
|
|
prompt: prompt || asset?.prompt,
|
|
metadata_raw: {
|
|
...(asset?.metadata_raw && typeof asset.metadata_raw === "object"
|
|
? asset.metadata_raw
|
|
: {}),
|
|
...(workflow ? { workflow } : {}),
|
|
...(prompt ? { prompt } : {}),
|
|
},
|
|
};
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
return asset;
|
|
}
|
|
}
|
|
|
|
const getAssetFilepath = (asset: any) => {
|
|
try {
|
|
const filepath = asset?.filepath || asset?.path || asset?.file_info?.filepath || "";
|
|
return filepath ? String(filepath) : "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
};
|
|
|
|
const buildDndPayloadFromAsset = (asset: any) => {
|
|
if (!asset || typeof asset !== "object") return null;
|
|
const filename = String(asset.filename || "").trim();
|
|
if (!filename) return null;
|
|
return {
|
|
filename,
|
|
subfolder: asset.subfolder || "",
|
|
type: String(asset.type || asset.source || "output").toLowerCase(),
|
|
root_id: pickRootId(asset) || undefined,
|
|
kind: String(asset.kind || "").toLowerCase(),
|
|
};
|
|
};
|
|
|
|
const buildBatchZipItemFromAsset = (asset: any) => {
|
|
if (!asset || typeof asset !== "object") return null;
|
|
const filename = String(asset.filename || asset.name || "").trim();
|
|
if (!filename) return null;
|
|
return {
|
|
filename,
|
|
subfolder: asset.subfolder || "",
|
|
type: String(asset.type || asset.source || "output").toLowerCase(),
|
|
root_id: pickRootId(asset) || undefined,
|
|
};
|
|
};
|
|
|
|
function triggerDownload(url: any, filename = "") {
|
|
if (!url) return;
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
if (filename) link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}
|
|
|
|
async function downloadSelectedAsZip(assets: any) {
|
|
const list = Array.isArray(assets) ? assets.filter((entry: any) => !_isFolderAsset(entry)) : [];
|
|
const items = list.map(buildBatchZipItemFromAsset).filter(Boolean);
|
|
if (items.length < 2) {
|
|
comfyToast("Select at least two files to download a ZIP.", "warning", 2600);
|
|
return;
|
|
}
|
|
|
|
let token = "";
|
|
try {
|
|
token = createSecureToken("mjr_", 24);
|
|
} catch (error: any) {
|
|
reportError(error, "[GridContextMenu] Batch ZIP token", {
|
|
showToast: APP_CONFIG.DEBUG_VERBOSE_ERRORS,
|
|
});
|
|
comfyToast("Could not create a secure ZIP token.", "error");
|
|
return;
|
|
}
|
|
|
|
comfyToast(`Preparing ZIP for ${items.length} files...`, "info", 1800);
|
|
const res = await post(ENDPOINTS.BATCH_ZIP_CREATE, {
|
|
token,
|
|
items,
|
|
strip_metadata: false,
|
|
});
|
|
if (!res?.ok) {
|
|
comfyToast(res?.error || "Failed to create ZIP.", "error");
|
|
return;
|
|
}
|
|
|
|
const filename = res?.data?.filename || `Majoor_Batch_${items.length}.zip`;
|
|
triggerDownload(buildBatchZipDownloadURL(token), filename);
|
|
comfyToast(`Downloading ${filename}...`, "info", 3000);
|
|
}
|
|
|
|
async function loadAssetsToCanvas(assets: any) {
|
|
const list = Array.isArray(assets) ? assets.filter(Boolean) : [];
|
|
const stagedItems = await Promise.all(
|
|
list.map(async (asset) => {
|
|
const payload = buildDndPayloadFromAsset(asset);
|
|
if (!payload) return null;
|
|
const staged = await stageToInputDetailed({
|
|
post,
|
|
endpoint: ENDPOINTS.STAGE_TO_INPUT,
|
|
payload,
|
|
index: false,
|
|
});
|
|
const relativePath = staged?.relativePath;
|
|
if (!relativePath) return null;
|
|
return {
|
|
payload,
|
|
relativePath,
|
|
droppedExt:
|
|
String(payload.filename || "")
|
|
.split(".")
|
|
.pop() || "",
|
|
};
|
|
}),
|
|
);
|
|
return createCanvasLoaderNodes({
|
|
app: getRawHostApp(),
|
|
items: stagedItems.filter(Boolean),
|
|
event: null,
|
|
});
|
|
}
|
|
|
|
const clearSelection = (gridContainer: any) => {
|
|
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: any) {
|
|
console.debug?.(e);
|
|
}
|
|
safeDispatchCustomEvent(
|
|
"mjr:selection-changed",
|
|
{ selectedIds: [] },
|
|
{ target: gridContainer, warnPrefix: "[GridContextMenu]" },
|
|
);
|
|
};
|
|
|
|
const selectCardForDetails = (gridContainer: any, card: any, asset: any, state: any) => {
|
|
if (!gridContainer) return;
|
|
try {
|
|
clearSelection(gridContainer);
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
try {
|
|
card?.classList?.add?.("is-selected");
|
|
card?.setAttribute?.("aria-selected", "true");
|
|
} catch (e: any) {
|
|
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: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
if (state && typeof state === "object") {
|
|
try {
|
|
state.selectedAssetIds = id ? [id] : [];
|
|
state.activeAssetId = id;
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
};
|
|
|
|
const getAllAssetsInGrid = (gridContainer: any) => {
|
|
const assets: any[] = [];
|
|
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: any) {
|
|
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: any) {
|
|
console.debug?.(e);
|
|
}
|
|
return assets;
|
|
};
|
|
|
|
const findIndexById = (assets: any, assetId: any) => {
|
|
try {
|
|
const id = String(assetId ?? "");
|
|
return (assets || []).findIndex((asset: any) => String(asset?.id ?? "") === id);
|
|
} catch {
|
|
return -1;
|
|
}
|
|
};
|
|
|
|
function findRenderedCard(assetId: any) {
|
|
if (assetId == null) return null;
|
|
try {
|
|
return document.querySelector(`[data-mjr-asset-id="${safeEscapeSelector(assetId)}"]`);
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function syncRenderedCardAsset(asset: any, patch: any = null) {
|
|
const card = findRenderedCard(asset?.id);
|
|
if (!card || !(card as any)._mjrAsset || typeof (card as any)._mjrAsset !== "object") return card;
|
|
try {
|
|
if (patch && typeof patch === "object") Object.assign((card as any)._mjrAsset, patch);
|
|
else if (asset && typeof asset === "object") Object.assign((card as any)._mjrAsset, asset);
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
return card;
|
|
}
|
|
|
|
const triggerBrowserGridReload = (gridContainer: any) => {
|
|
try {
|
|
const reason = gridContainer ? "grid-contextmenu" : "grid-contextmenu-global";
|
|
window?.dispatchEvent?.(new CustomEvent("mjr:reload-grid", { detail: { reason } }));
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
async function resetIndexForSingleAsset(asset: any, gridContainer: any) {
|
|
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 as any)._mjrAsset);
|
|
if (nextDot) oldDot.replaceWith(nextDot);
|
|
}
|
|
}
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
triggerBrowserGridReload(gridContainer);
|
|
return { ok: true, data: { refreshed: true } };
|
|
}
|
|
|
|
function setRating(asset: any, rating: any, onChanged: any) {
|
|
const assetId = asset?.id;
|
|
try {
|
|
asset.rating = rating;
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
onChanged?.();
|
|
} catch (e: any) {
|
|
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: any) => {
|
|
reportError(error, "[GridContextMenu] Rating update", {
|
|
showToast: APP_CONFIG.DEBUG_VERBOSE_ERRORS,
|
|
});
|
|
});
|
|
}
|
|
|
|
function _isBrowserScope(panelState: any) {
|
|
const scope = String(panelState?.scope || "").toLowerCase();
|
|
return scope === "custom" || scope === "input" || scope === "output";
|
|
}
|
|
|
|
function _browserFoldersEnabled() {
|
|
try {
|
|
const settings = loadMajoorSettings();
|
|
return !!(settings?.browser?.showFolders ?? false);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function _isWorkflowScope(panelState: any) {
|
|
return String(panelState?.scope || "").toLowerCase() === "workflow";
|
|
}
|
|
|
|
function _isFolderAsset(asset: any) {
|
|
return String(asset?.kind || "").toLowerCase() === "folder";
|
|
}
|
|
|
|
function _isWorkflowAsset(asset: any) {
|
|
return String(asset?.kind || "").toLowerCase() === "workflow";
|
|
}
|
|
|
|
function _isWorkflowThumbnailSourceAsset(asset: any) {
|
|
if (!asset || _isFolderAsset(asset) || _isWorkflowAsset(asset)) return false;
|
|
const filepath = getAssetFilepath(asset);
|
|
if (!filepath) return false;
|
|
const kind = String(asset?.kind || "").toLowerCase();
|
|
if (kind === "image" || kind === "video") return true;
|
|
const ext = String(filepath.split(".").pop() || "").toLowerCase();
|
|
return ["png", "jpg", "jpeg", "webp", "gif", "mp4", "webm", "mov", "mkv", "avi", "m4v"].includes(ext);
|
|
}
|
|
|
|
async function _resolveHydratedAssetWorkflow(asset: any) {
|
|
if (!asset || _isFolderAsset(asset)) return null;
|
|
const directWorkflow = resolveAssetWorkflow(asset);
|
|
if (directWorkflow) return directWorkflow;
|
|
|
|
const assetId = asset?.id;
|
|
if (assetId == null || assetId === "") return null;
|
|
|
|
try {
|
|
const result = await getAssetMetadata(assetId, { timeoutMs: 30_000 });
|
|
const freshAsset = result?.data;
|
|
if (result?.ok && freshAsset && typeof freshAsset === "object") {
|
|
Object.assign(asset, freshAsset);
|
|
return resolveAssetWorkflow(asset);
|
|
}
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function _openAssetWorkflowAsNewTab(asset: any) {
|
|
const workflow = await _resolveHydratedAssetWorkflow(asset);
|
|
if (!workflow || typeof workflow !== "object") {
|
|
comfyToast(
|
|
t(
|
|
"toast.assetWorkflowMissing",
|
|
"No embedded ComfyUI workflow was found for this asset.",
|
|
),
|
|
"warn",
|
|
2600,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const hostApp = getRawHostApp();
|
|
const importResult = importWorkflowPreferHostTab(workflow, hostApp);
|
|
if (!importResult.ok) {
|
|
comfyToast(
|
|
t(
|
|
"toast.workflowImportUnavailable",
|
|
"ComfyUI workflow import is unavailable in this frontend.",
|
|
),
|
|
"error",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const message =
|
|
importResult.mode === "new-tab"
|
|
? t("toast.workflowLoadedNewTab", "Workflow loaded in a new ComfyUI tab.")
|
|
: t("toast.workflowLoaded", "Workflow loaded");
|
|
comfyToast(message, "success", 1800);
|
|
}
|
|
|
|
async function _loadWorkflowAsset(asset: any) {
|
|
const filepath = String(asset?.filepath || asset?.path || asset?.full_path || "").trim();
|
|
if (!filepath) {
|
|
comfyToast(t("toast.workflowMissingPath", "Workflow file path is missing."), "error");
|
|
return;
|
|
}
|
|
const hostApp = getRawHostApp();
|
|
const dirtyState = isHostWorkflowDirty(hostApp);
|
|
if (dirtyState === true) {
|
|
const ok = await comfyConfirm(
|
|
t(
|
|
"dialog.workflowLoadReplaceDirty",
|
|
"Current canvas has unsaved changes. Replace it with this workflow?",
|
|
),
|
|
t("tab.workflow", "Workflow"),
|
|
);
|
|
if (!ok) return;
|
|
}
|
|
const result = await getWorkflowContent(filepath, { timeoutMs: 30_000 });
|
|
const workflow = result?.data?.workflow || result?.workflow || null;
|
|
if (!result?.ok || !workflow || typeof workflow !== "object") {
|
|
comfyToast(result?.error || t("toast.workflowLoadFailed", "Failed to load workflow."), "error");
|
|
return;
|
|
}
|
|
const importResult = importWorkflowPreferHostTab(workflow, hostApp);
|
|
if (!importResult.ok) {
|
|
// Host API unavailable: fallback to opening/downloading JSON so user can still import manually.
|
|
const downloadUrl = buildDownloadURL(filepath, { inline: true });
|
|
try {
|
|
if (downloadUrl && typeof window !== "undefined") {
|
|
window.open(downloadUrl, "_blank", "noopener,noreferrer");
|
|
}
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
comfyToast(
|
|
t(
|
|
"toast.workflowImportFallback",
|
|
"ComfyUI workflow import is unavailable in this frontend. Opened workflow JSON for manual import.",
|
|
),
|
|
"warn",
|
|
);
|
|
if (importResult.mode === "new-tab") {
|
|
comfyToast(t("toast.workflowLoadedNewTab", "Workflow loaded in a new ComfyUI tab."), "success");
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
await markWorkflowLoaded({ filepath }, { timeoutMs: 10_000 });
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
comfyToast(t("toast.workflowLoaded", "Workflow loaded"), "success", 1800);
|
|
}
|
|
|
|
function _requestWorkflowGridReload(reason: string) {
|
|
try {
|
|
window.dispatchEvent(new CustomEvent("mjr:reload-grid", { detail: { reason } }));
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
async function _duplicateWorkflowAsset(asset: any) {
|
|
const filepath = String(asset?.filepath || "").trim();
|
|
const result = await duplicateWorkflow({ filepath }, { timeoutMs: 30_000 });
|
|
if (!result?.ok) {
|
|
comfyToast(result?.error || t("toast.workflowSaveFailed", "Failed to save workflow."), "error");
|
|
return;
|
|
}
|
|
comfyToast(t("toast.workflowSaved", "Workflow saved"), "success", 1800);
|
|
_requestWorkflowGridReload("workflow-duplicate");
|
|
}
|
|
|
|
async function _renameWorkflowAsset(asset: any) {
|
|
const filepath = String(asset?.filepath || "").trim();
|
|
const current = String(asset?.display_name || asset?.filename || "").replace(/\.json$/i, "");
|
|
const name = await comfyPrompt(t("ctx.renameWorkflow", "Rename workflow"), current, t("tab.workflow", "Workflow"));
|
|
const safeName = String(name || "").trim();
|
|
if (!safeName) return;
|
|
const result = await moveWorkflow({ filepath, name: safeName }, { timeoutMs: 30_000 });
|
|
if (!result?.ok) {
|
|
comfyToast(result?.error || t("toast.workflowSaveFailed", "Failed to save workflow."), "error");
|
|
return;
|
|
}
|
|
comfyToast(t("toast.workflowUpdated", "Workflow updated"), "success", 1800);
|
|
_requestWorkflowGridReload("workflow-rename");
|
|
}
|
|
|
|
async function _categorizeWorkflowAsset(asset: any) {
|
|
const filepath = String(asset?.filepath || "").trim();
|
|
const current = String(asset?.subfolder || "").trim();
|
|
const category = await comfyPrompt(
|
|
t("dialog.workflowCategory", "Workflow category"),
|
|
current,
|
|
t("tab.workflow", "Workflow"),
|
|
);
|
|
if (category === null || category === undefined) return;
|
|
const result = await moveWorkflow({ filepath, category: String(category || "") }, { timeoutMs: 30_000 });
|
|
if (!result?.ok) {
|
|
comfyToast(result?.error || t("toast.workflowSaveFailed", "Failed to save workflow."), "error");
|
|
return;
|
|
}
|
|
comfyToast(t("toast.workflowUpdated", "Workflow updated"), "success", 1800);
|
|
_requestWorkflowGridReload("workflow-categorize");
|
|
}
|
|
|
|
async function _setWorkflowThumbnailFromLinkedAsset(asset: any) {
|
|
const filepath = String(asset?.filepath || "").trim();
|
|
if (!filepath) {
|
|
comfyToast(t("toast.noFilePath"), "error");
|
|
return;
|
|
}
|
|
|
|
const candidatesRes = await listWorkflowThumbnailCandidates({ filepath, limit: 12 }, { timeoutMs: 15_000 });
|
|
if (!candidatesRes?.ok) {
|
|
comfyToast(candidatesRes?.error || t("toast.workflowLoadFailed", "Failed to load workflow."), "error");
|
|
return;
|
|
}
|
|
|
|
const candidates = Array.isArray(candidatesRes.data) ? candidatesRes.data : [];
|
|
if (!candidates.length) {
|
|
comfyToast(
|
|
t(
|
|
"toast.workflowThumbnailNoCandidates",
|
|
"No linked outputs are available for this workflow yet.",
|
|
),
|
|
"warning",
|
|
2600,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const selectedCandidate = await openWorkflowAssetPicker({
|
|
title: t("ctx.setWorkflowThumbnail", "Set workflow thumbnail"),
|
|
workflow: asset,
|
|
items: candidates,
|
|
});
|
|
if (!selectedCandidate?.filepath) return;
|
|
|
|
const result = await setWorkflowThumbnail(
|
|
{ filepath, source_filepath: selectedCandidate.filepath },
|
|
{ timeoutMs: 30_000 },
|
|
);
|
|
if (!result?.ok) {
|
|
comfyToast(result?.error || t("toast.workflowSaveFailed", "Failed to save workflow."), "error");
|
|
return;
|
|
}
|
|
|
|
comfyToast(t("toast.workflowUpdated", "Workflow updated"), "success", 1800);
|
|
_requestWorkflowGridReload("workflow-thumbnail");
|
|
}
|
|
|
|
async function _exportWorkflowAsset(asset: any) {
|
|
const filepath = String(asset?.filepath || "").trim();
|
|
if (!filepath) {
|
|
comfyToast(t("toast.workflowMissingPath", "Workflow file path is missing."), "error");
|
|
return;
|
|
}
|
|
const result = await getWorkflowContent(filepath, { timeoutMs: 30_000 });
|
|
if (!result?.ok) {
|
|
comfyToast(result?.error || t("toast.workflowLoadFailed", "Failed to load workflow."), "error");
|
|
return;
|
|
}
|
|
const workflow = result?.data?.workflow || result?.workflow || null;
|
|
if (!workflow || typeof workflow !== "object") {
|
|
comfyToast(t("toast.workflowLoadFailed", "Failed to load workflow."), "error");
|
|
return;
|
|
}
|
|
const filename = String(asset?.filename || asset?.display_name || "workflow.json")
|
|
.replace(/[\\/:*?"<>|]+/g, "_")
|
|
.replace(/\.json$/i, "") || "workflow";
|
|
const blob = new Blob([`${JSON.stringify(workflow, null, 2)}\n`], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
try {
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `${filename}.json`;
|
|
link.rel = "noopener";
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
comfyToast(t("toast.workflowExported", "Workflow exported"), "success", 1800);
|
|
} finally {
|
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
|
}
|
|
}
|
|
|
|
async function _showWorkflowInExplorer(asset: any) {
|
|
const filepath = String(asset?.filepath || "").trim();
|
|
if (!filepath) {
|
|
comfyToast(t("toast.workflowMissingPath", "Workflow file path is missing."), "error");
|
|
return;
|
|
}
|
|
const result = await openInFolder({ filepath });
|
|
if (!result?.ok) {
|
|
comfyToast(result?.error || t("toast.openFolderFailed", "Failed to open folder."), "error");
|
|
return;
|
|
}
|
|
comfyToast(t("toast.openedInFolder", "Opened in folder"), "success", 1600);
|
|
}
|
|
|
|
|
|
async function _assignAssetAsWorkflowThumbnail(asset: any) {
|
|
const sourceFilepath = getAssetFilepath(asset);
|
|
if (!sourceFilepath) {
|
|
comfyToast(t("toast.noFilePath"), "error");
|
|
return;
|
|
}
|
|
const selectedWorkflow = await openWorkflowPicker({
|
|
title: t("ctx.assignAsWorkflowThumbnail", "Use as workflow thumbnail"),
|
|
sourceAsset: asset,
|
|
});
|
|
const selectedWorkflowPath = String(selectedWorkflow?.filepath || "").trim();
|
|
if (!selectedWorkflowPath) return;
|
|
|
|
const sourceKind = String(asset?.kind || "").toLowerCase();
|
|
const isVideo = sourceKind === "video" || /\.(mp4|webm|mov|mkv|avi|m4v)$/i.test(sourceFilepath);
|
|
comfyToast(
|
|
isVideo
|
|
? t("toast.workflowThumbnailConverting", "Preparing a 5 second animated workflow thumbnail...")
|
|
: t("toast.workflowThumbnailApplying", "Applying workflow thumbnail..."),
|
|
"info",
|
|
2200,
|
|
);
|
|
const result = await setWorkflowThumbnail(
|
|
{ filepath: selectedWorkflowPath, source_filepath: sourceFilepath },
|
|
{ timeoutMs: isVideo ? 75_000 : 30_000 },
|
|
);
|
|
if (!result?.ok) {
|
|
comfyToast(result?.error || t("toast.workflowSaveFailed", "Failed to save workflow."), "error");
|
|
return;
|
|
}
|
|
comfyToast(t("toast.workflowUpdated", "Workflow updated"), "success", 1800);
|
|
_requestWorkflowGridReload("workflow-thumbnail-assign");
|
|
}
|
|
|
|
async function _deleteWorkflowAsset(asset: any) {
|
|
const filepath = String(asset?.filepath || "").trim();
|
|
const ok = await comfyConfirm(
|
|
t("dialog.deleteWorkflowConfirm", "Delete this workflow JSON and its adjacent thumbnail files?"),
|
|
t("ctx.deleteWorkflow", "Delete workflow"),
|
|
);
|
|
if (!ok) return;
|
|
const result = await deleteWorkflow({ filepath }, { timeoutMs: 30_000 });
|
|
if (!result?.ok) {
|
|
comfyToast(result?.error || t("toast.fileDeleteFailed", "Failed to delete file."), "error");
|
|
return;
|
|
}
|
|
comfyToast(t("toast.workflowDeleted", "Workflow deleted"), "success", 1800);
|
|
_requestWorkflowGridReload("workflow-delete");
|
|
}
|
|
|
|
function _getSelectionSnapshot(gridContainer: any) {
|
|
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 }: { asset?: any; card?: any; gridContainer?: any } = {}) {
|
|
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: any) {
|
|
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: any) => {
|
|
el.classList.remove("is-selected");
|
|
el.setAttribute?.("aria-selected", "false");
|
|
});
|
|
card?.classList?.add?.("is-selected");
|
|
card?.setAttribute?.("aria-selected", "true");
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
return selectedIds;
|
|
}
|
|
|
|
function _buildRatingSubmenu(asset: any, canRate: any) {
|
|
const stars = (n: any) => "*".repeat(n) + "o".repeat(Math.max(0, 5 - n));
|
|
const updateRenderedRating = (value: any) => {
|
|
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 _resolveCreateFolderPath(currentPath: string, gridContainer: any): string {
|
|
const scope = String(gridContainer?.dataset?.mjrScope || "").toLowerCase();
|
|
if (scope !== "input" && scope !== "output") return currentPath;
|
|
const rel = String(currentPath || "").trim().replaceAll("\\", "/");
|
|
// Encode the scope + optional relative subfolder, e.g. "mjr://input/sub/dir"
|
|
return rel ? `mjr://${scope}/${rel}` : `mjr://${scope}`;
|
|
}
|
|
|
|
function _buildEmptyGridItems({ currentPath, gridContainer }: { currentPath?: any; gridContainer?: any } = {}) {
|
|
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: _resolveCreateFolderPath(currentPath, gridContainer),
|
|
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: any) {
|
|
comfyToast(
|
|
t("toast.createFolderFailedDetail", "Create folder failed: {error}", {
|
|
error: error?.message || String(error || ""),
|
|
}),
|
|
"error",
|
|
);
|
|
}
|
|
},
|
|
),
|
|
];
|
|
}
|
|
|
|
function _buildFolderItems({ asset, gridContainer, panelState }: { asset?: any; gridContainer?: any; panelState?: any } = {}) {
|
|
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: any) {
|
|
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: any) {
|
|
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: any) {
|
|
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: any) {
|
|
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: any) {
|
|
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: any) {
|
|
comfyToast(
|
|
t("toast.deleteFolderFailedDetail", {
|
|
error: error?.message || String(error || ""),
|
|
}),
|
|
"error",
|
|
);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
function _buildAssetItems({
|
|
asset,
|
|
card,
|
|
gridContainer,
|
|
panelState,
|
|
pointerX,
|
|
pointerY,
|
|
selection,
|
|
}: { asset?: any; card?: any; gridContainer?: any; panelState?: any; pointerX?: any; pointerY?: any; selection?: any } = {}) {
|
|
const { selectedAssetsNow, effectiveSelectionCount, isMultiSelected, hasSelection } = selection;
|
|
const items: any[] = [];
|
|
const inWorkflowScope = _isWorkflowScope(panelState);
|
|
const openInViewer = ({ assets = [] as any[], index = 0, mode = "" } = {}) =>
|
|
loadViewerOpenRequestModule()
|
|
.then((mod) => mod.requestViewerOpen({ assets, index, mode }))
|
|
.catch((e) => console.debug?.(e));
|
|
|
|
if (_isWorkflowAsset(asset)) {
|
|
items.push(
|
|
createItem(
|
|
t("ctx.loadWorkflow", "Load workflow"),
|
|
"pi pi-sitemap",
|
|
null,
|
|
() => _loadWorkflowAsset(asset),
|
|
),
|
|
createItem(
|
|
t("ctx.duplicateWorkflow", "Duplicate workflow"),
|
|
"pi pi-copy",
|
|
null,
|
|
() => _duplicateWorkflowAsset(asset),
|
|
),
|
|
createItem(
|
|
t("ctx.renameWorkflow", "Rename workflow"),
|
|
"pi pi-pencil",
|
|
null,
|
|
() => _renameWorkflowAsset(asset),
|
|
),
|
|
createItem(
|
|
t("ctx.categorizeWorkflow", "Set workflow category"),
|
|
"pi pi-folder",
|
|
null,
|
|
() => _categorizeWorkflowAsset(asset),
|
|
),
|
|
createItem(
|
|
t("ctx.setWorkflowThumbnail", "Set workflow thumbnail"),
|
|
"pi pi-image",
|
|
null,
|
|
() => _setWorkflowThumbnailFromLinkedAsset(asset),
|
|
),
|
|
createItem(
|
|
t("ctx.editWorkflowInfo", "Edit infos"),
|
|
"pi pi-info-circle",
|
|
null,
|
|
() => openWorkflowInfoDialog(asset),
|
|
),
|
|
createItem(
|
|
t("ctx.exportWorkflow", "Export workflow"),
|
|
"pi pi-download",
|
|
null,
|
|
() => _exportWorkflowAsset(asset),
|
|
),
|
|
createItem(
|
|
t("ctx.showInExplorer", "Show in Explorer"),
|
|
"pi pi-folder-open",
|
|
null,
|
|
() => _showWorkflowInExplorer(asset),
|
|
),
|
|
createItem(
|
|
t("ctx.deleteWorkflow", "Delete workflow"),
|
|
"pi pi-trash",
|
|
null,
|
|
() => _deleteWorkflowAsset(asset),
|
|
),
|
|
createSeparator(),
|
|
);
|
|
|
|
if (inWorkflowScope) {
|
|
items.push(
|
|
createItem("Open Graph Map", "pi pi-sitemap", null, async () => {
|
|
try {
|
|
const graphAsset = await enrichWorkflowGraphAsset(asset);
|
|
const { floatingViewerManager } = await loadFloatingViewerManagerModule();
|
|
await floatingViewerManager.openAssets({
|
|
assets: [graphAsset],
|
|
index: 0,
|
|
mode: "graph",
|
|
});
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}),
|
|
);
|
|
return items;
|
|
}
|
|
} else if (inWorkflowScope) {
|
|
return items;
|
|
}
|
|
|
|
items.push(
|
|
createItem("Open As New Tab", "pi pi-external-link", null, () =>
|
|
_openAssetWorkflowAsNewTab(asset),
|
|
),
|
|
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: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}),
|
|
createItem(
|
|
"Open in Floating Viewer",
|
|
"pi pi-window-maximize",
|
|
getShortcutDisplay("OPEN_FLOATING_VIEWER"),
|
|
async () => {
|
|
try {
|
|
const { floatingViewerManager } = await loadFloatingViewerManagerModule();
|
|
const selectedAssets = hasSelection ? getSelectedAssets(gridContainer) : [];
|
|
const mediaSelectedAssets = selectedAssets.filter(
|
|
(entry) => !_isFolderAsset(entry),
|
|
);
|
|
if (mediaSelectedAssets.length) {
|
|
await floatingViewerManager.openAssets({
|
|
assets: mediaSelectedAssets,
|
|
index: Math.max(
|
|
0,
|
|
mediaSelectedAssets.findIndex(
|
|
(entry) => String(entry?.id || "") === String(asset?.id || ""),
|
|
),
|
|
),
|
|
});
|
|
return;
|
|
}
|
|
await floatingViewerManager.openAssets({ assets: [asset], index: 0 });
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
),
|
|
createItem("Load Asset", "pi pi-upload", "L / L+Drop", async () => {
|
|
const selectedAssets = hasSelection ? getSelectedAssets(gridContainer) : [];
|
|
const selectedMediaAssets = selectedAssets.filter((entry: any) => !_isFolderAsset(entry));
|
|
const list = selectedMediaAssets.length ? selectedMediaAssets : [asset];
|
|
const created = await loadAssetsToCanvas(list);
|
|
if (!created) {
|
|
comfyToast("Failed to create a loader node for this asset.", "error");
|
|
}
|
|
}),
|
|
createItem("Open Graph Map", "pi pi-sitemap", null, async () => {
|
|
try {
|
|
const graphAsset = await enrichWorkflowGraphAsset(asset);
|
|
const { floatingViewerManager } = await loadFloatingViewerManagerModule();
|
|
await floatingViewerManager.openAssets({
|
|
assets: [graphAsset],
|
|
index: 0,
|
|
mode: "graph",
|
|
});
|
|
} catch (e: any) {
|
|
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: any) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
),
|
|
);
|
|
|
|
if (_isWorkflowThumbnailSourceAsset(asset)) {
|
|
items.push(
|
|
createItem(
|
|
t("ctx.assignAsWorkflowThumbnail", "Use as workflow thumbnail"),
|
|
"pi pi-image",
|
|
null,
|
|
() => _assignAssetAsWorkflowThumbnail(asset),
|
|
),
|
|
);
|
|
}
|
|
|
|
const mediaSelectedAssets = (hasSelection ? selectedAssetsNow : [] as any[]).filter(
|
|
(entry: any) => !_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: any) {
|
|
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: any) {
|
|
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.collectFiles", "Collect files"),
|
|
"pi pi-box",
|
|
null,
|
|
async () => {
|
|
comfyToast(t("toast.collectingFiles", "Collecting files..."), "info", 2500);
|
|
const res = await collectFiles(asset);
|
|
if (!res?.ok) {
|
|
comfyToast(res?.error || t("toast.collectFilesFailed", "Collect files failed"), "error");
|
|
return;
|
|
}
|
|
const data = res?.data || {};
|
|
const missingCount = Array.isArray(data.missing) ? data.missing.length : 0;
|
|
const where = data.fallback_used
|
|
? t("toast.collectFallbackDir", "output folder (source folder not writable)")
|
|
: t("toast.collectSameDir", "asset folder");
|
|
let message = t("toast.collectedFiles", "Collected {name} in {where}", {
|
|
name: String(data.zip_name || "zip"),
|
|
where,
|
|
});
|
|
if (missingCount > 0) {
|
|
message += ` — ${missingCount} ${t("toast.collectMissingInputs", "input(s) missing")}`;
|
|
}
|
|
comfyToast(message, missingCount > 0 ? "warning" : "success", 6000);
|
|
},
|
|
{ disabled: !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: any) {
|
|
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";
|
|
triggerDownload(url, filename);
|
|
comfyToast(
|
|
t("toast.downloadingFile", "Downloading {filename}...", { filename }),
|
|
"info",
|
|
3000,
|
|
);
|
|
},
|
|
{ disabled: !asset?.filepath },
|
|
),
|
|
createItem(
|
|
`Download selected as ZIP (${selectedCount})`,
|
|
"pi pi-file-zip",
|
|
null,
|
|
async () => {
|
|
await downloadSelectedAsZip(mediaSelectedAssets);
|
|
},
|
|
{ disabled: selectedCount < 2 },
|
|
),
|
|
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: any) {
|
|
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: any) {
|
|
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: any) {
|
|
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: any) {
|
|
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: number, y: number, asset: Record<string, any> | null, onChanged: (() => void) | null): void {
|
|
if (!asset) return;
|
|
openGridTagsPopover({
|
|
x,
|
|
y,
|
|
asset,
|
|
onChanged,
|
|
});
|
|
}
|
|
|
|
export function bindGridContextMenu({ gridContainer, getState = () => ({}) }: { gridContainer?: any; getState?: () => Record<string, any> } = {}): (() => void) | undefined {
|
|
if (!gridContainer) return;
|
|
if (
|
|
gridContainer._mjrGridContextMenuBound &&
|
|
typeof gridContainer._mjrGridContextMenuUnbind === "function"
|
|
) {
|
|
return gridContainer._mjrGridContextMenuUnbind;
|
|
}
|
|
|
|
const handler = async (event: any) => {
|
|
const panelState = (() => {
|
|
try {
|
|
return getState?.() || {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
})();
|
|
const card = safeClosest(event.target, ".mjr-asset-card");
|
|
|
|
if (!card) {
|
|
if (!_isBrowserScope(panelState)) return;
|
|
const scope = String(gridContainer?.dataset?.mjrScope || "").toLowerCase();
|
|
const subfolderPath = String(gridContainer?.dataset?.mjrSubfolder || "").trim();
|
|
// For input/output scopes at root level the subfolder is empty —
|
|
// still show the menu so users can create folders at the root, but
|
|
// only while folder browsing is enabled (browser.showFolders).
|
|
const allowRootFolderCreate =
|
|
(scope === "input" || scope === "output") && _browserFoldersEnabled();
|
|
if (!subfolderPath && !allowRootFolderCreate) return;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
openGridContextMenu({
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
items: _buildEmptyGridItems({ currentPath: subfolderPath, gridContainer }),
|
|
});
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const asset = (card as any)._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: any) {
|
|
console.error("[GridContextMenu] Failed to bind:", err);
|
|
}
|
|
|
|
gridContainer._mjrGridContextMenuBound = true;
|
|
const unbind = () => {
|
|
try {
|
|
gridContainer.removeEventListener("contextmenu", handler);
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
gridContainer._mjrGridContextMenuBound = false;
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
gridContainer._mjrGridContextMenuUnbind = null;
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
cancelAllRatingUpdates();
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
closeAllGridContextMenus();
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
gridContainer._mjrGridContextMenuUnbind = unbind;
|
|
return unbind;
|
|
}
|