bugfix: reader-nav is fully fixed; no settle-on-scroll (0.21.3)

This commit is contained in:
MechaCat02
2026-05-17 20:57:05 +02:00
parent 64ccc0ba84
commit 89b8785a40
6 changed files with 73 additions and 33 deletions

2
backend/Cargo.lock generated
View File

@@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.21.2" version = "0.21.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.21.2" version = "0.21.3"
edition = "2021" edition = "2021"
[lib] [lib]

View File

@@ -1,6 +1,6 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.21.2", "version": "0.21.3",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -61,10 +61,12 @@
--icon-lg: 22px; --icon-lg: 22px;
/* App-frame heights (fixed-position bars at the top and bottom of /* App-frame heights (fixed-position bars at the top and bottom of
the viewport). Used by the layout + reader to reserve content the viewport). These are first-paint fallbacks — the real
space and animate fullscreen mode. Recomputed once if the values are written by ResizeObservers on the actual elements
header padding/font-size ever changes — keep in sync. */ in +layout.svelte and the reader, so they reflect rendered
size and survive font / zoom / wrap changes. */
--app-header-h: 60px; --app-header-h: 60px;
--reader-nav-h: 56px;
--reader-bar-h: 56px; --reader-bar-h: 56px;
--z-dropdown: 10; --z-dropdown: 10;

View File

@@ -226,9 +226,9 @@
main { main {
padding: var(--space-4); padding: var(--space-4);
/* Reserve room for the fixed header so its presence doesn't /* Reserve room for the fixed header so its presence doesn't
overlap content. The min-height is a fallback that matches overlap content. The header height comes from a runtime
the header at typical viewport sizes (6072px); resize ResizeObserver (see onMount above) so this always tracks
observers would be more accurate but the gap is forgiving. */ the rendered size. */
padding-top: calc(var(--app-header-h) + var(--space-4)); padding-top: calc(var(--app-header-h) + var(--space-4));
max-width: 64rem; max-width: 64rem;
margin: 0 auto; margin: 0 auto;
@@ -236,6 +236,8 @@
} }
:global(html[data-reader-fullscreen='true']) main { :global(html[data-reader-fullscreen='true']) main {
padding-top: var(--space-4); /* No top reservation in focus mode — the chapter image runs
edge-to-edge once the header has slid off. */
padding-top: 0;
} }
</style> </style>

View File

@@ -69,6 +69,31 @@
let index = $state(initialIndex); let index = $state(initialIndex);
let continuousPageEls: HTMLImageElement[] = $state([]); let continuousPageEls: HTMLImageElement[] = $state([]);
let chapterBarEl: HTMLElement | undefined = $state(); let chapterBarEl: HTMLElement | undefined = $state();
let readerNavEl: HTMLElement | undefined = $state();
// Publish the reader nav's actual measured height. Sticky
// positioning had a "settle on scroll" effect: the bar's natural
// position sat 16px below the app header (main's space-4 padding),
// and only docked against the header once the user scrolled
// enough to consume that gap. The fix is to lift the bar out of
// document flow entirely (position: fixed) and reserve space in
// the chapter content via `--reader-nav-h`.
$effect(() => {
if (!readerNavEl) return;
const publish = () => {
document.documentElement.style.setProperty(
'--reader-nav-h',
`${readerNavEl!.offsetHeight}px`
);
};
publish();
const ro = new ResizeObserver(publish);
ro.observe(readerNavEl);
return () => {
ro.disconnect();
document.documentElement.style.removeProperty('--reader-nav-h');
};
});
// Publish the bottom chapter-bar's actual measured height so the // Publish the bottom chapter-bar's actual measured height so the
// continuous container's `padding-bottom` exactly matches it. Same // continuous container's `padding-bottom` exactly matches it. Same
@@ -389,7 +414,7 @@
<title>{pageTitle}</title> <title>{pageTitle}</title>
</svelte:head> </svelte:head>
<nav class="reader-nav" aria-label="reader"> <nav class="reader-nav" aria-label="reader" bind:this={readerNavEl}>
<a href="/manga/{manga.id}" class="back" data-testid="back-to-manga"> <a href="/manga/{manga.id}" class="back" data-testid="back-to-manga">
<ArrowLeft size={18} aria-hidden="true" /> <ArrowLeft size={18} aria-hidden="true" />
{#if manga.cover_image_path} {#if manga.cover_image_path}
@@ -605,45 +630,36 @@
{/if} {/if}
<style> <style>
/* Sticky directly under the fixed layout header. `top: var(--app-header-h)` /* Pinned to the viewport directly below the (also fixed) layout
pins the bar's top edge to the bottom edge of the layout header. `position: fixed` rather than `sticky` because the
header so it never slides under it. Focus mode slides it up latter would have a "settle on scroll" period until its natural
off-screen via a transform AND clips its height to 0 so the position consumes main's padding-top. Chapter content reserves
chapter pages get the full top of the viewport. */ room for this bar via `--reader-nav-h` (measured at runtime by
a ResizeObserver in onMount). Focus mode slides it up
off-screen via transform — the fixed origin keeps the slide
distance equal to the bar's height regardless of scroll. */
.reader-nav { .reader-nav {
position: sticky; position: fixed;
top: var(--app-header-h); top: var(--app-header-h);
left: 0;
right: 0;
z-index: 10; z-index: 10;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding-top: var(--space-2); padding: var(--space-2) var(--space-4);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
margin: 0 calc(-1 * var(--space-4)) var(--space-3);
padding-left: var(--space-4);
padding-right: var(--space-4);
background: var(--bg); background: var(--bg);
gap: var(--space-3); gap: var(--space-3);
flex-wrap: wrap; flex-wrap: wrap;
transition: transition:
transform 220ms ease-out, transform 220ms ease-out,
max-height 220ms ease-out, opacity 220ms ease-out;
opacity 220ms ease-out,
padding 220ms ease-out,
margin 220ms ease-out;
max-height: 200px;
overflow: hidden;
} }
:global(html[data-reader-fullscreen='true']) .reader-nav { :global(html[data-reader-fullscreen='true']) .reader-nav {
transform: translateY(-100%); transform: translateY(-100%);
max-height: 0;
padding-top: 0;
padding-bottom: 0;
margin-bottom: 0;
opacity: 0; opacity: 0;
border-bottom-color: transparent;
pointer-events: none; pointer-events: none;
} }
@@ -763,17 +779,37 @@
color: var(--text-muted); color: var(--text-muted);
} }
/* Reserve room for the fixed reader-nav above so the first page
image (single mode) and the top of the chapter stack
(continuous mode) aren't hidden behind it. The variable is
written by the ResizeObserver in onMount so the reservation
always matches actual rendered height. Focus mode collapses
the reservation in lockstep with the bar's slide-out. */
.page-wrap { .page-wrap {
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto;
gap: var(--space-2); gap: var(--space-2);
align-items: center; align-items: center;
padding-top: var(--reader-nav-h);
transition: padding-top 220ms ease-out;
} }
.continuous { .continuous {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding-top: var(--reader-nav-h);
transition:
padding-top 220ms ease-out,
padding-bottom 220ms ease-out;
}
:global(html[data-reader-fullscreen='true']) .page-wrap,
:global(html[data-reader-fullscreen='true']) .continuous {
padding-top: 0;
}
:global(html[data-reader-fullscreen='true']) .continuous {
padding-bottom: 0;
} }
.page-image { .page-image {