diff --git a/src/components/tools/AnnotationInfo.vue b/src/components/tools/AnnotationInfo.vue index 1b0f26ceb..38e90b594 100644 --- a/src/components/tools/AnnotationInfo.vue +++ b/src/components/tools/AnnotationInfo.vue @@ -4,9 +4,8 @@ import { useElementSize } from '@vueuse/core'; import { AnnotationToolStore } from '@/src/store/tools/useAnnotationTool'; import { OverlayInfo } from '@/src/composables/annotationTool'; -// These seem to work ¯\_(ツ)_/¯ const TOOLTIP_PADDING_X = 30; -const TOOLTIP_PADDING_Y = 10; +const TOOLTIP_PADDING_Y = 16; const props = defineProps<{ info: OverlayInfo; @@ -78,6 +77,7 @@ const offset = computed(() => { background: rgba(255, 255, 255, 0.9) !important; padding-left: 0; padding-right: 0; + pointer-events: none; } .tooltip-text { diff --git a/src/components/tools/polygon/PolygonTool.vue b/src/components/tools/polygon/PolygonTool.vue index e639ff3c4..14cbc7f20 100644 --- a/src/components/tools/polygon/PolygonTool.vue +++ b/src/components/tools/polygon/PolygonTool.vue @@ -92,6 +92,7 @@ import { Tools } from '@/src/store/tools/types'; import { getLPSAxisFromDir } from '@/src/utils/lps'; import { LPSAxisDir } from '@/src/types/lps'; import { usePolygonStore } from '@/src/store/tools/polygons'; +import { useRectangleStore } from '@/src/store/tools/rectangles'; import { useContextMenu, useCurrentTools, @@ -235,19 +236,41 @@ export default defineComponent({ // --- // - const { contextMenu, openContextMenu } = useContextMenu(); + const { contextMenu, openContextMenu: baseOpenContextMenu } = + useContextMenu(); + + const rectangleStore = useRectangleStore(); + const shouldSuppressInteraction = (id: ToolID) => { + const rectanglePlacing = rectangleStore.tools.some( + (tool) => tool.placing && tool.firstPoint && tool.secondPoint + ); + if (rectanglePlacing) return true; + if (placingTool.id.value && id !== placingTool.id.value) { + const placingToolData = activeToolStore.toolByID[placingTool.id.value]; + if (placingToolData?.points?.length > 0) return true; + } + return false; + }; + + const openContextMenu = (id: ToolID, event: any) => { + if (!shouldSuppressInteraction(id)) baseOpenContextMenu(id, event); + }; const currentTools = useCurrentTools( activeToolStore, viewAxis, - // only show this view's placing tool - computed(() => { - if (placingTool.id.value) return [placingTool.id.value]; - return []; - }) + computed(() => (placingTool.id.value ? [placingTool.id.value] : [])) ); - const { onHover, overlayInfo } = useHover(currentTools, slice); + const { onHover: baseOnHover, overlayInfo } = useHover(currentTools, slice); + + const onHover = (id: ToolID, event: any) => { + if (shouldSuppressInteraction(id)) { + baseOnHover(id, { ...event, hovering: false }); + return; + } + baseOnHover(id, event); + }; const mergePossible = computed( () => activeToolStore.mergeableTools.length >= 1 diff --git a/src/components/tools/polygon/PolygonWidget2D.vue b/src/components/tools/polygon/PolygonWidget2D.vue index 6c151f6ec..da252fc3c 100644 --- a/src/components/tools/polygon/PolygonWidget2D.vue +++ b/src/components/tools/polygon/PolygonWidget2D.vue @@ -101,8 +101,17 @@ export default defineComponent({ onVTKEvent(widget, 'onDraggingEvent', (eventData: any) => { dragging.value = eventData.dragging; }); + const anotherToolPlacing = computed(() => + toolStore.tools.some( + (t) => t.placing && t.id !== toolId.value && t.points.length > 0 + ) + ); const showHandles = computed(() => { - return lastHoverEventData.value?.hovering && !dragging.value; + return ( + lastHoverEventData.value?.hovering && + !dragging.value && + !anotherToolPlacing.value + ); }); watchEffect(() => { if (!lastHoverEventData.value) return; diff --git a/src/components/tools/rectangle/RectangleTool.vue b/src/components/tools/rectangle/RectangleTool.vue index 4b994fd4e..e0b5464c8 100644 --- a/src/components/tools/rectangle/RectangleTool.vue +++ b/src/components/tools/rectangle/RectangleTool.vue @@ -28,6 +28,7 @@ import { Tools } from '@/src/store/tools/types'; import { getLPSAxisFromDir } from '@/src/utils/lps'; import { LPSAxisDir } from '@/src/types/lps'; import { useRectangleStore } from '@/src/store/tools/rectangles'; +import { usePolygonStore } from '@/src/store/tools/polygons'; import { useCurrentTools, useContextMenu, @@ -39,6 +40,7 @@ import AnnotationInfo from '@/src/components/tools/AnnotationInfo.vue'; import { useFrameOfReference } from '@/src/composables/useFrameOfReference'; import { Maybe } from '@/src/types'; import { useSliceInfo } from '@/src/composables/useSliceInfo'; +import { ToolID } from '@/src/types/annotation-tool'; import { watchImmediate } from '@vueuse/core'; import RectangleWidget2D from './RectangleWidget2D.vue'; @@ -121,7 +123,8 @@ export default defineComponent({ // --- // - const { contextMenu, openContextMenu } = useContextMenu(); + const { contextMenu, openContextMenu: baseOpenContextMenu } = + useContextMenu(); const currentTools = useCurrentTools( activeToolStore, @@ -133,7 +136,31 @@ export default defineComponent({ }) ); - const { onHover, overlayInfo } = useHover(currentTools, slice); + const { onHover: baseOnHover, overlayInfo } = useHover(currentTools, slice); + + // Check if any polygon is actively being placed (has points) + const polygonStore = usePolygonStore(); + const isAnyPolygonPlacing = () => { + return polygonStore.tools.some( + (tool) => tool.placing && tool.points.length > 0 + ); + }; + + // Suppress hover/context menu when a polygon is actively being placed + const onHover = (id: ToolID, event: any) => { + if (isAnyPolygonPlacing()) { + baseOnHover(id, { ...event, hovering: false }); + return; + } + baseOnHover(id, event); + }; + + const openContextMenu = (id: ToolID, event: any) => { + if (isAnyPolygonPlacing()) { + return; + } + baseOpenContextMenu(id, event); + }; return { tools: currentTools, diff --git a/src/vtk/PolygonWidget/behavior.ts b/src/vtk/PolygonWidget/behavior.ts index bd11d34ef..6fb2d7db9 100644 --- a/src/vtk/PolygonWidget/behavior.ts +++ b/src/vtk/PolygonWidget/behavior.ts @@ -23,6 +23,11 @@ const DOUBLE_CLICK_SLIP_DISTANCE_MAX_SQUARED = export default function widgetBehavior(publicAPI: any, model: any) { model.classHierarchy.push('vtkPolygonWidgetBehavior'); + const anotherWidgetHasFocus = () => + model._widgetManager + .getWidgets() + .some((w: any) => w !== publicAPI && w.hasFocus()); + const setDragging = (isDragging: boolean) => { model._dragging = isDragging; publicAPI.invokeDraggingEvent({ @@ -192,8 +197,6 @@ export default function widgetBehavior(publicAPI: any, model: any) { if (model.widgetState.getPlacing() && manipulator) { // Dropping first point? if (model.widgetState.getHandles().length === 0) { - // update variables used by updateActiveStateHandle - model.activeState = model.widgetState.getMoveHandle(); model._widgetManager.grabFocus(publicAPI); } updateActiveStateHandle(event); @@ -248,8 +251,12 @@ export default function widgetBehavior(publicAPI: any, model: any) { // So we can rely on getSelections() to be up to date now overUnselectedHandle = false; - if (model.hasFocus) { - model._widgetManager.disablePicking(); + if (anotherWidgetHasFocus()) { + publicAPI.invokeHoverEvent({ + ...event, + hovering: false, + }); + return macro.VOID; } publicAPI.invokeHoverEvent({ @@ -336,7 +343,6 @@ export default function widgetBehavior(publicAPI: any, model: any) { (model.hasFocus && !model.activeState) || (model.activeState && !model.activeState.getActive()) ) { - // update if mouse hovered over handle/activeState for next onDown model._widgetManager.enablePicking(); model._interactor.render(); } @@ -415,13 +421,18 @@ export default function widgetBehavior(publicAPI: any, model: any) { }; publicAPI.handleRightButtonPress = (eventData: any) => { + // When placing, handle right-click regardless of what widget manager picked + if (model.widgetState.getPlacing()) { + removeLastHandle(); + return macro.EVENT_ABORT; + } + if (!model.activeState) { return macro.VOID; } - if (model.widgetState.getPlacing()) { - removeLastHandle(); - return macro.EVENT_ABORT; + if (anotherWidgetHasFocus()) { + return macro.VOID; } const eventWithWidgetAction = { @@ -451,7 +462,8 @@ export default function widgetBehavior(publicAPI: any, model: any) { // Called after we are finished/placed. publicAPI.loseFocus = () => { - if (model.hasFocus) { + const hadFocus = model.hasFocus; + if (hadFocus) { model._interactor.cancelAnimation(publicAPI); publicAPI.invokeEndInteractionEvent(); } @@ -461,6 +473,14 @@ export default function widgetBehavior(publicAPI: any, model: any) { model.widgetState.getMoveHandle().setOrigin(null); model.activeState = null; model.hasFocus = false; + if (hadFocus) { + model._widgetManager.releaseFocus(); + // Deactivate all widgets so stale activeStates don't persist + // (user may right-click again without moving mouse) + model._widgetManager + .getWidgets() + .forEach((w: any) => w.deactivateAllHandles()); + } model._widgetManager.enablePicking(); }; diff --git a/src/vtk/RulerWidget/behavior.ts b/src/vtk/RulerWidget/behavior.ts index b3b03cb9e..0676c6c9a 100644 --- a/src/vtk/RulerWidget/behavior.ts +++ b/src/vtk/RulerWidget/behavior.ts @@ -16,6 +16,11 @@ export function shouldIgnoreEvent(e: any) { export default function widgetBehavior(publicAPI: any, model: any) { model.classHierarchy.push('vtkRulerWidgetProp'); + const anotherWidgetHasFocus = () => + model._widgetManager + .getWidgets() + .some((w: any) => w !== publicAPI && w.hasFocus()); + model.interactionState = InteractionState.Select; let draggingState: any = null; @@ -184,6 +189,14 @@ export default function widgetBehavior(publicAPI: any, model: any) { return macro.EVENT_ABORT; } + if (anotherWidgetHasFocus()) { + publicAPI.invokeHoverEvent({ + ...eventData, + hovering: false, + }); + return macro.VOID; + } + publicAPI.invokeHoverEvent({ ...eventData, hovering: !!model.activeState || checkOverFill(), @@ -220,6 +233,11 @@ export default function widgetBehavior(publicAPI: any, model: any) { ) { return macro.VOID; } + + if (anotherWidgetHasFocus()) { + return macro.VOID; + } + publicAPI.invokeRightClickEvent(eventData); return macro.EVENT_ABORT; }; diff --git a/tests/specs/polygon-nested-interaction.e2e.ts b/tests/specs/polygon-nested-interaction.e2e.ts new file mode 100644 index 000000000..0e7fb9706 --- /dev/null +++ b/tests/specs/polygon-nested-interaction.e2e.ts @@ -0,0 +1,220 @@ +import { type ChainablePromiseElement } from 'webdriverio'; +import AppPage from '../pageobjects/volview.page'; +import { MINIMAL_DICOM } from './configTestUtils'; + +// Low-level mouse helpers +const clickAt = async (x: number, y: number) => { + await browser + .action('pointer') + .move({ x: Math.round(x), y: Math.round(y) }) + .down() + .up() + .perform(); +}; + +const rightClickAt = async (x: number, y: number) => { + await browser + .action('pointer') + .move({ x: Math.round(x), y: Math.round(y) }) + .down({ button: 2 }) + .up({ button: 2 }) + .perform(); +}; + +const moveTo = async (x: number, y: number) => { + await browser + .action('pointer') + .move({ x: Math.round(x), y: Math.round(y) }) + .perform(); +}; + +// Test setup +const setupTest = async () => { + await AppPage.open(`?urls=${MINIMAL_DICOM.url}&names=${MINIMAL_DICOM.name}`); + await AppPage.waitForViews(); + + const views2D = await AppPage.getViews2D(); + const axialView = views2D[0]; + const canvas = await axialView.$('canvas'); + const location = await canvas.getLocation(); + const size = await canvas.getSize(); + + return { + axialView, + centerX: location.x + size.width / 2, + centerY: location.y + size.height / 2, + }; +}; + +// Tool selection +const selectPolygonTool = async () => { + const btn = await $('button span i[class~=mdi-pentagon-outline]'); + await btn.click(); +}; + +const selectRectangleTool = async () => { + const btn = await $('button span i[class~=mdi-vector-square]'); + await btn.click(); +}; + +// High-level shape creation +const createSquarePolygon = async ( + centerX: number, + centerY: number, + halfSize: number +) => { + const s = halfSize; + await clickAt(centerX - s, centerY - s); + await clickAt(centerX + s, centerY - s); + await clickAt(centerX + s, centerY + s); + await clickAt(centerX - s, centerY + s); + await clickAt(centerX - s, centerY - s); // close +}; + +const createTrianglePolygon = async ( + centerX: number, + centerY: number, + offsets: [number, number][] +) => { + for (const [dx, dy] of offsets) { + await clickAt(centerX + dx, centerY + dy); + } + await clickAt(centerX + offsets[0][0], centerY + offsets[0][1]); // close +}; + +const startPolygonPoints = async ( + centerX: number, + centerY: number, + offsets: [number, number][] +) => { + for (const [dx, dy] of offsets) { + await clickAt(centerX + dx, centerY + dy); + } +}; + +const createRectangle = async ( + centerX: number, + centerY: number, + halfSize: number +) => { + await clickAt(centerX - halfSize, centerY - halfSize); + await clickAt(centerX + halfSize, centerY + halfSize); +}; + +// Assertions +const getCircleCount = async (axialView: ChainablePromiseElement) => { + const circles = await axialView.$$('svg circle'); + return circles.length; +}; + +const waitForPointRemoved = async ( + axialView: ChainablePromiseElement, + countBefore: number, + msg: string +) => { + await browser.waitUntil( + async () => (await getCircleCount(axialView)) === countBefore - 1, + { timeout: 5000, timeoutMsg: msg } + ); +}; + +const getVisibleCircleCount = async (axialView: ChainablePromiseElement) => { + const circles = await axialView.$$('svg circle'); + let count = 0; + for (const circle of circles) { + if ((await circle.getAttribute('visibility')) !== 'hidden') count++; + } + return count; +}; + +describe('Polygon tool nested interaction', () => { + it('should not show first polygon handles when hovering over it while placing second polygon', async () => { + const { axialView, centerX, centerY } = await setupTest(); + await selectPolygonTool(); + + await createSquarePolygon(centerX, centerY, 80); + expect(await getCircleCount(axialView)).toBe(4); + + await startPolygonPoints(centerX, centerY, [ + [-40, -40], + [40, -40], + ]); + + await moveTo(centerX - 80, centerY - 80); + + await browser.waitUntil( + async () => (await getVisibleCircleCount(axialView)) <= 2, + { + timeout: 3000, + timeoutMsg: + 'First polygon handles should NOT be visible when hovering while placing second polygon', + } + ); + }); + + it('should allow right-click to remove points on third polygon inside existing one', async () => { + const { axialView, centerX, centerY } = await setupTest(); + await selectPolygonTool(); + + await createSquarePolygon(centerX, centerY, 100); + await createTrianglePolygon(centerX, centerY, [ + [-60, -60], + [-20, -60], + [-40, -20], + ]); + await startPolygonPoints(centerX, centerY, [ + [20, 20], + [60, 20], + ]); + + const countBefore = await getCircleCount(axialView); + await rightClickAt(centerX + 40, centerY + 40); + await waitForPointRemoved( + axialView, + countBefore, + 'Right-click should remove one point from the third polygon' + ); + }); + + it('should allow right-click to remove points while placing new polygon inside existing one', async () => { + const { axialView, centerX, centerY } = await setupTest(); + await selectPolygonTool(); + + await createSquarePolygon(centerX, centerY, 80); + await startPolygonPoints(centerX, centerY, [ + [-40, -40], + [40, -40], + [40, 40], + ]); + + const countBefore = await getCircleCount(axialView); + await rightClickAt(centerX, centerY); + await waitForPointRemoved( + axialView, + countBefore, + 'Right-click should remove exactly one point from the placing polygon' + ); + }); + + it('should allow right-click to remove polygon points when placing inside a rectangle', async () => { + const { axialView, centerX, centerY } = await setupTest(); + + await selectRectangleTool(); + await createRectangle(centerX, centerY, 80); + + await selectPolygonTool(); + await startPolygonPoints(centerX, centerY, [ + [-40, -40], + [40, -40], + [40, 40], + ]); + + const countBefore = await getCircleCount(axialView); + await rightClickAt(centerX, centerY); + await waitForPointRemoved( + axialView, + countBefore, + 'Right-click should remove one point from the polygon when placing inside a rectangle' + ); + }); +}); diff --git a/tests/specs/remote-manifest.e2e.ts b/tests/specs/remote-manifest.e2e.ts index 6920afcdf..64b509c82 100644 --- a/tests/specs/remote-manifest.e2e.ts +++ b/tests/specs/remote-manifest.e2e.ts @@ -9,12 +9,14 @@ describe('VolView loading of remoteManifest.json', () => { }; const fileName = 'remoteFilesBadUrl.json'; await writeManifestToFile(manifest, fileName); - await openVolViewPage(fileName); + + const urlParams = `?urls=[tmp/${fileName}]`; + await volViewPage.open(urlParams); await volViewPage.waitForNotification(); }); - it.skip('should load relative URI with no name property', async () => { + it('should load relative URI with no name property', async () => { await downloadFile(MINIMAL_DICOM.url, MINIMAL_DICOM.name); const manifest = {