Files
JasonHoku-ComfyUI-Ultimate-…/resources/logic_virtual.js
T

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
}
});
}
}