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">
|
||||
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 { ProgressReporter } from '$lib/reader/progress';
|
||||
@@ -14,6 +21,7 @@
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
||||
import ThemeToggle from '$lib/components/layout/theme-toggle.svelte';
|
||||
import FoliateView from './foliate-view.svelte';
|
||||
import ReaderSettings from './reader-settings.svelte';
|
||||
import ReaderToc from './reader-toc.svelte';
|
||||
|
||||
let {
|
||||
@@ -47,6 +55,8 @@
|
||||
let toc = $state<FoliateTocItem[]>([]);
|
||||
let activeTocId = $state<number | null>(null);
|
||||
let isSidebarOpen = $state(false);
|
||||
let isSettingsOpen = $state(false);
|
||||
let isFixedLayout = $state(false);
|
||||
|
||||
let progress = $state(initialFraction);
|
||||
let viewer = $state<ReturnType<typeof FoliateView>>();
|
||||
@@ -140,6 +150,19 @@
|
||||
</span>
|
||||
{/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 />
|
||||
</header>
|
||||
|
||||
@@ -191,6 +214,7 @@
|
||||
onerror={(message) => (loadError = message)}
|
||||
onready={(detail) => {
|
||||
toc = detail.toc;
|
||||
isFixedLayout = detail.isFixedLayout;
|
||||
isReady = true;
|
||||
}}
|
||||
/>
|
||||
@@ -210,3 +234,5 @@
|
||||
{/if}
|
||||
</main>
|
||||
</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