Files
MajoorWaldi-ComfyUI-Majoor-…/ui/vue/composables/useVirtualGrid.ts
T

894 lines
34 KiB
TypeScript

/**
* useVirtualGrid.js - unified virtualization module for the Vue migration.
*
* This centralizes virtualization helpers that were previously split
* across VirtualScroller.js and InfiniteScroll.js.
*/
import { getFilenameKey, shouldHideSiblingAsset, unregisterHiddenSibling } from "../../features/grid/AssetCardRenderer.js";
import {
gridQueryHasActiveFilters,
readGridQueryFromDataset,
} from "../grid/useGridQuery.js";
import { syncAssetCollectionState } from "../grid/useAssetCollection.js";
import { resolvePageAdvance } from "../grid/usePagedAssets.js";
export function isPotentialScrollContainer(el: any) {
if (!el || el === window) return false;
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;
return true;
} catch {
return false;
}
}
export function detectScrollRoot(gridContainer: any) {
try {
const browse = gridContainer?.closest?.(".mjr-am-browse") || null;
if (browse && isPotentialScrollContainer(browse)) return browse;
} catch (e) {
console.debug?.(e);
}
try {
let cur = gridContainer?.parentElement;
while (cur && cur !== document.body && cur !== document.documentElement) {
if (isPotentialScrollContainer(cur)) return cur;
cur = cur.parentElement;
}
} catch (e) {
console.debug?.(e);
}
return gridContainer?.parentElement || null;
}
export function getScrollContainer(gridContainer: any, state: any) {
try {
const cur = state?.scrollRoot;
if (cur && cur instanceof HTMLElement) return cur;
} catch (e) {
console.debug?.(e);
}
const detected = detectScrollRoot(gridContainer);
if (detected && state) state.scrollRoot = detected;
return detected;
}
export function ensureVirtualGrid(gridContainer: any, state: any, deps: any) {
if (state.virtualGrid) {
// Guard: a disposed instance has its DOM detached; discard it so a fresh one is created.
if (state.virtualGrid._disposed) {
state.virtualGrid = null;
} else {
return state.virtualGrid;
}
}
const scrollRoot = getScrollContainer(gridContainer, state);
try {
deps.gridDebug("virtualGrid:scrollRoot", {
scrollRoot:
scrollRoot === document.body
? "document.body"
: scrollRoot === document.documentElement
? "document.documentElement"
: scrollRoot?.className || scrollRoot?.tagName || null,
});
} catch (e) {
console.debug?.(e);
}
state.virtualGrid = new deps.VirtualGrid(gridContainer, scrollRoot, deps.optionsFactory());
if (!state._cardKeydownHandler) {
const handler = (event: any) => {
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 (e) {
console.debug?.(e);
}
};
state._cardKeydownHandler = handler;
gridContainer.addEventListener("keydown", handler, true);
}
return state.virtualGrid;
}
export function ensureSentinel(gridContainer: any, state: any, sentinelClass: any) {
let sentinel = state.sentinel;
if (
sentinel &&
sentinel.isConnected &&
sentinel.parentNode === gridContainer &&
!sentinel.nextSibling
)
return sentinel;
if (sentinel) {
sentinel.remove();
} else {
sentinel = document.createElement("div");
sentinel.className = sentinelClass;
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 (e) {
console.debug?.(e);
}
}
return sentinel;
}
export function stopObserver(state: any, gridContainer: any = null) {
try {
if (state.observer) state.observer.disconnect();
} catch (e) {
console.debug?.(e);
}
state.observer = null;
try {
if (state.sentinel && state.sentinel.isConnected) state.sentinel.remove();
} catch (e) {
console.debug?.(e);
}
state.sentinel = null;
try {
if (state.scrollTarget && state.scrollHandler) {
state.scrollTarget.removeEventListener("scroll", state.scrollHandler);
}
} catch (e) {
console.debug?.(e);
}
state.scrollRoot = null;
state.scrollTarget = null;
state.scrollHandler = null;
state.ignoreNextScroll = false;
state.userScrolled = false;
state.allowUntilFilled = true;
if (gridContainer && state._cardKeydownHandler) {
try {
gridContainer.removeEventListener("keydown", state._cardKeydownHandler, true);
} catch (e) {
console.debug?.(e);
}
state._cardKeydownHandler = null;
}
}
export function captureScrollMetrics(state: any) {
const root = state?.scrollRoot;
if (!root) return null;
try {
if (!root.isConnected) return null;
} catch (e) {
console.debug?.(e);
}
const clientHeight = Number(root.clientHeight) || 0;
if (clientHeight <= 0) return null;
const scrollHeight = Number(root.scrollHeight) || 0;
const scrollTop = Number(root.scrollTop) || 0;
const bottomGap = scrollHeight - (scrollTop + clientHeight);
return { clientHeight, scrollHeight, scrollTop, bottomGap };
}
export function maybeKeepPinnedToBottom(_state: any, _before: any) {
return;
}
export function resolvePageAdvanceCount({ count = 0, limit = 0, offset = 0, total = null as number | null } = {}) {
return resolvePageAdvance({ count, limit, offset, total });
}
export function startInfiniteScroll(gridContainer: any, state: any, deps: any) {
if (!deps.config.INFINITE_SCROLL_ENABLED) return;
stopObserver(state);
const sentinel = ensureSentinel(gridContainer, state, deps.sentinelClass);
let rootEl;
try {
rootEl = state?.virtualGrid?.scrollElement || null;
} catch {
rootEl = null;
}
if (!rootEl) rootEl = getScrollContainer(gridContainer, state);
if (!rootEl || !(rootEl instanceof HTMLElement)) {
deps.gridDebug("infiniteScroll:disabled", { reason: "no scroll container" });
return;
}
state.scrollRoot = rootEl;
state.userScrolled = false;
const scrollTarget = rootEl;
state.scrollTarget = scrollTarget;
deps.gridDebug("infiniteScroll:setup", {
rootEl: rootEl?.className || rootEl?.tagName || null,
scrollTarget: scrollTarget?.className || scrollTarget?.tagName || null,
rootMargin: deps.config.INFINITE_SCROLL_ROOT_MARGIN || "800px",
threshold: deps.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(deps.config.BOTTOM_GAP_PX || 80));
if (m && m.bottomGap <= bottomGapPx) {
Promise.resolve(deps.loadNextPage(gridContainer, state)).catch(() => null);
}
} catch (e) {
console.debug?.(e);
}
};
try {
scrollTarget.addEventListener("scroll", state.scrollHandler, { passive: true });
} catch (e) {
console.debug?.(e);
}
}
const observerRoot = rootEl;
state.observer = new IntersectionObserver(
(entries) => {
for (const entry of entries || []) {
if (!entry.isIntersecting) continue;
if (state.loading || state.done) return;
const metrics = captureScrollMetrics(state);
if (!metrics) return;
const fillsViewport = metrics
? metrics.scrollHeight > metrics.clientHeight + 40
: false;
if (!state.userScrolled && fillsViewport && !state.allowUntilFilled) return;
try {
state.observer?.unobserve?.(sentinel);
} catch (e) {
console.debug?.(e);
}
state.userScrolled = false;
if (fillsViewport) state.allowUntilFilled = false;
Promise.resolve(deps.loadNextPage(gridContainer, state))
.catch(() => null)
.finally(() => {
if (state.done) return;
if (!sentinel.isConnected) return;
try {
state.observer?.observe?.(sentinel);
} catch (e) {
console.debug?.(e);
}
});
}
},
{
root: observerRoot,
rootMargin: deps.config.INFINITE_SCROLL_ROOT_MARGIN || "800px",
threshold: deps.config.INFINITE_SCROLL_THRESHOLD ?? 0.01,
},
);
state.observer.observe(sentinel);
}
function _assetMatchesActiveFilters(gridContainer: any, asset: any) {
if (!asset || typeof asset !== "object") return false;
const query = readGridQueryFromDataset(gridContainer?.dataset || {});
const scope = query.scope;
const subfolder = query.subfolder.toLowerCase();
const kind = query.kind;
const workflowOnly = query.workflowOnly;
const minRating = query.minRating;
const workflowType = query.workflowType.toLowerCase();
const workflowId = String(query.workflowId || "").trim();
const dateExact = query.dateExact;
const assetSubfolder = String(asset?.subfolder || "").trim().toLowerCase();
const assetType = String(asset?.type || asset?.source || "output")
.trim()
.toLowerCase();
const assetKind = String(asset?.kind || "").trim().toLowerCase();
const assetWorkflowType = String(asset?.workflow_type || asset?.workflowType || "")
.trim()
.toLowerCase();
const assetWorkflowId = String(
asset?.workflow_id ||
asset?.workflowId ||
asset?.metadata?.workflow_id ||
asset?.metadata?.workflow?.id ||
"",
).trim();
const assetDate = String(asset?.date_exact || asset?.date || "").trim();
const hasWorkflow = asset?.has_workflow ?? asset?.hasWorkflow ?? null;
if (scope && scope !== "all" && assetType && assetType !== scope) return false;
if (subfolder && assetSubfolder !== subfolder) return false;
if (kind && assetKind && assetKind !== kind) return false;
// Treat null/undefined has_workflow as "pending" (enrichment in progress)
// so freshly generated assets are not rejected by the workflow-only filter.
// Only reject when has_workflow is explicitly false/0.
if (workflowOnly && hasWorkflow !== null && !hasWorkflow) return false;
if (minRating > 0 && (Number(asset?.rating || 0) || 0) < minRating) return false;
if (workflowType && assetWorkflowType !== workflowType) return false;
if (workflowId && assetWorkflowId && assetWorkflowId !== workflowId) return false;
if (dateExact && assetDate && assetDate !== dateExact) return false;
return true;
}
export async function fetchPage(
gridContainer: any,
query: any,
limit: any,
offset: any,
deps: any,
{ requestId = 0, signal = null, cursor = null } = {},
) {
const coerceQueryText = (value: any) => {
if (typeof value === "string") return value;
if (value && typeof value === "object") {
if (typeof value.value === "string") return value.value;
if (typeof value.target?.value === "string") return value.target.value;
}
return String(value || "");
};
const queryState = readGridQueryFromDataset(gridContainer?.dataset || {});
const {
scope,
customRootId,
subfolder,
kind,
workflowOnly,
minRating,
minSizeMB,
maxSizeMB,
resolutionCompare,
minWidth,
minHeight,
maxWidth,
maxHeight,
workflowType,
workflowId,
workflowModel,
workflowRunsOn,
dateRange,
dateExact,
metadataSearchMode,
} = queryState;
const sortKey = queryState.sort || "mtime_desc";
const requestedQueryRaw = coerceQueryText(query).trim();
const requestedQuery = requestedQueryRaw || "*";
const normalizedRequestedQuery = /^\[object\s+HTML.*Element\]$/i.test(requestedQuery)
? "*"
: requestedQuery;
const safeQuery = deps.sanitizeQuery(normalizedRequestedQuery) || normalizedRequestedQuery;
try {
const isOutputScope = String(scope || "").toLowerCase() === "output";
const hasActiveFilters = gridQueryHasActiveFilters({
...queryState,
q: safeQuery,
});
const isDefaultOutputBrowse =
isOutputScope &&
Number(offset ?? 0) === 0 &&
safeQuery === "*" &&
!hasActiveFilters &&
String(sortKey || "mtime_desc").toLowerCase() === "mtime_desc";
const includeTotal = !(isOutputScope && (Number(offset ?? 0) > 0 || isDefaultOutputBrowse));
const groupStacksForRequest = !!queryState.groupStacks;
const cursorForRequest = Number(offset ?? 0) > 0 ? null : cursor || null;
const url = deps.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,
resolutionCompare,
minWidth: minWidth > 0 ? minWidth : null,
minHeight: minHeight > 0 ? minHeight : null,
maxWidth: maxWidth > 0 ? maxWidth : null,
maxHeight: maxHeight > 0 ? maxHeight : null,
workflowType: workflowType || null,
workflowId: workflowId || null,
workflowModel: workflowModel || null,
runsOn: workflowRunsOn || null,
dateRange: dateRange || null,
dateExact: dateExact || null,
sort: sortKey,
cursor: cursorForRequest,
includeTotal,
groupStacks: groupStacksForRequest,
metadataMode: metadataSearchMode || null,
});
const result = await deps.get(url, { timeoutMs: 120_000, ...(signal ? { signal } : {}) });
try {
const state = deps.getGridState(gridContainer);
if (state && Number(state.requestId) !== Number(requestId)) {
return { ok: false, stale: true, error: "Stale response" };
}
} catch (e) {
console.debug?.(e);
}
if (result.ok) {
const assets = result.data?.assets || [];
const serverCount = Array.isArray(assets) ? assets.length : 0;
const rawTotal = result.data?.total;
const total = rawTotal == null ? null : Number(rawTotal ?? 0) || 0;
const responseLimit = Math.max(0, Number(result.data?.limit) || 0);
const responseOffset = Math.max(0, Number(result.data?.offset) || 0);
const nextCursor = result.data?.next_cursor || result.data?.nextCursor || null;
const hasMore = typeof result.data?.has_more === "boolean" ? result.data.has_more : null;
return {
ok: true,
assets,
total,
count: serverCount,
limit: responseLimit,
offset: responseOffset,
nextCursor,
hasMore,
sortKey,
safeQuery,
};
}
try {
if (String(result?.code || "") === "ABORTED")
return { ok: false, aborted: true, error: "Aborted" };
} catch (e) {
console.debug?.(e);
}
return { ok: false, error: result.error };
} catch (error) {
try {
if (String((error as any)?.name || "") === "AbortError")
return { ok: false, aborted: true, error: "Aborted" };
} catch (e) {
console.debug?.(e);
}
return { ok: false, error: (error as any).message };
}
}
export function emitAgendaStatus(dateExact: any, hasResults: any) {
if (!dateExact) return;
try {
window?.dispatchEvent?.(
new CustomEvent("MJR:AgendaStatus", {
detail: { date: dateExact, hasResults: Boolean(hasResults) },
}),
);
} catch (e) {
console.debug?.(e);
}
}
export async function loadNextPage(gridContainer: any, state: any, deps: any) {
if (state.loading || state.done) return;
const limit = Math.max(1, Math.min(deps.config.MAX_PAGE_SIZE, deps.config.DEFAULT_PAGE_SIZE));
state.loading = true;
const before = deps.captureScrollMetrics(state);
deps.gridDebug("loadNextPage:start", {
q: String(state?.query || ""),
limit,
offset: Number(state?.offset ?? 0) || 0,
});
try {
const page = await fetchPage(gridContainer, state.query, limit, state.offset, deps, {
requestId: Number(state.requestId ?? 0) || 0,
signal: state.abortController?.signal || null,
});
const dateExact = readGridQueryFromDataset(gridContainer?.dataset || {}).dateExact;
emitAgendaStatus(
dateExact,
page.ok && Array.isArray(page.assets) && page.assets.length > 0,
);
if (!page.ok) {
if (page.aborted || page.stale) return;
state.done = true;
deps.stopObserver(state);
return;
}
if (page.total != null) state.total = page.total;
const added = deps.appendAssets(gridContainer, page.assets || [], state);
const consumedCount = resolvePageAdvanceCount({
count: page.count,
limit: page.limit || limit,
offset: state.offset,
total: page.total != null ? page.total : state.total,
});
state.offset += consumedCount;
deps.gridDebug("loadNextPage:append", { added, offset: state.offset });
deps.maybeKeepPinnedToBottom(state, before);
const knownTotal = Number(state.total);
if (
consumedCount <= 0 ||
(Number.isFinite(knownTotal) && knownTotal > 0 && state.offset >= knownTotal)
) {
state.done = true;
deps.stopObserver(state);
}
} finally {
state.loading = false;
}
}
export function getSortValue(asset: any, sortKey: any) {
switch (sortKey) {
case "mtime_desc":
// Negate so that larger mtime (newer) sorts first in ascending numeric order.
return -(Number(asset?.mtime) || 0);
case "mtime_asc":
return Number(asset?.mtime) || 0;
case "name_asc":
case "name_desc":
return String(asset?.filename || "").toLowerCase();
default:
// Default to newest-first (descending mtime) via negation.
return -(Number(asset?.mtime) || 0);
}
}
export function compareAssets(a: any, b: any, sortKey: any) {
if (sortKey === "name_desc") {
// Reverse the natural string order for descending name sort.
const av = String(a?.filename || "").toLowerCase();
const bv = String(b?.filename || "").toLowerCase();
if (av > bv) return -1;
if (av < bv) return 1;
return 0;
}
// For mtime sorts, getSortValue already negates desc values,
// so a plain ascending compare yields the correct order.
const av = getSortValue(a, sortKey);
const bv = getSortValue(b, sortKey);
if (av < bv) return -1;
if (av > bv) return 1;
return 0;
}
export function shouldInsertBefore(assetValue: any, cardValue: any, sortKey: any) {
if (sortKey === "name_asc") return String(assetValue) < String(cardValue);
if (sortKey === "name_desc") return String(assetValue) > String(cardValue);
return Number(assetValue) < Number(cardValue);
}
export function findInsertPosition(array: any, asset: any, sortKey: any) {
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;
}
export function findAssetElement(gridContainer: any, assetId: any) {
const escaped = _safeEscape(String(assetId));
return gridContainer.querySelector(`[data-mjr-asset-id="${escaped}"]`);
}
function _safeEscape(value: any) {
try {
return CSS?.escape
? CSS.escape(value)
: value.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
} catch {
return value.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
}
}
export function getUpsertBatchState(gridContainer: any, upsertState: any) {
let s = upsertState.get(gridContainer);
if (!s) {
s = { pending: new Map<string, any>(), timer: null, flushing: false };
upsertState.set(gridContainer, s);
}
return s;
}
function _normalizeAssetIdentityPart(value: any) {
return String(value ?? "")
.trim()
.toLowerCase();
}
function _isLivePlaceholderAsset(asset: any) {
return (
asset?._mjrLivePlaceholder === true ||
asset?.is_live_placeholder === true ||
String(asset?.id || "")
.trim()
.toLowerCase()
.startsWith("live:")
);
}
function _clearLivePlaceholderState(asset: any) {
if (!asset || typeof asset !== "object") return asset;
try {
delete asset._mjrLivePlaceholder;
delete asset._mjrLiveStatus;
delete asset._mjrLiveLabel;
delete asset.is_live_placeholder;
} catch (e) {
console.debug?.(e);
}
return asset;
}
function _getAssetIdentityKey(asset: any) {
if (!asset || typeof asset !== "object") return "";
const type = _normalizeAssetIdentityPart(asset?.type || asset?.source || "output");
const rootId = _normalizeAssetIdentityPart(asset?.root_id || asset?.custom_root_id || "");
const subfolder = _normalizeAssetIdentityPart(asset?.subfolder || "");
const filename = _normalizeAssetIdentityPart(asset?.filename || "");
if (filename) return `${type}|${rootId}|${subfolder}|${filename}`;
const filepath = _normalizeAssetIdentityPart(
asset?.filepath || asset?.path || asset?.fullpath || asset?.full_path || "",
);
if (!filepath) return "";
return `${type}|${rootId}|path|${filepath}`;
}
function _findExistingAssetIndex(state: any, assetId: any, candidateAsset: any) {
const list = Array.isArray(state?.assets) ? state.assets : [];
if (assetId) {
const exactIndex = list.findIndex((asset: any) => String(asset?.id || "") === assetId);
if (exactIndex > -1) return exactIndex;
}
const identityKey = _getAssetIdentityKey(candidateAsset);
if (!identityKey) return -1;
return list.findIndex((asset: any) => _getAssetIdentityKey(asset) === identityKey);
}
function _shouldKeepExistingAssetOnFilterMismatch(incomingAsset: any, existingAsset: any) {
if (!existingAsset || typeof existingAsset !== "object") return false;
if (!incomingAsset || typeof incomingAsset !== "object") return false;
if (_isLivePlaceholderAsset(existingAsset)) return true;
const protectedFields = [
"filename",
"filepath",
"path",
"fullpath",
"full_path",
"subfolder",
"type",
"source",
"root_id",
"custom_root_id",
"kind",
"rating",
"workflow_type",
"workflowType",
"date_exact",
"date",
];
return !protectedFields.some((field) => Object.prototype.hasOwnProperty.call(incomingAsset, field));
}
function dedupeAssetsByKey(state: any, deps: any) {
const seenIds = new Set();
const seenKeys = new Set();
const filenamePrimary = new Map();
const deduped: any[] = [];
for (const asset of Array.isArray(state?.assets) ? state.assets : []) {
const assetId = asset?.id != null ? String(asset.id) : "";
const key = deps.assetKey(asset);
const filenameKey = getFilenameKey(asset?.filename);
if (assetId && seenIds.has(assetId)) continue;
if (key && seenKeys.has(key)) continue;
if (filenameKey) {
const primary = filenamePrimary.get(filenameKey);
if (primary) {
const prevMembers = Array.isArray(primary._mjrDupMembers) ? primary._mjrDupMembers : [primary];
const prevIds = new Set(prevMembers.map((a: any) => String(a?.id || "")));
const merged = [
...prevMembers,
...[asset].filter((a) => !prevIds.has(String(a?.id || ""))),
];
primary._mjrDupStack = true;
primary._mjrDupMembers = merged;
primary._mjrDupCount = merged.length;
primary._mjrNameCollision = false;
delete primary._mjrNameCollisionCount;
delete primary._mjrNameCollisionPaths;
asset._mjrNameCollision = false;
delete asset._mjrNameCollisionCount;
delete asset._mjrNameCollisionPaths;
asset._mjrDupStack = false;
asset._mjrDupMembers = null;
asset._mjrDupCount = 0;
continue;
}
}
if (assetId) seenIds.add(assetId);
if (key) seenKeys.add(key);
if (filenameKey) filenamePrimary.set(filenameKey, asset);
asset._mjrNameCollision = false;
delete asset._mjrNameCollisionCount;
delete asset._mjrNameCollisionPaths;
deduped.push(asset);
}
state.assets = deduped;
syncAssetCollectionState(state, { assetKey: deps.assetKey });
}
/**
* Flush all pending upsert operations into the grid.
*
* Concurrency guard: `batchState.flushing` prevents re-entrant flushes.
* Items arriving during a flush are accumulated in `batchState.pending` and
* a follow-up flush is scheduled in the `finally` block (BUG-01).
*/
export function flushUpsertBatch(gridContainer: any, deps: any) {
const batchState = deps.upsertState.get(gridContainer) as any;
if (!batchState || batchState.pending.size === 0 || batchState.flushing) return;
batchState.flushing = true;
if (batchState.timer) {
clearTimeout(batchState.timer);
batchState.timer = null;
}
// Snapshot the current batch so items arriving during the flush are not lost (BUG-01).
const snapshot = new Map(batchState.pending) as Map<string, any>;
for (const key of snapshot.keys()) batchState.pending.delete(key);
const state = deps.getOrCreateState(gridContainer);
const vg = deps.ensureVirtualGrid(gridContainer, state);
try {
let modified = false;
for (const [assetId, asset] of snapshot.entries()) {
const incomingIsPlaceholder = _isLivePlaceholderAsset(asset);
state.assetKeyFn = deps.assetKey;
const siblingCheck = shouldHideSiblingAsset(asset, state, deps.loadMajoorSettings);
if (Array.isArray(siblingCheck?.removed) && siblingCheck.removed.length) {
const removedSet = new Set(siblingCheck.removed);
state.assets = state.assets.filter((item: any) => {
const keep = !removedSet.has(item);
if (!keep) {
unregisterHiddenSibling(state, item, state);
}
return keep;
});
try {
state.hiddenPngSiblings =
(Number(state.hiddenPngSiblings || 0) || 0) + siblingCheck.removed.length;
} catch (e) {
console.debug?.(e);
}
modified = true;
}
if (siblingCheck?.hidden) {
try {
state.hiddenPngSiblings = (Number(state.hiddenPngSiblings || 0) || 0) + 1;
} catch (e) {
console.debug?.(e);
}
continue;
}
const existingIndex = state.assets.findIndex((a: any) => String(a.id) === assetId);
const existingAsset = existingIndex > -1 ? state.assets[existingIndex] : null;
const candidateAsset = existingAsset ? { ...existingAsset, ...asset } : asset;
const resolvedExistingIndex =
existingIndex > -1
? existingIndex
: _findExistingAssetIndex(state, assetId, candidateAsset);
const resolvedExistingAsset =
resolvedExistingIndex > -1 ? state.assets[resolvedExistingIndex] : null;
const mergedCandidate = resolvedExistingAsset
? { ...resolvedExistingAsset, ...asset }
: candidateAsset;
const key = deps.assetKey(candidateAsset);
const matchesFilters = _assetMatchesActiveFilters(gridContainer, mergedCandidate);
if (!matchesFilters) {
if (resolvedExistingIndex > -1) {
if (_shouldKeepExistingAssetOnFilterMismatch(asset, resolvedExistingAsset)) {
Object.assign(resolvedExistingAsset, asset);
if (!incomingIsPlaceholder) {
_clearLivePlaceholderState(resolvedExistingAsset);
}
state.assets[resolvedExistingIndex] = { ...resolvedExistingAsset };
modified = true;
continue;
}
const [removedAsset] = state.assets.splice(resolvedExistingIndex, 1);
unregisterHiddenSibling(state, removedAsset, state);
modified = true;
}
continue;
}
if (resolvedExistingIndex > -1) {
const previousKey = deps.assetKey(resolvedExistingAsset);
Object.assign(resolvedExistingAsset, asset);
if (!incomingIsPlaceholder) {
_clearLivePlaceholderState(resolvedExistingAsset);
}
const mergedAsset = { ...resolvedExistingAsset };
const nextKey = deps.assetKey(mergedAsset);
state.assets[resolvedExistingIndex] = mergedAsset;
if (previousKey && previousKey !== nextKey) {
state.seenKeys?.delete?.(previousKey);
if (nextKey) state.seenKeys?.add?.(nextKey);
}
modified = true;
} else {
const alreadySeen =
state.seenKeys.has(key) ||
(asset.id != null && state.assetIdSet?.has?.(assetId));
if (!alreadySeen) {
const sortKey = readGridQueryFromDataset(gridContainer?.dataset || {}).sort || "mtime_desc";
const assetToInsert = incomingIsPlaceholder
? candidateAsset
: _clearLivePlaceholderState({ ...candidateAsset });
const insertPos = findInsertPosition(state.assets, assetToInsert, sortKey);
state.seenKeys.add(key);
if (asset.id != null) state.assetIdSet?.add?.(assetId);
state.assets.splice(insertPos, 0, assetToInsert); // findInsertPosition never returns -1
modified = true;
}
}
}
if (modified && vg) {
dedupeAssetsByKey(state, deps);
try {
gridContainer.dataset.mjrHiddenPngSiblings = String(
Number(state.hiddenPngSiblings || 0) || 0,
);
} catch (e) {
console.debug?.(e);
}
vg.setItems(state.assets);
}
} finally {
batchState.flushing = false;
// Items may have arrived during the flush — reschedule if needed (BUG-01).
// Use a short delay (1 frame) instead of the full debounce so rapid
// generation events chain quickly instead of waiting 200 ms between
// each visible card insertion.
if (batchState.pending.size > 0 && !batchState.timer) {
const followUpDelay = Math.min(16, deps.debounceMs);
batchState.timer = setTimeout(() => {
batchState.timer = null;
flushUpsertBatch(gridContainer, deps);
}, followUpDelay);
}
}
}
export function upsertAsset(gridContainer: any, asset: any, deps: any) {
if (!asset || !asset.id) return false;
const state = deps.getOrCreateState(gridContainer);
const assetId = String(asset.id);
const vg = deps.ensureVirtualGrid(gridContainer, state);
if (!vg) return false;
const batchState = getUpsertBatchState(gridContainer, deps.upsertState);
batchState.pending.set(assetId, asset);
if (batchState.pending.size >= deps.maxBatchSize) {
flushUpsertBatch(gridContainer, deps);
} else if (!batchState.timer && !batchState.flushing) {
batchState.timer = setTimeout(() => {
batchState.timer = null;
flushUpsertBatch(gridContainer, deps);
}, deps.debounceMs);
}
return true;
}