Files
MajoorWaldi-ComfyUI-Majoor-…/js/features/grid/GridView_impl.js
T
MajoorWaldi dc72be0139 feat: enhance JS testing and linting setup
- Added coverage reporting for Vitest with configuration in vitest.config.mjs
- Updated package.json to include new scripts for JS coverage, linting, and formatting
- Modified run_quality_gate.py to allow skipping ESLint and Prettier checks
- Introduced .prettierrc for consistent code formatting
- Created .prettierignore to exclude specific files and directories from formatting
2026-03-28 14:14:28 +01:00

1890 lines
64 KiB
JavaScript

/**
* Grid View Feature - Asset grid display
*/
import { get, hydrateAssetRatingTags } from "../../api/client.js";
import { buildListURL } from "../../api/endpoints.js";
import {
createAssetCard,
cleanupVideoThumbsIn,
cleanupCardMediaHandlers,
} from "../../components/Card.js";
import { createFolderCard } from "../../components/FolderCard.js";
import {
createRatingBadge,
createTagsBadge,
setFileBadgeCollision,
} from "../../components/Badges.js";
import { APP_CONFIG } from "../../app/config.js";
import { getViewerInstance } from "../../components/Viewer.js";
// Context menus are bound by the panel (GridContextMenu) to avoid duplicate handlers.
import { ASSET_RATING_CHANGED_EVENT, ASSET_TAGS_CHANGED_EVENT } from "../../app/events.js";
import { safeDispatchCustomEvent } from "../../utils/events.js";
import { debounce } from "../../utils/debounce.js";
import { pickRootId } from "../../utils/ids.js";
import { bindAssetDragStart } from "../dnd/DragDrop.js";
import { loadMajoorSettings } from "../../app/settings.js";
import { VirtualGrid } from "./VirtualGrid.js";
import { installGridKeyboard } from "./GridKeyboard.js";
import {
enqueueRatingTagsHydration as rtEnqueueRatingTagsHydration,
getRtHydrateState as rtGetRtHydrateState,
pruneRatingTagsHydrateSeen as rtPruneRatingTagsHydrateSeen,
pumpRatingTagsHydration as rtPumpRatingTagsHydration,
updateCardRatingTagsBadges as rtUpdateCardRatingTagsBadges,
} from "./RatingTagsHydrator.js";
import {
getSelectedIdSet as selectionGetSelectedIdSet,
safeEscapeId as selectionSafeEscapeId,
setSelectedIdsDataset as selectionSetSelectedIdsDataset,
setSelectionIds as selectionSetSelectionIds,
syncSelectionClasses as selectionSyncSelectionClasses,
} from "./GridSelectionManager.js";
import {
captureScrollMetrics as vsCaptureScrollMetrics,
ensureSentinel as vsEnsureSentinel,
ensureVirtualGrid as vsEnsureVirtualGrid,
getScrollContainer as vsGetScrollContainer,
isPotentialScrollContainer as vsIsPotentialScrollContainer,
maybeKeepPinnedToBottom as vsMaybeKeepPinnedToBottom,
startInfiniteScroll as vsStartInfiniteScroll,
stopObserver as vsStopObserver,
detectScrollRoot as vsDetectScrollRoot,
} from "./VirtualScroller.js";
import {
appendAssets as cardAppendAssets,
buildCollisionPaths as cardBuildCollisionPaths,
detectKind as cardDetectKind,
getExtUpper as cardGetExtUpper,
getFilenameKey as cardGetFilenameKey,
getStemLower as cardGetStemLower,
isHidePngSiblingsEnabled as cardIsHidePngSiblingsEnabled,
setCollisionTooltip as cardSetCollisionTooltip,
} from "./AssetCardRenderer.js";
import {
disposeStackGroupCards,
ensureStackGroupCard,
getStackAwareAssetKey,
} from "./StackGroupCards.js";
import {
compareAssets as infCompareAssets,
fetchPage as infFetchPage,
findAssetElement as infFindAssetElement,
findInsertPosition as infFindInsertPosition,
flushUpsertBatch as infFlushUpsertBatch,
getSortValue as infGetSortValue,
loadNextPage as infLoadNextPage,
shouldInsertBefore as infShouldInsertBefore,
upsertAsset as infUpsertAsset,
} from "./InfiniteScroll.js";
/**
* Strict typing for State management
* @typedef {Object} GridState
* @property {string} query Current search string
* @property {number} offset Pagination offset
* @property {number|null} total Total items on server
* @property {boolean} loading Is fetch in progress
* @property {boolean} done Is pagination exhausted
* @property {Set<string>} seenKeys Identifiers to prevent duplicates
* @property {Array<Object>} assets Array of Asset objects (Data Store)
* @property {Map<string, number>} filenameCounts Collision detection map
* @property {Set<string>} nonImageStems Logic for hiding PNG siblings
* @property {Map<string, Array<Object>>} stemMap Logic for hiding PNG siblings
* @property {Map<string, Array<HTMLElement>>} renderedFilenameMap Live DOM references
* @property {VirtualGrid|null} virtualGrid The virtual renderer instance
* @property {HTMLElement|null} sentinel Infinite scroll trigger
* @property {IntersectionObserver|null} observer Infinite scroll observer
*/
/** @type {WeakMap<HTMLElement, GridState>} */
const GRID_STATE = new WeakMap();
const RT_HYDRATE_CONCURRENCY = APP_CONFIG.RT_HYDRATE_CONCURRENCY ?? 2;
const RT_HYDRATE_QUEUE_MAX = APP_CONFIG.RT_HYDRATE_QUEUE_MAX ?? 100;
const RT_HYDRATE_SEEN_MAX = APP_CONFIG.RT_HYDRATE_SEEN_MAX ?? 20000;
const RT_HYDRATE_SEEN_TTL_MS = APP_CONFIG.RT_HYDRATE_SEEN_TTL_MS ?? 10 * 60 * 1000;
// RT_HYDRATE_STATE remains as the per-grid state map
const RT_HYDRATE_STATE = new WeakMap(); // gridContainer -> { queue, inflight, seen, active, lastPruneAt }
// ─────────────────────────────────────────────────────────────────────────────
// Upsert Batching - Accumulate upserts and flush once for performance
// ─────────────────────────────────────────────────────────────────────────────
const UPSERT_BATCH_DEBOUNCE_MS = 200; // Debounce window for batch flush
const UPSERT_BATCH_MAX_SIZE = 50; // Max items before forced flush
const UPSERT_BATCH_STATE = new WeakMap(); // gridContainer -> { pending: Map<id, asset>, timer: number|null }
// Temporary debug logging for grid + infinite scroll.
// Enable at runtime from the browser console:
// globalThis._mjrDebugGrid = true
const _gridDebugEnabled = () => {
try {
return !!globalThis?._mjrDebugGrid;
} catch {
return false;
}
};
const gridDebug = (...args) => {
try {
if (!_gridDebugEnabled()) return;
console.log("[Majoor][Grid]", ...args);
} catch (e) {
console.debug?.(e);
}
};
function _getRenderedCards(gridContainer) {
if (!gridContainer) return [];
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 [];
}
}
/**
* Get hydration state for a grid.
* @param {HTMLElement} gridContainer
* @returns {{queue: any[], inflight: number, seen: Set<string>, active: Set<string>, lastPruneAt: number}|null}
*/
function _getRtHydrateState(gridContainer) {
return rtGetRtHydrateState(gridContainer, RT_HYDRATE_STATE);
}
function _rtPruneBudgetRef(st) {
return {
get value() {
return Number(st?.pruneBudget || 0) || 0;
},
set value(v) {
try {
st.pruneBudget = Number(v) || 0;
} catch (e) {
console.debug?.(e);
}
},
};
}
function _pruneRatingTagsHydrateSeen(st) {
return rtPruneRatingTagsHydrateSeen(
st,
RT_HYDRATE_SEEN_MAX,
RT_HYDRATE_SEEN_TTL_MS,
_rtPruneBudgetRef(st),
);
}
function _updateCardRatingTagsBadges(card, rating, tags) {
return rtUpdateCardRatingTagsBadges(card, rating, tags, { createRatingBadge, createTagsBadge });
}
function _pumpRatingTagsHydration(gridContainer) {
return rtPumpRatingTagsHydration(gridContainer, {
stateMap: RT_HYDRATE_STATE,
concurrency: RT_HYDRATE_CONCURRENCY,
hydrateAssetRatingTags,
createRatingBadge,
createTagsBadge,
safeDispatchCustomEvent,
events: { rating: ASSET_RATING_CHANGED_EVENT, tags: ASSET_TAGS_CHANGED_EVENT },
});
}
function enqueueRatingTagsHydration(gridContainer, card, asset) {
const st = _getRtHydrateState(gridContainer);
return rtEnqueueRatingTagsHydration(gridContainer, card, asset, {
stateMap: RT_HYDRATE_STATE,
queueMax: RT_HYDRATE_QUEUE_MAX,
seenMax: RT_HYDRATE_SEEN_MAX,
seenTtlMs: RT_HYDRATE_SEEN_TTL_MS,
pruneBudgetRef: _rtPruneBudgetRef(st),
concurrency: RT_HYDRATE_CONCURRENCY,
hydrateAssetRatingTags,
createRatingBadge,
createTagsBadge,
safeDispatchCustomEvent,
events: { rating: ASSET_RATING_CHANGED_EVENT, tags: ASSET_TAGS_CHANGED_EVENT },
});
}
/**
* Update grid CSS classes based on current settings.
* Allows instant toggling of card elements (Filename, Dimensions, etc.) via CSS
* without rebuilding the DOM.
*/
function _updateGridSettingsClasses(container) {
if (!container) return;
// Inject dynamic styles if not present (ensures reactivity behaves correctly)
const styleId = "mjr-grid-settings-styles";
if (!document.getElementById(styleId)) {
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
/* Reactive visibility toggles */
/* Ensure card uses flex column to fill space when info is hidden */
.mjr-grid .mjr-asset-card,
.mjr-grid .mjr-card {
display: flex;
flex-direction: column;
}
/* Thumb grows to fill available space */
.mjr-grid .mjr-thumb {
flex: 1;
min-height: 0;
position: relative;
overflow: hidden;
}
/* Ensure media fills the thumb container */
.mjr-grid .mjr-thumb-media {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.mjr-grid .mjr-asset-card:hover {
background-color: var(--mjr-card-hover-color) !important;
}
.mjr-grid .mjr-asset-card.is-selected {
outline: 2px solid var(--mjr-card-selection-color) !important;
box-shadow: 0 0 0 2px var(--mjr-card-selection-color) !important;
}
.mjr-grid .mjr-card-filename { display: none; }
.mjr-grid.mjr-show-filename .mjr-card-filename { display: block; }
.mjr-grid .mjr-card-dot-wrapper { display: none; }
.mjr-grid.mjr-show-dot .mjr-card-dot-wrapper { display: inline-flex; }
.mjr-grid .mjr-asset-status-dot {
transition: color 0.3s ease, opacity 0.3s ease;
}
.mjr-grid .mjr-asset-status-dot.mjr-pulse-animation {
animation: mjr-pulse 1.5s infinite;
}
@keyframes mjr-pulse {
0% { opacity: 1; }
50% { opacity: 0.4; }
100% { opacity: 1; }
}
.mjr-grid .mjr-meta-res { display: none; }
.mjr-grid.mjr-show-dimensions .mjr-meta-res { display: inline; }
.mjr-grid .mjr-meta-duration { display: none; }
.mjr-grid.mjr-show-dimensions .mjr-meta-duration { display: inline; }
.mjr-grid .mjr-meta-date { display: none; }
.mjr-grid.mjr-show-date .mjr-meta-date { display: inline; }
.mjr-grid .mjr-meta-gentime { display: none; }
.mjr-grid.mjr-show-gentime .mjr-meta-gentime { display: inline; }
.mjr-grid .mjr-badge-ext { display: none !important; }
.mjr-grid.mjr-show-badges-ext .mjr-badge-ext { display: flex !important; }
.mjr-grid .mjr-badge-rating { display: none !important; }
.mjr-grid.mjr-show-badges-rating .mjr-badge-rating { display: flex !important; }
.mjr-grid .mjr-badge-tags { display: none !important; }
.mjr-grid.mjr-show-badges-tags .mjr-badge-tags { display: flex !important; }
/* Info container management */
/* Default to hidden to prevent flash or "black box" if js is slow */
.mjr-grid .mjr-card-info { display: none !important; }
/* Only show when explicitly enabled */
.mjr-grid.mjr-show-details .mjr-card-info { display: block !important; }
/* Separator management (experimental) */
/* If we want separators like " • ", we can use CSS pseudo-elements instead of text nodes */
.mjr-card-meta-row > span + span::before {
content: " • ";
opacity: 0.5;
margin: 0 4px;
}
/* Hide separator if previous element is hidden */
.mjr-card-meta-row > span[style*="display: none"] + span::before {
display: none;
}
`;
document.head.appendChild(style);
}
// Helper to toggle class based on config bool
const toggle = (cls, enabled) => {
if (enabled) container.classList.add(cls);
else container.classList.remove(cls);
};
toggle("mjr-show-filename", APP_CONFIG.GRID_SHOW_DETAILS_FILENAME);
toggle("mjr-show-dimensions", APP_CONFIG.GRID_SHOW_DETAILS_DIMENSIONS);
toggle("mjr-show-date", APP_CONFIG.GRID_SHOW_DETAILS_DATE);
toggle("mjr-show-gentime", APP_CONFIG.GRID_SHOW_DETAILS_GENTIME);
// toggle("mjr-show-time", APP_CONFIG.GRID_SHOW_DETAILS_TIME); // If we add explicit time setting
toggle("mjr-show-dot", APP_CONFIG.GRID_SHOW_WORKFLOW_DOT);
toggle("mjr-show-badges-ext", APP_CONFIG.GRID_SHOW_BADGES_EXTENSION);
toggle("mjr-show-badges-rating", APP_CONFIG.GRID_SHOW_BADGES_RATING);
toggle("mjr-show-badges-tags", APP_CONFIG.GRID_SHOW_BADGES_TAGS);
// Overall details vs generic show
toggle("mjr-show-details", APP_CONFIG.GRID_SHOW_DETAILS);
// Helper for CSS vars
container.style.setProperty("--mjr-grid-min-size", `${APP_CONFIG.GRID_MIN_SIZE}px`);
container.style.setProperty("--mjr-grid-gap", `${APP_CONFIG.GRID_GAP}px`);
// Fallback color vars on grid root so cards/badges always inherit even if parent scope differs.
container.style.setProperty("--mjr-star-active", APP_CONFIG.BADGE_STAR_COLOR);
container.style.setProperty("--mjr-badge-image", APP_CONFIG.BADGE_IMAGE_COLOR);
container.style.setProperty("--mjr-badge-video", APP_CONFIG.BADGE_VIDEO_COLOR);
container.style.setProperty("--mjr-badge-audio", APP_CONFIG.BADGE_AUDIO_COLOR);
container.style.setProperty("--mjr-badge-model3d", APP_CONFIG.BADGE_MODEL3D_COLOR);
container.style.setProperty(
"--mjr-badge-duplicate-alert",
APP_CONFIG.BADGE_DUPLICATE_ALERT_COLOR,
);
container.style.setProperty("--mjr-card-hover-color", APP_CONFIG.UI_CARD_HOVER_COLOR);
container.style.setProperty("--mjr-card-selection-color", APP_CONFIG.UI_CARD_SELECTION_COLOR);
container.style.setProperty("--mjr-rating-color", APP_CONFIG.UI_RATING_COLOR);
container.style.setProperty("--mjr-tag-color", APP_CONFIG.UI_TAG_COLOR);
}
/**
* Create grid container
* @returns {HTMLElement} The configured grid container
*/
export function createGridContainer() {
const container = document.createElement("div");
container.id = "mjr-assets-grid";
container.classList.add("mjr-grid");
container.tabIndex = 0;
container.setAttribute("role", "grid");
// Provide a stable accessor for full asset lists (VirtualGrid-safe).
try {
container._mjrGetAssets = () => {
try {
const state = GRID_STATE.get(container);
return Array.isArray(state?.assets) ? state.assets : [];
} catch {
return [];
}
};
} catch (e) {
console.debug?.(e);
}
try {
container._mjrSetSelection = (ids, activeId = "") =>
setSelectionIds(container, ids, { activeId });
container._mjrRemoveAssets = (assetIds, options = {}) =>
removeAssetsFromGrid(container, assetIds, options);
container._mjrSyncSelection = (ids) => syncSelectionClasses(container, ids);
container._mjrGetRenderedCards = () => {
try {
const state = GRID_STATE.get(container);
return state?.virtualGrid?.getRenderedCards?.() || [];
} catch {
return [];
}
};
container._mjrScrollToAssetId = (assetId) => {
const id = String(assetId || "").trim();
if (!id) return;
const state = GRID_STATE.get(container);
if (!state?.virtualGrid || !Array.isArray(state.assets)) return;
const idx = state.assets.findIndex((a) => String(a?.id || "") === id);
if (idx >= 0) state.virtualGrid.scrollToIndex(idx);
};
} catch (e) {
console.debug?.(e);
}
// Initial class application
_updateGridSettingsClasses(container);
// Listen for settings changes to update CSS classes reactively
const SETTINGS_REFRESH_DEBOUNCE_MS = 180;
const scheduleSettingsRefresh = debounce(() => {
refreshGrid(container);
}, SETTINGS_REFRESH_DEBOUNCE_MS);
const onSettingsChanged = () => {
// Apply class/CSS var updates immediately (cheap) for responsive UI.
requestAnimationFrame(() => {
_updateGridSettingsClasses(container);
});
// Heavy virtual-grid redraw is debounced to avoid picker-drag freeze.
scheduleSettingsRefresh();
};
// We bind to the custom event dispatched by settings.js
// Note: The event is dispatched on `window` usually via safeDispatchCustomEvent
try {
window.addEventListener("mjr-settings-changed", onSettingsChanged);
// Store cleanup function on container for disposeGrid
container._mjrSettingsChangedCleanup = () => {
try {
window.removeEventListener("mjr-settings-changed", onSettingsChanged);
} catch (e) {
console.debug?.(e);
}
};
} catch (e) {
console.debug?.(e);
}
// Bind delegated dragstart once (avoid per-card listeners; improves load perf on large grids).
try {
bindAssetDragStart(container);
} catch (e) {
console.debug?.(e);
}
// Install grid keyboard shortcuts
try {
const kbd = installGridKeyboard({
gridContainer: container,
getState: () => GRID_STATE.get(container) || {},
getSelectedAssets: () => {
try {
const state = GRID_STATE.get(container);
if (state?.assets) {
const ids = getSelectedIdSet(container);
return state.assets.filter((a) => ids.has(String(a.id)));
}
return _getRenderedCards(container)
.filter((c) => c?.classList?.contains?.("is-selected"))
.map((c) => c?._mjrAsset)
.filter(Boolean);
} catch {
return [];
}
},
getActiveAsset: () => {
// @ts-ignore
return document.activeElement?.closest?.(".mjr-asset-card")?._mjrAsset || null;
},
onAssetChanged: (asset) => {
if (!asset?.id) return;
const id = String(asset.id);
const cards = _getRenderedCards(container);
for (const card of cards) {
// @ts-ignore
if (String(card?._mjrAsset?.id) === id) {
// @ts-ignore
_updateCardRatingTagsBadges(card, asset.rating, asset.tags);
}
}
},
onOpenDetails: () => {
safeDispatchCustomEvent("mjr:open-sidebar", { tab: "details" });
},
getViewer: getViewerInstance,
});
kbd.bind();
// @ts-ignore
container._mjrGridKeyboard = kbd;
} catch (e) {
gridDebug("keyboard:installFailed", e);
}
// Bind double-click to open viewer
container.addEventListener("dblclick", (e) => {
/** @type {HTMLElement} */
// @ts-ignore
const card = e.target.closest(".mjr-asset-card");
if (!card) return;
/** @type {Object} */
// @ts-ignore
const asset = card._mjrAsset;
if (!asset) return;
// Get all assets in current grid for navigation
const state = GRID_STATE.get(container);
const allAssets = Array.isArray(state?.assets) ? state.assets : [];
const isFolder = String(asset?.kind || "").toLowerCase() === "folder";
if (isFolder) {
try {
container.dispatchEvent?.(
new CustomEvent("mjr:open-folder-asset", {
bubbles: true,
detail: { asset },
}),
);
// Fallback: if no panel-level handler is attached, reload from grid directly.
if (!container?._mjrHasCustomSubfolderHandler) {
loadAssets(container, state?.query || "*", { reset: true }).catch(() => {});
}
} catch (e) {
console.debug?.(e);
}
return;
}
// Open viewer with non-folder assets only
const mediaAssets = (allAssets || []).filter(
(a) => String(a?.kind || "").toLowerCase() !== "folder",
);
const mediaIndex = mediaAssets.findIndex((a) => a?.id === asset?.id);
if (!mediaAssets.length || mediaIndex < 0) return;
const viewer = getViewerInstance();
viewer.open(mediaAssets, mediaIndex);
});
return container;
}
const OVERLAY_CLASS = "mjr-grid-loading-overlay";
const SENTINEL_CLASS = "mjr-grid-sentinel";
const LOAD_ASSETS_TIMEOUT_MS = 25000;
function showLoadingOverlay(gridContainer, message = "Loading assets...") {
let overlay = gridContainer.querySelector(`.${OVERLAY_CLASS}`);
if (!overlay) {
overlay = document.createElement("div");
overlay.className = OVERLAY_CLASS;
}
overlay.style.cssText = `
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 16px;
pointer-events: none;
font-size: 14px;
line-height: 1.4;
color: var(--muted-text, #bdbdbd);
background: rgba(0, 0, 0, 0.35);
z-index: 4;
`;
overlay.textContent = message;
try {
const pos = String(getComputedStyle(gridContainer).position || "").toLowerCase();
if (!pos || pos === "static") {
gridContainer.style.position = "relative";
}
} catch (e) {
console.debug?.(e);
}
if (!gridContainer.contains(overlay)) {
gridContainer.appendChild(overlay);
}
overlay.style.display = "flex";
// Ensure the overlay is visible even when the grid is empty/collapsed.
if (!gridContainer.style.minHeight) {
gridContainer.style.minHeight = "160px";
}
}
function hideLoadingOverlay(gridContainer) {
const overlay = gridContainer.querySelector(`.${OVERLAY_CLASS}`);
if (overlay) {
overlay.remove();
}
try {
if (gridContainer.style.minHeight === "160px") {
gridContainer.style.minHeight = "";
}
} catch (e) {
console.debug?.(e);
}
}
const GRID_MESSAGE_CLASS = "mjr-grid-message";
function clearGridMessage(gridContainer) {
if (!gridContainer) return;
try {
const existing = gridContainer.querySelectorAll?.(`.${GRID_MESSAGE_CLASS}`) || [];
for (const el of existing) {
try {
el.remove();
} catch (e) {
console.debug?.(e);
}
}
} catch (e) {
console.debug?.(e);
}
}
function setGridMessage(gridContainer, text, { error = false, clear = false } = {}) {
if (!gridContainer) return;
// Remove previous message elements
clearGridMessage(gridContainer);
try {
const msg = document.createElement("div");
msg.className = GRID_MESSAGE_CLASS;
msg.textContent = String(text || "");
msg.style.cssText = `
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 16px;
pointer-events: none;
font-size: 14px;
line-height: 1.4;
color: ${error ? "var(--error-text, #f44336)" : "var(--muted-text, #bdbdbd)"};
background: rgba(0, 0, 0, 0.35);
z-index: 2;
`;
if (clear) {
try {
// Clear existing DOM content to avoid overlap with previous grid elements
gridContainer.replaceChildren();
} catch (e) {
console.debug?.(e);
}
gridContainer.appendChild(msg);
} else {
gridContainer.appendChild(msg);
}
} catch (e) {
console.debug?.(e);
}
}
function sanitizeQuery(query) {
if (!query || query.trim() === "") {
return query;
}
if (query === "*") {
return "*";
}
// Avoid expensive Unicode property regexes on large inputs; fallback for older engines.
let cleaned;
if (query.length > 256) {
cleaned = query.replace(/[^a-z0-9\s-]/gi, " ");
} else {
try {
cleaned = query.replace(/[^\p{L}\p{N}\s-]/gu, " ");
} catch {
cleaned = query.replace(/[^a-z0-9\s-]/gi, " ");
}
}
return cleaned.trim() || query;
}
/**
* Retrieve current grid state or initialize default.
* @param {HTMLElement} gridContainer
* @returns {GridState}
*/
function getOrCreateState(gridContainer) {
let state = GRID_STATE.get(gridContainer);
if (!state) {
state = {
query: "*",
offset: 0,
total: null,
loading: false,
done: false,
seenKeys: new Set(),
assets: [],
assetIdSet: new Set(),
filenameCounts: new Map(),
nonImageStems: new Set(),
// Virtual Grid mappings
stemMap: new Map(), // stem -> [Asset]
renderedFilenameMap: new Map(), // filenameKey -> [DOMElement] (only currently rendered)
sentinel: null,
observer: null,
scrollRoot: null,
scrollHandler: null,
ignoreNextScroll: false,
userScrolled: false,
allowUntilFilled: true,
requestId: 0,
abortController: null,
virtualGrid: null,
metricsEl: null,
metricsTimer: null,
};
GRID_STATE.set(gridContainer, state);
_attachGridMetrics(state, gridContainer);
}
return state;
}
function _formatLoadErrorMessage(prefix, err) {
const message = String(prefix || "Failed to load");
try {
const raw = String(err?.message || err || "").trim();
const low = raw.toLowerCase();
if (!raw) return message;
if (
low.includes("database is locked") ||
low.includes("database table is locked") ||
low.includes("database schema is locked")
) {
return `${message}: the database is temporarily locked. Please retry in a few seconds.`;
}
return `${message}: ${raw}`;
} catch {
return message;
}
}
function _attachGridMetrics(state, gridContainer) {
try {
if (!_gridDebugEnabled()) return;
if (!state || state.metricsEl) return;
const el = document.createElement("div");
el.id = "mjr-grid-metrics";
el.style.position = "fixed";
el.style.bottom = "10px";
el.style.right = "10px";
el.style.background = "rgba(0,0,0,0.6)";
el.style.color = "#fff";
el.style.padding = "5px";
el.style.fontSize = "12px";
el.style.zIndex = "9999";
el.style.pointerEvents = "none";
document.body.appendChild(el);
state.metricsEl = el;
_updateGridMetrics(state, gridContainer);
state.metricsTimer = setInterval(() => _updateGridMetrics(state, gridContainer), 2000);
} catch (e) {
console.debug?.(e);
}
}
function _updateGridMetrics(state, gridContainer) {
try {
if (!state?.metricsEl) return;
const assetCount = Array.isArray(state.assets) ? state.assets.length : 0;
const seenCount = state.seenKeys?.size || 0;
const rt = _getRtHydrateState(gridContainer);
const hydrateSeen = rt?.seen?.size || 0;
let memoryMB = "n/a";
try {
const used = performance?.memory?.usedJSHeapSize;
if (Number.isFinite(used)) memoryMB = (used / 1024 / 1024).toFixed(1);
} catch (e) {
console.debug?.(e);
}
state.metricsEl.textContent = `Assets: ${assetCount} / Seen: ${seenCount} / HydrateSeen: ${hydrateSeen} / Memory: ${memoryMB} MB`;
} catch (e) {
console.debug?.(e);
}
}
function _isPotentialScrollContainer(el) {
return vsIsPotentialScrollContainer(el);
}
function _detectScrollRoot(gridContainer) {
return vsDetectScrollRoot(gridContainer);
}
function _getScrollContainer(gridContainer, state) {
return vsGetScrollContainer(gridContainer, state);
}
/**
* Initializes the VirtualGrid renderer for this container.
* @param {HTMLElement} gridContainer
* @param {GridState} state
* @returns {VirtualGrid}
*/
function ensureVirtualGrid(gridContainer, state) {
return vsEnsureVirtualGrid(gridContainer, state, {
VirtualGrid,
gridDebug,
optionsFactory: () => ({
minItemWidth: APP_CONFIG.GRID_MIN_SIZE || 120,
gap: APP_CONFIG.GRID_GAP || 10,
bufferRows: 3,
createItem: (asset, _index) => {
const card =
String(asset?.kind || "").toLowerCase() === "folder"
? createFolderCard(asset)
: createAssetCard(asset);
const selectedIds = getSelectedIdSet(gridContainer);
// Handle selection
if (asset && asset.id != null && selectedIds.has(String(asset.id))) {
card.classList.add("is-selected");
card.setAttribute("aria-selected", "true");
}
// Handle collision badge if already known
if (asset?._mjrNameCollision) {
const badge = card.querySelector?.(".mjr-file-badge");
setFileBadgeCollision(badge, true, {
filename: asset?.filename || "",
count: asset?._mjrNameCollisionCount || 2,
paths: asset?._mjrNameCollisionPaths || [],
});
_setCollisionTooltip(card, asset?.filename, asset?._mjrNameCollisionCount || 2);
}
return card;
},
onItemRendered: (asset, card) => {
// Ensure selection classes are clean before applying any selection logic.
try {
card.classList.remove("is-selected");
card.classList.remove("is-active");
card.setAttribute("aria-selected", "false");
} catch (e) {
console.debug?.(e);
}
// Re-apply selection if this asset is selected.
try {
const selectedIds = getSelectedIdSet(gridContainer);
if (asset?.id != null && selectedIds.has(String(asset.id))) {
card.classList.add("is-selected");
card.setAttribute("aria-selected", "true");
}
} catch (e) {
console.debug?.(e);
}
// Hydrate ratings
enqueueRatingTagsHydration(gridContainer, card, asset);
ensureStackGroupCard(gridContainer, card, asset);
try {
state.virtualGrid?.scheduleRemeasure?.();
} catch (e) {
console.debug?.(e);
}
// Track rendered card for live updates (collisions)
const fnKey = _getFilenameKey(asset?.filename);
if (fnKey) {
let list = state.renderedFilenameMap.get(fnKey);
if (!list) {
list = [];
state.renderedFilenameMap.set(fnKey, list);
}
list.push(card);
}
},
onItemUpdated: (asset, card) => {
if (card) {
const oldCard = card;
const prevAsset = oldCard._mjrAsset || null;
const prevFnKey = _getFilenameKey(prevAsset?.filename);
// Rebuild full card so filename/thumb/meta/datasets stay in sync.
const replacement =
String(asset?.kind || "").toLowerCase() === "folder"
? createFolderCard(asset)
: createAssetCard(asset);
try {
if (oldCard.classList.contains("is-selected")) {
replacement.classList.add("is-selected");
replacement.setAttribute("aria-selected", "true");
}
} catch (e) {
console.debug?.(e);
}
try {
oldCard.parentElement?.replaceChild?.(replacement, oldCard);
} catch (e) {
console.debug?.(e);
}
card = replacement;
const nextFnKey = _getFilenameKey(asset?.filename);
try {
if (prevFnKey) {
const oldList = state.renderedFilenameMap.get(prevFnKey);
if (oldList) {
const idx = oldList.indexOf(oldCard);
if (idx > -1) oldList.splice(idx, 1);
if (!oldList.length) state.renderedFilenameMap.delete(prevFnKey);
}
}
} catch (e) {
console.debug?.(e);
}
try {
if (nextFnKey) {
let nextList = state.renderedFilenameMap.get(nextFnKey);
if (!nextList) {
nextList = [];
state.renderedFilenameMap.set(nextFnKey, nextList);
}
if (!nextList.includes(card)) nextList.push(card);
}
} catch (e) {
console.debug?.(e);
}
card._mjrAsset = asset;
_updateCardRatingTagsBadges(card, asset.rating, asset.tags);
enqueueRatingTagsHydration(gridContainer, card, asset);
ensureStackGroupCard(gridContainer, card, asset);
try {
state.virtualGrid?.scheduleRemeasure?.();
} catch (e) {
console.debug?.(e);
}
// Reset selection classes on reuse to avoid ghost selection
try {
card.classList.remove("is-selected");
card.classList.remove("is-active");
card.setAttribute("aria-selected", "false");
} catch (e) {
console.debug?.(e);
}
const selectedIds = getSelectedIdSet(gridContainer);
if (selectedIds && asset.id != null) {
if (selectedIds.has(String(asset.id))) {
card.classList.add("is-selected");
card.setAttribute("aria-selected", "true");
} else {
card.classList.remove("is-selected");
card.setAttribute("aria-selected", "false");
}
}
}
},
onItemRecycled: (card) => {
// Cleanup map
try {
const asset = card._mjrAsset;
const fnKey = _getFilenameKey(asset?.filename);
if (fnKey) {
const list = state.renderedFilenameMap.get(fnKey);
if (list) {
const idx = list.indexOf(card);
if (idx > -1) list.splice(idx, 1);
}
}
} catch (e) {
console.debug?.(e);
}
// Cleanup video thumbs
try {
// Use the exported cleanup if available or implement inline
cleanupVideoThumbsIn(card);
} catch (e) {
console.debug?.(e);
}
try {
cleanupCardMediaHandlers(card);
} catch (e) {
console.debug?.(e);
}
try {
card.classList.remove("is-selected");
card.classList.remove("is-active");
card.setAttribute("aria-selected", "false");
} catch (e) {
console.debug?.(e);
}
},
}),
});
}
function assetKey(asset) {
if (!asset || typeof asset !== "object") return "";
const fallback =
asset.id != null
? `id:${asset.id}`
: `${asset.type || ""}|${pickRootId(asset)}|${asset.filepath || ""}|${asset.subfolder || ""}|${asset.filename || ""}`;
try {
const grid = globalThis?.__MJR_LAST_ASSETKEY_GRID__ || null;
return getStackAwareAssetKey(grid, asset, fallback);
} catch (e) {
console.debug?.(e);
}
return fallback;
}
function getSelectedIdSet(gridContainer) {
return selectionGetSelectedIdSet(gridContainer);
}
function _setSelectedIdsDataset(gridContainer, selectedSet, activeId = "") {
return selectionSetSelectedIdsDataset(gridContainer, selectedSet, activeId);
}
function syncSelectionClasses(gridContainer, selectedIds) {
return selectionSyncSelectionClasses(gridContainer, selectedIds, _getRenderedCards);
}
function setSelectionIds(gridContainer, selectedIds, { activeId = "" } = {}) {
return selectionSetSelectionIds(gridContainer, selectedIds, { activeId }, _getRenderedCards);
}
function _safeEscapeId(value) {
return selectionSafeEscapeId(value);
}
function _getFilenameKey(filename) {
return cardGetFilenameKey(filename);
}
function _getExtUpper(filename) {
return cardGetExtUpper(filename);
}
function _getStemLower(filename) {
return cardGetStemLower(filename);
}
function _detectKind(asset, extUpper) {
return cardDetectKind(asset, extUpper);
}
function _isHidePngSiblingsEnabled() {
return cardIsHidePngSiblingsEnabled(loadMajoorSettings);
}
function _setCollisionTooltip(card, filename, count) {
return cardSetCollisionTooltip(card, filename, count);
}
function appendAssets(gridContainer, assets, state) {
return cardAppendAssets(gridContainer, assets, state, {
loadMajoorSettings,
clearGridMessage,
ensureVirtualGrid,
setFileBadgeCollision,
assetKey,
});
}
function _ensureSentinel(gridContainer, state) {
return vsEnsureSentinel(gridContainer, state, SENTINEL_CLASS);
}
function stopObserver(state) {
return vsStopObserver(state);
}
function captureScrollMetrics(state) {
return vsCaptureScrollMetrics(state);
}
function maybeKeepPinnedToBottom(_state, _before) {
return vsMaybeKeepPinnedToBottom(_state, _before);
}
/**
* Fetch a page of assets from the backend.
* @param {HTMLElement} gridContainer
* @param {string} query Search query
* @param {number} limit Page size
* @param {number} offset Offset
* @param {Object} options
* @param {number} [options.requestId]
* @param {AbortSignal} [options.signal]
* @returns {Promise<{ok: boolean, assets?: any[], total?: number, count?: number, error?: string, aborted?: boolean, stale?: boolean, sortKey?: string, safeQuery?: string}>}
*/
async function _fetchPage(
gridContainer,
query,
limit,
offset,
{ requestId = 0, signal = null } = {},
) {
return infFetchPage(
gridContainer,
query,
limit,
offset,
{
sanitizeQuery,
buildListURL,
get,
getGridState: (el) => GRID_STATE.get(el),
},
{ requestId, signal },
);
}
const _emitAgendaStatus = (dateExact, hasResults) => {
if (!dateExact) return;
try {
window?.dispatchEvent?.(
new CustomEvent("MJR:AgendaStatus", {
detail: { date: dateExact, hasResults: Boolean(hasResults) },
}),
);
} catch (e) {
console.debug?.(e);
}
};
async function loadNextPage(gridContainer, state) {
return infLoadNextPage(gridContainer, state, {
config: APP_CONFIG,
captureScrollMetrics,
gridDebug,
stopObserver,
appendAssets,
maybeKeepPinnedToBottom,
sanitizeQuery,
buildListURL,
get,
getGridState: (el) => GRID_STATE.get(el),
});
}
function startInfiniteScroll(gridContainer, state) {
return vsStartInfiniteScroll(gridContainer, state, {
config: APP_CONFIG,
gridDebug,
sentinelClass: SENTINEL_CLASS,
loadNextPage,
});
}
export async function loadAssets(gridContainer, query = "*", options = {}) {
const { reset = true } = options || {};
const state = getOrCreateState(gridContainer);
try {
globalThis.__MJR_LAST_ASSETKEY_GRID__ = gridContainer;
} catch (e) {
console.debug?.(e);
}
const requestedQuery = query && query.trim() ? query : "*";
state.query = sanitizeQuery(requestedQuery) || requestedQuery;
if (reset) {
try {
state.abortController?.abort?.();
} catch (e) {
console.debug?.(e);
}
try {
state.abortController =
typeof AbortController !== "undefined" ? new AbortController() : null;
} catch {
state.abortController = null;
}
try {
state.requestId = (Number(state.requestId) || 0) + 1;
} catch {
state.requestId = 1;
}
stopObserver(state);
state.offset = 0;
state.total = null;
state.done = false;
state.loading = false;
state.allowUntilFilled = true; // Reset: Allow auto-filling the viewport on new search
state.seenKeys = new Set();
state.assets = [];
state.assetIdSet = new Set();
state.filenameCounts = new Map();
state.stemMap = new Map();
state.renderedFilenameMap = new Map();
state.nonImageStems = new Set();
state.hiddenPngSiblings = 0;
try {
gridContainer.dataset.mjrHiddenPngSiblings = "0";
} catch (e) {
console.debug?.(e);
}
// Discard any pending upsert batch to prevent stale partial assets
// from racing with the fresh API response.
try {
const batchState = UPSERT_BATCH_STATE.get(gridContainer);
if (batchState) {
if (batchState.timer) {
clearTimeout(batchState.timer);
batchState.timer = null;
}
batchState.pending.clear();
batchState.flushing = false;
}
} catch (e) {
console.debug?.(e);
}
// Clear virtual grid items (but keep instance)
const vg = ensureVirtualGrid(gridContainer, state);
if (vg) vg.setItems([]);
// We don't clear innerHTML directly if VirtualGrid is active, as it manages it.
// But showLoadingOverlay expects to be appended.
// VirtualGrid sets container to relative. Overlay is absolute inset 0. It works.
showLoadingOverlay(
gridContainer,
state.query === "*" ? "Loading assets..." : `Searching for "${state.query}"...`,
);
}
try {
if (reset) {
let timeoutId = null;
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
const err = new Error(`Load assets timeout after ${LOAD_ASSETS_TIMEOUT_MS}ms`);
err.name = "LoadTimeoutError";
reject(err);
}, LOAD_ASSETS_TIMEOUT_MS);
});
try {
await Promise.race([loadNextPage(gridContainer, state), timeoutPromise]);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
} else {
await loadNextPage(gridContainer, state);
}
if (reset) {
hideLoadingOverlay(gridContainer);
// Check if items present in state, not just DOM (logic shift)
const hasItems = state.assets && state.assets.length > 0;
if (!hasItems && state.offset === 0) {
// Dispose VirtualGrid before setGridMessage clears the DOM via replaceChildren().
// Without this, state.virtualGrid keeps a stale (detached) instance and the next
// search renders assets into a detached container → blank grid.
if (state.virtualGrid) {
try {
state.virtualGrid.dispose();
} catch (e) {
console.debug?.(e);
}
state.virtualGrid = null;
}
setGridMessage(gridContainer, "No assets found", { clear: true });
} else {
startInfiniteScroll(gridContainer, state);
}
}
return { ok: true, count: state.offset, total: state.total || 0 };
} catch (err) {
try {
if (String(err?.name || "") === "LoadTimeoutError") {
// Force-unblock state so future reloads are not stuck behind a stale in-flight request.
try {
state.abortController?.abort?.();
} catch (e) {
console.debug?.(e);
}
try {
state.abortController =
typeof AbortController !== "undefined" ? new AbortController() : null;
} catch {
state.abortController = null;
}
try {
state.requestId = (Number(state.requestId) || 0) + 1;
} catch {
state.requestId = 1;
}
state.loading = false;
}
} catch (e) {
console.debug?.(e);
}
try {
if (String(err?.name || "") === "AbortError") {
return { ok: false, aborted: true, error: "Aborted" };
}
} catch (e) {
console.debug?.(e);
}
stopObserver(state);
if (reset) hideLoadingOverlay(gridContainer);
clearGridMessage(gridContainer);
setGridMessage(gridContainer, _formatLoadErrorMessage("Failed to load assets", err), {
error: true,
});
return { ok: false, error: err?.message || String(err) };
} finally {
if (reset) hideLoadingOverlay(gridContainer);
}
}
/**
* Prepare grid for a hard context/scope switch.
* Aborts in-flight requests, invalidates stale responses, and clears visible DOM immediately.
* @param {HTMLElement} gridContainer
*/
export function prepareGridForScopeSwitch(gridContainer) {
if (!gridContainer) return;
const state = getOrCreateState(gridContainer);
try {
state.abortController?.abort?.();
} catch (e) {
console.debug?.(e);
}
try {
state.abortController =
typeof AbortController !== "undefined" ? new AbortController() : null;
} catch {
state.abortController = null;
}
try {
state.requestId = (Number(state.requestId) || 0) + 1;
} catch {
state.requestId = 1;
}
stopObserver(state);
state.offset = 0;
state.total = null;
state.done = false;
state.loading = false;
state.assets = [];
state.assetIdSet = new Set();
state.seenKeys = new Set();
state.filenameCounts = new Map();
state.stemMap = new Map();
state.renderedFilenameMap = new Map();
state.nonImageStems = new Set();
try {
clearGridMessage(gridContainer);
hideLoadingOverlay(gridContainer);
} catch (e) {
console.debug?.(e);
}
// Physical clear to prevent visual overlap during rapid scope switches.
try {
if (state.virtualGrid) {
state.virtualGrid.setItems([], true);
} else {
gridContainer.replaceChildren();
}
} catch {
try {
gridContainer.replaceChildren();
} catch (e) {
console.debug?.(e);
}
}
}
/**
* Load a pre-defined list of assets into the grid (no paging/infinite scroll).
* Used for "Collections" views.
*/
export async function loadAssetsFromList(gridContainer, assets, options = {}) {
const { title = "Collection", reset = true } = options || {};
const state = getOrCreateState(gridContainer);
const list = Array.isArray(assets) ? assets : [];
if (reset) {
try {
state.abortController?.abort?.();
} catch (e) {
console.debug?.(e);
}
try {
state.abortController =
typeof AbortController !== "undefined" ? new AbortController() : null;
} catch {
state.abortController = null;
}
try {
state.requestId = (Number(state.requestId) || 0) + 1;
} catch {
state.requestId = 1;
}
stopObserver(state);
state.offset = 0;
state.total = list.length;
state.done = true;
state.loading = false;
state.query = String(title || "Collection");
state.seenKeys = new Set();
state.assets = [];
state.assetIdSet = new Set();
state.filenameCounts = new Map();
state.stemMap = new Map();
state.renderedFilenameMap = new Map();
state.nonImageStems = new Set();
state.hiddenPngSiblings = 0;
try {
gridContainer.dataset.mjrHiddenPngSiblings = "0";
} catch (e) {
console.debug?.(e);
}
const vg = ensureVirtualGrid(gridContainer, state);
if (vg) vg.setItems([]);
showLoadingOverlay(
gridContainer,
list.length ? `Loading ${title}...` : `${title} is empty`,
);
}
try {
// Apply the current sort order for consistent UX.
const sortKey = gridContainer.dataset.mjrSort || "mtime_desc";
const sorted = list.slice().sort((a, b) => compareAssets(a, b, sortKey));
appendAssets(gridContainer, sorted, state);
if (reset) {
hideLoadingOverlay(gridContainer);
const hasItems = state.assets && state.assets.length > 0;
if (!hasItems) {
if (state.virtualGrid) {
try {
state.virtualGrid.dispose();
} catch (e) {
console.debug?.(e);
}
state.virtualGrid = null;
}
setGridMessage(gridContainer, "No assets found", { clear: true });
}
}
return { ok: true, count: sorted.length, total: sorted.length };
} catch (err) {
stopObserver(state);
if (reset) hideLoadingOverlay(gridContainer);
clearGridMessage(gridContainer);
setGridMessage(gridContainer, _formatLoadErrorMessage("Failed to load collection", err), {
error: true,
});
return { ok: false, error: err?.message || String(err) };
} finally {
if (reset) hideLoadingOverlay(gridContainer);
}
}
/**
* Remove assets from grid state + UI (used after delete).
* Keeps selection dataset consistent and avoids UI re-appearance on scroll.
* @param {HTMLElement} gridContainer
* @param {Array<string|number|Object>} assetIds
* @param {Object} [options]
* @param {boolean} [options.updateSelection=true]
* @returns {{ok: boolean, removed: number, selectedIds: string[]}}
*/
export function removeAssetsFromGrid(gridContainer, assetIds, { updateSelection = true } = {}) {
if (!gridContainer) {
return { ok: false, removed: 0, selectedIds: [] };
}
const ids = new Set();
const list = Array.isArray(assetIds) ? assetIds : [assetIds];
for (const raw of list) {
if (raw == null) continue;
if (typeof raw === "object" && raw?.id != null) {
ids.add(String(raw.id));
} else {
ids.add(String(raw));
}
}
if (!ids.size) {
return { ok: false, removed: 0, selectedIds: Array.from(getSelectedIdSet(gridContainer)) };
}
const selectedIds = getSelectedIdSet(gridContainer);
if (updateSelection) {
for (const id of ids) selectedIds.delete(String(id));
_setSelectedIdsDataset(gridContainer, selectedIds);
}
// Best-effort selection class sync for visible cards.
try {
for (const card of _getRenderedCards(gridContainer)) {
if (!card?.classList?.contains?.("is-selected")) continue;
const id = card?.dataset?.mjrAssetId;
if (!id || !selectedIds.has(String(id))) {
card.classList.remove("is-selected");
card.setAttribute("aria-selected", "false");
}
}
} catch (e) {
console.debug?.(e);
}
const state = GRID_STATE.get(gridContainer);
let removedCount = 0;
if (state?.assets && state.assets.length) {
const next = [];
for (const asset of state.assets) {
const id = asset?.id != null ? String(asset.id) : "";
if (id && ids.has(id)) {
removedCount += 1;
try {
state.assetIdSet?.delete?.(id);
} catch (e) {
console.debug?.(e);
}
} else {
next.push(asset);
}
}
if (removedCount) {
state.assets = next;
try {
if (Number.isFinite(state.total))
state.total = Math.max(0, Number(state.total) - removedCount);
} catch (e) {
console.debug?.(e);
}
if (state.virtualGrid) {
state.virtualGrid.setItems(state.assets);
} else {
for (const id of ids) {
try {
const card = gridContainer.querySelector(
`[data-mjr-asset-id="${_safeEscapeId(id)}"]`,
);
card?.remove?.();
} catch (e) {
console.debug?.(e);
}
}
}
}
} else {
for (const id of ids) {
try {
const card = gridContainer.querySelector(
`[data-mjr-asset-id="${_safeEscapeId(id)}"]`,
);
if (card) {
card.remove?.();
removedCount += 1;
}
} catch (e) {
console.debug?.(e);
}
}
}
const selectedList = Array.from(selectedIds);
try {
gridContainer?.dispatchEvent?.(
new CustomEvent("mjr:selection-changed", { detail: { selectedIds: selectedList } }),
);
} catch (e) {
console.debug?.(e);
}
try {
const count = Number(state?.assets?.length ?? null);
const total = Number(state?.total ?? null);
if (Number.isFinite(count) || Number.isFinite(total)) {
gridContainer?.dispatchEvent?.(
new CustomEvent("mjr:grid-stats", { detail: { count, total } }),
);
}
} catch (e) {
console.debug?.(e);
}
return { ok: true, removed: removedCount, selectedIds: selectedList };
}
export function disposeGrid(gridContainer) {
const state = GRID_STATE.get(gridContainer);
if (!state) return;
stopObserver(state);
// Dispose virtual grid
if (state.virtualGrid) {
try {
state.virtualGrid.dispose();
} catch (e) {
console.debug?.(e);
}
state.virtualGrid = null;
}
if (state._cardKeydownHandler) {
try {
gridContainer.removeEventListener("keydown", state._cardKeydownHandler, true);
} catch (e) {
console.debug?.(e);
}
state._cardKeydownHandler = null;
}
// Cleanup summary bar listeners
try {
gridContainer._mjrSummaryBarDispose?.();
} catch (e) {
console.debug?.(e);
}
// Cleanup grid keyboard
try {
gridContainer._mjrGridKeyboard?.dispose?.();
} catch (e) {
console.debug?.(e);
}
// Cleanup settings-changed listener
try {
gridContainer._mjrSettingsChangedCleanup?.();
} catch (e) {
console.debug?.(e);
}
try {
cleanupVideoThumbsIn?.(gridContainer);
} catch (e) {
console.debug?.(e);
}
try {
disposeStackGroupCards(gridContainer);
} catch (e) {
console.debug?.(e);
}
try {
state.seenKeys?.clear?.();
state.stemMap?.clear?.();
state.renderedFilenameMap?.clear?.();
} catch (e) {
console.debug?.(e);
}
// Best-effort callback cleanup
try {
const st = _getRtHydrateState(gridContainer);
if (st) {
st.queue.length = 0;
st.active?.clear?.();
st.seen?.clear?.();
}
} catch (e) {
console.debug?.(e);
}
try {
RT_HYDRATE_STATE.delete(gridContainer);
} catch (e) {
console.debug?.(e);
}
try {
if (state.metricsTimer) {
clearInterval(state.metricsTimer);
state.metricsTimer = null;
}
if (state.metricsEl?.parentNode) {
state.metricsEl.parentNode.removeChild(state.metricsEl);
}
state.metricsEl = null;
} catch (e) {
console.debug?.(e);
}
GRID_STATE.delete(gridContainer);
}
/**
* Get sort value for an asset based on sort key
*/
function _getSortValue(asset, sortKey) {
return infGetSortValue(asset, sortKey);
}
function compareAssets(a, b, sortKey) {
return infCompareAssets(a, b, sortKey);
}
/**
* Determine if an asset should be inserted before another based on sort order
*/
function _shouldInsertBefore(assetValue, cardValue, sortKey) {
return infShouldInsertBefore(assetValue, cardValue, sortKey);
}
/**
* Find the correct insertion position in an array based on sort order
*/
function _findInsertPosition(array, asset, sortKey) {
return infFindInsertPosition(array, asset, sortKey);
}
/**
* Find asset element by ID
*/
function findAssetElement(gridContainer, assetId) {
return infFindAssetElement(gridContainer, assetId);
}
/**
* Get or create upsert batch state for a grid
*/
function _getUpsertBatchState(gridContainer) {
let s = UPSERT_BATCH_STATE.get(gridContainer);
if (!s) {
s = { pending: new Map(), timer: null, flushing: false };
UPSERT_BATCH_STATE.set(gridContainer, s);
}
return s;
}
/**
* Flush all pending upserts in one batch (single setItems call)
*/
function _flushUpsertBatch(gridContainer) {
return infFlushUpsertBatch(gridContainer, {
upsertState: UPSERT_BATCH_STATE,
getOrCreateState,
ensureVirtualGrid,
assetKey,
});
}
/**
* Update or insert a single asset in the grid (Live Update).
* Uses batching to avoid multiple setItems calls in rapid succession.
* @param {HTMLElement} gridContainer
* @param {Object} asset The new or updated asset data
* @returns {boolean} True if asset was queued for update
*/
export function upsertAsset(gridContainer, asset) {
return infUpsertAsset(gridContainer, asset, {
getOrCreateState,
ensureVirtualGrid,
upsertState: UPSERT_BATCH_STATE,
maxBatchSize: UPSERT_BATCH_MAX_SIZE,
debounceMs: UPSERT_BATCH_DEBOUNCE_MS,
assetKey,
});
}
/**
* Capture scroll anchor before making changes
*/
export function captureAnchor(gridContainer) {
const state = getOrCreateState(gridContainer);
const scrollContainer = _getScrollContainer(gridContainer, state);
const selectedElement = gridContainer.querySelector(".mjr-asset-card.is-selected");
const firstVisibleElement = getFirstVisibleAssetElement(gridContainer);
const el = selectedElement || firstVisibleElement;
if (!el) return null;
const rect = el.getBoundingClientRect();
const containerRect = gridContainer.getBoundingClientRect();
return {
id: el.dataset.mjrAssetId,
top: rect.top - containerRect.top,
scrollTop: scrollContainer?.scrollTop || 0,
};
}
/**
* Get first visible asset element
*/
function getFirstVisibleAssetElement(gridContainer) {
const cards = _getRenderedCards(gridContainer);
const containerRect = gridContainer.getBoundingClientRect();
const containerTop = containerRect.top;
const containerBottom = containerRect.bottom;
for (const card of cards) {
const cardRect = card.getBoundingClientRect();
if (cardRect.bottom >= containerTop && cardRect.top <= containerBottom) {
return card;
}
}
// If no card is visible, return the first one
return cards[0] || null;
}
function _buildCollisionPaths(bucket) {
return cardBuildCollisionPaths(bucket);
}
/**
* Restore scroll position based on anchor
*/
export async function restoreAnchor(gridContainer, anchor) {
if (!anchor) return;
const state = getOrCreateState(gridContainer);
const scrollContainer = _getScrollContainer(gridContainer, state);
if (!scrollContainer) return;
// Wait for layout to complete
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const el = findAssetElement(gridContainer, anchor.id);
if (!el) {
// Element no longer in DOM (selection lost after reload); restore raw scroll position.
scrollContainer.scrollTop = anchor.scrollTop || 0;
return;
}
const rect = el.getBoundingClientRect();
const containerRect = gridContainer.getBoundingClientRect();
const delta = rect.top - containerRect.top - anchor.top;
scrollContainer.scrollTop = (scrollContainer.scrollTop || 0) + delta;
}
/**
* Scroll the grid to the top (scrollTop = 0).
* Called after upserting a newly generated asset so it becomes visible at position 0.
*/
export function scrollGridToTop(gridContainer) {
try {
const state = GRID_STATE.get(gridContainer);
if (!state) return;
const scrollContainer = _getScrollContainer(gridContainer, state);
if (scrollContainer) scrollContainer.scrollTop = 0;
} catch (e) {
console.debug?.(e);
}
}
/**
* Force specific refresh of the grid visuals (e.g. after settings change).
*/
export function refreshGrid(gridContainer) {
try {
const state = GRID_STATE.get(gridContainer);
if (state && state.virtualGrid && state.assets) {
// Update VirtualGrid layout config from global settings
if (state.virtualGrid.updateConfig) {
state.virtualGrid.updateConfig(
{
minItemWidth: APP_CONFIG.GRID_MIN_SIZE,
gap: APP_CONFIG.GRID_GAP,
},
{ relayout: false },
);
}
// Re-setting items triggers a re-render of the visible range.
// Pass force=true to ensure Card.js re-reads APP_CONFIG for badges/details updates.
state.virtualGrid.setItems(state.assets, true);
}
} catch (e) {
console.debug?.(e);
}
}
// Scan-complete auto-refresh removed: the panel's handleCountersUpdate (path B)
// is the sole auto-reload trigger for both watcher and manual scans.
// Manual scans also emit mjr:reload-grid via StatusDot → requestQueuedReload.
// Having a third path here (direct loadAssets bypass) caused duplicate reloads.
export function bindGridScanListeners() {}
export function disposeGridScanListeners() {}