751 lines
23 KiB
JavaScript
751 lines
23 KiB
JavaScript
/**
|
|
* 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
|
|
}
|
|
});
|
|
}
|
|
} |