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:
@@ -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. */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user