Authoring a plugin (the SDK)
Write plugins with @triiiceratops/plugin-sdk. The SDK is framework-neutral and
dependency-light: a plugin mounts into a plain HTMLElement, reads and controls
the viewer through the live ViewerState, and returns a cleanup function. Render
it with vanilla JavaScript, React, Vue, Svelte, Lit, or any other framework that
mounts into an element — see Rendering UI in your
framework below.
This page is the authoring reference. For the packed-consumer test kit, see the plugin testing guide.
The mount contract
Core owns the panel and flyout container; your plugin owns what renders inside it and returns a teardown function:
import type { PluginContext } from 'triiiceratops';
function mount(container: HTMLElement, context: PluginContext): () => void {
const label = document.createElement('span');
label.textContent = 'hello from a plugin';
container.appendChild(label);
// Return cleanup — run on deactivation / retry / viewer teardown.
return () => {
label.remove();
};
}
Rendering UI in your framework
mount() receives a plain HTMLElement — render into it with whatever you
already use. Each framework has an optional adapter subpath
(@triiiceratops/plugin-sdk/{react,vue,svelte,lit}) that turns a selector into
that framework's native reactive primitive (a hook, a composable, a store, a
reactive controller) so state reads stay idiomatic. Here is the same "show
whether the toolbar is open" plugin, mounted five ways:
No adapter needed — read context.selectors.select(fn) directly (see
Selectors below for the memoization/equality details):
import type { PluginContext } from 'triiiceratops';
function mount(container: HTMLElement, context: PluginContext): () => void {
const label = document.createElement('span');
const open = context.selectors.select((s) => s.toolbarOpen);
label.textContent = open.get() ? 'open' : 'closed';
const stop = open.subscribe((value) => {
label.textContent = value ? 'open' : 'closed';
});
container.appendChild(label);
return () => {
stop();
label.remove();
};
}
@triiiceratops/plugin-sdk/react provides useViewerSelector, backed by
useSyncExternalStore:
import { createRoot } from 'react-dom/client';
import { useViewerSelector } from '@triiiceratops/plugin-sdk/react';
import type { PluginContext } from 'triiiceratops';
function PluginUI({ context }: { context: PluginContext }) {
const open = useViewerSelector(context, (s) => s.toolbarOpen);
return <span>{open ? 'open' : 'closed'}</span>;
}
function mount(container: HTMLElement, context: PluginContext): () => void {
const root = createRoot(container);
root.render(<PluginUI context={context} />);
return () => root.unmount();
}
@triiiceratops/plugin-sdk/vue provides a composable returning a readonly
Ref:
import { createApp, defineComponent, h, type PropType } from 'vue';
import { useViewerSelector } from '@triiiceratops/plugin-sdk/vue';
import type { PluginContext } from 'triiiceratops';
const PluginUI = defineComponent({
props: {
context: { type: Object as PropType<PluginContext>, required: true },
},
setup(props) {
const open = useViewerSelector(props.context, (s) => s.toolbarOpen);
return () => h('span', open.value ? 'open' : 'closed');
},
});
function mount(container: HTMLElement, context: PluginContext): () => void {
const app = createApp(PluginUI, { context });
app.mount(container);
return () => app.unmount();
}
@triiiceratops/plugin-sdk/svelte exposes a selector as a Svelte readable
store:
<!-- PluginUI.svelte -->
<script lang="ts">
import { viewerSelector } from '@triiiceratops/plugin-sdk/svelte';
let { context } = $props();
const open = viewerSelector(context, (s) => s.toolbarOpen);
</script>
<span>{$open ? 'open' : 'closed'}</span>
import { mount as mountComponent, unmount } from 'svelte';
import PluginUI from './PluginUI.svelte';
import type { PluginContext } from 'triiiceratops';
function mount(container: HTMLElement, context: PluginContext): () => void {
const app = mountComponent(PluginUI, {
target: container,
props: { context },
});
return () => unmount(app);
}
Svelte hosts use the same SDK path
There is no Svelte-only shortcut. The Svelte-component plugin path
(PluginDef, createPanelPlugin, createFlyoutPlugin) was removed in
1.0, because it put Svelte component types into every consumer's type
graph. Mount your Svelte component from mount() exactly as above; set a
stable uiId if you plan to control the plugin through config.plugins.
@triiiceratops/plugin-sdk/lit provides a ReactiveController:
import { SelectorController } from '@triiiceratops/plugin-sdk/lit';
import { LitElement, html } from 'lit';
import type { PluginContext } from 'triiiceratops';
class PluginUI extends LitElement {
createRenderRoot() {
return this; // light DOM
}
toolbar?: SelectorController<boolean>;
setContext(context: PluginContext) {
this.toolbar = new SelectorController(
this,
context.selectors.select((s) => s.toolbarOpen),
);
}
render() {
return html`<span>${this.toolbar?.value ? 'open' : 'closed'}</span>`;
}
}
customElements.define('plugin-ui', PluginUI);
function mount(container: HTMLElement, context: PluginContext): () => void {
const el = document.createElement('plugin-ui') as PluginUI;
el.setContext(context);
container.appendChild(el);
return () => el.remove();
}
definePlugin
definePlugin wraps declarative metadata and a view. Registration is
side-effect-free and does not activate anything; compatibility is negotiated
later, at activation, per viewer.
import { definePlugin, svgIcon } from '@triiiceratops/plugin-sdk';
const icon = svgIcon('<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>');
export function createExamplePlugin() {
return definePlugin({
name: '@example/my-plugin', // package-qualified, keys the registry
title: 'example_title', // chrome label (tooltip + panel header):
// resolved against `catalog` in the viewer's active locale, English
// fallback, then rendered verbatim if no key matches — so a literal
// like 'Example' works too. Omit it and the toolbar shows `name`.
uiId: 'my-plugin', // stable, DOM-safe key for config.plugins[uiId]
version: '1.0.0',
coreRange: '>=1.0.0-rc.0', // core versions this plugin supports
pluginApiRange: '^1.0.0', // plugin API versions supported
requiredCapabilities: [], // e.g. ['osd@5']
icon,
target: 'panel', // default target; or 'flyout'. Host can override at
// runtime via config.plugins[uiId].target / setPluginTarget.
// There is no `position` field here — a panel's dock side is chosen
// by the consuming app, not the plugin. See "Panel position" below.
dismiss: 'light', // flyout dismiss: 'light' (default) or 'explicit'; ignored for panels
catalog: { en: { example_title: 'Example' } }, // package-owned localization
view: {
mount(container, context) {
const selector = context.selectors.select((s) => s.toolbarOpen);
const label = document.createElement('span');
label.textContent = selector.get() ? 'open' : 'closed';
const stop = selector.subscribe((open) => {
label.textContent = open ? 'open' : 'closed';
});
container.appendChild(label);
return () => {
stop();
label.remove();
};
},
},
});
}
Panel position
A definePlugin plugin has no authoring-time way to pick where its panel
docks — that choice belongs to the consuming app, since it's the app that
knows its own layout. A host sets it per plugin, keyed by the same uiId
used for visible/open/target:
// <TriiiceratopsViewer manifestId="..." config={{
// plugins: { 'my-plugin': { position: 'right' } },
// }} />
position accepts 'left' | 'right' (default 'left'), applies reactively
after mount like target, and has an imperative
sibling, ViewerState.setPluginPosition(uiId, position). It's ignored while
the plugin's effective target is 'flyout' — a flyout is anchored to its
toolbar button, not docked to a side.
Toolbar icons
Produce the toolbar icon with svgIcon(fullSvgString). It validates the SVG
synchronously and throws at the call site on invalid input — scripts, event
attributes, external resources, and foreignObject are rejected. Core owns the
icon's dimensions, focus behavior, color, and accessibility attributes; you
supply only the shape:
import { svgIcon, SvgIconError } from '@triiiceratops/plugin-sdk';
try {
const icon = svgIcon('<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" /></svg>');
void icon;
} catch (err) {
if (err instanceof SvgIconError) {
// A developer error — fix the SVG string.
}
}
Activating a plugin
The common path: pass your plugin straight to the viewer's plugins prop and
core activates it for you. The createExamplePlugin() from the sections above
slots in exactly where a pre-built plugin like ImageManipulationPlugin would
go — see using plugins for the same
example wired up in each supported framework:
<script lang="ts">
import { TriiiceratopsViewer } from 'triiiceratops/svelte';
import { createExamplePlugin } from './my-plugin';
</script>
<TriiiceratopsViewer manifestId="..." plugins={[createExamplePlugin()]} />
In module builds you can also activate a plugin explicitly against a live
ViewerState, without going through the viewer component — this is what the
test kit uses, and what you'd reach for to activate a
plugin outside of TriiiceratopsViewer (a custom host, a manual test, a
one-off script):
The constructible ViewerState class comes from triiiceratops/svelte and needs
the svelte peer installed — it is the same class the viewer component itself
uses. For tests, prefer createHeadlessViewerState() from the
test kit, which needs no Svelte.
import { CORE_VERSION, pluginApiVersion, capabilities } from 'triiiceratops';
import { ViewerState } from 'triiiceratops/svelte';
import { activatePlugin } from '@triiiceratops/plugin-sdk';
import { createExamplePlugin } from './my-plugin';
const state = new ViewerState();
const activation = activatePlugin(createExamplePlugin(), {
container: document.getElementById('host')!,
viewerState: state,
coreVersion: CORE_VERSION,
pluginApiVersion,
capabilities,
});
// Later:
activation.deactivate();
Shipping as a script tag (IIFE)
To distribute your plugin as a <script> tag for no-build-step, Web Component
hosts (see how the plugin system works
for the delivery-format overview), register it into the page-level
window.Triiiceratops registry with @triiiceratops/plugin-sdk/register
instead of exporting it from a module:
// iife.ts — your plugin's IIFE entry point, bundled standalone
import { registerBrowserPlugin } from '@triiiceratops/plugin-sdk/register';
import { createExamplePlugin } from './my-plugin';
registerBrowserPlugin(createExamplePlugin());
registerBrowserPlugin only imports a type from triiiceratops (erased at
build), so bundling it pulls no runtime and no Svelte into your plugin's
script. Registration follows the same rule as definePlugin itself — it is
side-effect-free and does not activate anything; the host's core build
discovers and activates registered plugins by name. If two scripts register
the same plugin name with different versions, the first registration wins and
the second logs a console warning.
Reading and controlling state
context.viewerState is the actual live viewer state — the sole plugin-facing
state surface. Read properties directly (reads are synchronous and always
current). Change state only through supported commands (the parity rule:
anything the viewer's own UI can do, a plugin can do through a command). Direct
property assignment is not a supported mutation API.
import type { PluginContext } from 'triiiceratops';
function example(context: PluginContext) {
const { viewerState } = context;
// Read directly.
const canvasId: string | null = viewerState.canvasId;
void canvasId;
// Mutate through commands.
viewerState.nextCanvas();
viewerState.toggleAnnotations();
}
The canvas contract
Every canvas the viewer hands you — viewerState.canvases,
viewerState.getCanvases(manifestId, sequenceIndex), and every canvas passed
into a plugin — is raw IIIF Canvas JSON, IIIF Presentation 2 or 3 exactly as
the manifest authored it. There is no wrapper object and there are no accessor
methods. The active manifest is likewise raw JSON, at
viewerState.manifestEntry?.json.
The two versions spell the same things differently — a v2 canvas uses @id and
images[], a v3 canvas uses id and items[] — and every one of these values
is typed any, so TypeScript will not tell you which one you are holding.
Rather than branch on version yourself, read them with core's version-neutral
helpers:
| Helper | From | Reads |
|---|---|---|
getPaintingAnnotations(canvas) |
triiiceratops, triiiceratops/image-export |
the canvas's image-bearing annotations |
getCanvasId(canvas) |
triiiceratops/image-export |
id / @id |
getCanvasLabel(canvas, fallbackIndex?, locale?) |
triiiceratops/image-export |
label, in any of its shapes |
getThumbnailSrc(canvas) |
triiiceratops/image-export |
a thumbnail URL, with fallbacks |
resolveCanvasImage(canvas) / resolveAllCanvasImages |
triiiceratops/image-export |
resolved image URLs and Choices |
resolveLanguageValue(value, locale?) |
triiiceratops/image-export |
any IIIF language-mapped value |
import { getPaintingAnnotations } from 'triiiceratops';
import { getCanvasId, resolveAllCanvasImages } from 'triiiceratops/image-export';
import type { PluginContext } from 'triiiceratops';
function imagesOnCurrentCanvas(context: PluginContext) {
const { viewerState } = context;
const canvas = viewerState.canvases.find(
(c: any) => getCanvasId(c) === viewerState.canvasId,
);
// Annotation-level view: what the manifest says paints this canvas.
const painting = getPaintingAnnotations(canvas);
// A v2 annotation carries its image under `resource`, a v3 one under `body`.
// Or go straight to resolved image URLs, Choices included.
return resolveAllCanvasImages(canvas);
}
getPaintingAnnotations is total: it never throws and always returns an
array, including for null, a non-canvas, or a canvas whose items/images is
a bare object rather than an array. Do not reimplement it — enumerating a canvas
by hand is the one mistake here that fails silently, as a blank canvas with no
error.
Selectors
context.selectors.select(fn) returns a memoized { get(), subscribe() }
selector. It recomputes only when state changes and notifies only when the
selected value fails the equality gate (Object.is by default, or a supplied
comparator):
import type { PluginContext } from 'triiiceratops';
function watchCanvas(context: PluginContext) {
const canvas = context.selectors.select((s) => s.canvasId);
const stop = canvas.subscribe((id) => {
console.log('canvas changed to', id);
});
return stop; // unsubscribe
}
Notifications are batched and carry no payload — a notification means "state changed, read what you need," not a transition log. Subscribers read the current value rather than reconstructing intermediate states.
Knowing whether your panel or flyout is open
Core mounts your plugin once per viewer, into a content element it moves in
and out of the open panel/flyout. mount is not re-run when the user opens or
closes your surface, and your cleanup is not run on close — that's deliberate,
so state survives a close→reopen round trip. It also means you can't use mount
and cleanup as open/close hooks.
context.surface is your plugin's own chrome. Use it to pause work that only
matters while the user can actually see your UI:
import type { PluginContext } from 'triiiceratops';
function surfaceAware(context: PluginContext) {
const { surface } = context;
// `isOpen` and `target` are live getters — never snapshot them.
const open = context.selectors.select(() => surface.isOpen);
const render = (isOpen: boolean) => {
if (isOpen) {
// Start polling, attach an expensive OSD handler, resume an
// animation — whatever is wasted while nobody can see it.
} else {
// Pause it. Keep your state: the plugin is still activated.
}
};
render(open.get()); // may already be open (config.plugins[uiId].open)
return open.subscribe(render);
}
isOpen reflects every way a surface opens or closes: the plugin's toolbar
button, a flyout light-dismiss (outside click or Escape), the consumer's
config.plugins[uiId].open, and ViewerState.setPluginOpen. Read it as a plain
getter for a one-off check, or project it through a selector (as above) to react.
Like all viewer notifications, changes land on the batched flush, not
synchronously inside the click.
The surface also lets your content close itself — a "Done" or "Apply" button inside a flyout — and tells you which chrome you're rendering in, so a compact flyout can lay out differently from a docked panel:
import type { PluginContext } from 'triiiceratops';
function surfaceControls(context: PluginContext) {
const { surface } = context;
void surface.id; // your chrome id — the `config.plugins` key
void surface.target; // 'panel' | 'flyout', follows a runtime override
const done = document.createElement('button');
done.textContent = 'Done';
done.onclick = () => surface.close(); // also: open(), toggle()
return done;
}
When a plugin is activated with no chrome at all — a bare runActivation into a
container you placed yourself — surface.isOpen is true and the movers are
no-ops: there is nothing that could be hiding your UI, so surface-gated work
runs.
The raw OpenSeadragon viewer
The raw OSD viewer is a documented pass-through: viewerState.osdViewer is
null until OSD is ready. Await readiness with the SDK helper instead of
polling:
import { whenOsdReady } from '@triiiceratops/plugin-sdk';
import type { PluginContext } from 'triiiceratops';
async function fitToViewport(context: PluginContext) {
const osd = await whenOsdReady(context.viewerState);
osd.viewport.goHome();
}
The bundled OSD major is declared as the osd@5 capability and changes only with
a core major release.
Services
The plugin context carries three root-aware services.
Styles
context.styles.install(css, id) installs a global stylesheet under a
package-qualified key. It is deduplicated across viewers sharing a root,
reference-counted, and cleaned up automatically on deactivation. It works in both
light DOM and shadow DOM and is nonce-aware for CSP (see the
Content Security Policy guide):
import type { PluginContext } from 'triiiceratops';
function installStyles(context: PluginContext) {
const uninstall = context.styles.install(
'.my-plugin-panel { padding: 1rem; }',
'panel',
);
return uninstall; // release one reference
}
Shape your CSS and its install id with definePluginStyles(css, id) — a small,
dependency-free helper that first-party plugins use to export a named STYLES /
STYLE_ID pair instead of an inline string literal, so the CSS lives in its own
module (conventionally styles.ts) and the id stays a stable, reusable
constant. context.styles.install takes the pair exactly where it took the
literal string and id above:
import { definePluginStyles } from '@triiiceratops/plugin-sdk';
// Conventionally in its own styles.ts, imported by name wherever installed.
export const { STYLES, STYLE_ID } = definePluginStyles(
'.my-plugin-panel { padding: 1rem; }',
'panel',
);
Localization
Plugin localization catalogs are package-owned. context.locale.t(key, params?)
resolves against your catalog in the viewer's active locale with English
fallback, and subscribe reacts to per-viewer locale changes:
import type { PluginContext } from 'triiiceratops';
function greeting(context: PluginContext) {
const text = context.locale.t('example_title');
const stop = context.locale.subscribe((locale) => {
console.log('active locale is now', locale);
});
return { text, stop };
}
Your plugin's core-owned chrome is localized from the same catalog: core
resolves definePlugin's title through it, so the toolbar tooltip and the
docked-panel header follow the viewer's active locale exactly like the strings
you resolve yourself. name is identity, never copy — a plugin that omits
title gets its package name in the toolbar.
UI
context.ui.renderIcon(icon, container) renders a core-owned icon descriptor so
plugin-authored icons stay visually and semantically consistent with core.
Failure isolation and retry
Setup, mount, update, command, subscription-listener, and cleanup failures are
isolated: core keeps the viewer and other plugins running. A failed plugin shows
a plugin-local error state whose toolbar button stays visible with an error
indicator and offers retry (a manual, full re-activation). Failures are also
delivered through the structured pluginerror channel — as a DOM event from the
viewer root and as a host callback — carrying
{ pluginName, pluginVersion, phase, error, retry() }.
Verified against the packed packages
Every adapter is exercised in CI by a dedicated packed-consumer fixture
(plugin-svelte, plugin-react, plugin-vue, plugin-lit), each installing the
real SDK tarball and mounting a plugin against a live packed ViewerState. The
code samples above are additionally compiled against the packed tarballs by the
docs-examples fixture.