980 lines
37 KiB
JavaScript
980 lines
37 KiB
JavaScript
/**
|
|
* 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 { requestViewerOpen } from "../viewer/viewerOpenRequest.js";
|
|
import { showAddToCollectionMenu } from "../collections/contextmenu/addToCollectionMenu.js";
|
|
import { normalizeRenameFilename, validateFilename } from "../../utils/filenames.js";
|
|
import { getHotkeysState, isHotkeysSuspended } from "../panel/controllers/hotkeysState.js";
|
|
import { 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" },
|
|
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", ctrl: true, 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" },
|
|
};
|
|
|
|
/**
|
|
* 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) {
|
|
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, shortcut) {
|
|
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, rating, onChanged) {
|
|
const assetId = asset?.id;
|
|
if (!assetId) return;
|
|
const id = String(assetId);
|
|
|
|
try {
|
|
const prev = _ratingDebounceTimers.get(id);
|
|
if (prev) clearTimeout(prev);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Optimistic UI
|
|
try {
|
|
asset.rating = rating;
|
|
onChanged?.();
|
|
} catch (e) {
|
|
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) {
|
|
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 = () => {},
|
|
} = {}) {
|
|
if (!gridContainer) return { bind: () => {}, unbind: () => {}, dispose: () => {} };
|
|
|
|
let keydownHandler = null;
|
|
let activateGridHandler = null;
|
|
let bound = false;
|
|
const openInViewer = ({ assets = [], index = 0, mode = "" } = {}) => {
|
|
requestViewerOpen({ assets, index, mode });
|
|
};
|
|
|
|
const markGridActive = () => {
|
|
try {
|
|
setActiveGridContainer(gridContainer);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
window.__MJR_LAST_SELECTION_GRID__ = gridContainer;
|
|
} catch (e) {
|
|
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) {
|
|
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 getAllAssets = () => {
|
|
try {
|
|
if (Array.isArray(getState?.()?.assets) && getState().assets.length)
|
|
return getState().assets;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
if (typeof gridContainer?._mjrGetAssets === "function") {
|
|
const list = gridContainer._mjrGetAssets();
|
|
if (Array.isArray(list) && list.length) return list;
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
return getRenderedCards()
|
|
.map((card) => 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) => {
|
|
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) {
|
|
console.debug?.(e);
|
|
}
|
|
return 1;
|
|
};
|
|
|
|
const selectByIds = (ids, activeId) => {
|
|
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) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
const scrollToAssetId = (assetId) => {
|
|
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) {
|
|
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) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
const handleGridNavigation = (e) => {
|
|
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) => 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) => 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) => String(a?.id || ""))
|
|
.filter(Boolean);
|
|
selectByIds(rangeIds, nextId);
|
|
} else {
|
|
gridContainer._mjrSelectionAnchorId = nextId;
|
|
selectByIds([nextId], nextId);
|
|
}
|
|
|
|
scrollToAssetId(nextId);
|
|
try {
|
|
onSelectionChanged?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const handleKeydown = async (e) => {
|
|
// 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) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
const selected = getSelection();
|
|
const asset = getActive();
|
|
const state = getState();
|
|
|
|
// 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 = [];
|
|
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) => card._mjrAsset).filter(Boolean);
|
|
}
|
|
const idx = allAssets.findIndex((a) => String(a?.id) === String(asset?.id));
|
|
openInViewer({ assets: allAssets, index: Math.max(0, idx) });
|
|
}
|
|
} catch (err) {
|
|
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) {
|
|
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) {
|
|
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) =>
|
|
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) {
|
|
console.debug?.(e);
|
|
}
|
|
comfyToast(
|
|
t("toast.removedFromCollection", "Removed from collection"),
|
|
"success",
|
|
1400,
|
|
);
|
|
} catch (err) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Download (Ctrl+S)
|
|
if (matchesShortcut(e, GRID_SHORTCUTS.DOWNLOAD)) {
|
|
if (asset?.filepath) {
|
|
consume();
|
|
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;
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
console.debug?.(e);
|
|
}
|
|
onAssetChanged();
|
|
} else {
|
|
comfyToast(result?.error || t("toast.fileRenameFailed"), "error");
|
|
}
|
|
} catch (err) {
|
|
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 = [];
|
|
|
|
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) {
|
|
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) => !deletedIds.includes(String(a?.id || "")))
|
|
.map((a) => String(a?.id || ""))
|
|
.filter(Boolean);
|
|
gridContainer._mjrSetSelection(remaining, remaining[0] || "");
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
onSelectionChanged();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Select All (Ctrl+A)
|
|
if (matchesShortcut(e, GRID_SHORTCUTS.SELECT_ALL)) {
|
|
consume();
|
|
let ids = [];
|
|
try {
|
|
if (Array.isArray(state?.assets) && state.assets.length) {
|
|
ids = state.assets.map((a) => String(a?.id || "")).filter(Boolean);
|
|
} else if (typeof gridContainer?._mjrGetAssets === "function") {
|
|
const list = gridContainer._mjrGetAssets();
|
|
if (Array.isArray(list))
|
|
ids = list.map((a) => String(a?.id || "")).filter(Boolean);
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
if (!ids.length) {
|
|
try {
|
|
ids = getRenderedCards()
|
|
.map((card) => String(card?._mjrAsset?.id || ""))
|
|
.filter(Boolean);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
try {
|
|
if (typeof gridContainer?._mjrSetSelection === "function") {
|
|
gridContainer._mjrSetSelection(ids, ids[0] || "");
|
|
} else {
|
|
const cards = getRenderedCards();
|
|
cards.forEach((card) => {
|
|
card.classList.add("is-selected");
|
|
card.setAttribute("aria-selected", "true");
|
|
});
|
|
gridContainer.dataset.mjrSelectedAssetIds = JSON.stringify(ids);
|
|
gridContainer.dataset.mjrSelectedAssetId = String(ids[0] || "");
|
|
}
|
|
} catch (e) {
|
|
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) =>
|
|
card?.classList?.contains?.("is-selected"),
|
|
);
|
|
cards.forEach((card) => {
|
|
card.classList.remove("is-selected");
|
|
card.setAttribute("aria-selected", "false");
|
|
});
|
|
delete gridContainer.dataset.mjrSelectedAssetIds;
|
|
delete gridContainer.dataset.mjrSelectedAssetId;
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
onSelectionChanged();
|
|
return;
|
|
}
|
|
};
|
|
|
|
const bind = () => {
|
|
if (bound) return;
|
|
keydownHandler = handleKeydown;
|
|
activateGridHandler = () => markGridActive();
|
|
document.addEventListener("keydown", keydownHandler, 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 (activateGridHandler) {
|
|
gridContainer.removeEventListener("pointerdown", activateGridHandler, true);
|
|
gridContainer.removeEventListener("focusin", activateGridHandler, true);
|
|
}
|
|
keydownHandler = null;
|
|
activateGridHandler = null;
|
|
bound = false;
|
|
for (const t of _ratingDebounceTimers.values()) {
|
|
try {
|
|
clearTimeout(t);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
_ratingDebounceTimers.clear();
|
|
};
|
|
|
|
const dispose = () => {
|
|
unbind();
|
|
// Clear pending timers
|
|
for (const t of _ratingDebounceTimers.values()) {
|
|
try {
|
|
clearTimeout(t);
|
|
} catch (e) {
|
|
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) {
|
|
const shortcut = GRID_SHORTCUTS[actionName];
|
|
return shortcut ? formatShortcut(shortcut) : "";
|
|
}
|