diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index 7a5b59a3f5..479953b681 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -25,6 +25,7 @@ import interleaveCenterLoader from './utils/interleaveCenterLoader'; import nthLoader from './utils/nthLoader'; import interleaveTopToBottom from './utils/interleaveTopToBottom'; import initContextMenu from './initContextMenu'; +import initDoubleClick from './initDoubleClick'; // TODO: Cypress tests are currently grabbing this from the window? window.cornerstone = cornerstone; @@ -104,6 +105,12 @@ export default async function init({ clearOnModeExit: true, }); + // Stores the entire ViewportGridService getState when toggling to one up + // (e.g. via a double click) so that it can be restored when toggling back. + stateSyncService.register('toggleOneUpViewportGridStore', { + clearOnModeExit: true, + }); + const labelmapRepresentation = cornerstoneTools.Enums.SegmentationRepresentations.Labelmap; @@ -198,6 +205,11 @@ export default async function init({ commandsManager, }); + initDoubleClick({ + customizationService, + commandsManager, + }); + const newStackCallback = evt => { const { element } = evt.detail; utilities.stackPrefetch.enable(element); diff --git a/extensions/cornerstone/src/initContextMenu.ts b/extensions/cornerstone/src/initContextMenu.ts index a9f69db51e..c46e5053e9 100644 --- a/extensions/cornerstone/src/initContextMenu.ts +++ b/extensions/cornerstone/src/initContextMenu.ts @@ -1,6 +1,7 @@ import { eventTarget, EVENTS } from '@cornerstonejs/core'; import { Enums } from '@cornerstonejs/tools'; import { setEnabledElement } from './state'; +import { findNearbyToolData } from './utils/findNearbyToolData'; const cs3DToolsEvents = Enums.Events; @@ -47,28 +48,6 @@ function initContextMenu({ customizationService, commandsManager, }): void { - /** - * Finds tool nearby event position triggered. - * - * @param {Object} commandsManager mannager of commands - * @param {Object} event that has being triggered - * @returns cs toolData or undefined if not found. - */ - const findNearbyToolData = evt => { - if (!evt?.detail) { - return; - } - const { element, currentPoints } = evt.detail; - return commandsManager.runCommand( - 'getNearbyToolData', - { - element, - canvasCoordinates: currentPoints?.canvas, - }, - 'CORNERSTONE' - ); - }; - /* * Run the commands associated with the given button press, * defaults on button1 and button2 @@ -80,7 +59,7 @@ function initContextMenu({ const toRun = customizations[name]; console.log('initContextMenu::cornerstoneViewportHandleEvent', name, toRun); const options = { - nearbyToolData: findNearbyToolData(evt), + nearbyToolData: findNearbyToolData(commandsManager, evt), event: evt, }; commandsManager.run(toRun, options); diff --git a/extensions/cornerstone/src/initDoubleClick.ts b/extensions/cornerstone/src/initDoubleClick.ts new file mode 100644 index 0000000000..da8fb8fa49 --- /dev/null +++ b/extensions/cornerstone/src/initDoubleClick.ts @@ -0,0 +1,92 @@ +import { eventTarget, EVENTS } from '@cornerstonejs/core'; +import { Enums } from '@cornerstonejs/tools'; +import { CommandsManager, CustomizationService, Types } from '@ohif/core'; +import { findNearbyToolData } from './utils/findNearbyToolData'; + +const cs3DToolsEvents = Enums.Events; + +const DEFAULT_DOUBLE_CLICK = { + doubleClick: { + commandName: 'toggleOneUp', + commandOptions: {}, + }, +}; + +/** + * Generates a double click event name, consisting of: + * * alt when the alt key is down + * * ctrl when the cctrl key is down + * * shift when the shift key is down + * * 'doubleClick' + */ +function getDoubleClickEventName(evt: CustomEvent) { + const nameArr = []; + if (evt.detail.event.altKey) nameArr.push('alt'); + if (evt.detail.event.ctrlKey) nameArr.push('ctrl'); + if (evt.detail.event.shiftKey) nameArr.push('shift'); + nameArr.push('doubleClick'); + return nameArr.join(''); +} + +export type initDoubleClickArgs = { + customizationService: CustomizationService; + commandsManager: CommandsManager; +}; + +function initDoubleClick({ + customizationService, + commandsManager, +}: initDoubleClickArgs): void { + const cornerstoneViewportHandleDoubleClick = (evt: CustomEvent) => { + // Do not allow double click on a tool. + const nearbyToolData = findNearbyToolData(commandsManager, evt); + if (nearbyToolData) { + return; + } + + const eventName = getDoubleClickEventName(evt); + + // Allows for the customization of the double click on a viewport. + const customizations = + customizationService.get('cornerstoneViewportClickCommands') || + DEFAULT_DOUBLE_CLICK; + + const toRun = customizations[eventName]; + + if (!toRun) { + return; + } + + commandsManager.run(toRun); + }; + + function elementEnabledHandler(evt: CustomEvent) { + const { element } = evt.detail; + + element.addEventListener( + cs3DToolsEvents.MOUSE_DOUBLE_CLICK, + cornerstoneViewportHandleDoubleClick + ); + } + + function elementDisabledHandler(evt: CustomEvent) { + const { element } = evt.detail; + + element.removeEventListener( + cs3DToolsEvents.MOUSE_DOUBLE_CLICK, + cornerstoneViewportHandleDoubleClick + ); + } + + eventTarget.addEventListener( + EVENTS.ELEMENT_ENABLED, + elementEnabledHandler.bind(null) + ); + + eventTarget.addEventListener( + EVENTS.ELEMENT_DISABLED, + elementDisabledHandler.bind(null) + ); +} + +export default initDoubleClick; diff --git a/extensions/cornerstone/src/utils/findNearbyToolData.ts b/extensions/cornerstone/src/utils/findNearbyToolData.ts new file mode 100644 index 0000000000..ab57a67d25 --- /dev/null +++ b/extensions/cornerstone/src/utils/findNearbyToolData.ts @@ -0,0 +1,21 @@ +/** + * Finds tool nearby event position triggered. + * + * @param {Object} commandsManager mannager of commands + * @param {Object} event that has being triggered + * @returns cs toolData or undefined if not found. + */ +export const findNearbyToolData = (commandsManager, evt) => { + if (!evt?.detail) { + return; + } + const { element, currentPoints } = evt.detail; + return commandsManager.runCommand( + 'getNearbyToolData', + { + element, + canvasCoordinates: currentPoints?.canvas, + }, + 'CORNERSTONE' + ); +}; diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts index e44651101f..b2a80cbfe8 100644 --- a/extensions/default/src/commandsModule.ts +++ b/extensions/default/src/commandsModule.ts @@ -1,4 +1,4 @@ -import { ServicesManager, Types } from '@ohif/core'; +import { ServicesManager, utils } from '@ohif/core'; import { ContextMenuController, @@ -12,6 +12,8 @@ import findViewportsByPosition, { import { ContextMenuProps } from './CustomizeableContextMenu/types'; +const { subscribeToNextViewportGridChange } = utils; + export type HangingProtocolParams = { protocolId?: string; stageIndex?: number; @@ -159,12 +161,14 @@ const commandsModule = ({ * @param options.protocolId - the protocol ID to change to * @param options.stageId - the stageId to apply * @param options.stageIndex - the index of the stage to go to. + * @param options.reset - flag to indicate if the HP should be reset to its original and not restored to a previous state */ setHangingProtocol: ({ activeStudyUID = '', protocolId, stageId, stageIndex, + reset = false, }: HangingProtocolParams): boolean => { try { // Stores in the state the reuseID to displaySetUID mapping @@ -210,10 +214,11 @@ const commandsModule = ({ hangingProtocolService.setActiveStudyUID(activeStudyUID); } - const storedHanging = `${hangingProtocolService.getState().activeStudyUID + const storedHanging = `${ + hangingProtocolService.getState().activeStudyUID }:${protocolId}:${useStageIdx || 0}`; - const restoreProtocol = !!viewportGridStore[storedHanging]; + const restoreProtocol = !reset && viewportGridStore[storedHanging]; if ( protocolId === hpInfo.protocolId && @@ -273,8 +278,9 @@ const commandsModule = ({ activeStudy, } = hangingProtocolService.getActiveProtocol(); const { toggleHangingProtocol } = stateSyncService.getState(); - const storedHanging = `${activeStudy.StudyInstanceUID - }:${protocolId}:${stageIndex | 0}`; + const storedHanging = `${ + activeStudy.StudyInstanceUID + }:${protocolId}:${stageIndex | 0}`; if ( protocol.id === protocolId && (stageIndex === undefined || stageIndex === desiredStageIndex) @@ -294,7 +300,11 @@ const commandsModule = ({ }, }, }); - return actions.setHangingProtocol({ protocolId, stageIndex }); + return actions.setHangingProtocol({ + protocolId, + stageIndex, + reset: true, + }); } }, @@ -365,6 +375,111 @@ const commandsModule = ({ window.setTimeout(completeLayout, 0); }, + toggleOneUp() { + const viewportGridState = viewportGridService.getState(); + const { activeViewportIndex, viewports, layout } = viewportGridState; + const { + displaySetInstanceUIDs, + displaySetOptions, + viewportOptions, + } = viewports[activeViewportIndex]; + + if (layout.numCols === 1 && layout.numRows === 1) { + // The viewer is in one-up. Check if there is a state to restore/toggle back to. + const { toggleOneUpViewportGridStore } = stateSyncService.getState(); + + if (!toggleOneUpViewportGridStore.layout) { + return; + } + // There is a state to toggle back to. The viewport that was + // originally toggled to one up was the former active viewport. + const viewportIndexToUpdate = + toggleOneUpViewportGridStore.activeViewportIndex; + + // Determine which viewports need to be updated. This is particularly + // important when MPR is toggled to one up and a different reconstructable + // is swapped in. Note that currently HangingProtocolService.getViewportsRequireUpdate + // does not support viewport with multiple display sets. + const updatedViewports = + displaySetInstanceUIDs.length > 1 + ? [] + : displaySetInstanceUIDs + .map(displaySetInstanceUID => + hangingProtocolService.getViewportsRequireUpdate( + viewportIndexToUpdate, + displaySetInstanceUID + ) + ) + .flat(); + + // This findOrCreateViewport returns either one of the updatedViewports + // returned from the HP service OR if there is not one from the HP service then + // simply returns what was in the previous state. + const findOrCreateViewport = (viewportIndex: number) => { + const viewport = updatedViewports.find( + viewport => viewport.viewportIndex === viewportIndex + ); + + return viewport + ? { viewportOptions, displaySetOptions, ...viewport } + : toggleOneUpViewportGridStore.viewports[viewportIndex]; + }; + + const layoutOptions = viewportGridService.getLayoutOptionsFromState( + toggleOneUpViewportGridStore + ); + + // Restore the previous layout including the active viewport. + viewportGridService.setLayout({ + numRows: toggleOneUpViewportGridStore.layout.numRows, + numCols: toggleOneUpViewportGridStore.layout.numCols, + activeViewportIndex: viewportIndexToUpdate, + layoutOptions, + findOrCreateViewport, + }); + } else { + // We are not in one-up, so toggle to one up. + + // Store the current viewport grid state so we can toggle it back later. + stateSyncService.store({ + toggleOneUpViewportGridStore: viewportGridState, + }); + + // This findOrCreateViewport only return one viewport - the active + // one being toggled to one up. + const findOrCreateViewport = () => { + return { + displaySetInstanceUIDs, + displaySetOptions, + viewportOptions, + }; + }; + + // Set the layout to be 1x1/one-up. + viewportGridService.setLayout({ + numRows: 1, + numCols: 1, + findOrCreateViewport, + }); + + // Subscribe to ANY (i.e. manual and hanging protocol) layout changes so that + // any grid layout state to toggle to from one up is cleared. This is performed on + // a timeout to avoid clearing the state for the actual to one up change. + // Whenever the next layout change event is fired, the subscriptions are unsubscribed. + const clearToggleOneUpViewportGridStore = () => { + const toggleOneUpViewportGridStore = {}; + stateSyncService.store({ + toggleOneUpViewportGridStore, + }); + }; + + subscribeToNextViewportGridChange( + viewportGridService, + clearToggleOneUpViewportGridStore + ); + } + }, + openDICOMTagViewer() { const { activeViewportIndex, viewports } = viewportGridService.getState(); const activeViewportSpecificData = viewports[activeViewportIndex]; @@ -440,6 +555,11 @@ const commandsModule = ({ storeContexts: [], options: {}, }, + toggleOneUp: { + commandFn: actions.toggleOneUp, + storeContexts: [], + options: {}, + }, openDICOMTagViewer: { commandFn: actions.openDICOMTagViewer, }, diff --git a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx index 97ac7affca..5e5393f53e 100644 --- a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx +++ b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx @@ -80,7 +80,7 @@ function TrackedCornerstoneViewport(props) { return; } - annotation.config.style.setViewportToolStyles(`viewport-${viewportIndex}`, { + annotation.config.style.setViewportToolStyles(viewportId, { global: { lineDash: '4,4', }, diff --git a/platform/core/src/services/ViewportGridService/ViewportGridService.ts b/platform/core/src/services/ViewportGridService/ViewportGridService.ts index cfa5a5bc52..be98756701 100644 --- a/platform/core/src/services/ViewportGridService/ViewportGridService.ts +++ b/platform/core/src/services/ViewportGridService/ViewportGridService.ts @@ -2,6 +2,8 @@ import { PubSubService } from '../_shared/pubSubServiceInterface'; const EVENTS = { ACTIVE_VIEWPORT_INDEX_CHANGED: 'event::activeviewportindexchanged', + LAYOUT_CHANGED: 'event::layoutChanged', + GRID_STATE_CHANGED: 'event::gridStateChanged', }; class ViewportGridService extends PubSubService { @@ -101,12 +103,26 @@ class ViewportGridService extends PubSubService { * options that is initially provided as {} (eg to store intermediate state) * The function returns a viewport object to use at the given position. */ - public setLayout({ numCols, numRows, findOrCreateViewport = undefined }) { + public setLayout({ + numCols, + numRows, + layoutOptions, + layoutType = 'grid', + activeViewportIndex = undefined, + findOrCreateViewport = undefined, + }) { this.serviceImplementation._setLayout({ numCols, numRows, + layoutOptions, + layoutType, + activeViewportIndex, findOrCreateViewport, }); + this._broadcastEvent(this.EVENTS.LAYOUT_CHANGED, { + numCols, + numRows, + }); } public reset() { @@ -125,11 +141,25 @@ class ViewportGridService extends PubSubService { public set(state) { this.serviceImplementation._set(state); + this._broadcastEvent(this.EVENTS.GRID_STATE_CHANGED, { + state, + }); } public getNumViewportPanes() { return this.serviceImplementation._getNumViewportPanes(); } + + public getLayoutOptionsFromState(state) { + return state.viewports.map(viewport => { + return { + x: viewport.x, + y: viewport.y, + width: viewport.width, + height: viewport.height, + }; + }); + } } export default ViewportGridService; diff --git a/platform/core/src/types/Command.ts b/platform/core/src/types/Command.ts index 0b6129d0c0..837c835907 100644 --- a/platform/core/src/types/Command.ts +++ b/platform/core/src/types/Command.ts @@ -6,5 +6,5 @@ export interface Command { /** A set of commands, typically contained in a tool item or other configuration */ export interface Commands { - commands: Commands[]; + commands: Command[]; } diff --git a/platform/core/src/utils/index.js b/platform/core/src/utils/index.js index d428a66480..4c54c32efe 100644 --- a/platform/core/src/utils/index.js +++ b/platform/core/src/utils/index.js @@ -32,6 +32,7 @@ import { sortingCriteria, seriesSortCriteria, } from './sortStudy'; +import { subscribeToNextViewportGridChange } from './subscribeToNextViewportGridChange'; // Commented out unused functionality. // Need to implement new mechanism for derived displaySets using the displaySetManager. @@ -69,6 +70,7 @@ const utils = { debounce, roundNumber, downloadCSVReport, + subscribeToNextViewportGridChange, }; export { diff --git a/platform/core/src/utils/index.test.js b/platform/core/src/utils/index.test.js index 9b83f8db5a..cae7691913 100644 --- a/platform/core/src/utils/index.test.js +++ b/platform/core/src/utils/index.test.js @@ -35,6 +35,7 @@ describe('Top level exports', () => { 'resolveObjectPath', 'hierarchicalListUtils', 'progressTrackingUtils', + 'subscribeToNextViewportGridChange', ].sort(); const exports = Object.keys(utils.default).sort(); diff --git a/platform/core/src/utils/subscribeToNextViewportGridChange.ts b/platform/core/src/utils/subscribeToNextViewportGridChange.ts new file mode 100644 index 0000000000..e6c72d0485 --- /dev/null +++ b/platform/core/src/utils/subscribeToNextViewportGridChange.ts @@ -0,0 +1,37 @@ +import { ViewportGridService } from '../services'; + +/** + * Subscribes to the very next LAYOUT_CHANGED or GRID_STATE_CHANGED event that + * is not currently on the event queue. The subscriptions are made on a 'zero' + * timeout so as to avoid responding to any of those events currently on the event queue. + * The subscription persists only for a single invocation of either event. + * Once either event is fired, the subscriptions are unsubscribed. + * @param viewportGridService the viewport grid service to subscribe to + * @param gridChangeCallback the callback + */ +function subscribeToNextViewportGridChange( + viewportGridService: ViewportGridService, + gridChangeCallback: (arg: unknown) => void +): void { + const subscriber = () => { + const callback = (callbackProps: unknown) => { + subscriptions.forEach(subscription => subscription.unsubscribe()); + gridChangeCallback(callbackProps); + }; + + const subscriptions = [ + viewportGridService.subscribe( + viewportGridService.EVENTS.LAYOUT_CHANGED, + callback + ), + viewportGridService.subscribe( + viewportGridService.EVENTS.GRID_STATE_CHANGED, + callback + ), + ]; + }; + + window.setTimeout(subscriber, 0); +} + +export { subscribeToNextViewportGridChange }; diff --git a/platform/docs/docs/platform/services/ui/viewport-grid-service.md b/platform/docs/docs/platform/services/ui/viewport-grid-service.md index b6806343e2..d523a7415a 100644 --- a/platform/docs/docs/platform/services/ui/viewport-grid-service.md +++ b/platform/docs/docs/platform/services/ui/viewport-grid-service.md @@ -9,6 +9,15 @@ sidebar_label: Viewport Grid Service This is a new UI service, that handles the grid layout of the viewer. +## Events + +There are seven events that get publish in `ViewportGridService `: + +| Event | Description | +| ----------------------------- | --------------------------------------------------| +| ACTIVE_VIEWPORT_INDEX_CHANGED | Fires the index of the active viewport is changed | +| LAYOUT_CHANGED | Fires the layout is changed | +| GRID_STATE_CHANGED | Fires when the entire grid state is changed | ## Interface For a more detailed look on the options and return values each of these methods @@ -19,9 +28,10 @@ is expected to support, [check out it's interface in `@ohif/core`][interface] | `setActiveViewportIndex(index)` | Sets the active viewport index in the app | | `getState()` | Gets the states of the viewport (see below) | | `setDisplaySetsForViewport({ viewportIndex, displaySetInstanceUID })` | Sets displaySet for viewport based on displaySet Id | -| `setLayout({numCols, numRows, keepExtraViewports})` | Sets rows and columns. When the total number of viewports decreases, optionally keep the extra/offscreen viewports. | +| `setLayout({numCols, numRows, keepExtraViewports})` | Sets rows and columns. When the total number of viewports decreases, optionally keep the extra/offscreen viewports. | | `reset()` | Resets the default states | | `getNumViewportPanes()` | Gets the number of visible viewport panes | +| `getLayoutOptionsFromState(gridState)` | Utility method that produces a `ViewportLayoutOptions` based on the passed in state| ## Implementations diff --git a/platform/ui/src/contextProviders/ViewportGridProvider.tsx b/platform/ui/src/contextProviders/ViewportGridProvider.tsx index c3bff44dfd..d74a6d7158 100644 --- a/platform/ui/src/contextProviders/ViewportGridProvider.tsx +++ b/platform/ui/src/contextProviders/ViewportGridProvider.tsx @@ -146,6 +146,7 @@ export function ViewportGridProvider({ children, service }) { numRows, layoutOptions, layoutType = 'grid', + activeViewportIndex, findOrCreateViewport, } = action.payload; @@ -160,7 +161,7 @@ export function ViewportGridProvider({ children, service }) { // haven't been viewed yet, and add them in the appropriate order. const options = {}; - let activeViewportIndex; + let activeViewportIndexToSet = activeViewportIndex; for (let row = 0; row < numRows; row++) { for (let col = 0; col < numCols; col++) { const pos = col + row * numCols; @@ -170,10 +171,11 @@ export function ViewportGridProvider({ children, service }) { continue; } if ( - !activeViewportIndex || - state.viewports[pos]?.positionId === positionId + activeViewportIndexToSet == null && + state.viewports[state.activeViewportIndex]?.positionId === + positionId ) { - activeViewportIndex = pos; + activeViewportIndexToSet = pos; } const viewport = findOrCreateViewport(pos, positionId, options); if (!viewport) continue; @@ -199,6 +201,8 @@ export function ViewportGridProvider({ children, service }) { } } + activeViewportIndexToSet = activeViewportIndexToSet ?? 0; + const viewportIdSet = {}; for ( let viewportIndex = 0; @@ -223,7 +227,7 @@ export function ViewportGridProvider({ children, service }) { const ret = { ...state, - activeViewportIndex, + activeViewportIndex: activeViewportIndexToSet, layout: { ...state.layout, numCols, @@ -300,6 +304,7 @@ export function ViewportGridProvider({ children, service }) { numRows, numCols, layoutOptions = [], + activeViewportIndex, findOrCreateViewport, }) => dispatch({ @@ -309,6 +314,7 @@ export function ViewportGridProvider({ children, service }) { numRows, numCols, layoutOptions, + activeViewportIndex, findOrCreateViewport, }, }), @@ -375,9 +381,9 @@ export function ViewportGridProvider({ children, service }) { setActiveViewportIndex: index => service.setActiveViewportIndex(index), // run it through the service itself since we want to publish events setDisplaySetsForViewport, setDisplaySetsForViewports, - setLayout, + setLayout: layout => service.setLayout(layout), // run it through the service itself since we want to publish events reset, - set, + set: gridLayoutState => service.setState(gridLayoutState), // run it through the service itself since we want to publish events getNumViewportPanes, }; diff --git a/platform/viewer/cypress/integration/customization/OHIFDoubleClick.spec.js b/platform/viewer/cypress/integration/customization/OHIFDoubleClick.spec.js new file mode 100644 index 0000000000..d60c1be911 --- /dev/null +++ b/platform/viewer/cypress/integration/customization/OHIFDoubleClick.spec.js @@ -0,0 +1,52 @@ +describe('OHIF Double Click', () => { + beforeEach(() => { + cy.checkStudyRouteInViewer( + '1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1', + '&hangingProtocolId=@ohif/hp-extension.mn' + ); + cy.expectMinimumThumbnails(3); + cy.initCornerstoneToolsAliases(); + cy.initCommonElementsAliases(); + }); + + it('Should double click each viewport to one up and back', () => { + const numExpectedViewports = 3; + cy.get('[data-cy="viewport-pane"]') + .its('length') + .should('be.eq', numExpectedViewports); + + for (let i = 0; i < numExpectedViewports; i += 1) { + // For whatever reason, with Cypress tests, we have to activate the + // viewport we are double clicking first. + cy.get('[data-cy="viewport-pane"]') + .eq(i) + .trigger('mousedown', 'center', { force: true }) + .trigger('mouseup', 'center', { force: true }); + + // Wait for the viewport to be 'active'. + // TODO Is there a better way to do this? + cy.get('[data-cy="viewport-pane"]') + .eq(i) + .parent() + .find('[data-cy="viewport-pane"]') + .not('.pointer-events-none'); + + // The actual double click. + cy.get('[data-cy="viewport-pane"]') + .eq(i) + .trigger('dblclick', 'center'); + + cy.get('[data-cy="viewport-pane"]') + .its('length') + .should('be.eq', 1); + + cy.get('[data-cy="viewport-pane"]') + .eq(0) + .trigger('dblclick', 'center'); + + cy.get('[data-cy="viewport-pane"]') + .its('length') + .should('be.eq', numExpectedViewports); + } + }); +});