resize: reads before writes everywhere, and an open album drags like a closed one
This commit is contained in:
1 parent
52f5878297
commit
606250c361
2 files changed
+412
-162
No files matched your search
+19
-3
@@ -535,10 +535,26 @@ export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previ
|
|||||||
);
|
);
|
||||||
const width = Math.max(1, Math.round(rect.width * scale));
|
const width = Math.max(1, Math.round(rect.width * scale));
|
||||||
const height = Math.max(1, Math.round(rect.height * scale));
|
const height = Math.max(1, Math.round(rect.height * scale));
|
||||||
if (canvas.width !== width) canvas.width = width;
|
// A store within a couple of percent of the box is not visibly softer —
|
||||||
if (canvas.height !== height) canvas.height = height;
|
// that is the same argument reflow() already makes for every
|
||||||
|
// intermediate frame of a drag, and it holds just as well for the final
|
||||||
|
// one. Without the tolerance, ANY change reallocates: measured two
|
||||||
|
// ~52ms hitches landing at 1827ms and 1909ms against a drag that ended
|
||||||
|
// at 1823ms, i.e. the whole cost of a resize arriving after the user
|
||||||
|
// had stopped. paint() below is geometrically exact at any store size,
|
||||||
|
// so skipping the realloc costs nothing but sharpness we cannot see.
|
||||||
|
const REALLOC_TOLERANCE = 0.02;
|
||||||
|
const off = (a, b) => Math.abs(a - b) / Math.max(1, b) > REALLOC_TOLERANCE;
|
||||||
|
if (off(canvas.width, width) || off(canvas.height, height)) {
|
||||||
|
if (canvas.width !== width) canvas.width = width;
|
||||||
|
if (canvas.height !== height) canvas.height = height;
|
||||||
|
}
|
||||||
paint(rect);
|
paint(rect);
|
||||||
gl.finish();
|
// gl.finish() blocks the main thread until the GPU has drained, and it
|
||||||
|
// stalls hardest exactly when the GPU is busy — which, 140ms after a
|
||||||
|
// drag, it still is. Nothing here reads back, so there is nothing to
|
||||||
|
// synchronise with; the compositor will show the frame when it is done.
|
||||||
|
gl.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The ResizeObserver's path, and the reason it is not `resize`.
|
/** The ResizeObserver's path, and the reason it is not `resize`.
|
||||||
|
|||||||
+393
-159
@@ -927,11 +927,19 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function measureHero(node) {
|
function measureHero(node) {
|
||||||
const update = () => {
|
if (node.clientWidth > 0) heroWidth = node.clientWidth;
|
||||||
if (node.clientWidth > 0) heroWidth = node.clientWidth;
|
// Width from the ENTRY, not clientWidth: an entry is a post-layout
|
||||||
};
|
// fact, while a DOM read in a delivery forces layout whenever an
|
||||||
update();
|
// earlier callback in the same round has written — and the hero
|
||||||
const observer = new ResizeObserver(update);
|
// reflow writes on every frame of a drag. .hero-zone carries no
|
||||||
|
// border or padding, so the border box IS the client width.
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
const box = entries[entries.length - 1];
|
||||||
|
const w = Math.round(
|
||||||
|
box.borderBoxSize?.[0]?.inlineSize ?? box.contentRect.width,
|
||||||
|
);
|
||||||
|
if (w > 0) heroWidth = w;
|
||||||
|
});
|
||||||
observer.observe(node);
|
observer.observe(node);
|
||||||
return { destroy: () => observer.disconnect() };
|
return { destroy: () => observer.disconnect() };
|
||||||
}
|
}
|
||||||
@@ -1087,8 +1095,21 @@
|
|||||||
// The constant part of the content's flow-local fade line — written
|
// The constant part of the content's flow-local fade line — written
|
||||||
// by measure(), consumed every frame by apply().
|
// by measure(), consumed every frame by apply().
|
||||||
let stripCut = -99999;
|
let stripCut = -99999;
|
||||||
|
// measure()'s write block, deferred one rAF (latest wins). Written
|
||||||
|
// inside the ResizeObserver delivery, the custom properties dirty
|
||||||
|
// style mid-round and every later callback's first read — the grid
|
||||||
|
// reserve's — pays a full forced layout, every frame of a drag.
|
||||||
|
// Deferred, the writes ride the next frame's one natural layout.
|
||||||
|
// Every consumer already tolerates the unwritten state (the
|
||||||
|
// var(--travel, 999999px) sentinels exist for the pre-measure first
|
||||||
|
// paint), so a one-frame lag is the same situation, briefer.
|
||||||
|
let commit = null;
|
||||||
|
let commitRaf = 0;
|
||||||
|
// The chips' un-parked height, reused on parked non-settle passes so
|
||||||
|
// a drag deep in the page skips the data-park toggle (see below).
|
||||||
|
let chipsHCache = -1;
|
||||||
|
|
||||||
function measure() {
|
function measure(settled = true) {
|
||||||
const zone = page.querySelector(".hero-zone");
|
const zone = page.querySelector(".hero-zone");
|
||||||
if (!zone) return;
|
if (!zone) return;
|
||||||
// NEVER measure the pins themselves. They are `position: sticky`,
|
// NEVER measure the pins themselves. They are `position: sticky`,
|
||||||
@@ -1106,9 +1127,19 @@
|
|||||||
// is measured from .hero-zone, which is `position: relative` and
|
// is measured from .hero-zone, which is `position: relative` and
|
||||||
// cannot lie.
|
// cannot lie.
|
||||||
//
|
//
|
||||||
|
// READS FIRST, WRITES LAST — this runs on every frame of a window
|
||||||
|
// drag (the ResizeObserver below), and every style write here
|
||||||
|
// dirties the very geometry the next read then forces back up to
|
||||||
|
// date. Interleaved, one pass through this function cost up to six
|
||||||
|
// forced layouts; batched, it costs one (two while parked, for the
|
||||||
|
// data-park toggle). Nothing may read layout after the write block
|
||||||
|
// starts.
|
||||||
|
//
|
||||||
// Measured from the TOP OF THE BAND, so these compare directly
|
// Measured from the TOP OF THE BAND, so these compare directly
|
||||||
// against band-local gradient stops.
|
// against band-local gradient stops.
|
||||||
const span = zone.offsetTop + zone.offsetHeight - BAND_TOP;
|
const zoneTop = zone.offsetTop;
|
||||||
|
const zoneH = zone.offsetHeight;
|
||||||
|
const span = zoneTop + zoneH - BAND_TOP;
|
||||||
// Constant by construction: the band's top sits at scrollport
|
// Constant by construction: the band's top sits at scrollport
|
||||||
// -24px (BAND_TOP) and the glass bar's stuck bottom at +22px
|
// -24px (BAND_TOP) and the glass bar's stuck bottom at +22px
|
||||||
// (BAR_BOTTOM), so the bar clears the band's top by 46px and the
|
// (BAR_BOTTOM), so the bar clears the band's top by 46px and the
|
||||||
@@ -1138,17 +1169,17 @@
|
|||||||
const slot = page.querySelector(".logo-slot");
|
const slot = page.querySelector(".logo-slot");
|
||||||
let markRest = 16;
|
let markRest = 16;
|
||||||
let inkPark = markPx;
|
let inkPark = markPx;
|
||||||
|
// Written in the write block below, and only when measured here —
|
||||||
|
// absent copy/slot the previous values must stand, as they always
|
||||||
|
// have.
|
||||||
|
let markEnd = null;
|
||||||
if (copy && slot) {
|
if (copy && slot) {
|
||||||
markRest = copy.offsetHeight - slot.offsetTop - slot.offsetHeight;
|
markRest = copy.offsetHeight - slot.offsetTop - slot.offsetHeight;
|
||||||
page.style.setProperty("--mark-rest", `${markRest}px`);
|
|
||||||
// offsetHeight, not a rect: the slot carries the ride-up scale
|
// offsetHeight, not a rect: the slot carries the ride-up scale
|
||||||
// while scrolled and a rect would feed that back into itself.
|
// while scrolled and a rect would feed that back into itself.
|
||||||
const mark = slot.querySelector(".logo-wrap") ?? slot.querySelector(".name-title");
|
const mark = slot.querySelector(".logo-wrap") ?? slot.querySelector(".name-title");
|
||||||
const markHeight = mark?.offsetHeight ?? 0;
|
const markHeight = mark?.offsetHeight ?? 0;
|
||||||
page.style.setProperty(
|
markEnd = markHeight > markPx ? (markPx / markHeight).toFixed(4) : "1";
|
||||||
"--mark-end",
|
|
||||||
markHeight > markPx ? (markPx / markHeight).toFixed(4) : "1",
|
|
||||||
);
|
|
||||||
inkPark = markHeight > markPx ? markPx : markHeight || markPx;
|
inkPark = markHeight > markPx ? markPx : markHeight || markPx;
|
||||||
}
|
}
|
||||||
// The Play/chips row's resting height, MEASURED off the row itself;
|
// The Play/chips row's resting height, MEASURED off the row itself;
|
||||||
@@ -1177,97 +1208,53 @@
|
|||||||
// reader halfway through a scroll. The toggle is synchronous, so
|
// reader halfway through a scroll. The toggle is synchronous, so
|
||||||
// no paint can land between the two writes.
|
// no paint can land between the two writes.
|
||||||
const chipsEl = page.querySelector(".stat-chips");
|
const chipsEl = page.querySelector(".stat-chips");
|
||||||
const gap = page.querySelector(".actions-gap");
|
const gapEl = page.querySelector(".actions-gap");
|
||||||
const parkAttr = page.dataset.park ?? "0";
|
|
||||||
page.dataset.park = "0";
|
|
||||||
const chipsH = chipsEl?.getBoundingClientRect().height ?? 0;
|
|
||||||
page.dataset.park = parkAttr;
|
|
||||||
// The one-line floor, per breakpoint, and the top padding derived
|
// The one-line floor, per breakpoint, and the top padding derived
|
||||||
// from it (see --row-pad-top). The row is padTop + the chips'
|
// from it (see --row-pad-top). The row is padTop + the chips'
|
||||||
// offset against the 50px lead row + the chips + 8px.
|
// offset against the 50px lead row + the chips + 8px. Both reads
|
||||||
|
// sit BEFORE the park toggle: taken after the attribute flip they
|
||||||
|
// pay the style pass the flip dirties.
|
||||||
const floor =
|
const floor =
|
||||||
parseFloat(getComputedStyle(page).getPropertyValue("--row-min")) || 76;
|
parseFloat(getComputedStyle(page).getPropertyValue("--row-min")) || 76;
|
||||||
const padTop = (floor - 66) / 2 + 8;
|
const padTop = (floor - 66) / 2 + 8;
|
||||||
|
const gapH = chipsEl ? 0 : gapEl?.offsetHeight ?? floor;
|
||||||
|
// Guarded three ways. At rest data-park is already "0", and even
|
||||||
|
// a same-value attribute write is a style invalidation a drag
|
||||||
|
// frame cannot afford. Parked — scrolled deep, which is where the
|
||||||
|
// discography drags happen — the toggle costs a forced layout to
|
||||||
|
// un-collapse the chips, then a second when the next read lands
|
||||||
|
// after the flip back; so mid-drag (settled = false) the last
|
||||||
|
// measured height is reused and only the trailing settle pass,
|
||||||
|
// the mount and the data-change remeasures pay the exact read.
|
||||||
|
// Stale by at most one wrap boundary, above the viewport, for the
|
||||||
|
// 200ms until the settle pass — and scroll anchoring absorbs the
|
||||||
|
// correction.
|
||||||
|
const parkAttr = page.dataset.park ?? "0";
|
||||||
|
let chipsH;
|
||||||
|
if (chipsEl && parkAttr !== "0" && !settled && chipsHCache >= 0) {
|
||||||
|
chipsH = chipsHCache;
|
||||||
|
} else {
|
||||||
|
if (parkAttr !== "0") page.dataset.park = "0";
|
||||||
|
chipsH = chipsEl?.getBoundingClientRect().height ?? 0;
|
||||||
|
if (parkAttr !== "0") page.dataset.park = parkAttr;
|
||||||
|
chipsHCache = chipsEl ? chipsH : -1;
|
||||||
|
}
|
||||||
const rowH = chipsEl
|
const rowH = chipsEl
|
||||||
? Math.max(floor, Math.ceil(padTop + 11.5 + chipsH + 8))
|
? Math.max(floor, Math.ceil(padTop + 11.5 + chipsH + 8))
|
||||||
: gap?.offsetHeight ?? floor;
|
: gapH;
|
||||||
page.style.setProperty("--row-h", `${rowH}px`);
|
|
||||||
syncSocialDivider(page);
|
syncSocialDivider(page);
|
||||||
// The park point: the content edge stops exactly where the shrunk
|
// The park point: the content edge stops exactly where the shrunk
|
||||||
// wordmark's top lands PAD_TOP_PARKED below the glass bar with the
|
// wordmark's top lands PAD_TOP_PARKED below the glass bar with the
|
||||||
// identity still in its resting slots.
|
// identity still in its resting slots.
|
||||||
const strip =
|
const strip =
|
||||||
BAR_BOTTOM - BAND_TOP + PAD_TOP_PARKED + inkPark + markRest;
|
BAR_BOTTOM - BAND_TOP + PAD_TOP_PARKED + inkPark + markRest;
|
||||||
page.style.setProperty("--banner-h", `${banner}px`);
|
|
||||||
// Glue, not travel. --lift cancels .hero-copy's parked term
|
|
||||||
// exactly, so its bottom stays 0 at every p — the wordmark
|
|
||||||
// shrinks in place about its resting baseline. The row needs no
|
|
||||||
// travel var at all any more: its box is pinned to its resting
|
|
||||||
// slot in CSS.
|
|
||||||
page.style.setProperty("--lift", `${banner - 14 - markRest}px`);
|
|
||||||
|
|
||||||
// Where the pins sit in flow. the glass bar's net flow height is
|
|
||||||
// zero (-24 + 46 - 22), so the hero zone starts at exactly the
|
|
||||||
// same offset the pins do — and it is `position: relative`, so
|
|
||||||
// asking it cannot feed a sticky offset back into the measurement
|
|
||||||
// the way asking a pin would.
|
|
||||||
page.style.setProperty("--pin-origin", `${zone.offsetTop}px`);
|
|
||||||
page.style.setProperty("--hero-span", `${span}px`);
|
|
||||||
page.style.setProperty("--strip", `${strip}px`);
|
|
||||||
travel = span - strip > 1 ? span - strip : Infinity;
|
travel = span - strip > 1 ? span - strip : Infinity;
|
||||||
|
|
||||||
// The scroll-driven animations' constants (see the second
|
|
||||||
// @supports (animation-timeline: scroll()) block in the CSS).
|
|
||||||
// --travel is the shared animation-range span — the p
|
|
||||||
// denominator — so linear easing over 0..--travel gives
|
|
||||||
// animation progress ≡ p at every scrollTop. The 999999px
|
|
||||||
// sentinel mirrors travel = Infinity above (p pinned ~0: no
|
|
||||||
// animation progresses measurably), and every consumer reads
|
|
||||||
// var(--travel, 999999px) so the pre-measure first paint is
|
|
||||||
// inert too. --ride is the SAME delta as a length for
|
|
||||||
// to-keyframes, and must be a separate variable: tied to the
|
|
||||||
// range sentinel, a degenerate page's to-value would cancel
|
|
||||||
// into translate = -scrollTop; the 0px sentinel keeps it
|
|
||||||
// motionless exactly as p = 0 does today. --fade-span is the
|
|
||||||
// extras/badge fade's own range (travel / 2.4, where their
|
|
||||||
// opacity ramp clamps): dividing the 999999px sentinel instead
|
|
||||||
// would let a deep scroll on a degenerate page ghost-fade chips
|
|
||||||
// that today hold opacity 1 at any scrollTop.
|
|
||||||
const rideValid = span - strip > 1;
|
const rideValid = span - strip > 1;
|
||||||
page.style.setProperty(
|
// Probed with the other reads, consumed by the --row-slide write
|
||||||
"--travel",
|
// below.
|
||||||
`${rideValid ? (span - strip).toFixed(1) : 999999}px`,
|
const hasPlay = !!page.querySelector(".hero-play");
|
||||||
);
|
const hasPreview = !!page.querySelector(".hero-preview");
|
||||||
page.style.setProperty(
|
|
||||||
"--ride",
|
|
||||||
`${rideValid ? (span - strip).toFixed(1) : 0}px`,
|
|
||||||
);
|
|
||||||
page.style.setProperty(
|
|
||||||
"--fade-span",
|
|
||||||
`${rideValid ? ((span - strip) / 2.4).toFixed(1) : 999999}px`,
|
|
||||||
);
|
|
||||||
// The chips row's total leftward slide for the scroll-driven
|
|
||||||
// path: the sum of the layout width both buttons ahead of it in
|
|
||||||
// the flex row shed (22 mirrors --play-shrink; 13 is
|
|
||||||
// .hero-preview's 40→27). On that path the buttons' REAL widths
|
|
||||||
// are frozen and shrink by transform instead, so the flex row no
|
|
||||||
// longer supplies this motion. Presence-aware so a
|
|
||||||
// conditionally-rendered preview button cannot leave a phantom
|
|
||||||
// 13px slide, and validity-gated like --ride: on a degenerate
|
|
||||||
// page the range sentinel would otherwise creep the chips
|
|
||||||
// ~1.75px at extreme scrollTop where today's pinned p holds
|
|
||||||
// them motionless. (The button/glyph scale rides keep constant
|
|
||||||
// to-poses — their same-class residual is ≤1.1px on geometry
|
|
||||||
// the 390px hero-zone minimum makes unreachable; accepted.)
|
|
||||||
page.style.setProperty(
|
|
||||||
"--row-slide",
|
|
||||||
`${
|
|
||||||
rideValid
|
|
||||||
? (page.querySelector(".hero-play") ? 22 : 0) +
|
|
||||||
(page.querySelector(".hero-preview") ? 13 : 0)
|
|
||||||
: 0
|
|
||||||
}px`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ROUND 12 — the sheet's two poses, both in band coordinates.
|
// ROUND 12 — the sheet's two poses, both in band coordinates.
|
||||||
// At rest its gradient sits on the VISIBLE hero (onset at 60% of
|
// At rest its gradient sits on the VISIBLE hero (onset at 60% of
|
||||||
@@ -1280,18 +1267,6 @@
|
|||||||
const fogSpan = Math.round(span * 0.42);
|
const fogSpan = Math.round(span * 0.42);
|
||||||
const fogAbove = Math.round(fogSpan * 0.294);
|
const fogAbove = Math.round(fogSpan * 0.294);
|
||||||
const fogRest = Math.round(span * 0.6);
|
const fogRest = Math.round(span * 0.6);
|
||||||
page.style.setProperty("--fog-span", `${fogSpan}px`);
|
|
||||||
page.style.setProperty("--fog-above", `${fogAbove}px`);
|
|
||||||
page.style.setProperty("--fog-rest", `${fogRest}px`);
|
|
||||||
// The sheet's pose delta for its scroll-driven glide (usually
|
|
||||||
// negative): from-pose translateY(--fog-rest), to-pose
|
|
||||||
// translateY(--fog-rest + --fog-ride) ≡ translateY(strip -
|
|
||||||
// fog-above) — the same two exact endpoints the fallback calc
|
|
||||||
// has. 0px sentinel, same reasoning as --ride above.
|
|
||||||
page.style.setProperty(
|
|
||||||
"--fog-ride",
|
|
||||||
`${rideValid ? (strip - fogAbove - fogRest).toFixed(1) : 0}px`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The content's fade line, precomputed so apply() is arithmetic
|
// The content's fade line, precomputed so apply() is arithmetic
|
||||||
// only: flow-local cut = stripCut + scrollTop. The zone must sit
|
// only: flow-local cut = stripCut + scrollTop. The zone must sit
|
||||||
@@ -1312,6 +1287,11 @@
|
|||||||
// the list rows above the chips' real bottom edge. .stat-chips
|
// the list rows above the chips' real bottom edge. .stat-chips
|
||||||
// rides a translateX (chips-slide-ride), never a translateY, so its
|
// rides a translateX (chips-slide-ride), never a translateY, so its
|
||||||
// measured bottom is as scroll-invariant as the button's.
|
// measured bottom is as scroll-invariant as the button's.
|
||||||
|
// Measured here, ahead of the write block, under the PREVIOUS
|
||||||
|
// pass's custom properties: the quantity is the ink's offset below
|
||||||
|
// --edge, which the glue keeps constant at every p and every
|
||||||
|
// span/strip, so a one-pass-stale pose moves it by at most the
|
||||||
|
// frame's own drift — and the trailing settle pass reads it clean.
|
||||||
const body = page.querySelector(".scrollbody");
|
const body = page.querySelector(".scrollbody");
|
||||||
const rowInk = [
|
const rowInk = [
|
||||||
page.querySelector(".hero-play"),
|
page.querySelector(".hero-play"),
|
||||||
@@ -1352,48 +1332,146 @@
|
|||||||
// exists for. The host sits at .scrollbody's top (= the zone's
|
// exists for. The host sits at .scrollbody's top (= the zone's
|
||||||
// bottom) with a -18px margin hanging it 18px above the
|
// bottom) with a -18px margin hanging it 18px above the
|
||||||
// content top.
|
// content top.
|
||||||
if (scrollDriven && body) {
|
// The last two geometry reads, then the write block.
|
||||||
// A sticky inset resolves from inside the pane's padding
|
const scrollerPadTop = scrollDriven
|
||||||
// (the same 24px BAR_BOTTOM exists to cancel), so `top: X`
|
? parseFloat(getComputedStyle(scroller).paddingTop) || 0
|
||||||
// parks the box at scrollport X + padding — measured live:
|
: 0;
|
||||||
// top 217 parked at 241, a fade line one full slot too low
|
const frameH = scroller.clientHeight;
|
||||||
// AND a 24px content jump at the stick moment, since S below
|
|
||||||
// is derived in scrollport coordinates. Subtracting the
|
// JS state, not style — apply() and the sentinel observers read
|
||||||
// padding parks the box on the exact line the mask fallback
|
// these synchronously, so they are set with the reads, not
|
||||||
// draws, and makes the stick moment land precisely at S.
|
// deferred with the writes.
|
||||||
const padTop =
|
|
||||||
parseFloat(getComputedStyle(scroller).paddingTop) || 0;
|
|
||||||
page.style.setProperty(
|
|
||||||
"--cut-top",
|
|
||||||
`${(cutScreen - padTop).toFixed(1)}px`,
|
|
||||||
);
|
|
||||||
const hostFlowTop = zone.offsetTop + zone.offsetHeight - 18;
|
|
||||||
const fadeS = Math.max(0, hostFlowTop - cutScreen);
|
|
||||||
page.style.setProperty("--fade-s", `${fadeS.toFixed(1)}px`);
|
|
||||||
}
|
|
||||||
// Unconditional: .fade-frame is scroll-driven-only, but .sheetclip
|
|
||||||
// sizes itself from --frame-h on BOTH branches (it is what keeps the
|
|
||||||
// 300vh sheet out of the pane's scroll range). Setting it on the
|
|
||||||
// fallback path is otherwise inert — .fade-frame's rule lives inside
|
|
||||||
// the @supports block and never matches there.
|
|
||||||
page.style.setProperty("--frame-h", `${scroller.clientHeight}px`);
|
|
||||||
// The extras' hit-gate sentinel parks at the scrollTop where
|
|
||||||
// p crosses 0.42. Probed live (2026-07-24, this WebView2): the
|
|
||||||
// observers' rootBounds top coincides with the page's box top
|
|
||||||
// at scrollTop 0 — the pane's 24px padding does NOT offset the
|
|
||||||
// crossing — but the observer reports integer-snapped rects,
|
|
||||||
// so the -1 below folds the sentinel's own 1px height into the
|
|
||||||
// placement: the snapped bottom then reaches the root's top at
|
|
||||||
// the same integer scrollTop the old `p > 0.42` first exceeded
|
|
||||||
// (probed flip: auto at 377, none at 378, boundary 377.58).
|
|
||||||
// Off-screen sentinel on a degenerate page, mirroring travel =
|
|
||||||
// Infinity pinning p at 0 (travelFinite keeps the observers
|
|
||||||
// honest about that case too).
|
|
||||||
travelFinite = Number.isFinite(travel);
|
travelFinite = Number.isFinite(travel);
|
||||||
if (s2) {
|
|
||||||
s2.style.top = travelFinite
|
// ── the write block — geometry above, styles below, applied at
|
||||||
? `${(0.42 * travel - 1).toFixed(1)}px`
|
// the next rAF (latest measurement wins; see `commit`) ──
|
||||||
: "-99999px";
|
commit = () => {
|
||||||
|
if (markEnd != null) {
|
||||||
|
page.style.setProperty("--mark-rest", `${markRest}px`);
|
||||||
|
page.style.setProperty("--mark-end", markEnd);
|
||||||
|
}
|
||||||
|
page.style.setProperty("--row-h", `${rowH}px`);
|
||||||
|
page.style.setProperty("--banner-h", `${banner}px`);
|
||||||
|
// Glue, not travel. --lift cancels .hero-copy's parked term
|
||||||
|
// exactly, so its bottom stays 0 at every p — the wordmark
|
||||||
|
// shrinks in place about its resting baseline. The row needs no
|
||||||
|
// travel var at all any more: its box is pinned to its resting
|
||||||
|
// slot in CSS.
|
||||||
|
page.style.setProperty("--lift", `${banner - 14 - markRest}px`);
|
||||||
|
// Where the pins sit in flow. the glass bar's net flow height is
|
||||||
|
// zero (-24 + 46 - 22), so the hero zone starts at exactly the
|
||||||
|
// same offset the pins do — and it is `position: relative`, so
|
||||||
|
// asking it cannot feed a sticky offset back into the measurement
|
||||||
|
// the way asking a pin would.
|
||||||
|
page.style.setProperty("--pin-origin", `${zoneTop}px`);
|
||||||
|
page.style.setProperty("--hero-span", `${span}px`);
|
||||||
|
page.style.setProperty("--strip", `${strip}px`);
|
||||||
|
// The scroll-driven animations' constants (see the second
|
||||||
|
// @supports (animation-timeline: scroll()) block in the CSS).
|
||||||
|
// --travel is the shared animation-range span — the p
|
||||||
|
// denominator — so linear easing over 0..--travel gives
|
||||||
|
// animation progress ≡ p at every scrollTop. The 999999px
|
||||||
|
// sentinel mirrors travel = Infinity above (p pinned ~0: no
|
||||||
|
// animation progresses measurably), and every consumer reads
|
||||||
|
// var(--travel, 999999px) so the pre-measure first paint is
|
||||||
|
// inert too. --ride is the SAME delta as a length for
|
||||||
|
// to-keyframes, and must be a separate variable: tied to the
|
||||||
|
// range sentinel, a degenerate page's to-value would cancel
|
||||||
|
// into translate = -scrollTop; the 0px sentinel keeps it
|
||||||
|
// motionless exactly as p = 0 does today. --fade-span is the
|
||||||
|
// extras/badge fade's own range (travel / 2.4, where their
|
||||||
|
// opacity ramp clamps): dividing the 999999px sentinel instead
|
||||||
|
// would let a deep scroll on a degenerate page ghost-fade chips
|
||||||
|
// that today hold opacity 1 at any scrollTop.
|
||||||
|
page.style.setProperty(
|
||||||
|
"--travel",
|
||||||
|
`${rideValid ? (span - strip).toFixed(1) : 999999}px`,
|
||||||
|
);
|
||||||
|
page.style.setProperty(
|
||||||
|
"--ride",
|
||||||
|
`${rideValid ? (span - strip).toFixed(1) : 0}px`,
|
||||||
|
);
|
||||||
|
page.style.setProperty(
|
||||||
|
"--fade-span",
|
||||||
|
`${rideValid ? ((span - strip) / 2.4).toFixed(1) : 999999}px`,
|
||||||
|
);
|
||||||
|
// The chips row's total leftward slide for the scroll-driven
|
||||||
|
// path: the sum of the layout width both buttons ahead of it in
|
||||||
|
// the flex row shed (22 mirrors --play-shrink; 13 is
|
||||||
|
// .hero-preview's 40→27). On that path the buttons' REAL widths
|
||||||
|
// are frozen and shrink by transform instead, so the flex row no
|
||||||
|
// longer supplies this motion. Presence-aware so a
|
||||||
|
// conditionally-rendered preview button cannot leave a phantom
|
||||||
|
// 13px slide, and validity-gated like --ride: on a degenerate
|
||||||
|
// page the range sentinel would otherwise creep the chips
|
||||||
|
// ~1.75px at extreme scrollTop where today's pinned p holds
|
||||||
|
// them motionless. (The button/glyph scale rides keep constant
|
||||||
|
// to-poses — their same-class residual is ≤1.1px on geometry
|
||||||
|
// the 390px hero-zone minimum makes unreachable; accepted.)
|
||||||
|
page.style.setProperty(
|
||||||
|
"--row-slide",
|
||||||
|
`${rideValid ? (hasPlay ? 22 : 0) + (hasPreview ? 13 : 0) : 0}px`,
|
||||||
|
);
|
||||||
|
page.style.setProperty("--fog-span", `${fogSpan}px`);
|
||||||
|
page.style.setProperty("--fog-above", `${fogAbove}px`);
|
||||||
|
page.style.setProperty("--fog-rest", `${fogRest}px`);
|
||||||
|
// The sheet's pose delta for its scroll-driven glide (usually
|
||||||
|
// negative): from-pose translateY(--fog-rest), to-pose
|
||||||
|
// translateY(--fog-rest + --fog-ride) ≡ translateY(strip -
|
||||||
|
// fog-above) — the same two exact endpoints the fallback calc
|
||||||
|
// has. 0px sentinel, same reasoning as --ride above.
|
||||||
|
page.style.setProperty(
|
||||||
|
"--fog-ride",
|
||||||
|
`${rideValid ? (strip - fogAbove - fogRest).toFixed(1) : 0}px`,
|
||||||
|
);
|
||||||
|
if (scrollDriven && body) {
|
||||||
|
// A sticky inset resolves from inside the pane's padding
|
||||||
|
// (the same 24px BAR_BOTTOM exists to cancel), so `top: X`
|
||||||
|
// parks the box at scrollport X + padding — measured live:
|
||||||
|
// top 217 parked at 241, a fade line one full slot too low
|
||||||
|
// AND a 24px content jump at the stick moment, since S below
|
||||||
|
// is derived in scrollport coordinates. Subtracting the
|
||||||
|
// padding parks the box on the exact line the mask fallback
|
||||||
|
// draws, and makes the stick moment land precisely at S.
|
||||||
|
page.style.setProperty(
|
||||||
|
"--cut-top",
|
||||||
|
`${(cutScreen - scrollerPadTop).toFixed(1)}px`,
|
||||||
|
);
|
||||||
|
const hostFlowTop = zoneTop + zoneH - 18;
|
||||||
|
const fadeS = Math.max(0, hostFlowTop - cutScreen);
|
||||||
|
page.style.setProperty("--fade-s", `${fadeS.toFixed(1)}px`);
|
||||||
|
}
|
||||||
|
// Unconditional: .fade-frame is scroll-driven-only, but .sheetclip
|
||||||
|
// sizes itself from --frame-h on BOTH branches (it is what keeps the
|
||||||
|
// 300vh sheet out of the pane's scroll range). Setting it on the
|
||||||
|
// fallback path is otherwise inert — .fade-frame's rule lives inside
|
||||||
|
// the @supports block and never matches there.
|
||||||
|
page.style.setProperty("--frame-h", `${frameH}px`);
|
||||||
|
// The extras' hit-gate sentinel parks at the scrollTop where
|
||||||
|
// p crosses 0.42. Probed live (2026-07-24, this WebView2): the
|
||||||
|
// observers' rootBounds top coincides with the page's box top
|
||||||
|
// at scrollTop 0 — the pane's 24px padding does NOT offset the
|
||||||
|
// crossing — but the observer reports integer-snapped rects,
|
||||||
|
// so the -1 below folds the sentinel's own 1px height into the
|
||||||
|
// placement: the snapped bottom then reaches the root's top at
|
||||||
|
// the same integer scrollTop the old `p > 0.42` first exceeded
|
||||||
|
// (probed flip: auto at 377, none at 378, boundary 377.58).
|
||||||
|
// Off-screen sentinel on a degenerate page, mirroring travel =
|
||||||
|
// Infinity pinning p at 0 (travelFinite keeps the observers
|
||||||
|
// honest about that case too).
|
||||||
|
if (s2) {
|
||||||
|
s2.style.top = travelFinite
|
||||||
|
? `${(0.42 * travel - 1).toFixed(1)}px`
|
||||||
|
: "-99999px";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!commitRaf) {
|
||||||
|
commitRaf = requestAnimationFrame(() => {
|
||||||
|
commitRaf = 0;
|
||||||
|
const c = commit;
|
||||||
|
commit = null;
|
||||||
|
c?.();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1438,8 +1516,8 @@
|
|||||||
if (!frame) frame = requestAnimationFrame(apply);
|
if (!frame) frame = requestAnimationFrame(apply);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reflow() {
|
function reflow(settled = true) {
|
||||||
measure();
|
measure(settled);
|
||||||
// Supported path: nothing per-frame is left to apply — the
|
// Supported path: nothing per-frame is left to apply — the
|
||||||
// observers re-deliver on their own once measure() has moved
|
// observers re-deliver on their own once measure() has moved
|
||||||
// the sentinels, and running apply() here would pay the exact
|
// the sentinels, and running apply() here would pay the exact
|
||||||
@@ -1580,7 +1658,9 @@
|
|||||||
// reschedules itself, so there is no loop.
|
// reschedules itself, so there is no loop.
|
||||||
let settleTimer = 0;
|
let settleTimer = 0;
|
||||||
const reflowAndSettle = () => {
|
const reflowAndSettle = () => {
|
||||||
reflow();
|
// Mid-drag pass: parked chips reuse their cached height. The
|
||||||
|
// trailing pass (bare reflow, settled = true) measures exactly.
|
||||||
|
reflow(false);
|
||||||
clearTimeout(settleTimer);
|
clearTimeout(settleTimer);
|
||||||
settleTimer = setTimeout(reflow, 200);
|
settleTimer = setTimeout(reflow, 200);
|
||||||
};
|
};
|
||||||
@@ -1653,6 +1733,8 @@
|
|||||||
s1?.remove();
|
s1?.remove();
|
||||||
s2?.remove();
|
s2?.remove();
|
||||||
if (frame) cancelAnimationFrame(frame);
|
if (frame) cancelAnimationFrame(frame);
|
||||||
|
if (commitRaf) cancelAnimationFrame(commitRaf);
|
||||||
|
commit = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2730,8 +2812,36 @@
|
|||||||
// what keeps the reveal's per-frame re-stand authoritative.
|
// what keeps the reveal's per-frame re-stand authoritative.
|
||||||
let glide = 0;
|
let glide = 0;
|
||||||
let glideTo = null;
|
let glideTo = null;
|
||||||
|
// "Instant" writes land in the NEXT rAF, never inside the
|
||||||
|
// ResizeObserver delivery that asked for them. A write during
|
||||||
|
// delivery makes the observer loop re-run style and layout in the
|
||||||
|
// SAME frame to check for further changes — a whole extra layout
|
||||||
|
// pass per frame, paid on every frame of a drag while the reserve
|
||||||
|
// tracks the moving margin. Deferred one rAF, the write rides the
|
||||||
|
// frame's single natural layout, and the echo delivery it provokes
|
||||||
|
// is filtered by the entry guard below without a DOM read. A frame
|
||||||
|
// of lag on a min-height floor is invisible — and the reveal never
|
||||||
|
// waits on this path: it stands its own reserve synchronously.
|
||||||
|
let pendingTo = null;
|
||||||
|
let flushRaf = 0;
|
||||||
|
const flushReserve = () => {
|
||||||
|
flushRaf = 0;
|
||||||
|
if (pendingTo == null || !node.isConnected) {
|
||||||
|
pendingTo = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const standing = parseFloat(node.style.minHeight) || 0;
|
||||||
|
const to = pendingTo;
|
||||||
|
pendingTo = null;
|
||||||
|
// Never lower under a running reveal: it may have raised the
|
||||||
|
// standing value past this queued figure since the queueing pass,
|
||||||
|
// and dropping beneath it would clamp the pane mid-ease.
|
||||||
|
if (revealActive && to < standing) return;
|
||||||
|
if (to !== standing) node.style.minHeight = `${to}px`;
|
||||||
|
};
|
||||||
const setReserve = (to) => {
|
const setReserve = (to) => {
|
||||||
const from = parseFloat(node.style.minHeight) || 0;
|
const standing = parseFloat(node.style.minHeight) || 0;
|
||||||
|
const from = pendingTo ?? standing;
|
||||||
if (glide) {
|
if (glide) {
|
||||||
if (to <= glideTo) return; // already heading there or lower
|
if (to <= glideTo) return; // already heading there or lower
|
||||||
cancelAnimationFrame(glide);
|
cancelAnimationFrame(glide);
|
||||||
@@ -2747,9 +2857,12 @@
|
|||||||
// fall happens afterwards, from retire(), where it can glide.
|
// fall happens afterwards, from retire(), where it can glide.
|
||||||
if (revealActive && to < from) return;
|
if (revealActive && to < from) return;
|
||||||
if (to >= from - 2 || revealActive) {
|
if (to >= from - 2 || revealActive) {
|
||||||
if (node.style.minHeight !== `${to}px`) node.style.minHeight = `${to}px`;
|
if (pendingTo === to || (pendingTo == null && standing === to)) return;
|
||||||
|
pendingTo = to;
|
||||||
|
if (!flushRaf) flushRaf = requestAnimationFrame(flushReserve);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
pendingTo = null;
|
||||||
glideTo = to;
|
glideTo = to;
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
const DUR = 260;
|
const DUR = 260;
|
||||||
@@ -2757,24 +2870,63 @@
|
|||||||
if (!node.isConnected) { glide = 0; glideTo = null; return; }
|
if (!node.isConnected) { glide = 0; glideTo = null; return; }
|
||||||
const t = Math.min(1, (now - start) / DUR);
|
const t = Math.min(1, (now - start) / DUR);
|
||||||
const ease = 1 - (1 - t) ** 3;
|
const ease = 1 - (1 - t) ** 3;
|
||||||
node.style.minHeight = `${Math.round(from + (to - from) * ease)}px`;
|
node.style.minHeight = `${Math.round(standing + (to - standing) * ease)}px`;
|
||||||
if (t < 1) { glide = requestAnimationFrame(step); return; }
|
if (t < 1) { glide = requestAnimationFrame(step); return; }
|
||||||
glide = 0;
|
glide = 0;
|
||||||
glideTo = null;
|
glideTo = null;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Found once, re-queried only after a teardown replaced them: the
|
||||||
|
// pane and the tab bar cannot move while this grid is mounted.
|
||||||
|
let cachedPane = null;
|
||||||
|
let cachedBar = null;
|
||||||
|
// The reveal reserve's expensive half — the open row's offset inside
|
||||||
|
// the grid. Width alone cannot move it (tracks are fixed 150px): it
|
||||||
|
// moves when the expander changes ROW — a column-count change, a
|
||||||
|
// different album, art landing above it. Recomputing it on every
|
||||||
|
// frame of a drag paid rects plus a subtree sweep 48 times for a
|
||||||
|
// handful of distinct answers, and was most of the difference between
|
||||||
|
// ~20ms and ~60ms p95 frames with a panel open. Keyed on exactly what
|
||||||
|
// invalidates it; forced() clears the key for the DOM-driven cases
|
||||||
|
// (open, row change, close, art landing).
|
||||||
|
let rowOffCols = -1;
|
||||||
|
let rowOffXp = null;
|
||||||
|
let rowOffRow = null;
|
||||||
|
let rowOff = 0;
|
||||||
|
// A column-count change moves the expander only on Svelte's NEXT
|
||||||
|
// flush, so an offset read in the same pass sees the old layout.
|
||||||
|
let colsSettling = false;
|
||||||
|
|
||||||
const measure = () => {
|
const measure = () => {
|
||||||
const n = getComputedStyle(node).gridTemplateColumns.split(" ").length;
|
const n = getComputedStyle(node).gridTemplateColumns.split(" ").length;
|
||||||
if (gridCols[groupKey] !== n) gridCols = { ...gridCols, [groupKey]: n };
|
if (gridCols[groupKey] !== n) {
|
||||||
|
gridCols = { ...gridCols, [groupKey]: n };
|
||||||
|
// Re-measure once the expander has landed on its new row —
|
||||||
|
// the offset cached this pass is against a layout that is
|
||||||
|
// about to change. (Today's per-frame recompute healed on the
|
||||||
|
// next drag frame; a drag that ENDS on a column change has no
|
||||||
|
// next frame, so this settle pass also closes that hole.)
|
||||||
|
if (!colsSettling && node.querySelector(".album-expander")) {
|
||||||
|
colsSettling = true;
|
||||||
|
void tick().then(() => {
|
||||||
|
colsSettling = false;
|
||||||
|
if (node.isConnected) forced();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pane = scrollPaneFor(node);
|
if (!cachedPane?.isConnected) cachedPane = scrollPaneFor(node);
|
||||||
// Anchor on the TAB BAR, not the heading: the bar is what
|
// Anchor on the TAB BAR, not the heading: the bar is what
|
||||||
// `selectTab` pins, and the worst case is the bar sitting flush
|
// `selectTab` pins, and the worst case is the bar sitting flush
|
||||||
// at the pane's top. Everything below it (the bar itself + this
|
// at the pane's top. Everything below it (the bar itself + this
|
||||||
// grid) must then still fill a pane-height, or the scroll clamps.
|
// grid) must then still fill a pane-height, or the scroll clamps.
|
||||||
const bar = node.closest(".pane")?.querySelector(".disco-tabs")
|
if (!cachedBar?.isConnected) {
|
||||||
?? document.querySelector(".disco-tabs");
|
cachedBar = node.closest(".pane")?.querySelector(".disco-tabs")
|
||||||
|
?? document.querySelector(".disco-tabs");
|
||||||
|
}
|
||||||
|
const pane = cachedPane;
|
||||||
|
const bar = cachedBar;
|
||||||
if (!pane || !bar) return;
|
if (!pane || !bar) return;
|
||||||
const chrome = node.getBoundingClientRect().top - bar.getBoundingClientRect().top;
|
const chrome = node.getBoundingClientRect().top - bar.getBoundingClientRect().top;
|
||||||
let reserve = Math.max(0, Math.round(pane.clientHeight - chrome));
|
let reserve = Math.max(0, Math.round(pane.clientHeight - chrome));
|
||||||
@@ -2799,18 +2951,82 @@
|
|||||||
const xp = node.querySelector(".album-expander:not([inert])")
|
const xp = node.querySelector(".album-expander:not([inert])")
|
||||||
?? node.querySelector(".album-expander");
|
?? node.querySelector(".album-expander");
|
||||||
const openRow = xp?.previousElementSibling;
|
const openRow = xp?.previousElementSibling;
|
||||||
if (openRow) reserve = Math.max(reserve, revealReserve(node, openRow, pane, xp));
|
if (openRow) {
|
||||||
|
if (n !== rowOffCols || xp !== rowOffXp || openRow !== rowOffRow) {
|
||||||
|
rowOff = revealRowOffset(node, openRow, xp);
|
||||||
|
rowOffCols = n;
|
||||||
|
rowOffXp = xp;
|
||||||
|
rowOffRow = openRow;
|
||||||
|
}
|
||||||
|
// The cheap half stays live every frame: the pane's height and
|
||||||
|
// the banner margin move with the window, the row offset does
|
||||||
|
// not.
|
||||||
|
reserve = Math.max(
|
||||||
|
reserve,
|
||||||
|
Math.round(rowOff + pane.clientHeight - revealMargin(node)),
|
||||||
|
);
|
||||||
|
}
|
||||||
setReserve(reserve);
|
setReserve(reserve);
|
||||||
};
|
};
|
||||||
|
// The resize path still runs EVERY frame — the grid keeps scaling live.
|
||||||
|
// What it stops doing is repeating work whose inputs have not moved.
|
||||||
|
//
|
||||||
|
// The reserve is a pure function of the grid's width and the pane's
|
||||||
|
// height. When neither has changed since the last pass — which is every
|
||||||
|
// observer callback provoked by our own minHeight write, and every
|
||||||
|
// vertical-only resize — recomputing it cannot produce a different
|
||||||
|
// answer, so the guard is exact rather than approximate.
|
||||||
|
//
|
||||||
|
// Sizes come from the ENTRIES, never from clientWidth/clientHeight: an
|
||||||
|
// entry is a post-layout fact, but a DOM read here forces layout
|
||||||
|
// whenever anything earlier in the same delivery has written — and the
|
||||||
|
// reserve's own write provokes an echo delivery every time it moves.
|
||||||
|
// The pane is observed alongside the grid for the same reason; its
|
||||||
|
// height rides in with the entries and the echo path never touches
|
||||||
|
// the DOM at all.
|
||||||
|
let lastW = -1;
|
||||||
|
let lastPaneH = -1;
|
||||||
|
const onResize = (entries) => {
|
||||||
|
let w = lastW;
|
||||||
|
let paneH = lastPaneH;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const box = entry.borderBoxSize?.[0];
|
||||||
|
if (entry.target === node) {
|
||||||
|
w = Math.round(box ? box.inlineSize : entry.contentRect.width);
|
||||||
|
} else {
|
||||||
|
paneH = Math.round(box ? box.blockSize : entry.contentRect.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (w === lastW && paneH === lastPaneH) return;
|
||||||
|
lastW = w;
|
||||||
|
lastPaneH = paneH;
|
||||||
|
measure();
|
||||||
|
};
|
||||||
|
// Anything that changes the DOM rather than the box — an expander
|
||||||
|
// opening, a row change, a close — must not be filtered by that guard:
|
||||||
|
// its inputs are unchanged but its answer is not. The row-offset cache
|
||||||
|
// is keyed on the same DOM, so it drops here too.
|
||||||
|
const forced = () => {
|
||||||
|
lastW = -1;
|
||||||
|
lastPaneH = -1;
|
||||||
|
rowOffCols = -1;
|
||||||
|
rowOffXp = null;
|
||||||
|
rowOffRow = null;
|
||||||
|
measure();
|
||||||
|
};
|
||||||
|
|
||||||
measure();
|
measure();
|
||||||
gridMeasures.add(measure);
|
gridMeasures.add(forced);
|
||||||
const ro = new ResizeObserver(measure);
|
const ro = new ResizeObserver(onResize);
|
||||||
ro.observe(node);
|
ro.observe(node);
|
||||||
|
const paneEl = cachedPane ?? scrollPaneFor(node);
|
||||||
|
if (paneEl) ro.observe(paneEl);
|
||||||
return {
|
return {
|
||||||
destroy: () => {
|
destroy: () => {
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
gridMeasures.delete(measure);
|
gridMeasures.delete(forced);
|
||||||
cancelAnimationFrame(glide);
|
cancelAnimationFrame(glide);
|
||||||
|
cancelAnimationFrame(flushRaf);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3280,6 +3496,16 @@
|
|||||||
* that then walked back toward it, and the page moved three times. */
|
* that then walked back toward it, and the page moved three times. */
|
||||||
function vanishingAbove(grid, row, keep) {
|
function vanishingAbove(grid, row, keep) {
|
||||||
if (!grid) return 0;
|
if (!grid) return 0;
|
||||||
|
// Gather before measuring: with a single expander standing — every
|
||||||
|
// frame outside the 460ms of a row switch — there is nothing on its
|
||||||
|
// way out, and the rect and style reads below would be paid for an
|
||||||
|
// answer that is always zero. This runs on every frame of a reveal
|
||||||
|
// ease, so the empty case must cost only the subtree query.
|
||||||
|
let others = null;
|
||||||
|
for (const other of grid.querySelectorAll(".album-expander")) {
|
||||||
|
if (other !== keep) (others ??= []).push(other);
|
||||||
|
}
|
||||||
|
if (!others) return 0;
|
||||||
const rowTop = row.getBoundingClientRect().top;
|
const rowTop = row.getBoundingClientRect().top;
|
||||||
// The expander is a GRID ITEM on a row of its own, so the row-gap that
|
// The expander is a GRID ITEM on a row of its own, so the row-gap that
|
||||||
// separates it from its neighbour goes with it. Counting only its
|
// separates it from its neighbour goes with it. Counting only its
|
||||||
@@ -3291,8 +3517,7 @@
|
|||||||
// is exactly what makes a 10px move read as a bump.
|
// is exactly what makes a 10px move read as a bump.
|
||||||
const gap = parseFloat(getComputedStyle(grid).rowGap) || 0;
|
const gap = parseFloat(getComputedStyle(grid).rowGap) || 0;
|
||||||
let px = 0;
|
let px = 0;
|
||||||
for (const other of grid.querySelectorAll(".album-expander")) {
|
for (const other of others) {
|
||||||
if (other === keep) continue;
|
|
||||||
const r = other.getBoundingClientRect();
|
const r = other.getBoundingClientRect();
|
||||||
// Its whole outer contribution: height, its own collapse margin,
|
// Its whole outer contribution: height, its own collapse margin,
|
||||||
// and the row-gap only if the grid still uses one. expandSlide
|
// and the row-gap only if the grid still uses one. expandSlide
|
||||||
@@ -3304,11 +3529,20 @@
|
|||||||
return px;
|
return px;
|
||||||
}
|
}
|
||||||
|
|
||||||
function revealReserve(grid, row, pane, keep) {
|
/** The open row's offset inside the grid, net of anything mid-outro above
|
||||||
const rowInGrid = row.getBoundingClientRect().top
|
* it — the half of the reserve that costs layout reads. Split from
|
||||||
|
* revealReserve so measureCols can cache it across a drag: width alone
|
||||||
|
* cannot move it (tracks are fixed 150px), only a row change can. */
|
||||||
|
function revealRowOffset(grid, row, keep) {
|
||||||
|
return row.getBoundingClientRect().top
|
||||||
- grid.getBoundingClientRect().top
|
- grid.getBoundingClientRect().top
|
||||||
- vanishingAbove(grid, row, keep);
|
- vanishingAbove(grid, row, keep);
|
||||||
return Math.round(rowInGrid + pane.clientHeight - revealMargin(grid));
|
}
|
||||||
|
|
||||||
|
function revealReserve(grid, row, pane, keep) {
|
||||||
|
return Math.round(
|
||||||
|
revealRowOffset(grid, row, keep) + pane.clientHeight - revealMargin(grid),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function revealIntoView(node) {
|
function revealIntoView(node) {
|
||||||
|
|||||||
Reference in new issue
Block a user