Introduction

I implemented functionality in Mirador, a widely used IIIF (International Image Interoperability Framework) viewer, to meet the following requirements:

  1. Display the canvas (page) specified by URL parameters on initial load
  2. Highlight search terms within the specified canvas

This article shares the approach and implementation method for achieving these requirements.

Exploring Approaches

defaultSearchQuery Option

In Mirador 4, you can automatically execute a search at initialization by specifying the defaultSearchQuery option in the window settings:

const miradorViewer = Mirador.viewer({
  windows: [{
    manifestId: manifestUrl,
    canvasId: canvasId,
    defaultSearchQuery: 'search term',
  }],
});

This option is a convenient feature that automatically executes a search, but for this use case, the following points needed to be considered:

  1. Page navigation control - Since the specification automatically navigates to the page of the first hit when a search is executed, additional control is needed if you want to stay on the page specified by canvasId
  2. Knowing when the search completes - Since the search is executed asynchronously, monitoring Redux state is needed if you want to perform processing after completion

A More Direct Approach

After consideration, I adopted an approach that directly uses the receiveSearch action. With this approach:

  1. Call the Search API directly to get only hits matching the specified canvas
  2. Build a response in IIIF Search API format
  3. Register it as search results in Mirador using the receiveSearch action

This allows leveraging Mirador’s search highlight feature as-is while maintaining the display of the specified canvas.

Implementation

1. Function to Get Hits from the Search API

const getSearchHitsForCanvas = async (
  manifestUrl: string,
  canvasId: string,
  query: string
): Promise<{ id: string; chars: string; xywh: string }[]> => {
  try {
    // Get the search service URL from the manifest
    const manifestResponse = await fetch(manifestUrl);
    if (!manifestResponse.ok) return [];

    const manifest = await manifestResponse.json();

    // Find the IIIF Search API service
    const services = manifest.service || [];
    const searchService = (Array.isArray(services) ? services : [services]).find(
      (s: { profile?: string }) => s.profile?.includes('search')
    );

    if (!searchService) return [];

    const searchBaseUrl = searchService['@id'] || searchService.id;
    const searchUrl = `${searchBaseUrl}?q=${encodeURIComponent(query)}`;

    const response = await fetch(searchUrl);
    if (!response.ok) return [];

    const data = await response.json();
    const resources = data.resources || [];

    // Extract only hits matching the specified canvas
    const hits: { id: string; chars: string; xywh: string }[] = [];
    for (const resource of resources) {
      const [resourceCanvas, fragment] = resource.on.split('#');
      if (resourceCanvas === canvasId && fragment) {
        hits.push({
          id: resource['@id'] || resource.id,
          chars: resource.resource?.chars || query,
          xywh: fragment.replace('xywh=', ''),
        });
      }
    }
    return hits;
  } catch (error) {
    console.error('Failed to fetch search hits:', error);
    return [];
  }
};

This implementation dynamically retrieves the IIIF Search API endpoint from the manifest’s service property. This allows it to work with any IIIF-compliant server.

2. Mirador Initialization and Highlight Registration

// Initialize Mirador
const miradorViewer = window.Mirador.viewer({
  id: 'mirador-viewer',
  windows: [{
    id: 'window-1',
    manifestId: manifestUrl,
    canvasId: canvasId,  // Display the specified canvas
    thumbnailNavigationPosition: 'far-right',
  }],
  window: {
    allowFullscreen: true,
    allowClose: false,
    sideBarOpen: true,
  },
  workspaceControlPanel: {
    enabled: false,
  },
});

// Add highlights if search term and canvas are specified
if (searchQuery && canvasId) {
  const hits = await getSearchHitsForCanvas(manifestUrl, canvasId, searchQuery);

  if (hits.length > 0) {
    const M = window.Mirador as Record<string, unknown>;
    const receiveSearch = M.receiveSearch as (
      windowId: string,
      companionWindowId: string,
      searchId: string,
      searchJson: unknown
    ) => Record<string, unknown>;

    if (receiveSearch) {
      // Build a response in IIIF Search API format
      const searchResponse = {
        '@context': 'http://iiif.io/api/search/1/context.json',
        '@id': `${canvasId}/search?q=${encodeURIComponent(searchQuery)}`,
        '@type': 'sc:AnnotationList',
        within: {
          '@type': 'sc:Layer',
          total: hits.length,
        },
        resources: hits.map((hit, index) => ({
          '@id': `${canvasId}/search-result-${index}`,
          '@type': 'oa:Annotation',
          motivation: 'sc:painting',
          resource: {
            '@type': 'cnt:ContentAsText',
            chars: hit.chars,
          },
          on: `${canvasId}#xywh=${hit.xywh}`,
        })),
      };

      // Add the search panel to the right side
      const addCompanionWindow = M.addCompanionWindow as (
        windowId: string,
        payload: { content: string; position: string }
      ) => Record<string, unknown>;

      const addAction = addCompanionWindow('window-1', {
        content: 'search',
        position: 'right',
      });
      miradorViewer.store.dispatch(addAction);

      // Get the companionWindowId
      const state = miradorViewer.store.getState();
      const searchCompanionWindowId = Object.keys(state.companionWindows).find(
        id => state.companionWindows[id].content === 'search'
      );

      if (searchCompanionWindowId) {
        // Register the search results
        const searchAction = receiveSearch(
          'window-1',
          searchCompanionWindowId,
          `${canvasId}/search?q=${encodeURIComponent(searchQuery)}`,
          searchResponse
        );
        miradorViewer.store.dispatch(searchAction);
      }
    }
  }
}

Key Points

1. Accessing Mirador’s Action Functions

In Mirador 4, action functions can be accessed directly from the global object:

const M = window.Mirador as Record<string, unknown>;
const receiveSearch = M.receiveSearch;
const addCompanionWindow = M.addCompanionWindow;

Note that you access them directly as Mirador.receiveSearch, not through Mirador.actions.

2. IIIF Search API Format Response

The JSON passed to receiveSearch must conform to the IIIF Search API 1.0 format:

{
  "@context": "http://iiif.io/api/search/1/context.json",
  "@type": "sc:AnnotationList",
  "resources": [
    {
      "@type": "oa:Annotation",
      "motivation": "sc:painting",
      "resource": {
        "@type": "cnt:ContentAsText",
        "chars": "matched text"
      },
      "on": "canvasURI#xywh=x,y,width,height"
    }
  ]
}

3. CompanionWindow Placement

The placement of the search panel can be controlled with the position parameter:

  • left: Left side (same position as the metadata panel)
  • right: Right side

A natural layout is metadata on the left and search results on the right.

Conclusion

By directly calling the receiveSearch action, the following benefits were achieved:

  1. Canvas display control - Can add highlights while displaying the specified canvas on initial load
  2. Immediate highlight display - Highlights are displayed immediately because search results are registered directly
  3. Panel placement control - The position of the search panel can be freely configured

This method leverages Mirador’s Redux store mechanism and allows for flexible customization.