Source: /externalosd_tools.js

/**
 * Utilities for the OpenSeadragon Viewer.
 * Available as OpenSeadragon.tools instance (attaches itself on creation).
 * in xOpat: VIEWER.tools.[...]
 * @type {OpenSeadragon.Tools}
 */
OpenSeadragon.Tools = class {

    /**
     * @param context OpenSeadragon instance
     */
    constructor(context) {
        //todo initialize explicitly outside to help IDE resolution
        if (context.tools) throw "OSD Tools already instantiated on the given viewer instance!";
        context.tools = this;
        this.viewer = context;
    }

    /**
     * @param params Object that defines the focus
     * @param params.bounds OpenSeadragon.Rect, in viewport coordinates;
     *   both elements below must be defined if bounds are undefined
     * @param params.point OpenSeadragon.Point center of focus
     * @param params.zoomLevel Number, zoom level
     *
     * @param params.animationTime | params.duration (optional)
     * @param params.springStiffness | params.transition (optional)
     * @param params.immediately focus immediately if true (optional)
     * @param params.preferSameZoom optional, default: keep the user's viewport as close as possible if false,
     *   or keep the same zoom level if true; note this value is ignored if appropriate data not present
     */
    focus(params) {
        this.constructor.focus(this.viewer, params);
    }
    static focus(context, params) {
        let view = context.viewport,
            _centerSpringXAnimationTime = view.centerSpringX.animationTime,
            _centerSpringYAnimationTime = view.centerSpringY.animationTime,
            _zoomSpringAnimationTime = view.zoomSpring.animationTime;

        let duration = params.animationTime || params.duration;
        if (!isNaN(duration)) {
            view.centerSpringX.animationTime =
                view.centerSpringY.animationTime =
                    view.zoomSpring.animationTime =
                        duration;
        }

        let transition = params.springStiffness || params.transition;
        if (!isNaN(transition)) {
            view.centerSpringX.springStiffness =
                view.centerSpringY.springStiffness =
                    view.zoomSpring.springStiffness =
                        transition;
        }

        if ((params.point && params.zoomLevel) && (params.preferSameZoom || !params.bounds)) {
            view.panTo(params.point, params.immediately);
            view.zoomTo(params.zoomLevel, params.immediately);
        } else if (params.bounds) {
            view.fitBoundsWithConstraints(params.bounds, params.immediately);
        } else {
            throw "No valid focus data provided!";
        }
        view.applyConstraints();

        view.centerSpringX.animationTime = _centerSpringXAnimationTime;
        view.centerSpringY.animationTime = _centerSpringYAnimationTime;
        view.zoomSpring.animationTime = _zoomSpringAnimationTime;
    }

    /**
     * Create viewport screenshot
     * @param {boolean} toImage true if <img> element should be created, otherwise Context2D
     * @param {OpenSeadragon.Point|object} size the output size
     * @param {(OpenSeadragon.Rect|object|undefined)} [focus=undefined] screenshot
     *   focus area (screen coordinates), by default thw whole viewport
     * @return {CanvasRenderingContext2D|Image}
     */
    screenshot(toImage, size = {}, focus=undefined) {
        return this.constructor.screenshot(this.viewer, toImage, size, focus);
    }
    static screenshot(viewer, toImage, size = {}, focus=undefined) {
        if (viewer.drawer.canvas.width < 1) return undefined;

        if (!focus) focus = new OpenSeadragon.Rect(0, 0, window.innerWidth, window.innerHeight);
        size.width = size.x || focus.width;
        size.height = size.y || focus.height;
        let ar = size.x / size.y;
        if (focus.width < focus.height) focus.width *= ar;
        else focus.height /= ar;

        let canvas = document.createElement('canvas'),
            ctx = canvas.getContext('2d');
        canvas.width = size.x;
        canvas.height = size.y;
        ctx.drawImage(viewer.drawer.canvas, focus.x, focus.y, size.x, size.y, 0, 0, size.x, size.y);

        if (toImage) {
            let img = document.createElement("img");
            img.src = canvas.toDataURL();
            return img;
        }
        return ctx;
    }

    /**
     * Create thumbnail screenshot
     * TODO FIX THIS - VIEWER REFERENCE is too BIG to capture a thumbnail - we should download manually just a proportion of the image,
     *   right now we force the whole tiled image load, which depends on the screen size and is not optimal (especially if navigator is not defined)
     * @param {BackgroundItem|StandaloneBackgroundItem} config bg config
     * @param {OpenSeadragon.Point} size the output size
     * @param {number} timeout
     * @param {boolean} [size.preserveAspectRatio=true]
     * @return {Promise<CanvasRenderingContext2D>}
     */
    async navigatorThumbnail(config, size = {}, timeout=30000) {
        return this.constructor.navigatorThumbnail(this.viewer, config, size);
    }
    static async navigatorThumbnail(viewer, bgConfig, size = {}, timeout=30000) {
        if (viewer.drawer.canvas.width < 1) return Promise.reject("No image to create thumbnail from!");
        // todo works for background right now only -> check how we can extend for also viz layers
        if (!bgConfig.id) {
            console.error("Thumbnail can be created for now only from background configurations!");
            return Promise.reject("No background configuration provided!");
        }

        // Keep single offscreen renderer between apps
        let drawer;
        viewer.__ofscreenRender = (drawer = viewer.__ofscreenRender || OpenSeadragon.makeStandaloneFlexDrawer(viewer));
        if (viewer.navigator) {
            viewer = viewer.navigator;
        }

        let dataRef = APPLICATION_CONTEXT.config.data[bgConfig.dataReference];
        if (typeof bgConfig.dataReference !== "number" && !dataRef) {
            dataRef = bgConfig.dataReference; // use the value as actual data
        }

        const bgUrlFromEntry = (bgEntry) => {
            const resolved = window.SLIDE_PROTOCOLS.resolveBackground({
                spec: dataRef,
                bgEntry,
                isSecureMode: APPLICATION_CONTEXT.secure,
            });
            return resolved.kind === "tileSource" ? resolved.tileSource : resolved.url;
        };

        // todo multiple data images? how to retrieve existing configurations?
        const tiledImages = [-1];

        // First prepare images
        const imageSources = await Promise.all(tiledImages.map(async idx => {
            let source = idx > -1 && viewer.world.getItemAt(idx)?.source;
            if (!source) {
                // todo: might not carry over all OSD properties such as ajax headers
                const spec = bgUrlFromEntry(bgConfig);
                const SP = window.SLIDE_PROTOCOLS;
                const client = typeof spec === "string"
                    ? SP?.getActiveClientForUrl?.(spec)
                    : spec?.__xopatHttpClient;
                source = await SP.withActiveClient(client, () =>
                    viewer.instantiateTileSourceClass({tileSource: spec})
                );
                source = source.source;
                if (client && source && !source.__xopatHttpClient) source.__xopatHttpClient = client;
            }
            if (source.getThumbnail) {
                // if we have a thumbnail, replace the source with single-image thumbnail
                try {
                    let thumb = await source.getThumbnail();
                    if (thumb) {
                        thumb = await UTILITIES.imageLikeToImage(thumb);
                        if (thumb) source = new OpenSeadragon.PreviewSlideSource({image: thumb});
                    }
                } catch (e) {
                    // failed to load thumbnail via API, still, continue manual reconstruction
                    console.warn("Failed to retrieve thumbnail via API, continuing with manual reconstruction.", e);
                }
            }
            return source;
        })).catch(e => {
            // todo - consider: if some parts of the image were downloaded, try to continue with what is available
            console.error("Failed to instantiate background config, image not valid.", e);
            return undefined;
        });

        if (!imageSources) return false;

        // Use prepared sources to render the image thumbnail
        return new Promise(async (resolve, reject) => {
            let exited = false;
            let timeoutRef;

            let loadCount = 0;
            const images = [];
            for (let source of imageSources) {
                loadCount++;
                viewer.instantiateTiledImageClass({
                    tileSource: source,
                    success: async e => {
                        if (exited) return;

                        const ti = e.item;
                        // override drawer to ensure correct drawer is used
                        ti.getDrawer = () => drawer;
                        ti.__synthetic = true;

                        // simply download the current tiles, in case of thumbnail we just load the thumbnail
                        ti.update(true);
                        const updateReminder = setInterval(() => {
                            if (exited) clearInterval(updateReminder);
                            ti.update(true);
                        }, 500);
                        images.push(ti);

                        ti.whenFullyLoaded(() => {
                            if (exited) return;
                            clearInterval(updateReminder);
                            loadCount--;

                            if (loadCount < 1) {
                                clearTimeout(timeoutRef);
                                resolve(images);
                            }
                        });
                    }, error: e => {
                        if (exited) return;
                        loadCount--;
                        images.error = e;

                        if (loadCount < 1) {
                            clearTimeout(timeoutRef);
                            resolve(images);
                        }
                    }}
                );
            }

            if (loadCount < 1) {
                resolve(images);
            } else {
                timeoutRef = setTimeout(() => {
                    exited = true;
                    resolve(images);
                    // images.forEach(i => i.destroy());
                    // reject("Failed to retrieve tiled images and their tiles before timeout.");
                }, timeout);
            }
        }).then(async images => {
            // todo check images are properly freed if created...
            if (images.error) {
                throw images.error;
            }

            console.log("render using", images.length, "images", images)

            // The open pipeline namespaces shader ids per viewer (see
            // shader-id-namespace.ts). `bgConfig.id` is the un-prefixed
            // structural id; prefix it for renderer lookups.
            const __ns = viewer.__shaderNamespace;
            const __lookupId = __ns ? __ns + bgConfig.id : bgConfig.id;
            const existingConfig = viewer.drawer.renderer.getShaderLayerConfig(__lookupId);

            let config = existingConfig ? {...existingConfig} : {
                id: bgConfig.id,
                type: "identity",
                tiledImages: null,
                name: bgConfig.name || dataRef
            };
            config.tiledImages = images.map((_, idx) => idx);

            const originalTiledImages = config.tiledImages;
            const w = images[0].source.width;
            const h = images[0].source.height;
            const ar = w / h;
            const bounds = new OpenSeadragon.Rect(0, 0, 1, 1/ar);
            const preserve = size.preserveAspectRatio || size.preserveAspectRatio === undefined;
            if (preserve) {
                if (ar < 1) size.x = size.x * ar;
                else size.y = size.y / ar;
            }
            const context = await drawer.drawWithConfiguration(images, {[bgConfig.id]: config}, {
                bounds: bounds,
                center: new OpenSeadragon.Point(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2),
                rotation: 0,
                zoom: 1.0 / bounds.width,
            }, size);
            config.tiledImages = originalTiledImages;
            images.forEach(i => i.destroy());
            return context;
        });
    }

    /**
     * Retrieve label
     * @param {BackgroundItem|StandaloneBackgroundItem} config bg config
     * @return {Promise<Image>}
     */
    async retrieveLabel(config) {
        return this.constructor.retrieveLabel(this.viewer, config);
    }
    static async retrieveLabel(viewer, bgConfig) {
        if (viewer.drawer.canvas.width < 1) return Promise.reject("No image to create thumbnail from!");
        if (!bgConfig.id) {
            console.error("Thumbnail can be created for now only from background configurations!");
            return Promise.reject("No background configuration provided!");
        }

        let dataRef = APPLICATION_CONTEXT.config.data[bgConfig.dataReference];
        if (typeof bgConfig.dataReference !== "number" && !dataRef) {
            dataRef = bgConfig.dataReference; // use the value as actual data
        }

        const bgUrlFromEntry = (bgEntry) => {
            const resolved = window.SLIDE_PROTOCOLS.resolveBackground({
                spec: dataRef,
                bgEntry,
                isSecureMode: APPLICATION_CONTEXT.secure,
            });
            return resolved.kind === "tileSource" ? resolved.tileSource : resolved.url;
        };

        // todo find existing item index if bg config is loaded
        const idx = -1;
        let source = idx > -1 && viewer.world.getItemAt(idx)?.source;
        if (!source) {
            // todo: might not carry over all OSD properties such as ajax headers
            const spec = bgUrlFromEntry(bgConfig);
            const SP = window.SLIDE_PROTOCOLS;
            const client = typeof spec === "string"
                ? SP?.getActiveClientForUrl?.(spec)
                : spec?.__xopatHttpClient;
            source = await SP.withActiveClient(client, () =>
                viewer.instantiateTileSourceClass({tileSource: spec})
            );
            source = source.source;
            if (client && source && !source.__xopatHttpClient) source.__xopatHttpClient = client;
        }
        if (source.getLabel) {
            // if we have a thumbnail, replace the source with single-image thumbnail
            let label = await source.getLabel();
            if (label) {
                label = await UTILITIES.imageLikeToImage(label);
                return label;
            }
        }
        return undefined;
    }

    /**
     * Create Image Object for a desired background.
     * This method must be used to generate the image previews shown in menus - otherwise they are not accurate.
     * @param {BackgroundItem|StandaloneBackgroundItem} bgSpec bg config
     * @param width
     * @param height
     * @return {Promise<Image|HTMLImageElement>}
     */
    async createImagePreview(bgSpec, width=250, height=250) {
        // --- Preview URL fetch (unchanged) ---
        let dataRef = APPLICATION_CONTEXT.config.data[bgSpec.dataReference];
        if (typeof bgSpec.dataReference !== "number" && !dataRef) {
            dataRef = bgSpec.dataReference; // use the value as actual data
        }

        const eventArgs = {
            server: APPLICATION_CONTEXT.env.client.image_group_server,
            image: dataRef,
            imagePreview: null,
        };

        await VIEWER_MANAGER.raiseEventAwaiting("get-preview-url", eventArgs);

        if (eventArgs.imagePreview && typeof eventArgs.imagePreview.then === "function") {
            eventArgs.imagePreview = await eventArgs.imagePreview;
        }

        if (eventArgs.imagePreview instanceof Image) {
            const imageEl = eventArgs.imagePreview;
            imageEl.classList.add("max-w-[86%]", "max-h-[86%]", "object-contain", "select-none");
            // imageEl.id = `${this.windowId}-thumb-${idx}`;
            // document.getElementById(`${this.windowId}-thumb-${idx}`).replaceWith(imageEl);
            return imageEl;
        }

        const image = document.createElement("img");
        image.onerror = e => {
            e.target.classList.add("opacity-30");
            e.target.removeAttribute("src");
            if (eventArgs.needsRevoke) {
                URL.revokeObjectURL(eventArgs.imagePreview);
            }
        };

        if (!eventArgs.imagePreview) {
            try {
                const ctx = await this.viewer.tools.navigatorThumbnail(bgSpec, {x: width, y: height}, 60000);
                let data = ctx.canvas.toDataURL();
                if (data.length < 1000) {
                    console.warn("Image preview is too small, probably missing data - replacing with preview.");
                    data = APPLICATION_CONTEXT.url + "src/assets/dummy-slide.png";
                }
                image.src = data;
            } catch (e) {
                console.error(e);
                image.src = APPLICATION_CONTEXT.url + "src/assets/dummy-slide.png";
            }
        } else if (typeof eventArgs.imagePreview === "string") {
            image.src = eventArgs.imagePreview;
        } else {
            // todo not very smart fallback
            eventArgs.needsRevoke = true;
            eventArgs.imagePreview = URL.createObjectURL(eventArgs.imagePreview);
            image.onload = () => URL.revokeObjectURL(eventArgs.imagePreview);

            image.src = eventArgs.imagePreview;
        }
        return image;
    }

    // /**
    //  * Create region screenshot, the screenshot CAN BE ANYWHERE
    //  * @param {object} region region of interest in the image pixel space
    //  * @param {number} region.x
    //  * @param {number} region.y
    //  * @param {number} region.width
    //  * @param {number} region.height
    //  * @param {object} targetSize desired size (should have the same AR -aspect ratio- as region),
    //  *  the result tries to find a level on which the region
    //  *  is closest in size to the desired size
    //  * @param {number} targetSize.width
    //  * @param {number} targetSize.height
    //  * @param {function} onfinish function that is called on screenshot finish, argument is a canvas with resulting image
    //  * @param {object} [outputSize=targetSize] output image size, defaults to target size
    //  * @param {number} outputSize.width
    //  * @param {number} outputSize.height
    //  */
    // offlineScreenshot(region, targetSize, onfinish, outputSize=targetSize) {
    //     throw new Error("not implemented yet");
    //     // todo consume a configuration object, and render it -> could be used also for the

    //
    //     let referencedTiledImage = this.viewer.scalebar.getReferencedTiledImage();
    //
    //     const batches = {};
    //     this.viewer.addHandler('tile-loaded', e => {
    //         if (e.tile in batches) {
    //             const data = batches[e.tile];
    //             if (data.timeout) clearTimeout(data.timeout);
    //             data.onload(e.tile);
    //         }
    //     });
    //
    //     function download(tiledImage, level, x, y, onload, onfail) {
    //         const tile = tiledImage._getTile(level, x, y);
    //         if (!tile.loaded || !tile.loading) {
    //             batches[tile] = { onload, onfail, timeout: setTimeout(() => {
    //                     onfail(tile);
    //                     delete batches[tile].timeout;
    //                 }, 15000)};
    //             tiledImage._loadTile(tile, OpenSeadragon.now());
    //         }
    //     }
    //
    //     function buildImageForLayer(tiledImage, region, level, onBuilt) {
    //         let source = tiledImage.source,
    //             viewportX = region.x / source.width,
    //             viewportY = region.y / source.width,
    //             viewportXAndWidth = (region.x+region.width-1) / source.width,
    //             viewportYAdnHeight = (region.y+region.height-1) / source.width; //minus 1 to avoid next tile if not needed
    //
    //         let tileXY = source.getTileAtPoint(level, new OpenSeadragon.Point(viewportX, viewportY)),
    //             tileXWY = source.getTileAtPoint(level, new OpenSeadragon.Point(viewportXAndWidth, viewportY)),
    //             tileXYH = source.getTileAtPoint(level, new OpenSeadragon.Point(viewportX, viewportYAdnHeight)),
    //             tileXWYH = source.getTileAtPoint(level, new OpenSeadragon.Point(viewportXAndWidth, viewportYAdnHeight));
    //
    //         const onLoad = tile => {
    //             finish();
    //         }
    //
    //         const onFail = tile => {
    //             delete batches[tile];
    //             finish();
    //         };
    //
    //         function finish() {
    //             count--;
    //             if (count === 0) {
    //                 onBuilt();
    //             }
    //         }
    //
    //         // todo correct zoom based on size
    //         let count = 4;
    //         download(tiledImage, level, tileXY.x, tileXY.y, onLoad, onFail);
    //         if (tileXY.x !== tileXWY.x) download(tiledImage, level, tileXWY.x, tileXWY.y, onLoad, onFail);
    //         else count--;
    //         if (tileXY.y !== tileXYH.y) download(tiledImage, level, tileXYH.x, tileXYH.y, onLoad, onFail);
    //         else count--;
    //         //being forced to download all means diagonally too
    //         if (count === 4) download(tiledImage, level, tileXWYH.x, tileXWYH.y, onLoad, onFail);
    //         else count--;
    //     }
    //
    //     // todo consider multiimage support
    //     for (let tImage of [referencedTiledImage]) {
    //         const level = this.constructor._bestLevelForTiledImage(tImage, region, targetSize);
    //         // todo support multiple images, e.g. fluorescence
    //         buildImageForLayer(tImage, region, level, () => {
    //             let drawer;
    //             this.viewer.__ofscreenRender = (drawer = this.viewer.__ofscreenRender || OpenSeadragon.makeStandaloneFlexDrawer(this.viewer));
    //             drawer.renderer.setDimensions(0, 0, outputSize.width, outputSize.height, 1);
    //             const bg = tImage.getConfig("background");
    //             const config = this.viewer.drawer.renderer.getShaderLayerConfig(bg?.id);
    //             drawer.overrideConfigureAll({[config.id]: config});
    //
    //             const oldHandler = tImage.getTilesToDraw;
    //             tImage.getTilesToDraw = function() {
    //                 return Object.keys(batches);
    //             }
    //             const bounds = tImage.imageToViewportRectangle(region);
    //             drawer.draw([tImage], {
    //                 bounds: bounds,
    //                 center: new OpenSeadragon.Point(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2),
    //                 rotation: 0,
    //                 zoom: 1.0 / bounds.width,
    //             });
    //             tImage.getTilesToDraw = oldHandler;
    //             onfinish();
    //         });
    //     }
    // }
    // static _bestLevelForTiledImage(image, region, targetSize) {
    //
    //     //best level is found by tile size fit wrt. annotation size
    //     function getDiff(source, level) {
    //         let scale = source.getLevelScale(level);
    //
    //         //scale multiplication computes no. of pixels at given pyramid level
    //         return Math.min(Math.abs(region.width * scale - targetSize.width),
    //             Math.abs(region.height * scale - targetSize.height));
    //     }
    //
    //     let source = image.source,
    //         bestLevel = source.maxLevel,
    //         d = getDiff(source, bestLevel);
    //
    //     for (let i = source.maxLevel-1; i >= source.minLevel; i--) {
    //         let dd = getDiff(source, i);
    //         if (dd > d) break;
    //         bestLevel = i;
    //         d = dd;
    //     }
    //     return bestLevel;
    // }

    /**
     * Link the viewer to context-sharing navigation link: all viewers of the same context
     * will follow the same navigation path.
     * @param {any} context sync context - in which sync session you operate
     * @param {function(OpenSeadragon.Viewer, OpenSeadragon.Viewer)} mapper - custom sync logics instead of the default one, usually
     *   you want to call applyViewportState(...) method with your computed viewport state values
     */
    link(context=0, mapper=undefined) {
        this.constructor.link(this.viewer, context, mapper);
    }

    /**
     * Link the viewer to context-sharing navigation link: all viewers of the same context
     * will follow the same navigation path.
     * @param {OpenSeadragon.Viewer} self
     * @param {any} context sync context - in which sync session you operate
     * @param {function(OpenSeadragon.Viewer, OpenSeadragon.Viewer)} mapper - custom sync logics instead of the default one
     */
    static link(self, context = 0, mapper = undefined) {
        let ctx = this._linkContexts[context];
        if (!ctx) {
            ctx = this._linkContexts[context] = {
                subscribed: [],
                activeLeader: null
            };
        }

        // Any linked viewer may be the temporary source of a sync event.
        // A custom mapper defines how *targets* follow the source; it must not
        // prevent the source viewer from driving the session.
        const canLead = true;

        if (ctx.subscribed.includes(self)) {
            self.__sync_mapper = mapper;
            self.__sync_canLead = canLead;
            return;
        }

        self.__sync_mapper = mapper;
        self.__sync_canLead = canLead;

        const handler = function () {
            if (!self.__sync_canLead) return;

            if (ctx.activeLeader && ctx.activeLeader !== self) return;
            ctx.activeLeader = self;

            const leaderState = self.tools.readViewportState();

            for (let v of ctx.subscribed) {
                if (!v || v === self) continue;

                let state = leaderState;
                if (typeof v.__sync_mapper === "function") {
                    state = v.__sync_mapper(self, leaderState);
                    if (!state) continue;
                }

                v.tools.applyViewportState(state);
            }

            ctx.activeLeader = null;
        };

        self.__sync_handler = handler;
        ctx.subscribed.push(self);

        self.addHandler('zoom', handler);
        self.addHandler('pan', handler);
        self.addHandler('rotate', handler);
        self.addHandler('flip', handler);
    }

    applyViewportState(state) {
        this.constructor.applyViewportState(this.viewer, state);
    }
    static applyViewportState(viewer, state) {
        const vp = viewer.viewport;

        if (!state) return;

        const c = new OpenSeadragon.Point(state.center.x, state.center.y);

        // Clamp zoom
        const zMin = vp.getMinZoom?.() ?? -Infinity;
        const zMax = vp.getMaxZoom?.() ?? +Infinity;
        const z = Math.max(zMin, Math.min(zMax, state.zoom));

        vp.rotateTo(state.rotation, true);
        vp.zoomTo(z, c, true);
        vp.panTo(c, true);

        if (typeof vp.getFlip === "function") {
            if (vp.getFlip() !== state.flip)
                vp.setFlip(state.flip);
        }

        vp.applyConstraints?.();
    }

    readViewportState() {
        return this.constructor.readViewportState(this.viewer);
    }
    static readViewportState(viewer) {
        const vp = viewer.viewport;
        return {
            zoom: vp.getZoom(false),
            center: vp.getCenter(false),
            rotation: vp.getRotation(false),
            flip: vp.getFlip()
        };
    }

    isLinked() {
        return !!this.viewer.__sync_handler;
    }

    /**
     * Unlink the viewer from context-sharing navigation link.
     * @param context
     */
    unlink(context=0) {
        this.constructor.unlink(this.viewer, context);
    }

    /**
     * Unlink the viewer from context-sharing navigation link.
     * @param {OpenSeadragon.Viewer} self
     * @param context
     */
    static unlink(self, context=0) {
        const contextData = this._linkContexts[context];
        if (!contextData) return;
        const index = contextData.subscribed.indexOf(self);
        if (index < 0) return;
        self.removeHandler('zoom', self.__sync_handler);
        self.removeHandler('pan', self.__sync_handler);
        self.removeHandler('rotate', self.__sync_handler);
        self.removeHandler('flip', self.__sync_handler);
        delete self.__sync_handler;
        delete self.__sync_mapper;
        delete self.__sync_canLead;
        contextData.subscribed.splice(index, 1);
    }

    /**
     * Destroy the context-sharing navigation link for all viewers.
     * @param context
     */
    static destroyLink(context=0) {
        const contextData = this._linkContexts[context];
        if (!contextData) return;
        for (let v of contextData.subscribed) {
            v.removeHandler('zoom', v.__sync_handler);
            v.removeHandler('pan', v.__sync_handler);
            v.removeHandler('rotate', v.__sync_handler);
            v.removeHandler('flip', v.__sync_handler);
            delete v.__sync_handler;
        }
        delete contextData.subscribed;
        delete contextData.leading;
        delete contextData.activeLeader;
        delete this._linkContexts[context];
    }

    static destroyLinks() {
        for (let context in this._linkContexts) {
            this.destroyLink(context);
        }
    }

    syncViewers(viewer, otherViewer) {
        this.constructor.syncViewers(viewer, otherViewer);
    }
    static syncViewers(viewer, otherViewer) {
        this._syncViewports(viewer.viewport, otherViewer.viewport);
    }
    static _syncViewports(viewport, otherViewport) {
        otherViewport.fitBoundsWithConstraints(viewport.getBounds(), true);
    }
};

OpenSeadragon.Tools._linkContexts = {};