Skip to content
Snippets Groups Projects
Select Git revision
  • 67fb153939c03c08fd339cb094b7d0fa21c0fbb3
  • master default protected
2 results

License.txt

Blame
  • This project is licensed under the GNU General Public License v3.0 or later. Learn more
    OpenSeadragonViewer.js 13.48 KiB
    import React, { Component } from 'react';
    import PropTypes from 'prop-types';
    import isEqual from 'lodash/isEqual';
    import OpenSeadragon from 'openseadragon';
    import classNames from 'classnames';
    import ns from '../config/css-ns';
    import OpenSeadragonCanvasOverlay from '../lib/OpenSeadragonCanvasOverlay';
    import CanvasWorld from '../lib/CanvasWorld';
    import CanvasAnnotationDisplay from '../lib/CanvasAnnotationDisplay';
    
    /**
     * Represents a OpenSeadragonViewer in the mirador workspace. Responsible for mounting
     * and rendering OSD.
     */
    export class OpenSeadragonViewer extends Component {
      /**
       * annotationsMatch - compares previous annotations to current to determine
       * whether to add a new updateCanvas method to draw annotations
       * @param  {Array} currentAnnotations
       * @param  {Array} prevAnnotations
       * @return {Boolean}
       */
      static annotationsMatch(currentAnnotations, prevAnnotations) {
        if (currentAnnotations.length === 0 && prevAnnotations.length === 0) return true;
        if (currentAnnotations.length !== prevAnnotations.length) return false;
        return currentAnnotations.every((annotation, index) => {
          const newIds = annotation.resources.map(r => r.id);
          const prevIds = prevAnnotations[index].resources.map(r => r.id);
          if (newIds.length === 0 && prevIds.length === 0) return true;
          if (newIds.length !== prevIds.length) return false;
    
          if ((annotation.id === prevAnnotations[index].id) && (isEqual(newIds, prevIds))) {
            return true;
          }
          return false;
        });
      }
    
      /** */
      static resourceClip(contentResource) {
        const fragmentMatch = contentResource.id.match(/xywh=(.*)$/);
        if (!fragmentMatch) return undefined;
        const bounds = fragmentMatch[1].split(',').map(str => parseInt(str, 10));
        return new OpenSeadragon.Rect(...bounds);
      }
    
      /**
       * @param {Object} props
       */
      constructor(props) {
        super(props);
    
        this.viewer = null;
        this.osdCanvasOverlay = null;
        // An initial value for the updateCanvas method
        this.updateCanvas = () => {};
        this.ref = React.createRef();
        this.onUpdateViewport = this.onUpdateViewport.bind(this);
        this.onViewportChange = this.onViewportChange.bind(this);
        this.zoomToWorld = this.zoomToWorld.bind(this);
        this.osdUpdating = false;
      }
    
      /**
       * React lifecycle event
       */
      componentDidMount() {
        const { osdConfig, viewer } = this.props;
        if (!this.ref.current) {
          return;
        }
    
        this.viewer = new OpenSeadragon({
          id: this.ref.current.id,
          ...osdConfig,
        });
    
        this.osdCanvasOverlay = new OpenSeadragonCanvasOverlay(this.viewer);
        this.viewer.addHandler('update-viewport', this.onUpdateViewport);
        // Set a flag when OSD starts animating (so that viewer updates are not used)
        this.viewer.addHandler('animation-start', () => {
          this.osdUpdating = true;
        });
        this.viewer.addHandler('animation-finish', this.onViewportChange);
        this.viewer.addHandler('animation-finish', () => {
          this.osdUpdating = false;
        });
    
        this.updateCanvas = this.canvasUpdateCallback();
    
        if (viewer) {
          this.viewer.viewport.panTo(viewer, true);
          this.viewer.viewport.zoomTo(viewer.zoom, viewer, true);
        }
        this.addAllImageSources(!(viewer));
      }
    
      /**
       * When the tileSources change, make sure to close the OSD viewer.
       * When the annotations change, reset the updateCanvas method to make sure
       * they are added.
       * When the viewport state changes, pan or zoom the OSD viewer as appropriate
       */
      componentDidUpdate(prevProps) {
        const {
          viewer,
          canvasWorld,
          highlightedAnnotations, selectedAnnotations,
          searchAnnotations, selectedContentSearchAnnotations,
        } = this.props;
        const highlightsUpdated = !OpenSeadragonViewer.annotationsMatch(
          highlightedAnnotations, prevProps.highlightedAnnotations,
        );
        const selectionsUpdated = !OpenSeadragonViewer.annotationsMatch(
          selectedAnnotations, prevProps.selectedAnnotations,
        );
        const searchAnnotationsUpdated = !OpenSeadragonViewer.annotationsMatch(
          searchAnnotations, prevProps.searchAnnotations,
        );
    
        const selectedContentSearchAnnotationsUpdated = !OpenSeadragonViewer.annotationsMatch(
          selectedContentSearchAnnotations, prevProps.selectedContentSearchAnnotations,
        );
    
        if (
          searchAnnotationsUpdated
          || selectedContentSearchAnnotationsUpdated
          || highlightsUpdated
          || selectionsUpdated
        ) {
          this.updateCanvas = this.canvasUpdateCallback();
          this.viewer.forceRedraw();
        }
    
        if (!this.infoResponsesMatch(prevProps.infoResponses)
          || !this.nonTiledImagedMatch(prevProps.nonTiledImages)
        ) {
          this.viewer.close();
          const canvasesChanged = !(isEqual(canvasWorld.canvasIds, prevProps.canvasWorld.canvasIds));
          this.addAllImageSources((canvasesChanged || !viewer));
        } else if (!isEqual(canvasWorld.layers, prevProps.canvasWorld.layers)) {
          this.refreshTileProperties();
        } else if (viewer && !this.osdUpdating) {
          const { viewport } = this.viewer;
    
          if (viewer.x !== viewport.centerSpringX.target.value
            || viewer.y !== viewport.centerSpringY.target.value) {
            this.viewer.viewport.panTo(viewer, false);
          }
    
          if (viewer.zoom !== viewport.zoomSpring.target.value) {
            this.viewer.viewport.zoomTo(viewer.zoom, viewer, false);
          }
        }
      }
    
      /**
       */
      componentWillUnmount() {
        this.viewer.removeAllHandlers();
      }
    
      /**
       * onUpdateViewport - fires during OpenSeadragon render method.
       */
      onUpdateViewport(event) {
        this.updateCanvas();
      }
    
      /**
       * Forward OSD state to redux
       */
      onViewportChange(event) {
        const { updateViewport, windowId } = this.props;
    
        const { viewport } = event.eventSource;
    
        updateViewport(windowId, {
          x: Math.round(viewport.centerSpringX.target.value),
          y: Math.round(viewport.centerSpringY.target.value),
          zoom: viewport.zoomSpring.target.value,
        });
      }
    
      /** */
      canvasUpdateCallback() {
        return () => {
          this.osdCanvasOverlay.clear();
          this.osdCanvasOverlay.resize();
          this.osdCanvasOverlay.canvasUpdate(this.renderAnnotations.bind(this));
        };
      }
    
      /**
       * annotationsToContext - converts anontations to a canvas context
       */
      annotationsToContext(annotations, color = 'yellow', selected = false) {
        const { canvasWorld } = this.props;
        const context = this.osdCanvasOverlay.context2d;
        const zoomRatio = this.viewer.viewport.getZoom(true) / this.viewer.viewport.getMaxZoom();
        annotations.forEach((annotation) => {
          annotation.resources.forEach((resource) => {
            if (!canvasWorld.canvasIds.includes(resource.targetId)) return;
            const offset = canvasWorld.offsetByCanvas(resource.targetId);
            const canvasAnnotationDisplay = new CanvasAnnotationDisplay({
              color, offset, resource, selected, zoomRatio,
            });
            canvasAnnotationDisplay.toContext(context);
          });
        });
      }
    
      /** */
      addAllImageSources(zoomAfterAdd = true) {
        const { nonTiledImages, infoResponses } = this.props;
        Promise.all(
          infoResponses.map(infoResponse => this.addTileSource(infoResponse)),
          nonTiledImages.map(image => this.addNonTiledImage(image)),
        ).then(() => {
          if (infoResponses[0] || nonTiledImages[0]) {
            if (zoomAfterAdd) this.zoomToWorld();
            this.refreshTileProperties();
          }
        });
      }
    
      /** */
      addNonTiledImage(contentResource) {
        const { canvasWorld } = this.props;
        return new Promise((resolve, reject) => {
          if (!this.viewer) {
            return;
          }
    
          const clip = OpenSeadragonViewer.resourceClip(contentResource);
          this.viewer.addSimpleImage({
            ...(clip && { clip }),
            error: event => reject(event),
            fitBounds: new OpenSeadragon.Rect(
              ...canvasWorld.contentResourceToWorldCoordinates(contentResource),
            ),
            index: canvasWorld.layerIndexOfImageResource(contentResource),
            opacity: canvasWorld.layerOpacityOfImageResource(contentResource),
            success: event => resolve(event),
            url: contentResource.id,
          });
        });
      }
    
      /**
       */
      addTileSource(infoResponse) {
        const { canvasWorld } = this.props;
        return new Promise((resolve, reject) => {
          if (!this.viewer) {
            return;
          }
    
          const tileSource = infoResponse.json;
          const contentResource = canvasWorld.contentResource(infoResponse.id);
    
          if (!contentResource) return;
          const clip = OpenSeadragonViewer.resourceClip(contentResource);
    
          this.viewer.addTiledImage({
            ...(clip && { clip }),
            error: event => reject(event),
            fitBounds: new OpenSeadragon.Rect(
              ...canvasWorld.contentResourceToWorldCoordinates(contentResource),
            ),
            index: canvasWorld.layerIndexOfImageResource(contentResource),
            opacity: canvasWorld.layerOpacityOfImageResource(contentResource),
            success: event => resolve(event),
            tileSource,
          });
        });
      }
    
      /** */
      refreshTileProperties() {
        const { canvasWorld } = this.props;
        const { world } = this.viewer;
    
        const items = [];
        for (let i = 0; i < world.getItemCount(); i += 1) {
          items.push(world.getItemAt(i));
        }
    
        items.forEach((item, i) => {
          const contentResource = canvasWorld.contentResource(item.source['@id'] || item.source.id);
          if (!contentResource) return;
          const newIndex = canvasWorld.layerIndexOfImageResource(contentResource);
          if (i !== newIndex) world.setItemIndex(item, newIndex);
          item.setOpacity(canvasWorld.layerOpacityOfImageResource(contentResource));
        });
      }
    
      /**
       */
      fitBounds(x, y, w, h, immediately = true) {
        this.viewer.viewport.fitBounds(
          new OpenSeadragon.Rect(x, y, w, h),
          immediately,
        );
      }
    
      /**
       * infoResponsesMatch - compares previous tileSources to current to determine
       * whether a refresh of the OSD viewer is needed.
       * @param  {Array} prevTileSources
       * @return {Boolean}
       */
      infoResponsesMatch(prevInfoResponses) {
        const { infoResponses } = this.props;
        if (infoResponses.length === 0 && prevInfoResponses.length === 0) return true;
    
        return infoResponses.some((infoResponse, index) => {
          if (!prevInfoResponses[index]) {
            return false;
          }
    
          if (!infoResponse.json) {
            return false;
          }
    
          if (infoResponse.json['@id'] === (prevInfoResponses[index].json || {})['@id']) {
            return true;
          }
    
          return false;
        });
      }
    
      /**
       * nonTiledImagedMatch - compares previous images to current to determin
       * whether a refresh of the OSD viewer is needed
       */
      nonTiledImagedMatch(prevNonTiledImages) {
        const { nonTiledImages } = this.props;
        if (nonTiledImages.length === 0 && prevNonTiledImages.length === 0) return true;
    
        return nonTiledImages.some((image, index) => {
          if (!prevNonTiledImages[index]) {
            return false;
          }
          if (image.id === prevNonTiledImages[index].id) {
            return true;
          }
          return false;
        });
      }
    
      /**
       * zoomToWorld - zooms the viewer to the extent of the canvas world
       */
      zoomToWorld(immediately = true) {
        const { canvasWorld } = this.props;
        this.fitBounds(...canvasWorld.worldBounds(), immediately);
      }
    
      /** */
      renderAnnotations() {
        const {
          searchAnnotations,
          selectedContentSearchAnnotations,
          highlightedAnnotations,
          selectedAnnotations,
          palette,
        } = this.props;
    
        this.annotationsToContext(searchAnnotations, palette.highlights.secondary);
        this.annotationsToContext(
          selectedContentSearchAnnotations,
          palette.highlights.primary,
          true,
        );
    
        this.annotationsToContext(highlightedAnnotations, palette.highlights.secondary);
        this.annotationsToContext(selectedAnnotations, palette.highlights.primary, true);
      }
    
      /**
       * Renders things
       */
      render() {
        const {
          children, classes, label, t, windowId,
        } = this.props;
    
        const enhancedChildren = React.Children.map(children, child => (
          React.cloneElement(
            child,
            {
              zoomToWorld: this.zoomToWorld,
            },
          )
        ));
    
        return (
          <>
            <section
              className={classNames(ns('osd-container'), classes.osdContainer)}
              id={`${windowId}-osd`}
              ref={this.ref}
              aria-label={t('item', { label })}
            >
              { enhancedChildren }
            </section>
          </>
        );
      }
    }
    
    OpenSeadragonViewer.defaultProps = {
      children: null,
      highlightedAnnotations: [],
      infoResponses: [],
      label: null,
      nonTiledImages: [],
      osdConfig: {},
      palette: {},
      searchAnnotations: [],
      selectedAnnotations: [],
      selectedContentSearchAnnotations: [],
      viewer: null,
    };
    
    OpenSeadragonViewer.propTypes = {
      canvasWorld: PropTypes.instanceOf(CanvasWorld).isRequired,
      children: PropTypes.node,
      classes: PropTypes.objectOf(PropTypes.string).isRequired,
      highlightedAnnotations: PropTypes.arrayOf(PropTypes.object),
      infoResponses: PropTypes.arrayOf(PropTypes.object),
      label: PropTypes.string,
      nonTiledImages: PropTypes.array, // eslint-disable-line react/forbid-prop-types
      osdConfig: PropTypes.object, // eslint-disable-line react/forbid-prop-types
      palette: PropTypes.object, // eslint-disable-line react/forbid-prop-types
      searchAnnotations: PropTypes.arrayOf(PropTypes.object),
      selectedAnnotations: PropTypes.arrayOf(PropTypes.object),
      selectedContentSearchAnnotations: PropTypes.arrayOf(PropTypes.object),
      t: PropTypes.func.isRequired,
      updateViewport: PropTypes.func.isRequired,
      viewer: PropTypes.object, // eslint-disable-line react/forbid-prop-types
      windowId: PropTypes.string.isRequired,
    };