VerifyKitv0.5.1

Plugin Development

A complete guide to creating custom viewer plugins for the VerifyKit React PDF viewer.

This guide covers viewer plugins (ViewerPlugin) that extend the PDF viewer UI. For core engine plugins (VerifyKitPlugin) that extend the verification engine, see the Plugins guide.


Overview

The VerifyKit viewer uses a plugin architecture where every feature — zoom controls, search, page navigation, toolbar, sidebar — is implemented as a plugin. You can create custom plugins to add your own UI features, keyboard shortcuts, sidebar tabs, toolbar buttons, and document lifecycle handlers.

A viewer plugin is a plain object that implements the ViewerPlugin interface:

ts
interface ViewerPlugin {
  name: string
  dependencies?: string[]
  composedPlugins?: ViewerPlugin[]
 
  // Lifecycle hooks
  install?(ctx: PluginContext): void
  onDocumentLoad?(e: DocumentLoadEvent): void
  onDocumentUnload?(): void
  onPageChange?(e: PageChangeEvent): void
  onZoomChange?(e: ZoomChangeEvent): void
  onRotationChange?(e: RotationChangeEvent): void
  destroy?(): void
 
  // UI contributions
  renderToolbarSlot?: Partial<ToolbarSlots>
  sidebarTabs?: SidebarTabDefinition[]
  renderOverlay?: (props: OverlayRenderProps) => React.ReactNode
  renderRightPanel?: (props: OverlayRenderProps) => React.ReactNode
  renderPageOverlay?: (props: PageOverlayRenderProps) => React.ReactNode
  transformToolbarSlots?: (slots: ToolbarSlots) => ToolbarSlots
}

All properties except name are optional — implement only what your plugin needs.


Plugin Factory Function

Plugins are typically created using a factory function that accepts options and returns a ViewerPlugin object. This pattern allows users to configure the plugin at creation time:

tsx
import type { ViewerPlugin } from '@trexolab/verifykit-react'
 
interface MyPluginOptions {
  greeting?: string
}
 
function myPlugin(options: MyPluginOptions = {}): ViewerPlugin {
  const { greeting = 'Hello' } = options
 
  return {
    name: 'my-plugin',
 
    install(ctx) {
      console.log(`${greeting} from my-plugin!`)
    },
  }
}

Usage:

tsx
import { CoreViewer, VerifyKitProvider } from '@trexolab/verifykit-react'
 
const plugins = [myPlugin({ greeting: 'Hi' })]
 
function App() {
  return (
    <VerifyKitProvider config={{
      workerUrl: 'https://unpkg.com/pdfjs-dist@5.5.207/legacy/build/pdf.worker.min.mjs',
    }}>
      <CoreViewer fileUrl="/doc.pdf" plugins={plugins} />
    </VerifyKitProvider>
  )
}

Lifecycle Hooks

Plugins can respond to viewer events through lifecycle hooks. These are called in the order the plugins are registered.

install(ctx: PluginContext)

Called once when the plugin is installed into the viewer. This is where you access the store, register shortcuts, and set up any state your plugin needs.

The PluginContext provides:

PropertyTypeDescription
storeViewerStoreThe reactive viewer state store
getDocument()() => PDFDocumentProxy | nullGet the currently loaded PDF document
scrollToPage(n)(n: number) => voidScroll the viewer to a specific page
getScrollContainer()() => HTMLElement | nullGet the scroll container element
getViewerContainer()() => HTMLElement | nullGet the viewer root container
registerShortcut(s)(s: KeyboardShortcut) => () => voidRegister a keyboard shortcut
onOpenFile((file: File) => void) | undefinedOpen file handler from parent
t(key)(key: string) => stringTranslation function

onDocumentLoad(e: DocumentLoadEvent)

Called when a PDF document is successfully loaded. The event contains:

ts
interface DocumentLoadEvent {
  document: PDFDocumentProxy  // The PDF.js document proxy
  numPages: number            // Total number of pages
}

onDocumentUnload()

Called when the current document is unloaded (e.g., when a new file is opened or the viewer is unmounted).

onPageChange(e: PageChangeEvent)

Called when the visible page changes due to scrolling or navigation.

