Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
107 changes: 107 additions & 0 deletions __tests__/src/components/OpenSeadragonComponent.test.js
Original file line number Diff line number Diff line change
@@ -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(
<OpenSeadragonComponent windowId="test" viewerConfig={{ bounds }} />,
);

// 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(
<OpenSeadragonComponent windowId="test" viewerConfig={{ bounds: [0, 0, 3000, 2000] }} />,
);

// 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(
<OpenSeadragonComponent windowId="test" viewerConfig={{ bounds: [0, 0, 5000, 3000] }} />,
);

// Should not register a new tile-loaded handler
expect(addOnceHandler).not.toHaveBeenCalled();

// Should not call fitBoundsWithConstraints
expect(fitBoundsWithConstraints).not.toHaveBeenCalled();
});
});
23 changes: 23 additions & 0 deletions __tests__/src/sagas/windows.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
42 changes: 35 additions & 7 deletions src/components/OpenSeadragonComponent.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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(),
Expand Down Expand Up @@ -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);
}
Expand All @@ -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)
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/config/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions src/state/actions/canvas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions src/state/sagas/windows.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}
}
Expand Down