/** * 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, 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 { 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 | null = null; let floatingViewerManagerModulePromise: Promise | 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) { return String(panelState?.scope || "").toLowerCase() === "custom"; } 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 _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: 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: 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.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 | null, onChanged: (() => void) | null): void { if (!asset) return; openGridTagsPopover({ x, y, asset, onChanged, }); } export function bindGridContextMenu({ gridContainer, getState = () => ({}) }: { gridContainer?: any; getState?: () => Record } = {}): (() => 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 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 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; }