aksos-react-native-epub-reader 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +338 -2
  3. package/lib/EpubReader.d.ts +63 -0
  4. package/lib/EpubReader.js +199 -0
  5. package/lib/EpubReaderView.d.ts +24 -0
  6. package/lib/EpubReaderView.js +504 -0
  7. package/lib/assets/page-turn-1.wav +0 -0
  8. package/lib/assets/page-turn-2.wav +0 -0
  9. package/lib/assets/page-turn-3.wav +0 -0
  10. package/lib/assets/page-turn-4.wav +0 -0
  11. package/lib/engine/html.d.ts +1 -0
  12. package/lib/engine/html.js +5 -0
  13. package/lib/index.d.ts +6 -0
  14. package/lib/index.js +30 -0
  15. package/lib/sound.d.ts +1 -0
  16. package/lib/sound.js +71 -0
  17. package/lib/themes.d.ts +56 -0
  18. package/lib/themes.js +166 -0
  19. package/lib/types.d.ts +133 -0
  20. package/lib/types.js +2 -0
  21. package/lib/ui/AppearanceSheet.d.ts +15 -0
  22. package/lib/ui/AppearanceSheet.js +127 -0
  23. package/lib/ui/ContentsSheet.d.ts +27 -0
  24. package/lib/ui/ContentsSheet.js +87 -0
  25. package/lib/ui/CustomizeSheet.d.ts +16 -0
  26. package/lib/ui/CustomizeSheet.js +139 -0
  27. package/lib/ui/Glass.d.ts +15 -0
  28. package/lib/ui/Glass.js +27 -0
  29. package/lib/ui/HighlightMenu.d.ts +25 -0
  30. package/lib/ui/HighlightMenu.js +43 -0
  31. package/lib/ui/Icons.d.ts +17 -0
  32. package/lib/ui/Icons.js +193 -0
  33. package/lib/ui/LineGuide.d.ts +21 -0
  34. package/lib/ui/LineGuide.js +92 -0
  35. package/lib/ui/NoteEditor.d.ts +12 -0
  36. package/lib/ui/NoteEditor.js +61 -0
  37. package/lib/ui/Scrubber.d.ts +15 -0
  38. package/lib/ui/Scrubber.js +86 -0
  39. package/lib/ui/SearchSheet.d.ts +14 -0
  40. package/lib/ui/SearchSheet.js +67 -0
  41. package/lib/ui/Sheet.d.ts +19 -0
  42. package/lib/ui/Sheet.js +38 -0
  43. package/lib/ui/Slider.d.ts +14 -0
  44. package/lib/ui/Slider.js +53 -0
  45. package/package.json +75 -5
  46. package/src/EpubReader.tsx +256 -0
  47. package/src/EpubReaderView.tsx +721 -0
  48. package/src/assets/page-turn-1.wav +0 -0
  49. package/src/assets/page-turn-2.wav +0 -0
  50. package/src/assets/page-turn-3.wav +0 -0
  51. package/src/assets/page-turn-4.wav +0 -0
  52. package/src/engine/html.ts +2 -0
  53. package/src/engine/reader.js +1696 -0
  54. package/src/index.ts +6 -0
  55. package/src/sound.ts +66 -0
  56. package/src/themes.ts +188 -0
  57. package/src/types.ts +138 -0
  58. package/src/ui/AppearanceSheet.tsx +167 -0
  59. package/src/ui/ContentsSheet.tsx +144 -0
  60. package/src/ui/CustomizeSheet.tsx +189 -0
  61. package/src/ui/Glass.tsx +37 -0
  62. package/src/ui/HighlightMenu.tsx +64 -0
  63. package/src/ui/Icons.tsx +246 -0
  64. package/src/ui/LineGuide.tsx +87 -0
  65. package/src/ui/NoteEditor.tsx +55 -0
  66. package/src/ui/Scrubber.tsx +78 -0
  67. package/src/ui/SearchSheet.tsx +79 -0
  68. package/src/ui/Sheet.tsx +69 -0
  69. package/src/ui/Slider.tsx +30 -0
