@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.
Files changed (121) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +2 -1
  3. package/dist/barcodeScanner.d.ts +2 -0
  4. package/dist/barcodeScanner.d.ts.map +1 -0
  5. package/dist/barcodeScanner.js +2 -0
  6. package/dist/barcodeScanner.js.map +1 -0
  7. package/dist/createExpoRuntimeRegistry.d.ts.map +1 -1
  8. package/dist/createExpoRuntimeRegistry.js +5 -0
  9. package/dist/createExpoRuntimeRegistry.js.map +1 -1
  10. package/dist/expoRuntimePlanningMetadata.d.ts +9 -1
  11. package/dist/expoRuntimePlanningMetadata.d.ts.map +1 -1
  12. package/dist/expoRuntimePlanningMetadata.js +8 -0
  13. package/dist/expoRuntimePlanningMetadata.js.map +1 -1
  14. package/dist/generatedSources.d.ts +1 -0
  15. package/dist/generatedSources.d.ts.map +1 -1
  16. package/dist/generatedSources.js +9 -1
  17. package/dist/generatedSources.js.map +1 -1
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/planning.d.ts +1 -1
  23. package/dist/planning.d.ts.map +1 -1
  24. package/dist/planning.js +1 -1
  25. package/dist/planning.js.map +1 -1
  26. package/dist/reader/ExpoReaderSurfaceAdapter.d.ts +4 -0
  27. package/dist/reader/ExpoReaderSurfaceAdapter.d.ts.map +1 -0
  28. package/dist/reader/ExpoReaderSurfaceAdapter.js +102 -0
  29. package/dist/reader/ExpoReaderSurfaceAdapter.js.map +1 -0
  30. package/dist/reader/ReaderDocumentError.d.ts +6 -0
  31. package/dist/reader/ReaderDocumentError.d.ts.map +1 -0
  32. package/dist/reader/ReaderDocumentError.js +9 -0
  33. package/dist/reader/ReaderDocumentError.js.map +1 -0
  34. package/dist/reader/ReaderViewport.d.ts +4 -0
  35. package/dist/reader/ReaderViewport.d.ts.map +1 -0
  36. package/dist/reader/ReaderViewport.js +212 -0
  37. package/dist/reader/ReaderViewport.js.map +1 -0
  38. package/dist/reader/drivers/EpubReaderDriver.d.ts +20 -0
  39. package/dist/reader/drivers/EpubReaderDriver.d.ts.map +1 -0
  40. package/dist/reader/drivers/EpubReaderDriver.js +259 -0
  41. package/dist/reader/drivers/EpubReaderDriver.js.map +1 -0
  42. package/dist/reader/drivers/PdfReaderDriver.d.ts +18 -0
  43. package/dist/reader/drivers/PdfReaderDriver.d.ts.map +1 -0
  44. package/dist/reader/drivers/PdfReaderDriver.js +198 -0
  45. package/dist/reader/drivers/PdfReaderDriver.js.map +1 -0
  46. package/dist/reader/epub/EpubArchive.d.ts +11 -0
  47. package/dist/reader/epub/EpubArchive.d.ts.map +1 -0
  48. package/dist/reader/epub/EpubArchive.js +65 -0
  49. package/dist/reader/epub/EpubArchive.js.map +1 -0
  50. package/dist/reader/epub/EpubArchiveFetcher.d.ts +11 -0
  51. package/dist/reader/epub/EpubArchiveFetcher.d.ts.map +1 -0
  52. package/dist/reader/epub/EpubArchiveFetcher.js +44 -0
  53. package/dist/reader/epub/EpubArchiveFetcher.js.map +1 -0
  54. package/dist/reader/epub/mediaTypes.d.ts +3 -0
  55. package/dist/reader/epub/mediaTypes.d.ts.map +1 -0
  56. package/dist/reader/epub/mediaTypes.js +26 -0
  57. package/dist/reader/epub/mediaTypes.js.map +1 -0
  58. package/dist/reader/epub/parseEpubPackage.d.ts +22 -0
  59. package/dist/reader/epub/parseEpubPackage.d.ts.map +1 -0
  60. package/dist/reader/epub/parseEpubPackage.js +98 -0
  61. package/dist/reader/epub/parseEpubPackage.js.map +1 -0
  62. package/dist/reader/epub/path.d.ts +4 -0
  63. package/dist/reader/epub/path.d.ts.map +1 -0
  64. package/dist/reader/epub/path.js +28 -0
  65. package/dist/reader/epub/path.js.map +1 -0
  66. package/dist/reader/epub/sanitizeEpubDocument.d.ts +3 -0
  67. package/dist/reader/epub/sanitizeEpubDocument.d.ts.map +1 -0
  68. package/dist/reader/epub/sanitizeEpubDocument.js +90 -0
  69. package/dist/reader/epub/sanitizeEpubDocument.js.map +1 -0
  70. package/dist/reader/index.d.ts +2 -0
  71. package/dist/reader/index.d.ts.map +1 -0
  72. package/dist/reader/index.js +2 -0
  73. package/dist/reader/index.js.map +1 -0
  74. package/dist/reader/loadReaderSourceAsync.d.ts +2 -0
  75. package/dist/reader/loadReaderSourceAsync.d.ts.map +1 -0
  76. package/dist/reader/loadReaderSourceAsync.js +49 -0
  77. package/dist/reader/loadReaderSourceAsync.js.map +1 -0
  78. package/dist/reader/readerViewportPresentation.d.ts +4 -0
  79. package/dist/reader/readerViewportPresentation.d.ts.map +1 -0
  80. package/dist/reader/readerViewportPresentation.js +26 -0
  81. package/dist/reader/readerViewportPresentation.js.map +1 -0
  82. package/dist/reader/types.d.ts +57 -0
  83. package/dist/reader/types.d.ts.map +1 -0
  84. package/dist/reader/types.js +2 -0
  85. package/dist/reader/types.js.map +1 -0
  86. package/dist/reader/validateEpubArchiveEntriesAsync.d.ts +3 -0
  87. package/dist/reader/validateEpubArchiveEntriesAsync.d.ts.map +1 -0
  88. package/dist/reader/validateEpubArchiveEntriesAsync.js +48 -0
  89. package/dist/reader/validateEpubArchiveEntriesAsync.js.map +1 -0
  90. package/package.json +25 -4
  91. package/src/adapterEntrypoints.test.ts +48 -0
  92. package/src/barcodeScanner.ts +1 -0
  93. package/src/createExpoRuntimeRegistry.test.tsx +9 -1
  94. package/src/createExpoRuntimeRegistry.tsx +7 -1
  95. package/src/expoRuntimePlanningMetadata.ts +9 -1
  96. package/src/generatedSources.test.ts +11 -2
  97. package/src/generatedSources.ts +11 -1
  98. package/src/index.ts +5 -1
  99. package/src/planning.ts +4 -1
  100. package/src/reader/ExpoReaderSurfaceAdapter.tsx +170 -0
  101. package/src/reader/ReaderDocumentError.ts +11 -0
  102. package/src/reader/ReaderViewport.tsx +319 -0
  103. package/src/reader/drivers/EpubReaderDriver.ts +309 -0
  104. package/src/reader/drivers/PdfReaderDriver.ts +253 -0
  105. package/src/reader/epub/EpubArchive.ts +83 -0
  106. package/src/reader/epub/EpubArchiveFetcher.ts +58 -0
  107. package/src/reader/epub/mediaTypes.ts +16 -0
  108. package/src/reader/epub/parseEpubPackage.test.ts +116 -0
  109. package/src/reader/epub/parseEpubPackage.ts +152 -0
  110. package/src/reader/epub/path.test.ts +22 -0
  111. package/src/reader/epub/path.ts +26 -0
  112. package/src/reader/epub/sanitizeEpubDocument.ts +121 -0
  113. package/src/reader/index.ts +1 -0
  114. package/src/reader/loadReaderSourceAsync.test.ts +52 -0
  115. package/src/reader/loadReaderSourceAsync.ts +59 -0
  116. package/src/reader/readerViewportPresentation.ts +28 -0
  117. package/src/reader/rendererPackages.test.ts +3 -1
  118. package/src/reader/types.ts +61 -0
  119. package/src/reader/validateEpubArchiveEntriesAsync.test.ts +142 -0
  120. package/src/reader/validateEpubArchiveEntriesAsync.ts +73 -0
  121. package/src/resolveExpoRuntimePlan.test.ts +36 -0
@@ -1,11 +1,20 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
2
 
3
- import { getExpoBarcodeScannerViewSource } from './generatedSources';
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
  });
@@ -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 { getExpoBarcodeScannerViewSource } from './generatedSources';
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 { getExpoBarcodeScannerViewSource } from './generatedSources';
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,11 @@
1
+ import type { ReaderErrorCode } from '@ankhorage/zora';
2
+
3
+ export class ReaderDocumentError extends Error {
4
+ constructor(
5
+ readonly code: ReaderErrorCode,
6
+ message: string,
7
+ ) {
8
+ super(message);
9
+ this.name = 'ReaderDocumentError';
10
+ }
11
+ }
@@ -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
+ }