ts
interface PageChangeEvent {
  currentPage: number   // New current page (1-based)
  previousPage: number  // Previous page (1-based)
}

onZoomChange(e: ZoomChangeEvent)

Called when the zoom level changes.

ts
interface ZoomChangeEvent {
  scale: number
  previousScale: number
  fitMode: 'page-width' | 'page-fit' | 'custom'
}

onRotationChange(e: RotationChangeEvent)

Called when the page rotation changes.

ts
interface RotationChangeEvent {
  rotation: 0 | 90 | 180 | 270
  previousRotation: 0 | 90 | 180 | 270
}

destroy()

Called when the plugin is destroyed (viewer unmount). Clean up any event listeners, timers, or subscriptions here.


Adding Toolbar Slots

Plugins can contribute to named toolbar slots. Each slot is a render function that receives the store and returns React nodes:

tsx
function downloadPlugin(): ViewerPlugin {
  return {
    name: 'download',
 
    renderToolbarSlot: {
      Download: ({ store }) => {
        const handleDownload = () => {
          const { fileUrl, fileName } = store.getState()
          if (fileUrl) {
            const a = document.createElement('a')
            a.href = fileUrl
            a.download = fileName || 'document.pdf'
            a.click()
          }
        }
 
        return (
          <button onClick={handleDownload} title="Download PDF">
            Download
          </button>
        )
      },
    },
  }
}

Available toolbar slots include: SearchPopover, GoToPreviousPage, CurrentPageInput, NumberOfPages, GoToNextPage, ZoomOut, Zoom, ZoomIn, Rotate, CursorTool, OpenFile, Download, Print, ThemeToggle, FontScale, Fullscreen, MoreMenu. You can also define custom slot names.


Registering Keyboard Shortcuts

Register keyboard shortcuts through the PluginContext during install:

tsx
function myShortcutPlugin(): ViewerPlugin {
  let unsubscribe: (() => void) | null = null
 
  return {
    name: 'my-shortcuts',
 
    install(ctx) {
      unsubscribe = ctx.registerShortcut({
        id: 'my-plugin.goto-first',
        key: 'Home',
        ctrl: true,
        description: 'Go to first page',
        handler: (e) => {
          e.preventDefault()
          ctx.scrollToPage(1)
        },
      })
    },
 
    destroy() {
      unsubscribe?.()
    },
  }
}

The KeyboardShortcut interface:

PropertyTypeDescription
idstringUnique identifier
keystringKey to match (e.g., 'f', 'F3', '=', '-')
ctrlboolean?Require Ctrl/Cmd modifier
shiftboolean?Require Shift modifier
altboolean?Require Alt modifier
handler(e: KeyboardEvent) => voidHandler function
allowInInputboolean?Fire even when focus is in an input/textarea
descriptionstring?Human-readable description

The registerShortcut call returns an unsubscribe function. Call it in destroy() to clean up.


Adding Sidebar Tabs

Plugins can contribute sidebar tabs with icons, labels, and panel content:

tsx
import { BookOpen } from 'lucide-react' // or any icon library
 
interface OutlineTabProps {
  store: ViewerStore
}
 
function OutlineTab({ store }: OutlineTabProps) {
  const state = store.getState()
  return (
    <div style={{ padding: '12px' }}>
      <h3>Document Outline</h3>
      <p>Page {state.currentPage} of {state.numPages}</p>
    </div>
  )
}
 
function outlinePlugin(): ViewerPlugin {
  return {
    name: 'outline',
 
    sidebarTabs: [
      {
        id: 'outline',
        label: 'Outline',
        icon: <BookOpen size={18} />,
        component: OutlineTab,
        order: 10,          // Lower numbers appear first
        canRender: (state) => state.numPages > 1,
      },
    ],
  }
}

The SidebarTabDefinition interface:

PropertyTypeDescription
idstringUnique tab identifier
labelstringDisplay label shown in the sidebar
iconReact.ReactNodeIcon element for the tab
componentReact.ComponentType<SidebarTabProps>Tab panel component
ordernumberSort order (lower = earlier in the tab list)
canRender(state: ViewerStoreState) => booleanOptional — return false to hide the tab conditionally