@@ -0,0 +1,721 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { Alert, Animated, Linking, Platform, Pressable, Share, StatusBar, StyleSheet, Text, useColorScheme, useWindowDimensions, View } from 'react-native';
3
+ import { EpubReader } from './EpubReader';
4
+ import type { EpubReaderHandle } from './EpubReader';
5
+ import { chromeColors, DEFAULT_SETTINGS, isDark, themeColors } from './themes';
6
+ import type {
7
+ Bookmark,
8
+ BookLocation,
9
+ BookMetadata,
10
+ EpubSource,
11
+ Highlight,
12
+ HighlightColor,
13
+ PageTurn,
14
+ ReaderSettings,
15
+ RelocateEvent,
16
+ SearchResult,
17
+ SelectionEvent,
18
+ TocItem
19
+ } from './types';
20
+ import { AppearanceSheet } from './ui/AppearanceSheet';
21
+ import { ContentsSheet } from './ui/ContentsSheet';
22
+ import { CustomizeSheet } from './ui/CustomizeSheet';
23
+ import { playPageTurn } from './sound';
24
+ import { Glass } from './ui/Glass';
25
+ import { HighlightMenu } from './ui/HighlightMenu';
26
+ import { Icon } from './ui/Icons';
27
+ import { LineGuide } from './ui/LineGuide';
28
+ import type { DimLevel } from './ui/LineGuide';
29
+ import { NoteEditor } from './ui/NoteEditor';
30
+ import { SearchSheet } from './ui/SearchSheet';
31
+
32
+ export interface EpubReaderViewProps {
33
+ source: EpubSource;
34
+ /** shown in the Contents sheet until the book's own title is read */
35
+ title?: string;
36
+ safeInsets?: { top: number; bottom: number };
37
+ initialLocation?: BookLocation;
38
+ initialSettings?: Partial<ReaderSettings>;
39
+ initialHighlights?: Highlight[];
40
+ initialBookmarks?: Bookmark[];
41
+ onClose?: () => void;
42
+ onLocationChange?: (e: RelocateEvent) => void;
43
+ onHighlightsChange?: (list: Highlight[]) => void;
44
+ onBookmarksChange?: (list: Bookmark[]) => void;
45
+ onSettingsChange?: (s: ReaderSettings) => void;
46
+ onMetadata?: (m: BookMetadata) => void;
47
+ onCover?: (dataUri: string) => void;
48
+ onError?: (message: string) => void;
49
+ }
50
+
51
+ const RESET_THEME: Partial<ReaderSettings> = {
52
+ fontId: 'original',
53
+ bold: false,
54
+ customize: false,
55
+ lineHeight: 1.5,
56
+ letterSpacing: 0,
57
+ wordSpacing: 0,
58
+ margins: 0,
59
+ columns: 'auto',
60
+ justify: false
61
+ };
62
+
63
+ // expo-screen-orientation is optional (needed for the rotation lock button)
64
+ let ScreenOrientation: any = null;
65
+ try {
66
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
67
+ ScreenOrientation = require('expo-screen-orientation');
68
+ } catch {
69
+ ScreenOrientation = null;
70
+ }
71
+
72
+ const DIM_LABELS: { id: DimLevel; label: string }[] = [
73
+ { id: 'high', label: 'High' },
74
+ { id: 'medium', label: 'Medium' },
75
+ { id: 'low', label: 'Low' },
76
+ { id: 'none', label: 'None' }
77
+ ];
78
+
79
+ const SAMPLE = 'The reader turned the page slowly, listening to the wind move across the empty hills.';
80
+ const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
81
+
82
+ function defaultInsets() {
83
+ return Platform.OS === 'ios' ? { top: 50, bottom: 30 } : { top: StatusBar.currentHeight || 24, bottom: 0 };
84
+ }
85
+
86
+ type SheetName = null | 'contents' | 'search' | 'appearance' | 'customize';
87
+
88
+ export function EpubReaderView(props: EpubReaderViewProps) {
89
+ const safe = props.safeInsets || defaultInsets();
90
+ const screen = useWindowDimensions();
91
+ const systemDark = useColorScheme() === 'dark';
92
+ const reader = useRef<EpubReaderHandle>(null);
93
+
94
+ const [settings, setSettings] = useState<ReaderSettings>({ ...DEFAULT_SETTINGS, ...props.initialSettings });
95
+ const [highlights, setHighlights] = useState<Highlight[]>(props.initialHighlights || []);
96
+ const [bookmarks, setBookmarks] = useState<Bookmark[]>(props.initialBookmarks || []);
97
+ const [toc, setToc] = useState<TocItem[]>([]);
98
+ const [meta, setMeta] = useState<BookMetadata | null>(null);
99
+ const [rtl, setRtl] = useState(false);
100
+ const [loc, setLoc] = useState<RelocateEvent | null>(null);
101
+ const [index, setIndex] = useState<{ totalLength: number; cum: number[] } | null>(null);
102
+ const [cpp, setCpp] = useState(1200); // characters per page, measured from the chapters seen so far
103
+ // reading starts with nothing but the running header/footer; a tap shows the close button and the menu button
104
+ const [chrome, setChrome] = useState(false);
105
+ const [menuOpen, setMenuOpen] = useState(false);
106
+ const [pillW, setPillW] = useState(0);
107
+ const [lineGuide, setLineGuide] = useState(false);
108
+ const [dimLevel, setDimLevel] = useState<DimLevel>('high');
109
+ const [lineMenu, setLineMenu] = useState(false);
110
+ const [rotLocked, setRotLocked] = useState(false);
111
+ const [returnTo, setReturnTo] = useState<{ spine: number; offset: number; page: number } | null>(null);
112
+ const [sheet, setSheet] = useState<SheetName>(null);
113
+ const [active, setActive] = useState<{ id: string; rect: SelectionEvent['rect'] } | null>(null);
114
+ const [selection, setSelection] = useState<SelectionEvent | null>(null);
115
+ const [noteFor, setNoteFor] = useState<string | null>(null);
116
+ const [results, setResults] = useState<SearchResult[]>([]);
117
+ const [searching, setSearching] = useState(false);
118
+ const [error, setError] = useState<string | null>(null);
119
+ const searchId = useRef(0);
120
+ const seen = useRef<Record<number, { len: number; pc: number }>>({});
121
+ const layoutKey = useRef('');
122
+
123
+ const dark = isDark(settings, systemDark);
124
+ const page = themeColors(settings, systemDark);
125
+ const colors = useMemo(() => chromeColors(page, dark), [page, dark]);
126
+
127
+ // ----- chrome visibility animation
128
+ const fade = useRef(new Animated.Value(1)).current;
129
+ useEffect(() => {
130
+ Animated.timing(fade, { toValue: chrome ? 1 : 0, duration: 180, useNativeDriver: true }).start();
131
+ }, [chrome, fade]);
132
+ const fadeOut = fade.interpolate({ inputRange: [0, 1], outputRange: [1, 0] });
133
+ const menuFade = useRef(new Animated.Value(0)).current;
134
+ useEffect(() => {
135
+ Animated.timing(menuFade, { toValue: menuOpen ? 1 : 0, duration: 160, useNativeDriver: true }).start();
136
+ }, [menuOpen, menuFade]);
137
+ useEffect(() => {
138
+ if (!chrome) setMenuOpen(false);
139
+ }, [chrome]);
140
+
141
+ // ----- persistence helpers
142
+ const updateHighlights = useCallback(
143
+ (fn: (prev: Highlight[]) => Highlight[]) => {
144
+ setHighlights((prev) => {
145
+ const next = fn(prev);
146
+ props.onHighlightsChange?.(next);
147
+ return next;
148
+ });
149
+ },
150
+ // eslint-disable-next-line react-hooks/exhaustive-deps
151
+ [props.onHighlightsChange]
152
+ );
153
+ const updateBookmarks = useCallback(
154
+ (fn: (prev: Bookmark[]) => Bookmark[]) => {
155
+ setBookmarks((prev) => {
156
+ const next = fn(prev);
157
+ props.onBookmarksChange?.(next);
158
+ return next;
159
+ });
160
+ },
161
+ // eslint-disable-next-line react-hooks/exhaustive-deps
162
+ [props.onBookmarksChange]
163
+ );
164
+ const changeSettings = (patch: Partial<ReaderSettings>) => {
165
+ const next = { ...settings, ...patch };
166
+ setSettings(next);
167
+ props.onSettingsChange?.(next);
168
+ };
169
+
170
+ // ----- book page numbers (estimated from characters per page)
171
+ const onRelocate = (e: RelocateEvent) => {
172
+ const key = [settings.fontSize, settings.fontId, settings.lineHeight, settings.letterSpacing, settings.wordSpacing, settings.margins, settings.customize, settings.columns, settings.bold, settings.pageTurn === 'scroll'].join('|');
173
+ if (key !== layoutKey.current) {
174
+ layoutKey.current = key;
175
+ seen.current = {};
176
+ }
177
+ seen.current[e.spine] = { len: e.textLength, pc: e.pageCount };
178
+ let len = 0;
179
+ let pc = 0;
180
+ Object.values(seen.current).forEach((v) => {
181
+ len += v.len;
182
+ pc += v.pc;
183
+ });
184
+ const next = pc > 0 && len > 0 ? len / pc : 1200;
185
+ setCpp((old) => (Math.abs(old - next) / next > 0.01 ? next : old));
186
+ setLoc(e);
187
+ setActive(null);
188
+ props.onLocationChange?.(e);
189
+ };
190
+
191
+ const pageOf = useCallback(
192
+ (spine: number, offset: number) => {
193
+ if (!index) return spine + 1;
194
+ return Math.round(((index.cum[spine] || 0) + offset) / cpp) + 1;
195
+ },
196
+ [index, cpp]
197
+ );
198
+ const totalPages = index ? Math.max(1, Math.round(index.totalLength / cpp)) : loc ? loc.pageCount * loc.spineCount : 1;
199
+ const curPage = loc ? Math.min(totalPages, pageOf(loc.spine, loc.offset)) : 1;
200
+ const percent = Math.round((loc?.progress ?? 0) * 100);
201
+ const pagesLeft = loc ? Math.max(0, loc.pageCount - loc.page - 1) : 0;
202
+
203
+ // header / footer drawn on the page: chapter title + page number, or "N pages left" + "x of y" while the controls are shown
204
+ useEffect(() => {
205
+ if (!loc) return;
206
+ const header = chrome ? (pagesLeft === 0 ? 'Last page in chapter' : `${pagesLeft} page${pagesLeft === 1 ? '' : 's'} left in chapter`) : loc.chapter;
207
+ const footer = chrome ? `${curPage} of ${totalPages}` : `${curPage}`;
208
+ reader.current?.setLabels(header, footer);
209
+ }, [chrome, loc, curPage, totalPages, pagesLeft]);
210
+
211
+ // ----- bookmarks
212
+ const bookmarkHere = useMemo(
213
+ () => (loc ? bookmarks.filter((b) => b.spine === loc.spine && b.offset >= loc.offset && b.offset < Math.max(loc.end, loc.offset + 1)) : []),
214
+ [bookmarks, loc]
215
+ );
216
+ const toggleBookmark = () => {
217
+ if (!loc) return;
218
+ if (bookmarkHere.length) {
219
+ const ids = new Set(bookmarkHere.map((b) => b.id));
220
+ updateBookmarks((prev) => prev.filter((b) => !ids.has(b.id)));
221
+ } else {
222
+ updateBookmarks((prev) => [
223
+ ...prev,
224
+ { id: uid(), spine: loc.spine, offset: loc.offset, chapter: loc.chapter, excerpt: loc.excerpt, progress: loc.progress, createdAt: Date.now() }
225
+ ]);
226
+ }
227
+ };
228
+
229
+ // ----- highlights
230
+ const createHighlight = (sel: SelectionEvent, withNote: boolean) => {
231
+ setSelection(null);
232
+ const h: Highlight = {
233
+ id: uid(),
234
+ spine: sel.spine,
235
+ start: sel.start,
236
+ end: sel.end,
237
+ text: sel.text.replace(/\s+/g, ' ').trim(),
238
+ color: 'yellow',
239
+ chapter: loc?.chapter,
240
+ createdAt: Date.now()
241
+ };
242
+ updateHighlights((prev) => [...prev, h]);
243
+ reader.current?.clearSelection();
244
+ if (withNote) setNoteFor(h.id);
245
+ else setActive({ id: h.id, rect: sel.rect });
246
+ };
247
+ const patchHighlight = (id: string, patch: Partial<Highlight>) =>
248
+ updateHighlights((prev) => prev.map((h) => (h.id === id ? { ...h, ...patch } : h)));
249
+ const removeHighlight = (id: string) => {
250
+ updateHighlights((prev) => prev.filter((h) => h.id !== id));
251
+ setActive(null);
252
+ };
253
+ const activeHighlight = active ? highlights.find((h) => h.id === active.id) : undefined;
254
+ const noteHighlight = noteFor ? highlights.find((h) => h.id === noteFor) : undefined;
255
+
256
+ // ----- search
257
+ const runSearch = (q: string) => {
258
+ const id = ++searchId.current;
259
+ setResults([]);
260
+ if (q.trim().length < 2) {
261
+ setSearching(false);
262
+ reader.current?.cancelSearch(id);
263
+ return;
264
+ }
265
+ setSearching(true);
266
+ reader.current?.search(q, id);
267
+ };
268
+
269
+ const closeSheet = () => setSheet(null);
270
+ // remember where we were so the "back" pill can return after a jump (contents, search, bookmark ...)
271
+ const jump = (to: Parameters<EpubReaderHandle['goTo']>[0]) => {
272
+ if (loc) setReturnTo({ spine: loc.spine, offset: loc.offset, page: curPage });
273
+ setChrome(false);
274
+ reader.current?.goTo(to);
275
+ };
276
+ // two modals cannot be presented at once on iOS, so switch sheets after the first one is gone
277
+ const switchSheet = (to: SheetName) => {
278
+ setSheet(null);
279
+ setTimeout(() => setSheet(to), 380);
280
+ };
281
+ const toggleRotationLock = async () => {
282
+ if (!ScreenOrientation) {
283
+ Alert.alert('Rotation lock', 'Install expo-screen-orientation to enable the rotation lock.');
284
+ return;
285
+ }
286
+ try {
287
+ if (rotLocked) {
288
+ await ScreenOrientation.unlockAsync();
289
+ setRotLocked(false);
290
+ } else {
291
+ const o = await ScreenOrientation.getOrientationAsync();
292
+ const O = ScreenOrientation.Orientation;
293
+ const L = ScreenOrientation.OrientationLock;
294
+ await ScreenOrientation.lockAsync(o === O.LANDSCAPE_LEFT ? L.LANDSCAPE_LEFT : o === O.LANDSCAPE_RIGHT ? L.LANDSCAPE_RIGHT : L.PORTRAIT_UP);
295
+ setRotLocked(true);
296
+ }
297
+ } catch {
298
+ // not supported on this device
299
+ }
300
+ };
301
+ useEffect(
302
+ () => () => {
303
+ if (ScreenOrientation) ScreenOrientation.unlockAsync().catch(() => undefined);
304
+ },
305
+ []
306
+ );
307
+ const onLineGuideButton = () => {
308
+ setMenuOpen(false);
309
+ if (!lineGuide) {
310
+ setLineGuide(true);
311
+ setChrome(false);
312
+ } else setLineMenu(true);
313
+ };
314
+ const share = () => {
315
+ const text = [loc?.excerpt, meta?.title && `— ${meta.title}`].filter(Boolean).join(' ');
316
+ Share.share({ message: text || meta?.title || '' }).catch(() => undefined);
317
+ };
318
+
319
+ const progressFill = Math.max(0, Math.min(1, loc?.progress ?? 0));
320
+ const tx = colors.text;
321
+
322
+ return (
323
+ <View style={[styles.root, { backgroundColor: page.bg }]}>
324
+ <EpubReader
325
+ ref={reader}
326
+ source={props.source}
327
+ settings={settings}
328
+ systemDark={systemDark}
329
+ safeInsets={safe}
330
+ initialLocation={props.initialLocation}
331
+ highlights={highlights}
332
+ onReady={({ metadata, rtl: isRtl }) => {
333
+ setMeta(metadata);
334
+ setRtl(isRtl);
335
+ props.onMetadata?.(metadata);
336
+ }}
337
+ onToc={setToc}
338
+ onCover={props.onCover}
339
+ onIndexed={setIndex}
340
+ onRelocate={onRelocate}
341
+ onPageTurn={() => {
342
+ if (settings.pageSound) playPageTurn();
343
+ }}
344
+ onTap={() => {
345
+ if (active) setActive(null);
346
+ else if (menuOpen) setMenuOpen(false);
347
+ else setChrome((c) => !c);
348
+ }}
349
+ onHighlightTap={(id, rect) => setActive({ id, rect })}
350
+ onSelection={(sel) => {
351
+ setSelection(sel);
352
+ if (sel) {
353
+ setChrome(false);
354
+ setActive(null);
355
+ }
356
+ }}
357
+ onLink={(href) => Linking.openURL(href).catch(() => undefined)}
358
+ onSearchResults={(id, found, done) => {
359
+ if (id !== searchId.current) return;
360
+ setResults((prev) => prev.concat(found));
361
+ if (done) setSearching(false);
362
+ }}
363
+ onError={(m) => {
364
+ setError(m);
365
+ props.onError?.(m);
366
+ }}
367
+ />
368
+
369
+ {/* page brightness */}
370
+ <View pointerEvents="none" style={[StyleSheet.absoluteFill, { backgroundColor: '#000000', opacity: (1 - settings.brightness) * 0.75 }]} />
371
+
372
+ {/* line guide */}
373
+ {lineGuide && (
374
+ <LineGuide
375
+ bg={page.bg}
376
+ dark={dark}
377
+ dim={dimLevel}
378
+ lineHeight={settings.fontSize * (settings.customize ? settings.lineHeight : 1.5)}
379
+ minY={safe.top + 30}
380
+ maxY={screen.height - safe.bottom - 30}
381
+ />
382
+ )}
383
+
384
+ {/* bookmark ribbon while the controls are hidden */}
385
+ {bookmarkHere.length > 0 && (
386
+ <Animated.View pointerEvents="none" style={[styles.ribbon, { right: 22, opacity: fadeOut }]}>
387
+ <View style={styles.ribbonBody} />
388
+ <View style={styles.ribbonNotch} />
389
+ </Animated.View>
390
+ )}
391
+
392
+ {/* close */}
393
+ <Animated.View pointerEvents={chrome ? 'auto' : 'none'} style={[styles.closeWrap, { top: safe.top + 2, opacity: fade }]}>
394
+ <Pressable onPress={props.onClose} hitSlop={10} accessibilityRole="button" accessibilityLabel="Close book">
395
+ <Glass dark={dark} background={colors.glass} border={colors.glassBorder} style={styles.circle}>
396
+ <Icon name="close" color={tx} size={22} />
397
+ </Glass>
398
+ </Pressable>
399
+ </Animated.View>
400
+
401
+ {/* back to where we came from, like the "193" pill in Books */}
402
+ {returnTo && (
403
+ <Animated.View pointerEvents={chrome ? 'auto' : 'none'} style={[styles.returnWrap, { top: safe.top + 2, opacity: fade }]}>
404
+ <Pressable
405
+ onPress={() => {
406
+ reader.current?.goTo({ spine: returnTo.spine, offset: returnTo.offset });
407
+ setReturnTo(null);
408
+ }}
409
+ hitSlop={8}
410
+ accessibilityRole="button"
411
+ accessibilityLabel={`Return to page ${returnTo.page}`}
412
+ >
413
+ <Glass dark={dark} background={colors.glass} border={colors.glassBorder} style={styles.returnPill}>
414
+ <Icon name="undo" color={tx} size={22} />
415
+ <Text style={{ color: tx, fontSize: 20, marginStart: 8 }}>{returnTo.page}</Text>
416
+ </Glass>
417
+ </Pressable>
418
+ </Animated.View>
419
+ )}
420
+
421
+ {/* menu button (collapsed) */}
422
+ <Animated.View
423
+ pointerEvents={chrome && !menuOpen ? 'auto' : 'none'}
424
+ style={[styles.menuBtnWrap, { bottom: safe.bottom + 14, opacity: Animated.multiply(fade, Animated.subtract(1, menuFade)) }]}
425
+ >
426
+ <Pressable onPress={() => setMenuOpen(true)} accessibilityRole="button" accessibilityLabel="Reader menu">
427
+ <Glass dark={dark} background={colors.glass} border={colors.glassBorder} style={styles.menuBtn}>
428
+ <Icon name="list" color={tx} size={24} />
429
+ </Glass>
430
+ </Pressable>
431
+ </Animated.View>
432
+
433
+ {/* floating controls (expanded menu) */}
434
+ <Animated.View pointerEvents={chrome && menuOpen ? 'box-none' : 'none'} style={[styles.controls, { bottom: safe.bottom + 44, opacity: menuFade }]}>
435
+ <Pressable
436
+ onPress={() => {
437
+ setMenuOpen(false);
438
+ setSheet('contents');
439
+ }}
440
+ accessibilityRole="button"
441
+ accessibilityLabel="Contents"
442
+ style={styles.pillWrap}
443
+ >
444
+ <Glass dark={dark} background={colors.glass} border={colors.glassBorder} style={styles.pill}>
445
+ <View style={styles.pillRow} onLayout={(e) => setPillW(e.nativeEvent.layout.width)}>
446
+ <Text style={[styles.pillText, { color: tx }]}>Contents • {percent}%</Text>
447
+ <Icon name="list" color={tx} size={24} />
448
+ </View>
449
+ {/* progress fill with inverted text on top of it */}
450
+ <View
451
+ pointerEvents="none"
452
+ style={[styles.fill, { width: progressFill * pillW, backgroundColor: dark ? 'rgba(255,255,255,0.35)' : '#1c1408', [rtl ? 'right' : 'left']: 0 }]}
453
+ >
454
+ <View style={[styles.pillRow, { position: 'absolute', top: 0, width: pillW, [rtl ? 'right' : 'left']: 0 }]}>
455
+ <Text style={[styles.pillText, { color: dark ? tx : '#ffffff' }]}>Contents • {percent}%</Text>
456
+ <Icon name="list" color={dark ? tx : '#ffffff'} size={24} />
457
+ </View>
458
+ </View>
459
+ </Glass>
460
+ </Pressable>
461
+
462
+ <Pressable
463
+ onPress={() => {
464
+ setMenuOpen(false);
465
+ setSheet('search');
466
+ }}
467
+ accessibilityRole="button"
468
+ accessibilityLabel="Search book"
469
+ style={styles.pillWrap}
470
+ >
471
+ <Glass dark={dark} background={colors.glass} border={colors.glassBorder} style={styles.pill}>
472
+ <View style={styles.pillRow}>
473
+ <Text style={[styles.pillText, { color: tx }]}>Search Book</Text>
474
+ <Icon name="search" color={tx} size={24} />
475
+ </View>
476
+ </Glass>
477
+ </Pressable>
478
+
479
+ <Pressable
480
+ onPress={() => {
481
+ setMenuOpen(false);
482
+ setSheet('appearance');
483
+ }}
484
+ accessibilityRole="button"
485
+ accessibilityLabel="Themes and settings"
486
+ style={styles.pillWrap}
487
+ >
488
+ <Glass dark={dark} background={colors.glass} border={colors.glassBorder} style={styles.pill}>
489
+ <View style={styles.pillRow}>
490
+ <Text style={[styles.pillText, { color: tx }]}>Themes & Settings</Text>
491
+ <Icon name="textSize" color={tx} size={26} />
492
+ </View>
493
+ </Glass>
494
+ </Pressable>
495
+
496
+ <View style={styles.roundRow}>
497
+ <Pressable onPress={share} style={styles.roundWrap} accessibilityRole="button" accessibilityLabel="Share">
498
+ <Glass dark={dark} background={colors.glass} border={colors.glassBorder} style={styles.round}>
499
+ <Icon name="share" color={tx} size={24} />
500
+ </Glass>
501
+ </Pressable>
502
+ <Pressable onPress={toggleRotationLock} style={styles.roundWrap} accessibilityRole="button" accessibilityLabel="Rotation lock" accessibilityState={{ selected: rotLocked }}>
503
+ <Glass dark={dark} background={rotLocked ? (dark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.18)') : colors.glass} border={colors.glassBorder} style={styles.round}>
504
+ <Icon name="rotationLock" color={tx} size={26} active={rotLocked} />
505
+ </Glass>
506
+ </Pressable>
507
+ <Pressable onPress={onLineGuideButton} style={styles.roundWrap} accessibilityRole="button" accessibilityLabel="Line guide" accessibilityState={{ selected: lineGuide }}>
508
+ <Glass dark={dark} background={lineGuide ? (dark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.18)') : colors.glass} border={colors.glassBorder} style={styles.round}>
509
+ <Icon name="lineGuide" color={tx} size={26} active={lineGuide} />
510
+ </Glass>
511
+ </Pressable>
512
+ <Pressable onPress={toggleBookmark} style={styles.roundWrap} accessibilityRole="button" accessibilityLabel="Bookmark this page" accessibilityState={{ selected: bookmarkHere.length > 0 }}>
513
+ <Glass dark={dark} background={colors.glass} border={colors.glassBorder} style={styles.round}>
514
+ <Icon name="bookmark" color={tx} size={24} active={bookmarkHere.length > 0} />
515
+ </Glass>
516
+ </Pressable>
517
+ </View>
518
+ </Animated.View>
519
+
520
+ {selection && !active && !noteFor && (() => {
521
+ const w = 214;
522
+ const r = selection.rect;
523
+ const left = Math.max(8, Math.min(screen.width - w - 8, r.x + r.width / 2 - w / 2));
524
+ // the system callout (Copy / Look Up) sits above the selection, so ours goes below it
525
+ const below = r.y + r.height + 16;
526
+ const top = below + 54 > screen.height - safe.bottom ? Math.max(safe.top + 8, r.y - 120) : below;
527
+ return (
528
+ <Glass dark={dark} background={colors.sheetGlass} border={colors.glassBorder} intensity={55} style={[styles.selBar, { left, top, width: w }]}>
529
+ <Pressable
530
+ style={styles.selBtn}
531
+ onPress={() => createHighlight(selection, false)}
532
+ accessibilityRole="button"
533
+ accessibilityLabel="Highlight"
534
+ >
535
+ <Icon name="highlighter" color={tx} size={22} />
536
+ <Text style={[styles.selText, { color: tx }]}>Highlight</Text>
537
+ </Pressable>
538
+ <View style={[styles.selSep, { backgroundColor: colors.separator }]} />
539
+ <Pressable
540
+ style={styles.selBtn}
541
+ onPress={() => createHighlight(selection, true)}
542
+ accessibilityRole="button"
543
+ accessibilityLabel="Add note"
544
+ >
545
+ <Icon name="pencil" color={tx} size={22} />
546
+ <Text style={[styles.selText, { color: tx }]}>Note</Text>
547
+ </Pressable>
548
+ </Glass>
549
+ );
550
+ })()}
551
+
552
+ {activeHighlight && active && (
553
+ <HighlightMenu
554
+ highlight={activeHighlight}
555
+ rect={active.rect}
556
+ screen={screen}
557
+ colors={colors}
558
+ onColor={(c: HighlightColor) => patchHighlight(activeHighlight.id, { color: c })}
559
+ onToggleUnderline={() => patchHighlight(activeHighlight.id, { style: activeHighlight.style === 'underline' ? 'highlight' : 'underline' })}
560
+ onNote={() => {
561
+ setNoteFor(activeHighlight.id);
562
+ setActive(null);
563
+ }}
564
+ onDelete={() => removeHighlight(activeHighlight.id)}
565
+ onDismiss={() => setActive(null)}
566
+ />
567
+ )}
568
+
569
+ {error && (
570
+ <View style={[styles.error, { backgroundColor: colors.sheet }]}>
571
+ <Text style={{ color: colors.text, fontSize: 15, textAlign: 'center' }}>{error}</Text>
572
+ <Pressable onPress={() => setError(null)} style={{ marginTop: 12 }}>
573
+ <Text style={{ color: colors.accent, fontSize: 16 }}>OK</Text>
574
+ </Pressable>
575
+ </View>
576
+ )}
577
+
578
+ {lineMenu && (
579
+ <>
580
+ <Pressable style={StyleSheet.absoluteFill} onPress={() => setLineMenu(false)} accessibilityLabel="Dismiss" />
581
+ <View style={[styles.lineMenu, { bottom: safe.bottom + 20, backgroundColor: colors.dark ? '#4a4742' : '#ffffff', borderColor: colors.glassBorder }]}>
582
+ <Text style={[styles.lineMenuHead, { color: colors.subtext }]}>Background Dimming</Text>
583
+ {DIM_LABELS.map((d) => (
584
+ <Pressable
585
+ key={d.id}
586
+ style={styles.lineItem}
587
+ onPress={() => {
588
+ setDimLevel(d.id);
589
+ setLineMenu(false);
590
+ }}
591
+ >
592
+ <View style={styles.lineCheck}>{dimLevel === d.id && <Icon name="check" color={tx} size={20} />}</View>
593
+ <Text style={{ color: tx, fontSize: 19 }}>{d.label}</Text>
594
+ </Pressable>
595
+ ))}
596
+ <View style={[styles.lineSep, { backgroundColor: colors.separator }]} />
597
+ <Pressable
598
+ style={styles.lineItem}
599
+ onPress={() => {
600
+ setLineGuide(false);
601
+ setLineMenu(false);
602
+ }}
603
+ >
604
+ <View style={styles.lineCheck} />
605
+ <Text style={{ color: tx, fontSize: 19 }}>Turn Off Line Guide</Text>
606
+ </Pressable>
607
+ </View>
608
+ </>
609
+ )}
610
+
611
+ <ContentsSheet
612
+ visible={sheet === 'contents'}
613
+ colors={colors}
614
+ title={meta?.title || props.title || ''}
615
+ subtitle={`Page ${curPage} of ${totalPages}`}
616
+ toc={toc}
617
+ currentSpine={loc?.spine ?? 0}
618
+ pageOf={pageOf}
619
+ bookmarks={bookmarks}
620
+ highlights={highlights}
621
+ onClose={closeSheet}
622
+ onGoToc={(item) => {
623
+ closeSheet();
624
+ jump({ spine: item.spine, fragment: item.fragment });
625
+ }}
626
+ onGoBookmark={(b) => {
627
+ closeSheet();
628
+ jump({ spine: b.spine, offset: b.offset });
629
+ }}
630
+ onGoHighlight={(h) => {
631
+ closeSheet();
632
+ jump({ spine: h.spine, offset: h.start });
633
+ }}
634
+ onDeleteBookmark={(id) => updateBookmarks((prev) => prev.filter((b) => b.id !== id))}
635
+ onDeleteHighlight={(id) => updateHighlights((prev) => prev.filter((h) => h.id !== id))}
636
+ />
637
+ <SearchSheet
638
+ visible={sheet === 'search'}
639
+ colors={colors}
640
+ results={results}
641
+ searching={searching}
642
+ onClose={closeSheet}
643
+ onSearch={runSearch}
644
+ onPick={(r) => {
645
+ closeSheet();
646
+ if (loc) setReturnTo({ spine: loc.spine, offset: loc.offset, page: curPage });
647
+ setChrome(false);
648
+ reader.current?.showSearchResult(r);
649
+ }}
650
+ />
651
+ <AppearanceSheet
652
+ visible={sheet === 'appearance'}
653
+ colors={colors}
654
+ settings={settings}
655
+ systemDark={systemDark}
656
+ onChange={changeSettings}
657
+ onCustomize={() => switchSheet('customize')}
658
+ onClose={closeSheet}
659
+ />
660
+ <CustomizeSheet
661
+ visible={sheet === 'customize'}
662
+ colors={colors}
663
+ page={page}
664
+ settings={settings}
665
+ sample={loc?.excerpt || SAMPLE}
666
+ onChange={changeSettings}
667
+ onReset={() => changeSettings(RESET_THEME)}
668
+ onClose={() => switchSheet('appearance')}
669
+ />
670
+ <NoteEditor
671
+ visible={!!noteHighlight}
672
+ quote={noteHighlight?.text || ''}
673
+ initial={noteHighlight?.note || ''}
674
+ colors={colors}
675
+ onCancel={() => setNoteFor(null)}
676
+ onSave={(text) => {
677
+ if (noteFor) patchHighlight(noteFor, { note: text || undefined });
678
+ setNoteFor(null);
679
+ }}
680
+ />
681
+ </View>
682
+ );
683
+ }
684
+
685
+ const styles = StyleSheet.create({
686
+ root: { flex: 1 },
687
+ closeWrap: { position: 'absolute', right: 16 },
688
+ circle: { width: 42, height: 42, borderRadius: 21, alignItems: 'center', justifyContent: 'center' },
689
+ controls: { position: 'absolute', right: 16, width: '68%' },
690
+ selBar: { position: 'absolute', height: 48, borderRadius: 24, flexDirection: 'row', alignItems: 'center' },
691
+ selBtn: { flex: 1, height: '100%', flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6 },
692
+ selText: { fontSize: 15, fontWeight: '600' },
693
+ selSep: { width: StyleSheet.hairlineWidth, height: 24 },
694
+ lineMenu: {
695
+ position: 'absolute', left: 16, width: 290, borderRadius: 28, paddingVertical: 10, borderWidth: StyleSheet.hairlineWidth,
696
+ shadowColor: '#000', shadowOpacity: 0.3, shadowRadius: 16, shadowOffset: { width: 0, height: 6 }, elevation: 12
697
+ },
698
+ lineMenuHead: { fontSize: 15, fontWeight: '600', marginStart: 52, marginTop: 8, marginBottom: 4 },
699
+ lineItem: { flexDirection: 'row', alignItems: 'center', paddingVertical: 12, paddingHorizontal: 18 },
700
+ lineCheck: { width: 34 },
701
+ lineSep: { height: StyleSheet.hairlineWidth, marginVertical: 6, marginHorizontal: 18 },
702
+ menuBtnWrap: { position: 'absolute', right: 16 },
703
+ menuBtn: { width: 54, height: 54, borderRadius: 27, alignItems: 'center', justifyContent: 'center' },
704
+ returnWrap: { position: 'absolute', left: 16 },
705
+ returnPill: { height: 46, borderRadius: 23, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16 },
706
+ pillRow: { height: 50, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 22 },
707
+ pillWrap: { marginBottom: 8 },
708
+ pill: { height: 50, borderRadius: 25 },
709
+ pillText: { fontSize: 17, fontWeight: '500' },
710
+ fill: { position: 'absolute', top: 0, bottom: 0, overflow: 'hidden' },
711
+ roundRow: { flexDirection: 'row', gap: 8 },
712
+ roundWrap: { flex: 1 },
713
+ round: { height: 50, borderRadius: 25, alignItems: 'center', justifyContent: 'center' },
714
+ ribbon: { position: 'absolute', top: 0, width: 16 },
715
+ ribbonBody: { height: 26, backgroundColor: '#ff3b30' },
716
+ ribbonNotch: {
717
+ width: 0, height: 0, borderLeftWidth: 8, borderRightWidth: 8, borderBottomWidth: 7,
718
+ borderLeftColor: '#ff3b30', borderRightColor: '#ff3b30', borderBottomColor: 'transparent'
719
+ },
720
+ error: { position: 'absolute', left: 24, right: 24, top: '40%', padding: 20, borderRadius: 14, alignItems: 'center', elevation: 10 }
721
+ });