artist page: keep the album reveal's target reachable
Opening an album juddered: the page ran at the clicked row, then stopped dead just short of it. revealIntoView lifts the row to the banner line, which needs a pane-height of content beneath that row. Until the previous commit the pane's ~2000px of dead scroll always supplied it. With the range ending at the content, a row in the last screenful no longer has it, so the reveal eased toward a scrollTop the range could not hold and slammed into the clamp — measured on a 29-album artist: target 1846 against a max of 1594, 272px in a single frame, then 39 frames pinned, and the row parked 598px below the line instead of on it. Not a race with the expander either; it finishes growing at ~500ms and the target is still 252px out of reach. Reserve the row's own pane-height, the same way the grid already reserves one for the tab bar so a tab switch cannot clamp the scroll — extended to the open expander's row and released when it closes. - On the grid's min-height, because that is what moves this page: .scrollbody's height does not answer to padding at all (border-box against a fixed height — 0px, 200px and 600px of padding-bottom all measured the same 1787px box). - Reserved, not predicted. The expander's height is not the page's growth: the slack in this very reserve swallowed 435 of a 486px panel, so a 486px expander grew the page by 66. - Stood up synchronously by the reveal as well as by measureCols, which only recomputes off a ResizeObserver — that fires after the 420ms animation has already parked against the old clamp. - Released on the expander's destroy. measureCols cannot notice the panel leaving: the raised min-height is exactly what stops the grid's box from changing, so the observer sees nothing and the reserve would stick for the rest of the visit, leaving a screenful of empty page under the grid. - Re-measured across the settle after an open. What moves the target row is the card art this very click asked for; those images land over the following few hundred ms and rows above the row grow as they do (measured: the row sat 488px into the grid when the reveal read it, 555px once settled). Only the first open of a page pays this. - The reveal clamps its target regardless and idles on it for up to a second, so a corrected reserve still completes the travel instead of ending short. Verified on a 29-album artist and an 11-album one: every open lands the row at exactly the 280px line, first open of a fresh page included, with no direction reversals and no frames against the clamp. Frame timing improves on both — cold 13 frames over 16ms (worst 79.6) to 2 (worst 60.6, the album fetch and decode, which predates this), warm 2 (worst 18.4) to 0 (worst 9). The pane's scroll range still ends exactly at the content with no expander open, and returns to it on close.
This commit is contained in:
1 parent
e58410d9f8
commit
52b1b8e622
1 file changed
+144
-16
+144
-16
@@ -2386,6 +2386,13 @@
|
||||
// "one pane-height below the tab bar" keeps every scroll position where
|
||||
// the bar is visible achievable, so the bar never moves. It is a
|
||||
// min-height, so tabs with plenty of releases are unaffected.
|
||||
// Every mounted grid's own measure(), so the expander can force a recompute.
|
||||
// A ResizeObserver cannot cover this: the reserve is applied as the grid's
|
||||
// min-height, which is precisely what stops the grid's box from changing
|
||||
// when the panel comes and goes — the observer sees nothing and the reserve
|
||||
// sticks at whatever the page happened to measure mid-settle.
|
||||
const gridMeasures = new Set();
|
||||
|
||||
function measureCols(node, groupKey) {
|
||||
const measure = () => {
|
||||
const n = getComputedStyle(node).gridTemplateColumns.split(" ").length;
|
||||
@@ -2400,15 +2407,63 @@
|
||||
?? document.querySelector(".disco-tabs");
|
||||
if (!pane || !bar) return;
|
||||
const chrome = node.getBoundingClientRect().top - bar.getBoundingClientRect().top;
|
||||
const reserve = Math.max(0, Math.round(pane.clientHeight - chrome));
|
||||
let reserve = Math.max(0, Math.round(pane.clientHeight - chrome));
|
||||
// Same argument one level down, for the open expander's own row.
|
||||
// revealIntoView lifts that row to the banner line, and a row in the
|
||||
// last screenful has less beneath it than the pane is tall — so the
|
||||
// reveal eased toward a scrollTop the range could not hold, ran the
|
||||
// curve on toward it and stopped dead against the clamp (measured on
|
||||
// a 29-album artist: wanted 1846, max 1594, 272px in one frame then
|
||||
// 39 frames pinned). Reserving the row's own pane-height fixes the
|
||||
// target instead of chasing it, and it belongs HERE rather than only
|
||||
// in the reveal: the expander's height is not the page's growth (the
|
||||
// slack in this very reserve swallowed 435 of a 486px panel), so it
|
||||
// cannot be predicted from the panel — only reserved against. The
|
||||
// reveal stands the same figure up synchronously; this keeps it
|
||||
// true across resizes and hands it back on close.
|
||||
const openRow = node.querySelector(".album-expander")?.previousElementSibling;
|
||||
if (openRow) reserve = Math.max(reserve, revealReserve(node, openRow, pane));
|
||||
if (node.style.minHeight !== `${reserve}px`) node.style.minHeight = `${reserve}px`;
|
||||
};
|
||||
measure();
|
||||
gridMeasures.add(measure);
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(node);
|
||||
return { destroy: () => ro.disconnect() };
|
||||
return {
|
||||
destroy: () => {
|
||||
ro.disconnect();
|
||||
gridMeasures.delete(measure);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Re-measure when the expander opens, moves row or closes. On the FIRST open
|
||||
// of a freshly loaded page the grid is still settling — card art and wrapped
|
||||
// titles land and push the target row down (measured: 488px into the grid
|
||||
// when the reveal read it, 555px once settled) — so the reserve taken at
|
||||
// click time comes up short and the row parks below the line. The reveal
|
||||
// idles on its target for a beat, so a corrected reserve arriving here is
|
||||
// enough for it to finish the travel.
|
||||
$effect(() => {
|
||||
void openFamilyKey;
|
||||
let done = false;
|
||||
const remeasure = () => {
|
||||
if (!done) gridMeasures.forEach((m) => m());
|
||||
};
|
||||
void tick().then(remeasure);
|
||||
// Spread across the settle, because what moves the row is the card art
|
||||
// this very click asked for: opening an album resolves and preloads its
|
||||
// covers, those images land over the next few hundred ms, and rows above
|
||||
// the target grow as they do. One measurement at click time is taken
|
||||
// before any of that. Only the first open of a page pays it — afterwards
|
||||
// the art is cached and the first measurement is already right.
|
||||
const timers = [80, 250, 550, 850].map((ms) => setTimeout(remeasure, ms));
|
||||
return () => {
|
||||
done = true;
|
||||
timers.forEach(clearTimeout);
|
||||
};
|
||||
});
|
||||
|
||||
/** Index in group.items after which the expander renders (-1 = not here):
|
||||
* the last card of the open family's row. Same-row switches therefore keep
|
||||
* the same insertion point and morph in place; cross-row switches move the
|
||||
@@ -2792,30 +2847,94 @@
|
||||
// mid-flight (measured settling ~500px short). Recomputing the target
|
||||
// every frame and setting scrollTop directly is immune to both, and
|
||||
// also tracks an old expander collapsing above during row switches.
|
||||
/** The line the reveal lands a row on: clears the sticky results-head AND
|
||||
* the parked banner under it, plus breathing room. READ, not hard-coded:
|
||||
* --banner-h is a fraction of the hero now, so a constant tuned to the old
|
||||
* 124px banner would drop a clicked row underneath a taller one. Matches
|
||||
* .release's scroll-margin-top, which is the same expression in CSS. */
|
||||
function revealMargin(node) {
|
||||
const artistPage = node.closest(".artist-page");
|
||||
const banner = artistPage
|
||||
? parseFloat(getComputedStyle(artistPage).getPropertyValue("--banner-h")) || 176
|
||||
: 176;
|
||||
return Math.round(22 + banner + 18);
|
||||
}
|
||||
|
||||
/** How tall the grid must be for revealIntoView to lift `row` to that line:
|
||||
* the row's own offset inside the grid, plus a pane-height below it. Shared,
|
||||
* because both users need the SAME number for different reasons — the reveal
|
||||
* must have it standing before its first frame or it eases at a target the
|
||||
* range cannot hold, and measureCols must maintain it across resizes and
|
||||
* hand it back when the expander closes. */
|
||||
function revealReserve(grid, row, pane) {
|
||||
const rowInGrid = row.getBoundingClientRect().top
|
||||
- grid.getBoundingClientRect().top;
|
||||
return Math.round(rowInGrid + pane.clientHeight - revealMargin(grid));
|
||||
}
|
||||
|
||||
function revealIntoView(node) {
|
||||
const row = node.previousElementSibling; // last card of the open row
|
||||
const pane = scrollPaneFor(node);
|
||||
if (!row || !pane) return;
|
||||
// Clears the sticky results-head AND the parked banner under it, plus
|
||||
// breathing room. READ, not hard-coded: --banner-h is a fraction of the
|
||||
// hero now, so a constant tuned to the old 124px banner would drop a
|
||||
// clicked row underneath a taller one. Matches .release's
|
||||
// scroll-margin-top, which is the same expression in CSS.
|
||||
const page = node.closest(".artist-page");
|
||||
const banner = page
|
||||
? parseFloat(getComputedStyle(page).getPropertyValue("--banner-h")) || 176
|
||||
: 176;
|
||||
const MARGIN = Math.round(22 + banner + 18);
|
||||
const MARGIN = revealMargin(node);
|
||||
const DURATION = 420;
|
||||
const from = pane.scrollTop;
|
||||
const start = performance.now();
|
||||
// Stand the reserve up NOW, before the first frame. measureCols owns it
|
||||
// and recomputes the identical figure, but only off a ResizeObserver —
|
||||
// which fires after this animation has already run its 420ms and parked
|
||||
// against the old clamp (measured: the reserve landed, raising the range
|
||||
// to 1852, with the page long since stopped at 1528).
|
||||
// On the grid's min-height because that is what moves this page:
|
||||
// .scrollbody's height does not answer to padding at all — 0px, 200px
|
||||
// and 600px of padding-bottom all measured the same 1787px box.
|
||||
const grid = row.parentElement;
|
||||
if (grid?.classList.contains("grid")) {
|
||||
const need = revealReserve(grid, row, pane);
|
||||
if ((parseFloat(grid.style.minHeight) || 0) < need) {
|
||||
grid.style.minHeight = `${need}px`;
|
||||
}
|
||||
}
|
||||
// Long enough to absorb a grid that is still settling, short enough that
|
||||
// nothing can creep for a noticeable time after the panel is open.
|
||||
const SETTLE = 1000;
|
||||
let raf = requestAnimationFrame(function step(now) {
|
||||
const t = Math.min(1, (now - start) / DURATION);
|
||||
const elapsed = now - start;
|
||||
const t = Math.min(1, elapsed / DURATION);
|
||||
const ease = 1 - (1 - t) ** 3;
|
||||
const target = row.getBoundingClientRect().top
|
||||
const want = row.getBoundingClientRect().top
|
||||
- pane.getBoundingClientRect().top + pane.scrollTop - MARGIN;
|
||||
pane.scrollTop = from + (target - from) * ease;
|
||||
raf = t < 1 ? requestAnimationFrame(step) : null;
|
||||
// Clamped regardless of the reserve: easing at a target the range
|
||||
// cannot hold is exactly what made the open judder.
|
||||
const target = Math.max(
|
||||
0,
|
||||
Math.min(want, pane.scrollHeight - pane.clientHeight),
|
||||
);
|
||||
if (t < 1) {
|
||||
pane.scrollTop = from + (target - from) * ease;
|
||||
raf = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
// The ease can finish against a target that has since moved: on the
|
||||
// FIRST open of a freshly loaded page the grid is still settling —
|
||||
// card art and wrapped titles land, the row lifts, and the reserve
|
||||
// measured a moment ago comes up short (measured 1458 against the
|
||||
// 1525 the settled page wants, landing the row 61px below the line).
|
||||
// Close the remainder exponentially rather than ending short or
|
||||
// snapping; it is imperceptible when the gap is already zero, which
|
||||
// is the normal case.
|
||||
// Waiting on `want`, not on the clamped target: while the reserve is
|
||||
// still short the two are equal the moment the page reaches the
|
||||
// clamp, and quitting there is exactly how the row gets left below
|
||||
// the line. Idle here until the reserve grows or SETTLE expires.
|
||||
const gap = target - pane.scrollTop;
|
||||
if (Math.abs(want - pane.scrollTop) < 0.5 || elapsed > SETTLE) {
|
||||
if (Math.abs(gap) < 1) pane.scrollTop = target;
|
||||
raf = null;
|
||||
return;
|
||||
}
|
||||
if (Math.abs(gap) > 0.5) pane.scrollTop += gap * 0.2;
|
||||
raf = requestAnimationFrame(step);
|
||||
});
|
||||
const cancel = () => raf && cancelAnimationFrame(raf);
|
||||
// The user's own scrolling wins immediately.
|
||||
@@ -2824,6 +2943,15 @@
|
||||
destroy: () => {
|
||||
cancel();
|
||||
pane.removeEventListener("wheel", cancel);
|
||||
// Hand the reveal's reserve back. measureCols cannot notice on
|
||||
// its own: its ResizeObserver only fires when the grid's BOX
|
||||
// changes, and the raised min-height is exactly what stops it
|
||||
// changing when the panel leaves — so the reserve would stick
|
||||
// for the rest of the visit and leave a screenful of empty page
|
||||
// below the grid. Clearing it shrinks the grid, which is the
|
||||
// resize that makes measureCols recompute the plain tab-bar
|
||||
// reserve (or the next row's, on a cross-row switch).
|
||||
if (grid?.classList.contains("grid")) grid.style.minHeight = "";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user