feat: reading view modes — progress bar, paginated, spacing, width, focus
Some checks failed
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Failing after 27s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 2m50s
Release / Docker / runner (push) Successful in 3m3s
Release / Gitea Release (push) Has been skipped
Some checks failed
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Failing after 27s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 2m50s
Release / Docker / runner (push) Successful in 3m3s
Release / Gitea Release (push) Has been skipped
Five new reader settings (persisted in localStorage as reader_layout_v1): - Read mode: Scroll (default) vs Pages — paginated mode splits content into viewport-height pages, navigate with tap left/right, arrow keys, or Prev/Next buttons. Recalculates on content change. - Line spacing: Tight (1.55) / Normal (1.85) / Loose (2.2) via --reading-line-height CSS var on :root. - Reading width: Narrow (58ch) / Normal (72ch) / Wide (90ch) via --reading-max-width CSS var on :root. - Paragraph style: Spaced (default) vs Indented (text-indent: 2em, tight margin — book-like feel). - Focus mode: hides audio player, language switcher, bottom nav and comments so only the text remains. Scroll progress bar: thin 2px brand-colored bar fixed at top of viewport in scroll mode, fills as you read through the chapter. All options added to the floating settings gear panel. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -106,12 +106,14 @@ html {
|
||||
:root {
|
||||
--reading-font: system-ui, -apple-system, sans-serif;
|
||||
--reading-size: 1.05rem;
|
||||
--reading-line-height: 1.85;
|
||||
--reading-max-width: 72ch;
|
||||
}
|
||||
|
||||
/* ── Chapter prose ─────────────────────────────────────────────────── */
|
||||
.prose-chapter {
|
||||
max-width: 72ch;
|
||||
line-height: 1.85;
|
||||
max-width: var(--reading-max-width, 72ch);
|
||||
line-height: var(--reading-line-height, 1.85);
|
||||
font-family: var(--reading-font);
|
||||
font-size: var(--reading-size);
|
||||
color: var(--color-muted);
|
||||
@@ -134,6 +136,12 @@ html {
|
||||
margin-bottom: 1.2em;
|
||||
}
|
||||
|
||||
/* Indented paragraph style — book-like, no gap, indent instead */
|
||||
.prose-chapter.para-indented p {
|
||||
text-indent: 2em;
|
||||
margin-bottom: 0.35em;
|
||||
}
|
||||
|
||||
.prose-chapter em {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
@@ -147,6 +155,31 @@ html {
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
/* ── Reading progress bar ───────────────────────────────────────────── */
|
||||
.reading-progress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
z-index: 100;
|
||||
background: var(--color-brand);
|
||||
pointer-events: none;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
/* ── Paginated reader ───────────────────────────────────────────────── */
|
||||
.paginated-container {
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.paginated-container .prose-chapter {
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* ── Hide scrollbars (used on horizontal carousels) ────────────────── */
|
||||
.scrollbar-none {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack, getContext } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||
@@ -56,7 +57,114 @@
|
||||
if (settingsCtx) settingsCtx.fontSize = v;
|
||||
}
|
||||
|
||||
// Translation state
|
||||
// ── Layout / reading-view prefs (localStorage) ──────────────────────────────
|
||||
type ReadMode = 'scroll' | 'paginated';
|
||||
type LineSpacing = 'compact' | 'normal' | 'relaxed';
|
||||
type ReadWidth = 'narrow' | 'normal' | 'wide';
|
||||
type ParaStyle = 'spaced' | 'indented';
|
||||
|
||||
interface LayoutPrefs {
|
||||
readMode: ReadMode;
|
||||
lineSpacing: LineSpacing;
|
||||
readWidth: ReadWidth;
|
||||
paraStyle: ParaStyle;
|
||||
focusMode: boolean;
|
||||
}
|
||||
|
||||
const LAYOUT_KEY = 'reader_layout_v1';
|
||||
const LINE_HEIGHTS: Record<LineSpacing, number> = { compact: 1.55, normal: 1.85, relaxed: 2.2 };
|
||||
const READ_WIDTHS: Record<ReadWidth, string> = { narrow: '58ch', normal: '72ch', wide: 'min(90ch, 100%)' };
|
||||
const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false };
|
||||
|
||||
function loadLayout(): LayoutPrefs {
|
||||
if (!browser) return DEFAULT_LAYOUT;
|
||||
try {
|
||||
const raw = localStorage.getItem(LAYOUT_KEY);
|
||||
if (raw) return { ...DEFAULT_LAYOUT, ...JSON.parse(raw) as Partial<LayoutPrefs> };
|
||||
} catch { /* ignore */ }
|
||||
return DEFAULT_LAYOUT;
|
||||
}
|
||||
|
||||
let layout = $state<LayoutPrefs>(loadLayout());
|
||||
|
||||
function setLayout<K extends keyof LayoutPrefs>(key: K, value: LayoutPrefs[K]) {
|
||||
layout = { ...layout, [key]: value };
|
||||
if (browser) localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout));
|
||||
}
|
||||
|
||||
// Apply reading CSS vars whenever layout changes
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
document.documentElement.style.setProperty('--reading-line-height', String(LINE_HEIGHTS[layout.lineSpacing]));
|
||||
document.documentElement.style.setProperty('--reading-max-width', READ_WIDTHS[layout.readWidth]);
|
||||
});
|
||||
|
||||
// ── Scroll progress bar ──────────────────────────────────────────────────────
|
||||
let scrollProgress = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (!browser || layout.readMode === 'paginated') { scrollProgress = 0; return; }
|
||||
function onScroll() {
|
||||
const el = document.documentElement;
|
||||
const max = el.scrollHeight - el.clientHeight;
|
||||
scrollProgress = max > 0 ? el.scrollTop / max : 0;
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
});
|
||||
|
||||
// ── Paginated mode ───────────────────────────────────────────────────────────
|
||||
let pageIndex = $state(0);
|
||||
let totalPages = $state(1);
|
||||
let paginatedContainerEl = $state<HTMLDivElement | null>(null);
|
||||
let paginatedContentEl = $state<HTMLDivElement | null>(null);
|
||||
let containerH = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (layout.readMode !== 'paginated') { pageIndex = 0; totalPages = 1; return; }
|
||||
// Re-run when html changes or container is bound
|
||||
void html; void paginatedContainerEl; void paginatedContentEl;
|
||||
requestAnimationFrame(() => {
|
||||
if (!paginatedContainerEl || !paginatedContentEl) return;
|
||||
const h = paginatedContainerEl.clientHeight;
|
||||
if (h > 0) {
|
||||
containerH = h;
|
||||
totalPages = Math.max(1, Math.ceil(paginatedContentEl.scrollHeight / h));
|
||||
pageIndex = Math.min(pageIndex, totalPages - 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Reset page index when chapter changes
|
||||
$effect(() => { void data.chapter.number; pageIndex = 0; });
|
||||
|
||||
function handlePaginatedClick(e: MouseEvent) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
if (e.clientX - rect.left > rect.width / 2) {
|
||||
if (pageIndex < totalPages - 1) pageIndex++;
|
||||
} else {
|
||||
if (pageIndex > 0) pageIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard nav for paginated mode
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (layout.readMode !== 'paginated') return;
|
||||
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (pageIndex < totalPages - 1) pageIndex++;
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
|
||||
e.preventDefault();
|
||||
if (pageIndex > 0) pageIndex--;
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
// ── Translation state
|
||||
const SUPPORTED_LANGS = [
|
||||
{ code: 'ru', label: 'RU' },
|
||||
{ code: 'id', label: 'ID' },
|
||||
@@ -189,6 +297,11 @@
|
||||
<title>{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })} — {data.book.title} — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Reading progress bar (scroll mode) -->
|
||||
{#if layout.readMode === 'scroll'}
|
||||
<div class="reading-progress" style="width: {scrollProgress * 100}%"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Top nav -->
|
||||
<div class="flex items-center justify-between mb-6 gap-4">
|
||||
<a
|
||||
@@ -235,8 +348,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Language switcher (not shown for preview chapters) -->
|
||||
{#if !data.isPreview}
|
||||
<!-- Language switcher (not shown for preview chapters or focus mode) -->
|
||||
{#if !data.isPreview && !layout.focusMode}
|
||||
<div class="flex items-center gap-2 mb-6 flex-wrap">
|
||||
<span class="text-(--color-muted) text-xs">Read in:</span>
|
||||
|
||||
@@ -292,8 +405,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Audio player -->
|
||||
{#if !data.isPreview}
|
||||
<!-- Audio player (hidden in focus mode) -->
|
||||
{#if !data.isPreview && !layout.focusMode}
|
||||
{#if !page.data.user}
|
||||
<!-- Unauthenticated: sign-in prompt -->
|
||||
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-border) flex items-center justify-between gap-4">
|
||||
@@ -353,13 +466,66 @@
|
||||
<div class="text-(--color-muted) text-center py-16">
|
||||
<p>{fetchError || m.reader_audio_error()}</p>
|
||||
</div>
|
||||
{:else if layout.readMode === 'paginated'}
|
||||
<!-- ── Paginated reader ─────────────────────────────────────────────── -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={paginatedContainerEl}
|
||||
class="paginated-container mt-8"
|
||||
style="height: {layout.focusMode ? 'calc(100svh - 8rem)' : 'calc(100svh - 26rem)'};"
|
||||
onclick={handlePaginatedClick}
|
||||
>
|
||||
<div
|
||||
bind:this={paginatedContentEl}
|
||||
class="prose-chapter {layout.paraStyle === 'indented' ? 'para-indented' : ''}"
|
||||
style="transform: translateY({containerH > 0 ? -(pageIndex * containerH) : 0}px);"
|
||||
>
|
||||
{@html html}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page indicator + nav -->
|
||||
<div class="flex items-center justify-between mt-4 select-none">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { if (pageIndex > 0) pageIndex--; }}
|
||||
disabled={pageIndex === 0}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-muted) hover:text-(--color-text) disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<span class="text-sm text-(--color-muted) tabular-nums">
|
||||
{pageIndex + 1} <span class="opacity-40">/</span> {totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { if (pageIndex < totalPages - 1) pageIndex++; }}
|
||||
disabled={pageIndex === totalPages - 1}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-muted) hover:text-(--color-text) disabled:opacity-30 transition-colors"
|
||||
>
|
||||
Next
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tap hint -->
|
||||
<p class="text-center text-xs text-(--color-muted)/40 mt-2">Tap left/right · Arrow keys · Space</p>
|
||||
{:else}
|
||||
<div class="prose-chapter mt-8">
|
||||
<!-- ── Scroll reader ────────────────────────────────────────────────── -->
|
||||
<div class="prose-chapter mt-8 {layout.paraStyle === 'indented' ? 'para-indented' : ''}">
|
||||
{@html html}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Bottom nav -->
|
||||
<!-- Bottom nav + comments (hidden in paginated focus mode) -->
|
||||
{#if !(layout.focusMode && layout.readMode === 'paginated')}
|
||||
<div class="flex justify-between mt-12 pt-6 border-t border-(--color-border) gap-4">
|
||||
{#if data.prev}
|
||||
<a
|
||||
@@ -381,7 +547,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Chapter comments -->
|
||||
<div class="mt-12">
|
||||
<CommentsSection
|
||||
slug={data.book.slug}
|
||||
@@ -390,6 +555,7 @@
|
||||
currentUserId={page.data.user?.id ?? ''}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Floating reader settings ─────────────────────────────────────────── -->
|
||||
{#if settingsCtx}
|
||||
@@ -480,6 +646,95 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="border-t border-(--color-border)"></div>
|
||||
|
||||
<!-- Read mode -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Read mode</p>
|
||||
<div class="flex gap-1.5">
|
||||
{#each ([['scroll', 'Scroll'], ['paginated', 'Pages']] as const) as [mode, label]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('readMode', mode)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.readMode === mode
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.readMode === mode}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line spacing -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Line spacing</p>
|
||||
<div class="flex gap-1.5">
|
||||
{#each ([['compact', 'Tight'], ['normal', 'Normal'], ['relaxed', 'Loose']] as const) as [s, label]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('lineSpacing', s)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.lineSpacing === s
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.lineSpacing === s}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reading width -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Width</p>
|
||||
<div class="flex gap-1.5">
|
||||
{#each ([['narrow', 'Narrow'], ['normal', 'Normal'], ['wide', 'Wide']] as const) as [w, label]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('readWidth', w)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.readWidth === w
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.readWidth === w}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paragraph style -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Paragraphs</p>
|
||||
<div class="flex gap-1.5">
|
||||
{#each ([['spaced', 'Spaced'], ['indented', 'Indented']] as const) as [s, label]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('paraStyle', s)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.paraStyle === s
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.paraStyle === s}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Focus mode -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('focusMode', !layout.focusMode)}
|
||||
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.focusMode
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.focusMode}
|
||||
>
|
||||
<span>Focus mode</span>
|
||||
<span class="opacity-60 text-xs">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
|
||||
</button>
|
||||
|
||||
<p class="text-xs text-(--color-muted)/60 text-center">Changes save automatically</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user