npm package: @joelouf/doc-viewer
Description: A modular, zero-dependency document page viewer built for displaying pre-rendered page images in a print-preview-style interface.
Author: joelouf
Latest version: 1.2.0

# @joelouf/doc-viewer

A modular, zero-dependency document page viewer built for displaying pre-rendered page images in a print-preview-style interface. The core logic ships as framework-agnostic modules consumed by both a **Web Component** and **React hooks**, including:
- Zoom math
- Image loading
- Virtualization
- Reduced-motion detection

This component is strictly a display layer. All document parsing happens
server-side. You provide it an array of image URLs (one per page), and it handles the rest.

## Features
 
- **Zero dependencies** - no runtime dependencies for the core or Web Component; React is an optional peer dependency for the hooks layer
- **Web Component + React hooks** - use the self-contained `<document-page-viewer>` custom element in any framework, or compose your own UI with the React hooks
- **Modular architecture** - core modules (zoom, image loading, virtualization, reduced motion) are independently importable and useful outside the viewer
- **Shadow DOM encapsulation** - styles are fully isolated and won't leak into or be affected by your application's CSS
- **CSS custom property theming** - override a handful of CSS variables to match your app's design system
- **Keyboard navigation** - full keyboard support for page navigation, zoom, fullscreen, and sidebar toggling
- **Thumbnail sidebar** - collapsible sidebar with page thumbnails for quick navigation
- **Fit modes** - fit to width, fit to page, and actual size (100%), with discrete zoom steps from 25% to 400%
- **Scroll-center-preserving zoom** - zooming in/out keeps the viewport centered on the same content
- **Image preloading** - adjacent pages are preloaded ahead of time for seamless scrolling
- **Page virtualization** - pages far from the current viewport are unloaded to conserve memory in large documents
- **Pinch-to-zoom** - native two-finger zoom on touch devices
- **Ctrl+Wheel zoom** - standard desktop zoom gesture
- **Click-and-drag panning** - grab and pan when content overflows the viewport
- **Fullscreen support** - toggle fullscreen with a button or the `F` key
- **Reduced motion support** - detects `prefers-reduced-motion` at runtime (not just via CSS) and disables smooth scrolling and animations accordingly
- **Error handling and retry** - failed page loads show an error state with a retry button
- **Loading skeletons** - shimmer placeholders while pages load
- **Mobile responsive** - adaptive toolbar with collapsible zoom controls on small screens
- **Accessible** - ARIA roles, labels, live regions, and screen reader announcements throughout

## Architecture

```
core/
  types.js                  # Constants, URL validation, fit-mode checks
  zoom.js                   # clampZoom, nextZoomStep, computeFitZoom, etc.
  image-loader.js           # ImageLoadController (load, preload, retry, virtualize)
  reduced-motion.js         # ReducedMotionController (live media query listener)
---
document-page-viewer.js     # Web Component (Shadow DOM, toolbar, thumbnails, observers)
---
react/
  useZoom.js                # Hook wrapping core zoom functions
  usePageVirtualization.js  # Hook wrapping ImageLoadController
  useDocumentViewer.js      # Composed hook (zoom + virtualization + keyboard + fullscreen)
```

Each layer is independently useful. `useZoom` can power any zoomable interface. `usePageVirtualization` can manage image loading for any scroll-based gallery. The Web Component composes them all into a self-contained viewer.

## Install
 
```bash
npm install @joelouf/doc-viewer
```

## Quick Start
 
### Plain HTML (Web Component)
 
```html
<script type="module" src="@joelouf/doc-viewer/document-page-viewer.js"></script>
 
<document-page-viewer
  id="viewer"
  show-thumbnails
  fit-mode="width"
  style="width: 100%; height: 100vh;">
</document-page-viewer>
 
<script>
  document.getElementById('viewer').pages = [
    '/pages/page-1.png',
    '/pages/page-2.png',
    '/pages/page-3.png'
  ];
</script>
```
 
### React (Hooks)
 
```jsx
import { useDocumentViewer } from '@joelouf/doc-viewer/react';
 
function Viewer({ pages }) {
  const viewer = useDocumentViewer({ pages, defaultFitMode: 'width' });
 
  // Build your own UI using:
  // viewer.zoom, viewer.currentPage, viewer.handleKeyDown, viewer.zoomIn(), viewer.zoomOut(), viewer.scrollToPage(n), etc.
}
```
 
### Vue / Svelte / Angular
 
The Web Component works in any framework:
 
```html
<script type="module">
  import '@joelouf/doc-viewer';
</script>
 
<document-page-viewer show-thumbnails fit-mode="width"></document-page-viewer>
```
 
