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