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:
@@ -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>
|
||||||
Reference in New Issue
Block a user