- Implemented caching for tool availability and versions. - Added functions to check for ExifTool and FFprobe availability. - Included version satisfaction checks against minimum required versions. - Created utility functions for parsing boolean and float values from environment variables. - Introduced shared utilities for error message sanitization and logging with emoji indicators. - Established a Result pattern for error handling without exceptions. - Added scan throttling helpers to manage background scans efficiently. - Implemented time utilities for timestamps and performance measurement. - Defined shared types, enums, and constants for consistent error handling and file classification. - Created version management utilities to retrieve version information from pyproject.toml. - Developed installation script for runtime dependencies. - Added tests for ExifTool batch processing and various configuration scenarios. - Implemented tests for version route aliases and runtime output root configuration.
590 lines
22 KiB
JavaScript
590 lines
22 KiB
JavaScript
/**
|
|
* VirtualGrid
|
|
* @description
|
|
* High-performance virtualized grid renderer.
|
|
* Handles thousands of items by only rendering the visible viewport + buffer rows.
|
|
* Supports absolute positioning efficient updates.
|
|
*/
|
|
export class VirtualGrid {
|
|
/**
|
|
* @param {HTMLElement} container The container to render items into (wrapper)
|
|
* @param {HTMLElement|Window} scrollElement The element that scrolls (usually parent or window)
|
|
* @param {Object} options Configuration object
|
|
* @param {function(object, number): HTMLElement} options.createItem Callback to create a DOM node for an item
|
|
* @param {function(object, HTMLElement): void} [options.onItemRendered] Called after creating a new DOM node
|
|
* @param {function(HTMLElement): void} [options.onItemRecycled] Called before removing a DOM node (cleanup)
|
|
* @param {function(object, HTMLElement): void} [options.onItemUpdated] Called when reusing a DOM node with updated data
|
|
* @param {number} [options.minItemWidth=120] Minimum width in pixels
|
|
* @param {number} [options.gap=10] Gap between items in pixels
|
|
* @param {number} [options.bufferRows=2] Rows to render above/below viewport
|
|
*/
|
|
constructor(container, scrollElement, options = {}) {
|
|
/** @type {HTMLElement} */
|
|
this.container = container;
|
|
|
|
/** @type {HTMLElement|Window} */
|
|
this.scrollElement = scrollElement;
|
|
|
|
/** @type {Required<typeof options>} */
|
|
this.options = {
|
|
minItemWidth: options.minItemWidth || 120,
|
|
gap: options.gap || 10,
|
|
bufferRows: options.bufferRows || 2,
|
|
createItem: options.createItem || (() => document.createElement("div")),
|
|
onItemRendered: options.onItemRendered || (() => {}),
|
|
onItemRecycled: options.onItemRecycled || (() => {}),
|
|
onItemUpdated: options.onItemUpdated || (() => {}),
|
|
};
|
|
|
|
/** @type {any[]} List of data items to render */
|
|
this.items = [];
|
|
|
|
this.visibleRange = { start: 0, end: 0 };
|
|
|
|
/** @type {Map<number, HTMLElement>} Map of index -> rendered wrapper element */
|
|
this.renderedItems = new Map();
|
|
|
|
// Metrics
|
|
this.columnCount = 1;
|
|
this.itemWidth = this.options.minItemWidth;
|
|
this.rowHeight = 200; // Initial guess
|
|
this.metaHeight = 0; // Height of non-image part
|
|
this.measured = false;
|
|
|
|
/** @type {number[]} Samples for dynamic height calculation */
|
|
this.measureSamples = [];
|
|
|
|
this.renderPending = false;
|
|
this.rafId = 0;
|
|
this.lastWidth = 0;
|
|
this._resizeDebounce = 0;
|
|
this._lastLayoutWidth = 0;
|
|
this._lastLayoutHeight = 0;
|
|
this._layoutGuardThreshold = 12;
|
|
this._itemsVersion = 0;
|
|
this._lastLayoutItemsVersion = -1;
|
|
|
|
// Observer for container width changes
|
|
const scheduleResize = (width) => {
|
|
if (this._resizeDebounce) {
|
|
clearTimeout(this._resizeDebounce);
|
|
}
|
|
this._resizeDebounce = window.setTimeout(() => {
|
|
this._resizeDebounce = 0;
|
|
if (width > 0) {
|
|
this.lastWidth = width;
|
|
this.onResize();
|
|
}
|
|
}, 100);
|
|
};
|
|
|
|
this.resizeObserver = new ResizeObserver((entries) => {
|
|
// Use the container's actual content width (excludes border/scrollbar of itself)
|
|
// entry.contentRect.width is reliable for the observed element
|
|
for (const entry of entries) {
|
|
if (entry.target === this.container) {
|
|
const width = entry.contentRect.width;
|
|
if (width > 0 && Math.abs(width - this.lastWidth) > 20) {
|
|
scheduleResize(width);
|
|
}
|
|
break; // Only care about container
|
|
}
|
|
}
|
|
});
|
|
// Always observe container for correct width relative to parent padding/scrollbars
|
|
this.resizeObserver.observe(this.container);
|
|
|
|
this.onScrollBound = this.onScroll.bind(this);
|
|
|
|
// Dedicated scroll-container mode: normally scrollElement is an HTMLElement.
|
|
// Keep a safety fallback for legacy configurations.
|
|
this.scrollTarget = (this.scrollElement === document.body || this.scrollElement === document.documentElement)
|
|
? window
|
|
: this.scrollElement;
|
|
|
|
if (this.scrollTarget) {
|
|
this.scrollTarget.addEventListener("scroll", this.onScrollBound, { passive: true });
|
|
}
|
|
|
|
// Style the container for absolute positioning
|
|
this.container.style.position = "relative";
|
|
this.container.style.display = "block"; // Override 'grid'
|
|
// Use overflow: visible so IntersectionObserver can properly detect sentinel
|
|
// Parent wrapper (gridWrapper) handles scrolling and clipping with overflow: auto
|
|
this.container.style.overflow = "visible";
|
|
this.container.style.width = "100%";
|
|
}
|
|
|
|
_debugEnabled() {
|
|
try {
|
|
return !!globalThis?._mjrDebugGrid;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
_warn(...args) {
|
|
try {
|
|
if (!this._debugEnabled()) return;
|
|
console.warn("[Majoor][VirtualGrid]", ...args);
|
|
} catch {}
|
|
}
|
|
|
|
/**
|
|
* Update the list of items to display. Triggers a relayout.
|
|
* @param {any[]} items New list of data items (assets)
|
|
* @param {boolean} [force=false] Force re-creation of DOM elements (e.g. settings change)
|
|
*/
|
|
setItems(items, force = false) {
|
|
// Fix: Cancel pending scroll render to prevent race conditions
|
|
if (this.renderPending && this.rafId) {
|
|
cancelAnimationFrame(this.rafId);
|
|
this.renderPending = false;
|
|
}
|
|
|
|
if (force) {
|
|
// Recycle all currently rendered items
|
|
for (const [, el] of this.renderedItems) {
|
|
// Try to clean up
|
|
const card = this._getCard(el);
|
|
if (card) {
|
|
try { this.options.onItemRecycled(card); } catch {}
|
|
}
|
|
el.remove();
|
|
}
|
|
this.renderedItems.clear();
|
|
// Reset measurement state in case layout settings changed (e.g. badges affecting height)
|
|
this.measured = false;
|
|
this.measureSamples = [];
|
|
this._lastLayoutWidth = 0;
|
|
this._lastLayoutHeight = 0;
|
|
}
|
|
|
|
this.items = items || [];
|
|
this._itemsVersion += 1;
|
|
this.updateLayout(force);
|
|
}
|
|
|
|
/**
|
|
* Update configuration dynamicallly (e.g. settings change)
|
|
* @param {Object} conf
|
|
* @param {number} [conf.minItemWidth]
|
|
* @param {number} [conf.gap]
|
|
*/
|
|
updateConfig({ minItemWidth, gap }) {
|
|
let changed = false;
|
|
if (minItemWidth != null && minItemWidth !== this.options.minItemWidth) {
|
|
this.options.minItemWidth = Number(minItemWidth);
|
|
changed = true;
|
|
}
|
|
if (gap != null && gap !== this.options.gap) {
|
|
this.options.gap = Number(gap);
|
|
changed = true;
|
|
}
|
|
if (changed) {
|
|
// Force re-measure and layout
|
|
this.onResize();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up observers and listeners when grid is destroyed.
|
|
*/
|
|
dispose() {
|
|
this.resizeObserver.disconnect();
|
|
if (this.scrollTarget) {
|
|
this.scrollTarget.removeEventListener("scroll", this.onScrollBound);
|
|
}
|
|
this.container.innerHTML = "";
|
|
this.renderedItems.clear();
|
|
}
|
|
|
|
getRenderedCards() {
|
|
const out = [];
|
|
try {
|
|
for (const [, el] of this.renderedItems) {
|
|
const card = this._getCard(el);
|
|
if (card) out.push(card);
|
|
}
|
|
} catch {}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Handle resize: re-calculate columns and render.
|
|
* @private
|
|
*/
|
|
onResize() {
|
|
this.updateMetrics();
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* Handle scroll: request render frame.
|
|
* @private
|
|
*/
|
|
onScroll() {
|
|
if (!this.renderPending) {
|
|
this.renderPending = true;
|
|
this.rafId = requestAnimationFrame(() => {
|
|
this.render();
|
|
this.renderPending = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log debug metrics.
|
|
*/
|
|
debug() {
|
|
console.log("[VirtualGrid Debug]", {
|
|
scrollTop: this.scrollElement?.scrollTop,
|
|
scrollHeight: this.scrollElement?.scrollHeight,
|
|
clientHeight: this.scrollElement?.clientHeight,
|
|
containerHeight: this.container?.style?.height,
|
|
items: this.items?.length,
|
|
rendered: this.renderedItems?.size,
|
|
scrollTarget: this.scrollTarget
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Calculate column count and dimensions based on container width.
|
|
* @private
|
|
*/
|
|
updateMetrics() {
|
|
// Use container's clientWidth as the source of truth.
|
|
// This automatically accounts for parent padding and parent scrollbars.
|
|
let containerWidth = this.container.clientWidth;
|
|
|
|
// Fallback or sanity check
|
|
if (!containerWidth) return;
|
|
|
|
const gap = this.options.gap;
|
|
const minW = this.options.minItemWidth;
|
|
|
|
// Derive effective usable width from style and scrollbar metrics (no hardcoded magic gap).
|
|
let padL = 0;
|
|
let padR = 0;
|
|
try {
|
|
const cs = window.getComputedStyle(this.container);
|
|
padL = Number.parseFloat(cs?.paddingLeft || "0") || 0;
|
|
padR = Number.parseFloat(cs?.paddingRight || "0") || 0;
|
|
} catch {}
|
|
let scrollbar = 0;
|
|
try {
|
|
const se = this.scrollElement;
|
|
if (se && se !== window && se instanceof HTMLElement) {
|
|
scrollbar = Math.max(0, Number(se.offsetWidth || 0) - Number(se.clientWidth || 0));
|
|
}
|
|
} catch {}
|
|
const safety = 2;
|
|
let availWidth = Math.max(1, Math.floor(containerWidth - padL - padR - scrollbar - safety));
|
|
|
|
this.columnCount = Math.max(1, Math.floor((availWidth + gap) / (minW + gap)));
|
|
|
|
// Calculate exact item width to fill space (like auto-fill)
|
|
this.itemWidth = Math.floor((availWidth - (this.columnCount - 1) * gap) / this.columnCount);
|
|
|
|
// Update row height estimate if we have a measured meta height
|
|
if (this.measured && this.metaHeight > 0) {
|
|
// Assume 1:1 aspect ratio for image part + meta height
|
|
this.rowHeight = this.itemWidth + this.metaHeight;
|
|
}
|
|
|
|
this.updateLayout();
|
|
}
|
|
|
|
/**
|
|
* Updates container height and triggers render.
|
|
* @private
|
|
*/
|
|
updateLayout(force = false) {
|
|
if (!this.items.length) {
|
|
this.container.style.height = "0px";
|
|
return;
|
|
}
|
|
|
|
const gap = this.options.gap;
|
|
const totalRows = Math.ceil(this.items.length / this.columnCount);
|
|
const totalHeight = totalRows * this.rowHeight + (totalRows - 1) * gap;
|
|
|
|
const containerWidth = this.container.clientWidth || this.container.offsetWidth || this.lastWidth || 0;
|
|
const itemsChanged = this._lastLayoutItemsVersion !== this._itemsVersion;
|
|
if (!force && !itemsChanged && containerWidth > 0) {
|
|
const widthDelta = Math.abs(containerWidth - this._lastLayoutWidth);
|
|
const heightDelta = Math.abs(totalHeight - this._lastLayoutHeight);
|
|
const threshold = this._layoutGuardThreshold;
|
|
if (widthDelta <= threshold && heightDelta <= threshold) {
|
|
// Skip heavy recalculation when size didn't change significantly.
|
|
return;
|
|
}
|
|
}
|
|
|
|
this._lastLayoutWidth = containerWidth;
|
|
this._lastLayoutHeight = totalHeight;
|
|
this._lastLayoutItemsVersion = this._itemsVersion;
|
|
this.container.style.height = `${totalHeight}px`;
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* Detects dynamic height of items by sampling the first few rendered elements.
|
|
* @param {HTMLElement} element The card element to measure
|
|
* @private
|
|
*/
|
|
measureItem(element) {
|
|
if (this.measured) return;
|
|
// Try to find the thumb and measure aspect ratio / meta height
|
|
// This is a heuristic.
|
|
try {
|
|
const rect = element.getBoundingClientRect();
|
|
if (rect.height > 0) {
|
|
const fullHeight = rect.height;
|
|
const meta = fullHeight - this.itemWidth;
|
|
|
|
this.measureSamples.push(meta);
|
|
|
|
// Sample at least 3 items to avoid outliers
|
|
if (this.measureSamples.length >= 3) {
|
|
// Calculate median meta height
|
|
const sorted = [...this.measureSamples].sort((a,b) => a-b);
|
|
const mid = Math.floor(sorted.length / 2);
|
|
this.metaHeight = sorted[mid];
|
|
if (this.metaHeight < 20) this.metaHeight = 50;
|
|
|
|
this.rowHeight = this.itemWidth + this.metaHeight;
|
|
this.measured = true;
|
|
this.updateLayout(true);
|
|
} else if (this.measureSamples.length === 1) {
|
|
// Initial update to ensure grid has some height
|
|
let tempMeta = meta;
|
|
if (tempMeta < 20) tempMeta = 50;
|
|
this.rowHeight = this.itemWidth + tempMeta;
|
|
this.updateLayout(true);
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
/**
|
|
* Best-effort re-measure after async content (badges/metadata) is applied.
|
|
* This avoids layout drift when card height changes after initial render.
|
|
*/
|
|
scheduleRemeasure() {
|
|
if (this.measured) return;
|
|
try {
|
|
requestAnimationFrame(() => {
|
|
try { this.updateMetrics(); } catch {}
|
|
});
|
|
setTimeout(() => {
|
|
try { this.updateMetrics(); } catch {}
|
|
}, 60);
|
|
} catch {}
|
|
}
|
|
|
|
/**
|
|
* Core render loop. Calculates visible range and creates/removes DOM elements.
|
|
*/
|
|
render() {
|
|
if (!this.items.length || this.columnCount < 1) return;
|
|
|
|
// Cache layout rects to avoid repeated DOM reads
|
|
let scrollTop = 0;
|
|
let viewportHeight = 0;
|
|
const safeRect = (el) => {
|
|
try {
|
|
return el?.getBoundingClientRect?.() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
const containerRect = safeRect(this.container);
|
|
const rootRect = this.scrollTarget === window ? null : safeRect(this.scrollElement);
|
|
|
|
if (this.scrollTarget === window) {
|
|
viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
|
if (containerRect) {
|
|
scrollTop = Math.max(0, -containerRect.top);
|
|
} else {
|
|
scrollTop = window.scrollY || window.pageYOffset || document.documentElement.scrollTop || 0;
|
|
}
|
|
} else {
|
|
viewportHeight = this.scrollElement?.clientHeight || 0;
|
|
if (containerRect && rootRect) {
|
|
scrollTop = Math.max(0, -(containerRect.top - rootRect.top));
|
|
} else {
|
|
scrollTop = this.scrollElement?.scrollTop || 0;
|
|
}
|
|
}
|
|
|
|
// Sanity check
|
|
if (viewportHeight <= 0) viewportHeight = 600;
|
|
|
|
const gap = this.options.gap;
|
|
const rowTotal = this.rowHeight + gap;
|
|
|
|
// Calculate visible row range
|
|
const startRow = Math.floor(scrollTop / rowTotal);
|
|
const visibleRows = Math.ceil(viewportHeight / rowTotal);
|
|
|
|
const buffer = this.options.bufferRows;
|
|
|
|
// Determine index range
|
|
const firstRow = Math.max(0, startRow - buffer);
|
|
const lastRow = startRow + visibleRows + buffer;
|
|
|
|
const startIndex = firstRow * this.columnCount;
|
|
const endIndex = Math.min(this.items.length, (lastRow + 1) * this.columnCount);
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
let createdCount = 0;
|
|
// Track used keys (indices) to cleanup later
|
|
const desiredIndices = new Set();
|
|
|
|
// Render loop
|
|
for (let i = startIndex; i < endIndex; i++) {
|
|
desiredIndices.add(i);
|
|
|
|
let el = this.renderedItems.get(i);
|
|
const item = this.items[i];
|
|
|
|
// Check if element matches data (handle list mutations)
|
|
// Fix: Use ID check to avoid re-creating DOM when object ref changes but item is same
|
|
const isSameItem = el && (
|
|
el._virtualItem === item ||
|
|
(item.id != null && el._virtualItem?.id === item.id)
|
|
);
|
|
|
|
if (el && !isSameItem) {
|
|
// Fix: onItemRecycled should receive the card context, not the wrapper.
|
|
const card = this._getCard(el);
|
|
if (card) this.options.onItemRecycled(card);
|
|
|
|
el.remove();
|
|
this.renderedItems.delete(i);
|
|
el = undefined;
|
|
} else if (el && isSameItem && el._virtualItem !== item) {
|
|
// Update reference but keep DOM
|
|
el._virtualItem = item;
|
|
this.options.onItemUpdated(item, this._getCard(el));
|
|
}
|
|
|
|
if (!el) {
|
|
// Create wrapper for positioning
|
|
el = document.createElement("div");
|
|
el.className = "mjr-virtual-cell";
|
|
el.style.position = "absolute";
|
|
// CRITICAL: Set width BEFORE appending/measuring so content flows correctly
|
|
el.style.width = `${this.itemWidth}px`;
|
|
|
|
let card;
|
|
try {
|
|
card = this.options.createItem(item, i);
|
|
} catch (e) {
|
|
this._warn("createItem failed", e);
|
|
card = document.createElement("div");
|
|
}
|
|
|
|
// Add to wrapper
|
|
el.appendChild(card);
|
|
|
|
el._virtualItem = item;
|
|
|
|
fragment.appendChild(el);
|
|
createdCount += 1;
|
|
this.renderedItems.set(i, el);
|
|
|
|
// IMPORTANT: The card element inside is what we measure and update
|
|
// But VirtualGrid manages the 'el' wrapper position.
|
|
|
|
if (!this.measured) {
|
|
this.measureItem(card);
|
|
}
|
|
|
|
this.options.onItemRendered(item, card);
|
|
}
|
|
|
|
// Update position for everyone in range (in case resize happened)
|
|
if (el) {
|
|
const row = Math.floor(i / this.columnCount);
|
|
const col = i % this.columnCount;
|
|
const x = col * (this.itemWidth + gap);
|
|
const y = row * (this.rowHeight + gap);
|
|
|
|
// CRITICAL FIX: Use left/top instead of transform to avoid being overwritten by
|
|
// selection state CSS which applies transform: scale().
|
|
el.style.transform = ""; // clear any legacy transform
|
|
el.style.left = `${x}px`;
|
|
el.style.top = `${y}px`;
|
|
|
|
el.style.width = `${this.itemWidth}px`;
|
|
el.style.height = `${this.rowHeight}px`;
|
|
|
|
// Force child card to fill wrapper?
|
|
// The wrapper is absolute. The card inside should probably be width 100%.
|
|
const card = el.firstElementChild;
|
|
if (card) {
|
|
card.style.width = "100%";
|
|
card.style.height = "100%";
|
|
}
|
|
}
|
|
}
|
|
|
|
if (createdCount > 0) {
|
|
this.container.appendChild(fragment);
|
|
}
|
|
|
|
// Cleanup
|
|
for (const [i, el] of this.renderedItems) {
|
|
if (!desiredIndices.has(i)) {
|
|
const card = el.firstElementChild;
|
|
if (card) this.options.onItemRecycled(card);
|
|
el.remove();
|
|
this.renderedItems.delete(i);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scroll the scroll container so that a given item index is visible.
|
|
* Works even when the item is not yet rendered in the DOM (virtualized).
|
|
* @param {number} index Index of the item in the items array
|
|
*/
|
|
scrollToIndex(index) {
|
|
if (index < 0 || index >= this.items.length) return;
|
|
|
|
const gap = this.options.gap;
|
|
const row = Math.floor(index / this.columnCount);
|
|
const itemTop = row * (this.rowHeight + gap);
|
|
const itemBottom = itemTop + this.rowHeight;
|
|
|
|
if (this.scrollTarget === window) {
|
|
const containerRect = this.container.getBoundingClientRect();
|
|
const viewportH = window.innerHeight || document.documentElement.clientHeight;
|
|
const absTop = containerRect.top + (window.scrollY || 0) + itemTop;
|
|
const absBottom = absTop + this.rowHeight;
|
|
const scrollY = window.scrollY || window.pageYOffset || 0;
|
|
|
|
if (absBottom > scrollY + viewportH) {
|
|
window.scrollTo({ top: absBottom - viewportH, behavior: "auto" });
|
|
} else if (absTop < scrollY) {
|
|
window.scrollTo({ top: absTop, behavior: "auto" });
|
|
}
|
|
} else if (this.scrollElement) {
|
|
const el = this.scrollElement;
|
|
const viewportH = el.clientHeight;
|
|
|
|
if (itemBottom > el.scrollTop + viewportH) {
|
|
el.scrollTop = itemBottom - viewportH;
|
|
} else if (itemTop < el.scrollTop) {
|
|
el.scrollTop = itemTop;
|
|
}
|
|
}
|
|
}
|
|
|
|
_getCard(el) {
|
|
return el.firstElementChild || el;
|
|
}
|
|
}
|