diff --git a/build/src/AllAnnotationsPanel.jsx b/build/src/AllAnnotationsPanel.jsx
new file mode 100644
index 00000000..a57d8461
--- /dev/null
+++ b/build/src/AllAnnotationsPanel.jsx
@@ -0,0 +1,592 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import {
+ ConnectedCompanionWindow as CompanionWindow,
+ OSDReferences,
+ selectAnnotation as selectAnnotationAction,
+ setCanvas as setCanvasAction,
+ updateWindow as updateWindowAction,
+} from 'mirador';
+import Box from '@mui/material/Box';
+import CircularProgress from '@mui/material/CircularProgress';
+import Divider from '@mui/material/Divider';
+import List from '@mui/material/List';
+import ListItemButton from '@mui/material/ListItemButton';
+import ListItemText from '@mui/material/ListItemText';
+import TextField from '@mui/material/TextField';
+import Typography from '@mui/material/Typography';
+import SvgIcon from '@mui/material/SvgIcon';
+
+const PANEL_KEY = 'allAnnotations';
+
+function AllAnnotationsIcon(props) {
+ return (
+
+
+
+ );
+}
+
+function getLabel(label, fallback) {
+ if (!label) return fallback;
+ if (typeof label === 'string') return label;
+ if (Array.isArray(label)) return label.join(', ');
+ if (label.en?.length) return label.en.join(', ');
+ const first = Object.values(label).find((value) => Array.isArray(value) && value.length);
+ return first ? first.join(', ') : fallback;
+}
+
+function canvasesFromManifest(manifest) {
+ if (Array.isArray(manifest?.items)) {
+ return manifest.items
+ .filter((item) => item?.type === 'Canvas' || item?.id)
+ .map((canvas, index) => ({
+ id: canvas.id,
+ label: getLabel(canvas.label, `Page ${index + 1}`),
+ index,
+ width: canvas.width,
+ height: canvas.height,
+ }))
+ .filter((canvas) => canvas.id);
+ }
+
+ const canvases = manifest?.sequences?.[0]?.canvases || [];
+ return canvases.map((canvas, index) => ({
+ id: canvas['@id'] || canvas.id,
+ label: getLabel(canvas.label, `Page ${index + 1}`),
+ index,
+ width: canvas.width,
+ height: canvas.height,
+ })).filter((canvas) => canvas.id);
+}
+
+
+function annotationTargetCanvasId(annotation) {
+ const target = annotation?.target;
+
+ if (typeof target === 'string') {
+ return target.split('#')[0] || null;
+ }
+
+ const source = target?.source;
+ if (typeof source === 'string') return source.split('#')[0] || null;
+ if (source?.id) return String(source.id).split('#')[0] || null;
+ if (source?.['@id']) return String(source['@id']).split('#')[0] || null;
+
+ if (target?.id) return String(target.id).split('#')[0] || null;
+ if (target?.['@id']) return String(target['@id']).split('#')[0] || null;
+
+ return null;
+}
+
+function selectorObjects(annotation) {
+ const target = annotation?.target;
+ if (!target || typeof target === 'string') return [];
+ if (Array.isArray(target.selector)) return target.selector;
+ return target.selector ? [target.selector] : [];
+}
+
+function svgSelectorValue(annotation) {
+ const selector = selectorObjects(annotation).find((item) =>
+ item?.type === 'SvgSelector' || item?.['@type'] === 'oa:SvgSelector');
+ return typeof selector?.value === 'string' ? selector.value : '';
+}
+
+function svgBounds(annotation) {
+ const value = svgSelectorValue(annotation);
+ if (!value || typeof DOMParser === 'undefined' || !document?.body) return null;
+
+ let host;
+ try {
+ const parsed = new DOMParser().parseFromString(value, 'image/svg+xml');
+ const parsedSvg = parsed.documentElement;
+ if (!parsedSvg || parsedSvg.nodeName.toLowerCase() !== 'svg') return null;
+
+ host = document.createElement('div');
+ host.setAttribute('aria-hidden', 'true');
+ host.style.cssText = [
+ 'position:absolute',
+ 'left:-100000px',
+ 'top:-100000px',
+ 'width:1px',
+ 'height:1px',
+ 'visibility:hidden',
+ 'overflow:hidden',
+ 'pointer-events:none',
+ ].join(';');
+
+ const svg = document.importNode(parsedSvg, true);
+ host.appendChild(svg);
+ document.body.appendChild(host);
+
+ const drawable = svg.querySelectorAll('path,rect,circle,ellipse,polygon,polyline,line');
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+
+ drawable.forEach((node) => {
+ if (typeof node.getBBox !== 'function') return;
+ try {
+ const box = node.getBBox();
+ if (![box.x, box.y, box.width, box.height].every(Number.isFinite)) return;
+ if (box.width <= 0 && box.height <= 0) return;
+ minX = Math.min(minX, box.x);
+ minY = Math.min(minY, box.y);
+ maxX = Math.max(maxX, box.x + box.width);
+ maxY = Math.max(maxY, box.y + box.height);
+ }
+ catch (_error) {
+ // Ignore an individual SVG element that cannot report a bounding box.
+ }
+ });
+
+ if (![minX, minY, maxX, maxY].every(Number.isFinite) || maxX <= minX || maxY <= minY) {
+ return null;
+ }
+
+ return {
+ x: minX,
+ y: minY,
+ width: maxX - minX,
+ height: maxY - minY,
+ };
+ }
+ catch (error) {
+ console.warn('Unable to read Mirador SVG annotation bounds.', error);
+ return null;
+ }
+ finally {
+ if (host?.parentNode) host.parentNode.removeChild(host);
+ }
+}
+
+function selectorValue(annotation) {
+ const target = annotation?.target;
+
+ if (typeof target === 'string') {
+ const hash = target.indexOf('#');
+ return hash >= 0 ? target.slice(hash + 1) : '';
+ }
+
+ const selectors = Array.isArray(target?.selector)
+ ? target.selector
+ : target?.selector
+ ? [target.selector]
+ : [];
+
+ for (const selector of selectors) {
+ if (typeof selector === 'string' && selector.includes('xywh=')) {
+ return selector;
+ }
+ if (typeof selector?.value === 'string' && selector.value.includes('xywh=')) {
+ return selector.value;
+ }
+ }
+
+ return '';
+}
+
+function annotationBounds(annotation, canvas) {
+ const value = selectorValue(annotation);
+ const match = value.match(/(?:^|[#&])?xywh=(?:(pixel|percent):)?([0-9.]+),([0-9.]+),([0-9.]+),([0-9.]+)/i);
+
+ let bounds = null;
+ if (match) {
+ let [, unit, x, y, width, height] = match;
+ x = Number(x);
+ y = Number(y);
+ width = Number(width);
+ height = Number(height);
+
+ if ([x, y, width, height].every(Number.isFinite) && width > 0 && height > 0) {
+ if (unit?.toLowerCase() === 'percent') {
+ if (canvas?.width && canvas?.height) {
+ x = (x / 100) * canvas.width;
+ y = (y / 100) * canvas.height;
+ width = (width / 100) * canvas.width;
+ height = (height / 100) * canvas.height;
+ }
+ else {
+ return null;
+ }
+ }
+ bounds = { x, y, width, height };
+ }
+ }
+
+ // MAE commonly saves drawn regions as an SvgSelector and, in some cases,
+ // the accompanying FragmentSelector contains only the canvas URI. Use the
+ // SVG geometry as the zoom target when no xywh fragment is available.
+ if (!bounds) bounds = svgBounds(annotation);
+ if (!bounds) return null;
+
+ const paddingX = bounds.width * 0.15;
+ const paddingY = bounds.height * 0.15;
+
+ return {
+ x: Math.max(0, bounds.x - paddingX),
+ y: Math.max(0, bounds.y - paddingY),
+ width: bounds.width + (paddingX * 2),
+ height: bounds.height + (paddingY * 2),
+ };
+}
+
+function viewerMatchesCanvas(tiledImage, canvas) {
+ if (!tiledImage || !canvas?.width || !canvas?.height) return Boolean(tiledImage);
+
+ try {
+ const size = tiledImage.getContentSize?.();
+ if (!size?.x || !size?.y) return false;
+
+ // IIIF/OpenSeadragon dimensions should normally be exact, but allow a
+ // tiny tolerance for implementations that round image dimensions.
+ const widthMatches = Math.abs(size.x - canvas.width) <= 2;
+ const heightMatches = Math.abs(size.y - canvas.height) <= 2;
+ return widthMatches && heightMatches;
+ }
+ catch (_error) {
+ return false;
+ }
+}
+
+function focusAnnotationWhenViewerReady(
+ windowId,
+ annotation,
+ canvas,
+ selectAnnotation,
+ updateWindow,
+ attempt = 0,
+) {
+ const bounds = annotationBounds(annotation, canvas);
+ const osdRef = OSDReferences.get(windowId);
+ const viewer = osdRef?.current;
+ const tiledImage = viewer?.world?.getItemAt?.(0);
+
+ const ready = viewer?.viewport
+ && tiledImage
+ && viewerMatchesCanvas(tiledImage, canvas);
+
+ if (!ready) {
+ if (attempt < 50) {
+ window.setTimeout(
+ () => focusAnnotationWhenViewerReady(
+ windowId,
+ annotation,
+ canvas,
+ selectAnnotation,
+ updateWindow,
+ attempt + 1,
+ ),
+ 100,
+ );
+ }
+ else {
+ console.warn('Timed out waiting for Mirador/OpenSeadragon to display the target canvas.');
+ }
+ return;
+ }
+
+ // Canvas changes can complete after the first Redux selection. Reassert the
+ // annotation state only after the new OSD image/overlay is actually ready.
+ updateWindow(windowId, { highlightAllAnnotations: false });
+ selectAnnotation(windowId, annotation.id);
+
+ const applyFocus = () => {
+ const currentRef = OSDReferences.get(windowId);
+ const currentViewer = currentRef?.current;
+ const currentImage = currentViewer?.world?.getItemAt?.(0);
+ if (!currentViewer?.viewport || !currentImage) return;
+
+ // Reassert selection after OSD has had a chance to replace the old canvas.
+ updateWindow(windowId, { highlightAllAnnotations: false });
+ selectAnnotation(windowId, annotation.id);
+ currentViewer.forceRedraw?.();
+
+ if (!bounds) return;
+
+ try {
+ const rect = currentImage.imageToViewportRectangle(
+ bounds.x,
+ bounds.y,
+ bounds.width,
+ bounds.height,
+ );
+ currentViewer.viewport.fitBounds(rect, false);
+ currentViewer.forceRedraw?.();
+ }
+ catch (error) {
+ console.warn('Unable to zoom to Mirador annotation.', error);
+ }
+ };
+
+ // The Redux canvas changes before OSD necessarily finishes replacing its world item.
+ // Apply immediately, then retry a few times. The later passes are intentionally harmless
+ // and ensure a single click works even when consecutive canvases have identical dimensions.
+ window.requestAnimationFrame(applyFocus);
+ [250, 600, 1100].forEach((delay) => window.setTimeout(applyFocus, delay));
+}
+
+function bodyText(body) {
+ const bodies = Array.isArray(body) ? body : body ? [body] : [];
+ for (const item of bodies) {
+ const value = item?.value ?? item?.chars ?? item?.label;
+ if (typeof value === 'string' && value.trim()) {
+ const tmp = document.createElement('div');
+ tmp.innerHTML = value;
+ return (tmp.textContent || tmp.innerText || value).trim();
+ }
+ }
+ return '';
+}
+
+function annotationText(annotation) {
+ return bodyText(annotation?.body)
+ || annotation?.label
+ || annotation?.motivation
+ || 'Annotation';
+}
+
+function AllAnnotationsPanel({
+ id,
+ windowId,
+ manifestId,
+ endpoint,
+ selectedCanvasId,
+ selectedAnnotationId,
+ setCanvas,
+ selectAnnotation,
+ updateWindow,
+}) {
+ const [groups, setGroups] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [filter, setFilter] = useState('');
+ const [pendingFocus, setPendingFocus] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function load() {
+ if (!manifestId || !endpoint) {
+ setGroups([]);
+ setLoading(false);
+ return;
+ }
+
+ setLoading(true);
+ setError('');
+
+ try {
+ const manifestResponse = await fetch(manifestId, {
+ credentials: 'same-origin',
+ headers: { Accept: 'application/json, application/ld+json' },
+ });
+ if (!manifestResponse.ok) {
+ throw new Error(`Unable to load manifest (${manifestResponse.status})`);
+ }
+
+ const manifest = await manifestResponse.json();
+ const canvases = canvasesFromManifest(manifest);
+
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ canvasIds: canvases.map((canvas) => canvas.id) }),
+ });
+ if (!response.ok) {
+ const detail = await response.text().catch(() => '');
+ throw new Error(`Unable to load all annotations (${response.status})${detail ? `: ${detail}` : ''}`);
+ }
+
+ const payload = await response.json();
+ const byCanvas = new Map();
+ const manifestCanvasIds = new Set(canvases.map((canvas) => canvas.id));
+
+ (payload.items || []).forEach((item) => {
+ // The Web Annotation target is authoritative. This also repairs the
+ // display of older rows whose adapter canvas_id does not match the
+ // annotation's target.source.
+ const targetCanvasId = annotationTargetCanvasId(item.annotation);
+ const canvasId = targetCanvasId && manifestCanvasIds.has(targetCanvasId)
+ ? targetCanvasId
+ : item.canvasId;
+
+ if (!byCanvas.has(canvasId)) byCanvas.set(canvasId, []);
+ byCanvas.get(canvasId).push(item.annotation);
+ });
+
+ const nextGroups = canvases
+ .map((canvas) => ({ ...canvas, annotations: byCanvas.get(canvas.id) || [] }))
+ .filter((canvas) => canvas.annotations.length > 0);
+
+ if (!cancelled) setGroups(nextGroups);
+ }
+ catch (e) {
+ if (!cancelled) setError(e?.message || String(e));
+ }
+ finally {
+ if (!cancelled) setLoading(false);
+ }
+ }
+
+ load();
+ return () => { cancelled = true; };
+ }, [manifestId, endpoint]);
+
+
+ useEffect(() => {
+ if (!pendingFocus || selectedCanvasId !== pendingFocus.canvas.id) return;
+
+ // Mirador selection state drives the visual highlight. It is safe to set
+ // this before the annotation page finishes loading; the overlay picks it
+ // up as soon as the persisted annotation is received into the store.
+ updateWindow(windowId, { highlightAllAnnotations: false });
+ selectAnnotation(windowId, pendingFocus.annotation.id);
+
+ // The Redux canvas id changes before OpenSeadragon has necessarily
+ // replaced the old tiled image. Wait until OSD is displaying the target
+ // canvas, then reassert selection, redraw the annotation overlay, and zoom.
+ focusAnnotationWhenViewerReady(
+ windowId,
+ pendingFocus.annotation,
+ pendingFocus.canvas,
+ selectAnnotation,
+ updateWindow,
+ );
+
+ setPendingFocus(null);
+ }, [pendingFocus, selectedCanvasId, selectAnnotation, updateWindow, windowId]);
+
+ const focusAnnotation = (canvas, annotation) => {
+ if (!annotation?.id) {
+ setCanvas(windowId, canvas.id);
+ return;
+ }
+
+ setPendingFocus({ canvas, annotation });
+
+ if (selectedCanvasId === canvas.id) {
+ updateWindow(windowId, { highlightAllAnnotations: false });
+ selectAnnotation(windowId, annotation.id);
+ focusAnnotationWhenViewerReady(
+ windowId,
+ annotation,
+ canvas,
+ selectAnnotation,
+ updateWindow,
+ );
+ setPendingFocus(null);
+ return;
+ }
+
+ setCanvas(windowId, canvas.id);
+ };
+
+ const filteredGroups = useMemo(() => {
+ const needle = filter.trim().toLowerCase();
+ if (!needle) return groups;
+ return groups.map((group) => ({
+ ...group,
+ annotations: group.annotations.filter((annotation) =>
+ `${group.label} ${annotationText(annotation)}`.toLowerCase().includes(needle)),
+ })).filter((group) => group.annotations.length > 0);
+ }, [groups, filter]);
+
+ const total = groups.reduce((sum, group) => sum + group.annotations.length, 0);
+
+ return (
+
+
+ setFilter(event.target.value)}
+ />
+
+
+ {loading && (
+
+
+
+ )}
+
+ {!loading && error && (
+
+ {error}
+
+ )}
+
+ {!loading && !error && total === 0 && (
+
+ No annotations in this manifest.
+
+ )}
+
+ {!loading && !error && filteredGroups.map((group) => (
+
+
+ {group.label} ({group.annotations.length})
+
+
+ {group.annotations.map((annotation, index) => (
+ focusAnnotation(group, annotation)}
+ sx={{ pl: 3, alignItems: 'flex-start' }}
+ >
+
+
+ ))}
+
+
+
+ ))}
+
+ );
+}
+
+const allAnnotationsPanelPlugin = {
+ name: 'AllAnnotationsPanel',
+ companionWindowKey: PANEL_KEY,
+ component: AllAnnotationsPanel,
+ mapStateToProps: (state, { windowId }) => ({
+ manifestId: state?.windows?.[windowId]?.manifestId,
+ selectedCanvasId: state?.windows?.[windowId]?.canvasId,
+ selectedAnnotationId: state?.windows?.[windowId]?.selectedAnnotationId,
+ endpoint: state?.config?.annotation?.allEndpoint,
+ }),
+ mapDispatchToProps: (dispatch) => ({
+ setCanvas: (windowId, canvasId) => dispatch(setCanvasAction(windowId, canvasId)),
+ selectAnnotation: (windowId, annotationId) =>
+ dispatch(selectAnnotationAction(windowId, annotationId)),
+ updateWindow: (windowId, payload) =>
+ dispatch(updateWindowAction(windowId, payload)),
+ }),
+};
+
+const AllAnnotationsButton = () => ;
+AllAnnotationsButton.value = PANEL_KEY;
+
+const allAnnotationsButtonPlugin = {
+ target: 'WindowSideBarButtons',
+ mode: 'add',
+ name: 'AllAnnotationsButton',
+ value: PANEL_KEY,
+ component: AllAnnotationsButton,
+};
+
+export { allAnnotationsPanelPlugin, allAnnotationsButtonPlugin };