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,144 @@
1
+ import React, { useState } from 'react';
2
+ import { Alert, FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
3
+ import { HIGHLIGHT_COLORS } from '../themes';
4
+ import type { ChromeColors } from '../themes';
5
+ import type { Bookmark, Highlight, TocItem } from '../types';
6
+ import { Sheet } from './Sheet';
7
+
8
+ export type ContentsTab = 'contents' | 'bookmarks' | 'notes';
9
+
10
+ interface Props {
11
+ visible: boolean;
12
+ colors: ChromeColors;
13
+ title: string;
14
+ /** e.g. "Page 100 of 219" */
15
+ subtitle: string;
16
+ toc: TocItem[];
17
+ currentSpine: number;
18
+ /** estimated page number in the whole book for a spine item + character offset */
19
+ pageOf: (spine: number, offset: number) => number;
20
+ bookmarks: Bookmark[];
21
+ highlights: Highlight[];
22
+ initialTab?: ContentsTab;
23
+ onClose: () => void;
24
+ onGoToc: (item: TocItem) => void;
25
+ onGoBookmark: (b: Bookmark) => void;
26
+ onGoHighlight: (h: Highlight) => void;
27
+ onDeleteBookmark: (id: string) => void;
28
+ onDeleteHighlight: (id: string) => void;
29
+ }
30
+
31
+ const TABS: { id: ContentsTab; label: string }[] = [
32
+ { id: 'contents', label: 'Chapters' },
33
+ { id: 'bookmarks', label: 'Bookmarks' },
34
+ { id: 'notes', label: 'Highlights' }
35
+ ];
36
+
37
+ /** Contents sheet: Chapters | Bookmarks | Highlights, each row with its page number. */
38
+ export function ContentsSheet(p: Props) {
39
+ const { colors } = p;
40
+ const [tab, setTab] = useState<ContentsTab>(p.initialTab || 'contents');
41
+ const confirmDelete = (what: string, fn: () => void) =>
42
+ Alert.alert(`Delete ${what}?`, undefined, [
43
+ { text: 'Cancel', style: 'cancel' },
44
+ { text: 'Delete', style: 'destructive', onPress: fn }
45
+ ]);
46
+ const empty = (text: string) => <Text style={[styles.empty, { color: colors.subtext }]}>{text}</Text>;
47
+ const rowBorder = { borderColor: colors.separator };
48
+
49
+ return (
50
+ <Sheet visible={p.visible} colors={colors} onClose={p.onClose} closeIcon="check" title={p.title} subtitle={p.subtitle} height="90%">
51
+ <View style={[styles.segment, { backgroundColor: colors.card }]}>
52
+ {TABS.map((t) => (
53
+ <Pressable
54
+ key={t.id}
55
+ onPress={() => setTab(t.id)}
56
+ style={[styles.segItem, tab === t.id && { backgroundColor: colors.dark ? 'rgba(255,255,255,0.22)' : '#ffffff' }]}
57
+ accessibilityRole="tab"
58
+ accessibilityState={{ selected: tab === t.id }}
59
+ >
60
+ <Text style={{ color: colors.text, fontWeight: tab === t.id ? '600' : '400', fontSize: 16 }}>{t.label}</Text>
61
+ </Pressable>
62
+ ))}
63
+ </View>
64
+
65
+ {tab === 'contents' && (
66
+ <FlatList
67
+ data={p.toc}
68
+ keyExtractor={(_, i) => String(i)}
69
+ ListEmptyComponent={empty('No table of contents')}
70
+ renderItem={({ item }) => {
71
+ const active = item.spine === p.currentSpine && !item.fragment;
72
+ return (
73
+ <Pressable onPress={() => p.onGoToc(item)} style={[styles.row, rowBorder, { paddingStart: 20 + item.level * 18 }]}>
74
+ <Text style={[styles.rowText, { color: active ? colors.accent : colors.text, flex: 1 }]} numberOfLines={2}>
75
+ {item.label}
76
+ </Text>
77
+ <Text style={[styles.page, { color: colors.subtext }]}>{p.pageOf(item.spine, 0)}</Text>
78
+ </Pressable>
79
+ );
80
+ }}
81
+ />
82
+ )}
83
+
84
+ {tab === 'bookmarks' && (
85
+ <FlatList
86
+ data={p.bookmarks.slice().sort((a, b) => a.progress - b.progress)}
87
+ keyExtractor={(b) => b.id}
88
+ ListEmptyComponent={empty('No bookmarks yet. Tap the bookmark button while reading.')}
89
+ renderItem={({ item }) => (
90
+ <Pressable
91
+ onPress={() => p.onGoBookmark(item)}
92
+ onLongPress={() => confirmDelete('bookmark', () => p.onDeleteBookmark(item.id))}
93
+ style={[styles.row, rowBorder]}
94
+ >
95
+ <View style={{ flex: 1 }}>
96
+ <Text style={[styles.meta, { color: colors.accent }]}>{item.chapter}</Text>
97
+ <Text style={[styles.rowText, { color: colors.text }]} numberOfLines={2}>
98
+ {item.excerpt}
99
+ </Text>
100
+ </View>
101
+ <Text style={[styles.page, { color: colors.subtext }]}>{p.pageOf(item.spine, item.offset)}</Text>
102
+ </Pressable>
103
+ )}
104
+ />
105
+ )}
106
+
107
+ {tab === 'notes' && (
108
+ <FlatList
109
+ data={p.highlights.slice().sort((a, b) => a.spine - b.spine || a.start - b.start)}
110
+ keyExtractor={(h) => h.id}
111
+ ListEmptyComponent={empty('No highlights or notes yet. Select text to add one.')}
112
+ renderItem={({ item }) => (
113
+ <Pressable
114
+ onPress={() => p.onGoHighlight(item)}
115
+ onLongPress={() => confirmDelete('highlight', () => p.onDeleteHighlight(item.id))}
116
+ style={[styles.row, rowBorder]}
117
+ >
118
+ <View style={[styles.bar, { backgroundColor: HIGHLIGHT_COLORS[item.color] }]} />
119
+ <View style={{ flex: 1 }}>
120
+ {!!item.note && <Text style={[styles.rowText, { color: colors.text, fontWeight: '600' }]}>{item.note}</Text>}
121
+ <Text style={[styles.rowText, { color: item.note ? colors.subtext : colors.text }]} numberOfLines={3}>
122
+ {item.text}
123
+ </Text>
124
+ {!!item.chapter && <Text style={[styles.meta, { color: colors.subtext }]}>{item.chapter}</Text>}
125
+ </View>
126
+ <Text style={[styles.page, { color: colors.subtext }]}>{p.pageOf(item.spine, item.start)}</Text>
127
+ </Pressable>
128
+ )}
129
+ />
130
+ )}
131
+ </Sheet>
132
+ );
133
+ }
134
+
135
+ const styles = StyleSheet.create({
136
+ segment: { flexDirection: 'row', marginHorizontal: 16, marginTop: 34, marginBottom: 6, borderRadius: 18, padding: 3 },
137
+ segItem: { flex: 1, alignItems: 'center', paddingVertical: 8, borderRadius: 15 },
138
+ row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 17, paddingHorizontal: 20, marginHorizontal: 0, borderBottomWidth: StyleSheet.hairlineWidth },
139
+ rowText: { fontSize: 18, lineHeight: 24 },
140
+ page: { fontSize: 18, marginStart: 12, minWidth: 34, textAlign: 'right' },
141
+ meta: { fontSize: 13, marginBottom: 2, fontWeight: '600' },
142
+ bar: { width: 3, alignSelf: 'stretch', borderRadius: 2, marginEnd: 12 },
143
+ empty: { textAlign: 'center', padding: 32, fontSize: 16 }
144
+ });
@@ -0,0 +1,189 @@
1
+ import React, { useState } from 'react';
2
+ import { Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
3
+ import { FONTS, getFont, serifFont } from '../themes';
4
+ import type { ChromeColors } from '../themes';
5
+ import type { ColumnsSetting, ReaderSettings, ThemeColors } from '../types';
6
+ import { Icon } from './Icons';
7
+ import type { IconName } from './Icons';
8
+ import { Sheet } from './Sheet';
9
+ import { Slider } from './Slider';
10
+
11
+ interface Props {
12
+ visible: boolean;
13
+ colors: ChromeColors;
14
+ page: ThemeColors;
15
+ settings: ReaderSettings;
16
+ sample: string;
17
+ onChange: (patch: Partial<ReaderSettings>) => void;
18
+ onReset: () => void;
19
+ onClose: () => void;
20
+ }
21
+
22
+ const COLUMN_LABEL: Record<string, string> = { auto: 'Auto Set', '1': 'One', '2': 'Two' };
23
+ const GREEN = { true: '#34c759' };
24
+
25
+ function Toggle(props: React.ComponentProps<typeof Switch>) {
26
+ return (
27
+ <View style={styles.toggleBox}>
28
+ <Switch {...props} />
29
+ </View>
30
+ );
31
+ }
32
+
33
+ /** "Customize Theme" sheet: font, bold, spacing, margins, columns, justify, sound. */
34
+ export function CustomizeSheet({ visible, colors, page, settings: s, sample, onChange, onReset, onClose }: Props) {
35
+ const [fonts, setFonts] = useState(false);
36
+ const [columnsMenu, setColumnsMenu] = useState(false);
37
+ const off = !s.customize;
38
+ const font = getFont(s.fontId);
39
+ const previewFont = s.fontId === 'system' ? undefined : serifFont;
40
+ const border = { borderTopWidth: StyleSheet.hairlineWidth, borderColor: colors.separator };
41
+
42
+ const sliderRow = (label: string, icon: IconName, value: number, min: number, max: number, step: number, show: string, key: keyof ReaderSettings) => (
43
+ <View style={[styles.sliderBlock, border, off && styles.dim]} pointerEvents={off ? 'none' : 'auto'}>
44
+ <Text style={[styles.caption, { color: colors.subtext }]}>{label}</Text>
45
+ <View style={styles.sliderRow}>
46
+ <Icon name={icon} color={colors.text} size={24} />
47
+ <View style={{ flex: 1, marginHorizontal: 12 }}>
48
+ <Slider value={value} min={min} max={max} step={step} onChange={(v) => onChange({ [key]: v } as Partial<ReaderSettings>)} color={colors.text} track={colors.separator} thumbColor="#ffffff" />
49
+ </View>
50
+ <Text style={[styles.value, { color: colors.subtext }]}>{show}</Text>
51
+ </View>
52
+ </View>
53
+ );
54
+
55
+ const row = (icon: IconName, label: string, right: React.ReactNode, onPress?: () => void, top = true, iconActive = false) => {
56
+ const content = (
57
+ <>
58
+ <View style={styles.rowIcon}>
59
+ <Icon name={icon} color={colors.text} size={22} active={iconActive} />
60
+ </View>
61
+ <Text style={[styles.label, { color: colors.text }]}>{label}</Text>
62
+ {right}
63
+ </>
64
+ );
65
+ return onPress ? (
66
+ <Pressable style={[styles.line, top && border]} onPress={onPress}>
67
+ {content}
68
+ </Pressable>
69
+ ) : (
70
+ <View style={[styles.line, top && border]}>{content}</View>
71
+ );
72
+ };
73
+
74
+ const close = () => {
75
+ setFonts(false);
76
+ setColumnsMenu(false);
77
+ onClose();
78
+ };
79
+
80
+ return (
81
+ <Sheet visible={visible} colors={colors} onClose={close} closeIcon="check" title="Customize Theme" height="92%">
82
+ <View style={[styles.preview, { backgroundColor: page.bg }]}>
83
+ <Text style={[styles.aa, { color: page.fg, fontFamily: previewFont }]}>Aa</Text>
84
+ <Text
85
+ numberOfLines={4}
86
+ style={{
87
+ color: page.fg, fontFamily: previewFont, fontSize: Math.min(s.fontSize, 22),
88
+ lineHeight: Math.min(s.fontSize, 22) * (s.customize ? s.lineHeight : 1.5),
89
+ fontWeight: s.bold ? '700' : '400', textAlign: s.justify ? 'justify' : 'auto'
90
+ }}
91
+ >
92
+ {sample}
93
+ </Text>
94
+ </View>
95
+
96
+ <ScrollView contentContainerStyle={styles.pad}>
97
+ <View style={[styles.group, { backgroundColor: colors.card }]}>
98
+ {row(
99
+ 'textSize',
100
+ 'Font',
101
+ <View style={styles.value2}>
102
+ <Text style={{ color: colors.subtext, fontSize: 17 }}>{font.name}</Text>
103
+ <Icon name={fonts ? 'chevronUp' : 'chevronRight'} color={colors.subtext} size={18} />
104
+ </View>,
105
+ () => setFonts((v) => !v),
106
+ false
107
+ )}
108
+ {fonts &&
109
+ FONTS.map((f) => (
110
+ <Pressable
111
+ key={f.id}
112
+ onPress={() => {
113
+ onChange({ fontId: f.id });
114
+ setFonts(false);
115
+ }}
116
+ style={[styles.line, border]}
117
+ >
118
+ <View style={styles.check}>{s.fontId === f.id && <Icon name="check" color={colors.text} size={20} />}</View>
119
+ <Text style={[styles.label, { color: colors.text }]}>{f.name}</Text>
120
+ </Pressable>
121
+ ))}
122
+ {row('bold', 'Bold Text', <Toggle value={s.bold} onValueChange={(v) => onChange({ bold: v })} trackColor={GREEN} />)}
123
+ </View>
124
+
125
+ <Text style={[styles.heading, { color: colors.text }]}>Accessibility & Layout Options</Text>
126
+ <View style={[styles.group, { backgroundColor: colors.card }]}>
127
+ {row('sliders', 'Customize', <Toggle value={s.customize} onValueChange={(v) => onChange({ customize: v })} trackColor={GREEN} />, undefined, false)}
128
+ {sliderRow('LINE SPACING', 'lineSpacing', s.lineHeight, 1.1, 2.2, 0.05, s.lineHeight.toFixed(2), 'lineHeight')}
129
+ {sliderRow('CHARACTER SPACING', 'charSpacing', s.letterSpacing, -50, 100, 5, `${s.letterSpacing}%`, 'letterSpacing')}
130
+ {sliderRow('WORD SPACING', 'wordSpacing', s.wordSpacing, -50, 100, 5, `${s.wordSpacing}%`, 'wordSpacing')}
131
+ {sliderRow('MARGINS', 'margins', s.margins, -50, 100, 5, `${s.margins}%`, 'margins')}
132
+ </View>
133
+
134
+ <View style={[styles.group, { backgroundColor: colors.card, marginTop: 16 }]}>
135
+ {row(
136
+ 'columns',
137
+ 'Columns',
138
+ <View style={styles.value2}>
139
+ <Text style={{ color: colors.subtext, fontSize: 17 }}>{COLUMN_LABEL[String(s.columns)]}</Text>
140
+ <Icon name={columnsMenu ? 'chevronUp' : 'chevronDown'} color={colors.subtext} size={18} />
141
+ </View>,
142
+ () => setColumnsMenu((v) => !v),
143
+ false
144
+ )}
145
+ {columnsMenu &&
146
+ (['auto', 1, 2] as ColumnsSetting[]).map((c) => (
147
+ <Pressable
148
+ key={String(c)}
149
+ style={[styles.line, border]}
150
+ onPress={() => {
151
+ onChange({ columns: c });
152
+ setColumnsMenu(false);
153
+ }}
154
+ >
155
+ <View style={styles.check}>{s.columns === c && <Icon name="check" color={colors.text} size={20} />}</View>
156
+ <Text style={[styles.label, { color: colors.text }]}>{COLUMN_LABEL[String(c)]}</Text>
157
+ </Pressable>
158
+ ))}
159
+ {row('justify', 'Justify Text', <Toggle value={s.justify} onValueChange={(v) => onChange({ justify: v })} trackColor={GREEN} />)}
160
+ {row(s.pageSound ? 'sound' : 'soundOff', 'Page Turn Sound', <Toggle value={s.pageSound} onValueChange={(v) => onChange({ pageSound: v })} trackColor={GREEN} />)}
161
+ </View>
162
+
163
+ <Pressable onPress={onReset} style={[styles.reset, { backgroundColor: colors.card }]}>
164
+ <Text style={{ color: '#ff3b30', fontSize: 18, fontWeight: '600' }}>Reset Theme</Text>
165
+ </Pressable>
166
+ </ScrollView>
167
+ </Sheet>
168
+ );
169
+ }
170
+
171
+ const styles = StyleSheet.create({
172
+ preview: { paddingHorizontal: 22, paddingTop: 12, paddingBottom: 18, marginBottom: 10 },
173
+ aa: { fontSize: 34, textAlign: 'right', marginBottom: 6 },
174
+ pad: { paddingHorizontal: 16, paddingBottom: 40 },
175
+ group: { borderRadius: 24, overflow: 'hidden' },
176
+ line: { flexDirection: 'row', alignItems: 'center', minHeight: 56, paddingHorizontal: 18 },
177
+ rowIcon: { width: 34, height: 28, alignItems: 'flex-start', justifyContent: 'center' },
178
+ toggleBox: { height: 31, minWidth: 51, alignItems: 'center', justifyContent: 'center' },
179
+ label: { flex: 1, fontSize: 18, lineHeight: 24 },
180
+ check: { width: 34, height: 24, justifyContent: 'center' },
181
+ value2: { flexDirection: 'row', alignItems: 'center', gap: 4 },
182
+ heading: { fontSize: 22, fontWeight: '700', marginTop: 26, marginBottom: 10, marginStart: 4 },
183
+ sliderBlock: { paddingHorizontal: 18, paddingTop: 10, paddingBottom: 6 },
184
+ dim: { opacity: 0.45 },
185
+ caption: { fontSize: 14, marginBottom: 2 },
186
+ sliderRow: { flexDirection: 'row', alignItems: 'center' },
187
+ value: { width: 50, textAlign: 'right', fontSize: 17, lineHeight: 22 },
188
+ reset: { marginTop: 18, height: 58, borderRadius: 29, alignItems: 'center', justifyContent: 'center' }
189
+ });
@@ -0,0 +1,37 @@
1
+ import React from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
+ import type { StyleProp, ViewStyle } from 'react-native';
4
+
5
+ // expo-blur is optional: with it the controls are frosted glass, without it they are plain translucent.
6
+ let BlurView: React.ComponentType<any> | null = null;
7
+ try {
8
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
9
+ BlurView = require('expo-blur').BlurView;
10
+ } catch {
11
+ BlurView = null;
12
+ }
13
+
14
+ interface Props {
15
+ style?: StyleProp<ViewStyle>;
16
+ /** translucent tint drawn over the blur (use ChromeColors.glass or .sheetGlass) */
17
+ background: string;
18
+ border?: string;
19
+ dark: boolean;
20
+ /** blur strength 0..100 */
21
+ intensity?: number;
22
+ children?: React.ReactNode;
23
+ }
24
+
25
+ /** Frosted glass surface: blurs the book behind it so reading is not interrupted. */
26
+ export function Glass({ style, background, border, dark, intensity = 38, children }: Props) {
27
+ const base: ViewStyle = { overflow: 'hidden', borderWidth: border ? StyleSheet.hairlineWidth : 0, borderColor: border };
28
+ if (BlurView) {
29
+ return (
30
+ <BlurView intensity={intensity} tint={dark ? 'dark' : 'light'} style={[base, style]}>
31
+ <View style={[StyleSheet.absoluteFill, { backgroundColor: background }]} pointerEvents="none" />
32
+ {children}
33
+ </BlurView>
34
+ );
35
+ }
36
+ return <View style={[base, { backgroundColor: background }, style]}>{children}</View>;
37
+ }
@@ -0,0 +1,64 @@
1
+ import React from 'react';
2
+ import { Pressable, StyleSheet, View } from 'react-native';
3
+ import { HIGHLIGHT_COLORS } from '../themes';
4
+ import type { ChromeColors } from '../themes';
5
+ import type { Highlight, HighlightColor } from '../types';
6
+ import { Glass } from './Glass';
7
+ import { Icon } from './Icons';
8
+
9
+ interface Props {
10
+ highlight: Highlight;
11
+ rect: { x: number; y: number; width: number; height: number };
12
+ screen: { width: number; height: number };
13
+ colors: ChromeColors;
14
+ onColor: (c: HighlightColor) => void;
15
+ onToggleUnderline: () => void;
16
+ onNote: () => void;
17
+ onDelete: () => void;
18
+ onDismiss: () => void;
19
+ }
20
+
21
+ const W = 316;
22
+ const H = 50;
23
+
24
+ /** Floating glass palette shown over a highlight, like Apple Books. */
25
+ export function HighlightMenu({ highlight, rect, screen, colors, onColor, onToggleUnderline, onNote, onDelete, onDismiss }: Props) {
26
+ const left = Math.max(8, Math.min(screen.width - W - 8, rect.x + rect.width / 2 - W / 2));
27
+ const above = rect.y - H - 10;
28
+ const top = above > 60 ? above : rect.y + rect.height + 10;
29
+ return (
30
+ <>
31
+ <Pressable style={StyleSheet.absoluteFill} onPress={onDismiss} accessibilityLabel="Dismiss" />
32
+ <Glass dark={colors.dark} background={colors.sheetGlass} border={colors.glassBorder} intensity={55} style={[styles.menu, { left, top }]}>
33
+ {(Object.keys(HIGHLIGHT_COLORS) as HighlightColor[]).map((c) => (
34
+ <Pressable key={c} onPress={() => onColor(c)} hitSlop={4} accessibilityLabel={`${c} highlight`}>
35
+ <View style={[styles.dot, { backgroundColor: HIGHLIGHT_COLORS[c], borderWidth: highlight.color === c ? 2 : 0, borderColor: colors.text }]} />
36
+ </Pressable>
37
+ ))}
38
+ <Pressable onPress={onToggleUnderline} hitSlop={6} accessibilityLabel="Underline">
39
+ <Icon name="underline" color={colors.text} size={22} active={highlight.style === 'underline'} strokeWidth={highlight.style === 'underline' ? 2.4 : 1.8} />
40
+ </Pressable>
41
+ <Pressable onPress={onNote} hitSlop={6} accessibilityLabel="Note">
42
+ <Icon name="pencil" color={colors.text} size={22} />
43
+ </Pressable>
44
+ <Pressable onPress={onDelete} hitSlop={6} accessibilityLabel="Remove highlight">
45
+ <Icon name="trash" color="#ff3b30" size={22} />
46
+ </Pressable>
47
+ </Glass>
48
+ </>
49
+ );
50
+ }
51
+
52
+ const styles = StyleSheet.create({
53
+ menu: {
54
+ position: 'absolute',
55
+ width: W,
56
+ height: H,
57
+ borderRadius: 25,
58
+ flexDirection: 'row',
59
+ alignItems: 'center',
60
+ justifyContent: 'space-around',
61
+ paddingHorizontal: 12
62
+ },
63
+ dot: { width: 26, height: 26, borderRadius: 13 }
64
+ });
@@ -0,0 +1,246 @@
1
+ import React from 'react';
2
+ import Svg, { Circle, G, Line, Path, Rect } from 'react-native-svg';
3
+
4
+ /**
5
+ * Vector icon set (24 x 24 grid, rounded strokes). Every icon takes the colour it is drawn in, so it follows the
6
+ * theme (light / dark / any page colour). Some icons carry state: `active` fills or closes them
7
+ * (bookmark, rotation lock, line guide).
8
+ */
9
+ export type IconName =
10
+ | 'close' | 'check' | 'chevronRight' | 'chevronUp' | 'chevronDown'
11
+ | 'list' | 'search' | 'textSize' | 'share' | 'bookmark' | 'rotationLock' | 'lineGuide' | 'undo'
12
+ | 'sun' | 'sunLow' | 'moon' | 'contrast'
13
+ | 'pageSlide' | 'pageCurl' | 'pageFade' | 'pageScroll'
14
+ | 'sliders' | 'lineSpacing' | 'charSpacing' | 'wordSpacing' | 'margins' | 'columns' | 'bold' | 'justify'
15
+ | 'trash' | 'pencil' | 'underline' | 'highlighter' | 'sound' | 'soundOff';
16
+
17
+ interface Props {
18
+ name: IconName;
19
+ color: string;
20
+ size?: number;
21
+ /** filled / closed state for bookmark, rotation lock, line guide */
22
+ active?: boolean;
23
+ strokeWidth?: number;
24
+ }
25
+
26
+ const RAYS = [0, 45, 90, 135, 180, 225, 270, 315].map((deg) => {
27
+ const a = (deg * Math.PI) / 180;
28
+ return { x1: 12 + 6.6 * Math.cos(a), y1: 12 + 6.6 * Math.sin(a), x2: 12 + 9.2 * Math.cos(a), y2: 12 + 9.2 * Math.sin(a) };
29
+ });
30
+
31
+ export function Icon({ name, color, size = 24, active = false, strokeWidth = 1.8 }: Props) {
32
+ const p = { stroke: color, strokeWidth, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };
33
+ let body: React.ReactNode = null;
34
+
35
+ switch (name) {
36
+ case 'close':
37
+ body = <Path d="M6 6 L18 18 M18 6 L6 18" {...p} />;
38
+ break;
39
+ case 'check':
40
+ body = <Path d="M5 12.5 L10 17.5 L19 7" {...p} />;
41
+ break;
42
+ case 'chevronRight':
43
+ body = <Path d="M9 5.5 L15.5 12 L9 18.5" {...p} />;
44
+ break;
45
+ case 'chevronUp':
46
+ body = <Path d="M5.5 15 L12 8.5 L18.5 15" {...p} />;
47
+ break;
48
+ case 'chevronDown':
49
+ body = <Path d="M5.5 9 L12 15.5 L18.5 9" {...p} />;
50
+ break;
51
+ case 'list':
52
+ body = (
53
+ <G>
54
+ {[7, 12, 17].map((y) => (
55
+ <G key={y}>
56
+ <Circle cx={4.8} cy={y} r={1.25} fill={color} />
57
+ <Path d={`M9 ${y} H20`} {...p} />
58
+ </G>
59
+ ))}
60
+ </G>
61
+ );
62
+ break;
63
+ case 'search':
64
+ body = (
65
+ <G>
66
+ <Circle cx={10.8} cy={10.8} r={6.3} {...p} />
67
+ <Path d="M15.6 15.6 L20.5 20.5" {...p} />
68
+ </G>
69
+ );
70
+ break;
71
+ case 'textSize':
72
+ body = (
73
+ <G>
74
+ <Path d="M2.8 18 L6.3 9.2 L9.8 18 M4.1 14.9 H8.5" {...p} />
75
+ <Path d="M11.6 18 L16.6 5.6 L21.6 18 M13.5 13.6 H19.7" {...p} />
76
+ </G>
77
+ );
78
+ break;
79
+ case 'share':
80
+ body = (
81
+ <G>
82
+ <Path d="M8.5 10 H7 A1.7 1.7 0 0 0 5.3 11.7 V19 A1.7 1.7 0 0 0 7 20.7 H17 A1.7 1.7 0 0 0 18.7 19 V11.7 A1.7 1.7 0 0 0 17 10 H15.5" {...p} />
83
+ <Path d="M12 14.5 V3.5 M8.2 7.3 L12 3.5 L15.8 7.3" {...p} />
84
+ </G>
85
+ );
86
+ break;
87
+ case 'bookmark':
88
+ body = (
89
+ <Path
90
+ d="M7.2 3.8 H16.8 A1.2 1.2 0 0 1 18 5 V20.2 L12 16.2 L6 20.2 V5 A1.2 1.2 0 0 1 7.2 3.8 Z"
91
+ {...p}
92
+ fill={active ? color : 'none'}
93
+ />
94
+ );
95
+ break;
96
+ case 'rotationLock':
97
+ body = (
98
+ <G>
99
+ <Path d="M20.2 13 A8.2 8.2 0 1 1 17 5.9" {...p} />
100
+ <Path d="M17.2 2.8 V6.2 H13.8" {...p} />
101
+ <Rect x={9} y={11.2} width={6} height={5} rx={1.2} {...p} fill={active ? color : 'none'} />
102
+ <Path d={active ? 'M10.4 11.2 V10 A1.6 1.6 0 0 1 13.6 10 V11.2' : 'M10.4 11.2 V9.9 A1.6 1.6 0 0 1 13.6 9.9'} {...p} />
103
+ </G>
104
+ );
105
+ break;
106
+ case 'lineGuide':
107
+ body = (
108
+ <G>
109
+ <Path d="M4 6.2 H20 M4 17.8 H20" {...p} opacity={0.5} />
110
+ <Rect x={2.8} y={9.2} width={18.4} height={5.6} rx={2.8} {...p} fill={active ? color : 'none'} fillOpacity={active ? 0.28 : 0} />
111
+ </G>
112
+ );
113
+ break;
114
+ case 'undo':
115
+ body = <Path d="M8.5 6 L4 10.5 L8.5 15 M4.5 10.5 H14.5 A5 5 0 0 1 14.5 20.5 H11" {...p} />;
116
+ break;
117
+ case 'sun':
118
+ body = (
119
+ <G>
120
+ <Circle cx={12} cy={12} r={3.9} {...p} />
121
+ {RAYS.map((r, i) => (
122
+ <Line key={i} x1={r.x1} y1={r.y1} x2={r.x2} y2={r.y2} {...p} />
123
+ ))}
124
+ </G>
125
+ );
126
+ break;
127
+ case 'sunLow':
128
+ body = (
129
+ <G>
130
+ <Circle cx={12} cy={12} r={2.4} {...p} />
131
+ {RAYS.map((r, i) => (
132
+ <Line key={i} x1={12 + (r.x1 - 12) * 0.8} y1={12 + (r.y1 - 12) * 0.8} x2={12 + (r.x2 - 12) * 0.62} y2={12 + (r.y2 - 12) * 0.62} {...p} />
133
+ ))}
134
+ </G>
135
+ );
136
+ break;
137
+ case 'moon':
138
+ body = <Path d="M20 14.2 A8.4 8.4 0 1 1 9.8 4 A6.6 6.6 0 0 0 20 14.2 Z" {...p} />;
139
+ break;
140
+ case 'contrast':
141
+ body = (
142
+ <G>
143
+ <Circle cx={12} cy={12} r={8.2} {...p} />
144
+ <Path d="M12 3.8 A8.2 8.2 0 0 1 12 20.2 Z" fill={color} stroke={color} strokeWidth={1} />
145
+ </G>
146
+ );
147
+ break;
148
+ case 'pageSlide':
149
+ body = (
150
+ <G>
151
+ <Path d="M9 5.2 H5.2 A1.9 1.9 0 0 0 3.3 7.1 V17.9 A1.9 1.9 0 0 0 5.2 19.8 H9" {...p} opacity={0.55} />
152
+ <Rect x={8} y={3.2} width={12.7} height={16.6} rx={2.4} {...p} />
153
+ </G>
154
+ );
155
+ break;
156
+ case 'pageCurl':
157
+ body = (
158
+ <G>
159
+ <Path d="M6.7 3.5 H14.5 L20 9 V18.6 A1.9 1.9 0 0 1 18.1 20.5 H6.7 A1.9 1.9 0 0 1 4.8 18.6 V5.4 A1.9 1.9 0 0 1 6.7 3.5 Z" {...p} />
160
+ <Path d="M14.5 3.5 V7.4 A1.6 1.6 0 0 0 16.1 9 H20" {...p} />
161
+ </G>
162
+ );
163
+ break;
164
+ case 'pageFade':
165
+ body = <Path d="M13.2 2.8 L5.2 13.2 H11 L10.2 21.2 L18.8 10.4 H13 Z" {...p} />;
166
+ break;
167
+ case 'pageScroll':
168
+ body = (
169
+ <G>
170
+ <Rect x={5} y={3} width={14} height={18} rx={2.4} {...p} />
171
+ <Path d="M9.4 9.6 L12 7 L14.6 9.6 M9.4 14.4 L12 17 L14.6 14.4" {...p} />
172
+ </G>
173
+ );
174
+ break;
175
+ case 'sliders':
176
+ body = (
177
+ <G>
178
+ <Path d="M3.5 8 H9.2 M14.8 8 H20.5 M3.5 16 H14.2 M19.8 16 H20.5" {...p} />
179
+ <Circle cx={12} cy={8} r={2.8} {...p} />
180
+ <Circle cx={17} cy={16} r={2.8} {...p} />
181
+ </G>
182
+ );
183
+ break;
184
+ case 'lineSpacing':
185
+ body = <Path d="M10.5 6.5 H20.5 M10.5 12 H20.5 M10.5 17.5 H20.5 M5 4.5 V19.5 M2.9 6.6 L5 4.5 L7.1 6.6 M2.9 17.4 L5 19.5 L7.1 17.4" {...p} />;
186
+ break;
187
+ case 'charSpacing':
188
+ body = <Path d="M7.6 13.6 L12 3.8 L16.4 13.6 M9.2 10.4 H14.8 M3.8 19.2 H20.2 M6.2 16.9 L3.8 19.2 L6.2 21.5 M17.8 16.9 L20.2 19.2 L17.8 21.5" {...p} />;
189
+ break;
190
+ case 'wordSpacing':
191
+ body = (
192
+ <G>
193
+ <Rect x={2.8} y={4.5} width={7} height={4.2} rx={1.2} {...p} />
194
+ <Rect x={14.2} y={4.5} width={7} height={4.2} rx={1.2} {...p} />
195
+ <Path d="M3.8 15.2 H20.2 M6.2 12.9 L3.8 15.2 L6.2 17.5 M17.8 12.9 L20.2 15.2 L17.8 17.5" {...p} />
196
+ </G>
197
+ );
198
+ break;
199
+ case 'margins':
200
+ body = (
201
+ <G>
202
+ <Rect x={3.8} y={3} width={16.4} height={18} rx={2.4} {...p} />
203
+ <Path d="M8 7 V17 M16 7 V17" {...p} />
204
+ </G>
205
+ );
206
+ break;
207
+ case 'columns':
208
+ body = (
209
+ <G>
210
+ <Rect x={3.5} y={4} width={7.6} height={16} rx={1.8} {...p} />
211
+ <Rect x={12.9} y={4} width={7.6} height={16} rx={1.8} {...p} />
212
+ </G>
213
+ );
214
+ break;
215
+ case 'bold':
216
+ body = <Path d="M8 4.6 V19.4 M8 4.6 H13 A3.6 3.6 0 0 1 13 11.8 H8 M8 11.8 H14 A3.8 3.8 0 0 1 14 19.4 H8" {...p} strokeWidth={strokeWidth + 0.5} />;
217
+ break;
218
+ case 'justify':
219
+ body = <Path d="M4 6 H20 M4 10 H20 M4 14 H20 M4 18 H20" {...p} />;
220
+ break;
221
+ case 'trash':
222
+ body = <Path d="M4.5 7 H19.5 M9.3 7 V4.6 H14.7 V7 M6.6 7 L7.6 19.6 A1.2 1.2 0 0 0 8.8 20.7 H15.2 A1.2 1.2 0 0 0 16.4 19.6 L17.4 7 M10.2 10.8 V16.4 M13.8 10.8 V16.4" {...p} />;
223
+ break;
224
+ case 'pencil':
225
+ body = <Path d="M4 20 L5.2 15.2 L16.2 4.2 A2.2 2.2 0 0 1 19.4 7.4 L8.4 18.4 Z M14 6.4 L17.2 9.6" {...p} />;
226
+ break;
227
+ case 'highlighter':
228
+ body = <Path d="M14.2 4.6 L19.4 9.8 L10.6 18.6 L5.4 13.4 Z M5.4 13.4 L4.2 17.6 L7.6 18.6 M3.5 21 H12.5" {...p} />;
229
+ break;
230
+ case 'underline':
231
+ body = <Path d="M7.4 4.4 V11 A4.6 4.6 0 0 0 16.6 11 V4.4 M5 20 H19" {...p} />;
232
+ break;
233
+ case 'sound':
234
+ body = <Path d="M4 9.5 H7.5 L12.5 5.2 V18.8 L7.5 14.5 H4 Z M16 9 A4.2 4.2 0 0 1 16 15 M18.6 6.6 A7.6 7.6 0 0 1 18.6 17.4" {...p} />;
235
+ break;
236
+ case 'soundOff':
237
+ body = <Path d="M4 9.5 H7.5 L12.5 5.2 V18.8 L7.5 14.5 H4 Z M16.2 9.4 L21 14.6 M21 9.4 L16.2 14.6" {...p} />;
238
+ break;
239
+ }
240
+
241
+ return (
242
+ <Svg width={size} height={size} viewBox="0 0 24 24">
243
+ {body}
244
+ </Svg>
245
+ );
246
+ }