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 the writing half: a drawing layer over the image, a panel with a body editor and persistence-aware undo/redo, and an AnnotationStorageAdapter seam so annotations persist wherever your institution keeps them. 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. Nothing in the format is this plugin’s invention, so an annotation written here is readable by anything that reads IIIF.
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 core floor — coreRange: '>=1.0.0', the first core that ships overlay-layer registration — and refuses to activate against anything older, loudly, 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. Hand it to the viewer the way you hand it 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 — the persisted set is core’s read-only overlay — 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. Close the panel and they go back to being the read-only overlay they were: the editing affordance is exactly as long-lived as the panel that explains it.
The drawing layer is built on core’s own published primitives — an overlay layer for its DOM, canvasToScreen/screenToCanvas for projection, subscribeFrame for reprojection — so no third party’s object model sits between the editor and the viewer.
Drawing
Drawing is modal: the reader 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. One tool, one gesture — nothing has to guess whether a press was a click or 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 hand jitter does not litter an annotation set with two-pixel 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 — 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.
Pointer-drag panning is the one thing arming suppresses, and Space is its escape hatch. A tool is armed only while the panel that explains it is on screen, and there are two exits — Escape, and closing the panel — both of which 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 centre of the current view, which the ordinary editing verbs then move and size. Creating and editing share one set of verbs.
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 folio at fit zoom as at 8×.
Storage: the adapter seam
An adapter is pure storage. It knows nothing about how a shape is displayed: 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 a failure is never invisible.
Your adapter’s own host has to be reachable under your Content Security Policy — it is an ordinary connect-src the viewer knows nothing about.
The conformance suite
The suite the built-in adapter passes is exported for yours to run against. It checks storage behaviour 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 storage behaviour is all an adapter owns.
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 bleed between them.
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 is single-browser storage — useful for a demo, a workshop, or a reader’s private notes, and not a multi-user production store. Bring an adapter for that.
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: the extension seam
extension is the other seam, for a host application rather than for a different body shape. Gate creation, prefill a draft, transform an annotation on its way out, 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 — so a host decision can be made from host 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 | Honoured only when it is within |
|
| Never overwrites a motivation the host already set. |
|
| Where the plugin chrome 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 a reader’s own edit takes, so display sync, id reconciliation and error rollback all apply to it. The history holds fifty steps and does not outlive the canvas — it 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 dismisses explicitly rather than on an outside click: while a tool is armed, a click on the canvas is how the reader draws, and a light-dismissing flyout would close itself on the first stroke.
Styling
The drawing layer is DOM and SVG, so CSS styles it — theming follows 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: they are core’s, not this layer’s. Their size is --tri-annotation-point-size and their colour --tri-annotation-color — the same two public tokens core’s read-only overlay draws and measures a marker from — so a point looks the same open for editing as it does at rest, and one declaration restyles both. This plugin declares neither of its own, deliberately: a second setting could only disagree with the first, and a point would change under the reader the moment it was opened. --tri-annotation-point-fill and --tri-annotation-point-stroke still override the colour for this layer alone, which is the only way to make the two disagree on purpose.
Panel chrome takes panelBg / panelContent like every other panel; the plugin’s own CSS reads the public palette, radius and input tokens, so retinting those retints it.
Localization
The plugin ships its own catalog — English and German — and resolves every label in the viewer’s active locale, English fallback. 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 are contracts, not bugs. Each is a deliberate fence for this release:
Image canvases only. A canvas under a canvas claim — a recording played by the AV plugin, say — leaves
annotatableCanvasIds, because core paints nothing there for a comment to be anchored against. Annotating a point in a recording is a separate future plugin, not a gap in this one.One target per written annotation. An annotation carrying several geometries is rendered on read, and the editor writes one target.
No ellipse on read-back. An ellipse is stored and re-edited as the polygon it persists as.
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 what a reader wrote is your server’s decision, not the viewer’s.
Design notes
The plugin is authored entirely on the framework-neutral plugin SDK — core never imports it — and needs no requiredCapabilities: overlay layers are not optional in core, so the coreRange floor is the whole compatibility statement.