From 6360cf56eaa5e4eacafb8a166e128d27868898f9 Mon Sep 17 00:00:00 2001 From: xmarre <54859656+xmarre@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:49:32 +0200 Subject: [PATCH] Prevent stale thumbnails during fast scrolling --- tests/test_gallery_math.mjs | 24 ++++ web/image_conveyor.js | 233 ++++++++++++++++++++++++++++-------- web/image_conveyor_math.mjs | 38 ++++++ 3 files changed, 243 insertions(+), 52 deletions(-) diff --git a/tests/test_gallery_math.mjs b/tests/test_gallery_math.mjs index 28f254d..d0f72e5 100644 --- a/tests/test_gallery_math.mjs +++ b/tests/test_gallery_math.mjs @@ -4,6 +4,8 @@ import { calculateGalleryMetrics, calculateVisibleCardRange, isDragLeavingDocument, + isHighVelocityScroll, + planCardSlotReuse, planViewScrollSwitch } from '../web/image_conveyor_math.mjs' @@ -38,6 +40,28 @@ test('range clamps scroll after filtering to a smaller collection', () => { assert.deepEqual([range.start, range.end], [0, 3]) }) +test('recycled card slots stay attached to overlapping item identities', () => { + const assignments = planCardSlotReuse( + ['a', 'b', 'c', 'd', null], + ['c', 'd', 'e', 'f', 'g'] + ) + assert.deepEqual(assignments, [2, 3, 0, 1, 4]) + assert.equal(new Set(assignments).size, assignments.length) + + const previous = Array.from({ length: 40 }, (_, index) => `item-${index}`) + const next = Array.from({ length: 40 }, (_, index) => `item-${index + 4}`) + const shiftedAssignments = planCardSlotReuse(previous, next) + const retained = shiftedAssignments.filter((slotIndex, index) => previous[slotIndex] === next[index]) + assert.equal(retained.length, 36) +}) + +test('only high-velocity scrolling defers intermediate thumbnail requests', () => { + assert.equal(isHighVelocityScroll(80, 16, 220), false) + assert.equal(isHighVelocityScroll(20, 0, 220), false) + assert.equal(isHighVelocityScroll(240, 16, 220), true) + assert.equal(isHighVelocityScroll(350, 100, 220), true) +}) + test('tab switches preserve independent scroll positions', () => { const toInput = planViewScrollSwitch( 'conveyor', diff --git a/web/image_conveyor.js b/web/image_conveyor.js index 881f50f..8f7bbb4 100644 --- a/web/image_conveyor.js +++ b/web/image_conveyor.js @@ -6,6 +6,8 @@ import { calculateGalleryMetrics, calculateVisibleCardRange, isDragLeavingDocument, + isHighVelocityScroll, + planCardSlotReuse, planViewScrollSwitch } from './image_conveyor_math.mjs' @@ -36,6 +38,7 @@ const MIN_NODE_WIDTH = 520 const MIN_NODE_HEIGHT = 760 const CARD_GAP = 10 const GALLERY_OVERSCAN_ROWS = 2 +const FAST_SCROLL_SETTLE_MS = 80 const CARD_SIZES = { small: { minWidth: 124, thumbnail: 160 }, medium: { minWidth: 172, thumbnail: 256 }, @@ -1105,6 +1108,20 @@ function thumbnailUrl(item, density = 'medium') { return api.apiURL(`/image-conveyor/thumbnail?${params.toString()}`) } +function cachedThumbnailUrl(ctx, item, density) { + if (!item?.source_version) return thumbnailUrl(item, density) + let urls = ctx.thumbnailUrlCache.get(item) + if (!urls) { + urls = new Map() + ctx.thumbnailUrlCache.set(item, urls) + } + const cached = urls.get(density) + if (cached?.sourceVersion === item.source_version) return cached.url + const url = thumbnailUrl(item, density) + urls.set(density, { sourceVersion: item.source_version, url }) + return url +} + function makeItemFromInputFile(entry) { const relativePath = normalizeSourcePath(entry?.relative_path).replace(/^\/+/, '') const filename = String(entry?.filename ?? '').trim() @@ -1239,11 +1256,12 @@ function ensureStyles() { .bil-card.bil-focused { outline: 2px solid rgba(150,200,255,.95); outline-offset: -3px; } .bil-card.bil-drag-target { outline: 2px dashed rgba(120,185,255,.95); outline-offset: -4px; } .bil-media { position: relative; flex: 1 1 auto; min-height: 0; background: transparent; cursor: pointer; } - .bil-thumb { width: 100%; height: 100%; display: block; object-fit: contain; background: transparent; } + .bil-thumb { width: 100%; height: 100%; display: block; object-fit: contain; background: transparent; opacity: 0; } + .bil-thumb.bil-thumb-ready { opacity: 1; } .bil-thumb-error { opacity: .32; filter: grayscale(1); outline: 1px dashed rgba(255,110,110,.78); outline-offset: -2px; } .bil-card-overlay { position: absolute; inset: 6px 6px auto 6px; display: flex; align-items: flex-start; justify-content: space-between; gap: 4px; pointer-events: none; } .bil-card-check { pointer-events: auto; width: 17px; height: 17px; margin: 0; accent-color: #6aaef7; } - .bil-badge { padding: 2px 6px; border-radius: 999px; font-size: 10px; text-transform: uppercase; letter-spacing: .025em; background: rgba(20,20,20,.72); backdrop-filter: blur(3px); } + .bil-badge { padding: 2px 6px; border-radius: 999px; font-size: 10px; text-transform: uppercase; letter-spacing: .025em; background: rgba(20,20,20,.82); } .bil-badge-pending { color: #e2e2e2; } .bil-badge-queued { color: #ffd276; } .bil-badge-processed { color: #8bea9e; } .bil-count-badge { color: #cce4ff; text-transform: none; } .bil-card-footer { flex: 0 0 ${CARD_FOOTER_HEIGHT}px; display: flex; flex-direction: column; justify-content: center; gap: 4px; padding: 5px 7px 6px; min-width: 0; } @@ -1486,7 +1504,23 @@ function createCardSlot(node, ctx) { const card = document.createElement('div') card.className = 'bil-card' card.style.display = 'none' - const slot = { card, itemId: null, item: null, previewUrl: '', bindToken: 0, draggable: false } + const slot = { + card, + itemId: null, + item: null, + itemIndex: -1, + inputView: null, + label: '', + displayPath: '', + subfolder: '', + previewUrl: '', + bindToken: 0, + layoutKey: '', + badgeKey: '', + selected: null, + focused: null, + draggable: false + } card.addEventListener('dragstart', (event) => { if (!slot.draggable || !slot.itemId) { event.preventDefault(); return } @@ -1523,6 +1557,7 @@ function createCardSlot(node, ctx) { thumb.className = 'bil-thumb' thumb.loading = 'lazy' thumb.decoding = 'async' + thumb.fetchPriority = 'low' thumb.draggable = false const overlay = document.createElement('div') overlay.className = 'bil-card-overlay' @@ -1595,71 +1630,120 @@ function ensureCardPool(node, needed) { } } +function resetCardThumbnail(slot) { + slot.bindToken += 1 + slot.previewUrl = '' + slot.thumb.onload = null + slot.thumb.onerror = null + slot.thumb.classList.remove('bil-thumb-ready', 'bil-thumb-error') + slot.thumb.removeAttribute('src') +} + +function bindCardThumbnail(slot, url) { + const token = ++slot.bindToken + slot.previewUrl = url + slot.thumb.onload = () => { + if (token !== slot.bindToken || slot.previewUrl !== url) return + slot.thumb.classList.remove('bil-thumb-error') + slot.thumb.classList.add('bil-thumb-ready') + } + slot.thumb.onerror = () => { + if (token !== slot.bindToken || slot.previewUrl !== url) return + slot.thumb.classList.remove('bil-thumb-ready') + slot.thumb.classList.add('bil-thumb-error') + } + slot.thumb.src = url +} + function hideUnusedCards(ctx, start = 0) { for (let index = start; index < ctx.cardPool.length; index += 1) { const slot = ctx.cardPool[index] - slot.itemId = null; slot.item = null; slot.previewUrl = ''; slot.bindToken += 1 + slot.itemId = null; slot.item = null; slot.itemIndex = -1; slot.inputView = null + slot.layoutKey = ''; slot.badgeKey = ''; slot.selected = null; slot.focused = null slot.card.style.display = 'none' slot.card.classList.remove('bil-selected', 'bil-focused', 'bil-drag-target') - slot.thumb.removeAttribute('src') + resetCardThumbnail(slot) } } -function updateCardSlot(node, slot, item, itemIndex, metrics, selected, annotatedCounts) { +function updateCardSlot(node, slot, item, itemIndex, metrics, selected, annotatedCounts, uiState, allowThumbnailLoad) { const ctx = node.__bil const inputView = ctx.browser.activeView === 'input' const itemId = inputView ? item.relative_path : item.id - const { uiState } = getRenderableState(node) const displayPath = inputView ? item.relative_path : getItemDisplayPath(item, uiState) const label = item.filename || item.relative_path || displayPath - const row = Math.floor(itemIndex / metrics.columns) - const column = itemIndex % metrics.columns - const left = column * (metrics.cardWidth + CARD_GAP) - const top = row * metrics.rowStride - const url = thumbnailUrl(item, activeBrowser(ctx).size) + const browser = activeBrowser(ctx) + const url = cachedThumbnailUrl(ctx, item, browser.size) + const thumbnailChanged = slot.previewUrl !== url + if (thumbnailChanged && slot.previewUrl) resetCardThumbnail(slot) + + const subfolder = inputView ? (item.subfolder || 'input root') : '' + const staticContentChanged = slot.itemIndex !== itemIndex || + slot.inputView !== inputView || + slot.label !== label || + slot.displayPath !== displayPath || + slot.subfolder !== subfolder slot.itemId = itemId; slot.item = item - slot.card.style.display = 'flex' - slot.card.style.width = `${metrics.cardWidth}px` - slot.card.style.height = `${metrics.cardHeight}px` - slot.card.style.transform = `translate3d(${left}px, ${top}px, 0)` - slot.card.classList.toggle('bil-selected', selected.has(itemId)) - slot.card.classList.toggle('bil-focused', activeBrowser(ctx).focusedId === itemId) - slot.checkbox.checked = selected.has(itemId) - slot.checkbox.setAttribute('aria-label', `Select ${label}`) - slot.card.title = displayPath - slot.name.textContent = label - slot.indexText.textContent = `#${itemIndex + 1}` - slot.path.textContent = inputView ? (item.subfolder || 'input root') : displayPath - slot.path.title = slot.path.textContent - slot.draggable = !inputView && canReorderConveyor(ctx) - slot.card.draggable = slot.draggable - slot.pendingBtn.hidden = inputView - slot.processedBtn.hidden = inputView - slot.deleteBtn.hidden = inputView - slot.addBtn.hidden = !inputView + slot.itemIndex = itemIndex; slot.inputView = inputView + slot.label = label; slot.displayPath = displayPath; slot.subfolder = subfolder + if (slot.card.style.display !== 'flex') slot.card.style.display = 'flex' + const layoutKey = `${itemIndex}:${metrics.columns}:${metrics.cardWidth}:${metrics.cardHeight}` + if (slot.layoutKey !== layoutKey) { + const row = Math.floor(itemIndex / metrics.columns) + const column = itemIndex % metrics.columns + const left = column * (metrics.cardWidth + CARD_GAP) + const top = row * metrics.rowStride + slot.card.style.width = `${metrics.cardWidth}px` + slot.card.style.height = `${metrics.cardHeight}px` + slot.card.style.transform = `translate3d(${left}px, ${top}px, 0)` + slot.layoutKey = layoutKey + } + const isSelected = selected.has(itemId) + if (slot.selected !== isSelected) { + slot.selected = isSelected + slot.card.classList.toggle('bil-selected', isSelected) + slot.checkbox.checked = isSelected + } + const isFocused = browser.focusedId === itemId + if (slot.focused !== isFocused) { + slot.focused = isFocused + slot.card.classList.toggle('bil-focused', isFocused) + } + if (staticContentChanged) { + slot.checkbox.setAttribute('aria-label', `Select ${label}`) + slot.card.title = displayPath + slot.name.textContent = label + slot.indexText.textContent = `#${itemIndex + 1}` + slot.path.textContent = inputView ? subfolder : displayPath + slot.path.title = slot.path.textContent + slot.thumb.alt = label + } + const draggable = !inputView && canReorderConveyor(ctx) + if (slot.draggable !== draggable) { + slot.draggable = draggable + slot.card.draggable = draggable + } + if (slot.pendingBtn.hidden !== inputView) slot.pendingBtn.hidden = inputView + if (slot.processedBtn.hidden !== inputView) slot.processedBtn.hidden = inputView + if (slot.deleteBtn.hidden !== inputView) slot.deleteBtn.hidden = inputView + if (slot.addBtn.hidden === inputView) slot.addBtn.hidden = !inputView if (inputView) { const count = annotatedCounts.get(item.relative_path) || 0 - slot.badge.className = 'bil-badge bil-count-badge' - slot.badge.textContent = count ? `In conveyor ×${count}` : 'Input' + const badgeKey = `input:${count}` + if (slot.badgeKey !== badgeKey) { + slot.badge.className = 'bil-badge bil-count-badge' + slot.badge.textContent = count ? `In conveyor ×${count}` : 'Input' + slot.badgeKey = badgeKey + } } else { - slot.badge.className = `bil-badge bil-badge-${item.status}` - slot.badge.textContent = item.status - } - slot.thumb.alt = label - if (slot.previewUrl !== url) { - const token = ++slot.bindToken - slot.thumb.classList.remove('bil-thumb-error') - slot.thumb.onload = () => { - if (token !== slot.bindToken) return - slot.thumb.classList.remove('bil-thumb-error') + const badgeKey = `conveyor:${item.status}` + if (slot.badgeKey !== badgeKey) { + slot.badge.className = `bil-badge bil-badge-${item.status}` + slot.badge.textContent = item.status + slot.badgeKey = badgeKey } - slot.thumb.onerror = () => { - if (token !== slot.bindToken) return - slot.thumb.classList.add('bil-thumb-error') - } - slot.thumb.src = url - slot.previewUrl = url } + if (allowThumbnailLoad && slot.previewUrl !== url) bindCardThumbnail(slot, url) } function renderVisibleCards(node) { @@ -1687,7 +1771,7 @@ function renderVisibleCards(node) { pendingRestore?.scrollTop ?? ctx.list.scrollTop ) const selected = getViewSelectedIds(node) - const { state } = getRenderableState(node) + const { state, uiState } = getRenderableState(node) let annotatedCounts = new Map() if (view === 'input') { if (ctx.annotatedCountsRevision !== ctx.queueRevision) { @@ -1706,6 +1790,8 @@ function renderVisibleCards(node) { if (pendingRestore) { activeBrowser(ctx).scrollTop = range.scrollTop ctx.list.scrollTop = range.scrollTop + ctx.scrollSampleTop = range.scrollTop + ctx.scrollSampleAt = globalThis.performance?.now?.() ?? Date.now() ctx.pendingScrollRestore = null } else if (range.scrollTop !== ctx.list.scrollTop) { ctx.list.scrollTop = range.scrollTop @@ -1715,8 +1801,27 @@ function renderVisibleCards(node) { if (ctx.renderedRangeKey === key) return const needed = range.end - range.start ensureCardPool(node, needed) + const nextItemIds = items.slice(range.start, range.end).map((item) => ( + view === 'input' ? item.relative_path : item.id + )) + const assignments = planCardSlotReuse(ctx.cardPool.map((slot) => slot.itemId), nextItemIds) + const assigned = new Set(assignments) + ctx.cardPool = [ + ...assignments.map((index) => ctx.cardPool[index]), + ...ctx.cardPool.filter((_, index) => !assigned.has(index)) + ] for (let offset = 0; offset < needed; offset += 1) { - updateCardSlot(node, ctx.cardPool[offset], items[range.start + offset], range.start + offset, metrics, selected, annotatedCounts) + updateCardSlot( + node, + ctx.cardPool[offset], + items[range.start + offset], + range.start + offset, + metrics, + selected, + annotatedCounts, + uiState, + !ctx.deferThumbnailLoads + ) } hideUnusedCards(ctx, needed) ctx.renderedRangeKey = key @@ -2066,6 +2171,8 @@ function buildGalleryDom(node) { draggedId: null, empty: null, state: null, uiState: null, renderVersion: 0, inputVersion: 0, renderedRangeKey: '', renderFrame: 0, renderViewportOnly: false, pendingScrollRestore: null, + deferThumbnailLoads: false, scrollSettleTimer: 0, + scrollSampleTop: 0, scrollSampleAt: 0, listResizeObserver: null, widgetOuterHeight: 0, widgetInnerHeight: 0, widgetWidth: 0, pointerInside: false, middlePanPointerId: null, documentPasteHandler: null, documentMiddlePanMoveHandler: null, documentMiddlePanEndHandler: null, @@ -2073,7 +2180,8 @@ function buildGalleryDom(node) { searchTimer: 0, lightbox: null, lastMetrics: null, removed: false, uploadDepth: 0, dropzoneLabel: '', clearExternalDragState: null, - queueRevision: 0, annotatedCountsRevision: -1, annotatedCounts: new Map() + queueRevision: 0, annotatedCountsRevision: -1, annotatedCounts: new Map(), + thumbnailUrlCache: new WeakMap() } const ctx = node.__bil ctx.lightbox = createLightbox(node) @@ -2193,6 +2301,25 @@ function buildGalleryDom(node) { list.addEventListener('scroll', () => { if (ctx.pendingScrollRestore?.view === ctx.browser.activeView) return + const now = globalThis.performance?.now?.() ?? Date.now() + const previousAt = ctx.scrollSampleAt + const previousTop = ctx.scrollSampleTop + ctx.scrollSampleTop = list.scrollTop + ctx.scrollSampleAt = now + if (previousAt && (ctx.deferThumbnailLoads || isHighVelocityScroll( + list.scrollTop - previousTop, + now - previousAt, + ctx.lastMetrics?.rowStride + ))) { + ctx.deferThumbnailLoads = true + clearTimeout(ctx.scrollSettleTimer) + ctx.scrollSettleTimer = setTimeout(() => { + if (ctx.removed) return + ctx.deferThumbnailLoads = false + ctx.renderedRangeKey = '' + scheduleRenderNode(node, { viewportOnly: true, forceVisibleRows: true }) + }, FAST_SCROLL_SETTLE_MS) + } activeBrowser(ctx).scrollTop = list.scrollTop scheduleRenderNode(node, { viewportOnly: true }) }, { passive: true }) @@ -2464,6 +2591,8 @@ function initializeNode(node, widget) { ctx.inputAbortController?.abort?.() ctx.inputAbortController = null clearTimeout(ctx.searchTimer) + clearTimeout(ctx.scrollSettleTimer) + ctx.scrollSettleTimer = 0 ctx.lightbox?.root?.remove?.() ctx.listResizeObserver?.disconnect?.() ctx.listResizeObserver = null diff --git a/web/image_conveyor_math.mjs b/web/image_conveyor_math.mjs index 5e83409..9268ecb 100644 --- a/web/image_conveyor_math.mjs +++ b/web/image_conveyor_math.mjs @@ -49,6 +49,44 @@ export function calculateVisibleCardRange( } } +export function planCardSlotReuse(previousItemIds, nextItemIds) { + const previous = Array.isArray(previousItemIds) ? previousItemIds : [] + const next = Array.isArray(nextItemIds) ? nextItemIds : [] + const availableById = new Map() + for (let index = 0; index < previous.length; index += 1) { + const itemId = previous[index] + if (itemId != null && !availableById.has(itemId)) availableById.set(itemId, index) + } + + const assignments = new Array(next.length).fill(-1) + const used = new Set() + for (let index = 0; index < next.length; index += 1) { + const previousIndex = availableById.get(next[index]) + if (previousIndex == null || used.has(previousIndex)) continue + assignments[index] = previousIndex + used.add(previousIndex) + } + + const free = [] + for (let index = 0; index < previous.length; index += 1) { + if (!used.has(index)) free.push(index) + } + let freeIndex = 0 + let nextNewIndex = previous.length + for (let index = 0; index < assignments.length; index += 1) { + if (assignments[index] >= 0) continue + assignments[index] = freeIndex < free.length ? free[freeIndex++] : nextNewIndex++ + } + return assignments +} + +export function isHighVelocityScroll(deltaPixels, elapsedMs, rowStride) { + const distance = Math.abs(Number(deltaPixels) || 0) + const elapsed = Math.max(8, Number(elapsedMs) || 8) + const stride = Math.max(1, Number(rowStride) || 1) + return distance >= stride * 1.5 || distance / elapsed >= stride / 32 +} + export function planViewScrollSwitch( activeView, nextView,