Skip to content

Plugin Development Guide

Johannes Baiter edited this page Jul 20, 2021 · 1 revision

Prerequisites

Before you start developing your own Mirador 3 plugin, you should be familiar with a few technologies that are essential for Mirador 3 plugin development:

  • Familarity with a JavaScript build tool: This can be something like Webpack, Parcel, Snowpack, etc, as long as it supports loading ES6 modules. This is needed, since Mirador 3 plugins cannot be loaded from a <script> tag at the moment, but have to be part of the same bundle as Mirador 3. If you don't have a favorite tool yet, Parcel is very easy to use.
  • React: This should be obvious, Mirador 3 is a React app and plugin development is based on adding custom React components to the app. Essential resources are the React Tutorial and the section on Higher Order Components
  • Redux and React-Redux: The state in Mirador 3 is completely handled by the Redux library. Plugins that want to access or mutate the app state, or need their own global state, must make use of it. Essential resources are the Redux Essentials and the Redux Fundamentals. It's also a good idea to read through the Selector Intro for an introduction to selector functions, which are heavily used in M3.
  • Redux-Saga: Side-effects in Mirador 3 are handled with this library. It's also a highly useful tool for reacting to Redux actions, which can come in extremely handy when developing plugins (e.g. if you want your plugin to react to a page change). Essential resources are the Redux-Saga Introduction and Redux-Saga Basic Concepts.

You can learn all three of those technologies "as you go", but I recommend to work at least through the basic introductions beforehand.

What's in a Mirador 3 plugin

A Mirador 3 plugin is a React Component that targets plugin-aware React Components within Mirador. Such a plugin component can attach itself to a plugin-aware Mirador 3 component in two distinct ways:

  • add: The target component includes a slot where the plugin component will be added as part of the target components' virtual DOM tree. This is used to extend existing components with new widgets, e.g. a button in the window title bar or a new entry in the ribbon menu.
  • wrap: The plugin components wraps the target component and (optionally!) renders the target component as a child of its own virtual DOM tree. This allows the complete substitution of every plugin-aware React Component inside of Mirador 3. The wrapped component can be accessed via the TargetComponent prop that gets passed to the wrapping plugin component, this can be used to render it in the plugin's JSX via. <props.TargetComponent ...props>. This is useful if you want to e.g. add the option to toggle the visibility of a given component or you want to wrap the existing component with custom markup.

To define a plugin, create a JavaScript object with the following keys:

{
    // The plugin component that should be added
    component: MyPluginComponent,
    // The name of the plugin-aware Mirador 3 component that this plugin targets
    target: 'SomeMiradorComponent',
    // Can be 'add' or 'wrap', defines how the plugin component is rendered
    mode: 'add',
    // Refer to the Redux documentation for these two functions. The props that are
    // passed are those of the target component
    mapDispatchToProps: (dispatch, props) => { /* ... */ },
    mapStateToProps: (state, props) => { /* ... */ },
    // Define new sub-stores along with their respective reducers
    reducers: { /* ... */ },
    // Define a custom saga that should be run as part of the Mirador 3 root saga
    saga: myCustomSaga,
}

Then, to tell Mirador 3 to load the plugin, pass an array of all plugin definitions as the second argument to the Mirador.viewer initialization function:

// This assumes that the above plugin definition is exported from a `myPlugin.js` file in the
// same directory.
import myPluginDefinition from './myPlugin';

const myMiradorInstance = Mirador.viewer({ /* mirador config ... */ }, [myPluginDefinition]);

Locating plugin-aware Mirador 3 components

To check if a given component supports add plugins, search for uses of the PluginHook component inside of src/components modules in the Mirador 3 source code (or use this GitHub Link). The position of the <PluginHook /> definition will be the position where the plugin component is rendered.

For components that support wrap plugins, search for uses of the withPlugins HOC inside of the src/containers modules, (or use this GitHub Link). All of the container components that are wrapped with this HOC can be wrapped by a plugin component.

Accessing the Mirador 3 app state with Redux Selectors

If you want to get access to the current application state, e.g. to tell what the currently displayed manifest is, you will have to access Mirador 3's global application state, which is implemented as a Redux store. You cannot access it directly, but will instead have to supply a mapStateToProps object in your plugin definition that maps your plugin component's props to a selector function that pulls a value from the Redux store and passes it as a prop.

The tl;dr of a selector function is that it's a function that thakes the Redux State or one or more other selectors and extracts and returns some data from that. Think of them like a stored procedure in a DBMS, targetting your Redux state.

