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,256 @@
1
+ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
+ import { WebView } from 'react-native-webview';
4
+ import type { WebViewMessageEvent } from 'react-native-webview';
5
+ import { READER_HTML } from './engine/html';
6
+ import { themeColors, toEngineSettings } from './themes';
7
+ import type {
8
+ BookLocation,
9
+ BookMetadata,
10
+ EpubSource,
11
+ Highlight,
12
+ ReaderSettings,
13
+ RelocateEvent,
14
+ SearchResult,
15
+ SelectionEvent,
16
+ TocItem
17
+ } from './types';
18
+
19
+ export interface EpubReaderProps {
20
+ source: EpubSource;
21
+ settings: ReaderSettings;
22
+ /** system colour scheme, used when settings.appearance is 'device' */
23
+ systemDark?: boolean;
24
+ /** Space reserved for status bar / home indicator. The engine adds room for the running header and footer. */
25
+ safeInsets?: { top: number; bottom: number };
26
+ initialLocation?: BookLocation | { progress: number };
27
+ highlights?: Highlight[];
28
+ /** Items added to the native text-selection menu. `onMenuAction` fires with the item key. */
29
+ menuItems?: { label: string; key: string }[];
30
+ onMenuAction?: (key: string, selection: SelectionEvent | null) => void;
31
+ onReady?: (info: { metadata: BookMetadata; rtl: boolean }) => void;
32
+ onToc?: (items: TocItem[]) => void;
33
+ onCover?: (dataUri: string) => void;
34
+ onRelocate?: (e: RelocateEvent) => void;
35
+ /** fired once the whole book has been indexed: cumulative character offset of every spine item */
36
+ onIndexed?: (info: { totalLength: number; cum: number[] }) => void;
37
+ onSelection?: (e: SelectionEvent | null) => void;
38
+ onHighlightTap?: (id: string, rect: SelectionEvent['rect']) => void;
39
+ onTap?: () => void;
40
+ /** a page turn animation is starting (use it to play a sound) */
41
+ onPageTurn?: () => void;
42
+ onLink?: (href: string) => void;
43
+ onSearchResults?: (searchId: number, results: SearchResult[], done: boolean) => void;
44
+ onEndReached?: (edge: 'start' | 'end') => void;
45
+ onError?: (message: string) => void;
46
+ }
47
+
48
+ export interface EpubReaderHandle {
49
+ next(): void;
50
+ prev(): void;
51
+ goTo(location: { spine: number; offset?: number; fragment?: string } | { progress: number }): void;
52
+ search(query: string, searchId: number): void;
53
+ cancelSearch(searchId?: number): void;
54
+ /** Jump to a search result and flash the match. */
55
+ showSearchResult(r: SearchResult): void;
56
+ clearSelection(): void;
57
+ /** Replace the running header / footer text drawn on the page. */
58
+ setLabels(header: string, footer: string): void;
59
+ }
60
+
61
+ async function uriToBase64(uri: string): Promise<string> {
62
+ const res = await fetch(uri);
63
+ const blob = await res.blob();
64
+ return new Promise<string>((resolve, reject) => {
65
+ const fr = new FileReader();
66
+ fr.onerror = () => reject(fr.error);
67
+ fr.onloadend = () => {
68
+ const s = String(fr.result);
69
+ resolve(s.slice(s.indexOf(',') + 1));
70
+ };
71
+ fr.readAsDataURL(blob);
72
+ });
73
+ }
74
+
75
+ const LS = String.fromCharCode(0x2028);
76
+ const PS = String.fromCharCode(0x2029);
77
+
78
+ // U+2028/2029 are legal in JSON but terminate a JS string literal, so escape them.
79
+ function toScript(cmd: object): string {
80
+ const json = JSON.stringify(JSON.stringify(cmd)).split(LS).join('\\u2028').split(PS).join('\\u2029');
81
+ return `window.__cmd(${json});true;`;
82
+ }
83
+
84
+ export const EpubReader = forwardRef<EpubReaderHandle, EpubReaderProps>(function EpubReader(props, ref) {
85
+ const { source, settings, systemDark = false, safeInsets = { top: 0, bottom: 0 }, highlights, menuItems } = props;
86
+ const web = useRef<WebView>(null);
87
+ const cb = useRef(props);
88
+ cb.current = props;
89
+ const [engineReady, setEngineReady] = useState(false);
90
+ const opened = useRef(false);
91
+ const selection = useRef<SelectionEvent | null>(null);
92
+ const metaRef = useRef<BookMetadata>({ title: '', author: '', language: '', publisher: '' });
93
+
94
+ const engineSettings = useMemo(
95
+ () => toEngineSettings(settings, systemDark, safeInsets),
96
+ // eslint-disable-next-line react-hooks/exhaustive-deps
97
+ [settings, systemDark, safeInsets.top, safeInsets.bottom]
98
+ );
99
+
100
+ const send = useCallback((cmd: object) => {
101
+ web.current?.injectJavaScript(toScript(cmd));
102
+ }, []);
103
+
104
+ // open (or re-open) the book once the engine is up
105
+ const sourceKey = 'uri' in source ? source.uri : source.base64.length + ':' + source.base64.slice(0, 32);
106
+ useEffect(() => {
107
+ if (!engineReady) return;
108
+ let cancelled = false;
109
+ opened.current = false;
110
+ (async () => {
111
+ try {
112
+ const data = 'uri' in source ? await uriToBase64(source.uri) : source.base64;
113
+ if (cancelled) return;
114
+ send({
115
+ cmd: 'open',
116
+ data,
117
+ settings: engineSettings,
118
+ highlights: highlights || [],
119
+ location: cb.current.initialLocation
120
+ });
121
+ opened.current = true;
122
+ } catch (e) {
123
+ cb.current.onError?.(String((e as Error)?.message || e));
124
+ }
125
+ })();
126
+ return () => {
127
+ cancelled = true;
128
+ };
129
+ // eslint-disable-next-line react-hooks/exhaustive-deps
130
+ }, [engineReady, sourceKey]);
131
+
132
+ useEffect(() => {
133
+ if (opened.current) send({ cmd: 'settings', settings: engineSettings });
134
+ }, [engineSettings, send]);
135
+
136
+ useEffect(() => {
137
+ if (opened.current) send({ cmd: 'highlights', list: highlights || [] });
138
+ }, [highlights, send]);
139
+
140
+ useImperativeHandle(
141
+ ref,
142
+ () => ({
143
+ next: () => send({ cmd: 'next' }),
144
+ prev: () => send({ cmd: 'prev' }),
145
+ goTo: (location) => send({ cmd: 'goto', location }),
146
+ search: (query, searchId) => send({ cmd: 'search', query, id: searchId }),
147
+ cancelSearch: (searchId) => send({ cmd: 'cancelSearch', id: searchId }),
148
+ showSearchResult: (r) => {
149
+ send({ cmd: 'goto', location: { spine: r.spine, offset: r.offset } });
150
+ send({ cmd: 'showHit', offset: r.offset, length: r.length });
151
+ },
152
+ clearSelection: () => {
153
+ selection.current = null;
154
+ send({ cmd: 'clearSelection' });
155
+ },
156
+ setLabels: (header, footer) => send({ cmd: 'labels', header, footer })
157
+ }),
158
+ [send]
159
+ );
160
+
161
+ const onMessage = useCallback((e: WebViewMessageEvent) => {
162
+ let msg: { type: string; data: any };
163
+ try {
164
+ msg = JSON.parse(e.nativeEvent.data);
165
+ } catch {
166
+ return;
167
+ }
168
+ const p = cb.current;
169
+ const d = msg.data;
170
+ switch (msg.type) {
171
+ case 'ready':
172
+ setEngineReady(true);
173
+ break;
174
+ case 'metadata':
175
+ metaRef.current = d;
176
+ break;
177
+ case 'toc':
178
+ p.onReady?.({ metadata: metaRef.current, rtl: !!d.rtl });
179
+ p.onToc?.(d.items);
180
+ break;
181
+ case 'cover':
182
+ p.onCover?.(d.uri);
183
+ break;
184
+ case 'relocate':
185
+ p.onRelocate?.(d);
186
+ break;
187
+ case 'indexed':
188
+ p.onIndexed?.(d);
189
+ break;
190
+ case 'selection':
191
+ selection.current = d;
192
+ p.onSelection?.(d);
193
+ break;
194
+ case 'selectionClear':
195
+ selection.current = null;
196
+ p.onSelection?.(null);
197
+ break;
198
+ case 'highlightTap':
199
+ p.onHighlightTap?.(d.id, d.rect);
200
+ break;
201
+ case 'tap':
202
+ p.onTap?.();
203
+ break;
204
+ case 'pageTurn':
205
+ p.onPageTurn?.();
206
+ break;
207
+ case 'link':
208
+ p.onLink?.(d.href);
209
+ break;
210
+ case 'searchResults':
211
+ p.onSearchResults?.(d.id, d.results, d.done);
212
+ break;
213
+ case 'end':
214
+ p.onEndReached?.(d.edge);
215
+ break;
216
+ case 'error':
217
+ p.onError?.(d.message);
218
+ break;
219
+ }
220
+ }, []);
221
+
222
+ const bg = themeColors(settings, systemDark).bg;
223
+
224
+ return (
225
+ <View style={[styles.fill, { backgroundColor: bg }]}>
226
+ <WebView
227
+ ref={web}
228
+ style={[styles.fill, { backgroundColor: bg }]}
229
+ originWhitelist={['*']}
230
+ source={{ html: READER_HTML }}
231
+ onMessage={onMessage}
232
+ javaScriptEnabled
233
+ domStorageEnabled={false}
234
+ scrollEnabled={false}
235
+ bounces={false}
236
+ overScrollMode="never"
237
+ showsHorizontalScrollIndicator={false}
238
+ showsVerticalScrollIndicator={false}
239
+ automaticallyAdjustContentInsets={false}
240
+ contentInsetAdjustmentBehavior="never"
241
+ allowsInlineMediaPlayback
242
+ hideKeyboardAccessoryView
243
+ setSupportMultipleWindows={false}
244
+ menuItems={menuItems}
245
+ onCustomMenuSelection={(e) => {
246
+ const key = (e.nativeEvent as { key: string }).key;
247
+ cb.current.onMenuAction?.(key, selection.current);
248
+ }}
249
+ onShouldStartLoadWithRequest={(req) => req.url === 'about:blank' || req.url.startsWith('data:') || req.url.startsWith('about:')}
250
+ onError={(e) => cb.current.onError?.(e.nativeEvent.description)}
251
+ />
252
+ </View>
253
+ );
254
+ });
255
+
256
+ const styles = StyleSheet.create({ fill: { flex: 1 } });