dbf-mirador 4.3.0-testing3 → 4.3.0-testing4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbf-mirador",
3
- "version": "4.3.0-testing3",
3
+ "version": "4.3.0-testing4",
4
4
  "description": "An open-source, web-based 'multi-up' viewer that supports zoom-pan-rotate functionality, ability to display/compare simple images, and images with annotations.",
5
5
  "type": "module",
6
6
  "main": "./dist/mirador.min.js",
@@ -85,6 +85,7 @@
85
85
  "uuid": "^8.1.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
86
86
  },
87
87
  "devDependencies": {
88
+ "@ckeditor/ckeditor5-react": "~11.0.1",
88
89
  "@emotion/react": "^11.10.6",
89
90
  "@emotion/styled": "^11.10.6",
90
91
  "@eslint/compat": "^2.0.3",
@@ -100,6 +101,8 @@
100
101
  "@vitest/coverage-v8": "^4.1.2",
101
102
  "@vitest/ui": "^4.1.2",
102
103
  "chalk": "^5.3.0",
104
+ "ckeditor5": "~47.6.1",
105
+ "dbf-mirador": "file:.",
103
106
  "eslint": "^10.0.0",
104
107
  "eslint-config-prettier": "^10.1.8",
105
108
  "eslint-plugin-import": "^2.32.0",
@@ -125,7 +128,8 @@
125
128
  "vitest-fetch-mock": "^0.4.2"
126
129
  },
127
130
  "overrides": {
128
- "brace-expansion@1": "^1.1.16"
131
+ "brace-expansion@1": "^1.1.16",
132
+ "dbf-mirador": "file:."
129
133
  },