The component receives { store: ViewerStore } as props, giving it full access to the viewer state.


Overlays and Page Overlays

Viewer Overlay

Render a floating overlay above the viewer (useful for find bars, notifications, floating panels):

tsx
function notificationPlugin(): ViewerPlugin {
  return {
    name: 'notification',
 
    renderOverlay: ({ store }) => {
      const { numPages } = store.getState()
      return (
        <div style={{
          position: 'absolute', top: 8, right: 8,
          padding: '8px 12px', background: '#333', color: '#fff',
          borderRadius: 4, fontSize: 12,
        }}>
          {numPages} pages loaded
        </div>
      )
    },
  }
}

Right Panel

Render a panel to the right of the document area (e.g., a signature details panel):

tsx
renderRightPanel: ({ store }) => {
  return (
    <div style={{ width: 300, padding: 16, borderLeft: '1px solid #eee' }}>
      <h3>Details</h3>
      {/* panel content */}
    </div>
  )
}

Per-Page Overlay

Render content on top of each rendered page. This is called for every visible page and receives page-specific information:

tsx
renderPageOverlay: ({ store, pageNum, scale, rotation, page }) => {
  return (
    <div style={{
      position: 'absolute', top: 4, left: 4,
      fontSize: 10, color: '#999',
    }}>
      Page {pageNum}
    </div>
  )
}

Accessing the Store

The ViewerStore is a reactive state container that plugins use to read and update viewer state. Access it through PluginContext.store:

tsx
install(ctx) {
  const store = ctx.store
 
  // Read current state
  const state = store.getState()
  console.log('Current page:', state.currentPage)
  console.log('Zoom:', state.scale)
  console.log('Fit mode:', state.fitMode)
 
  // Update state
  store.setState({ currentPage: 5 })
 
  // Subscribe to state changes
  const unsub = store.subscribe((state, prevState) => {
    if (state.currentPage !== prevState.currentPage) {
      console.log('Page changed to', state.currentPage)
    }
  })
 
  // Unsubscribe later in destroy()
}

Composing Plugins

A plugin can compose other plugins using the composedPlugins property. This is how the defaultLayoutPlugin works — it bundles toolbar, zoom, search, page navigation, and other plugins into a single meta-plugin:

tsx
function myLayoutPlugin(): ViewerPlugin {
  return {
    name: 'my-layout',
    composedPlugins: [
      zoomPlugin(),
      searchPlugin(),
      myCustomPlugin(),
    ],
  }
}

Dependencies

If your plugin requires another plugin to be installed first, declare it with dependencies:

tsx
function advancedSearchPlugin(): ViewerPlugin {
  return {
    name: 'advanced-search',
    dependencies: ['search'],  // 'search' plugin must be installed first
 
    install(ctx) {
      // Safe to assume search plugin is already installed
    },
  }
}

The viewer will warn if a dependency is not satisfied.


Full Example: Word Count Plugin

Here is a complete plugin that counts words in the current PDF and displays the count in a sidebar tab, with a keyboard shortcut to toggle the sidebar:

tsx
import React, { useEffect, useState } from 'react'
import type {
  ViewerPlugin,
  PluginContext,
  SidebarTabProps,
  DocumentLoadEvent,
} from '@trexolab/verifykit-react'
 
// ── Sidebar tab component ────────────────────────────────────────────────────
 
