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); } }