/**
* OpenSeadragon paperjs overlay plugin based on paper.js
* @version 0.4.13
*
* Includes additional open source libraries which are subject to copyright notices
* as indicated accompanying those segments of code.
*
* Original code:
* Copyright (c) 2022-2024, Thomas Pearce
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* * Neither the name of osd-paperjs-annotation nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/
import { OpenSeadragon } from './osd-loader.mjs';
import { paper } from './paperjs.mjs';
import './paper-extensions.mjs';
import './osd-extensions.mjs';
import { makeFaIcon } from './utils/faIcon.mjs';
(function (OpenSeadragon) {
if (typeof OpenSeadragon === 'undefined') {
console.error('[paper-overlay.mjs] requires OpenSeadragon and paper.js');
return;
}
if (typeof paper==='undefined') {
console.error('[paper-overlay.mjs] requires OpenSeadragon and paper.js');
return;
}
if(OpenSeadragon.Viewer.prototype.PaperOverlays){
console.warn('Cannot redefine Viewer.prototype.PaperOverlays');
return;
}
Object.defineProperty(OpenSeadragon.Viewer.prototype, 'PaperOverlays',{
get: function PaperOverlays(){
return this._PaperOverlays || (this._PaperOverlays = []);
}
});
})(OpenSeadragon);
/********************************************************************************************** */
/********************************************************************************************** */
/**
*
* Represents a PaperOverlay associated with an OpenSeadragon Viewer.
* A PaperOverlay is a Paper.js overlay that can be either synced to the zoomable image or fixed to the viewer.
*
* @class
* @memberof OSDPaperjsAnnotation
*/
class PaperOverlay extends OpenSeadragon.EventSource{
/**
* Creates an instance of the PaperOverlay.
* overlayType: 'image' to zoom/pan with the image(s), 'viewer' stay fixed.
* @param {OpenSeadragon.Viewer} viewer - The viewer object.
* @param {Object} opts - The options for the overlay.
* @param {string} [opts.overlayType='image'] - "image" or "viewer". The type of overlay: 'image' to zoom/pan with the image(s), 'viewer' stay fixed.
* @property {OpenSeadragon.Viewer} viewer - The OpenSeadragon viewer object.
* @property {string} overlayType - "image" or "viewer"
* @property {paper.Scope} paperScope - the paper.Scope object for this overlay
*/
constructor(viewer,opts){
super();
let defaultOpts = {
overlayType: 'image',
}
opts=OpenSeadragon.extend(true,defaultOpts,opts);
this.viewer = viewer;
this.overlayType = opts.overlayType;
viewer.PaperOverlays.push(this);
let ctr = counter();
this._id = 'paper-overlay-canvas-' + ctr;
this._containerWidth = 0;
this._containerHeight = 0;
this._canvasdiv = document.createElement('div');
this._canvasdiv.setAttribute('id','paper-overlay-'+ctr);
this._canvasdiv.classList.add('paper-overlay');
this._canvasdiv.style.position = 'absolute';
this._canvasdiv.style.left = "0px";
this._canvasdiv.style.top = "0px";
this._canvasdiv.style.width = '100%';
this._canvasdiv.style.height = '100%';
this._canvas = document.createElement('canvas');
this._canvas.setAttribute('id', this._id);
this._canvasdiv.appendChild(this._canvas);
viewer.canvas.appendChild(this._canvasdiv);
this._viewerButtons = [];
this.paperScope = new paper.PaperScope();
this.paperScope.overlay = this;
let ps = this.paperScope.setup(this._canvas);
this.paperScope.project.overlay = this;
this.ps = ps;
this._paperProject=ps.project;
this._resize();
if(this.overlayType=='image'){
// set up the viewport and tiledImages to create pape.Layers for each
this.viewer.viewport._setupPaper(this); // depends on _setupPaper defined in osd-extensions.mjs
for(let i = 0; i < viewer.world.getItemCount(); ++i){
let item = this.viewer.world.getItemAt(i);
this._setupTiledImage(item);
}
this.onAddItem = (self=>function(ev){
let tiledImage = ev.item;
self._setupTiledImage(tiledImage);
})(this);
this.onRemoveItem = (self=>function(ev){
let tiledImage = ev.item;
self._removeTiledImage(tiledImage);
})(this);
// add handlers so that new items added to the scene are set up and removed appropriately
viewer.world.addHandler('add-item',this.onAddItem);
viewer.world.addHandler('remove-item',this.onRemoveItem);
this._updatePaperView();
} else if (this.overlayType == 'viewer'){
// set up the viewer with a paper.Layer
this.viewer._setupPaper(this); // depends on _setupPaper defined in osd-extensions.mjs
} else {
console.error('Unrecognized overlay type: '+this.overlayType);
}
// TODO changes these from members to variables
this.onViewerDestroy=(self=>function(){
self.destroy(true);
})(this);
this.onViewportChange=(self=>function(){
self._updatePaperView();
})(this);
this.onViewerResetSize=(self=>function(ev){
//need to setTimeout to wait for some value (viewport.getZoom()?) to actually be updated before doing our update
//need to check for destroyed because this will get called as part of the viewer destroy chain, and we've set the timeout
setTimeout(()=>{
if(self.destroyed){
return;
}
self._resize();
self._updatePaperView();
});
})(this);
this.onViewerResize=(self=>function(){
self._resize();
self.paperScope.view.emit('resize',{size:new paper.Size(self._containerWidth, self._containerHeight)})
self._updatePaperView();
})(this);
this.onViewerRotate=(self=>function(ev){
//TODO: change from this to self; confirm nothing breaks
this._pivot = ev.pivot || this._getCenter();
})(this);
this.onViewerFlip=(self=>function(ev){
self.paperScope.view.setFlipped(ev.flipped, viewer.viewport.getRotation(true));
})(this);
viewer.addHandler('resize',this.onViewerResize);
viewer.addHandler('reset-size',this.onViewerResetSize)
viewer.addHandler('viewport-change', this.onViewportChange);
viewer.addHandler('rotate', this.onViewerRotate);
viewer.addHandler('flip', this.onViewerFlip);
viewer.addOnceHandler('destroy', this.onViewerDestroy);
}
/**
* The scale factor for the overlay. Equal to the pixel width of the viewer's drawing canvas
*/
get scaleFactor(){
const previousScaleFactor = this._currentScaleFactor;
this._currentScaleFactor = this.viewer.drawer.canvas.clientWidth;
if(previousScaleFactor !== this._currentScaleFactor){
this.raiseEvent('update-scale', {scaleFactor: this._currentScaleFactor});
}
return this._currentScaleFactor;
}
/**
* Adds a button to the viewer. The button is created with the provided parameters.
* @param {Object} params - The parameters for the button.
* @param {string} params.tooltip - The tooltip text for the button.
* @param {string} params.onClick - The function to be called when the button is clicked.
* @param {string} params.faIconClass - Font Awesome icon classes for the button icon.
* @returns {any} The button object.
*/
addViewerButton(params={}){
const prefixUrl=this.viewer.prefixUrl;
let button = new OpenSeadragon.Button({
tooltip: params.tooltip,
srcRest: prefixUrl+`button_rest.png`,
srcGroup: prefixUrl+`button_grouphover.png`,
srcHover: prefixUrl+`button_hover.png`,
srcDown: prefixUrl+`button_pressed.png`,
onClick: params.onClick,
});
if(params.faIconClass){
let i = makeFaIcon(params.faIconClass);
i.style = 'position:absolute;top:calc(50% - 4px);left:50%;transform:translate(-50%, -50%);color:#01010187;';
button.element.appendChild(i);
}
this.viewer.buttonGroup.buttons.push(button);
this.viewer.buttonGroup.element.appendChild(button.element);
this._viewerButtons.push(button);
return button;
}
/**
* Brings the overlay to the front, making it appear on top of other overlays.
* This method changes the z-index of the overlay to bring it forward.
* The overlay will appear on top of any other overlays that are currently on the viewer.
*/
bringToFront(){
this.viewer.PaperOverlays.splice(this.viewer.PaperOverlays.indexOf(this),1);
this.viewer.PaperOverlays.push(this);
this.viewer.PaperOverlays.forEach(overlay=>this.viewer.canvas.appendChild(overlay._canvasdiv));
this.paperScope.activate();
}
/**
* Sends the overlay to the back, making it appear behind other overlays.
* This method changes the z-index of the overlay to send it backward.
* The overlay will appear behind any other overlays that are currently on the viewer.
*/
sendToBack(){
this.viewer.PaperOverlays.splice(this.viewer.PaperOverlays.indexOf(this),1);
this.viewer.PaperOverlays.splice(0,0,this);
this.viewer.PaperOverlays.forEach(overlay=>this.viewer.canvas.appendChild(overlay._canvasdiv));
this.viewer.PaperOverlays[this.viewer.PaperOverlays.length-1].paperScope.activate();
}
/**
* Destroys the overlay and removes it from the viewer.
* This method cleans up the resources associated with the overlay and removes it from the viewer.
*
* @param {boolean} viewerDestroyed - Whether the viewer has been destroyed.
* If `viewerDestroyed` is true, it indicates that the viewer itself is being destroyed, and this method
* will not attempt to remove the overlay from the viewer, as it will be automatically removed during the viewer's cleanup process.
*/
destroy(viewerDestroyed){
this.destroyed = true;
this._canvasdiv.remove();
this.paperScope.project && this.paperScope.project.remove();
this.ps && this.ps.remove();
if(!viewerDestroyed){
this.viewer.removeHandler('viewport-change',this.onViewportChange);
this.viewer.removeHandler('resize',this.onViewerResize);
this.viewer.removeHandler('reset-size',this.onViewerResetSize);
this.viewer.removeHandler('rotate',this.onViewerRotate);
this.viewer.removeHandler('flip',this.onViewerFlip);
this.viewer.world.removeHandler('add-item', this.onAddItem);
this.viewer.world.removeHandler('remove-item', this.onRemoveItem);
this.setOSDMouseNavEnabled(true);
this._viewerButtons.forEach(button=>{
button.element.remove();
});
this._viewerButtons = [];
this.viewer.PaperOverlays.splice(this.viewer.PaperOverlays.indexOf(this),1);
if(this.viewer.PaperOverlays.length>0){
this.viewer.PaperOverlays[this.viewer.PaperOverlays.length-1].paperScope.activate();
}
}
}
/**
* Clears the overlay by removing all paper items from the overlay's Paper.js project.
* This method removes all Paper.js items (such as paths, shapes, etc.) that have been added to the overlay.
* After calling this method, the overlay will be empty, and any content that was previously drawn on it will be removed.
*/
clear(){
this.paperScope.project.clear();
}
// ----------
/**
* Gets the canvas element of the overlay.
*
* @returns {HTMLCanvasElement} The canvas element.
*/
canvas() {
return this._canvas;
}
// ----------
/**
* This method allows you to add CSS classes to the canvas element of the overlay.
* Adding classes can be useful for styling the overlay or associating specific styles with it.
*
* @param {string} c - The class name to add to the canvas element.
* @returns {PaperOverlay} The PaperOverlay object itself, allowing for method chaining.
*/
addClass(c){
this._canvas.classList.add(...arguments);
return this;
}
/**
* This method allows you to remove CSS classes from the canvas element of the overlay.
* Removing classes can be useful for updating the overlay's appearance or changing its associated styles.
* @param {string} c - The class name to remove from the canvas element.
* @returns {PaperOverlay} The PaperOverlay object itself, allowing for method chaining.
*/
removeClass(c){
this._canvas.classList.remove(...arguments);
return this;
}
/**
* This method allows you to set custom attributes on the canvas element of the overlay.
* Setting attributes can be useful for additional customization or to store metadata related to the overlay.
*
* @param {string} attr - The name of the attribute to set on the canvas element.
* @param {string} value - The value to set for the specified attribute.
* @returns {PaperOverlay} The PaperOverlay object itself, allowing for method chaining.
*/
setAttribute(attr, value){
this._canvas.setAttribute(attr,value);
return this;
}
/**
* This method allows you to add custom event listeners to the canvas element of the overlay.
* You can listen for various events, such as mouse clicks or custom events, and perform actions accordingly.
*
* @param {string} event - The name of the event to listen for on the canvas element.
* @param {function} listener - The function to call when the specified event is triggered.
* @returns {PaperOverlay} The PaperOverlay object itself, allowing for method chaining.
*/
addEventListener(event,listener){
this._canvas.addEventListener(event,listener);
return this;
}
/**
* This method allows you to remove a previously added event listener from the canvas element of the overlay.
* If the specified event and listener pair match an existing event listener, it will be removed.
*
* @param {string} event - The name of the event to stop listening for on the canvas element.
* @param {function} listener - The function that was previously registered as the event listener.
* @returns {PaperOverlay} The PaperOverlay object itself, allowing for method chaining.
*/
removeEventListener(event,listener){
this._canvas.removeEventListener(event,listener);
return this;
}
// returns: mouseNavEnabled status BEFORE the call (for reverting)
// raises 'mouse-nav-enabled' event
/**
* This method allows you to enable or disable mouse navigation in the viewer associated with the overlay.
* Mouse navigation includes actions such as panning and zooming.
* It returns a boolean value indicating whether mouse navigation was enabled before the method call.
*
* @param {boolean} enabled - Whether to enable or disable mouse navigation.
* @returns {boolean} Whether mouse navigation was enabled before the call.
*/
setOSDMouseNavEnabled(enabled=true){
let wasMouseNavEnabled = this.viewer.isMouseNavEnabled();
this.viewer.setMouseNavEnabled(enabled);
if(enabled !== wasMouseNavEnabled){
this.viewer.raiseEvent('mouse-nav-changed',{enabled: enabled, overlay: this});
}
return wasMouseNavEnabled;
}
// ----------
/**
* This method allows you to enable or disable automatic rescaling of items within the overlay
* based on the zoom level of the OpenSeadragon viewer.
* When enabled, items in the overlay will be rescaled automatically as the viewer's zoom level changes.
*
* @param {boolean} shouldHandle - Whether to enable or disable automatic rescaling.
* @see {@link rescaleItems}
*/
autoRescaleItems(shouldHandle=false){
let _this=this;
this.ps.view.off('zoom-changed',_rescale);
if(shouldHandle) this.ps.view.on('zoom-changed',_rescale );
function _rescale(){
_this.rescaleItems();
}
}
//-----------
/**
* Rescales all items in the overlay according to the current zoom level of the viewer.
* This method manually rescales all Paper.js items that have the `rescale` property set to a truthy value.
* The rescaling is based on the current zoom level of the viewer, ensuring that the items maintain their relative size on the viewer.
* @see {@link autoRescaleItems}
*/
rescaleItems(){
this._paperProject.getItems({match:function(o){return o.rescale}}).forEach(function(item){
item.applyRescale();
});
}
/**
* Convert from paper coordinate frame to the pixel on the underlying canvas element
*/
paperToCanvasCoordinates(x, y){
let point = new OpenSeadragon.Point(x, y).divide(this.scaleFactor);
return this.viewer.viewport.viewportToViewerElementCoordinates(point);
}
/**
* Gets the image data from the viewer.
* @memberof OSDPaperjsAnnotation.PaperOverlay#
* @function getImageData
* @param {number} x - The x coordinate of the top left corner of the image data.
* @param {number} y - The y coordinate of the top left corner of the image data.
* @param {number} w - The width of the image data.
* @param {number} h - The height of the image data.
* @returns {ImageData} The image data.
*/
getImageData(x, y, w, h){
x = x || 0;
y = y || 0;
w = w == undefined ? this.viewer.drawer.canvas.width : w;
h = h == undefined ? this.viewer.drawer.canvas.height : h;
// deal with flipping the x coordinate if needed
if(this.ps.view.getFlipped()){
x = this.viewer.drawer.canvas.width - x - w;
}
return this.viewer.drawer.canvas.getContext('2d',{willReadFrequently:true}).getImageData(x, y, w, h);
}
/**
* Gets a raster object representing the viewport.
* @memberof OSDPaperjsAnnotation.PaperOverlay#
* @function getViewportRaster
* @param {boolean} withImageData - Whether to include image data in the raster object.
* @returns {any} The raster object.
*/
getViewportRaster(withImageData = true){
let view = this.paperScope.view;
//TO DO: make this query subregions of the viewport directly instead of always returning the entire thing
let center = view.viewToProject(new paper.Point(view.viewSize.width/2, view.viewSize.height/2 ));
let rotation = -1 * this.viewer.viewport.getRotation();
let rasterDef = {
insert:false,
}
if(withImageData) rasterDef.canvas = this.viewer.drawer.canvas;
else rasterDef.size = new paper.Size(this.viewer.drawer.canvas.width,this.viewer.drawer.canvas.height);
let raster = new paper.Raster(rasterDef);
raster.position = center;
raster.rotate(rotation);
let scaleFactor = view.viewSize.width / view.getZoom() / this.viewer.drawer.canvas.width;
raster.scale(scaleFactor);
if(view.getFlipped()){
const angle = view.getRotation();
raster.rotate(-angle);
raster.scale(-1, 1);
raster.rotate(angle)
}
return raster;
}
//------------
/**
* _setupTiledImage
* Depends on TiledImage._setupPaper being installed by osd-extensions.mjs
* @private
*/
_setupTiledImage(tiledImage){
tiledImage._setupPaper(this);
}
/**
* @private
*/
_removeTiledImage(tiledImage){
tiledImage._paperLayerMap?.get(this.paperScope)?.remove();
tiledImage._paperLayerMap?.delete(this.paperScope);
}
/**
* Resizes the overlay to match the size of the viewer container.
* This method updates the dimensions of the overlay's canvas element to match the size of the viewer container.
* If the viewer container's size changes (e.g., due to a browser window resize), you can call this method to keep the overlay in sync with the viewer size.
* Additionally, this method updates the Paper.js view size to match the new canvas dimensions.
* @private
*/
_resize()
{
let update=false;
if (this._containerWidth !== this.viewer.container.clientWidth) {
this._containerWidth = this.viewer.container.clientWidth;
this._canvasdiv.setAttribute('width', this._containerWidth);
this._canvas.setAttribute('width', this._containerWidth);
update=true;
}
if (this._containerHeight !== this.viewer.container.clientHeight) {
this._containerHeight = this.viewer.container.clientHeight;
this._canvasdiv.setAttribute('height', this._containerHeight);
this._canvas.setAttribute('height', this._containerHeight);
update=true;
}
if(update){
this.paperScope.view.viewSize = new paper.Size(this._containerWidth, this._containerHeight);
this.paperScope.view.update();
}
}
/**
* Updates the Paper.js view to match the zoom and center of the OpenSeadragon viewer.
* This method synchronizes the Paper.js view with the current zoom and center of the OpenSeadragon viewer.
* When the viewer's zoom or center changes, this method should be called to ensure that the Paper.js view is updated accordingly.
* @private
*/
_updatePaperView() {
if(this.overlayType === 'viewer'){
return;
}
let viewportZoom = this.viewer.viewport.getZoom(true);
let oldZoom = this.paperScope.view.getZoom();
this.paperScope.view.setZoom(this.viewer.viewport._containerInnerSize.x * viewportZoom / this.scaleFactor);
let center = this._getCenter();
this.viewer.drawer.canvas.pixelRatio = window.devicePixelRatio;
this.paperScope.view.center = new paper.Point(center.x, center.y).multiply(this.scaleFactor);
let degrees = this.viewer.viewport.getRotation(true);
let pivot = this._getPivot();
this.paperScope.view.setRotation(degrees, pivot);
const newZoom = this.paperScope.view.getZoom();
if(Math.abs(newZoom - oldZoom)>0.0000001){
this.paperScope.view.emit('zoom-changed',{zoom:newZoom});
}
this.paperScope.view.update();
}
_getPivot(){
if(!this._pivot) return;
return this._pivot.multiply(this.scaleFactor);
}
_getCenter(){
return this.viewer.viewport.getCenter(true);
}
};
export {PaperOverlay};
let counter = (function () {
let i = 1;
return function () {
return i++;
}
})();