### Core Only
 
Import the framework-agnostic modules directly:
 
```js
import {
  computeFitZoom,
  clampZoom,
  ImageLoadController
} from '@joelouf/doc-viewer/core';
 
const zoom = computeFitZoom('width', { w: 800, h: 600 });
```

### Blob Input (Dynamically Generated Pages)

Pass `Blob` objects directly as page sources. The viewer manages object URL creation and cleanup internally.

```js
// Example: canvas-rendered pages passed as Blobs.
const blobs = await Promise.all(
  canvases.map((canvas) =>
    new Promise((resolve) => canvas.toBlob(resolve, 'image/png'))
  )
);

document.getElementById('viewer').pages = blobs;
```

```jsx
// React: mixed URL and Blob sources.
const viewer = useDocumentViewer({
  pages: ['/static/cover.png', dynamicBlob, '/static/back.png'],
});
```

## API
 
### Web Component Attributes
 
| Attribute | Property | Type | Default |
|---|---|---|---|
| `pages` | `pages` | `(string \| Blob)[]` | `[]` |
| `default-zoom` | `defaultZoom` | `number` | `1.0` |
| `fit-mode` | `fitMode` | `'width' \| 'page' \| 'actual'` | `'width'` |
| `show-thumbnails` | `showThumbnails` | `boolean` | `true` |
| `class-name` | - | `string` | - |
 
The `pages` attribute accepts a JSON-stringified array of image URLs. The `pages` property accepts a native JavaScript array of URL strings, Blob objects, or a mix of both.
 
### Read-Only Properties
 
| Property | Type | Description |
|---|---|---|
| `currentPage` | `number` | Currently visible page (1-based) |
| `zoom` | `number` | Current zoom level (0.25-4.0) |
| `activeFitMode` | `string \| null` | Active fit mode, or `null` if manually zoomed |
| `prefersReducedMotion` | `boolean` | Live reduced-motion preference |
 
### Public Methods
 
| Method | Description |
|---|---|
| `scrollToPage(num)` | Scroll to a specific page (1-based) |
| `setZoom(level)` | Set zoom to a specific level (clamped to 0.25-4.0) |
 
### Events
 
| Event | `detail` |
|---|---|
| `page-change` | `{ page, previousPage, total }` |
| `zoom-change` | `{ zoom, previousZoom, percent, fitMode }` |
 
```js
const viewer = document.querySelector('document-page-viewer');
 
viewer.addEventListener('page-change', (e) => {
  console.log(`Page ${e.detail.page} of ${e.detail.total}`);
});
 
viewer.addEventListener('zoom-change', (e) => {
  console.log(`Zoom: ${e.detail.percent}`);
});
```

### Keyboard Shortcuts

| Key | Action |
|---|---|
| `↑` / `PageUp` | Previous page |
| `↓` / `PageDown` | Next page |
| `Home` / `End` | First / last page |
| `+` / `Ctrl+=` | Zoom in |
| `-` / `Ctrl+-` | Zoom out |
| `F` | Toggle fullscreen |
| `T` | Toggle thumbnails |
| `Escape` | Exit fullscreen |

## React Hooks

### `useDocumentViewer(options?)`

The primary hook. Composes zoom, virtualization, keyboard shortcuts, fullscreen, and reduced motion into a single return object.

```jsx
const viewer = useDocumentViewer({
  pages: ['/page-1.png', '/page-2.png'],
  // 'width' | 'page' | 'actual'
  defaultFitMode: 'width',
  defaultZoom: 1.0,
  showThumbnails: true,
  onPageChange: (detail) => {},
  onZoomChange: (detail) => {},
});
```

Returns state (`currentPage`, `zoom`, `fitMode`, `isFullscreen`, `prefersReducedMotion`, `totalPages`), actions (`zoomIn`, `zoomOut`, `setZoom`, `applyFitMode`, `scrollToPage`, `toggleFullscreen`, `toggleThumbnails`), virtualization methods (`loadPage`, `loadWithNeighbors`, `retryPage`, `virtualizePage`, `getPageState`, `getNaturalSize`), event handlers (`handleKeyDown`), and refs (`viewportRef`, `hostRef`).

### `useZoom(options?)`

Standalone zoom state management. Useful for building any zoomable interface.

```jsx
const { zoom, fitMode, zoomIn, zoomOut, setZoom, applyFitMode, zoomPercent } = useZoom({
  defaultZoom: 1.0,
  defaultFitMode: 'width',
  onZoomChange: (detail) => {},
});
```

### `usePageVirtualization({ pages })`
 
Standalone image loading and virtualization. Useful for any scroll-based image gallery.
 
