Triiiceratops Plugin system

Plugin System

The Triiiceratops plugin system: component-based extensions that render as docked sidebar panels or as flyouts over the canvas.

Triiiceratops has a component-based plugin system for extending the viewer. A plugin renders its UI either as a panel — docked to a sidebar, banded below the image, or floated over it — or as a compact flyout popover anchored to its toolbar button.

How the Plugin System Works

Each first-party plugin is its own independently versioned npm package under the @triiiceratops scope, and ships in two delivery formats.

Format

Use case

Plugins delivered via

IIFE

Static HTML pages, no build step

Script tags + the window.Triiiceratops registry

ES Modules

Front-end framework projects (any bundler)

import statements

  • ES Modules — for bundler projects (React, Vue, Svelte, Lit, or any other framework — Vite, webpack, Rollup, …), import the plugin's factory from its scoped package and pass it to the viewer. Plugins run in the page's realm and receive the live ViewerState directly. Every plugin's ESM build leaves svelte external as an ordinary peer, so your bundler dedupes it against core's copy the way it dedupes any other shared dependency.

  • IIFE — each plugin's IIFE (@triiiceratops/plugin-*/dist/iife.js) registers a factory into the shared, order-independent window.Triiiceratops.plugins registry. Loading a script does not activate the plugin; activation is explicit and per-viewer. Scripts may load in any order, except @triiiceratops/plugin-av — see below.

Script order: @triiiceratops/plugin-av loads after core

Every plugin IIFE but one bundles its own Svelte runtime, and may therefore load before or after the core script. @triiiceratops/plugin-av is the exception: it reads core's Svelte runtime off window.Triiiceratops instead of shipping a second copy of it, which keeps roughly 12 KB gzip off every page that loads it. So core's script must run first:

<script src="triiiceratops-element.iife.js"></script>
<script src="triiiceratops-plugin-av.iife.js"></script>

Getting it wrong is a named diagnostic, not a broken page: the plugin's bundle checks the namespace before it evaluates anything, logs one console.error saying which half is missing, and does not register — so window.Triiiceratops.plugins.get('@triiiceratops/plugin-av') returns undefined. A core too old to share a runtime is refused the same way, and a core that shares a runtime but does not declare the shared-svelte-runtime capability is refused at activation with a PluginCompatibilityError.

This is a first-party-only arrangement. svelte/internal is private, unversioned API, and the guarantee behind sharing it is that core and the plugin are built and released from one repository at one Svelte version — which is why the plugin pins coreRange to an exact core version. A third-party plugin is released on its own schedule, so it must bundle its own Svelte runtime, as the plugin authoring guide describes.


Adding a plugin to your viewer

A plugin is handed to the viewer through its plugins list. How you set that list depends on how the viewer is embedded:

  • React — the plugins prop of <TriiiceratopsViewer> from triiiceratops/react.

  • Vue — the :plugins prop of <TriiiceratopsViewer> from triiiceratops/vue.

  • Svelte — the plugins prop of <TriiiceratopsViewer>.

  • Everything else (vanilla JS, plain HTML, other frameworks) — the .plugins property of the <triiiceratops-viewer> web component.

Plugins are plain objects, so they cannot go through an HTML attribute; the web component always receives them as a JavaScript property. (The React and Vue wrappers do that assignment for you, and do it correctly whether or not the element has upgraded yet.) Registering a plugin package does not activate it — activation is per-viewer and happens when the list is assigned.

Activation lifetime is keyed to plugin identity, not to the identity of the list: re-supplying an equal list leaves running plugins completely untouched, so a parent re-render never tears down and restarts your plugins.

Every example below adds @triiiceratops/plugin-image-manipulation; each plugin is added the same way.

With a bundler, import the registration entry once to define the element, then set .plugins on it:

import 'triiiceratops/element/register';
import { ImageManipulationPlugin } from '@triiiceratops/plugin-image-manipulation';

// `viewer` is your <triiiceratops-viewer> element.
viewer.plugins = [ImageManipulationPlugin];

With no bundler at all, plugin IIFEs register a factory into the shared, order-independent window.Triiiceratops.plugins registry; the scripts may load in any order:

<script src="https://unpkg.com/triiiceratops/dist/triiiceratops-element.iife.js"></script>
<script src="https://unpkg.com/@triiiceratops/plugin-image-manipulation/dist/iife.js"></script>

