feat: add the reader settings panel

Typeface, size, weight, line height, letter spacing, justify and hyphenate,
plus flow, columns, line width, gap and margins — persisted in localStorage
and applied live.

Fixed-layout books hide the typography and column controls: they render
through foliate-fxl, which has no setStyles and ignores the paginator's
attributes, so those sliders would do nothing.
This commit is contained in:
2026-08-11 22:27:04 -04:00
parent 190e7af76d
commit fe12f4be76
4 changed files with 351 additions and 1 deletions
@@ -1,7 +1,14 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { ChevronLeft, ChevronRight, PanelLeft, RotateCcw, TriangleAlert } from '@lucide/svelte'; import {
ChevronLeft,
ChevronRight,
PanelLeft,
RotateCcw,
Settings2,
TriangleAlert
} from '@lucide/svelte';
import type { FoliateRelocateDetail, FoliateTocItem } from '$foliate/view.js'; import type { FoliateRelocateDetail, FoliateTocItem } from '$foliate/view.js';
import { ProgressReporter } from '$lib/reader/progress'; import { ProgressReporter } from '$lib/reader/progress';
@@ -14,6 +21,7 @@
import * as Tooltip from '$lib/components/ui/tooltip/index'; import * as Tooltip from '$lib/components/ui/tooltip/index';
import ThemeToggle from '$lib/components/layout/theme-toggle.svelte'; import ThemeToggle from '$lib/components/layout/theme-toggle.svelte';
import FoliateView from './foliate-view.svelte'; import FoliateView from './foliate-view.svelte';
import ReaderSettings from './reader-settings.svelte';
import ReaderToc from './reader-toc.svelte'; import ReaderToc from './reader-toc.svelte';
let { let {
@@ -47,6 +55,8 @@
let toc = $state<FoliateTocItem[]>([]); let toc = $state<FoliateTocItem[]>([]);
let activeTocId = $state<number | null>(null); let activeTocId = $state<number | null>(null);
let isSidebarOpen = $state(false); let isSidebarOpen = $state(false);
let isSettingsOpen = $state(false);
let isFixedLayout = $state(false);
let progress = $state(initialFraction); let progress = $state(initialFraction);
let viewer = $state<ReturnType<typeof FoliateView>>(); let viewer = $state<ReturnType<typeof FoliateView>>();
@@ -140,6 +150,19 @@
</span> </span>
{/if} {/if}
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} size-8"
onclick={() => (isSettingsOpen = true)}
disabled={!isReady}
>
<Settings2 class="size-4" />
</Tooltip.Trigger>
<Tooltip.Content side="bottom"><p>Reading settings</p></Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<ThemeToggle /> <ThemeToggle />
</header> </header>
@@ -191,6 +214,7 @@
onerror={(message) => (loadError = message)} onerror={(message) => (loadError = message)}
onready={(detail) => { onready={(detail) => {
toc = detail.toc; toc = detail.toc;
isFixedLayout = detail.isFixedLayout;
isReady = true; isReady = true;
}} }}
/> />
@@ -210,3 +234,5 @@
{/if} {/if}
</main> </main>
</Sidebar.Provider> </Sidebar.Provider>
<ReaderSettings bind:open={isSettingsOpen} {isFixedLayout} />
@@ -0,0 +1,265 @@
<script lang="ts">
import { RotateCcw } from '@lucide/svelte';
import type { ReaderFlow, ReaderSettings } from '$lib/schema/reader';
import { READER_BOUNDS, READER_FONT_OPTIONS } from '$lib/reader/settings';
import { getReaderSettingsState } from '$lib/state/reader-settings.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { Slider } from '$lib/components/ui/slider/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import * as Sheet from '$lib/components/ui/sheet/index.js';
import * as ToggleGroup from '$lib/components/ui/toggle-group/index.js';
let {
open = $bindable(false),
/**
* Fixed-layout books render through foliate-fxl, which has no setStyles and
* ignores the paginator's attributes — so the controls below would do
* nothing. Hide them rather than leave dead sliders on screen.
*/
isFixedLayout = false
}: {
open?: boolean;
isFixedLayout?: boolean;
} = $props();
const state = getReaderSettingsState();
/** Slider binds an array; unwrap to the single value the setting holds. */
function slide<K extends keyof ReaderSettings>(key: K) {
return (value: number) => state.set(key, value as ReaderSettings[K]);
}
</script>
<Sheet.Root bind:open>
<Sheet.Content side="right" class="w-full gap-0 overflow-y-auto sm:max-w-sm">
<Sheet.Header>
<Sheet.Title class="font-serif">Reading</Sheet.Title>
<Sheet.Description>
{isFixedLayout
? 'This book has a fixed layout, so its typography is set by the publisher.'
: 'Applies to every book you read.'}
</Sheet.Description>
</Sheet.Header>
<div class="flex flex-col gap-6 px-4 pb-6">
{#if !isFixedLayout}
<section class="flex flex-col gap-4">
<h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
Typography
</h3>
<div class="flex flex-col gap-2">
<Label for="reader-font">Typeface</Label>
<NativeSelect
id="reader-font"
class="w-full"
value={state.settings.fontFamily}
onchange={(event) => state.set('fontFamily', event.currentTarget.value)}
>
{#each READER_FONT_OPTIONS as font (font.value)}
<option value={font.value}>{font.label}</option>
{/each}
</NativeSelect>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<Label for="reader-size">Size</Label>
<span class="font-mono text-xs text-muted-foreground tabular-nums">
{state.settings.fontSize}px
</span>
</div>
<Slider
id="reader-size"
type="single"
value={state.settings.fontSize}
min={READER_BOUNDS.fontSize.min}
max={READER_BOUNDS.fontSize.max}
step={READER_BOUNDS.fontSize.step}
onValueChange={slide('fontSize')}
/>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<Label for="reader-weight">Weight</Label>
<span class="font-mono text-xs text-muted-foreground tabular-nums">
{state.settings.fontWeight}
</span>
</div>
<Slider
id="reader-weight"
type="single"
value={state.settings.fontWeight}
min={READER_BOUNDS.fontWeight.min}
max={READER_BOUNDS.fontWeight.max}
step={READER_BOUNDS.fontWeight.step}
onValueChange={slide('fontWeight')}
/>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<Label for="reader-leading">Line height</Label>
<span class="font-mono text-xs text-muted-foreground tabular-nums">
{state.settings.lineHeight.toFixed(2)}
</span>
</div>
<Slider
id="reader-leading"
type="single"
value={state.settings.lineHeight}
min={READER_BOUNDS.lineHeight.min}
max={READER_BOUNDS.lineHeight.max}
step={READER_BOUNDS.lineHeight.step}
onValueChange={slide('lineHeight')}
/>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<Label for="reader-tracking">Letter spacing</Label>
<span class="font-mono text-xs text-muted-foreground tabular-nums">
{state.settings.letterSpacing.toFixed(2)}em
</span>
</div>
<Slider
id="reader-tracking"
type="single"
value={state.settings.letterSpacing}
min={READER_BOUNDS.letterSpacing.min}
max={READER_BOUNDS.letterSpacing.max}
step={READER_BOUNDS.letterSpacing.step}
onValueChange={slide('letterSpacing')}
/>
</div>
<div class="flex items-center justify-between gap-4">
<Label for="reader-justify" class="font-normal">Justify text</Label>
<Switch
id="reader-justify"
checked={state.settings.justify}
onCheckedChange={(checked) => state.set('justify', checked)}
/>
</div>
<div class="flex items-center justify-between gap-4">
<Label for="reader-hyphenate" class="font-normal">Hyphenate</Label>
<Switch
id="reader-hyphenate"
checked={state.settings.hyphenate}
onCheckedChange={(checked) => state.set('hyphenate', checked)}
/>
</div>
</section>
<Separator />
{/if}
<section class="flex flex-col gap-4">
<h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
Layout
</h3>
<div class="flex flex-col gap-2">
<Label>Flow</Label>
<ToggleGroup.Root
type="single"
variant="outline"
value={state.settings.flow}
onValueChange={(value) => value && state.set('flow', value as ReaderFlow)}
class="justify-start"
>
<ToggleGroup.Item value="paginated">Pages</ToggleGroup.Item>
<ToggleGroup.Item value="scrolled">Scroll</ToggleGroup.Item>
</ToggleGroup.Root>
</div>
{#if !isFixedLayout}
<div class="flex flex-col gap-2">
<Label>Columns</Label>
<ToggleGroup.Root
type="single"
variant="outline"
value={String(state.settings.maxColumnCount)}
onValueChange={(value) => value && state.set('maxColumnCount', Number(value))}
class="justify-start"
>
<ToggleGroup.Item value="1">Single</ToggleGroup.Item>
<ToggleGroup.Item value="2">Spread</ToggleGroup.Item>
</ToggleGroup.Root>
<p class="text-xs text-muted-foreground">
A spread needs the window to be wide enough for two columns.
</p>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<Label for="reader-width">Line width</Label>
<span class="font-mono text-xs text-muted-foreground tabular-nums">
{state.settings.maxInlineSize}px
</span>
</div>
<Slider
id="reader-width"
type="single"
value={state.settings.maxInlineSize}
min={READER_BOUNDS.maxInlineSize.min}
max={READER_BOUNDS.maxInlineSize.max}
step={READER_BOUNDS.maxInlineSize.step}
onValueChange={slide('maxInlineSize')}
/>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<Label for="reader-gap">Column gap</Label>
<span class="font-mono text-xs text-muted-foreground tabular-nums">
{state.settings.gap}%
</span>
</div>
<Slider
id="reader-gap"
type="single"
value={state.settings.gap}
min={READER_BOUNDS.gap.min}
max={READER_BOUNDS.gap.max}
step={READER_BOUNDS.gap.step}
onValueChange={slide('gap')}
/>
</div>
{/if}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<Label for="reader-margin">Margins</Label>
<span class="font-mono text-xs text-muted-foreground tabular-nums">
{state.settings.margin}px
</span>
</div>
<Slider
id="reader-margin"
type="single"
value={state.settings.margin}
min={READER_BOUNDS.margin.min}
max={READER_BOUNDS.margin.max}
step={READER_BOUNDS.margin.step}
onValueChange={slide('margin')}
/>
</div>
</section>
<Separator />
<Button variant="outline" onclick={() => state.reset()} class="self-start">
<RotateCcw class="size-4" />
Reset to defaults
</Button>
</div>
</Sheet.Content>
</Sheet.Root>
@@ -0,0 +1,7 @@
import Root from "./slider.svelte";
export {
Root,
//
Root as Slider,
};
@@ -0,0 +1,52 @@
<script lang="ts">
import { Slider as SliderPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(),
orientation = "horizontal",
class: className,
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<SliderPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="slider"
{orientation}
class={cn(
"data-vertical:min-h-40 relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:w-auto data-vertical:flex-col",
className
)}
{...restProps}
>
{#snippet children({ thumbItems })}
<span
data-slot="slider-track"
data-orientation={orientation}
class={cn(
"rounded-full bg-muted data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1 relative grow overflow-hidden bg-muted data-horizontal:w-full data-vertical:h-full"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
class={cn(
"bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full"
)}
/>
</span>
{#each thumbItems as thumb (thumb.index)}
<SliderPrimitive.Thumb
data-slot="slider-thumb"
index={thumb.index}
class="relative size-3 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 block shrink-0 select-none disabled:pointer-events-none disabled:opacity-50"
/>
{/each}
{/snippet}
</SliderPrimitive.Root>