A framework-agnostic, universal document renderer with optional chunked loading polyrender.wisp.place/
at main 329 lines 9.9 kB view raw
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}