diff --git a/.eslintrc b/.eslintrc
index daaa641e2..a594c641c 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -77,7 +77,7 @@
"import/no-anonymous-default-export": "off",
"import/no-extraneous-dependencies": "off",
"max-len": ["error", {
- "code": 120,
+ "code": 130,
"ignoreComments": true,
"ignoreStrings": true,
"ignoreTemplateLiterals": true,
diff --git a/__tests__/src/components/OpenSeadragonComponent.test.js b/__tests__/src/components/OpenSeadragonComponent.test.js
new file mode 100644
index 000000000..854b111ce
--- /dev/null
+++ b/__tests__/src/components/OpenSeadragonComponent.test.js
@@ -0,0 +1,107 @@
+import { render } from '@tests/utils/test-utils';
+import OpenSeadragon from 'openseadragon';
+import OpenSeadragonComponent from '../../../src/components/OpenSeadragonComponent';
+
+vi.mock('openseadragon');
+
+describe('OpenSeadragonComponent', () => {
+ let addOnceHandler;
+ let fitBoundsWithConstraints;
+
+ beforeEach(() => {
+ addOnceHandler = vi.fn();
+ fitBoundsWithConstraints = vi.fn();
+
+ // Mock methods used in the component
+ OpenSeadragon.mockImplementation(() => ({
+ addHandler: vi.fn(),
+ addOnceHandler,
+ canvas: {},
+ destroy: vi.fn(),
+ innerTracker: {},
+ removeAllHandlers: vi.fn(),
+ viewport: {
+ centerSpringX: { target: { value: 0 } },
+ centerSpringY: { target: { value: 0 } },
+ fitBounds: vi.fn(),
+ fitBoundsWithConstraints,
+ zoomSpring: { target: { value: 1 } },
+ },
+ world: { addOnceHandler },
+ }));
+
+ OpenSeadragon.Rect = vi.fn((x, y, width, height) => ({
+ height, width, x, y,
+ }));
+ });
+
+ /**
+ * Invoke the most recently registered tile-loaded handler
+ */
+ function invokeTileLoadedHandler() {
+ // Extract and invoke the most recently registered 'tile-loaded' handler
+ // to simulate OSD firing the event when tiles finish loading
+ // OSD provides addOnceHandler to register events on viewer
+ const { lastCall } = addOnceHandler.mock; // Vitest's lastCall
+ const [_eventName, tileLoadedHandler] = lastCall || [];
+ if (tileLoadedHandler) tileLoadedHandler();
+ }
+
+ /**
+ * Render component and complete initial tile loading
+ * @param {Array} bounds - Initial bounds
+ * @returns {object} Render result
+ */
+ function renderAndInitialize(bounds = [0, 0, 5000, 3000]) {
+ const result = render(
+ ,
+ );
+
+ // Component registers a 'tile-loaded' handler during mount to set initial viewport
+ invokeTileLoadedHandler();
+
+ // Clear mocks after initialization
+ fitBoundsWithConstraints.mockClear();
+ addOnceHandler.mockClear();
+
+ return result;
+ }
+
+ it('resets zoom and center when bounds change', () => {
+ const { rerender } = renderAndInitialize();
+
+ // Change bounds to different dimensions
+ rerender(
+ ,
+ );
+
+ // Component registered a 'tile-loaded' handler when bounds change
+ invokeTileLoadedHandler();
+
+ // Should call fitBoundsWithConstraints with the new bounds to reset zoom and center
+ expect(fitBoundsWithConstraints).toHaveBeenCalledWith(
+ expect.objectContaining({
+ height: 2000,
+ width: 3000,
+ x: 0,
+ y: 0,
+ }),
+ true,
+ );
+ });
+
+ it('does not reset zoom when bounds remain the same', () => {
+ const { rerender } = renderAndInitialize();
+
+ // Rerender with same bounds
+ rerender(
+ ,
+ );
+
+ // Should not register a new tile-loaded handler
+ expect(addOnceHandler).not.toHaveBeenCalled();
+
+ // Should not call fitBoundsWithConstraints
+ expect(fitBoundsWithConstraints).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/src/sagas/windows.test.js b/__tests__/src/sagas/windows.test.js
index 5426dd0ff..12cf16e57 100644
--- a/__tests__/src/sagas/windows.test.js
+++ b/__tests__/src/sagas/windows.test.js
@@ -148,6 +148,29 @@ describe('window-level sagas', () => {
.run();
});
+ it('overrides default preserveViewport: false when initialViewerConfig is set', () => {
+ const action = {
+ window: {
+ canvasId: '1',
+ id: 'x',
+ initialViewerConfig: {
+ x: 934,
+ y: 782,
+ zoom: 0.0007,
+ },
+ manifestId: 'manifest.json',
+ },
+ };
+
+ return expectSaga(setWindowStartingCanvas, action)
+ .provide([
+ [select(getManifests), { 'manifest.json': {} }],
+ [call(setCanvas, 'x', '1', null, { preserveViewport: true }), { type: 'setCanvasThunk' }],
+ ])
+ .put({ type: 'setCanvasThunk' })
+ .run();
+ });
+
it('calculates the starting canvas and calls setCanvas', () => {
const action = {
window: {
diff --git a/src/components/OpenSeadragonComponent.jsx b/src/components/OpenSeadragonComponent.jsx
index d8eedca0b..b4abb9b75 100644
--- a/src/components/OpenSeadragonComponent.jsx
+++ b/src/components/OpenSeadragonComponent.jsx
@@ -17,6 +17,8 @@ function OpenSeadragonComponent({
const [grabbing, setGrabbing] = useState(false);
const viewerRef = useRef(undefined);
const initialViewportSet = useRef(false);
+ const lastAppliedBounds = useRef(null);
+ const isResettingViewport = useRef(false);
const [, forceUpdate] = useReducer(x => x + 1, 0);
const moveHandler = useDebouncedCallback(useCallback((event) => {
@@ -28,6 +30,9 @@ function OpenSeadragonComponent({
const { viewport } = event.eventSource;
if (!initialViewportSet.current) return;
+
+ // Don't save viewport changes during automatic recentering
+ if (isResettingViewport.current) return;
onUpdateViewport({
bounds: viewport.getBounds(),
@@ -62,6 +67,7 @@ function OpenSeadragonComponent({
if (!viewerConfig.x && !viewerConfig.y && !viewerConfig.zoom) {
if (viewerConfig.bounds) {
viewport.fitBounds(new Openseadragon.Rect(...viewerConfig.bounds), true);
+ lastAppliedBounds.current = viewerConfig.bounds;
} else {
viewport.goHome(true);
}
@@ -79,6 +85,35 @@ function OpenSeadragonComponent({
return;
}
+ // Check if bounds changed - always recenter when bounds change)
+ if (viewerConfig.bounds) {
+ const boundsChanged = !lastAppliedBounds.current
+ || viewerConfig.bounds.length !== lastAppliedBounds.current.length
+ || viewerConfig.bounds.some((val, idx) => val !== lastAppliedBounds.current[idx]);
+
+ // Bounds changed - recenter regardless of whether x/y/zoom exist
+ if (boundsChanged) {
+ isResettingViewport.current = true;
+ lastAppliedBounds.current = viewerConfig.bounds;
+
+ // Wait for the tiles to be fully loaded before recentering
+ const handleTilesLoaded = () => {
+ const rect = new Openseadragon.Rect(...viewerConfig.bounds);
+ viewport.fitBoundsWithConstraints(rect, true);
+ isResettingViewport.current = false;
+ };
+
+ viewer.addOnceHandler('tile-loaded', handleTilesLoaded);
+ return;
+ }
+ }
+
+ // Apply preserved viewport only if bounds haven't changed
+ // Don't apply x/y/zoom if we don't have them (rely on bounds instead)
+ if (!viewerConfig.x || !viewerConfig.y || !viewerConfig.zoom) {
+ return;
+ }
+
// @ts-expect-error
if (viewerConfig.x != null && viewerConfig.y != null
&& (Math.round(viewerConfig.x) !== Math.round(viewport.centerSpringX.target.value)
@@ -99,13 +134,6 @@ function OpenSeadragonComponent({
if (viewerConfig.flip != null && (viewerConfig.flip || false) !== viewport.getFlip()) {
viewport.setFlip(viewerConfig.flip);
}
-
- if (viewerConfig.bounds && !viewerConfig.x && !viewerConfig.y && !viewerConfig.zoom) {
- const rect = new Openseadragon.Rect(...viewerConfig.bounds);
- if (rect.equals(viewport.getBounds())) {
- viewport.fitBounds(rect, false);
- }
- }
}, [initialViewportSet, setInitialBounds, viewerConfig, viewerRef]);
// initialize OSD stuff when this component is mounted
diff --git a/src/config/settings.js b/src/config/settings.js
index 1550e0ef9..c53db1ae1 100644
--- a/src/config/settings.js
+++ b/src/config/settings.js
@@ -546,7 +546,7 @@ export default {
alwaysBlend: false,
blendTime: 0.1,
preserveImageSizeOnResize: true,
- preserveViewport: true,
+ preserveViewport: false,
showNavigationControl: false,
zoomPerClick: 1, // disable zoom-to-click
zoomPerDoubleClick: 2.0
diff --git a/src/state/actions/canvas.js b/src/state/actions/canvas.js
index 6ce6ceba5..baf6a3e77 100644
--- a/src/state/actions/canvas.js
+++ b/src/state/actions/canvas.js
@@ -25,9 +25,8 @@ export function setCanvas(windowId, canvasId, newGroup = undefined, options = {}
}
dispatch({
- ...options,
canvasId,
- preserveViewport,
+ preserveViewport: options?.preserveViewport ?? preserveViewport,
type: ActionTypes.SET_CANVAS,
visibleCanvases,
windowId,
diff --git a/src/state/sagas/windows.js b/src/state/sagas/windows.js
index 540296794..295ca6a90 100644
--- a/src/state/sagas/windows.js
+++ b/src/state/sagas/windows.js
@@ -95,7 +95,10 @@ export function* setWindowStartingCanvas(action) {
const windowId = action.id || action.window.id;
if (canvasId) {
- const thunk = yield call(setCanvas, windowId, canvasId, null, { preserveViewport: !!action.payload });
+ // Preserve viewport when initialViewerConfig exists, event if the preserveViewport OSD setting is set to false
+ const preserveViewport = !!action.payload || !!(action.window?.initialViewerConfig);
+ // When canvasId is explicitly provided, always pass preserveViewport flag
+ const thunk = yield call(setCanvas, windowId, canvasId, null, { preserveViewport });
yield put(thunk);
} else {
const getMiradorManifest = yield select(getMiradorManifestWrapper);
@@ -107,7 +110,11 @@ export function* setWindowStartingCanvas(action) {
|| miradorManifest.canvasAt(canvasIndex || 0)
|| miradorManifest.canvasAt(0);
if (startCanvas) {
- const thunk = yield call(setCanvas, windowId, startCanvas.id);
+ const preserveViewport = !!action.payload || !!(action.window?.initialViewerConfig);
+ // When canvas is calculated, only pass preserveViewport when true
+ const thunk = preserveViewport
+ ? yield call(setCanvas, windowId, startCanvas.id, null, { preserveViewport })
+ : yield call(setCanvas, windowId, startCanvas.id);
yield put(thunk);
}
}