Source: overlays/screenshot/screenshot.mjs

/**
 * 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 { ToolBase } from '../../papertools/base.mjs';
import { PaperOverlay } from '../../paper-overlay.mjs';
import { OpenSeadragon } from '../../osd-loader.mjs';
import { paper } from '../../paperjs.mjs';
import { changeDpiBlob } from './changedpi.mjs';
import { domObjectFromHTML } from '../../utils/domObjectFromHTML.mjs';

class ScreenshotOverlay{
    /**
     * Creates an instance of the ScreenshotOverlay.
     *
     * @param {OpenSeadragon.Viewer} viewer - The OpenSeadragon viewer object.
     * @param {Object} [options]
     * @param {String} [options.downloadMessage] - A message to display in the download window
     */
    constructor(viewer, options){
        this.viewer = viewer;
        let overlay = this.overlay = new PaperOverlay(viewer,{overlayType:'viewer'})
        let tool = this.tool = new ScreenshotTool(this.overlay.paperScope, this);
        this.dummyTool = new this.overlay.paperScope.Tool();//to capture things like mouseMove, keyDown etc (when actual tool is not active)
        this.dummyTool.activate();
        this._mouseNavEnabledAtActivation = true;
        const button = overlay.addViewerButton({
            faIconClass:'fa-camera',
            tooltip:'Take Screenshot',
            onClick:()=>{
                tool.active ? this.deactivate() : this.activate();
            }
        });

        button.element.querySelector('svg.icon')?.style.setProperty('width', '1em');

        this._makeDialog(options); //creates this.dialog

        this.tool.addEventListener('region-selected',bounds=>this._setupScreenshotDialog(bounds));
     
    }
    /**
     * Activates the overlay.
     */
    activate(){
        let reactivate = this.overlay.setOSDMouseNavEnabled(false);
        this._mouseNavEnabledAtActivation = this._mouseNavEnabledAtActivation || reactivate;
        this.overlay.bringToFront();
        this.tool.activate();
    }
    /**
     * Deactivates the overlay.
     */
    deactivate(){
        this.dialog.classList.add('hidden');
        this.tool.deactivate(true);
        this.dummyTool.activate();
        this.overlay.setOSDMouseNavEnabled(this._mouseNavEnabledAtActivation);
        this._mouseNavEnabledAtActivation = false;
        this.overlay.sendToBack();
    }

    _startRegion(){
        this.dialog.classList.add('hidden');
        this.tool.activate();
    }

    _makeDialog(options){
        let html = `<div class="screenshot-dialog hidden">
            <div class="size">
                <h3>Aspect Ratio</h3>
                <label>Lock</label><input class="lock-aspect-ratio" type="checkbox"/>
                <input type="number" min="0" value="1" class="aspect-width"/> x <input type="number" min="0" value="1" class="aspect-height"/>
                <button class="apply-aspect-ratio">Apply</button>
            </div>
            <hr>
            <div>
                <h3>Selected Region</h3>
                <div class="size">
                    <div><input class="region-width region-dim" type="number" min="0"/> x <input class="region-height region-dim" type="number" min="0"/> px 
                    (<span class="region-width-mm"></span> x <span class="region-height-mm"></span> mm)</div>
                </div>
                <div class="scalebar">
                    <label>Include scale bar:</label> <input class="include-scalebar"type="checkbox">
                    <div class="scalebar-opts">
                    <p>Enter desired scale bar width in millimeters and height in pixels.<br>Width will be rounded to the nearest pixel.</p>
                    <label>Width (mm):</label><input class="scalebar-width" type="number" min="0.001" step="0.01">
                    <label>Height (px):</label><input class="scalebar-height" type="number" min="1" step="1">
                    </div>
                </div> 
            <div>
            <hr>
            <div class="screenshot-results">
                <div class="instructions">
                    <h3>Create your screenshot</h3>
                    <div>
                        <label>Select size:</label>
                        <select class="select-size"></select>
                        <button class="create-screenshot">Create</button>
                    </div>
                    
                </div>
                <div class="download">
                    <h3>View/Download</h3>
                    <div class="download-message">${options?.downloadMessage || ''}</div>
                    <div><a class="open-screenshot screenshot-link" target="_blank"><button>Open in new tab</button></a> | 
                    <a class="download-screenshot screenshot-link" download="screenshot.png"><button>Download</button></a></div>
                    <div><button class="cancel-screenshot">Change size</button></div>
                </div>
                <div class="pending-message"><h3>View/Download</h3>
                Creating your screenshot...
                <div class="screenshot-progress">
                    <progress></progress>
                    <div>Loaded <span class="loaded"></span> of <span class="total"><span> tiles</div>
                </div>
                <div><button class="cancel-screenshot">Change size</button></div>
                </div>
            </div>
            <hr>
            <button class='rect'>Select a new area</button> | <button class='close'>Close</button>
        </div>`;

        let css = `<style data-type="screenshot-tool">
            .screenshot-dialog{
                position: absolute;
                top: 50%;
                left: 50%;
                transform: translate(-50%, -50%);
                padding: 1em;
                border: thin black solid;
                background-color: white;
                color: black;
            }
            .screenshot-dialog.hidden{
                display:none;
            }
            .screenshot-dialog h3{
                margin: 0.1em 0;
            }
            .screenshot-dialog input[type=number]{
                width: 5em;
            }
            .screenshot-results>*{
                display:none;
                min-height:6em;
            }
            .screenshot-results.created .download{
                display:block;
            }
            .screenshot-results.pending .pending-message{
                display:block;
            }
            .screenshot-results:not(.created):not(.pending) .instructions{
                display:block;
            }
            .screenshot-link{
                display:inline-block;
                margin-bottom: 0.5em;
            }
            .screenshot-dialog .download-message:not(:empty){
                margin-bottom:1em;
            }
            .scalebar-opts.hidden{
                visibility:hidden;
            }
        </style>`;
        if(!document.querySelector('style[data-type="screenshot-tool"]')){
            document.querySelector('head').appendChild(domObjectFromHTML(css));
        }

        const el = domObjectFromHTML(html);
        this.viewer.container.appendChild(el);

        el.addEventListener('mousemove',ev=>ev.stopPropagation());
        el.querySelectorAll('.close').forEach(e=>e.addEventListener('click',()=>this.deactivate()));
        el.querySelectorAll('.rect').forEach(e=>e.addEventListener('click',()=>this._startRegion()));
        el.querySelectorAll('.cancel-screenshot').forEach(e=>e.addEventListener('click',()=>el.querySelector('.screenshot-results').classList.remove('pending','created')));
        el.querySelectorAll('.create-screenshot').forEach(e=>e.addEventListener('click',()=>{
            const sel = el.querySelector('.select-size');
            const selectedOption = sel.options[sel.selectedIndex];
            const data = JSON.parse(selectedOption.getAttribute('data-dims')); 
            this.dialog.querySelector('.screenshot-results').classList.add('pending');
            this._createScreenshot(data).then(blobURL=>{
                const x = this.dialog.querySelector('.screenshot-results');
                x.classList.remove('pending');
                x.classList.add('created');
                this.dialog.querySelector('.screenshot-link').href = this.blobURL;
            }).catch(e=>{
                alert('There was a problem creating the screenshot. ' + e );
            });
        }));
        el.querySelectorAll('button.download-screenshot').forEach(e=>e.addEventListener('click',()=>{
            let a = el.querySelectorAll('a.download-screenshot');
            a.dispatchEvent(new Event('change'));
        }));
        el.querySelectorAll('.aspect-width').forEach(e=>{
            e.addEventListener('change',ev=>this.tool.setAspectWidth( Number(ev.target.value) ));
            e.dispatchEvent(new Event('change'));
        });
        el.querySelectorAll('.aspect-height').forEach(e=>{
            e.addEventListener('change',ev=>this.tool.setAspectHeight( Number(ev.target.value) ));
            e.dispatchEvent(new Event('change'));
        });
        el.querySelectorAll('.lock-aspect-ratio').forEach(e=>{
            e.addEventListener('change',ev=>this.tool.setAspectLocked( ev.target.checked ));
            e.dispatchEvent(new Event('change'));
        });
        el.querySelectorAll('.apply-aspect-ratio').forEach(e=>e.addEventListener('click',ev=>this._applyAspectRatio()));
        el.querySelectorAll('.region-dim').forEach(e=>e.addEventListener('change',()=>this._updateROI()));
        el.querySelectorAll('.scalebar-width').forEach(e=>{
            e.addEventListener('change',ev=>this._scalebarWidth = Number(ev.target.value), this._resetScreenshotResults() );
            e.dispatchEvent(new Event('change'));
        });
        el.querySelectorAll('.scalebar-height').forEach(e=>{
            e.addEventListener('change',ev=>this._scalebarHeight = Number(ev.target.value), this._resetScreenshotResults() );
            e.dispatchEvent(new Event('change'));
        });
        el.querySelectorAll('.include-scalebar').forEach(e=>{
            e.addEventListener('change',ev=>{
                this._includeScalebar = ev.target.checked;
                let opts = el.querySelector('.scalebar-opts');
                this._includeScalebar ? opts.classList.remove('hidden') : opts.classList.add('hidden');
                this._resetScreenshotResults();
            });
            e.dispatchEvent(new Event('change'));
        });
        this.dialog = el;
    }
    _updateROI(){
        let w = this.dialog.querySelector('.region-width').value;
        let h = this.dialog.querySelector('.region-height').value;
        this._currentBounds.width = Number(w);
        this._currentBounds.height = Number(h);
        this._setupScreenshotDialog(this._currentBounds);
        
        if(this.dialog.querySelector('.lock-aspect-ratio').checked){
            this._applyAspectRatio();
        } 
        
    }
    _applyAspectRatio(){
        // adjust by the smallest amount to match the aspect ratio
        let currentRatio = this._currentBounds.width / this._currentBounds.height;
        let desiredRatio = this.tool._aspectWidth / this.tool._aspectHeight;
        if(currentRatio / desiredRatio > 1){
            this._currentBounds.width = Math.round(this._currentBounds.height * desiredRatio);
            this._setupScreenshotDialog(this._currentBounds);
        } else if (currentRatio / desiredRatio < 1){
            this._currentBounds.height = Math.round(this._currentBounds.width / desiredRatio);
            this._setupScreenshotDialog(this._currentBounds);
        }
        
    }
    _setupScreenshotDialog(bounds){
        
        // this.tool.deactivate();
        this._resetScreenshotResults();
        this._currentBounds = bounds;

        this.dialog.querySelector('.region-width').value = bounds.width;
        this.dialog.querySelector('.region-height').value = bounds.height;

        let vp = this.viewer.viewport;
        let ti = this.viewer.world.getItemAt(this.viewer.currentPage());
        let boundsRect = new OpenSeadragon.Rect(bounds.x, bounds.y, bounds.width, bounds.height);
        let viewportRect = vp.viewerElementToViewportRectangle( boundsRect );
        let imageBounds = vp.viewportToImageRectangle(viewportRect);

        const scaleFactor =  Math.max(imageBounds.width, imageBounds.height) / Math.max(boundsRect.width, boundsRect.height);
        let imageRect = {width: boundsRect.width * scaleFactor, height: boundsRect.height * scaleFactor};

        let calculated_mm = false;
        this._mpp = null;
        this.dialog.querySelector('.include-scalebar').disabled = true;  
        if(this.viewer.world.getItemCount() === 1){
            let mpp = this.viewer.world.getItemAt(0).source.mpp;
            if(mpp){
                this.dialog.querySelector('.region-width-mm').textContent = ''+(mpp.x / 1000 * imageRect.width).toFixed(3);
                this.dialog.querySelector('.region-height-mm').textContent = ''+(mpp.y / 1000 * imageRect.height).toFixed(3);
                calculated_mm = true;
                this.dialog.querySelector('.include-scalebar').disabled = false;
                this._mpp = mpp;   
            }
            
        }
        if(!calculated_mm){
            this.dialog.querySelectorAll('.region-width-mm, .region-height-mm').forEach(e=>e.textContent ='??');
        }

        let select = this.dialog.querySelector('.select-size');
        select.textContent = '';
        
        let w = imageRect.width;
        let h = imageRect.height;
        const maxDim = 23767;
        const maxArea = 268435456;
        while(w > bounds.width && h > bounds.height){
            let data ={
                w: Math.round(w), 
                h: Math.round(h), 
                imageBounds:imageBounds,
                scaleFactor: w / imageRect.width,
            }
            let option = document.createElement('option');
            select.appendChild(option);
            option.textContent = `${Math.round(w)} x ${Math.round(h)}`;
            option.setAttribute('data-dims', JSON.stringify(data));
            if(w > maxDim || h > maxDim || w*h > maxArea){
                // if the canvas is too big, don't even offer it as an option
                option.setAttribute('disabled',true);
            }
            w = w / 2;
            h = h / 2;
        }

        let data = {
            w: bounds.width, 
            h: bounds.height, 
            imageBounds:imageBounds,
            scaleFactor: bounds.width / imageRect.width,
        }
        let option = document.createElement('option');
        select.appendChild(option);
        option.textContent = `${Math.round(w)} x ${Math.round(h)}`;
        option.setAttribute('data-dims', JSON.stringify(data));
        
        this.dialog.classList.remove('hidden');
    }

    _resetScreenshotResults(){
        this.dialog?.querySelector('.screenshot-results').classList.remove('created','pending');
    }

    _setProgress(loaded, total){
        if(this.dialog){
            const progress = this.dialog.querySelector('progress');
            progress.value = loaded;
            progress.max = total;
            this.dialog.querySelector('.loaded').textContent = loaded;
            this.dialog.querySelector('.total').textContent = total;
        }
    }
    
    _createScreenshot(data){
        let w = data.w;
        let h = data.h;
        let ib = data.imageBounds;
        let imageBounds = new OpenSeadragon.Rect(ib.x, ib.y, ib.width, ib.height, ib.degrees);
        let scaleFactor = data.scaleFactor;
        return new Promise((resolve, reject)=>{
            try{
                //make div for new viewer
                let pixelRatio = OpenSeadragon.pixelDensityRatio;
                w = w / pixelRatio;
                h = h / pixelRatio;
                
                const d = document.createElement('div');
                document.body.appendChild(d);
                d.style.cssText = `width:${w}px;height:${h}px;position:fixed;left:-${w*2}px;`;

                let ts = this.viewer.tileSources[this.viewer.currentPage()];
                let ti = this.viewer.world.getItemAt(this.viewer.currentPage());
                let ssViewer = OpenSeadragon({
                    element: d,
                    tileSources:[ts],
                    crossOriginPolicy: this.viewer.crossOriginPolicy,
                    prefixUrl: this.viewer.prefixUrl,
                    immediateRender:true,
                });
                ssViewer.viewport.setRotation(this.viewer.viewport.getRotation(true), true);
                ssViewer.addHandler('tile-drawn',(ev)=>{
                    // console.log(ev.tiledImage.coverage, ev.tile.level, ev.tile.x, ev.tile.y);
                    let coverage = ev.tiledImage.coverage;
                    let levels = Object.keys(coverage);
                    let maxLevel = levels[levels.length - 1];
                    if(ev.tile.level == maxLevel){
                        let full = coverage[maxLevel];
                        let status = Object.values(full).map(o=>Object.values(o)).flat();
                        // console.log(`Loaded ${loaded.filter(l=>l).length} of ${loaded.length} tiles`);
                        this._setProgress(status.filter(x=>x).length, status.length);
                    }
                    
                });
                ssViewer.addHandler('open',()=>{
                    ssViewer.world.getItemAt(0).setRotation(ti.getRotation(true), true);
                    ssViewer.world.getItemAt(0).addOnceHandler('fully-loaded-change',(ev)=>{
                        // draw scalebar if requested
                        if(this._includeScalebar && this._mpp){
                            let pixelWidth = Math.round(this._scalebarWidth * 1000 / this._mpp.x * scaleFactor);
                            let pixelHeight = Math.round(this._scalebarHeight);
                            let canvas = ssViewer.drawer.canvas;
                            let context = canvas.getContext('2d');
                            let canvasWidth = canvas.width;
                            let canvasHeight = canvas.height;
                            context.fillRect(canvasWidth - pixelHeight, canvasHeight - pixelHeight, -pixelWidth, -pixelHeight);
                        }
                        ssViewer.drawer.canvas.toBlob( async blob => {
                            if(pixelRatio != 1){
                                blob = await changeDpiBlob(blob, 96 * pixelRatio);
                            }
                            if(this.blobURL){
                                URL.revokeObjectURL(this.blobURL);
                            }
                            this.blobURL = URL.createObjectURL(blob);

                            resolve(this.blobURL);
                            
                            let container = ssViewer.element;
                            ssViewer.destroy();
                            container.remove();
                        });
                    })
                    // ssViewer.viewport.panTo(bounds.getCenter(), true);
                    let bounds = ssViewer.viewport.imageToViewportRectangle(imageBounds);
                    ssViewer.viewport.fitBounds(bounds);
                });
            } catch(e){
                reject(e);
            }
            
        });
        
    }
}

