Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(doubleClick): Add Double click detection (#375) #382

Merged
merged 9 commits into from
Jan 26, 2023
256 changes: 256 additions & 0 deletions packages/tools/examples/doubleClickWithStackAnnotationTools/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new example for double click.

RenderingEngine,
Types,
Enums,
getRenderingEngine,
} from '@cornerstonejs/core';
import {
initDemo,
createImageIdsAndCacheMetaData,
setTitleAndDescription,
addDropdownToToolbar,
} from '../../../../utils/demo/helpers';
import * as cornerstoneTools from '@cornerstonejs/tools';

// This is for debugging purposes
console.warn(
'Click on index.ts to open source code for this example --------->'
);

const {
LengthTool,
ProbeTool,
RectangleROITool,
EllipticalROITool,
BidirectionalTool,
AngleTool,
ToolGroupManager,
ArrowAnnotateTool,
Enums: csToolsEnums,
} = cornerstoneTools;

const { ViewportType } = Enums;
const { Events } = cornerstoneTools.Enums;
const { MouseBindings } = csToolsEnums;

const renderingEngineId = 'myRenderingEngine';
const viewportId = 'CT_STACK';

document.documentElement.style.userSelect = 'none';

// ======== Set up page ======== //
setTitleAndDescription(
'Double Click With Stack Annotation Tools',
'Double click detection before/during/after using annotation tools on a stack viewport.'
);

const content = document.getElementById('content');
const element = document.createElement('div');

// Disable right click context menu so we can have right click tools
element.oncontextmenu = (e) => e.preventDefault();

element.id = 'cornerstone-element';

// Listen for the browser double click event in an ancestor of the viewport element for best results.
// Listening for the browser double click event on the viewport element itself, could result
// in receiving a 'dblclick' event when cornerstone is trying to suppress it.
content.addEventListener('dblclick', () => {
browserDoubleClickEventStatus.innerText =
"Browser 'dblclick' event detected on an ancestor of the viewport element.";
toggleCanvasSize();

const renderEngine = getRenderingEngine(renderingEngineId);
renderEngine.resize(true);

statusDiv.style.backgroundColor = '#00ff00';
});

element.addEventListener(Events.MOUSE_DOUBLE_CLICK, () => {
cs3dDoubleClickEventStatus.innerText = `'${Events.MOUSE_DOUBLE_CLICK}' event detected on the viewport element.`;
statusDiv.style.backgroundColor = null;
});

element.addEventListener(Events.MOUSE_DOWN, () => {
browserDoubleClickEventStatus.innerText = '';
cs3dDoubleClickEventStatus.innerText = '';
});

content.appendChild(element);

// instruction elements
const instructionsDiv = document.createElement('div');
instructionsDiv.style.width = element.style.width;

content.append(instructionsDiv);

let instructions = document.createElement('p');
instructions.innerText = `Select a tool from the drop down above the viewport.
Left Click to use the selected tool.
Try double clicking at any point before/during/after use.
`;

instructionsDiv.append(instructions);

instructions = document.createElement('p');
instructions.innerText = `When a double click is detected, the viewport size changes and an info message is displayed below.
Note that a double click during tool usage/interaction is suppressed`;

instructionsDiv.append(instructions);

// double click status info elements
const statusDiv = document.createElement('div');
statusDiv.style.width = element.style.width;

content.append(statusDiv);

const browserDoubleClickEventStatus = document.createElement('p');
statusDiv.append(browserDoubleClickEventStatus);

const cs3dDoubleClickEventStatus = document.createElement('p');
statusDiv.append(cs3dDoubleClickEventStatus);

// canvas sizing
const canvasSizes = ['500px', '750px'];

function toggleCanvasSize() {
const canvasSize = canvasSizes.shift();
canvasSizes.push(canvasSize);

element.style.width = canvasSize;
element.style.height = canvasSize;

statusDiv.style.width = canvasSize;

instructionsDiv.style.width = canvasSize;
}

toggleCanvasSize();

// ============================= //

const toolGroupId = 'STACK_TOOL_GROUP_ID';

const toolsNames = [
LengthTool.toolName,
ProbeTool.toolName,
RectangleROITool.toolName,
EllipticalROITool.toolName,
BidirectionalTool.toolName,
AngleTool.toolName,
ArrowAnnotateTool.toolName,
];
let selectedToolName = toolsNames[0];

