Plugin model
Plugin model
Section titled “Plugin model”LensPDF mounts plugins into nine slots:
overlay.canvas— drawn on top of the page tile.panel.right,panel.left,panel.bottom— side / bottom panels.toolbar.top,toolbar.left,toolbar.bottom— toolbar pills.annotation.source— non-visual; supplies annotation data viaAnnotationSourceProvider.dialog.modal— modal dialog launched from another plugin.
The manifest
Section titled “The manifest”Every plugin shares a manifest:
interface ViewerPluginManifest { id: string; // "vendor.area.feature" version: string; // semver — bump on protocol-affecting changes slot: ViewerSlot; replaces?: string; // shadow another plugin's id in slot lookups}Visual plugins (overlay / panel / toolbar / dialog) implement
mount(ctx: ViewerContext): ReactNode. AnnotationSourceProvider
instead provides subscribe(ctx, onChange) returning an unsubscribe
callback.
ViewerContext carries the live viewer state and the same
ViewerServices your host wired up:
interface ViewerContext { readonly page: number; // 1-indexed current page readonly zoom: number; // multiplier; 1.0 = 100% readonly pan: { x: number; y: number }; // CSS px readonly viewport: { width: number; height: number }; // CSS px readonly selectionBbox: readonly [number, number, number, number] | null; readonly document: { pageCount: number; pageDimensions: ReadonlyArray<{ width: number; height: number }> }; readonly services: ViewerServices;}Plugin shapes
Section titled “Plugin shapes”OverlayPlugin
Section titled “OverlayPlugin”interface OverlayPlugin extends ViewerPluginManifest { slot: "overlay.canvas"; mount(ctx: ViewerContext): ReactNode;}Use for overlays that draw on top of the page canvas (rulers, finding boxes, brand-spec violations, etc.).
PanelPlugin
Section titled “PanelPlugin”interface PanelPlugin extends ViewerPluginManifest { slot: "panel.right" | "panel.left" | "panel.bottom"; title: string; // tab / header label order?: number; // lower renders first mount(ctx: ViewerContext): ReactNode;}ToolbarPlugin
Section titled “ToolbarPlugin”interface ToolbarPlugin extends ViewerPluginManifest { slot: "toolbar.top" | "toolbar.left" | "toolbar.bottom"; order?: number; mount(ctx: ViewerContext): ReactNode;}AnnotationSourceProvider
Section titled “AnnotationSourceProvider”Non-visual; supplies annotation data to the viewer. The viewer subscribes on mount and the provider invokes the callback with the current list and on every change.
interface AnnotationSourceProvider extends ViewerPluginManifest { slot: "annotation.source"; subscribe( ctx: ViewerContext, onChange: (annotations: ReadonlyArray<unknown>) => void, ): () => void; // returns an unsubscribe}DialogPlugin
Section titled “DialogPlugin”interface DialogPlugin extends ViewerPluginManifest { slot: "dialog.modal"; mount(ctx: ViewerContext): ReactNode;}Registering a plugin
Section titled “Registering a plugin”import { register, type OverlayPlugin } from "@printwithsynergy/lens-pdf/plugin";
const ruler: OverlayPlugin = { id: "demo.overlay.ruler", version: "0.1.0", slot: "overlay.canvas", mount(ctx) { return <RulerOverlay zoom={ctx.zoom} viewport={ctx.viewport} />; },};
register(ruler);register throws if an id is already registered or if a replaces claim
collides — both are programmer errors.
unregister(id) removes a plugin and frees any replaces claim it held.
listAll() returns every registered plugin (including the shadowed ones)
for inspection / debugging.
_resetRegistryForTesting() is exported for tests only — production code
never calls it.
Reading plugins back at render-time
Section titled “Reading plugins back at render-time”The host mounts each slot by calling getPluginsForSlot(slot):
import { Fragment } from "react";import { getPluginsForSlot, type ViewerContext,} from "@printwithsynergy/lens-pdf/plugin";
function OverlaySlot({ ctx }: { ctx: ViewerContext }) { const plugins = getPluginsForSlot("overlay.canvas"); return ( <> {plugins.map((p) => ( <Fragment key={p.id}>{p.mount(ctx)}</Fragment> ))} </> );}getPluginsForSlot returns plugins:
- Sorted by
orderascending (lowest first); insertion order breaks ties. - With anything shadowed by a
replacesclaim filtered out.
Replacing a first-party plugin
Section titled “Replacing a first-party plugin”When a plugin pack ships a drop-in alternative, set replaces on the
override:
register({ id: "thirdparty.panel.findings", version: "0.1.0", slot: "panel.right", replaces: "vendor.panel.findings", // shadow the original title: "Findings", mount: (ctx) => <ThirdPartyFindings ctx={ctx} />,});Constraints:
- The replacement must declare the same
slotas the target. Cross-slot overrides are not supported (panels can’t replace overlays, etc.). - At most one plugin can claim a given
replacestarget — a second registration that targets the same id throws. - The target id does not need to be registered yet. The override registers cleanly even before the target loads, and starts shadowing as soon as the target appears.
Viewer shell plugins (LensPDF / LensPDFDemo)
Section titled “Viewer shell plugins (LensPDF / LensPDFDemo)”The drop-in components also expose a focused shell-plugin API for sidebar/menu/tool customization without touching the global plugin registry.
Import from @printwithsynergy/lens-pdf/components:
type LensPDFShellSlot = "panel.left" | "overlay.toolbar" | "topbar";
interface LensPDFShellPlugin { id: string; slot: LensPDFShellSlot; order?: number; replaces?: string; isAvailable?: (ctx: LensPDFShellPluginContext) => boolean; render: (ctx: LensPDFShellPluginContext) => ReactNode;}Pass plugins directly:
<LensPDF pdfUrl="/proofs/abc.pdf" plugins={[ { id: "acme.left.custom", slot: "panel.left", order: 15, render: (ctx) => <div>Page {ctx.currentPage}</div>, }, ]}/>replaces uses the same shadow semantics as the global registry:
set replaces: "<builtin-id>" to override a first-party shell plugin.
Shell slots
Section titled “Shell slots”| Slot | Where it renders | Typical use |
|---|---|---|
panel.left | Tools menu — persistent left sidebar on desktop; hamburger-toggled drawer on mobile. Host menuActions render above plugin nodes here. | Mode picker, separations panel, layers panel, annotations panel, custom inspectors. |
overlay.toolbar | Sticky toolbar above the canvas. | Annotation toolbar, sticky tool palettes. |
topbar | Inside LensTopBar, to the right of the brand block. | Save-status indicators, search inputs, host-controlled stateful UI. |
For simple link / button actions in the tools menu, prefer the
declarative menuActions
prop on <LensPDF> — no plugin authoring required.
OverlayItem
Section titled “OverlayItem”Plugins and host adapters translate their domain types — findings,
annotations, brand-spec violations — into OverlayItems before handing
them to a core component. The shape is deliberately minimal:
interface OverlayItem { readonly id: string; readonly page: number; // 1-indexed readonly bbox?: readonly [number, number, number, number]; // PDF points readonly regions?: ReadonlyArray<readonly [number, number, number, number]>; readonly tier?: "error" | "warning" | "advisory" | "info" | "neutral"; readonly color?: string; // CSS hex, optional override readonly label?: string; readonly description?: string; readonly code?: string; // short identifier code readonly data?: Record<string, unknown>; // round-trip payload}PageCanvas and PageNavigator consume OverlayItem[] directly. The
default tier→colour map is error red, warning amber, advisory blue,
info / neutral slate (see SEVERITY_COLORS in /types); set color
on an item to override per-item.
Located vs. loc-less findings
Section titled “Located vs. loc-less findings”An OverlayItem is locatable when it carries a bbox, a non-empty
regions array, or both. Use the hasViewerLocation / splitFindingsByLocation
helpers from @printwithsynergy/lens-pdf/plugin rather than rolling
your own check — they’re the single source of truth the viewer uses.
| Shape | Drawn on canvas | Sidebar | Selection behavior |
|---|---|---|---|
bbox only | one box + F-number badge | yes | navigate to page + zoom-to-fit the box |
regions only | every rect highlighted; one F-number badge on the first rect | yes | navigate + zoom-to-fit the union of all rects |
bbox and regions | the bbox plus every region | yes | navigate + fit the union of bbox + regions |
| neither (loc-less) | nothing on canvas — annotation only | yes | navigate to the page; the viewer never frames a loc-less finding |
Adapter authors mapping multi-rect findings (e.g. “the same low-res
image placed in 4 corners”, every run of a misspelled word) should
populate regions so each instance highlights and the viewer can
frame them as one group. The built-in adapters in
@printwithsynergy/lens-pdf/adapters (fromCodex/Lint/Callas/Pitstop/Artwork)
pass regions through verbatim when the source carries them.