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.
- package/LICENSE +21 -0
- package/README.md +338 -2
- package/lib/EpubReader.d.ts +63 -0
- package/lib/EpubReader.js +199 -0
- package/lib/EpubReaderView.d.ts +24 -0
- package/lib/EpubReaderView.js +504 -0
- package/lib/assets/page-turn-1.wav +0 -0
- package/lib/assets/page-turn-2.wav +0 -0
- package/lib/assets/page-turn-3.wav +0 -0
- package/lib/assets/page-turn-4.wav +0 -0
- package/lib/engine/html.d.ts +1 -0
- package/lib/engine/html.js +5 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +30 -0
- package/lib/sound.d.ts +1 -0
- package/lib/sound.js +71 -0
- package/lib/themes.d.ts +56 -0
- package/lib/themes.js +166 -0
- package/lib/types.d.ts +133 -0
- package/lib/types.js +2 -0
- package/lib/ui/AppearanceSheet.d.ts +15 -0
- package/lib/ui/AppearanceSheet.js +127 -0
- package/lib/ui/ContentsSheet.d.ts +27 -0
- package/lib/ui/ContentsSheet.js +87 -0
- package/lib/ui/CustomizeSheet.d.ts +16 -0
- package/lib/ui/CustomizeSheet.js +139 -0
- package/lib/ui/Glass.d.ts +15 -0
- package/lib/ui/Glass.js +27 -0
- package/lib/ui/HighlightMenu.d.ts +25 -0
- package/lib/ui/HighlightMenu.js +43 -0
- package/lib/ui/Icons.d.ts +17 -0
- package/lib/ui/Icons.js +193 -0
- package/lib/ui/LineGuide.d.ts +21 -0
- package/lib/ui/LineGuide.js +92 -0
- package/lib/ui/NoteEditor.d.ts +12 -0
- package/lib/ui/NoteEditor.js +61 -0
- package/lib/ui/Scrubber.d.ts +15 -0
- package/lib/ui/Scrubber.js +86 -0
- package/lib/ui/SearchSheet.d.ts +14 -0
- package/lib/ui/SearchSheet.js +67 -0
- package/lib/ui/Sheet.d.ts +19 -0
- package/lib/ui/Sheet.js +38 -0
- package/lib/ui/Slider.d.ts +14 -0
- package/lib/ui/Slider.js +53 -0
- package/package.json +75 -5
- package/src/EpubReader.tsx +256 -0
- package/src/EpubReaderView.tsx +721 -0
- package/src/assets/page-turn-1.wav +0 -0
- package/src/assets/page-turn-2.wav +0 -0
- package/src/assets/page-turn-3.wav +0 -0
- package/src/assets/page-turn-4.wav +0 -0
- package/src/engine/html.ts +2 -0
- package/src/engine/reader.js +1696 -0
- package/src/index.ts +6 -0
- package/src/sound.ts +66 -0
- package/src/themes.ts +188 -0
- package/src/types.ts +138 -0
- package/src/ui/AppearanceSheet.tsx +167 -0
- package/src/ui/ContentsSheet.tsx +144 -0
- package/src/ui/CustomizeSheet.tsx +189 -0
- package/src/ui/Glass.tsx +37 -0
- package/src/ui/HighlightMenu.tsx +64 -0
- package/src/ui/Icons.tsx +246 -0
- package/src/ui/LineGuide.tsx +87 -0
- package/src/ui/NoteEditor.tsx +55 -0
- package/src/ui/Scrubber.tsx +78 -0
- package/src/ui/SearchSheet.tsx +79 -0
- package/src/ui/Sheet.tsx +69 -0
- package/src/ui/Slider.tsx +30 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { EpubReaderView } from './EpubReaderView';
|
|
2
|
+
export type { EpubReaderViewProps } from './EpubReaderView';
|
|
3
|
+
export { EpubReader } from './EpubReader';
|
|
4
|
+
export type { EpubReaderProps, EpubReaderHandle } from './EpubReader';
|
|
5
|
+
export { THEMES, FONTS, HIGHLIGHT_COLORS, DEFAULT_SETTINGS, getTheme, getFont, chromeColors } from './themes';
|
|
6
|
+
export * from './types';
|
package/src/sound.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Page-turn sound: real recorded page flips (see README for credit), a different one each turn.
|
|
2
|
+
// expo-audio is optional: without it the reader is simply silent.
|
|
3
|
+
//
|
|
4
|
+
// One player per recording, and every play first waits for the rewind to finish.
|
|
5
|
+
// (Calling play() right after an un-awaited seekTo(0) on a player that already finished plays nothing.)
|
|
6
|
+
let audio: any = null;
|
|
7
|
+
let missing = false;
|
|
8
|
+
let players: any[] = [];
|
|
9
|
+
let last = -1;
|
|
10
|
+
let modeSet = false;
|
|
11
|
+
|
|
12
|
+
function sources(): any[] {
|
|
13
|
+
// static requires so the bundler can find the assets
|
|
14
|
+
return [
|
|
15
|
+
require('./assets/page-turn-1.wav'),
|
|
16
|
+
require('./assets/page-turn-2.wav'),
|
|
17
|
+
require('./assets/page-turn-3.wav'),
|
|
18
|
+
require('./assets/page-turn-4.wav')
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function load(): boolean {
|
|
23
|
+
if (audio) return true;
|
|
24
|
+
if (missing) return false;
|
|
25
|
+
try {
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
27
|
+
audio = require('expo-audio');
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
missing = true;
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function player(i: number): any {
|
|
36
|
+
if (!players[i]) {
|
|
37
|
+
players[i] = audio.createAudioPlayer(sources()[i]);
|
|
38
|
+
players[i].volume = 0.7;
|
|
39
|
+
}
|
|
40
|
+
return players[i];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function playPageTurn(): void {
|
|
44
|
+
if (!load()) return;
|
|
45
|
+
try {
|
|
46
|
+
if (!modeSet) {
|
|
47
|
+
modeSet = true;
|
|
48
|
+
// do not stop the user's music, and follow the silent switch
|
|
49
|
+
Promise.resolve(audio.setAudioModeAsync?.({ playsInSilentMode: false, interruptionMode: 'mixWithOthers' })).catch(() => undefined);
|
|
50
|
+
}
|
|
51
|
+
// random recording, never the same one twice in a row
|
|
52
|
+
let i = Math.floor(Math.random() * 4);
|
|
53
|
+
if (i === last) i = (i + 1) % 4;
|
|
54
|
+
last = i;
|
|
55
|
+
const p = player(i);
|
|
56
|
+
Promise.resolve(p.seekTo(0))
|
|
57
|
+
.catch(() => undefined)
|
|
58
|
+
.then(() => p.play())
|
|
59
|
+
.catch(() => {
|
|
60
|
+
// a broken player is replaced on its next use
|
|
61
|
+
players = [];
|
|
62
|
+
});
|
|
63
|
+
} catch {
|
|
64
|
+
players = [];
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/themes.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
import type { HighlightColor, ReaderFont, ReaderSettings, ReaderTheme, ThemeColors } from './types';
|
|
3
|
+
|
|
4
|
+
export const THEMES: ReaderTheme[] = [
|
|
5
|
+
{
|
|
6
|
+
id: 'original',
|
|
7
|
+
name: 'Original',
|
|
8
|
+
light: { bg: '#ffffff', fg: '#1c1c1e', link: '#0a60ff' },
|
|
9
|
+
dark: { bg: '#000000', fg: '#f2f2f7', link: '#64a8ff' }
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
id: 'quiet',
|
|
13
|
+
name: 'Quiet',
|
|
14
|
+
light: { bg: '#e8e8ea', fg: '#58585c', link: '#3a6ea5' },
|
|
15
|
+
dark: { bg: '#121212', fg: '#8e8e93', link: '#6c9bd1' }
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'paper',
|
|
19
|
+
name: 'Paper',
|
|
20
|
+
light: { bg: '#f6f0e1', fg: '#3b3226', link: '#8a5a2b' },
|
|
21
|
+
dark: { bg: '#1d1d1f', fg: '#e9e0cd', link: '#d6a86a' }
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'bold',
|
|
25
|
+
name: 'Bold',
|
|
26
|
+
bold: true,
|
|
27
|
+
light: { bg: '#ffffff', fg: '#000000', link: '#0a60ff' },
|
|
28
|
+
dark: { bg: '#000000', fg: '#ffffff', link: '#64a8ff' }
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'calm',
|
|
32
|
+
name: 'Calm',
|
|
33
|
+
light: { bg: '#ece1c9', fg: '#4a3f2c', link: '#7a5a1f' },
|
|
34
|
+
dark: { bg: '#3d372d', fg: '#f0e7d3', link: '#e0b66f' }
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'focus',
|
|
38
|
+
name: 'Focus',
|
|
39
|
+
light: { bg: '#f3efe2', fg: '#2d2a24', link: '#9a4b1c' },
|
|
40
|
+
dark: { bg: '#1a1913', fg: '#e8e2d0', link: '#d98f5a' }
|
|
41
|
+
}
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const SERIF = Platform.OS === 'ios' ? 'Georgia' : 'serif';
|
|
45
|
+
|
|
46
|
+
/** Font stacks. iOS ships most of these, on Android the stacks fall back to the system serif / sans. */
|
|
47
|
+
export const FONTS: ReaderFont[] = [
|
|
48
|
+
{ id: 'original', name: 'Original', css: 'original' },
|
|
49
|
+
{ id: 'system', name: 'System', css: "-apple-system, system-ui, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif" },
|
|
50
|
+
{ id: 'newyork', name: 'New York', css: "ui-serif, 'New York', Georgia, serif" },
|
|
51
|
+
{ id: 'athelas', name: 'Athelas', css: "Athelas, Georgia, serif" },
|
|
52
|
+
{ id: 'charter', name: 'Charter', css: "Charter, 'Bitstream Charter', Georgia, serif" },
|
|
53
|
+
{ id: 'georgia', name: 'Georgia', css: "Georgia, 'Times New Roman', serif" },
|
|
54
|
+
{ id: 'iowan', name: 'Iowan', css: "'Iowan Old Style', Georgia, serif" },
|
|
55
|
+
{ id: 'palatino', name: 'Palatino', css: "Palatino, 'Palatino Linotype', 'Book Antiqua', serif" },
|
|
56
|
+
{ id: 'seravek', name: 'Seravek', css: "Seravek, 'Avenir Next', -apple-system, sans-serif" },
|
|
57
|
+
{ id: 'times', name: 'Times New Roman', css: "'Times New Roman', Times, serif" },
|
|
58
|
+
{ id: 'naskh', name: 'Naskh (عربي / پښتو)', css: "'Noto Naskh Arabic', 'Geeza Pro', 'Droid Arabic Naskh', 'Scheherazade New', serif" }
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
export const HIGHLIGHT_COLORS: Record<HighlightColor, string> = {
|
|
62
|
+
yellow: '#ffd60a',
|
|
63
|
+
green: '#34c759',
|
|
64
|
+
blue: '#0a84ff',
|
|
65
|
+
pink: '#ff375f',
|
|
66
|
+
purple: '#bf5af2'
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const DEFAULT_SETTINGS: ReaderSettings = {
|
|
70
|
+
themeId: 'original',
|
|
71
|
+
appearance: 'device',
|
|
72
|
+
fontId: 'original',
|
|
73
|
+
fontSize: 19,
|
|
74
|
+
bold: false,
|
|
75
|
+
customize: false,
|
|
76
|
+
lineHeight: 1.5,
|
|
77
|
+
letterSpacing: 0,
|
|
78
|
+
wordSpacing: 0,
|
|
79
|
+
margins: 0,
|
|
80
|
+
columns: 'auto',
|
|
81
|
+
justify: false,
|
|
82
|
+
pageTurn: 'slide',
|
|
83
|
+
pageSound: true,
|
|
84
|
+
brightness: 1,
|
|
85
|
+
direction: 'auto'
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const getTheme = (id: string): ReaderTheme => THEMES.find((t) => t.id === id) || THEMES[0];
|
|
89
|
+
export const getFont = (id: string): ReaderFont => FONTS.find((f) => f.id === id) || FONTS[0];
|
|
90
|
+
export const serifFont = SERIF;
|
|
91
|
+
|
|
92
|
+
export function isDark(settings: ReaderSettings, systemDark: boolean): boolean {
|
|
93
|
+
return settings.appearance === 'device' ? systemDark : settings.appearance === 'dark';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function themeColors(settings: ReaderSettings, systemDark: boolean): ThemeColors {
|
|
97
|
+
const t = getTheme(settings.themeId);
|
|
98
|
+
return isDark(settings, systemDark) ? t.dark : t.light;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const BASE_MARGIN = 28;
|
|
102
|
+
|
|
103
|
+
/** Settings object understood by the WebView engine. */
|
|
104
|
+
export function toEngineSettings(settings: ReaderSettings, systemDark: boolean, insets: { top: number; bottom: number }) {
|
|
105
|
+
const theme = getTheme(settings.themeId);
|
|
106
|
+
const c = themeColors(settings, systemDark);
|
|
107
|
+
const custom = settings.customize;
|
|
108
|
+
return {
|
|
109
|
+
bg: c.bg,
|
|
110
|
+
fg: c.fg,
|
|
111
|
+
link: c.link,
|
|
112
|
+
bold: !!theme.bold || settings.bold,
|
|
113
|
+
fontFamily: getFont(settings.fontId).css,
|
|
114
|
+
fontSize: settings.fontSize,
|
|
115
|
+
lineHeight: custom ? settings.lineHeight : 1.5,
|
|
116
|
+
letterSpacing: custom ? (settings.letterSpacing / 100) * 0.25 : 0,
|
|
117
|
+
wordSpacing: custom ? (settings.wordSpacing / 100) * 0.5 : 0,
|
|
118
|
+
marginX: Math.round(BASE_MARGIN * (1 + (custom ? settings.margins : 0) / 100)),
|
|
119
|
+
columns: settings.columns,
|
|
120
|
+
justify: settings.justify,
|
|
121
|
+
mode: settings.pageTurn,
|
|
122
|
+
direction: settings.direction,
|
|
123
|
+
insets: { top: insets.top + 40, bottom: insets.bottom + 40 }
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ChromeColors {
|
|
128
|
+
/** translucent control background (pills, circles), tinted with the page colour */
|
|
129
|
+
glass: string;
|
|
130
|
+
glassBorder: string;
|
|
131
|
+
/** solid sheet colour and a translucent one for sheets that float over the book */
|
|
132
|
+
sheet: string;
|
|
133
|
+
sheetGlass: string;
|
|
134
|
+
/** rows / cards inside a sheet: a neutral wash that works on any theme */
|
|
135
|
+
card: string;
|
|
136
|
+
text: string;
|
|
137
|
+
subtext: string;
|
|
138
|
+
separator: string;
|
|
139
|
+
/** highlight for the current chapter / selected items */
|
|
140
|
+
accent: string;
|
|
141
|
+
dark: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function mix(hex: string, other: string, amount: number): string {
|
|
145
|
+
const p = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
146
|
+
const a = p(hex);
|
|
147
|
+
const b = p(other);
|
|
148
|
+
const m = a.map((v, i) => Math.round(v + (b[i] - v) * amount));
|
|
149
|
+
return '#' + m.map((v) => v.toString(16).padStart(2, '0')).join('');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function rgba(hex: string, alpha: number): string {
|
|
153
|
+
const v = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
|
154
|
+
return `rgba(${v[0]},${v[1]},${v[2]},${alpha})`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Chrome (buttons, menus, sheets) is frosted glass tinted with the page colour, so a Calm page gets warm glass and a
|
|
159
|
+
* black page gets dark glass. Text and icons use the neutral text colour of the light / dark variant.
|
|
160
|
+
*/
|
|
161
|
+
export function chromeColors(colors: ThemeColors, dark: boolean): ChromeColors {
|
|
162
|
+
if (dark) {
|
|
163
|
+
return {
|
|
164
|
+
glass: rgba(mix(colors.bg, '#ffffff', 0.22), 0.4),
|
|
165
|
+
glassBorder: 'rgba(255,255,255,0.16)',
|
|
166
|
+
sheet: mix(colors.bg, '#ffffff', 0.1),
|
|
167
|
+
sheetGlass: rgba(mix(colors.bg, '#ffffff', 0.1), 0.84),
|
|
168
|
+
card: 'rgba(255,255,255,0.1)',
|
|
169
|
+
text: '#ffffff',
|
|
170
|
+
subtext: '#a09d96',
|
|
171
|
+
separator: 'rgba(255,255,255,0.14)',
|
|
172
|
+
accent: '#ffb340',
|
|
173
|
+
dark
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
glass: rgba(mix(colors.bg, '#ffffff', 0.55), 0.5),
|
|
178
|
+
glassBorder: 'rgba(0,0,0,0.1)',
|
|
179
|
+
sheet: mix(colors.bg, '#ffffff', 0.5),
|
|
180
|
+
sheetGlass: rgba(mix(colors.bg, '#ffffff', 0.5), 0.86),
|
|
181
|
+
card: 'rgba(0,0,0,0.06)',
|
|
182
|
+
text: '#000000',
|
|
183
|
+
subtext: '#6c6c70',
|
|
184
|
+
separator: 'rgba(0,0,0,0.14)',
|
|
185
|
+
accent: '#ff9500',
|
|
186
|
+
dark
|
|
187
|
+
};
|
|
188
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
export type HighlightColor = 'yellow' | 'green' | 'blue' | 'pink' | 'purple';
|
|
2
|
+
export type HighlightStyle = 'highlight' | 'underline';
|
|
3
|
+
|
|
4
|
+
/** Position in the book. Offsets are text-character offsets inside the spine item, so they survive font/size changes. */
|
|
5
|
+
export interface BookLocation {
|
|
6
|
+
spine: number;
|
|
7
|
+
offset: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Highlight {
|
|
11
|
+
id: string;
|
|
12
|
+
spine: number;
|
|
13
|
+
start: number;
|
|
14
|
+
end: number;
|
|
15
|
+
text: string;
|
|
16
|
+
color: HighlightColor;
|
|
17
|
+
style?: HighlightStyle;
|
|
18
|
+
note?: string;
|
|
19
|
+
chapter?: string;
|
|
20
|
+
createdAt: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Bookmark {
|
|
24
|
+
id: string;
|
|
25
|
+
spine: number;
|
|
26
|
+
offset: number;
|
|
27
|
+
chapter: string;
|
|
28
|
+
excerpt: string;
|
|
29
|
+
progress: number;
|
|
30
|
+
createdAt: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TocItem {
|
|
34
|
+
label: string;
|
|
35
|
+
spine: number;
|
|
36
|
+
fragment: string;
|
|
37
|
+
level: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BookMetadata {
|
|
41
|
+
title: string;
|
|
42
|
+
author: string;
|
|
43
|
+
language: string;
|
|
44
|
+
publisher: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RelocateEvent {
|
|
48
|
+
spine: number;
|
|
49
|
+
/** offset of the first character on the page */
|
|
50
|
+
offset: number;
|
|
51
|
+
/** offset just past the last character on the page */
|
|
52
|
+
end: number;
|
|
53
|
+
page: number;
|
|
54
|
+
pageCount: number;
|
|
55
|
+
spineCount: number;
|
|
56
|
+
/** number of characters in this spine item */
|
|
57
|
+
textLength: number;
|
|
58
|
+
/** 0..1 across the whole book (exact once `indexed` is true) */
|
|
59
|
+
progress: number;
|
|
60
|
+
chapter: string;
|
|
61
|
+
excerpt: string;
|
|
62
|
+
indexed: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SelectionEvent {
|
|
66
|
+
spine: number;
|
|
67
|
+
start: number;
|
|
68
|
+
end: number;
|
|
69
|
+
text: string;
|
|
70
|
+
rect: { x: number; y: number; width: number; height: number };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface SearchResult {
|
|
74
|
+
spine: number;
|
|
75
|
+
offset: number;
|
|
76
|
+
length: number;
|
|
77
|
+
chapter: string;
|
|
78
|
+
before: string;
|
|
79
|
+
match: string;
|
|
80
|
+
after: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ThemeColors {
|
|
84
|
+
/** page background */
|
|
85
|
+
bg: string;
|
|
86
|
+
/** body text */
|
|
87
|
+
fg: string;
|
|
88
|
+
link: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A theme has a light and a dark variant, like the themes in Apple Books. */
|
|
92
|
+
export interface ReaderTheme {
|
|
93
|
+
id: string;
|
|
94
|
+
name: string;
|
|
95
|
+
light: ThemeColors;
|
|
96
|
+
dark: ThemeColors;
|
|
97
|
+
bold?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type Appearance = 'light' | 'dark' | 'device';
|
|
101
|
+
export type PageTurn = 'slide' | 'curl' | 'fade' | 'scroll';
|
|
102
|
+
export type ColumnsSetting = 'auto' | 1 | 2;
|
|
103
|
+
|
|
104
|
+
export interface ReaderFont {
|
|
105
|
+
id: string;
|
|
106
|
+
name: string;
|
|
107
|
+
/** CSS font-family, or 'original' to keep the book's own fonts */
|
|
108
|
+
css: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface ReaderSettings {
|
|
112
|
+
themeId: string;
|
|
113
|
+
appearance: Appearance;
|
|
114
|
+
fontId: string;
|
|
115
|
+
fontSize: number;
|
|
116
|
+
/** extra bold weight on top of the theme's own */
|
|
117
|
+
bold: boolean;
|
|
118
|
+
/** when false the spacing values below are ignored (defaults are used) */
|
|
119
|
+
customize: boolean;
|
|
120
|
+
lineHeight: number;
|
|
121
|
+
/** percent, 0 = default */
|
|
122
|
+
letterSpacing: number;
|
|
123
|
+
/** percent, 0 = default */
|
|
124
|
+
wordSpacing: number;
|
|
125
|
+
/** percent change of the side margins, 0 = default */
|
|
126
|
+
margins: number;
|
|
127
|
+
columns: ColumnsSetting;
|
|
128
|
+
justify: boolean;
|
|
129
|
+
pageTurn: PageTurn;
|
|
130
|
+
/** paper sound when a page is turned (Curl) */
|
|
131
|
+
pageSound: boolean;
|
|
132
|
+
/** 0.3 .. 1, dims the page like the Books brightness slider */
|
|
133
|
+
brightness: number;
|
|
134
|
+
/** 'auto' follows the book's page-progression-direction / language */
|
|
135
|
+
direction: 'auto' | 'ltr' | 'rtl';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type EpubSource = { uri: string } | { base64: string };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
import { isDark, serifFont, THEMES } from '../themes';
|
|
4
|
+
import type { ChromeColors } from '../themes';
|
|
5
|
+
import type { Appearance, PageTurn, ReaderSettings } 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
|
+
settings: ReaderSettings;
|
|
15
|
+
systemDark: boolean;
|
|
16
|
+
onChange: (patch: Partial<ReaderSettings>) => void;
|
|
17
|
+
onCustomize: () => void;
|
|
18
|
+
onClose: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const SIZE = { min: 12, max: 40, step: 2 };
|
|
22
|
+
|
|
23
|
+
const TURNS: { id: PageTurn; label: string; icon: IconName }[] = [
|
|
24
|
+
{ id: 'slide', label: 'Slide', icon: 'pageSlide' },
|
|
25
|
+
{ id: 'curl', label: 'Curl', icon: 'pageCurl' },
|
|
26
|
+
{ id: 'fade', label: 'Fast Fade', icon: 'pageFade' },
|
|
27
|
+
{ id: 'scroll', label: 'Scroll', icon: 'pageScroll' }
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const APPEARANCES: { id: Appearance; label: string; icon: IconName; key: string }[] = [
|
|
31
|
+
{ id: 'light', label: 'Light', icon: 'sun', key: 'light' },
|
|
32
|
+
{ id: 'dark', label: 'Dark', icon: 'moon', key: 'dark' },
|
|
33
|
+
{ id: 'device', label: 'Match Device', icon: 'contrast', key: 'device' },
|
|
34
|
+
{ id: 'device', label: 'Match Surroundings', icon: 'sunLow', key: 'surroundings' }
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** Icon of the current appearance choice: the button always shows the state it is in. */
|
|
38
|
+
const appearanceIcon = (a: Appearance): IconName => (a === 'light' ? 'sun' : a === 'dark' ? 'moon' : 'contrast');
|
|
39
|
+
|
|
40
|
+
/** "Themes & Settings" sheet. */
|
|
41
|
+
export function AppearanceSheet({ visible, colors, settings: s, systemDark, onChange, onCustomize, onClose }: Props) {
|
|
42
|
+
const [menu, setMenu] = useState<null | 'turn' | 'appearance'>(null);
|
|
43
|
+
const dark = isDark(s, systemDark);
|
|
44
|
+
const turn = TURNS.find((t) => t.id === s.pageTurn) || TURNS[0];
|
|
45
|
+
|
|
46
|
+
const menuView = menu && (
|
|
47
|
+
<>
|
|
48
|
+
<Pressable style={StyleSheet.absoluteFill} onPress={() => setMenu(null)} />
|
|
49
|
+
<View style={[styles.menu, { backgroundColor: colors.dark ? '#4a4742' : '#ffffff', borderColor: colors.glassBorder }]}>
|
|
50
|
+
{menu === 'turn'
|
|
51
|
+
? TURNS.map((t) => (
|
|
52
|
+
<Pressable
|
|
53
|
+
key={t.id}
|
|
54
|
+
style={styles.menuItem}
|
|
55
|
+
onPress={() => {
|
|
56
|
+
onChange({ pageTurn: t.id });
|
|
57
|
+
setMenu(null);
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<View style={styles.check}>{s.pageTurn === t.id && <Icon name="check" color={colors.text} size={20} />}</View>
|
|
61
|
+
<Icon name={t.icon} color={colors.text} size={24} />
|
|
62
|
+
<Text style={[styles.menuText, { color: colors.text }]}>{t.label}</Text>
|
|
63
|
+
</Pressable>
|
|
64
|
+
))
|
|
65
|
+
: APPEARANCES.map((a) => (
|
|
66
|
+
<Pressable
|
|
67
|
+
key={a.key}
|
|
68
|
+
style={styles.menuItem}
|
|
69
|
+
onPress={() => {
|
|
70
|
+
onChange({ appearance: a.id });
|
|
71
|
+
setMenu(null);
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
<View style={styles.check}>{s.appearance === a.id && a.key !== 'surroundings' && <Icon name="check" color={colors.text} size={20} />}</View>
|
|
75
|
+
<Icon name={a.icon} color={colors.text} size={24} />
|
|
76
|
+
<Text style={[styles.menuText, { color: colors.text }]}>{a.label}</Text>
|
|
77
|
+
</Pressable>
|
|
78
|
+
))}
|
|
79
|
+
</View>
|
|
80
|
+
</>
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<Sheet visible={visible} colors={colors} onClose={onClose} title="Themes & Settings" height="auto" overlay={menuView || undefined}>
|
|
85
|
+
<View style={styles.body}>
|
|
86
|
+
<View style={styles.row}>
|
|
87
|
+
<View style={[styles.pill, { flex: 1, backgroundColor: colors.card }]}>
|
|
88
|
+
<Pressable
|
|
89
|
+
style={styles.half}
|
|
90
|
+
onPress={() => onChange({ fontSize: Math.max(SIZE.min, s.fontSize - SIZE.step) })}
|
|
91
|
+
accessibilityLabel="Smaller text"
|
|
92
|
+
>
|
|
93
|
+
<Text style={{ color: colors.text, fontSize: 15, fontWeight: '500' }}>A</Text>
|
|
94
|
+
</Pressable>
|
|
95
|
+
<View style={[styles.divider, { backgroundColor: colors.separator }]} />
|
|
96
|
+
<Pressable
|
|
97
|
+
style={styles.half}
|
|
98
|
+
onPress={() => onChange({ fontSize: Math.min(SIZE.max, s.fontSize + SIZE.step) })}
|
|
99
|
+
accessibilityLabel="Larger text"
|
|
100
|
+
>
|
|
101
|
+
<Text style={{ color: colors.text, fontSize: 26, fontWeight: '500' }}>A</Text>
|
|
102
|
+
</Pressable>
|
|
103
|
+
</View>
|
|
104
|
+
<View style={[styles.pill, { width: 150, backgroundColor: colors.card }]}>
|
|
105
|
+
<Pressable style={[styles.half, menu === 'turn' && { backgroundColor: colors.separator }]} onPress={() => setMenu(menu === 'turn' ? null : 'turn')} accessibilityLabel={`Page turn style: ${turn.label}`}>
|
|
106
|
+
<Icon name={turn.icon} color={colors.text} size={26} />
|
|
107
|
+
</Pressable>
|
|
108
|
+
<Pressable style={[styles.half, menu === 'appearance' && { backgroundColor: colors.separator }]} onPress={() => setMenu(menu === 'appearance' ? null : 'appearance')} accessibilityLabel="Appearance">
|
|
109
|
+
<Icon name={appearanceIcon(s.appearance)} color={colors.text} size={26} />
|
|
110
|
+
</Pressable>
|
|
111
|
+
</View>
|
|
112
|
+
</View>
|
|
113
|
+
|
|
114
|
+
<View style={styles.brightness}>
|
|
115
|
+
<Icon name="sunLow" color={colors.subtext} size={20} />
|
|
116
|
+
<View style={{ flex: 1, marginHorizontal: 10 }}>
|
|
117
|
+
<Slider value={s.brightness} min={0.3} max={1} step={0.01} onChange={(v) => onChange({ brightness: v })} color={colors.text} track={colors.separator} thumbColor="#ffffff" />
|
|
118
|
+
</View>
|
|
119
|
+
<Icon name="sun" color={colors.subtext} size={26} />
|
|
120
|
+
</View>
|
|
121
|
+
|
|
122
|
+
<View style={styles.grid}>
|
|
123
|
+
{THEMES.map((t) => {
|
|
124
|
+
const c = dark ? t.dark : t.light;
|
|
125
|
+
const on = t.id === s.themeId;
|
|
126
|
+
return (
|
|
127
|
+
<Pressable
|
|
128
|
+
key={t.id}
|
|
129
|
+
onPress={() => onChange({ themeId: t.id })}
|
|
130
|
+
style={[styles.tile, { backgroundColor: c.bg, borderColor: on ? colors.text : colors.separator, borderWidth: on ? 3 : StyleSheet.hairlineWidth }]}
|
|
131
|
+
accessibilityRole="radio"
|
|
132
|
+
accessibilityState={{ selected: on }}
|
|
133
|
+
>
|
|
134
|
+
<Text style={{ color: c.fg, fontSize: 30, fontFamily: serifFont, fontWeight: t.bold ? '800' : '400' }}>Aa</Text>
|
|
135
|
+
<Text style={{ color: c.fg, fontSize: 15, fontFamily: serifFont, fontWeight: t.bold ? '700' : '400' }}>{t.name}</Text>
|
|
136
|
+
</Pressable>
|
|
137
|
+
);
|
|
138
|
+
})}
|
|
139
|
+
</View>
|
|
140
|
+
|
|
141
|
+
<Pressable onPress={onCustomize} style={[styles.customize, { backgroundColor: colors.card }]} accessibilityRole="button">
|
|
142
|
+
<Icon name="sliders" color={colors.text} size={22} />
|
|
143
|
+
<Text style={{ color: colors.text, fontSize: 18, fontWeight: '600', marginStart: 10 }}>Customize</Text>
|
|
144
|
+
</Pressable>
|
|
145
|
+
</View>
|
|
146
|
+
</Sheet>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const styles = StyleSheet.create({
|
|
151
|
+
body: { paddingHorizontal: 16, paddingBottom: 28 },
|
|
152
|
+
row: { flexDirection: 'row', gap: 10 },
|
|
153
|
+
pill: { height: 58, borderRadius: 29, flexDirection: 'row', alignItems: 'center', overflow: 'hidden' },
|
|
154
|
+
half: { flex: 1, height: '100%', alignItems: 'center', justifyContent: 'center' },
|
|
155
|
+
divider: { width: StyleSheet.hairlineWidth, height: 24 },
|
|
156
|
+
brightness: { flexDirection: 'row', alignItems: 'center', marginTop: 14, marginBottom: 8, paddingHorizontal: 4 },
|
|
157
|
+
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginTop: 6 },
|
|
158
|
+
tile: { width: '31.6%', height: 92, borderRadius: 22, alignItems: 'center', justifyContent: 'center' },
|
|
159
|
+
customize: { marginTop: 12, height: 58, borderRadius: 29, flexDirection: 'row', alignItems: 'center', justifyContent: 'center' },
|
|
160
|
+
menu: {
|
|
161
|
+
position: 'absolute', top: 80, right: 16, width: 250, borderRadius: 24, paddingVertical: 8, borderWidth: StyleSheet.hairlineWidth,
|
|
162
|
+
shadowColor: '#000', shadowOpacity: 0.3, shadowRadius: 16, shadowOffset: { width: 0, height: 6 }, elevation: 12
|
|
163
|
+
},
|
|
164
|
+
menuItem: { flexDirection: 'row', alignItems: 'center', paddingVertical: 12, paddingHorizontal: 14 },
|
|
165
|
+
check: { width: 26, alignItems: 'center' },
|
|
166
|
+
menuText: { fontSize: 18, marginStart: 14, flexShrink: 1 }
|
|
167
|
+
});
|