Two issues were causing grey/unloaded video cards:
1. preload="none" blocked browsers from fetching the first frame for
poster display. Changed to preload="metadata" so the first frame
shows even before play starts.
2. IntersectionObserver had edge cases with CSS transforms on the
virtual scroll canvas — observer wasn't firing for transformed
cards. Replaced with direct control via virtual scroll lifecycle:
- Videos get src set + .load() called when card is added to DOM
- Playback starts after fragment is appended to grid (play() needs
element to be in the DOM)
- Videos are paused + reset via _stopVideoPlayback when cards are
pooled or removed
This uses the virtual scroll range directly (which already handles
pan/zoom/scroll correctly) instead of relying on IntersectionObserver
which has known issues with CSS transforms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1082 lines
37 KiB
JavaScript
1082 lines
37 KiB
JavaScript
/**
|
|
* TRUE VIRTUAL SCROLL - ZERO FLICKER
|
|
* With full keyboard navigation support and AUTO-CALCULATED DIMENSIONS
|
|
*/
|
|
|
|
// --- 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;
|
|
|
|
// --- RAF BATCHING STATE (prevents flicker from simultaneous pan+zoom) ---
|
|
let rafPending = false;
|
|
let pendingPanDeltaX = 0;
|
|
let pendingPanDeltaY = 0;
|
|
let pendingZoomDelta = 0;
|
|
let pendingZoomMouseX = 0;
|
|
let pendingZoomMouseY = 0;
|
|
let hasPendingZoom = false;
|
|
|
|
// --- GRID METRICS (Auto-calculated from image data) ---
|
|
let itemHeight = 420; // Will be auto-calculated
|
|
let itemWidth = 260; // Will be auto-calculated
|
|
let columnsCount = 4;
|
|
let rowHeight = 370; // Will be auto-calculated
|
|
|
|
// --- DIMENSION CALCULATION CONSTANTS ---
|
|
const CARD_PADDING = 10; // Space between cards
|
|
const CARD_METADATA_HEIGHT = 50; // Height of the metadata/button area below image
|
|
const ASPECT_RATIO_FALLBACK = 2/3; // Default aspect ratio if no images loaded (portrait)
|
|
const MIN_CARD_WIDTH = 150; // Minimum card width
|
|
const MAX_CARD_WIDTH = 500; // Maximum card width
|
|
|
|
/**
|
|
* Calculate optimal grid dimensions based on image aspect ratios
|
|
* This runs once when data is loaded or when images change
|
|
*/
|
|
function calculateGridDimensions() {
|
|
// Use activeData (all items) for dimension sampling, not processedData (filtered/sorted)
|
|
// This prevents dimension changes when filters/sorts change
|
|
const sourceData = (activeData && activeData.length > 0) ? activeData : processedData;
|
|
if (!sourceData || sourceData.length === 0) {
|
|
console.log('[Grid Dimensions] No data available, using defaults');
|
|
return;
|
|
}
|
|
|
|
// Sample up to 50 images evenly distributed across the dataset for a representative average
|
|
const totalItems = sourceData.length;
|
|
const sampleSize = Math.min(50, totalItems);
|
|
const step = Math.max(1, Math.floor(totalItems / sampleSize));
|
|
let totalAspectRatio = 0;
|
|
let validSamples = 0;
|
|
|
|
for (let i = 0; i < totalItems && validSamples < sampleSize; i += step) {
|
|
const item = sourceData[i];
|
|
if (item.width && item.height && item.width > 0 && item.height > 0) {
|
|
totalAspectRatio += item.width / item.height;
|
|
validSamples++;
|
|
}
|
|
}
|
|
|
|
// Calculate average aspect ratio
|
|
const avgAspectRatio = validSamples > 0
|
|
? totalAspectRatio / validSamples
|
|
: ASPECT_RATIO_FALLBACK;
|
|
|
|
// Get viewport width to determine optimal card width
|
|
const viewport = document.getElementById('viewport');
|
|
const viewportWidth = viewport ? viewport.clientWidth : 1200;
|
|
|
|
// Calculate column count based on viewport and desired card size
|
|
const colInput = document.getElementById('col-count');
|
|
const manualCols = colInput ? parseInt(colInput.value) : 0;
|
|
|
|
if (manualCols > 0) {
|
|
columnsCount = manualCols;
|
|
// Calculate card width to fit columns
|
|
itemWidth = Math.max(MIN_CARD_WIDTH,
|
|
Math.min(MAX_CARD_WIDTH,
|
|
Math.floor((viewportWidth - (columnsCount * CARD_PADDING)) / columnsCount)
|
|
)
|
|
);
|
|
} else {
|
|
// Auto-calculate optimal card width (aim for ~250px base width)
|
|
const targetCardWidth = 250;
|
|
columnsCount = Math.max(1, Math.floor(viewportWidth / (targetCardWidth + CARD_PADDING)));
|
|
itemWidth = Math.floor((viewportWidth - (columnsCount * CARD_PADDING)) / columnsCount);
|
|
itemWidth = Math.max(MIN_CARD_WIDTH, Math.min(MAX_CARD_WIDTH, itemWidth));
|
|
}
|
|
|
|
// Calculate item height based on aspect ratio and card width
|
|
const imageWidth = itemWidth - CARD_PADDING;
|
|
const imageHeight = Math.floor(imageWidth / avgAspectRatio);
|
|
itemHeight = imageHeight + CARD_METADATA_HEIGHT + CARD_PADDING;
|
|
|
|
// Row height is slightly less than item height for tighter packing
|
|
rowHeight = itemHeight - 50;
|
|
|
|
console.log(`[Grid Dimensions] Auto-calculated:`);
|
|
console.log(` - Avg Aspect Ratio: ${avgAspectRatio.toFixed(3)} (from ${validSamples} samples)`);
|
|
console.log(` - Columns: ${columnsCount}`);
|
|
console.log(` - Item Width: ${itemWidth}px`);
|
|
console.log(` - Item Height: ${itemHeight}px`);
|
|
console.log(` - Row Height: ${rowHeight}px`);
|
|
console.log(` - Image dimensions: ${imageWidth}x${imageHeight}px`);
|
|
}
|
|
|
|
/**
|
|
* Recalculate dimensions when data changes
|
|
* Call this after processedData is updated
|
|
*/
|
|
function updateGridDimensions() {
|
|
calculateGridDimensions();
|
|
|
|
// Force a full re-render with new dimensions
|
|
if (processedData && processedData.length > 0) {
|
|
renderDOM();
|
|
}
|
|
}
|
|
|
|
// Expose for external use
|
|
window.updateGridDimensions = updateGridDimensions;
|
|
|
|
// --- 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' });
|
|
|
|
// --- VIDEO AUTOPLAY ---
|
|
// Videos in rendered cards (visible range + buffer) auto-play.
|
|
// Videos in pooled/removed cards are paused.
|
|
// Uses virtual scroll range directly instead of IntersectionObserver
|
|
// which has edge cases with CSS transforms on the canvas.
|
|
function _stopVideoPlayback(card) {
|
|
const vid = card.querySelector('video');
|
|
if (!vid) return;
|
|
vid.pause();
|
|
vid.currentTime = 0;
|
|
}
|
|
|
|
// --- 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 ---
|
|
// --- DOM CARD POOL ---
|
|
// Recycled cards are hidden and reused instead of destroyed/recreated.
|
|
// Cuts DOM allocation churn by ~90% during scrolling with large datasets.
|
|
const _cardPool = [];
|
|
const MAX_POOL_SIZE = 100; // Don't pool more than this (memory tradeoff)
|
|
|
|
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) {
|
|
// Pause any video in this card before pooling/removing
|
|
_stopVideoPlayback(node);
|
|
if (_cardPool.length < MAX_POOL_SIZE) {
|
|
// Pool the card for reuse instead of destroying
|
|
node.style.display = 'none';
|
|
_cardPool.push(node);
|
|
} else if (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 genOrderNumber = idToIndexMap.get(data.id) || (globalIndex + 1); // Generation order number (backwards-compatible)
|
|
|
|
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) {
|
|
// Try to recycle a pooled card, otherwise create new
|
|
if (_cardPool.length > 0) {
|
|
card = _cardPool.pop();
|
|
// Recycle: rebuild card content with new data via createCard
|
|
// createCard returns a fresh element — swap children into pooled shell
|
|
card._dataItem = data;
|
|
card.id = `card-${data.id}`;
|
|
card.dataset.id = data.id;
|
|
const freshCard = createCard(data);
|
|
card.textContent = '';
|
|
while (freshCard.firstChild) card.appendChild(freshCard.firstChild);
|
|
card.style.display = '';
|
|
} else {
|
|
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;
|
|
|
|
// Update card number to reflect generation order
|
|
const indexTag = card.querySelector('.index-tag');
|
|
if (indexTag) indexTag.textContent = `#${genOrderNumber}`;
|
|
|
|
nodeMap.set(data.id, card);
|
|
if (!card.parentNode || card.parentNode !== grid) {
|
|
fragment.appendChild(card);
|
|
}
|
|
newCardsAdded++;
|
|
|
|
const img = card.querySelector('img[data-src]');
|
|
if (img && !img.src) {
|
|
img.src = img.dataset.src;
|
|
img.onload = () => img.style.opacity = '1';
|
|
}
|
|
// Videos: set src immediately so preload="metadata" fetches poster frame.
|
|
// Playback starts after fragment is in the DOM (see below).
|
|
const vid = card.querySelector('video');
|
|
if (vid && vid.dataset.src && !vid.src) {
|
|
vid.src = vid.dataset.src;
|
|
vid.load();
|
|
}
|
|
|
|
} 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++;
|
|
|
|
// Update card number when position changes (sort/filter changed)
|
|
const indexTag = card.querySelector('.index-tag');
|
|
if (indexTag) indexTag.textContent = `#${genOrderNumber}`;
|
|
}
|
|
}
|
|
});
|
|
|
|
if (fragment.childNodes.length > 0) {
|
|
grid.appendChild(fragment);
|
|
}
|
|
|
|
// After cards are in the DOM, start playback on every video in the rendered set.
|
|
// This ensures videos play as soon as they enter the virtual scroll range,
|
|
// regardless of CSS transforms on the canvas (IntersectionObserver can miss these).
|
|
itemsToShow.forEach(data => {
|
|
const card = nodeMap.get(data.id);
|
|
if (card) {
|
|
const vid = card.querySelector('video');
|
|
if (vid && vid.paused) {
|
|
vid.play().catch(() => { /* autoplay policy may block */ });
|
|
}
|
|
}
|
|
});
|
|
|
|
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`);
|
|
}
|
|
|
|
// Only set tabindex so keyboard shortcuts work when user clicks in
|
|
// Do NOT call viewport.focus() here — it steals focus from ComfyUI during live updates
|
|
viewport.setAttribute('tabindex', '0');
|
|
}
|
|
|
|
// --- RECALCULATE LAYOUT ---
|
|
function recalculateLayout() {
|
|
const viewport = document.getElementById('viewport');
|
|
if (!viewport) return;
|
|
|
|
const oldColCount = columnsCount;
|
|
const oldItemWidth = itemWidth;
|
|
const oldItemHeight = itemHeight;
|
|
|
|
// Recalculate dimensions based on current viewport and data
|
|
calculateGridDimensions();
|
|
|
|
if (oldColCount !== columnsCount || oldItemWidth !== itemWidth || oldItemHeight !== itemHeight) {
|
|
console.log(`[Grid] Layout changed: cols ${oldColCount}→${columnsCount}, w ${oldItemWidth}→${itemWidth}, h ${oldItemHeight}→${itemHeight}`);
|
|
renderDOM();
|
|
}
|
|
}
|
|
|
|
// --- MAIN RENDER ---
|
|
let isRendering = false; // Guard against recursive renderDOM calls
|
|
function renderDOM() {
|
|
if (isRendering) return; // Prevent recursive calls from recalculateLayout
|
|
isRendering = true;
|
|
|
|
const grid = document.getElementById('grid');
|
|
if (!grid) { isRendering = false; return; }
|
|
|
|
console.log('[Grid] 🔄 Full re-render');
|
|
|
|
grid.innerHTML = '';
|
|
nodeMap.clear();
|
|
|
|
// Recalculate dimensions (may change columnsCount)
|
|
calculateGridDimensions();
|
|
|
|
// Calculate visible range from current viewport position instead of resetting to 0
|
|
visibleRange = calculateVisibleRange();
|
|
// Ensure we have a reasonable range even if viewport position is at origin
|
|
if (visibleRange.end <= visibleRange.start) {
|
|
visibleRange = { start: 0, end: Math.min(MAX_VISIBLE_ITEMS, processedData.length) };
|
|
}
|
|
renderVisibleItems();
|
|
|
|
// Only set tabindex so keyboard shortcuts work when user clicks in
|
|
// Do NOT call viewport.focus() here — it steals focus from ComfyUI during live updates
|
|
viewport.setAttribute('tabindex', '0');
|
|
|
|
isRendering = false;
|
|
}
|
|
|
|
// --- 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})`;
|
|
scheduleVisibleUpdate();
|
|
// Auto-save viewport position on any pan/zoom change
|
|
if (typeof scheduleViewportSave === 'function') scheduleViewportSave();
|
|
}
|
|
|
|
/**
|
|
* Apply all pending pan+zoom transforms in a single animation frame.
|
|
* This prevents flicker when zooming and panning simultaneously,
|
|
* because both transforms are applied atomically before the next paint.
|
|
*/
|
|
function applyPendingTransforms() {
|
|
rafPending = false;
|
|
|
|
// Apply zoom first (adjusts panOffset to keep cursor stable)
|
|
if (hasPendingZoom) {
|
|
updateZoom(pendingZoomDelta, pendingZoomMouseX, pendingZoomMouseY);
|
|
hasPendingZoom = false;
|
|
pendingZoomDelta = 0;
|
|
}
|
|
|
|
// Apply pan delta
|
|
if (pendingPanDeltaX !== 0 || pendingPanDeltaY !== 0) {
|
|
panOffsetX += pendingPanDeltaX;
|
|
panOffsetY += pendingPanDeltaY;
|
|
pendingPanDeltaX = 0;
|
|
pendingPanDeltaY = 0;
|
|
updateTransform();
|
|
}
|
|
}
|
|
|
|
function scheduleRAF() {
|
|
if (!rafPending) {
|
|
rafPending = true;
|
|
requestAnimationFrame(applyPendingTransforms);
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
// If panning while zooming, update panStart so the next mousemove
|
|
// doesn't overwrite the zoom-adjusted offsets (prevents flicker/jump)
|
|
if (isPanning || isMiddleMousePanning) {
|
|
panStartX = lastMouseX - panOffsetX;
|
|
panStartY = lastMouseY - panOffsetY;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* Quick Favorite — toggle favorite on the Nth visible card in reading order.
|
|
* Maps number keys 1-9 to cards visible in the current viewport.
|
|
* Works with any sort mode, zoom level, or pan position.
|
|
*/
|
|
function _quickFavoriteByPosition(n) {
|
|
if (!viewport || !processedData || processedData.length === 0) return;
|
|
|
|
// Use actual DOM bounding rects to find cards truly visible on screen.
|
|
// This handles dynamic card sizes, header offsets, and any transform edge cases.
|
|
var vpRect = viewport.getBoundingClientRect();
|
|
var visibleCards = [];
|
|
var grid = document.querySelector('.grid');
|
|
if (!grid) return;
|
|
|
|
var cards = grid.querySelectorAll('.card');
|
|
cards.forEach(function(card) {
|
|
var rect = card.getBoundingClientRect();
|
|
// Card center must be within the viewport bounds
|
|
var cx = (rect.left + rect.right) / 2;
|
|
var cy = (rect.top + rect.bottom) / 2;
|
|
if (cx >= vpRect.left && cx <= vpRect.right && cy >= vpRect.top && cy <= vpRect.bottom) {
|
|
visibleCards.push({ card: card, cx: cx, cy: cy });
|
|
}
|
|
});
|
|
|
|
// Sort in reading order: top-to-bottom, then left-to-right
|
|
// Group by rows using a tolerance (cards in the same row have similar cy)
|
|
var rowTolerance = 20;
|
|
visibleCards.sort(function(a, b) {
|
|
if (Math.abs(a.cy - b.cy) > rowTolerance) return a.cy - b.cy;
|
|
return a.cx - b.cx;
|
|
});
|
|
|
|
// Map key N to Nth visible card (1-indexed)
|
|
if (n < 1 || n > visibleCards.length) return;
|
|
var target = visibleCards[n - 1];
|
|
var card = target.card;
|
|
var item = card._dataItem;
|
|
if (!item) return;
|
|
|
|
// Toggle favorite
|
|
item.favorited = !item.favorited;
|
|
|
|
// Update the DOM card
|
|
if (card) {
|
|
var favBtn = card.querySelector('.favorite-btn');
|
|
if (favBtn) {
|
|
favBtn.classList.toggle('favorited', item.favorited);
|
|
favBtn.innerText = item.favorited ? '\u2605' : '\u2606';
|
|
}
|
|
|
|
// Visual feedback — brief green/red border flash
|
|
var color = item.favorited ? '#00cc44' : '#cc4444';
|
|
var origZIndex = card.style.zIndex;
|
|
card.style.boxShadow = '0 0 20px ' + color + ', 0 0 40px ' + color;
|
|
card.style.borderColor = color;
|
|
card.style.zIndex = '9999';
|
|
setTimeout(function() {
|
|
card.style.boxShadow = '';
|
|
card.style.borderColor = '';
|
|
card.style.zIndex = origZIndex;
|
|
}, 300);
|
|
|
|
// Show number badge briefly
|
|
var badge = document.createElement('div');
|
|
badge.textContent = n;
|
|
badge.style.cssText = 'position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 48px; font-weight: bold; color: ' + color + '; text-shadow: 0 0 10px rgba(0,0,0,0.8); pointer-events: none; z-index: 10000; opacity: 1; transition: opacity 0.3s;';
|
|
card.appendChild(badge);
|
|
setTimeout(function() { badge.style.opacity = '0'; }, 200);
|
|
setTimeout(function() { if (badge.parentNode) badge.parentNode.removeChild(badge); }, 500);
|
|
}
|
|
|
|
// Persist the change
|
|
if (typeof markItemChanged === 'function') markItemChanged(item);
|
|
if (typeof scheduleJSONUpdate === 'function') scheduleJSONUpdate();
|
|
}
|
|
|
|
function autoFitZoom() {
|
|
if (!canvas || !viewport || !processedData || processedData.length === 0) {
|
|
console.log('[Grid] Cannot auto-fit: missing data or viewport');
|
|
return;
|
|
}
|
|
|
|
// Account for topbar height so the first row sits just below it
|
|
const header = document.getElementById('header');
|
|
const headerH = header ? header.offsetHeight : 0;
|
|
|
|
const totalWidth = columnsCount * itemWidth;
|
|
const viewportWidth = viewport.clientWidth;
|
|
|
|
// Scale to fit all columns across the viewport width
|
|
const targetScale = (viewportWidth / totalWidth) * 0.95;
|
|
currentScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, targetScale));
|
|
|
|
// Center horizontally, pin top row just below the header
|
|
const scaledWidth = totalWidth * currentScale;
|
|
panOffsetX = (viewportWidth - scaledWidth) / 2;
|
|
panOffsetY = headerH;
|
|
|
|
updateTransform();
|
|
|
|
console.log(`[Grid] 🎯 Auto-fit first row: ${columnsCount} columns, scale: ${currentScale.toFixed(2)}`);
|
|
}
|
|
|
|
function goToImage(imageNumber) {
|
|
if (!processedData || processedData.length === 0) {
|
|
console.log('[Grid] No data to navigate');
|
|
return;
|
|
}
|
|
|
|
let targetItem = null;
|
|
let targetIndex = -1;
|
|
|
|
// Card numbers reflect sorted position (1-based), so #N = processedData[N-1]
|
|
if (imageNumber >= 1 && imageNumber <= processedData.length) {
|
|
targetIndex = imageNumber - 1;
|
|
targetItem = processedData[targetIndex];
|
|
}
|
|
|
|
if (!targetItem || targetIndex === -1) {
|
|
console.log(`[Grid] Image #${imageNumber} not found`);
|
|
alert(`Image #${imageNumber} not found in current view (${processedData.length} items visible)`);
|
|
return;
|
|
}
|
|
|
|
const row = Math.floor(targetIndex / columnsCount);
|
|
const col = targetIndex % columnsCount;
|
|
const x = col * itemWidth;
|
|
const y = row * rowHeight;
|
|
|
|
const viewportWidth = viewport.clientWidth;
|
|
const viewportHeight = viewport.clientHeight;
|
|
|
|
panOffsetY = (viewportHeight / 2) - (y * currentScale) - ((rowHeight * currentScale) / 2);
|
|
|
|
updateTransform();
|
|
updateVisibleItems();
|
|
|
|
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 ---
|
|
let lastMouseX = 0;
|
|
let lastMouseY = 0;
|
|
|
|
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) => {
|
|
lastMouseX = e.clientX;
|
|
lastMouseY = e.clientY;
|
|
if (!isPanning && !isMiddleMousePanning) return;
|
|
e.preventDefault();
|
|
|
|
// If a zoom is also pending, batch the pan via rAF to prevent flicker
|
|
if (hasPendingZoom) {
|
|
pendingPanDeltaX = e.clientX - panStartX - panOffsetX;
|
|
pendingPanDeltaY = e.clientY - panStartY - panOffsetY;
|
|
scheduleRAF();
|
|
} else {
|
|
// No pending zoom — apply pan immediately for responsiveness
|
|
panOffsetX = e.clientX - panStartX;
|
|
panOffsetY = e.clientY - panStartY;
|
|
updateTransform();
|
|
}
|
|
});
|
|
|
|
viewport.addEventListener('wheel', (e) => {
|
|
e.preventDefault();
|
|
// Batch zoom via rAF to prevent flicker with simultaneous pan
|
|
if (isPanning || isMiddleMousePanning) {
|
|
pendingZoomDelta += (e.deltaY > 0 ? -1 : 1);
|
|
pendingZoomMouseX = e.clientX;
|
|
pendingZoomMouseY = e.clientY;
|
|
hasPendingZoom = true;
|
|
scheduleRAF();
|
|
} else {
|
|
// Not panning — apply zoom immediately for responsiveness
|
|
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;
|
|
|
|
viewport.addEventListener('touchstart', (e) => {
|
|
viewport.focus();
|
|
viewport.setAttribute('tabindex', '0');
|
|
|
|
if (e.touches.length === 1) {
|
|
isTouching = true;
|
|
const touch = e.touches[0];
|
|
touchStartX = touch.clientX - panOffsetX;
|
|
touchStartY = touch.clientY - panOffsetY;
|
|
} else if (e.touches.length === 2) {
|
|
e.preventDefault();
|
|
wasZooming = true;
|
|
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) {
|
|
if (wasZooming) {
|
|
const touch = e.touches[0];
|
|
touchStartX = touch.clientX - panOffsetX;
|
|
touchStartY = touch.clientY - panOffsetY;
|
|
wasZooming = false;
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
const touch = e.touches[0];
|
|
panOffsetX = touch.clientX - touchStartX;
|
|
panOffsetY = touch.clientY - touchStartY;
|
|
updateTransform();
|
|
} else if (e.touches.length === 2) {
|
|
e.preventDefault();
|
|
const touch1 = e.touches[0];
|
|
const touch2 = e.touches[1];
|
|
|
|
const currentDistance = Math.hypot(
|
|
touch2.clientX - touch1.clientX,
|
|
touch2.clientY - touch1.clientY
|
|
);
|
|
|
|
const centerX = (touch1.clientX + touch2.clientX) / 2;
|
|
const centerY = (touch1.clientY + touch2.clientY) / 2;
|
|
|
|
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;
|
|
}
|
|
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();
|
|
autoFitZoom();
|
|
break;
|
|
case ' ':
|
|
e.preventDefault();
|
|
const rowScroll = rowHeight * currentScale;
|
|
if (e.shiftKey) {
|
|
panOffsetY += rowScroll;
|
|
} else {
|
|
panOffsetY -= rowScroll;
|
|
}
|
|
updateTransform();
|
|
updateVisibleItems();
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
panOffsetY += 100;
|
|
updateTransform();
|
|
updateVisibleItems();
|
|
break;
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
panOffsetY -= 100;
|
|
updateTransform();
|
|
updateVisibleItems();
|
|
break;
|
|
case 'ArrowLeft':
|
|
e.preventDefault();
|
|
panOffsetX += 100;
|
|
updateTransform();
|
|
updateVisibleItems();
|
|
break;
|
|
case 'ArrowRight':
|
|
e.preventDefault();
|
|
panOffsetX -= 100;
|
|
updateTransform();
|
|
updateVisibleItems();
|
|
break;
|
|
}
|
|
|
|
// 1-9 (no modifiers): Quick favorite — toggle favorite on Nth visible card
|
|
// Determines which cards are visible in the viewport, sorts in reading order,
|
|
// and maps key N to the Nth card. Works with any sort, zoom, or pan.
|
|
if (!e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey &&
|
|
e.code >= 'Digit1' && e.code <= 'Digit9') {
|
|
e.preventDefault();
|
|
const n = parseInt(e.code.replace('Digit', ''));
|
|
_quickFavoriteByPosition(n);
|
|
}
|
|
|
|
// Shift+0-9: Quick column count change
|
|
// Use e.code (Digit0-Digit9) because e.key returns shifted symbols (!@#$) when Shift is held
|
|
if (e.shiftKey && e.code >= 'Digit0' && e.code <= 'Digit9') {
|
|
e.preventDefault();
|
|
const colInput = document.getElementById('col-count');
|
|
const num = parseInt(e.code.replace('Digit', ''));
|
|
if (num === 0) {
|
|
// Shift+0 = Auto columns
|
|
if (colInput) colInput.value = '';
|
|
localStorage.removeItem('ultimate_grid_cols');
|
|
} else {
|
|
// Shift+1-9 = Set exact column count
|
|
if (colInput) colInput.value = num;
|
|
localStorage.setItem('ultimate_grid_cols', num);
|
|
}
|
|
if (typeof recalcColumns === 'function') {
|
|
recalcColumns();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- 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();
|
|
}
|
|
|
|
// --- VIEWPORT POSITION PERSISTENCE ---
|
|
// Save viewport position to localStorage for persistence across fullscreen toggles and reloads
|
|
function saveViewportPosition() {
|
|
try {
|
|
const sessInput = document.getElementById('session-input');
|
|
const sessionKey = sessInput ? sessInput.value : 'default';
|
|
const state = {
|
|
panOffsetX: panOffsetX,
|
|
panOffsetY: panOffsetY,
|
|
currentScale: currentScale,
|
|
timestamp: Date.now()
|
|
};
|
|
localStorage.setItem(`ultimate_grid_viewport_${sessionKey}`, JSON.stringify(state));
|
|
} catch (e) {
|
|
console.warn('[Viewport] Failed to save position:', e);
|
|
}
|
|
}
|
|
|
|
// Restore viewport position from localStorage
|
|
// Returns true if position was restored, false otherwise
|
|
function restoreViewportPosition() {
|
|
try {
|
|
const sessInput = document.getElementById('session-input');
|
|
const sessionKey = sessInput ? sessInput.value : 'default';
|
|
const saved = localStorage.getItem(`ultimate_grid_viewport_${sessionKey}`);
|
|
if (!saved) return false;
|
|
|
|
const state = JSON.parse(saved);
|
|
|
|
// Only restore if saved within the last 24 hours
|
|
if (Date.now() - state.timestamp > 24 * 60 * 60 * 1000) {
|
|
localStorage.removeItem(`ultimate_grid_viewport_${sessionKey}`);
|
|
return false;
|
|
}
|
|
|
|
panOffsetX = state.panOffsetX;
|
|
panOffsetY = state.panOffsetY;
|
|
currentScale = state.currentScale;
|
|
|
|
updateTransform();
|
|
updateVisibleItems();
|
|
|
|
console.log(`[Viewport] Restored position: panX=${panOffsetX.toFixed(0)}, panY=${panOffsetY.toFixed(0)}, scale=${currentScale.toFixed(2)}`);
|
|
return true;
|
|
} catch (e) {
|
|
console.warn('[Viewport] Failed to restore position:', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Auto-save viewport position periodically during interaction
|
|
let viewportSaveTimer = null;
|
|
function scheduleViewportSave() {
|
|
if (viewportSaveTimer) clearTimeout(viewportSaveTimer);
|
|
viewportSaveTimer = setTimeout(saveViewportPosition, 500);
|
|
}
|
|
|
|
// Hook into updateTransform to auto-save position on pan/zoom changes
|
|
const _originalUpdateTransform = updateTransform;
|
|
// We can't reassign updateTransform since it's used by reference, so we hook via the scheduleVisibleUpdate path
|
|
// Instead, add save scheduling to mouse/touch/keyboard interactions
|
|
|
|
// Expose functions
|
|
window.zoomIn = zoomIn;
|
|
window.zoomOut = zoomOut;
|
|
window.resetZoom = resetZoom;
|
|
window.autoFitZoom = autoFitZoom;
|
|
window.goToImage = goToImage;
|
|
window.updateVisibleItems = updateVisibleItems;
|
|
window.forceVisibleRangeUpdate = forceVisibleRangeUpdate;
|
|
window.saveViewportPosition = saveViewportPosition;
|
|
window.restoreViewportPosition = restoreViewportPosition;
|
|
|
|
// --- 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;
|
|
|
|
// Initialize keyboard shortcuts immediately
|
|
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 = '';
|
|
}
|
|
});
|
|
|
|
gotoInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
gotoInput.blur();
|
|
}
|
|
});
|
|
}
|
|
} |