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) { 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); void viewer?.goTo(href);
isSidebarOpen = false;
} }
onMount(() => { onMount(() => {
@@ -191,18 +192,10 @@
</div> </div>
{/if} {/if}
<div class="flex min-h-0 flex-1 items-stretch {isReady ? '' : 'hidden'}"> <div class="min-h-0 flex-1 p-4 {isReady ? '' : 'hidden'}">
<Button <!-- The page itself: same colour the book is themed to, so the two
variant="ghost" meet seamlessly and the border reads as the edge of the sheet. -->
size="icon" <div class="relative h-full overflow-hidden rounded-lg border bg-card shadow-sm">
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">
{#if file} {#if file}
<FoliateView <FoliateView
bind:this={viewer} bind:this={viewer}
@@ -219,18 +212,30 @@
}} }}
/> />
{/if} {/if}
</div>
<!-- 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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="my-auto size-10 shrink-0" class="absolute top-1/2 right-1 size-10 -translate-y-1/2 opacity-40 transition-opacity hover:opacity-100"
onclick={() => viewer?.goForward()} onclick={() => viewer?.goForward()}
aria-label="Next page" aria-label="Next page"
> >
<ChevronRight class="size-5" /> <ChevronRight class="size-5" />
</Button> </Button>
</div> </div>
</div>
{/if} {/if}
</main> </main>
</Sidebar.Provider> </Sidebar.Provider>
@@ -48,6 +48,20 @@
// Reactive so the effects below re-run once the book is open — otherwise a // 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. // theme flip while it was still loading would never reach the book.
let ready = $state(false); 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. * Where to resume from.
@@ -130,6 +144,11 @@
onMount(() => { onMount(() => {
let disposed = false; let disposed = false;
const resizeObserver = new ResizeObserver(([entry]) => {
hostWidth = entry.contentRect.width;
});
if (container) resizeObserver.observe(container);
(async () => { (async () => {
try { try {
await loadFoliate(); await loadFoliate();
@@ -180,6 +199,7 @@
return () => { return () => {
disposed = true; disposed = true;
resizeObserver.disconnect();
for (const { doc } of view?.renderer?.getContents?.() ?? []) { for (const { doc } of view?.renderer?.getContents?.() ?? []) {
doc.removeEventListener('keydown', onKeydown); doc.removeEventListener('keydown', onKeydown);
} }
@@ -211,9 +231,25 @@
}); });
</script> </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> <style>
/* foliate-view extends bare HTMLElement, so it has no default display. */ /* 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, letterSpacing: 0,
margin: 48, margin: 48,
gap: 6, 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, maxColumnCount: 2,
flow: 'paginated', flow: 'paginated',
justify: true, justify: true,
@@ -30,7 +33,7 @@ export const READER_BOUNDS = {
letterSpacing: { min: -0.05, max: 0.2, step: 0.01 }, letterSpacing: { min: -0.05, max: 0.2, step: 0.01 },
margin: { min: 0, max: 120, step: 4 }, margin: { min: 0, max: 120, step: 4 },
gap: { min: 0, max: 15, step: 1 }, gap: { min: 0, max: 15, step: 1 },
maxInlineSize: { min: 480, max: 1400, step: 20 } maxInlineSize: { min: 400, max: 2400, step: 20 }
} as const; } 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. * Builds the two stylesheets foliate injects into each section document.
* *
* The paginator creates two <style> elements per document: the first is * 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.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 * head.append()ed, so it wins. setStyles takes the pair as a tuple and
* as a tuple and re-applies it on every section load. * re-applies it on every section load.
* *
* The split is the point. Typography goes in `before` so a book that styles its * Nearly everything goes in `after`, with !important. These are the reader's
* own headings still gets to; colour goes in `after` because most EPUBs set a * settings, and EPUBs routinely set their own font-family, font-size and
* body background and would otherwise leave the page white against a dark UI. * 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( export function buildReaderStyles(
s: ReaderSettings, s: ReaderSettings,
p: ReaderPalette p: ReaderPalette
): [before: string, after: string] { ): [before: string, after: string] {
const hyphens = s.hyphenate ? 'auto' : 'manual';
const before = ` const before = `
@namespace epub "http://www.idpf.org/2007/ops"; @namespace epub "http://www.idpf.org/2007/ops";
/* On html, not body, and without !important: the book's rem/em headings ${TEXT} {
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'};
-webkit-hyphenate-limit-before: 3; -webkit-hyphenate-limit-before: 3;
-webkit-hyphenate-limit-after: 2; -webkit-hyphenate-limit-after: 2;
-webkit-hyphenate-limit-lines: 2; -webkit-hyphenate-limit-lines: 2;
@@ -80,18 +74,50 @@ export function buildReaderStyles(
orphans: 2; 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 { pre {
white-space: pre-wrap; white-space: pre-wrap;
} }
`; `;
const after = ` 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. /* 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 Without it the paginator's media query follows the OS, so a book with
dark styles can invert against the app. */ dark styles can invert against the app. */
@@ -99,15 +125,14 @@ export function buildReaderStyles(
color-scheme: ${p.dark ? 'dark' : 'light'}; color-scheme: ${p.dark ? 'dark' : 'light'};
} }
/* !important only here. Most EPUBs set their own body background and /* Most EPUBs set their own body background and colour, so an
colour, and an unprioritised override loses to them. */ unprioritised override loses to them and the page stays white. */
html, body { html, body {
background: ${p.bg} !important; background: ${p.bg} !important;
color: ${p.fg} !important; color: ${p.fg} !important;
} }
p, div, span, li, td, th, dl, dd, dt, ${TEXT}, ${HEADINGS}, span, dl, figcaption, caption {
h1, h2, h3, h4, h5, h6, blockquote, figcaption {
color: ${p.fg} !important; 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), margin: z.number().int().min(0).max(120),
/** % of the viewport, the space between columns. */ /** % of the viewport, the space between columns. */
gap: z.number().int().min(0).max(15), 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. */ /** 1 for a single page, 2 for a spread. Reflowable books only. */
maxColumnCount: z.number().int().min(1).max(2), maxColumnCount: z.number().int().min(1).max(2),
flow: readerFlowSchema, flow: readerFlowSchema,