@teipublisher/pb-components
Version:
Collection of webcomponents underlying TEI Publisher
533 lines (490 loc) • 16.8 kB
JavaScript
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">⇤</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);
}