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:
co-authored by
Claude Opus 5
parent
34bd3aed83
commit
dde076153a
@@ -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?.() || [])]
|
||||||
|
|||||||
Reference in New Issue
Block a user