<triiiceratops-viewer manifest-id="https://example.org/manifest.json"></triiiceratops-viewer>

<script>
    customElements.whenDefined('triiiceratops-viewer').then(() => {
        const viewer = document.querySelector('triiiceratops-viewer');
        viewer.plugins = [
            window.Triiiceratops.plugins.get(
                '@triiiceratops/plugin-image-manipulation',
            ),
        ];
    });
</script>

Multiple and configured plugins

plugins is a list — add as many as you like. A plugin that takes options exposes a create* factory; call it and pass the result. See the available plugins reference.

import { ImageDownloadPlugin } from '@triiiceratops/plugin-image-export';
import { createPdfExportPlugin } from '@triiiceratops/plugin-pdf-export';

viewer.plugins = [ImageDownloadPlugin, createPdfExportPlugin()];

Panels and flyouts

A panel docks at one of four positions. left and right put it in that sidebar stack, where panels on the same side stack vertically and the side's width is set once by leftPanelWidth / rightPanelWidth — there is no per-plugin width. bottom is a full-width band below the image in the center column. overlay floats the panel over the image itself, inside the viewer area.

A flyout is a compact popover anchored to the plugin's toolbar button. It opens on click (click-outside / Esc to dismiss) and grows toward the canvas so it never opens off-screen. It stays mounted while closed, so background work keeps running. Use a flyout for a few compact controls; use a panel when the UI needs more room.

Panels behave the same way: a plugin is mounted once per viewer and stays mounted while its surface is closed. A plugin that wants to pause while it is not visible reads context.surface.isOpen — see knowing whether your panel or flyout is open.

The authored target is only a default. Every plugin registers both a panel and a flyout entry, so the effective target is switchable at runtime — like visible/open — via config.plugins[id].target or viewerState.setPluginTarget(id, target). A panel's dock position works the same way: config.plugins[id].position or viewerState.setPluginPosition(id, position) sets it for any plugin as a consumer-only decision; definePlugin itself has no position field, so a plugin author cannot fix one. This lets one plugin render as a panel on desktop and a flyout on a narrow viewport; see controlling plugin UI at runtime below for the per-framework code. Switching remounts the plugin UI in the new container, so a plugin that must survive the switch keeps its state in viewer state or its own store, not in local component state.

Controlling Plugin UI Through Config

Plugin toolbar button visibility and plugin panel open/closed state can be controlled through the same config object used for built-in panes.

Configuration shape:

type ViewerConfig = {
    plugins?: Record<
        string,
        {
            visible?: boolean; // show/hide the plugin toolbar button
            open?: boolean; // open/close the plugin panel
            showCloseButton?: boolean; // Default: true
            target?: 'panel' | 'flyout'; // override where the plugin renders
            position?: 'left' | 'right' | 'bottom' | 'overlay'; // override the panel's dock position
        }
    >;
};

The record key is the plugin's stable id — its uiId. First-party plugins set short, documented ids (av, annotation-editor, pdf-export, image-download, image-manipulation). If a plugin omits uiId, core derives a stable id from its package name by replacing every run of unsafe characters with - (e.g. @scope/plugin-fooscope-plugin-foo).

Every field is a sparse override applied on top of the plugin's authored defaults; omitting a field leaves the current live value untouched:

  • visible: false hides only the plugin's toolbar button. A plugin can also hide its own button on a canvas it has nothing for (surface.setAvailable, which closes an open surface as it goes); both must agree for the button to render.

  • open: true opens the plugin's surface if it is registered.

  • showCloseButton: false removes the close button from the plugin's docked panel header, and with it the Escape-to-close path — one flag, not two. The default is true, matching every core panel.

  • target: 'flyout' | 'panel' moves the plugin between its docked panel and its anchored flyout; the switch remounts the plugin UI.

  • position: 'left' | 'right' | 'bottom' | 'overlay' docks the panel somewhere else — a consuming app's own choice, independent of whatever the plugin was authored with. Ignored while the effective target is 'flyout', since a flyout is anchored to its toolbar button rather than docked.

Update config and the change applies reactively.

Controlling plugin UI at runtime

A common use is switching a plugin to a flyout on narrow viewports, or docking it to whichever side fits your layout:

