Prevent stale thumbnails during fast scrolling

This commit is contained in:
xmarre
2026-08-09 22:49:32 +02:00
parent 260126ffc5
commit 6360cf56ea
3 changed files with 243 additions and 52 deletions
+24
View File
@@ -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',
+181 -52
View File
@@ -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
+38
View File
@@ -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,