Triiiceratops Annotation editor

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-editor

triiiceratops, @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];

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

FragmentSelector

Ellipse

drag a bounding box

SvgSelector <polygon>

Polygon

click once per vertex; Enter or a double-click closes the outline

SvgSelector <polygon>

Point

a single click

PointSelector

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

i

insert a vertex after the focused polygon vertex

x

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',
        });
    },
};
  • load may return skeleton entries — annotations whose bodies have not been fetched — marked __fullBodyLoaded: false. Implement the optional hydrate and the plugin fetches a full body when one is opened. The marker is read once and stripped; it never round-trips.

  • update and create may return the server-normalized annotation to replace the cached copy, or void to keep the payload that was sent.

  • The optional destroy is 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

adapter

Storage. LocalStorageAdapter on the preconfigured plugin.

user

{ id, name? }, stamped onto a new annotation as its creator.

tools

all five

Which tools the panel offers, in the order given.

defaultTool

first in tools

Honoured only when it is within tools.

defaultMotivation

'commenting'

Never overwrites a motivation the host already set.

target

'panel'

Where the plugin chrome renders — 'panel' or 'flyout'.

ui

showModeToggle (true), startInCreateMode (false), showUndoRedo (true), purposes (W3C_PURPOSES), allowMultipleBodies (true).

bodyEditor, extension, onPersistenceError

See the sections above.

prepareAnnotation, canCreateAnnotation, getCreateDisabledReason

Flat equivalents of the matching extension hooks, for a host that needs one hook rather than the seam.

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

--tri-annotation-draw-stroke

var(--tri-color-primary)

--tri-annotation-draw-stroke-width

2px

--tri-annotation-draw-fill

20% --tri-color-primary, mixed to transparent

--tri-annotation-draw-cursor

crosshair

--tri-annotation-edit-move-cursor

move

--tri-annotation-handle-size

10px

--tri-annotation-handle-fill

var(--tri-color-primary)

--tri-annotation-handle-stroke

var(--tri-color-base-100)

--tri-annotation-point-fill

var(--tri-annotation-color)

--tri-annotation-point-stroke

var(--tri-annotation-color)

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 AnnotationPage for 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.

  • ADR 0020 — why arming a tool takes pointer events in the DOM instead of claiming input at the gesture arbiter.

  • ADR 0021 — why the editing surface is first-party, and why the editor draws one annotation while core draws the set.

  • ADR 0022 — why an ellipse is stored as a polygon.