@ankhorage/expo-runtime 3.1.1 → 3.2.2
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 +24 -0
- package/README.md +2 -1
- package/dist/barcodeScanner.d.ts +2 -0
- package/dist/barcodeScanner.d.ts.map +1 -0
- package/dist/barcodeScanner.js +2 -0
- package/dist/barcodeScanner.js.map +1 -0
- 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/generatedSources.d.ts +1 -0
- package/dist/generatedSources.d.ts.map +1 -1
- package/dist/generatedSources.js +9 -1
- package/dist/generatedSources.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/planning.d.ts +1 -1
- package/dist/planning.d.ts.map +1 -1
- package/dist/planning.js +1 -1
- package/dist/planning.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/index.d.ts +2 -0
- package/dist/reader/index.d.ts.map +1 -0
- package/dist/reader/index.js +2 -0
- package/dist/reader/index.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/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 +25 -4
- package/src/adapterEntrypoints.test.ts +48 -0
- package/src/barcodeScanner.ts +1 -0
- package/src/createExpoRuntimeRegistry.test.tsx +9 -1
- package/src/createExpoRuntimeRegistry.tsx +7 -1
- package/src/expoRuntimePlanningMetadata.ts +9 -1
- package/src/generatedSources.test.ts +11 -2
- package/src/generatedSources.ts +11 -1
- package/src/index.ts +5 -1
- package/src/planning.ts +4 -1
- 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/index.ts +1 -0
- package/src/reader/loadReaderSourceAsync.test.ts +52 -0
- package/src/reader/loadReaderSourceAsync.ts +59 -0
- package/src/reader/readerViewportPresentation.ts +28 -0
- package/src/reader/rendererPackages.test.ts +3 -1
- 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
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
getExpoBarcodeScannerViewSource,
|
|
5
|
+
getExpoReaderSurfaceViewSource,
|
|
6
|
+
} from './generatedSources';
|
|
4
7
|
|
|
5
8
|
describe('generated Expo runtime sources', () => {
|
|
6
9
|
it('emits the barcode scanner view adapter in canonical source format', () => {
|
|
7
10
|
expect(getExpoBarcodeScannerViewSource()).toBe(
|
|
8
|
-
"export { ExpoBarcodeScannerAdapter as ExpoBarcodeScannerView } from '@ankhorage/expo-runtime';\n",
|
|
11
|
+
"export { ExpoBarcodeScannerAdapter as ExpoBarcodeScannerView } from '@ankhorage/expo-runtime/barcode-scanner';\n",
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('emits the reader surface adapter through its capability-scoped entrypoint', () => {
|
|
16
|
+
expect(getExpoReaderSurfaceViewSource()).toBe(
|
|
17
|
+
"export { ExpoReaderSurfaceAdapter as ExpoReaderSurfaceView } from '@ankhorage/expo-runtime/reader';\n",
|
|
9
18
|
);
|
|
10
19
|
});
|
|
11
20
|
});
|
package/src/generatedSources.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
const EXPO_BARCODE_SCANNER_VIEW_SOURCE = [
|
|
2
2
|
'export { ExpoBarcodeScannerAdapter as ExpoBarcodeScannerView }',
|
|
3
|
-
" from '@ankhorage/expo-runtime';",
|
|
3
|
+
" from '@ankhorage/expo-runtime/barcode-scanner';",
|
|
4
|
+
'\n',
|
|
5
|
+
] as const;
|
|
6
|
+
|
|
7
|
+
const EXPO_READER_SURFACE_VIEW_SOURCE = [
|
|
8
|
+
'export { ExpoReaderSurfaceAdapter as ExpoReaderSurfaceView }',
|
|
9
|
+
" from '@ankhorage/expo-runtime/reader';",
|
|
4
10
|
'\n',
|
|
5
11
|
] as const;
|
|
6
12
|
|
|
7
13
|
export function getExpoBarcodeScannerViewSource(): string {
|
|
8
14
|
return EXPO_BARCODE_SCANNER_VIEW_SOURCE.join('');
|
|
9
15
|
}
|
|
16
|
+
|
|
17
|
+
export function getExpoReaderSurfaceViewSource(): string {
|
|
18
|
+
return EXPO_READER_SURFACE_VIEW_SOURCE.join('');
|
|
19
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -48,11 +48,15 @@ export {
|
|
|
48
48
|
resolveExpoRuntimeGeneratedAppOutput,
|
|
49
49
|
resolveExpoRuntimeNativeOutput,
|
|
50
50
|
} from './generatedAppOutput';
|
|
51
|
-
export {
|
|
51
|
+
export {
|
|
52
|
+
getExpoBarcodeScannerViewSource,
|
|
53
|
+
getExpoReaderSurfaceViewSource,
|
|
54
|
+
} from './generatedSources';
|
|
52
55
|
export {
|
|
53
56
|
type ExpoRuntimeLayoutIntegrationPlan,
|
|
54
57
|
resolveExpoRuntimeLayoutIntegration,
|
|
55
58
|
} from './layoutIntegrationPlanning';
|
|
59
|
+
export { ExpoReaderSurfaceAdapter } from './reader/ExpoReaderSurfaceAdapter';
|
|
56
60
|
export type {
|
|
57
61
|
ExpoRuntimeAdapterId,
|
|
58
62
|
ExpoRuntimePlan,
|
package/src/planning.ts
CHANGED
|
@@ -8,7 +8,10 @@ export {
|
|
|
8
8
|
resolveExpoRuntimeGeneratedAppOutput,
|
|
9
9
|
resolveExpoRuntimeNativeOutput,
|
|
10
10
|
} from './generatedAppOutput';
|
|
11
|
-
export {
|
|
11
|
+
export {
|
|
12
|
+
getExpoBarcodeScannerViewSource,
|
|
13
|
+
getExpoReaderSurfaceViewSource,
|
|
14
|
+
} from './generatedSources';
|
|
12
15
|
export {
|
|
13
16
|
type ExpoRuntimeLayoutIntegrationPlan,
|
|
14
17
|
resolveExpoRuntimeLayoutIntegration,
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { type ReaderErrorEvent, ReaderSurface, type ReaderSurfaceProps } from '@ankhorage/zora';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Image, StyleSheet, View } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import ReaderViewport from './ReaderViewport';
|
|
6
|
+
import type { ReaderAppearance, ReaderCommand, ReaderViewportState } from './types';
|
|
7
|
+
|
|
8
|
+
const INITIAL_STATE: ReaderViewportState = {
|
|
9
|
+
canGoNext: false,
|
|
10
|
+
canGoPrevious: false,
|
|
11
|
+
page: 1,
|
|
12
|
+
progress: 0,
|
|
13
|
+
status: 'loading',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function ExpoReaderSurfaceAdapter(props: ReaderSurfaceProps) {
|
|
17
|
+
const sourceUri = resolveReaderSourceUri(props.source);
|
|
18
|
+
const controller = useReaderController(props, sourceUri);
|
|
19
|
+
const appearance = useReaderAppearance(props);
|
|
20
|
+
const viewport = sourceUri ? (
|
|
21
|
+
<ReaderAdapterViewport
|
|
22
|
+
appearance={appearance}
|
|
23
|
+
command={controller.command}
|
|
24
|
+
format={props.format}
|
|
25
|
+
initialLocation={props.location}
|
|
26
|
+
onError={controller.handleError}
|
|
27
|
+
onOpenExternalLink={controller.handleOpenExternalLink}
|
|
28
|
+
onStateChange={controller.handleStateChange}
|
|
29
|
+
sourceUri={sourceUri}
|
|
30
|
+
/>
|
|
31
|
+
) : undefined;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<ReaderSurface
|
|
35
|
+
{...props}
|
|
36
|
+
canGoNext={controller.state.canGoNext}
|
|
37
|
+
canGoPrevious={controller.state.canGoPrevious}
|
|
38
|
+
chapterLabel={controller.state.location?.chapterTitle ?? props.chapterLabel}
|
|
39
|
+
onNextPage={controller.handleNextPage}
|
|
40
|
+
onPreviousPage={controller.handlePreviousPage}
|
|
41
|
+
page={controller.state.page}
|
|
42
|
+
pageCount={controller.state.pageCount}
|
|
43
|
+
progress={controller.state.progress}
|
|
44
|
+
status={controller.error ? 'error' : sourceUri ? controller.state.status : 'idle'}
|
|
45
|
+
viewport={viewport}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ReaderAdapterViewport({
|
|
51
|
+
appearance,
|
|
52
|
+
command,
|
|
53
|
+
format,
|
|
54
|
+
initialLocation,
|
|
55
|
+
onError,
|
|
56
|
+
onOpenExternalLink,
|
|
57
|
+
onStateChange,
|
|
58
|
+
sourceUri,
|
|
59
|
+
}: React.ComponentProps<typeof ReaderViewport>) {
|
|
60
|
+
return (
|
|
61
|
+
<View style={styles.viewport}>
|
|
62
|
+
<ReaderViewport
|
|
63
|
+
appearance={appearance}
|
|
64
|
+
command={command}
|
|
65
|
+
dom={DOM_CONFIGURATION}
|
|
66
|
+
format={format}
|
|
67
|
+
initialLocation={initialLocation}
|
|
68
|
+
key={`${format}:${sourceUri}`}
|
|
69
|
+
onError={onError}
|
|
70
|
+
onOpenExternalLink={onOpenExternalLink}
|
|
71
|
+
onStateChange={onStateChange}
|
|
72
|
+
sourceUri={sourceUri}
|
|
73
|
+
/>
|
|
74
|
+
</View>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function useReaderController(props: ReaderSurfaceProps, sourceUri: string | undefined) {
|
|
79
|
+
const { onLocationChange, onNextPage, onOpenExternalLink, onPreviousPage, onReaderError } = props;
|
|
80
|
+
const [state, setState] = React.useState<ReaderViewportState>(INITIAL_STATE);
|
|
81
|
+
const [error, setError] = React.useState<ReaderErrorEvent>();
|
|
82
|
+
const { command, issueCommand } = useReaderCommandState(sourceUri);
|
|
83
|
+
const handlePreviousPage = React.useCallback(() => {
|
|
84
|
+
issueCommand('previous');
|
|
85
|
+
void onPreviousPage?.();
|
|
86
|
+
}, [issueCommand, onPreviousPage]);
|
|
87
|
+
const handleNextPage = React.useCallback(() => {
|
|
88
|
+
issueCommand('next');
|
|
89
|
+
void onNextPage?.();
|
|
90
|
+
}, [issueCommand, onNextPage]);
|
|
91
|
+
const handleStateChange = React.useCallback(
|
|
92
|
+
async (nextState: ReaderViewportState) => {
|
|
93
|
+
setState(nextState);
|
|
94
|
+
setError(undefined);
|
|
95
|
+
if (nextState.location) await onLocationChange?.(nextState.location);
|
|
96
|
+
},
|
|
97
|
+
[onLocationChange],
|
|
98
|
+
);
|
|
99
|
+
const handleError = React.useCallback(
|
|
100
|
+
async (event: ReaderErrorEvent) => {
|
|
101
|
+
setError(event);
|
|
102
|
+
await onReaderError?.(event);
|
|
103
|
+
},
|
|
104
|
+
[onReaderError],
|
|
105
|
+
);
|
|
106
|
+
const handleOpenExternalLink = React.useCallback(
|
|
107
|
+
async (event: { readonly url: string }) => onOpenExternalLink?.(event),
|
|
108
|
+
[onOpenExternalLink],
|
|
109
|
+
);
|
|
110
|
+
return {
|
|
111
|
+
command,
|
|
112
|
+
error,
|
|
113
|
+
handleError,
|
|
114
|
+
handleNextPage,
|
|
115
|
+
handleOpenExternalLink,
|
|
116
|
+
handlePreviousPage,
|
|
117
|
+
handleStateChange,
|
|
118
|
+
state,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function useReaderCommandState(sourceUri: string | undefined) {
|
|
123
|
+
const [command, setCommand] = React.useState<ReaderCommand>();
|
|
124
|
+
const commandIdRef = React.useRef(0);
|
|
125
|
+
const issueCommand = React.useCallback(
|
|
126
|
+
(type: ReaderCommand['type']) => {
|
|
127
|
+
if (!sourceUri) return;
|
|
128
|
+
commandIdRef.current += 1;
|
|
129
|
+
setCommand({ id: commandIdRef.current, sourceUri, type });
|
|
130
|
+
},
|
|
131
|
+
[sourceUri],
|
|
132
|
+
);
|
|
133
|
+
return { command, issueCommand };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function useReaderAppearance(props: ReaderSurfaceProps): ReaderAppearance {
|
|
137
|
+
const colorScheme = props.readerColorScheme ?? 'system';
|
|
138
|
+
const fontScale = props.fontScale ?? 1;
|
|
139
|
+
const lineHeight = resolveLineHeight(props.lineHeight ?? 'normal');
|
|
140
|
+
return React.useMemo(
|
|
141
|
+
() => ({ colorScheme, fontScale, lineHeight }),
|
|
142
|
+
[colorScheme, fontScale, lineHeight],
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function resolveReaderSourceUri(source: ReaderSurfaceProps['source']): string | undefined {
|
|
147
|
+
if (typeof source === 'string') return source;
|
|
148
|
+
if (typeof source === 'number') return Image.resolveAssetSource(source).uri;
|
|
149
|
+
return source ? source.uri : undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function resolveLineHeight(lineHeight: NonNullable<ReaderSurfaceProps['lineHeight']>): number {
|
|
153
|
+
if (lineHeight === 'compact') return 1.25;
|
|
154
|
+
if (lineHeight === 'relaxed') return 1.75;
|
|
155
|
+
return 1.5;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const styles = StyleSheet.create({
|
|
159
|
+
viewport: { flex: 1, minHeight: 360 },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const DOM_CONFIGURATION = {
|
|
163
|
+
allowsInlineMediaPlayback: false,
|
|
164
|
+
javaScriptCanOpenWindowsAutomatically: false,
|
|
165
|
+
mediaPlaybackRequiresUserAction: true,
|
|
166
|
+
originWhitelist: ['about:blank', 'blob:*'],
|
|
167
|
+
scrollEnabled: false,
|
|
168
|
+
style: styles.viewport,
|
|
169
|
+
unstable_useExpoModulesBridge: false,
|
|
170
|
+
};
|
|
@@ -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
|
+
}
|