···11+Copyright (c) 2026 Aria Quinlan
22+33+This software is provided ‘as-is’, without any express or implied
44+warranty. In no event will the authors be held liable for any damages
55+arising from the use of this software.
66+77+Permission is granted to anyone to use this software for any purpose,
88+including commercial applications, and to alter it and redistribute it
99+freely, subject to the following restrictions:
1010+1111+1. The origin of this software must not be misrepresented; you must not
1212+claim that you wrote the original software. If you use this software
1313+in a product, an acknowledgment in the product documentation would be
1414+appreciated but is not required.
1515+1616+2. Altered source versions must be plainly marked as such, and must not be
1717+misrepresented as being the original software.
1818+1919+3. This notice may not be removed or altered from any source
2020+distribution.
+404
README.md
···11+# DocView
22+33+A framework-agnostic, universal document renderer for the browser. Render PDFs, EPUBs, DOCX files, CSVs, source code, and plain text — with optional support for pre-rendered page images and chunked streaming for large documents.
44+55+**Core** (`@docview/core`) is a vanilla TypeScript library with zero framework dependencies. **React** (`@docview/react`) provides a thin wrapper component and hook. Both are designed for drop-in use in any web project.
66+77+## Features
88+99+- **Multi-format rendering** — PDF, EPUB, DOCX, CSV/TSV, source code (100+ languages), plain text
1010+- **Chunked loading** — Stream large documents via pre-rendered page images or split PDF chunks
1111+- **Fetch adapters** — Pass data directly or provide a lazy-loading callback for on-demand fetching
1212+- **CSS variable theming** — Dark and light themes built in, fully customizable via `--dv-*` variables
1313+- **Framework-agnostic** — Use vanilla JS, React, or build your own wrapper
1414+- **Lazy peer dependencies** — Only loads renderer libraries (pdfjs, epubjs, etc.) when that format is actually used
1515+- **Custom renderers** — Register your own renderer for any format via the plugin registry
1616+- **TypeScript-first** — Complete type definitions for all APIs
1717+1818+## Installation
1919+2020+```bash
2121+# Core (vanilla JS)
2222+npm install @docview/core
2323+2424+# React wrapper
2525+npm install @docview/react
2626+2727+# Install peer dependencies for the formats you need:
2828+npm install pdfjs-dist # PDF
2929+npm install epubjs # EPUB
3030+npm install docx-preview # DOCX
3131+npm install papaparse # CSV/TSV
3232+npm install highlight.js # Code syntax highlighting
3333+```
3434+3535+You only need to install peer dependencies for the formats you plan to render. Unused formats won't add to your bundle.
3636+3737+## Quick Start
3838+3939+### Vanilla JS
4040+4141+```typescript
4242+import { DocView } from '@docview/core'
4343+import '@docview/core/styles.css'
4444+4545+const viewer = new DocView(document.getElementById('viewer')!, {
4646+ source: { type: 'url', url: '/document.pdf' },
4747+ theme: 'dark',
4848+ toolbar: true,
4949+ onReady: (info) => {
5050+ console.log(`Loaded: ${info.pageCount} pages`)
5151+ },
5252+ onPageChange: (page, total) => {
5353+ console.log(`Page ${page} of ${total}`)
5454+ },
5555+})
5656+5757+// Imperative control
5858+viewer.goToPage(5)
5959+viewer.setZoom('fit-width')
6060+6161+// Clean up
6262+viewer.destroy()
6363+```
6464+6565+### React
6666+6767+```tsx
6868+import { DocumentViewer } from '@docview/react'
6969+import '@docview/core/styles.css'
7070+7171+function App() {
7272+ return (
7373+ <DocumentViewer
7474+ source={{ type: 'url', url: '/report.pdf' }}
7575+ theme="dark"
7676+ style={{ width: '100%', height: '80vh' }}
7777+ onReady={(info) => console.log(`${info.pageCount} pages`)}
7878+ onPageChange={(page, total) => console.log(`${page}/${total}`)}
7979+ />
8080+ )
8181+}
8282+```
8383+8484+### React with Ref
8585+8686+```tsx
8787+import { useRef } from 'react'
8888+import { DocumentViewer, type DocumentViewerRef } from '@docview/react'
8989+import '@docview/core/styles.css'
9090+9191+function App() {
9292+ const viewerRef = useRef<DocumentViewerRef>(null)
9393+9494+ return (
9595+ <>
9696+ <DocumentViewer
9797+ ref={viewerRef}
9898+ source={{ type: 'url', url: '/report.pdf' }}
9999+ style={{ width: '100%', height: '80vh' }}
100100+ />
101101+ <button onClick={() => viewerRef.current?.goToPage(1)}>
102102+ Go to first page
103103+ </button>
104104+ </>
105105+ )
106106+}
107107+```
108108+109109+### React Hook (headless)
110110+111111+```tsx
112112+import { useDocumentRenderer } from '@docview/react'
113113+import '@docview/core/styles.css'
114114+115115+function CustomViewer({ url }: { url: string }) {
116116+ const { containerRef, state, goToPage, setZoom } = useDocumentRenderer({
117117+ source: { type: 'url', url },
118118+ theme: 'dark',
119119+ toolbar: false, // Hide built-in toolbar, build your own
120120+ })
121121+122122+ return (
123123+ <div>
124124+ <div ref={containerRef} style={{ width: '100%', height: '600px' }} />
125125+ <div>
126126+ <button onClick={() => goToPage(state.currentPage - 1)}>Prev</button>
127127+ <span>{state.currentPage} / {state.totalPages}</span>
128128+ <button onClick={() => goToPage(state.currentPage + 1)}>Next</button>
129129+ <button onClick={() => setZoom(state.zoom * 1.2)}>Zoom In</button>
130130+ </div>
131131+ </div>
132132+ )
133133+}
134134+```
135135+136136+## Document Sources
137137+138138+DocView accepts four types of document sources:
139139+140140+### File (binary data)
141141+142142+```typescript
143143+// From a File input
144144+const file = inputElement.files[0]
145145+source = { type: 'file', data: file, filename: file.name }
146146+147147+// From an ArrayBuffer
148148+source = { type: 'file', data: arrayBuffer, mimeType: 'application/pdf' }
149149+150150+// From a Uint8Array
151151+source = { type: 'file', data: uint8Array, filename: 'doc.pdf' }
152152+```
153153+154154+### URL
155155+156156+```typescript
157157+source = { type: 'url', url: 'https://example.com/doc.pdf' }
158158+159159+// With custom headers (e.g., auth)
160160+source = {
161161+ type: 'url',
162162+ url: '/api/documents/123.pdf',
163163+ fetchOptions: { headers: { Authorization: 'Bearer ...' } },
164164+}
165165+```
166166+167167+### Pre-rendered Pages (for browsing without the original document)
168168+169169+```typescript
170170+// Direct data
171171+source = {
172172+ type: 'pages',
173173+ pages: [
174174+ { pageNumber: 1, imageUrl: '/pages/1.webp', width: 1654, height: 2339 },
175175+ { pageNumber: 2, imageUrl: '/pages/2.webp', width: 1654, height: 2339 },
176176+ ],
177177+}
178178+179179+// Lazy fetch adapter (loads pages on demand as user scrolls)
180180+source = {
181181+ type: 'pages',
182182+ pages: {
183183+ totalPages: 500,
184184+ fetchPage: async (pageNumber) => ({
185185+ pageNumber,
186186+ imageUrl: `/api/pages/${pageNumber}.webp`,
187187+ width: 1654,
188188+ height: 2339,
189189+ }),
190190+ },
191191+}
192192+```
193193+194194+### Chunked PDF (streaming large documents)
195195+196196+```typescript
197197+source = {
198198+ type: 'chunked',
199199+ totalPages: 500,
200200+ // PDF chunks for full-fidelity rendering
201201+ chunks: {
202202+ totalChunks: 10,
203203+ totalPages: 500,
204204+ fetchChunk: async (index) => {
205205+ const res = await fetch(`/api/chunks/${index}.pdf`)
206206+ return {
207207+ data: await res.arrayBuffer(),
208208+ pageStart: index * 50 + 1,
209209+ pageEnd: Math.min((index + 1) * 50, 500),
210210+ }
211211+ },
212212+ getChunkIndexForPage: (page) => Math.floor((page - 1) / 50),
213213+ },
214214+ // Optional: fast browse images while chunks load
215215+ browsePages: {
216216+ totalPages: 500,
217217+ fetchPage: async (pageNumber) => ({
218218+ pageNumber,
219219+ imageUrl: `/api/browse/${pageNumber}.webp`,
220220+ width: 1654,
221221+ height: 2339,
222222+ }),
223223+ },
224224+}
225225+```
226226+227227+## Theming
228228+229229+DocView uses CSS custom properties for all visual styling. Override any `--dv-*` variable to customize:
230230+231231+```css
232232+/* Custom theme */
233233+.my-viewer .docview {
234234+ --dv-bg: #1e1e2e;
235235+ --dv-surface: #2a2a3e;
236236+ --dv-text: #cdd6f4;
237237+ --dv-accent: #89b4fa;
238238+ --dv-border: #45475a;
239239+ --dv-page-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
240240+ --dv-font-sans: 'JetBrains Mono', monospace;
241241+}
242242+```
243243+244244+Built-in themes: `dark` (default) and `light`. Set via the `theme` prop/option, or `'system'` to auto-detect from `prefers-color-scheme`.
245245+246246+### Key CSS Variables
247247+248248+| Variable | Description |
249249+|----------|-------------|
250250+| `--dv-bg` | Background color |
251251+| `--dv-surface` | Toolbar and panel backgrounds |
252252+| `--dv-text` | Primary text color |
253253+| `--dv-text-secondary` | Secondary/muted text |
254254+| `--dv-accent` | Accent color (links, focus rings) |
255255+| `--dv-border` | Border color |
256256+| `--dv-page-bg` | Document page background |
257257+| `--dv-page-shadow` | Document page drop shadow |
258258+| `--dv-font-sans` | Sans-serif font stack |
259259+| `--dv-font-mono` | Monospace font stack |
260260+| `--dv-radius` | Border radius |
261261+| `--dv-toolbar-height` | Toolbar height |
262262+263263+See `styles.css` for the complete list.
264264+265265+## Format-Specific Options
266266+267267+### PDF
268268+269269+```typescript
270270+{
271271+ pdf: {
272272+ workerSrc: '/pdf.worker.min.js', // pdf.js worker URL
273273+ cMapUrl: '/cmaps/', // Character map directory
274274+ textLayer: true, // Enable text selection (default true)
275275+ annotationLayer: false, // Show PDF annotations
276276+ }
277277+}
278278+```
279279+280280+### Code
281281+282282+```typescript
283283+{
284284+ code: {
285285+ language: 'typescript', // Force language (auto-detected from extension)
286286+ lineNumbers: true, // Show line numbers (default true)
287287+ wordWrap: false, // Enable word wrapping (default false)
288288+ tabSize: 2, // Tab width in spaces (default 2)
289289+ }
290290+}
291291+```
292292+293293+### CSV
294294+295295+```typescript
296296+{
297297+ csv: {
298298+ delimiter: ',', // Field delimiter (auto-detected)
299299+ header: true, // First row is header (default true)
300300+ maxRows: 10000, // Max rows to render (default 10000)
301301+ sortable: true, // Enable column sorting (default true)
302302+ }
303303+}
304304+```
305305+306306+### EPUB
307307+308308+```typescript
309309+{
310310+ epub: {
311311+ flow: 'paginated', // 'paginated' or 'scrolled' (default 'paginated')
312312+ fontSize: 16, // Font size in pixels (default 16)
313313+ fontFamily: 'Georgia', // Font override
314314+ }
315315+}
316316+```
317317+318318+## Custom Renderers
319319+320320+Register a renderer for any format:
321321+322322+```typescript
323323+import { DocView, BaseRenderer, type DocViewOptions, type DocumentFormat } from '@docview/core'
324324+325325+class MarkdownRenderer extends BaseRenderer {
326326+ readonly format: DocumentFormat = 'custom-markdown'
327327+328328+ protected async onMount(viewport: HTMLElement, options: DocViewOptions) {
329329+ // Your rendering logic here
330330+ const text = await this.loadText(options.source)
331331+ const html = myMarkdownLib.render(text)
332332+ viewport.innerHTML = html
333333+ this.setReady({ format: 'custom-markdown', pageCount: 1 })
334334+ }
335335+336336+ protected onDestroy() {}
337337+}
338338+339339+// Register globally
340340+DocView.registerRenderer('custom-markdown', () => new MarkdownRenderer())
341341+342342+// Use it
343343+new DocView(container, {
344344+ source: { type: 'url', url: '/readme.md' },
345345+ format: 'custom-markdown',
346346+})
347347+```
348348+349349+## Supported Formats
350350+351351+| Format | Peer Dependency | Auto-detected Extensions |
352352+|--------|----------------|-------------------------|
353353+| PDF | `pdfjs-dist` | `.pdf` |
354354+| EPUB | `epubjs` | `.epub` |
355355+| DOCX | `docx-preview` | `.docx`, `.doc` |
356356+| CSV/TSV | `papaparse` | `.csv`, `.tsv` |
357357+| Code | `highlight.js` | `.js`, `.ts`, `.py`, `.rs`, `.go`, `.java`, `.c`, `.cpp`, +80 more |
358358+| Text | _(none)_ | `.txt` |
359359+| Markdown | `highlight.js` | `.md` (rendered as syntax-highlighted code) |
360360+| JSON | `highlight.js` | `.json` |
361361+| XML/HTML | `highlight.js` | `.xml`, `.html`, `.svg` |
362362+| Pages | _(none)_ | N/A (explicit `type: 'pages'`) |
363363+| Chunked PDF | `pdfjs-dist` | N/A (explicit `type: 'chunked'`) |
364364+365365+## Browser Support
366366+367367+- Chrome/Edge 88+
368368+- Firefox 78+
369369+- Safari 15.4+ (OffscreenCanvas support for Web Worker rendering)
370370+371371+## Project Structure
372372+373373+```
374374+packages/
375375+├── core/ @docview/core — Framework-agnostic TypeScript core
376376+│ ├── src/
377377+│ │ ├── types.ts # All interfaces and types
378378+│ │ ├── docview.ts # Main DocView class
379379+│ │ ├── renderer.ts # Abstract base renderer
380380+│ │ ├── registry.ts # Format → renderer factory mapping
381381+│ │ ├── toolbar.ts # Built-in toolbar DOM builder
382382+│ │ ├── utils.ts # Format detection, data conversion, DOM helpers
383383+│ │ ├── styles.css # CSS variables theme system
384384+│ │ └── renderers/
385385+│ │ ├── pdf.ts # PDF (pdfjs-dist)
386386+│ │ ├── browse-pages.ts # Pre-rendered page images
387387+│ │ ├── chunked-pdf.ts # Chunked PDF streaming
388388+│ │ ├── epub.ts # EPUB (epubjs)
389389+│ │ ├── docx.ts # DOCX (docx-preview)
390390+│ │ ├── csv.ts # CSV/TSV (papaparse)
391391+│ │ ├── code.ts # Code (highlight.js)
392392+│ │ └── text.ts # Plain text
393393+│ └── package.json
394394+└── react/ @docview/react — React wrapper
395395+ ├── src/
396396+ │ ├── DocumentViewer.tsx # Drop-in component
397397+ │ ├── useDocumentRenderer.ts # Headless hook
398398+ │ └── index.ts
399399+ └── package.json
400400+```
401401+402402+## License
403403+404404+MIT
+20
examples/basic/LICENSE
···11+Copyright (c) 2026 Aria Quinlan
22+33+This software is provided ‘as-is’, without any express or implied
44+warranty. In no event will the authors be held liable for any damages
55+arising from the use of this software.
66+77+Permission is granted to anyone to use this software for any purpose,
88+including commercial applications, and to alter it and redistribute it
99+freely, subject to the following restrictions:
1010+1111+1. The origin of this software must not be misrepresented; you must not
1212+claim that you wrote the original software. If you use this software
1313+in a product, an acknowledgment in the product documentation would be
1414+appreciated but is not required.
1515+1616+2. Altered source versions must be plainly marked as such, and must not be
1717+misrepresented as being the original software.
1818+1919+3. This notice may not be removed or altered from any source
2020+distribution.
···11+import { defineConfig, type Plugin } from 'vite'
22+33+/**
44+ * The core library's `requirePeerDep` does `import(moduleName)` where
55+ * `moduleName` is a variable — Vite cannot statically analyze that, so the
66+ * import fails at runtime in the browser.
77+ *
88+ * This plugin rewrites the variable import into a lookup of static `import()`
99+ * calls that Vite CAN resolve and pre-bundle.
1010+ */
1111+function resolvePeerDeps(): Plugin {
1212+ const peerDeps = [
1313+ 'pdfjs-dist',
1414+ 'epubjs',
1515+ 'docx-preview',
1616+ 'papaparse',
1717+ 'highlight.js',
1818+ 'jszip',
1919+ 'xlsx',
2020+ ]
2121+2222+ // Build a code snippet that maps module names → static imports.
2323+ // highlight.js needs `.default` unwrapping for ESM compatibility.
2424+ const cases = peerDeps
2525+ .map((d) => ` case '${d}': return import('${d}').then(m => m.default || m);`)
2626+ .join('\n')
2727+2828+ const replacement = [
2929+ '(async (name) => { switch(name) {',
3030+ cases,
3131+ ' default: throw new Error(`Unknown peer dep: ${name}`);',
3232+ ' }})(moduleName)',
3333+ ].join('\n')
3434+3535+ return {
3636+ name: 'resolve-docview-peer-deps',
3737+ enforce: 'pre',
3838+ transform(code: string, id: string) {
3939+ // Only transform the @docview/core bundle
4040+ if (!id.includes('docview')) return
4141+ if (!code.includes('moduleName')) return
4242+4343+ // Replace `await import(moduleName)` or `await import(\n moduleName\n)`
4444+ const result = code.replace(
4545+ /await\s+import\(\s*(?:\/\*.*?\*\/\s*)?moduleName\s*\)/g,
4646+ `await ${replacement}`,
4747+ )
4848+4949+ if (result !== code) return result
5050+ },
5151+ }
5252+}
5353+5454+export default defineConfig({
5555+ plugins: [resolvePeerDeps()],
5656+ optimizeDeps: {
5757+ include: [
5858+ 'pdfjs-dist',
5959+ 'epubjs',
6060+ 'docx-preview',
6161+ 'papaparse',
6262+ 'highlight.js',
6363+ 'jszip',
6464+ 'xlsx',
6565+ ],
6666+ },
6767+})
+20
examples/vanilla/LICENSE
···11+Copyright (c) 2026 Aria Quinlan
22+33+This software is provided ‘as-is’, without any express or implied
44+warranty. In no event will the authors be held liable for any damages
55+arising from the use of this software.
66+77+Permission is granted to anyone to use this software for any purpose,
88+including commercial applications, and to alter it and redistribute it
99+freely, subject to the following restrictions:
1010+1111+1. The origin of this software must not be misrepresented; you must not
1212+claim that you wrote the original software. If you use this software
1313+in a product, an acknowledgment in the product documentation would be
1414+appreciated but is not required.
1515+1616+2. Altered source versions must be plainly marked as such, and must not be
1717+misrepresented as being the original software.
1818+1919+3. This notice may not be removed or altered from any source
2020+distribution.
···11+Copyright (c) 2026 Aria Quinlan
22+33+This software is provided ‘as-is’, without any express or implied
44+warranty. In no event will the authors be held liable for any damages
55+arising from the use of this software.
66+77+Permission is granted to anyone to use this software for any purpose,
88+including commercial applications, and to alter it and redistribute it
99+freely, subject to the following restrictions:
1010+1111+1. The origin of this software must not be misrepresented; you must not
1212+claim that you wrote the original software. If you use this software
1313+in a product, an acknowledgment in the product documentation would be
1414+appreciated but is not required.
1515+1616+2. Altered source versions must be plainly marked as such, and must not be
1717+misrepresented as being the original software.
1818+1919+3. This notice may not be removed or altered from any source
2020+distribution.
···11+// ---------------------------------------------------------------------------
22+// Document Sources — what consumers pass in to tell DocView what to render
33+// ---------------------------------------------------------------------------
44+55+/** A direct file provided as binary data. */
66+export interface FileSource {
77+ type: 'file'
88+ /** The file content as a Blob, ArrayBuffer, or Uint8Array. */
99+ data: Blob | ArrayBuffer | Uint8Array
1010+ /** MIME type override. If omitted, detected from filename or data. */
1111+ mimeType?: string
1212+ /** Original filename, used for format detection and display. */
1313+ filename?: string
1414+}
1515+1616+/** A URL pointing to a remotely-hosted document. */
1717+export interface UrlSource {
1818+ type: 'url'
1919+ /** URL to fetch the document from. */
2020+ url: string
2121+ /** MIME type override. If omitted, detected from URL extension or response headers. */
2222+ mimeType?: string
2323+ /** Display filename. If omitted, derived from the URL path. */
2424+ filename?: string
2525+ /** Custom fetch options (headers, credentials, etc.). */
2626+ fetchOptions?: RequestInit
2727+}
2828+2929+/** Pre-rendered page images — for browsing without the original document. */
3030+export interface PagesSource {
3131+ type: 'pages'
3232+ /** Direct page data array, OR a fetch adapter for lazy loading. */
3333+ pages: PageData[] | PageFetchAdapter
3434+ /** Optional text layer data for search and copy/paste over images. */
3535+ textLayer?: TextLayerData[] | TextFetchAdapter
3636+}
3737+3838+/**
3939+ * Chunked document source — for streaming large files piece by piece.
4040+ * Can provide PDF chunks (for full-fidelity rendering) and/or browse page
4141+ * images (for fast initial display while chunks load).
4242+ */
4343+export interface ChunkedSource {
4444+ type: 'chunked'
4545+ /** PDF chunks for high-quality streaming, OR a fetch adapter. */
4646+ chunks: ChunkData[] | ChunkFetchAdapter
4747+ /** Total page count across all chunks. */
4848+ totalPages: number
4949+ /** Optional pre-rendered page images as fast fallback while chunks load. */
5050+ browsePages?: PageData[] | PageFetchAdapter
5151+ /** Optional text layer for browse pages. */
5252+ textLayer?: TextLayerData[] | TextFetchAdapter
5353+}
5454+5555+/** Union of all document source types. */
5656+export type DocumentSource = FileSource | UrlSource | PagesSource | ChunkedSource
5757+5858+5959+// ---------------------------------------------------------------------------
6060+// Page & Chunk Data — individual units of content
6161+// ---------------------------------------------------------------------------
6262+6363+/** A single pre-rendered page image. */
6464+export interface PageData {
6565+ /** 1-indexed page number. */
6666+ pageNumber: number
6767+ /** URL to the page image (mutually exclusive with imageBlob). */
6868+ imageUrl?: string
6969+ /** Blob containing the page image (mutually exclusive with imageUrl). */
7070+ imageBlob?: Blob
7171+ /** Image width in pixels. */
7272+ width: number
7373+ /** Image height in pixels. */
7474+ height: number
7575+}
7676+7777+/** A chunk of a PDF or other paginated document. */
7878+export interface ChunkData {
7979+ /** Binary data of the chunk (a valid, renderable PDF for PDF chunks). */
8080+ data: ArrayBuffer | Blob
8181+ /** First page in this chunk (1-indexed). */
8282+ pageStart: number
8383+ /** Last page in this chunk (1-indexed, inclusive). */
8484+ pageEnd: number
8585+}
8686+8787+/** Extracted text content for a single page, enabling search and copy/paste. */
8888+export interface TextLayerData {
8989+ /** 1-indexed page number this text belongs to. */
9090+ pageNumber: number
9191+ /** Array of text items with position information. */
9292+ items: TextItem[]
9393+}
9494+9595+/** A single text fragment with position data for overlay rendering. */
9696+export interface TextItem {
9797+ /** The text string. */
9898+ str: string
9999+ /** X position as fraction of page width (0–1). */
100100+ x: number
101101+ /** Y position as fraction of page height (0–1). */
102102+ y: number
103103+ /** Width as fraction of page width. */
104104+ width: number
105105+ /** Height as fraction of page height. */
106106+ height: number
107107+ /** Font size in points (optional). */
108108+ fontSize?: number
109109+}
110110+111111+112112+// ---------------------------------------------------------------------------
113113+// Fetch Adapters — for lazy-loading pages and chunks on demand
114114+// ---------------------------------------------------------------------------
115115+116116+/** Adapter for lazily fetching individual page images. */
117117+export interface PageFetchAdapter {
118118+ /** Total number of pages in the document. */
119119+ totalPages: number
120120+ /** Fetch a single page image by 1-indexed page number. */
121121+ fetchPage(pageNumber: number): Promise<PageData>
122122+ /** Optional batch fetch for a range of pages (inclusive). */
123123+ fetchRange?(startPage: number, endPage: number): Promise<PageData[]>
124124+ /** Optional: called when adapter is no longer needed, to clean up. */
125125+ dispose?(): void
126126+}
127127+128128+/** Adapter for lazily fetching document chunks (e.g., split PDFs). */
129129+export interface ChunkFetchAdapter {
130130+ /** Total number of chunks. */
131131+ totalChunks: number
132132+ /** Total number of pages across all chunks. */
133133+ totalPages: number
134134+ /** Fetch a chunk by 0-indexed chunk index. */
135135+ fetchChunk(index: number): Promise<ChunkData>
136136+ /** Given a 1-indexed page number, return the chunk index that contains it. */
137137+ getChunkIndexForPage(pageNumber: number): number
138138+ /** Optional: called when adapter is no longer needed. */
139139+ dispose?(): void
140140+}
141141+142142+/** Adapter for lazily fetching text layer data. */
143143+export interface TextFetchAdapter {
144144+ /** Fetch text layer for a single page. */
145145+ fetchPageText(pageNumber: number): Promise<TextLayerData>
146146+ /** Optional batch fetch. */
147147+ fetchRange?(startPage: number, endPage: number): Promise<TextLayerData[]>
148148+ /** Optional dispose. */
149149+ dispose?(): void
150150+}
151151+152152+153153+// ---------------------------------------------------------------------------
154154+// Options — configuration for the DocView instance
155155+// ---------------------------------------------------------------------------
156156+157157+export interface DocViewOptions {
158158+ /** The document to render. */
159159+ source: DocumentSource
160160+ /** Explicit format override. If omitted, auto-detected from source. */
161161+ format?: DocumentFormat
162162+ /** Color theme. Defaults to 'dark'. */
163163+ theme?: 'light' | 'dark' | 'system'
164164+ /** Additional CSS class(es) to add to the root container. */
165165+ className?: string
166166+ /** Page to display initially (1-indexed). Defaults to 1. */
167167+ initialPage?: number
168168+ /** Initial zoom level. Number = scale factor, string = fit mode. */
169169+ zoom?: number | 'fit-width' | 'fit-page' | 'auto'
170170+171171+ /** Toolbar configuration. `true` = default toolbar, `false` = hidden. */
172172+ toolbar?: boolean | ToolbarConfig
173173+ /** Show page number / total in the toolbar or overlay. */
174174+ showPageNumbers?: boolean
175175+176176+ // --- Callbacks ---
177177+178178+ /** Called when the document is loaded and first page is rendered. */
179179+ onReady?: (info: DocumentInfo) => void
180180+ /** Called when the visible page changes. */
181181+ onPageChange?: (page: number, totalPages: number) => void
182182+ /** Called on unrecoverable errors. */
183183+ onError?: (error: DocViewError) => void
184184+ /** Called when zoom level changes. */
185185+ onZoomChange?: (zoom: number) => void
186186+ /** Called when loading state changes. */
187187+ onLoadingChange?: (loading: boolean) => void
188188+189189+ // --- Format-specific options ---
190190+191191+ pdf?: PdfOptions
192192+ code?: CodeOptions
193193+ csv?: CsvOptions
194194+ epub?: EpubOptions
195195+ odt?: OdtOptions
196196+ ods?: OdsOptions
197197+}
198198+199199+export interface PdfOptions {
200200+ /** URL to the pdf.js worker script. If omitted, uses bundled worker or CDN. */
201201+ workerSrc?: string
202202+ /** URL to the character map files directory. */
203203+ cMapUrl?: string
204204+ /** Whether to render the transparent text layer for selection. Default true. */
205205+ textLayer?: boolean
206206+ /** Whether to render the annotation layer. Default false. */
207207+ annotationLayer?: boolean
208208+}
209209+210210+export interface CodeOptions {
211211+ /** Language identifier for syntax highlighting (e.g., 'typescript', 'python'). */
212212+ language?: string
213213+ /** Show line numbers. Default true. */
214214+ lineNumbers?: boolean
215215+ /** Enable word wrapping. Default false. */
216216+ wordWrap?: boolean
217217+ /** Tab size in spaces. Default 2. */
218218+ tabSize?: number
219219+}
220220+221221+export interface CsvOptions {
222222+ /** Field delimiter character. Default auto-detected, falling back to ','. */
223223+ delimiter?: string
224224+ /** Whether the first row is a header. Default true. */
225225+ header?: boolean
226226+ /** Maximum number of rows to render. Default 10000. */
227227+ maxRows?: number
228228+ /** Enable column sorting. Default true. */
229229+ sortable?: boolean
230230+}
231231+232232+export interface EpubOptions {
233233+ /** Flow mode: paginated (book-like) or scrolled. Default 'paginated'. */
234234+ flow?: 'paginated' | 'scrolled'
235235+ /** Font size in pixels. Default 16. */
236236+ fontSize?: number
237237+ /** Font family override. */
238238+ fontFamily?: string
239239+}
240240+241241+export interface OdtOptions {
242242+ /** Font size in pixels to use as a base. Default 16. */
243243+ fontSize?: number
244244+ /** Font family override. */
245245+ fontFamily?: string
246246+}
247247+248248+export interface OdsOptions {
249249+ /** Maximum number of rows to render per sheet. Default 10000. */
250250+ maxRows?: number
251251+ /** Enable column sorting. Default true. */
252252+ sortable?: boolean
253253+ /** Whether the first row is a header. Default true. */
254254+ header?: boolean
255255+}
256256+257257+export interface ToolbarConfig {
258258+ /** Show page navigation controls. Default true. */
259259+ navigation?: boolean
260260+ /** Show zoom controls. Default true. */
261261+ zoom?: boolean
262262+ /** Show format/filename display. Default true. */
263263+ info?: boolean
264264+ /** Show download button (if source is downloadable). Default false. */
265265+ download?: boolean
266266+ /** Show fullscreen toggle. Default true. */
267267+ fullscreen?: boolean
268268+ /** Toolbar position. Default 'top'. */
269269+ position?: 'top' | 'bottom'
270270+}
271271+272272+273273+// ---------------------------------------------------------------------------
274274+// Document Info & State
275275+// ---------------------------------------------------------------------------
276276+277277+/** Information about a loaded document, provided via the onReady callback. */
278278+export interface DocumentInfo {
279279+ /** Detected or overridden format. */
280280+ format: DocumentFormat
281281+ /** Total number of pages (or 1 for non-paginated formats). */
282282+ pageCount: number
283283+ /** Document title if available from metadata. */
284284+ title?: string
285285+ /** Document author if available from metadata. */
286286+ author?: string
287287+ /** Original filename if known. */
288288+ filename?: string
289289+ /** File size in bytes if known. */
290290+ fileSize?: number
291291+}
292292+293293+/** Current viewer state, queryable from a DocView instance. */
294294+export interface DocViewState {
295295+ /** Whether the document is currently loading. */
296296+ loading: boolean
297297+ /** Current error, if any. */
298298+ error: DocViewError | null
299299+ /** Current 1-indexed page number. */
300300+ currentPage: number
301301+ /** Total page count. */
302302+ totalPages: number
303303+ /** Current zoom scale factor. */
304304+ zoom: number
305305+ /** Loaded document info (null until onReady fires). */
306306+ documentInfo: DocumentInfo | null
307307+}
308308+309309+310310+// ---------------------------------------------------------------------------
311311+// Errors
312312+// ---------------------------------------------------------------------------
313313+314314+export type DocViewErrorCode =
315315+ | 'FORMAT_UNSUPPORTED'
316316+ | 'FORMAT_DETECTION_FAILED'
317317+ | 'PEER_DEPENDENCY_MISSING'
318318+ | 'SOURCE_LOAD_FAILED'
319319+ | 'RENDER_FAILED'
320320+ | 'CHUNK_LOAD_FAILED'
321321+ | 'PAGE_OUT_OF_RANGE'
322322+ | 'UNKNOWN'
323323+324324+export class DocViewError extends Error {
325325+ code: DocViewErrorCode
326326+ detail?: unknown
327327+328328+ constructor(code: DocViewErrorCode, message: string, detail?: unknown) {
329329+ super(message)
330330+ this.name = 'DocViewError'
331331+ this.code = code
332332+ this.detail = detail
333333+ }
334334+}
335335+336336+337337+// ---------------------------------------------------------------------------
338338+// Format Types
339339+// ---------------------------------------------------------------------------
340340+341341+export type DocumentFormat =
342342+ | 'pdf'
343343+ | 'epub'
344344+ | 'docx'
345345+ | 'odt'
346346+ | 'ods'
347347+ | 'csv'
348348+ | 'tsv'
349349+ | 'code'
350350+ | 'text'
351351+ | 'markdown'
352352+ | 'html'
353353+ | 'json'
354354+ | 'xml'
355355+ | 'pages' // pre-rendered page images (no original document)
356356+ | 'chunked-pdf' // chunked PDF streaming
357357+ | (string & {}) // open union — consumers can register custom formats
358358+359359+360360+// ---------------------------------------------------------------------------
361361+// Renderer Interface — implemented by each format renderer
362362+// ---------------------------------------------------------------------------
363363+364364+/**
365365+ * The contract every format renderer must fulfill. Each renderer manages
366366+ * its own DOM subtree within the provided container element. The DocView
367367+ * orchestrator calls these methods in response to user actions and source
368368+ * changes.
369369+ */
370370+export interface Renderer {
371371+ /** Unique format identifier this renderer handles. */
372372+ readonly format: DocumentFormat
373373+374374+ /**
375375+ * Initialize and render the document into the container.
376376+ * Called once after the renderer is created. The container is an empty div
377377+ * scoped to this renderer — the renderer owns its entire DOM subtree.
378378+ */
379379+ mount(container: HTMLElement, options: DocViewOptions): Promise<void>
380380+381381+ /**
382382+ * React to changed options (theme, zoom, etc.) without full re-mount.
383383+ * Only the changed fields will be present.
384384+ */
385385+ update(changed: Partial<DocViewOptions>): Promise<void>
386386+387387+ /** Navigate to a specific page (1-indexed). */
388388+ goToPage(page: number): void
389389+390390+ /** Get total page count. Returns 1 for non-paginated formats. */
391391+ getPageCount(): number
392392+393393+ /** Get current page number (1-indexed). */
394394+ getCurrentPage(): number
395395+396396+ /** Set zoom level. Accepts a scale factor or fit mode string. */
397397+ setZoom(zoom: number | 'fit-width' | 'fit-page'): void
398398+399399+ /** Get current zoom as a numeric scale factor. */
400400+ getZoom(): number
401401+402402+ /** Perform a text search within the document. Returns match count. */
403403+ search?(query: string): Promise<number>
404404+405405+ /** Navigate to the next/previous search result. */
406406+ nextSearchResult?(direction: 'forward' | 'backward'): void
407407+408408+ /** Clean up all DOM, event listeners, and resources. */
409409+ destroy(): void
410410+}
411411+412412+/**
413413+ * Factory function that creates a renderer instance. Registered in the
414414+ * format registry so DocView can instantiate the right renderer for each
415415+ * document format.
416416+ */
417417+export type RendererFactory = () => Renderer
418418+419419+420420+// ---------------------------------------------------------------------------
421421+// Events (for vanilla JS event-driven usage)
422422+// ---------------------------------------------------------------------------
423423+424424+export interface DocViewEventMap {
425425+ ready: DocumentInfo
426426+ pagechange: { page: number; totalPages: number }
427427+ zoomchange: { zoom: number }
428428+ error: DocViewError
429429+ loadingchange: { loading: boolean }
430430+ destroy: void
431431+}
432432+433433+export type DocViewEventType = keyof DocViewEventMap
+305
packages/core/src/utils.ts
···11+import type { DocumentSource, DocumentFormat, DocViewError } from './types.js'
22+import { DocViewError as DVError } from './types.js'
33+44+// ---------------------------------------------------------------------------
55+// DOM Helpers
66+// ---------------------------------------------------------------------------
77+88+/** Create an element with optional class and attributes. */
99+export function el<K extends keyof HTMLElementTagNameMap>(
1010+ tag: K,
1111+ className?: string,
1212+ attrs?: Record<string, string>,
1313+): HTMLElementTagNameMap[K] {
1414+ const element = document.createElement(tag)
1515+ if (className) element.className = className
1616+ if (attrs) {
1717+ for (const [k, v] of Object.entries(attrs)) {
1818+ element.setAttribute(k, v)
1919+ }
2020+ }
2121+ return element
2222+}
2323+2424+/** Create an SVG icon from a path string (16x16 viewBox). */
2525+export function svgIcon(pathD: string): SVGSVGElement {
2626+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
2727+ svg.setAttribute('viewBox', '0 0 16 16')
2828+ svg.setAttribute('fill', 'none')
2929+ svg.setAttribute('stroke', 'currentColor')
3030+ svg.setAttribute('stroke-width', '1.5')
3131+ svg.setAttribute('stroke-linecap', 'round')
3232+ svg.setAttribute('stroke-linejoin', 'round')
3333+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
3434+ path.setAttribute('d', pathD)
3535+ svg.appendChild(path)
3636+ return svg
3737+}
3838+3939+/** Common SVG icon paths (16x16 coordinate space). */
4040+export const icons = {
4141+ chevronLeft: 'M10 3 L5 8 L10 13',
4242+ chevronRight: 'M6 3 L11 8 L6 13',
4343+ zoomIn: 'M7.5 3v9M3 7.5h9M12.5 12.5 L15 15',
4444+ zoomOut: 'M3 7.5h9M12.5 12.5 L15 15',
4545+ fitWidth: 'M1 4h14M1 12h14M4 1v3M4 12v3M12 1v3M12 12v3',
4646+ fullscreen: 'M2 5V2h3M11 2h3v3M14 11v3h-3M5 14H2v-3',
4747+ download: 'M8 2v8M4 7l4 4 4-4M3 13h10',
4848+} as const
4949+5050+/** Remove all child nodes from an element. */
5151+export function clearElement(el: HTMLElement): void {
5252+ while (el.firstChild) el.removeChild(el.firstChild)
5353+}
5454+5555+5656+// ---------------------------------------------------------------------------
5757+// Format Detection
5858+// ---------------------------------------------------------------------------
5959+6060+const EXTENSION_MAP: Record<string, DocumentFormat> = {
6161+ pdf: 'pdf',
6262+ epub: 'epub',
6363+ docx: 'docx',
6464+ doc: 'docx',
6565+ odt: 'odt',
6666+ ods: 'ods',
6767+ csv: 'csv',
6868+ tsv: 'tsv',
6969+ txt: 'text',
7070+ text: 'text',
7171+ md: 'markdown',
7272+ markdown: 'markdown',
7373+ html: 'html',
7474+ htm: 'html',
7575+ json: 'json',
7676+ xml: 'xml',
7777+ svg: 'xml',
7878+ // Common code extensions
7979+ js: 'code', jsx: 'code', ts: 'code', tsx: 'code', mjs: 'code', cjs: 'code',
8080+ py: 'code', rb: 'code', rs: 'code', go: 'code', java: 'code', kt: 'code',
8181+ c: 'code', h: 'code', cpp: 'code', hpp: 'code', cc: 'code',
8282+ cs: 'code', swift: 'code', m: 'code',
8383+ php: 'code', pl: 'code', r: 'code', lua: 'code', zig: 'code',
8484+ sh: 'code', bash: 'code', zsh: 'code', fish: 'code', ps1: 'code',
8585+ sql: 'code', graphql: 'code', gql: 'code',
8686+ yaml: 'code', yml: 'code', toml: 'code', ini: 'code', env: 'code',
8787+ dockerfile: 'code', makefile: 'code',
8888+ css: 'code', scss: 'code', sass: 'code', less: 'code',
8989+ vue: 'code', svelte: 'code', astro: 'code',
9090+ hs: 'code', elm: 'code', ex: 'code', exs: 'code', erl: 'code',
9191+ clj: 'code', cljs: 'code', lisp: 'code', scm: 'code',
9292+ dart: 'code', scala: 'code', groovy: 'code',
9393+ proto: 'code', thrift: 'code',
9494+ tf: 'code', hcl: 'code',
9595+ sol: 'code', move: 'code',
9696+ wasm: 'code', wat: 'code',
9797+}
9898+9999+const MIME_MAP: Record<string, DocumentFormat> = {
100100+ 'application/pdf': 'pdf',
101101+ 'application/epub+zip': 'epub',
102102+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
103103+ 'application/msword': 'docx',
104104+ 'application/vnd.oasis.opendocument.text': 'odt',
105105+ 'application/vnd.oasis.opendocument.spreadsheet': 'ods',
106106+ 'text/csv': 'csv',
107107+ 'text/tab-separated-values': 'tsv',
108108+ 'text/plain': 'text',
109109+ 'text/markdown': 'markdown',
110110+ 'text/html': 'html',
111111+ 'application/json': 'json',
112112+ 'application/xml': 'xml',
113113+ 'text/xml': 'xml',
114114+ 'image/svg+xml': 'xml',
115115+ 'application/javascript': 'code',
116116+ 'text/javascript': 'code',
117117+ 'application/typescript': 'code',
118118+ 'text/x-python': 'code',
119119+ 'text/x-rust': 'code',
120120+ 'text/x-go': 'code',
121121+ 'text/x-java-source': 'code',
122122+ 'text/x-c': 'code',
123123+ 'text/x-c++src': 'code',
124124+ 'text/css': 'code',
125125+ 'text/x-yaml': 'code',
126126+ 'text/x-toml': 'code',
127127+ 'text/x-shellscript': 'code',
128128+ 'application/x-sh': 'code',
129129+ 'text/x-sql': 'code',
130130+}
131131+132132+/** Map file extension to highlight.js language identifier. */
133133+const EXTENSION_TO_LANGUAGE: Record<string, string> = {
134134+ js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
135135+ ts: 'typescript', tsx: 'typescript',
136136+ py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
137137+ kt: 'kotlin', c: 'c', h: 'c', cpp: 'cpp', hpp: 'cpp', cc: 'cpp',
138138+ cs: 'csharp', swift: 'swift', m: 'objectivec',
139139+ php: 'php', pl: 'perl', r: 'r', lua: 'lua',
140140+ sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash', ps1: 'powershell',
141141+ sql: 'sql', graphql: 'graphql',
142142+ yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini',
143143+ dockerfile: 'dockerfile', makefile: 'makefile',
144144+ css: 'css', scss: 'scss', sass: 'scss', less: 'less',
145145+ html: 'html', htm: 'html', xml: 'xml', svg: 'xml',
146146+ json: 'json', md: 'markdown', markdown: 'markdown',
147147+ vue: 'html', svelte: 'html', astro: 'html',
148148+ hs: 'haskell', elm: 'elm', ex: 'elixir', exs: 'elixir', erl: 'erlang',
149149+ clj: 'clojure', cljs: 'clojure', lisp: 'lisp', scm: 'scheme',
150150+ dart: 'dart', scala: 'scala', groovy: 'groovy',
151151+ proto: 'protobuf', tf: 'hcl', sol: 'solidity',
152152+}
153153+154154+/** Extract file extension from a filename or URL path. */
155155+export function getExtension(filenameOrUrl: string): string {
156156+ const clean = filenameOrUrl.split('?')[0].split('#')[0]
157157+ const lastSlash = clean.lastIndexOf('/')
158158+ const basename = lastSlash >= 0 ? clean.slice(lastSlash + 1) : clean
159159+ const dot = basename.lastIndexOf('.')
160160+ if (dot < 0) return basename.toLowerCase() // no extension, use whole name (e.g., "Makefile")
161161+ return basename.slice(dot + 1).toLowerCase()
162162+}
163163+164164+/** Detect document format from a source descriptor. */
165165+export function detectFormat(source: DocumentSource): DocumentFormat | null {
166166+ // Pages and chunked sources have explicit types
167167+ if (source.type === 'pages') return 'pages'
168168+ if (source.type === 'chunked') return 'chunked-pdf'
169169+170170+ // Try MIME type first
171171+ const mime = 'mimeType' in source ? source.mimeType : undefined
172172+ if (mime && MIME_MAP[mime]) return MIME_MAP[mime]
173173+174174+ // Try filename / URL extension
175175+ let name: string | undefined
176176+ if ('filename' in source) name = source.filename
177177+ if (!name && source.type === 'url') name = source.url
178178+179179+ if (name) {
180180+ const ext = getExtension(name)
181181+ if (EXTENSION_MAP[ext]) return EXTENSION_MAP[ext]
182182+ }
183183+184184+ return null
185185+}
186186+187187+/** Get highlight.js language from file extension. */
188188+export function getLanguageFromExtension(filename: string): string | undefined {
189189+ const ext = getExtension(filename)
190190+ return EXTENSION_TO_LANGUAGE[ext]
191191+}
192192+193193+/** Determine which underlying renderer format to use. Some formats alias to the same renderer. */
194194+export function getRendererFormat(format: DocumentFormat): DocumentFormat {
195195+ switch (format) {
196196+ case 'markdown':
197197+ case 'html':
198198+ case 'json':
199199+ case 'xml':
200200+ // These are rendered as code with language-specific highlighting
201201+ return 'code'
202202+ case 'tsv':
203203+ return 'csv'
204204+ case 'chunked-pdf':
205205+ return 'chunked-pdf'
206206+ case 'pages':
207207+ return 'pages'
208208+ default:
209209+ return format
210210+ }
211211+}
212212+213213+214214+// ---------------------------------------------------------------------------
215215+// Data Conversion
216216+// ---------------------------------------------------------------------------
217217+218218+/** Convert various binary types to ArrayBuffer. */
219219+export async function toArrayBuffer(
220220+ data: Blob | ArrayBuffer | Uint8Array,
221221+): Promise<ArrayBuffer> {
222222+ if (data instanceof ArrayBuffer) return data
223223+ if (data instanceof Uint8Array) return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer
224224+ return data.arrayBuffer()
225225+}
226226+227227+/** Convert various binary types to Blob. */
228228+export function toBlob(
229229+ data: Blob | ArrayBuffer | Uint8Array,
230230+ mimeType = 'application/octet-stream',
231231+): Blob {
232232+ if (data instanceof Blob) return data
233233+ return new Blob([data as BlobPart], { type: mimeType })
234234+}
235235+236236+/** Read binary data as UTF-8 text. */
237237+export async function toText(data: Blob | ArrayBuffer | Uint8Array): Promise<string> {
238238+ if (data instanceof Blob) return data.text()
239239+ const decoder = new TextDecoder('utf-8')
240240+ return decoder.decode(data instanceof ArrayBuffer ? data : data.buffer)
241241+}
242242+243243+/** Fetch a URL as ArrayBuffer with optional custom fetch options. */
244244+export async function fetchAsBuffer(
245245+ url: string,
246246+ options?: RequestInit,
247247+): Promise<ArrayBuffer> {
248248+ const response = await fetch(url, options)
249249+ if (!response.ok) {
250250+ throw new DVError(
251251+ 'SOURCE_LOAD_FAILED',
252252+ `Failed to fetch ${url}: ${response.status} ${response.statusText}`,
253253+ )
254254+ }
255255+ return response.arrayBuffer()
256256+}
257257+258258+259259+// ---------------------------------------------------------------------------
260260+// Peer Dependency Loading
261261+// ---------------------------------------------------------------------------
262262+263263+/**
264264+ * Attempt a dynamic import and throw a helpful error if the module is missing.
265265+ * Each renderer calls this for its peer dependency.
266266+ */
267267+export async function requirePeerDep<T>(
268268+ moduleName: string,
269269+ formatName: string,
270270+): Promise<T> {
271271+ try {
272272+ const mod = await import(/* @vite-ignore */ moduleName)
273273+ return mod as T
274274+ } catch {
275275+ throw new DVError(
276276+ 'PEER_DEPENDENCY_MISSING',
277277+ `The "${moduleName}" package is required to render ${formatName} files. ` +
278278+ `Install it with: npm install ${moduleName}`,
279279+ )
280280+ }
281281+}
282282+283283+284284+// ---------------------------------------------------------------------------
285285+// Misc
286286+// ---------------------------------------------------------------------------
287287+288288+/** Clamp a number between min and max. */
289289+export function clamp(value: number, min: number, max: number): number {
290290+ return Math.max(min, Math.min(max, value))
291291+}
292292+293293+/** Debounce a function. */
294294+export function debounce<T extends (...args: unknown[]) => void>(
295295+ fn: T,
296296+ ms: number,
297297+): T & { cancel(): void } {
298298+ let timer: ReturnType<typeof setTimeout>
299299+ const debounced = ((...args: unknown[]) => {
300300+ clearTimeout(timer)
301301+ timer = setTimeout(() => fn(...args), ms)
302302+ }) as T & { cancel(): void }
303303+ debounced.cancel = () => clearTimeout(timer)
304304+ return debounced
305305+}
···11+Copyright (c) 2026 Aria Quinlan
22+33+This software is provided ‘as-is’, without any express or implied
44+warranty. In no event will the authors be held liable for any damages
55+arising from the use of this software.
66+77+Permission is granted to anyone to use this software for any purpose,
88+including commercial applications, and to alter it and redistribute it
99+freely, subject to the following restrictions:
1010+1111+1. The origin of this software must not be misrepresented; you must not
1212+claim that you wrote the original software. If you use this software
1313+in a product, an acknowledgment in the product documentation would be
1414+appreciated but is not required.
1515+1616+2. Altered source versions must be plainly marked as such, and must not be
1717+misrepresented as being the original software.
1818+1919+3. This notice may not be removed or altered from any source
2020+distribution.