fix(app): stabilize timeline scrolling during updates
Publish real virtual-row measurements while suppressing momentum-breaking scroll writes, then reconcile the reader anchor once scrolling settles. Keep live patches out of the timeline during active gestures and add Electron coverage for tall-row overlap, residual motion, and existing-message updates.
This commit is contained in:
@@ -1,8 +1,15 @@
|
||||
export function createSessionTimelineScrollPolicy({ isUserScrolling, writeScroll }) {
|
||||
export function createSessionTimelineScrollPolicy({
|
||||
isUserScrolling,
|
||||
writeScroll,
|
||||
onSuppressedAdjustment = () => {},
|
||||
}) {
|
||||
let explicitDepth = 0;
|
||||
|
||||
function scrollToFn(offset, options = {}, instance) {
|
||||
if (explicitDepth === 0 && isUserScrolling()) {
|
||||
if (Number.isFinite(options.adjustments) && options.adjustments !== 0) {
|
||||
onSuppressedAdjustment(offset, options, instance);
|
||||
}
|
||||
return;
|
||||
}
|
||||
writeScroll(offset, options, instance);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { defaultRangeExtractor, elementScroll, useVirtualizer } from '@tanstack/vue-virtual';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import {
|
||||
defaultRangeExtractor,
|
||||
elementScroll,
|
||||
measureElement as measureVirtualElement,
|
||||
useVirtualizer,
|
||||
} from '@tanstack/vue-virtual';
|
||||
import { createSessionTimelineScrollPolicy } from './session-timeline-scroll-policy.mjs';
|
||||
|
||||
function estimatedTextHeight(text = '') {
|
||||
@@ -74,6 +79,7 @@ export function resolveReaderAnchorIndex(anchor, items = []) {
|
||||
export function useSessionTimelineViewport({
|
||||
items,
|
||||
scrollElement,
|
||||
timelineElement,
|
||||
scrollMargin,
|
||||
overscan = 6,
|
||||
gap = 14,
|
||||
@@ -81,9 +87,16 @@ export function useSessionTimelineViewport({
|
||||
userScroll,
|
||||
}) {
|
||||
const tailFollowReady = ref(false);
|
||||
let settlementActive = false;
|
||||
let compensatedTimeline = null;
|
||||
let originalTimelineTranslate = '';
|
||||
let suppressedAdjustment = 0;
|
||||
const scrollPolicy = createSessionTimelineScrollPolicy({
|
||||
isUserScrolling: () => userScroll?.isActive() ?? false,
|
||||
isUserScrolling: () => (
|
||||
settlementActive || (userScroll?.isActive() ?? false)
|
||||
),
|
||||
writeScroll: elementScroll,
|
||||
onSuppressedAdjustment: applySuppressedAdjustment,
|
||||
});
|
||||
let virtualizer = null;
|
||||
const rangeExtractor = createViewportRangeExtractor({
|
||||
@@ -106,17 +119,151 @@ export function useSessionTimelineViewport({
|
||||
isScrollingResetDelay: 450,
|
||||
useScrollendEvent: true,
|
||||
useAnimationFrameWithResizeObserver: true,
|
||||
measureElement: measureVirtualElement,
|
||||
scrollToFn: scrollPolicy.scrollToFn,
|
||||
})));
|
||||
|
||||
const virtualRows = computed(() => virtualizer.value.getVirtualItems());
|
||||
const totalSize = computed(() => virtualizer.value.getTotalSize());
|
||||
|
||||
function resolveTimelineElement(instance = virtualizer?.value) {
|
||||
return timelineElement?.value
|
||||
|| [...(instance?.elementsCache?.values?.() || [])]
|
||||
.find(element => element.isConnected)?.parentElement
|
||||
|| null;
|
||||
}
|
||||
|
||||
function applySuppressedAdjustment(_offset, options, instance) {
|
||||
const adjustment = Number(options.adjustments) || 0;
|
||||
if (adjustment === 0) return;
|
||||
const target = resolveTimelineElement(instance);
|
||||
if (!target) return;
|
||||
if (compensatedTimeline !== target) {
|
||||
if (compensatedTimeline) {
|
||||
compensatedTimeline.style.translate = originalTimelineTranslate;
|
||||
}
|
||||
compensatedTimeline = target;
|
||||
originalTimelineTranslate = target.style.translate || '';
|
||||
suppressedAdjustment = 0;
|
||||
}
|
||||
suppressedAdjustment += adjustment;
|
||||
target.style.translate = `0 ${-suppressedAdjustment}px`;
|
||||
}
|
||||
|
||||
function clearSuppressedAdjustment() {
|
||||
if (compensatedTimeline) {
|
||||
compensatedTimeline.style.translate = originalTimelineTranslate;
|
||||
}
|
||||
compensatedTimeline = null;
|
||||
originalTimelineTranslate = '';
|
||||
suppressedAdjustment = 0;
|
||||
}
|
||||
|
||||
function measureElement(element) {
|
||||
if (!element) return;
|
||||
virtualizer.value.measureElement(element);
|
||||
}
|
||||
|
||||
async function settleAfterUserScroll(commit = () => Promise.resolve()) {
|
||||
if (settlementActive) return false;
|
||||
const instance = virtualizer.value;
|
||||
const element = scrollElement.value;
|
||||
if (!instance || !element) {
|
||||
clearSuppressedAdjustment();
|
||||
return false;
|
||||
}
|
||||
|
||||
const scrollOffset = element.scrollTop;
|
||||
const viewportRect = element.getBoundingClientRect();
|
||||
const mountedRows = [...instance.elementsCache.entries()]
|
||||
.filter(([, row]) => row.isConnected)
|
||||
.map(([key, row]) => ({ key, row, rect: row.getBoundingClientRect() }));
|
||||
const visibleAnchor = mountedRows
|
||||
.filter(({ rect }) => (
|
||||
rect.bottom > viewportRect.top && rect.top < viewportRect.bottom
|
||||
))
|
||||
.sort((left, right) => left.rect.top - right.rect.top)[0];
|
||||
const fallbackMeasurement = instance.getVirtualItemForOffset(scrollOffset);
|
||||
const anchor = visibleAnchor
|
||||
? {
|
||||
key: visibleAnchor.key,
|
||||
screenOffset: visibleAnchor.rect.top - viewportRect.top,
|
||||
}
|
||||
: fallbackMeasurement
|
||||
? {
|
||||
key: fallbackMeasurement.key,
|
||||
screenOffset: fallbackMeasurement.start - scrollOffset,
|
||||
}
|
||||
: null;
|
||||
settlementActive = true;
|
||||
try {
|
||||
// Publish the coalesced live patch inside the same geometry transaction.
|
||||
// Real row sizes remain live throughout; only scrollTop corrections are
|
||||
// suppressed until the reader anchor can be reconciled once.
|
||||
await commit();
|
||||
if (userScroll?.isActive() ?? false) return false;
|
||||
|
||||
const indexByKey = new Map(
|
||||
items.value.map((item, index) => [item?.key || index, index]),
|
||||
);
|
||||
let appliedMeasurements = 0;
|
||||
let stableFrames = 0;
|
||||
for (let pass = 0; pass < 12 && stableFrames < 2; pass++) {
|
||||
await nextTick();
|
||||
if (userScroll?.isActive() ?? false) return false;
|
||||
let changed = false;
|
||||
const settledRows = [...instance.elementsCache.entries()]
|
||||
.filter(([, row]) => row.isConnected)
|
||||
.map(([key, row]) => ({ key, size: Math.round(row.getBoundingClientRect().height) }))
|
||||
.filter(({ size }) => size > 0);
|
||||
for (const { key, size } of settledRows) {
|
||||
const index = indexByKey.get(key);
|
||||
if (index === undefined) continue;
|
||||
const cachedSize = instance.itemSizeCache.get(key)
|
||||
?? instance.options.estimateSize(index);
|
||||
if (cachedSize === size) continue;
|
||||
instance.resizeItem(index, size);
|
||||
appliedMeasurements++;
|
||||
changed = true;
|
||||
}
|
||||
stableFrames = changed ? 0 : stableFrames + 1;
|
||||
const targetWindow = element.ownerDocument?.defaultView;
|
||||
if (targetWindow) {
|
||||
await new Promise(resolve => targetWindow.requestAnimationFrame(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
let targetOffset = scrollOffset;
|
||||
if (anchor) {
|
||||
const anchorIndex = indexByKey.get(anchor.key);
|
||||
const nextMeasurement = anchorIndex === undefined
|
||||
? null
|
||||
: instance.getMeasurements?.()[anchorIndex];
|
||||
if (nextMeasurement) targetOffset = nextMeasurement.start - anchor.screenOffset;
|
||||
}
|
||||
|
||||
const viewportWasRepositioned = (userScroll?.isActive() ?? false)
|
||||
|| Math.abs(element.scrollTop - scrollOffset) >= 1;
|
||||
if (viewportWasRepositioned) {
|
||||
clearSuppressedAdjustment();
|
||||
instance.scrollOffset = element.scrollTop;
|
||||
} else if (Math.abs(element.scrollTop - targetOffset) >= 0.5) {
|
||||
clearSuppressedAdjustment();
|
||||
instance.scrollOffset = targetOffset;
|
||||
elementScroll(targetOffset, { behavior: 'auto' }, instance);
|
||||
} else {
|
||||
clearSuppressedAdjustment();
|
||||
instance.scrollOffset = element.scrollTop;
|
||||
}
|
||||
return appliedMeasurements > 0;
|
||||
} catch (error) {
|
||||
clearSuppressedAdjustment();
|
||||
throw error;
|
||||
} finally {
|
||||
settlementActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
function indexAtViewportEnd(inset = 0) {
|
||||
const instance = virtualizer.value;
|
||||
const viewportSize = instance.scrollRect?.height || scrollElement.value?.clientHeight || 0;
|
||||
@@ -248,6 +395,7 @@ export function useSessionTimelineViewport({
|
||||
virtualRows,
|
||||
totalSize,
|
||||
measureElement,
|
||||
settleAfterUserScroll,
|
||||
indexAtViewportEnd,
|
||||
scrollToIndex,
|
||||
scrollToEnd,
|
||||
|
||||
@@ -67,19 +67,29 @@ const userScroll = createSessionUserScroll({ onEnd: handleUserScrollEnd });
|
||||
const timelineViewport = useSessionTimelineViewport({
|
||||
items: timelineItems,
|
||||
scrollElement: wrapRef,
|
||||
timelineElement: timelineRef,
|
||||
scrollMargin: timelineScrollMargin,
|
||||
scrollPaddingEnd: NAV_HEIGHT,
|
||||
userScroll,
|
||||
});
|
||||
const { virtualRows, totalSize, measureElement, waitForStableLayout } = timelineViewport;
|
||||
const {
|
||||
virtualRows,
|
||||
totalSize,
|
||||
measureElement,
|
||||
settleAfterUserScroll,
|
||||
waitForStableLayout,
|
||||
} = timelineViewport;
|
||||
const liveReloadCoordinator = createSessionLiveReloadCoordinator({
|
||||
isScrolling: () => userScroll.isActive(),
|
||||
load: loadLiveSnapshot,
|
||||
commit: commitLiveSnapshot,
|
||||
});
|
||||
|
||||
function handleUserScrollEnd() {
|
||||
if (active.value) void liveReloadCoordinator.flush();
|
||||
async function handleUserScrollEnd() {
|
||||
if (!active.value) return;
|
||||
await settleAfterUserScroll(() => (
|
||||
active.value ? liveReloadCoordinator.flush() : Promise.resolve()
|
||||
));
|
||||
}
|
||||
|
||||
function syncTimelineScrollMargin() {
|
||||
|
||||
Reference in New Issue
Block a user