Skip to content

Commit 53d8c21

Browse files
Merge pull request #906 from diksha78dev/fix/issue-873
[NSOC'26[][GSSOC'26]Perf: Implement virtualized scrolling for Book Vault and 3D Library
2 parents 3acf234 + ffe791c commit 53d8c21

4 files changed

Lines changed: 408 additions & 259 deletions

File tree

‎frontend/js/config.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ const CONFIG = {
3636
// Google Books API endpoint
3737
API_BASE: 'https://www.googleapis.com/books/v1/volumes',
3838

39+
// UI Configuration
40+
CHUNK_SIZE: 20,
41+
3942
// Dynamic base URL helper for auth endpoints
4043
// Returns the current origin (works in dev and prod)
4144
getApiBaseUrl: function() {

‎frontend/js/library-3d.js‎

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -811,11 +811,48 @@ class BookshelfRenderer3D {
811811
container.style.gap = '25px';
812812
container.style.width = '100%';
813813

814-
books.forEach((book) => {
815-
const bookCard = this.createBookCard2D(book, shelfType);
816-
container.appendChild(bookCard);
814+
let renderIndex = 0;
815+
const chunkSize = window.CONFIG?.CHUNK_SIZE || 20;
816+
817+
if (container._shelfObserver) {
818+
container._shelfObserver.disconnect();
819+
}
820+
821+
const sentinel = document.createElement('div');
822+
sentinel.style.width = '100%';
823+
sentinel.style.height = '20px';
824+
sentinel.style.gridColumn = '1 / -1';
825+
826+
container._shelfObserver = new IntersectionObserver((entries) => {
827+
if (entries[0].isIntersecting) {
828+
renderChunk();
829+
}
817830
});
818831

832+
const renderChunk = () => {
833+
const chunk = books.slice(renderIndex, renderIndex + chunkSize);
834+
if (chunk.length === 0) return;
835+
836+
if (sentinel.parentNode) {
837+
container.removeChild(sentinel);
838+
}
839+
840+
chunk.forEach((book) => {
841+
const bookCard = this.createBookCard2D(book, shelfType);
842+
container.appendChild(bookCard);
843+
});
844+
845+
renderIndex += chunkSize;
846+
if (renderIndex < books.length) {
847+
container.appendChild(sentinel);
848+
container._shelfObserver.observe(sentinel);
849+
} else if (container._shelfObserver) {
850+
container._shelfObserver.disconnect();
851+
}
852+
};
853+
854+
renderChunk();
855+
819856
container.setAttribute('aria-label', `${shelfLabels[shelfType]} - ${books.length} book${books.length !== 1 ? 's' : ''}`);
820857
}
821858

@@ -1249,11 +1286,48 @@ class BookshelfRenderer3D {
12491286
12501287
container.innerHTML = '';
12511288
1252-
books.forEach((book, index) => {
1253-
const bookSpine = this.createBookSpine(book, index, shelfType);
1254-
container.appendChild(bookSpine);
1289+
let renderIndex = 0;
1290+
const chunkSize = window.CONFIG?.CHUNK_SIZE || 20;
1291+
1292+
if (container._shelfObserver) {
1293+
container._shelfObserver.disconnect();
1294+
}
1295+
1296+
const sentinel = document.createElement('div');
1297+
sentinel.style.width = '100%';
1298+
sentinel.style.height = '20px';
1299+
sentinel.style.flexShrink = '0';
1300+
1301+
container._shelfObserver = new IntersectionObserver((entries) => {
1302+
if (entries[0].isIntersecting) {
1303+
renderChunk();
1304+
}
12551305
});
12561306
1307+
const renderChunk = () => {
1308+
const chunk = books.slice(renderIndex, renderIndex + chunkSize);
1309+
if (chunk.length === 0) return;
1310+
1311+
if (sentinel.parentNode) {
1312+
container.removeChild(sentinel);
1313+
}
1314+
1315+
chunk.forEach((book, i) => {
1316+
const bookSpine = this.createBookSpine(book, renderIndex + i, shelfType);
1317+
container.appendChild(bookSpine);
1318+
});
1319+
1320+
renderIndex += chunkSize;
1321+
if (renderIndex < books.length) {
1322+
container.appendChild(sentinel);
1323+
container._shelfObserver.observe(sentinel);
1324+
} else if (container._shelfObserver) {
1325+
container._shelfObserver.disconnect();
1326+
}
1327+
};
1328+
1329+
renderChunk();
1330+
12571331
// Update aria-label with book count
12581332
container.setAttribute('aria-label', `${shelfLabels[shelfType]} - ${books.length} book${books.length !== 1 ? 's' : ''}`);
12591333

‎frontend/js/vault.js‎

Lines changed: 113 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -212,72 +212,125 @@ document.addEventListener('DOMContentLoaded', async () => {
212212
return;
213213
}
214214

215-
dataCollection.forEach(file => {
216-
const documentCard = document.createElement('div');
217-
documentCard.className = 'genre-card';
218-
documentCard.style.flexDirection = 'column';
219-
documentCard.style.padding = '25px 20px';
220-
documentCard.style.gap = '10px';
221-
documentCard.style.position = 'relative';
222-
223-
let iconTypeMap = 'fa-file-shield';
224-
if (file.type.startsWith('image/')) iconTypeMap = 'fa-file-image';
225-
else if (file.type === 'application/pdf' || file.name.endsWith('.pdf')) iconTypeMap = 'fa-file-pdf';
226-
else if (file.type.startsWith('text/')) iconTypeMap = 'fa-file-lines';
227-
228-
let localizedObjectURL = "#";
229-
if (file.binaryData instanceof Blob) {
230-
localizedObjectURL = URL.createObjectURL(file.binaryData);
231-
activeObjectUrlsPool.push(localizedObjectURL);
215+
let vaultJsRenderIndex = 0;
216+
const vaultJsChunkSize = window.CONFIG?.CHUNK_SIZE || 20;
217+
218+
if (vaultGrid._vaultObserver) {
219+
vaultGrid._vaultObserver.disconnect();
220+
}
221+
222+
const sentinel = document.createElement('div');
223+
sentinel.style.width = '100%';
224+
sentinel.style.height = '20px';
225+
sentinel.style.gridColumn = '1 / -1';
226+
227+
vaultGrid._vaultObserver = new IntersectionObserver((entries) => {
228+
if (entries[0].isIntersecting) {
229+
renderChunk();
232230
}
231+
});
233232

234-
const computedSizeInMB = (file.size / (1024 * 1024)).toFixed(2);
235-
const safeName = DOMPurify.sanitize(file.name);
236-
const safeGenre = DOMPurify.sanitize(file.genre);
237-
const safeDescription = DOMPurify.sanitize(file.description || 'No annotation summary log notes provided.');
238-
const safeMetadataString = DOMPurify.sanitize(`${computedSizeInMB} MB • ${file.uploadedAt}`);
239-
240-
const badgeClassMap = file.privacy === 'private' ? 'status-is-private' : 'status-is-public';
241-
const iconClassMap = file.privacy === 'private' ? 'fa-user-lock' : 'fa-share-nodes';
242-
243-
documentCard.innerHTML = `
244-
<button class="purge-item-trigger" data-row-id="${file.id}" title="Delete document registry entry"
245-
style="position: absolute; top: 12px; right: 12px; background: none; border: none; color: #e74c3c; cursor: pointer; font-size: 1.1rem; opacity: 0.6; transition: opacity 0.2s;">
246-
<i class="fa-solid fa-trash-can"></i>
247-
</button>
248-
249-
<i class="fa-solid ${iconTypeMap}" style="font-size: 2.6rem; color: var(--text-main); margin-bottom: 3px;"></i>
250-
251-
<span style="font-weight: 600; text-align: center; word-break: break-word; font-family: 'Georgia', serif; font-size: 1.05rem;">${safeName}</span>
252-
253-
<span style="font-size: 0.78rem; font-weight: 500; font-style: italic; opacity: 0.85; background: rgba(255,255,255,0.04); padding: 3px 10px; border-radius: 12px; display: inline-block;">
254-
Classification: ${safeGenre}
255-
</span>
233+
function renderChunk() {
234+
const chunk = dataCollection.slice(vaultJsRenderIndex, vaultJsRenderIndex + vaultJsChunkSize);
235+
if (chunk.length === 0) return;
256236

257-
<p style="font-size: 0.85rem; opacity: 0.7; text-align: center; margin: 4px 0; max-height: 50px; overflow-y: auto; width: 100%; word-break: break-word; line-height: 1.35; padding: 0 4px;">
258-
${safeDescription}
259-
</p>
237+
if (sentinel.parentNode) {
238+
vaultGrid.removeChild(sentinel);
239+
}
240+
241+
chunk.forEach(file => {
242+
const documentCard = document.createElement('div');
243+
documentCard.className = 'genre-card';
244+
documentCard.style.flexDirection = 'column';
245+
documentCard.style.padding = '25px 20px';
246+
documentCard.style.gap = '10px';
247+
documentCard.style.position = 'relative';
248+
249+
let iconTypeMap = 'fa-file-shield';
250+
if (file.type.startsWith('image/')) iconTypeMap = 'fa-file-image';
251+
else if (file.type === 'application/pdf' || file.name.endsWith('.pdf')) iconTypeMap = 'fa-file-pdf';
252+
else if (file.type.startsWith('text/')) iconTypeMap = 'fa-file-lines';
253+
254+
let localizedObjectURL = "#";
255+
if (file.binaryData instanceof Blob) {
256+
localizedObjectURL = URL.createObjectURL(file.binaryData);
257+
activeObjectUrlsPool.push(localizedObjectURL);
258+
}
260259

261-
<span style="font-size: 0.72rem; opacity: 0.45; text-align: center; letter-spacing: 0.3px;">${safeMetadataString}</span>
260+
const computedSizeInMB = (file.size / (1024 * 1024)).toFixed(2);
261+
const safeName = DOMPurify.sanitize(file.name);
262+
const safeGenre = DOMPurify.sanitize(file.genre);
263+
const safeDescription = DOMPurify.sanitize(file.description || 'No annotation summary log notes provided.');
264+
const safeMetadataString = DOMPurify.sanitize(`${computedSizeInMB} MB • ${file.uploadedAt}`);
262265

263-
<div>
264-
<span class="visibility-status-badge ${badgeClassMap}"><i class="fa-solid ${iconClassMap}"></i> Settings: ${file.privacy}</span>
265-
</div>
266+
const badgeClassMap = file.privacy === 'private' ? 'status-is-private' : 'status-is-public';
267+
const iconClassMap = file.privacy === 'private' ? 'fa-user-lock' : 'fa-share-nodes';
268+
269+
documentCard.innerHTML = `
270+
<button class="purge-item-trigger" data-row-id="${file.id}" title="Delete document registry entry"
271+
style="position: absolute; top: 12px; right: 12px; background: none; border: none; color: #e74c3c; cursor: pointer; font-size: 1.1rem; opacity: 0.6; transition: opacity 0.2s;">
272+
<i class="fa-solid fa-trash-can"></i>
273+
</button>
274+
275+
<i class="fa-solid ${iconTypeMap}" style="font-size: 2.6rem; color: var(--text-main); margin-bottom: 3px;"></i>
276+
277+
<span style="font-weight: 600; text-align: center; word-break: break-word; font-family: 'Georgia', serif; font-size: 1.05rem;">${safeName}</span>
278+
279+
<span style="font-size: 0.78rem; font-weight: 500; font-style: italic; opacity: 0.85; background: rgba(255,255,255,0.04); padding: 3px 10px; border-radius: 12px; display: inline-block;">
280+
Classification: ${safeGenre}
281+
</span>
282+
283+
<p style="font-size: 0.85rem; opacity: 0.7; text-align: center; margin: 4px 0; max-height: 50px; overflow-y: auto; width: 100%; word-break: break-word; line-height: 1.35; padding: 0 4px;">
284+
${safeDescription}
285+
</p>
286+
287+
<span style="font-size: 0.72rem; opacity: 0.45; text-align: center; letter-spacing: 0.3px;">${safeMetadataString}</span>
288+
289+
<div>
290+
<span class="visibility-status-badge ${badgeClassMap}"><i class="fa-solid ${iconClassMap}"></i> Settings: ${file.privacy}</span>
291+
</div>
266292
267-
<div style="display: flex; gap: 10px; width: 100%; margin-top: 10px;">
268-
<a href="${localizedObjectURL}" target="_blank" class="btn-preview"
269-
style="flex: 1; text-align: center; font-size: 12px; padding: 8px 0; text-decoration: none; border-radius: 6px; display: inline-block; background: var(--border-focus, #9b59b6); color: white; font-weight: 500;">
270-
<i class="fa-solid fa-up-right-from-square"></i> Open
271-
</a>
272-
<a href="${localizedObjectURL}" download="${safeName}" class="btn-secondary"
273-
style="flex: 1; text-align: center; font-size: 12px; padding: 8px 0; text-decoration: none; border-radius: 6px; border: 1px solid currentColor; display: inline-block;">
274-
<i class="fa-solid fa-download"></i> Extract
275-
</a>
276-
</div>
277-
`;
293+
<div style="display: flex; gap: 10px; width: 100%; margin-top: 10px;">
294+
<a href="${localizedObjectURL}" target="_blank" class="btn-preview"
295+
style="flex: 1; text-align: center; font-size: 12px; padding: 8px 0; text-decoration: none; border-radius: 6px; display: inline-block; background: var(--border-focus, #9b59b6); color: white; font-weight: 500;">
296+
<i class="fa-solid fa-up-right-from-square"></i> Open
297+
</a>
298+
<a href="${localizedObjectURL}" download="${safeName}" class="btn-secondary"
299+
style="flex: 1; text-align: center; font-size: 12px; padding: 8px 0; text-decoration: none; border-radius: 6px; border: 1px solid currentColor; display: inline-block;">
300+
<i class="fa-solid fa-download"></i> Extract
301+
</a>
302+
</div>
303+
`;
304+
305+
const dropBtn = documentCard.querySelector('.purge-item-trigger');
306+
if (dropBtn) {
307+
dropBtn.addEventListener('mouseover', () => dropBtn.style.opacity = '1');
308+
dropBtn.addEventListener('mouseout', () => dropBtn.style.opacity = '0.6');
309+
dropBtn.addEventListener('click', async (event) => {
310+
if (confirm("Confirm Deletion Action: This file tracking record will be cleared from your local storage system cache completely. Proceed?")) {
311+
try {
312+
await vaultDb.securedFiles.delete(file.id);
313+
displayVaultFiles(document.getElementById('vault-search-query') ? document.getElementById('vault-search-query').value : '');
314+
} catch (err) {
315+
console.error("Dexie processing dropped during record destruction processing phase:", err);
316+
}
317+
}
318+
});
319+
}
278320

279-
vaultGrid.appendChild(documentCard);
280-
});
321+
vaultGrid.appendChild(documentCard);
322+
});
323+
324+
vaultJsRenderIndex += vaultJsChunkSize;
325+
if (vaultJsRenderIndex < dataCollection.length) {
326+
vaultGrid.appendChild(sentinel);
327+
vaultGrid._vaultObserver.observe(sentinel);
328+
} else if (vaultGrid._vaultObserver) {
329+
vaultGrid._vaultObserver.disconnect();
330+
}
331+
}
332+
333+
renderChunk();
281334

282335
initializeDestructionTriggers();
283336

@@ -346,25 +399,7 @@ document.addEventListener('DOMContentLoaded', async () => {
346399
}
347400
}
348401
function initializeDestructionTriggers() {
349-
const dropButtons = document.querySelectorAll('.purge-item-trigger');
350-
dropButtons.forEach(button => {
351-
button.addEventListener('mouseover', () => button.style.opacity = '1');
352-
button.addEventListener('mouseout', () => button.style.opacity = '0.6');
353-
354-
button.addEventListener('click', async (event) => {
355-
const elementTarget = event.target.closest('.purge-item-trigger');
356-
const uniqueRowID = parseInt(elementTarget.getAttribute('data-row-id'), 10);
357-
358-
if (confirm("Confirm Deletion Action: This file tracking record will be cleared from your local storage system cache completely. Proceed?")) {
359-
try {
360-
await vaultDb.securedFiles.delete(uniqueRowID);
361-
displayVaultFiles(searchInput ? searchInput.value : '');
362-
} catch (err) {
363-
console.error("Dexie processing dropped during record destruction processing phase:", err);
364-
}
365-
}
366-
});
367-
});
402+
// Obsolete: event listeners are now attached per-element in displayVaultFiles
368403
}
369404

370405
if (searchInput) {

0 commit comments

Comments
 (0)