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