Browse Source

added View All Annotations tab

main
astanley 2 weeks ago
parent
commit
0c862b9785
  1. 592
      build/src/AllAnnotationsPanel.jsx

592
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 (
<SvgIcon {...props} viewBox="0 0 24 24">
<path d="M4 5h3v3H4V5zm5 0h11v3H9V5zM4 10.5h3v3H4v-3zm5 0h11v3H9v-3zM4 16h3v3H4v-3zm5 0h11v3H9v-3z" />
</SvgIcon>
);
}
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 (
<CompanionWindow id={id} windowId={windowId} title={`All annotations (${total})`}>
<Box sx={{ p: 1.5 }}>
<TextField
fullWidth
size="small"
label="Search annotations"
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
</Box>
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
<CircularProgress size={28} />
</Box>
)}
{!loading && error && (
<Typography color="error" sx={{ px: 2, py: 1 }} variant="body2">
{error}
</Typography>
)}
{!loading && !error && total === 0 && (
<Typography sx={{ px: 2, py: 1 }} variant="body2">
No annotations in this manifest.
</Typography>
)}
{!loading && !error && filteredGroups.map((group) => (
<Box key={group.id}>
<Typography
component="h3"
sx={{ px: 2, pt: 1.5, pb: 0.5, fontWeight: 600 }}
variant="subtitle2"
>
{group.label} ({group.annotations.length})
</Typography>
<List dense disablePadding>
{group.annotations.map((annotation, index) => (
<ListItemButton
key={annotation.id || `${group.id}-${index}`}
selected={selectedCanvasId === group.id && selectedAnnotationId === annotation.id}
onClick={() => focusAnnotation(group, annotation)}
sx={{ pl: 3, alignItems: 'flex-start' }}
>
<ListItemText
primary={annotationText(annotation)}
primaryTypographyProps={{ variant: 'body2' }}
/>
</ListItemButton>
))}
</List>
<Divider />
</Box>
))}
</CompanionWindow>
);
}
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 = () => <AllAnnotationsIcon fontSize="small" />;
AllAnnotationsButton.value = PANEL_KEY;
const allAnnotationsButtonPlugin = {
target: 'WindowSideBarButtons',
mode: 'add',
name: 'AllAnnotationsButton',
value: PANEL_KEY,
component: AllAnnotationsButton,
};
export { allAnnotationsPanelPlugin, allAnnotationsButtonPlugin };
Loading…
Cancel
Save