Annotations Module — IO Migration Guide
The annotations module has been migrated to xOpat's generic IO pipeline (window.IO_PIPELINE, APPLICATION_CONTEXT.io — see src/IO_PIPELINE.md for the design). This document covers what changed, why, and how to adapt existing integrations.
What changed
1. Six pre-action events removed
The following events with the setCancelled / isCancelled flag protocol no longer exist:
| Removed event | Was raised by | Listeners must now register a |
|---|---|---|
annotation-before-create |
promoteHelperAnnotation |
guard with direction: "pre-create" |
annotation-before-delete |
_deleteAnnotation |
guard with direction: "pre-delete" |
annotation-before-edit |
beginSelectionEdit |
guard with direction: "pre-update" and meta.kind === "edit-start" |
annotation-before-preset-change |
changeAnnotationPreset |
guard with direction: "pre-update" and meta.kind === "preset-change" |
annotation-before-replace |
replaceAnnotation (real) |
guard with direction: "pre-update" and meta.kind === "replace" |
annotation-before-replace-doppelganger |
replaceAnnotation (UI swap) |
guard with direction: "pre-update" and meta.kind === "replace-doppelganger" |
Repository grep at migration time confirmed there were zero in-tree listeners on these events. External plugins/forks must adopt the guard API.
2. Public method signatures are unchanged (still synchronous)
The five user-facing mutation methods remain synchronous, despite routing through the IO pipeline. The pipeline's IOResource.create / update / delete are sync-core (see "sync core" in src/IO_PIPELINE.md): validate → sync guards → local apply → history push all happen in the caller's frame; the sink dispatch is queued and runs in the background. Returned objects carry a .settled: Promise<IOResult> for callers that want server confirmation.
| Method | Sync return | Notes |
|---|---|---|
OSDAnnotations.FabricWrapper#deleteAnnotation |
boolean |
Opts in to rollbackOnAsyncRefuse: true — server reject reverts the local removal. |
OSDAnnotations.FabricWrapper#promoteHelperAnnotation |
boolean |
Same — server reject reverts the local create. |
OSDAnnotations.FabricWrapper#replaceAnnotation |
boolean |
Default-off rollback (a swap is easier to live with than flicker). |
OSDAnnotations.FabricWrapper#changeAnnotationPreset |
boolean |
Default-off rollback. |
OSDAnnotations.FabricWrapper#beginSelectionEdit |
boolean |
Guard-only check (no dispatch). |
So existing call sites continue to work without await. Mouse-move and edit hot paths stay native (no microtask yield from the pipeline). The private _deleteAnnotation, _promoteHelperAnnotation, _replaceAnnotation, _addAnnotation keep their synchronous shapes and bypass the resource pipeline by design (cleanup paths, undo replays, bulk operations).
If you want to know whether the server accepted the change, await .settled:
const result = fabric.deleteAnnotation(annotation);
if (!result.ok) return; // sync guard refused
await result.settled; // optional: wait for server confirmation
For 99% of UI code, the sync result.ok check is enough. The toast + io:refused event surface server outcomes asynchronously when no caller is watching.
3. Preset silent-factory fallback removed
OSDAnnotations.Preset.fromJSONFriendlyObject(parsedObject, context) no longer falls back to polygon when factoryID is unknown. It now throws an error and surfaces a toast (Preset uses an unsupported shape "X" and was rejected.). The bulk preset import path (PresetManager.import) catches per-item and continues; one bad preset does not abort the whole import.
4. Manual history pushes replaced by auto-history
promoteHelperAnnotation, deleteAnnotation, replaceAnnotation, and changeAnnotationPreset previously called APPLICATION_CONTEXT.history.push(...) / .pushExecuted(...) directly. They now pass inverseApply to annotationResource.create / update / delete, and the IO pipeline pushes the history entry automatically (see "Auto-history" in src/IO_PIPELINE.md). Net effect: same undo/redo behavior, but a sink bound to crud:annotation participates in the replay (with meta.fromUndo / meta.fromRedo flags sinks can opt out of via accepts(ctx)).
How to migrate listener code
Pattern: replacing addHandler('annotation-before-X', …)
Before:
fabric.addHandler('annotation-before-delete', e => {
if (currentUser.role !== 'admin') {
e.setCancelled(true);
}
});
After:
IO_PIPELINE.registerGuard({
ownerId: 'my-plugin',
resource: 'annotation',
direction: 'pre-delete',
handler: () => currentUser.role === 'admin'
? { ok: true }
: {
ok: false, refused: true,
reason: 'non-admin attempted delete',
userMessage: 'Only admins can delete annotations.',
code: 'W_PERM_DENIED',
},
});
The pipeline shows the toast automatically (userMessage), emits io:refused on VIEWER_MANAGER, and the user-driven deleteAnnotation call returns false so the caller can roll back UI state.
Pattern: filtering on meta.kind
annotation-before-edit, annotation-before-preset-change, annotation-before-replace, and annotation-before-replace-doppelganger all map to direction: "pre-update". To replicate the original event's specificity, filter on ctx.meta.kind:
| Old event | ctx.direction |
ctx.meta.kind |
|---|---|---|
annotation-before-edit |
pre-update |
"edit-start" |
annotation-before-preset-change |
pre-update |
"preset-change" |
annotation-before-replace |
pre-update |
"replace" |
annotation-before-replace-doppelganger |
pre-update |
"replace-doppelganger" |
// e.g. veto only preset changes
IO_PIPELINE.registerGuard({
ownerId: 'my-plugin',
resource: 'annotation',
direction: 'pre-update',
handler: (ctx, patch) => {
if (ctx.meta.kind !== 'preset-change') return { ok: true };
// your check here
},
});
To veto every flavor at once, use direction: '*'.
Pattern: per-viewer scoping
The old events carried viewer in their payload. The new context carries ctx.viewerId (the OSD viewer's uniqueId); filter inside your handler:
handler: (ctx, payload) => {
if (ctx.viewerId !== thisViewersId) return { ok: true };
// your check
}
Pattern: confirmation dialog before delete
IO_PIPELINE.registerGuard({
ownerId: 'confirm-delete-plugin',
resource: 'annotation',
direction: 'pre-delete',
handler: async (ctx) => {
const confirmed = await Dialogs.confirm('Delete this annotation?', 'Confirm');
return confirmed ? { ok: true }
: { ok: false, refused: true, reason: 'user cancelled' };
},
});
Pattern: a server-backed live-sync sink
The annotations module declares the crud:annotation and crud:preset capabilities in its include.json. Bind them in your app config (ENV.client.io):
"io": {
"bindings": {
"annotations": {
"crud:annotation": ["my-server-sync"]
}
},
"sinkOverrides": {
"my-server-sync": { "proxy": "cerit", "baseURL": "/api/v1/annotations" }
}
}
Provide a sink implementation in your module (the module composes its own defaults with IO_PIPELINE.sinkOverrides('my-server-sync')):
IO_PIPELINE.registerSink({
id: 'my-server-sync',
supports: ['crud'],
async create(ctx, item) { /* PUT to server, return { ok: true } or refusal */ },
async update(ctx, patch) { /* … */ },
async delete(ctx) { /* … */ },
});
Auto-history makes the server stay in lockstep with undo/redo: undoing a delete fires create on the server (with ctx.meta.fromUndo === true); redoing it fires delete again. To opt out of replays, add accepts(ctx) { return !ctx.meta.fromUndo && !ctx.meta.fromRedo; }.
What did NOT change
These are preserved so existing listeners and integrations keep working:
Post-action events (unchanged)
annotation-create,annotation-delete,annotation-edit,annotation-edit-endannotation-replace,annotation-replace-doppelganger,annotation-loadedannotation-preset-change,annotation-filter-changeannotations-visibility-changed,annotation-selection-changedannotation-add-comment,annotation-delete-comment,annotation-set-private- All
preset-*events (preset-create,preset-delete,preset-update,preset-meta-add,preset-meta-remove,preset-select) - All layer events (
active-layer-changed,layer-selection-changed,layer-objects-changed,layer-visibility-changed) - Visual / IO events (
visual-property-changed,import,export,export-partial)
Subsystems (unchanged)
- The Convertor layer (
modules/annotations/convert/*) — pure format encode/decode. - The HistoryProvider registered via
APPLICATION_CONTEXT.history.registerProvider— its delegate-basedcanUndo / canRedogating still works exactly as before. - The plugin's user-facing export/import buttons (
plugins/annotations/methods/io.mjs) — they usefabric.export()/fabric.import(), both of which sit on top of the Convertor layer. - Bulk-import path (
addAnnotationsBulk,_loadObjects, theimportBundleIO hook) — bulk import does NOT fire per-item CRUD. The owner'simportBundlehook applies the whole set in one call. This avoids "bulk-fetched data immediately syncs back to the server" loops. - The private
_deleteAnnotation,_promoteHelperAnnotation,_replaceAnnotation,_addAnnotation— unchanged shape, used by undo callbacks, edit-cancel paths, and bulk delete (deleteObject). They bypass the resource pipeline by design.
Known follow-ups
These would extend the migration further; nothing in this list blocks the existing functionality:
- Preset CRUD wrapping:
presets.jsaddPreset / removePreset / updatePresetare NOT yet routed throughpresetResource.create / delete / update. Today thepresetResource.validateruns only on bulk import (viaPreset.fromJSONFriendlyObject). Wrapping the three methods would activate per-item preset CRUD when admin bindscrud:preset, plus auto-history ifinverseApplyis supplied. The wrapping is straightforward but changes their return types (sync →Promise); deferring until needed. - freeFormTool external callers:
freeFormTool.jsis nowasyncend-to-end. Its callers from fabric mouse handlers fire-and-forget the resulting promises, which is fine for OSD event handlers (it doesn't await them). - objectAdvancedFactories:
recalculateandtranslateusevoid this._context.fabric.replaceAnnotation(…)to discard the new async promise rather than propagating async into the factory API. The local canvas swap completes one microtask after the call returns; visible behavior is unchanged. If you want guards to be able to abort factory-driven transformations, await the call instead.
Quick checklist for adapting an existing plugin
- Search for
addHandler('annotation-before-andaddFabricHandler('annotation-before-. For each hit, port toIO_PIPELINE.registerGuard(...)per the table above. - Search for
setCancelled(true). Each call site becomesreturn { ok: false, refused: true, reason, userMessage }from the guard handler. - If you call any of the five
asyncmethods listed in §2, decide whether you need toawaitthe result or fire-and-forget. Mouse-event handlers can fire-and-forget; sequencing logic shouldawait. - If your plugin imports presets with custom
factoryIDs, ensure the factories are registered before import, OR register apre-createguard on thepresetresource that rewrites unknown factory ids to a known substitute. - If you maintain a server-side annotation store, expose it as a sink (
IO_PIPELINE.registerSink({ id, supports: ['crud'], create, read, update, delete })) and bind it inENV.client.io.bindings.annotations.crud:annotation. Auto-history will keep the server in sync with undo/redo automatically.
See also
src/IO_PIPELINE.md— full design of the IO pipeline (capabilities, sinks, bindings, guards, auto-history, KV storage).src/EVENTS.md—io:refused,io:rejected-by-accepts,io:fully-refusedevents.src/AGENTS.md— quick API reference for plugin authors.