Skip to content

Commit 7b6d23b

Browse files
authored
Merge pull request #4220 from ProjectMirador/marlo-pres-view
Reset zoom and center on each page
2 parents caaf0ae + e78884b commit 7b6d23b

7 files changed

Lines changed: 177 additions & 13 deletions

File tree

.eslintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
"import/no-anonymous-default-export": "off",
7878
"import/no-extraneous-dependencies": "off",
7979
"max-len": ["error", {
80-
"code": 120,
80+
"code": 130,
8181
"ignoreComments": true,
8282
"ignoreStrings": true,
8383
"ignoreTemplateLiterals": true,
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { render } from '@tests/utils/test-utils';
2+
import OpenSeadragon from 'openseadragon';
3+
import OpenSeadragonComponent from '../../../src/components/OpenSeadragonComponent';
4+
5+
vi.mock('openseadragon');
6+
7+
describe('OpenSeadragonComponent', () => {
8+
let addOnceHandler;
9+
let fitBoundsWithConstraints;
10+
11+
beforeEach(() => {
12+
addOnceHandler = vi.fn();
13+
fitBoundsWithConstraints = vi.fn();
14+
15+
// Mock methods used in the component
16+
OpenSeadragon.mockImplementation(() => ({
17+
addHandler: vi.fn(),
18+
addOnceHandler,
19+
canvas: {},
20+
destroy: vi.fn(),
21+
innerTracker: {},
22+
removeAllHandlers: vi.fn(),
23+
viewport: {
24+
centerSpringX: { target: { value: 0 } },
25+
centerSpringY: { target: { value: 0 } },
26+
fitBounds: vi.fn(),
27+
fitBoundsWithConstraints,
28+
zoomSpring: { target: { value: 1 } },
29+
},
30+
world: { addOnceHandler },
31+
}));
32+
33+
OpenSeadragon.Rect = vi.fn((x, y, width, height) => ({
34+
height, width, x, y,
35+
}));
36+
});
37+
38+
/**
39+
* Invoke the most recently registered tile-loaded handler
40+
*/
41+
function invokeTileLoadedHandler() {
42+
// Extract and invoke the most recently registered 'tile-loaded' handler
43+
// to simulate OSD firing the event when tiles finish loading
44+
// OSD provides addOnceHandler to register events on viewer
45+
const { lastCall } = addOnceHandler.mock; // Vitest's lastCall
46+
const [_eventName, tileLoadedHandler] = lastCall || [];
47+
if (tileLoadedHandler) tileLoadedHandler();
48+
}
49+
50+
/**
51+
* Render component and complete initial tile loading
52+
* @param {Array} bounds - Initial bounds
53+
* @returns {object} Render result
54+
*/
55+
function renderAndInitialize(bounds = [0, 0, 5000, 3000]) {
56+
const result = render(
57+
<OpenSeadragonComponent windowId="test" viewerConfig={{ bounds }} />,
58+
);
59+
60+
// Component registers a 'tile-loaded' handler during mount to set initial viewport
61+
invokeTileLoadedHandler();
62+
63+
// Clear mocks after initialization
64+
fitBoundsWithConstraints.mockClear();
65+
addOnceHandler.mockClear();
66+
67+
return result;
68+
}
69+
70+
it('resets zoom and center when bounds change', () => {
71+
const { rerender } = renderAndInitialize();
72+
73+
// Change bounds to different dimensions
74+
rerender(
75+
<OpenSeadragonComponent windowId="test" viewerConfig={{ bounds: [0, 0, 3000, 2000] }} />,
76+
);
77+
78+
// Component registered a 'tile-loaded' handler when bounds change
79+
invokeTileLoadedHandler();
80+
81+
// Should call fitBoundsWithConstraints with the new bounds to reset zoom and center
82+
expect(fitBoundsWithConstraints).toHaveBeenCalledWith(
83+
expect.objectContaining({
84+
height: 2000,
85+
width: 3000,
86+
x: 0,
87+
y: 0,
88+
}),
89+
true,
90+
);
91+
});
92+
93+
it('does not reset zoom when bounds remain the same', () => {
94+
const { rerender } = renderAndInitialize();
95+
96+
// Rerender with same bounds
97+
rerender(
98+
<OpenSeadragonComponent windowId="test" viewerConfig={{ bounds: [0, 0, 5000, 3000] }} />,
99+
);
100+
101+
// Should not register a new tile-loaded handler
102+
expect(addOnceHandler).not.toHaveBeenCalled();
103+
104+
// Should not call fitBoundsWithConstraints
105+
expect(fitBoundsWithConstraints).not.toHaveBeenCalled();
106+
});
107+
});

__tests__/src/sagas/windows.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,29 @@ describe('window-level sagas', () => {
148148
.run();
149149
});
150150

151+
it('overrides default preserveViewport: false when initialViewerConfig is set', () => {
152+
const action = {
153+
window: {
154+
canvasId: '1',
155+
id: 'x',
156+
initialViewerConfig: {
157+
x: 934,
158+
y: 782,
159+
zoom: 0.0007,
160+
},
161+
manifestId: 'manifest.json',
162+
},
163+
};
164+
165+
return expectSaga(setWindowStartingCanvas, action)
166+
.provide([
167+
[select(getManifests), { 'manifest.json': {} }],
168+
[call(setCanvas, 'x', '1', null, { preserveViewport: true }), { type: 'setCanvasThunk' }],
169+
])
170+
.put({ type: 'setCanvasThunk' })
171+
.run();
172+
});
173+
151174
it('calculates the starting canvas and calls setCanvas', () => {
152175
const action = {
153176
window: {

src/components/OpenSeadragonComponent.jsx

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ function OpenSeadragonComponent({
1717
const [grabbing, setGrabbing] = useState(false);
1818
const viewerRef = useRef(undefined);
1919
const initialViewportSet = useRef(false);
20+
const lastAppliedBounds = useRef(null);
21+
const isResettingViewport = useRef(false);
2022
const [, forceUpdate] = useReducer(x => x + 1, 0);
2123

2224
const moveHandler = useDebouncedCallback(useCallback((event) => {
@@ -28,6 +30,9 @@ function OpenSeadragonComponent({
2830
const { viewport } = event.eventSource;
2931

3032
if (!initialViewportSet.current) return;
33+
34+
// Don't save viewport changes during automatic recentering
35+
if (isResettingViewport.current) return;
3136

3237
onUpdateViewport({
3338
bounds: viewport.getBounds(),
@@ -62,6 +67,7 @@ function OpenSeadragonComponent({
6267
if (!viewerConfig.x && !viewerConfig.y && !viewerConfig.zoom) {
6368
if (viewerConfig.bounds) {
6469
viewport.fitBounds(new Openseadragon.Rect(...viewerConfig.bounds), true);
70+
lastAppliedBounds.current = viewerConfig.bounds;
6571
} else {
6672
viewport.goHome(true);
6773
}
@@ -79,6 +85,35 @@ function OpenSeadragonComponent({
7985
return;
8086
}
8187

88+
// Check if bounds changed - always recenter when bounds change)
89+
if (viewerConfig.bounds) {
90+
const boundsChanged = !lastAppliedBounds.current
91+
|| viewerConfig.bounds.length !== lastAppliedBounds.current.length
92+
|| viewerConfig.bounds.some((val, idx) => val !== lastAppliedBounds.current[idx]);
93+
94+
// Bounds changed - recenter regardless of whether x/y/zoom exist
95+
if (boundsChanged) {
96+
isResettingViewport.current = true;
97+
lastAppliedBounds.current = viewerConfig.bounds;
98+
99+
// Wait for the tiles to be fully loaded before recentering
100+
const handleTilesLoaded = () => {
101+
const rect = new Openseadragon.Rect(...viewerConfig.bounds);
102+
viewport.fitBoundsWithConstraints(rect, true);
103+
isResettingViewport.current = false;
104+
};
105+
106+
viewer.addOnceHandler('tile-loaded', handleTilesLoaded);
107+
return;
108+
}
109+
}
110+
111+
// Apply preserved viewport only if bounds haven't changed
112+
// Don't apply x/y/zoom if we don't have them (rely on bounds instead)
113+
if (!viewerConfig.x || !viewerConfig.y || !viewerConfig.zoom) {
114+
return;
115+
}
116+
82117
// @ts-expect-error
83118
if (viewerConfig.x != null && viewerConfig.y != null
84119
&& (Math.round(viewerConfig.x) !== Math.round(viewport.centerSpringX.target.value)
@@ -99,13 +134,6 @@ function OpenSeadragonComponent({
99134
if (viewerConfig.flip != null && (viewerConfig.flip || false) !== viewport.getFlip()) {
100135
viewport.setFlip(viewerConfig.flip);
101136
}
102-
103-
if (viewerConfig.bounds && !viewerConfig.x && !viewerConfig.y && !viewerConfig.zoom) {
104-
const rect = new Openseadragon.Rect(...viewerConfig.bounds);
105-
if (rect.equals(viewport.getBounds())) {
106-
viewport.fitBounds(rect, false);
107-
}
108-
}
109137
}, [initialViewportSet, setInitialBounds, viewerConfig, viewerRef]);
110138

111139
// initialize OSD stuff when this component is mounted

src/config/settings.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,7 @@ export default {
546546
alwaysBlend: false,
547547
blendTime: 0.1,
548548
preserveImageSizeOnResize: true,
549-
preserveViewport: true,
549+
preserveViewport: false,
550550
showNavigationControl: false,
551551
zoomPerClick: 1, // disable zoom-to-click
552552
zoomPerDoubleClick: 2.0

src/state/actions/canvas.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,8 @@ export function setCanvas(windowId, canvasId, newGroup = undefined, options = {}
2525
}
2626

2727
dispatch({
28-
...options,
2928
canvasId,
30-
preserveViewport,
29+
preserveViewport: options?.preserveViewport ?? preserveViewport,
3130
type: ActionTypes.SET_CANVAS,
3231
visibleCanvases,
3332
windowId,

src/state/sagas/windows.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ export function* setWindowStartingCanvas(action) {
9595
const windowId = action.id || action.window.id;
9696

9797
if (canvasId) {
98-
const thunk = yield call(setCanvas, windowId, canvasId, null, { preserveViewport: !!action.payload });
98+
// Preserve viewport when initialViewerConfig exists, event if the preserveViewport OSD setting is set to false
99+
const preserveViewport = !!action.payload || !!(action.window?.initialViewerConfig);
100+
// When canvasId is explicitly provided, always pass preserveViewport flag
101+
const thunk = yield call(setCanvas, windowId, canvasId, null, { preserveViewport });
99102
yield put(thunk);
100103
} else {
101104
const getMiradorManifest = yield select(getMiradorManifestWrapper);
@@ -107,7 +110,11 @@ export function* setWindowStartingCanvas(action) {
107110
|| miradorManifest.canvasAt(canvasIndex || 0)
108111
|| miradorManifest.canvasAt(0);
109112
if (startCanvas) {
110-
const thunk = yield call(setCanvas, windowId, startCanvas.id);
113+
const preserveViewport = !!action.payload || !!(action.window?.initialViewerConfig);
114+
// When canvas is calculated, only pass preserveViewport when true
115+
const thunk = preserveViewport
116+
? yield call(setCanvas, windowId, startCanvas.id, null, { preserveViewport })
117+
: yield call(setCanvas, windowId, startCanvas.id);
111118
yield put(thunk);
112119
}
113120
}

0 commit comments

Comments
 (0)