/**
 * @class 
 * @extends ToolBase
 * 
 */
class ScreenshotTool extends ToolBase{
    
    
    constructor(paperScope, overlay){
        super(paperScope);
        let self = this;

        this._ps = paperScope;
        this.compoundPath = new paper.CompoundPath({children:[],fillRule:'evenodd'});
        this.compoundPath.visible = false;
        this.compoundPath.fillColor = 'black';
        this.compoundPath.opacity = 0.3;

        this.project.toolLayer.addChild(this.compoundPath);

        this.crosshairTool = new paper.Group();
        let h1 = new paper.Path({segments:[new paper.Point(0,0),new paper.Point(0,0)],strokeScaling:false,strokeWidth:1,strokeColor:'black'});
        let h2 = new paper.Path({segments:[new paper.Point(0,0),new paper.Point(0,0)],strokeScaling:false,strokeWidth:1,strokeColor:'white',dashArray:[6,6]});
        let v1 = new paper.Path({segments:[new paper.Point(0,0),new paper.Point(0,0)],strokeScaling:false,strokeWidth:1,strokeColor:'black'});
        let v2 = new paper.Path({segments:[new paper.Point(0,0),new paper.Point(0,0)],strokeScaling:false,strokeWidth:1,strokeColor:'white',dashArray:[6,6]});
        this.crosshairTool.addChildren([h1,h2,v1,v2]);
        this.project.toolLayer.addChild(this.crosshairTool);
        this.crosshairTool.visible = false;
       
        this._aspectHeight = 1;
        this._aspectWidth = 1;
        this._aspectLocked = false;

        
        //add properties to this.tools so that they properly appear on html
        this.tool.onMouseDown= (ev) => {
            this.crosshairTool.visible = false;
            this.compoundPath.visible = true;
            this.compoundPath.removeChildren();
            this.compoundPath.addChild(new paper.Path.Rectangle(this._ps.view.bounds));
            window.cp = this.compoundPath;
        }
        this.tool.onMouseDrag= (ev) => {
            this.compoundPath.removeChildren(1);
            let point = this.getPoint(ev);
            this.compoundPath.addChild(new paper.Path.Rectangle(ev.downPoint, point));
        }
        this.tool.onMouseMove= (ev) => {
            this.crosshairTool.visible = true;
            setCursorPosition(self.tool, ev.point);
        }
        this.tool.onMouseUp = (ev) => {
            let point = this.getPoint(ev);
            this.broadcast('region-selected',new paper.Rectangle(ev.downPoint, point));
            // this.compoundPath.visible = false;
        }
        this.tool.extensions.onKeyDown=function(ev){
            if(ev.key=='escape'){
                overlay.deactivate();
            }
        }
        this.extensions.onActivate = () => {
            this.crosshairTool.visible = true;
            this.compoundPath.visible = false;
        }
        this.extensions.onDeactivate = (finished) => {
            this.crosshairTool.visible = false;
            this.compoundPath.visible = false;
        }   
        

        function setCursorPosition(tool, point){
            
            let pt = tool.view.projectToView(point);
            let left=tool.view.viewToProject(new paper.Point(0, pt.y))
            let right=tool.view.viewToProject(new paper.Point(tool.view.viewSize.width, pt.y))
            let top=tool.view.viewToProject(new paper.Point(pt.x, 0))
            let bottom=tool.view.viewToProject(new paper.Point(pt.x,tool.view.viewSize.height))
            // console.log(viewBounds)
            h1.segments[0].point = left;
            h2.segments[0].point = left;
            h1.segments[1].point = right;
            h2.segments[1].point = right;
            v1.segments[0].point = top;
            v2.segments[0].point = top;
            v1.segments[1].point = bottom;
            v2.segments[1].point = bottom;
        }

    }

    activate(){
        this.tool.activate();
        this.crosshairTool.visible = true;
        this.compoundPath.visible = false;
    }
    deactivate(){
        this.crosshairTool.visible = false;
        this.compoundPath.visible = false;
    }
    setAspectHeight(h){
        this._aspectHeight = h;
    }
    setAspectWidth(w){
        this._aspectWidth = w;
    }
    setAspectLocked(l){
        this._aspectLocked = l;
    }
    getPoint(ev){
        let point = ev.point;
        if(this._aspectLocked){
            let delta = ev.point.subtract(ev.downPoint);
            
            if(Math.abs(delta.x) > Math.abs(delta.y)){
                point.y = ev.downPoint.y + (delta.y < 0 ? -1 : 1 ) * Math.abs(delta.x) * this._aspectHeight / this._aspectWidth;
            } else {
                point.x = ev.downPoint.x + (delta.x < 0 ? -1 : 1 ) * Math.abs(delta.y) * this._aspectWidth / this._aspectHeight;
            }
        }
        return point;
    }

    
}
export {ScreenshotTool};
export {ScreenshotOverlay};