Mirador 3 ships with a ton of selector functions that should satisfy most of your needs as a plugin author, you can find them in the src/state/selectors directory. Unfortunately they're barely documented at the moment, but grepping for them in the code base and unit tests should give you a good idea of how to use them. Maybe you can even take the time to make a documentation PR when you've understood a selector? :-)

To illustrate, here's a small plugin definition that receives the identifier of the manifest that is currently rendered in its associated window. It targets the OpenSeadragonViewer component, which receives a prop windowId that has the identifier of the window that renders the current instance of the component. With this, we can use the getWindow selector to get the state of the current window, which has a property manifestId with the manifest that is displayed.

import { getWindow } from 'mirador/selectors';

const myPlugin = {
  component: MyPluginComponent,
  target: 'OpenSeadragonViewer',
  mode: 'add',
  // The function receives the Redux state and the target component's props
  mapStateToProps: (state, { windowId }) => ({
    // The plugin component will receive a `manifestId` prop with the identifier
    // of the window's current manifest
    manifestId: getWindow(state, { windowId }).manifestId,
  })
}

The hard part of the above is finding out which part of the Redux state you actually need and which of the pre-existing selectors you can use to get that. For the former, it's a good idea to have the Redux DevTools installed, they allow you to browse through the Redux state and check where the information you need is located. From there, you can browse through the available selectors and check if there is one that extracts it already. Generally the selector modules are structured like this:

  • Selectors for a few common top level sub-stores are located in the getters module
  • Selectors for specific sub-stores and their children are usually in a module that's named after the sub-store, e.g. the companionWindows sub-store has a companionWindows.js selector module that has selector functions to access its values

Mutating Mirador 3 state with Redux Actions

If you want to trigger some change in the application, you have to change the corresponding value in the Redux store. As usual, this cannot be done directly, but only through actions that are triggered by action creator functions. Much like selectors, Mirador ships with creator functions for every possible action, and we can use them in our plugins.

To use action creator functions from Mirador 3, map them to your plugin component's props with the mapDispatchToProps field in the plugin definition. As an example, here's a custom pagination plugin that uses the setNextCanvas and setPreviousCanvas action creator functions to page through a manifest's canvases (maybe the plugin adds a slideshow mode?)

import { setNextCanvas, setPreviousCanvas } from 'mirador/actions';

const myPlugin = {
  component: MyPluginComponent,
  target: 'OpenSeadragonViewer',
  mode: 'add',
  // The function receives the dispatch function and the target component's props
  mapDispatchToProps: (dispatch, { windowId }) => ({
    // The props are functions that create the action with the action creator function and
    // dispatch it to the Redux middleware, i.e. you can ju st call props.setNextCanvas()
    // in the component
    setNextCanvas: () => dispatch(setNextCanvas(windowId)),
    setPreviousCanvas: () => dispatch(setPreviousCanvas(windowId)),
  })
}

Reacting to Mirador 3 Redux Actions with a custom plugin Saga

TODO

Example I: Stateless add plugin

This is a minimal plugin that simply adds a small button to the window bar that simply prints Hello World from the window title bar! to the console.

The final result of the button rendered in the title bar

Step 1: Locating the target component

First, we need to find out which component we should target for rendering the button. For this, it's very useful to run Mirador 3 locally (i.e. without source minification) and use the React developer tools in the browser to find the corresponding React component.

Finding the target component with the React Developer Tools in Firefox

The component tree tells us that the component that renders the window title bar that we want to add our button to is the WindowTopBar. But does it have a <PluginHook /> for rendering add plugins? Grepping for it doesn't yield anything, but there are two other nodes in the virtual DOM that relate to plugins: <WindowTopBarPluginArea /> and <WindowTopBarPluginMenu />! The latter renders its children as a drop-down menu that is accessible via a "ribbon menu", while the former directly adds its children to the window top bar, just what we need!

Step 2: Writing the plugin component Looking at the source code of the WindowTopBar component, we can see that the buttons that are already present in the top bar all make use of the MiradorMenuButton component to render. So it's probably a good idea to use it in our plugin as well! It has just a few simple props, of which we are only going to use onClick and the support for child elements:

// This should be in a `MyPlugin.jsx` file
// Mirador 3 uses the `material-ui` React UI framework, so we can use it as a source for icons
import FlashOnIcon from '@material-ui/icons/FlashOn';

export default () => (
    <MiradorMenuButton
        onClick={() => console.log('Hello World from the window title bar!')}
    >
        <FlashOnIcon />
    </MiradorMenuButton>
);

Step 3: Defining and registering the plugin

Now that we know our target component (WindowTopBarPluginArea) and we have our plugin component in MyPlugin.jsx, all that's left is to write a small plugin definition and tell Mirador 3 to load it!