130
134
  "peerDependencies": {
131
135
  "@emotion/react": "^11.10.6",
@@ -0,0 +1,2 @@
1
+ <?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
2
+ <svg width="800px" height="800px" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--gis" preserveAspectRatio="xMidYMid meet"><path d="M50.002 0C30.763 0 15 15.718 15 34.902c0 7.432 2.374 14.34 6.392 20.019L45.73 96.994c3.409 4.453 5.675 3.607 8.51-.235l26.843-45.683c.542-.981.967-2.026 1.338-3.092A34.446 34.446 0 0 0 85 34.902C85 15.718 69.24 0 50.002 0zm0 16.354c10.359 0 18.597 8.218 18.597 18.548c0 10.33-8.238 18.544-18.597 18.544c-10.36 0-18.601-8.215-18.601-18.544c0-10.33 8.241-18.548 18.6-18.548z" fill="#000000"></path></svg>
@@ -2,11 +2,12 @@ import { useRef, useEffect, useCallback, useMemo } from 'react';
2
2
  import ReactDOM from 'react-dom';
3
3
  import PropTypes from 'prop-types';
4
4
  import { useDebouncedCallback } from 'use-debounce';
5
+ import partition from 'lodash/partition';
5
6
  import sortBy from 'lodash/sortBy';
6
7
  import xor from 'lodash/xor';
7
8
  import OpenSeadragonCanvasOverlay from '../lib/OpenSeadragonCanvasOverlay';
8
9
  import CanvasWorld from '../lib/CanvasWorld';
9
- import CanvasAnnotationDisplay, { POI_ICON_HEIGHT_PX } from '../lib/CanvasAnnotationDisplay';
10
+ import CanvasAnnotationDisplay, { poiHitTarget } from '../lib/CanvasAnnotationDisplay';
10
11
  import { buildPath2D } from '../lib/svgShapesToPath';
11
12
 
12
13
  /** @private */
@@ -30,10 +31,10 @@ function isAnnotationAtPoint({ canvasWorld, osdCanvasOverlay, viewer }, resource
30
31
  // CanvasAnnotationDisplay#pointContext), so its clickable footprint must counter-scale the
31
32
  // same way, or the hit region would shrink/grow with the image instead of the icon.
32
33
  const zoomRatio = viewportCanvas.viewportToImageZoom(viewer.viewport.getZoom(true));
33
- const hitRadius = POI_ICON_HEIGHT_PX / 2 / zoomRatio;
34
- const dx = relativeX - x;
35
- const dy = relativeY - y;
36
- return dx * dx + dy * dy <= hitRadius * hitRadius;
34
+ const hitTarget = poiHitTarget(x, y, zoomRatio);
35
+ const dx = relativeX - hitTarget.x;
36
+ const dy = relativeY - hitTarget.y;
37
+ return dx * dx + dy * dy <= hitTarget.radius * hitTarget.radius;
37
38
  }
38
39
 
39
40
  if (resource.fragmentSelector) {
@@ -97,30 +98,36 @@ export function AnnotationsOverlay({
97
98
  const annotationsToContext = useCallback(
98
99
  (renderedAnnotations, currentPalette) => {
99
100
  const context = osdCanvasOverlay.context2d;
100
- renderedAnnotations.forEach((annotation) => {
101
- annotation.resources.forEach((resource) => {
102
- const osdCanvasIndex = canvasWorld.canvases.findIndex((canvas) => canvas.id === resource.targetId);
103
- if (osdCanvasIndex === -1) return;
104
- const viewportCanvas = viewer.world.getItemAt(osdCanvasIndex);
105
- if (!viewportCanvas) return;
106
- const offset = canvasWorld.offsetByCanvas(resource.targetId);
107
- const zoomRatio = viewportCanvas.viewportToImageZoom(viewer.viewport.getZoom(true));
108
- const canvasAnnotationDisplay = new CanvasAnnotationDisplay({
109
- hovered: hoveredAnnotationIds.includes(resource.id),
110
- offset,
111
- palette: {
112
- ...currentPalette,
113
- default: {
114
- ...currentPalette.default,
115
- ...(!highlightAllAnnotations && currentPalette.hidden),
116
- },
101
+ const resources = renderedAnnotations.flatMap((annotation) => annotation.resources);
102
+
103
+ // POI pins always draw last, on top of every other annotation shape on the same canvas -
104
+ // notably including a Journey's own annotation target (an svg/fragment selector, not a
105
+ // pointSelector), which would otherwise draw in plain store order and can end up covering
106
+ // - and stealing clicks from - a POI pin it happens to overlap on screen.
107
+ const [otherResources, poiResources] = partition(resources, (resource) => !resource.pointSelector);
108
+
109
+ [...otherResources, ...poiResources].forEach((resource) => {
110
+ const osdCanvasIndex = canvasWorld.canvases.findIndex((canvas) => canvas.id === resource.targetId);
111
+ if (osdCanvasIndex === -1) return;
112
+ const viewportCanvas = viewer.world.getItemAt(osdCanvasIndex);
113
+ if (!viewportCanvas) return;
114
+ const offset = canvasWorld.offsetByCanvas(resource.targetId);
115
+ const zoomRatio = viewportCanvas.viewportToImageZoom(viewer.viewport.getZoom(true));
116
+ const canvasAnnotationDisplay = new CanvasAnnotationDisplay({
117
+ hovered: hoveredAnnotationIds.includes(resource.id),
118
+ offset,
119
+ palette: {
120
+ ...currentPalette,
121
+ default: {
122
+ ...currentPalette.default,
123
+ ...(!highlightAllAnnotations && currentPalette.hidden),
117
124
  },
118
- resource,
119
- selected: selectedAnnotationId === resource.id,
120
- zoomRatio,
121
- });
122
- canvasAnnotationDisplay.toContext(context);
125
+ },
126
+ resource,
127
+ selected: selectedAnnotationId === resource.id,
128
+ zoomRatio,
123
129
  });
130
+ canvasAnnotationDisplay.toContext(context);
124
131
  });
125
132
  },
126
133
  [osdCanvasOverlay, viewer, canvasWorld, highlightAllAnnotations, hoveredAnnotationIds, selectedAnnotationId],
@@ -0,0 +1,80 @@
1
+ import { useEffect, useId, useRef } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { viewer } from '../init';
4
+ import { poiPreviewPlugins } from '../plugins/poiPreviewPlugin.tsx';
5
+
6
+ // Hides Mirador's generic multi-window IIIF-viewer chrome (top bar, workspace controls,
7
+ // canvas panel) so a manifest reads as a single interactive map rather than a document
8
+ // viewer, and opens straight onto the annotation sidebar since that's where a map's
9
+ // POIs/journeys live. Exported so callers who need Mirador's raw `viewer()`/`Mirador.viewer()`
10
+ // API (e.g. the integration demo config) can build on the same preset instead of copying it.
11
+ export const defaultMapViewerConfig = {
12
+ theme: {
13
+ components: {
14
+ WindowTopBar: {
15
+ styleOverrides: {
16
+ root: {
17
+ display: 'none',
18
+ },
19
+ },
20
+ },
21
+ },
22
+ },
23
+ window: {
24
+ defaultSideBarPanel: 'annotations',
25
+ // Closed by default (issue #410): the left sidebar (annotation list, table of
26
+ // contents, etc.) is Mirador's own document-viewer chrome, not part of the map UI -
27
+ // POIs/journeys are opened through the canvas or the poiPreview companion window
28
+ // instead. `highlightAllAnnotations` still draws markers on the canvas regardless.
29
+ sideBarOpenByDefault: false,
30
+ highlightAllAnnotations: true,
31
+ panels: {
32
+ canvas: false,
33
+ },
34
+ },
35
+ workspaceControlPanel: {
36
+ enabled: false,
37
+ },
38
+ };
39
+
40
+ /**
41
+ * Drop-in map renderer: give it a IIIF manifest URL, get Mirador pre-configured to look
42
+ * and behave like a map instead of a generic multi-window viewer. Wraps `viewer()` so
43
+ * callers don't need to know Mirador's own instantiation/config API.
44
+ */
45
+ export function MapViewer({ manifestId }) {
46
+ const baseId = useId().replace(/:/g, '');
47
+ const wrapperRef = useRef(null);
48
+
49
+ useEffect(() => {
50
+ const wrapper = wrapperRef.current;
51
+ if (!wrapper) return undefined;
52
+
53
+ const container = document.createElement('div');
54
+ container.id = `mirador-map-viewer-${baseId}`;
55
+ container.style.position = 'absolute';
56
+ container.style.inset = 0;
57
+ wrapper.appendChild(container);
58
+
59
+ const instance = viewer(
60
+ {
61
+ ...defaultMapViewerConfig,
62
+ id: container.id,
63
+ windows: [{ manifestId }],
64
+ },
65
+ poiPreviewPlugins,
66
+ );
67
+
68
+ return () => {
69
+ instance.unmount();
70
+ container.remove();
71
+ };
72
+ }, [baseId, manifestId]);
73
+
74
+ return <div ref={wrapperRef} style={{ height: '100%', position: 'relative', width: '100%' }} />;
75
+ }
76
+
77
+ MapViewer.propTypes = {
78
+ /** URL of the IIIF manifest to render as a map. */
79
+ manifestId: PropTypes.string.isRequired,
80
+ };
@@ -41,6 +41,7 @@ export * from './ManifestInfo';
41
41
  export * from './ManifestListItem';
42
42
  export * from './ManifestListItemError';
43
43
  export * from './ManifestRelatedLinks';
44
+ export * from './MapViewer';
44
45
  export * from './MinimalWindow';
45
46
  export * from './MiradorMenuButton';
46
47
  export * from './MosaicRenderPreview';
@@ -13,13 +13,41 @@ import { buildPath2D } from '../lib/svgShapesToPath';
13
13
  const POI_ICON_PATH_D =
14
14
  'M50.002 0C30.763 0 15 15.718 15 34.902c0 7.432 2.374 14.34 6.392 20.019L45.73 96.994c3.409 4.453 5.675 3.607 8.51-.235l26.843-45.683c.542-.981.967-2.026 1.338-3.092A34.446 34.446 0 0 0 85 34.902C85 15.718 69.24 0 50.002 0zm0 16.354c10.359 0 18.597 8.218 18.597 18.548c0 10.33-8.238 18.544-18.597 18.544c-10.36 0-18.601-8.215-18.601-18.544c0-10.33 8.241-18.548 18.6-18.548z';
15
15
  const POI_ICON_VIEWBOX_SIZE = 100;
16
- /** The icon's own point (its pin tip), in viewBox units - this is what gets placed exactly on the annotated coordinate, not the icon's bounding-box center. */
17
- const POI_ICON_TIP = { x: 50, y: 97 };
16
+ /** The icon's own point (its pin tip), in viewBox units - this is what gets placed exactly on the annotated coordinate, not the icon's bounding-box center. Derived from the path's actual lowest point (~50, 100), not just its nearby `L` command endpoint. */
17
+ const POI_ICON_TIP = { x: 50, y: 100 };
18
18
  /** Center/radius of the icon's circular head, in viewBox units - where a journey-order badge is drawn, overlapping the icon like a numbered pin. */
19
19
  const POI_ICON_HEAD = { x: 50, y: 34.9, radius: 18.6 };
20
+ /** Font size of the journey-order number, relative to POI_ICON_HEAD.radius, so it's bigger than the badge circle would otherwise imply while the circle itself stays the same size. */
21
+ const POI_ICON_HEAD_FONT_SCALE = 1.6;
20
22
  const POI_ICON_DEFAULT_FILL = '#1e88e5';
21
23
  /** Constant on-screen height (CSS px) for the POI icon, regardless of zoom - counter-scaled the same way this class already counter-scales stroke width (see `lineWidth /= zoomRatio` in svgContext). */
22
- export const POI_ICON_HEIGHT_PX = 32;
24
+ export const POI_ICON_HEIGHT_PX = 44;
25
+ /**
26
+ * On-screen radius (CSS px, before counter-scaling by zoomRatio) of a POI marker's
27
+ * clickable/tappable hit target - deliberately independent of, and larger than, the icon's
28
+ * visual size (POI_ICON_HEIGHT_PX). The icon tapers to a fine point at its anchor, which is
29
+ * hard to hit precisely with a mouse and well under the ~44-48px minimum touch target size
30
+ * recommended for mobile (Apple HIG / Material Design), so the tappable area is deliberately
31
+ * generous rather than matching the icon's own footprint.
32
+ */
33
+ export const POI_ICON_HIT_RADIUS_PX = 32;
34
+
35
+ /**
36
+ * The on-screen hit-test circle (already counter-scaled by zoomRatio, in the same coordinate
37
+ * space as a resource's `pointSelector`) for a POI marker anchored at (x, y). Centered on the
38
+ * icon's round head rather than its pin tip: the tip is the anchor point used for rendering
39
+ * (see pointContext/POI_ICON_TIP), but it's the least visually prominent, hardest-to-aim-for
40
+ * part of the icon, so hit-testing there instead of at the head would make the marker harder,
41
+ * not easier, to hit.
42
+ */
43
+ export function poiHitTarget(x, y, zoomRatio) {
44
+ const iconScale = POI_ICON_HEIGHT_PX / zoomRatio / POI_ICON_VIEWBOX_SIZE;
45
+ return {
46
+ radius: POI_ICON_HIT_RADIUS_PX / zoomRatio,
47
+ x: x + (POI_ICON_HEAD.x - POI_ICON_TIP.x) * iconScale,
48
+ y: y + (POI_ICON_HEAD.y - POI_ICON_TIP.y) * iconScale,
49
+ };
50
+ }
23
51
 
24
52
  export default class CanvasAnnotationDisplay {
25
53
  /** */
@@ -95,7 +123,7 @@ export default class CanvasAnnotationDisplay {
95
123
  this.context.fill();
96
124
 
97
125
  this.context.fillStyle = this.currentPalette().fillStyle || POI_ICON_DEFAULT_FILL;
98
- this.context.font = `bold ${POI_ICON_HEAD.radius}px sans-serif`;
126
+ this.context.font = `bold ${POI_ICON_HEAD.radius * POI_ICON_HEAD_FONT_SCALE}px sans-serif`;
99
127
  this.context.textAlign = 'center';
100
128
  this.context.textBaseline = 'middle';
101
129
  this.context.fillText(String(order), POI_ICON_HEAD.x, POI_ICON_HEAD.y);
@@ -0,0 +1,357 @@
1
+ import { useState, type ComponentType } from 'react';
2
+ import useMediaQuery from '@mui/material/useMediaQuery';
3
+ import { useTheme } from '@mui/material/styles';
4
+ import {
5
+ ConnectedCompanionWindow as CompanionWindow,
6
+ addCompanionWindow,
7
+ getAnnotations,
8
+ getCompanionWindow,
9
+ getCompanionWindows,
10
+ getVisibleCanvases,
11
+ updateCompanionWindow,
12
+ // Relative, not `from 'dbf-mirador'`: this file lives inside the dbf-mirador package
13
+ // itself (unlike its copy in the Strapi maps plugin, a real external consumer), and
14
+ // there's no self-referencing node_modules link or dist build for the package name to
15
+ // resolve against during local dev.
16
+ } from '../index';
17
+
18
+ // A "dumb" way to preview a POI/journey (issue #375): a Mirador companion window plugin,
19
+ // registered alongside dbf-mirador-annotation-editor's own (see MiradorMaeViewer.tsx) via
20
+ // Mirador's `companionWindowKey` mechanism - no core patch needed, just adding this plugin
21
+ // to the same `plugins` array. The actual "Preview" button lives per-row in
22
+ // dbf-mirador-annotation-editor's own CanvasListItem.jsx, right next to Edit/Delete
23
+ // (see that package's own PR for issue #375) - it opens this companion window passing the
24
+ // specific row's id as `annotationid`, the same convention MAE's own 'annotationCreation'
25
+ // companion window already uses for its own Edit button.
26
+ //
27
+ // Journeys (issue #378) get a richer path here: instead of the "dumb" dual-locale dump
28
+ // below, a Journey renders its ordered stops as numbered cards with a single active
29
+ // locale (toggle EN/AR), closer to how the public site will eventually present it. Two
30
+ // pieces of the Figma design are intentionally NOT built here, per the issue author's own
31
+ // follow-up comment saying they "wait more precision": numbered pins drawn on the map
32
+ // canvas itself, and an SVG line joining those pins in journey order.
33
+
34
+ const POI_PREVIEW_CONTENT_ID = 'mapsPoiPreview';
35
+
36
+ type TextualAnnotationBody = {
37
+ purpose: 'identifying' | 'describing';
38
+ type: 'TextualBody';
39
+ language: string;
40
+ value: string;
41
+ };
42
+
43
+ // Mirrors the maps plugin's `Annotation['dbf:mediaEn']`/`Annotation['dbf:mediaAr']`
44
+ // (issue #377) - one media selection per language, unlike the single, language-agnostic
45
+ // `dbf:media` extension issue #391 briefly introduced.
46
+ type DbfMedia = {
47
+ source: 'upload' | 'iiif-image' | 'media-item';
48
+ id: string;
49
+ title: string | null;
50
+ mediaType?: string | null;
51
+ thumbnailUrl: string | null;
52
+ };
53
+
54
+ type RawAnnotation = {
55
+ id: string;
56
+ 'dbf:kind'?: 'POI' | 'Journey';
57
+ 'dbf:mediaEn'?: DbfMedia | null;
58
+ 'dbf:mediaAr'?: DbfMedia | null;
59
+ // A POI's membership in a journey and its position within it - a Journey never lists
60
+ // its own POIs, membership is always POI -> Journey (see mirador-annotation-editor's
61
+ // annotationListGrouping.js, which this plugin can't import directly since grouping
62
+ // isn't part of that package's public API - so the same filter+sort is redone below).
63
+ 'dbf:journey'?: { id: string; order: number } | null;
64
+ body?: TextualAnnotationBody[];
65
+ };
66
+
67
+ // Both locales, side by side - see the module comment above: the point of a "dumb" preview
68
+ // is to see everything a poi/journey holds, not to render it the way the public site
69
+ // eventually will for one active language.
70
+ const LOCALES = ['en', 'ar'] as const;
71
+
72
+ const textBody = (
73
+ annotation: RawAnnotation,
74
+ language: string,
75
+ purpose: 'identifying' | 'describing'
76
+ ): string =>
77
+ annotation.body?.find(
78
+ (item): item is TextualAnnotationBody =>
79
+ item.type === 'TextualBody' && item.purpose === purpose && item.language === language
80
+ )?.value ?? '';
81
+
82
+ const mediaForLocale = (annotation: RawAnnotation, locale: (typeof LOCALES)[number]): DbfMedia | null | undefined =>
83
+ locale === 'en' ? annotation['dbf:mediaEn'] : annotation['dbf:mediaAr'];
84
+
85
+ // A journey's ordered stops, derived the same way the rest of the codebase does: every
86
+ // POI whose `dbf:journey.id` points at this journey, sorted by `dbf:journey.order`.
87
+ export const getOrderedJourneyPois = (items: RawAnnotation[], journeyId: string): RawAnnotation[] =>
88
+ items
89
+ .filter(
90
+ (item): item is RawAnnotation & { 'dbf:journey': { id: string; order: number } } =>
91
+ item['dbf:kind'] === 'POI' && item['dbf:journey']?.id === journeyId
92
+ )
93
+ .sort((a, b) => a['dbf:journey'].order - b['dbf:journey'].order);
94
+
95
+ const JOURNEY_PREVIEW_LOCALES = ['en', 'ar'] as const;
96
+ type JourneyPreviewLocale = (typeof JOURNEY_PREVIEW_LOCALES)[number];
97
+
98
+ // Arabic is the only RTL locale this project handles today (mirrors the same check in
99
+ // mirador-annotation-editor's POITemplate.jsx/JourneyTemplate.jsx).
100
+ const isRtlLocale = (locale: string) => locale.toLowerCase().startsWith('ar');
101
+
102
+ interface JourneyPreviewContentProps {
103
+ id: string;
104
+ journey: RawAnnotation;
105
+ pois: RawAnnotation[];
106
+ windowId: string;
107
+ }
108
+
109
+ const JourneyPreviewContent = ({ id, journey, pois, windowId }: JourneyPreviewContentProps) => {
110
+ const [locale, setLocale] = useState<JourneyPreviewLocale>('en');
111
+ const journeyTitle = textBody(journey, locale, 'identifying');
112
+
113
+ return (
114
+ <CompanionWindow id={id} title={journeyTitle || 'Journey'} windowId={windowId}>
115
+ <div dir={isRtlLocale(locale) ? 'rtl' : 'ltr'} style={{ padding: 16 }}>
116
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 4, marginBottom: 16 }}>
117
+ {JOURNEY_PREVIEW_LOCALES.map((code) => (
118
+ <button
119
+ aria-pressed={locale === code}
120
+ key={code}
121
+ onClick={() => setLocale(code)}
122
+ style={{
123
+ background: locale === code ? '#333' : 'transparent',
124
+ border: '1px solid #333',
125
+ borderRadius: 4,
126
+ color: locale === code ? '#fff' : '#333',
127
+ cursor: 'pointer',
128
+ padding: '4px 12px',
129
+ }}
130
+ type="button"
131
+ >
132
+ {code.toUpperCase()}
133
+ </button>
134
+ ))}
135
+ </div>
136
+ {pois.length === 0 && <p>This journey has no stops yet.</p>}
137
+ {pois.map((poi, index) => {
138
+ const poiTitle = textBody(poi, locale, 'identifying');
139
+ const description = textBody(poi, locale, 'describing');
140
+ const media = mediaForLocale(poi, locale);
141
+ const isLast = index === pois.length - 1;
142
+ return (
143
+ <div key={poi.id} style={{ display: 'flex' }}>
144
+ <div
145
+ style={{
146
+ alignItems: 'center',
147
+ display: 'flex',
148
+ flexDirection: 'column',
149
+ marginInlineEnd: 16,
150
+ }}
151
+ >
152
+ <span
153
+ style={{
154
+ alignItems: 'center',
155
+ background: '#000',
156
+ borderRadius: '50%',
157
+ color: '#fff',
158
+ display: 'flex',
159
+ flexShrink: 0,
160
+ fontWeight: 'bold',
161
+ height: 28,
162
+ justifyContent: 'center',
163
+ width: 28,
164
+ }}
165
+ >
166
+ {index + 1}
167
+ </span>
168
+ {/* A plain vertical stepper line standing in for the map's own dashed
169
+ connector line, which is out of scope here - see the module comment. */}
170
+ {!isLast && <div style={{ background: '#ccc', flex: 1, margin: '4px 0', width: 2 }} />}
171
+ </div>
172
+ <div style={{ flex: 1, paddingBottom: isLast ? 0 : 24 }}>
173
+ <h3 style={{ margin: '0 0 8px' }}>{poiTitle || '—'}</h3>
174
+ {media?.thumbnailUrl && (
175
+ <img
176
+ alt={media.title ?? ''}
177
+ src={media.thumbnailUrl}
178
+ style={{ borderRadius: 8, marginBottom: 8, maxWidth: '100%' }}
179
+ />
180
+ )}
181
+ {description && (
182
+ // eslint-disable-next-line react/no-danger -- description is CKEditor HTML, the same content the public site will eventually render
183
+ <div dangerouslySetInnerHTML={{ __html: description }} />
184
+ )}
185
+ </div>
186
+ </div>
187
+ );
188
+ })}
189
+ </div>
190
+ </CompanionWindow>
191
+ );
192
+ };
193
+
194
+ interface PoiPreviewContentProps {
195
+ annotation: RawAnnotation | null;
196
+ id: string;
197
+ windowId: string;
198
+ }
199
+
200
+ const PoiPreviewContent = ({ annotation, id, windowId }: PoiPreviewContentProps) => (
201
+ <CompanionWindow id={id} title={annotation?.['dbf:kind'] ?? 'Preview'} windowId={windowId}>
202
+ <div style={{ padding: 16 }}>
203
+ {!annotation && <p>This annotation could not be found - it may have been deleted.</p>}
204
+ {annotation &&
205
+ LOCALES.map((locale) => {
206
+ const title = textBody(annotation, locale, 'identifying');
207
+ const description = textBody(annotation, locale, 'describing');
208
+ const media = mediaForLocale(annotation, locale);
209
+ return (
210
+ <section key={locale} style={{ marginBottom: 24 }}>
211
+ <h3 style={{ textTransform: 'uppercase' }}>{locale}</h3>
212
+ <p>
213
+ <strong>{title || '—'}</strong>
214
+ </p>
215
+ {description && (
216
+ // eslint-disable-next-line react/no-danger -- descriptionEn/Ar is CKEditor HTML, the same content the public site will eventually render
217
+ <div dangerouslySetInnerHTML={{ __html: description }} />
218
+ )}
219
+ {media &&
220
+ (media.thumbnailUrl ? (
221
+ <img alt={media.title ?? ''} src={media.thumbnailUrl} style={{ maxWidth: '100%' }} />
222
+ ) : (
223
+ <p>
224
+ {media.title}
225
+ {media.mediaType ? ` (${media.mediaType})` : ''}
226
+ </p>
227
+ ))}
228
+ </section>
229
+ );
230
+ })}
231
+ </div>
232
+ </CompanionWindow>
233
+ );
234
+
235
+ interface PreviewContentProps {
236
+ annotation: RawAnnotation | null;
237
+ id: string;
238
+ journeyPois: RawAnnotation[];
239
+ windowId: string;
240
+ }
241
+
242
+ const PreviewContent = ({ annotation, id, journeyPois, windowId }: PreviewContentProps) => {
243
+ if (annotation && annotation['dbf:kind'] === 'Journey') {
244
+ return <JourneyPreviewContent id={id} journey={annotation} pois={journeyPois} windowId={windowId} />;
245
+ }
246
+ return <PoiPreviewContent annotation={annotation} id={id} windowId={windowId} />;
247
+ };
248
+
249
+ const poiPreviewCompanionWindowPlugin = {
250
+ component: PreviewContent,
251
+ companionWindowKey: POI_PREVIEW_CONTENT_ID,
252
+ mapStateToProps: (state: unknown, { id, windowId }: { id: string; windowId: string }) => {
253
+ const annotationId = getCompanionWindow(state, { companionWindowId: id })?.annotationid as
254
+ | string
255
+ | undefined;
256
+ const canvasId = getVisibleCanvases(state, { windowId })[0]?.id;
257
+ const pages: Record<string, { json?: { items?: unknown[] } }> | undefined = canvasId
258
+ ? getAnnotations(state)[canvasId]
259
+ : undefined;
260
+ const items = (pages ? Object.values(pages) : []).flatMap(
261
+ (page) => (page.json?.items ?? []) as RawAnnotation[]
262
+ );
263
+ const annotation = items.find((item) => item.id === annotationId) ?? null;
264
+ return {
265
+ annotation,
266
+ journeyPois:
267
+ annotation && annotation['dbf:kind'] === 'Journey' ? getOrderedJourneyPois(items, annotation.id) : [],
268
+ };
269
+ },
270
+ };
271
+
272
+ // A canvas annotation resource, as AnnotationsOverlay's own `annotations`/`searchAnnotations`
273
+ // props carry it (see AnnotationItem#pointSelector/#svgSelector) - a POI marker is any resource
274
+ // with a pointSelector (CanvasAnnotationDisplay#pointContext), a Journey's own canvas target is
275
+ // one with an svgSelector (AnnotationsOverlay#isAnnotationAtPoint's own svgSelector branch).
276
+ type AnnotationResourceLike = { id: string; pointSelector?: unknown; svgSelector?: unknown };
277
+ type AnnotationListItem = { resources?: AnnotationResourceLike[] };
278
+
279
+ interface AnnotationsOverlayTargetProps {
280
+ annotations?: AnnotationListItem[];
281
+ searchAnnotations?: AnnotationListItem[];
282
+ selectAnnotation?: (windowId: string, annotationId: string) => void;
283
+ windowId: string;
284
+ [key: string]: unknown;
285
+ }
286
+
287
+ interface AnnotationsOverlayPoiClickWrapperProps {
288
+ TargetComponent: ComponentType<Record<string, unknown>>;
289
+ targetProps: AnnotationsOverlayTargetProps;
290
+ addCompanionWindow: typeof addCompanionWindow;
291
+ updateCompanionWindow: typeof updateCompanionWindow;
292
+ existingPreviewCompanionWindowId?: string;
293
+ }
294
+
295
+ // Wraps AnnotationsOverlay (the component that owns the OSD canvas-click handler and the
296
+ // select/deselect-annotation dispatch, see AnnotationsOverlay#toggleAnnotation) so that
297
+ // clicking a POI marker OR a Journey's own path on the canvas - not just a row in a list
298
+ // somewhere - opens this same preview companion window a "Preview" button would (issue #375's
299
+ // own follow-up: pins on the map itself; issue #378 extends this to journeys). Re-uses an
300
+ // already-open preview window instead of stacking a new one per click.
301
+ const AnnotationsOverlayPoiClickWrapper = ({
302
+ TargetComponent,
303
+ targetProps,
304
+ addCompanionWindow: dispatchAddCompanionWindow,
305
+ updateCompanionWindow: dispatchUpdateCompanionWindow,
306
+ existingPreviewCompanionWindowId,
307
+ }: AnnotationsOverlayPoiClickWrapperProps) => {
308
+ const { annotations = [], searchAnnotations = [], selectAnnotation } = targetProps;
309
+
310
+ const theme = useTheme();
311
+ // Issue #410: on a phone-width viewport there's no room for a right-hand rail next to
312
+ // the map, so the POI/journey preview opens as a bottom sheet instead.
313
+ const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
314
+ const previewPosition = isMobile ? 'bottom' : 'right';
315
+
316
+ const selectAnnotationAndMaybePreview = (clickedWindowId: string, annotationId: string) => {
317
+ selectAnnotation?.(clickedWindowId, annotationId);
318
+
319
+ const resource = [...annotations, ...searchAnnotations]
320
+ .flatMap((item) => item.resources ?? [])
321
+ .find((item) => item.id === annotationId);
322
+
323
+ // pointSelector => POI pin, svgSelector => a Journey's own canvas target (its path/stops
324
+ // line) - anything else (e.g. a fragmentSelector) has no preview content, skip it.
325
+ if (!resource?.pointSelector && !resource?.svgSelector) return;
326
+
327
+ if (existingPreviewCompanionWindowId) {
328
+ dispatchUpdateCompanionWindow(clickedWindowId, existingPreviewCompanionWindowId, {
329
+ annotationid: annotationId,
330
+ position: previewPosition,
331
+ });
332
+ } else {
333
+ dispatchAddCompanionWindow(clickedWindowId, {
334
+ annotationid: annotationId,
335
+ content: POI_PREVIEW_CONTENT_ID,
336
+ position: previewPosition,
337
+ });
338
+ }
339
+ };
340
+
341
+ return <TargetComponent {...targetProps} selectAnnotation={selectAnnotationAndMaybePreview} />;
342
+ };
343
+
344
+ const poiPreviewClickPlugin = {
345
+ target: 'AnnotationsOverlay',
346
+ mode: 'wrap',
347
+ component: AnnotationsOverlayPoiClickWrapper,
348
+ mapStateToProps: (state: unknown, { windowId }: { windowId: string }) => {
349
+ const existing = Object.values(getCompanionWindows(state) as Record<string, { id: string; windowId: string; content: string | null }>).find(
350
+ (cw) => cw.windowId === windowId && cw.content === POI_PREVIEW_CONTENT_ID,
351
+ );
352
+ return { existingPreviewCompanionWindowId: existing?.id };
353
+ },
354
+ mapDispatchToProps: { addCompanionWindow, updateCompanionWindow },
355
+ };
356
+
357
+ export const poiPreviewPlugins = [poiPreviewCompanionWindowPlugin, poiPreviewClickPlugin];