books-ui: library, detail page, EPUB/PDF reader, reading status/rating/sessions migration, progress/notes APIs, owner gating and tests
This commit is contained in:
999
src/routes/trackers/books/[id=integer]/read/+page.svelte
Normal file
999
src/routes/trackers/books/[id=integer]/read/+page.svelte
Normal file
@@ -0,0 +1,999 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import EpubReader from '#lib/books/reader/EpubReader.svelte';
|
||||
import PdfReader from '#lib/books/reader/PdfReader.svelte';
|
||||
import {
|
||||
defaultSettings,
|
||||
themeColors,
|
||||
type ReaderEngine,
|
||||
type ReaderSettings,
|
||||
type ReaderTheme,
|
||||
type Relocation,
|
||||
type TextSelection,
|
||||
type TocItem
|
||||
} from '#lib/books/reader/types.js';
|
||||
import {
|
||||
highlightColors,
|
||||
highlightCss,
|
||||
type Highlight,
|
||||
type HighlightColor
|
||||
} from '#lib/books/shared.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||
import ListTree from 'lucide-svelte/icons/list-tree';
|
||||
import Highlighter from 'lucide-svelte/icons/highlighter';
|
||||
import Type from 'lucide-svelte/icons/type';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import Copy from 'lucide-svelte/icons/copy';
|
||||
import MessageSquarePlus from 'lucide-svelte/icons/message-square-plus';
|
||||
import Minus from 'lucide-svelte/icons/minus';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const SETTINGS_KEY = 'books-reader-settings';
|
||||
const isPdf = $derived(data.file.engine === 'pdf');
|
||||
const progressUrl = $derived(
|
||||
resolve('/trackers/books/[id=integer]/progress', { id: String(data.book.id) })
|
||||
);
|
||||
const notesUrl = $derived(
|
||||
resolve('/trackers/books/[id=integer]/notes', { id: String(data.book.id) })
|
||||
);
|
||||
const detailUrl = $derived(resolve('/trackers/books/[id=integer]', { id: String(data.book.id) }));
|
||||
|
||||
let engine = $state<ReaderEngine>();
|
||||
let settings = $state<ReaderSettings>({ ...defaultSettings });
|
||||
let siteDark = $state(true);
|
||||
// Seeded from the load; the reader adds/edits/deletes in place (writable derived).
|
||||
let highlights = $derived<Highlight[]>(data.highlights);
|
||||
|
||||
let toc = $state<TocItem[]>([]);
|
||||
let ready = $state(false);
|
||||
let loading = $state<number | null>(null);
|
||||
let failure = $state<string | null>(null);
|
||||
let location = $state<Relocation | null>(null);
|
||||
let panel = $state<'toc' | 'highlights' | 'settings' | null>(null);
|
||||
let selection = $state<TextSelection | null>(null);
|
||||
let noteFor = $state<'selection' | number | null>(null);
|
||||
let noteDraft = $state('');
|
||||
let activeId = $state<number | null>(null);
|
||||
let saveState = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
let scrubbing = $state<number | null>(null);
|
||||
|
||||
const theme = $derived<ReaderTheme>(
|
||||
settings.theme === 'auto' ? (siteDark ? 'dark' : 'light') : settings.theme
|
||||
);
|
||||
const colors = $derived(themeColors[theme]);
|
||||
const active = $derived(highlights.find((h) => h.id === activeId) ?? null);
|
||||
const percent = $derived(scrubbing ?? location?.percent ?? data.resume.percent);
|
||||
const start = $derived(data.jumpTo ?? data.resume.locator);
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? 'null');
|
||||
if (saved && typeof saved === 'object') settings = { ...defaultSettings, ...saved };
|
||||
} catch {
|
||||
// ignore broken settings
|
||||
}
|
||||
siteDark = !getComputedStyle(document.documentElement).colorScheme.includes('light');
|
||||
const onHide = () => flush(true);
|
||||
const onVisibility = () => document.visibilityState === 'hidden' && flush(true);
|
||||
window.addEventListener('pagehide', onHide);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
return () => {
|
||||
window.removeEventListener('pagehide', onHide);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
flush(true);
|
||||
};
|
||||
});
|
||||
|
||||
function updateSettings(patch: Partial<ReaderSettings>) {
|
||||
settings = { ...settings, ...patch };
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
// ---- progress saving: debounced while reading, flushed on leave ----
|
||||
let sessionId: number | null = null;
|
||||
let pending: Relocation | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let baseline: string | null = null;
|
||||
|
||||
function onrelocate(r: Relocation) {
|
||||
location = r;
|
||||
if (!ready) return;
|
||||
if (baseline === null) {
|
||||
baseline = r.locator; // the restored position itself isn't reading
|
||||
return;
|
||||
}
|
||||
if (r.locator === baseline && !pending) return;
|
||||
pending = r;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => flush(), 2000);
|
||||
}
|
||||
|
||||
async function flush(leaving = false) {
|
||||
clearTimeout(timer);
|
||||
if (!pending) return;
|
||||
const r = pending;
|
||||
pending = null;
|
||||
const body = JSON.stringify({
|
||||
fileId: data.file.id,
|
||||
locator: r.locator,
|
||||
page: r.page,
|
||||
percent: Math.round(r.percent * 100) / 100,
|
||||
pageCount: r.pageCount,
|
||||
sessionId
|
||||
});
|
||||
saveState = 'saving';
|
||||
try {
|
||||
const res = await fetch(progressUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body,
|
||||
keepalive: leaving
|
||||
});
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
sessionId = (await res.json()).sessionId ?? sessionId;
|
||||
saveState = 'saved';
|
||||
} catch {
|
||||
saveState = 'error';
|
||||
if (!leaving) pending ??= r;
|
||||
}
|
||||
}
|
||||
|
||||
beforeNavigate(() => void flush(true));
|
||||
|
||||
// ---- highlights ----
|
||||
async function createHighlight(color: HighlightColor, note: string | null = null) {
|
||||
const sel = selection;
|
||||
if (!sel) return;
|
||||
const res = await fetch(notesUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fileId: data.file.id,
|
||||
locator: sel.locator,
|
||||
page: sel.page ?? location?.page ?? null,
|
||||
highlight: sel.text.slice(0, 20000),
|
||||
note: note || null,
|
||||
color
|
||||
})
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const h: Highlight = await res.json();
|
||||
highlights = [h, ...highlights];
|
||||
engine?.addHighlight(h);
|
||||
engine?.clearSelection();
|
||||
selection = null;
|
||||
noteFor = null;
|
||||
noteDraft = '';
|
||||
}
|
||||
|
||||
async function patchHighlight(
|
||||
h: Highlight,
|
||||
patch: { note?: string | null; color?: HighlightColor }
|
||||
) {
|
||||
const res = await fetch(`${notesUrl}/${h.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(patch)
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const updated: Highlight = await res.json();
|
||||
highlights = highlights.map((x) => (x.id === h.id ? updated : x));
|
||||
if (patch.color) engine?.addHighlight(updated);
|
||||
}
|
||||
|
||||
async function removeHighlight(h: Highlight) {
|
||||
const res = await fetch(`${notesUrl}/${h.id}`, { method: 'DELETE' });
|
||||
if (!res.ok && res.status !== 404) return;
|
||||
highlights = highlights.filter((x) => x.id !== h.id);
|
||||
engine?.removeHighlight(h);
|
||||
activeId = null;
|
||||
}
|
||||
|
||||
function openHighlight(id: number) {
|
||||
activeId = id;
|
||||
noteDraft = highlights.find((h) => h.id === id)?.note ?? '';
|
||||
noteFor = null;
|
||||
}
|
||||
|
||||
function onselection(s: TextSelection | null) {
|
||||
selection = s;
|
||||
if (s) activeId = null;
|
||||
else if (noteFor === 'selection') noteFor = null;
|
||||
}
|
||||
|
||||
// ---- navigation ----
|
||||
function onkey(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('input, textarea, select, [contenteditable="true"]')) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case 'PageDown':
|
||||
case 'l':
|
||||
engine?.next();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'PageUp':
|
||||
case 'h':
|
||||
engine?.prev();
|
||||
break;
|
||||
case ' ':
|
||||
if (isPdf) return;
|
||||
if (e.shiftKey) engine?.prev();
|
||||
else engine?.next();
|
||||
break;
|
||||
case 'Escape':
|
||||
if (panel || activeId || selection) {
|
||||
panel = null;
|
||||
activeId = null;
|
||||
engine?.clearSelection();
|
||||
selection = null;
|
||||
} else return;
|
||||
break;
|
||||
case 't':
|
||||
panel = panel === 'toc' ? null : 'toc';
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
async function jump(target: string | number) {
|
||||
await engine?.goTo(target);
|
||||
if (window.matchMedia('(max-width: 900px)').matches) panel = null;
|
||||
}
|
||||
|
||||
const fmtPercent = (p: number) => `${p < 10 ? p.toFixed(1) : Math.round(p)}%`;
|
||||
const tocLabel = $derived(location?.label ?? null);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onkey} />
|
||||
|
||||
<div class="reader" style:--r-bg={colors.bg} style:--r-fg={colors.fg} data-theme={theme}>
|
||||
<header class="bar top">
|
||||
<a href={detailUrl} class="icon-btn" aria-label="Back to the book"><ArrowLeft size={18} /></a>
|
||||
<div class="min-w-0 flex-1 text-center">
|
||||
<p class="truncate text-sm font-medium">{data.book.title}</p>
|
||||
<p class="truncate text-xs opacity-60">{tocLabel ?? data.book.authors.join(', ')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class={['icon-btn', panel === 'toc' && 'on']}
|
||||
aria-label="Contents"
|
||||
title="Contents (t)"
|
||||
onclick={() => (panel = panel === 'toc' ? null : 'toc')}><ListTree size={18} /></button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class={['icon-btn', panel === 'highlights' && 'on']}
|
||||
aria-label="Highlights"
|
||||
onclick={() => (panel = panel === 'highlights' ? null : 'highlights')}
|
||||
><Highlighter size={18} />{#if highlights.length}<span class="count">{highlights.length}</span
|
||||
>{/if}</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class={['icon-btn', panel === 'settings' && 'on']}
|
||||
aria-label="Display settings"
|
||||
onclick={() => (panel = panel === 'settings' ? null : 'settings')}><Type size={18} /></button
|
||||
>
|
||||
</header>
|
||||
|
||||
<div class="stage">
|
||||
<main class="book">
|
||||
{#if isPdf}
|
||||
<PdfReader
|
||||
bind:this={engine}
|
||||
url={data.file.url}
|
||||
name={data.file.name}
|
||||
{start}
|
||||
startPercent={data.resume.percent}
|
||||
{highlights}
|
||||
{settings}
|
||||
{theme}
|
||||
onready={(t) => {
|
||||
toc = t;
|
||||
ready = true;
|
||||
}}
|
||||
{onrelocate}
|
||||
{onselection}
|
||||
onhighlightclick={openHighlight}
|
||||
{onkey}
|
||||
onloading={(f) => (loading = f)}
|
||||
onerror={(m) => (failure = m)}
|
||||
/>
|
||||
{:else}
|
||||
<EpubReader
|
||||
bind:this={engine}
|
||||
url={data.file.url}
|
||||
name={data.file.name}
|
||||
{start}
|
||||
startPercent={data.resume.percent}
|
||||
{highlights}
|
||||
{settings}
|
||||
{theme}
|
||||
onready={(t) => {
|
||||
toc = t;
|
||||
ready = true;
|
||||
}}
|
||||
{onrelocate}
|
||||
{onselection}
|
||||
onhighlightclick={openHighlight}
|
||||
{onkey}
|
||||
onloading={(f) => (loading = f)}
|
||||
onerror={(m) => (failure = m)}
|
||||
/>
|
||||
{#if ready && settings.flow === 'paginated'}
|
||||
<button
|
||||
type="button"
|
||||
class="turn left"
|
||||
aria-label="Previous page"
|
||||
onclick={() => engine?.prev()}><ChevronLeft size={22} /></button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="turn right"
|
||||
aria-label="Next page"
|
||||
onclick={() => engine?.next()}><ChevronRight size={22} /></button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if failure}
|
||||
<div class="overlay">
|
||||
<p class="font-medium">Couldn't open this book</p>
|
||||
<p class="mt-1 text-sm opacity-70">{failure}</p>
|
||||
<a href={detailUrl} class="mt-4 text-sm underline">back to the book</a>
|
||||
</div>
|
||||
{:else if !ready}
|
||||
<div class="overlay">
|
||||
<p class="text-sm opacity-70">
|
||||
{loading === null || loading === 0
|
||||
? 'Opening…'
|
||||
: `Downloading ${Math.round(loading * 100)}%`}
|
||||
</p>
|
||||
<span class="loadbar"><span style:width={`${(loading ?? 0) * 100}%`}></span></span>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
{#if panel}
|
||||
<aside class="panel" aria-label={panel}>
|
||||
<div class="panel-head">
|
||||
<h2>
|
||||
{panel === 'toc' ? 'Contents' : panel === 'highlights' ? 'Highlights' : 'Display'}
|
||||
</h2>
|
||||
<button type="button" class="icon-btn" aria-label="Close" onclick={() => (panel = null)}
|
||||
><X size={16} /></button
|
||||
>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{#if panel === 'toc'}
|
||||
{#snippet tree(items: TocItem[], depth: number)}
|
||||
<ul>
|
||||
{#each items as item, i (i)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class={['toc-item', tocLabel === item.label && 'current']}
|
||||
style:padding-left={`${0.75 + depth * 0.9}rem`}
|
||||
onclick={() => jump(item.target)}>{item.label}</button
|
||||
>
|
||||
{#if item.children.length && depth < 4}{@render tree(
|
||||
item.children,
|
||||
depth + 1
|
||||
)}{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/snippet}
|
||||
{#if toc.length}{@render tree(toc, 0)}{:else}<p class="empty">
|
||||
No table of contents in this file.
|
||||
</p>{/if}
|
||||
{:else if panel === 'highlights'}
|
||||
{#if highlights.length}
|
||||
<ul class="space-y-2">
|
||||
{#each highlights as h (h.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="hl-item"
|
||||
style:--hl={highlightCss[(h.color as HighlightColor) ?? 'yellow'] ??
|
||||
highlightCss.yellow}
|
||||
onclick={() => {
|
||||
if (h.locator) void jump(h.locator);
|
||||
openHighlight(h.id);
|
||||
}}
|
||||
>
|
||||
<span class="quote">{h.highlight}</span>
|
||||
{#if h.note}<span class="note">{h.note}</span>{/if}
|
||||
{#if h.page}<span class="meta">p. {h.page}</span>{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="empty">
|
||||
Select text to highlight it. Highlights also show on the book page.
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="space-y-5">
|
||||
<fieldset>
|
||||
<legend>Theme</legend>
|
||||
<div class="themes">
|
||||
{#each ['auto', 'light', 'sepia', 'dark'] as const as t (t)}
|
||||
<button
|
||||
type="button"
|
||||
class={['theme-swatch', settings.theme === t && 'on']}
|
||||
style:background={t === 'auto'
|
||||
? 'linear-gradient(135deg, #fdfdfb 50%, #121214 50%)'
|
||||
: themeColors[t].bg}
|
||||
style:color={t === 'auto' ? '#888' : themeColors[t].fg}
|
||||
onclick={() => updateSettings({ theme: t })}>{t}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
{#if isPdf}
|
||||
<fieldset>
|
||||
<legend>Zoom</legend>
|
||||
<div class="stepper">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Zoom out"
|
||||
onclick={() =>
|
||||
updateSettings({
|
||||
pdfZoom: Math.max(0.5, +(settings.pdfZoom - 0.1).toFixed(2))
|
||||
})}><Minus size={14} /></button
|
||||
>
|
||||
<span>{Math.round(settings.pdfZoom * 100)}%</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Zoom in"
|
||||
onclick={() =>
|
||||
updateSettings({
|
||||
pdfZoom: Math.min(3, +(settings.pdfZoom + 0.1).toFixed(2))
|
||||
})}><Plus size={14} /></button
|
||||
>
|
||||
</div>
|
||||
</fieldset>
|
||||
{:else}
|
||||
<fieldset>
|
||||
<legend>Text size</legend>
|
||||
<div class="stepper">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Smaller text"
|
||||
onclick={() =>
|
||||
updateSettings({ fontSize: Math.max(70, settings.fontSize - 10) })}
|
||||
><Minus size={14} /></button
|
||||
>
|
||||
<span>{settings.fontSize}%</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Larger text"
|
||||
onclick={() =>
|
||||
updateSettings({ fontSize: Math.min(220, settings.fontSize + 10) })}
|
||||
><Plus size={14} /></button
|
||||
>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Font</legend>
|
||||
<div class="seg">
|
||||
{#each [['publisher', 'Book'], ['serif', 'Serif'], ['sans', 'Sans']] as const as [value, label] (value)}
|
||||
<button
|
||||
type="button"
|
||||
class={settings.font === value && 'on'}
|
||||
onclick={() => updateSettings({ font: value })}>{label}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Line spacing</legend>
|
||||
<div class="seg">
|
||||
{#each [1.3, 1.5, 1.75] as value (value)}
|
||||
<button
|
||||
type="button"
|
||||
class={settings.lineHeight === value && 'on'}
|
||||
onclick={() => updateSettings({ lineHeight: value })}
|
||||
>{value === 1.3 ? 'Tight' : value === 1.5 ? 'Normal' : 'Loose'}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Layout</legend>
|
||||
<div class="seg">
|
||||
<button
|
||||
type="button"
|
||||
class={settings.flow === 'paginated' && 'on'}
|
||||
onclick={() => updateSettings({ flow: 'paginated' })}>Pages</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class={settings.flow === 'scrolled' && 'on'}
|
||||
onclick={() => updateSettings({ flow: 'scrolled' })}>Scroll</button
|
||||
>
|
||||
</div>
|
||||
</fieldset>
|
||||
{/if}
|
||||
<p class="text-xs opacity-60">
|
||||
Keys: ← → or h l to turn pages, t for contents, Esc to close.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selection || active}
|
||||
<div class="sheet" role="dialog" aria-label={active ? 'Highlight' : 'Highlight selection'}>
|
||||
{#if active}
|
||||
<p class="sheet-quote">{active.highlight}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
{#each highlightColors as c (c)}
|
||||
<button
|
||||
type="button"
|
||||
class={['dot', active.color === c && 'on']}
|
||||
style:background={highlightCss[c]}
|
||||
aria-label={`Colour ${c}`}
|
||||
onclick={() => patchHighlight(active, { color: c })}
|
||||
></button>
|
||||
{/each}
|
||||
<span class="flex-1"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label="Delete highlight"
|
||||
onclick={() => removeHighlight(active)}><Trash2 size={16} /></button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label="Close"
|
||||
onclick={() => (activeId = null)}><X size={16} /></button
|
||||
>
|
||||
</div>
|
||||
<textarea
|
||||
class="sheet-note"
|
||||
rows="2"
|
||||
placeholder="Add a note…"
|
||||
bind:value={noteDraft}
|
||||
onblur={() => {
|
||||
if ((active.note ?? '') !== noteDraft.trim())
|
||||
void patchHighlight(active, { note: noteDraft.trim() || null });
|
||||
}}></textarea>
|
||||
{:else if selection}
|
||||
<div class="flex items-center gap-2">
|
||||
{#each highlightColors as c (c)}
|
||||
<button
|
||||
type="button"
|
||||
class="dot"
|
||||
style:background={highlightCss[c]}
|
||||
aria-label={`Highlight ${c}`}
|
||||
title={`Highlight ${c}`}
|
||||
onclick={() => createHighlight(c, noteFor === 'selection' ? noteDraft.trim() : null)}
|
||||
></button>
|
||||
{/each}
|
||||
<span class="flex-1"></span>
|
||||
<button
|
||||
type="button"
|
||||
class={['icon-btn', noteFor === 'selection' && 'on']}
|
||||
aria-label="Highlight with a note"
|
||||
onclick={() => {
|
||||
noteFor = noteFor === 'selection' ? null : 'selection';
|
||||
noteDraft = '';
|
||||
}}><MessageSquarePlus size={16} /></button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label="Copy"
|
||||
onclick={() => {
|
||||
void navigator.clipboard?.writeText(selection?.text ?? '');
|
||||
engine?.clearSelection();
|
||||
selection = null;
|
||||
}}><Copy size={16} /></button
|
||||
>
|
||||
</div>
|
||||
{#if noteFor === 'selection'}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<textarea
|
||||
class="sheet-note"
|
||||
rows="2"
|
||||
placeholder="Note, then pick a colour"
|
||||
autofocus
|
||||
bind:value={noteDraft}></textarea>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<footer class="bar bottom">
|
||||
<span class="w-12 text-xs tabular-nums opacity-70">{fmtPercent(percent)}</span>
|
||||
<input
|
||||
class="scrub"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
value={percent}
|
||||
aria-label="Position in book"
|
||||
style:--fill={`${percent}%`}
|
||||
oninput={(e) => (scrubbing = Number(e.currentTarget.value))}
|
||||
onchange={async (e) => {
|
||||
await engine?.goToPercent(Number(e.currentTarget.value));
|
||||
scrubbing = null;
|
||||
}}
|
||||
/>
|
||||
<span class="w-24 text-right text-xs tabular-nums opacity-70">
|
||||
{#if location?.page && location.pageCount}{location.page} / {location.pageCount}{:else if saveState === 'saving'}saving…{:else if saveState === 'error'}not
|
||||
saved{:else if saveState === 'saved'}saved{/if}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.reader {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--r-bg);
|
||||
color: var(--r-fg);
|
||||
--r-line: color-mix(in srgb, var(--r-fg) 12%, transparent);
|
||||
--r-soft: color-mix(in srgb, var(--r-fg) 7%, transparent);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.75rem;
|
||||
flex: none;
|
||||
}
|
||||
.top {
|
||||
height: 3.25rem;
|
||||
border-bottom: 1px solid var(--r-line);
|
||||
}
|
||||
.bottom {
|
||||
height: 3rem;
|
||||
border-top: 1px solid var(--r-line);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.icon-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
opacity: 0.75;
|
||||
transition:
|
||||
background 0.15s,
|
||||
opacity 0.15s;
|
||||
}
|
||||
.icon-btn:hover,
|
||||
.icon-btn.on {
|
||||
opacity: 1;
|
||||
background: var(--r-soft);
|
||||
}
|
||||
.count {
|
||||
position: absolute;
|
||||
top: 0.1rem;
|
||||
right: 0.05rem;
|
||||
font-size: 0.6rem;
|
||||
min-width: 1rem;
|
||||
line-height: 1rem;
|
||||
border-radius: 999px;
|
||||
background: var(--r-fg);
|
||||
color: var(--r-bg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
.book {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.turn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
translate: 0 -50%;
|
||||
z-index: 2;
|
||||
width: 2.5rem;
|
||||
height: 5rem;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.5rem;
|
||||
opacity: 0.25;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.turn:hover {
|
||||
opacity: 0.8;
|
||||
background: var(--r-soft);
|
||||
}
|
||||
.turn.left {
|
||||
left: 0.5rem;
|
||||
}
|
||||
.turn.right {
|
||||
right: 0.5rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.turn {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
background: var(--r-bg);
|
||||
padding: 2rem;
|
||||
}
|
||||
.loadbar {
|
||||
margin-top: 0.75rem;
|
||||
width: 12rem;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--r-line);
|
||||
overflow: hidden;
|
||||
}
|
||||
.loadbar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--r-fg);
|
||||
transition: width 0.2s;
|
||||
}
|
||||
.panel {
|
||||
width: 22rem;
|
||||
max-width: 100%;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--r-line);
|
||||
background: var(--r-bg);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.panel {
|
||||
position: absolute;
|
||||
inset: 0 0 0 auto;
|
||||
z-index: 5;
|
||||
box-shadow: -12px 0 32px rgb(0 0 0 / 0.2);
|
||||
}
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 0.75rem 0.6rem 1rem;
|
||||
border-bottom: 1px solid var(--r-line);
|
||||
}
|
||||
.panel-head h2 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.empty {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.65;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.toc-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
padding-block: 0.4rem;
|
||||
padding-right: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.toc-item:hover {
|
||||
background: var(--r-soft);
|
||||
opacity: 1;
|
||||
}
|
||||
.toc-item.current {
|
||||
font-weight: 600;
|
||||
opacity: 1;
|
||||
}
|
||||
.hl-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border-left: 3px solid var(--hl);
|
||||
background: color-mix(in srgb, var(--hl) 10%, transparent);
|
||||
}
|
||||
.hl-item:hover {
|
||||
background: color-mix(in srgb, var(--hl) 18%, transparent);
|
||||
}
|
||||
.hl-item .quote {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hl-item .note {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.hl-item .meta {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
fieldset legend {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.65;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.themes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.theme-swatch {
|
||||
height: 2.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--r-line);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.theme-swatch.on {
|
||||
outline: 2px solid var(--r-fg);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.stepper,
|
||||
.seg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--r-line);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stepper button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
.stepper button:hover,
|
||||
.seg button:hover {
|
||||
background: var(--r-soft);
|
||||
}
|
||||
.stepper span {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.seg button {
|
||||
flex: 1;
|
||||
height: 2.25rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.seg button + button {
|
||||
border-left: 1px solid var(--r-line);
|
||||
}
|
||||
.seg button.on {
|
||||
background: var(--r-fg);
|
||||
color: var(--r-bg);
|
||||
}
|
||||
.sheet {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 3.75rem;
|
||||
translate: -50% 0;
|
||||
z-index: 10;
|
||||
width: min(26rem, calc(100% - 1.5rem));
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.875rem;
|
||||
border: 1px solid var(--r-line);
|
||||
background: color-mix(in srgb, var(--r-bg) 92%, var(--r-fg));
|
||||
box-shadow: 0 12px 40px rgb(0 0 0 / 0.25);
|
||||
animation: rise 0.15s ease-out;
|
||||
}
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
translate: -50% 6px;
|
||||
}
|
||||
}
|
||||
.sheet-quote {
|
||||
font-family: Georgia, serif;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
max-height: 6rem;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.sheet-note {
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--r-line);
|
||||
background: transparent;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.875rem;
|
||||
color: inherit;
|
||||
}
|
||||
.dot {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.dot:hover {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
.dot.on {
|
||||
border-color: var(--r-fg);
|
||||
}
|
||||
.scrub {
|
||||
flex: 1;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--r-fg) var(--fill),
|
||||
color-mix(in srgb, var(--r-fg) 18%, transparent) var(--fill)
|
||||
);
|
||||
cursor: pointer;
|
||||
}
|
||||
.scrub::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--r-fg);
|
||||
}
|
||||
.scrub::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--r-fg);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user