/** * TRUE VIRTUAL SCROLL - ZERO FLICKER * With full keyboard navigation support */ // --- VIRTUAL WINDOW STATE --- let visibleRange = { start: 0, end: 50 }; const MAX_VISIBLE_ITEMS = 50; let updateTimeout = null; // --- PAN/ZOOM STATE --- let currentScale = 1; const MIN_SCALE = 0.1; const MAX_SCALE = 10; let isPanning = false; let isMiddleMousePanning = false; let panStartX = 0; let panStartY = 0; let panOffsetX = 0; let panOffsetY = 0; // --- GRID METRICS --- let itemHeight = 400; let itemWidth = 260; let columnsCount = 4; let rowHeight = 350; // --- LAZY LOADING --- const imageObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; if (img.dataset.src && !img.src) { img.src = img.dataset.src; img.onload = () => img.style.opacity = '1'; imageObserver.unobserve(img); } } }); }, { rootMargin: '400px' }); // --- CALCULATE VISIBLE RANGE --- function calculateVisibleRange() { const viewport = document.getElementById('viewport'); if (!viewport) return visibleRange; const effectiveScrollTop = (-panOffsetY / currentScale); const viewportHeight = viewport.clientHeight / currentScale; const firstVisibleRow = Math.floor(effectiveScrollTop / rowHeight); const lastVisibleRow = Math.ceil((effectiveScrollTop + viewportHeight) / rowHeight); const bufferRows = 3; const startRow = Math.max(0, firstVisibleRow - bufferRows); const endRow = lastVisibleRow + bufferRows; const start = startRow * columnsCount; const end = Math.min(processedData.length, endRow * columnsCount); return { start, end }; } // --- FORCE VISIBLE RANGE UPDATE (for prepended items) --- function forceVisibleRangeUpdate(prependedCount) { console.log(`[Grid] 🔝 ${prependedCount} items prepended - recalculating ALL positions`); const effectiveScrollTop = (-panOffsetY / currentScale); if (effectiveScrollTop < rowHeight * 5) { const newItemRows = Math.ceil(prependedCount / columnsCount); const additionalItems = newItemRows * columnsCount; visibleRange.start = 0; visibleRange.end = Math.min( processedData.length, visibleRange.end + additionalItems ); console.log(`[Grid] Expanded visible range: ${visibleRange.start}-${visibleRange.end}`); } renderVisibleItems(true); } // --- UPDATE VISIBLE ITEMS --- function updateVisibleItems() { const newRange = calculateVisibleRange(); if (newRange.start === visibleRange.start && newRange.end === visibleRange.end) { const grid = document.getElementById('grid'); if (grid && processedData.length > 0) { const totalRows = Math.ceil(processedData.length / columnsCount); const totalHeight = totalRows * rowHeight; if (grid.style.height !== `${totalHeight}px`) { grid.style.height = `${totalHeight}px`; } } return; } visibleRange = newRange; renderVisibleItems(); } function scheduleVisibleUpdate() { if (updateTimeout) { clearTimeout(updateTimeout); } updateTimeout = setTimeout(() => { updateVisibleItems(); }, 100); } // --- RENDER VISIBLE ITEMS --- function renderVisibleItems(forcePositionUpdate = false) { const grid = document.getElementById('grid'); if (!grid || !processedData || processedData.length === 0) return; const totalRows = Math.ceil(processedData.length / columnsCount); const totalHeight = totalRows * rowHeight; grid.style.height = `${totalHeight}px`; const itemsToShow = processedData.slice(visibleRange.start, visibleRange.end); const visibleIds = new Set(itemsToShow.map(item => item.id)); const toRemove = []; for (const [id, node] of nodeMap) { if (!visibleIds.has(id)) { toRemove.push(id); } } toRemove.forEach(id => { const node = nodeMap.get(id); if (node && node.parentNode) { node.remove(); } nodeMap.delete(id); }); const fragment = document.createDocumentFragment(); let newCardsAdded = 0; let positionsUpdated = 0; itemsToShow.forEach((data, offsetIndex) => { const globalIndex = visibleRange.start + offsetIndex; const row = Math.floor(globalIndex / columnsCount); const col = globalIndex % columnsCount; const x = col * itemWidth; const y = row * rowHeight; let card = nodeMap.get(data.id); if (!card) { card = createCard(data); card.style.position = 'absolute'; card.style.left = `${x}px`; card.style.top = `${y}px`; card.style.width = `${itemWidth - 10}px`; card.style.zIndex = globalIndex; nodeMap.set(data.id, card); fragment.appendChild(card); newCardsAdded++; const img = card.querySelector('img[data-src]'); if (img) imageObserver.observe(img); } else { const currentLeft = parseInt(card.style.left) || 0; const currentTop = parseInt(card.style.top) || 0; const currentZIndex = parseInt(card.style.zIndex) || 0; if (forcePositionUpdate || currentLeft !== x || currentTop !== y || currentZIndex !== globalIndex) { card.style.left = `${x}px`; card.style.top = `${y}px`; card.style.zIndex = globalIndex; positionsUpdated++; } } }); if (fragment.childNodes.length > 0) { grid.appendChild(fragment); } if (forcePositionUpdate) { console.log(`[Grid] Added ${newCardsAdded} new, repositioned ${positionsUpdated} existing cards`); } else if (newCardsAdded > 0) { console.log(`[Grid] Added ${newCardsAdded} new cards, kept ${nodeMap.size - newCardsAdded} existing`); } viewport.focus(); viewport.setAttribute('tabindex', '0'); } // --- RECALCULATE LAYOUT --- function recalculateLayout() { const viewport = document.getElementById('viewport'); if (!viewport) return; const colInput = document.getElementById('col-count'); const manualCols = colInput ? parseInt(colInput.value) : 0; const oldColCount = columnsCount; if (manualCols > 0) { columnsCount = manualCols; } else { const viewportWidth = viewport.clientWidth; columnsCount = Math.max(1, Math.floor(viewportWidth / itemWidth)); } if (oldColCount !== columnsCount) { console.log(`[Grid] Column count changed: ${oldColCount} → ${columnsCount}, triggering re-render`); renderDOM(); } } // --- MAIN RENDER --- function renderDOM() { const grid = document.getElementById('grid'); if (!grid) return; console.log('[Grid] 🔄 Full re-render'); grid.innerHTML = ''; nodeMap.clear(); recalculateLayout(); visibleRange = { start: 0, end: Math.min(MAX_VISIBLE_ITEMS, processedData.length) }; renderVisibleItems(); viewport.focus(); viewport.setAttribute('tabindex', '0'); } // --- PAN/ZOOM CONTROLS --- const canvas = document.getElementById('canvas'); const viewport = document.getElementById('viewport'); function updateTransform() { if (!canvas) return; canvas.style.transform = `translate(${panOffsetX}px, ${panOffsetY}px) scale(${currentScale})`; // Save viewport position to localStorage saveViewportPosition(); scheduleVisibleUpdate(); } // Save current viewport position to localStorage function saveViewportPosition() { try { const viewportState = { panOffsetX: panOffsetX, panOffsetY: panOffsetY, currentScale: currentScale, timestamp: Date.now() }; // Use session name if available for session-specific positions const storageKey = (typeof fullManifest !== 'undefined' && fullManifest?.meta?.session_name) ? `viewport_${fullManifest.meta.session_name}` : 'viewport_global'; localStorage.setItem(storageKey, JSON.stringify(viewportState)); } catch (e) { // Silent fail if localStorage is full console.warn('[Grid] Could not save viewport position:', e); } } // Restore viewport position from localStorage function restoreViewportPosition() { try { // Try session-specific position first let storageKey = 'viewport_global'; if (typeof fullManifest !== 'undefined' && fullManifest?.meta?.session_name) { storageKey = `viewport_${fullManifest.meta.session_name}`; } const saved = localStorage.getItem(storageKey); if (!saved) { console.log('[Grid] No saved viewport position found'); return false; } const viewportState = JSON.parse(saved); // Check if saved state is not too old (optional - 30 days) const age = Date.now() - (viewportState.timestamp || 0); const maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days if (age > maxAge) { console.log('[Grid] Saved viewport position too old, ignoring'); return false; } // Restore position panOffsetX = viewportState.panOffsetX || 0; panOffsetY = viewportState.panOffsetY || 0; currentScale = viewportState.currentScale || 1; // Clamp values to valid ranges currentScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, currentScale)); console.log(`[Grid] ✅ Restored viewport position: X=${panOffsetX.toFixed(0)}, Y=${panOffsetY.toFixed(0)}, Scale=${currentScale.toFixed(2)}`); updateTransform(); return true; } catch (e) { console.warn('[Grid] Could not restore viewport position:', e); return false; } } function getZoomDelta() { if (currentScale < 0.5) return 0.05; else if (currentScale < 1) return 0.15; else if (currentScale < 3) return 0.3; else if (currentScale < 6) return 0.5; else return 0.8; } function updateZoom(delta, mouseX, mouseY) { if (!canvas || !viewport) return; const oldScale = currentScale; const adaptiveDelta = delta > 0 ? getZoomDelta() : -getZoomDelta(); currentScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, currentScale + adaptiveDelta)); if (oldScale === currentScale) return; const rect = viewport.getBoundingClientRect(); const offsetX = mouseX - rect.left; const offsetY = mouseY - rect.top; const scaleFactor = currentScale / oldScale; panOffsetX = offsetX - (offsetX - panOffsetX) * scaleFactor; panOffsetY = offsetY - (offsetY - panOffsetY) * scaleFactor; updateTransform(); } function zoomIn() { if (!viewport) return; updateZoom(1, viewport.clientWidth / 2, viewport.clientHeight / 2); } function zoomOut() { if (!viewport) return; updateZoom(-1, viewport.clientWidth / 2, viewport.clientHeight / 2); } function resetZoom() { if (!canvas || !viewport) return; currentScale = 1; panOffsetX = 0; panOffsetY = 0; updateTransform(); autoFitZoom(); } // --- AUTO-FIT ZOOM: Fit first row perfectly in viewport --- function autoFitZoom() { if (!canvas || !viewport || !processedData || processedData.length === 0) { console.log('[Grid] Cannot auto-fit: missing data or viewport'); return; } // Only fit the first row horizontally const totalWidth = columnsCount * itemWidth; const viewportWidth = viewport.clientWidth; // Calculate scale to fit width const targetScale = (viewportWidth / totalWidth) * 0.95; // 95% to add small padding // Clamp to min/max currentScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, targetScale)); // Center horizontally, position at top const scaledWidth = totalWidth * currentScale; panOffsetX = (viewportWidth - scaledWidth) / 2; panOffsetY = 20; // Small top margin updateTransform(); console.log(`[Grid] 🎯 Auto-fit first row: ${columnsCount} columns, scale: ${currentScale.toFixed(2)}`); } // --- GO TO IMAGE: Navigate to specific image number --- function goToImage(imageNumber) { if (!processedData || processedData.length === 0) { console.log('[Grid] No data to navigate'); return; } // Find item by index number (not ID) let targetItem = null; let targetIndex = -1; for (let i = 0; i < processedData.length; i++) { const item = processedData[i]; const itemIndex = idToIndexMap.get(item.id) || 0; if (itemIndex === imageNumber) { targetItem = item; targetIndex = i; break; } } if (!targetItem || targetIndex === -1) { console.log(`[Grid] Image #${imageNumber} not found`); alert(`Image #${imageNumber} not found in current view`); return; } // Calculate position in grid const row = Math.floor(targetIndex / columnsCount); const col = targetIndex % columnsCount; const x = col * itemWidth; const y = row * rowHeight; // Center the image in viewport const viewportWidth = viewport.clientWidth; const viewportHeight = viewport.clientHeight; // Calculate pan offsets to center this card // panOffsetX = (viewportWidth / 2) - (x * currentScale) - ((itemWidth * currentScale) / 2); panOffsetY = (viewportHeight / 2) - (y * currentScale) - ((rowHeight * currentScale) / 2); updateTransform(); // Make sure the item is rendered updateVisibleItems(); // Highlight the card briefly setTimeout(() => { const card = document.getElementById(`card-${targetItem.id}`); if (card) { card.style.transition = 'box-shadow 0.3s, border-color 0.3s'; card.style.boxShadow = '0 0 30px rgba(0, 209, 178, 0.8)'; card.style.borderColor = 'var(--accent)'; setTimeout(() => { card.style.boxShadow = ''; card.style.borderColor = ''; }, 2000); } }, 200); console.log(`[Grid] 📍 Navigated to image #${imageNumber} at position (${row}, ${col})`); } // --- MOUSE CONTROLS --- if (viewport) { viewport.addEventListener('mousedown', (e) => { viewport.focus(); viewport.setAttribute('tabindex', '0'); if (e.button === 0) { isPanning = true; panStartX = e.clientX - panOffsetX; panStartY = e.clientY - panOffsetY; viewport.style.cursor = 'grabbing'; e.preventDefault(); } else if (e.button === 1) { isMiddleMousePanning = true; panStartX = e.clientX - panOffsetX; panStartY = e.clientY - panOffsetY; viewport.style.cursor = 'grabbing'; e.preventDefault(); } }); window.addEventListener('mouseup', (e) => { if (e.button === 0 && isPanning) { isPanning = false; if (viewport) viewport.style.cursor = 'grab'; } else if (e.button === 1 && isMiddleMousePanning) { isMiddleMousePanning = false; if (viewport) viewport.style.cursor = 'grab'; } }); window.addEventListener('mousemove', (e) => { if (!isPanning && !isMiddleMousePanning) return; e.preventDefault(); panOffsetX = e.clientX - panStartX; panOffsetY = e.clientY - panStartY; updateTransform(); }); viewport.addEventListener('wheel', (e) => { e.preventDefault(); updateZoom(e.deltaY > 0 ? -1 : 1, e.clientX, e.clientY); }, { passive: false }); viewport.addEventListener('contextmenu', (e) => { if (e.button === 1) e.preventDefault(); }); // --- TOUCH CONTROLS FOR MOBILE --- let touchStartDistance = 0; let touchStartScale = 1; let isTouching = false; let touchStartX = 0; let touchStartY = 0; let wasZooming = false; // NEW: Track if we were just zooming viewport.addEventListener('touchstart', (e) => { viewport.focus(); viewport.setAttribute('tabindex', '0'); if (e.touches.length === 1) { // Single touch - pan isTouching = true; const touch = e.touches[0]; touchStartX = touch.clientX - panOffsetX; touchStartY = touch.clientY - panOffsetY; } else if (e.touches.length === 2) { // Two fingers - pinch to zoom e.preventDefault(); wasZooming = true; // NEW: Mark that we're zooming const touch1 = e.touches[0]; const touch2 = e.touches[1]; touchStartDistance = Math.hypot( touch2.clientX - touch1.clientX, touch2.clientY - touch1.clientY ); touchStartScale = currentScale; } }, { passive: false }); viewport.addEventListener('touchmove', (e) => { if (e.touches.length === 1 && isTouching) { // NEW: If we just finished zooming, reset pan start position if (wasZooming) { const touch = e.touches[0]; touchStartX = touch.clientX - panOffsetX; touchStartY = touch.clientY - panOffsetY; wasZooming = false; return; // Skip this frame to avoid jump } // Single touch - pan e.preventDefault(); const touch = e.touches[0]; panOffsetX = touch.clientX - touchStartX; panOffsetY = touch.clientY - touchStartY; updateTransform(); } else if (e.touches.length === 2) { // Two fingers - pinch to zoom e.preventDefault(); const touch1 = e.touches[0]; const touch2 = e.touches[1]; // Calculate current distance between fingers const currentDistance = Math.hypot( touch2.clientX - touch1.clientX, touch2.clientY - touch1.clientY ); // Calculate center point between fingers const centerX = (touch1.clientX + touch2.clientX) / 2; const centerY = (touch1.clientY + touch2.clientY) / 2; // Calculate new scale const scaleChange = currentDistance / touchStartDistance; const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, touchStartScale * scaleChange)); if (newScale !== currentScale) { const rect = viewport.getBoundingClientRect(); const offsetX = centerX - rect.left; const offsetY = centerY - rect.top; const scaleFactor = newScale / currentScale; panOffsetX = offsetX - (offsetX - panOffsetX) * scaleFactor; panOffsetY = offsetY - (offsetY - panOffsetY) * scaleFactor; currentScale = newScale; updateTransform(); } } }, { passive: false }); viewport.addEventListener('touchend', (e) => { if (e.touches.length === 0) { isTouching = false; wasZooming = false; // NEW: Reset zoom flag } if (e.touches.length < 2) { touchStartDistance = 0; } }); } // --- RESIZE OBSERVER --- const resizeObserver = new ResizeObserver(() => { recalculateLayout(); }); if (viewport) { resizeObserver.observe(viewport); } // --- KEYBOARD SHORTCUTS --- function setupKeyboardShortcuts() { document.addEventListener('keydown', (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; switch (e.key) { case '+': case '=': e.preventDefault(); zoomIn(); break; case '-': case '_': e.preventDefault(); zoomOut(); break; case '0': e.preventDefault(); // resetZoom(); autoFitZoom(); break; case ' ': // Spacebar - scroll by one row e.preventDefault(); const rowScroll = rowHeight * currentScale; if (e.shiftKey) { // Shift+Space = scroll up panOffsetY += rowScroll; } else { // Space = scroll down panOffsetY -= rowScroll; } updateTransform(); updateVisibleItems(); // IMMEDIATE update instead of scheduled break; case 'ArrowUp': e.preventDefault(); panOffsetY += 100; updateTransform(); updateVisibleItems(); // IMMEDIATE update instead of scheduled break; case 'ArrowDown': e.preventDefault(); panOffsetY -= 100; updateTransform(); updateVisibleItems(); // IMMEDIATE update instead of scheduled break; case 'ArrowLeft': e.preventDefault(); panOffsetX += 100; updateTransform(); updateVisibleItems(); // IMMEDIATE update instead of scheduled break; case 'ArrowRight': e.preventDefault(); panOffsetX -= 100; updateTransform(); updateVisibleItems(); // IMMEDIATE update instead of scheduled break; } }); }; // --- LEGACY COMPATIBILITY --- function measureGridItem() { metrics.ready = true; renderDOM(); } function recalcColumns() { recalculateLayout(); autoFitZoom(); } function updateVirtualWindow(force = false) { if (force) renderDOM(); } function scheduleRender() { renderDOM(); } function onDataAdded() { renderDOM(); } // Expose functions window.zoomIn = zoomIn; window.zoomOut = zoomOut; window.resetZoom = resetZoom; window.autoFitZoom = autoFitZoom; window.goToImage = goToImage; window.updateVisibleItems = updateVisibleItems; window.forceVisibleRangeUpdate = forceVisibleRangeUpdate; // --- MOBILE NAVIGATION FUNCTIONS --- function scrollDownOneRow() { const rowScroll = rowHeight * currentScale; panOffsetY -= rowScroll; updateTransform(); updateVisibleItems(); } function scrollUpOneRow() { const rowScroll = rowHeight * currentScale; panOffsetY += rowScroll; updateTransform(); updateVisibleItems(); } // Expose mobile navigation functions window.scrollDownOneRow = scrollDownOneRow; window.scrollUpOneRow = scrollUpOneRow; setupKeyboardShortcuts(); // Set up Go To Image input handler if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', setupGoToInput); } else { setupGoToInput(); } function setupGoToInput() { const gotoInput = document.getElementById('goto-input'); if (gotoInput) { gotoInput.addEventListener('blur', () => { const imageNum = parseInt(gotoInput.value); if (imageNum && imageNum > 0) { goToImage(imageNum); gotoInput.value = ''; // Clear after navigation } }); // Still support Enter key for desktop users gotoInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { gotoInput.blur(); // Trigger the blur event } }); } }