fix(renderer): keep the reader anchored when a session image settles

virtual-core skips scroll compensation when an already-measured row above
the viewport is re-measured during an upward scroll: that is normally an
estimate correction, and compensating it makes rows jump while the reader
scrolls back through history. An image finishing loading is not an estimate
correction -- the row really did get taller -- so skipping compensation
pushed everything on screen down by the full image height. Scrolling back
through a session with screenshots moved the reader by ~490px per image.

The element now announces load and error through a composed event, and the
timeline compensates size changes for rows that just settled media while
leaving virtual-core's guard in place for every other re-measurement.

loading="lazy" comes off the image at the same time. Rows are only mounted
within a few viewports, so the attribute bought almost nothing while making
load timing depend on Chromium's connection heuristics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tommy0103
2026-08-03 01:29:02 +08:00
co-authored by Claude Opus 5
parent 34bd3aed83
commit dde076153a
2 changed files with 70 additions and 5 deletions
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { SESSION_IMAGE_SETTLED_EVENT } from '../session-image-contract.js';
defineOptions({ name: 'SessionImage' }); defineOptions({ name: 'SessionImage' });
@@ -12,12 +13,24 @@ const props = defineProps({
const status = ref('loading'); const status = ref('loading');
const accessibleLabel = computed(() => props.alt || props.title || 'Session image'); const accessibleLabel = computed(() => props.alt || props.title || 'Session image');
function handleLoad() { // Announce synchronously, before the resize observation this growth triggers,
status.value = 'loaded'; // so the timeline already knows the row is about to change size for a reason
// the reader did not cause.
function announceSettled(event) {
event.target?.dispatchEvent(new CustomEvent(SESSION_IMAGE_SETTLED_EVENT, {
bubbles: true,
composed: true,
}));
} }
function handleError() { function handleLoad(event) {
status.value = 'loaded';
announceSettled(event);
}
function handleError(event) {
status.value = 'error'; status.value = 'error';
announceSettled(event);
} }
</script> </script>
@@ -32,7 +45,6 @@ function handleError() {
:src="src" :src="src"
:alt="alt" :alt="alt"
:title="title || undefined" :title="title || undefined"
loading="lazy"
decoding="async" decoding="async"
@load="handleLoad" @load="handleLoad"
@error="handleError" @error="handleError"
@@ -1,4 +1,4 @@
import { computed, nextTick, ref } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { import {
defaultRangeExtractor, defaultRangeExtractor,
elementScroll, elementScroll,
@@ -6,6 +6,11 @@ import {
useVirtualizer, useVirtualizer,
} from '@tanstack/vue-virtual'; } from '@tanstack/vue-virtual';
import { createSessionTimelineScrollPolicy } from './session-timeline-scroll-policy.mjs'; import { createSessionTimelineScrollPolicy } from './session-timeline-scroll-policy.mjs';
import { SESSION_IMAGE_SETTLED_EVENT } from './session-image-contract.js';
// How long after an image settles its row still counts as "grew because media
// finished" rather than "grew because the estimate was wrong".
const MEDIA_SETTLE_WINDOW_MS = 1_000;
function estimatedTextHeight(text = '') { function estimatedTextHeight(text = '') {
return Math.min(560, Math.ceil(String(text).length / 72) * 20); return Math.min(560, Math.ceil(String(text).length / 72) * 20);
@@ -126,6 +131,54 @@ export function useSessionTimelineViewport({
const virtualRows = computed(() => virtualizer.value.getVirtualItems()); const virtualRows = computed(() => virtualizer.value.getVirtualItems());
const totalSize = computed(() => virtualizer.value.getTotalSize()); const totalSize = computed(() => virtualizer.value.getTotalSize());
// virtual-core deliberately skips scroll compensation when an already-measured
// row above the viewport is re-measured during an upward scroll, because that
// is normally an estimate correction and compensating it makes rows jump while
// the reader scrolls back. An image finishing is not an estimate correction:
// the row really did get taller, so leaving it uncompensated pushes everything
// the reader is looking at down the screen. Track which rows just settled
// media and compensate only those.
const mediaSettledAt = new Map();
function noteMediaSettled(event) {
const row = event.target?.closest?.('.virtual-timeline-row');
const index = Number(row?.dataset?.index);
if (!Number.isInteger(index)) return;
const now = performance.now();
for (const [key, at] of mediaSettledAt) {
if (now - at > MEDIA_SETTLE_WINDOW_MS) mediaSettledAt.delete(key);
}
mediaSettledAt.set(items.value[index]?.key ?? index, now);
}
function isMediaSettling(key) {
const at = mediaSettledAt.get(key);
if (at === undefined) return false;
if (performance.now() - at > MEDIA_SETTLE_WINDOW_MS) {
mediaSettledAt.delete(key);
return false;
}
return true;
}
watch(
() => timelineElement?.value,
(element, previous) => {
previous?.removeEventListener(SESSION_IMAGE_SETTLED_EVENT, noteMediaSettled);
element?.addEventListener(SESSION_IMAGE_SETTLED_EVENT, noteMediaSettled);
},
{ immediate: true },
);
virtualizer.value.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
if (item.start >= instance.getScrollOffset() + instance.scrollAdjustments) return false;
// Never measured before: the estimate is what placed the reader, so the
// estimate-to-actual difference always has to be taken out.
if (!instance.itemSizeCache.has(item.key)) return true;
if (isMediaSettling(item.key)) return true;
return instance.scrollDirection !== 'backward';
};
function resolveTimelineElement(instance = virtualizer?.value) { function resolveTimelineElement(instance = virtualizer?.value) {
return timelineElement?.value return timelineElement?.value
|| [...(instance?.elementsCache?.values?.() || [])] || [...(instance?.elementsCache?.values?.() || [])]