A framework-agnostic, universal document renderer with optional chunked loading
polyrender.wisp.place/
1import type {
2 Renderer,
3 PolyRenderOptions,
4 DocumentFormat,
5 DocumentInfo,
6 PolyRenderState,
7} from './types.js'
8import { PolyRenderError } from './types.js'
9import { el, clearElement } from './utils.js'
10
11/**
12 * Abstract base class for format renderers. Provides common state management,
13 * DOM scaffolding, and helper methods. Concrete renderers extend this and
14 * implement the abstract methods.
15 *
16 * Subclasses must implement:
17 * - `onMount(viewport, options)` — render content into the viewport element
18 * - `onDestroy()` — clean up format-specific resources
19 * - `format` getter
20 */
21export abstract class BaseRenderer implements Renderer {
22 abstract readonly format: DocumentFormat
23
24 protected container!: HTMLElement
25 protected viewport!: HTMLElement
26 protected options!: PolyRenderOptions
27 protected state: PolyRenderState = {
28 loading: true,
29 error: null,
30 currentPage: 1,
31 totalPages: 1,
32 zoom: 1,
33 documentInfo: null,
34 }
35
36 async mount(container: HTMLElement, options: PolyRenderOptions): Promise<void> {
37 this.container = container
38 this.options = options
39 this.state.currentPage = options.initialPage ?? 1
40
41 // Create viewport element
42 this.viewport = el('div', 'dv-viewport')
43 this.viewport.setAttribute('role', 'document')
44 container.appendChild(this.viewport)
45
46 // Delegate to subclass
47 try {
48 await this.onMount(this.viewport, options)
49 } catch (err) {
50 const error = err instanceof PolyRenderError
51 ? err
52 : new PolyRenderError('RENDER_FAILED', String(err), err)
53 this.state.error = error
54 this.state.loading = false
55 this.showError(error)
56 throw error
57 }
58 }
59
60 async update(changed: Partial<PolyRenderOptions>): Promise<void> {
61 Object.assign(this.options, changed)
62 await this.onUpdate(changed)
63 }
64
65 goToPage(page: number): void {
66 const clamped = Math.max(1, Math.min(page, this.state.totalPages))
67 if (clamped === this.state.currentPage) return
68 this.state.currentPage = clamped
69 this.onPageChange(clamped)
70 this.options.onPageChange?.(clamped, this.state.totalPages)
71 }
72
73 getPageCount(): number {
74 return this.state.totalPages
75 }
76
77 getCurrentPage(): number {
78 return this.state.currentPage
79 }
80
81 setZoom(zoom: number | 'fit-width' | 'fit-page'): void {
82 const resolved = typeof zoom === 'number'
83 ? zoom
84 : this.resolveZoomMode(zoom)
85 this.state.zoom = resolved
86 this.onZoomChange(resolved)
87 this.options.onZoomChange?.(resolved)
88 }
89
90 getZoom(): number {
91 return this.state.zoom
92 }
93
94 destroy(): void {
95 this.onDestroy()
96 clearElement(this.container)
97 }
98
99 // --- Subclass hooks ---
100
101 /** Render the document into the viewport. */
102 protected abstract onMount(viewport: HTMLElement, options: PolyRenderOptions): Promise<void>
103
104 /** Clean up format-specific resources. */
105 protected abstract onDestroy(): void
106
107 /** React to option changes. Default: no-op. */
108 protected async onUpdate(_changed: Partial<PolyRenderOptions>): Promise<void> {}
109
110 /** Navigate to a page in the rendered content. Default: no-op. */
111 protected onPageChange(_page: number): void {}
112
113 /** Apply a zoom change. Default: no-op. */
114 protected onZoomChange(_zoom: number): void {}
115
116 // --- Helpers available to subclasses ---
117
118 /** Resolve 'fit-width' or 'fit-page' to a numeric scale based on viewport size. */
119 protected resolveZoomMode(_mode: 'fit-width' | 'fit-page'): number {
120 // Default implementation — subclasses with page dimensions override this
121 return 1
122 }
123
124 /** Show a loading spinner in the viewport. */
125 protected showLoading(message = 'Loading document…'): HTMLElement {
126 const loading = el('div', 'dv-loading')
127 loading.innerHTML = `<div class="dv-spinner"></div><span>${message}</span>`
128 this.viewport.appendChild(loading)
129 this.state.loading = true
130 this.options.onLoadingChange?.(true)
131 return loading
132 }
133
134 /** Remove loading state. */
135 protected hideLoading(): void {
136 const loading = this.viewport.querySelector('.dv-loading')
137 if (loading) loading.remove()
138 this.state.loading = false
139 this.options.onLoadingChange?.(false)
140 }
141
142 /** Show an error message in the viewport. */
143 protected showError(error: PolyRenderError): void {
144 clearElement(this.viewport)
145 const errorEl = el('div', 'dv-error')
146 errorEl.innerHTML = `
147 <div class="dv-error-code">${error.code}</div>
148 <div class="dv-error-message">${error.message}</div>
149 `
150 this.viewport.appendChild(errorEl)
151 }
152
153 /** Mark the document as ready and fire the onReady callback. */
154 protected setReady(info: DocumentInfo): void {
155 this.state.documentInfo = info
156 this.state.totalPages = info.pageCount
157 this.state.loading = false
158 this.hideLoading()
159 this.options.onReady?.(info)
160 this.options.onLoadingChange?.(false)
161 }
162
163 /** Fire page change callback (call after updating state.currentPage). */
164 protected emitPageChange(): void {
165 this.options.onPageChange?.(this.state.currentPage, this.state.totalPages)
166 }
167}