Files

1042 lines
39 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<typeof import("../viewer/viewerOpenRequest.js")> | 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<string, any>; 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<string, any>)[actionName];
return shortcut ? formatShortcut(shortcut) : "";
}