1042 lines
39 KiB
TypeScript
1042 lines
39 KiB
TypeScript
/**
|
||
* 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) : "";
|
||
}
|