addDropdownToToolbar({
options: { values: toolsNames, defaultValue: selectedToolName },
onSelectedValueChange: (newSelectedToolNameAsStringOrNumber) => {
const newSelectedToolName = String(newSelectedToolNameAsStringOrNumber);
const toolGroup = ToolGroupManager.getToolGroup(toolGroupId);

// Set the new tool active
toolGroup.setToolActive(newSelectedToolName, {
bindings: [
{
mouseButton: MouseBindings.Primary, // Left Click
},
],
});

// Set the old tool passive
toolGroup.setToolPassive(selectedToolName);

selectedToolName = <string>newSelectedToolName;
},
});

/**
* Runs the demo
*/
async function run() {
// Init Cornerstone and related libraries
await initDemo();

// Add tools to Cornerstone3D
cornerstoneTools.addTool(LengthTool);
cornerstoneTools.addTool(ProbeTool);
cornerstoneTools.addTool(RectangleROITool);
cornerstoneTools.addTool(EllipticalROITool);
cornerstoneTools.addTool(BidirectionalTool);
cornerstoneTools.addTool(AngleTool);
cornerstoneTools.addTool(ArrowAnnotateTool);

// Define a tool group, which defines how mouse events map to tool commands for
// Any viewport using the group
const toolGroup = ToolGroupManager.createToolGroup(toolGroupId);

// Add the tools to the tool group
toolGroup.addTool(LengthTool.toolName);
toolGroup.addTool(ProbeTool.toolName);
toolGroup.addTool(RectangleROITool.toolName);
toolGroup.addTool(EllipticalROITool.toolName);
toolGroup.addTool(BidirectionalTool.toolName);
toolGroup.addTool(AngleTool.toolName);
toolGroup.addTool(ArrowAnnotateTool.toolName);

// Set the initial state of the tools, here we set one tool active on left click.
// This means left click will draw that tool.
toolGroup.setToolActive(LengthTool.toolName, {
bindings: [
{
mouseButton: MouseBindings.Primary, // Left Click
},
],
});
// We set all the other tools passive here, this means that any state is rendered, and editable
// But aren't actively being drawn (see the toolModes example for information)
toolGroup.setToolPassive(ProbeTool.toolName);
toolGroup.setToolPassive(RectangleROITool.toolName);
toolGroup.setToolPassive(EllipticalROITool.toolName);
toolGroup.setToolPassive(BidirectionalTool.toolName);
toolGroup.setToolPassive(AngleTool.toolName);
toolGroup.setToolPassive(ArrowAnnotateTool.toolName);

// Get Cornerstone imageIds and fetch metadata into RAM
const imageIds = await createImageIdsAndCacheMetaData({
StudyInstanceUID:
'1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463',
SeriesInstanceUID:
'1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561',
wadoRsRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb',
});

// Instantiate a rendering engine
const renderingEngine = new RenderingEngine(renderingEngineId);

// Create a stack viewport
const viewportInput = {
viewportId,
type: ViewportType.STACK,
element,
defaultOptions: {
background: <Types.Point3>[0.2, 0, 0.2],
},
};

renderingEngine.enableElement(viewportInput);

// Set the tool group on the viewport
toolGroup.addViewport(viewportId, renderingEngineId);

// Get the stack viewport that was created
const viewport = <Types.IStackViewport>(
renderingEngine.getViewport(viewportId)
);

// Define a stack containing a single image
const stack = [imageIds[0]];

// Set the stack on the viewport
viewport.setStack(stack);

// Render the image
viewport.render();
}

run();
16 changes: 16 additions & 0 deletions packages/tools/src/eventDispatchers/mouseToolEventDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
mouseUp,
mouseWheel,
} from './mouseEventHandlers';
import suppressEventForToolInteraction from './shared/suppressEventForToolInteraction';

