feat: add reader settings state and stylesheet generator
Settings are validated on read, not only on write: several go straight onto foliate's renderer as custom-element attributes, where a bad value wedges the layout silently rather than throwing. Unknown keys are stripped and missing ones filled from the defaults, so settings added later do not invalidate what a reader already has stored. The stylesheet is built as foliate's [before, after] pair. The paginator prepends the first to the section's <head> and appends the second, so the book's own CSS overrides the first and loses to the second. Typography goes in the first — a book that styles its own headings still gets to, and font size lands on <html> without !important so rem/em headings scale rather than being flattened. Colour goes in the second, because most EPUBs set a body background and would otherwise leave the page white against a dark UI. Font choices are reused from the app theme's FONT_STACKS rather than duplicated, so the reader cannot drift from the rest of the UI. Rename the ambient declaration to foliate-js.d.ts: as foliate.d.ts beside foliate.ts, TypeScript takes it for that file's emitted declaration, drops it from the program, and every $foliate import fails with TS2307.
This commit is contained in:
+4
@@ -5,6 +5,10 @@
|
|||||||
* ambient declaration is the only candidate — which is the point: svelte-check
|
* ambient declaration is the only candidate — which is the point: svelte-check
|
||||||
* never walks the untyped vendored JS. Only the surface Chitai calls is typed.
|
* never walks the untyped vendored JS. Only the surface Chitai calls is typed.
|
||||||
* When you start using a new method, add it here rather than casting to `any`.
|
* When you start using a new method, add it here rather than casting to `any`.
|
||||||
|
*
|
||||||
|
* The filename must not match a sibling .ts. As foliate.d.ts next to foliate.ts,
|
||||||
|
* TypeScript takes it for that file's emitted declaration and drops it from the
|
||||||
|
* program, and every $foliate import fails with TS2307.
|
||||||
*/
|
*/
|
||||||
declare module '$foliate/view.js' {
|
declare module '$foliate/view.js' {
|
||||||
/** A TOC entry. `id` is assigned by foliate's `assignIDs`, not by the book. */
|
/** A TOC entry. `id` is assigned by foliate's `assignIDs`, not by the book. */
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { FONT_STACKS } from '$lib/theme/presets';
|
||||||
|
import { readerSettingsSchema, type ReaderSettings } from '$lib/schema/reader';
|
||||||
|
|
||||||
|
export const READER_SETTINGS_STORAGE_KEY = 'chitai:reader-settings';
|
||||||
|
|
||||||
|
/** Georgia — the app's own title face, and a reasonable default for long prose. */
|
||||||
|
export const DEFAULT_READER_SETTINGS: ReaderSettings = {
|
||||||
|
fontFamily: FONT_STACKS[1].value,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
letterSpacing: 0,
|
||||||
|
margin: 48,
|
||||||
|
gap: 6,
|
||||||
|
maxInlineSize: 720,
|
||||||
|
maxColumnCount: 2,
|
||||||
|
flow: 'paginated',
|
||||||
|
justify: true,
|
||||||
|
hyphenate: true
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Reused from the app theme: system stacks only, so nothing silently falls back. */
|
||||||
|
export const READER_FONT_OPTIONS = FONT_STACKS;
|
||||||
|
|
||||||
|
/** Slider bounds, kept next to the schema they mirror. */
|
||||||
|
export const READER_BOUNDS = {
|
||||||
|
fontSize: { min: 12, max: 32, step: 1 },
|
||||||
|
fontWeight: { min: 300, max: 700, step: 100 },
|
||||||
|
lineHeight: { min: 1, max: 2.5, step: 0.05 },
|
||||||
|
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 }
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads stored settings, falling back to defaults on anything unusable.
|
||||||
|
*
|
||||||
|
* Unknown keys are stripped and missing ones filled, so settings added in a
|
||||||
|
* later release do not invalidate what a reader already has stored.
|
||||||
|
*/
|
||||||
|
export function loadReaderSettings(): ReaderSettings {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(READER_SETTINGS_STORAGE_KEY);
|
||||||
|
if (!raw) return DEFAULT_READER_SETTINGS;
|
||||||
|
const parsed = readerSettingsSchema.safeParse({
|
||||||
|
...DEFAULT_READER_SETTINGS,
|
||||||
|
...JSON.parse(raw)
|
||||||
|
});
|
||||||
|
return parsed.success ? parsed.data : DEFAULT_READER_SETTINGS;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_READER_SETTINGS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveReaderSettings(settings: ReaderSettings) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(READER_SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
} catch (error) {
|
||||||
|
// A full quota must not stop anyone reading.
|
||||||
|
console.warn('Could not save reader settings', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The half of the settings that foliate takes as renderer attributes.
|
||||||
|
*
|
||||||
|
* The renderer has no JS property API — these must be set with setAttribute.
|
||||||
|
* Note there is no `margin` shorthand upstream, and no `spread`: on a reflowable
|
||||||
|
* book a two-page spread is max-column-count 2.
|
||||||
|
*/
|
||||||
|
export function toRendererAttributes(s: ReaderSettings): Record<string, string> {
|
||||||
|
return {
|
||||||
|
flow: s.flow,
|
||||||
|
gap: `${s.gap}%`,
|
||||||
|
'margin-top': `${s.margin}px`,
|
||||||
|
'margin-bottom': `${s.margin}px`,
|
||||||
|
'margin-left': `${s.margin}px`,
|
||||||
|
'margin-right': `${s.margin}px`,
|
||||||
|
'max-inline-size': `${s.maxInlineSize}px`,
|
||||||
|
'max-column-count': String(s.maxColumnCount)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import type { ReaderSettings } from '$lib/schema/reader';
|
||||||
|
|
||||||
|
export interface ReaderPalette {
|
||||||
|
bg: string;
|
||||||
|
fg: string;
|
||||||
|
muted: string;
|
||||||
|
link: string;
|
||||||
|
dark: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the app palette out of the live document.
|
||||||
|
*
|
||||||
|
* The reader route uses a +layout@ breakout, so it never runs (root)/+layout and
|
||||||
|
* cannot reach the theme context. It does not need to: hooks.server.ts inlines
|
||||||
|
* <style id="chitai-theme"> with the full palette into every page, so the tokens
|
||||||
|
* are resolved on documentElement before first paint.
|
||||||
|
*
|
||||||
|
* Call this after the dark class has landed, not in the same tick as a mode flip.
|
||||||
|
*/
|
||||||
|
export function readReaderPalette(): ReaderPalette {
|
||||||
|
const styles = getComputedStyle(document.documentElement);
|
||||||
|
const token = (name: string, fallback: string) =>
|
||||||
|
styles.getPropertyValue(name).trim() || fallback;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// --card rather than --background: the book is a sheet of paper on the page.
|
||||||
|
bg: token('--card', '#ffffff'),
|
||||||
|
fg: token('--foreground', '#000000'),
|
||||||
|
muted: token('--muted-foreground', '#666666'),
|
||||||
|
link: token('--primary', '#0066cc'),
|
||||||
|
dark: document.documentElement.classList.contains('dark')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function buildReaderStyles(
|
||||||
|
s: ReaderSettings,
|
||||||
|
p: ReaderPalette
|
||||||
|
): [before: string, after: string] {
|
||||||
|
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'};
|
||||||
|
-webkit-hyphenate-limit-before: 3;
|
||||||
|
-webkit-hyphenate-limit-after: 2;
|
||||||
|
-webkit-hyphenate-limit-lines: 2;
|
||||||
|
hanging-punctuation: allow-end last;
|
||||||
|
widows: 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 {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const after = `
|
||||||
|
/* 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. */
|
||||||
|
html {
|
||||||
|
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. */
|
||||||
|
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 {
|
||||||
|
color: ${p.fg} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:any-link {
|
||||||
|
color: ${p.link} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border-color: ${p.muted} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep line art and diagrams legible on a dark page without touching
|
||||||
|
photographs, which invert badly. */
|
||||||
|
${
|
||||||
|
p.dark
|
||||||
|
? `svg { color: ${p.fg}; }
|
||||||
|
img[src$=".svg"] { filter: invert(1) hue-rotate(180deg); }`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
img, svg, video {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return [before, after];
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted reader preferences.
|
||||||
|
*
|
||||||
|
* Validated on read, not just on write: several of these values are written
|
||||||
|
* straight onto foliate's renderer as custom-element attributes, where a bad
|
||||||
|
* value silently wedges the layout instead of throwing. Anything that fails
|
||||||
|
* parsing falls back to the defaults.
|
||||||
|
*/
|
||||||
|
export const readerFlowSchema = z.enum(['paginated', 'scrolled']);
|
||||||
|
|
||||||
|
export const readerSettingsSchema = z.object({
|
||||||
|
fontFamily: z.string().min(1).max(200),
|
||||||
|
/** px, applied to <html> so the book's own rem/em headings scale with it. */
|
||||||
|
fontSize: z.number().int().min(12).max(32),
|
||||||
|
fontWeight: z.number().int().min(300).max(700),
|
||||||
|
lineHeight: z.number().min(1).max(2.5),
|
||||||
|
/** em */
|
||||||
|
letterSpacing: z.number().min(-0.05).max(0.2),
|
||||||
|
/** px, applied to all four renderer margins. */
|
||||||
|
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),
|
||||||
|
/** 1 for a single page, 2 for a spread. Reflowable books only. */
|
||||||
|
maxColumnCount: z.number().int().min(1).max(2),
|
||||||
|
flow: readerFlowSchema,
|
||||||
|
justify: z.boolean(),
|
||||||
|
hyphenate: z.boolean()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ReaderSettings = z.infer<typeof readerSettingsSchema>;
|
||||||
|
export type ReaderFlow = z.infer<typeof readerFlowSchema>;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { getContext, setContext } from 'svelte';
|
||||||
|
|
||||||
|
import type { ReaderSettings } from '$lib/schema/reader';
|
||||||
|
import {
|
||||||
|
DEFAULT_READER_SETTINGS,
|
||||||
|
loadReaderSettings,
|
||||||
|
saveReaderSettings,
|
||||||
|
toRendererAttributes
|
||||||
|
} from '$lib/reader/settings';
|
||||||
|
|
||||||
|
export class ReaderSettingsState {
|
||||||
|
settings = $state<ReaderSettings>(DEFAULT_READER_SETTINGS);
|
||||||
|
|
||||||
|
/** The half foliate takes as renderer attributes. */
|
||||||
|
readonly attributes = $derived(toRendererAttributes(this.settings));
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Defaults during SSR, real values once the browser has localStorage. The
|
||||||
|
// reader only applies these after mount, so there is nothing to flash.
|
||||||
|
if (browser) this.settings = loadReaderSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
set<K extends keyof ReaderSettings>(key: K, value: ReaderSettings[K]) {
|
||||||
|
this.settings = { ...this.settings, [key]: value };
|
||||||
|
saveReaderSettings(this.settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.settings = DEFAULT_READER_SETTINGS;
|
||||||
|
saveReaderSettings(this.settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const READER_SETTINGS_KEY = Symbol('READER_SETTINGS');
|
||||||
|
|
||||||
|
export function setReaderSettingsState() {
|
||||||
|
return setContext(READER_SETTINGS_KEY, new ReaderSettingsState());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReaderSettingsState() {
|
||||||
|
return getContext<ReturnType<typeof setReaderSettingsState>>(READER_SETTINGS_KEY);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user