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:
+10
-1
@@ -1871,6 +1871,8 @@
|
||||
function adaptNavbarByWidth() {
|
||||
if (!navList) return;
|
||||
|
||||
const isMobileViewport = window.matchMedia('(max-width: 991.98px)').matches;
|
||||
|
||||
function isNavOverflowing(bufferPx) {
|
||||
const buffer = typeof bufferPx === 'number' ? bufferPx : 0;
|
||||
const collapseOverflow = navCollapse ? (navCollapse.scrollWidth > (navCollapse.clientWidth - buffer)) : false;
|
||||
@@ -1881,7 +1883,14 @@
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
+148
-42
@@ -209,6 +209,7 @@
|
||||
// View mode persistence
|
||||
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
|
||||
const MOBILE_LIBRARY_MAX_WIDTH = 900;
|
||||
const MOBILE_LIBRARY_MIN_ITEMS = 24;
|
||||
|
||||
function isMobileViewport() {
|
||||
return window.matchMedia(`(max-width: ${MOBILE_LIBRARY_MAX_WIDTH}px)`).matches;
|
||||
@@ -219,8 +220,9 @@ function setViewMode(mode) {
|
||||
const container = document.getElementById('itemsContainer');
|
||||
const tableHeader = document.getElementById('tableHeader');
|
||||
const renderedItems = Array.from(container.querySelectorAll('.item-card'));
|
||||
const detachedItems = Array.isArray(window.mobileDetachedLibraryItems)
|
||||
? window.mobileDetachedLibraryItems
|
||||
const virtualizationState = window.mobileLibraryVirtualizationState || null;
|
||||
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))];
|
||||
|
||||
@@ -247,33 +249,44 @@ function setViewMode(mode) {
|
||||
|
||||
function initMobileWindowedLibraryLoading() {
|
||||
const container = document.getElementById('itemsContainer');
|
||||
if (!container || !isMobileViewport()) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allItems = Array.from(container.querySelectorAll('.library-item'));
|
||||
if (allItems.length <= 24) {
|
||||
return;
|
||||
if (!window.mobileLibraryVirtualizationState) {
|
||||
window.mobileLibraryVirtualizationState = {
|
||||
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');
|
||||
topSpacer.className = 'mobile-window-spacer top';
|
||||
const bottomSpacer = document.createElement('div');
|
||||
bottomSpacer.className = 'mobile-window-spacer bottom';
|
||||
function restoreAllImages() {
|
||||
state.items.forEach(item => {
|
||||
const image = item.querySelector('img.item-image');
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.mobileDetachedLibraryItems = allItems;
|
||||
allItems.forEach(item => item.remove());
|
||||
container.appendChild(topSpacer);
|
||||
container.appendChild(bottomSpacer);
|
||||
|
||||
let averageItemHeight = Math.max(Math.round(window.innerHeight * 0.35), 230);
|
||||
let lastStart = -1;
|
||||
let lastEnd = -1;
|
||||
let framePending = false;
|
||||
const originalSrc = image.dataset.mobileSrc || '';
|
||||
if (!image.getAttribute('src') && originalSrc) {
|
||||
image.setAttribute('src', originalSrc);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateImageMemory(startIndex, endIndex) {
|
||||
const imageBuffer = 4;
|
||||
allItems.forEach((item, index) => {
|
||||
state.items.forEach((item, index) => {
|
||||
const image = item.querySelector('img.item-image');
|
||||
if (!image) {
|
||||
return;
|
||||
@@ -281,9 +294,9 @@ function initMobileWindowedLibraryLoading() {
|
||||
|
||||
if (!image.dataset.mobileSrc) {
|
||||
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;
|
||||
if (shouldKeepLoaded) {
|
||||
@@ -300,38 +313,42 @@ function initMobileWindowedLibraryLoading() {
|
||||
}
|
||||
|
||||
function renderVisibleWindow() {
|
||||
if (!state.active || !state.topSpacer || !state.bottomSpacer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportHeight = window.innerHeight || 800;
|
||||
const scrollTop = window.scrollY || window.pageYOffset || 0;
|
||||
const renderBuffer = viewportHeight * 1.5;
|
||||
|
||||
let startIndex = Math.max(0, Math.floor((scrollTop - renderBuffer) / averageItemHeight));
|
||||
let endIndex = Math.min(allItems.length, Math.ceil((scrollTop + viewportHeight + renderBuffer) / averageItemHeight));
|
||||
let startIndex = Math.max(0, Math.floor((scrollTop - renderBuffer) / state.averageItemHeight));
|
||||
let endIndex = Math.min(state.items.length, Math.ceil((scrollTop + viewportHeight + renderBuffer) / state.averageItemHeight));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
lastStart = startIndex;
|
||||
lastEnd = endIndex;
|
||||
state.lastStart = startIndex;
|
||||
state.lastEnd = endIndex;
|
||||
|
||||
while (topSpacer.nextSibling && topSpacer.nextSibling !== bottomSpacer) {
|
||||
topSpacer.nextSibling.remove();
|
||||
while (state.topSpacer.nextSibling && state.topSpacer.nextSibling !== state.bottomSpacer) {
|
||||
state.topSpacer.nextSibling.remove();
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
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 measuredCount = 0;
|
||||
for (let index = startIndex; index < endIndex; index += 1) {
|
||||
const height = allItems[index].getBoundingClientRect().height;
|
||||
const height = state.items[index].getBoundingClientRect().height;
|
||||
if (height > 0) {
|
||||
measuredHeightSum += height;
|
||||
measuredCount += 1;
|
||||
@@ -340,28 +357,117 @@ function initMobileWindowedLibraryLoading() {
|
||||
|
||||
if (measuredCount > 0) {
|
||||
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`;
|
||||
bottomSpacer.style.height = `${Math.max(0, (allItems.length - endIndex) * averageItemHeight)}px`;
|
||||
state.topSpacer.style.height = `${Math.max(0, startIndex * state.averageItemHeight)}px`;
|
||||
state.bottomSpacer.style.height = `${Math.max(0, (state.items.length - endIndex) * state.averageItemHeight)}px`;
|
||||
updateImageMemory(startIndex, endIndex);
|
||||
}
|
||||
|
||||
function scheduleWindowRender() {
|
||||
if (framePending) {
|
||||
if (!state.active || state.framePending) {
|
||||
return;
|
||||
}
|
||||
framePending = true;
|
||||
state.framePending = true;
|
||||
requestAnimationFrame(() => {
|
||||
framePending = false;
|
||||
state.framePending = false;
|
||||
renderVisibleWindow();
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', scheduleWindowRender, { passive: true });
|
||||
window.addEventListener('resize', scheduleWindowRender, { passive: true });
|
||||
scheduleWindowRender();
|
||||
function activateVirtualization() {
|
||||
if (state.active) {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user