From 056ccf00ebd007fd2545bee9b8540ef3e486fb6f Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 8 Aug 2026 18:46:17 -0700 Subject: [PATCH] examples/deepzoom: update OpenSeadragon to 6.1.0 Signed-off-by: Benjamin Gilbert --- examples/deepzoom/static/openseadragon.js | 1164 ++++++++++++++++----- 1 file changed, 892 insertions(+), 272 deletions(-) diff --git a/examples/deepzoom/static/openseadragon.js b/examples/deepzoom/static/openseadragon.js index b545aca4..f75dc017 100644 --- a/examples/deepzoom/static/openseadragon.js +++ b/examples/deepzoom/static/openseadragon.js @@ -1,6 +1,6 @@ -//! openseadragon 6.0.2 -//! Built on 2026-03-12 -//! Git commit: v6.0.2-0-7842cd92 +//! openseadragon 6.1.0 +//! Built on 2026-08-06 +//! Git commit: v6.1.0-0-1dc52bd0 //! http://openseadragon.github.io //! License: http://openseadragon.github.io/license/ @@ -90,7 +90,7 @@ /** * @namespace OpenSeadragon - * @version openseadragon 6.0.2 + * @version openseadragon 6.1.0 * @classdesc The root namespace for OpenSeadragon. All utility methods * and classes are defined on or below this namespace. * @@ -377,6 +377,24 @@ * @property {Boolean} [loadDestinationTilesOnAnimation=true] * If true, tiles are loaded only at the destination of an animation. * If false, tiles are loaded along the animation path during the animation. + * @property {Boolean} [cooperativeGestures=false] + * When true, prevents the viewer from trapping scroll/gesture on an embedded page + * (modelled on the cooperative gesture handling in Google Maps / Leaflet / MapTiler). + * On touch, a single finger scrolls the page while two fingers pan/zoom the viewer. + * On desktop, plain mouse-wheel scrolls the page while Ctrl+wheel (or Cmd+wheel on Mac) zooms. + * When a gesture is blocked, a hint overlay is shown. + * {@link OpenSeadragon.Viewer#canvas-cooperative-gesture} event is raised so consuming apps + * can customise or suppress it. Automatically suspended while the viewer is + * full-page/fullscreen (there is no surrounding page to scroll past). + *