import MyPlugin from './MyPlugin';

const myPlugin = {
    component: MyPlugin,
    target: 'WindowTopBarPluginArea',
    mode: 'add'
}

const mirador = Mirador.viewer({ /* cfg goes here */ }, [myPlugin]);

Example II: Stateless wrap plugin that replaces a default component

For this example, we'll replace Mirador's default branding with our own, a small flower icon:

end result of wrap plugin

The basic approach is the same as for the add case:.

  1. Digging around in the component tree shows that we need to replace the Branding component
  2. For our custom branding, we use the original component as a blueprint and simply replace the icon:
    // MyBranding.jsx
    import React from 'react';
    import IconButton from '@material-ui/core/IconButton';
    import Typography from '@material-ui/core/Typography';
    import LocalFloristIcon from '@material-ui/icons/LocalFlorist';
    
    // The wrapping plugin components gets the same props as the component to be wrapped
    export default function MyBranding({ variant, ...ContainerProps }) {
      return (
        <div {...ContainerProps}>
          { variant === 'wide' && (
          <div>
            <Typography align="center" component="p" variant="h3">{t('mirador')}</Typography>
          </div>
          )}
          <Typography align="center">
            <IconButton
              component="a"
              href="https://example.com"
              target="_blank"
              rel="noopener"
            >
                <LocalFloristIcon />
            </IconButton>
          </Typography>
        </div>
      );
    }
  3. For registering the plugin, we only need to swap out the components and the mode:
    import Mirador from 'mirador'
    import MyBranding from './MyBranding'
    
    const myPlugin = {
        component: MyBranding,
        target: 'Branding',
        mode: 'wrap'
    };
    const mirador = Mirador.viewer({ /* cfg goes here */ }, [myPlugin]);

Example III: Plugin that interacts with the app state

For this exercise, we're going to implement a plugin that adds two new buttons to the canvas navigation, one to seek five canvases backwards and one to seek five canvases forward, to allow users to quickly skip through a manifest without having to use the thumbnail bar.

TODO: Add image of final plugin here

For this, we first locate the component responsible for rendering the existing forward/backward buttons, namely ViewerNavigation. A quick look at the component's JSX tells us it does not include a <PluginHook />, so we can't use the add mode for our plugin but have to wrap the target component instead and supply our own replacement for it.

Since we intend to keep the existing forward/backward buttons, we'll start by simply copying the existing ViewerNavigation component (not the container!) to e.g. ExtendedViewerNavigation. The existing code uses relative imports to the Mirador code base, which we will have to convert to absolute imports that target mirador/dist/es, so e.g. ../containers/MiradorMenuButton becomes mirador/dist/es/src/containers/MiradorMenuButton.

Once this is out of the way, we can start writing our plugin definition and check if everything still looks as it did before, with the difference that the forward/backward buttons are now rendered by our plugin component:

import ExtendedViewerNavigation from './ExtendedViewerNavigation';

const extendedNavigation = {
    component: ExtendedViewerNavigation,
    target: 'ViewerNavigation',
    mode: 'wrap'
}

const mirador = Mirador.viewer({ /* cfg goes here */ }, [extendedNavigation]);

TODO: Image of canvas navigation plus component tree that shows the ExtendedViewerNavigation

Success! Now on to the heart of the plugin, the two new buttons.

  • Plugin to skip 5 canvases forward/backwards
  • Extends ViewerNavigation component with a wrap-style plugin that adds two new buttons
  • Implements new selector seekCanvasGroupings(amount: number) to seek forward/backward, logic very similar to existing getNextCanvasGrouping action creator
  • Uses setCanvas action creator
  • Uses getCanvasGroupings and getCurrentCanvas selectors
  • Concepts/Patterns that should be introduced and explained:
    • Finding and understanding an existing component and its state interactions, i.e. the action creators and selectors it uses
    • Writing a custom selector that has dependencies
    • Using mapStateToProps and mapDispatchToProps for plugins

Example IV: Plugin that triggers actions asynchronously and reacts to Redux actions with a saga

  • 'Slideshow' plugin that wraps over if the last page is reached and stops when the user changes the canvas manually
  • Hardcoded interval, single button to start/stop in topbar
  • Uses setCanvas, setNextCanvas actions
  • Uses getNextCanvasGrouping selector
  • Uses a saga to continuously advance the canvases after the interval
  • IfSET_CANVAS is called during this time, abort the slideshow
  • Concepts/Patterns that should be introduced and explained:
    • Implementing a custom redux store state variable and actions and selectors to query/update it
    • Concurrency with sagas: take, put, delay, race
    • Reacting to M3 events with sagas