fix: make the reading viewport a script-driven scroll container so sticky and view timelines work
This commit is contained in:
@@ -99,8 +99,19 @@ const browserRuntime = (): NavigationRuntime => ({
|
|||||||
pushHash: (hash) => window.history.pushState(window.history.state, "", hash),
|
pushHash: (hash) => window.history.pushState(window.history.state, "", hash),
|
||||||
replaceHash: (hash) =>
|
replaceHash: (hash) =>
|
||||||
window.history.replaceState(window.history.state, "", hash),
|
window.history.replaceState(window.history.state, "", hash),
|
||||||
scrollTarget: (target, behavior) =>
|
scrollTarget: (target, behavior) => {
|
||||||
target.scrollIntoView({ behavior, block: "start" }),
|
// Inside the live reading stage the document is the only real scroller;
|
||||||
|
// scrollIntoView would move the script-driven inner viewport instead.
|
||||||
|
if (target.closest('[data-reading-stage="live"]') === null) {
|
||||||
|
target.scrollIntoView({ behavior, block: "start" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const margin = parseFloat(getComputedStyle(target).scrollMarginTop) || 0;
|
||||||
|
window.scrollTo({
|
||||||
|
top: window.scrollY + target.getBoundingClientRect().top - margin,
|
||||||
|
behavior,
|
||||||
|
});
|
||||||
|
},
|
||||||
focusHeading: (target) => {
|
focusHeading: (target) => {
|
||||||
target
|
target
|
||||||
.querySelector<HTMLElement>('h1[tabindex="-1"], h2[tabindex="-1"]')
|
.querySelector<HTMLElement>('h1[tabindex="-1"], h2[tabindex="-1"]')
|
||||||
|
|||||||
@@ -48,8 +48,10 @@ export function createSignStage(resources: SceneResourceBag) {
|
|||||||
Math.min(window.scrollY, contentHeight - viewportHeight),
|
Math.min(window.scrollY, contentHeight - viewportHeight),
|
||||||
);
|
);
|
||||||
if (offset !== lastOffset) {
|
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;
|
lastOffset = offset;
|
||||||
|
viewport.scrollTop = offset;
|
||||||
}
|
}
|
||||||
// Quantize the opening reveal: the mask image is rebuilt at most
|
// Quantize the opening reveal: the mask image is rebuilt at most
|
||||||
// REVEAL_STEPS times on the way in instead of on every scroll event.
|
// 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 });
|
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 = () => {
|
const layoutChanged = () => {
|
||||||
dirty = true;
|
dirty = true;
|
||||||
if (ownsStage()) update(root.clientWidth, window.innerHeight);
|
if (ownsStage()) update(root.clientWidth, window.innerHeight);
|
||||||
@@ -184,7 +201,7 @@ export function createSignStage(resources: SceneResourceBag) {
|
|||||||
delete stage.dataset.readingStage;
|
delete stage.dataset.readingStage;
|
||||||
stage.style.removeProperty('height');
|
stage.style.removeProperty('height');
|
||||||
}
|
}
|
||||||
main.style.removeProperty('transform');
|
if (viewport) viewport.scrollTop = 0;
|
||||||
viewport?.style.removeProperty('mask-image');
|
viewport?.style.removeProperty('mask-image');
|
||||||
viewport?.style.removeProperty('height');
|
viewport?.style.removeProperty('height');
|
||||||
for (const image of lazyImages) image.loading = 'lazy';
|
for (const image of lazyImages) image.loading = 'lazy';
|
||||||
|
|||||||
@@ -524,12 +524,12 @@
|
|||||||
.reading-stage[data-reading-stage='live'] > .reading-viewport {
|
.reading-stage[data-reading-stage='live'] > .reading-viewport {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
overflow: clip;
|
/* A real scroll container (scrolled from script, never by the user), so
|
||||||
|
sticky children and view timelines resolve against the reading window. */
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
.reading-stage[data-reading-stage='live'] .portfolio-main {
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The stationary reading edge supplies the live reveal. View timelines use
|
/* The stationary reading edge supplies the live reveal. View timelines use
|
||||||
layout positions, which no longer follow the translated content here. */
|
layout positions, which no longer follow the translated content here. */
|
||||||
|
|||||||
Reference in New Issue
Block a user