@ankhorage/expo-runtime 3.1.0 → 3.2.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/CHANGELOG.md +19 -0
- package/README.md +2 -1
- package/dist/createExpoRuntimeRegistry.d.ts.map +1 -1
- package/dist/createExpoRuntimeRegistry.js +5 -0
- package/dist/createExpoRuntimeRegistry.js.map +1 -1
- package/dist/expoRuntimePlanningMetadata.d.ts +9 -1
- package/dist/expoRuntimePlanningMetadata.d.ts.map +1 -1
- package/dist/expoRuntimePlanningMetadata.js +8 -0
- package/dist/expoRuntimePlanningMetadata.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/reader/ExpoReaderSurfaceAdapter.d.ts +4 -0
- package/dist/reader/ExpoReaderSurfaceAdapter.d.ts.map +1 -0
- package/dist/reader/ExpoReaderSurfaceAdapter.js +102 -0
- package/dist/reader/ExpoReaderSurfaceAdapter.js.map +1 -0
- package/dist/reader/ReaderDocumentError.d.ts +6 -0
- package/dist/reader/ReaderDocumentError.d.ts.map +1 -0
- package/dist/reader/ReaderDocumentError.js +9 -0
- package/dist/reader/ReaderDocumentError.js.map +1 -0
- package/dist/reader/ReaderViewport.d.ts +4 -0
- package/dist/reader/ReaderViewport.d.ts.map +1 -0
- package/dist/reader/ReaderViewport.js +212 -0
- package/dist/reader/ReaderViewport.js.map +1 -0
- package/dist/reader/drivers/EpubReaderDriver.d.ts +20 -0
- package/dist/reader/drivers/EpubReaderDriver.d.ts.map +1 -0
- package/dist/reader/drivers/EpubReaderDriver.js +259 -0
- package/dist/reader/drivers/EpubReaderDriver.js.map +1 -0
- package/dist/reader/drivers/PdfReaderDriver.d.ts +18 -0
- package/dist/reader/drivers/PdfReaderDriver.d.ts.map +1 -0
- package/dist/reader/drivers/PdfReaderDriver.js +198 -0
- package/dist/reader/drivers/PdfReaderDriver.js.map +1 -0
- package/dist/reader/epub/EpubArchive.d.ts +11 -0
- package/dist/reader/epub/EpubArchive.d.ts.map +1 -0
- package/dist/reader/epub/EpubArchive.js +65 -0
- package/dist/reader/epub/EpubArchive.js.map +1 -0
- package/dist/reader/epub/EpubArchiveFetcher.d.ts +11 -0
- package/dist/reader/epub/EpubArchiveFetcher.d.ts.map +1 -0
- package/dist/reader/epub/EpubArchiveFetcher.js +44 -0
- package/dist/reader/epub/EpubArchiveFetcher.js.map +1 -0
- package/dist/reader/epub/mediaTypes.d.ts +3 -0
- package/dist/reader/epub/mediaTypes.d.ts.map +1 -0
- package/dist/reader/epub/mediaTypes.js +26 -0
- package/dist/reader/epub/mediaTypes.js.map +1 -0
- package/dist/reader/epub/parseEpubPackage.d.ts +22 -0
- package/dist/reader/epub/parseEpubPackage.d.ts.map +1 -0
- package/dist/reader/epub/parseEpubPackage.js +98 -0
- package/dist/reader/epub/parseEpubPackage.js.map +1 -0
- package/dist/reader/epub/path.d.ts +4 -0
- package/dist/reader/epub/path.d.ts.map +1 -0
- package/dist/reader/epub/path.js +28 -0
- package/dist/reader/epub/path.js.map +1 -0
- package/dist/reader/epub/sanitizeEpubDocument.d.ts +3 -0
- package/dist/reader/epub/sanitizeEpubDocument.d.ts.map +1 -0
- package/dist/reader/epub/sanitizeEpubDocument.js +90 -0
- package/dist/reader/epub/sanitizeEpubDocument.js.map +1 -0
- package/dist/reader/loadReaderSourceAsync.d.ts +2 -0
- package/dist/reader/loadReaderSourceAsync.d.ts.map +1 -0
- package/dist/reader/loadReaderSourceAsync.js +49 -0
- package/dist/reader/loadReaderSourceAsync.js.map +1 -0
- package/dist/reader/navigation/resolveReaderKeyboardIntent.d.ts +9 -0
- package/dist/reader/navigation/resolveReaderKeyboardIntent.d.ts.map +1 -0
- package/dist/reader/navigation/resolveReaderKeyboardIntent.js +19 -0
- package/dist/reader/navigation/resolveReaderKeyboardIntent.js.map +1 -0
- package/dist/reader/navigation/resolveReaderNavigationIntent.d.ts +11 -0
- package/dist/reader/navigation/resolveReaderNavigationIntent.d.ts.map +1 -0
- package/dist/reader/navigation/resolveReaderNavigationIntent.js +16 -0
- package/dist/reader/navigation/resolveReaderNavigationIntent.js.map +1 -0
- package/dist/reader/readerViewportPresentation.d.ts +4 -0
- package/dist/reader/readerViewportPresentation.d.ts.map +1 -0
- package/dist/reader/readerViewportPresentation.js +26 -0
- package/dist/reader/readerViewportPresentation.js.map +1 -0
- package/dist/reader/types.d.ts +57 -0
- package/dist/reader/types.d.ts.map +1 -0
- package/dist/reader/types.js +2 -0
- package/dist/reader/types.js.map +1 -0
- package/dist/reader/validateEpubArchiveEntriesAsync.d.ts +3 -0
- package/dist/reader/validateEpubArchiveEntriesAsync.d.ts.map +1 -0
- package/dist/reader/validateEpubArchiveEntriesAsync.js +48 -0
- package/dist/reader/validateEpubArchiveEntriesAsync.js.map +1 -0
- package/package.json +27 -5
- package/src/createExpoRuntimeRegistry.test.tsx +9 -1
- package/src/createExpoRuntimeRegistry.tsx +7 -1
- package/src/expoRuntimePlanningMetadata.ts +9 -1
- package/src/index.ts +1 -0
- package/src/reader/ExpoReaderSurfaceAdapter.tsx +170 -0
- package/src/reader/ReaderDocumentError.ts +11 -0
- package/src/reader/ReaderViewport.tsx +319 -0
- package/src/reader/drivers/EpubReaderDriver.ts +309 -0
- package/src/reader/drivers/PdfReaderDriver.ts +253 -0
- package/src/reader/epub/EpubArchive.ts +83 -0
- package/src/reader/epub/EpubArchiveFetcher.ts +58 -0
- package/src/reader/epub/mediaTypes.ts +16 -0
- package/src/reader/epub/parseEpubPackage.test.ts +116 -0
- package/src/reader/epub/parseEpubPackage.ts +152 -0
- package/src/reader/epub/path.test.ts +22 -0
- package/src/reader/epub/path.ts +26 -0
- package/src/reader/epub/sanitizeEpubDocument.ts +121 -0
- package/src/reader/loadReaderSourceAsync.test.ts +52 -0
- package/src/reader/loadReaderSourceAsync.ts +59 -0
- package/src/reader/navigation/resolveReaderKeyboardIntent.test.ts +38 -0
- package/src/reader/navigation/resolveReaderKeyboardIntent.ts +20 -0
- package/src/reader/navigation/resolveReaderNavigationIntent.test.ts +48 -0
- package/src/reader/navigation/resolveReaderNavigationIntent.ts +23 -0
- package/src/reader/readerViewportPresentation.ts +28 -0
- package/src/reader/rendererPackages.test.ts +24 -0
- package/src/reader/types.ts +61 -0
- package/src/reader/validateEpubArchiveEntriesAsync.test.ts +142 -0
- package/src/reader/validateEpubArchiveEntriesAsync.ts +73 -0
- package/src/resolveExpoRuntimePlan.test.ts +36 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
'use dom';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { EpubReaderDriver } from './drivers/EpubReaderDriver';
|
|
6
|
+
import { PdfReaderDriver } from './drivers/PdfReaderDriver';
|
|
7
|
+
import { loadReaderSourceAsync } from './loadReaderSourceAsync';
|
|
8
|
+
import { resolveReaderKeyboardIntent } from './navigation/resolveReaderKeyboardIntent';
|
|
9
|
+
import { resolveReaderNavigationIntent } from './navigation/resolveReaderNavigationIntent';
|
|
10
|
+
import { ReaderDocumentError } from './ReaderDocumentError';
|
|
11
|
+
import { LOADING_STATE, READER_STYLES } from './readerViewportPresentation';
|
|
12
|
+
import type {
|
|
13
|
+
ReaderDriver,
|
|
14
|
+
ReaderDriverState,
|
|
15
|
+
ReaderTouchStart,
|
|
16
|
+
ReaderViewportProps,
|
|
17
|
+
ReaderViewportState,
|
|
18
|
+
} from './types';
|
|
19
|
+
|
|
20
|
+
type ReaderTrigger = NonNullable<ReaderViewportState['location']>['trigger'];
|
|
21
|
+
type EmitReaderState = (state: ReaderDriverState, trigger: ReaderTrigger) => void;
|
|
22
|
+
|
|
23
|
+
export default function ReaderViewport(props: ReaderViewportProps) {
|
|
24
|
+
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
25
|
+
const runtime = useReaderLifecycle(props, containerRef);
|
|
26
|
+
useReaderCommand(props.command, props.sourceUri, runtime.driverRef, runtime.emitState);
|
|
27
|
+
const input = useReaderInput(runtime.driverRef, runtime.driverStateRef, runtime.emitState);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
aria-label="Reader viewport"
|
|
32
|
+
className={`reader-root reader-${props.appearance.colorScheme}`}
|
|
33
|
+
onKeyDown={input.handleKeyDown}
|
|
34
|
+
onTouchEnd={input.handleTouchEnd}
|
|
35
|
+
onTouchStart={input.handleTouchStart}
|
|
36
|
+
role="document"
|
|
37
|
+
tabIndex={0}
|
|
38
|
+
>
|
|
39
|
+
<style>{READER_STYLES}</style>
|
|
40
|
+
<div className="reader-content" ref={containerRef} />
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function useReaderLifecycle(
|
|
46
|
+
props: ReaderViewportProps,
|
|
47
|
+
containerRef: React.RefObject<HTMLDivElement | null>,
|
|
48
|
+
) {
|
|
49
|
+
const driverRef = React.useRef<ReaderDriver | undefined>(undefined);
|
|
50
|
+
const driverStateRef = React.useRef<ReaderDriverState | undefined>(undefined);
|
|
51
|
+
const emitState = useEmitReaderState(props, driverStateRef);
|
|
52
|
+
useReaderDriverEffect(props, containerRef, driverRef, driverStateRef, emitState);
|
|
53
|
+
return { driverRef, driverStateRef, emitState };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function useEmitReaderState(
|
|
57
|
+
props: ReaderViewportProps,
|
|
58
|
+
stateRef: React.RefObject<ReaderDriverState | undefined>,
|
|
59
|
+
): EmitReaderState {
|
|
60
|
+
const { format, onStateChange } = props;
|
|
61
|
+
return React.useCallback<EmitReaderState>(
|
|
62
|
+
(state, trigger) => {
|
|
63
|
+
stateRef.current = state;
|
|
64
|
+
void onStateChange(toViewportState(state, format, trigger));
|
|
65
|
+
},
|
|
66
|
+
[format, onStateChange, stateRef],
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function useReaderDriverEffect(
|
|
71
|
+
props: ReaderViewportProps,
|
|
72
|
+
containerRef: React.RefObject<HTMLDivElement | null>,
|
|
73
|
+
driverRef: React.RefObject<ReaderDriver | undefined>,
|
|
74
|
+
driverStateRef: React.RefObject<ReaderDriverState | undefined>,
|
|
75
|
+
emitState: EmitReaderState,
|
|
76
|
+
): void {
|
|
77
|
+
const { appearance, format, onError, onOpenExternalLink, onStateChange, sourceUri } = props;
|
|
78
|
+
const { colorScheme, fontScale, lineHeight } = appearance;
|
|
79
|
+
const [initialLocation] = React.useState(props.initialLocation);
|
|
80
|
+
|
|
81
|
+
React.useEffect(
|
|
82
|
+
() =>
|
|
83
|
+
mountReaderDriver({
|
|
84
|
+
container: containerRef.current,
|
|
85
|
+
driverRef,
|
|
86
|
+
driverStateRef,
|
|
87
|
+
emitState,
|
|
88
|
+
onError,
|
|
89
|
+
onStateChange,
|
|
90
|
+
reader: {
|
|
91
|
+
appearance: { colorScheme, fontScale, lineHeight },
|
|
92
|
+
format,
|
|
93
|
+
initialLocation,
|
|
94
|
+
onError,
|
|
95
|
+
onOpenExternalLink,
|
|
96
|
+
sourceUri,
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
[
|
|
100
|
+
colorScheme,
|
|
101
|
+
containerRef,
|
|
102
|
+
driverRef,
|
|
103
|
+
driverStateRef,
|
|
104
|
+
emitState,
|
|
105
|
+
format,
|
|
106
|
+
fontScale,
|
|
107
|
+
initialLocation,
|
|
108
|
+
lineHeight,
|
|
109
|
+
onError,
|
|
110
|
+
onOpenExternalLink,
|
|
111
|
+
onStateChange,
|
|
112
|
+
sourceUri,
|
|
113
|
+
],
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function mountReaderDriver(args: MountReaderDriverArgs): () => void {
|
|
118
|
+
const controller = new AbortController();
|
|
119
|
+
const holder: { driver?: ReaderDriver } = {};
|
|
120
|
+
args.container?.replaceChildren();
|
|
121
|
+
void args.onStateChange(LOADING_STATE);
|
|
122
|
+
if (args.container) {
|
|
123
|
+
void initializeReaderDriver(args.reader, args.container, controller.signal, args.emitState)
|
|
124
|
+
.then(async (driver) => {
|
|
125
|
+
if (controller.signal.aborted) return driver.destroy();
|
|
126
|
+
holder.driver = driver;
|
|
127
|
+
args.driverRef.current = driver;
|
|
128
|
+
args.emitState(driver.getState(), 'location');
|
|
129
|
+
})
|
|
130
|
+
.catch((error: unknown) => {
|
|
131
|
+
if (!controller.signal.aborted)
|
|
132
|
+
void args.onError(normalizeReaderError(error, args.reader.format));
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return () => {
|
|
136
|
+
controller.abort();
|
|
137
|
+
args.driverRef.current = undefined;
|
|
138
|
+
args.driverStateRef.current = undefined;
|
|
139
|
+
if (holder.driver) void holder.driver.destroy();
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function initializeReaderDriver(
|
|
144
|
+
props: Pick<
|
|
145
|
+
ReaderViewportProps,
|
|
146
|
+
'appearance' | 'format' | 'initialLocation' | 'onError' | 'onOpenExternalLink' | 'sourceUri'
|
|
147
|
+
>,
|
|
148
|
+
container: HTMLElement,
|
|
149
|
+
signal: AbortSignal,
|
|
150
|
+
emitState: EmitReaderState,
|
|
151
|
+
): Promise<ReaderDriver> {
|
|
152
|
+
const bytes = await loadReaderSourceAsync(props.sourceUri, signal);
|
|
153
|
+
if (props.format === 'pdf') {
|
|
154
|
+
return PdfReaderDriver.create({
|
|
155
|
+
bytes,
|
|
156
|
+
container,
|
|
157
|
+
initialLocation: props.initialLocation,
|
|
158
|
+
onExternalLink: (url) => void props.onOpenExternalLink({ url }),
|
|
159
|
+
onRenderError: (error) => void props.onError(normalizeReaderError(error, props.format)),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return EpubReaderDriver.create({
|
|
163
|
+
appearance: props.appearance,
|
|
164
|
+
bytes,
|
|
165
|
+
container,
|
|
166
|
+
initialLocation: props.initialLocation,
|
|
167
|
+
onExternalLink: (url) => void props.onOpenExternalLink({ url }),
|
|
168
|
+
onStateChange: (state) => emitState(state, 'location'),
|
|
169
|
+
signal,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
interface MountReaderDriverArgs {
|
|
174
|
+
readonly container: HTMLElement | null;
|
|
175
|
+
readonly driverRef: React.RefObject<ReaderDriver | undefined>;
|
|
176
|
+
readonly driverStateRef: React.RefObject<ReaderDriverState | undefined>;
|
|
177
|
+
readonly emitState: EmitReaderState;
|
|
178
|
+
readonly onError: ReaderViewportProps['onError'];
|
|
179
|
+
readonly onStateChange: ReaderViewportProps['onStateChange'];
|
|
180
|
+
readonly reader: Pick<
|
|
181
|
+
ReaderViewportProps,
|
|
182
|
+
'appearance' | 'format' | 'initialLocation' | 'onError' | 'onOpenExternalLink' | 'sourceUri'
|
|
183
|
+
>;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function useReaderCommand(
|
|
187
|
+
command: ReaderViewportProps['command'],
|
|
188
|
+
sourceUri: string,
|
|
189
|
+
driverRef: React.RefObject<ReaderDriver | undefined>,
|
|
190
|
+
emitState: EmitReaderState,
|
|
191
|
+
): void {
|
|
192
|
+
const lastCommandIdRef = React.useRef(0);
|
|
193
|
+
React.useEffect(() => {
|
|
194
|
+
if (command?.sourceUri !== sourceUri || command.id <= lastCommandIdRef.current) return;
|
|
195
|
+
lastCommandIdRef.current = command.id;
|
|
196
|
+
const trigger = command.type === 'next' ? 'nextControl' : 'previousControl';
|
|
197
|
+
void navigate(driverRef.current, command.type, trigger, emitState);
|
|
198
|
+
}, [command, driverRef, emitState, sourceUri]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function useReaderInput(
|
|
202
|
+
driverRef: React.RefObject<ReaderDriver | undefined>,
|
|
203
|
+
stateRef: React.RefObject<ReaderDriverState | undefined>,
|
|
204
|
+
emitState: EmitReaderState,
|
|
205
|
+
) {
|
|
206
|
+
const touchStartRef = React.useRef<ReaderTouchStart | undefined>(undefined);
|
|
207
|
+
const handleTouchStart = React.useCallback((event: React.TouchEvent<HTMLDivElement>) => {
|
|
208
|
+
const touch = event.changedTouches[0];
|
|
209
|
+
if (touch) {
|
|
210
|
+
touchStartRef.current = {
|
|
211
|
+
interactive: isInteractiveTarget(event.target),
|
|
212
|
+
x: touch.clientX,
|
|
213
|
+
y: touch.clientY,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}, []);
|
|
217
|
+
const handleTouchEnd = React.useCallback(
|
|
218
|
+
(event: React.TouchEvent<HTMLDivElement>) => {
|
|
219
|
+
handleSwipe(event, touchStartRef.current, driverRef.current, stateRef.current, emitState);
|
|
220
|
+
touchStartRef.current = undefined;
|
|
221
|
+
},
|
|
222
|
+
[driverRef, emitState, stateRef],
|
|
223
|
+
);
|
|
224
|
+
const handleKeyDown = React.useCallback(
|
|
225
|
+
(event: React.KeyboardEvent<HTMLDivElement>) =>
|
|
226
|
+
handleKeyboard(event, driverRef.current, stateRef.current, emitState),
|
|
227
|
+
[driverRef, emitState, stateRef],
|
|
228
|
+
);
|
|
229
|
+
return { handleKeyDown, handleTouchEnd, handleTouchStart };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function handleSwipe(
|
|
233
|
+
event: React.TouchEvent<HTMLDivElement>,
|
|
234
|
+
start: ReaderTouchStart | undefined,
|
|
235
|
+
driver: ReaderDriver | undefined,
|
|
236
|
+
state: ReaderDriverState | undefined,
|
|
237
|
+
emitState: EmitReaderState,
|
|
238
|
+
): void {
|
|
239
|
+
const touch = event.changedTouches[0];
|
|
240
|
+
if (!start || !touch || !driver || !state) return;
|
|
241
|
+
const intent = resolveReaderNavigationIntent({
|
|
242
|
+
canGoNext: state.canGoNext,
|
|
243
|
+
canGoPrevious: state.canGoPrevious,
|
|
244
|
+
deltaX: touch.clientX - start.x,
|
|
245
|
+
deltaY: touch.clientY - start.y,
|
|
246
|
+
direction: driver.direction,
|
|
247
|
+
hasSelection: !window.getSelection()?.isCollapsed,
|
|
248
|
+
isInteractiveTarget: start.interactive,
|
|
249
|
+
viewportWidth: event.currentTarget.clientWidth,
|
|
250
|
+
});
|
|
251
|
+
if (intent) void navigate(driver, intent, 'swipe', emitState);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function handleKeyboard(
|
|
255
|
+
event: React.KeyboardEvent<HTMLDivElement>,
|
|
256
|
+
driver: ReaderDriver | undefined,
|
|
257
|
+
state: ReaderDriverState | undefined,
|
|
258
|
+
emitState: EmitReaderState,
|
|
259
|
+
): void {
|
|
260
|
+
if (!driver || !state) return;
|
|
261
|
+
const intent = resolveReaderKeyboardIntent({
|
|
262
|
+
canGoNext: state.canGoNext,
|
|
263
|
+
canGoPrevious: state.canGoPrevious,
|
|
264
|
+
direction: driver.direction,
|
|
265
|
+
hasModifier: event.altKey || event.ctrlKey || event.metaKey || event.shiftKey,
|
|
266
|
+
isInteractiveTarget: isInteractiveTarget(event.target),
|
|
267
|
+
key: event.key,
|
|
268
|
+
});
|
|
269
|
+
if (!intent) return;
|
|
270
|
+
event.preventDefault();
|
|
271
|
+
void navigate(driver, intent, 'keyboard', emitState);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function navigate(
|
|
275
|
+
driver: ReaderDriver | undefined,
|
|
276
|
+
intent: 'next' | 'previous',
|
|
277
|
+
trigger: ReaderTrigger,
|
|
278
|
+
emitState: EmitReaderState,
|
|
279
|
+
): Promise<void> {
|
|
280
|
+
if (!driver) return;
|
|
281
|
+
if (intent === 'next') await driver.goNext(trigger);
|
|
282
|
+
else await driver.goPrevious(trigger);
|
|
283
|
+
emitState(driver.getState(), trigger);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function toViewportState(
|
|
287
|
+
state: ReaderDriverState,
|
|
288
|
+
format: ReaderViewportProps['format'],
|
|
289
|
+
trigger: ReaderTrigger,
|
|
290
|
+
): ReaderViewportState {
|
|
291
|
+
return {
|
|
292
|
+
canGoNext: state.canGoNext,
|
|
293
|
+
canGoPrevious: state.canGoPrevious,
|
|
294
|
+
location: { format, trigger, ...state },
|
|
295
|
+
page: state.page,
|
|
296
|
+
pageCount: state.pageCount,
|
|
297
|
+
progress: state.progression,
|
|
298
|
+
status: 'ready',
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function isInteractiveTarget(target: EventTarget): boolean {
|
|
303
|
+
return (
|
|
304
|
+
target instanceof Element &&
|
|
305
|
+
target.closest(
|
|
306
|
+
'a, button, input, select, textarea, [contenteditable="true"], [role="button"]',
|
|
307
|
+
) !== null
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function normalizeReaderError(error: unknown, format: ReaderViewportProps['format']) {
|
|
312
|
+
if (error instanceof ReaderDocumentError)
|
|
313
|
+
return { code: error.code, format, message: error.message };
|
|
314
|
+
return {
|
|
315
|
+
code: 'load-failed' as const,
|
|
316
|
+
format,
|
|
317
|
+
message: error instanceof Error ? error.message : 'The document could not be loaded.',
|
|
318
|
+
};
|
|
319
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/* eslint-disable max-lines-per-function, complexity -- EPUB assembly keeps archive cleanup transactional. */
|
|
2
|
+
import { EpubNavigator, type EpubNavigatorListeners } from '@readium/navigator';
|
|
3
|
+
import { Locator, Manifest, Publication } from '@readium/shared';
|
|
4
|
+
|
|
5
|
+
import { EpubArchive } from '../epub/EpubArchive';
|
|
6
|
+
import { EpubArchiveFetcher } from '../epub/EpubArchiveFetcher';
|
|
7
|
+
import { HTML_MEDIA_TYPES, inferEpubMediaType } from '../epub/mediaTypes';
|
|
8
|
+
import { type ParsedEpubPackage, parseEpubPackage } from '../epub/parseEpubPackage';
|
|
9
|
+
import { sanitizeEpubDocument, sanitizeEpubStylesheet } from '../epub/sanitizeEpubDocument';
|
|
10
|
+
import type { ReaderAppearance, ReaderDriver, ReaderDriverState } from '../types';
|
|
11
|
+
|
|
12
|
+
export class EpubReaderDriver implements ReaderDriver {
|
|
13
|
+
readonly direction: 'ltr' | 'rtl';
|
|
14
|
+
readonly #archive: EpubArchive;
|
|
15
|
+
readonly #navigator: EpubNavigator;
|
|
16
|
+
readonly #objectUrls: string[];
|
|
17
|
+
readonly #onExternalLink: (url: string) => void;
|
|
18
|
+
readonly #onStateChange: (state: ReaderDriverState) => void;
|
|
19
|
+
readonly #publication: Publication;
|
|
20
|
+
readonly #fixedLayout: boolean;
|
|
21
|
+
#page = 1;
|
|
22
|
+
#suppressStateNotifications = false;
|
|
23
|
+
|
|
24
|
+
private constructor(args: {
|
|
25
|
+
archive: EpubArchive;
|
|
26
|
+
direction: 'ltr' | 'rtl';
|
|
27
|
+
fixedLayout: boolean;
|
|
28
|
+
navigator: EpubNavigator;
|
|
29
|
+
objectUrls: string[];
|
|
30
|
+
onExternalLink: (url: string) => void;
|
|
31
|
+
onStateChange: (state: ReaderDriverState) => void;
|
|
32
|
+
publication: Publication;
|
|
33
|
+
}) {
|
|
34
|
+
this.#archive = args.archive;
|
|
35
|
+
this.direction = args.direction;
|
|
36
|
+
this.#fixedLayout = args.fixedLayout;
|
|
37
|
+
this.#navigator = args.navigator;
|
|
38
|
+
this.#objectUrls = args.objectUrls;
|
|
39
|
+
this.#onExternalLink = args.onExternalLink;
|
|
40
|
+
this.#onStateChange = args.onStateChange;
|
|
41
|
+
this.#publication = args.publication;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static async create(args: {
|
|
45
|
+
appearance: ReaderAppearance;
|
|
46
|
+
bytes: Uint8Array;
|
|
47
|
+
container: HTMLElement;
|
|
48
|
+
initialLocation?: string;
|
|
49
|
+
onExternalLink: (url: string) => void;
|
|
50
|
+
onStateChange: (state: ReaderDriverState) => void;
|
|
51
|
+
signal: AbortSignal;
|
|
52
|
+
}): Promise<EpubReaderDriver> {
|
|
53
|
+
const archive = await EpubArchive.open(args.bytes, args.signal);
|
|
54
|
+
const objectUrls: string[] = [];
|
|
55
|
+
try {
|
|
56
|
+
const packageData = await parseEpubPackage(archive, args.signal);
|
|
57
|
+
const { publication, objectUrls: createdUrls } = await createPublication(
|
|
58
|
+
archive,
|
|
59
|
+
packageData,
|
|
60
|
+
args.signal,
|
|
61
|
+
);
|
|
62
|
+
objectUrls.push(...createdUrls);
|
|
63
|
+
const holder: { driver?: EpubReaderDriver } = {};
|
|
64
|
+
const listeners: EpubNavigatorListeners = {
|
|
65
|
+
frameLoaded: (window) =>
|
|
66
|
+
attachFrameLinkPolicy(window, (url) => {
|
|
67
|
+
if (holder.driver !== undefined) holder.driver.#onExternalLink(url);
|
|
68
|
+
}),
|
|
69
|
+
positionChanged: () => {
|
|
70
|
+
if (holder.driver !== undefined) holder.driver.#notifyState();
|
|
71
|
+
},
|
|
72
|
+
timelineItemChanged: () => {
|
|
73
|
+
if (holder.driver !== undefined) holder.driver.#notifyState();
|
|
74
|
+
},
|
|
75
|
+
tap: () => false,
|
|
76
|
+
click: () => false,
|
|
77
|
+
zoom: noop,
|
|
78
|
+
miscPointer: noop,
|
|
79
|
+
scroll: noop,
|
|
80
|
+
customEvent: noop,
|
|
81
|
+
handleLocator: () => false,
|
|
82
|
+
textSelected: noop,
|
|
83
|
+
contentProtection: noop,
|
|
84
|
+
contextMenu: noop,
|
|
85
|
+
peripheral: noop,
|
|
86
|
+
};
|
|
87
|
+
const initialPosition = parseInitialLocator(args.initialLocation);
|
|
88
|
+
const navigator = new EpubNavigator(
|
|
89
|
+
args.container,
|
|
90
|
+
publication,
|
|
91
|
+
listeners,
|
|
92
|
+
[],
|
|
93
|
+
initialPosition,
|
|
94
|
+
{
|
|
95
|
+
defaults: {},
|
|
96
|
+
preferences: resolvePreferences(args.appearance),
|
|
97
|
+
injectables: { allowedDomains: [], rules: [] },
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
const driver = new EpubReaderDriver({
|
|
101
|
+
archive,
|
|
102
|
+
direction: packageData.direction,
|
|
103
|
+
fixedLayout: packageData.fixedLayout,
|
|
104
|
+
navigator,
|
|
105
|
+
objectUrls,
|
|
106
|
+
onExternalLink: args.onExternalLink,
|
|
107
|
+
onStateChange: args.onStateChange,
|
|
108
|
+
publication,
|
|
109
|
+
});
|
|
110
|
+
holder.driver = driver;
|
|
111
|
+
await navigator.load();
|
|
112
|
+
driver.#notifyState();
|
|
113
|
+
return driver;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
|
116
|
+
await archive.close();
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
getState(): ReaderDriverState {
|
|
122
|
+
const locator = this.#navigator.currentLocator;
|
|
123
|
+
const spineIndex = Math.max(
|
|
124
|
+
0,
|
|
125
|
+
this.#publication.readingOrder.items.findIndex((link) => link.href === locator.href),
|
|
126
|
+
);
|
|
127
|
+
const progressionInResource = locator.locations.progression ?? 0;
|
|
128
|
+
const totalProgression =
|
|
129
|
+
locator.locations.totalProgression ??
|
|
130
|
+
(spineIndex + progressionInResource) / this.#publication.readingOrder.items.length;
|
|
131
|
+
if (this.#fixedLayout) this.#page = spineIndex + 1;
|
|
132
|
+
return {
|
|
133
|
+
canGoNext: this.#navigator.canGoForward,
|
|
134
|
+
canGoPrevious: this.#navigator.canGoBackward,
|
|
135
|
+
chapterId: locator.href,
|
|
136
|
+
chapterTitle: locator.title,
|
|
137
|
+
locator: JSON.stringify(locator.serialize()),
|
|
138
|
+
page: this.#page,
|
|
139
|
+
pageCount: this.#fixedLayout ? this.#publication.readingOrder.items.length : undefined,
|
|
140
|
+
progression: Math.max(0, Math.min(1, totalProgression)),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async goNext(_trigger: Parameters<ReaderDriver['goNext']>[0]): Promise<void> {
|
|
145
|
+
if (!this.#navigator.canGoForward) return;
|
|
146
|
+
this.#suppressStateNotifications = true;
|
|
147
|
+
try {
|
|
148
|
+
await navigate((done) => this.#navigator.goForward(shouldAnimateNavigation(), done));
|
|
149
|
+
if (!this.#fixedLayout) this.#page += 1;
|
|
150
|
+
} finally {
|
|
151
|
+
this.#suppressStateNotifications = false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async goPrevious(_trigger: Parameters<ReaderDriver['goPrevious']>[0]): Promise<void> {
|
|
156
|
+
if (!this.#navigator.canGoBackward) return;
|
|
157
|
+
this.#suppressStateNotifications = true;
|
|
158
|
+
try {
|
|
159
|
+
await navigate((done) => this.#navigator.goBackward(shouldAnimateNavigation(), done));
|
|
160
|
+
if (!this.#fixedLayout) this.#page = Math.max(1, this.#page - 1);
|
|
161
|
+
} finally {
|
|
162
|
+
this.#suppressStateNotifications = false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async destroy(): Promise<void> {
|
|
167
|
+
await this.#navigator.destroy();
|
|
168
|
+
await this.#archive.close();
|
|
169
|
+
this.#objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#notifyState(): void {
|
|
173
|
+
if (!this.#suppressStateNotifications) this.#onStateChange(this.getState());
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function createPublication(
|
|
178
|
+
archive: EpubArchive,
|
|
179
|
+
packageData: ParsedEpubPackage,
|
|
180
|
+
signal: AbortSignal,
|
|
181
|
+
): Promise<{ publication: Publication; objectUrls: string[] }> {
|
|
182
|
+
const objectUrls: string[] = [];
|
|
183
|
+
const resourceUrls = new Map<string, string>();
|
|
184
|
+
const itemByPath = new Map(
|
|
185
|
+
[...packageData.readingOrder, ...packageData.resources].map((item) => [item.href, item]),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
for (const path of archive.paths()) {
|
|
189
|
+
const mediaType = itemByPath.get(path)?.mediaType ?? inferEpubMediaType(path);
|
|
190
|
+
if (HTML_MEDIA_TYPES.has(mediaType) || mediaType === 'text/css') continue;
|
|
191
|
+
const resource = await archive.read(path, signal);
|
|
192
|
+
const resourceBuffer = resource.buffer.slice(
|
|
193
|
+
resource.byteOffset,
|
|
194
|
+
resource.byteOffset + resource.byteLength,
|
|
195
|
+
) as ArrayBuffer;
|
|
196
|
+
const url = URL.createObjectURL(new Blob([resourceBuffer], { type: mediaType }));
|
|
197
|
+
objectUrls.push(url);
|
|
198
|
+
resourceUrls.set(path, url);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const overrides = new Map<string, Uint8Array>();
|
|
202
|
+
for (const path of archive.paths()) {
|
|
203
|
+
const mediaType = itemByPath.get(path)?.mediaType ?? inferEpubMediaType(path);
|
|
204
|
+
if (mediaType !== 'text/css') continue;
|
|
205
|
+
const css = sanitizeEpubStylesheet(await archive.readText(path, signal), path, (resourcePath) =>
|
|
206
|
+
resourceUrls.get(resourcePath),
|
|
207
|
+
);
|
|
208
|
+
const url = URL.createObjectURL(new Blob([css], { type: mediaType }));
|
|
209
|
+
objectUrls.push(url);
|
|
210
|
+
resourceUrls.set(path, url);
|
|
211
|
+
overrides.set(path, new TextEncoder().encode(css));
|
|
212
|
+
}
|
|
213
|
+
for (const [path, item] of itemByPath) {
|
|
214
|
+
if (!HTML_MEDIA_TYPES.has(item.mediaType)) continue;
|
|
215
|
+
const source = sanitizeEpubDocument(
|
|
216
|
+
await archive.readText(path, signal),
|
|
217
|
+
path,
|
|
218
|
+
(resourcePath) => resourceUrls.get(resourcePath),
|
|
219
|
+
);
|
|
220
|
+
overrides.set(path, new TextEncoder().encode(source));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const manifest = Manifest.deserialize({
|
|
224
|
+
metadata: {
|
|
225
|
+
conformsTo: ['https://readium.org/webpub-manifest/profiles/epub'],
|
|
226
|
+
language: packageData.language,
|
|
227
|
+
layout: packageData.fixedLayout ? 'fixed' : 'reflowable',
|
|
228
|
+
readingProgression: packageData.direction,
|
|
229
|
+
title: packageData.title,
|
|
230
|
+
},
|
|
231
|
+
links: [
|
|
232
|
+
{
|
|
233
|
+
href: 'https://reader.invalid/manifest.json',
|
|
234
|
+
rel: 'self',
|
|
235
|
+
type: 'application/webpub+json',
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
readingOrder: packageData.readingOrder.map(toManifestLink),
|
|
239
|
+
resources: packageData.resources.map(toManifestLink),
|
|
240
|
+
toc: packageData.tableOfContents.map((item) => ({
|
|
241
|
+
href: item.href,
|
|
242
|
+
title: item.title,
|
|
243
|
+
type: 'application/xhtml+xml',
|
|
244
|
+
})),
|
|
245
|
+
});
|
|
246
|
+
if (manifest === undefined) throw new Error('Could not construct the EPUB publication manifest.');
|
|
247
|
+
const links = [...manifest.readingOrder.items, ...(manifest.resources?.items ?? [])];
|
|
248
|
+
return {
|
|
249
|
+
publication: new Publication({
|
|
250
|
+
fetcher: new EpubArchiveFetcher(archive, links, overrides),
|
|
251
|
+
manifest,
|
|
252
|
+
}),
|
|
253
|
+
objectUrls,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function toManifestLink(item: ParsedEpubPackage['readingOrder'][number]) {
|
|
258
|
+
return {
|
|
259
|
+
href: item.href,
|
|
260
|
+
type: item.mediaType,
|
|
261
|
+
properties:
|
|
262
|
+
item.properties.length === 0
|
|
263
|
+
? undefined
|
|
264
|
+
: Object.fromEntries(item.properties.map((key) => [key, true])),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function resolvePreferences(appearance: ReaderAppearance) {
|
|
269
|
+
const dark = appearance.colorScheme === 'dark';
|
|
270
|
+
const sepia = appearance.colorScheme === 'sepia';
|
|
271
|
+
return {
|
|
272
|
+
backgroundColor: dark ? '#111111' : sepia ? '#f4ecd8' : '#ffffff',
|
|
273
|
+
fontSize: appearance.fontScale,
|
|
274
|
+
lineHeight: appearance.lineHeight,
|
|
275
|
+
textColor: dark ? '#f5f5f5' : sepia ? '#3f3527' : '#111111',
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function parseInitialLocator(value: string | undefined): Locator | undefined {
|
|
280
|
+
if (!value) return undefined;
|
|
281
|
+
try {
|
|
282
|
+
return Locator.deserialize(JSON.parse(value));
|
|
283
|
+
} catch {
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function attachFrameLinkPolicy(window: Window, onExternalLink: (url: string) => void): void {
|
|
289
|
+
window.document.addEventListener('click', (event) => {
|
|
290
|
+
const target = event.target instanceof Element ? event.target.closest('a') : null;
|
|
291
|
+
const externalUrl = target?.getAttribute('data-reader-external-href');
|
|
292
|
+
if (!externalUrl) return;
|
|
293
|
+
event.preventDefault();
|
|
294
|
+
event.stopPropagation();
|
|
295
|
+
onExternalLink(externalUrl);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function navigate(start: (done: (ok: boolean) => void) => void): Promise<void> {
|
|
300
|
+
return new Promise((resolve) => start(() => resolve()));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function shouldAnimateNavigation(): boolean {
|
|
304
|
+
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function noop(): void {
|
|
308
|
+
// Readium requires a complete listener object; these events are outside the reader contract.
|
|
309
|
+
}
|