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,87 @@
1
+ import React, { useRef, useState } from 'react';
2
+ import { PanResponder, StyleSheet, View } from 'react-native';
3
+
4
+ export type DimLevel = 'high' | 'medium' | 'low' | 'none';
5
+
6
+ /** Opacity of the page-coloured veil over everything except the guide line. */
7
+ export const DIM_ALPHA: Record<DimLevel, number> = { high: 0.86, medium: 0.62, low: 0.36, none: 0 };
8
+
9
+ function rgba(hex: string, a: number): string {
10
+ const v = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
11
+ return `rgba(${v[0]},${v[1]},${v[2]},${a})`;
12
+ }
13
+
14
+ interface Props {
15
+ /** page background colour (#rrggbb) */
16
+ bg: string;
17
+ dark: boolean;
18
+ dim: DimLevel;
19
+ /** height of one line of text */
20
+ lineHeight: number;
21
+ /** vertical range the guide may move in */
22
+ minY: number;
23
+ maxY: number;
24
+ }
25
+
26
+ /**
27
+ * Line Guide: dims the page except for one line that can be dragged up and down.
28
+ * The veil ignores touches, so page turns and taps still reach the book.
29
+ */
30
+ export function LineGuide({ bg, dark, dim, lineHeight, minY, maxY }: Props) {
31
+ const h = Math.round(lineHeight) + 14;
32
+ const [y, setY] = useState(minY + 40);
33
+ const state = useRef({ start: 0, minY, maxY, h });
34
+ state.current.minY = minY;
35
+ state.current.maxY = maxY;
36
+ state.current.h = h;
37
+ const yRef = useRef(y);
38
+ yRef.current = y;
39
+
40
+ const pan = useRef(
41
+ PanResponder.create({
42
+ onStartShouldSetPanResponder: () => true,
43
+ onMoveShouldSetPanResponder: () => true,
44
+ onPanResponderGrant: () => {
45
+ state.current.start = yRef.current;
46
+ },
47
+ onPanResponderMove: (_e, g) => {
48
+ const s = state.current;
49
+ setY(Math.max(s.minY, Math.min(s.maxY - s.h, s.start + g.dy)));
50
+ }
51
+ })
52
+ ).current;
53
+
54
+ const veil = rgba(bg, DIM_ALPHA[dim]);
55
+ return (
56
+ <View style={StyleSheet.absoluteFill} pointerEvents="box-none">
57
+ {dim !== 'none' && (
58
+ <>
59
+ <View pointerEvents="none" style={[styles.veil, { top: 0, height: y, backgroundColor: veil }]} />
60
+ <View pointerEvents="none" style={[styles.veil, { top: y + h, bottom: 0, backgroundColor: veil }]} />
61
+ </>
62
+ )}
63
+ <View
64
+ {...pan.panHandlers}
65
+ style={[
66
+ styles.band,
67
+ {
68
+ top: y,
69
+ height: h,
70
+ borderRadius: h / 2,
71
+ borderColor: dark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.22)',
72
+ backgroundColor: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)'
73
+ }
74
+ ]}
75
+ accessibilityLabel="Line guide"
76
+ />
77
+ </View>
78
+ );
79
+ }
80
+
81
+ const styles = StyleSheet.create({
82
+ veil: { position: 'absolute', left: 0, right: 0 },
83
+ band: {
84
+ position: 'absolute', left: 10, right: 10, borderWidth: StyleSheet.hairlineWidth * 2,
85
+ shadowColor: '#000', shadowOpacity: 0.35, shadowRadius: 8, shadowOffset: { width: 0, height: 3 }, elevation: 6
86
+ }
87
+ });
@@ -0,0 +1,55 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { KeyboardAvoidingView, Modal, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
3
+ import type { ChromeColors } from '../themes';
4
+
5
+ interface Props {
6
+ visible: boolean;
7
+ quote: string;
8
+ initial: string;
9
+ colors: ChromeColors;
10
+ onSave: (note: string) => void;
11
+ onCancel: () => void;
12
+ }
13
+
14
+ export function NoteEditor({ visible, quote, initial, colors, onSave, onCancel }: Props) {
15
+ const [text, setText] = useState(initial);
16
+ useEffect(() => setText(initial), [initial, visible]);
17
+ return (
18
+ <Modal visible={visible} animationType="slide" transparent onRequestClose={onCancel}>
19
+ <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.wrap}>
20
+ <Pressable style={StyleSheet.absoluteFill} onPress={onCancel} />
21
+ <View style={[styles.card, { backgroundColor: colors.sheet }]}>
22
+ <View style={styles.head}>
23
+ <Pressable onPress={onCancel} hitSlop={10}>
24
+ <Text style={{ color: colors.accent, fontSize: 17 }}>Cancel</Text>
25
+ </Pressable>
26
+ <Text style={{ color: colors.text, fontSize: 17, fontWeight: '600' }}>Note</Text>
27
+ <Pressable onPress={() => onSave(text.trim())} hitSlop={10}>
28
+ <Text style={{ color: colors.accent, fontSize: 17, fontWeight: '600' }}>Save</Text>
29
+ </Pressable>
30
+ </View>
31
+ <Text numberOfLines={3} style={[styles.quote, { color: colors.subtext, borderColor: colors.accent }]}>
32
+ {quote}
33
+ </Text>
34
+ <TextInput
35
+ autoFocus
36
+ multiline
37
+ value={text}
38
+ onChangeText={setText}
39
+ placeholder="Add a note"
40
+ placeholderTextColor={colors.subtext}
41
+ style={[styles.input, { color: colors.text, backgroundColor: colors.card }]}
42
+ />
43
+ </View>
44
+ </KeyboardAvoidingView>
45
+ </Modal>
46
+ );
47
+ }
48
+
49
+ const styles = StyleSheet.create({
50
+ wrap: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.35)' },
51
+ card: { borderTopLeftRadius: 14, borderTopRightRadius: 14, padding: 16 },
52
+ head: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 12 },
53
+ quote: { fontSize: 14, borderStartWidth: 3, paddingStart: 10, marginBottom: 12, fontStyle: 'italic' },
54
+ input: { minHeight: 110, maxHeight: 220, borderRadius: 10, padding: 12, fontSize: 16, textAlignVertical: 'top' }
55
+ });
@@ -0,0 +1,78 @@
1
+ import React, { useRef, useState } from 'react';
2
+ import { PanResponder, StyleSheet, View } from 'react-native';
3
+
4
+ interface Props {
5
+ value: number; // 0..1
6
+ onScrub?: (v: number) => void;
7
+ onComplete: (v: number) => void;
8
+ color: string;
9
+ track: string;
10
+ thumbColor?: string;
11
+ /** hide the thumb for a flat progress bar */
12
+ noThumb?: boolean;
13
+ rtl?: boolean;
14
+ }
15
+
16
+ /** Minimal horizontal slider (no native dependency). */
17
+ export function Scrubber({ value, onScrub, onComplete, color, track, thumbColor, noThumb, rtl }: Props) {
18
+ const [width, setWidth] = useState(1);
19
+ const [drag, setDrag] = useState<number | null>(null);
20
+ const latest = useRef({ width: 1, onScrub, onComplete, rtl });
21
+ latest.current = { width, onScrub, onComplete, rtl };
22
+ const startX = useRef(0);
23
+
24
+ const toValue = (x: number) => {
25
+ const { width: w, rtl: r } = latest.current;
26
+ const v = Math.max(0, Math.min(1, x / w));
27
+ return r ? 1 - v : v;
28
+ };
29
+
30
+ const pan = useRef(
31
+ PanResponder.create({
32
+ onStartShouldSetPanResponder: () => true,
33
+ onMoveShouldSetPanResponder: () => true,
34
+ onPanResponderTerminationRequest: () => false,
35
+ onPanResponderGrant: (e) => {
36
+ startX.current = e.nativeEvent.locationX;
37
+ const v = toValue(startX.current);
38
+ setDrag(v);
39
+ latest.current.onScrub?.(v);
40
+ },
41
+ onPanResponderMove: (_e, g) => {
42
+ const v = toValue(startX.current + g.dx);
43
+ setDrag(v);
44
+ latest.current.onScrub?.(v);
45
+ },
46
+ onPanResponderRelease: (_e, g) => {
47
+ const v = toValue(startX.current + g.dx);
48
+ setDrag(null);
49
+ latest.current.onComplete(v);
50
+ }
51
+ })
52
+ ).current;
53
+
54
+ const shown = drag ?? value;
55
+ const frac = rtl ? 1 - shown : shown;
56
+ return (
57
+ <View style={styles.hit} onLayout={(e) => setWidth(e.nativeEvent.layout.width)} {...pan.panHandlers}>
58
+ <View style={[styles.track, { backgroundColor: track }]} pointerEvents="none">
59
+ <View style={{ height: 4, backgroundColor: color, width: `${frac * 100}%` }} />
60
+ </View>
61
+ {!noThumb && (
62
+ <View
63
+ pointerEvents="none"
64
+ style={[styles.thumb, { backgroundColor: thumbColor || color, left: Math.max(0, Math.min(width - 28, frac * (width - 28))) }]}
65
+ />
66
+ )}
67
+ </View>
68
+ );
69
+ }
70
+
71
+ const styles = StyleSheet.create({
72
+ hit: { height: 40, justifyContent: 'center' },
73
+ track: { height: 4, borderRadius: 2, overflow: 'hidden', flexDirection: 'row' },
74
+ thumb: {
75
+ position: 'absolute', width: 28, height: 28, borderRadius: 14, top: 6,
76
+ shadowColor: '#000', shadowOpacity: 0.3, shadowRadius: 3, shadowOffset: { width: 0, height: 1 }, elevation: 3
77
+ }
78
+ });
@@ -0,0 +1,79 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
3
+ import type { ChromeColors } from '../themes';
4
+ import type { SearchResult } from '../types';
5
+ import { Icon } from './Icons';
6
+ import { Sheet } from './Sheet';
7
+
8
+ interface Props {
9
+ visible: boolean;
10
+ colors: ChromeColors;
11
+ results: SearchResult[];
12
+ searching: boolean;
13
+ onClose: () => void;
14
+ onSearch: (query: string) => void;
15
+ onPick: (r: SearchResult) => void;
16
+ }
17
+
18
+ export function SearchSheet({ visible, colors, results, searching, onClose, onSearch, onPick }: Props) {
19
+ const [q, setQ] = useState('');
20
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
21
+
22
+ useEffect(() => {
23
+ clearTimeout(timer.current);
24
+ timer.current = setTimeout(() => onSearch(q), 350);
25
+ return () => clearTimeout(timer.current);
26
+ // eslint-disable-next-line react-hooks/exhaustive-deps
27
+ }, [q]);
28
+
29
+ return (
30
+ <Sheet visible={visible} colors={colors} onClose={onClose} height="85%" title="Search">
31
+ <View style={[styles.field, { backgroundColor: colors.card }]}>
32
+ <Icon name="search" color={colors.subtext} size={20} />
33
+ <TextInput
34
+ value={q}
35
+ onChangeText={setQ}
36
+ placeholder="Search in book"
37
+ placeholderTextColor={colors.subtext}
38
+ style={[styles.input, { color: colors.text }]}
39
+ autoCorrect={false}
40
+ returnKeyType="search"
41
+ onSubmitEditing={() => onSearch(q)}
42
+ clearButtonMode="while-editing"
43
+ />
44
+ </View>
45
+ <FlatList
46
+ data={results}
47
+ keyboardShouldPersistTaps="handled"
48
+ keyExtractor={(_, i) => String(i)}
49
+ ListHeaderComponent={
50
+ q.trim().length >= 2 ? (
51
+ <Text style={[styles.count, { color: colors.subtext }]}>
52
+ {searching ? 'Searching...' : `${results.length}${results.length >= 500 ? '+' : ''} results`}
53
+ </Text>
54
+ ) : null
55
+ }
56
+ renderItem={({ item }) => (
57
+ <Pressable onPress={() => onPick(item)} style={[styles.row, { borderColor: colors.separator }]}>
58
+ <Text style={[styles.chapter, { color: colors.accent }]} numberOfLines={1}>
59
+ {item.chapter}
60
+ </Text>
61
+ <Text style={{ color: colors.text, fontSize: 15, lineHeight: 21 }} numberOfLines={3}>
62
+ {item.before}
63
+ <Text style={{ fontWeight: '700', backgroundColor: 'rgba(255,214,10,0.45)' }}>{item.match}</Text>
64
+ {item.after}
65
+ </Text>
66
+ </Pressable>
67
+ )}
68
+ />
69
+ </Sheet>
70
+ );
71
+ }
72
+
73
+ const styles = StyleSheet.create({
74
+ field: { flexDirection: 'row', alignItems: 'center', marginHorizontal: 16, marginBottom: 8, paddingHorizontal: 10, borderRadius: 10, height: 38 },
75
+ input: { flex: 1, marginStart: 6, fontSize: 16, paddingVertical: 0 },
76
+ count: { paddingHorizontal: 20, paddingVertical: 8, fontSize: 13 },
77
+ row: { paddingVertical: 12, paddingHorizontal: 20, borderBottomWidth: StyleSheet.hairlineWidth },
78
+ chapter: { fontSize: 12, fontWeight: '600', marginBottom: 3 }
79
+ });
@@ -0,0 +1,69 @@
1
+ import React from 'react';
2
+ import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
3
+ import type { ViewStyle } from 'react-native';
4
+ import type { ChromeColors } from '../themes';
5
+ import { Glass } from './Glass';
6
+ import { Icon } from './Icons';
7
+
8
+ interface Props {
9
+ visible: boolean;
10
+ title?: string;
11
+ subtitle?: string;
12
+ colors: ChromeColors;
13
+ height?: ViewStyle['height'];
14
+ /** 'x' puts a glass close button on the left (Themes, Search); 'check' puts a solid check on the right (Contents, Customize) */
15
+ closeIcon?: 'x' | 'check';
16
+ onClose: () => void;
17
+ children: React.ReactNode;
18
+ /** rendered above the content, e.g. popup menus */
19
+ overlay?: React.ReactNode;
20
+ }
21
+
22
+ /** Rounded frosted-glass sheet: the book stays faintly visible behind it. */
23
+ export function Sheet({ visible, title, subtitle, colors, height = '70%', closeIcon = 'x', onClose, children, overlay }: Props) {
24
+ const button =
25
+ closeIcon === 'check' ? (
26
+ <Pressable onPress={onClose} hitSlop={10} style={[styles.circle, { backgroundColor: colors.dark ? '#ffffff' : '#000000' }]} accessibilityRole="button" accessibilityLabel="Done">
27
+ <Icon name="check" color={colors.dark ? '#000000' : '#ffffff'} size={24} strokeWidth={2.2} />
28
+ </Pressable>
29
+ ) : (
30
+ <Pressable onPress={onClose} hitSlop={10} accessibilityRole="button" accessibilityLabel="Close">
31
+ <Glass dark={colors.dark} background={colors.glass} border={colors.glassBorder} style={styles.circle}>
32
+ <Icon name="close" color={colors.text} size={22} />
33
+ </Glass>
34
+ </Pressable>
35
+ );
36
+
37
+ return (
38
+ <Modal visible={visible} transparent animationType="slide" onRequestClose={onClose} statusBarTranslucent>
39
+ <Pressable style={styles.backdrop} onPress={onClose} />
40
+ <Glass dark={colors.dark} background={colors.sheetGlass} border={colors.glassBorder} intensity={70} style={[styles.sheet, { height }]}>
41
+ <View style={styles.header}>
42
+ {closeIcon === 'x' && button}
43
+ <View style={styles.titleBox} pointerEvents="none">
44
+ {!!title && (
45
+ <Text style={[styles.title, { color: colors.text }]} numberOfLines={1}>
46
+ {title}
47
+ </Text>
48
+ )}
49
+ {!!subtitle && <Text style={[styles.subtitle, { color: colors.text }]}>{subtitle}</Text>}
50
+ </View>
51
+ <View style={{ flex: 1 }} />
52
+ {closeIcon === 'check' && button}
53
+ </View>
54
+ {children}
55
+ {overlay}
56
+ </Glass>
57
+ </Modal>
58
+ );
59
+ }
60
+
61
+ const styles = StyleSheet.create({
62
+ backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.18)' },
63
+ sheet: { borderTopLeftRadius: 34, borderTopRightRadius: 34, paddingTop: 12 },
64
+ header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, minHeight: 52, marginBottom: 6 },
65
+ circle: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center' },
66
+ titleBox: { position: 'absolute', left: 70, right: 70, alignItems: 'center' },
67
+ title: { fontSize: 17, fontWeight: '600' },
68
+ subtitle: { fontSize: 17, marginTop: 14 }
69
+ });
@@ -0,0 +1,30 @@
1
+ import React, { useRef } from 'react';
2
+ import { Scrubber } from './Scrubber';
3
+
4
+ interface Props {
5
+ value: number;
6
+ min: number;
7
+ max: number;
8
+ step: number;
9
+ onChange: (v: number) => void;
10
+ color: string;
11
+ track: string;
12
+ thumbColor?: string;
13
+ }
14
+
15
+ const round = (v: number, step: number) => Math.round(v / step) * step;
16
+
17
+ /** Value slider on top of Scrubber; emits only when the stepped value changes. */
18
+ export function Slider({ value, min, max, step, onChange, color, track, thumbColor }: Props) {
19
+ const last = useRef(value);
20
+ const toValue = (f: number) => +(round(min + f * (max - min), step)).toFixed(4);
21
+ const emit = (f: number) => {
22
+ const v = toValue(f);
23
+ if (v !== last.current) {
24
+ last.current = v;
25
+ onChange(v);
26
+ }
27
+ };
28
+ last.current = value;
29
+ return <Scrubber value={(value - min) / (max - min)} color={color} track={track} thumbColor={thumbColor} onScrub={emit} onComplete={emit} />;
30
+ }