2609 lines
94 KiB
JavaScript
2609 lines
94 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 { 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 { 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";
|
|
|
|
/**
|
|
* 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;
|
|
// Globals removed to prevent cross-grid contamination
|
|
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);
|
|
let rtHydrateSeenPruneBudget = 0;
|
|
// 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 = 50; // 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 {}
|
|
};
|
|
|
|
function _getRenderedCards(gridContainer) {
|
|
if (!gridContainer) return [];
|
|
try {
|
|
if (typeof gridContainer._mjrGetRenderedCards === "function") {
|
|
const cards = gridContainer._mjrGetRenderedCards();
|
|
if (Array.isArray(cards)) return cards;
|
|
}
|
|
} catch {}
|
|
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) {
|
|
try {
|
|
if (!gridContainer) return null;
|
|
let s = RT_HYDRATE_STATE.get(gridContainer);
|
|
if (!s) {
|
|
s = {
|
|
queue: [],
|
|
inflight: 0,
|
|
seen: new Set(),
|
|
active: new Set(),
|
|
lastPruneAt: 0
|
|
};
|
|
RT_HYDRATE_STATE.set(gridContainer, s);
|
|
}
|
|
return s;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function pruneRatingTagsHydrateSeen(st) {
|
|
if (!st || !st.seen) return;
|
|
const now = Date.now();
|
|
const needsSizePrune = st.seen.size > RT_HYDRATE_SEEN_MAX;
|
|
const needsTimePrune =
|
|
st.lastPruneAt > 0
|
|
&& (now - st.lastPruneAt) > RT_HYDRATE_SEEN_TTL_MS
|
|
&& st.seen.size > Math.max(1000, Math.floor(RT_HYDRATE_SEEN_MAX / 4));
|
|
|
|
if (!needsSizePrune && !needsTimePrune) return;
|
|
if (rtHydrateSeenPruneBudget > 0) {
|
|
rtHydrateSeenPruneBudget -= 1;
|
|
return;
|
|
}
|
|
|
|
// Simple prune: clear all if oversize (easiest logic for set) -> actually Set doesn't sort by time.
|
|
// If we want LRU we need Map. But here we just clear or we accept unbounded growth until hard reset.
|
|
// Logic: if too big or too old, clear it.
|
|
st.seen.clear();
|
|
st.lastPruneAt = now;
|
|
}
|
|
|
|
function _updateCardRatingTagsBadges(card, rating, tags) {
|
|
if (!card) return;
|
|
try {
|
|
if (!card.isConnected) return;
|
|
} catch {}
|
|
const thumb = card.querySelector?.(".mjr-thumb");
|
|
if (!thumb) return;
|
|
|
|
const oldRatingBadge = thumb.querySelector?.(".mjr-rating-badge");
|
|
const ratingValue = Math.max(0, Math.min(5, Number(rating) || 0));
|
|
if (ratingValue <= 0) {
|
|
try {
|
|
oldRatingBadge?.remove?.();
|
|
} catch {}
|
|
} else if (!oldRatingBadge) {
|
|
const nextRatingBadge = createRatingBadge(ratingValue);
|
|
if (nextRatingBadge) thumb.appendChild(nextRatingBadge);
|
|
} else {
|
|
// Update in place to avoid full DOM replacement.
|
|
try {
|
|
const cur = oldRatingBadge.querySelectorAll?.("span")?.length || 0;
|
|
if (cur !== ratingValue) {
|
|
const stars = [];
|
|
for (let i = 0; i < ratingValue; i++) {
|
|
const star = document.createElement("span");
|
|
star.textContent = "★";
|
|
star.style.color = "var(--mjr-rating-color, var(--mjr-star-active, #FFD45A))";
|
|
star.style.marginRight = i < ratingValue - 1 ? "2px" : "0";
|
|
stars.push(star);
|
|
}
|
|
oldRatingBadge.replaceChildren(...stars);
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
const oldTagsBadge = thumb.querySelector?.(".mjr-tags-badge");
|
|
if (oldTagsBadge) {
|
|
if (Array.isArray(tags) && tags.length) {
|
|
oldTagsBadge.textContent = tags.join(", ");
|
|
oldTagsBadge.style.display = "";
|
|
} else {
|
|
oldTagsBadge.textContent = "";
|
|
oldTagsBadge.style.display = "none";
|
|
}
|
|
} else {
|
|
const nextTagsBadge = createTagsBadge(Array.isArray(tags) ? tags : []);
|
|
thumb.appendChild(nextTagsBadge);
|
|
}
|
|
}
|
|
|
|
function _pumpRatingTagsHydration(gridContainer) {
|
|
const st = _getRtHydrateState(gridContainer);
|
|
if (!st) return;
|
|
while (st.inflight < RT_HYDRATE_CONCURRENCY && st.queue.length) {
|
|
const job = st.queue.shift();
|
|
if (!job) break;
|
|
st.inflight += 1;
|
|
(async () => {
|
|
const res = await hydrateAssetRatingTags(job.id);
|
|
if (!res || !res.ok || !res.data) return;
|
|
const updated = res.data;
|
|
const rating = Number(updated.rating || 0) || 0;
|
|
const tags = Array.isArray(updated.tags) ? updated.tags : [];
|
|
|
|
try {
|
|
if (job.asset) {
|
|
job.asset.rating = rating;
|
|
job.asset.tags = tags;
|
|
}
|
|
} catch {}
|
|
|
|
_updateCardRatingTagsBadges(job.card, rating, tags);
|
|
|
|
safeDispatchCustomEvent(
|
|
ASSET_RATING_CHANGED_EVENT,
|
|
{ assetId: String(job.id), rating },
|
|
{ warnPrefix: "[GridView]" }
|
|
);
|
|
safeDispatchCustomEvent(
|
|
ASSET_TAGS_CHANGED_EVENT,
|
|
{ assetId: String(job.id), tags },
|
|
{ warnPrefix: "[GridView]" }
|
|
);
|
|
})()
|
|
.catch(() => null)
|
|
.finally(() => {
|
|
try {
|
|
st.active.delete(String(job.id));
|
|
} catch {}
|
|
st.inflight -= 1;
|
|
_pumpRatingTagsHydration(gridContainer);
|
|
});
|
|
}
|
|
}
|
|
|
|
function enqueueRatingTagsHydration(gridContainer, card, asset) {
|
|
const id = asset?.id != null ? String(asset.id) : "";
|
|
if (!id) return;
|
|
const st = _getRtHydrateState(gridContainer);
|
|
if (!st) return;
|
|
|
|
if (st.seen.has(id)) return;
|
|
|
|
const rating = Number(asset?.rating || 0) || 0;
|
|
const tags = asset?.tags;
|
|
const tagsEmpty = !(Array.isArray(tags) && tags.length);
|
|
if (rating > 0 && !tagsEmpty) {
|
|
st.seen.add(id);
|
|
return;
|
|
}
|
|
|
|
// Hydrate only when both are missing in DB (avoid extra IO).
|
|
if (rating > 0 || !tagsEmpty) {
|
|
st.seen.add(id);
|
|
return;
|
|
}
|
|
|
|
// Keep queue bounded to avoid unbounded memory growth on large libraries.
|
|
try {
|
|
while (st.queue.length >= RT_HYDRATE_QUEUE_MAX) {
|
|
const dropped = st.queue.shift();
|
|
try {
|
|
if (dropped?.id) st.active.delete(String(dropped.id));
|
|
} catch {}
|
|
try {
|
|
if (dropped?.id) st.seen.delete(String(dropped.id));
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
|
|
st.seen.add(id);
|
|
pruneRatingTagsHydrateSeen(st);
|
|
try {
|
|
st.active.add(id);
|
|
} catch {}
|
|
st.queue.push({ id, card, asset });
|
|
_pumpRatingTagsHydration(gridContainer);
|
|
}
|
|
|
|
/**
|
|
* 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 {}
|
|
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 {}
|
|
|
|
// Initial class application
|
|
_updateGridSettingsClasses(container);
|
|
|
|
// Listen for settings changes to update CSS classes reactively
|
|
let settingsRefreshTimer = null;
|
|
const SETTINGS_REFRESH_DEBOUNCE_MS = 180;
|
|
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.
|
|
if (settingsRefreshTimer) {
|
|
clearTimeout(settingsRefreshTimer);
|
|
settingsRefreshTimer = null;
|
|
}
|
|
settingsRefreshTimer = setTimeout(() => {
|
|
settingsRefreshTimer = null;
|
|
refreshGrid(container);
|
|
}, SETTINGS_REFRESH_DEBOUNCE_MS);
|
|
};
|
|
|
|
// 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 {}
|
|
try {
|
|
if (settingsRefreshTimer) {
|
|
clearTimeout(settingsRefreshTimer);
|
|
settingsRefreshTimer = null;
|
|
}
|
|
} catch {}
|
|
};
|
|
} catch {}
|
|
|
|
// Bind delegated dragstart once (avoid per-card listeners; improves load perf on large grids).
|
|
try {
|
|
bindAssetDragStart(container);
|
|
} catch {}
|
|
|
|
// 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' });
|
|
}
|
|
});
|
|
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
|
|
let allAssets, currentIndex;
|
|
|
|
// Use state.assets if available (VirtualGrid support)
|
|
const state = GRID_STATE.get(container);
|
|
if (state && state.assets && state.assets.length > 0) {
|
|
allAssets = state.assets;
|
|
currentIndex = allAssets.findIndex(a => a.id === asset.id);
|
|
} else {
|
|
// Fallback to DOM (legacy)
|
|
const allCards = _getRenderedCards(container);
|
|
// @ts-ignore
|
|
allAssets = allCards.map(c => c._mjrAsset).filter(Boolean);
|
|
currentIndex = allAssets.findIndex(a => a.id === asset.id);
|
|
}
|
|
|
|
// Open viewer
|
|
const viewer = getViewerInstance();
|
|
viewer.open(allAssets, currentIndex);
|
|
});
|
|
|
|
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 {}
|
|
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 {}
|
|
}
|
|
|
|
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 {}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
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 {}
|
|
gridContainer.appendChild(msg);
|
|
} else {
|
|
gridContainer.appendChild(msg);
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
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 = query;
|
|
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) {
|
|
let 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 {}
|
|
}
|
|
|
|
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 {}
|
|
state.metricsEl.textContent = `Assets: ${assetCount} / Seen: ${seenCount} / HydrateSeen: ${hydrateSeen} / Memory: ${memoryMB} MB`;
|
|
} catch {}
|
|
}
|
|
|
|
function _isPotentialScrollContainer(el) {
|
|
if (!el || el === window) return false;
|
|
// In this project we want a dedicated scroll container (not window scrolling).
|
|
if (el === document.body || el === document.documentElement) return false;
|
|
try {
|
|
const style = window.getComputedStyle(el);
|
|
const overflowY = String(style?.overflowY || "");
|
|
if (!/(auto|scroll|overlay)/.test(overflowY)) return false;
|
|
const clientH = Number(el.clientHeight) || 0;
|
|
if (clientH <= 0) return false;
|
|
|
|
// IMPORTANT: At initial load the container may not overflow yet.
|
|
// If CSS already declares it scrollable and it has a height, it is a valid scroll root.
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function _detectScrollRoot(gridContainer) {
|
|
// Prefer an explicit browse container if present.
|
|
// This is the intended scroll root for the grid UI.
|
|
try {
|
|
const browse = gridContainer?.closest?.(".mjr-am-browse") || null;
|
|
if (browse && _isPotentialScrollContainer(browse)) return browse;
|
|
} catch {}
|
|
|
|
// Prefer the nearest *actually usable* scroll container.
|
|
try {
|
|
let cur = gridContainer?.parentElement;
|
|
while (cur && cur !== document.body && cur !== document.documentElement) {
|
|
if (_isPotentialScrollContainer(cur)) return cur;
|
|
cur = cur.parentElement;
|
|
}
|
|
} catch {}
|
|
|
|
// Fallback: the immediate parent, even if it isn't scrollable yet.
|
|
// (Still better than silently switching to window scroll.)
|
|
return gridContainer?.parentElement || null;
|
|
}
|
|
|
|
function _getScrollContainer(gridContainer, state) {
|
|
try {
|
|
const cur = state?.scrollRoot;
|
|
if (cur && cur instanceof HTMLElement) return cur;
|
|
} catch {}
|
|
|
|
const detected = _detectScrollRoot(gridContainer);
|
|
if (detected && state) state.scrollRoot = detected;
|
|
return detected;
|
|
}
|
|
|
|
/**
|
|
* Initializes the VirtualGrid renderer for this container.
|
|
* @param {HTMLElement} gridContainer
|
|
* @param {GridState} state
|
|
* @returns {VirtualGrid}
|
|
*/
|
|
function ensureVirtualGrid(gridContainer, state) {
|
|
if (state.virtualGrid) return state.virtualGrid;
|
|
|
|
const scrollRoot = _getScrollContainer(gridContainer, state);
|
|
|
|
try {
|
|
gridDebug("virtualGrid:scrollRoot", {
|
|
scrollRoot: scrollRoot === document.body ? "document.body" : scrollRoot === document.documentElement ? "document.documentElement" : scrollRoot?.className || scrollRoot?.tagName || null,
|
|
});
|
|
} catch {}
|
|
|
|
state.virtualGrid = new VirtualGrid(gridContainer, scrollRoot, {
|
|
minItemWidth: APP_CONFIG.GRID_MIN_SIZE || 120,
|
|
gap: APP_CONFIG.GRID_GAP || 10,
|
|
bufferRows: 3,
|
|
createItem: (asset, _index) => {
|
|
const card = 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 {}
|
|
// 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 {}
|
|
|
|
// Hydrate ratings
|
|
enqueueRatingTagsHydration(gridContainer, card, asset);
|
|
try {
|
|
state.virtualGrid?.scheduleRemeasure?.();
|
|
} catch {}
|
|
|
|
// 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) {
|
|
card._mjrAsset = asset;
|
|
_updateCardRatingTagsBadges(card, asset.rating, asset.tags);
|
|
try {
|
|
state.virtualGrid?.scheduleRemeasure?.();
|
|
} catch {}
|
|
// 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 {}
|
|
|
|
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 {}
|
|
|
|
// Cleanup video thumbs
|
|
try {
|
|
// Use the exported cleanup if available or implement inline
|
|
cleanupVideoThumbsIn(card);
|
|
} catch {}
|
|
try {
|
|
cleanupCardMediaHandlers(card);
|
|
} catch {}
|
|
try {
|
|
card.classList.remove("is-selected");
|
|
card.classList.remove("is-active");
|
|
card.setAttribute("aria-selected", "false");
|
|
} catch {}
|
|
}
|
|
});
|
|
|
|
if (!state._cardKeydownHandler) {
|
|
const handler = (event) => {
|
|
try {
|
|
if (!event?.key) return;
|
|
if (event.key !== "Enter" && event.key !== " ") return;
|
|
const card = event.target?.closest?.(".mjr-asset-card");
|
|
if (!card) return;
|
|
event.preventDefault();
|
|
card.click();
|
|
} catch {}
|
|
};
|
|
state._cardKeydownHandler = handler;
|
|
gridContainer.addEventListener("keydown", handler, true);
|
|
}
|
|
|
|
return state.virtualGrid;
|
|
}
|
|
|
|
function assetKey(asset) {
|
|
if (!asset || typeof asset !== "object") return "";
|
|
if (asset.id != null) return `id:${asset.id}`;
|
|
const fp = asset.filepath || "";
|
|
const t = asset.type || "";
|
|
const rid = pickRootId(asset);
|
|
return `${t}|${rid}|${fp}|${asset.subfolder || ""}|${asset.filename || ""}`;
|
|
}
|
|
|
|
function getSelectedIdSet(gridContainer) {
|
|
const selected = new Set();
|
|
if (!gridContainer) return selected;
|
|
try {
|
|
const raw = gridContainer.dataset?.mjrSelectedAssetIds;
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw);
|
|
if (Array.isArray(parsed)) {
|
|
for (const id of parsed) {
|
|
if (id == null) continue;
|
|
selected.add(String(id));
|
|
}
|
|
}
|
|
return selected;
|
|
}
|
|
} catch {}
|
|
try {
|
|
const id = gridContainer.dataset?.mjrSelectedAssetId;
|
|
if (id) selected.add(String(id));
|
|
} catch {}
|
|
return selected;
|
|
}
|
|
|
|
function _setSelectedIdsDataset(gridContainer, selectedSet, activeId = "") {
|
|
const list = Array.from(selectedSet || []);
|
|
const active = activeId ? String(activeId) : (list[0] ? String(list[0]) : "");
|
|
try {
|
|
if (list.length) {
|
|
gridContainer.dataset.mjrSelectedAssetIds = JSON.stringify(list);
|
|
if (active) gridContainer.dataset.mjrSelectedAssetId = String(active);
|
|
else delete gridContainer.dataset.mjrSelectedAssetId;
|
|
} else {
|
|
delete gridContainer.dataset.mjrSelectedAssetIds;
|
|
delete gridContainer.dataset.mjrSelectedAssetId;
|
|
}
|
|
} catch {}
|
|
return list;
|
|
}
|
|
|
|
export function syncSelectionClasses(gridContainer, selectedIds) {
|
|
if (!gridContainer) return;
|
|
const set = selectedIds instanceof Set ? selectedIds : new Set(Array.from(selectedIds || []).map(String));
|
|
try {
|
|
for (const card of _getRenderedCards(gridContainer)) {
|
|
const id = card?.dataset?.mjrAssetId;
|
|
if (id && set.has(String(id))) {
|
|
card.classList.add("is-selected");
|
|
card.setAttribute("aria-selected", "true");
|
|
} else {
|
|
card.classList.remove("is-selected");
|
|
card.setAttribute("aria-selected", "false");
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
export function setSelectionIds(gridContainer, selectedIds, { activeId = "" } = {}) {
|
|
if (!gridContainer) return [];
|
|
const set = new Set(Array.from(selectedIds || []).map(String).filter(Boolean));
|
|
const list = _setSelectedIdsDataset(gridContainer, set, activeId);
|
|
syncSelectionClasses(gridContainer, set);
|
|
try {
|
|
gridContainer.dispatchEvent?.(
|
|
new CustomEvent("mjr:selection-changed", { detail: { selectedIds: list, activeId: activeId || list[0] || "" } })
|
|
);
|
|
} catch {}
|
|
return list;
|
|
}
|
|
|
|
function _safeEscapeId(value) {
|
|
try {
|
|
return CSS?.escape ? CSS.escape(String(value)) : String(value).replace(/["\\]/g, "\\$&");
|
|
} catch {
|
|
return String(value).replace(/["\\]/g, "\\$&");
|
|
}
|
|
}
|
|
|
|
function _getFilenameKey(filename) {
|
|
try {
|
|
return String(filename || "").trim().toLowerCase();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function _getExtUpper(filename) {
|
|
try {
|
|
return (String(filename || "").split(".").pop() || "").toUpperCase();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function _getStemLower(filename) {
|
|
try {
|
|
const s = String(filename || "");
|
|
const idx = s.lastIndexOf(".");
|
|
const stem = idx > 0 ? s.slice(0, idx) : s;
|
|
return String(stem || "").trim().toLowerCase();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function _detectKind(asset, extUpper) {
|
|
const k = String(asset?.kind || "").toLowerCase();
|
|
if (k) return k;
|
|
const images = new Set(["PNG", "JPG", "JPEG", "WEBP", "GIF", "BMP", "TIF", "TIFF"]);
|
|
const videos = new Set(["MP4", "WEBM", "MOV", "AVI", "MKV"]);
|
|
const audio = new Set(["MP3", "WAV", "OGG", "FLAC"]);
|
|
if (images.has(extUpper)) return "image";
|
|
if (videos.has(extUpper)) return "video";
|
|
if (audio.has(extUpper)) return "audio";
|
|
return "unknown";
|
|
}
|
|
|
|
function _isHidePngSiblingsEnabled() {
|
|
try {
|
|
const s = loadMajoorSettings();
|
|
return !!s?.siblings?.hidePngSiblings;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function _setCollisionTooltip(card, filename, count) {
|
|
if (!card || count == null) return;
|
|
const n = Number(count) || 0;
|
|
if (n < 2) return;
|
|
try {
|
|
const row = card.querySelector?.(".mjr-card-filename");
|
|
if (row) {
|
|
row.title = `${String(filename || "")}\nName collision: ${n} items in this view`;
|
|
return;
|
|
}
|
|
} catch {}
|
|
try {
|
|
card.title = `Name collision: ${n} items in this view`;
|
|
} catch {}
|
|
}
|
|
|
|
function appendAssets(gridContainer, assets, state) {
|
|
const hidePngSiblings = _isHidePngSiblingsEnabled();
|
|
const filenameCounts = state.filenameCounts || new Map();
|
|
state.filenameCounts = filenameCounts;
|
|
const nonImageStems = state.nonImageStems || new Set();
|
|
state.nonImageStems = nonImageStems;
|
|
|
|
clearGridMessage(gridContainer);
|
|
|
|
// Use VirtualGrid
|
|
const vg = ensureVirtualGrid(gridContainer, state);
|
|
if (!vg) return 0;
|
|
|
|
if (state.hiddenPngSiblings == null) state.hiddenPngSiblings = 0;
|
|
if (!hidePngSiblings) state.hiddenPngSiblings = 0;
|
|
|
|
const stemMap = state.stemMap || new Map();
|
|
state.stemMap = stemMap;
|
|
const assetIdSet = state.assetIdSet || new Set();
|
|
state.assetIdSet = assetIdSet;
|
|
|
|
const filenameToAssets = new Map();
|
|
for (const existing of state.assets || []) {
|
|
const key = _getFilenameKey(existing?.filename);
|
|
if (!key) continue;
|
|
let list = filenameToAssets.get(key);
|
|
if (!list) {
|
|
list = [];
|
|
filenameToAssets.set(key, list);
|
|
}
|
|
list.push(existing);
|
|
}
|
|
|
|
let addedCount = 0;
|
|
let needsUpdate = false;
|
|
const validNewAssets = [];
|
|
|
|
// [OPTIMIZATION] Single pass removal for PNG siblings
|
|
const assetsToRemoveFromState = new Set();
|
|
|
|
const applyFilenameCollisions = () => {
|
|
try {
|
|
for (const [key, bucket] of filenameToAssets.entries()) {
|
|
const count = bucket.length;
|
|
filenameCounts.set(key, count);
|
|
if (count < 2) {
|
|
for (const asset of bucket) {
|
|
asset._mjrNameCollision = false;
|
|
delete asset._mjrNameCollisionCount;
|
|
delete asset._mjrNameCollisionPaths;
|
|
}
|
|
const renderedList = state.renderedFilenameMap.get(key);
|
|
if (renderedList) {
|
|
for (const card of renderedList) {
|
|
const badge = card.querySelector?.(".mjr-file-badge");
|
|
setFileBadgeCollision(badge, false);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
const paths = _buildCollisionPaths(bucket);
|
|
for (const asset of bucket) {
|
|
asset._mjrNameCollision = true;
|
|
asset._mjrNameCollisionCount = count;
|
|
asset._mjrNameCollisionPaths = paths;
|
|
}
|
|
const renderedList = state.renderedFilenameMap.get(key);
|
|
if (renderedList) {
|
|
for (const card of renderedList) {
|
|
const badge = card.querySelector?.(".mjr-file-badge");
|
|
const asset = card._mjrAsset;
|
|
setFileBadgeCollision(badge, true, {
|
|
filename: asset?.filename || "",
|
|
count,
|
|
paths: asset?._mjrNameCollisionPaths || paths,
|
|
});
|
|
_setCollisionTooltip(card, asset?.filename, count);
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
};
|
|
|
|
// Pre-pass: identify video stems only.
|
|
// The setting is "hide PNG previews when a corresponding video exists",
|
|
// so sidecars like .json/.txt must not hide PNG assets.
|
|
if (hidePngSiblings) {
|
|
for (const asset of assets || []) {
|
|
const filename = String(asset?.filename || "");
|
|
const extUpper = _getExtUpper(filename);
|
|
const kind = _detectKind(asset, extUpper);
|
|
if (kind === "video") {
|
|
const stem = _getStemLower(filename);
|
|
if (stem) nonImageStems.add(stem);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const asset of assets || []) {
|
|
const filename = String(asset?.filename || "");
|
|
const extUpper = _getExtUpper(filename);
|
|
const stemLower = _getStemLower(filename);
|
|
const kind = _detectKind(asset, extUpper);
|
|
|
|
// Hide PNG Siblings Logic
|
|
if (hidePngSiblings && stemLower) {
|
|
if (kind === "video") {
|
|
// nonImageStems already updated in pre-pass
|
|
|
|
// Check if we need to hide EXISTING PNG siblings (retroactive hiding)
|
|
const list = stemMap.get(stemLower);
|
|
if (list) {
|
|
for (let i = list.length - 1; i >= 0; i--) {
|
|
if (_getExtUpper(list[i].filename) === "PNG") {
|
|
assetsToRemoveFromState.add(list[i]);
|
|
// Also remove from stemMap immediately so we don't process it again
|
|
list.splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
} else if (extUpper === "PNG" && nonImageStems.has(stemLower)) {
|
|
state.hiddenPngSiblings += 1;
|
|
continue; // Skip adding this PNG
|
|
}
|
|
}
|
|
|
|
// Name Collisions Logic
|
|
const fnKey = _getFilenameKey(filename);
|
|
if (fnKey) {
|
|
let bucket = filenameToAssets.get(fnKey);
|
|
if (!bucket) {
|
|
bucket = [];
|
|
filenameToAssets.set(fnKey, bucket);
|
|
}
|
|
bucket.push(asset);
|
|
}
|
|
|
|
const key = assetKey(asset);
|
|
if (!key) continue;
|
|
if (state.seenKeys.has(key)) continue;
|
|
// Defensive: also skip if an asset with the same id already exists
|
|
if (asset.id != null && assetIdSet.has(String(asset.id))) continue;
|
|
state.seenKeys.add(key);
|
|
if (asset.id != null) assetIdSet.add(String(asset.id));
|
|
|
|
validNewAssets.push(asset);
|
|
|
|
// Update stemMap
|
|
if (stemLower) {
|
|
let list = stemMap.get(stemLower);
|
|
if (!list) {
|
|
list = [];
|
|
stemMap.set(stemLower, list);
|
|
}
|
|
list.push(asset);
|
|
}
|
|
|
|
addedCount++;
|
|
}
|
|
|
|
// [OPTIMIZATION] Apply batch removals
|
|
if (assetsToRemoveFromState.size > 0) {
|
|
state.hiddenPngSiblings += assetsToRemoveFromState.size;
|
|
// Single pass removal
|
|
state.assets = state.assets.filter(a => !assetsToRemoveFromState.has(a));
|
|
for (const removed of assetsToRemoveFromState) {
|
|
try {
|
|
if (removed?.id != null) assetIdSet.delete(String(removed.id));
|
|
} catch {}
|
|
}
|
|
try {
|
|
for (const removed of assetsToRemoveFromState) {
|
|
const key = _getFilenameKey(removed?.filename);
|
|
if (!key) continue;
|
|
const bucket = filenameToAssets.get(key);
|
|
if (!bucket) continue;
|
|
const idx = bucket.indexOf(removed);
|
|
if (idx > -1) bucket.splice(idx, 1);
|
|
if (!bucket.length) filenameToAssets.delete(key);
|
|
}
|
|
} catch {}
|
|
needsUpdate = true;
|
|
}
|
|
|
|
if (validNewAssets.length > 0) {
|
|
state.assets.push(...validNewAssets);
|
|
needsUpdate = true;
|
|
}
|
|
|
|
if (needsUpdate) {
|
|
applyFilenameCollisions();
|
|
// Update the virtual grid
|
|
vg.setItems(state.assets);
|
|
|
|
// Ensure sentinel is at the bottom
|
|
if (state.sentinel) {
|
|
gridContainer.appendChild(state.sentinel);
|
|
}
|
|
}
|
|
|
|
try {
|
|
gridContainer.dataset.mjrHidePngSiblingsEnabled = hidePngSiblings ? "1" : "0";
|
|
gridContainer.dataset.mjrHiddenPngSiblings = String(Number(state.hiddenPngSiblings || 0) || 0);
|
|
} catch {}
|
|
|
|
return addedCount;
|
|
}
|
|
|
|
function ensureSentinel(gridContainer, state) {
|
|
let sentinel = state.sentinel;
|
|
// Fix: Force ensure it is last child
|
|
if (sentinel && sentinel.isConnected && sentinel.parentNode === gridContainer && !sentinel.nextSibling) return sentinel;
|
|
|
|
if (sentinel) {
|
|
sentinel.remove();
|
|
} else {
|
|
sentinel = document.createElement("div");
|
|
sentinel.className = SENTINEL_CLASS;
|
|
// z-index -10 to prevent interference
|
|
sentinel.style.cssText = "height: 1px; width: 100%; position: absolute; bottom: 0; left: 0; pointer-events: none; z-index: -10;";
|
|
state.sentinel = sentinel;
|
|
}
|
|
gridContainer.appendChild(sentinel);
|
|
|
|
if (state.observer) {
|
|
try { state.observer.observe(sentinel); } catch {}
|
|
}
|
|
return sentinel;
|
|
}
|
|
function stopObserver(state) {
|
|
try {
|
|
if (state.observer) state.observer.disconnect();
|
|
} catch {}
|
|
state.observer = null;
|
|
try {
|
|
if (state.sentinel && state.sentinel.isConnected) state.sentinel.remove();
|
|
} catch {}
|
|
state.sentinel = null;
|
|
|
|
try {
|
|
if (state.scrollTarget && state.scrollHandler) {
|
|
state.scrollTarget.removeEventListener("scroll", state.scrollHandler);
|
|
}
|
|
} catch {}
|
|
state.scrollRoot = null;
|
|
state.scrollTarget = null;
|
|
state.scrollHandler = null;
|
|
state.ignoreNextScroll = false;
|
|
state.userScrolled = false;
|
|
state.allowUntilFilled = true;
|
|
}
|
|
|
|
function captureScrollMetrics(state) {
|
|
const root = state?.scrollRoot;
|
|
if (!root) return null;
|
|
|
|
// Dedicated scroll container only
|
|
const clientHeight = Number(root.clientHeight) || 0;
|
|
const scrollHeight = Number(root.scrollHeight) || 0;
|
|
const scrollTop = Number(root.scrollTop) || 0;
|
|
|
|
const bottomGap = scrollHeight - (scrollTop + clientHeight);
|
|
return { clientHeight, scrollHeight, scrollTop, bottomGap };
|
|
}
|
|
|
|
function maybeKeepPinnedToBottom(_state, _before) {
|
|
// Disabled: Standard infinite scroll should not force pin to bottom on append.
|
|
// This logic is problematic for grids where we want to seeing new content naturally.
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* 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 } = {}) {
|
|
const scope = gridContainer?.dataset?.mjrScope || "output";
|
|
const customRootId = gridContainer?.dataset?.mjrCustomRootId || "";
|
|
const subfolder = gridContainer?.dataset?.mjrSubfolder || "";
|
|
const kind = gridContainer?.dataset?.mjrFilterKind || "";
|
|
const workflowOnly = gridContainer?.dataset?.mjrFilterWorkflowOnly === "1";
|
|
const minRating = Number(gridContainer?.dataset?.mjrFilterMinRating || 0) || 0;
|
|
const minSizeMB = Number(gridContainer?.dataset?.mjrFilterMinSizeMB || 0) || 0;
|
|
const maxSizeMB = Number(gridContainer?.dataset?.mjrFilterMaxSizeMB || 0) || 0;
|
|
const minWidth = Number(gridContainer?.dataset?.mjrFilterMinWidth || 0) || 0;
|
|
const minHeight = Number(gridContainer?.dataset?.mjrFilterMinHeight || 0) || 0;
|
|
const workflowType = String(gridContainer?.dataset?.mjrFilterWorkflowType || "").trim().toUpperCase();
|
|
const dateRange = String(gridContainer?.dataset?.mjrFilterDateRange || "").trim().toLowerCase();
|
|
const dateExact = String(gridContainer?.dataset?.mjrFilterDateExact || "").trim();
|
|
const sortKey = gridContainer?.dataset?.mjrSort || "mtime_desc";
|
|
|
|
const requestedQuery = query && query.trim() ? query : "*";
|
|
const safeQuery = sanitizeQuery(requestedQuery) || requestedQuery;
|
|
|
|
try {
|
|
const includeTotal = !(String(scope || "").toLowerCase() === "output" && Number(offset || 0) > 0);
|
|
const url = buildListURL({
|
|
q: safeQuery,
|
|
limit,
|
|
offset,
|
|
scope,
|
|
subfolder,
|
|
customRootId: customRootId || null,
|
|
kind: kind || null,
|
|
hasWorkflow: workflowOnly ? true : null,
|
|
minRating: minRating > 0 ? minRating : null,
|
|
minSizeMB: minSizeMB > 0 ? minSizeMB : null,
|
|
maxSizeMB: maxSizeMB > 0 ? maxSizeMB : null,
|
|
minWidth: minWidth > 0 ? minWidth : null,
|
|
minHeight: minHeight > 0 ? minHeight : null,
|
|
workflowType: workflowType || null,
|
|
dateRange: dateRange || null,
|
|
dateExact: dateExact || null,
|
|
sort: sortKey,
|
|
includeTotal
|
|
});
|
|
const result = await get(url, signal ? { signal } : undefined);
|
|
|
|
// Ignore stale responses (e.g. user changed scope/filters while request was in flight).
|
|
try {
|
|
const state = GRID_STATE.get(gridContainer);
|
|
if (state && Number(state.requestId) !== Number(requestId)) {
|
|
return { ok: false, stale: true, error: "Stale response" };
|
|
}
|
|
} catch {}
|
|
|
|
if (result.ok) {
|
|
let assets = (result.data?.assets) || [];
|
|
const rawTotal = result.data?.total;
|
|
const total = rawTotal == null ? null : (Number(rawTotal || 0) || 0);
|
|
|
|
return { ok: true, assets, total, count: assets.length, sortKey, safeQuery };
|
|
} else {
|
|
try {
|
|
if (String(result?.code || "") === "ABORTED") return { ok: false, aborted: true, error: "Aborted" };
|
|
} catch {}
|
|
return { ok: false, error: result.error };
|
|
}
|
|
} catch (error) {
|
|
try {
|
|
if (String(error?.name || "") === "AbortError") return { ok: false, aborted: true, error: "Aborted" };
|
|
} catch {}
|
|
return { ok: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
const emitAgendaStatus = (dateExact, hasResults) => {
|
|
if (!dateExact) return;
|
|
try {
|
|
window?.dispatchEvent?.(
|
|
new CustomEvent("MJR:AgendaStatus", {
|
|
detail: { date: dateExact, hasResults: Boolean(hasResults) },
|
|
})
|
|
);
|
|
} catch {}
|
|
};
|
|
|
|
async function loadNextPage(gridContainer, state) {
|
|
if (state.loading || state.done) return;
|
|
const limit = Math.max(1, Math.min(APP_CONFIG.MAX_PAGE_SIZE, APP_CONFIG.DEFAULT_PAGE_SIZE));
|
|
state.loading = true;
|
|
const before = captureScrollMetrics(state);
|
|
gridDebug("loadNextPage:start", {
|
|
q: String(state?.query || ""),
|
|
limit,
|
|
offset: Number(state?.offset || 0) || 0,
|
|
done: !!state?.done,
|
|
loading: !!state?.loading,
|
|
assetsLen: Array.isArray(state?.assets) ? state.assets.length : -1,
|
|
scroll: before,
|
|
});
|
|
try {
|
|
const page = await fetchPage(gridContainer, state.query, limit, state.offset, {
|
|
requestId: Number(state.requestId) || 0,
|
|
signal: state.abortController?.signal || null
|
|
});
|
|
gridDebug("loadNextPage:response", {
|
|
ok: !!page?.ok,
|
|
aborted: !!page?.aborted,
|
|
stale: !!page?.stale,
|
|
error: page?.error || null,
|
|
count: Number(page?.count || 0) || 0,
|
|
total: page?.total ?? null,
|
|
offsetBefore: Number(state?.offset || 0) || 0,
|
|
});
|
|
const dateExact = String(gridContainer?.dataset?.mjrFilterDateExact || "").trim();
|
|
emitAgendaStatus(dateExact, page.ok && Array.isArray(page.assets) && page.assets.length > 0);
|
|
if (!page.ok) {
|
|
if (page.aborted || page.stale) return;
|
|
state.done = true;
|
|
stopObserver(state);
|
|
|
|
// [ISSUE 5] Error Retry Mechanism
|
|
const errDiv = document.createElement("div");
|
|
errDiv.style.padding = "20px";
|
|
errDiv.style.textAlign = "center";
|
|
errDiv.style.gridColumn = "1 / -1";
|
|
|
|
const msg = document.createElement("p");
|
|
msg.className = "mjr-muted";
|
|
msg.style.color = "var(--error-text, #f44336)";
|
|
msg.textContent = `Error loading: ${page.error}`;
|
|
|
|
const retryBtn = document.createElement("button");
|
|
retryBtn.textContent = "Retry";
|
|
retryBtn.className = "mjr-btn mjr-btn-secondary"; // Use existing class if available
|
|
retryBtn.style.marginTop = "10px";
|
|
retryBtn.onclick = () => {
|
|
errDiv.remove();
|
|
state.done = false;
|
|
state.loading = false;
|
|
// Retry immediately
|
|
loadNextPage(gridContainer, state);
|
|
};
|
|
|
|
errDiv.appendChild(msg);
|
|
errDiv.appendChild(retryBtn);
|
|
gridContainer.appendChild(errDiv);
|
|
return;
|
|
}
|
|
|
|
if (page.total != null) {
|
|
state.total = page.total;
|
|
}
|
|
const added = appendAssets(gridContainer, page.assets || [], state);
|
|
state.offset += (page.count || 0);
|
|
|
|
try {
|
|
const count = Number(state?.offset || 0) || 0;
|
|
const total = Number(state?.total ?? 0) || 0;
|
|
const evt = new CustomEvent("mjr:grid-stats", { detail: { count, total } });
|
|
gridContainer?.dispatchEvent?.(evt);
|
|
if (gridContainer && typeof gridContainer.dataset === "object") {
|
|
try {
|
|
gridContainer.dataset.mjrShown = String(count);
|
|
gridContainer.dataset.mjrTotal = String(total);
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
|
|
gridDebug("loadNextPage:append", {
|
|
added,
|
|
pageCount: Number(page?.count || 0) || 0,
|
|
newOffset: Number(state?.offset || 0) || 0,
|
|
assetsLen: Array.isArray(state?.assets) ? state.assets.length : -1,
|
|
total: state.total ?? null,
|
|
});
|
|
|
|
maybeKeepPinnedToBottom(state, before);
|
|
|
|
// Fix: Conservative stop condition.
|
|
// We ignore state.total because it might be approximate.
|
|
// We only stop if we received 0 items.
|
|
// Note: We could also stop if count < limit, but relying on 0 is safer against
|
|
// backends that might return partial pages due to filtering/timeouts.
|
|
if ((page.count || 0) === 0) {
|
|
state.done = true;
|
|
stopObserver(state);
|
|
gridDebug("loadNextPage:done", { reason: "page.count==0", offset: Number(state?.offset || 0) || 0 });
|
|
}
|
|
} finally {
|
|
state.loading = false;
|
|
gridDebug("loadNextPage:finally", { loading: false, done: !!state?.done });
|
|
}
|
|
}
|
|
|
|
function startInfiniteScroll(gridContainer, state) {
|
|
if (!APP_CONFIG.INFINITE_SCROLL_ENABLED) return;
|
|
stopObserver(state);
|
|
const sentinel = ensureSentinel(gridContainer, state);
|
|
|
|
// IMPORTANT: Use the same scroll root as VirtualGrid.
|
|
// If we re-detect incorrectly, the IntersectionObserver never fires on scroll and we
|
|
// remain stuck on the first page (often ~30 items depending on backend caps).
|
|
let rootEl = null;
|
|
try {
|
|
rootEl = state?.virtualGrid?.scrollElement || null;
|
|
} catch {
|
|
rootEl = null;
|
|
}
|
|
|
|
// Fallback: detect a scroll root consistently.
|
|
if (!rootEl) rootEl = _getScrollContainer(gridContainer, state);
|
|
|
|
// Enforce dedicated scroll container mode.
|
|
if (!rootEl || !(rootEl instanceof HTMLElement)) {
|
|
gridDebug("infiniteScroll:disabled", { reason: "no scroll container" });
|
|
return;
|
|
}
|
|
|
|
state.scrollRoot = rootEl;
|
|
state.userScrolled = false;
|
|
|
|
const scrollTarget = rootEl;
|
|
state.scrollTarget = scrollTarget;
|
|
|
|
gridDebug("infiniteScroll:setup", {
|
|
rootEl: rootEl?.className || rootEl?.tagName || null,
|
|
scrollTarget: scrollTarget?.className || scrollTarget?.tagName || null,
|
|
rootMargin: APP_CONFIG.INFINITE_SCROLL_ROOT_MARGIN || "800px",
|
|
threshold: APP_CONFIG.INFINITE_SCROLL_THRESHOLD ?? 0.01,
|
|
offset: Number(state?.offset || 0) || 0,
|
|
done: !!state?.done,
|
|
});
|
|
|
|
if (scrollTarget && !state.scrollHandler) {
|
|
state.scrollHandler = () => {
|
|
if (state.ignoreNextScroll) {
|
|
state.ignoreNextScroll = false;
|
|
return;
|
|
}
|
|
state.userScrolled = true;
|
|
try {
|
|
if (state.loading || state.done || state.allowUntilFilled) return;
|
|
const m = captureScrollMetrics(state);
|
|
const bottomGapPx = Math.max(0, Number(APP_CONFIG.BOTTOM_GAP_PX || 80));
|
|
if (m && m.bottomGap <= bottomGapPx) {
|
|
Promise.resolve(loadNextPage(gridContainer, state)).catch(() => null);
|
|
}
|
|
} catch {}
|
|
};
|
|
try {
|
|
scrollTarget.addEventListener("scroll", state.scrollHandler, { passive: true });
|
|
} catch {}
|
|
}
|
|
|
|
const observerRoot = rootEl;
|
|
|
|
state.observer = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries || []) {
|
|
if (!entry.isIntersecting) continue;
|
|
if (state.loading || state.done) return;
|
|
|
|
gridDebug("infiniteScroll:intersect", {
|
|
offset: Number(state?.offset || 0) || 0,
|
|
total: state?.total ?? null,
|
|
loading: !!state?.loading,
|
|
done: !!state?.done,
|
|
metrics: captureScrollMetrics(state),
|
|
targetConnected: !!sentinel?.isConnected,
|
|
});
|
|
|
|
// Prevent repeated auto-loads when new content is appended while the user
|
|
// stays pinned at the bottom. Require a user scroll between loads unless
|
|
// the grid doesn't fill the viewport yet (initial fill).
|
|
const metrics = captureScrollMetrics(state);
|
|
const fillsViewport = metrics ? metrics.scrollHeight > metrics.clientHeight + 40 : false;
|
|
|
|
if (!state.userScrolled && fillsViewport && !state.allowUntilFilled) {
|
|
return;
|
|
}
|
|
try {
|
|
state.observer?.unobserve?.(sentinel);
|
|
} catch {}
|
|
|
|
state.userScrolled = false;
|
|
if (fillsViewport) state.allowUntilFilled = false;
|
|
|
|
Promise.resolve(loadNextPage(gridContainer, state))
|
|
.catch(() => null)
|
|
.finally(() => {
|
|
if (state.done) return;
|
|
if (!sentinel.isConnected) return;
|
|
try {
|
|
state.observer?.observe?.(sentinel);
|
|
} catch {}
|
|
});
|
|
}
|
|
},
|
|
{
|
|
root: observerRoot,
|
|
rootMargin: APP_CONFIG.INFINITE_SCROLL_ROOT_MARGIN || "800px",
|
|
threshold: APP_CONFIG.INFINITE_SCROLL_THRESHOLD ?? 0.01
|
|
}
|
|
);
|
|
state.observer.observe(sentinel);
|
|
}
|
|
|
|
export async function loadAssets(gridContainer, query = "*", options = {}) {
|
|
const { reset = true } = options || {};
|
|
const state = getOrCreateState(gridContainer);
|
|
|
|
const requestedQuery = query && query.trim() ? query : "*";
|
|
state.query = sanitizeQuery(requestedQuery) || requestedQuery;
|
|
|
|
if (reset) {
|
|
try {
|
|
state.abortController?.abort?.();
|
|
} catch {}
|
|
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 {}
|
|
|
|
// 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 {}
|
|
|
|
// 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) {
|
|
// Manually clear to show message
|
|
if (state.virtualGrid) state.virtualGrid.setItems([]);
|
|
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 {}
|
|
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 {}
|
|
try {
|
|
if (String(err?.name || "") === "AbortError") {
|
|
return { ok: false, aborted: true, error: "Aborted" };
|
|
}
|
|
} catch {}
|
|
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 {}
|
|
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 {}
|
|
|
|
// 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 {}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 {}
|
|
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 {}
|
|
|
|
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) => {
|
|
const av = getSortValue(a, sortKey);
|
|
const bv = getSortValue(b, sortKey);
|
|
if (av < bv) return -1;
|
|
if (av > bv) return 1;
|
|
return 0;
|
|
});
|
|
|
|
appendAssets(gridContainer, sorted, state);
|
|
|
|
if (reset) {
|
|
hideLoadingOverlay(gridContainer);
|
|
const hasItems = state.assets && state.assets.length > 0;
|
|
if (!hasItems) {
|
|
if (state.virtualGrid) state.virtualGrid.setItems([]);
|
|
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)) };
|
|
}
|
|
|
|
let 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 {}
|
|
|
|
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 {}
|
|
} 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 {}
|
|
try {
|
|
if (Number.isFinite(state.offset)) state.offset = Math.max(0, Number(state.offset) - removedCount);
|
|
} catch {}
|
|
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 {}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
for (const id of ids) {
|
|
try {
|
|
const card = gridContainer.querySelector(`[data-mjr-asset-id="${_safeEscapeId(id)}"]`);
|
|
if (card) {
|
|
card.remove?.();
|
|
removedCount += 1;
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
const selectedList = Array.from(selectedIds);
|
|
try {
|
|
gridContainer?.dispatchEvent?.(new CustomEvent("mjr:selection-changed", { detail: { selectedIds: selectedList } }));
|
|
} catch {}
|
|
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 {}
|
|
|
|
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 {}
|
|
state.virtualGrid = null;
|
|
}
|
|
|
|
if (state._cardKeydownHandler) {
|
|
try {
|
|
gridContainer.removeEventListener("keydown", state._cardKeydownHandler, true);
|
|
} catch {}
|
|
state._cardKeydownHandler = null;
|
|
}
|
|
|
|
// Cleanup summary bar listeners
|
|
try {
|
|
gridContainer._mjrSummaryBarDispose?.();
|
|
} catch {}
|
|
|
|
// Cleanup grid keyboard
|
|
try {
|
|
gridContainer._mjrGridKeyboard?.dispose?.();
|
|
} catch {}
|
|
|
|
// Cleanup settings-changed listener
|
|
try {
|
|
gridContainer._mjrSettingsChangedCleanup?.();
|
|
} catch {}
|
|
try {
|
|
cleanupVideoThumbsIn?.(gridContainer);
|
|
} catch {}
|
|
try {
|
|
state.seenKeys?.clear?.();
|
|
state.stemMap?.clear?.();
|
|
state.renderedFilenameMap?.clear?.();
|
|
} catch {}
|
|
// Best-effort callback cleanup
|
|
try {
|
|
const st = _getRtHydrateState(gridContainer);
|
|
if (st) {
|
|
st.queue.length = 0;
|
|
st.active?.clear?.();
|
|
st.seen?.clear?.();
|
|
}
|
|
} catch {}
|
|
try {
|
|
RT_HYDRATE_STATE.delete(gridContainer);
|
|
} catch {}
|
|
try {
|
|
if (state.metricsTimer) {
|
|
clearInterval(state.metricsTimer);
|
|
state.metricsTimer = null;
|
|
}
|
|
if (state.metricsEl?.parentNode) {
|
|
state.metricsEl.parentNode.removeChild(state.metricsEl);
|
|
}
|
|
state.metricsEl = null;
|
|
} catch {}
|
|
GRID_STATE.delete(gridContainer);
|
|
}
|
|
|
|
/**
|
|
* Get sort value for an asset based on sort key
|
|
*/
|
|
function getSortValue(asset, sortKey) {
|
|
switch (sortKey) {
|
|
case 'mtime_desc':
|
|
return -(Number(asset?.mtime) || 0); // Negative for descending order
|
|
case 'mtime_asc':
|
|
return Number(asset?.mtime) || 0;
|
|
case 'name_asc':
|
|
return String(asset?.filename || '').toLowerCase();
|
|
case 'name_desc':
|
|
return String(asset?.filename || '').toLowerCase(); // Will be handled specially in comparison
|
|
default:
|
|
return -(Number(asset?.mtime) || 0); // Default to mtime_desc
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine if an asset should be inserted before another based on sort order
|
|
*/
|
|
function shouldInsertBefore(assetValue, cardValue, sortKey) {
|
|
if (sortKey === 'name_asc') {
|
|
// For ascending string comparisons
|
|
return String(assetValue) < String(cardValue);
|
|
} else if (sortKey === 'name_desc') {
|
|
// For descending string comparisons
|
|
return String(assetValue) > String(cardValue);
|
|
} else {
|
|
// For numeric comparisons
|
|
return Number(assetValue) < Number(cardValue);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find the correct insertion position in an array based on sort order
|
|
*/
|
|
function findInsertPosition(array, asset, sortKey) {
|
|
const assetValue = getSortValue(asset, sortKey);
|
|
for (let i = 0; i < array.length; i++) {
|
|
const arrValue = getSortValue(array[i], sortKey);
|
|
if (shouldInsertBefore(assetValue, arrValue, sortKey)) {
|
|
return i;
|
|
}
|
|
}
|
|
return array.length; // Append to end
|
|
}
|
|
|
|
/**
|
|
* Find asset element by ID
|
|
*/
|
|
function findAssetElement(gridContainer, assetId) {
|
|
return gridContainer.querySelector(`[data-mjr-asset-id="${CSS.escape ? CSS.escape(String(assetId)) : String(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) {
|
|
const batchState = UPSERT_BATCH_STATE.get(gridContainer);
|
|
if (!batchState || batchState.pending.size === 0) return;
|
|
if (batchState.flushing) return; // Prevent re-entrancy
|
|
|
|
batchState.flushing = true;
|
|
if (batchState.timer) {
|
|
clearTimeout(batchState.timer);
|
|
batchState.timer = null;
|
|
}
|
|
|
|
const state = getOrCreateState(gridContainer);
|
|
const vg = ensureVirtualGrid(gridContainer, state);
|
|
|
|
try {
|
|
// Process all pending assets
|
|
let modified = false;
|
|
|
|
for (const [assetId, asset] of batchState.pending.entries()) {
|
|
const key = assetKey(asset);
|
|
const existingIndex = state.assets.findIndex(a => String(a.id) === assetId);
|
|
|
|
if (existingIndex > -1) {
|
|
// Update existing
|
|
const existingAsset = state.assets[existingIndex];
|
|
const collision = existingAsset._mjrNameCollision;
|
|
const collisionCount = existingAsset._mjrNameCollisionCount;
|
|
|
|
Object.assign(existingAsset, asset);
|
|
|
|
if (collision && !asset._mjrNameCollision) {
|
|
existingAsset._mjrNameCollision = true;
|
|
existingAsset._mjrNameCollisionCount = collisionCount;
|
|
}
|
|
state.assets[existingIndex] = { ...existingAsset };
|
|
modified = true;
|
|
} else {
|
|
// Defensive: skip if already tracked by seenKeys OR if another asset
|
|
// with the same id somehow exists (guards against race conditions).
|
|
const alreadySeen = state.seenKeys.has(key) ||
|
|
(asset.id != null && state.assetIdSet?.has?.(assetId));
|
|
if (!alreadySeen) {
|
|
const sortKey = gridContainer.dataset.mjrSort || "mtime_desc";
|
|
const insertPos = findInsertPosition(state.assets, asset, sortKey);
|
|
|
|
state.seenKeys.add(key);
|
|
if (asset.id != null) state.assetIdSet?.add?.(assetId);
|
|
|
|
// If insertion is near the top, mark re-sort to avoid large index shifts.
|
|
const shouldResort = insertPos <= Math.min(10, state.assets.length);
|
|
if (insertPos === -1) {
|
|
state.assets.push(asset);
|
|
} else {
|
|
state.assets.splice(insertPos, 0, asset);
|
|
}
|
|
if (shouldResort) {
|
|
try {
|
|
state._mjrResortPending = true;
|
|
} catch {}
|
|
}
|
|
modified = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Single setItems call for all changes
|
|
if (modified && vg) {
|
|
// If many top inserts occurred, full re-sort to stabilize indices.
|
|
if (state._mjrResortPending) {
|
|
try {
|
|
const sortKey = gridContainer.dataset.mjrSort || "mtime_desc";
|
|
state.assets.sort((a, b) => {
|
|
const av = getSortValue(a, sortKey);
|
|
const bv = getSortValue(b, sortKey);
|
|
if (av < bv) return -1;
|
|
if (av > bv) return 1;
|
|
return 0;
|
|
});
|
|
} catch {}
|
|
try { state._mjrResortPending = false; } catch {}
|
|
}
|
|
vg.setItems(state.assets);
|
|
}
|
|
} catch (err) {
|
|
gridDebug("upsertBatch:flushError", err);
|
|
} finally {
|
|
batchState.pending.clear();
|
|
batchState.flushing = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
if (!asset || !asset.id) return false;
|
|
|
|
const state = getOrCreateState(gridContainer);
|
|
const assetId = String(asset.id);
|
|
|
|
const vg = ensureVirtualGrid(gridContainer, state);
|
|
if (!vg) return false;
|
|
|
|
// Add to batch
|
|
const batchState = _getUpsertBatchState(gridContainer);
|
|
batchState.pending.set(assetId, asset);
|
|
|
|
// Schedule flush with debounce, or flush immediately if batch is large
|
|
if (batchState.pending.size >= UPSERT_BATCH_MAX_SIZE) {
|
|
_flushUpsertBatch(gridContainer);
|
|
} else if (!batchState.timer && !batchState.flushing) {
|
|
batchState.timer = setTimeout(() => {
|
|
batchState.timer = null;
|
|
_flushUpsertBatch(gridContainer);
|
|
}, UPSERT_BATCH_DEBOUNCE_MS);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Force flush any pending upsert batch (call before operations that need up-to-date grid)
|
|
* @param {HTMLElement} gridContainer
|
|
*/
|
|
export function flushUpsertBatch(gridContainer) {
|
|
_flushUpsertBatch(gridContainer);
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
const out = [];
|
|
const seen = new Set();
|
|
for (const asset of bucket || []) {
|
|
const raw =
|
|
asset?.filepath
|
|
|| asset?.path
|
|
|| (asset?.subfolder ? `${asset.subfolder}/${asset.filename || ""}` : `${asset?.filename || ""}`);
|
|
const p = String(raw || "").trim();
|
|
if (!p) continue;
|
|
const key = p.toLowerCase();
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* 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) return;
|
|
|
|
const rect = el.getBoundingClientRect();
|
|
const containerRect = gridContainer.getBoundingClientRect();
|
|
const delta = (rect.top - containerRect.top) - anchor.top;
|
|
|
|
scrollContainer.scrollTop = (scrollContainer.scrollTop || 0) + delta;
|
|
}
|
|
|
|
/**
|
|
* Check if user is at bottom of grid (within threshold)
|
|
*/
|
|
export function isAtBottom(gridContainer, threshold = null) {
|
|
const state = getOrCreateState(gridContainer);
|
|
const scrollContainer = _getScrollContainer(gridContainer, state);
|
|
if (!scrollContainer) return false;
|
|
|
|
const effectiveThreshold = Number(threshold) || Number(APP_CONFIG.BOTTOM_GAP_PX) || 80;
|
|
const atBottom = (scrollContainer.scrollHeight - (scrollContainer.scrollTop + scrollContainer.clientHeight)) < effectiveThreshold;
|
|
return atBottom;
|
|
}
|
|
|
|
/**
|
|
* Scroll to bottom of grid
|
|
*/
|
|
export function scrollToBottom(gridContainer) {
|
|
const state = getOrCreateState(gridContainer);
|
|
const scrollContainer = _getScrollContainer(gridContainer, state);
|
|
if (!scrollContainer) return;
|
|
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
});
|
|
}
|
|
|
|
// 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 {}
|
|
}
|
|
|
|
/**
|
|
* Clear grid placeholder
|
|
*/
|
|
export function clearGrid(gridContainer) {
|
|
try {
|
|
const state = GRID_STATE.get(gridContainer);
|
|
if (state) {
|
|
stopObserver(state);
|
|
state.offset = 0;
|
|
state.total = null;
|
|
state.done = false;
|
|
state.loading = false;
|
|
state.seenKeys = new Set();
|
|
}
|
|
} catch {}
|
|
clearGridMessage(gridContainer);
|
|
setGridMessage(gridContainer, "Type to search or wait for the scan to finish");
|
|
}
|
|
|
|
// [ISSUE 1] Real-time Refresh Implementation
|
|
// Manages the global scan-complete handlers so we can teardown on panel unload.
|
|
const GRID_SCAN_EVENT_NAMES = ["mjr-scan-complete", "mjr:scan-complete"];
|
|
const GRID_SCAN_REFRESH_DEBOUNCE_MS = Number(APP_CONFIG?.GRID_SCAN_REFRESH_DEBOUNCE_MS) || 300;
|
|
let _gridScanListenersBound = false;
|
|
|
|
const _onScanComplete = (_e) => {
|
|
try {
|
|
if (typeof document === "undefined" || !document.querySelectorAll) return;
|
|
const grids = document.querySelectorAll(".mjr-grid");
|
|
for (const grid of grids) {
|
|
try {
|
|
if (!grid?.isConnected) continue;
|
|
|
|
const state = GRID_STATE.get(grid);
|
|
if (!state) continue;
|
|
|
|
if (grid._mjrRefreshTimer) clearTimeout(grid._mjrRefreshTimer);
|
|
|
|
grid._mjrRefreshTimer = setTimeout(() => {
|
|
grid._mjrRefreshTimer = null;
|
|
if (!grid.isConnected) return;
|
|
loadAssets(grid, state.query, { reset: true });
|
|
}, GRID_SCAN_REFRESH_DEBOUNCE_MS);
|
|
} catch (err) {
|
|
console.warn("[Majoor] Auto-refresh error:", err);
|
|
}
|
|
}
|
|
} catch {
|
|
// swallow to avoid leaking errors into the global listener
|
|
}
|
|
};
|
|
|
|
const _attachGridScanListeners = () => {
|
|
if (_gridScanListenersBound) return;
|
|
if (typeof window === "undefined") return;
|
|
|
|
try {
|
|
for (const name of GRID_SCAN_EVENT_NAMES) {
|
|
window.addEventListener(name, _onScanComplete);
|
|
}
|
|
_gridScanListenersBound = true;
|
|
} catch (err) {
|
|
console.warn("Failed to register global scan listener:", err);
|
|
}
|
|
};
|
|
|
|
const _detachGridScanListeners = () => {
|
|
if (!_gridScanListenersBound) return;
|
|
if (typeof window === "undefined") {
|
|
_gridScanListenersBound = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
for (const name of GRID_SCAN_EVENT_NAMES) {
|
|
window.removeEventListener(name, _onScanComplete);
|
|
}
|
|
} catch (err) {
|
|
console.warn("Failed to unregister global scan listener:", err);
|
|
} finally {
|
|
_gridScanListenersBound = false;
|
|
}
|
|
};
|
|
|
|
export function bindGridScanListeners() {
|
|
_attachGridScanListeners();
|
|
}
|
|
|
|
export function disposeGridScanListeners() {
|
|
_detachGridScanListeners();
|
|
}
|
|
|