/** * Grid Keyboard Shortcuts - Keyboard navigation and actions for asset grid * * Shortcuts are designed to be: * - Mnemonic (easy to remember) * - Standard (consistent with Lightroom, Windows, macOS) * - Non-conflicting with browser/ComfyUI defaults * * @see docs/SHORTCUTS.md for full documentation */ import { comfyToast } from "../../app/toast.js"; import { t } from "../../app/i18n.js"; import { comfyPrompt } from "../../app/dialogs.js"; import { openInFolder, updateAssetRating, deleteAsset, renameAsset, removeFilepathsFromCollection, } from "../../api/client.js"; import { buildDownloadURL } from "../../api/endpoints.js"; import { ASSET_RATING_CHANGED_EVENT } from "../../app/events.js"; import { safeDispatchCustomEvent } from "../../utils/events.js"; import { showAddToCollectionMenu } from "../collections/contextmenu/addToCollectionMenu.js"; import { normalizeRenameFilename, validateFilename } from "../../utils/filenames.js"; import { getHotkeysState, isHotkeysSuspended } from "../panel/controllers/hotkeysState.js"; import { clearActiveGridContainer, getActiveGridContainer, setActiveGridContainer, } from "../panel/panelRuntimeRefs.js"; /** * Keyboard shortcut definitions * Format: { key, ctrl, shift, alt, action, description } */ const GRID_SHORTCUTS = { // Viewing OPEN_VIEWER: { key: "Enter", description: "Open selected in viewer" }, OPEN_VIEWER_ALT: { key: " ", description: "Open selected in viewer" }, OPEN_FLOATING_VIEWER: { key: "v", description: "Open selected in Floating Viewer" }, METADATA_PANEL: { key: "d", description: "Show metadata panel (Details)" }, COMPARE_AB: { key: "c", description: "Compare A/B (2 selected)" }, SIDE_BY_SIDE: { key: "g", description: "Side-by-side Grid view (2-4 selected)" }, // Rating (Lightroom/Bridge standard) RATING_1: { key: "1", description: "Set rating 1 star" }, RATING_2: { key: "2", description: "Set rating 2 stars" }, RATING_3: { key: "3", description: "Set rating 3 stars" }, RATING_4: { key: "4", description: "Set rating 4 stars" }, RATING_5: { key: "5", description: "Set rating 5 stars" }, RATING_RESET: { key: "0", description: "Reset rating" }, // Organization EDIT_TAGS: { key: "t", description: "Edit Tags" }, ADD_TO_COLLECTION: { key: "b", description: "Add to collection (Bookmark)" }, REMOVE_FROM_COLLECTION: { key: "b", shift: true, description: "Remove from collection" }, // File operations COPY_PATH: { key: "c", ctrl: true, shift: true, description: "Copy file path" }, DOWNLOAD: { key: "s", description: "Download file" }, RENAME: { key: "F2", description: "Rename file" }, DELETE: { key: "Delete", description: "Delete file" }, DELETE_ALT: { key: "Backspace", description: "Delete file" }, OPEN_IN_FOLDER: { key: "e", ctrl: true, shift: true, description: "Open in folder (Explorer)" }, // Selection SELECT_ALL: { key: "a", ctrl: true, description: "Select all" }, DESELECT_ALL: { key: "d", ctrl: true, description: "Deselect all" }, // Navigation NAV_UP: { key: "ArrowUp", description: "Navigate up" }, NAV_DOWN: { key: "ArrowDown", description: "Navigate down" }, NAV_LEFT: { key: "ArrowLeft", description: "Navigate left" }, NAV_RIGHT: { key: "ArrowRight", description: "Navigate right" }, }; let viewerOpenRequestModulePromise: Promise | null = null; function loadViewerOpenRequestModule() { if (!viewerOpenRequestModulePromise) { viewerOpenRequestModulePromise = import("../viewer/viewerOpenRequest.js"); } return viewerOpenRequestModulePromise; } /** * Format shortcut for display in context menu * @param {Object} shortcut - Shortcut definition * @returns {string} Formatted shortcut string (e.g., "Ctrl+Shift+C") */ function formatShortcut(shortcut: any) { if (!shortcut) return ""; const parts = []; if (shortcut.ctrl) parts.push("Ctrl"); if (shortcut.shift) parts.push("Shift"); if (shortcut.alt) parts.push("Alt"); let key = shortcut.key; // Format special keys if (key === " ") key = "Space"; if (key === "Delete") key = "Del"; if (key === "ArrowUp") key = "<-‘"; if (key === "ArrowDown") key = "<-“"; if (key === "ArrowLeft") key = "<-"; if (key === "ArrowRight") key = "->"; parts.push(key.length === 1 ? key.toUpperCase() : key); return parts.join("+"); } /** * Check if a keyboard event matches a shortcut definition * @param {KeyboardEvent} e - Keyboard event * @param {Object} shortcut - Shortcut definition * @returns {boolean} True if event matches shortcut */ function matchesShortcut(e: any, shortcut: any) { if (!shortcut || !e) return false; const keyMatches = e.key.toLowerCase() === shortcut.key.toLowerCase() || e.key === shortcut.key; const ctrlMatches = !!shortcut.ctrl === (e.ctrlKey || e.metaKey); const shiftMatches = !!shortcut.shift === e.shiftKey; const altMatches = !!shortcut.alt === e.altKey; return keyMatches && ctrlMatches && shiftMatches && altMatches; } /** * Rating debounce state */ const _ratingDebounceTimers = new Map(); function setRating(asset: any, rating: any, onChanged: any) { const assetId = asset?.id; if (!assetId) return; const id = String(assetId); try { const prev = _ratingDebounceTimers.get(id); if (prev) clearTimeout(prev); } catch (e: any) { console.debug?.(e); } // Optimistic UI try { asset.rating = rating; onChanged?.(); } catch (e: any) { console.debug?.(e); } const timerId = setTimeout(async () => { try { _ratingDebounceTimers.delete(id); const result = await updateAssetRating(assetId, rating); if (!result?.ok) { comfyToast(result?.error || t("toast.ratingUpdateFailed"), "error"); return; } comfyToast(t("toast.ratingSetN", { n: rating }), "success", 1500); safeDispatchCustomEvent( ASSET_RATING_CHANGED_EVENT, { assetId: String(assetId), rating }, { warnPrefix: "[GridKeyboard]" }, ); } catch (err: any) { console.error("[GridKeyboard] Rating update failed:", err); comfyToast(t("toast.ratingUpdateError"), "error"); } }, 350); _ratingDebounceTimers.set(id, timerId); } /** * Install keyboard shortcuts for the asset grid * * @param {Object} options - Configuration options * @param {HTMLElement} options.gridContainer - Grid container element * @param {Function} options.getState - Function to get panel state * @param {Function} options.getSelectedAssets - Function to get selected assets * @param {Function} options.getActiveAsset - Function to get the active/focused asset * @param {Function} options.onOpenDetails - Callback to open details panel * @param {Function} options.onOpenTagsEditor - Callback to open tags editor * @param {Function} options.onSelectionChanged - Callback when selection changes * @param {Function} options.onAssetChanged - Callback when asset is modified * @returns {Object} Object with bind, unbind, and dispose methods */ export function installGridKeyboard({ gridContainer, getState = () => ({}), getSelectedAssets = () => [], getActiveAsset = () => null, onOpenDetails = () => {}, onOpenTagsEditor = () => {}, onSelectionChanged = () => {}, onAssetChanged = () => {}, }: { gridContainer?: any; getState?: () => Record; getSelectedAssets?: () => any[]; getActiveAsset?: () => any; onOpenDetails?: (asset?: any) => void; onOpenTagsEditor?: (asset?: any) => void; onSelectionChanged?: () => void; onAssetChanged?: (asset?: any) => void } = {}): any { if (!gridContainer) return { bind: () => {}, unbind: () => {}, dispose: () => {} }; let keydownHandler: any = null; let keyupHandler: any = null; let dragstartHandler: any = null; let activateGridHandler: any = null; let bound = false; let pendingDownloadShortcut = false; const openInViewer = ({ assets = [] as any[], index = 0, mode = "" } = {}) => { void loadViewerOpenRequestModule() .then((mod) => mod.requestViewerOpen({ assets, index, mode })) .catch((e) => console.debug?.(e)); }; const markGridActive = () => { try { setActiveGridContainer(gridContainer); } catch (e: any) { console.debug?.(e); } try { (window as any).__MJR_LAST_SELECTION_GRID__ = gridContainer; } catch (e: any) { console.debug?.(e); } }; const getSelection = () => { try { return getSelectedAssets() || []; } catch { return []; } }; const getRenderedCards = () => { try { if (typeof gridContainer?._mjrGetRenderedCards === "function") { const cards = gridContainer._mjrGetRenderedCards(); if (Array.isArray(cards)) return cards; } } catch (e: any) { console.debug?.(e); } try { return Array.from(gridContainer.querySelectorAll(".mjr-asset-card")); } catch { return []; } }; const getActive = () => { try { const active = getActiveAsset(); if (active) return active; // Fallback: first selected const selected = getSelection(); return selected[0] || null; } catch { return null; } }; const downloadAsset = (asset: any) => { if (!asset?.filepath) return false; const url = buildDownloadURL(asset.filepath); const link = document.createElement("a"); link.href = url; link.download = asset.filename || "download"; document.body.appendChild(link); link.click(); document.body.removeChild(link); comfyToast( t("toast.downloadingFile", "Downloading {filename}...", { filename: asset.filename, }), "info", 3000, ); return true; }; const getAllAssets = () => { try { if (Array.isArray(getState?.()?.assets) && getState().assets.length) return getState().assets; } catch (e: any) { console.debug?.(e); } try { if (typeof gridContainer?._mjrGetAssets === "function") { const list = gridContainer._mjrGetAssets(); if (Array.isArray(list) && list.length) return list; } } catch (e: any) { console.debug?.(e); } try { return getRenderedCards() .map((card: any) => card?._mjrAsset) .filter(Boolean); } catch { return []; } }; const getGridColumns = () => { try { const cards = getRenderedCards(); if (cards.length >= 2) { const firstTop = Math.round(cards[0].getBoundingClientRect().top); const sameRow = cards.filter((c: any) => { try { return Math.abs(Math.round(c.getBoundingClientRect().top) - firstTop) <= 4; } catch { return false; } }); if (sameRow.length >= 1) return Math.max(1, sameRow.length); } if (cards.length >= 1) { const cardRect = cards[0].getBoundingClientRect(); const hostRect = gridContainer.getBoundingClientRect(); const cardW = Math.max(1, Math.round(cardRect.width)); const hostW = Math.max(1, Math.round(hostRect.width)); return Math.max(1, Math.floor(hostW / cardW)); } } catch (e: any) { console.debug?.(e); } return 1; }; const selectByIds = (ids: any, activeId: any) => { const list = Array.isArray(ids) ? ids.map(String).filter(Boolean) : []; const active = String(activeId || list[0] || ""); try { if (typeof gridContainer?._mjrSetSelection === "function") { gridContainer._mjrSetSelection(list, active); } else { gridContainer.dataset.mjrSelectedAssetIds = JSON.stringify(list); gridContainer.dataset.mjrSelectedAssetId = active; } } catch (e: any) { console.debug?.(e); } }; const scrollToAssetId = (assetId: any) => { const id = String(assetId || "").trim(); if (!id) return; // Prefer VirtualGrid-aware scroll (calculates position from index, works for off-screen items) try { if (typeof gridContainer?._mjrScrollToAssetId === "function") { gridContainer._mjrScrollToAssetId(id); return; } } catch (e: any) { console.debug?.(e); } // Fallback: DOM-based scroll (only works if the card is already rendered) try { const esc = typeof CSS !== "undefined" && CSS?.escape ? CSS.escape(id) : id.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1"); const card = gridContainer.querySelector(`.mjr-asset-card[data-mjr-asset-id="${esc}"]`); card?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); } catch (e: any) { console.debug?.(e); } }; const handleGridNavigation = (e: any) => { const key = String(e.key || ""); const isArrow = key === "ArrowLeft" || key === "ArrowRight" || key === "ArrowUp" || key === "ArrowDown"; const isEdge = key === "Home" || key === "End"; if (!isArrow && !isEdge) return false; if (e.ctrlKey || e.altKey || e.metaKey) return false; const assets = getAllAssets(); if (!Array.isArray(assets) || assets.length === 0) return false; const activeAsset = getActive(); const currentId = String( activeAsset?.id || gridContainer?.dataset?.mjrSelectedAssetId || "", ); let currentIndex = assets.findIndex((a: any) => String(a?.id || "") === currentId); const hadSelection = currentIndex >= 0; if (currentIndex < 0) currentIndex = 0; const cols = Math.max(1, getGridColumns()); let nextIndex = currentIndex; if (key === "ArrowLeft") nextIndex -= 1; else if (key === "ArrowRight") nextIndex += 1; else if (key === "ArrowUp") nextIndex -= cols; else if (key === "ArrowDown") nextIndex += cols; else if (key === "Home") nextIndex = 0; else if (key === "End") nextIndex = assets.length - 1; nextIndex = Math.max(0, Math.min(assets.length - 1, nextIndex)); // If nothing is selected yet, first arrow/home/end should still select an item. if (hadSelection && nextIndex === currentIndex && key !== "Home" && key !== "End") return false; const nextId = String(assets[nextIndex]?.id || ""); if (!nextId) return false; markGridActive(); if (e.shiftKey) { let anchorId = String(gridContainer?._mjrSelectionAnchorId || ""); if (!anchorId) anchorId = currentId || nextId; const anchorIndex = assets.findIndex((a: any) => String(a?.id || "") === anchorId); const from = anchorIndex >= 0 ? anchorIndex : currentIndex; const lo = Math.min(from, nextIndex); const hi = Math.max(from, nextIndex); const rangeIds = assets .slice(lo, hi + 1) .map((a: any) => String(a?.id || "")) .filter(Boolean); selectByIds(rangeIds, nextId); } else { gridContainer._mjrSelectionAnchorId = nextId; selectByIds([nextId], nextId); } scrollToAssetId(nextId); try { onSelectionChanged?.(); } catch (e: any) { console.debug?.(e); } return true; }; const handleKeydown = async (e: any) => { // Skip if typing in input const target = e.target; if ( target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) ) { return; } // Skip if hotkeys suspended (dialog open, etc.) if (isHotkeysSuspended()) return; // Viewer must have priority over grid shortcuts. if (getHotkeysState().scope === "viewer") return; // Skip if grid not visible/focused // Allow if focus is: // 1. Inside grid (contains active) // 2. On grid parent wrapper (active contains grid) // 3. On body, but only for the active grid // 4. On a card (explicit check though covered by 1 usually) if (document.activeElement !== document.body) { const active = document.activeElement; const inGrid = gridContainer.contains(active); const wrapsGrid = active && active.contains && active.contains(gridContainer); const isCard = active?.closest?.(".mjr-asset-card"); if (!inGrid && !wrapsGrid && !isCard) { return; } } else { const activeGrid = getActiveGridContainer(); if (activeGrid && activeGrid !== gridContainer) { return; } } const consume = () => { e.preventDefault(); e.stopPropagation(); }; // Grid navigation (arrows + Home/End, with Shift range extension) try { if (handleGridNavigation(e)) { consume(); return; } } catch (e: any) { console.debug?.(e); } const selected = getSelection(); const asset = getActive(); const state = getState(); if (matchesShortcut(e, GRID_SHORTCUTS.DOWNLOAD)) { if (asset?.filepath) { consume(); pendingDownloadShortcut = true; return; } } // Rating shortcuts (1-5, 0 for reset) - handled by dedicated rating controller when enabled. const ratingHotkeysActive = !!getHotkeysState().ratingHotkeysActive; if (!e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) { if (!ratingHotkeysActive && matchesShortcut(e, GRID_SHORTCUTS.RATING_1)) { if (asset?.id) { consume(); setRating(asset, 1, onAssetChanged); return; } } if (!ratingHotkeysActive && matchesShortcut(e, GRID_SHORTCUTS.RATING_2)) { if (asset?.id) { consume(); setRating(asset, 2, onAssetChanged); return; } } if (!ratingHotkeysActive && matchesShortcut(e, GRID_SHORTCUTS.RATING_3)) { if (asset?.id) { consume(); setRating(asset, 3, onAssetChanged); return; } } if (!ratingHotkeysActive && matchesShortcut(e, GRID_SHORTCUTS.RATING_4)) { if (asset?.id) { consume(); setRating(asset, 4, onAssetChanged); return; } } if (!ratingHotkeysActive && matchesShortcut(e, GRID_SHORTCUTS.RATING_5)) { if (asset?.id) { consume(); setRating(asset, 5, onAssetChanged); return; } } if (!ratingHotkeysActive && matchesShortcut(e, GRID_SHORTCUTS.RATING_RESET)) { if (asset?.id) { consume(); setRating(asset, 0, onAssetChanged); return; } } } // Open Viewer (Enter or Space) if ( matchesShortcut(e, GRID_SHORTCUTS.OPEN_VIEWER) || matchesShortcut(e, GRID_SHORTCUTS.OPEN_VIEWER_ALT) ) { if (selected.length > 0) { consume(); try { if (selected.length >= 2 && selected.length <= 4) { openInViewer({ assets: selected, index: 0 }); } else { // Open all assets from grid, starting at active let allAssets: any[] = []; if (Array.isArray(state?.assets) && state.assets.length) { allAssets = state.assets; } else if (typeof gridContainer?._mjrGetAssets === "function") { const list = gridContainer._mjrGetAssets(); if (Array.isArray(list) && list.length) allAssets = list; } else { const cards = getRenderedCards(); allAssets = cards.map((card: any) => card._mjrAsset).filter(Boolean); } const idx = allAssets.findIndex((a: any) => String(a?.id) === String(asset?.id)); openInViewer({ assets: allAssets, index: Math.max(0, idx) }); } } catch (err: any) { console.error("[GridKeyboard] Open viewer failed:", err); } return; } } // Metadata panel (D) if (matchesShortcut(e, GRID_SHORTCUTS.METADATA_PANEL)) { if (asset) { consume(); onOpenDetails(asset); return; } } // Compare A/B (C) - 2 selected only if (matchesShortcut(e, GRID_SHORTCUTS.COMPARE_AB)) { if (selected.length === 2) { consume(); try { openInViewer({ assets: selected, index: 0, mode: "ab" }); } catch (e: any) { console.debug?.(e); } return; } } // Side-by-side (G) - 2-4 selected if (matchesShortcut(e, GRID_SHORTCUTS.SIDE_BY_SIDE)) { if (selected.length >= 2 && selected.length <= 4) { consume(); try { openInViewer({ assets: selected, index: 0, mode: "sidebyside" }); } catch (e: any) { console.debug?.(e); } return; } } // Edit Tags (T) if (matchesShortcut(e, GRID_SHORTCUTS.EDIT_TAGS)) { if (asset?.id) { consume(); onOpenTagsEditor(asset); return; } } // Add to collection (B) if (matchesShortcut(e, GRID_SHORTCUTS.ADD_TO_COLLECTION)) { if (selected.length > 0 || asset) { consume(); const list = selected.length > 0 ? selected : [asset]; // Show menu at center of grid const rect = gridContainer.getBoundingClientRect(); await showAddToCollectionMenu({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, assets: list, }); return; } } // Remove from collection (Shift+B) if (matchesShortcut(e, GRID_SHORTCUTS.REMOVE_FROM_COLLECTION)) { const collectionId = state?.collectionId; if (collectionId && (selected.length > 0 || asset)) { consume(); try { const list = selected.length > 0 ? selected : [asset]; const filepaths = list .map((a: any) => String(a?.filepath || a?.path || a?.file_info?.filepath || "").trim(), ) .filter(Boolean); if (!filepaths.length) { comfyToast( t( "toast.removeFromCollectionFailed", "No valid files to remove from collection", ), "warning", ); return; } const res = await removeFilepathsFromCollection(collectionId, filepaths); if (!res?.ok) { comfyToast( res?.error || t( "toast.removeFromCollectionFailed", "Failed to remove from collection", ), "error", ); return; } try { window.dispatchEvent( new CustomEvent("mjr:reload-grid", { detail: { reason: "remove-from-collection" }, }), ); } catch (e: any) { console.debug?.(e); } comfyToast( t("toast.removedFromCollection", "Removed from collection"), "success", 1400, ); } catch (err: any) { comfyToast( t( "toast.removeFromCollectionError", "Error removing from collection: {error}", { error: err?.message || String(err || "") }, ), "error", ); } return; } } // Copy file path (Ctrl+Shift+C) if (matchesShortcut(e, GRID_SHORTCUTS.COPY_PATH)) { if (asset?.filepath) { consume(); try { await navigator.clipboard.writeText(asset.filepath); comfyToast(t("toast.pathCopied"), "success", 2000); } catch { comfyToast(t("toast.pathCopyFailed"), "error"); } return; } } // Open in Folder (Ctrl+Shift+E) if (matchesShortcut(e, GRID_SHORTCUTS.OPEN_IN_FOLDER)) { if (asset?.id) { consume(); const res = await openInFolder(asset.id); if (!res?.ok) { comfyToast(res?.error || t("toast.openFolderFailed"), "error"); } else { comfyToast(t("toast.openedInFolder"), "info", 2000); } return; } } // Rename (F2) if (matchesShortcut(e, GRID_SHORTCUTS.RENAME)) { if (asset?.id) { consume(); const currentName = asset.filename || ""; const rawInput = await comfyPrompt( t("dialog.rename.title", "Rename file"), currentName, ); const newName = normalizeRenameFilename(rawInput, currentName); if (newName && newName !== currentName) { const validation = validateFilename(newName); if (!validation.valid) { comfyToast(validation.reason, "error"); return; } try { const result = await renameAsset(asset, newName); if (result?.ok) { const fresh = result?.data?.asset; if (fresh && typeof fresh === "object") { Object.assign(asset, fresh); } else { asset.filename = newName; asset.filepath = 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, ); } } comfyToast(t("toast.fileRenamedSuccess"), "success"); try { window.dispatchEvent( new CustomEvent("mjr:reload-grid", { detail: { reason: "rename-keyboard" }, }), ); } catch (e: any) { console.debug?.(e); } onAssetChanged(); } else { comfyToast(result?.error || t("toast.fileRenameFailed"), "error"); } } catch (err: any) { comfyToast( t("toast.errorRenaming", "Error renaming file: {error}", { error: err?.message || String(err || ""), }), "error", ); } } return; } } // Delete (Delete/Backspace) if ( matchesShortcut(e, GRID_SHORTCUTS.DELETE) || matchesShortcut(e, GRID_SHORTCUTS.DELETE_ALT) ) { if (asset?.id || selected.length > 0) { consume(); const toDelete = selected.length > 0 ? selected : [asset]; let successCount = 0; let errorCount = 0; const deletedIds: any[] = []; for (const a of toDelete) { if (!a?.id) continue; try { const result = await deleteAsset(a.id); if (result?.ok) { successCount++; deletedIds.push(String(a.id)); } else { errorCount++; } } catch { errorCount++; } } if (deletedIds.length) { try { const removeFn = gridContainer?._mjrRemoveAssets; if (typeof removeFn === "function") { removeFn(deletedIds); } else { for (const id of deletedIds) { const card = gridContainer.querySelector( `[data-mjr-asset-id="${id}"]`, ); card?.remove?.(); } } } catch (e: any) { console.debug?.(e); } } if (errorCount === 0) { comfyToast( successCount === 1 ? t("toast.filesDeletedShort", "{n} files deleted", { n: successCount }) : t("toast.filesDeletedShort", "{n} files deleted", { n: successCount, }), "success", ); } else { comfyToast( t("toast.filesDeletedShortPartial", "{success} deleted, {failed} failed", { success: successCount, failed: errorCount, }), "warning", ); } try { if (typeof gridContainer?._mjrSetSelection === "function") { const remaining = selected .filter((a: any) => !deletedIds.includes(String(a?.id || ""))) .map((a: any) => String(a?.id || "")) .filter(Boolean); gridContainer._mjrSetSelection(remaining, remaining[0] || ""); } } catch (e: any) { console.debug?.(e); } onSelectionChanged(); return; } } // Select All (Ctrl+A) if (matchesShortcut(e, GRID_SHORTCUTS.SELECT_ALL)) { consume(); let ids: any[] = []; try { if (Array.isArray(state?.assets) && state.assets.length) { ids = state.assets.map((a: any) => String(a?.id || "")).filter(Boolean); } else if (typeof gridContainer?._mjrGetAssets === "function") { const list = gridContainer._mjrGetAssets(); if (Array.isArray(list)) ids = list.map((a: any) => String(a?.id || "")).filter(Boolean); } } catch (e: any) { console.debug?.(e); } if (!ids.length) { try { ids = getRenderedCards() .map((card: any) => String(card?._mjrAsset?.id || "")) .filter(Boolean); } catch (e: any) { console.debug?.(e); } } try { if (typeof gridContainer?._mjrSetSelection === "function") { gridContainer._mjrSetSelection(ids, ids[0] || ""); } else { const cards = getRenderedCards(); cards.forEach((card: any) => { card.classList.add("is-selected"); card.setAttribute("aria-selected", "true"); }); gridContainer.dataset.mjrSelectedAssetIds = JSON.stringify(ids); gridContainer.dataset.mjrSelectedAssetId = String(ids[0] || ""); } } catch (e: any) { console.debug?.(e); } onSelectionChanged(); return; } // Deselect All (Ctrl+D) if (matchesShortcut(e, GRID_SHORTCUTS.DESELECT_ALL)) { consume(); try { if (typeof gridContainer?._mjrSetSelection === "function") { gridContainer._mjrSetSelection([], ""); } else { const cards = getRenderedCards().filter((card: any) => card?.classList?.contains?.("is-selected"), ); cards.forEach((card: any) => { card.classList.remove("is-selected"); card.setAttribute("aria-selected", "false"); }); delete gridContainer.dataset.mjrSelectedAssetIds; delete gridContainer.dataset.mjrSelectedAssetId; } } catch (e: any) { console.debug?.(e); } onSelectionChanged(); return; } }; const handleKeyup = (e: any) => { if (String(e?.key || "").toLowerCase() !== "s") return; if (!pendingDownloadShortcut) return; pendingDownloadShortcut = false; if (isHotkeysSuspended()) return; if (getHotkeysState().scope === "viewer") return; const asset = getActive(); if (!asset?.filepath) return; e.preventDefault?.(); e.stopPropagation?.(); e.stopImmediatePropagation?.(); downloadAsset(asset); }; const handleDragStart = () => { pendingDownloadShortcut = false; }; const bind = () => { if (bound) return; keydownHandler = handleKeydown; keyupHandler = handleKeyup; dragstartHandler = handleDragStart; activateGridHandler = () => markGridActive(); document.addEventListener("keydown", keydownHandler, true); document.addEventListener("keyup", keyupHandler, true); document.addEventListener("dragstart", dragstartHandler, true); gridContainer.addEventListener("pointerdown", activateGridHandler, true); gridContainer.addEventListener("focusin", activateGridHandler, true); bound = true; }; const unbind = () => { if (!bound || !keydownHandler) return; document.removeEventListener("keydown", keydownHandler, true); if (keyupHandler) document.removeEventListener("keyup", keyupHandler, true); if (dragstartHandler) document.removeEventListener("dragstart", dragstartHandler, true); if (activateGridHandler) { gridContainer.removeEventListener("pointerdown", activateGridHandler, true); gridContainer.removeEventListener("focusin", activateGridHandler, true); } keydownHandler = null; keyupHandler = null; dragstartHandler = null; activateGridHandler = null; pendingDownloadShortcut = false; try { clearActiveGridContainer(gridContainer); } catch (e: any) { console.debug?.(e); } try { if ((window as any).__MJR_LAST_SELECTION_GRID__ === gridContainer) { delete (window as any).__MJR_LAST_SELECTION_GRID__; } } catch (e: any) { console.debug?.(e); } bound = false; for (const t of _ratingDebounceTimers.values()) { try { clearTimeout(t); } catch (e: any) { console.debug?.(e); } } _ratingDebounceTimers.clear(); }; const dispose = () => { unbind(); // Clear pending timers for (const t of _ratingDebounceTimers.values()) { try { clearTimeout(t); } catch (e: any) { console.debug?.(e); } } _ratingDebounceTimers.clear(); }; return { bind, unbind, dispose }; } /** * Get shortcut string for a specific action (for display in context menus) * @param {string} actionName - Name of the action (e.g., "OPEN_VIEWER", "METADATA_PANEL") * @returns {string} Formatted shortcut string or empty string */ export function getShortcutDisplay(actionName: string): string { const shortcut = (GRID_SHORTCUTS as Record)[actionName]; return shortcut ? formatShortcut(shortcut) : ""; }