fix: make the reading viewport a script-driven scroll container so sticky and view timelines work

This commit is contained in:
2026-10-10 18:10:28 +03:00
parent 6c18228ed6
commit 71959f7541
3 changed files with 36 additions and 8 deletions

View File

@@ -48,8 +48,10 @@ export function createSignStage(resources: SceneResourceBag) {
Math.min(window.scrollY, contentHeight - viewportHeight),
);
if (offset !== lastOffset) {
main.style.transform = `translate3d(0, ${-offset}px, 0)`;
// The viewport is a real (overflow: hidden) scroll container driven by
// the document scroll, so sticky and view-timeline layout work inside it.
lastOffset = offset;
viewport.scrollTop = offset;
}
// Quantize the opening reveal: the mask image is rebuilt at most
// REVEAL_STEPS times on the way in instead of on every scroll event.
@@ -149,6 +151,21 @@ export function createSignStage(resources: SceneResourceBag) {
};
resources.listen(window, 'scroll', moveContent, { passive: true });
// Anything the browser scrolls inside the viewport (find-in-page, focus,
// scrollIntoView) is forwarded to the document, which stays the one scroller.
if (viewport)
resources.listen(
viewport,
'scroll',
() => {
if (!ownsStage() || Number.isNaN(lastOffset)) return;
const drift = viewport.scrollTop - lastOffset;
if (Math.abs(drift) < 1) return;
viewport.scrollTop = lastOffset;
window.scrollBy({ top: drift, behavior: 'instant' });
},
{ passive: true },
);
const layoutChanged = () => {
dirty = true;
if (ownsStage()) update(root.clientWidth, window.innerHeight);
@@ -184,7 +201,7 @@ export function createSignStage(resources: SceneResourceBag) {
delete stage.dataset.readingStage;
stage.style.removeProperty('height');
}
main.style.removeProperty('transform');
if (viewport) viewport.scrollTop = 0;
viewport?.style.removeProperty('mask-image');
viewport?.style.removeProperty('height');
for (const image of lazyImages) image.loading = 'lazy';