fix: make the reader's typography, width and arrow keys work

Typography was in the stylesheet foliate prepends, which the book overrides;
moved to the appended one. The window keydown handler was never bound.

Also: wider reading area, page on a card, arrows moved onto it, spread gutter,
and the sidebar stays open on chapter clicks.
This commit is contained in:
2026-08-11 22:45:14 -04:00
parent 6318115380
commit 9155129ccd
5 changed files with 135 additions and 62 deletions
@@ -95,8 +95,9 @@
}
function navigateToChapter(href: string) {
// Deliberately leaves the sidebar open: picking a chapter is usually part of
// browsing several, and closing it each time made that tedious.
void viewer?.goTo(href);
isSidebarOpen = false;
}
onMount(() => {
@@ -191,18 +192,10 @@
</div>
{/if}
<div class="flex min-h-0 flex-1 items-stretch {isReady ? '' : 'hidden'}">
<Button
variant="ghost"
size="icon"
class="my-auto size-10 shrink-0"
onclick={() => viewer?.goBack()}
aria-label="Previous page"
>
<ChevronLeft class="size-5" />
</Button>
<div class="min-w-0 flex-1">
<div class="min-h-0 flex-1 p-4 {isReady ? '' : 'hidden'}">
<!-- The page itself: same colour the book is themed to, so the two
meet seamlessly and the border reads as the edge of the sheet. -->
<div class="relative h-full overflow-hidden rounded-lg border bg-card shadow-sm">
{#if file}
<FoliateView
bind:this={viewer}
@@ -219,17 +212,29 @@
}}
/>
{/if}
</div>
<Button
variant="ghost"
size="icon"
class="my-auto size-10 shrink-0"
onclick={() => viewer?.goForward()}
aria-label="Next page"
>
<ChevronRight class="size-5" />
</Button>
<!-- Overlaid rather than placed beside the page, so they stay within
reach of the text. They sit in foliate's own page margin. -->
<Button
variant="ghost"
size="icon"
class="absolute top-1/2 left-1 size-10 -translate-y-1/2 opacity-40 transition-opacity hover:opacity-100"
onclick={() => viewer?.goBack()}
aria-label="Previous page"
>
<ChevronLeft class="size-5" />
</Button>
<Button
variant="ghost"
size="icon"
class="absolute top-1/2 right-1 size-10 -translate-y-1/2 opacity-40 transition-opacity hover:opacity-100"
onclick={() => viewer?.goForward()}
aria-label="Next page"
>
<ChevronRight class="size-5" />
</Button>
</div>
</div>
{/if}
</main>
@@ -48,6 +48,20 @@
// Reactive so the effects below re-run once the book is open — otherwise a
// theme flip while it was still loading would never reach the book.
let ready = $state(false);
let hostWidth = $state(0);
/**
* How many columns the paginator will actually lay out.
*
* It decides this internally and exposes it only as a custom property inside
* its shadow root, so the formula is mirrored here to place the spread
* divider. Kept in step with paginator.js's `divisor`.
*/
const columns = $derived(
settings.flow === 'scrolled' || hostWidth === 0
? 1
: Math.min(settings.maxColumnCount, Math.ceil(hostWidth / settings.maxInlineSize))
);
/**
* Where to resume from.
@@ -130,6 +144,11 @@
onMount(() => {
let disposed = false;
const resizeObserver = new ResizeObserver(([entry]) => {
hostWidth = entry.contentRect.width;
});
if (container) resizeObserver.observe(container);
(async () => {
try {
await loadFoliate();
@@ -180,6 +199,7 @@
return () => {
disposed = true;
resizeObserver.disconnect();
for (const { doc } of view?.renderer?.getContents?.() ?? []) {
doc.removeEventListener('keydown', onKeydown);
}
@@ -211,9 +231,25 @@
});
</script>
<svelte:window {onkeydown} />
<!--
Not {onkeydown}: that shorthand resolves to the global window.onkeydown
property, which TypeScript accepts and which is null at runtime, so the
handler silently never ran and arrows only worked once the book had focus.
-->
<svelte:window onkeydown={onKeydown} />
<div bind:this={container} class="foliate-host"></div>
<div class="relative h-full w-full">
<div bind:this={container} class="foliate-host"></div>
{#if ready && columns === 2}
<!-- The gutter between the two pages of a spread. Sits in the column gap,
so it never crosses text. -->
<div
aria-hidden="true"
class="pointer-events-none absolute inset-y-[6%] left-1/2 w-px -translate-x-1/2 bg-border"
></div>
{/if}
</div>
<style>
/* foliate-view extends bare HTMLElement, so it has no default display. */
+5 -2
View File
@@ -12,7 +12,10 @@ export const DEFAULT_READER_SETTINGS: ReaderSettings = {
letterSpacing: 0,
margin: 48,
gap: 6,
maxInlineSize: 720,
// foliate caps the reading area at this times the column count, so 1000 gives
// a 2000px spread — enough to fill a large display rather than leaving the
// book as a narrow strip. Its own default of 720 is noticeably tight.
maxInlineSize: 1000,
maxColumnCount: 2,
flow: 'paginated',
justify: true,
@@ -30,7 +33,7 @@ export const READER_BOUNDS = {
letterSpacing: { min: -0.05, max: 0.2, step: 0.01 },
margin: { min: 0, max: 120, step: 4 },
gap: { min: 0, max: 15, step: 1 },
maxInlineSize: { min: 480, max: 1400, step: 20 }
maxInlineSize: { min: 400, max: 2400, step: 20 }
} as const;
/**
+58 -33
View File
@@ -35,43 +35,37 @@ export function readReaderPalette(dark: boolean): ReaderPalette {
};
}
/** Body-text containers. Headings are excluded so their scale survives. */
const TEXT = 'p, li, dd, dt, blockquote, td, th, div';
const HEADINGS = 'h1, h2, h3, h4, h5, h6';
/**
* Builds the two stylesheets foliate injects into each section document.
*
* The paginator creates two <style> elements per document: the first is
* head.prepend()ed, so the book's own CSS overrides it; the second is
* head.append()ed, so it wins on equal specificity. setStyles accepts the pair
* as a tuple and re-applies it on every section load.
* head.append()ed, so it wins. setStyles takes the pair as a tuple and
* re-applies it on every section load.
*
* The split is the point. Typography goes in `before` so a book that styles its
* own headings still gets to; colour goes in `after` because most EPUBs set a
* body background and would otherwise leave the page white against a dark UI.
* Nearly everything goes in `after`, with !important. These are the reader's
* settings, and EPUBs routinely set their own font-family, font-size and
* line-height on body and on paragraphs — put the settings in `before` and the
* book simply overrides them, which is what made the typography controls look
* like they did nothing.
*
* `before` is left for the few defaults a book should be free to override.
*/
export function buildReaderStyles(
s: ReaderSettings,
p: ReaderPalette
): [before: string, after: string] {
const hyphens = s.hyphenate ? 'auto' : 'manual';
const before = `
@namespace epub "http://www.idpf.org/2007/ops";
/* On html, not body, and without !important: the book's rem/em headings
then scale with the setting instead of being flattened to one size. */
html {
font-size: ${s.fontSize}px;
}
body {
font-family: ${s.fontFamily};
font-weight: ${s.fontWeight};
line-height: ${s.lineHeight};
letter-spacing: ${s.letterSpacing}em;
}
p, li, blockquote, dd, div {
line-height: ${s.lineHeight};
text-align: ${s.justify ? 'justify' : 'start'};
-webkit-hyphens: ${s.hyphenate ? 'auto' : 'manual'};
hyphens: ${s.hyphenate ? 'auto' : 'manual'};
${TEXT} {
-webkit-hyphenate-limit-before: 3;
-webkit-hyphenate-limit-after: 2;
-webkit-hyphenate-limit-lines: 2;
@@ -80,18 +74,50 @@ export function buildReaderStyles(
orphans: 2;
}
/* Justification must not silently override an explicit align attribute. */
[align="left"] { text-align: left; }
[align="right"] { text-align: right; }
[align="center"] { text-align: center; }
[align="justify"] { text-align: justify; }
pre {
white-space: pre-wrap;
}
`;
const after = `
/* The rem base. Headings sized in em/rem keep their scale relative to it. */
html {
font-size: ${s.fontSize}px !important;
}
body {
font-size: 1rem !important;
font-weight: ${s.fontWeight} !important;
}
/* inherit rather than a fixed size: books that set an absolute size in pt
or px on their paragraphs would otherwise ignore the size setting
entirely, while headings keep whatever relative scale they declare. */
${TEXT} {
font-size: inherit !important;
}
body, ${TEXT}, ${HEADINGS}, figcaption, caption {
font-family: ${s.fontFamily} !important;
}
body, ${TEXT} {
line-height: ${s.lineHeight} !important;
letter-spacing: ${s.letterSpacing}em !important;
}
p, li, dd, blockquote {
text-align: ${s.justify ? 'justify' : 'start'} !important;
-webkit-hyphens: ${hyphens} !important;
hyphens: ${hyphens} !important;
}
/* Justification must not silently override an explicit align attribute. */
[align="left"] { text-align: left !important; }
[align="right"] { text-align: right !important; }
[align="center"] { text-align: center !important; }
[align="justify"] { text-align: justify !important; }
/* Tells the book's own prefers-color-scheme rules which way we are going.
Without it the paginator's media query follows the OS, so a book with
dark styles can invert against the app. */
@@ -99,15 +125,14 @@ export function buildReaderStyles(
color-scheme: ${p.dark ? 'dark' : 'light'};
}
/* !important only here. Most EPUBs set their own body background and
colour, and an unprioritised override loses to them. */
/* Most EPUBs set their own body background and colour, so an
unprioritised override loses to them and the page stays white. */
html, body {
background: ${p.bg} !important;
color: ${p.fg} !important;
}
p, div, span, li, td, th, dl, dd, dt,
h1, h2, h3, h4, h5, h6, blockquote, figcaption {
${TEXT}, ${HEADINGS}, span, dl, figcaption, caption {
color: ${p.fg} !important;
}
+6 -2
View File
@@ -22,8 +22,12 @@ export const readerSettingsSchema = z.object({
margin: z.number().int().min(0).max(120),
/** % of the viewport, the space between columns. */
gap: z.number().int().min(0).max(15),
/** px, the maximum width of a single column. */
maxInlineSize: z.number().int().min(480).max(1400),
/**
* px, the maximum width of a single column. foliate caps the whole reading
* area at this times the column count, so on a wide screen it is what decides
* how much of the window the book actually uses.
*/
maxInlineSize: z.number().int().min(400).max(2400),
/** 1 for a single page, 2 for a spread. Reflowable books only. */
maxColumnCount: z.number().int().min(1).max(2),
flow: readerFlowSchema,