|
12 | 12 | import ReaderProgressBar from './ReaderProgressBar.svelte'; |
13 | 13 | import TimeEstimate from './TimeEstimate.svelte'; |
14 | 14 | import KeyboardShortcuts from './KeyboardShortcuts.svelte'; |
| 15 | + import PaginatedViewport from './PaginatedViewport.svelte'; |
| 16 | + import ReaderSettingsPanel from './ReaderSettingsPanel.svelte'; |
| 17 | + import { loadReaderSettings, persistReaderSettings, DEFAULT_READER_SETTINGS, type ReaderSettings } from './reader-settings'; |
15 | 18 |
|
16 | 19 | interface Props { |
17 | 20 | epubUrl: string; |
|
104 | 107 | { label: 'Shortcuts', key: '?' } |
105 | 108 | ]; |
106 | 109 |
|
107 | | - // Reader settings (persisted in localStorage) |
108 | | - let readerTheme = $state<'dark' | 'light' | 'sepia' | 'oled'>('dark'); |
109 | | - let fontFamily = $state<'serif' | 'sans' | 'mono' | 'display'>('serif'); |
110 | | - let fontSize = $state(18); |
111 | | - let lineHeight = $state(1.6); |
112 | | - let margins = $state<'narrow' | 'medium' | 'wide'>('medium'); |
113 | | - let textAlign = $state<'start' | 'justify'>('start'); |
114 | | - let flow = $state<'paginated' | 'scrolled'>('paginated'); |
| 110 | + // Reader settings (persisted in localStorage via shared module) |
| 111 | + let settings = $state<ReaderSettings>({ ...DEFAULT_READER_SETTINGS }); |
| 112 | +
|
| 113 | + // Derived aliases — keeps existing template references working; all writes |
| 114 | + // go back into `settings` so there is a single source of truth. |
| 115 | + const readerTheme = $derived(settings.theme); |
| 116 | + const fontFamily = $derived(settings.fontFamily); |
| 117 | + const fontSize = $derived(settings.fontSize); |
| 118 | + const lineHeight = $derived(settings.lineHeight); |
| 119 | + const margins = $derived(settings.margins); |
| 120 | + const textAlign = $derived(settings.textAlign); |
| 121 | + const flow = $derived(settings.flow); |
115 | 122 |
|
116 | 123 | let hideTimer: ReturnType<typeof setTimeout> | null = null; |
117 | 124 |
|
118 | 125 | const anyPanelOpen = $derived(showToc || showSettings || showBookmarks || showSearch || showFormatMenu); |
119 | 126 |
|
120 | | - const themes: Record<string, { bg: string; text: string; link: string }> = { |
| 127 | + const themes: Record<ReaderSettings['theme'], { bg: string; text: string; link: string }> = { |
121 | 128 | dark: { bg: '#181514', text: '#f0ebe3', link: '#d4a253' }, |
122 | 129 | light: { bg: '#faf8f5', text: '#1a1a1a', link: '#b8862e' }, |
123 | 130 | sepia: { bg: '#f4ecd8', text: '#5b4636', link: '#8b6914' }, |
124 | 131 | oled: { bg: '#000000', text: '#b0b0b0', link: '#d4a253' } |
125 | 132 | }; |
126 | 133 |
|
127 | | - const fonts: Record<string, string> = { |
| 134 | + const fonts: Record<ReaderSettings['fontFamily'], string> = { |
128 | 135 | serif: "Georgia, 'Times New Roman', serif", |
129 | 136 | sans: "system-ui, -apple-system, 'Segoe UI', sans-serif", |
130 | 137 | mono: "'JetBrains Mono', 'Fira Code', monospace", |
131 | 138 | display: "'Playfair Display', Georgia, serif" |
132 | 139 | }; |
133 | 140 |
|
134 | | - const marginValues: Record<string, string> = { |
| 141 | + const marginValues: Record<ReaderSettings['margins'], string> = { |
135 | 142 | narrow: '2%', |
136 | 143 | medium: '6%', |
137 | 144 | wide: '12%' |
|
144 | 151 | pink: 'rgba(251, 113, 133, 0.3)' |
145 | 152 | }; |
146 | 153 |
|
147 | | - // ── Settings persistence ── |
148 | | - function loadSettings() { |
149 | | - if (!browser) return; |
150 | | - try { |
151 | | - const saved = localStorage.getItem('nexus-reader-settings'); |
152 | | - if (saved) { |
153 | | - const s = JSON.parse(saved); |
154 | | - if (s.theme && s.theme in themes) readerTheme = s.theme; |
155 | | - if (s.fontFamily && s.fontFamily in fonts) fontFamily = s.fontFamily; |
156 | | - if (s.fontSize) fontSize = s.fontSize; |
157 | | - if (s.lineHeight) lineHeight = s.lineHeight; |
158 | | - if (s.margins && s.margins in marginValues) margins = s.margins; |
159 | | - if (s.textAlign === 'start' || s.textAlign === 'justify') textAlign = s.textAlign; |
160 | | - if (s.flow) flow = s.flow; |
161 | | - } |
162 | | - } catch { /* ignore */ } |
163 | | - } |
| 154 | + // ── Settings persistence (via shared module) ── |
| 155 | + // Writes are persisted via $effect below; reads happen lazily in |
| 156 | + // initFoliateReader so the reader hydrates with whatever's in localStorage. |
164 | 157 |
|
165 | | - function persistSettings() { |
| 158 | + $effect(() => { |
| 159 | + // Persist on every change. `persistReaderSettings` merges into stored state. |
166 | 160 | if (!browser) return; |
167 | | - localStorage.setItem('nexus-reader-settings', JSON.stringify({ |
168 | | - theme: readerTheme, fontFamily, fontSize, lineHeight, margins, textAlign, flow |
169 | | - })); |
170 | | - } |
| 161 | + persistReaderSettings(settings); |
| 162 | + }); |
171 | 163 |
|
172 | 164 | // ── Build CSS for foliate-js renderer ── |
173 | 165 | function getReaderCSS(): string { |
|
218 | 210 | function applyStyles() { |
219 | 211 | if (!view?.renderer?.setStyles) return; |
220 | 212 | view.renderer.setStyles(getReaderCSS()); |
221 | | - persistSettings(); |
222 | 213 | } |
223 | 214 |
|
224 | 215 | // ── Flatten TOC for display ── |
|
507 | 498 | function handleKeydown(e: KeyboardEvent) { |
508 | 499 | if (showSearch && e.key !== 'Escape') return; |
509 | 500 | if (showNoteInput && e.key !== 'Escape') return; |
| 501 | + // ArrowLeft/Right are owned by PaginatedViewport (gated by settings.inputs.keyboard). |
510 | 502 | switch (e.key) { |
511 | | - case 'ArrowLeft': e.preventDefault(); prevPage(); break; |
512 | | - case 'ArrowRight': e.preventDefault(); nextPage(); break; |
513 | 503 | case 'Escape': |
514 | 504 | e.preventDefault(); |
515 | 505 | if (showAnnotationPopup) dismissAnnotationPopup(); |
|
528 | 518 |
|
529 | 519 | // ── Svelte action for foliate-js lifecycle ── |
530 | 520 | function initFoliateReader(node: HTMLElement) { |
531 | | - loadSettings(); |
| 521 | + // Hydrate settings from localStorage before the view opens |
| 522 | + settings = loadReaderSettings(); |
532 | 523 |
|
533 | 524 | // Sync initial prop values |
534 | 525 | currentProgress = initialProgress; |
|
684 | 675 | if (view && ready) applyStyles(); |
685 | 676 | }); |
686 | 677 |
|
687 | | - // Handle flow change |
| 678 | + // Handle flow + direction changes — drive the foliate renderer directly. |
688 | 679 | $effect(() => { |
689 | | - if (!view || !ready) return; |
690 | | - void flow; |
691 | | - view.renderer.setAttribute('flow', flow); |
692 | | - persistSettings(); |
| 680 | + if (!view?.renderer) return; |
| 681 | + view.renderer.setAttribute('flow', settings.flow); |
| 682 | + view.renderer.setAttribute('dir', settings.direction); |
693 | 683 | }); |
694 | 684 |
|
695 | 685 | const progressPercent = $derived(Math.round(currentProgress * 100)); |
|
790 | 780 | style="background-color: {themes[readerTheme].bg};" |
791 | 781 | onmousemove={handleReaderMouseMove} |
792 | 782 | > |
793 | | - <!-- foliate-js container --> |
794 | | - <div |
795 | | - use:initFoliateReader |
796 | | - class="absolute overflow-hidden" |
797 | | - style="top: 0; bottom: 0; left: 0; right: 0;" |
798 | | - ></div> |
| 783 | + <!-- foliate-js container, wrapped by PaginatedViewport for shared input/animation behavior --> |
| 784 | + <div class="absolute" style="top: 0; bottom: 0; left: 0; right: 0;"> |
| 785 | + <PaginatedViewport |
| 786 | + {settings} |
| 787 | + onPrev={() => view?.prev()} |
| 788 | + onNext={() => view?.next()} |
| 789 | + onToggleUI={() => { showSettings = !showSettings; }} |
| 790 | + > |
| 791 | + {#snippet children(_ctx: { effectiveSpread: 'single' | 'dual'; animationKey: number })} |
| 792 | + <div |
| 793 | + use:initFoliateReader |
| 794 | + class="h-full w-full overflow-hidden" |
| 795 | + ></div> |
| 796 | + {/snippet} |
| 797 | + </PaginatedViewport> |
| 798 | + </div> |
799 | 799 |
|
800 | 800 | <!-- Click zone overlay for navigation (only outside the iframe) --> |
801 | 801 | <button |
|
912 | 912 | {#each [{ key: 'light', label: 'Light', bg: '#faf8f5', ring: '#ccc' }, { key: 'sepia', label: 'Sepia', bg: '#f4ecd8', ring: '#c4a96a' }, { key: 'dark', label: 'Dark', bg: '#181514', ring: '#555' }, { key: 'oled', label: 'OLED', bg: '#000000', ring: '#333' }] as { key, label, bg, ring } (key)} |
913 | 913 | <button |
914 | 914 | class="flex flex-col items-center justify-center gap-1.5 rounded-lg border px-2 py-2.5 text-[10px] transition-all {readerTheme === key ? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)]' : 'border-cream/[0.08] text-cream/40 hover:border-cream/20 hover:text-cream/60'}" |
915 | | - onclick={() => { readerTheme = key as typeof readerTheme; }} |
| 915 | + onclick={() => { settings.theme = key as ReaderSettings['theme']; }} |
916 | 916 | > |
917 | 917 | <span |
918 | 918 | class="h-5 w-5 rounded-full border-2" |
|
932 | 932 | <button |
933 | 933 | class="rounded-lg border px-2 py-2 text-xs transition-all {fontFamily === key ? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)]' : 'border-cream/[0.08] text-cream/50 hover:border-cream/20 hover:text-cream/70'}" |
934 | 934 | style="font-family: {font};" |
935 | | - onclick={() => { fontFamily = key as typeof fontFamily; }} |
| 935 | + onclick={() => { settings.fontFamily = key as ReaderSettings['fontFamily']; }} |
936 | 936 | >{label}</button> |
937 | 937 | {/each} |
938 | 938 | </div> |
|
946 | 946 | </label> |
947 | 947 | <div class="flex items-center gap-3"> |
948 | 948 | <Type size={12} class="shrink-0 text-cream/30" /> |
949 | | - <input id="reader-font-size" type="range" min="12" max="36" step="1" bind:value={fontSize} class="reader-range flex-1" /> |
| 949 | + <input id="reader-font-size" type="range" min="12" max="36" step="1" bind:value={settings.fontSize} class="reader-range flex-1" /> |
950 | 950 | <Type size={20} class="shrink-0 text-cream/30" /> |
951 | 951 | </div> |
952 | 952 | </div> |
|
957 | 957 | <span>Line Height</span> |
958 | 958 | <span class="normal-case tracking-normal text-cream/60">{lineHeight.toFixed(1)}</span> |
959 | 959 | </label> |
960 | | - <input id="reader-line-height" type="range" min="1.0" max="2.0" step="0.1" bind:value={lineHeight} class="reader-range w-full" /> |
| 960 | + <input id="reader-line-height" type="range" min="1.0" max="2.0" step="0.1" bind:value={settings.lineHeight} class="reader-range w-full" /> |
961 | 961 | </div> |
962 | 962 |
|
963 | 963 | <!-- Margins --> |
|
967 | 967 | {#each [{ key: 'narrow', label: 'Narrow' }, { key: 'medium', label: 'Medium' }, { key: 'wide', label: 'Wide' }] as { key, label } (key)} |
968 | 968 | <button |
969 | 969 | class="flex-1 rounded-lg border px-3 py-2 text-xs transition-all {margins === key ? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)]' : 'border-cream/[0.08] text-cream/50 hover:border-cream/20 hover:text-cream/70'}" |
970 | | - onclick={() => { margins = key as typeof margins; }} |
| 970 | + onclick={() => { settings.margins = key as ReaderSettings['margins']; }} |
971 | 971 | >{label}</button> |
972 | 972 | {/each} |
973 | 973 | </div> |
|
979 | 979 | <div class="flex gap-2"> |
980 | 980 | <button |
981 | 981 | class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-xs transition-all {textAlign === 'start' ? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)]' : 'border-cream/[0.08] text-cream/50 hover:border-cream/20 hover:text-cream/70'}" |
982 | | - onclick={() => { textAlign = 'start'; }} |
| 982 | + onclick={() => { settings.textAlign = 'start'; }} |
983 | 983 | > |
984 | 984 | <AlignLeft size={13} strokeWidth={1.5} /> Left |
985 | 985 | </button> |
986 | 986 | <button |
987 | 987 | class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-xs transition-all {textAlign === 'justify' ? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)]' : 'border-cream/[0.08] text-cream/50 hover:border-cream/20 hover:text-cream/70'}" |
988 | | - onclick={() => { textAlign = 'justify'; }} |
| 988 | + onclick={() => { settings.textAlign = 'justify'; }} |
989 | 989 | > |
990 | 990 | <AlignJustify size={13} strokeWidth={1.5} /> Justified |
991 | 991 | </button> |
992 | 992 | </div> |
993 | 993 | </div> |
994 | 994 |
|
995 | | - <!-- Reading Mode --> |
996 | | - <div> |
997 | | - <span class="settings-label">Reading Mode</span> |
998 | | - <div class="flex gap-2"> |
999 | | - <button |
1000 | | - class="flex-1 rounded-lg border px-3 py-2 text-xs transition-all {flow === 'paginated' ? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)]' : 'border-cream/[0.08] text-cream/50 hover:border-cream/20 hover:text-cream/70'}" |
1001 | | - onclick={() => { flow = 'paginated'; }} |
1002 | | - >Paginated</button> |
1003 | | - <button |
1004 | | - class="flex-1 rounded-lg border px-3 py-2 text-xs transition-all {flow === 'scrolled' ? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)]' : 'border-cream/[0.08] text-cream/50 hover:border-cream/20 hover:text-cream/70'}" |
1005 | | - onclick={() => { flow = 'scrolled'; }} |
1006 | | - >Scrolled</button> |
1007 | | - </div> |
1008 | | - </div> |
| 995 | + <!-- Shared paginated / flow / spread / inputs / direction panel --> |
| 996 | + <ReaderSettingsPanel bind:settings variant="epub" /> |
1009 | 997 | </div> |
1010 | 998 | </div> |
1011 | 999 | {/if} |
|
0 commit comments