function WordCountTab({ store }: SidebarTabProps) {
  const [wordCount, setWordCount] = useState<number | null>(null)
  const [pageBreakdown, setPageBreakdown] = useState<Map<number, number>>(
    new Map()
  )
 
  useEffect(() => {
    // Read word count from plugin state stored in the store
    const state = store.getState() as any
    if (state._wordCountData) {
      setWordCount(state._wordCountData.total)
      setPageBreakdown(state._wordCountData.perPage)
    }
 
    const unsub = store.subscribe((s: any) => {
      if (s._wordCountData) {
        setWordCount(s._wordCountData.total)
        setPageBreakdown(s._wordCountData.perPage)
      }
    })
 
    return unsub
  }, [store])
 
  if (wordCount === null) {
    return <div style={{ padding: 16 }}>Counting words...</div>
  }
 
  return (
    <div style={{ padding: 16, fontSize: 14 }}>
      <h3 style={{ margin: '0 0 12px' }}>Word Count</h3>
      <p style={{ margin: '0 0 8px', fontWeight: 600 }}>
        Total: {wordCount.toLocaleString()} words
      </p>
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <thead>
          <tr>
            <th style={{ textAlign: 'left', padding: '4px 0' }}>Page</th>
            <th style={{ textAlign: 'right', padding: '4px 0' }}>Words</th>
          </tr>
        </thead>
        <tbody>
          {Array.from(pageBreakdown.entries()).map(([page, count]) => (
            <tr key={page}>
              <td style={{ padding: '2px 0' }}>{page}</td>
              <td style={{ textAlign: 'right', padding: '2px 0' }}>
                {count.toLocaleString()}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}
 
// ── Plugin factory ───────────────────────────────────────────────────────────
 
interface WordCountPluginOptions {
  /** Icon for the sidebar tab. Defaults to a "W" character. */
  icon?: React.ReactNode
}
 
function wordCountPlugin(options: WordCountPluginOptions = {}): ViewerPlugin {
  const { icon = <span style={{ fontWeight: 700 }}>W</span> } = options
 
  let ctx: PluginContext | null = null
  let unsubShortcut: (() => void) | null = null
 
  async function countWords(doc: import('pdfjs-dist').PDFDocumentProxy) {
    const perPage = new Map<number, number>()
    let total = 0
 
    for (let i = 1; i <= doc.numPages; i++) {
      const page = await doc.getPage(i)
      const content = await page.getTextContent()
      const text = content.items
        .map((item: any) => item.str)
        .join(' ')
      const words = text.split(/\s+/).filter((w: string) => w.length > 0).length
      perPage.set(i, words)
      total += words
    }
 
    return { total, perPage }
  }
 
  return {
    name: 'word-count',
 
    sidebarTabs: [
      {
        id: 'word-count',
        label: 'Word Count',
        icon,
        component: WordCountTab,
        order: 50,
      },
    ],
 
    install(context) {
      ctx = context
 
      // Register Ctrl+Shift+W to log the word count
      unsubShortcut = context.registerShortcut({
        id: 'word-count.log',
        key: 'w',
        ctrl: true,
        shift: true,
        description: 'Log word count to console',
        handler: (e) => {
          e.preventDefault()
          const state = context.store.getState() as any
          if (state._wordCountData) {
            console.log('Word count:', state._wordCountData.total)
          }
        },
      })
    },
 
    async onDocumentLoad(e) {
      const data = await countWords(e.document)
      ctx?.store.setState({ _wordCountData: data } as any)
    },
 
    onDocumentUnload() {
      ctx?.store.setState({ _wordCountData: null } as any)
    },
 
    destroy() {
      unsubShortcut?.()
      ctx = null
    },
  }
}
 
export { wordCountPlugin }

Usage:

tsx
import { CoreViewer, VerifyKitProvider, defaultLayoutPlugin } from '@trexolab/verifykit-react'
import { wordCountPlugin } from './word-count-plugin'
 
const plugins = [defaultLayoutPlugin(), wordCountPlugin()]
 
function App() {
  return (
    <VerifyKitProvider config={{
      workerUrl: 'https://unpkg.com/pdfjs-dist@5.5.207/legacy/build/pdf.worker.min.mjs',
    }}>
      <CoreViewer fileUrl="/report.pdf" plugins={plugins} />
    </VerifyKitProvider>
  )
}

Tips

  • Keep plugins focused. Each plugin should do one thing well. Compose multiple small plugins rather than building a monolithic one.
  • Clean up in destroy(). Always unsubscribe shortcuts, timers, and event listeners to prevent memory leaks.
  • Use the factory pattern. Return a fresh plugin object from a factory function so each viewer instance gets its own state.
  • Inline styles only. The VerifyKit SDK uses inline styles (no CSS-in-JS libraries, no Tailwind) to keep the bundle self-contained. Follow this convention in your plugins.
  • Name plugins uniquely. Plugin names must be unique within a viewer instance. Use a namespace prefix (e.g., myorg.word-count) for custom plugins.