Assign a new config object on the element:

const mq = window.matchMedia('(max-width: 640px)');
const sync = () => {
    viewer.config = {
        plugins: {
            'image-manipulation': { target: mq.matches ? 'flyout' : 'panel' },
        },
    };
};
sync();
mq.addEventListener('change', sync);

The element also exposes its live viewer state through the getter-only viewerState property (see the state bridge), so el.viewerState?.setPluginTarget(id, target) is available too. config is the declarative option; the bridge is the imperative one.


Defining a plugin

Plugins are framework-agnostic. Author new plugins with the framework-neutral SDK (@triiiceratops/plugin-sdk) and definePlugin: a plugin mounts into a plain HTMLElement, so you can render it with vanilla JavaScript, React, Vue, Svelte, Lit, or a custom element, and it behaves the same in every host. definePlugin gives you a mount contract; the live ViewerState for reads and supported commands; root-aware style, locale, and UI services; failure isolation; and a conformance test kit.

See the plugin authoring guide and the plugin testing guide for the full API and examples.

definePlugin is the only plugin path — there is no Svelte-only shortcut, and a Svelte host mounts its component from the SDK's mount() like every other framework. See the Svelte tab in rendering UI in your framework.


Available Plugins

Plugin

What it does

Renders as

Audio & Video

Plays a canvas's Sound and Video bodies — media stage over the canvas rect, transport in the control bar, waveforms, captions, transcript

Panel (transcript) + canvas stage

Annotation Editor

Draws and edits a canvas’s annotations — rectangle, ellipse, polygon, point and whole-canvas tools, keyboard parity, and a storage adapter the host supplies

Panel (or flyout)

Image Manipulation

Brightness, contrast, saturation, invert, and grayscale controls for the displayed image

Flyout

Image Download

Downloads the current canvas (composite, single image, or current view) as a raster image

Panel

PDF Export

Exports a range of canvases as a browser-generated PDF, with optional OCR text and a cover sheet

Panel

Each page above has its own install command, setup snippet, and configuration reference.


Package Exports Reference

Export path

Description

triiiceratops

Core Svelte component and utilities

triiiceratops/style.css

Core stylesheet (Svelte usage)

triiiceratops/image-export

Version-neutral IIIF canvas helpers — see the canvas contract

triiiceratops/svelte

Svelte 5 component — a superset of the root entry

triiiceratops/react

React 19 framework wrapper

triiiceratops/vue

Vue 3.5 framework wrapper

triiiceratops/selectors

Framework-neutral selector runtime

triiiceratops/testing

Headless viewer state + createTestViewerHandle()

triiiceratops/element

Web Component self-contained IIFE

triiiceratops/element/register

Web Component ESM registration

@triiiceratops/plugin-sdk

Plugin SDK (base)

@triiiceratops/plugin-sdk/register

Browser/IIFE registration (registerBrowserPlugin)

@triiiceratops/plugin-sdk/register-shared

Browser/IIFE registration for a plugin that cannot load before core — without a shared-runtime load-order gate it silently no-ops with no diagnostic, so third-party plugins should keep using /register

@triiiceratops/plugin-sdk/{svelte,react,vue,lit}

SDK framework adapters

@triiiceratops/plugin-sdk/testing

SDK plugin test kit

@triiiceratops/plugin-av

Audio & video plugin (ES module)

@triiiceratops/plugin-av/iife

Audio & video plugin (IIFE — serve the whole dist/ directory, and load it after core)

@triiiceratops/plugin-annotation-editor

Annotation editor plugin (ES module)

@triiiceratops/plugin-annotation-editor/testing

Adapter conformance suite — see the conformance suite

@triiiceratops/plugin-annotation-editor/iife

Annotation editor plugin (IIFE — either script order)

@triiiceratops/plugin-image-manipulation

Image manipulation plugin (ES module)

@triiiceratops/plugin-image-manipulation/iife

Image manipulation plugin (IIFE)

@triiiceratops/plugin-image-export

Image download plugin (ES module)

@triiiceratops/plugin-image-export/iife

Image download plugin (IIFE)

@triiiceratops/plugin-pdf-export

PDF export plugin (ES module)

@triiiceratops/plugin-pdf-export/iife

PDF export plugin (IIFE)