```jsx
const {
  imageLoader, loadGeneration, getPageState, getNaturalSize,
  getFirstLoadedAspect, loadPage, loadWithNeighbors, retryPage, virtualizePage,
} = usePageVirtualization({ pages });
```

## Core Modules

All core modules are framework-agnostic, pure functions or imperative controllers with zero DOM side effects (except `ImageLoadController` which creates `<img>` elements and `ReducedMotionController` which listens to a media query).

### Zoom Functions

```js
import {
  // Clamp and snap to nearest step
  clampZoom,
  // Next discrete zoom level up
  nextZoomStep,
  // Next discrete zoom level down
  prevZoomStep,
  // Calculate zoom for a fit mode + viewport
  computeFitZoom,
  // Page display width at a zoom level
  computePageDisplaySize,
  // Scroll position adjustment after zoom change
  preserveScrollCenter,
} from '@joelouf/doc-viewer/core';
```

### Constants

```js
import {
  // ['width', 'page', 'actual']
  FIT_MODES,
  // [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]
  ZOOM_STEPS,
  // 8.5 / 11 (US Letter)
  DEFAULT_PAGE_ASPECT,
  // 816 (96dpi × 8.5 inches)
  PAGE_NATURAL_WIDTH,
  // 24px between pages
  PAGE_GAP,
  // 120px thumbnail width
  THUMB_WIDTH,
  // 164px sidebar width
  SIDEBAR_WIDTH,
  // 768px
  MOBILE_BREAKPOINT,
  // 2 pages ahead/behind
  PRELOAD_DISTANCE,
  // 5 pages before unloading
  VIRTUALIZE_DISTANCE,
} from '@joelouf/doc-viewer/core';
```

### ImageLoadController

Manages per-image state machines (idle → loading → loaded | error), preloading of adjacent pages, retry logic, and virtualization.

```js
import { ImageLoadController, ImageState } from '@joelouf/doc-viewer/core';

const loader = new ImageLoadController({
  urls: ['/page-1.png', '/page-2.png', '/page-3.png'],
  onStateChange: (index, state, img) => {
    // State is one of: ImageState.IDLE, LOADING, LOADED, ERROR
  },
});

// Load page 0 and its neighbors
loader.loadWithNeighbors(0);

// Retry a failed page
loader.retry(2);

// Unload a distant page to free memory
loader.virtualize(5);

// Clean up
loader.destroy();
```

### ReducedMotionController

Listens to the `prefers-reduced-motion` media query and detects runtime changes (e.g., user toggles the OS setting while the viewer is open).

```js
import { ReducedMotionController } from '@joelouf/doc-viewer/core';

const ctrl = new ReducedMotionController((reduced) => {
  console.log('Reduced motion:', reduced);
});

// Current state
console.log(ctrl.matches);

// Remove listener
ctrl.destroy();
```

## CSS Custom Properties
 
Style the viewer by setting CSS custom properties on the `<document-page-viewer>` element or any ancestor:
 
| Property | Default | Description |
|---|---|---|
| `--dpv-bg` | `#525659` | Viewport background |
| `--dpv-toolbar-bg` | `#2b2d31` | Toolbar background |
| `--dpv-toolbar-text` | `#d4d4d8` | Toolbar text and icon color |
| `--dpv-page-shadow` | `0 2px 16px rgba(0,0,0,0.35)` | Shadow around page frames |
| `--dpv-thumbnail-border` | `#52525b` | Thumbnail border color |
| `--dpv-accent` | `#3b82f6` | Accent color (active states, focus rings) |
 
```css
document-page-viewer {
  --dpv-bg: #1a1a2e;
  --dpv-toolbar-bg: #16213e;
  --dpv-accent: #e94560;
}
```

## URL and Source Security

The viewer validates all page sources before use.

**URL strings** — only the following are accepted:

- `https://` URLs
- Relative paths (`/`, `./`, `../`)
- `http://localhost` and `http://127.*` (for local development)

`javascript:`, `data:`, and `blob:` URL strings are rejected. External images are loaded with `crossorigin="anonymous"`.

**Blob objects** — accepted directly as page sources. The viewer creates object URLs internally via `URL.createObjectURL()` and revokes them automatically when pages change or the component disconnects. This is the recommended approach for dynamically generated page images (e.g., canvas-rendered previews).

All page content is rendered via `<img>` elements only - no `innerHTML`, `iframe`, `embed`, or `object` tags are used.

## Browser Support

Chrome 90+, Firefox 90+, Safari 15.4+, Edge 90+, iOS Safari 15.4+, Android Chrome 90+.

## License

MIT