1 changed files with 592 additions and 0 deletions
@ -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…
Reference in new issue