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;