@mohamed-0-turki/preview-file
v0.1.7
Published
Browser file preview library for PDF, Word, Excel, PowerPoint, images, CSV, text, and more — framework-agnostic, TypeScript-ready, and easy to integrate.
Maintainers
Readme
preview-file
A dependency-free-by-design, browser-only file preview library. Drop PDF, Word, PowerPoint, Excel, CSV, source code, text, image and archive (ZIP, 7z, RAR, TAR, TAR.GZ/TGZ, and compressed streams) files into any element and get a rich, self-managed preview — real page geometry for PDF/Word, native slide rendering for PowerPoint, spreadsheet-style data views for Excel/CSV, Monaco-powered syntax highlighting for source code, an in-place archive file browser that previews the files inside it, and a Liquid Glass multi-side control chrome that only shows the controls the active preview supports.
- No framework required. Vanilla JS is a first-class citizen; React, Vue, Svelte and Angular use the exact same
preview()call. - Capability-driven controls. Each previewer declares what it can do and the chrome renders exactly that (zoom, pages, sheets, rotate, thumbnails, lens, download, fullscreen) across the top, left, right and bottom edges.
- Lazy by default. Heavy engines (pdf.js, docx-preview, SheetJS, Monaco Editor) are loaded on demand, only when their format is actually previewed.
- Layout-free. The library renders inside whatever element you provide. It never opens a modal and never owns your page.
- Light by design. A single shared
--pf-*Liquid Glass token set drives every preview; no dark mode, no theme API to configure.
Supported formats
| Type | Extensions | Preview | Notes |
| --- | --- | --- | --- |
| PDF | .pdf | Rendered pages with real A4 geometry, page shadows, continuous / single-page mode, go-to-page, rotate | pdf.js |
| Word | .docx, .docm, .dotx, .dotm | Paginated document with headings, tables and embedded images | docx-preview |
| Word (legacy) | .doc, .dot | In-preview fallback card with a Download button | Binary format not rendered |
| PowerPoint | .pptx, .pptm, .potx, .potm, .ppsx, .ppsm | Native slide rendering with layout, colors, shapes, charts, tables and embedded images; slides-as-pages navigation, zoom, fit, continuous / single-page mode | pptx-viewer |
| PowerPoint (legacy / OpenDocument) | .ppt, .pps, .pot, .odp | In-preview fallback card with a Download button | Binary / OpenDocument formats not rendered in-browser |
| Excel | .xlsx, .xlsm, .xlsb, .xls, .xltx, .xltm, .xlt | Sheet tabs, navigation, frozen headers, virtualized grid | SheetJS |
| CSV | .csv | Virtualized table, sheet-style navigation | Built-in (no dependency) |
| Text | .txt, .log | Copy, word-wrap toggle | Built-in |
| Code | .js, .tsx, .py, .json, .hbs, .ps1, .cshtml, .tf, … any file with an extension or name Monaco recognizes — all 91 Monaco languages, plus filenames like Dockerfile, Gemfile, tsconfig.json | Read-only Monaco editor: syntax highlighting, folding, line numbers, zoom, copy, word-wrap | Monaco (CDN, lazy) |
| Markdown | .md, .markdown, .mkd, .mdwn, … | GitHub-style rendered document (tables, task lists, highlighted fenced code) with a Preview ⇄ Code toggle | Built-in (marked, lazy) |
| Images | .jpg, .png, .gif, .webp, .svg, .avif, .bmp, .apng | Zoom/fit, inspection loupe, rotate | Built-in |
| Archive | .zip, .zipx, .7z, .rar, .cab, .tar, .tar.gz, .tgz, .tar.bz2, .tbz, .tar.xz, .txz, .tar.zst, .tzst, .gz, .bz2, .xz, .zst, .ar, .deb, .cpio | Single-pane structure explorer: folders, breadcrumbs, back/forward/up/root, per-file download, password unlock; selecting a file highlights its row only (it never opens a nested preview) | Built-in (@zip.js/zip.js, fflate, 7z-wasm) |
Unsupported or oversized files render a retriable error card (with a Download button) inside the container instead of breaking your layout. Detection is table-driven from one shared format descriptor (src/archives/formats.ts), so any future archive type is added in that table plus one provider case — no MIME special-casing. RAR and CAB files can be browsed (and their single and extracted members opened/downloaded) thanks to 7-Zip's decoders embedded via 7z-wasm, but creating them is not possible here and they were verified by signature.
Install
npm install @mohamed-0-turki/preview-fileThe package is ESM-only and ships its own TypeScript types (dist/index.d.ts).
preview()requires a browser environment (document, canvas, workers). It throws in Node/SSR — see Framework usage.
Quick start
import { preview, clearPreview } from '@mohamed-0-turki/preview-file'From a File
const input = document.getElementById('file') as HTMLInputElement
input.addEventListener('change', async () => {
const file = input.files?.[0]
if (!file) return
const container = document.getElementById('preview')!
try {
await preview(file, container)
} catch (error) {
console.error(error) // container already shows the error card
}
})From a URL (string)
await preview('https://example.com/report.pdf', container)Strings are treated as URLs. The library fetches them, infers the file name from the URL, and keeps the response's content type.
Remote files must be served with permissive CORS headers. Blocked/missing URLs render the error card.
From a Blob (fetch result, canvas output, …)
const res = await fetch('/data/report.xlsx')
const blob = await res.blob()
await preview(blob, container) // name is inferred as "file"; prefer File when you have a nameClearing
// Unmounts controls, destroys renderer state, empties the container.
clearPreview(container)Options
interface PreviewOptions {
/** Maximum accepted file size, in bytes. Larger files show an error card. */
maxBytes?: number
/** URL of the pdf.js worker module (`pdf.worker.mjs`). Use to point a single
* preview at a self-hosted worker, overriding `setPdfWorkerSrc()` and the
* built-in CDN default. */
workerSrc?: string
/** Image magnifier (loupe) configuration. */
magnifier?: {
lensSize?: number // lens diameter in px (default 160)
magnification?: number // 2 | 4 | 8 | 12 | 16 (default 8)
borderWidth?: number // lens border thickness in px
}
/** Code preview (Monaco) configuration. */
monaco?: {
/** Base URL of a self-hosted Monaco AMD build — expected to contain
* `vs/loader.js`. See `setMonacoBaseUrl()`. */
baseUrl?: string
}
}await preview(file, container, {
maxBytes: 25 * 1024 * 1024, // 25 MB
workerSrc: '/workers/pdf.worker.min.mjs', // optional, self-hosted worker
magnifier: { lensSize: 180, magnification: 12 },
monaco: { baseUrl: '/vendor/monaco-editor/min' }, // optional, self-hosted Monaco
})PDF rendering & the worker
PDF pages are rendered by pdf.js, which runs its
worker in a separate thread via pdf.worker.mjs. This package never reads or inlines
that worker through a bundler-specific import, so it works identically in Vite, Webpack,
Rollup, Next.js, Nuxt, Remix, Astro and vanilla <script type="module">.
Default worker URL. The library points pdf.js at a version-pinned copy of the worker
hosted on a public CDN (cdn.jsdelivr.net), so PDF previews work out of the box with no
bundler configuration. The version always matches the pdfjs-dist instance this package
resolved, so the worker and the pdf.js API can never drift.
Bring your own worker. setPdfWorkerSrc(url) changes the worker globally; the
per-preview options.workerSrc overrides it for a single call. Provide your own worker
whenever you:
- self-host your assets (recommended for production), or
- run behind a CSP that blocks third-party scripts, or
- need to render PDFs offline / in an air-gapped environment.
The worker is only fetched when a PDF is actually rendered — never at import time.
// Global option, set once before previewing any PDF:
import { preview, setPdfWorkerSrc } from '@mohamed-0-turki/preview-file'
setPdfWorkerSrc('/workers/pdf.worker.min.mjs')
await preview(pdfUrl, container)// Per-preview option:
await preview(pdfUrl, container, { workerSrc: '/workers/pdf.worker.min.mjs' })Making the worker available in each build setup
The worker file lives at node_modules/pdfjs-dist/build/pdf.worker.min.mjs. How you serve
it depends on your setup:
Vanilla JS / any static server — copy the file next to your app:
cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/workers/Vite — copy or import it as a URL (the library itself never needs ?raw — this is
your app's choice, if you want to self-host):
import { setPdfWorkerSrc } from '@mohamed-0-turki/preview-file'
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
setPdfWorkerSrc(workerUrl as string)Next.js — copy the worker into public/:
mkdir -p public/workers
cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/workers/// lib/preview.ts
import { setPdfWorkerSrc } from '@mohamed-0-turki/preview-file'
setPdfWorkerSrc('/workers/pdf.worker.min.mjs')Webpack — copy it to your output directory (or use worker-loader/?file-loader
semantics) and point at the emitted URL. If the worker is served cross-origin, pdf.js
wraps it in a same-origin loader automatically.
If the worker URL can't be loaded, pdf.js falls back to running its worker code on the main thread (a console warning appears) and the PDF still renders.
Source-code preview & the Monaco base URL
Source code renders in a read-only Monaco Editor with syntax highlighting, folding,
line numbers, zoom (via font size) and the text controls (copy, word wrap). Monaco is not
bundled and not statically imported: the library loads Monaco's AMD build from a
version-pinned copy on a public CDN the first time a code file is previewed, exactly like
the pdf.js worker in the section above. Because the build resolves its worker URLs and
CSS against require.config.baseUrl, the CDN path works cross-origin with zero bundler
configuration.
Every Monaco language is reachable — no hand-maintained list. Monaco is capability-aware,
not MIME-aware: before the other previewers even get a vote, the code previewer asks whether
Monaco's own language registry recognizes the file — by exact file name, extension, or declared
MIME — through a generated index (src/utils/monaco-languages.ts, built from
monaco.languages.getLanguages(), 91 languages in the pinned version). Files .hbs, .ps1,
.cshtml, .tf, .pug, .sol or .razor get a Monaco editor without anyone curating a list.
Only files Monaco genuinely resolves are routed to it: a .yaml served as application/yaml,
a .py served as application/x-python-code, or a .ts reported as video/mp2t all still land
on Monaco because the extension is a Monaco language. Resolution order is: exact MIME key
(so PDF/Word/Excel/Presentation/image/Markdown/CSV/text keep their MIME-based ownership), then
capability fallback in registration order with Code first — Monaco's index, then the other
previewers' predicates. MIME-exact keys always outrank the fallback, so text/markdown stays on
Markdown, text/plain on Text, and text/csv on CSV even though Monaco declares some of them.
Files Monaco does not recognize are never forced into it — an unknown text/x-* falls to the
plain-text renderer, and a file no previewer matches shows the unsupported-file card. At render
time the language id itself is resolved the same way Monaco autodetects — extension, name,
declared MIME, #!/ shebang where Monaco declares one (python, node, …) — falling back to
plaintext.
Bring your own Monaco. Self-host the AMD build (monaco-editor/min/ — it ships
vs/loader.js, editor.main.js, the worker files and CSS) and point at it whenever you
self-host assets, run behind a CSP, or need offline previews. Two ways to configure:
// Global — set once before previewing any code file:
import { preview, setMonacoBaseUrl } from '@mohamed-0-turki/preview-file'
setMonacoBaseUrl('/vendor/monaco-editor/min')
await preview(sourceCodeFile, container)// Per-preview — overrides the global default for a single call:
await preview(sourceCodeFile, container, { monaco: { baseUrl: '/vendor/monaco-editor/min' } })The editor is fetched only when a code file is actually rendered — never at import time.
If the scripts fail to load (offline, blocked CDN), preview() rejects and the container
shows the error card — that failure is the signal to configure setMonacoBaseUrl() /
options.monaco.baseUrl for your own environment.
Look & feel
Every preview renders from a single shared Liquid Glass token set (--pf-*:
surfaces, ink, accent, chrome glass, segments, hover and panel shadows), injected
once per page scoped to .pf-root and used uniformly by the chrome, the renderers
and the state/message cards. There is exactly one light palette — no dark mode
and no theme API to configure. Renderers and custom controls source colors from
var(--pf-*, fallback) so a custom page theme can restyle the preview by
overriding the tokens on .pf-root. See ADR-0014
for the design rationale.
Markdown preview
Markdown files (.md, .markdown, .mkd, … — MIME text/markdown) open as a
GitHub-style rendered document: headings, lists, task lists, tables, block quotes,
inline code, autolinked URLs and images, styled to match GitHub's own look. Sanitized
before display — <script>, <iframe>, <form> and on*/javascript:/data: vectors
are stripped. Fenced code blocks are syntax-highlighted by Monaco's real tokenizer,
chunked off the main flow so many fences don't jank a frame; unknown fences stay plain.
A pinned Mode switch in the toolbar toggles Preview ⇄ Code: the Code view shows
the raw source in the same read-only Monaco editor (markdown language) with highlighting,
line numbers and scrolling. The editor is created lazily on the first switch to Code — a
Markdown file with no fenced blocks never fetches Monaco at all — and kept alive across
toggles (visibility is toggled, not destroyed), so switching back and forth is instant. If
Monaco can't load, the Code view falls back to a scrollable plain-text view rather than
breaking. Rendering (including the (~300 KB) marked runtime) happens only when a
Markdown file is previewed.
Archives: explorer-only browser
ZIP, ZIPX, TAR and the whole compressed-TAR family open as an in-place structure
explorer: .tar.gz/.tgz/.tar.bz2/.tbz/.tar.xz/.txz/.tar.zst/.tzst
decompress to a browsable TAR, bare .gz/.bz2/.xz/.zst streams expose a single
inner file, and .7z, .rar, .cab, .ar/.deb, .cpio … list their contents the
same way. Extraction runs in the browser via 7-Zip (7z-wasm), archive parsing for
TAR/AR/CPIO is hand-rolled, and Zip/fflate cover the rest. The archive's own explorer
UI renders inside the preview stage, while the outer toolbar keeps its File
→ Download action for saving the original archive.
- Folders & files — the listing looks like a file manager: directories first, virtualized rows for large archives, size column, per-file Download button, and back / forward / up / root navigation with clickable breadcrumbs. Selecting a file merely highlights its row; archives are explorer-only and never open a nested preview.
- Explorer-only selection — clicking a file merely highlights its row
(
.pf-arc-row--cur+aria-current); it never opens a nested preview, so the explorer and the outer chrome never share space. Symbolic and hard links show a note with their target instead of recursing. - Password-protected archives — encrypted ZIP entries decrypt once the password is
entered in the unlock bar (ZIP AES first, then ZipCrypto); encrypted
.7z/.rarmembers extract through 7-Zip with the same password. Two shapes are handled: most archives list freely and only gate reading an encrypted entry, but a header-encrypted.7z/.rarcannot list at all until the password is accepted, so the unlock bar is shown up front. Correctness is validated by reading the first encrypted file, wrong passwords are rejected in place, and unencrypted files inside a locked archive remain browsable. Passwords only live on the provider instance and are dropped when the preview closes.
The chrome & previewer capabilities
After a successful render, preview() mounts a multi-side control chrome inside the preview container: .pf-controls → .pf-top / .pf-body / .pf-bottom, with the body a row of [left rail] [stage] [right rail]. Every surface is an in-flow flex sibling of the stage — nothing is sticky, fixed or an overlay, so controllers can never cover the content. Only the controls the active previewer actually implements appear; empty regions collapse away:
Top bar — document context + once-per-session actions:
- File — file icon + ellipsized file name + format badge
- Mode — Preview ⇄ Code toggle (Markdown)
- Text — copy, word-wrap toggle (text, code, Markdown)
- Download — pinned to the right; always reachable
Left rail —
- Thumbnails — toggle (PDF, Word, PowerPoint) — unused today; self-hides when no renderer implements it (see ARCHITECTURE.md → "Thumbnails")
Right rail —
- Zoom — zoom in/out, actual size, live % chip (documents & spreadsheets and text; images get their zoom in the bottom Magnifier controller instead)
- Fit — fit width / fit page
- Rotate — clockwise / counter-clockwise / exact-degree input / reset (PDF, PowerPoint, images)
- View — continuous ↔ single page; Fullscreen
Bottom bar — live content navigation & tools:
- Sheets — spreadsheet sheet tabs (Excel, CSV), like a desktop spreadsheet app
- Pages — previous / next / go-to-page (PDF, Word, PowerPoint)
- Magnifier — bottom-centered image controller: Magnification + Lens size pill pairs with the zoom cluster (images only)
There is no overflow ⋯ menu: the top/bottom bars scroll horizontally and the
rails wrap on narrow surfaces, so every control stays reachable on every screen.
The chrome is keyboard-accessible (radiogroup segments with roving tabindex,
arrow keys, aria-pressed toggles, visible focus rings), uses ≥ 38 px touch
targets on coarse pointers, hides group labels ≤ 480 px, and honors
prefers-reduced-motion.
Archives: explorer-only browser
Opening an archive renders a single-pane structure explorer instead of a
list: .pf-arc-tree owns all archive chrome — back / forward / up / root
navigation, breadcrumbs, format badge, the virtualized entry list with sizes and
per-file download, and a footer with counts. Selecting a file highlights the
row only (.pf-arc-row--cur + aria-current); it never opens a nested
preview, so the explorer and the outer chrome never share space. Password-
protected archives boot into a dedicated Liquid Glass unlock card — lock
icon, hint, password field with a show/hide toggle, Unlock + Cancel, and inline
error/loading states; while locked the listing, breadcrumbs and footer counts
are hidden so no metadata leaks until a valid password is accepted.
Framework usage
All frameworks share one call: preview(source, container, options). The only requirement is that it runs on the client, after the container exists in the DOM.
React
Call it in an effect, once per source. Use clearPreview on unmount/source change to avoid leaks.
import { useEffect, useRef } from 'react'
import { preview, clearPreview } from '@mohamed-0-turki/preview-file'
export function FilePreview({ source }: { source: File | string | null }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
if (source == null) return
let cancelled = false
preview(source, el).catch((error) => console.error(error))
return () => {
cancelled = true
clearPreview(el)
}
}, [source])
return <div ref={ref} className="h-96 w-full" />
}Next.js (App Router)
Preview components must be client components ("use client"). To keep the heavy preview engine out of the server bundle entirely, load the previewer itself with next/dynamic and ssr: false.
// components/FilePreview.tsx
'use client'
import { useEffect, useRef } from 'react'
import dynamic from 'next/dynamic'
const DynamicPreview = dynamic(() => import('./DynamicPreview'), {
ssr: false,
loading: () => <p>Loading previewer…</p>,
})
export function FilePreview({ source }: { source: File | string | null }) {
// ...
}// components/DynamicPreview.tsx
'use client'
import { useEffect, useRef } from 'react'
import { preview, clearPreview } from '@mohamed-0-turki/preview-file'
export default function DynamicPreview({ source }: { source: File | string | null }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el || source == null) return
preview(source, el).catch((error) => console.error(error))
return () => clearPreview(el)
}, [source])
return <div ref={ref} className="h-96 w-full" />
}SSR note:
preview-fileonly touches the DOM insidepreview()/clearPreview(), which you call inside a side effect — so the module is safe to import statically.next/dynamic(..., { ssr: false })is an extra guarantee that keeps parsing / pdf.js out of the server bundle.
Next.js (Pages Router)
Identical pattern, with a .tsx client component; if you prefer, disable SSR per component. Effects only ever run on the client.
Vue / Nuxt
Vue composition:
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { preview, clearPreview } from '@mohamed-0-turki/preview-file'
const el = ref<HTMLDivElement>()
const source = ref<File | null>(null)
onMounted(async () => {
if (el.value && source.value) await preview(source.value, el.value)
})
onBeforeUnmount(() => {
if (el.value) clearPreview(el.value)
})
</script>
<template>
<div ref="el" class="preview-box" />
</template>In Nuxt, keep the component in components/ (client-rendered by default for interactive components) or mark it with client-only when the source is generated at runtime.
Svelte / SvelteKit
<script lang="ts">
import { onDestroy } from 'svelte'
import { preview, clearPreview } from '@mohamed-0-turki/preview-file'
export let source: File | string | null = null
let container: HTMLDivElement
$: if (source && container) {
preview(source, container).catch(console.error)
}
onDestroy(() => {
if (container) clearPreview(container)
})
</script>
<div bind:this={container} class="preview-box" />Angular
import { Component, ElementRef, OnDestroy, Input, AfterViewInit } from '@angular/core'
import { preview, clearPreview } from '@mohamed-0-turki/preview-file'
@Component({
selector: 'app-file-preview',
template: `<div #box class="preview-box"></div>`,
standalone: true,
})
export class FilePreviewComponent implements AfterViewInit, OnDestroy {
@Input({ required: true }) source!: File | string
private el!: HTMLDivElement
constructor(private host: ElementRef) {}
ngAfterViewInit() {
this.el = this.host.nativeElement.querySelector('div')!
preview(this.source, this.el).catch(console.error)
}
ngOnDestroy() {
clearPreview(this.el)
}
}Vanilla JS
<input type="file" id="file" />
<div id="preview" style="width: 100%; height: 70vh"></div>
<script type="module">
import { preview, clearPreview } from '@mohamed-0-turki/preview-file'
const container = document.getElementById('preview')
let current = null
document.getElementById('file').addEventListener('change', (e) => {
if (current) clearPreview(container)
current = e.target.files[0]
preview(current, container).catch(console.error)
})
</script>Container requirements
- Give the container an explicit size (
width/height). The library renders at 100 % of the container and its controls float inside it. - The container's
positionis set torelativeautomatically if it isstatic.
API reference
Functions
| Signature | Description |
| --- | --- |
| preview(source, container, options?) | Resolve, detect, parse, render and mount controls for a file into container. Resolves when the preview is mounted (parsing is async). Throws on failure — the container also shows an error card. |
| clearPreview(container) | Tear down the active preview in container (controls, renderer state) and empty it. |
| setPdfWorkerSrc(url) | Point pdf.js at a worker module URL (self-hosted, bundler-emitted, or blob). Global; overrides the CDN default. See PDF rendering & the worker. |
| setMonacoBaseUrl(url) | Point source-code previews at a self-hosted Monaco AMD build (a directory containing vs/loader.js). Global; options.monaco.baseUrl overrides it per call. See Source-code preview & the Monaco base URL. |
| getPreviewer(mimeType, name?) | Return the first previewer that can handle mimeType, or undefined. The optional file name lets Monaco's capability check resolve against Monaco's own language metadata. |
| registerPreviewer(PreviewerConstructor) | Register a custom previewer (see Extending). |
| clearPreviewers() | Remove all registered previewers. |
| getRenderer(resultType) / registerRenderer(RendererConstructor) / clearRenderers() | The renderer registry, mirrored on the previewer one (see Extending). |
| detectType(name, declaredType?) | Resolve the effective MIME type from a declared type plus the file extension. |
| parseCsv(text) | Parse CSV text into rows (string[][]) at 1 MB a time. Exported for reuse. |
Types
type SourceInput = File | Blob | string
interface ResolvedSource {
readonly name: string
readonly blob: Blob
}
interface PreviewResult {
readonly type: string // e.g. "application/pdf", "application/vnd.word",
// "application/vnd.spreadsheet", "application/vnd.presentation",
// "text/csv", "text/code", "text/plain", "image/png"
readonly data: unknown
}
interface FileInput {
readonly name: string
readonly mimeType: string
readonly data: Uint8Array<ArrayBuffer>
}
interface Previewer {
readonly name: string
readonly supportedMimeTypes: readonly string[]
canPreview(mimeType: string): boolean
preview(file: FileInput, options?: PreviewOptions): Promise<PreviewResult>
}
type PreviewerConstructor = new () => Previewer
interface Renderer {
readonly name: string
readonly supportedTypes: readonly string[]
canRender(type: string): boolean
render(
container: HTMLElement,
result: PreviewResult,
options?: PreviewOptions
): PreviewAdapter | void | Promise<PreviewAdapter | void>
destroy?(container: HTMLElement): void
}The full capability contract returned by renderers is PreviewAdapter (see src/controls/types.ts): optional canZoom, canDownload, canFullscreen, zoomPercent, zoomIn/Out/reset, download, fullscreen, plus lens, pages, fit, rotate, sheets, text, singlePage, thumbnails, viewMode (Preview ⇄ Code) — see The chrome & previewer capabilities.
Extending
The full pipeline is extensible at both ends: previewers turn a file into a typed PreviewResult, renderers turn a result into DOM plus an optional PreviewAdapter.
Custom previewer
import { registerPreviewer, preview } from '@mohamed-0-turki/preview-file'
import type { FileInput, PreviewOptions, PreviewResult } from '@mohamed-0-turki/preview-file'
class TeamNotesPreviewer {
readonly name = 'team-notes'
readonly supportedMimeTypes = ['application/x-team-notes']
canPreview(mimeType: string): boolean {
return mimeType === 'application/x-team-notes'
}
async preview(file: FileInput, options?: PreviewOptions): Promise<PreviewResult> {
return { type: 'application/x-team-notes', data: { text: new TextDecoder().decode(file.data) } }
}
}
registerPreviewer(TeamNotesPreviewer)
await preview(teamNotesFile, container)Custom renderer
import { registerRenderer } from '@mohamed-0-turki/preview-file'
import type { Renderer, PreviewResult } from '@mohamed-0-turki/preview-file'
import type { PreviewAdapter } from '@mohamed-0-turki/preview-file'
class TeamNotesRenderer implements Renderer {
readonly name = 'team-notes'
readonly supportedTypes = ['application/x-team-notes']
canRender(type: string): boolean {
return type === 'application/x-team-notes'
}
render(container: HTMLElement, result: PreviewResult): PreviewAdapter | void {
const { text } = result.data as { text: string }
const pre = document.createElement('pre')
pre.textContent = text
container.appendChild(pre)
// Return an adapter to opt into toolbar actions, or omit it for a plain view.
}
}
registerRenderer(TeamNotesRenderer)Calls to
registerPreviewer/registerRendererare additive — the built-in previewers remain registered. UseclearPreviewers()/clearRenderers()when you need to fully replace the set.
Performance notes
- Lazy loading.
pdfjs-dist,docx-preview,xlsx,pptx-viewer,markedand Monaco Editor are loaded only when their format is first previewed — Monaco through its AMD build (scripts + workers + CSS) from the configured base URL,marked(Markdown) only when a Markdown file is previewed. The demo site's initial bundle never includes them. - Virtualization. Excel sheets and large CSV files render as virtualized tables — only the visible rows are in the DOM; 80 000-row CSV files stay responsive.
- Independent containers.
preview()is container-scoped; multiple previews on one page don't interfere. - Cancellation. A generation guard makes stale async work inert: calling
clearPreview()or a newpreview()on the same container discards in-flight work from the previous call.
Browser support
Modern evergreen browsers (Chrome, Edge, Firefox, Safari). The package uses WeakMap, Uint8Array, AbortController-free fetch, dynamic import(), fullscreen/pointer APIs, and (for PowerPoint) DOMParser + SVG with foreignObject; source-code previews additionally inject a classic <script> / AMD require — all present in evergreen browsers.
Development
npm install
npm run typecheck # tsc --noEmit (noUnusedLocals/Parameters, verbatimModuleSyntax)
npm run lint # oxlint
npm run build # emits dist/Project structure
The library is organized as layered, side-effect-free modules. Each layer holds a
README.md describing its contract, and index.ts exposes its public surface.
src/
index.ts # public API (re-exports from the layers below)
preview.ts # orchestration: resolve → detect → previewer → renderer → controls
types.ts # cross-cutting types (PreviewOptions, PreviewResult, FileInput, …)
pdf-worker.ts # pdf.js worker URL resolution (setPdfWorkerSrc)
monaco.ts # Monaco base URL resolution (setMonacoBaseUrl, CDN default)
sources/ # SourceInput → ResolvedSource (File/Blob/string normalization)
previewers/ # file → typed, capability-tagged PreviewResult
registry.ts # previewer registry (built on the shared utils/registry factory)
types.ts # Previewer contract
result-types.ts # ResultData guards (`isWordResultData`, `isCsvResultData`, …)
renderers/ # result → DOM + PreviewAdapter (toolbar capabilities)
registry.ts # renderer registry (shared factory)
types.ts # Renderer contract
docview.ts # shared paged-document stage + PagedDocController (PDF, Word & PowerPoint)
render-state.ts # per-container render state helper
monaco-loader.ts # lazy AMD loader for Monaco (script injection + require.config)
monaco-view.ts # shared Monaco editor + zoom/wrap/copy controller (code + Markdown Code view)
monaco-language.ts # resolves a file to a Monaco language id via getLanguages()
monaco-languages.ts # generated routing index from Monaco's language registry (see scripts/)
monaco-types.ts # self-contained typing shim (monaco-editor is devDependency only)
interaction/ # image magnification (loupe) and zoom/pan
virtual-table.ts# shared virtualized grid (Excel & CSV) + table transforms
controls/ # capability-driven chrome (top/left/right/bottom multi-side bars)
utils/ # framework-agnostic helpers (registry, detectType, parseCsv, …)
utils/theme.ts # single .pf-root-scoped --pf-* Liquid Glass token set (light only)
scripts/
generate-monaco-languages.mjs # regenerate the routing index from Monaco's own registry
build-icons.mjs # regenerate src/icons/icons.ts + copy SVGs into dist/
docs/
adr/ # architecture decision records (why the design is what it is)
templates/ # copy-paste scaffolding for new previewers/renderers/controls
TESTING.md # testing strategy & recommended test locations
EXTENDING.md # extension points & plugin-style integration- AI agents: read
AI_GUIDE.mdbefore editing — it covers the hard rules, standard workflows and the verification loop. - See ARCHITECTURE.md for the full design, and CONTRIBUTING.md for contribution guidelines.
The interactive demo lives in playground/ — a React + Vite app that consumes the built package from the repo root via a file: dependency:
npm run build # root first, so the playground installs a fresh dist/
cd playground
npm install
npm run dev # http://localhost:5173 (predev rebuilds the package automatically)License
MIT — see LICENSE.
Icons
The toolbar and preview controls use icons from the Lucide
icon set (lucide-static v0.460.0), which are shipped as local SVG assets in
dist/icons/ and inlined into the controls at runtime so they inherit the
control styling via currentColor. Lucide is licensed under the ISC license —
see dist/icons/NOTICE.md (source: src/icons/NOTICE.md) for the full
attribution and license text.
