Enhance mobile library loading: implement virtualization for improved performance and memory management on mobile devices

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-23 22:05:52 +02:00
parent f6755aad42
commit 52656c715e
2 changed files with 158 additions and 43 deletions
+10 -1
View File
@@ -1871,6 +1871,8 @@
function adaptNavbarByWidth() { function adaptNavbarByWidth() {
if (!navList) return; if (!navList) return;
const isMobileViewport = window.matchMedia('(max-width: 991.98px)').matches;
function isNavOverflowing(bufferPx) { function isNavOverflowing(bufferPx) {
const buffer = typeof bufferPx === 'number' ? bufferPx : 0; const buffer = typeof bufferPx === 'number' ? bufferPx : 0;
const collapseOverflow = navCollapse ? (navCollapse.scrollWidth > (navCollapse.clientWidth - buffer)) : false; const collapseOverflow = navCollapse ? (navCollapse.scrollWidth > (navCollapse.clientWidth - buffer)) : false;
@@ -1881,7 +1883,14 @@
applyCompactMode(); applyCompactMode();
if (window.innerWidth < 992) { // Desktop/tablet-large: keep complete navigation visible.
if (!isMobileViewport) {
restoreAllNavItems();
return;
}
// Mobile menu is collapsed: avoid hiding links preemptively.
if (navCollapse && !navCollapse.classList.contains('show')) {
restoreAllNavItems(); restoreAllNavItems();
return; return;
} }
+148 -42
View File
@@ -209,6 +209,7 @@
// View mode persistence // View mode persistence
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode'; const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
const MOBILE_LIBRARY_MAX_WIDTH = 900; const MOBILE_LIBRARY_MAX_WIDTH = 900;
const MOBILE_LIBRARY_MIN_ITEMS = 24;
function isMobileViewport() { function isMobileViewport() {
return window.matchMedia(`(max-width: ${MOBILE_LIBRARY_MAX_WIDTH}px)`).matches; return window.matchMedia(`(max-width: ${MOBILE_LIBRARY_MAX_WIDTH}px)`).matches;
@@ -219,8 +220,9 @@ function setViewMode(mode) {
const container = document.getElementById('itemsContainer'); const container = document.getElementById('itemsContainer');
const tableHeader = document.getElementById('tableHeader'); const tableHeader = document.getElementById('tableHeader');
const renderedItems = Array.from(container.querySelectorAll('.item-card')); const renderedItems = Array.from(container.querySelectorAll('.item-card'));
const detachedItems = Array.isArray(window.mobileDetachedLibraryItems) const virtualizationState = window.mobileLibraryVirtualizationState || null;
? window.mobileDetachedLibraryItems const detachedItems = virtualizationState && virtualizationState.active && Array.isArray(virtualizationState.items)
? virtualizationState.items.filter(item => !container.contains(item))
: []; : [];
const items = [...renderedItems, ...detachedItems.filter(item => !container.contains(item))]; const items = [...renderedItems, ...detachedItems.filter(item => !container.contains(item))];
@@ -247,33 +249,44 @@ function setViewMode(mode) {
function initMobileWindowedLibraryLoading() { function initMobileWindowedLibraryLoading() {
const container = document.getElementById('itemsContainer'); const container = document.getElementById('itemsContainer');
if (!container || !isMobileViewport()) { if (!container) {
return; return;
} }
const allItems = Array.from(container.querySelectorAll('.library-item')); if (!window.mobileLibraryVirtualizationState) {
if (allItems.length <= 24) { window.mobileLibraryVirtualizationState = {
return; active: false,
items: [],
topSpacer: null,
bottomSpacer: null,
averageItemHeight: Math.max(Math.round(window.innerHeight * 0.35), 230),
lastStart: -1,
lastEnd: -1,
framePending: false,
scrollListener: null,
resizeListener: null,
initialized: false
};
} }
const state = window.mobileLibraryVirtualizationState;
const topSpacer = document.createElement('div'); function restoreAllImages() {
topSpacer.className = 'mobile-window-spacer top'; state.items.forEach(item => {
const bottomSpacer = document.createElement('div'); const image = item.querySelector('img.item-image');
bottomSpacer.className = 'mobile-window-spacer bottom'; if (!image) {
return;
}
window.mobileDetachedLibraryItems = allItems; const originalSrc = image.dataset.mobileSrc || '';
allItems.forEach(item => item.remove()); if (!image.getAttribute('src') && originalSrc) {
container.appendChild(topSpacer); image.setAttribute('src', originalSrc);
container.appendChild(bottomSpacer); }
});
let averageItemHeight = Math.max(Math.round(window.innerHeight * 0.35), 230); }
let lastStart = -1;
let lastEnd = -1;
let framePending = false;
function updateImageMemory(startIndex, endIndex) { function updateImageMemory(startIndex, endIndex) {
const imageBuffer = 4; const imageBuffer = 4;
allItems.forEach((item, index) => { state.items.forEach((item, index) => {
const image = item.querySelector('img.item-image'); const image = item.querySelector('img.item-image');
if (!image) { if (!image) {
return; return;
@@ -281,9 +294,9 @@ function initMobileWindowedLibraryLoading() {
if (!image.dataset.mobileSrc) { if (!image.dataset.mobileSrc) {
image.dataset.mobileSrc = image.getAttribute('src') || ''; image.dataset.mobileSrc = image.getAttribute('src') || '';
image.loading = 'lazy';
image.decoding = 'async';
} }
image.loading = 'lazy';
image.decoding = 'async';
const shouldKeepLoaded = index >= startIndex - imageBuffer && index < endIndex + imageBuffer; const shouldKeepLoaded = index >= startIndex - imageBuffer && index < endIndex + imageBuffer;
if (shouldKeepLoaded) { if (shouldKeepLoaded) {
@@ -300,38 +313,42 @@ function initMobileWindowedLibraryLoading() {
} }
function renderVisibleWindow() { function renderVisibleWindow() {
if (!state.active || !state.topSpacer || !state.bottomSpacer) {
return;
}
const viewportHeight = window.innerHeight || 800; const viewportHeight = window.innerHeight || 800;
const scrollTop = window.scrollY || window.pageYOffset || 0; const scrollTop = window.scrollY || window.pageYOffset || 0;
const renderBuffer = viewportHeight * 1.5; const renderBuffer = viewportHeight * 1.5;
let startIndex = Math.max(0, Math.floor((scrollTop - renderBuffer) / averageItemHeight)); let startIndex = Math.max(0, Math.floor((scrollTop - renderBuffer) / state.averageItemHeight));
let endIndex = Math.min(allItems.length, Math.ceil((scrollTop + viewportHeight + renderBuffer) / averageItemHeight)); let endIndex = Math.min(state.items.length, Math.ceil((scrollTop + viewportHeight + renderBuffer) / state.averageItemHeight));
if (endIndex - startIndex < 14) { if (endIndex - startIndex < 14) {
endIndex = Math.min(allItems.length, startIndex + 14); endIndex = Math.min(state.items.length, startIndex + 14);
} }
if (startIndex === lastStart && endIndex === lastEnd) { if (startIndex === state.lastStart && endIndex === state.lastEnd) {
return; return;
} }
lastStart = startIndex; state.lastStart = startIndex;
lastEnd = endIndex; state.lastEnd = endIndex;
while (topSpacer.nextSibling && topSpacer.nextSibling !== bottomSpacer) { while (state.topSpacer.nextSibling && state.topSpacer.nextSibling !== state.bottomSpacer) {
topSpacer.nextSibling.remove(); state.topSpacer.nextSibling.remove();
} }
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (let index = startIndex; index < endIndex; index += 1) { for (let index = startIndex; index < endIndex; index += 1) {
fragment.appendChild(allItems[index]); fragment.appendChild(state.items[index]);
} }
container.insertBefore(fragment, bottomSpacer); container.insertBefore(fragment, state.bottomSpacer);
let measuredHeightSum = 0; let measuredHeightSum = 0;
let measuredCount = 0; let measuredCount = 0;
for (let index = startIndex; index < endIndex; index += 1) { for (let index = startIndex; index < endIndex; index += 1) {
const height = allItems[index].getBoundingClientRect().height; const height = state.items[index].getBoundingClientRect().height;
if (height > 0) { if (height > 0) {
measuredHeightSum += height; measuredHeightSum += height;
measuredCount += 1; measuredCount += 1;
@@ -340,28 +357,117 @@ function initMobileWindowedLibraryLoading() {
if (measuredCount > 0) { if (measuredCount > 0) {
const measuredAverage = measuredHeightSum / measuredCount; const measuredAverage = measuredHeightSum / measuredCount;
averageItemHeight = Math.round((averageItemHeight * 3 + measuredAverage) / 4); state.averageItemHeight = Math.round((state.averageItemHeight * 3 + measuredAverage) / 4);
} }
topSpacer.style.height = `${Math.max(0, startIndex * averageItemHeight)}px`; state.topSpacer.style.height = `${Math.max(0, startIndex * state.averageItemHeight)}px`;
bottomSpacer.style.height = `${Math.max(0, (allItems.length - endIndex) * averageItemHeight)}px`; state.bottomSpacer.style.height = `${Math.max(0, (state.items.length - endIndex) * state.averageItemHeight)}px`;
updateImageMemory(startIndex, endIndex); updateImageMemory(startIndex, endIndex);
} }
function scheduleWindowRender() { function scheduleWindowRender() {
if (framePending) { if (!state.active || state.framePending) {
return; return;
} }
framePending = true; state.framePending = true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
framePending = false; state.framePending = false;
renderVisibleWindow(); renderVisibleWindow();
}); });
} }
window.addEventListener('scroll', scheduleWindowRender, { passive: true }); function activateVirtualization() {
window.addEventListener('resize', scheduleWindowRender, { passive: true }); if (state.active) {
scheduleWindowRender(); return;
}
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
if (state.items.length <= MOBILE_LIBRARY_MIN_ITEMS) {
return;
}
state.topSpacer = document.createElement('div');
state.topSpacer.className = 'mobile-window-spacer top';
state.bottomSpacer = document.createElement('div');
state.bottomSpacer.className = 'mobile-window-spacer bottom';
state.items.forEach(item => item.remove());
container.appendChild(state.topSpacer);
container.appendChild(state.bottomSpacer);
state.lastStart = -1;
state.lastEnd = -1;
state.framePending = false;
state.active = true;
if (!state.scrollListener) {
state.scrollListener = () => scheduleWindowRender();
window.addEventListener('scroll', state.scrollListener, { passive: true });
}
scheduleWindowRender();
}
function deactivateVirtualization() {
if (!state.active) {
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
return;
}
if (state.scrollListener) {
window.removeEventListener('scroll', state.scrollListener);
state.scrollListener = null;
}
if (state.topSpacer && state.topSpacer.parentNode === container) {
state.topSpacer.remove();
}
if (state.bottomSpacer && state.bottomSpacer.parentNode === container) {
state.bottomSpacer.remove();
}
const fragment = document.createDocumentFragment();
state.items.forEach(item => fragment.appendChild(item));
container.appendChild(fragment);
restoreAllImages();
state.topSpacer = null;
state.bottomSpacer = null;
state.lastStart = -1;
state.lastEnd = -1;
state.framePending = false;
state.active = false;
const currentMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
setViewMode(currentMode);
}
function syncVirtualizationWithViewport() {
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
const shouldVirtualize = isMobileViewport() && state.items.length > MOBILE_LIBRARY_MIN_ITEMS;
if (shouldVirtualize) {
activateVirtualization();
scheduleWindowRender();
} else {
deactivateVirtualization();
}
}
if (!state.initialized) {
state.resizeListener = () => syncVirtualizationWithViewport();
window.addEventListener('resize', state.resizeListener, { passive: true });
state.initialized = true;
}
syncVirtualizationWithViewport();
} }
// Initialize view mode // Initialize view mode