A framework-agnostic, universal document renderer with optional chunked loading
polyrender.wisp.place/
1import type {
2 PolyRenderOptions,
3 PolyRenderState,
4 DocumentFormat,
5 Renderer,
6 RendererFactory,
7 PolyRenderEventMap,
8 PolyRenderEventType,
9 ToolbarConfig,
10} from './types.js'
11import { PolyRenderError } from './types.js'
12import { registry } from './registry.js'
13import { detectFormat, getRendererFormat, clearElement } from './utils.js'
14import { createToolbar, type ToolbarHandle } from './toolbar.js'
15import { registerBuiltinRenderers } from './renderers/index.js'
16
17// Register built-in renderers on first import
18let registered = false
19function ensureRegistered() {
20 if (!registered) {
21 registerBuiltinRenderers()
22 registered = true
23 }
24}
25
26/**
27 * PolyRender — Universal Document Viewer
28 *
29 * Framework-agnostic entry point. Creates a document viewer inside a container
30 * element, auto-detecting the format and loading the appropriate renderer.
31 *
32 * @example
33 * ```ts
34 * import { PolyRender } from '@polyrender/core'
35 * import '@polyrender/core/styles.css'
36 *
37 * const viewer = new PolyRender(document.getElementById('viewer')!, {
38 * source: { type: 'url', url: '/document.pdf' },
39 * theme: 'dark',
40 * onReady: (info) => console.log('Loaded:', info.pageCount, 'pages'),
41 * })
42 *
43 * // Navigate
44 * viewer.goToPage(5)
45 *
46 * // Clean up
47 * viewer.destroy()
48 * ```
49 */
50export class PolyRender {
51 private container: HTMLElement
52 private options: PolyRenderOptions
53 private renderer: Renderer | null = null
54 private toolbar: ToolbarHandle | null = null
55 private root: HTMLElement
56 private listeners = new Map<string, Set<(data: unknown) => void>>()
57 private destroyed = false
58 private wrapActive = false
59
60 constructor(container: HTMLElement, options: PolyRenderOptions) {
61 ensureRegistered()
62
63 this.container = container
64 this.options = { ...options }
65
66 // Create root element
67 this.root = document.createElement('div')
68 this.root.className = `polyrender${options.className ? ` ${options.className}` : ''}`
69 this.root.setAttribute('data-theme', this.resolveTheme(options.theme))
70 container.appendChild(this.root)
71
72 // Initialize asynchronously
73 this.init().catch((err) => {
74 const error = err instanceof PolyRenderError
75 ? err
76 : new PolyRenderError('UNKNOWN', String(err), err)
77 options.onError?.(error)
78 this.emit('error', error)
79 })
80 }
81
82 // ---------------------------------------------------------------------------
83 // Public API
84 // ---------------------------------------------------------------------------
85
86 /** Navigate to a specific page (1-indexed). */
87 goToPage(page: number): void {
88 this.renderer?.goToPage(page)
89 this.updateToolbar()
90 }
91
92 /** Get the current page number. */
93 getCurrentPage(): number {
94 return this.renderer?.getCurrentPage() ?? 1
95 }
96
97 /** Get the total page count. */
98 getPageCount(): number {
99 return this.renderer?.getPageCount() ?? 0
100 }
101
102 /** Set zoom level. */
103 setZoom(zoom: number | 'fit-width' | 'fit-page'): void {
104 this.renderer?.setZoom(zoom)
105 this.updateToolbar()
106 }
107
108 /** Get current zoom as a numeric scale. */
109 getZoom(): number {
110 return this.renderer?.getZoom() ?? 1
111 }
112
113 /** Get current viewer state. */
114 getState(): PolyRenderState {
115 if (!this.renderer) {
116 return {
117 loading: true,
118 error: null,
119 currentPage: 1,
120 totalPages: 0,
121 zoom: 1,
122 documentInfo: null,
123 }
124 }
125 return {
126 loading: false,
127 error: null,
128 currentPage: this.renderer.getCurrentPage(),
129 totalPages: this.renderer.getPageCount(),
130 zoom: this.renderer.getZoom(),
131 documentInfo: null, // Would need to store from onReady
132 }
133 }
134
135 /** Update options (theme, zoom, etc.) without re-mounting. */
136 async update(changed: Partial<PolyRenderOptions>): Promise<void> {
137 Object.assign(this.options, changed)
138
139 if (changed.theme) {
140 this.root.setAttribute('data-theme', this.resolveTheme(changed.theme))
141 }
142 if (changed.className !== undefined) {
143 this.root.className = `polyrender${changed.className ? ` ${changed.className}` : ''}`
144 }
145
146 await this.renderer?.update(changed)
147 }
148
149 /** Subscribe to events. Returns an unsubscribe function. */
150 on<K extends PolyRenderEventType>(
151 event: K,
152 callback: (data: PolyRenderEventMap[K]) => void,
153 ): () => void {
154 if (!this.listeners.has(event)) {
155 this.listeners.set(event, new Set())
156 }
157 const cb = callback as (data: unknown) => void
158 this.listeners.get(event)!.add(cb)
159 return () => this.listeners.get(event)?.delete(cb)
160 }
161
162 /** Destroy the viewer and clean up all resources. */
163 destroy(): void {
164 if (this.destroyed) return
165 this.destroyed = true
166
167 this.toolbar?.destroy()
168 this.renderer?.destroy()
169 this.root.remove()
170 this.listeners.clear()
171 this.emit('destroy', undefined as never)
172 }
173
174 /** Register a custom renderer for a format. */
175 static registerRenderer(format: DocumentFormat, factory: RendererFactory): void {
176 ensureRegistered()
177 registry.register(format, factory)
178 }
179
180 /** Get all registered format names. */
181 static getFormats(): DocumentFormat[] {
182 ensureRegistered()
183 return registry.formats()
184 }
185
186 // ---------------------------------------------------------------------------
187 // Internal
188 // ---------------------------------------------------------------------------
189
190 private async init(): Promise<void> {
191 // Detect format
192 const explicitFormat = this.options.format
193 const detectedFormat = detectFormat(this.options.source)
194 const format = explicitFormat ?? detectedFormat
195
196 if (!format) {
197 throw new PolyRenderError(
198 'FORMAT_DETECTION_FAILED',
199 'Could not detect the document format. Provide a `format` option or ensure ' +
200 'the source has a recognizable filename, URL extension, or MIME type.',
201 )
202 }
203
204 // Resolve renderer format (e.g., 'markdown' -> 'code', 'tsv' -> 'csv')
205 const rendererFormat = getRendererFormat(format)
206
207 // Create renderer
208 const renderer = registry.create(rendererFormat)
209 if (!renderer) {
210 throw new PolyRenderError(
211 'FORMAT_UNSUPPORTED',
212 `No renderer registered for format "${rendererFormat}". ` +
213 `Available formats: ${registry.formats().join(', ')}`,
214 )
215 }
216
217 this.renderer = renderer
218
219 // Wire up options callbacks to also emit events
220 const originalOnReady = this.options.onReady
221 this.options.onReady = (info) => {
222 originalOnReady?.(info)
223 this.emit('ready', info)
224 this.updateToolbar()
225 }
226
227 const originalOnPageChange = this.options.onPageChange
228 this.options.onPageChange = (page, total) => {
229 originalOnPageChange?.(page, total)
230 this.emit('pagechange', { page, totalPages: total })
231 this.updateToolbar()
232 }
233
234 const originalOnZoomChange = this.options.onZoomChange
235 this.options.onZoomChange = (zoom) => {
236 originalOnZoomChange?.(zoom)
237 this.emit('zoomchange', { zoom })
238 this.updateToolbar()
239 }
240
241 const originalOnError = this.options.onError
242 this.options.onError = (err) => {
243 originalOnError?.(err)
244 this.emit('error', err)
245 }
246
247 // Formats whose renderers support the wrap/fit toggle
248 const supportsWrap =
249 rendererFormat === 'code' ||
250 rendererFormat === 'text' ||
251 rendererFormat === 'comic'
252
253 // Text renderer starts with wrap on (pre-wrap by default in CSS)
254 this.wrapActive = rendererFormat === 'text'
255
256 // Create toolbar (before renderer mount, so it appears above the viewport)
257 const toolbarOpt = this.options.toolbar
258 if (toolbarOpt !== false) {
259 const config: ToolbarConfig = toolbarOpt === true || toolbarOpt === undefined
260 ? {} // Default config
261 : toolbarOpt
262
263 this.toolbar = createToolbar(config, {
264 onPrevPage: () => this.goToPage(this.getCurrentPage() - 1),
265 onNextPage: () => this.goToPage(this.getCurrentPage() + 1),
266 onPageInput: (p) => this.goToPage(p),
267 onZoomIn: () => this.setZoom(this.getZoom() * 1.2),
268 onZoomOut: () => this.setZoom(this.getZoom() / 1.2),
269 onFitWidth: () => this.setZoom('fit-width'),
270 onFullscreen: () => this.toggleFullscreen(),
271 onWrapToggle: supportsWrap ? () => this.doWrapToggle() : undefined,
272 }, this.getState())
273
274 if (supportsWrap) {
275 this.toolbar.setWrapActive(this.wrapActive)
276 }
277
278 if (config.position === 'bottom') {
279 this.root.appendChild(this.toolbar.element)
280 } else {
281 this.root.insertBefore(this.toolbar.element, this.root.firstChild)
282 }
283 }
284
285 // Create renderer container
286 const rendererContainer = document.createElement('div')
287 rendererContainer.style.display = 'contents'
288 this.root.appendChild(rendererContainer)
289
290 // Mount renderer
291 await renderer.mount(rendererContainer, this.options)
292 }
293
294 private resolveTheme(theme?: 'light' | 'dark' | 'system'): string {
295 if (theme === 'system') {
296 return window.matchMedia('(prefers-color-scheme: dark)').matches
297 ? 'dark'
298 : 'light'
299 }
300 return theme ?? 'dark'
301 }
302
303 private updateToolbar(): void {
304 this.toolbar?.updateState(this.getState())
305 }
306
307 private doWrapToggle(): void {
308 if (!this.renderer?.toggleWrap) return
309 this.wrapActive = this.renderer.toggleWrap()
310 this.toolbar?.setWrapActive(this.wrapActive)
311 }
312
313 private toggleFullscreen(): void {
314 if (document.fullscreenElement === this.root) {
315 document.exitFullscreen()
316 } else {
317 this.root.requestFullscreen?.()
318 }
319 }
320
321 private emit<K extends PolyRenderEventType>(event: K, data: PolyRenderEventMap[K]): void {
322 const callbacks = this.listeners.get(event)
323 if (callbacks) {
324 for (const cb of callbacks) {
325 try { cb(data) } catch { /* swallow listener errors */ }
326 }
327 }
328 }
329}