Annotation Editor
Create and edit a canvas's annotations in the viewer: a first-party drawing layer with rectangle, ellipse, polygon, point and whole-canvas tools, full keyboard parity, and a storage adapter you supply.
Core renders and selects the annotations a manifest publishes; it writes none. @triiiceratops/plugin-annotation-editor adds editing: a drawing layer over the image, a panel with a body editor and persistence-aware undo/redo, and an AnnotationStorageAdapter interface for saving annotations to any storage backend. Every tool is operable from the keyboard, for creating a shape as well as for editing one.
Annotations are persisted as W3C Web Annotations targeting canvas coordinates: a FragmentSelector (xywh=) for a rectangle, an SvgSelector carrying a <polygon> for an ellipse or a polygon, a PointSelector for a point, and no selector at all for a whole-canvas note. These are standard formats, so any IIIF-compatible software can read the saved annotations.
Install
pnpm add @triiiceratops/plugin-annotation-editornpm install @triiiceratops/plugin-annotation-editorbun add @triiiceratops/plugin-annotation-editortriiiceratops, @triiiceratops/plugin-sdk and svelte are peers. The plugin declares a minimum core version, coreRange: '>=1.0.0', the first core that ships overlay-layer registration. Against anything older it refuses to activate and reports the failure on core’s plugin error channel rather than mounting a button that does nothing.
Registering it
AnnotationEditorPlugin is the preconfigured plugin: every tool, rectangle armed by default, and the built-in LocalStorageAdapter. Add it to the viewer like any other plugin. See adding a plugin to your viewer for the full per-framework code:
import 'triiiceratops/element/register';
import { AnnotationEditorPlugin } from '@triiiceratops/plugin-annotation-editor';
viewer.plugins = [AnnotationEditorPlugin];import { TriiiceratopsViewer } from 'triiiceratops/react';
import { AnnotationEditorPlugin } from '@triiiceratops/plugin-annotation-editor';
const plugins = [AnnotationEditorPlugin];
export function Reader() {
return (
<TriiiceratopsViewer
manifestId="https://example.org/manifest.json"
plugins={plugins}
style={{ display: 'block', height: '600px' }}
/>
);
}<script setup lang="ts">
import { TriiiceratopsViewer, type SdkPlugin } from 'triiiceratops/vue';
import { AnnotationEditorPlugin } from '@triiiceratops/plugin-annotation-editor';
const plugins: readonly SdkPlugin[] = [AnnotationEditorPlugin];
</script>
<template>
<TriiiceratopsViewer
manifest-id="https://example.org/manifest.json"
:plugins="plugins"
style="display: block; height: 600px"
/>
</template><script>
import { TriiiceratopsViewer } from 'triiiceratops/svelte';
import 'triiiceratops/style.css';
import { AnnotationEditorPlugin } from '@triiiceratops/plugin-annotation-editor';
</script>
<TriiiceratopsViewer
manifestId="https://example.org/manifest.json"
plugins={[AnnotationEditorPlugin]}
/>Use createAnnotationEditorPlugin(config) for anything else: your own adapter, a subset of the tools, the current user, or any of the configuration below. It returns a plugin you hand to the same plugins list:
import {
createAnnotationEditorPlugin,
LocalStorageAdapter,
} from '@triiiceratops/plugin-annotation-editor';
const annotations = createAnnotationEditorPlugin({
adapter: new LocalStorageAdapter(),
user: { id: 'user-123', name: 'Jane Doe' },
tools: ['rectangle', 'polygon', 'point'],
});
viewer.plugins = [annotations];As a script tag (IIFE)
The IIFE bundles its own Svelte runtime, so it shares nothing private with core and the two scripts may load in either order, unlike @triiiceratops/plugin-av. Loading the script only registers a factory; activation is per-viewer.
<script src="/assets/triiiceratops-element.iife.js"></script>
<script src="/assets/plugin-annotation-editor/iife.js"></script>
<triiiceratops-viewer id="viewer"></triiiceratops-viewer>
<script>
document.getElementById('viewer').plugins = [
window.Triiiceratops.plugins.get(
'@triiiceratops/plugin-annotation-editor',
),
];
</script>The IIFE path uses the built-in LocalStorageAdapter: a custom adapter is a JavaScript object, so it needs the module entry.
What it renders
A panel with an Edit/Create toggle, the tool buttons for whichever tools are configured, a one-line instruction for the armed tool, undo/redo, and the body editor for the selected annotation. It opens in Edit mode, where clicking an annotation selects it.
A drawing layer over the image, holding the annotation currently under edit, its handles, and the in-progress preview. It draws only that one annotation (core’s read-only overlay draws the saved set), and it is what makes drawing modal.
Editable shapes. While the editor’s surface is open, every annotation core draws becomes selectable, focusable and operable. When the panel closes, they are read-only again.
The drawing layer is built on core’s own public APIs: an overlay layer for its DOM, canvasToScreen/screenToCanvas for projection, subscribeFrame for reprojection. The editor uses no third-party object model.
Drawing
Drawing is modal: the user arms a tool from the panel, and for as long as it is armed the drawing layer takes pointer events across the whole image, so a drag draws instead of panning. Each tool has one gesture, so a press is never ambiguous between a click and a drag.
Tool | Gesture | Persisted target |
|---|---|---|
Rectangle | drag a bounding box |
|
Ellipse | drag a bounding box |
|
Polygon | click once per vertex; Enter or a double-click closes the outline |
|
Point | a single click |
|
Whole canvas | activate the tool; there is nothing to draw | no selector |
A region smaller than four canvas pixels in either dimension is discarded rather than committed, so small accidental drags do not create tiny shapes. A point is a click with no extent, so the guard does not apply to it.
What still works while a tool is armed:
Wheel zoom, unchanged, so you can place a vertex precisely without disarming.
Keyboard zoom and arrow-key panning, unchanged.
Hold Space to pan by dragging. The layer drops its own pointer events for as long as Space is held and the viewer’s ordinary panning takes over underneath; a Space-pan never commits a shape.
Arming a tool disables only pointer-drag panning; hold Space to pan instead. A tool stays armed only while its panel is open. Escape and closing the panel both disarm it, and both cancel rather than commit.
Keyboard
Creation from the keyboard is place-then-shape: arming a tool and pressing Enter drops a default-sized shape at the center of the current view, which the standard editing keys then move and resize. Creating and editing use the same keys.
Key | Effect |
|---|---|
Arrows | nudge the focused handle, vertex or whole shape by one canvas pixel |
Shift + arrows | the larger step, ten canvas pixels |
Tab / Shift+Tab | cycle the shape’s handles and vertices |
Enter | commit; for a polygon in progress, close the outline |
Escape | cancel the shape or edit in progress, else discard any pointer draft and disarm |
Delete | remove the selected annotation |
| insert a vertex after the focused polygon vertex |
| remove the focused polygon vertex |
Nudges are in canvas pixels, not screen pixels, so one press moves a vertex the same distance across the image at fit zoom as at 8×.
Storage: the adapter interface
An adapter is pure storage. It handles no display logic: the plugin owns display sync, caching, create-versus-update resolution, id reconciliation, creator and timestamp stamping, hydration, and error handling around it. A conforming adapter is roughly these four functions.
import type {
AdapterLoadResult,
AnnotationStorageAdapter,
W3CAnnotation,
} from '@triiiceratops/plugin-annotation-editor';
const url = (manifestId: string, canvasId: string) =>
`/api/annotations?manifest=${encodeURIComponent(manifestId)}` +
`&canvas=${encodeURIComponent(canvasId)}`;
export const adapter: AnnotationStorageAdapter = {
id: 'my-server',
name: 'Institutional annotation server',
async load(manifestId, canvasId) {
const response = await fetch(url(manifestId, canvasId));
return (await response.json()) as AdapterLoadResult[];
},
// Return the canonical annotation (or just its id string) when your server
// mints its own IRI — the plugin then reconciles the id everywhere at once.
// Returning `void` keeps the client-generated one.
async create(manifestId, canvasId, annotation) {
const response = await fetch(url(manifestId, canvasId), {
method: 'POST',
body: JSON.stringify(annotation),
});
return (await response.json()) as W3CAnnotation;
},
async update(manifestId, canvasId, annotation) {
await fetch(url(manifestId, canvasId), {
method: 'PUT',
body: JSON.stringify(annotation),
});
},
async delete(manifestId, canvasId, annotationId) {
await fetch(`${url(manifestId, canvasId)}&id=${annotationId}`, {
method: 'DELETE',
});
},
};loadmay return skeleton entries (annotations whose bodies have not been fetched) marked__fullBodyLoaded: false. Implement the optionalhydrateand the plugin fetches a full body when one is opened. The marker is read once and stripped; it never round-trips.updateandcreatemay return the server-normalized annotation to replace the cached copy, orvoidto keep the payload that was sent.The optional
destroyis called on teardown.
A failed write rolls back the plugin’s optimistic cache and display changes and then calls onPersistenceError with the operation, the annotation id, and a retry() that re-runs the exact failed call:
import {
createAnnotationEditorPlugin,
type AnnotationPersistenceError,
} from '@triiiceratops/plugin-annotation-editor';
const annotations = createAnnotationEditorPlugin({
onPersistenceError: (error: AnnotationPersistenceError) => {
// Already rolled back by the time this runs: decide how to surface it.
console.warn(`annotation ${error.op} failed`, error.cause);
void error.retry();
},
});Omit the handler and the plugin logs and shows a dismissible error line in the panel, so failures are always visible.
Your adapter’s own host has to be reachable under your Content Security Policy as an ordinary connect-src the viewer knows nothing about.
The conformance suite
The conformance suite used for the built-in adapter is exported so you can run it against your own. It checks storage behavior only (load, create, update and delete round-trips, verbatim body preservation including structured and unknown shapes, key isolation, and the two opt-in capabilities), because an adapter is responsible only for storage.
import { runAdapterContractTests } from '@triiiceratops/plugin-annotation-editor/testing';
import { MyAdapter } from './MyAdapter';
runAdapterContractTests(() => new MyAdapter(), {
supportsIdReconciliation: true,
supportsHydrate: true,
});Call it at the top level of a vitest file: it registers its own describe/it blocks, invokes the factory fresh before each test so a stateful adapter starts clean, and uses a unique manifest and canvas pair per test so a storage-backed adapter cannot leak state between tests.
The built-in LocalStorage adapter
LocalStorageAdapter is the default on AnnotationEditorPlugin and the reference minimal adapter: localStorage reads and writes, nothing more. It writes under one frozen, versioned, package-qualified key per canvas:
@triiiceratops/plugin-annotation-editor:v1:<manifestId>:<canvasId>That key is the stable contract and will not change without a :v2: bump. It stores data in one browser only, which suits a demo, a workshop, or a user’s private notes. For a multi-user production store, supply your own adapter.
Replacing the body editor
The built-in body editor edits W3C bodies (a value, a format, a language, and a purpose from the W3C_PURPOSES vocabulary) and leaves a structured body it does not understand read-only and untouched across a save. To edit your own metadata model in place, pass bodyEditor: either a Svelte component taking an api prop, or a framework-neutral render(container, api) returning its own cleanup.
import {
createAnnotationEditorPlugin,
type AnnotationBodyEditorApi,
} from '@triiiceratops/plugin-annotation-editor';
const annotations = createAnnotationEditorPlugin({
bodyEditor: {
render(container: HTMLElement, api: AnnotationBodyEditorApi) {
const input = document.createElement('textarea');
const [body] = api.bodies as { value?: string }[];
input.value = body?.value ?? '';
input.onchange = () => {
void api.save([{ type: 'TextualBody', value: input.value }]);
};
container.append(input);
return () => input.remove();
},
},
});api carries the full annotation in canvas space, its bodies normalized to an array, the runtime context (manifest, canvas, user, host context), isHydrating, and save / cancel / requestDelete. The plugin owns the geometry and the persistence; the body editor owns only the bodies.
Host hooks
extension is the other extension point, for a host application rather than for a different body shape. Use it to gate creation, prefill a draft, transform an annotation before it is saved, or observe selection without forking the plugin.
import {
createAnnotationEditorPlugin,
type AnnotationEditorRuntimeContext,
type W3CAnnotation,
} from '@triiiceratops/plugin-annotation-editor';
const annotations = createAnnotationEditorPlugin({
extension: {
canCreate: (context: AnnotationEditorRuntimeContext) =>
context.user !== undefined,
getCreateDisabledReason: () => 'Sign in to annotate.',
// The draft is what the reader sees in the panel AND what is saved.
prepareDraft: (annotation: W3CAnnotation) => ({
...annotation,
motivation: 'describing',
}),
onSelectionChange: (annotation: W3CAnnotation | null) => {
console.log(annotation?.id ?? 'nothing selected');
},
},
});The hooks are getContext, subscribe, canCreate, getCreateDisabledReason, prepareDraft, beforeSave and onSelectionChange. Every one of them receives a runtime context naming the current manifest and canvas, whether the editor is open, the selected annotation, the configured user, and whatever getContext returned. The host can decide from its own state without the plugin knowing what that state is. Call the invalidate that subscribe is handed when canCreate should be re-evaluated.
Configuration
Option | Default | Notes |
|---|---|---|
| — | Storage. |
| — |
|
| all five | Which tools the panel offers, in the order given. |
| first in | Honored only when it is within |
|
| Never overwrites a motivation the host already set. |
|
| Where the plugin UI renders: |
| — |
|
| — | See the sections above. |
| — | Flat equivalents of the matching |
Undo and redo are persistence-aware: each step is replayed through the same write path as a user’s own edit, so display sync, id reconciliation and error rollback all apply to it. The history holds fifty steps and is cleared on a canvas change and on teardown, because an annotation’s id is only meaningful against the canvas it targets.
Configuring its UI
The plugin’s uiId is annotation-editor, so it is controlled through config.plugins['annotation-editor'] like any other plugin. See controlling plugin UI through config.
viewer.config = {
plugins: {
'annotation-editor': { position: 'right', open: true },
},
};Hosted as a flyout, it closes explicitly rather than on an outside click: while a tool is armed, clicks on the canvas draw, so a flyout that closed on outside clicks would close on the first stroke.
Styling
The drawing layer is DOM and SVG, so CSS styles it, using the viewer’s --tri- custom properties like everything else. There is no styling option: set these on the viewer element or any ancestor.
Custom property | Default |
|---|---|
|
|
|
|
| 20% --tri-color-primary, mixed to transparent |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Point markers are the exception: core draws them, not this layer. Their size is --tri-annotation-point-size and their color --tri-annotation-color, the same two public tokens core’s read-only overlay uses, so a point looks the same while being edited as at rest, and one declaration restyles both. This plugin adds no point size or color token of its own, so a point does not change when it is opened for editing. --tri-annotation-point-fill and --tri-annotation-point-stroke override the color on this layer only, for when you want editing points to look different.
Panel chrome takes panelBg / panelContent like every other panel; the plugin’s own CSS reads the public palette, radius and input tokens, so changing those tokens restyles it.
Localization
The plugin ships its own catalog (English and German) and resolves every label in the viewer’s active locale, falling back to English. Its toolbar tooltip and panel header come from the same catalog, so they follow a locale change with the rest of the viewer’s chrome.
Documented limitations
These limits are intentional in this release:
Image canvases only. A canvas another plugin has taken over through a canvas claim (such as a recording played by the AV plugin) is dropped from
annotatableCanvasIds, because core renders no image there to annotate. Annotating a point in a recording is planned as a separate plugin.One target per written annotation. An annotation with several geometries is displayed, but the editor writes only one target.
No ellipse on read-back. An ellipse is stored and re-edited as a polygon.
No annotation list is published. The plugin writes through your adapter and syncs the viewer’s display; it does not serve an
AnnotationPagefor anyone else to fetch. Publishing saved annotations is handled by your server, not the viewer.
Design notes
The plugin is authored entirely on the framework-neutral plugin SDK, core never imports it, and it needs no requiredCapabilities: overlay layers are not optional in core, so the coreRange minimum is the only compatibility requirement.