From 959d4323bc83bf4740216c340aa7e1da2c3e3440 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Thu, 18 Jun 2026 15:47:35 -0500 Subject: [PATCH] Fix Leaflet IIIF image viewer --- .../resource/ResourceViewer.test.tsx | 40 +++++++++++++++ .../components/resource/ResourceViewer.tsx | 21 ++++---- .../leaflet_viewer_controller.js | 50 +++++++++++++++++-- 3 files changed, 96 insertions(+), 15 deletions(-) diff --git a/frontend/src/__tests__/components/resource/ResourceViewer.test.tsx b/frontend/src/__tests__/components/resource/ResourceViewer.test.tsx index 81be286e..40dabbfd 100644 --- a/frontend/src/__tests__/components/resource/ResourceViewer.test.tsx +++ b/frontend/src/__tests__/components/resource/ResourceViewer.test.tsx @@ -287,6 +287,19 @@ const iiifManifestData = { }, } as Parameters[0]['data']; +const iiifImageDataWithoutGeometry = { + attributes: { dct_references_s: {} }, + meta: { + ui: { + viewer: { + protocol: 'iiif_image', + endpoint: + 'https://cdm17287.contentdm.oclc.org/digital/iiif/wpamaps/2535/info.json', + }, + }, + }, +} as Parameters[0]['data']; + describe('ResourceViewer', () => { let rectSpy: ReturnType; @@ -369,6 +382,33 @@ describe('ResourceViewer', () => { }); describe('Leaflet-backed viewer remounts', () => { + it('renders IIIF Image API endpoints through the Leaflet IIIF protocol instead of Mirador', async () => { + const { container } = render( + + ); + + await act(async () => {}); + + expect( + container.querySelector('iframe[title="Mirador viewer"]') + ).toBeNull(); + + const viewer = container.querySelector('#leaflet-viewer'); + expect(viewer).not.toBeNull(); + expect(viewer?.getAttribute('data-leaflet-viewer-protocol-value')).toBe( + 'Iiif' + ); + expect(viewer?.getAttribute('data-leaflet-viewer-available-value')).toBe( + 'true' + ); + expect(viewer?.getAttribute('data-leaflet-viewer-url-value')).toBe( + 'https://cdm17287.contentdm.oclc.org/digital/iiif/wpamaps/2535/info.json' + ); + expect(viewer?.hasAttribute('data-leaflet-viewer-map-geom-value')).toBe( + false + ); + }); + it('replaces the viewer container when the resource changes', async () => { const { rerender, container } = render( diff --git a/frontend/src/components/resource/ResourceViewer.tsx b/frontend/src/components/resource/ResourceViewer.tsx index 83faf695..f8082c74 100644 --- a/frontend/src/components/resource/ResourceViewer.tsx +++ b/frontend/src/components/resource/ResourceViewer.tsx @@ -365,7 +365,8 @@ export function ResourceViewer({ data, pageValue }: ResourceViewerProps) { const protocol = data.meta?.ui?.viewer?.protocol || ''; const endpoint = data.meta?.ui?.viewer?.endpoint || ''; const geometry = data.meta?.ui?.viewer?.geometry; - const available = !!protocol && !!endpoint && !!geometry; + const available = + !!protocol && !!endpoint && (protocol === 'iiif_image' || !!geometry); const layerIdentifier = data.attributes.ogm?.gbl_wxsIdentifier_s || data.attributes.ogm?.gbl_wxsidentifier_s || @@ -384,7 +385,7 @@ export function ResourceViewer({ data, pageValue }: ResourceViewerProps) { // Helper function to determine viewer type const getViewerType = (protocol: string) => { - if (['iiif_manifest', 'iiif_image'].includes(protocol)) { + if (protocol === 'iiif_manifest') { return 'mirador'; } if (['cog', 'pmtiles'].includes(protocol)) { @@ -426,6 +427,9 @@ export function ResourceViewer({ data, pageValue }: ResourceViewerProps) { if (protocol === 'arcgis_image_map_layer') { return 'ImageMapLayer'; } + if (protocol === 'iiif_image') { + return 'Iiif'; + } if (protocol === 'open_index_map') { return 'IndexMap'; } @@ -451,14 +455,9 @@ export function ResourceViewer({ data, pageValue }: ResourceViewerProps) { ); } - // For iiif_image, we synthesize a Presentation manifest via a local SSR route. - // Mirador runs in a sandboxed iframe, so use absolute URLs for both real and - // synthesized manifests. + // Mirador runs in a sandboxed iframe, so use an absolute URL for the manifest. const pageOrigin = window.location.origin; - const manifestUrl = - protocol === 'iiif_image' - ? `${pageOrigin}/iiif/manifest?image_service=${encodeURIComponent(endpoint)}` - : new URL(endpoint, pageOrigin).toString(); + const manifestUrl = new URL(endpoint, pageOrigin).toString(); const miradorUrl = new URL('/mirador', pageOrigin); miradorUrl.searchParams.set('manifest', manifestUrl); @@ -604,9 +603,7 @@ export function ResourceViewer({ data, pageValue }: ResourceViewerProps) { data-controller="leaflet-viewer" data-leaflet-viewer-available-value={available} data-leaflet-viewer-map-geom-value={JSON.stringify(geometry)} - data-leaflet-viewer-layer-id-value={ - data.attributes.ogm.gbl_wxsIdentifier_s || '' - } + data-leaflet-viewer-layer-id-value={layerIdentifier} data-leaflet-viewer-options-value={JSON.stringify( leafletViewerOptions )} diff --git a/frontend/src/geoblacklight/leaflet_viewer_controller.js b/frontend/src/geoblacklight/leaflet_viewer_controller.js index 43ab65eb..97b13859 100644 --- a/frontend/src/geoblacklight/leaflet_viewer_controller.js +++ b/frontend/src/geoblacklight/leaflet_viewer_controller.js @@ -3,7 +3,27 @@ import BaseLeafletViewerController from '@geoblacklight/frontend/app/javascript/ import Sleep from 'geoblacklight/leaflet/controls/sleep'; import { registerLeafletGestureHandling } from '../config/leafletGestureHandling'; +let leafletIiifPromise; + +async function ensureLeafletIiif() { + if (Leaflet.tileLayer.iiif) return; + + globalThis.L = Leaflet; + if (typeof window !== 'undefined') window.L = Leaflet; + + leafletIiifPromise ||= import('leaflet-iiif'); + await leafletIiifPromise; + + if (!Leaflet.tileLayer.iiif) { + throw new Error('leaflet-iiif did not register L.tileLayer.iiif'); + } +} + export default class LeafletViewerController extends BaseLeafletViewerController { + get isIiifImage() { + return this.protocolValue === 'Iiif'; + } + // Keep the GeoBlacklight controller behavior, but let local MAP options reach L.map. async loadMap() { if (this.map) return; @@ -12,12 +32,23 @@ export default class LeafletViewerController extends BaseLeafletViewerController const sleepSettings = this.optionsValue.SLEEP || { SLEEP: false }; const mapSettings = this.optionsValue.MAP || {}; - this.map = map(this.element, { ...sleepSettings, ...mapSettings }); + const iiifSettings = this.isIiifImage + ? { + center: [0, 0], + crs: Leaflet.CRS.Simple, + zoom: 0, + } + : {}; + this.map = map(this.element, { + ...sleepSettings, + ...mapSettings, + ...iiifSettings, + }); if (sleepSettings.SLEEP) this.map.addHandler('SLEEP', Sleep); - this.map.addLayer(this.basemap); + if (!this.isIiifImage) this.map.addLayer(this.basemap); this.map.addLayer(this.overlay); - this.fitBounds(this.bounds); + if (!this.isIiifImage) this.fitBounds(this.bounds); this.map.options.selected_color = this.optionsValue.SELECTED_COLOR || 'blue'; @@ -38,4 +69,17 @@ export default class LeafletViewerController extends BaseLeafletViewerController this.dispatch('loaded'); } + + async getPreviewOverlay(protocol, url, options) { + if (protocol === 'Iiif') { + await ensureLeafletIiif(); + return Leaflet.tileLayer.iiif(url, { + ...options, + fitBounds: true, + setMaxBounds: true, + }); + } + + return super.getPreviewOverlay(protocol, url, options); + } }