A framework-agnostic, universal document renderer with optional chunked loading
polyrender.wisp.place/
1import { useRef, useEffect, useState, useCallback } from 'react'
2import type {
3 PolyRenderOptions,
4 PolyRenderState,
5 DocumentInfo,
6 PolyRenderError,
7 DocumentSource,
8} from '@polyrender/core'
9import { PolyRender } from '@polyrender/core'
10
11export interface UseDocumentRendererOptions
12 extends Omit<PolyRenderOptions, 'source' | 'onReady' | 'onPageChange' | 'onZoomChange' | 'onError' | 'onLoadingChange'> {
13 source: DocumentSource | null | undefined
14 onReady?: (info: DocumentInfo) => void
15 onPageChange?: (page: number, totalPages: number) => void
16 onZoomChange?: (zoom: number) => void
17 onError?: (error: PolyRenderError) => void
18 onLoadingChange?: (loading: boolean) => void
19}
20
21export interface UseDocumentRendererReturn {
22 /** Ref to attach to the container div. */
23 containerRef: React.RefObject<HTMLDivElement | null>
24 /** Current viewer state. */
25 state: PolyRenderState
26 /** Navigate to a page. */
27 goToPage: (page: number) => void
28 /** Set zoom level. */
29 setZoom: (zoom: number | 'fit-width' | 'fit-page') => void
30 /** Whether the viewer is mounted and ready. */
31 ready: boolean
32 /** Current error, if any. */
33 error: PolyRenderError | null
34}
35
36/**
37 * React hook for the PolyRender document renderer.
38 *
39 * Manages the lifecycle of a PolyRender instance, bridging its imperative API
40 * to React's declarative model. Handles mounting, updating, and cleanup.
41 *
42 * @example
43 * ```tsx
44 * function MyViewer({ url }: { url: string }) {
45 * const { containerRef, state, goToPage } = useDocumentRenderer({
46 * source: { type: 'url', url },
47 * theme: 'dark',
48 * })
49 *
50 * return (
51 * <div>
52 * <div ref={containerRef} style={{ width: '100%', height: '600px' }} />
53 * <p>Page {state.currentPage} of {state.totalPages}</p>
54 * </div>
55 * )
56 * }
57 * ```
58 */
59export function useDocumentRenderer(
60 options: UseDocumentRendererOptions,
61): UseDocumentRendererReturn {
62 const containerRef = useRef<HTMLDivElement | null>(null)
63 const instanceRef = useRef<PolyRender | null>(null)
64 const optionsRef = useRef(options)
65
66 const [state, setState] = useState<PolyRenderState>({
67 loading: true,
68 error: null,
69 currentPage: 1,
70 totalPages: 0,
71 zoom: 1,
72 documentInfo: null,
73 })
74
75 const [ready, setReady] = useState(false)
76 const [error, setError] = useState<PolyRenderError | null>(null)
77
78 // Keep options ref current
79 optionsRef.current = options
80
81 // Mount / unmount effect
82 useEffect(() => {
83 const container = containerRef.current
84 if (!container || !options.source) return
85
86 // Clear any previous instance
87 if (instanceRef.current) {
88 instanceRef.current.destroy()
89 instanceRef.current = null
90 }
91
92 setReady(false)
93 setError(null)
94 setState((s) => ({ ...s, loading: true, error: null }))
95
96 const instance = new PolyRender(container, {
97 ...options,
98 source: options.source,
99 onReady: (info) => {
100 setReady(true)
101 setState((s) => ({
102 ...s,
103 loading: false,
104 totalPages: info.pageCount,
105 documentInfo: info,
106 }))
107 optionsRef.current.onReady?.(info)
108 },
109 onPageChange: (page, totalPages) => {
110 setState((s) => ({ ...s, currentPage: page, totalPages }))
111 optionsRef.current.onPageChange?.(page, totalPages)
112 },
113 onZoomChange: (zoom) => {
114 setState((s) => ({ ...s, zoom }))
115 optionsRef.current.onZoomChange?.(zoom)
116 },
117 onError: (err) => {
118 setError(err)
119 setState((s) => ({ ...s, loading: false, error: err }))
120 optionsRef.current.onError?.(err)
121 },
122 onLoadingChange: (loading) => {
123 setState((s) => ({ ...s, loading }))
124 optionsRef.current.onLoadingChange?.(loading)
125 },
126 })
127
128 instanceRef.current = instance
129
130 return () => {
131 instance.destroy()
132 instanceRef.current = null
133 }
134 // Re-mount when source identity changes
135 // eslint-disable-next-line react-hooks/exhaustive-deps
136 }, [options.source, options.format, options.theme])
137
138 // Update non-source options without re-mounting
139 useEffect(() => {
140 if (!instanceRef.current) return
141
142 const changed: Partial<PolyRenderOptions> = {}
143 if (options.theme) changed.theme = options.theme
144 if (options.className !== undefined) changed.className = options.className
145 if (options.zoom !== undefined) changed.zoom = options.zoom
146
147 if (Object.keys(changed).length > 0) {
148 instanceRef.current.update(changed)
149 }
150 }, [options.theme, options.className, options.zoom])
151
152 const goToPage = useCallback((page: number) => {
153 instanceRef.current?.goToPage(page)
154 }, [])
155
156 const setZoom = useCallback((zoom: number | 'fit-width' | 'fit-page') => {
157 instanceRef.current?.setZoom(zoom)
158 }, [])
159
160 return {
161 containerRef,
162 state,
163 goToPage,
164 setZoom,
165 ready,
166 error,
167 }
168}