UNPKG

@teipublisher/pb-components

Version:
533 lines (490 loc) 16.8 kB
import { LitElement, html, css } from 'lit-element'; import '@lrnwebcomponents/es-global-bridge'; import { pbMixin } from './pb-mixin.js'; import { resolveURL } from './utils.js'; /** * View zoomable images using a IIIF server. * * @fires pb-start-update - When received, resets the facsimile viewer * @fires pb-load-facsimile - When received, adds an image to the current image sequence. Emitted by * `pb-facs-link`. The event detail should contain an object with the properties `url`, `order` and `element`, * where `url` is the relative or absolute URL to the image, `order` is an integer specifying the position at which * the image should be inserted in the list, and `element` points to the `pb-facs-link` element triggering the event. * @fires pb-show-annotation - When received, sets up the viewer to select a particular image and highlight coordinates * @fires pb-facsimile-status - Indicates the status of loading an image into the viewer. The status is indicated * by the `status` property in event.detail as follows: `loading` - image was requested; `loaded` - image is displayed; * `fail` - image could not be loaded. * * @cssprop --pb-facsimile-height=auto - Max. height of the image viewer * @cssprop --pb-facsimile-border - Style for the annotation highlight border * @csspart image - exposes the inner div hosting the image viewer * * @slot before - use for content which should be shown above the facsimile viewer * @slot after - use for content which should be shown below the facsimile viewer */ export class PbFacsimile extends pbMixin(LitElement) { static get properties() { return { ...super.properties, /** * Set to false to prevent the appearance of the default navigation controls. * Note that if set to false, the customs buttons set by the options * zoomInButton, zoomOutButton etc, are rendered inactive. */ showNavigationControl: { type: Boolean, attribute: 'show-navigation-control', }, // Set to true to make the navigator minimap appear. showNavigator: { type: Boolean, attribute: 'show-navigator', }, /** If true then the 'previous" and 'next' button is displayed switch between images. */ showSequenceMode: { type: Boolean, attribute: 'show-sequence-control', }, /** If true then the 'Go home' button is displayed to go back to the original zoom and pan. */ showHomeControl: { type: Boolean, attribute: 'show-home-control', }, /** If true then the 'Toggle full page' button is displayed to switch between full page and normal mode. */ showFullPageControl: { type: Boolean, attribute: 'show-full-page-control', }, /** * if true shows a 'download' button */ showDownloadButton: { type: Boolean, attribute: 'show-download-control', }, /** * Default zoom between: set to 0 to adjust to viewer size. */ defaultZoomLevel: { type: Number, attribute: 'default-zoom-level', }, /** * If true then the rotate left/right controls will be displayed * as part of the standard controls. This is also subject to the * browser support for rotate (e.g. viewer.drawer.canRotate()). */ showRotationControl: { type: Boolean, attribute: 'show-rotation-control', }, // Constrain during pan constrainDuringPan: { type: Boolean, attribute: 'contrain-during-pan', }, /** * The percentage ( as a number from 0 to 1 ) of the source image * which must be kept within the viewport. * If the image is dragged beyond that limit, it will 'bounce' * back until the minimum visibility ratio is achieved. * Setting this to 0 and wrapHorizontal ( or wrapVertical ) * to true will provide the effect of an infinitely scrolling viewport. */ visibilityRatio: { type: Number, attribute: 'visibility-ratio', }, /** * If set, thumbnails of all images are shown in a reference strip at the * bottom of the viewer. */ referenceStrip: { type: Boolean, attribute: 'reference-strip', }, /** * Size ratio for the reference strip thumbnails. 0.2 by default. */ referenceStripSizeRatio: { type: Number, attribute: 'reference-strip-size-ratio', }, /** * Type of the source of the image to display: either 'iiif' or 'image' * (for simple image links not served via IIIF). */ type: { type: String, }, baseUri: { type: String, attribute: 'base-uri', }, /** * Path pointing to the location of openseadragon user interface images. */ prefixUrl: { type: String, attribute: 'prefix-url', }, /** * Array of facsimiles * */ facsimiles: { type: Array, }, /** * Will be true if images were loaded for display, false if there are no images * to show. */ loaded: { type: Boolean, reflect: true, }, /** * CORS (Cross-Origin Resource Sharing) policy - wraps the OSD Viewer option - * only sensible values are 'anonymous' (default) or 'use-credentials'. */ crossOriginPolicy: { type: String, attribute: 'cors', }, }; } constructor() { super(); this._facsimiles = []; this.baseUri = ''; this.crossOriginPolicy = 'anonymous'; this.type = 'iiif'; this.visibilityRatio = 1; this.defaultZoomLevel = 0; this.sequenceMode = false; this.showHomeControl = false; this.showNavigator = false; this.showNavigationControl = false; this.showFullPageControl = false; this.showRotationControl = false; this.showDownloadButton = false; this.constrainDuringPan = false; this.referenceStrip = false; this.referenceStripSizeRatio = 0.2; this.prefixUrl = '../images/openseadragon/'; this.loaded = false; } set facsimiles(facs) { this._facsimiles = facs || []; this.loaded = this._facsimiles.length > 0; this.emitTo('pb-facsimile-status', { status: 'loading' }); } connectedCallback() { super.connectedCallback(); this.subscribeTo('pb-start-update', this._clearAll.bind(this)); this.subscribeTo('pb-load-facsimile', e => { const { element, order } = e.detail; const itemOrder = this._facsimiles.map(item => item.getOrder ? item.getOrder() : Number.POSITIVE_INFINITY, ); const insertAt = itemOrder.reduce((result, next, index) => { if (order < next) return result; if (order === next) return index; return index + 1; }, 0); this._facsimiles.splice(insertAt, 0, element); this.loaded = this._facsimiles.length > 0; this._scheduleFacsimileObserver(); }); this.subscribeTo('pb-show-annotation', this._showAnnotationListener.bind(this)); } firstUpdated() { try { const bridge = window.ESGlobalBridge.requestAvailability(); const path = resolveURL('../lib/openseadragon.min.js'); // check if OpenSeadragon is already loaded if (bridge.imports['openseadragon']) { this._initOpenSeadragon(); return; } // Wait for OpenSeadragon to load window.addEventListener( 'es-bridge-openseadragon-loaded', this._initOpenSeadragon.bind(this), { once: true }, ); // load OpenSeadragon bridge.load('openseadragon', path); } catch (error) { console.error(error.message); } } render() { return html` <slot name="before"></slot> <!-- Openseadragon --> <div id="viewer" part="image"></div> <slot name="after"></slot> ${this.showDownloadButton ? html`<a id="downloadBtn" title="Download">&#8676;</a>` : ''} `; } static get styles() { return css` :host { display: flex; flex-direction: column; position: relative; background: transparent; } #runtime-overlay { border: var(--pb-facsimile-border, 4px solid rgba(0, 0, 128, 0.5)); } #viewer { flex: 1; position: relative; max-height: var(--pb-facsimile-height, auto); width: 100%; } #downloadBtn { position: absolute; z-index: 100; bottom: 0.25rem; width: 1.35rem; height: 1.35rem; transform: rotate(-90deg); cursor: pointer; border: thin solid #d7dde8; display: flex; align-items: center; justify-content: center; border-radius: 0.75rem; background-image: linear-gradient(to left, #fafafa 0%, #d7dde8 51%, #bbbbbb 100%); font-size: 1.2rem; box-shadow: -2px 1px 5px 0px rgba(0, 0, 0, 0.75); } #downloadBtn:hover { background-image: radial-gradient(white, #efefef); } `; } // Init openseadragon _initOpenSeadragon() { const prefixUrl = resolveURL(this.prefixUrl + (this.prefixUrl.endsWith('/') ? '' : '/')); const options = { element: this.shadowRoot.getElementById('viewer'), prefixUrl, preserveViewport: true, showZoomControl: true, sequenceMode: this.showSequenceMode, showHomeControl: this.showHomeControl, showFullPageControl: this.showFullPageControl, showNavigator: this.showNavigator, showNavigationControl: this.showNavigationControl, showRotationControl: this.showRotationControl, autoHideControls: false, visibilityRatio: 1, minZoomLevel: 1, defaultZoomLevel: this.defaultZoomLevel, constrainDuringPan: true, crossOriginPolicy: this.crossOriginPolicy, }; if (this.referenceStrip) { options.showReferenceStrip = true; options.referenceStripSizeRatio = this.referenceStripSizeRatio; } this.viewer = OpenSeadragon(options); if (this.showFullPageControl) { this._overrideFullscreenButton(); } this.viewer.addHandler('open', () => { this.resetZoom(); this.emitTo('pb-facsimile-status', { status: 'loaded', facsimiles: this._facsimiles }); }); this.viewer.addHandler('open-failed', ev => { console.error('<pb-facsimile> open failed: %s', ev.message); this.loaded = false; this.emitTo('pb-facsimile-status', { status: 'fail' }); }); const download = this.shadowRoot.querySelector('#downloadBtn'); if (this.showDownloadButton) { download.addEventListener('click', ev => { ev.preventDefault(); const currentImage = this.viewer.drawer.canvas.toDataURL('image/png'); const downloadLink = document.createElement('a'); downloadLink.href = currentImage; downloadLink.download = 'download'; downloadLink.click(); }); } /* handling of full-screen view requires to hide/unhide the content of body to allow full screen viewer to full-page functionality. Standard OSD completely deletes all body children disconnecting all event-handlers that have been there. This solution just uses style.display to hide/show. Former display value of pb-page will be preserved. */ this.ownerPage = document.querySelector('pb-page'); if (this.ownerPage) { this.pbPageDisplay = window.getComputedStyle(this.ownerPage).getPropertyValue('display'); this.viewer.addHandler('full-screen', ev => { if (ev.fullScreen) { this.ownerPage.style.display = 'none'; } else { this.viewer.clearOverlays(); this.emitTo('pb-refresh'); this.ownerPage.style.display = this.pbPageDisplay; } }); } this._scheduleFacsimileObserver(); this.signalReady(); } /** * A single transcription can have tons of pb-facs-link elements. Always debounce loading these */ _scheduleFacsimileObserver() { if (this._facsimileObserverScheduled) { return; } this._facsimileObserverScheduled = true; setTimeout(() => { this._facsimileObserverScheduled = false; this._facsimileObserver(); }, 0); } _facsimileObserver() { if (!this.viewer) { return; } if (this._facsimiles.length === 0) { this.viewer.close(); return; } const uris = this._facsimiles.map(facsLink => { const url = this.baseUri + (facsLink.getImage ? facsLink.getImage() : facsLink); if (this.type === 'iiif') { return `${url}/info.json`; } return { tileSource: { type: 'image', url, buildPyramid: false, }, }; }); const deduplicatedUris = []; const uriSet = new Set(); for (const uri of uris) { const hashKey = JSON.stringify(uri); if (!uriSet.has(hashKey)) { uriSet.add(hashKey); deduplicatedUris.push(uri); } } this.viewer.open(deduplicatedUris); this.viewer.goToPage(0); } _clearAll() { if (!this.viewer) { return; } this.resetZoom(); this.viewer.clearOverlays(); this.facsimiles = []; } _showAnnotationListener(event) { if (!this.viewer) { return; } const overlayId = 'runtime-overlay'; // remove old overlay this.viewer.removeOverlay(this.overlay); // check event data for completeness if (!event.detail.file || event.detail.file === 0) { return console.error('file missing', event.detail); } if ( event.detail.coordinates && (!event.detail.coordinates[0] || event.detail.coordinates.length !== 4) ) { return console.error('coords incomplete or missing', event.detail); } // find page to show const page = event.detail.element ? this._pageByElement(event.detail.element) : this._pageIndexByUrl(event.detail.file); if (page < 0) { return console.error('page not found', event.detail); } if (this.viewer.currentPage() !== page) { this.viewer.goToPage(page); } if (event.detail.coordinates) { // deconstruct given coordinates into variables const [x1, y1, w, h] = event.detail.coordinates; const tiledImage = this.viewer.world.getItemAt(0); const currentRect = tiledImage.viewportToImageRectangle(tiledImage.getBounds(true)); // scroll into view? if (!currentRect.containsPoint(new OpenSeadragon.Point(x1, y1))) { this.viewer.viewport.fitBoundsWithConstraints( tiledImage.imageToViewportRectangle(x1, y1, currentRect.width, currentRect.height), ); } // create new overlay const overlay = document.createElement('div'); this.overlay = overlay; overlay.id = overlayId; // place marker const marker = tiledImage.imageToViewportRectangle(x1, y1, w, h); this.viewer.addOverlay({ element: overlay, location: marker, }); } } _pageByElement(element) { return this._facsimiles.indexOf(element); } _pageIndexByUrl(file) { return this._facsimiles.findIndex(element => element.getImage() === file); } // reset zoom resetZoom() { if (!this.viewer) { return; } this.viewer.viewport.goHome(); } _overrideFullscreenButton() { // The full page control in OSD makes all body elements disappear, causing all kinds of bugs in // our webcomponents. Replace it with something that just calls `requestFullScreen` instead. // This approach (but more elaborate) is also PR-ed to OSD // (https://github.com/openseadragon/openseadragon/pull/2786). This code can be dropped the // moment we upgrade to OSD. const toggleFullscreenButton = this.viewer.buttonGroup.buttons.find( button => button.tooltip === 'Toggle full page', ); if (!toggleFullscreenButton) { return; } const releaseHandler = async () => { if (!document.fullscreenElement) { await this.viewer.element.requestFullscreen(); return; } await document.exitFullscreen(); }; toggleFullscreenButton.onRelease = releaseHandler; const oldRaiseEvent = toggleFullscreenButton.raiseEvent; toggleFullscreenButton.raiseEvent = (eventName, args) => { if (eventName === 'release') { releaseHandler(); } else { oldRaiseEvent.call(toggleFullscreenButton, eventName, args); } }; } } if (!customElements.get('pb-facsimile')) { customElements.define('pb-facsimile', PbFacsimile); }