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
8 changes: 6 additions & 2 deletions src/components/tools/SelectTool.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,18 @@ const isAnnotationWidgetState = (
onVTKEvent(
view.interactor,
'onLeftButtonPress',
(event: any) => {
async (event: any) => {
if (PLACING_TOOLS.includes(toolStore.currentTool)) {
// avoid bugs when starting a placing tool on an existing tool and right clicking and deleting existing tools
return;
}

const withModifiers = !!(event.shiftKey || event.controlKey);
const selectedData = view.widgetManager.getSelectedData();
// Pick where the button went down. The widget manager's standing pick is
// whatever its last tracked mouse move resolved, which can be a different
// position or, mid capture, nothing at all.
const { x, y } = event.position;
const selectedData = await view.widgetManager.getSelectedDataForXY(x, y);
if ('widget' in selectedData) {
const widget =
selectedData.widget as Partial<vtkAnnotationToolWidget> | null;
Expand Down
2 changes: 1 addition & 1 deletion src/shims-vtk.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ declare module '@kitware/vtk.js/Widgets/Core/WidgetManager' {
): vtkAbstractWidget | null;
removeWidgets(): void;
removeWidget(widget: vtkAbstractWidget | vtkAbstractWidgetFactory): void;
getSelectedDataForXY(x: number, y: number): Promise<SelectedData>;
getSelectedDataForXY(x: number, y: number): Promise<SelectedData | {}>;
updateSelectionFromXY(x: number, y: number): void;
updateSelectionFromMouseEvent(event: MouseEvent): void;
getSelectedData(): SelectedData | {};
Expand Down
130 changes: 130 additions & 0 deletions tests/specs/select-annotation-at-press.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { type ChainablePromiseElement } from 'webdriverio';
import AppPage from '../pageobjects/volview.page';
import { clickAt, setupTest, waitForCircleCount } from './annotationTestUtils';

// BoundingRectangle.vue draws this around the selected annotation
const getSelectionRectCount = async (axialView: ChainablePromiseElement) => {
const rects = await axialView.$$('svg rect[stroke="lightgray"]');
return rects.length;
};

const waitForSelectionRectCount = (
axialView: ChainablePromiseElement,
expected: number,
timeoutMsg: string
) =>
browser.waitUntil(
async () => (await getSelectionRectCount(axialView)) === expected,
{ timeout: 5000, timeoutMsg }
);

// One input source held across action chains, so a press can land exactly where
// an earlier chain left the pointer. Chains that keep it perform without
// releasing actions, as releasing resets the pointer to the viewport origin.
const HOVERING_MOUSE = 'hovering-mouse';
const hoveringMouse = () => browser.action('pointer', { id: HOVERING_MOUSE });

// A move with a duration is interpolated into a stream of pointer moves. A
// zero duration dispatches exactly one, which is what teleportTo relies on.
const INSTANT = 0;
const NUDGE_PX = 2;

// Two moves in one chain, so the one landing on (x, y) is never the first move
// after an idle period, which vtk.js reports as StartMouseMove and the widget
// manager ignores. The pick therefore runs at (x, y).
const nudgeTo = (x: number, y: number) =>
hoveringMouse()
.move({
duration: INSTANT,
x: Math.round(x) + NUDGE_PX,
y: Math.round(y) + NUDGE_PX,
})
.move({ duration: INSTANT, x: Math.round(x), y: Math.round(y) })
.perform(true);

// vtk.js reports the first pointer move after ~200ms of stillness as
// StartMouseMove, which the widget manager does not subscribe to. A single move
// after that idle therefore relocates the pointer while leaving the widget
// manager's pick standing at the old position.
const IDLE_MS = 400;

const teleportTo = async (x: number, y: number) => {
await browser.pause(IDLE_MS);
await hoveringMouse()
.move({ duration: INSTANT, x: Math.round(x), y: Math.round(y) })
.perform(true);
};

const pressAtPointer = () => hoveringMouse().down().up().perform(true);

const placeRectangle = async (cx: number, cy: number, halfSize: number) => {
await AppPage.selectTool('mdi-vector-square');
await clickAt(cx - halfSize, cy - halfSize);
await clickAt(cx + halfSize, cy + halfSize);
};

// Hovers the handle and presses until the annotation selects, which proves the
// widget manager resolved a pick there and left it as its standing selection.
const hoverAndSelect = async (
axialView: ChainablePromiseElement,
x: number,
y: number
) =>
browser.waitUntil(
async () => {
await nudgeTo(x, y);
await pressAtPointer();
return (await getSelectionRectCount(axialView)) === 1;
},
{
timeout: 15000,
interval: 500,
timeoutMsg: 'Pressing on the annotation handle should select it',
}
);

describe('Selection picks at the press position', () => {
it('does not select an annotation the pointer left without a tracked move', async () => {
const { axialView, centerX, centerY } = await setupTest();

const handleX = centerX - 80;
const handleY = centerY - 80;
await placeRectangle(centerX, centerY, 80);
await waitForCircleCount(axialView, 2, 'Rectangle should have two handles');

await AppPage.selectTool('mdi-cursor-default');
await hoverAndSelect(axialView, handleX, handleY);

// Empty image area, well clear of both handles and the rectangle outline
await teleportTo(centerX + 140, centerY - 140);
await pressAtPointer();

await waitForSelectionRectCount(
axialView,
0,
'Pressing on empty space should deselect, not act on the pick left behind at the handle'
);
});

// Control for the case above: same annotation, same press position, only the
// move onto empty space is one the widget manager tracks. Deselecting here
// shows the press does reach the view and that nothing is pickable there.
it('deselects when the move onto empty space is tracked', async () => {
const { axialView, centerX, centerY } = await setupTest();

await placeRectangle(centerX, centerY, 80);
await waitForCircleCount(axialView, 2, 'Rectangle should have two handles');

await AppPage.selectTool('mdi-cursor-default');
await hoverAndSelect(axialView, centerX - 80, centerY - 80);

await nudgeTo(centerX + 140, centerY - 140);
await pressAtPointer();

await waitForSelectionRectCount(
axialView,
0,
'Pressing on empty space should deselect the rectangle'
);
});
});
Loading