/**
* Enable these listeners are emitted in order, and can be cancelled/prevented from bubbling
Expand All @@ -28,6 +29,14 @@ const enable = function (element: HTMLDivElement): void {
Events.MOUSE_DOUBLE_CLICK,
mouseDoubleClick as EventListener
);

// Best way to prevent a double click during tool interaction is on the event capture phase.
element.addEventListener(
Events.MOUSE_DOUBLE_CLICK,
suppressEventForToolInteraction as EventListener,
{ capture: true } // capture phase event
);

element.addEventListener(Events.MOUSE_DRAG, mouseDrag as EventListener);
element.addEventListener(Events.MOUSE_MOVE, mouseMove as EventListener);
element.addEventListener(Events.MOUSE_UP, mouseUp as EventListener);
Expand All @@ -50,6 +59,13 @@ const disable = function (element: HTMLDivElement) {
Events.MOUSE_DOUBLE_CLICK,
mouseDoubleClick as EventListener
);

element.removeEventListener(
Events.MOUSE_DOUBLE_CLICK,
suppressEventForToolInteraction as EventListener,
{ capture: true } // capture phase event
);

element.removeEventListener(Events.MOUSE_DRAG, mouseDrag as EventListener);
element.removeEventListener(Events.MOUSE_MOVE, mouseMove as EventListener);
element.removeEventListener(Events.MOUSE_UP, mouseUp as EventListener);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { state } from '../../store';

/**
* @function suppressEventForToolInteraction This is used as a generic event handler for tool events
* on viewports that should be suppressed when the tool in being interacted with.
*
* @param evt the event to possibly suppress
*/
export default function suppressEventForToolInteraction(evt) {
if (state.isInteractingWithTool) {
// Allow no further siblings or ancestors (on bubble phase) or descendants (on capture phase)
// to handle this event.
evt.stopImmediatePropagation();
evt.preventDefault();
return false;
}
}
11 changes: 10 additions & 1 deletion packages/tools/src/eventListeners/mouse/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import mouseDoubleClickListener from './mouseDoubleClickListener';
import mouseDownListener from './mouseDownListener';
import mouseDownListener, {
mouseDoubleClickIgnoreListener,
} from './mouseDownListener';
import mouseMoveListener from './mouseMoveListener';

/**
Expand All @@ -12,6 +14,9 @@ import mouseMoveListener from './mouseMoveListener';
*/
function disable(element: HTMLDivElement): void {
element.removeEventListener('dblclick', mouseDoubleClickListener);
element.removeEventListener('dblclick', mouseDoubleClickIgnoreListener, {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds the listener for every enabled viewport. It is a bit of a waste since only one (global) listener is required.

capture: true, // capture phase is the best way to ignore double clicks
});
element.removeEventListener('mousedown', mouseDownListener);
element.removeEventListener('mousemove', mouseMoveListener);
}
Expand All @@ -29,6 +34,10 @@ function enable(element: HTMLDivElement): void {
disable(element);

element.addEventListener('dblclick', mouseDoubleClickListener);
element.addEventListener('dblclick', mouseDoubleClickIgnoreListener, {
capture: true, // capture phase is the best way to ignore double clicks
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there any reason we are adding again the dblclick listener?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes there are two reasons...

  • If the logic of mouseDoubleClickIgnoreListener is moved into mouseDoubleClickListener, then the ignoreDoubleClick flag in mouseDownListener has to be exposed. Furthermore its mutation from true to false also has to be exposed. I do not particularly like that.
  • Notice that a capture phase listener is used which mouseDoubleClickListener is not. To best prevent listeners of dblclick to NOT get an ignored double click, a capture phase listener is best.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then can we put them as inline comment?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most certainly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I will move this logic code out of this index.ts and put it in mouseDownListener because none of it needs to be exposed outside that module.


element.addEventListener('mousedown', mouseDownListener);
element.addEventListener('mousemove', mouseMoveListener);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,21 @@ function mouseDoubleClickListener(evt: MouseEvent): void {
deltaPoints,
};

triggerEvent(element, Events.MOUSE_DOUBLE_CLICK, eventDetail);
const preventDefault = !triggerEvent(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we rename this to consumed or similar?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or eventDidNotPropagate

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or even better const eventDidPropagate = trigger ...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that triggerEvent in turn calls dispatchEvent. I checked the mdn docs for dispatchEvent (https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent) and its return value has NOTHING to do with propagation. In fact it is related to preventDefault. So I still prefer the name I had. However of your choices I think consumed is best.

element,
Events.MOUSE_DOUBLE_CLICK,
eventDetail
);

if (preventDefault) {
// The Events.MOUSE_DOUBLE_CLICK was handled or cancelled, thus nobody else should handle this 'dblclick' event.

// Use stopImmediatePropagation to lessen the possibility that a third party 'dblclick'
// listener receives this event. However, there still is no guarantee
// that any third party listener has not already handled the event.
evt.stopImmediatePropagation();
evt.preventDefault();
}
}

export default mouseDoubleClickListener;
Loading