Reference
title: "Plugin model" description: "Slot identifiers, plugin shapes, and registration semantics. Includes the replaces mechanism for shadowing first-party plugins with third-party drop-in alternatives." group: "Reference" order: 6
Plugin model
LoupePDF 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
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
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
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
interface ToolbarPlugin extends ViewerPluginManifest {
slot: "toolbar.top" | "toolbar.left" | "toolbar.bottom";
order?: number;
mount(ctx: ViewerContext): ReactNode;
}
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
interface DialogPlugin extends ViewerPluginManifest {
slot: "dialog.modal";
mount(ctx: ViewerContext): ReactNode;
}
Registering a plugin
import { register, type OverlayPlugin } from "@printwithsynergy/loupe-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
The host mounts each slot by calling getPluginsForSlot(slot):
import { Fragment } from "react";
import {
getPluginsForSlot,
type ViewerContext,
} from "@printwithsynergy/loupe-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
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 (LoupePDF / LoupePDFDemo)
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/loupe-pdf/components:
type LoupePDFShellSlot = "panel.left" | "overlay.toolbar";
interface LoupePDFShellPlugin {
id: string;
slot: LoupePDFShellSlot;
order?: number;
replaces?: string;
isAvailable?: (ctx: LoupePDFShellPluginContext) => boolean;
render: (ctx: LoupePDFShellPluginContext) => ReactNode;
}
Pass plugins directly:
<LoupePDF
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.
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 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.