+ * The hint text can be customised globally via + * OpenSeadragon.setString('GestureHints.Touch', '…') and + * OpenSeadragon.setString('GestureHints.Scroll', '…') (the scroll string's + * {0} placeholder is filled with the platform modifier), or per-gesture by + * changing the message on the canvas-cooperative-gesture event. + *

+ * Suppressing the hint overlay can be done by setting event.preventDefaultAction = true; on the + * canvas-cooperative-gesture event. * @property {OpenSeadragon.GestureSettings} [gestureSettingsMouse] * Settings for gestures generated by a mouse pointer device. (See {@link OpenSeadragon.GestureSettings}) * @property {Boolean} [gestureSettingsMouse.dragToPan=true] - Pan on drag gesture @@ -520,6 +538,14 @@ * @property {String} [navigatorDisplayRegionColor='#900'] * Specifies the border color of the display region rectangle of the navigator minimap * + * @property {String|DrawerImplementation|Array|null} [navigatorDrawer=null] + * Specifies the drawer type to use for the navigator minimap, overriding the + * parent viewer's drawer. Accepts the same values as the {@link OpenSeadragon.Options#drawer} + * option (e.g. 'canvas', 'webgl', 'html'). When null (the default), the navigator + * inherits the parent viewer's full drawer configuration, including any fallback candidates + * (e.g. if the viewer uses ['webgl', 'canvas'], the navigator will also fall back from + * WebGL to canvas on failure). + * * @property {Number} [controlsFadeDelay=2000] * The number of milliseconds to wait once the user has stopped interacting * with the interface before beginning to fade the controls. Assumes @@ -547,7 +573,18 @@ * The higher the minPixelRatio, the lower the quality of the image that * is considered sufficient to stop rendering a given zoom level. For * example, if you are targeting mobile devices with less bandwidth you may - * try setting this to 1.5 or higher. + * try setting this to 1.5 or higher. The default value of 0.5 means that + * one level is always fetched ahead. This means users will usually see + * sharper data as they navigate, since during zoom we have already one level + * up loaded. If you experience high network traffic/latency, you might want + * to set this value to 1.0 (~fetch at most identical pixel size) or higher + * to force upsampling. + * + * @property {Number} [discardLevelsBelowDownsampleRatio=1] + * You can force the viewer to skip levels that have smaller pixel ratio + * difference gap than a specified value. For example, setting the value to + * 4 with each level smaller by 2 (powers of two), the viewer will access + * every other level (that is, levels spaced by a 4x downsample factor). * * @property {Boolean} [mouseNavEnabled=true] * Is the user able to interact with the image via mouse or touch. Default @@ -717,6 +754,15 @@ * @property {String|Boolean} [crossOriginPolicy=false] * Valid values are 'Anonymous', 'use-credentials', and false. If false, canvas requests will * not use CORS, and the canvas will be tainted. + *

+ * When using the WebGL drawer (the default) with cross-origin tile sources, this option must + * be set to 'Anonymous' (or 'use-credentials') and the tile server must respond with + * appropriate CORS headers (e.g. Access-Control-Allow-Origin: *). Without this, WebGL cannot + * upload tile images as textures due to browser security restrictions. The viewer can only + * fall back to canvas rendering if 'canvas' is included in the configured drawer candidates + * (see {@link OpenSeadragon.Options#drawer} and {@link OpenSeadragon.Options#navigatorDrawer}). + * This applies equally to the navigator minimap — see also navigatorDrawer if you want to + * use canvas for the navigator explicitly. * * @property {Boolean} [ajaxWithCredentials=false] * Whether to set the withCredentials XHR flag for AJAX requests. @@ -886,10 +932,10 @@ function OpenSeadragon( options ){ * @since 1.0.0 */ $.version = { - versionStr: '6.0.2', + versionStr: '6.1.0', major: parseInt('6', 10), - minor: parseInt('0', 10), - revision: parseInt('2', 10) + minor: parseInt('1', 10), + revision: parseInt('0', 10) }; @@ -1289,6 +1335,7 @@ function OpenSeadragon( options ){ wrapVertical: false, visibilityRatio: 0.5, //-> how much of the viewer can be negative space minPixelRatio: 0.5, //->closer to 0 draws tiles meant for a higher zoom at this zoom + discardLevelsBelowDownsampleRatio: 1, defaultZoomLevel: 0, minZoomLevel: null, maxZoomLevel: null, @@ -1354,6 +1401,7 @@ function OpenSeadragon( options ){ flickMomentum: 0.25, pinchRotate: false }, + cooperativeGestures: false, zoomPerClick: 2, zoomPerScroll: 1.2, zoomPerDblClickDrag: 1.2, @@ -1409,6 +1457,7 @@ function OpenSeadragon( options ){ navigatorOpacity: 0.8, navigatorBorderColor: '#555', navigatorDisplayRegionColor: '#900', + navigatorDrawer: null, // INITIAL ROTATION degrees: 0, @@ -2129,19 +2178,29 @@ function OpenSeadragon( options ){ /** - * Sets the specified element's touch-action style attribute to 'none'. + * Sets the specified element's touch-action style attribute to the given value. * @function * @param {Element|String} element + * @param {String} value - CSS touch-action value, e.g. 'none' or 'pan-x pan-y'. */ - setElementTouchActionNone: function( element ) { + setElementTouchAction: function( element, value ) { element = $.getElement( element ); if ( typeof element.style.touchAction !== 'undefined' ) { - element.style.touchAction = 'none'; + element.style.touchAction = value; } else if ( typeof element.style.msTouchAction !== 'undefined' ) { - element.style.msTouchAction = 'none'; + element.style.msTouchAction = value; } }, + /** + * Sets the specified element's touch-action style attribute to 'none'. + * @function + * @param {Element|String} element + */ + setElementTouchActionNone: function( element ) { + $.setElementTouchAction( element, 'none' ); + }, + /** * Sets the specified element's pointer-events style attribute to the passed value. @@ -4086,6 +4145,14 @@ $.EventSource.prototype = { this.blurHandler = options.blurHandler || null; /*eslint-enable no-multi-spaces*/ + /** + * If true, a single touch contact is left to the browser (not captured / preventDefault'd) + * so the page can scroll past the element; gestures engage only once a second finger lands. + * @member {Boolean} cooperativeGestureHandling + * @memberof OpenSeadragon.MouseTracker# + */ + this.cooperativeGestureHandling = options.cooperativeGestureHandling || false; + //Store private properties in a scope sealed hash map const _this = this; @@ -4123,6 +4190,7 @@ $.EventSource.prototype = { touchend: function ( event ) { onTouchEnd( _this, event ); }, touchmove: function ( event ) { onTouchMove( _this, event ); }, touchcancel: function ( event ) { onTouchCancel( _this, event ); }, + cooperativeTouchMove: function ( event ) { onCooperativeTouchMove( _this, event ); }, gesturestart: function ( event ) { onGestureStart( _this, event ); }, // Safari/Safari iOS gesturechange: function ( event ) { onGestureChange( _this, event ); }, // Safari/Safari iOS @@ -4224,6 +4292,34 @@ $.EventSource.prototype = { return this; }, + /** + * Enable or disable cooperative gesture handling at runtime, managing the non-passive + * touchmove listener's lifecycle (the rest of the cooperative logic reads the flag live). + * @function + * @param {Boolean} enabled + * @returns {OpenSeadragon.MouseTracker} Chainable. + */ + setCooperativeGestureHandling: function ( enabled ) { + enabled = !!enabled; + if ( enabled === this.cooperativeGestureHandling ) { + return this; + } + this.cooperativeGestureHandling = enabled; + + // Only the touchmove listener needs explicit add/remove. + // startTracking/stopTracking will just read the value set above. + const delegate = THIS[ this.hash ]; + if ( delegate && delegate.tracking ) { + if ( enabled ) { + $.addEvent( this.element, 'touchmove', delegate.cooperativeTouchMove, { passive: false, capture: false } ); + } else { + $.removeEvent( this.element, 'touchmove', delegate.cooperativeTouchMove, false ); + } + } + //chain + return this; + }, + /** * Returns the {@link OpenSeadragon.MouseTracker.GesturePointList|GesturePointList} for the given pointer device type, * creating and caching a new {@link OpenSeadragon.MouseTracker.GesturePointList|GesturePointList} if one doesn't already exist for the type. @@ -5104,7 +5200,10 @@ $.EventSource.prototype = { * Set to true to prevent this MouseTracker from generating a gesture from the event. * Valid on eventType "pointerdown". * @property {Boolean} stopPropagation - * Set to true prevent the event from propagating to ancestor/descendent elements on capture/bubble phase. + * Set to true to prevent the event from propagating to ancestor/descendent elements on the capture/bubble phase. + * @property {Boolean} stopImmediatePropagation + * Set to true to prevent any further listeners for this event from being invoked on the current target, + * and to stop the event from propagating any further in the event flow (no additional capture or bubble). * @property {Boolean} shouldCapture * (Internal Use) Set to true if the pointer should be captured (events (re)targeted to tracker element). * @property {Boolean} shouldReleaseCapture @@ -5360,6 +5459,13 @@ $.EventSource.prototype = { ); } + // In cooperative mode, also listen for touchmove non-passively so we can block the + // native two-finger page scroll that pointer events / touch-action can't reliably stop + // on iOS. (Single-finger touches are left alone so the page can still scroll.) + if ( tracker.cooperativeGestureHandling ) { + $.addEvent( tracker.element, 'touchmove', delegate.cooperativeTouchMove, { passive: false, capture: false } ); + } + clearTrackedPointers( tracker ); delegate.tracking = true; @@ -5385,6 +5491,10 @@ $.EventSource.prototype = { ); } + if ( tracker.cooperativeGestureHandling ) { + $.removeEvent( tracker.element, 'touchmove', delegate.cooperativeTouchMove, false ); + } + clearTrackedPointers( tracker ); delegate.tracking = false; @@ -5608,6 +5718,25 @@ $.EventSource.prototype = { // Device-specific DOM event handlers /////////////////////////////////////////////////////////////////////////////// + /** + * @private + * @inner + */ + function handlePropagation( eventInfo, event ) { + if ( eventInfo.isStoppable && event ) { + const canStopImmediate = typeof event.stopImmediatePropagation === 'function'; + const canStop = typeof event.stopPropagation === 'function'; + + if ( eventInfo.stopImmediatePropagation && canStopImmediate ) { + event.stopImmediatePropagation(); + } + else if ( eventInfo.stopPropagation && canStop ) { + event.stopPropagation(); + } + } + } + + /** * @private * @inner @@ -5626,9 +5755,8 @@ $.EventSource.prototype = { if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) { $.cancelEvent( event ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); - } + + handlePropagation( eventInfo, event ); } @@ -5650,9 +5778,8 @@ $.EventSource.prototype = { if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) { $.cancelEvent( event ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); - } + + handlePropagation( eventInfo, event ); } @@ -5691,9 +5818,8 @@ $.EventSource.prototype = { if ( ( eventArgs && eventArgs.preventDefault ) || ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) ) { $.cancelEvent( event ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); - } + + handlePropagation( eventInfo, event ); } @@ -5733,9 +5859,8 @@ $.EventSource.prototype = { if ( ( eventArgs && eventArgs.preventDefault ) || ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) ) { $.cancelEvent( event ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); - } + + handlePropagation( eventInfo, event ); } @@ -5775,9 +5900,8 @@ $.EventSource.prototype = { if ( ( eventArgs && eventArgs.preventDefault ) || ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) ) { $.cancelEvent( event ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); - } + + handlePropagation( eventInfo, event ); } @@ -5874,9 +5998,8 @@ $.EventSource.prototype = { if ( ( eventArgs && eventArgs.preventDefault ) || ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) ) { $.cancelEvent( event ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); - } + + handlePropagation( eventInfo, event ); } @@ -5968,9 +6091,8 @@ $.EventSource.prototype = { tracker.scrollHandler( eventArgs ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( originalEvent ); - } + handlePropagation( eventInfo, originalEvent ); + if ( ( eventArgs && eventArgs.preventDefault ) || ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) ) { $.cancelEvent( originalEvent ); } @@ -6002,9 +6124,7 @@ $.EventSource.prototype = { updatePointerCaptured( tracker, gPoint, false ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); - } + handlePropagation( eventInfo, event ); } @@ -6053,9 +6173,8 @@ $.EventSource.prototype = { if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) { $.cancelEvent( event ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); - } + + handlePropagation( eventInfo, event ); } @@ -6096,9 +6215,8 @@ $.EventSource.prototype = { if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) { $.cancelEvent( event ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); - } + + handlePropagation( eventInfo, event ); } @@ -6132,8 +6250,22 @@ $.EventSource.prototype = { if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) { $.cancelEvent( event ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); + + handlePropagation( eventInfo, event ); + } + + + /** + * Blocks the browser's native two-finger page scroll in cooperative gesture mode. Bound as a + * non-passive listener (called in startTracking) because preventDefault() + * reliably stops native two-finger scroll on iOS Safari, which seems to ignore mid-gesture + * touch-action changes. Single-finger touches are left alone so the page can still scroll. + * @private + * @inner + */ + function onCooperativeTouchMove( tracker, event ) { + if ( event.touches && event.touches.length >= 2 ) { + event.preventDefault(); } } @@ -6164,9 +6296,7 @@ $.EventSource.prototype = { updatePointerCancel( tracker, eventInfo, gPoint ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); - } + handlePropagation( eventInfo, event ); } @@ -6217,9 +6347,7 @@ $.EventSource.prototype = { }, true ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); - } + handlePropagation( eventInfo, event ); } @@ -6246,9 +6374,7 @@ $.EventSource.prototype = { }, false ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); - } + handlePropagation( eventInfo, event ); } @@ -6349,9 +6475,8 @@ $.EventSource.prototype = { if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) { $.cancelEvent( event ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); - } + + handlePropagation( eventInfo, event ); } @@ -6386,9 +6511,8 @@ $.EventSource.prototype = { if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) { $.cancelEvent( event ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); - } + + handlePropagation( eventInfo, event ); } @@ -6433,9 +6557,9 @@ $.EventSource.prototype = { if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) { $.cancelEvent( event ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); - } + + handlePropagation( eventInfo, event ); + if ( eventInfo.shouldCapture ) { if ( implicitlyCaptured ) { updatePointerCaptured( tracker, gPoint, true ); @@ -6508,9 +6632,8 @@ $.EventSource.prototype = { if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) { $.cancelEvent( event ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); - } + + handlePropagation( eventInfo, event ); // Per spec, pointerup events are supposed to release capture. Not all browser // versions have adhered to the spec, and there's no harm in releasing @@ -6587,9 +6710,8 @@ $.EventSource.prototype = { if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) { $.cancelEvent( event ); } - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); - } + + handlePropagation( eventInfo, event ); } @@ -6616,9 +6738,7 @@ $.EventSource.prototype = { //TODO need to only do this if our element is target? updatePointerCancel( tracker, eventInfo, gPoint ); - if ( eventInfo.stopPropagation ) { - $.stopEvent( event ); - } + handlePropagation( eventInfo, event ); } @@ -6668,7 +6788,12 @@ $.EventSource.prototype = { if ( trackedGPoint ) { if ( trackedGPoint.captured ) { - $.console.warn('stopTrackingPointer() called on captured pointer'); + // In cooperative mode the browser legitimately cancels pointers the viewer has + // captured (it shares the touch stream to scroll the page), so reaching here is + // expected and not worth warning about, but we still release to keep state clean. + if ( !tracker.cooperativeGestureHandling ) { + $.console.warn('stopTrackingPointer() called on captured pointer'); + } releasePointer( tracker, trackedGPoint ); } @@ -6692,13 +6817,15 @@ $.EventSource.prototype = { * @inner */ function getEventProcessDefaults( tracker, eventInfo ) { + eventInfo.stopPropagation = false; + eventInfo.stopImmediatePropagation = false; + switch ( eventInfo.eventType ) { case 'pointermove': eventInfo.isStoppable = true; eventInfo.isCancelable = true; eventInfo.preventDefault = false; eventInfo.preventGesture = !tracker.hasGestureHandlers; - eventInfo.stopPropagation = false; break; case 'pointerover': case 'pointerout': @@ -6710,28 +6837,24 @@ $.EventSource.prototype = { eventInfo.isCancelable = true; eventInfo.preventDefault = false; // onContextMenu(), onKeyDown(), onKeyUp(), onKeyPress() may set true eventInfo.preventGesture = false; - eventInfo.stopPropagation = false; break; case 'pointerdown': eventInfo.isStoppable = true; eventInfo.isCancelable = true; eventInfo.preventDefault = false; // updatePointerDown() may set true (tracker.hasGestureHandlers) eventInfo.preventGesture = !tracker.hasGestureHandlers; - eventInfo.stopPropagation = false; break; case 'pointerup': eventInfo.isStoppable = true; eventInfo.isCancelable = true; eventInfo.preventDefault = false; eventInfo.preventGesture = !tracker.hasGestureHandlers; - eventInfo.stopPropagation = false; break; case 'wheel': eventInfo.isStoppable = true; eventInfo.isCancelable = true; eventInfo.preventDefault = false; // handleWheelEvent() may set true eventInfo.preventGesture = !tracker.hasScrollHandler; - eventInfo.stopPropagation = false; break; case 'gotpointercapture': case 'lostpointercapture': @@ -6740,21 +6863,18 @@ $.EventSource.prototype = { eventInfo.isCancelable = false; eventInfo.preventDefault = false; eventInfo.preventGesture = false; - eventInfo.stopPropagation = false; break; case 'click': eventInfo.isStoppable = true; eventInfo.isCancelable = true; eventInfo.preventDefault = !!tracker.clickHandler; eventInfo.preventGesture = false; - eventInfo.stopPropagation = false; break; case 'dblclick': eventInfo.isStoppable = true; eventInfo.isCancelable = true; eventInfo.preventDefault = !!tracker.dblClickHandler; eventInfo.preventGesture = false; - eventInfo.stopPropagation = false; break; case 'focus': case 'blur': @@ -6765,7 +6885,6 @@ $.EventSource.prototype = { eventInfo.isCancelable = false; eventInfo.preventDefault = false; eventInfo.preventGesture = false; - eventInfo.stopPropagation = false; break; } } @@ -6834,7 +6953,9 @@ $.EventSource.prototype = { $.console.warn('updatePointerCaptured() - pointsList.captureCount went negative'); } } - } else { + } else if ( !tracker.cooperativeGestureHandling ) { + // Expected in cooperative mode: a capture/release event can arrive for a pointer the + // browser already cancelled (and we stopped tracking) during the page-scroll handoff. $.console.warn('updatePointerCaptured() called on untracked pointer'); } } @@ -7137,9 +7258,18 @@ $.EventSource.prototype = { //$.console.log('contacts++ ', pointsList.contacts); if ( !eventInfo.preventGesture && !eventInfo.defaultPrevented ) { - eventInfo.shouldCapture = true; - eventInfo.shouldReleaseCapture = false; - eventInfo.preventDefault = true; + // cooperativeGestureHandling: + // leave a single touch contact to the browser (so the page can scroll past the viewer). + // Only engage once a second finger lands, at which point the gesture becomes a pinch. + const deferToBrowser = tracker.cooperativeGestureHandling && + gPoint.type === 'touch' && + pointsList.contacts < 2; + + if ( !deferToBrowser ) { + eventInfo.shouldCapture = true; + eventInfo.shouldReleaseCapture = false; + eventInfo.preventDefault = true; + } if ( tracker.dragHandler || tracker.dragEndHandler || tracker.pinchHandler ) { $.MouseTracker.gesturePointVelocityTracker.addPoint( tracker, gPoint ); @@ -7162,6 +7292,18 @@ $.EventSource.prototype = { } } else if ( pointsList.contacts === 2 ) { if ( tracker.pinchHandler && gPoint.type === 'touch' ) { + // cooperativeGestureHandling: + // The first finger was left uncaptured so the page could scroll; now that a + // second finger has started the pinch, capture any uncaptured contact so the + // viewer keeps receiving its events for the rest of the gesture. + if ( tracker.cooperativeGestureHandling ) { + const capturePoints = pointsList.asArray(); + for ( let p = 0; p < capturePoints.length; p++ ) { + if ( !capturePoints[ p ].captured ) { + capturePointer( tracker, capturePoints[ p ] ); + } + } + } // Initialize for pinch delegate.pinchGPoints = pointsList.asArray(); delegate.lastPinchDist = delegate.currentPinchDist = delegate.pinchGPoints[ 0 ].currentPos.distanceTo( delegate.pinchGPoints[ 1 ].currentPos ); @@ -7517,7 +7659,11 @@ $.EventSource.prototype = { userData: tracker.userData } ); - eventInfo.preventDefault = true; + // In cooperative mode a single touch is left to the browser, so don't + // preventDefault its move or we'd block the native page scroll. + if ( !( tracker.cooperativeGestureHandling && updateGPoint.type === 'touch' ) ) { + eventInfo.preventDefault = true; + } delegate.sentDragEvent = true; } } else if ( pointsList.contacts === 2 ) { @@ -8485,6 +8631,12 @@ $.Viewer = function( options ) { _this._showMessage( msg ); }); + // Cooperative gesture handling is suspended in full-page/fullscreen, so re-apply the touch-action + // and tracker config whenever full-page mode changes. + this.addHandler( 'full-page', function () { + _this._updateCooperativeGestureHandling(); + }); + $.ControlDock.call( this, options ); //Deal with tile sources @@ -8518,7 +8670,8 @@ $.Viewer = function( options ) { style.top = "0px"; style.left = "0px"; }(this.canvas.style)); - $.setElementTouchActionNone( this.canvas ); + // touch-action on the canvas (and container below) is applied by + // _updateCooperativeGestureHandling() once the inner tracker exists. if (options.tabIndex !== "") { this.canvas.tabIndex = (options.tabIndex === undefined ? 0 : options.tabIndex); } @@ -8534,7 +8687,6 @@ $.Viewer = function( options ) { style.top = "0px"; style.textAlign = "left"; // needed to protect against }( this.container.style )); - $.setElementTouchActionNone( this.container ); this.container.insertBefore( this.canvas, this.container.firstChild ); this.element.appendChild( this.container ); @@ -8549,6 +8701,7 @@ $.Viewer = function( options ) { this.innerTracker = new $.MouseTracker({ userData: 'Viewer.innerTracker', element: this.canvas, + cooperativeGestureHandling: this._isCooperative, startDisabled: !this.mouseNavEnabled, clickTimeThreshold: this.clickTimeThreshold, clickDistThreshold: this.clickDistThreshold, @@ -8574,6 +8727,10 @@ $.Viewer = function( options ) { blurHandler: $.delegate( this, onCanvasBlur ), }); + // Apply the initial cooperative gesture config (canvas/container touch-action + inner tracker) + // now that the tracker exists. Single source of truth, also used on full-page change and toggle. + this._updateCooperativeGestureHandling(); + this.outerTracker = new $.MouseTracker({ userData: 'Viewer.outerTracker', element: this.container, @@ -8586,7 +8743,7 @@ $.Viewer = function( options ) { leaveHandler: $.delegate( this, onContainerLeave ) }); - if( this.toolbar ){ + if ( this.toolbar ){ this.toolbar = new $.ControlDock({ element: this.toolbar }); } @@ -8594,9 +8751,9 @@ $.Viewer = function( options ) { THIS[ this.hash ].prevContainerSize = _getSafeElemSize( this.container ); - if(window.ResizeObserver){ + if (window.ResizeObserver) { this._autoResizePolling = false; - this._resizeObserver = new ResizeObserver(function(){ + this._resizeObserver = new ResizeObserver(function() { THIS[_this.hash].needsResize = true; }); @@ -8812,12 +8969,21 @@ $.Viewer = function( options ) { displayRegionColor: this.navigatorDisplayRegionColor, crossOriginPolicy: this.crossOriginPolicy, animationTime: this.animationTime, - drawer: this.drawer.getType(), + drawer: this.navigatorDrawer || options.drawer, drawerOptions: this.drawerOptions, loadTilesWithAjax: this.loadTilesWithAjax, ajaxHeaders: this.ajaxHeaders, ajaxWithCredentials: this.ajaxWithCredentials, }); + + this.navigator.addOnceHandler('drawer-error', (event) => { + $.console.warn( + 'OpenSeadragon navigator drawer error: ' + event.error + + ' If tiles are cross-origin, set crossOriginPolicy: "Anonymous" or ' + + '"use-credentials" (requires CORS headers on the tile server), or set ' + + 'navigatorDrawer: "canvas" to use canvas explicitly.' + ); + }); } // Sequence mode @@ -9253,6 +9419,11 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, this.paging.destroy(); } + // Tear down the cooperative gesture hint overlay + // (the element is removed with the container below) + clearTimeout( this._cooperativeOverlayTimeout ); + this.cooperativeOverlay = null; + // Remove both the canvas and container elements added by OpenSeadragon // This will also remove its children (like the canvas) if (this.container && this.container.parentNode === this.element) { @@ -9347,7 +9518,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, let supported = false; if (Drawer) { try { - supported = Drawer.isSupported(); + supported = Drawer.isSupported(drawerOptions || this.drawerOptions[drawerCandidate]); } catch (e) { $.console.warn('Error in %s isSupported(); treating this drawer as unsupported:', drawerCandidate, e && e.message ? e.message : e); } @@ -9355,7 +9526,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, if (supported) { // if the drawer is supported, create it and return it. // first destroy the previous drawer - if(oldDrawer && mainDrawer){ + if (oldDrawer && mainDrawer){ oldDrawer.destroy(); } @@ -9970,7 +10141,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, * @property {Boolean} [options.zombieCache] In the case that this method removes any TiledImage instance, * allow the item-referenced cache to remain in memory even without active tiles. Default false. * @property {Number} [options.degrees=0] Initial rotation of the tiled image around - * its top left corner in degrees. + * its center in degrees. * @property {Boolean} [options.flipped=false] Whether to horizontally flip the image. * @property {String} [options.compositeOperation] How the image is composited onto other images. * @property {String} [options.crossOriginPolicy] The crossOriginPolicy for this specific image, @@ -10205,6 +10376,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, blendTime: this.blendTime, alwaysBlend: this.alwaysBlend, minPixelRatio: this.minPixelRatio, + discardLevelsBelowDownsampleRatio: this.discardLevelsBelowDownsampleRatio, smoothTileEdgesMinZoom: this.smoothTileEdgesMinZoom, iOSDevice: this.iOSDevice, crossOriginPolicy: options.crossOriginPolicy, @@ -11057,6 +11229,129 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, } }, + // Whether cooperative gesture handling currently applies. + // Single source of truth for the gesture guards so the condition stays consistent across handlers. + // Suspended in full-page/fullscreen, where there's no surrounding page to scroll past. + get _isCooperative() { + return this.cooperativeGestures && !this.isFullPage(); + }, + + /** + * Enable or disable cooperative gesture handling at runtime. + * @function + * @param {Boolean} enabled + * @returns {OpenSeadragon.Viewer} Chainable. + */ + setCooperativeGestures: function ( enabled ) { + this.cooperativeGestures = !!enabled; + this._updateCooperativeGestureHandling(); + return this; + }, + + // Single source of truth for applying cooperative gesture config from the current state. + // Used during construction, on full-page change, and on runtime toggle. + // When active, `pan-x pan-y` lets the browser scroll the page on a one-finger touch, + // while two-finger gestures fall through to OSD; otherwise the viewer captures all touches (`none`). + // The container is an ancestor of the canvas and touch-action is intersected up the ancestor chain, so both must be set. + _updateCooperativeGestureHandling: function () { + const active = this._isCooperative; + const touchAction = active ? 'pan-x pan-y' : 'none'; + $.setElementTouchAction( this.canvas, touchAction ); + $.setElementTouchAction( this.container, touchAction ); + this.innerTracker.setCooperativeGestureHandling( active ); + }, + + // private + _raiseCooperativeGestureEvent: function( gesture, event ) { + let message; + if ( gesture === 'scroll' ) { + const modifier = /Mac/i.test( navigator.platform || navigator.userAgent || '' ) ? '⌘' : 'Ctrl'; + message = $.getString( 'GestureHints.Scroll', modifier ); + } else { + message = $.getString( 'GestureHints.Touch' ); + } + + const cooperativeGestureArgs = { + eventSource: this, + tracker: event.eventSource, + pointerType: event.pointerType, + gesture: gesture, + position: event.position, + message: message, + originalEvent: event.originalEvent, + preventDefaultAction: false + }; + + /** + * Raised when a gesture is blocked by cooperative gesture handling. + * + * @event canvas-cooperative-gesture + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event. + * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event. + * @property {String} pointerType - "mouse", "touch", "pen", etc. + * @property {String} gesture - The blocked gesture: "drag" (one-finger touch) or "scroll" (mouse wheel). + * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element. + * @property {String} message - The default hint text; change this to customise the built-in hint. + * @property {Object} originalEvent - The original DOM event. + * @property {Boolean} preventDefaultAction - Set to true to suppress the built-in hint. Default: false. + * @property {?Object} userData - Arbitrary subscriber-defined object. + */ + this.raiseEvent( 'canvas-cooperative-gesture', cooperativeGestureArgs ); + + // Return args to allow modifying message and behaviour + return cooperativeGestureArgs; + }, + + /** + * Shows the cooperative gesture hint overlay with the given text, fading it back out after a + * short delay. + * + * The overlay is added as a sibling of the canvas with pointer-events:none, so it never + * interferes with the gesture it's hinting about. + * @function OpenSeadragon.Viewer.prototype._showCooperativeMessage + * @private + * @param {String} hintText - The hint text to display. Defaults to i18n UI strings, but users can hook into this. + */ + _showCooperativeMessage: function ( hintText ) { + if ( !this.cooperativeOverlay ) { + + this.cooperativeOverlay = $.makeNeutralElement( "div" ); + + // Hook for custom styles + this.cooperativeOverlay.className = "openseadragon-cooperative-overlay"; + + this.cooperativeOverlay.setAttribute( "aria-hidden", "true" ); + + const overlayStyle = this.cooperativeOverlay.style; + overlayStyle.position = "absolute"; + overlayStyle.top = overlayStyle.left = overlayStyle.right = overlayStyle.bottom = "0"; + overlayStyle.display = "grid"; + overlayStyle.placeItems = "center"; + overlayStyle.textAlign = "center"; + overlayStyle.padding = "24px"; + overlayStyle.color = "#fff"; + overlayStyle.background = "rgba(0, 0, 0, 0.5)"; + // Allow pass-thru on all pointer events + overlayStyle.pointerEvents = "none"; + overlayStyle.opacity = "0"; + overlayStyle.transition = "opacity 0.3s ease"; + + this.cooperativeOverlay.appendChild( $.makeNeutralElement( "div" ) ); + this.container.appendChild( this.cooperativeOverlay ); + } + + this.cooperativeOverlay.firstChild.textContent = hintText; + this.cooperativeOverlay.style.opacity = "1"; + + // Reset fade-out timer + clearTimeout( this._cooperativeOverlayTimeout ); + this._cooperativeOverlayTimeout = setTimeout( () => { + this.cooperativeOverlay.style.opacity = "0"; + }, 1500 ); + }, + // private _drawOverlays: function() { const length = this.currentOverlays.length; @@ -11491,10 +11786,10 @@ function getActiveActionFromKey(code, shift) { /** * Handles the keyup event on the viewer's canvas element. * - * @private * For the released key, marks both the shifted and non-shifted navigation actions as inactive in the _activeActions object. * If either action is released before reaching the minimum frame threshold, sets that action as "virtually held" in _navActionVirtuallyHeld, * ensuring smooth completion of the minimum pan or zoom distance regardless of modifier key release order. + * @private */ function onCanvasKeyUp(event) { @@ -11763,13 +12058,18 @@ function onCanvasDrag( event ) { gestureSettings = this.gestureSettingsByDeviceType( event.pointerType ); + // In cooperative gesture mode a one-finger touch drag should scroll the surrounding + // page rather than pan the image, so we skip the pan for single touches. Two-finger + // gestures are routed to the pinch handler and are left untouched. + const cooperativeTouchDrag = this._isCooperative && event.pointerType === 'touch'; + if(!canvasDragEventArgs.preventDefaultAction && this.viewport){ if (gestureSettings.dblClickDragToZoom && THIS[ this.hash ].draggingToZoom){ const factor = Math.pow( this.zoomPerDblClickDrag, event.delta.y / 50); this.viewport.zoomBy(factor); } - else if (gestureSettings.dragToPan && !THIS[ this.hash ].draggingToZoom) { + else if (gestureSettings.dragToPan && !THIS[ this.hash ].draggingToZoom && !cooperativeTouchDrag) { if( !this.panHorizontal ){ event.delta.x = 0; } @@ -11802,6 +12102,19 @@ function onCanvasDrag( event ) { this.viewport.panBy( this.viewport.deltaPointsFromPixels( event.delta.negate() ), gestureSettings.flickEnabled && !this.constrainDuringPan); } + // The one-finger pan was suppressed in cooperative mode; notify the app once per gesture + // (not on every move). The flag is reset at the start of the next gesture in onCanvasPress. + if ( cooperativeTouchDrag && gestureSettings.dragToPan && !THIS[ this.hash ].draggingToZoom && + !THIS[ this.hash ].cooperativeGestureActive ) { + THIS[ this.hash ].cooperativeGestureActive = true; + + // Raise the event to allow the app to modify the message or suppress it entirely + const cooperativeArgs = this._raiseCooperativeGestureEvent( 'drag', event ); + if ( !cooperativeArgs.preventDefaultAction ) { + this._showCooperativeMessage( cooperativeArgs.message ); + } + } + } } @@ -11840,11 +12153,15 @@ function onCanvasDragEnd( event ) { gestureSettings = this.gestureSettingsByDeviceType( event.pointerType ); + // Match onCanvasDrag: in cooperative mode a one-finger touch shouldn't fling the image. + const cooperativeTouchDrag = this._isCooperative && event.pointerType === 'touch'; + if (!canvasDragEndEventArgs.preventDefaultAction && this.viewport) { if ( !THIS[ this.hash ].draggingToZoom && gestureSettings.dragToPan && gestureSettings.flickEnabled && - event.speed >= gestureSettings.flickMinSpeed) { + event.speed >= gestureSettings.flickMinSpeed && + !cooperativeTouchDrag) { let amplitudeX = 0; if (this.panHorizontal) { amplitudeX = gestureSettings.flickMomentum * event.speed * @@ -11869,7 +12186,6 @@ function onCanvasDragEnd( event ) { THIS[ this.hash ].draggingToZoom = false; } - } function onCanvasEnter( event ) { @@ -11944,8 +12260,6 @@ function onCanvasPress( event ) { * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event. * @property {String} pointerType - "mouse", "touch", "pen", etc. * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element. - * @property {Boolean} insideElementPressed - True if the left mouse button is currently being pressed and was initiated inside the tracked element, otherwise false. - * @property {Boolean} insideElementReleased - True if the cursor still inside the tracked element when the button was released. * @property {Object} originalEvent - The original DOM event. * @property {?Object} userData - Arbitrary subscriber-defined object. */ @@ -11953,11 +12267,13 @@ function onCanvasPress( event ) { tracker: event.eventSource, pointerType: event.pointerType, position: event.position, - insideElementPressed: event.insideElementPressed, - insideElementReleased: event.insideElementReleased, originalEvent: event.originalEvent }); + // Reset the once-per-gesture cooperative hint guard at the start of each gesture, so it shows + // again next time regardless of how the previous gesture ended (a one-finger drag handing off to + // a page scroll ends via pointer cancel, which fires neither drag-end nor release). + THIS[ this.hash ].cooperativeGestureActive = false; const gestureSettings = this.gestureSettingsByDeviceType( event.pointerType ); if ( gestureSettings.dblClickDragToZoom ){ @@ -12186,6 +12502,12 @@ function onCanvasScroll( event ) { let gestureSettings; let factor; + // cooperativeGestures: + // Mouse wheel zooms only while Ctrl/Cmd is held; without a modifier we let the browser scroll the page past the viewer instead + const allowPageScroll = this._isCooperative && + !event.originalEvent.ctrlKey && + !event.originalEvent.metaKey; + /* Certain scroll devices fire the scroll event way too fast so we are injecting a simple adjustment to keep things * partially normalized. If we have already fired an event within the last 'minScrollDelta' milliseconds we skip * this one and wait for the next event. */ @@ -12201,7 +12523,7 @@ function onCanvasScroll( event ) { shift: event.shift, originalEvent: event.originalEvent, preventDefaultAction: false, - preventDefault: true + preventDefault: !allowPageScroll }; /** @@ -12217,7 +12539,7 @@ function onCanvasScroll( event ) { * @property {Boolean} shift - True if the shift key was pressed during this event. * @property {Object} originalEvent - The original DOM event. * @property {Boolean} preventDefaultAction - Set to true to prevent default scroll to zoom behaviour. Default: false. - * @property {Boolean} preventDefault - Set to true to prevent the default user-agent's handling of the wheel event. Default: true. + * @property {Boolean} preventDefault - Set to true to prevent the default user-agent's handling of the wheel event. Default: true (false in cooperative mode when no Ctrl/Cmd modifier is held, so the page can scroll). * @property {?Object} userData - Arbitrary subscriber-defined object. */ this.raiseEvent('canvas-scroll', canvasScrollEventArgs ); @@ -12229,18 +12551,26 @@ function onCanvasScroll( event ) { gestureSettings = this.gestureSettingsByDeviceType( event.pointerType ); if ( gestureSettings.scrollToZoom ) { - factor = Math.pow( this.zoomPerScroll, event.scroll ); - this.viewport.zoomBy( - factor, - gestureSettings.zoomToRefPoint ? this.viewport.pointFromPixel( event.position, true ) : null - ); - this.viewport.applyConstraints(); + if ( allowPageScroll ) { + // Raise the event to allow the app to modify the message or suppress it entirely + const cooperativeArgs = this._raiseCooperativeGestureEvent( 'scroll', event ); + if ( !cooperativeArgs.preventDefaultAction ) { + this._showCooperativeMessage( cooperativeArgs.message ); + } + } else { + factor = Math.pow( this.zoomPerScroll, event.scroll ); + this.viewport.zoomBy( + factor, + gestureSettings.zoomToRefPoint ? this.viewport.pointFromPixel( event.position, true ) : null + ); + this.viewport.applyConstraints(); + } } } event.preventDefault = canvasScrollEventArgs.preventDefault; } else { - event.preventDefault = true; + event.preventDefault = !allowPageScroll; } } @@ -12328,7 +12658,7 @@ function updateMulti( viewer ) { } } -function doViewerResize(viewer, containerSize){ +function doViewerResize(viewer, containerSize) { const viewport = viewer.viewport; const zoom = viewport.getZoom(); const center = viewport.getCenter(); @@ -12421,24 +12751,21 @@ function updateOnce( viewer ) { } let viewerWasResized = false; - if (viewer.autoResize || THIS[viewer.hash].forceResize){ + if (viewer.autoResize || THIS[viewer.hash].forceResize) { let containerSize; - if(viewer._autoResizePolling){ + if (viewer._autoResizePolling) { containerSize = _getSafeElemSize(viewer.container); const prevContainerSize = THIS[viewer.hash].prevContainerSize; if (!containerSize.equals(prevContainerSize)) { THIS[viewer.hash].needsResize = true; } } - if(THIS[viewer.hash].needsResize){ + if (THIS[viewer.hash].needsResize) { doViewerResize(viewer, containerSize || _getSafeElemSize(viewer.container)); viewerWasResized = true; } - } - - const viewportChange = viewer.viewport.update() || viewerWasResized; let animated = viewer.world.update(viewportChange) || viewportChange; @@ -13152,6 +13479,33 @@ $.extend( $.Navigator.prototype, $.EventSource.prototype, $.Viewer.prototype, /* return $.Viewer.prototype.destroy.apply(this); }, + /** + * Controls the visibility of the navigator element. + * @function + * @param {Boolean} visible - True to show the navigator, false to hide it. + * @return {OpenSeadragon.Navigator} Chainable. + */ + setVisible: function (visible) { + if (this.element) { + if (visible) { + this.element.style.display = this._previousDisplayStyle || ''; + this._previousDisplayStyle = undefined; + + if (this.viewport) { + this.updateSize(); + this.update(this.viewer.viewport); + } + } else { + this._previousDisplayStyle = this.element.style.display; + this.element.style.display = 'none'; + } + } else { + $.console.warn("[OpenSeadragon.Navigator.setVisible] Navigator element is not defined."); + } + + return $.Viewer.prototype.setVisible.apply(this, [visible]); +}, + // private _getMatchingItem: function(theirItem) { const count = this.world.getItemCount(); @@ -13424,6 +13778,11 @@ const I18N = { RotateLeft: "Rotate left", RotateRight: "Rotate right", Flip: "Flip Horizontally" + }, + + GestureHints: { + Touch: "Use two fingers to pan the image", + Scroll: "Use {0} + scroll to zoom the image" } }; @@ -13885,6 +14244,12 @@ $.TileSource = function( options ) { * @member {Boolean} ready * @memberof OpenSeadragon.TileSource# */ + /** + * Discard levels until reaching in-between accepted level below the downsample ratio. Overrides + * the global option value. + * @member {Number} [discardLevelsBelowDownsampleRatio=undefined] + * @memberof OpenSeadragon.TileSource# + */ this.addHandler('ready', e => { const source = e.tileSource; @@ -15244,6 +15609,23 @@ function configureFromObject( tileSource, configuration ){ * @see http://iiif.io/api/image/ * @param {String} [options.tileFormat='jpg'] * The extension that will be used when requiring tiles. + * @param {String} [options.tileQuality] + * The IIIF quality to request for each tile. The Image API spec defines + * 'native', 'color', 'grey' and 'bitonal' for version 1.x, and 'default', + * 'color', 'gray' and 'bitonal' for versions 2.x and 3.x. Servers may + * advertise additional qualities via the info.json profile (v2) or + * extraQualities (v3); these are accepted without warning. Unknown + * values produce a console warning but are still sent to the server. + * Defaults to 'native' for 1.x and 'default' for 2.x and 3.x. + * @see https://iiif.io/api/image/3.0/#quality + * @param {String[]} [options.extraQualities] + * Additional quality values the server supports beyond the IIIF Image + * API 3.x spec defaults. Normally populated automatically from the + * server's info.json `extraQualities` field, but may also be passed + * explicitly. Values listed here are treated as known qualities and + * will not trigger the unknown quality warning when used as + * `tileQuality`. + * @see https://iiif.io/api/image/3.0/#53-extra-functionality */ $.IIIFTileSource = function( options ){ @@ -15264,6 +15646,10 @@ $.IIIFTileSource = function( options ){ this.version = options.version; + if ( this.tileQuality ) { + warnIfUnknownQuality( this.tileQuality, this.version, options ); + } + this.isLevel0 = checkLevel0( options ); // N.B. 2.0 renamed scale_factors to scaleFactors @@ -15684,11 +16070,15 @@ $.extend( $.IIIFTileSource.prototype, $.TileSource.prototype, /** @lends OpenSea tileHeight = this.getTileHeight(level); iiifTileSizeWidth = Math.round( tileWidth / scale ); iiifTileSizeHeight = Math.round( tileHeight / scale ); - if (this.version === 1) { - iiifQuality = "native." + this.tileFormat; + let quality; + if ( this.tileQuality ) { + quality = this.tileQuality; + } else if ( this.version === 1 ) { + quality = "native"; } else { - iiifQuality = "default." + this.tileFormat; + quality = "default"; } + iiifQuality = quality + "." + this.tileFormat; if ( levelWidth < tileWidth && levelHeight < tileHeight ){ if ( this.version === 2 && levelWidth === this.width ) { iiifSize = "full"; @@ -15792,11 +16182,13 @@ $.extend( $.IIIFTileSource.prototype, $.TileSource.prototype, /** @lends OpenSea */ function constructLevels(options) { const levels = []; + const quality = options.tileQuality || + (options.version === 1 ? 'native' : 'default'); for(let i = 0; i < options.sizes.length; i++) { levels.push({ url: options._id + '/full/' + options.sizes[i].width + ',' + (options.version === 3 ? options.sizes[i].height : '') + - '/0/default.' + options.tileFormat, + '/0/' + quality + '.' + options.tileFormat, width: options.sizes[i].width, height: options.sizes[i].height }); @@ -15807,6 +16199,52 @@ $.extend( $.IIIFTileSource.prototype, $.TileSource.prototype, /** @lends OpenSea } + /** + * Collect IIIF qualities the server may accept for a given info.json. + * Combines the spec-defined set for the API version with any qualities + * advertised by the server (profile.qualities for v2, extraQualities for v3). + * @function + * @param {Number} version + * @param {Object} options - the info.json data + * @returns {String[]} + */ + function getKnownQualities ( version, options ) { + const specQualities = version === 1 ? + [ 'native', 'color', 'grey', 'bitonal' ] : + [ 'default', 'color', 'gray', 'bitonal' ]; + const advertised = []; + if ( version === 2 && Array.isArray(options.profile) ) { + for ( let i = 1; i < options.profile.length; i++ ) { + const entry = options.profile[i]; + if ( entry && Array.isArray(entry.qualities) ) { + advertised.push.apply(advertised, entry.qualities); + } + } + } + if ( version === 3 && Array.isArray(options.extraQualities) ) { + advertised.push.apply(advertised, options.extraQualities); + } + return specQualities.concat(advertised); + } + + /** + * Emit a console warning if tileQuality is not in the set of known + * qualities for this server. Does not throw; the value is still used. + * @function + */ + function warnIfUnknownQuality ( quality, version, options ) { + const known = getKnownQualities(version, options); + if ( known.indexOf(quality) === -1 ) { + $.console.warn( + "[IIIFTileSource] tileQuality '%s' is not in the set of " + + "qualities known for this image (%s). The request will still " + + "be sent; the server may reject it.", + quality, + known.join(', ') + ); + } + } + function configureFromXml10(xmlDoc) { //parse the xml if ( !xmlDoc || !xmlDoc.documentElement ) { @@ -16238,7 +16676,7 @@ $.extend( $.IIIFTileSource.prototype, $.TileSource.prototype, /** @lends OpenSea } }; - $.extend($.IrisTileSource.prototype, $.TileSource.prototype, { + $.extend($.IrisTileSource.prototype, $.TileSource.prototype, /** @lends OpenSeadragon.IrisTileSource.prototype */{ /** * Return URL string for image metadata * @function @@ -17465,13 +17903,11 @@ const OpenSeadragon = $; // alias for JSDoc /** * @class OpenSeadragon.PriorityQueue * @classdesc Fast priority queue. Implemented as a Heap. + * @param {?OpenSeadragon.PriorityQueue} optHeap Optional Heap + * to initialize heap with. */ OpenSeadragon.PriorityQueue = class PriorityQueue { - /** - * @param {?OpenSeadragon.PriorityQueue} optHeap Optional Heap or - * Object to initialize heap with. - */ constructor(optHeap = undefined) { /** * The nodes of the heap. @@ -17514,7 +17950,7 @@ OpenSeadragon.PriorityQueue = class PriorityQueue { } /** - * Adds multiple key-value pairs from another Heap or Object + * Adds multiple key-value pairs from another Heap * @param {?OpenSeadragon.PriorityQueue} heap Object containing the data to add. */ insertAll(heap) { @@ -18276,7 +18712,6 @@ OpenSeadragon.DataTypeConverter = class DataTypeConverter { * Note: although we try to implement the type guessing, do * not rely on this functionality! Prefer explicit type declaration. * - * @function guessType * @param x object to get unique identifier for * - can be array, in that case, alphabetically-ordered list of inner unique types * is returned (null, undefined are ignored) @@ -20830,7 +21265,7 @@ function transform( stiffness, x ) { * @param {String} [options.src] - URL of image to download. * @param {Tile} [options.tile] - Tile that belongs the data to. * @param {TileSource} [options.source] - Image loading strategy - * @param {String} [options.loadWithAjax] - Whether to load this image with AJAX. + * @param {Boolean} [options.loadWithAjax] - Whether to load this image with AJAX. * @param {String} [options.ajaxHeaders] - Headers to add to the image request if using AJAX. * @param {Boolean} [options.ajaxWithCredentials] - Whether to set withCredentials on AJAX requests. * @param {String} [options.crossOriginPolicy] - CORS policy to use for downloads @@ -21192,7 +21627,7 @@ $.ImageLoader.prototype = { * @param {String} [options.src] - URL of image to download. * @param {Tile} [options.tile] - Tile that belongs the data to. The tile instance * is not internally used and serves for custom TileSources implementations. - * @param {String} [options.loadWithAjax] - Whether to load this image with AJAX. + * @param {Boolean} [options.loadWithAjax] - Whether to load this image with AJAX. * @param {String} [options.ajaxHeaders] - Headers to add to the image request if using AJAX. * @param {String|Boolean} [options.crossOriginPolicy] - CORS policy to use for downloads * @param {String} [options.postData] - POST parameters (usually but not necessarily in k=v&k2=v2... form, @@ -21991,7 +22426,7 @@ $.Tile.prototype = { dataType: type, tile: this, cacheKey: key, - cutoff: tiledImage.source.getClosestLevel(), + cutoff: tiledImage.savedCutOffLevel, }); const havingRecord = this._caches[key]; if (havingRecord !== cachedItem) { @@ -22943,10 +23378,14 @@ OpenSeadragon.DrawerBase = class DrawerBase { /** * @abstract - * @returns {Boolean} Whether the drawer implementation is supported by the browser. Must be overridden by extending classes. + * @param {OpenSeadragon.BaseDrawerOptions} [options] Options, if available. + * For details please see {@link OpenSeadragon.DrawerOptions}. + * @returns {Boolean} Whether the drawer implementation is supported by the browser. + * Must be overridden by extending classes. */ - static isSupported() { + static isSupported(options = undefined) { $.console.error('Drawer.isSupported must be implemented by child class'); + return false; } /** @@ -23324,9 +23763,10 @@ class HTMLDrawer extends OpenSeadragon.DrawerBase{ } /** + * @param {Object} options - Options for this drawer. * @returns {Boolean} always true */ - static isSupported() { + static isSupported(options) { return true; } @@ -23419,7 +23859,7 @@ class HTMLDrawer extends OpenSeadragon.DrawerBase{ } // Iterate over the tiles to draw, and draw them - for (let i = lastDrawn.length - 1; i >= 0; i--) { + for (let i = 0; i < lastDrawn.length; i++) { const tile = lastDrawn[ i ]; this._drawTile( tile ); @@ -23587,9 +24027,10 @@ class CanvasDrawer extends OpenSeadragon.DrawerBase{ } /** + * @param {Object} options - Options for this drawer. * @returns {Boolean} true if canvas is supported by the browser, otherwise false */ - static isSupported(){ + static isSupported(options){ return $.supportsCanvas; } @@ -23878,7 +24319,7 @@ class CanvasDrawer extends OpenSeadragon.DrawerBase{ } usedClip = true; } - tiledImage._hasOpaqueTile = false; + if ( tiledImage.placeholderFillStyle && tiledImage._hasOpaqueTile === false ) { let placeholderRect = this.viewportToDrawerRectangle(tiledImage.getBoundsNoRotate(true)); if (sketchScale) { @@ -24051,6 +24492,9 @@ class CanvasDrawer extends OpenSeadragon.DrawerBase{ context.save(); + const opacity = context.globalAlpha * tile.opacity; + context.globalAlpha = opacity; + if (typeof scale === 'number' && scale !== 1) { // draw tile at a different scale position = position.times(scale); @@ -24066,7 +24510,7 @@ class CanvasDrawer extends OpenSeadragon.DrawerBase{ //ie its done fading or fading is turned off, and if we are drawing //an image with an alpha channel, then the only way //to avoid seeing the tile underneath is to clear the rectangle - if (context.globalAlpha === 1 && tile.hasTransparency) { + if (opacity === 1 && tile.hasTransparency) { if (shouldRoundPositionAndSize) { // Round to the nearest whole pixel so we don't get seams from overlap. position.x = Math.round(position.x); @@ -25298,9 +25742,10 @@ function determineSubPixelRoundingRule(subPixelRoundingRules) { * Functional test: true if WebGL is supported and the real first-pass shader pipeline * can render (same shaders/context path used at runtime). Uses a temp context and * WebglContextManager, draws known non-black pixels to an FBO, then readPixels. + * @param {Object} options - Options for this drawer. * @returns {Boolean} true if WebGL is supported and the pipeline renders successfully */ - static isSupported(){ + static isSupported(options){ let contextManager = null; let testTexture = null; let gl = null; @@ -26144,7 +26589,7 @@ function determineSubPixelRoundingRule(subPixelRoundingRules) { let data = cache.data; let isCanvas = false; - if (data instanceof CanvasRenderingContext2D) { + if (data instanceof CanvasRenderingContext2D || data instanceof OffscreenCanvasRenderingContext2D) { data = data.canvas; isCanvas = true; } @@ -26215,7 +26660,7 @@ function determineSubPixelRoundingRule(subPixelRoundingRules) { context.drawImage( data, 0, 0 ); data = context; } - if (data instanceof CanvasRenderingContext2D) { + if (data instanceof CanvasRenderingContext2D || data instanceof OffscreenCanvasRenderingContext2D ) { return data; } $.console.error("Unsupported data used for WebGL Drawer - probably a bug!"); @@ -27609,9 +28054,8 @@ $.Viewport.prototype = { * @function * @param {Number} degrees The degrees by which to rotate the viewport. * @param {OpenSeadragon.Point} [pivot] (Optional) point in viewport coordinates + * @param {Boolean} [immediately=false] Whether to animate to the new angle * around which the rotation should be performed. Defaults to the center of the viewport. - * * @param {Boolean} [immediately=false] Whether to animate to the new angle - * or rotate immediately. * @returns {OpenSeadragon.Viewport} Chainable. */ rotateBy: function(degrees, pivot, immediately){ @@ -27620,11 +28064,21 @@ $.Viewport.prototype = { /** * @function + * @param {OpenSeadragon.Point} [newContainerSize] - current size if not defined + * @param {Boolean} [maintain=false] - if true, bounds are adjusted to maintain the current zoom level * @returns {OpenSeadragon.Viewport} Chainable. * @fires OpenSeadragon.Viewer.event:resize */ - resize: function( newContainerSize, maintain ) { - const oldBounds = this.getBoundsNoRotate(); + resize: function( newContainerSize = undefined, maintain = false ) { + if (!newContainerSize) { + if (!this.viewer) { + $.console.warn('[Viewport::resize] needs newContainerSize argument when the viewport.viewer reference is not defined!'); + return this; + } + const el = $.getElement(this.viewer.container); + newContainerSize = new $.Point(el.clientWidth || 1, el.clientHeight || 1); + } + const oldBounds = this.getBoundsNoRotate(false); const newBounds = oldBounds; let widthDeltaFactor; this._sizeChanged = !this.containerSize.equals(newContainerSize); @@ -28352,7 +28806,7 @@ $.Viewport.prototype = { * @memberof OpenSeadragon.Viewer * @type {object} * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. - * @property {Number} flipped - The flip state after this change. + * @property {Boolean} flipped - The flip state after this change. * @property {?Object} userData - Arbitrary subscriber-defined object. */ this.viewer.raiseEvent('flip', {flipped: state}); @@ -28483,6 +28937,7 @@ $.Viewport.prototype = { * @param {Number} [options.blendTime] - See {@link OpenSeadragon.Options}. * @param {Boolean} [options.alwaysBlend] - See {@link OpenSeadragon.Options}. * @param {Number} [options.minPixelRatio] - See {@link OpenSeadragon.Options}. + * @param {Number} [options.discardLevelsBelowDownsampleRatio] - See {@link OpenSeadragon.Options}. * @param {Number} [options.smoothTileEdgesMinZoom] - See {@link OpenSeadragon.Options}. * @param {Boolean} [options.iOSDevice] - See {@link OpenSeadragon.Options}. * @param {Number} [options.opacity=1] - Set to draw at proportional opacity. If zero, images will not draw. @@ -28571,6 +29026,16 @@ $.TiledImage = function( options ) { const ajaxHeaders = options.ajaxHeaders; delete options.ajaxHeaders; + if (options.source.discardLevelsBelowDownsampleRatio) { + options.discardLevelsBelowDownsampleRatio = options.source.discardLevelsBelowDownsampleRatio; + } + + if (!Number.isFinite(options.discardLevelsBelowDownsampleRatio) || options.discardLevelsBelowDownsampleRatio < 1) { + $.console.warn("discardLevelsBelowDownsampleRatio must be a positive number, defaulting to ", + $.DEFAULT_SETTINGS.discardLevelsBelowDownsampleRatio); + delete options.discardLevelsBelowDownsampleRatio; + } + // Setter ensures lowercase this.crossOriginPolicy = options.crossOriginPolicy; delete options.crossOriginPolicy; @@ -28605,6 +29070,7 @@ $.TiledImage = function( options ) { blendTime: $.DEFAULT_SETTINGS.blendTime, alwaysBlend: $.DEFAULT_SETTINGS.alwaysBlend, minPixelRatio: $.DEFAULT_SETTINGS.minPixelRatio, + discardLevelsBelowDownsampleRatio: $.DEFAULT_SETTINGS.discardLevelsBelowDownsampleRatio, smoothTileEdgesMinZoom: $.DEFAULT_SETTINGS.smoothTileEdgesMinZoom, iOSDevice: $.DEFAULT_SETTINGS.iOSDevice, debugMode: $.DEFAULT_SETTINGS.debugMode, @@ -28648,6 +29114,13 @@ $.TiledImage = function( options ) { animationTime: this.animationTime }); + /** + * Cached cutoff level which should not change. It is THE level + * which covers the whole image while being cheap to load. + * @type {Number|*} + */ + this.savedCutOffLevel = this.source.getClosestLevel(); + this._updateForScale(); if (fitBounds) { @@ -29840,37 +30313,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag return this.viewer.world.getItemAt(0) === this; }, - // private - _getLevelsInterval: function() { - let lowestLevel = Math.max( - this.source.minLevel, - Math.floor(Math.log(this.minZoomImageRatio) / Math.log(2)) - ); - const currentZeroRatio = this.viewport.deltaPixelsFromPointsNoRotate( - this.source.getPixelRatio(0), true).x * - this._scaleSpring.current.value; - let highestLevel = Math.min( - Math.abs(this.source.maxLevel), - Math.abs(Math.floor( - Math.log(currentZeroRatio / this.minPixelRatio) / Math.log(2) - )) - ); - - // Calculations for the interval of levels to draw - // can return invalid intervals; fix that here if necessary - highestLevel = Math.max(highestLevel, this.source.minLevel || 0); - lowestLevel = Math.min(lowestLevel, highestLevel); - return { - lowestLevel: lowestLevel, - highestLevel: highestLevel - }; - }, - // returns boolean flag of whether the image should be marked as fully loaded _updateLevelsForViewport: function(){ - const levelsInterval = this._getLevelsInterval(); - const lowestLevel = levelsInterval.lowestLevel; // the lowest level we should draw at our current zoom - const highestLevel = levelsInterval.highestLevel; // the highest level we should draw at our current zoom const drawArea = this.getDrawArea(); let loadArea = drawArea; @@ -29895,47 +30339,35 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag return this._fullyLoaded; } - // make a list of levels to use for the current zoom level - const levelList = this._getCachedArray('levelList', highestLevel - lowestLevel + 1); - // go from highest to lowest resolution - for (let i = 0, level = highestLevel; level >= lowestLevel; level--, i++) { - levelList[i] = level; - } + // Figure the list of levels we should draw at the current zoom + const minLevel = this.source.minLevel || 0; + const maxLevel = this.source.maxLevel || 0; - // if a single-tile level is loaded, add that to the end of the list - // as a fallback to use during zooming out, until a lower-res tile is - // loaded - for (let level = highestLevel + 1; level <= this.source.maxLevel; level++) { - const tile = ( - this.tilesMatrix[level] && - this.tilesMatrix[level][0] && - this.tilesMatrix[level][0][0] - ); - if (tile && tile.isBottomMost && tile.isRightMost && tile.loaded) { - levelList.push(level); - break; - } - } + let coverageSucceeded = false; + const targetZeroRatio = this.viewport.deltaPixelsFromPointsNoRotate( + this.source.getPixelRatio(Math.max(this.savedCutOffLevel, 0)), false + ).x * this._scaleSpring.current.value; + const optimalRatio = this.immediateRender ? 1 : targetZeroRatio; + let maxPxRatio = this.source.getPixelRatio(maxLevel).x; - // Update any level that will be drawn. - // We are iterating from highest resolution to lowest resolution - // Once a level fully covers the viewport the loop is halted and - // lower-resolution levels are skipped - let useLevel = false; - for (let i = 0; i < levelList.length; i++) { - const level = levelList[i]; + // Find the level whose render pixel ratio is closest to 1 + for (let level = maxLevel; level >= minLevel; level--) { + const levelPixelRatio = this.source.getPixelRatio(level); - const currentRenderPixelRatio = this.viewport.deltaPixelsFromPointsNoRotate( - this.source.getPixelRatio(level), - true - ).x * this._scaleSpring.current.value; + // If we require e.g., 4x downsample factor between levels, here we check that last time we + // accepted a level with ratio X, next time we accept only level ratio Y where Y/X >= 4 + if (this.discardLevelsBelowDownsampleRatio > 1 && + levelPixelRatio.x / maxPxRatio < this.discardLevelsBelowDownsampleRatio && level !== maxLevel) { + continue; + } + maxPxRatio = levelPixelRatio.x; - // make sure we skip levels until currentRenderPixelRatio becomes >= minPixelRatio - // but always use the last level in the list so we draw something - if (i === levelList.length - 1 || currentRenderPixelRatio >= this.minPixelRatio ) { - useLevel = true; - } else if (!useLevel) { + const currentRenderPixelRatio = + this.viewport.deltaPixelsFromPointsNoRotate(levelPixelRatio, true).x * + this._scaleSpring.current.value; + // Keep skipping levels that are too big to render, but always keep to render min level + if (currentRenderPixelRatio < this.minPixelRatio && level !== minLevel) { continue; } @@ -29944,17 +30376,6 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag false ).x * this._scaleSpring.current.value; - const targetZeroRatio = this.viewport.deltaPixelsFromPointsNoRotate( - this.source.getPixelRatio( - Math.max( - this.source.getClosestLevel(), - 0 - ) - ), - false - ).x * this._scaleSpring.current.value; - - const optimalRatio = this.immediateRender ? 1 : targetZeroRatio; const levelOpacity = Math.min(1, (currentRenderPixelRatio - 0.5) / 0.5); const levelVisibility = optimalRatio / Math.abs( optimalRatio - targetRenderPixelRatio @@ -29979,10 +30400,59 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag // Stop the loop if lower-res tiles would all be covered by // already drawn tiles if (this._providesCoverage(this.coverage, level)) { + coverageSucceeded = true; + break; + } + + // We assume 'coverage ok' if we hit the cutoff level + coverageSucceeded = level === this.savedCutOffLevel; + + if (coverageSucceeded) { break; } } + if (!coverageSucceeded) { + // Force the cutoff level to be drawn - which happens if coverage test fails and we did not covered cutfOffLevel yet + const level = this.savedCutOffLevel; + const targetRenderPixelRatio = this.viewport.deltaPixelsFromPointsNoRotate( + this.source.getPixelRatio(level), + false + ).x * this._scaleSpring.current.value; + + const targetZeroRatio = this.viewport.deltaPixelsFromPointsNoRotate( + this.source.getPixelRatio(Math.max(this.savedCutOffLevel, 0)), false + ).x * this._scaleSpring.current.value; + + const currentRenderPixelRatio = + this.viewport.deltaPixelsFromPointsNoRotate( + this.source.getPixelRatio(level), + true + ).x * this._scaleSpring.current.value; + + const optimalRatio = this.immediateRender ? 1 : targetZeroRatio; + const levelOpacity = Math.min(1, (currentRenderPixelRatio - 0.5) / 0.5); + const levelVisibility = optimalRatio / Math.abs( + optimalRatio - targetRenderPixelRatio + ); + + // Update the level and keep track of 'best' tiles to load + const result = this._updateLevel( + level, + levelOpacity, + levelVisibility, + drawArea, + loadArea, + currentTime, + bestLoadTileCandidates + ); + + this.viewer.world.ensureTilesUpToDate(result.tilesToDraw); + + bestLoadTileCandidates = result.bestLoadTileCandidates; + this._tilesToDraw[level] = result.tilesToDraw; + } + // Load the new 'best' n tiles if (bestLoadTileCandidates && bestLoadTileCandidates.length > 0) { @@ -30157,9 +30627,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag const numberOfTiles = this.source.getNumTiles(level); const viewportCenter = this.viewport.pixelFromPoint(this.viewport.getCenter()); this._resetCoverage(this.coverage, level); - if (loadArea) { - this._resetCoverage(this.loadingCoverage, level); - } + this._resetCoverage(this.loadingCoverage, level); let tilesToDraw = null; let tileIndex = 0; @@ -30271,11 +30739,15 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }, /** - * Visit all tiles in an a given area on a given level. + * Visit all tiles in a given area on a given level. Can be used as 'all' predicate. + * If not used as predicate, returns true. * @private * @param {Number} level * @param {OpenSeadragon.Rect} area - * @param {Function} callback - x, y, total - tile x, y position and total number of tiles + * @param {Function} callback - x, y, total - tile x, y position and total number of tiles, if + * the method returns boolean false, the iteration is stopped (_visitTiles returns false), otherwise + * the iteration continues and the return value of _visitTiles is true + * @returns {boolean} true if function was not early-exited, false otherwise */ _visitTiles: function(level, area, callback) { const bbox = area.getBoundingBox(); @@ -30301,8 +30773,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag for (let x = drawTopLeftTile.x; x <= drawBottomRightTile.x; x++) { for (let y = drawTopLeftTile.y; y <= drawBottomRightTile.y; y++) { - - let flippedX; + let flippedX = x; if (this.getFlip()) { const xMod = ( numberOfTiles.x + ( x % numberOfTiles.x ) ) % numberOfTiles.x; flippedX = x + numberOfTiles.x - xMod - xMod - 1; @@ -30315,9 +30786,13 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag continue; } - callback(flippedX, y, numTiles); + // If callback returns false, we exit all loops immediately + if (callback(flippedX, y, numTiles) === false) { + return false; + } } } + return true; }, /** @@ -30427,7 +30902,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag return false; } tile.loading = true; - this._setTileLoaded(tile, record.data, null, null, record.type); + this._setTileLoaded(tile, record.data, null, record.type); return true; }, @@ -30630,17 +31105,17 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag if (conversion) { const desiredType = $.converter.getConversionPathFinalType(conversion); $.converter.convert(tile, data, dataType, desiredType).then(newData => { - this._setTileLoaded(tile, newData, null, tileRequest, desiredType); + this._setTileLoaded(tile, newData, tileRequest, desiredType); }).catch(e => { $.console.warn("Failed to satisfy original type [%s] %s from %s: %s", desiredType, tile, dataType, e); - this._setTileLoaded(tile, data, null, tileRequest, dataType); + this._setTileLoaded(tile, data, tileRequest, dataType); }); } else { $.console.warn( "Ignoring default base tile data type %s: no conversion possible from %s", this.originalDataType, dataType); - this._setTileLoaded(tile, data, null, tileRequest, dataType); + this._setTileLoaded(tile, data, tileRequest, dataType); } } else { - this._setTileLoaded(tile, data, null, tileRequest, dataType); + this._setTileLoaded(tile, data, tileRequest, dataType); } }, @@ -30649,11 +31124,10 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @param {OpenSeadragon.Tile} tile * @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object, * can be null: in that case, cache is assigned to a tile without further processing - * @param {?Number} cutoff ignored, @deprecated * @param {?XMLHttpRequest} tileRequest * @param {?String} [dataType=undefined] data type, derived automatically if not set */ - _setTileLoaded: function(tile, data, cutoff, tileRequest, dataType) { + _setTileLoaded: function(tile, data, tileRequest, dataType) { tile.tiledImage = this; //unloaded with tile.unload(), so we need to set it back // does nothing if tile.cacheKey already present @@ -30935,16 +31409,42 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @returns {Boolean} */ _isCovered: function( coverage, level, x, y ) { + // Whole-level shortcut: if every tile at level+1 provides coverage, + // then level is completely covered by higher-resolution content. if ( x === undefined || y === undefined ) { return this._providesCoverage( coverage, level + 1 ); - } else { - return ( - this._providesCoverage( coverage, level + 1, 2 * x, 2 * y ) && - this._providesCoverage( coverage, level + 1, 2 * x, 2 * y + 1 ) && - this._providesCoverage( coverage, level + 1, 2 * x + 1, 2 * y ) && - this._providesCoverage( coverage, level + 1, 2 * x + 1, 2 * y + 1 ) - ); } + + const nextLevel = level + 1; + + // No finer level -> nothing can cover this tile. + if (nextLevel > this.source.maxLevel) { + return false; + } + + // If we don't even have a coverage map for the finer level yet, + // we conservatively assume it's not covered. + if (!coverage[nextLevel]) { + return false; + } + + // Geometric bounds of this tile in tiled-image normalized coordinates. + const parentBounds = this.getTileBounds(level, x, y); + + let covered = true; + const self = this; + + // Visit all tiles at the next level that intersect this parent tile's area. + // _visitTiles handles wrap and flip consistently with how coverage indices + // are generated in _updateLevel. + this._visitTiles(nextLevel, parentBounds, function(childX, childY) { + if (!self._providesCoverage(coverage, nextLevel, childX, childY)) { + covered = false; + } + return covered; + }); + + return covered; }, /** @@ -31305,17 +31805,24 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag return internalCache.await(); } - // Force reset - if (internalCache && !internalCache.loaded) { - internalCache.await().then(() => internalCache.destroy()); + const drawerID = drawer.getId(); + + // always destroy the outdated record so we do not leak its resources + if (internalCache) { + this._safeDestroyInternal(internalCache); + delete this[DRAWER_INTERNAL_CACHE][drawerID]; } $.console.assert(this._tRef, "Data Create called from invalidation routine needs tile reference!"); const transformedData = drawer.internalCacheCreate(this, this._tRef); $.console.assert(transformedData !== undefined, "[DrawerBase.internalCacheCreate] must return a value if usePrivateCache is enabled!"); - const drawerID = drawer.getId(); + if (transformedData === undefined || transformedData === null) { + // do not store in the internal cache which would later hand undefined/null to the drawer destructor + return $.Promise.resolve(undefined); + } internalCache = this[DRAWER_INTERNAL_CACHE][drawerID] = new $.InternalCacheRecord(transformedData, drawerID, (data) => drawer.internalCacheFree(data)); + internalCache._drawer = drawer; // kept so in-place data overwrite can rebuild via same drawer return internalCache.await(); } @@ -31333,18 +31840,24 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag return internalCache; } - // Force reset + const drawerID = drawer.getId(); + if (internalCache) { - internalCache.destroy(); + this._safeDestroyInternal(internalCache); + delete this[DRAWER_INTERNAL_CACHE][drawerID]; } $.console.assert(this._tRef, "Data Create called from drawing loop needs tile reference!"); const transformedData = drawer.internalCacheCreate(this, this._tRef); $.console.assert(transformedData !== undefined, "[DrawerBase.internalCacheCreate] must return a value if usePrivateCache is enabled!"); + if (transformedData === undefined || transformedData === null) { + // do not store in the internal cache which would later hand undefined/null to the drawer destructor + return undefined; + } - const drawerID = drawer.getId(); internalCache = this[DRAWER_INTERNAL_CACHE][drawerID] = new $.InternalCacheRecord(transformedData, drawerID, (data) => drawer.internalCacheFree(data)); + internalCache._drawer = drawer; // kept so in-place data overwrite can rebuild via same drawer return internalCache; } @@ -31438,18 +31951,113 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag if (drawerId) { const cache = internal[drawerId]; if (cache) { - cache.destroy(); + this._safeDestroyInternal(cache); delete internal[drawerId]; } } else { for (const iCache in internal) { - internal[iCache].destroy(); + this._safeDestroyInternal(internal[iCache]); } delete this[DRAWER_INTERNAL_CACHE]; } } } + /** + * Destroy a single internal cache record without letting a faulty drawer + * destructor abort the surrounding cleanup loop. If the record is still loading + * (async/preload build in flight), defer the destroy until it resolves, otherwise + * InternalCacheRecord.destroy() is a no-op (guarded by 'loaded') and the resource + * would leak once the pending build completes on an already-orphaned record. + * @param {OpenSeadragon.InternalCacheRecord} cache + * @private + */ + _safeDestroyInternal(cache) { + if (cache.loaded) { + try { + cache.destroy(); + } catch (e) { + $.console.error("[CacheRecord] internal cache destroy threw:", e); + } + } else { + cache.await().then(() => cache.destroy()) + .catch((e) => $.console.error("[CacheRecord] internal cache destroy threw:", e)); + } + } + + /** + * Rebuild every drawer's internal (derived) cache from the current main data after an + * in-place overwrite. Double-buffered. Preload/async drawers therefore keep + * drawing the previous texture until the replacement is loaded (no blink). + * @private + */ + _refreshInternalCaches() { + const internal = this[DRAWER_INTERNAL_CACHE]; + if (!internal) { + return; + } + for (const drawerID in internal) { + this._rebuildInternalCache(drawerID, internal[drawerID]); + } + } + + /** + * @param {string} drawerID + * @param {OpenSeadragon.InternalCacheRecord} old the record being replaced + * @private + */ + _rebuildInternalCache(drawerID, old) { + const internal = this[DRAWER_INTERNAL_CACHE]; + const drawer = old && old._drawer; + + if (!internal || !drawer || !this._tRef) { + if (old) { + this._safeDestroyInternal(old); + } + if (internal) { + delete internal[drawerID]; + } + return; + } + + let transformedData; + try { + transformedData = drawer.internalCacheCreate(this, this._tRef); + } catch (e) { + $.console.error("[CacheRecord._rebuildInternalCache] internalCacheCreate threw:", e); + transformedData = undefined; + } + if (transformedData === undefined || transformedData === null) { + this._safeDestroyInternal(old); + delete internal[drawerID]; + return; + } + + const fresh = new $.InternalCacheRecord(transformedData, drawerID, + (data) => drawer.internalCacheFree(data)); + fresh._drawer = drawer; + + const swap = () => { + const currentMap = this[DRAWER_INTERNAL_CACHE]; + if (this._destroyed || !currentMap || currentMap[drawerID] !== old) { + this._safeDestroyInternal(fresh); + return; + } + currentMap[drawerID] = fresh; + this._safeDestroyInternal(old); + this._triggerNeedsDraw(); + }; + + if (fresh.loaded) { + swap(); // sync (non-preload) drawer: replace immediately, no blink + } else { + fresh.await().then(swap).catch(e => { + $.console.error("[CacheRecord._rebuildInternalCache] internal cache refresh failed:", e); + this._safeDestroyInternal(fresh); + }); + } + } + /** * Conversion requires tile references: * keep the most 'up to date' ref here. It is called and managed automatically. @@ -31476,7 +32084,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * Must not be called on active cache, e.g. first call destroy(). */ revive() { - $.console.assert(!this.loaded && !this._type, "[CacheRecord::revive] must not be called when loaded!"); + $.console.assert(!this.loaded && !this._type, "[CacheRecord.revive] must not be called when loaded!"); this._tiles = []; this._data = null; this._type = null; @@ -31503,15 +32111,21 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this._destroySelfUnsafe(this._data, this._type); } else if (this._promise) { const oldType = this._type; - this._promise.then(x => this._destroySelfUnsafe(x, oldType)).catch($.console.error); + this._promise.then(x => this._destroySelfUnsafe(x, oldType)) + .catch((e) => $.console.error("[CacheRecord.destroy] async threw:", e)); } } } _destroySelfUnsafe(data, type) { - // ensure old data destroyed - $.converter.destroy(data, type); + // ensure old data destroyed - never let a data/internal destructor throw abort the + // bookkeeping reset below, or the cache system is left in an inconsistent state. + try { + $.converter.destroy(data, type); + } catch (e) { + $.console.error("[CacheRecord._destroySelfUnsafe] data destroy threw:", e); + } this.destroyInternalCache(); // might've got revived in meanwhile if async ... if (!this._destroyed) { @@ -31607,8 +32221,9 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag if (this._tiles[i] === tile) { this._tiles.splice(i, 1); if (this._tRef === tile) { - // keep fresh ref - this._tRef = this._tiles[i - 1]; + // keep a valid fresh ref: pick any remaining tile (splice already shifted + // later tiles down into index i), or null when none remain + this._tRef = this._tiles.length ? this._tiles[Math.min(i, this._tiles.length - 1)] : null; } return true; } @@ -31674,12 +32289,10 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this._type = type; this._data = data; this._promise = $.Promise.resolve(data); - const internal = this[DRAWER_INTERNAL_CACHE]; - if (internal) { - for (const iCache in internal) { - internal[iCache].setDataAs(data, type); - } - } + // main data changed: rebuild each drawer's internal (derived) cache from the new + // data. Double-buffered so preload/async drawers keep showing the old texture + // until the replacement is ready (no blink); the old one is freed after the swap. + this._refreshInternalCaches(); this._triggerNeedsDraw(); return this._promise; } @@ -31694,9 +32307,11 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this._promise = $.Promise.resolve(data); const internal = this[DRAWER_INTERNAL_CACHE]; if (internal) { + // same as above - force regenerate for (const iCache in internal) { - internal[iCache].setDataAs(data, type); + this._safeDestroyInternal(internal[iCache]); } + delete this[DRAWER_INTERNAL_CACHE]; } this._triggerNeedsDraw(); return this._data; @@ -31859,8 +32474,13 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag */ destroy() { if (this.loaded) { - if (this._ondestroy) { - this._ondestroy(this._data); + // only invoke destroy on real data, otherwise skip + if (this._ondestroy && this._data !== null && this._data !== undefined) { + try { + this._ondestroy(this._data); + } catch (e) { + $.console.error("[InternalCacheRecord.destroy] drawer internal cache free threw:", e); + } } this._data = null; this.loaded = false; @@ -32774,7 +33394,7 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W for (let i = 0; i < allTiles.length; i++) { const tile = allTiles[i]; const isRecentlyTouched = tile.lastTouchTime >= drawnTstamp; - const isAboveCutoff = tile.level <= (tile.tiledImage.source.getClosestLevel() || 0); + const isAboveCutoff = tile.level <= (tile.tiledImage.savedCutOffLevel || 0); if (isRecentlyTouched || isAboveCutoff) { tilesToRestore[restoreIndex++] = tile;