Multi-viewport integration guide (Annotations use-case)
This app can run multiple inner OpenSeadragon viewers (viewports) at the same time. A correct integration must never assume window.VIEWER is “the viewer”. Instead, always scope your logic to a specific viewer instance.
This README shows the recommended interfaces + event patterns using the annotations plugin as the example, with a generic annotations API (no DICOM).
Viewer identity vs slot identity
viewer.uniqueId is data-derived (from BackgroundConfig.id, ultimately the dataReference). When two viewports are opened against the same slide — for example via params.activeBackgroundIndex: [0, 1] with background[0] and background[1] both pointing at dataReference: 0 — they intentionally share the same uniqueId. This is the contract: data caches, IO sinks, and history entries keyed by uniqueId then naturally treat the two viewports as one piece of data.
Because of that, do not use uniqueId to ask "which viewer slot am I". Multiple viewers can match, and VIEWER_MANAGER.getViewerIndex(uniqueId) / getViewer(uniqueId) will return the first one only.
For per-viewer-instance routing — replay markers, per-viewer cursors, anything that has to distinguish "left viewer of the same slide" from "right viewer of the same slide" — use:
For per-viewer state, prefer viewerSingletonModule(class, viewer); it keys on the viewer reference and is collision-free by construction.
Viz selection lives on the background entry
Each background entry carries its own visualizationIndex (config.background[i].visualizationIndex). Slot k's currently rendered visualization is
There is no separate per-slot activeVisualizationIndex array — it was removed; the binding rides with the bg entry, so slot reordering / insertion / deletion preserves it. To read viz for a viewer, use ViewerSelectionState.getViewerVisualizationIndex(viewer, appContext). To change viz from UI, call APPLICATION_CONTEXT.updateViewerSelection(slot, { visualizationIndex }) — it mutates the slot's bg entry and reopens.
Legacy params/sessions/snapshots that still carry top-level activeVisualizationIndex or background[i].goalIndex are folded into visualizationIndex at config-parse time (with a one-time console warning).
Core primitives you should use
1) Global and Viewer-aware singleton access
The reference to the annotations module is not something that has to do with multi-viewports. But we will need it - we can either use the global helper, or better, a callback that gets fired when the module is active:
this.integrateWithSingletonModule('annotations', async (module) => {
//...
});
// OR
const mod = singletonModule("annotations"); // only works if module is available - make a requirement dependency in include.json if you must
singletonModule(id)-> global singleton instance
this.integrateWithViewerSingletonModule('OSDAnnotationsFabricWrapper', viewerRef, async (module) => {
//...
});
// OR
const mod = viewerSingletonModule('OSDAnnotationsFabricWrapper', viewerRef); // only works if module is available - make a requirement dependency in include.json if you must
viewerSingletonModule(className, viewer) -> viewer-context instance (for viewer-singletons, we need the global annotation reference)
2) Broadcasted viewer events
Use VIEWER_MANAGER.broadcastHandler(...) for events that happen per viewer (OSD events like open):
VIEWER_MANAGER.broadcastHandler("open", (e) => {
const viewer = e.eventSource; // <-- the viewer that triggered the event
// do viewer-scoped work here
});
Also useful:
- VIEWER_MANAGER.addHandler("viewer-reset", ...) for lifecycle cleanup (ViewerManager event)
3) Viewer-bound APIs in the annotations module
The annotations module for example exposes a deterministic viewer binding. Moreover, it has it's own 'active viewer' logic, which is necessary if, for example, user annotates and drags mouse on a different viewport, while we need the drag to stay on the origin.
const annotations = singletonModule("annotations"); // global module singleton
const fabric = annotations.getFabric(viewer); // viewer-bound fabric wrapper (deterministic)
Avoid relying on annotations.viewer (which tracks an “active viewer”) for correctness. Prefer passing the viewer explicitly.
Better yet, keep internal viewer reference that resolves to the correct viewer instance (e.g. you can lock viewer ref update,
when users annotate, to avoid problems), and offer a getter:
The multi-viewport pitfall
If you write:
…you’re reading metadata from the currently active global viewer, which can differ from: - the viewport where the user clicked “Save”, or - the viewport that just opened a slide
Multi-viewport integrations must always use:
- the viewer instance from the event (e.eventSource), OR
- an explicit viewer parameter you already have
Recommended integration pattern (Annotations + Generic API)
Generic API (example)
Assume a minimal REST API:
GET /api/annotations?slideId=...->{ objects: [...] }POST /api/annotations?slideId=...with body{ objects: [...] }
Where slideId comes from the viewer’s opened content metadata.
A) Load annotations when that viewport opens content
VIEWER_MANAGER.broadcastHandler("open", async (e) => {
const viewer = e.eventSource;
const annotations = singletonModule("annotations");
const fabric = annotations.getFabric(viewer);
// 1) Read slide metadata from THIS viewer
const tiledImage = viewer?.scalebar?.getReferencedTiledImage?.();
if (!tiledImage?.source?.getMetadata) return;
const meta = tiledImage.source.getMetadata().imageInfo;
const slideId = meta?.slideId || meta?.seriesUID || meta?.id; // pick your app’s identifier
if (!slideId) return;
// 2) Clear this viewport’s canvas before loading
await fabric.loadObjects({ objects: [] }, true);
// 3) Fetch and load objects into THIS viewport only
const res = await fetch(`/api/annotations?slideId=${encodeURIComponent(slideId)}`, {
headers: { Accept: "application/json" },
});
if (!res.ok) return;
const imported = await res.json(); // { objects: [...] }
if (imported?.objects?.length) {
await fabric.loadObjects(imported, true); // clear=true is safe on slide switch
}
});
Why this works
- Runs once per viewport open
- Uses e.eventSource so the correct viewer is always targeted
- Uses a viewer-bound fabric wrapper so objects cannot leak across viewports
B) Save annotations for the viewport that triggered the action
Best practice: pass the viewer explicitly in the save event payload
Note that this part is simplified, if your API supports it, you should store annotations
per element, bidnig to events like annotation-created. Here, we provide a handler
for 'save' action performed by user, which, if not handled and the annotations plugin is active,
downloads the annotations as a file. So even if you implemented per-element saving, you still would
likely want to implement this to save annotations on user demand, instead of downloading files.
Handle it:
module.addHandler("save-annotations", async (e) => {
const viewer = e.viewer; // REQUIRED: viewport to save
const fabric = module.getFabric(viewer); // viewer-bound wrapper
const tiledImage = viewer?.scalebar?.getReferencedTiledImage?.();
if (!tiledImage?.source?.getMetadata) throw new Error("No slide open in this viewport");
const meta = tiledImage.source.getMetadata().imageInfo;
const slideId = meta?.slideId || meta?.seriesUID || meta?.id;
if (!slideId) throw new Error("Missing slideId in metadata");
// Export objects from this viewport only
const exported = await fabric.exportObjects(); // { objects: [...] } (example API)
if (!exported?.objects?.length) return;
const res = await fetch(`/api/annotations?slideId=${encodeURIComponent(slideId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(exported),
});
if (!res.ok) throw new Error("Save failed");
e.setHandled?.("Annotations saved.");
});
Fallback (less reliable): use a tracked “active viewer”
If you cannot pass viewer through the event payload, you may fallback to:
This can be wrong if focus/hover changes “active viewer” between click and handler execution.
Minimal interface contract for multi-viewport-safe modules
A module that supports multi-viewports effectively should provide:
1) Deterministic viewer binding
2) Viewer-aware events
module.addHandler("save-annotations", (e) => {
// expects e.viewer (preferred) OR otherwise uses explicit viewer binding
});
3) Hooks bound to viewer lifecycle
VIEWER_MANAGER.broadcastHandler("open", (e) => loadFor(e.eventSource));
VIEWER_MANAGER.addHandler("viewer-reset", (e) => cleanupFor(e.viewer));
Checklist
- [ ] Never read slide metadata from global
VIEWERin multi-viewport flows - [ ] Always get the viewer from the event (
e.eventSource) or pass it explicitly - [ ] Load annotations on
VIEWER_MANAGER.broadcastHandler("open", ...)per viewport - [ ] Save annotations with
module.getFabric(viewer)(viewer-scoped export) - [ ] Clear per viewport before loading:
loadObjects(imported, true)