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

301 lines
12 KiB
JavaScript

/** Infinite scroll / upsert helpers extracted from GridView_impl.js (P3-B-05). */
export async function fetchPage(
gridContainer,
query,
limit,
offset,
deps,
{ 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 resolutionCompare =
String(gridContainer?.dataset?.mjrFilterResolutionCompare || "gte") === "lte"
? "lte"
: "gte";
const minWidth = Number(gridContainer?.dataset?.mjrFilterMinWidth || 0) || 0;
const minHeight = Number(gridContainer?.dataset?.mjrFilterMinHeight || 0) || 0;
const maxWidth = Number(gridContainer?.dataset?.mjrFilterMaxWidth || 0) || 0;
const maxHeight = Number(gridContainer?.dataset?.mjrFilterMaxHeight || 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 groupStacks = String(gridContainer?.dataset?.mjrGroupStacks || "") === "1";
const requestedQuery = query && query.trim() ? query : "*";
const safeQuery = deps.sanitizeQuery(requestedQuery) || requestedQuery;
try {
const includeTotal = !(
String(scope || "").toLowerCase() === "output" && Number(offset ?? 0) > 0
);
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,
dateRange: dateRange || null,
dateExact: dateExact || null,
sort: sortKey,
includeTotal,
groupStacks,
});
const result = await deps.get(url, signal ? { signal } : undefined);
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;
return { ok: true, assets, total, count: serverCount, 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?.name || "") === "AbortError")
return { ok: false, aborted: true, error: "Aborted" };
} catch (e) {
console.debug?.(e);
}
return { ok: false, error: error.message };
}
}
export function emitAgendaStatus(dateExact, hasResults) {
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, state, deps) {
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 = 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;
deps.stopObserver(state);
return;
}
if (page.total != null) state.total = page.total;
const added = deps.appendAssets(gridContainer, page.assets || [], state);
state.offset += page.count || 0;
deps.gridDebug("loadNextPage:append", { added, offset: state.offset });
deps.maybeKeepPinnedToBottom(state, before);
if ((page.count || 0) === 0) {
state.done = true;
deps.stopObserver(state);
}
} finally {
state.loading = false;
}
}
export function getSortValue(asset, sortKey) {
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, b, sortKey) {
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, cardValue, sortKey) {
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, 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;
}
export function findAssetElement(gridContainer, assetId) {
const escaped = _safeEscape(String(assetId));
return gridContainer.querySelector(`[data-mjr-asset-id="${escaped}"]`);
}
function _safeEscape(value) {
try {
return CSS?.escape
? CSS.escape(value)
: value.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
} catch {
return value.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
}
}
export function getUpsertBatchState(gridContainer, upsertState) {
let s = upsertState.get(gridContainer);
if (!s) {
s = { pending: new Map(), timer: null, flushing: false };
upsertState.set(gridContainer, s);
}
return s;
}
/**
* 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, deps) {
const batchState = deps.upsertState.get(gridContainer);
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);
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 key = deps.assetKey(asset);
const existingIndex = state.assets.findIndex((a) => String(a.id) === assetId);
if (existingIndex > -1) {
const existingAsset = state.assets[existingIndex];
Object.assign(existingAsset, asset);
state.assets[existingIndex] = { ...existingAsset };
modified = true;
} else {
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);
state.assets.splice(insertPos, 0, asset); // findInsertPosition never returns -1
modified = true;
}
}
}
if (modified && vg) vg.setItems(state.assets);
} finally {
batchState.flushing = false;
// Items may have arrived during the flush — reschedule if needed (BUG-01).
if (batchState.pending.size > 0 && !batchState.timer) {
batchState.timer = setTimeout(() => {
batchState.timer = null;
flushUpsertBatch(gridContainer, deps);
}, deps.debounceMs);
}
}
}
export function upsertAsset(gridContainer, asset, deps) {
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;
}