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 };