@idealyst/pdf 1.2.129
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/package.json +75 -0
- package/src/PDFViewer.native.tsx +185 -0
- package/src/PDFViewer.tsx +367 -0
- package/src/index.native.ts +36 -0
- package/src/index.ts +41 -0
- package/src/types.ts +136 -0
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@idealyst/pdf",
|
|
3
|
+
"version": "1.2.129",
|
|
4
|
+
"description": "Cross-platform PDF viewer for React and React Native",
|
|
5
|
+
"readme": "README.md",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"module": "src/index.ts",
|
|
8
|
+
"types": "src/index.ts",
|
|
9
|
+
"react-native": "src/index.native.ts",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/IdealystIO/idealyst-framework.git",
|
|
13
|
+
"directory": "packages/pdf"
|
|
14
|
+
},
|
|
15
|
+
"author": "Idealyst <contact@idealyst.io>",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"react-native": "./src/index.native.ts",
|
|
23
|
+
"import": "./src/index.ts",
|
|
24
|
+
"require": "./src/index.ts",
|
|
25
|
+
"types": "./src/index.ts"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"prepublishOnly": "echo 'Publishing TypeScript source directly'",
|
|
30
|
+
"publish:npm": "npm publish"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"pdfjs-dist": ">=3.0.0",
|
|
34
|
+
"react": ">=16.8.0",
|
|
35
|
+
"react-native": ">=0.60.0",
|
|
36
|
+
"react-native-blob-util": ">=0.19.0",
|
|
37
|
+
"react-native-pdf": ">=6.0.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"pdfjs-dist": {
|
|
41
|
+
"optional": true
|
|
42
|
+
},
|
|
43
|
+
"react-native": {
|
|
44
|
+
"optional": true
|
|
45
|
+
},
|
|
46
|
+
"react-native-blob-util": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"react-native-pdf": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/react": "^19.1.0",
|
|
55
|
+
"pdfjs-dist": "^4.0.0",
|
|
56
|
+
"react": "^19.1.0",
|
|
57
|
+
"react-native": "^0.80.1",
|
|
58
|
+
"react-native-blob-util": "^0.19.0",
|
|
59
|
+
"react-native-pdf": "^6.7.0",
|
|
60
|
+
"typescript": "^5.0.0"
|
|
61
|
+
},
|
|
62
|
+
"files": [
|
|
63
|
+
"src",
|
|
64
|
+
"README.md"
|
|
65
|
+
],
|
|
66
|
+
"keywords": [
|
|
67
|
+
"react",
|
|
68
|
+
"react-native",
|
|
69
|
+
"pdf",
|
|
70
|
+
"viewer",
|
|
71
|
+
"document",
|
|
72
|
+
"cross-platform",
|
|
73
|
+
"pdfjs"
|
|
74
|
+
]
|
|
75
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDFViewer - Native implementation
|
|
3
|
+
*
|
|
4
|
+
* Uses react-native-pdf for rendering PDF documents on React Native.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React, {
|
|
8
|
+
forwardRef,
|
|
9
|
+
useImperativeHandle,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
useCallback,
|
|
13
|
+
} from 'react';
|
|
14
|
+
import { View, Text, StyleSheet } from 'react-native';
|
|
15
|
+
import Pdf from 'react-native-pdf';
|
|
16
|
+
import type { PDFViewerProps, PDFViewerRef, PDFSource } from './types';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Normalize a PDFSource into a react-native-pdf compatible source object.
|
|
20
|
+
*/
|
|
21
|
+
function resolveSource(source: PDFSource): { uri: string } {
|
|
22
|
+
if (typeof source === 'string') {
|
|
23
|
+
return { uri: source };
|
|
24
|
+
}
|
|
25
|
+
if ('uri' in source) {
|
|
26
|
+
return { uri: source.uri };
|
|
27
|
+
}
|
|
28
|
+
// base64 → data URI for react-native-pdf
|
|
29
|
+
return { uri: `data:application/pdf;base64,${source.base64}` };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Convert fitPolicy string to react-native-pdf numeric value.
|
|
34
|
+
* 0 = fit width, 1 = fit height, 2 = fit both
|
|
35
|
+
*/
|
|
36
|
+
function resolveFitPolicy(fitPolicy: 'width' | 'height' | 'both'): 0 | 1 | 2 {
|
|
37
|
+
switch (fitPolicy) {
|
|
38
|
+
case 'width':
|
|
39
|
+
return 0;
|
|
40
|
+
case 'height':
|
|
41
|
+
return 1;
|
|
42
|
+
case 'both':
|
|
43
|
+
return 2;
|
|
44
|
+
default:
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* PDFViewer component for React Native.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```tsx
|
|
54
|
+
* import { PDFViewer } from '@idealyst/pdf';
|
|
55
|
+
*
|
|
56
|
+
* <PDFViewer
|
|
57
|
+
* source="https://example.com/document.pdf"
|
|
58
|
+
* onLoad={({ totalPages }) => console.log('Pages:', totalPages)}
|
|
59
|
+
* onPageChange={(page, total) => console.log(page, '/', total)}
|
|
60
|
+
* style={{ flex: 1 }}
|
|
61
|
+
* />
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export const PDFViewer = forwardRef<PDFViewerRef, PDFViewerProps>((props, ref) => {
|
|
65
|
+
const {
|
|
66
|
+
source,
|
|
67
|
+
page = 1,
|
|
68
|
+
onPageChange,
|
|
69
|
+
onLoad,
|
|
70
|
+
onError,
|
|
71
|
+
zoomEnabled = true,
|
|
72
|
+
minZoom = 1,
|
|
73
|
+
maxZoom = 5,
|
|
74
|
+
direction = 'vertical',
|
|
75
|
+
showPageIndicator = true,
|
|
76
|
+
fitPolicy = 'width',
|
|
77
|
+
style,
|
|
78
|
+
testID,
|
|
79
|
+
} = props;
|
|
80
|
+
|
|
81
|
+
const pdfRef = useRef<any>(null);
|
|
82
|
+
const [currentPage, setCurrentPage] = useState(page);
|
|
83
|
+
const [totalPages, setTotalPages] = useState(0);
|
|
84
|
+
const [controlledPage, setControlledPage] = useState(page);
|
|
85
|
+
|
|
86
|
+
// Update controlled page when prop changes
|
|
87
|
+
React.useEffect(() => {
|
|
88
|
+
setControlledPage(page);
|
|
89
|
+
}, [page]);
|
|
90
|
+
|
|
91
|
+
const handleLoadComplete = useCallback(
|
|
92
|
+
(numberOfPages: number) => {
|
|
93
|
+
setTotalPages(numberOfPages);
|
|
94
|
+
onLoad?.({ totalPages: numberOfPages });
|
|
95
|
+
},
|
|
96
|
+
[onLoad]
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const handlePageChanged = useCallback(
|
|
100
|
+
(pageNum: number, numPages: number) => {
|
|
101
|
+
setCurrentPage(pageNum);
|
|
102
|
+
setTotalPages(numPages);
|
|
103
|
+
onPageChange?.(pageNum, numPages);
|
|
104
|
+
},
|
|
105
|
+
[onPageChange]
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const handleError = useCallback(
|
|
109
|
+
(error: any) => {
|
|
110
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
111
|
+
onError?.(err);
|
|
112
|
+
},
|
|
113
|
+
[onError]
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Imperative handle
|
|
117
|
+
useImperativeHandle(
|
|
118
|
+
ref,
|
|
119
|
+
() => ({
|
|
120
|
+
goToPage: (targetPage: number) => {
|
|
121
|
+
setControlledPage(targetPage);
|
|
122
|
+
},
|
|
123
|
+
setZoom: (_level: number) => {
|
|
124
|
+
// react-native-pdf does not support imperative zoom control;
|
|
125
|
+
// zoom is handled by user gestures within the configured min/max range
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
[]
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const resolvedSource = resolveSource(source);
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<View style={[styles.container, style]} testID={testID}>
|
|
135
|
+
<Pdf
|
|
136
|
+
ref={pdfRef}
|
|
137
|
+
source={resolvedSource}
|
|
138
|
+
page={controlledPage}
|
|
139
|
+
horizontal={direction === 'horizontal'}
|
|
140
|
+
fitPolicy={resolveFitPolicy(fitPolicy)}
|
|
141
|
+
minScale={minZoom}
|
|
142
|
+
maxScale={maxZoom}
|
|
143
|
+
enablePaging={false}
|
|
144
|
+
enableAntialiasing={true}
|
|
145
|
+
enableAnnotationRendering={true}
|
|
146
|
+
onLoadComplete={handleLoadComplete}
|
|
147
|
+
onPageChanged={handlePageChanged}
|
|
148
|
+
onError={handleError}
|
|
149
|
+
style={styles.pdf}
|
|
150
|
+
/>
|
|
151
|
+
{showPageIndicator && totalPages > 0 && (
|
|
152
|
+
<View style={styles.indicator}>
|
|
153
|
+
<Text style={styles.indicatorText}>
|
|
154
|
+
{currentPage} / {totalPages}
|
|
155
|
+
</Text>
|
|
156
|
+
</View>
|
|
157
|
+
)}
|
|
158
|
+
</View>
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
PDFViewer.displayName = 'PDFViewer';
|
|
163
|
+
|
|
164
|
+
const styles = StyleSheet.create({
|
|
165
|
+
container: {
|
|
166
|
+
flex: 1,
|
|
167
|
+
position: 'relative',
|
|
168
|
+
},
|
|
169
|
+
pdf: {
|
|
170
|
+
flex: 1,
|
|
171
|
+
},
|
|
172
|
+
indicator: {
|
|
173
|
+
position: 'absolute',
|
|
174
|
+
bottom: 16,
|
|
175
|
+
right: 16,
|
|
176
|
+
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
|
177
|
+
paddingHorizontal: 12,
|
|
178
|
+
paddingVertical: 4,
|
|
179
|
+
borderRadius: 4,
|
|
180
|
+
},
|
|
181
|
+
indicatorText: {
|
|
182
|
+
color: '#fff',
|
|
183
|
+
fontSize: 14,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDFViewer - Web implementation
|
|
3
|
+
*
|
|
4
|
+
* Uses pdfjs-dist (Mozilla PDF.js) for rendering PDF documents on the web.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React, {
|
|
8
|
+
forwardRef,
|
|
9
|
+
useImperativeHandle,
|
|
10
|
+
useRef,
|
|
11
|
+
useEffect,
|
|
12
|
+
useState,
|
|
13
|
+
useCallback,
|
|
14
|
+
} from 'react';
|
|
15
|
+
import * as pdfjs from 'pdfjs-dist';
|
|
16
|
+
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
|
17
|
+
import type { PDFViewerProps, PDFViewerRef, PDFSource } from './types';
|
|
18
|
+
|
|
19
|
+
// Configure the PDF.js worker
|
|
20
|
+
// Users can override this by setting pdfjs.GlobalWorkerOptions.workerSrc before rendering
|
|
21
|
+
if (!pdfjs.GlobalWorkerOptions.workerSrc) {
|
|
22
|
+
pdfjs.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.mjs`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a PDFSource into a pdfjs-dist compatible source parameter.
|
|
27
|
+
*/
|
|
28
|
+
function resolveSource(source: PDFSource): string | { data: Uint8Array } {
|
|
29
|
+
if (typeof source === 'string') return source;
|
|
30
|
+
if ('uri' in source) return source.uri;
|
|
31
|
+
|
|
32
|
+
// base64 → Uint8Array
|
|
33
|
+
const binaryStr = atob(source.base64);
|
|
34
|
+
const bytes = new Uint8Array(binaryStr.length);
|
|
35
|
+
for (let i = 0; i < binaryStr.length; i++) {
|
|
36
|
+
bytes[i] = binaryStr.charCodeAt(i);
|
|
37
|
+
}
|
|
38
|
+
return { data: bytes };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* PDFViewer component for web.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```tsx
|
|
46
|
+
* import { PDFViewer } from '@idealyst/pdf';
|
|
47
|
+
*
|
|
48
|
+
* <PDFViewer
|
|
49
|
+
* source="https://example.com/document.pdf"
|
|
50
|
+
* onLoad={({ totalPages }) => console.log('Pages:', totalPages)}
|
|
51
|
+
* onPageChange={(page, total) => console.log(page, '/', total)}
|
|
52
|
+
* style={{ flex: 1 }}
|
|
53
|
+
* />
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export const PDFViewer = forwardRef<PDFViewerRef, PDFViewerProps>((props, ref) => {
|
|
57
|
+
const {
|
|
58
|
+
source,
|
|
59
|
+
page = 1,
|
|
60
|
+
onPageChange,
|
|
61
|
+
onLoad,
|
|
62
|
+
onError,
|
|
63
|
+
zoomEnabled = true,
|
|
64
|
+
minZoom = 1,
|
|
65
|
+
maxZoom = 5,
|
|
66
|
+
direction = 'vertical',
|
|
67
|
+
showPageIndicator = true,
|
|
68
|
+
fitPolicy = 'width',
|
|
69
|
+
style,
|
|
70
|
+
testID,
|
|
71
|
+
} = props;
|
|
72
|
+
|
|
73
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
74
|
+
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
|
75
|
+
const pdfDocRef = useRef<PDFDocumentProxy | null>(null);
|
|
76
|
+
const pageRefs = useRef<Map<number, HTMLCanvasElement>>(new Map());
|
|
77
|
+
const [totalPages, setTotalPages] = useState(0);
|
|
78
|
+
const [currentPage, setCurrentPage] = useState(page);
|
|
79
|
+
const [zoom, setZoom] = useState(1);
|
|
80
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
81
|
+
const loadIdRef = useRef(0);
|
|
82
|
+
|
|
83
|
+
// Render a single page to a canvas
|
|
84
|
+
const renderPage = useCallback(
|
|
85
|
+
async (doc: PDFDocumentProxy, pageNum: number, canvas: HTMLCanvasElement, scale: number) => {
|
|
86
|
+
const pdfPage = await doc.getPage(pageNum);
|
|
87
|
+
const viewport = pdfPage.getViewport({ scale });
|
|
88
|
+
|
|
89
|
+
canvas.width = viewport.width;
|
|
90
|
+
canvas.height = viewport.height;
|
|
91
|
+
|
|
92
|
+
const context = canvas.getContext('2d');
|
|
93
|
+
if (!context) return;
|
|
94
|
+
|
|
95
|
+
await pdfPage.render({
|
|
96
|
+
canvasContext: context,
|
|
97
|
+
viewport,
|
|
98
|
+
}).promise;
|
|
99
|
+
},
|
|
100
|
+
[]
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Calculate scale based on fitPolicy and container size
|
|
104
|
+
const calculateScale = useCallback(
|
|
105
|
+
(doc: PDFDocumentProxy, containerWidth: number, containerHeight: number) => {
|
|
106
|
+
// We need the first page to determine dimensions
|
|
107
|
+
return doc.getPage(1).then((firstPage) => {
|
|
108
|
+
const unscaledViewport = firstPage.getViewport({ scale: 1 });
|
|
109
|
+
const pageWidth = unscaledViewport.width;
|
|
110
|
+
const pageHeight = unscaledViewport.height;
|
|
111
|
+
|
|
112
|
+
switch (fitPolicy) {
|
|
113
|
+
case 'width':
|
|
114
|
+
return containerWidth / pageWidth;
|
|
115
|
+
case 'height':
|
|
116
|
+
return containerHeight / pageHeight;
|
|
117
|
+
case 'both': {
|
|
118
|
+
const scaleW = containerWidth / pageWidth;
|
|
119
|
+
const scaleH = containerHeight / pageHeight;
|
|
120
|
+
return Math.min(scaleW, scaleH);
|
|
121
|
+
}
|
|
122
|
+
default:
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
[fitPolicy]
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Load and render the PDF document
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
const id = ++loadIdRef.current;
|
|
133
|
+
setIsLoading(true);
|
|
134
|
+
|
|
135
|
+
const loadPDF = async () => {
|
|
136
|
+
try {
|
|
137
|
+
// Destroy previous document
|
|
138
|
+
if (pdfDocRef.current) {
|
|
139
|
+
await pdfDocRef.current.destroy();
|
|
140
|
+
pdfDocRef.current = null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const resolved = resolveSource(source);
|
|
144
|
+
const doc = await pdfjs.getDocument(resolved).promise;
|
|
145
|
+
|
|
146
|
+
if (id !== loadIdRef.current) {
|
|
147
|
+
doc.destroy();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
pdfDocRef.current = doc;
|
|
152
|
+
const numPages = doc.numPages;
|
|
153
|
+
setTotalPages(numPages);
|
|
154
|
+
setIsLoading(false);
|
|
155
|
+
onLoad?.({ totalPages: numPages });
|
|
156
|
+
|
|
157
|
+
// Get container dimensions for scale calculation
|
|
158
|
+
const container = containerRef.current;
|
|
159
|
+
if (!container) return;
|
|
160
|
+
|
|
161
|
+
const containerWidth = container.clientWidth;
|
|
162
|
+
const containerHeight = container.clientHeight;
|
|
163
|
+
const baseScale = await calculateScale(doc, containerWidth, containerHeight);
|
|
164
|
+
|
|
165
|
+
// Clear previous canvases
|
|
166
|
+
const canvasContainer = canvasContainerRef.current;
|
|
167
|
+
if (!canvasContainer) return;
|
|
168
|
+
canvasContainer.innerHTML = '';
|
|
169
|
+
pageRefs.current.clear();
|
|
170
|
+
|
|
171
|
+
// Render all pages
|
|
172
|
+
for (let i = 1; i <= numPages; i++) {
|
|
173
|
+
const canvas = document.createElement('canvas');
|
|
174
|
+
canvas.style.display = 'block';
|
|
175
|
+
canvas.style.margin = direction === 'vertical' ? '8px auto' : '8px';
|
|
176
|
+
canvas.dataset.page = String(i);
|
|
177
|
+
canvasContainer.appendChild(canvas);
|
|
178
|
+
pageRefs.current.set(i, canvas);
|
|
179
|
+
|
|
180
|
+
await renderPage(doc, i, canvas, baseScale * zoom);
|
|
181
|
+
}
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (id !== loadIdRef.current) return;
|
|
184
|
+
setIsLoading(false);
|
|
185
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
186
|
+
onError?.(error);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
loadPDF();
|
|
191
|
+
|
|
192
|
+
return () => {
|
|
193
|
+
loadIdRef.current++;
|
|
194
|
+
if (pdfDocRef.current) {
|
|
195
|
+
pdfDocRef.current.destroy();
|
|
196
|
+
pdfDocRef.current = null;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}, [source]);
|
|
200
|
+
|
|
201
|
+
// Re-render on zoom change
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
if (!pdfDocRef.current || !containerRef.current) return;
|
|
204
|
+
|
|
205
|
+
const doc = pdfDocRef.current;
|
|
206
|
+
const container = containerRef.current;
|
|
207
|
+
const containerWidth = container.clientWidth;
|
|
208
|
+
const containerHeight = container.clientHeight;
|
|
209
|
+
|
|
210
|
+
const rerender = async () => {
|
|
211
|
+
const baseScale = await calculateScale(doc, containerWidth, containerHeight);
|
|
212
|
+
const scale = baseScale * zoom;
|
|
213
|
+
|
|
214
|
+
const entries = Array.from(pageRefs.current.entries());
|
|
215
|
+
for (let i = 0; i < entries.length; i++) {
|
|
216
|
+
const [pageNum, canvas] = entries[i];
|
|
217
|
+
await renderPage(doc, pageNum, canvas, scale);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
rerender();
|
|
222
|
+
}, [zoom, calculateScale, renderPage]);
|
|
223
|
+
|
|
224
|
+
// Scroll to page when `page` prop changes
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
const canvas = pageRefs.current.get(page);
|
|
227
|
+
if (canvas) {
|
|
228
|
+
canvas.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'start' });
|
|
229
|
+
setCurrentPage(page);
|
|
230
|
+
}
|
|
231
|
+
}, [page]);
|
|
232
|
+
|
|
233
|
+
// Track current page via scroll position
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
const container = canvasContainerRef.current;
|
|
236
|
+
if (!container || totalPages === 0) return;
|
|
237
|
+
|
|
238
|
+
const handleScroll = () => {
|
|
239
|
+
const scrollContainer = container.parentElement;
|
|
240
|
+
if (!scrollContainer) return;
|
|
241
|
+
|
|
242
|
+
const scrollTop = scrollContainer.scrollTop;
|
|
243
|
+
const scrollLeft = scrollContainer.scrollLeft;
|
|
244
|
+
|
|
245
|
+
let closestPage = 1;
|
|
246
|
+
let closestDistance = Infinity;
|
|
247
|
+
|
|
248
|
+
const scrollEntries = Array.from(pageRefs.current.entries());
|
|
249
|
+
for (let i = 0; i < scrollEntries.length; i++) {
|
|
250
|
+
const [pageNum, canvas] = scrollEntries[i];
|
|
251
|
+
const distance =
|
|
252
|
+
direction === 'vertical'
|
|
253
|
+
? Math.abs(canvas.offsetTop - scrollTop)
|
|
254
|
+
: Math.abs(canvas.offsetLeft - scrollLeft);
|
|
255
|
+
|
|
256
|
+
if (distance < closestDistance) {
|
|
257
|
+
closestDistance = distance;
|
|
258
|
+
closestPage = pageNum;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (closestPage !== currentPage) {
|
|
263
|
+
setCurrentPage(closestPage);
|
|
264
|
+
onPageChange?.(closestPage, totalPages);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const scrollContainer = container.parentElement;
|
|
269
|
+
scrollContainer?.addEventListener('scroll', handleScroll, { passive: true });
|
|
270
|
+
return () => scrollContainer?.removeEventListener('scroll', handleScroll);
|
|
271
|
+
}, [totalPages, currentPage, direction, onPageChange]);
|
|
272
|
+
|
|
273
|
+
// Wheel zoom handler
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
if (!zoomEnabled) return;
|
|
276
|
+
const container = containerRef.current;
|
|
277
|
+
if (!container) return;
|
|
278
|
+
|
|
279
|
+
const handleWheel = (e: WheelEvent) => {
|
|
280
|
+
if (!e.ctrlKey && !e.metaKey) return;
|
|
281
|
+
e.preventDefault();
|
|
282
|
+
|
|
283
|
+
setZoom((prev) => {
|
|
284
|
+
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
|
285
|
+
return Math.min(maxZoom, Math.max(minZoom, prev + delta));
|
|
286
|
+
});
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
container.addEventListener('wheel', handleWheel, { passive: false });
|
|
290
|
+
return () => container.removeEventListener('wheel', handleWheel);
|
|
291
|
+
}, [zoomEnabled, minZoom, maxZoom]);
|
|
292
|
+
|
|
293
|
+
// Imperative handle
|
|
294
|
+
useImperativeHandle(
|
|
295
|
+
ref,
|
|
296
|
+
() => ({
|
|
297
|
+
goToPage: (targetPage: number) => {
|
|
298
|
+
const canvas = pageRefs.current.get(targetPage);
|
|
299
|
+
if (canvas) {
|
|
300
|
+
canvas.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'start' });
|
|
301
|
+
setCurrentPage(targetPage);
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
setZoom: (level: number) => {
|
|
305
|
+
setZoom(Math.min(maxZoom, Math.max(minZoom, level)));
|
|
306
|
+
},
|
|
307
|
+
}),
|
|
308
|
+
[minZoom, maxZoom]
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const containerStyle: React.CSSProperties = {
|
|
312
|
+
position: 'relative',
|
|
313
|
+
width: '100%',
|
|
314
|
+
height: '100%',
|
|
315
|
+
overflow: 'auto',
|
|
316
|
+
backgroundColor: '#f0f0f0',
|
|
317
|
+
...(style as any),
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const canvasContainerStyle: React.CSSProperties = {
|
|
321
|
+
display: 'flex',
|
|
322
|
+
flexDirection: direction === 'vertical' ? 'column' : 'row',
|
|
323
|
+
alignItems: 'center',
|
|
324
|
+
minHeight: '100%',
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const indicatorStyle: React.CSSProperties = {
|
|
328
|
+
position: 'absolute',
|
|
329
|
+
bottom: 16,
|
|
330
|
+
right: 16,
|
|
331
|
+
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
|
332
|
+
color: '#fff',
|
|
333
|
+
padding: '4px 12px',
|
|
334
|
+
borderRadius: 4,
|
|
335
|
+
fontSize: 14,
|
|
336
|
+
fontFamily: 'system-ui, sans-serif',
|
|
337
|
+
pointerEvents: 'none',
|
|
338
|
+
zIndex: 10,
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
return (
|
|
342
|
+
<div ref={containerRef} style={containerStyle} data-testid={testID}>
|
|
343
|
+
<div ref={canvasContainerRef} style={canvasContainerStyle} />
|
|
344
|
+
{showPageIndicator && totalPages > 0 && (
|
|
345
|
+
<div style={indicatorStyle}>
|
|
346
|
+
{currentPage} / {totalPages}
|
|
347
|
+
</div>
|
|
348
|
+
)}
|
|
349
|
+
{isLoading && (
|
|
350
|
+
<div
|
|
351
|
+
style={{
|
|
352
|
+
position: 'absolute',
|
|
353
|
+
inset: 0,
|
|
354
|
+
display: 'flex',
|
|
355
|
+
alignItems: 'center',
|
|
356
|
+
justifyContent: 'center',
|
|
357
|
+
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
|
358
|
+
}}
|
|
359
|
+
>
|
|
360
|
+
Loading PDF...
|
|
361
|
+
</div>
|
|
362
|
+
)}
|
|
363
|
+
</div>
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
PDFViewer.displayName = 'PDFViewer';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @idealyst/pdf - Native exports
|
|
3
|
+
*
|
|
4
|
+
* Cross-platform PDF viewer for React and React Native.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```tsx
|
|
8
|
+
* import { PDFViewer, PDFViewerRef } from '@idealyst/pdf';
|
|
9
|
+
*
|
|
10
|
+
* // Basic usage
|
|
11
|
+
* <PDFViewer source="https://example.com/document.pdf" style={{ flex: 1 }} />
|
|
12
|
+
*
|
|
13
|
+
* // With local file
|
|
14
|
+
* <PDFViewer source={{ uri: '/path/to/local.pdf' }} />
|
|
15
|
+
*
|
|
16
|
+
* // With ref for imperative control
|
|
17
|
+
* const pdfRef = useRef<PDFViewerRef>(null);
|
|
18
|
+
*
|
|
19
|
+
* <PDFViewer ref={pdfRef} source="https://example.com/document.pdf" />
|
|
20
|
+
*
|
|
21
|
+
* pdfRef.current?.goToPage(5);
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// Type exports
|
|
26
|
+
export type {
|
|
27
|
+
PDFSource,
|
|
28
|
+
PDFDocumentInfo,
|
|
29
|
+
PDFViewerProps,
|
|
30
|
+
PDFViewerRef,
|
|
31
|
+
FitPolicy,
|
|
32
|
+
PDFDirection,
|
|
33
|
+
} from './types';
|
|
34
|
+
|
|
35
|
+
// Component export
|
|
36
|
+
export { PDFViewer } from './PDFViewer.native';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @idealyst/pdf - Web exports
|
|
3
|
+
*
|
|
4
|
+
* Cross-platform PDF viewer for React and React Native.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```tsx
|
|
8
|
+
* import { PDFViewer, PDFViewerRef } from '@idealyst/pdf';
|
|
9
|
+
*
|
|
10
|
+
* // Basic usage
|
|
11
|
+
* <PDFViewer source="https://example.com/document.pdf" style={{ flex: 1 }} />
|
|
12
|
+
*
|
|
13
|
+
* // With callbacks
|
|
14
|
+
* <PDFViewer
|
|
15
|
+
* source={{ uri: 'https://example.com/document.pdf' }}
|
|
16
|
+
* onLoad={({ totalPages }) => console.log('Pages:', totalPages)}
|
|
17
|
+
* onPageChange={(page, total) => console.log(page, '/', total)}
|
|
18
|
+
* />
|
|
19
|
+
*
|
|
20
|
+
* // With ref for imperative control
|
|
21
|
+
* const pdfRef = useRef<PDFViewerRef>(null);
|
|
22
|
+
*
|
|
23
|
+
* <PDFViewer ref={pdfRef} source="https://example.com/document.pdf" />
|
|
24
|
+
*
|
|
25
|
+
* pdfRef.current?.goToPage(5);
|
|
26
|
+
* pdfRef.current?.setZoom(2);
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
// Type exports
|
|
31
|
+
export type {
|
|
32
|
+
PDFSource,
|
|
33
|
+
PDFDocumentInfo,
|
|
34
|
+
PDFViewerProps,
|
|
35
|
+
PDFViewerRef,
|
|
36
|
+
FitPolicy,
|
|
37
|
+
PDFDirection,
|
|
38
|
+
} from './types';
|
|
39
|
+
|
|
40
|
+
// Component export
|
|
41
|
+
export { PDFViewer } from './PDFViewer';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for @idealyst/pdf
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ViewStyle } from 'react-native';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PDF source - URL string, object with uri, or base64-encoded data
|
|
9
|
+
*/
|
|
10
|
+
export type PDFSource = string | { uri: string } | { base64: string };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Information about a loaded PDF document
|
|
14
|
+
*/
|
|
15
|
+
export interface PDFDocumentInfo {
|
|
16
|
+
/** Total number of pages */
|
|
17
|
+
totalPages: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fit policy for rendering pages
|
|
22
|
+
* - 'width': fit page width to container
|
|
23
|
+
* - 'height': fit page height to container
|
|
24
|
+
* - 'both': fit entire page within container
|
|
25
|
+
*/
|
|
26
|
+
export type FitPolicy = 'width' | 'height' | 'both';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Scroll/page direction
|
|
30
|
+
*/
|
|
31
|
+
export type PDFDirection = 'horizontal' | 'vertical';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Props for the PDFViewer component
|
|
35
|
+
*/
|
|
36
|
+
export interface PDFViewerProps {
|
|
37
|
+
/**
|
|
38
|
+
* PDF source - URL string, { uri } object, or { base64 } data.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* // URL string
|
|
42
|
+
* source="https://example.com/document.pdf"
|
|
43
|
+
*
|
|
44
|
+
* // URI object
|
|
45
|
+
* source={{ uri: 'https://example.com/document.pdf' }}
|
|
46
|
+
*
|
|
47
|
+
* // Base64 encoded
|
|
48
|
+
* source={{ base64: 'JVBERi0xLjQK...' }}
|
|
49
|
+
*/
|
|
50
|
+
source: PDFSource;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Current page number (1-indexed).
|
|
54
|
+
* When provided, the viewer scrolls to this page.
|
|
55
|
+
* @default 1
|
|
56
|
+
*/
|
|
57
|
+
page?: number;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Called when the visible page changes.
|
|
61
|
+
* @param page - Current page number (1-indexed)
|
|
62
|
+
* @param totalPages - Total number of pages in the document
|
|
63
|
+
*/
|
|
64
|
+
onPageChange?: (page: number, totalPages: number) => void;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Called when the PDF document loads successfully.
|
|
68
|
+
*/
|
|
69
|
+
onLoad?: (info: PDFDocumentInfo) => void;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Called when an error occurs loading the PDF.
|
|
73
|
+
*/
|
|
74
|
+
onError?: (error: Error) => void;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Enable pinch-to-zoom / scroll-to-zoom.
|
|
78
|
+
* @default true
|
|
79
|
+
*/
|
|
80
|
+
zoomEnabled?: boolean;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Minimum zoom level.
|
|
84
|
+
* @default 1
|
|
85
|
+
*/
|
|
86
|
+
minZoom?: number;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Maximum zoom level.
|
|
90
|
+
* @default 5
|
|
91
|
+
*/
|
|
92
|
+
maxZoom?: number;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Scroll direction for page navigation.
|
|
96
|
+
* @default 'vertical'
|
|
97
|
+
*/
|
|
98
|
+
direction?: PDFDirection;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Show a page indicator overlay (e.g. "3 / 10").
|
|
102
|
+
* @default true
|
|
103
|
+
*/
|
|
104
|
+
showPageIndicator?: boolean;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* How pages should be scaled to fit the container.
|
|
108
|
+
* @default 'width'
|
|
109
|
+
*/
|
|
110
|
+
fitPolicy?: FitPolicy;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Container style.
|
|
114
|
+
*/
|
|
115
|
+
style?: ViewStyle;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Test ID for testing frameworks.
|
|
119
|
+
*/
|
|
120
|
+
testID?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Ref methods for imperative PDFViewer control
|
|
125
|
+
*/
|
|
126
|
+
export interface PDFViewerRef {
|
|
127
|
+
/**
|
|
128
|
+
* Navigate to a specific page (1-indexed).
|
|
129
|
+
*/
|
|
130
|
+
goToPage: (page: number) => void;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Set the zoom level programmatically.
|
|
134
|
+
*/
|
|
135
|
+
setZoom: (level: number) => void;
|
|
136
|
+
}
|