From f091285c3199428db1cb948637659e6072d9a516 Mon Sep 17 00:00:00 2001 From: Alireza Date: Fri, 17 Jul 2026 12:02:21 -0400 Subject: [PATCH 1/3] feat(tools): touch interaction support with dispatcher fallback to mouse callbacks Touch dispatchers now fall back to the mouse counterpart callback when a tool has no touch-specific handler (touchDragCallback to mouseDragCallback, pre/postTouchStartCallback to pre/postMouseDownCallback, touchTapCallback to mouseClickCallback), gated by the tool declaring Touch in supportedInteractionTypes. This makes the previously mouse-only entry tools (Brush, Scissors, PaintFill, MIPJumpToClick, ClickSegment, Sculptor, RegionSegment, WholeBodySegment) reachable by touch, and the rendering engine now sets touch-action none on enabled viewport elements so the browser no longer consumes viewport gestures. --- .../RenderingEngine/BaseRenderingEngine.ts | 9 + .../ContextPoolRenderingEngine.ts | 5 + .../RenderingEngine/TiledRenderingEngine.ts | 5 + .../helpers/elementTouchAction.ts | 31 + .../getCurrentVolumeViewportSlice.ts | 4 +- .../tools/examples/touchAllTools/index.ts | 647 ++++++++++++++++++ .../getTouchCallbackWithMouseFallback.ts | 48 ++ .../touchEventHandlers/touchDrag.ts | 18 +- .../touchEventHandlers/touchStart.ts | 25 +- .../touchEventHandlers/touchStartActivate.ts | 8 +- .../touchEventHandlers/touchTap.ts | 49 +- .../touchToolEventDispatcher.ts | 1 + .../touch/touchStartListener.ts | 6 + .../src/store/ToolGroupManager/ToolGroup.ts | 4 +- .../tools/src/tools/AdvancedMagnifyTool.ts | 256 ++++++- packages/tools/src/tools/CrosshairsTool.ts | 7 +- packages/tools/src/tools/MagnifyTool.ts | 53 +- .../tools/src/tools/TrackballRotateTool.ts | 6 + .../tools/src/tools/VolumeCroppingTool.ts | 1 + packages/tools/src/tools/VolumeRotateTool.ts | 46 +- .../src/tools/annotation/ArrowAnnotateTool.ts | 10 +- .../src/tools/annotation/ClickSegmentTool.ts | 34 + .../src/tools/annotation/DragProbeTool.ts | 11 +- .../tools/annotation/LivewireContourTool.ts | 108 ++- .../tools/src/tools/annotation/ProbeTool.ts | 64 +- .../src/tools/annotation/RegionSegmentTool.ts | 24 + .../src/tools/annotation/SplineROITool.ts | 120 +++- .../tools/annotation/WholeBodySegmentTool.ts | 18 +- .../tools/src/tools/segmentation/BrushTool.ts | 118 +++- .../tools/segmentation/CircleScissorsTool.ts | 34 +- .../RectangleROIStartEndThresholdTool.ts | 1 + .../segmentation/RectangleScissorsTool.ts | 35 +- .../tools/segmentation/SegmentLabelTool.ts | 2 +- .../tools/segmentation/SegmentSelectTool.ts | 2 +- .../tools/segmentation/SphereScissorsTool.ts | 34 +- packages/tools/src/utilities/touch/index.ts | 14 + utils/ExampleRunner/template-config.js | 259 +++---- 37 files changed, 1919 insertions(+), 198 deletions(-) create mode 100644 packages/core/src/RenderingEngine/helpers/elementTouchAction.ts create mode 100644 packages/tools/examples/touchAllTools/index.ts create mode 100644 packages/tools/src/eventDispatchers/shared/getTouchCallbackWithMouseFallback.ts diff --git a/packages/core/src/RenderingEngine/BaseRenderingEngine.ts b/packages/core/src/RenderingEngine/BaseRenderingEngine.ts index a39f86e071..904e8f1f16 100644 --- a/packages/core/src/RenderingEngine/BaseRenderingEngine.ts +++ b/packages/core/src/RenderingEngine/BaseRenderingEngine.ts @@ -8,6 +8,10 @@ import viewportTypeUsesCustomRenderingPipeline, { viewportUsesCustomRenderingPipeline, } from './helpers/viewportTypeUsesCustomRenderingPipeline'; import getOrCreateCanvas from './helpers/getOrCreateCanvas'; +import { + setElementTouchActionNone, + restoreElementTouchAction, +} from './helpers/elementTouchAction'; import { getShouldUseCPURendering, getUseGenericViewport, @@ -567,6 +571,10 @@ abstract class BaseRenderingEngine { // Make the element not focusable, we use this for modifier keys to work element.tabIndex = -1; + // Deliver touch input to cornerstone tools instead of the browser + // (scroll, pinch-zoom, double-tap zoom). Restored in _resetViewport. + setElementTouchActionNone(element); + const canvas = getOrCreateCanvas(element); // Add a viewport with no offset @@ -718,6 +726,7 @@ abstract class BaseRenderingEngine { element.removeAttribute('data-viewport-uid'); element.removeAttribute('data-rendering-engine-uid'); + restoreElementTouchAction(element); // clear drawing const context = canvas.getContext('2d'); diff --git a/packages/core/src/RenderingEngine/ContextPoolRenderingEngine.ts b/packages/core/src/RenderingEngine/ContextPoolRenderingEngine.ts index cc6f2d4dad..8273e22bf6 100644 --- a/packages/core/src/RenderingEngine/ContextPoolRenderingEngine.ts +++ b/packages/core/src/RenderingEngine/ContextPoolRenderingEngine.ts @@ -11,6 +11,7 @@ import viewportTypeUsesCustomRenderingPipeline, { import getOrCreateCanvas, { updateCanvasSizeAndAspectRatio, } from './helpers/getOrCreateCanvas'; +import { setElementTouchActionNone } from './helpers/elementTouchAction'; import type * as EventTypes from '../types/EventTypes'; import type { ViewportInput, @@ -92,6 +93,10 @@ class ContextPoolRenderingEngine extends BaseRenderingEngine { element.tabIndex = -1; + // Deliver touch input to cornerstone tools instead of the browser + // (scroll, pinch-zoom, double-tap zoom). Restored in _resetViewport. + setElementTouchActionNone(element); + // Assign viewport to a context // Stack viewports can be distributed across contexts. Planar Next may mount // volume-backed GPU renderings, which cannot safely share volume textures diff --git a/packages/core/src/RenderingEngine/TiledRenderingEngine.ts b/packages/core/src/RenderingEngine/TiledRenderingEngine.ts index b4d469603a..42eb832b19 100644 --- a/packages/core/src/RenderingEngine/TiledRenderingEngine.ts +++ b/packages/core/src/RenderingEngine/TiledRenderingEngine.ts @@ -8,6 +8,7 @@ import type IStackViewport from '../types/IStackViewport'; import type IVolumeViewport from '../types/IVolumeViewport'; import { vtkOffscreenMultiRenderWindow } from './vtkClasses'; import { attachWebGLContextEvents } from './helpers/attachWebGLContextEvents'; +import { setElementTouchActionNone } from './helpers/elementTouchAction'; import type * as EventTypes from '../types/EventTypes'; import type { @@ -144,6 +145,10 @@ class TiledRenderingEngine extends BaseRenderingEngine { // Make the element not focusable, we use this for modifier keys to work element.tabIndex = -1; + // Deliver touch input to cornerstone tools instead of the browser + // (scroll, pinch-zoom, double-tap zoom). Restored in _resetViewport. + setElementTouchActionNone(element); + const { offScreenCanvasWidth, offScreenCanvasHeight, xOffset } = offscreenCanvasProperties; diff --git a/packages/core/src/RenderingEngine/helpers/elementTouchAction.ts b/packages/core/src/RenderingEngine/helpers/elementTouchAction.ts new file mode 100644 index 0000000000..7b14130185 --- /dev/null +++ b/packages/core/src/RenderingEngine/helpers/elementTouchAction.ts @@ -0,0 +1,31 @@ +const PRIOR_TOUCH_ACTION_ATTRIBUTE = 'data-prior-touch-action'; + +/** + * Sets `touch-action: none` on an enabled viewport element so the browser + * does not consume touch input (scrolling, pinch-zoom, double-tap zoom) + * before cornerstone tools receive the events. The prior inline value is + * stored on the element so disable can restore it. + */ +export function setElementTouchActionNone(element: HTMLDivElement): void { + if (!element.hasAttribute(PRIOR_TOUCH_ACTION_ATTRIBUTE)) { + element.setAttribute( + PRIOR_TOUCH_ACTION_ATTRIBUTE, + element.style.touchAction + ); + } + element.style.touchAction = 'none'; +} + +/** + * Restores the inline `touch-action` value the element had before + * setElementTouchActionNone. No-op for elements that were never enabled. + */ +export function restoreElementTouchAction(element: HTMLDivElement): void { + if (!element.hasAttribute(PRIOR_TOUCH_ACTION_ATTRIBUTE)) { + return; + } + element.style.touchAction = element.getAttribute( + PRIOR_TOUCH_ACTION_ATTRIBUTE + ); + element.removeAttribute(PRIOR_TOUCH_ACTION_ATTRIBUTE); +} diff --git a/packages/core/src/utilities/getCurrentVolumeViewportSlice.ts b/packages/core/src/utilities/getCurrentVolumeViewportSlice.ts index a9418361e5..b3cd62ea5a 100644 --- a/packages/core/src/utilities/getCurrentVolumeViewportSlice.ts +++ b/packages/core/src/utilities/getCurrentVolumeViewportSlice.ts @@ -49,7 +49,9 @@ function getCurrentVolumeViewportSlice(viewport: IVolumeViewport) { // Using glMatrix.equals() because the number may be not exactly equal to // 1 due to rounding issues if (!glMatrix.equals(1, maxIJKRowVec) || !glMatrix.equals(1, maxIJKColVec)) { - throw new Error('Livewire is not available for rotate/oblique viewports'); + throw new Error( + 'getCurrentVolumeViewportSlice is not available for rotate/oblique viewports' + ); } const { voxelManager } = viewport.getImageData(); diff --git a/packages/tools/examples/touchAllTools/index.ts b/packages/tools/examples/touchAllTools/index.ts new file mode 100644 index 0000000000..6cccfb4117 --- /dev/null +++ b/packages/tools/examples/touchAllTools/index.ts @@ -0,0 +1,647 @@ +import type { Types } from '@cornerstonejs/core'; +import { + RenderingEngine, + Enums, + setVolumesForViewports, + volumeLoader, + getConfiguration, + getRenderingCapabilities, +} from '@cornerstonejs/core'; +import { getBooleanUrlParam } from '../../../../utils/demo/helpers/exampleParameters'; +import { + initDemo, + createImageIdsAndCacheMetaData, + setTitleAndDescription, + setCtTransferFunctionForVolumeActor, + addDropdownToToolbar, + addSliderToToolbar, + addButtonToToolbar, + ctVoiRange, +} from '../../../../utils/demo/helpers'; +import addSegmentIndexDropdown from '../../../../utils/demo/helpers/addSegmentIndexDropdown'; +import { eventTarget } from '@cornerstonejs/core'; +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 { + ToolGroupManager, + Enums: csToolsEnums, + segmentation, + annotation, + utilities: cstUtils, + // Manipulation + WindowLevelTool, + PanTool, + ZoomTool, + StackScrollTool, + PlanarRotateTool, + WindowLevelRegionTool, + VolumeRotateTool, + MagnifyTool, + AdvancedMagnifyTool, + // Measurement / annotation + LengthTool, + BidirectionalTool, + ArrowAnnotateTool, + AngleTool, + CobbAngleTool, + EllipticalROITool, + CircleROITool, + RectangleROITool, + ProbeTool, + DragProbeTool, + PlanarFreehandROITool, + SplineROITool, + LivewireContourTool, + SculptorTool, + // Segmentation + BrushTool, + RectangleScissorsTool, + CircleScissorsTool, + SphereScissorsTool, + PaintFillTool, + LabelMapEditWithContourTool, + // Reference + CrosshairsTool, +} = cornerstoneTools; + +const { MouseBindings } = csToolsEnums; +const { ViewportType } = Enums; +const { segmentation: segmentationUtils } = cstUtils; + +const volumeName = 'CT_VOLUME_ID'; +const volumeLoaderScheme = 'cornerstoneStreamingImageVolume'; +const volumeId = `${volumeLoaderScheme}:${volumeName}`; +const segmentationId = 'TOUCH_SEGMENTATION_ID'; +const volumeToolGroupId = 'VOLUME_TOOLGROUP_ID'; +const stackToolGroupId = 'STACK_TOOLGROUP_ID'; +const renderingEngineId = 'myRenderingEngine'; +const viewportIdSagittal = 'CT_SAGITTAL'; +const viewportIdStack = 'CT_STACK'; + +setTitleAndDescription( + 'Touch: All Tools', + 'One page to exercise the touch interaction surface on a phone or tablet: ' + + 'manipulation, all measurement tools, labelmap brushing/scissors/fill, ' + + 'contour labelmap editing, click-to-segment, AdvancedMagnify and ' + + 'Crosshairs (linking the two volume viewports). Magnify runs on the ' + + 'stack viewport only. No app-level touch-action is set here - the ' + + 'rendering engine now applies it to viewport elements itself.' +); + +// Two rows: sagittal volume viewport on top, stack viewport below, each +// near full width on a phone and capped for desktop. +const size = 'min(94vw, 512px)'; +const content = document.getElementById('content'); +const viewportGrid = document.createElement('div'); + +viewportGrid.style.display = 'flex'; +viewportGrid.style.flexDirection = 'column'; +viewportGrid.style.gap = '2px'; + +const elementSagittal = document.createElement('div'); +const elementStack = document.createElement('div'); + +[elementSagittal, elementStack].forEach((element) => { + element.style.width = size; + element.style.height = size; + element.style.flexShrink = '0'; + // Disable right click context menu so we can have right click tools + element.oncontextmenu = (e) => e.preventDefault(); + viewportGrid.appendChild(element); +}); + +content.appendChild(viewportGrid); + +const instructions = document.createElement('p'); +instructions.innerText = ` + Touch gestures: + - 1-finger drag: the selected tool (dropdown above) + - 2-finger pinch: zoom (also pans with the pinch midpoint) + - 3-finger drag: scroll slices + - Spline/Livewire: tap to place points; double-tap to close + - AdvancedMagnify: tap places the loupe; long-press the loupe for zoom factors + - Probe/DragProbe: the readout renders offset above the finger + + Mouse still works everywhere: left = selected tool, middle = pan, right = zoom, wheel = scroll. + Magnify is only available on the stack viewport (bottom one). + On phones the volume is capped to 200 slices and rendered with half-precision textures to fit mobile GPU memory. + `; + +content.append(instructions); + +const brushInstanceNames = { + CircularBrush: 'CircularBrush', + CircularEraser: 'CircularEraser', + SphereBrush: 'SphereBrush', + ThresholdCircularBrush: 'ThresholdCircularBrush', +}; + +const brushStrategies = { + [brushInstanceNames.CircularBrush]: 'FILL_INSIDE_CIRCLE', + [brushInstanceNames.CircularEraser]: 'ERASE_INSIDE_CIRCLE', + [brushInstanceNames.SphereBrush]: 'FILL_INSIDE_SPHERE', + [brushInstanceNames.ThresholdCircularBrush]: 'THRESHOLD_INSIDE_CIRCLE', +}; + +// Tools selectable onto Primary (and therefore 1-finger touch). Order matters +// only for the dropdown display. +const selectableToolNames = [ + // Manipulation + WindowLevelTool.toolName, + PanTool.toolName, + ZoomTool.toolName, + StackScrollTool.toolName, + PlanarRotateTool.toolName, + WindowLevelRegionTool.toolName, + VolumeRotateTool.toolName, + MagnifyTool.toolName, + AdvancedMagnifyTool.toolName, + // Measurement + LengthTool.toolName, + BidirectionalTool.toolName, + ArrowAnnotateTool.toolName, + AngleTool.toolName, + CobbAngleTool.toolName, + EllipticalROITool.toolName, + CircleROITool.toolName, + RectangleROITool.toolName, + ProbeTool.toolName, + DragProbeTool.toolName, + PlanarFreehandROITool.toolName, + SplineROITool.toolName, + LivewireContourTool.toolName, + SculptorTool.toolName, + // Segmentation + brushInstanceNames.CircularBrush, + brushInstanceNames.CircularEraser, + brushInstanceNames.SphereBrush, + brushInstanceNames.ThresholdCircularBrush, + RectangleScissorsTool.toolName, + CircleScissorsTool.toolName, + SphereScissorsTool.toolName, + PaintFillTool.toolName, + LabelMapEditWithContourTool.toolName, +]; + +function setSelectedTool(toolName: string) { + [volumeToolGroupId, stackToolGroupId].forEach((toolGroupId) => { + const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); + + if (!toolGroup.hasTool(toolName)) { + return; + } + + const previousToolName = toolGroup.getActivePrimaryMouseButtonTool(); + + if (previousToolName === toolName) { + return; + } + + if (previousToolName) { + // Passive keeps existing annotations visible and editable; Crosshairs + // renders its gizmo even when passive, so it is fully disabled instead. + if (previousToolName === CrosshairsTool.toolName) { + toolGroup.setToolDisabled(previousToolName); + } else { + toolGroup.setToolPassive(previousToolName); + } + } + + toolGroup.setToolActive(toolName, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + }); +} + +addDropdownToToolbar({ + labelText: 'Tool (1-finger / left click)', + options: { + values: selectableToolNames, + defaultValue: WindowLevelTool.toolName, + }, + onSelectedValueChange: (nameAsStringOrNumber) => { + setSelectedTool(String(nameAsStringOrNumber)); + }, +}); + +addSliderToToolbar({ + title: 'Brush Size', + range: [5, 50], + defaultValue: 25, + onSelectedValueChange: (valueAsStringOrNumber) => { + const value = Number(valueAsStringOrNumber); + segmentationUtils.setBrushSizeForToolGroup(volumeToolGroupId, value); + }, +}); + +addSegmentIndexDropdown(segmentationId); + +addButtonToToolbar({ + title: 'Reset Cameras', + onClick: () => { + const renderingEngine = window.renderingEngine as RenderingEngine; + [viewportIdSagittal, viewportIdStack].forEach((viewportId) => { + const viewport = renderingEngine.getViewport(viewportId); + viewport.resetCamera(); + viewport.render(); + }); + }, +}); + +addButtonToToolbar({ + title: 'Clear Annotations', + onClick: () => { + annotation.state.removeAllAnnotations(); + (window.renderingEngine as RenderingEngine).render(); + }, +}); + +function addToolsToGroup(toolGroup, { isVolumeGroup }: { isVolumeGroup }) { + // Always-on manipulation bindings matching OHIF's standard touch surface + toolGroup.addTool(PanTool.toolName); + toolGroup.addTool(ZoomTool.toolName); + toolGroup.addTool(StackScrollTool.toolName); + + toolGroup.setToolActive(PanTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Auxiliary }], + }); + toolGroup.setToolActive(ZoomTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Secondary }, { numTouchPoints: 2 }], + }); + toolGroup.setToolActive(StackScrollTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Wheel }, { numTouchPoints: 3 }], + }); + + // Selectable manipulation tools + toolGroup.addTool(WindowLevelTool.toolName); + toolGroup.addTool(PlanarRotateTool.toolName); + toolGroup.addTool(WindowLevelRegionTool.toolName); + toolGroup.addTool(AdvancedMagnifyTool.toolName); + + // Measurement tools + toolGroup.addTool(LengthTool.toolName); + toolGroup.addTool(BidirectionalTool.toolName); + toolGroup.addTool(ArrowAnnotateTool.toolName); + toolGroup.addTool(AngleTool.toolName); + toolGroup.addTool(CobbAngleTool.toolName); + toolGroup.addTool(EllipticalROITool.toolName); + toolGroup.addTool(CircleROITool.toolName); + toolGroup.addTool(RectangleROITool.toolName); + toolGroup.addTool(ProbeTool.toolName); + toolGroup.addTool(DragProbeTool.toolName); + toolGroup.addTool(PlanarFreehandROITool.toolName); + toolGroup.addTool(SplineROITool.toolName); + toolGroup.addTool(LivewireContourTool.toolName); + toolGroup.addTool(SculptorTool.toolName); + + if (isVolumeGroup) { + toolGroup.addTool(VolumeRotateTool.toolName); + + // Labelmap editing tools + Object.entries(brushInstanceNames).forEach(([name]) => { + toolGroup.addToolInstance(name, BrushTool.toolName, { + activeStrategy: brushStrategies[name], + ...(name === brushInstanceNames.ThresholdCircularBrush + ? { + threshold: { + range: [200, 1000] as Types.Point2, // CT bone + isDynamic: false, + dynamicRadius: 0, + }, + } + : {}), + }); + }); + toolGroup.addTool(RectangleScissorsTool.toolName); + toolGroup.addTool(CircleScissorsTool.toolName); + toolGroup.addTool(SphereScissorsTool.toolName); + toolGroup.addTool(PaintFillTool.toolName); + toolGroup.addTool(LabelMapEditWithContourTool.toolName); + } else { + // Magnify is stack-viewport-only (it throws on volume viewports) + toolGroup.addTool(MagnifyTool.toolName); + } + + // Every selectable tool starts passive (annotations stay editable and + // tool groups always have toolOptions for each tool, matching how modes + // set up their groups); the selected tool is then activated onto Primary. + selectableToolNames.forEach((toolName) => { + if (toolGroup.hasTool(toolName)) { + toolGroup.setToolPassive(toolName); + } + }); + + toolGroup.setToolActive(WindowLevelTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); +} + +// Same coarse-pointer check the other examples use for mobile-specific setup +const isMobile = window.matchMedia('(any-pointer:coarse)').matches; + +// ---- On-page log console ------------------------------------------------- +// Device debugging without a tethered inspector: captures console output, +// uncaught errors and a WebGL capability report; the Logs button shows a +// copyable textarea. +const logEntries: string[] = []; + +function captureLog(level: string, args: unknown[]) { + const text = args + .map((arg) => { + if (arg instanceof Error) { + return `${arg.message}\n${arg.stack ?? ''}`; + } + if (typeof arg === 'object' && arg !== null) { + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } + } + return String(arg); + }) + .join(' '); + + logEntries.push(`[${level}] ${text}`); + if (logEntries.length > 400) { + logEntries.shift(); + } +} + +(['log', 'info', 'warn', 'error'] as const).forEach((level) => { + const original = console[level].bind(console); + console[level] = (...args: unknown[]) => { + captureLog(level, args); + original(...args); + }; +}); +window.addEventListener('error', (e) => + captureLog('uncaught', [ + e.message, + `${e.filename}:${e.lineno}`, + e.error?.stack ?? '(no stack)', + ]) +); +window.addEventListener('unhandledrejection', (e) => + captureLog('unhandledrejection', [ + e.reason, + (e.reason as Error)?.stack ?? '(no stack)', + ]) +); + +const glCaps = { webgl2: false, norm16: false }; + +function logWebGLDiagnostics() { + console.log('diag userAgent:', navigator.userAgent); + console.log('diag devicePixelRatio:', window.devicePixelRatio); + console.log('diag isMobile (any-pointer:coarse):', isMobile); + + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl2'); + if (!gl) { + console.error('diag webgl2: NOT AVAILABLE'); + return; + } + glCaps.webgl2 = true; + glCaps.norm16 = !!gl.getExtension('EXT_texture_norm16'); + const dbg = gl.getExtension('WEBGL_debug_renderer_info'); + console.log( + 'diag renderer:', + dbg + ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) + : gl.getParameter(gl.RENDERER) + ); + console.log( + 'diag EXT_texture_norm16:', + !!gl.getExtension('EXT_texture_norm16') + ); + console.log( + 'diag EXT_color_buffer_float:', + !!gl.getExtension('EXT_color_buffer_float'), + 'EXT_color_buffer_half_float:', + !!gl.getExtension('EXT_color_buffer_half_float'), + 'OES_texture_float_linear:', + !!gl.getExtension('OES_texture_float_linear') + ); + console.log( + 'diag MAX_3D_TEXTURE_SIZE:', + gl.getParameter(gl.MAX_3D_TEXTURE_SIZE), + 'MAX_TEXTURE_SIZE:', + gl.getParameter(gl.MAX_TEXTURE_SIZE) + ); + // Free the probe context; iOS caps live WebGL contexts aggressively + gl.getExtension('WEBGL_lose_context')?.loseContext(); +} + +function addLogConsoleButton() { + const overlay = document.createElement('textarea'); + overlay.readOnly = true; + overlay.style.cssText = + 'position:fixed;left:2vw;bottom:60px;width:96vw;height:45vh;z-index:1000;' + + 'display:none;font-size:11px;background:#111;color:#eee;'; + + const button = document.createElement('button'); + button.innerText = 'Logs'; + button.style.cssText = + 'position:fixed;right:8px;bottom:8px;z-index:1001;padding:8px 14px;'; + button.onclick = () => { + const visible = overlay.style.display !== 'none'; + overlay.style.display = visible ? 'none' : 'block'; + if (!visible) { + overlay.value = logEntries.join('\n'); + overlay.scrollTop = overlay.scrollHeight; + } + }; + + const copyButton = document.createElement('button'); + copyButton.innerText = 'Copy'; + copyButton.style.cssText = + 'position:fixed;right:88px;bottom:8px;z-index:1001;padding:8px 14px;'; + copyButton.onclick = () => { + overlay.style.display = 'block'; + overlay.value = logEntries.join('\n'); + overlay.select(); + // clipboard API needs a secure context; LAN http is not one + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(overlay.value); + } else { + document.execCommand('copy'); + } + }; + + document.body.append(overlay, button, copyButton); +} +// --------------------------------------------------------------------------- + +async function run() { + addLogConsoleButton(); + logWebGLDiagnostics(); + + eventTarget.addEventListener( + Enums.Events.IMAGE_VOLUME_LOADING_COMPLETED, + () => console.log('diag volume loading completed') + ); + + // isMobile drops the engine to a single WebGL context - iOS silently + // evicts pages holding several live contexts (the default pool is 7), + // which blacks out the affected viewports with no console error. + // Half-precision volume textures (preferSizeOverAccuracy) only where + // norm16 is actually missing; ?psoa=1 forces them for debugging. + await initDemo({ + core: { + isMobile, + rendering: + getBooleanUrlParam('psoa') || (isMobile && !glCaps.norm16) + ? { preferSizeOverAccuracy: true } + : {}, + }, + }); + + console.log( + 'diag rendering config:', + JSON.stringify(getConfiguration().rendering) + ); + console.log( + 'diag rendering capabilities:', + JSON.stringify(getRenderingCapabilities()) + ); + + const toolClasses = [ + WindowLevelTool, + PanTool, + ZoomTool, + StackScrollTool, + PlanarRotateTool, + WindowLevelRegionTool, + VolumeRotateTool, + MagnifyTool, + AdvancedMagnifyTool, + LengthTool, + BidirectionalTool, + ArrowAnnotateTool, + AngleTool, + CobbAngleTool, + EllipticalROITool, + CircleROITool, + RectangleROITool, + ProbeTool, + DragProbeTool, + PlanarFreehandROITool, + SplineROITool, + LivewireContourTool, + SculptorTool, + BrushTool, + RectangleScissorsTool, + CircleScissorsTool, + SphereScissorsTool, + PaintFillTool, + LabelMapEditWithContourTool, + CrosshairsTool, + ]; + + toolClasses.forEach((toolClass) => cornerstoneTools.addTool(toolClass)); + + const volumeToolGroup = ToolGroupManager.createToolGroup(volumeToolGroupId); + const stackToolGroup = ToolGroupManager.createToolGroup(stackToolGroupId); + + addToolsToGroup(volumeToolGroup, { isVolumeGroup: true }); + addToolsToGroup(stackToolGroup, { isVolumeGroup: false }); + + let 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://d14fa38qiwhyfd.cloudfront.net/dicomweb', + }); + + // Cap the volume depth on phones so the CT (plus its labelmap) fits in + // mobile GPU memory; desktop loads the full series. + const mobileMaxSlices = 200; + if (isMobile && imageIds.length > mobileMaxSlices) { + const start = Math.floor((imageIds.length - mobileMaxSlices) / 2); + imageIds = imageIds.slice(start, start + mobileMaxSlices); + } + + console.log('diag imageIds count (after mobile cap):', imageIds.length); + + const volume = await volumeLoader.createAndCacheVolume(volumeId, { + imageIds, + }); + + // Note: do NOT read volume.sizeInBytes here - on an image-backed volume it + // dereferences slice 0's image, which is not cached yet, and the throw + // (VoxelManager.bytePerVoxel on null) aborts the rest of the setup. + console.log('diag volume dimensions:', volume.dimensions); + + const renderingEngine = new RenderingEngine(renderingEngineId); + (window as { renderingEngine? }).renderingEngine = renderingEngine; + + renderingEngine.setViewports([ + { + viewportId: viewportIdSagittal, + type: ViewportType.ORTHOGRAPHIC, + element: elementSagittal, + defaultOptions: { + orientation: Enums.OrientationAxis.SAGITTAL, + background: [0, 0, 0] as Types.Point3, + }, + }, + { + viewportId: viewportIdStack, + type: ViewportType.STACK, + element: elementStack, + defaultOptions: { + background: [0, 0, 0] as Types.Point3, + }, + }, + ]); + + volumeToolGroup.addViewport(viewportIdSagittal, renderingEngineId); + stackToolGroup.addViewport(viewportIdStack, renderingEngineId); + + volume.load(); + + await setVolumesForViewports( + renderingEngine, + [{ volumeId, callback: setCtTransferFunctionForVolumeActor }], + [viewportIdSagittal] + ); + + const stackViewport = renderingEngine.getViewport( + viewportIdStack + ) as Types.IStackViewport; + await stackViewport.setStack(imageIds, Math.floor(imageIds.length / 2)); + stackViewport.setProperties({ voiRange: ctVoiRange }); + + // Labelmap segmentation for the brush/scissors/fill/contour-edit tools + volumeLoader.createAndCacheDerivedLabelmapVolume(volumeId, { + volumeId: segmentationId, + }); + segmentation.addSegmentations([ + { + segmentationId, + representation: { + type: csToolsEnums.SegmentationRepresentations.Labelmap, + data: { + volumeId: segmentationId, + }, + }, + }, + ]); + await segmentation.addSegmentationRepresentations(viewportIdSagittal, [ + { + segmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }, + ]); + + renderingEngine.render(); +} + +run(); diff --git a/packages/tools/src/eventDispatchers/shared/getTouchCallbackWithMouseFallback.ts b/packages/tools/src/eventDispatchers/shared/getTouchCallbackWithMouseFallback.ts new file mode 100644 index 0000000000..e19a1df99c --- /dev/null +++ b/packages/tools/src/eventDispatchers/shared/getTouchCallbackWithMouseFallback.ts @@ -0,0 +1,48 @@ +import { MouseBindings } from '../../enums'; + +/** + * Synthesizes the mouse-detail fields a mouse callback may read but which + * the normalized touch event details lack (additive only - existing touch + * fields are never overwritten). + */ +function augmentTouchDetailForMouseCallback(detail) { + detail.mouseButton ??= MouseBindings.Primary; + detail.startPoints ??= detail.currentPoints; + detail.lastPoints ??= detail.currentPoints; + detail.deltaPoints ??= { + page: [0, 0], + client: [0, 0], + canvas: [0, 0], + world: [0, 0, 0], + }; +} + +/** + * Resolves the callback a touch dispatcher should invoke on a tool: + * - the explicit touch callback when the tool implements it (always wins, + * regardless of supportedInteractionTypes), otherwise + * - the mouse counterpart, but only when the tool declares 'Touch' in its + * supportedInteractionTypes. + * + * Returns undefined when the tool has neither (or the mouse counterpart is + * not enabled for touch), so callers can skip the tool. + */ +export default function getTouchCallbackWithMouseFallback( + tool, + touchCallbackName: string, + mouseCallbackName: string +): ((evt) => unknown) | undefined { + if (typeof tool?.[touchCallbackName] === 'function') { + return (evt) => tool[touchCallbackName](evt); + } + + if ( + tool?.supportedInteractionTypes?.includes('Touch') && + typeof tool[mouseCallbackName] === 'function' + ) { + return (evt) => { + augmentTouchDetailForMouseCallback(evt.detail); + return tool[mouseCallbackName](evt); + }; + } +} diff --git a/packages/tools/src/eventDispatchers/touchEventHandlers/touchDrag.ts b/packages/tools/src/eventDispatchers/touchEventHandlers/touchDrag.ts index 83e680523e..aff92541d7 100644 --- a/packages/tools/src/eventDispatchers/touchEventHandlers/touchDrag.ts +++ b/packages/tools/src/eventDispatchers/touchEventHandlers/touchDrag.ts @@ -1,10 +1,12 @@ import getActiveToolForTouchEvent from '../shared/getActiveToolForTouchEvent'; +import getTouchCallbackWithMouseFallback from '../shared/getTouchCallbackWithMouseFallback'; import { state } from '../../store/state'; import type { TouchDragEventType } from '../../types/EventTypes'; /** - * touchDrag - Event handler for touchDrag events. Uses `customCallbackHandler` to fire - * the `touchDragCallback` function on active tools. + * touchDrag - Event handler for touchDrag events. Fires the `touchDragCallback` + * function on the active tool, falling back to `mouseDragCallback` for tools + * that declare 'Touch' support. */ export default function touchDrag(evt: TouchDragEventType) { if (state.isInteractingWithTool) { @@ -13,11 +15,15 @@ export default function touchDrag(evt: TouchDragEventType) { const activeTool = getActiveToolForTouchEvent(evt); - const noFoundToolOrDoesNotHaveTouchDragCallback = - !activeTool || typeof activeTool.touchDragCallback !== 'function'; - if (noFoundToolOrDoesNotHaveTouchDragCallback) { + const dragCallback = getTouchCallbackWithMouseFallback( + activeTool, + 'touchDragCallback', + 'mouseDragCallback' + ); + + if (!dragCallback) { return; } - activeTool.touchDragCallback(evt); + dragCallback(evt); } diff --git a/packages/tools/src/eventDispatchers/touchEventHandlers/touchStart.ts b/packages/tools/src/eventDispatchers/touchEventHandlers/touchStart.ts index dfb15bb235..2fde1d9477 100644 --- a/packages/tools/src/eventDispatchers/touchEventHandlers/touchStart.ts +++ b/packages/tools/src/eventDispatchers/touchEventHandlers/touchStart.ts @@ -19,6 +19,7 @@ import filterToolsWithMoveableHandles from '../../store/filterToolsWithMoveableH import filterToolsWithAnnotationsForElement from '../../store/filterToolsWithAnnotationsForElement'; import filterMoveableAnnotationTools from '../../store/filterMoveableAnnotationTools'; import getActiveToolForTouchEvent from '../shared/getActiveToolForTouchEvent'; +import getTouchCallbackWithMouseFallback from '../shared/getTouchCallbackWithMouseFallback'; import getToolsWithModesForTouchEvent from '../shared/getToolsWithModesForTouchEvent'; const { Active, Passive } = ToolModes; @@ -33,10 +34,16 @@ export default function touchStart(evt: EventTypes.TouchStartEventType) { } const activeTool = getActiveToolForTouchEvent(evt); - // Check for preTouchStartCallbacks, + // Check for preTouchStartCallbacks (falling back to preMouseDownCallback + // for tools that declare 'Touch' support without a touch-specific handler). // If the tool claims it consumed the event, prevent further checks. - if (activeTool && typeof activeTool.preTouchStartCallback === 'function') { - const consumedEvent = activeTool.preTouchStartCallback(evt); + const preCallback = getTouchCallbackWithMouseFallback( + activeTool, + 'preTouchStartCallback', + 'preMouseDownCallback' + ); + if (preCallback) { + const consumedEvent = preCallback(evt); if (consumedEvent) { return; @@ -113,9 +120,15 @@ export default function touchStart(evt: EventTypes.TouchStartEventType) { return; } - // Run the postTouchStartCallback for the active tool if it exists - if (activeTool && typeof activeTool.postTouchStartCallback === 'function') { - const consumedEvent = activeTool.postTouchStartCallback(evt); + // Run the postTouchStartCallback for the active tool if it exists (falling + // back to postMouseDownCallback for tools that declare 'Touch' support). + const postCallback = getTouchCallbackWithMouseFallback( + activeTool, + 'postTouchStartCallback', + 'postMouseDownCallback' + ); + if (postCallback) { + const consumedEvent = postCallback(evt); if (consumedEvent) { // If the tool claims it consumed the event, prevent further checks. diff --git a/packages/tools/src/eventDispatchers/touchEventHandlers/touchStartActivate.ts b/packages/tools/src/eventDispatchers/touchEventHandlers/touchStartActivate.ts index 4c9b590448..fa73aaa8bd 100644 --- a/packages/tools/src/eventDispatchers/touchEventHandlers/touchStartActivate.ts +++ b/packages/tools/src/eventDispatchers/touchEventHandlers/touchStartActivate.ts @@ -30,7 +30,11 @@ export default function touchStartActivate( } if (activeTool.addNewAnnotation) { - const annotation = activeTool.addNewAnnotation(evt, 'touch'); - setAnnotationSelected(annotation.annotationUID); + try { + const annotation = activeTool.addNewAnnotation(evt, 'touch'); + setAnnotationSelected(annotation.annotationUID); + } catch (error) { + console.warn('Error adding new annotation, viewport not ready:', error); + } } } diff --git a/packages/tools/src/eventDispatchers/touchEventHandlers/touchTap.ts b/packages/tools/src/eventDispatchers/touchEventHandlers/touchTap.ts index 16b6ab1f46..3736ddcb44 100644 --- a/packages/tools/src/eventDispatchers/touchEventHandlers/touchTap.ts +++ b/packages/tools/src/eventDispatchers/touchEventHandlers/touchTap.ts @@ -1,9 +1,48 @@ -import customCallbackHandler from '../shared/customCallbackHandler'; +import { state } from '../../store/state'; +import ToolModes from '../../enums/ToolModes'; +import { getToolGroupForViewport } from '../../store/ToolGroupManager'; +import getTouchCallbackWithMouseFallback from '../shared/getTouchCallbackWithMouseFallback'; +import type { EventTypes } from '../../types'; + +const { Active } = ToolModes; /** - * touchTap - Event handler for touch tap events. Uses `customCallbackHandler` to fire - * the `touchTapCallback` function on active tools. + * touchTap - Event handler for touch tap events. Fires the `touchTapCallback` + * of the first active tool that implements it, falling back to + * `mouseClickCallback` for tools that declare 'Touch' support. */ -const touchTap = customCallbackHandler.bind(null, 'Touch', 'touchTapCallback'); +export default function touchTap(evt: EventTypes.TouchTapEventType) { + if (state.isInteractingWithTool) { + return; + } + + const { renderingEngineId, viewportId } = evt.detail; + const toolGroup = getToolGroupForViewport(viewportId, renderingEngineId); + + if (!toolGroup) { + return; + } + + const toolGroupToolNames = Object.keys(toolGroup.toolOptions); + + for (let j = 0; j < toolGroupToolNames.length; j++) { + const toolName = toolGroupToolNames[j]; + const toolOptions = toolGroup.toolOptions[toolName]; + + if (toolOptions.mode !== Active) { + continue; + } + + const toolInstance = toolGroup.getToolInstance(toolName); + const tapCallback = getTouchCallbackWithMouseFallback( + toolInstance, + 'touchTapCallback', + 'mouseClickCallback' + ); -export default touchTap; + if (tapCallback) { + tapCallback(evt); + return; + } + } +} diff --git a/packages/tools/src/eventDispatchers/touchToolEventDispatcher.ts b/packages/tools/src/eventDispatchers/touchToolEventDispatcher.ts index 02779e865a..9ff3e6bdd9 100644 --- a/packages/tools/src/eventDispatchers/touchToolEventDispatcher.ts +++ b/packages/tools/src/eventDispatchers/touchToolEventDispatcher.ts @@ -40,6 +40,7 @@ const disable = function (element: HTMLDivElement) { ); element.removeEventListener(Events.TOUCH_DRAG, touchDrag as EventListener); element.removeEventListener(Events.TOUCH_END, touchEnd as EventListener); + element.removeEventListener(Events.TOUCH_TAP, touchTap as EventListener); element.removeEventListener(Events.TOUCH_PRESS, touchPress as EventListener); }; diff --git a/packages/tools/src/eventListeners/touch/touchStartListener.ts b/packages/tools/src/eventListeners/touch/touchStartListener.ts index 84e766a747..f5d50fa8fa 100644 --- a/packages/tools/src/eventListeners/touch/touchStartListener.ts +++ b/packages/tools/src/eventListeners/touch/touchStartListener.ts @@ -431,6 +431,12 @@ function _checkTouchTap(evt: TouchEvent): void { tapState.taps += 1; tapState.tapTimeout = setTimeout(() => { + // A new touch gesture is already in progress; emitting the deferred tap + // now would interrupt the active tool's touch loop mid-stroke. + if (state.isTouchStart) { + tapState = utilities.deepClone(defaultTapState); + return; + } const eventDetail: EventTypes.TouchTapEventDetail = { event: evt, eventName: TOUCH_TAP, diff --git a/packages/tools/src/store/ToolGroupManager/ToolGroup.ts b/packages/tools/src/store/ToolGroupManager/ToolGroup.ts index 9c63bf04a7..780ba45fca 100644 --- a/packages/tools/src/store/ToolGroupManager/ToolGroup.ts +++ b/packages/tools/src/store/ToolGroupManager/ToolGroup.ts @@ -820,7 +820,9 @@ export default class ToolGroup { toolName, sourceToolMode, { - bindings: sourceToolOptions.bindings ?? [], + // Tools added without ever receiving a mode have no toolOptions + // entry, but they still appear in _toolInstances. + bindings: sourceToolOptions?.bindings ?? [], } ); }); diff --git a/packages/tools/src/tools/AdvancedMagnifyTool.ts b/packages/tools/src/tools/AdvancedMagnifyTool.ts index 7283185655..251d0abfdf 100644 --- a/packages/tools/src/tools/AdvancedMagnifyTool.ts +++ b/packages/tools/src/tools/AdvancedMagnifyTool.ts @@ -323,7 +323,8 @@ class AdvancedMagnifyTool extends AnnotationTool { element: HTMLDivElement, annotation: AdvancedMagnifyAnnotation, canvasCoords: Types.Point2, - proximity: number + proximity: number, + interactionType?: string ): boolean => { const { viewport } = getEnabledElement(element); const { points } = annotation.data.handles; @@ -335,6 +336,14 @@ class AdvancedMagnifyTool extends AnnotationTool { return true; } + // On touch the glass interior is a whole-tool grab (move) as well: + // fingers have no hover to discover the border band, and an unconsumed + // touch inside the glass would fall through to addNewAnnotation and + // stack another magnifier on top of this one. + if (interactionType === 'touch' && radiusPoint < radius) { + return true; + } + return false; }; @@ -400,7 +409,9 @@ class AdvancedMagnifyTool extends AnnotationTool { evt.preventDefault(); }; - _endCallback = (evt: EventTypes.InteractionEventType): void => { + _endCallback = ( + evt: EventTypes.InteractionEventType | EventTypes.TouchPressEventType + ): void => { const eventDetail = evt.detail; const { element } = eventDetail; @@ -478,6 +489,33 @@ class AdvancedMagnifyTool extends AnnotationTool { triggerAnnotationRenderForViewportIds(viewportIdsToRender); }; + /** + * TOUCH_PRESS listener active while a loupe is grabbed: touching down near + * the loupe always starts a move/resize on TOUCH_START, which sets + * state.isInteractingWithTool and makes the touchPress dispatcher drop the + * event, so the press that opens the zoom-factor picker has to be handled + * by the interaction itself. Ends the in-progress move before showing the + * picker. + */ + _pressModifyCallback = (evt: EventTypes.TouchPressEventType): void => { + const { element, startPoints } = evt.detail; + const annotation = this.editData.annotation as AdvancedMagnifyAnnotation; + // Same touch proximity filterMoveableAnnotationTools uses (36 vs 6). + const proximity = 36; + + if ( + !this.isPointNearTool(element, annotation, startPoints.canvas, proximity) + ) { + return; + } + + // Keep the touchPress dispatcher from re-opening the picker once + // _endCallback clears state.isInteractingWithTool. + evt.stopImmediatePropagation(); + this._endCallback(evt); + this.showZoomFactorsList(evt, annotation); + }; + _dragHandle = (evt: EventTypes.InteractionEventType): void => { const eventDetail = evt.detail; const { element } = eventDetail; @@ -553,6 +591,7 @@ class AdvancedMagnifyTool extends AnnotationTool { element.addEventListener(Events.TOUCH_END, this._endCallback); element.addEventListener(Events.TOUCH_DRAG, this._dragModifyCallback); element.addEventListener(Events.TOUCH_TAP, this._endCallback); + element.addEventListener(Events.TOUCH_PRESS, this._pressModifyCallback); }; _deactivateModify = (element) => { @@ -565,6 +604,7 @@ class AdvancedMagnifyTool extends AnnotationTool { element.removeEventListener(Events.TOUCH_END, this._endCallback); element.removeEventListener(Events.TOUCH_DRAG, this._dragModifyCallback); element.removeEventListener(Events.TOUCH_TAP, this._endCallback); + element.removeEventListener(Events.TOUCH_PRESS, this._pressModifyCallback); }; /** @@ -696,10 +736,15 @@ class AdvancedMagnifyTool extends AnnotationTool { // Basic dropdown component that allows the user to select a different zoom factor. // configurations.actions may be changed to use a customized dropdown. public showZoomFactorsList( - evt: EventTypes.InteractionEventType, + evt: EventTypes.InteractionEventType | EventTypes.TouchPressEventType, annotation: AdvancedMagnifyAnnotation ) { - const { element, currentPoints } = evt.detail; + const { element } = evt.detail; + // TOUCH_PRESS events carry no currentPoints; use the press start point. + const currentPoints = + 'currentPoints' in evt.detail + ? evt.detail.currentPoints + : evt.detail.startPoints; const enabledElement = getEnabledElement(element); const { viewport } = enabledElement; const { canvas: canvasPoint } = currentPoints; @@ -729,6 +774,47 @@ class AdvancedMagnifyTool extends AnnotationTool { dropdown.focus(); } + /** + * Touch counterpart of the Secondary+Shift zoom-factor action: a + * long-press (TOUCH_PRESS) near the magnifying glass border opens the + * same picker. The touchPress dispatcher calls the first active tool that + * implements this callback, without any proximity check, so the hit-test + * happens here (and an earlier tool in the group implementing + * touchPressCallback would shadow this one). When TOUCH_START has already + * grabbed the loupe the dispatcher drops the event entirely and + * _pressModifyCallback delivers the press instead. + */ + touchPressCallback = (evt: EventTypes.TouchPressEventType): void => { + const { element, startPoints } = evt.detail; + const enabledElement = getEnabledElement(element); + + if (!enabledElement) { + return; + } + + const { viewport } = enabledElement; + const canvasPoint = startPoints.canvas; + // Same touch proximity filterMoveableAnnotationTools uses (36 vs 6). + const proximity = 36; + + const annotations = (getAnnotations(this.getToolName(), element) ?? + []) as AdvancedMagnifyAnnotation[]; + const annotation = annotations + .filter((a) => a.data.sourceViewportId === viewport.id) + .find( + (a) => + isAnnotationVisible(a.annotationUID) && + !isAnnotationLocked(a.annotationUID) && + this.isPointNearTool(element, a, canvasPoint, proximity) + ); + + if (!annotation) { + return; + } + + this.showZoomFactorsList(evt, annotation); + }; + private _getZoomFactorsListDropdown(currentZoomFactor, onChangeCallback) { const { zoomFactorList } = this.configuration.magnifyingGlass; const dropdown = document.createElement('select'); @@ -739,7 +825,15 @@ class AdvancedMagnifyTool extends AnnotationTool { position: 'absolute', }); - ['mousedown', 'mouseup', 'mousemove', 'click'].forEach((eventName) => { + [ + 'mousedown', + 'mouseup', + 'mousemove', + 'click', + 'touchstart', + 'touchend', + 'touchmove', + ].forEach((eventName) => { dropdown.addEventListener(eventName, (evt) => evt.stopPropagation()); }); @@ -909,6 +1003,7 @@ class AdvancedMagnifyViewportManager { position, zoomFactor, autoPan, + annotation, }); this._addSourceElementEventListener(sourceElement); @@ -1174,6 +1269,7 @@ class AdvancedMagnifyViewport { private _resized = false; private _resizeViewportAsync: () => void; private _canAutoPan = false; + private _annotation: AdvancedMagnifyAnnotation; private _autoPan: { enabled: boolean; padding: number; @@ -1190,6 +1286,7 @@ class AdvancedMagnifyViewport { position = [0, 0], zoomFactor, autoPan, + annotation, }: { magnifyViewportId?: string; sourceEnabledElement: Types.IEnabledElement; @@ -1201,11 +1298,13 @@ class AdvancedMagnifyViewport { padding: number; callback: AutoPanCallback; }; + annotation?: AdvancedMagnifyAnnotation; }) { // Private properties this._viewportId = magnifyViewportId ?? csUtils.uuidv4(); this._sourceEnabledElement = sourceEnabledElement; this._autoPan = autoPan; + this._annotation = annotation; // Public properties this.radius = radius; @@ -1215,6 +1314,10 @@ class AdvancedMagnifyViewport { this._browserMouseDownCallback = this._browserMouseDownCallback.bind(this); this._browserMouseUpCallback = this._browserMouseUpCallback.bind(this); + this._browserTouchStartCallback = + this._browserTouchStartCallback.bind(this); + this._browserTouchEndCallback = this._browserTouchEndCallback.bind(this); + this._touchPressCallback = this._touchPressCallback.bind(this); this._handleToolModeChanged = this._handleToolModeChanged.bind(this); this._mouseDragCallback = this._mouseDragCallback.bind(this); this._resizeViewportAsync = <() => void>( @@ -1544,6 +1647,51 @@ class AdvancedMagnifyViewport { element.removeEventListener('mousemove', this._cancelMouseEventCallback); } + private _browserTouchEndCallback(evt: TouchEvent) { + // Unlike mouse, touches end one finger at a time; only restore the + // shielding once the last finger lifts, otherwise a remaining finger's + // touchmove would be blocked from the document-level drag listeners. + if (evt.touches?.length > 0) { + return; + } + + const { element } = this._enabledElement.viewport; + + document.removeEventListener('touchend', this._browserTouchEndCallback); + document.removeEventListener('touchcancel', this._browserTouchEndCallback); + + // Restrict the scope of magnifying glass events again + element.addEventListener('touchend', this._cancelMouseEventCallback, { + passive: false, + }); + element.addEventListener('touchmove', this._cancelMouseEventCallback, { + passive: false, + }); + } + + private _browserTouchStartCallback(evt: TouchEvent) { + const { element } = this._enabledElement.viewport; + + // Enable auto pan only when the first finger lands inside of the + // magnifying glass viewport (same rule as _browserMouseDownCallback); + // additional fingers must not flip the decision mid-gesture. + if (evt.touches?.length === 1) { + this._canAutoPan = !!(evt.target as HTMLElement)?.closest( + '.advancedMagnifyTool' + ); + } + + // Wait for the gesture to end to restrict the scope of magnifying glass + // events again ('touchcancel' covers OS-interrupted gestures) + document.addEventListener('touchend', this._browserTouchEndCallback); + document.addEventListener('touchcancel', this._browserTouchEndCallback); + + // Allow touchend and touchmove events so annotations can be manipulated + // when the finger passes over the magnifying glass (dragging a handle). + element.removeEventListener('touchend', this._cancelMouseEventCallback); + element.removeEventListener('touchmove', this._cancelMouseEventCallback); + } + private _mouseDragCallback(evt: EventTypes.InteractionEventType) { if (!state.isInteractingWithTool) { return; @@ -1612,6 +1760,46 @@ class AdvancedMagnifyViewport { autoPan.callback(autoPanCallbackData); } + /** + * A long-press inside the loupe opens the zoom-factor picker. TOUCH_PRESS + * fires on the magnify element itself (its cloned tool group excludes + * AdvancedMagnify, so the touch dispatcher can never route it there). + */ + private _touchPressCallback(evt: EventTypes.TouchPressEventType) { + if (state.isInteractingWithTool) { + return; + } + + const toolInstance = this._sourceToolGroup?.getToolInstance( + ADVANCED_MAGNIFY_TOOL_NAME + ) as AdvancedMagnifyTool; + + if (!toolInstance || !this._annotation) { + return; + } + + const { viewport: sourceViewport } = this._sourceEnabledElement; + const pressCanvas = evt.detail.startPoints.canvas; + // Loupe-local canvas to source-viewport canvas: the loupe's top-left + // sits at (position - radius) in source canvas space (see update()). + const sourceCanvasPoint: Types.Point2 = [ + pressCanvas[0] + this.position[0] - this.radius, + pressCanvas[1] + this.position[1] - this.radius, + ]; + + // The dropdown must live on the SOURCE element: the loupe element has + // overflow hidden and a 50% border radius that would clip it. + toolInstance.showZoomFactorsList( + { + detail: { + element: sourceViewport.element, + startPoints: { canvas: sourceCanvasPoint }, + }, + } as unknown as EventTypes.TouchPressEventType, + this._annotation + ); + } + private _addBrowserEventListeners(element) { // mousedown on document is handled in the capture phase because the other // mousedown event listener added to the magnifying glass element does not @@ -1628,6 +1816,30 @@ class AdvancedMagnifyViewport { element.addEventListener('mouseup', this._cancelMouseEventCallback); element.addEventListener('mousemove', this._cancelMouseEventCallback); element.addEventListener('dblclick', this._cancelMouseEventCallback); + + // touchstart on document is handled in the capture phase for the same + // reason as mousedown above (the element-level cancel stops bubbling). + document.addEventListener( + 'touchstart', + this._browserTouchStartCallback, + true + ); + + // Touch events must not bubble up either: without this the source + // viewport's touchstart listener also runs and clobbers the shared + // touch-pipeline state, so gestures inside the loupe get attributed to + // the source viewport. passive: false because the cancel callback calls + // preventDefault (also suppresses synthesized mouse events / ghost + // clicks for touches inside the loupe). + element.addEventListener('touchstart', this._cancelMouseEventCallback, { + passive: false, + }); + element.addEventListener('touchend', this._cancelMouseEventCallback, { + passive: false, + }); + element.addEventListener('touchmove', this._cancelMouseEventCallback, { + passive: false, + }); } private _removeBrowserEventListeners(element) { @@ -1642,6 +1854,18 @@ class AdvancedMagnifyViewport { element.removeEventListener('mouseup', this._cancelMouseEventCallback); element.removeEventListener('mousemove', this._cancelMouseEventCallback); element.removeEventListener('dblclick', this._cancelMouseEventCallback); + + document.removeEventListener( + 'touchstart', + this._browserTouchStartCallback, + true + ); + document.removeEventListener('touchend', this._browserTouchEndCallback); + document.removeEventListener('touchcancel', this._browserTouchEndCallback); + + element.removeEventListener('touchstart', this._cancelMouseEventCallback); + element.removeEventListener('touchend', this._cancelMouseEventCallback); + element.removeEventListener('touchmove', this._cancelMouseEventCallback); } private _addEventListeners(element) { @@ -1660,6 +1884,18 @@ class AdvancedMagnifyViewport { this._mouseDragCallback as EventListener ); + // Touch has no hover: TOUCH_DRAG alone drives autoPan (the callback only + // reads currentPoints.canvas, which touch drags carry too). + element.addEventListener( + cstEvents.TOUCH_DRAG, + this._mouseDragCallback as EventListener + ); + + element.addEventListener( + cstEvents.TOUCH_PRESS, + this._touchPressCallback as EventListener + ); + this._addBrowserEventListeners(element); } @@ -1679,6 +1915,16 @@ class AdvancedMagnifyViewport { this._mouseDragCallback as EventListener ); + element.removeEventListener( + cstEvents.TOUCH_DRAG, + this._mouseDragCallback as EventListener + ); + + element.removeEventListener( + cstEvents.TOUCH_PRESS, + this._touchPressCallback as EventListener + ); + this._removeBrowserEventListeners(element); } diff --git a/packages/tools/src/tools/CrosshairsTool.ts b/packages/tools/src/tools/CrosshairsTool.ts index 098899af6c..96737d4251 100644 --- a/packages/tools/src/tools/CrosshairsTool.ts +++ b/packages/tools/src/tools/CrosshairsTool.ts @@ -44,6 +44,7 @@ import { getSlabThicknessOrDefault, jumpToFocalPoint, } from '../utilities/genericViewportToolHelpers'; +import { isMobile } from '../utilities/touch'; import * as lineSegment from '../utilities/math/line'; import type { @@ -165,7 +166,7 @@ class CrosshairsTool extends AnnotationTool { constructor( toolProps: PublicToolProps = {}, defaultToolProps: ToolProps = { - supportedInteractionTypes: ['Mouse'], + supportedInteractionTypes: ['Mouse', 'Touch'], configuration: { shadow: true, // renders a colored circle on top right of the viewports whose color @@ -219,7 +220,7 @@ class CrosshairsTool extends AnnotationTool { size: 2, }, mobile: { - enabled: false, + enabled: isMobile(), opacity: 0.8, handleRadius: 9, referenceLinesCenterGapRatio: 0.05, @@ -684,7 +685,7 @@ class CrosshairsTool extends AnnotationTool { canvasCoords: Types.Point2, proximity: number ): boolean => { - if (this._pointNearTool(element, annotation, canvasCoords, 6)) { + if (this._pointNearTool(element, annotation, canvasCoords, proximity)) { return true; } diff --git a/packages/tools/src/tools/MagnifyTool.ts b/packages/tools/src/tools/MagnifyTool.ts index 67a547116c..ef1e675cc6 100644 --- a/packages/tools/src/tools/MagnifyTool.ts +++ b/packages/tools/src/tools/MagnifyTool.ts @@ -30,6 +30,7 @@ class MagnifyTool extends BaseTool { enabledElement: Types.IEnabledElement; renderingEngine: Types.IRenderingEngine; currentPoints: IPoints; + isTouch: boolean; } | null; constructor( @@ -40,6 +41,9 @@ class MagnifyTool extends BaseTool { magnifySize: 10, // parallel scale , higher more zoom magnifyWidth: 250, //px magnifyHeight: 250, //px + // On touch, the loupe is lifted above the contact point; this is the + // gap in px between the contact point and the loupe's bottom edge. + touchOffset: 40, //px }, } ) { @@ -91,12 +95,17 @@ class MagnifyTool extends BaseTool { this.getToolName() ); + // True when reached via preTouchStartCallback (explicit alias or the + // dispatcher-level touch fallback) — both deliver the TOUCH_START event. + const isTouch = evt.type === Events.TOUCH_START; + this.editData = { referencedImageId, viewportIdsToRender, enabledElement, renderingEngine, currentPoints, + isTouch, }; this._createMagnificationViewport(); @@ -115,6 +124,36 @@ class MagnifyTool extends BaseTool { this.preMouseDownCallback(evt); }; + /** + * Positions the magnify loupe element relative to the interaction point. + * Mouse: centered on the pointer (unchanged legacy behavior). Touch: lifted + * above the contact point by `configuration.touchOffset` so the finger does + * not occlude the loupe, clamped to the viewport bounds. + */ + private _positionMagnifyElement = ( + magnifyElement: HTMLDivElement, + canvasPos: Types.Point2, + element: HTMLDivElement + ) => { + const { magnifyWidth, magnifyHeight, touchOffset } = this.configuration; + + let left = canvasPos[0] - magnifyWidth / 2; + let top = canvasPos[1] - magnifyHeight / 2; + + if (this.editData?.isTouch) { + top = canvasPos[1] - magnifyHeight - (touchOffset ?? 0); + + const maxLeft = Math.max(element.clientWidth - magnifyWidth, 0); + const maxTop = Math.max(element.clientHeight - magnifyHeight, 0); + + left = Math.min(Math.max(left, 0), maxLeft); + top = Math.min(Math.max(top, 0), maxTop); + } + + magnifyElement.style.top = `${top}px`; + magnifyElement.style.left = `${left}px`; + }; + _createMagnificationViewport = () => { const { enabledElement, @@ -170,12 +209,7 @@ class MagnifyTool extends BaseTool { } // Todo: use CSS transform instead of setting top and left for better performance - magnifyToolElement.style.top = `${ - canvasPos[1] - this.configuration.magnifyHeight / 2 - }px`; - magnifyToolElement.style.left = `${ - canvasPos[0] - this.configuration.magnifyWidth / 2 - }px`; + this._positionMagnifyElement(magnifyToolElement, canvasPos, element); const magnifyViewport = renderingEngine.getViewport( MAGNIFY_VIEWPORT_ID @@ -275,12 +309,7 @@ class MagnifyTool extends BaseTool { return; } - magnifyElement.style.top = `${ - canvasPos[1] - this.configuration.magnifyHeight / 2 - }px`; - magnifyElement.style.left = `${ - canvasPos[0] - this.configuration.magnifyWidth / 2 - }px`; + this._positionMagnifyElement(magnifyElement, canvasPos, element); const { focalPoint, position } = magnifyViewport.getCamera(); diff --git a/packages/tools/src/tools/TrackballRotateTool.ts b/packages/tools/src/tools/TrackballRotateTool.ts index 7e6ecb3227..24b04acecb 100644 --- a/packages/tools/src/tools/TrackballRotateTool.ts +++ b/packages/tools/src/tools/TrackballRotateTool.ts @@ -73,15 +73,21 @@ class TrackballRotateTool extends BaseTool { if (this.cleanUp !== null) { // Clean up previous event listener document.removeEventListener('mouseup', this.cleanUp); + document.removeEventListener('touchend', this.cleanUp); } this.cleanUp = () => { + // Both listener types are armed below; whichever fires first must + // clear the other so no stale once-listener lingers on document. + document.removeEventListener('mouseup', this.cleanUp); + document.removeEventListener('touchend', this.cleanUp); mapper.setSampleDistance(originalSampleDistance); viewport.render(); this._hasResolutionChanged = false; }; document.addEventListener('mouseup', this.cleanUp, { once: true }); + document.addEventListener('touchend', this.cleanUp, { once: true }); } return true; }; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 4c1a2805b5..8b2338c4a9 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -238,6 +238,7 @@ class VolumeCroppingTool extends BaseTool { constructor( toolProps: PublicToolProps = {}, defaultToolProps: ToolProps = { + supportedInteractionTypes: ['Mouse', 'Touch'], configuration: { showCornerSpheres: true, showHandles: true, diff --git a/packages/tools/src/tools/VolumeRotateTool.ts b/packages/tools/src/tools/VolumeRotateTool.ts index ec275e5d5b..101f96bef4 100644 --- a/packages/tools/src/tools/VolumeRotateTool.ts +++ b/packages/tools/src/tools/VolumeRotateTool.ts @@ -3,7 +3,7 @@ import { getEnabledElement } from '@cornerstonejs/core'; import type { Types } from '@cornerstonejs/core'; import { mat4, vec3 } from 'gl-matrix'; -import type { PublicToolProps, ToolProps } from '../types'; +import type { EventTypes, PublicToolProps, ToolProps } from '../types'; import type { MouseWheelEventType } from '../types/EventTypes'; import getViewportICamera from '../utilities/getViewportICamera'; import setViewportCamera from '../utilities/setViewportCamera'; @@ -16,7 +16,7 @@ const DIRECTIONS = { }; /** - * Tool that rotates the camera on mouse wheel. + * Tool that rotates the camera on mouse wheel, and on horizontal mouse/touch drag. * It rotates the camera around the focal point, and around a defined axis. Default * axis is set to be Z axis, but it can be configured to any custom normalized axis. * @@ -32,6 +32,7 @@ class VolumeRotateTool extends BaseTool { configuration: { direction: DIRECTIONS.Z, rotateIncrementDegrees: 30, + rotateDragDegreesPerPixel: 0.5, }, } ) { @@ -41,21 +42,50 @@ class VolumeRotateTool extends BaseTool { mouseWheelCallback(evt: MouseWheelEventType) { // https://github.com/kitware/vtk-js/blob/HEAD/Sources/Interaction/Manipulators/MouseCameraUnicamRotateManipulator/index.js#L73 const { element, wheel } = evt.detail; + const { rotateIncrementDegrees } = this.configuration; + const { direction: deltaY } = wheel; + + //Calculate angle in radian as glmatrix rotate is in radian + const angle = (deltaY * (rotateIncrementDegrees * Math.PI)) / 180; + + this._rotate(element, angle); + } + + mouseDragCallback(evt: EventTypes.InteractionEventType) { + this._dragCallback(evt); + } + + touchDragCallback(evt: EventTypes.InteractionEventType) { + this._dragCallback(evt); + } + + _dragCallback(evt: EventTypes.InteractionEventType) { + const { element, deltaPoints } = evt.detail; + const { rotateDragDegreesPerPixel } = this.configuration; + const deltaX = deltaPoints.canvas[0]; + + // High-resolution pointers can fire drag events before a full pixel of + // horizontal movement has accumulated. + if (!deltaX) { + return; + } + + const angle = (deltaX * rotateDragDegreesPerPixel * Math.PI) / 180; + + this._rotate(element, angle); + } + + _rotate(element: HTMLDivElement, angle: number) { const enabledElement = getEnabledElement(element); const { viewport } = enabledElement; - const { direction, rotateIncrementDegrees } = this.configuration; + const { direction } = this.configuration; const camera = getViewportICamera(viewport); const { viewUp, position, focalPoint } = camera; - const { direction: deltaY } = wheel; - const [cx, cy, cz] = focalPoint; const [ax, ay, az] = direction; - //Calculate angle in radian as glmatrix rotate is in radian - const angle = (deltaY * (rotateIncrementDegrees * Math.PI)) / 180; - // position[3] = 1.0 // focalPoint[3] = 1.0 // viewUp[3] = 0.0 diff --git a/packages/tools/src/tools/annotation/ArrowAnnotateTool.ts b/packages/tools/src/tools/annotation/ArrowAnnotateTool.ts index c823f55f8b..12422abebd 100644 --- a/packages/tools/src/tools/annotation/ArrowAnnotateTool.ts +++ b/packages/tools/src/tools/annotation/ArrowAnnotateTool.ts @@ -455,11 +455,15 @@ class ArrowAnnotateTool extends AnnotationTool { touchTapCallback = (evt: EventTypes.TouchTapEventType) => { if (evt.detail.taps == 2) { - this.doubleClickCallback(evt); + // Same touch proximity filterMoveableAnnotationTools uses (36 vs 6). + this.doubleClickCallback(evt, 36); } }; - doubleClickCallback = (evt: EventTypes.TouchTapEventType): void => { + doubleClickCallback = ( + evt: EventTypes.TouchTapEventType, + proximity = 6 + ): void => { const eventDetail = evt.detail; const { element } = eventDetail; let annotations = getAnnotations(this.getToolName(), element); @@ -478,7 +482,7 @@ class ArrowAnnotateTool extends AnnotationTool { element, annotation as ArrowAnnotation, eventDetail.currentPoints.canvas, - 6 // Todo: get from configuration + proximity ) ); diff --git a/packages/tools/src/tools/annotation/ClickSegmentTool.ts b/packages/tools/src/tools/annotation/ClickSegmentTool.ts index acd4e7e2a2..8c55400070 100644 --- a/packages/tools/src/tools/annotation/ClickSegmentTool.ts +++ b/packages/tools/src/tools/annotation/ClickSegmentTool.ts @@ -802,6 +802,40 @@ class ClickSegmentTool extends GrowCutBaseTool { return true; } + /** + * Touch entry point. Touch has no hover, so the plus-cursor arming that + * gates mouse clicks can never have run. Instead, arm at tap time: run the + * exact hover probe (awaited) at the tap point, which plants a fresh + * lastProbe verdict for this point, then hand the event to the unchanged + * mouse flow. A blocked verdict rejects the tap with the segmentation + * error toast instead of silently ignoring it. + */ + preTouchStartCallback = async ( + evt: EventTypes.TouchStartEventType + ): Promise => { + if (this.mode !== ToolModes.Active || this.segmentationInProgress) { + return false; + } + + // TouchStartEventDetail carries element + currentPoints, which is all + // runHoverProbe reads. + await this.runHoverProbe(evt as unknown as EventTypes.MouseMoveEventType); + + if (this.lastProbe && !this.lastProbe.ok) { + growCutLog.info('tap ignored: probe reported no proper region', { + canvasPoint: this.lastProbe.canvas, + }); + this.notifySegmentationError( + 'No meaningful region at the tapped location; nothing was segmented.' + ); + return true; + } + + return this.preMouseDownCallback( + evt as unknown as EventTypes.MouseDownActivateEventType + ); + }; + private async runClick( clickData: GrowCutToolData, worldPoint: Types.Point3, diff --git a/packages/tools/src/tools/annotation/DragProbeTool.ts b/packages/tools/src/tools/annotation/DragProbeTool.ts index 88f8c23a12..935604845c 100644 --- a/packages/tools/src/tools/annotation/DragProbeTool.ts +++ b/packages/tools/src/tools/annotation/DragProbeTool.ts @@ -8,6 +8,7 @@ import { } from '../../drawingSvg'; import { getViewportIdsWithToolToRender } from '../../utilities/viewportFilters'; import { hideElementCursor } from '../../cursors/elementCursor'; +import { Events } from '../../enums'; import type { Annotation, EventTypes, @@ -56,11 +57,19 @@ class DragProbeTool extends ProbeTool { ): ProbeAnnotation => { const eventDetail = evt.detail; const { currentPoints, element } = eventDetail; - const worldPos = currentPoints.world; const enabledElement = getEnabledElement(element); const { viewport } = enabledElement; + // Reached with the TOUCH_START event via postTouchStartCallback (both the + // explicit alias below and the dispatcher-level fallback). + const isTouch = evt.type === Events.TOUCH_START; + const worldPos = this.getTouchAdjustedWorldPos( + viewport, + currentPoints, + isTouch + ); + this.isDrawing = true; // Native ("next") viewports expose no getCamera; read orientation via the bridge. const camera = getViewportICamera(viewport); diff --git a/packages/tools/src/tools/annotation/LivewireContourTool.ts b/packages/tools/src/tools/annotation/LivewireContourTool.ts index 183ebddb34..db32139b73 100644 --- a/packages/tools/src/tools/annotation/LivewireContourTool.ts +++ b/packages/tools/src/tools/annotation/LivewireContourTool.ts @@ -42,6 +42,17 @@ import { getCalibratedLengthUnitsAndScale, throttle } from '../../utilities'; const CLICK_CLOSE_CURVE_SQR_DIST = 10 ** 2; // px +// Mirror the (non-configurable) tapMaxDistance and tapToleranceMs in +// eventListeners/touch/touchStartListener.ts. A gesture that travels further +// than tapMaxDistance never emits its own TOUCH_TAP, but one that ends within +// tapMaxDistance of an active tap chain's start is still counted into that +// chain's aggregated TOUCH_TAP. Committing such a gesture on TOUCH_END records +// where and when it happened so the chain's TOUCH_TAP echo can be ignored in +// _mouseDownCallback instead of double-committing the point or force-closing +// the path. +const TOUCH_TAP_MAX_CANVAS_DISTANCE = 24; +const TOUCH_TAP_TOLERANCE_MS = 300; + class LivewireContourTool extends ContourSegmentationBaseTool { public static toolName = 'LivewireContour'; @@ -67,6 +78,8 @@ class LivewireContourTool extends ContourSegmentationBaseTool { sliceToWorld?: (point: Types.Point2) => Types.Point3; originalPath?: Types.Point3[]; contourHoleProcessingEnabled?: boolean; + /** Where and when the last point was committed on TOUCH_END */ + touchEndCommit?: { canvasPoint: Types.Point2; time: number }; } | null; isDrawing: boolean; isHandleOutsideImage = false; @@ -94,6 +107,14 @@ class LivewireContourTool extends ContourSegmentationBaseTool { */ snapHandleNearby: 2, + /** + * Distance in canvas pixels from the first control point within + * which a tap closes the curve when drawing with touch. Mouse + * clicks keep the default 10px target; finger taps get a larger + * one, matching the 36px touch proximity used for handle grabs. + */ + touchCloseCurveDistance: 36, + /** * Interpolation is only available for segmentation versions of these * tools. To use it on the segmentation tools, set enabled to true, @@ -526,7 +547,33 @@ class LivewireContourTool extends ContourSegmentationBaseTool { }; private _mouseDownCallback = (evt: EventTypes.InteractionEventType): void => { - const doubleClick = evt.type === Events.MOUSE_DOUBLE_CLICK; + const isTouchEvent = + evt.type === Events.TOUCH_TAP || evt.type === Events.TOUCH_END; + // A double tap arrives as a single TOUCH_TAP event carrying the tap + // count (see touchStartListener) and closes the curve exactly like a + // mouse double click does. + const doubleTap = + evt.type === Events.TOUCH_TAP && + (evt.detail as unknown as EventTypes.TouchTapEventDetail).taps >= 2; + const doubleClick = evt.type === Events.MOUSE_DOUBLE_CLICK || doubleTap; + + // A drag that ends within tapMaxDistance of an active tap chain's start + // is committed by the TOUCH_END path and still counted into the chain's + // aggregated TOUCH_TAP; ignore that echo so the already-committed point + // is not added again and the path is not force-closed. + if (doubleTap && this.editData.touchEndCommit) { + const { canvasPoint, time } = this.editData.touchEndCommit; + if ( + Date.now() - time <= TOUCH_TAP_TOLERANCE_MS * 2 && + math.point.distanceToPoint( + evt.detail.currentPoints.canvas, + canvasPoint + ) <= TOUCH_TAP_MAX_CANVAS_DISTANCE + ) { + return; + } + } + const { annotation, viewportIdsToRender, @@ -562,6 +609,9 @@ class LivewireContourTool extends ContourSegmentationBaseTool { index: -1, distSquared: Infinity, }; + const closeDistSquared = isTouchEvent + ? this.configuration.touchCloseCurveDistance ** 2 + : CLICK_CLOSE_CURVE_SQR_DIST; // Check if there is a control point close to the cursor for (let i = 0, len = controlPoints.length; i < len; i++) { @@ -575,7 +625,7 @@ class LivewireContourTool extends ContourSegmentationBaseTool { ); if ( - distSquared <= CLICK_CLOSE_CURVE_SQR_DIST && + distSquared <= closeDistSquared && distSquared < closestHandlePoint.distSquared ) { closestHandlePoint.distSquared = distSquared; @@ -671,6 +721,44 @@ class LivewireContourTool extends ContourSegmentationBaseTool { evt.preventDefault(); }; + private _touchDragDuringDrawCallback = ( + evt: EventTypes.TouchDragEventType + ): void => { + // Ignore multi-touch while drawing: the mean point of two fingers is + // never where the user wants the preview. + if (evt.detail.currentPointsList.length > 1) { + return; + } + this._mouseMoveCallback(evt); + }; + + private _touchEndDuringDrawCallback = ( + evt: EventTypes.TouchEndEventType + ): void => { + const { startPointsList, currentPointsList, startPoints, currentPoints } = + evt.detail; + + if (startPointsList.length > 1 || currentPointsList.length > 1) { + return; + } + + // Gestures that stay within the tap distance are committed by the + // TOUCH_TAP path; committing them here as well would add the point twice. + const dragDistance = math.point.distanceToPoint( + startPoints.canvas, + currentPoints.canvas + ); + if (dragDistance <= TOUCH_TAP_MAX_CANVAS_DISTANCE) { + return; + } + + this.editData.touchEndCommit = { + canvasPoint: currentPoints.canvas, + time: Date.now(), + }; + this._mouseDownCallback(evt); + }; + public editHandle( worldPos: Types.Point3, element, @@ -844,6 +932,14 @@ class LivewireContourTool extends ContourSegmentationBaseTool { ); element.addEventListener(Events.TOUCH_TAP, this._mouseDownCallback); + element.addEventListener( + Events.TOUCH_DRAG, + this._touchDragDuringDrawCallback + ); + element.addEventListener( + Events.TOUCH_END, + this._touchEndDuringDrawCallback + ); }; private _deactivateDraw = (element) => { @@ -857,6 +953,14 @@ class LivewireContourTool extends ContourSegmentationBaseTool { ); element.removeEventListener(Events.TOUCH_TAP, this._mouseDownCallback); + element.removeEventListener( + Events.TOUCH_DRAG, + this._touchDragDuringDrawCallback + ); + element.removeEventListener( + Events.TOUCH_END, + this._touchEndDuringDrawCallback + ); }; public renderAnnotation( diff --git a/packages/tools/src/tools/annotation/ProbeTool.ts b/packages/tools/src/tools/annotation/ProbeTool.ts index 004d753b3f..9a12429ee5 100644 --- a/packages/tools/src/tools/annotation/ProbeTool.ts +++ b/packages/tools/src/tools/annotation/ProbeTool.ts @@ -41,6 +41,7 @@ import type { ToolHandle, PublicToolProps, SVGDrawingHelper, + InteractionTypes, } from '../../types'; import type { ProbeAnnotation } from '../../types/ToolSpecificAnnotationTypes'; import type { StyleSpecifier } from '../../types/AnnotationStyle'; @@ -107,6 +108,16 @@ class ProbeTool extends AnnotationTool { x: 6, y: -6, }, + /** + * Canvas offset (in CSS px) applied to the interaction point while + * placing or dragging via touch, so the probe point is not occluded + * by the finger. Negative y moves the point above the contact point. + * Set to { x: 0, y: 0 } to disable. Mouse interactions are unaffected. + */ + touchCanvasOffset: { + x: 0, + y: -40, + }, }, }; @@ -195,19 +206,30 @@ class ProbeTool extends AnnotationTool { * a Probe Annotation and stores it in the annotationManager * * @param evt - EventTypes.NormalizedMouseEventType + * @param interactionType - The interaction type used to add the annotation; + * touch placements apply the configured touchCanvasOffset. * @returns The annotation object. * */ addNewAnnotation = ( - evt: EventTypes.InteractionEventType + evt: EventTypes.InteractionEventType, + interactionType: InteractionTypes = 'Mouse' ): ProbeAnnotation => { const eventDetail = evt.detail; const { currentPoints, element } = eventDetail; - const worldPos = currentPoints.world; const enabledElement = getEnabledElement(element); const { viewport } = enabledElement; + // Dispatchers pass 'touch'/'mouse' (lowercase) despite the + // InteractionTypes casing, so compare case-insensitively. + const isTouch = interactionType.toLowerCase() === 'touch'; + const worldPos = this.getTouchAdjustedWorldPos( + viewport, + currentPoints, + isTouch + ); + this.isDrawing = true; const annotation = (( @@ -274,6 +296,35 @@ class ProbeTool extends AnnotationTool { } } + /** + * Returns the world position for an interaction event. For touch + * interactions the canvas point is shifted by + * `configuration.touchCanvasOffset` before being projected to world, so + * the placed/dragged point is not occluded by the finger. For mouse + * interactions the original world position is returned unchanged. + */ + protected getTouchAdjustedWorldPos( + viewport: Types.IViewport, + currentPoints: { canvas: Types.Point2; world: Types.Point3 }, + isTouch: boolean + ): Types.Point3 { + const { touchCanvasOffset } = this.configuration; + + if (!isTouch || !touchCanvasOffset) { + return currentPoints.world; + } + + const { x = 0, y = 0 } = touchCanvasOffset; + + if (!x && !y) { + return currentPoints.world; + } + + const [canvasX, canvasY] = currentPoints.canvas; + + return viewport.canvasToWorld([canvasX + x, canvasY + y]); + } + handleSelectedCallback( evt: EventTypes.InteractionEventType, annotation: ProbeAnnotation @@ -346,7 +397,14 @@ class ProbeTool extends AnnotationTool { this.isDrawing = true; const eventDetail = evt.detail; const { currentPoints, element } = eventDetail; - const worldPos = currentPoints.world; + + const { viewport } = getEnabledElement(element); + const isTouch = evt.type === Events.TOUCH_DRAG; + const worldPos = this.getTouchAdjustedWorldPos( + viewport, + currentPoints, + isTouch + ); const { annotation, viewportIdsToRender, newAnnotation } = this.editData; const { data } = annotation; diff --git a/packages/tools/src/tools/annotation/RegionSegmentTool.ts b/packages/tools/src/tools/annotation/RegionSegmentTool.ts index 71214e1c4b..83ceffdc51 100644 --- a/packages/tools/src/tools/annotation/RegionSegmentTool.ts +++ b/packages/tools/src/tools/annotation/RegionSegmentTool.ts @@ -166,6 +166,18 @@ class RegionSegmentTool extends GrowCutBaseTool { Events.MOUSE_CLICK, this._endCallback as unknown as EventListener ); + element.addEventListener( + Events.TOUCH_END, + this._endCallback as unknown as EventListener + ); + element.addEventListener( + Events.TOUCH_DRAG, + this._dragCallback as EventListener + ); + element.addEventListener( + Events.TOUCH_TAP, + this._endCallback as unknown as EventListener + ); } private _deactivateDraw = (element: HTMLDivElement): void => { @@ -181,6 +193,18 @@ class RegionSegmentTool extends GrowCutBaseTool { Events.MOUSE_CLICK, this._endCallback as unknown as EventListener ); + element.removeEventListener( + Events.TOUCH_END, + this._endCallback as unknown as EventListener + ); + element.removeEventListener( + Events.TOUCH_DRAG, + this._dragCallback as EventListener + ); + element.removeEventListener( + Events.TOUCH_TAP, + this._endCallback as unknown as EventListener + ); }; renderAnnotation( diff --git a/packages/tools/src/tools/annotation/SplineROITool.ts b/packages/tools/src/tools/annotation/SplineROITool.ts index 94d4bb24d6..4fd3c88713 100644 --- a/packages/tools/src/tools/annotation/SplineROITool.ts +++ b/packages/tools/src/tools/annotation/SplineROITool.ts @@ -60,6 +60,19 @@ import { convertContourSegmentationAnnotation } from '../../utilities/contourSeg const SPLINE_MIN_POINTS = 3; const SPLINE_CLICK_CLOSE_CURVE_DIST = 10; +// Mirrors the (non-configurable) tapMaxDistance in +// eventListeners/touch/touchStartListener.ts. Gestures that stay within this +// distance are committed by the TOUCH_TAP path; longer drags are committed on +// TOUCH_END. A longer drag that ends near the anchor of an active tap chain +// is still counted as a tap by the listener, so lift-commits are recorded in +// editData and the trailing TOUCH_TAP is dropped in _mouseDownCallback. +const TOUCH_TAP_MAX_CANVAS_DISTANCE = 24; + +// Mirrors the (non-configurable) tapToleranceMs in +// eventListeners/touch/touchStartListener.ts. TOUCH_TAP fires one tolerance +// after the last touchend of a tap chain. +const TOUCH_TAP_TOLERANCE_MS = 300; + const DEFAULT_SPLINE_CONFIG = { resolution: 20, controlPointAdditionDistance: 6, @@ -110,6 +123,10 @@ class SplineROITool extends ContourSegmentationBaseTool { newAnnotation?: boolean; hasMoved?: boolean; lastCanvasPoint?: Types.Point2; + /** Close-curve snap distance (canvas px) for the preview, per input source */ + closeCurveDistance?: number; + lastLiftCommitCanvasPoint?: Types.Point2; + lastLiftCommitTime?: number; contourHoleProcessingEnabled?: boolean; } | null; isDrawing: boolean; @@ -174,6 +191,13 @@ class SplineROITool extends ContourSegmentationBaseTool { * to the cursor position before the second point is placed. */ enableTwoPointPreview: false, + /** + * Distance in canvas pixels from the first control point within + * which a tap closes the curve when drawing with touch. Mouse + * clicks keep the default 10px target; finger taps get a larger + * one, matching the 36px touch proximity used for handle grabs. + */ + touchCloseCurveDistance: 36, lastControlPointDeletionKeys: ['Backspace', 'Delete'], }, actions: { @@ -517,13 +541,48 @@ class SplineROITool extends ContourSegmentationBaseTool { ); this.editData.lastCanvasPoint = evt.detail.currentPoints.canvas; + this.editData.closeCurveDistance = + evt.type === Events.TOUCH_DRAG + ? this.configuration.spline.touchCloseCurveDistance + : SPLINE_CLICK_CLOSE_CURVE_DIST; triggerAnnotationRenderForViewportIds(viewportIdsToRender); evt.preventDefault(); }; private _mouseDownCallback = (evt: EventTypes.InteractionEventType): void => { - const doubleClick = evt.type === Events.MOUSE_DOUBLE_CLICK; + const isTouchEvent = + evt.type === Events.TOUCH_TAP || evt.type === Events.TOUCH_END; + // A double tap arrives as a single TOUCH_TAP event carrying the tap + // count (see touchStartListener) and closes the curve exactly like a + // mouse double click does. + const doubleTap = + evt.type === Events.TOUCH_TAP && + (evt.detail as unknown as EventTypes.TouchTapEventDetail).taps >= 2; + const doubleClick = evt.type === Events.MOUSE_DOUBLE_CLICK || doubleTap; + + // The tap listener anchors an active tap chain at its first tap, so a + // drag that ends near that anchor is counted as a tap even though it + // travelled beyond the tap distance. Such a drag was already committed on + // TOUCH_END; drop the trailing TOUCH_TAP or it would add a duplicate + // point and close the curve. + if (doubleTap && this.editData.lastLiftCommitCanvasPoint) { + const { lastLiftCommitCanvasPoint, lastLiftCommitTime } = this.editData; + const tapDistance = math.point.distanceToPoint( + evt.detail.currentPoints.canvas, + lastLiftCommitCanvasPoint + ); + // TOUCH_TAP is emitted one tap tolerance after the chain's last + // touchend, so a lift-commit that ended the chain surfaces here about + // TOUCH_TAP_TOLERANCE_MS later; double it to absorb timer jitter. + if ( + Date.now() - lastLiftCommitTime <= 2 * TOUCH_TAP_TOLERANCE_MS && + tapDistance <= TOUCH_TAP_MAX_CANVAS_DISTANCE + ) { + return; + } + } + const { annotation, viewportIdsToRender } = this.editData; const { data } = annotation; @@ -554,9 +613,12 @@ class SplineROITool extends ContourSegmentationBaseTool { if (data.handles.points.length >= 3) { this.createMemo(element, annotation); const { instance: spline } = data.spline; + const closeCurveDistance = isTouchEvent + ? this.configuration.spline.touchCloseCurveDistance + : SPLINE_CLICK_CLOSE_CURVE_DIST; const closestControlPoint = spline.getClosestControlPointWithinDistance( canvasPoint, - SPLINE_CLICK_CLOSE_CURVE_DIST + closeCurveDistance ); if (closestControlPoint?.index === 0) { @@ -584,6 +646,42 @@ class SplineROITool extends ContourSegmentationBaseTool { evt.preventDefault(); }; + private _touchDragDuringDrawCallback = ( + evt: EventTypes.TouchDragEventType + ): void => { + // Ignore multi-touch while drawing: the mean point of two fingers is + // never where the user wants the preview. + if (evt.detail.currentPointsList.length > 1) { + return; + } + this._mouseMoveCallback(evt); + }; + + private _touchEndDuringDrawCallback = ( + evt: EventTypes.TouchEndEventType + ): void => { + const { startPointsList, currentPointsList, startPoints, currentPoints } = + evt.detail; + + if (startPointsList.length > 1 || currentPointsList.length > 1) { + return; + } + + // Gestures that stay within the tap distance are committed by the + // TOUCH_TAP path; committing them here as well would add the point twice. + const dragDistance = math.point.distanceToPoint( + startPoints.canvas, + currentPoints.canvas + ); + if (dragDistance <= TOUCH_TAP_MAX_CANVAS_DISTANCE) { + return; + } + + this.editData.lastLiftCommitCanvasPoint = currentPoints.canvas; + this.editData.lastLiftCommitTime = Date.now(); + this._mouseDownCallback(evt); + }; + protected _dragCallback = (evt: EventTypes.InteractionEventType): void => { this.isDrawing = true; const eventDetail = evt.detail; @@ -728,6 +826,14 @@ class SplineROITool extends ContourSegmentationBaseTool { ); element.addEventListener(Events.TOUCH_TAP, this._mouseDownCallback); + element.addEventListener( + Events.TOUCH_DRAG, + this._touchDragDuringDrawCallback + ); + element.addEventListener( + Events.TOUCH_END, + this._touchEndDuringDrawCallback + ); }; private _deactivateDraw = (element) => { @@ -742,6 +848,14 @@ class SplineROITool extends ContourSegmentationBaseTool { ); element.removeEventListener(Events.TOUCH_TAP, this._mouseDownCallback); + element.removeEventListener( + Events.TOUCH_DRAG, + this._touchDragDuringDrawCallback + ); + element.removeEventListener( + Events.TOUCH_END, + this._touchEndDuringDrawCallback + ); }; protected isContourSegmentationTool(): boolean { @@ -896,7 +1010,7 @@ class SplineROITool extends ContourSegmentationBaseTool { // For splines with 2 or more control points, use the existing preview logic const previewPolylinePoints = spline.getPreviewPolylinePoints( lastCanvasPoint, - SPLINE_CLICK_CLOSE_CURVE_DIST + this.editData?.closeCurveDistance ?? SPLINE_CLICK_CLOSE_CURVE_DIST ); drawPolylineSvg( diff --git a/packages/tools/src/tools/annotation/WholeBodySegmentTool.ts b/packages/tools/src/tools/annotation/WholeBodySegmentTool.ts index fd081974f7..a96a824417 100644 --- a/packages/tools/src/tools/annotation/WholeBodySegmentTool.ts +++ b/packages/tools/src/tools/annotation/WholeBodySegmentTool.ts @@ -126,8 +126,8 @@ class WholeBodySegmentTool extends GrowCutBaseTool { const enabledElement = getEnabledElement(element); const { viewport } = enabledElement; - await this.runGrowCut(); this._deactivateDraw(element); + await this.runGrowCut(); this.growCutData = null; @@ -285,6 +285,14 @@ class WholeBodySegmentTool extends GrowCutBaseTool { ); // @ts-expect-error element.addEventListener(Events.MOUSE_CLICK, this._endCallback); + // @ts-expect-error + element.addEventListener(Events.TOUCH_END, this._endCallback); + element.addEventListener( + Events.TOUCH_DRAG, + this._dragCallback as unknown as EventListener + ); + // @ts-expect-error + element.addEventListener(Events.TOUCH_TAP, this._endCallback); } private _deactivateDraw = (element: HTMLDivElement): void => { @@ -296,6 +304,14 @@ class WholeBodySegmentTool extends GrowCutBaseTool { ); // @ts-expect-error element.removeEventListener(Events.MOUSE_CLICK, this._endCallback); + // @ts-expect-error + element.removeEventListener(Events.TOUCH_END, this._endCallback); + element.removeEventListener( + Events.TOUCH_DRAG, + this._dragCallback as unknown as EventListener + ); + // @ts-expect-error + element.removeEventListener(Events.TOUCH_TAP, this._endCallback); }; private _projectWorldPointAcrossSlices( diff --git a/packages/tools/src/tools/segmentation/BrushTool.ts b/packages/tools/src/tools/segmentation/BrushTool.ts index 6bcdde7a3b..6d6bcc9b19 100644 --- a/packages/tools/src/tools/segmentation/BrushTool.ts +++ b/packages/tools/src/tools/segmentation/BrushTool.ts @@ -45,6 +45,9 @@ class BrushTool extends LabelmapBaseTool { canvas: Types.Point2; world: Types.Point3; } | null = null; + // Native-event timestamp of the interaction that activated the current draw + // loop, used to discard delayed TOUCH_TAP events from a previous gesture. + private _drawStartTimeStamp = 0; private _lazyEdit = new LazyBrushEditController(); constructor( @@ -218,14 +221,17 @@ class BrushTool extends LabelmapBaseTool { element: HTMLDivElement, centerCanvas: Types.Point2, viewportId: string, - segmentationId: string + segmentationId: string, + isTouch = false ): void { this._lazyEdit.scheduleCleanup({ element, centerCanvas, viewportId, segmentationId, - refreshCursor: this._refreshCursor.bind(this), + refreshCursor: isTouch + ? (cleanupElement: HTMLDivElement) => this._clearCursor(cleanupElement) + : this._refreshCursor.bind(this), }); } @@ -265,6 +271,7 @@ class BrushTool extends LabelmapBaseTool { // This might be a mouse down this._previewData.isDrag = false; this._previewData.timerStart = Date.now(); + this._drawStartTimeStamp = (eventData.event as Event)?.timeStamp ?? 0; const canvasPoint = vec2.clone(currentPoints.canvas) as Types.Point2; const worldPoint = viewport.canvasToWorld([ canvasPoint[0], @@ -284,6 +291,7 @@ class BrushTool extends LabelmapBaseTool { return false; } this._calculateCursor(element, canvasPoint); + BrushTool.activeCursorTool = this; this._resetLazyEditState(); if (this._isLazyLabelmapEditingEnabled(this._hoverData.viewport)) { @@ -442,9 +450,38 @@ class BrushTool extends LabelmapBaseTool { this._refreshCursor(element, centerCanvas); } + private _isTouchInteraction(evt: EventTypes.InteractionEventType): boolean { + const { eventName } = evt.detail; + return ( + eventName === Events.TOUCH_START || + eventName === Events.TOUCH_START_ACTIVATE || + eventName === Events.TOUCH_DRAG || + eventName === Events.TOUCH_END || + eventName === Events.TOUCH_TAP + ); + } + + private _clearCursor(element: HTMLDivElement): void { + const enabledElement = getEnabledElement(element); + this._hoverData = undefined; + if (enabledElement) { + triggerAnnotationRenderForViewportUIDs([enabledElement.viewport.id]); + } + } + private _dragCallback = (evt: EventTypes.InteractionEventType): void => { const eventData = evt.detail; const { element, currentPoints } = eventData; + + const { currentPointsList } = eventData as EventTypes.TouchDragEventDetail; + if (currentPointsList?.length > 1) { + // A second finger reclassifies the gesture (pinch zoom, multi-finger + // scroll); stop painting and roll the stroke back rather than keep + // drawing at the mean touch point. + this._cancelTouchStroke(element); + return; + } + const enabledElement = getEnabledElement(element); const { viewport } = enabledElement; @@ -539,6 +576,35 @@ class BrushTool extends LabelmapBaseTool { this._previewData.startPoint = currentCanvasClone; }; + /** + * Cancels an in-progress touch stroke: rolls back the voxel writes recorded + * so far and tears the draw loop down, so a gesture that turns out to be + * multi-finger does not leave paint behind. The memo is committed only to + * materialize its undo state and is discarded without reaching the history + * stack. + */ + private _cancelTouchStroke(element: HTMLDivElement): void { + const { memo } = this; + if (memo?.commitMemo?.()) { + memo.restoreMemo(true); + } + this.memo = null; + + this._deactivateDraw(element); + resetElementCursor(element); + + this._editData = null; + this._lastDragInfo = null; + this._previewData.preview = null; + this._previewData.isDrag = false; + if (this._previewData.timer) { + window.clearTimeout(this._previewData.timer); + this._previewData.timer = null; + } + this._resetLazyEditState(); + this._clearCursor(element); + } + private _calculateCursor(element, _centerCanvas) { const enabledElement = getEnabledElement(element); const operationData = this.getOperationData(element); @@ -562,6 +628,17 @@ class BrushTool extends LabelmapBaseTool { private _endCallback = (evt: EventTypes.InteractionEventType): void => { const eventData = evt.detail; const { element } = eventData; + + if (eventData.eventName === Events.TOUCH_TAP) { + // TOUCH_TAP is dispatched on a delay after touchend, so a tap from a + // previous gesture can land while a new stroke is in progress. Only + // honor taps belonging to the gesture that activated this draw loop. + const tapTimeStamp = (eventData.event as Event)?.timeStamp ?? 0; + if (tapTimeStamp < this._drawStartTimeStamp) { + return; + } + } + const enabledElement = getEnabledElement(element); const operationData = this.getOperationData(element); @@ -598,14 +675,21 @@ class BrushTool extends LabelmapBaseTool { element, evt.detail.currentPoints.canvas as Types.Point2, enabledElement.viewport.id, - operationData.segmentationId + operationData.segmentationId, + this._isTouchInteraction(evt) ); triggerAnnotationRenderForViewportUIDs( this._hoverData.viewportIdsToRender ); } else { this._resetLazyEditState(); - this.updateCursor(evt); + if (this._isTouchInteraction(evt)) { + // On touch there is no pointer hovering after release, so clear the + // cursor instead of re-showing it at the lift point. + this._clearCursor(element); + } else { + this.updateCursor(evt); + } } this.applyActiveStrategyCallback( @@ -720,6 +804,19 @@ class BrushTool extends LabelmapBaseTool { Events.MOUSE_CLICK, this._endCallback as EventListener ); + + element.addEventListener( + Events.TOUCH_END, + this._endCallback as EventListener + ); + element.addEventListener( + Events.TOUCH_DRAG, + this._dragCallback as EventListener + ); + element.addEventListener( + Events.TOUCH_TAP, + this._endCallback as EventListener + ); }; /** @@ -738,6 +835,19 @@ class BrushTool extends LabelmapBaseTool { Events.MOUSE_CLICK, this._endCallback as EventListener ); + + element.removeEventListener( + Events.TOUCH_END, + this._endCallback as EventListener + ); + element.removeEventListener( + Events.TOUCH_DRAG, + this._dragCallback as EventListener + ); + element.removeEventListener( + Events.TOUCH_TAP, + this._endCallback as EventListener + ); }; public invalidateBrushCursor() { diff --git a/packages/tools/src/tools/segmentation/CircleScissorsTool.ts b/packages/tools/src/tools/segmentation/CircleScissorsTool.ts index b7fa919a97..2cc7550d18 100644 --- a/packages/tools/src/tools/segmentation/CircleScissorsTool.ts +++ b/packages/tools/src/tools/segmentation/CircleScissorsTool.ts @@ -242,9 +242,20 @@ class CircleScissorsTool extends LabelmapBaseTool { }; _dragCallback = (evt: EventTypes.InteractionEventType) => { - this.isDrawing = true; const eventDetail = evt.detail; const { element } = eventDetail; + + const { currentPointsList } = + eventDetail as EventTypes.TouchDragEventDetail; + if (currentPointsList?.length > 1) { + // A second finger reclassifies the gesture (pinch zoom, multi-finger + // scroll); drop the rubber band instead of resizing it at the mean + // touch point and applying on release. + this._cancelTouchDraw(element); + return; + } + + this.isDrawing = true; const { currentPoints } = eventDetail; const currentCanvasPoints = currentPoints.canvas; const enabledElement = getEnabledElement(element); @@ -271,6 +282,27 @@ class CircleScissorsTool extends LabelmapBaseTool { triggerAnnotationRenderForViewportIds(viewportIdsToRender); }; + /** + * Cancels an in-progress touch draw when the gesture becomes multi-finger: + * the rubber band lives only in editData (never in the annotation state), + * so teardown plus a render makes it disappear without applying anything. + */ + private _cancelTouchDraw(element: HTMLDivElement): void { + if (!this.isDrawing) { + return; + } + const viewportIdsToRender = this.editData?.viewportIdsToRender; + + this._deactivateDraw(element); + resetElementCursor(element); + this.editData = null; + this.isDrawing = false; + + if (viewportIdsToRender) { + triggerAnnotationRenderForViewportIds(viewportIdsToRender); + } + } + _endCallback = (evt: EventTypes.InteractionEventType) => { const eventDetail = evt.detail; const { element } = eventDetail; diff --git a/packages/tools/src/tools/segmentation/RectangleROIStartEndThresholdTool.ts b/packages/tools/src/tools/segmentation/RectangleROIStartEndThresholdTool.ts index df0fd5390a..2d406aaec0 100644 --- a/packages/tools/src/tools/segmentation/RectangleROIStartEndThresholdTool.ts +++ b/packages/tools/src/tools/segmentation/RectangleROIStartEndThresholdTool.ts @@ -81,6 +81,7 @@ class RectangleROIStartEndThresholdTool extends RectangleROITool { constructor( toolProps: PublicToolProps = {}, defaultToolProps: ToolProps = { + supportedInteractionTypes: ['Mouse', 'Touch'], configuration: { // Whether to store point data in the annotation storePointData: false, diff --git a/packages/tools/src/tools/segmentation/RectangleScissorsTool.ts b/packages/tools/src/tools/segmentation/RectangleScissorsTool.ts index 1d11cf7fce..e3cbc81b46 100644 --- a/packages/tools/src/tools/segmentation/RectangleScissorsTool.ts +++ b/packages/tools/src/tools/segmentation/RectangleScissorsTool.ts @@ -235,11 +235,21 @@ class RectangleScissorsTool extends LabelmapBaseTool { }; _dragCallback = (evt: EventTypes.InteractionEventType) => { - this.isDrawing = true; - const eventDetail = evt.detail; const { element } = eventDetail; + const { currentPointsList } = + eventDetail as EventTypes.TouchDragEventDetail; + if (currentPointsList?.length > 1) { + // A second finger reclassifies the gesture (pinch zoom, multi-finger + // scroll); drop the rubber band instead of resizing it at the mean + // touch point and applying on release. + this._cancelTouchDraw(element); + return; + } + + this.isDrawing = true; + const { annotation, viewportIdsToRender, handleIndex } = this.editData; const { data } = annotation; @@ -309,6 +319,27 @@ class RectangleScissorsTool extends LabelmapBaseTool { triggerAnnotationRenderForViewportIds(viewportIdsToRender); }; + /** + * Cancels an in-progress touch draw when the gesture becomes multi-finger: + * the rubber band lives only in editData (never in the annotation state), + * so teardown plus a render makes it disappear without applying anything. + */ + private _cancelTouchDraw(element: HTMLDivElement): void { + if (!this.isDrawing) { + return; + } + const viewportIdsToRender = this.editData?.viewportIdsToRender; + + this._deactivateDraw(element); + resetElementCursor(element); + this.editData = null; + this.isDrawing = false; + + if (viewportIdsToRender) { + triggerAnnotationRenderForViewportIds(viewportIdsToRender); + } + } + _endCallback = (evt: EventTypes.InteractionEventType) => { const eventDetail = evt.detail; const { element } = eventDetail; diff --git a/packages/tools/src/tools/segmentation/SegmentLabelTool.ts b/packages/tools/src/tools/segmentation/SegmentLabelTool.ts index 39bf8ac896..97b02ab4b1 100644 --- a/packages/tools/src/tools/segmentation/SegmentLabelTool.ts +++ b/packages/tools/src/tools/segmentation/SegmentLabelTool.ts @@ -46,7 +46,7 @@ class SegmentLabelTool extends BaseTool { }, }, defaultToolProps: ToolProps = { - supportedInteractionTypes: ['Mouse', 'Touch'], + supportedInteractionTypes: ['Mouse'], configuration: { hoverTimeout: 100, searchRadius: 6, // search for border in a 6px radius diff --git a/packages/tools/src/tools/segmentation/SegmentSelectTool.ts b/packages/tools/src/tools/segmentation/SegmentSelectTool.ts index 0d771fa807..918ce71466 100644 --- a/packages/tools/src/tools/segmentation/SegmentSelectTool.ts +++ b/packages/tools/src/tools/segmentation/SegmentSelectTool.ts @@ -33,7 +33,7 @@ class SegmentSelectTool extends BaseTool { constructor( toolProps: PublicToolProps = {}, defaultToolProps: ToolProps = { - supportedInteractionTypes: ['Mouse', 'Touch'], + supportedInteractionTypes: ['Mouse'], configuration: { hoverTimeout: 100, mode: SegmentSelectTool.SelectMode.Border, diff --git a/packages/tools/src/tools/segmentation/SphereScissorsTool.ts b/packages/tools/src/tools/segmentation/SphereScissorsTool.ts index 88d30ecf56..45a93a30b9 100644 --- a/packages/tools/src/tools/segmentation/SphereScissorsTool.ts +++ b/packages/tools/src/tools/segmentation/SphereScissorsTool.ts @@ -212,9 +212,20 @@ class SphereScissorsTool extends LabelmapBaseTool { }; _dragCallback = (evt: EventTypes.InteractionEventType) => { - this.isDrawing = true; const eventDetail = evt.detail; const { element } = eventDetail; + + const { currentPointsList } = + eventDetail as EventTypes.TouchDragEventDetail; + if (currentPointsList?.length > 1) { + // A second finger reclassifies the gesture (pinch zoom, multi-finger + // scroll); drop the rubber band instead of resizing it at the mean + // touch point and applying on release. + this._cancelTouchDraw(element); + return; + } + + this.isDrawing = true; const { currentPoints } = eventDetail; const currentCanvasPoints = currentPoints.canvas; const enabledElement = getEnabledElement(element); @@ -239,6 +250,27 @@ class SphereScissorsTool extends LabelmapBaseTool { triggerAnnotationRenderForViewportIds(viewportIdsToRender); }; + /** + * Cancels an in-progress touch draw when the gesture becomes multi-finger: + * the rubber band lives only in editData (never in the annotation state), + * so teardown plus a render makes it disappear without applying anything. + */ + private _cancelTouchDraw(element: HTMLDivElement): void { + if (!this.isDrawing) { + return; + } + const viewportIdsToRender = this.editData?.viewportIdsToRender; + + this._deactivateDraw(element); + resetElementCursor(element); + this.editData = null; + this.isDrawing = false; + + if (viewportIdsToRender) { + triggerAnnotationRenderForViewportIds(viewportIdsToRender); + } + } + _endCallback = (evt: EventTypes.InteractionEventType) => { const eventDetail = evt.detail; const { element } = eventDetail; diff --git a/packages/tools/src/utilities/touch/index.ts b/packages/tools/src/utilities/touch/index.ts index 2e83e5bed8..dc28b27f6c 100644 --- a/packages/tools/src/utilities/touch/index.ts +++ b/packages/tools/src/utilities/touch/index.ts @@ -234,6 +234,19 @@ function _getDistance3D(point0: Types.Point3, point1: Types.Point3): number { ); } +/** + * Returns true when the platform reports a coarse pointer (touch-capable + * device). Same `(any-pointer:coarse)` check the examples use to enable + * mobile-specific tool configuration; false in non-browser environments. + */ +function isMobile(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(any-pointer:coarse)').matches + ); +} + export { getMeanPoints, getMeanTouchPoints, @@ -243,4 +256,5 @@ export { getDeltaPoints, getDeltaDistance, getDeltaRotation, + isMobile, }; diff --git a/utils/ExampleRunner/template-config.js b/utils/ExampleRunner/template-config.js index 16b675fb7e..cd5c62f8cc 100644 --- a/utils/ExampleRunner/template-config.js +++ b/utils/ExampleRunner/template-config.js @@ -1,126 +1,133 @@ -const path = require('path'); - -const csRenderBasePath = path.resolve('packages/core/src/index'); -const csToolsBasePath = path.resolve('packages/tools/src/index'); -const csAiBasePath = path.resolve('packages/ai/src/index'); -const csLabelmapInterpolationBasePath = path.resolve( - 'packages/labelmap-interpolation/src/index' -); -const csPolymorphicSegmentationBasePath = path.resolve( - 'packages/polymorphic-segmentation/src/index' -); -const csAdapters = path.resolve('packages/adapters/src/index'); -const csDICOMImageLoaderDistPath = path.resolve( - 'packages/dicomImageLoader/src/index' -); -const csMetadataBasePath = path.resolve('packages/metadata/src'); -const csUtilsBasePath = path.resolve('packages/utils/src'); -const csNiftiPath = path.resolve('packages/nifti-volume-loader/src/index'); - -module.exports = function buildConfig(name, destPath, root, exampleBasePath) { - return ` -// THIS FILE IS AUTOGENERATED - DO NOT EDIT -const path = require('path') - -const rules = require('./rules-examples.js'); -const modules = [path.resolve('../node_modules/'), path.resolve('../../../node_modules/')]; - -const rspack = require('@rspack/core'); - -module.exports = { - mode: 'development', - devtool: 'inline-source-map', - plugins: [ - new rspack.HtmlRspackPlugin({ - template: '${root.replace(/\\/g, '/')}/utils/ExampleRunner/template.html', - }), - new rspack.DefinePlugin({ - __BASE_PATH__: "''", - }), - new rspack.CopyRspackPlugin({ - patterns: [ - { - from: - '../../../node_modules/dicom-microscopy-viewer/dist/dynamic-import/', - to: '${destPath.replace(/\\/g, '/') + '/dicom-microscopy-viewer'}', - noErrorOnMissing: true, - }, - { - from: - '../../../node_modules/onnxruntime-web/dist', - to: '${destPath.replace(/\\/g, '/')}/ort', - }, - ], - }), - new rspack.ProvidePlugin({ - Buffer: ['buffer', 'Buffer'], - }), - ], - entry: path.join('${exampleBasePath.replace(/\\/g, '/')}'), - output: { - path: '${destPath.replace(/\\/g, '/')}', - filename: '${name}.js', - }, - module: { - rules, - }, - experiments: { - asyncWebAssembly: true, - nativeWatcher: true - }, - externals: { - "dicom-microscopy-viewer": { - root: "window", - commonjs: "dicomMicroscopyViewer", - }, - }, - resolve: { - alias: { - '@cornerstonejs/core': '${csRenderBasePath.replace(/\\/g, '/')}', - '@cornerstonejs/tools': '${csToolsBasePath.replace(/\\/g, '/')}', - '@cornerstonejs/ai': '${csAiBasePath.replace(/\\/g, '/')}', - '@cornerstonejs/polymorphic-segmentation': '${csPolymorphicSegmentationBasePath.replace( - /\\/g, - '/' - )}', - '@cornerstonejs/labelmap-interpolation': '${csLabelmapInterpolationBasePath.replace( - /\\/g, - '/' - )}', - '@cornerstonejs/nifti-volume-loader': '${csNiftiPath.replace( - /\\/g, - '/' - )}', - '@cornerstonejs/adapters': '${csAdapters.replace(/\\/g, '/')}', - '@cornerstonejs/metadata': '${csMetadataBasePath.replace(/\\/g, '/')}', - '@cornerstonejs/utils': '${csUtilsBasePath.replace(/\\/g, '/')}', - '@cornerstonejs/dicom-image-loader': '${csDICOMImageLoaderDistPath.replace( - /\\/g, - '/' - )}', - }, - modules, - extensions: ['.ts', '.tsx', '.js', '.jsx'], - fallback: { - fs: false, - path: require.resolve('path-browserify'), - events: false, - buffer: require.resolve('buffer'), - // vtk.js 36 pulls in xmlbuilder2@4 -> @oozcitak/url which requires - // node's built-in 'url' module, unavailable in the browser bundle. - url: false, - }, - }, - devServer: { - hot: true, - open: false, - port: ${process.env.CS3D_PORT || 3000}, - historyApiFallback: true, - allowedHosts: [ - '127.0.0.1', - 'localhost', - ], - }, -}; -`; -}; +const path = require('path'); + +const csRenderBasePath = path.resolve('packages/core/src/index'); +const csToolsBasePath = path.resolve('packages/tools/src/index'); +const csAiBasePath = path.resolve('packages/ai/src/index'); +const csLabelmapInterpolationBasePath = path.resolve( + 'packages/labelmap-interpolation/src/index' +); +const csPolymorphicSegmentationBasePath = path.resolve( + 'packages/polymorphic-segmentation/src/index' +); +const csAdapters = path.resolve('packages/adapters/src/index'); +const csDICOMImageLoaderDistPath = path.resolve( + 'packages/dicomImageLoader/src/index' +); +const csMetadataBasePath = path.resolve('packages/metadata/src'); +const csUtilsBasePath = path.resolve('packages/utils/src'); +const csNiftiPath = path.resolve('packages/nifti-volume-loader/src/index'); + +module.exports = function buildConfig(name, destPath, root, exampleBasePath) { + return ` +// THIS FILE IS AUTOGENERATED - DO NOT EDIT +const path = require('path') + +const rules = require('./rules-examples.js'); +const modules = [path.resolve('../node_modules/'), path.resolve('../../../node_modules/')]; + +const rspack = require('@rspack/core'); + +module.exports = { + mode: 'development', + devtool: 'inline-source-map', + plugins: [ + new rspack.HtmlRspackPlugin({ + template: '${root.replace(/\\/g, '/')}/utils/ExampleRunner/template.html', + }), + new rspack.DefinePlugin({ + __BASE_PATH__: "''", + }), + new rspack.CopyRspackPlugin({ + patterns: [ + { + from: + '../../../node_modules/dicom-microscopy-viewer/dist/dynamic-import/', + to: '${destPath.replace(/\\/g, '/') + '/dicom-microscopy-viewer'}', + noErrorOnMissing: true, + }, + { + from: + '../../../node_modules/onnxruntime-web/dist', + to: '${destPath.replace(/\\/g, '/')}/ort', + }, + ], + }), + new rspack.ProvidePlugin({ + Buffer: ['buffer', 'Buffer'], + }), + ], + entry: path.join('${exampleBasePath.replace(/\\/g, '/')}'), + output: { + path: '${destPath.replace(/\\/g, '/')}', + filename: '${name}.js', + }, + module: { + rules, + }, + experiments: { + asyncWebAssembly: true, + nativeWatcher: true + }, + externals: { + "dicom-microscopy-viewer": { + root: "window", + commonjs: "dicomMicroscopyViewer", + }, + }, + resolve: { + alias: { + '@cornerstonejs/core': '${csRenderBasePath.replace(/\\/g, '/')}', + '@cornerstonejs/tools': '${csToolsBasePath.replace(/\\/g, '/')}', + '@cornerstonejs/ai': '${csAiBasePath.replace(/\\/g, '/')}', + '@cornerstonejs/polymorphic-segmentation': '${csPolymorphicSegmentationBasePath.replace( + /\\/g, + '/' + )}', + '@cornerstonejs/labelmap-interpolation': '${csLabelmapInterpolationBasePath.replace( + /\\/g, + '/' + )}', + '@cornerstonejs/nifti-volume-loader': '${csNiftiPath.replace( + /\\/g, + '/' + )}', + '@cornerstonejs/adapters': '${csAdapters.replace(/\\/g, '/')}', + '@cornerstonejs/metadata': '${csMetadataBasePath.replace(/\\/g, '/')}', + '@cornerstonejs/utils': '${csUtilsBasePath.replace(/\\/g, '/')}', + '@cornerstonejs/dicom-image-loader': '${csDICOMImageLoaderDistPath.replace( + /\\/g, + '/' + )}', + }, + modules, + extensions: ['.ts', '.tsx', '.js', '.jsx'], + fallback: { + fs: false, + path: require.resolve('path-browserify'), + events: false, + buffer: require.resolve('buffer'), + // vtk.js 36 pulls in xmlbuilder2@4 -> @oozcitak/url which requires + // node's built-in 'url' module, unavailable in the browser bundle. + url: false, + }, + }, + devServer: { + hot: true, + open: false, + port: ${process.env.CS3D_PORT || 3000}, + historyApiFallback: true, + ${ + // CS3D_ALLOW_LAN=1 exposes the dev server on the local network so + // examples can be opened from a phone/tablet (touch testing). + process.env.CS3D_ALLOW_LAN + ? `host: '0.0.0.0', + allowedHosts: 'all',` + : `allowedHosts: [ + '127.0.0.1', + 'localhost', + ],` + } + }, +}; +`; +}; From f327a7051b53ee04a94fd65a5b5e12d928701588 Mon Sep 17 00:00:00 2001 From: Alireza Date: Fri, 17 Jul 2026 12:56:36 -0400 Subject: [PATCH 2/3] fix(tools): address review feedback on touch interactions Crosshairs skips the global interaction lock per touch gesture instead of per device capability so mouse drags on hybrid hardware keep the lock; brush cursor clearing rerenders all linked viewports; TrackballRotate and VolumeCropping clean up on touchend/touchcancel; touchAllTools example description matches the two-viewport roster and drops vestigial Crosshairs wiring; example runner LAN flag requires CS3D_ALLOW_LAN=1 exactly. --- .../tools/examples/touchAllTools/index.ts | 16 +++-------- packages/tools/src/tools/CrosshairsTool.ts | 28 +++++++++++++++---- .../tools/src/tools/TrackballRotateTool.ts | 9 ++++-- .../tools/src/tools/VolumeCroppingTool.ts | 11 ++++++++ .../tools/src/tools/segmentation/BrushTool.ts | 11 +++++++- utils/ExampleRunner/template-config.js | 7 +++-- 6 files changed, 58 insertions(+), 24 deletions(-) diff --git a/packages/tools/examples/touchAllTools/index.ts b/packages/tools/examples/touchAllTools/index.ts index 6cccfb4117..39658520bd 100644 --- a/packages/tools/examples/touchAllTools/index.ts +++ b/packages/tools/examples/touchAllTools/index.ts @@ -65,8 +65,6 @@ const { SphereScissorsTool, PaintFillTool, LabelMapEditWithContourTool, - // Reference - CrosshairsTool, } = cornerstoneTools; const { MouseBindings } = csToolsEnums; @@ -87,8 +85,8 @@ setTitleAndDescription( 'Touch: All Tools', 'One page to exercise the touch interaction surface on a phone or tablet: ' + 'manipulation, all measurement tools, labelmap brushing/scissors/fill, ' + - 'contour labelmap editing, click-to-segment, AdvancedMagnify and ' + - 'Crosshairs (linking the two volume viewports). Magnify runs on the ' + + 'contour labelmap editing and AdvancedMagnify, on a sagittal volume ' + + 'viewport (top) and a stack viewport (bottom). Magnify runs on the ' + 'stack viewport only. No app-level touch-action is set here - the ' + 'rendering engine now applies it to viewport elements itself.' ); @@ -203,13 +201,8 @@ function setSelectedTool(toolName: string) { } if (previousToolName) { - // Passive keeps existing annotations visible and editable; Crosshairs - // renders its gizmo even when passive, so it is fully disabled instead. - if (previousToolName === CrosshairsTool.toolName) { - toolGroup.setToolDisabled(previousToolName); - } else { - toolGroup.setToolPassive(previousToolName); - } + // Passive keeps existing annotations visible and editable. + toolGroup.setToolPassive(previousToolName); } toolGroup.setToolActive(toolName, { @@ -541,7 +534,6 @@ async function run() { SphereScissorsTool, PaintFillTool, LabelMapEditWithContourTool, - CrosshairsTool, ]; toolClasses.forEach((toolClass) => cornerstoneTools.addTool(toolClass)); diff --git a/packages/tools/src/tools/CrosshairsTool.ts b/packages/tools/src/tools/CrosshairsTool.ts index 96737d4251..d4cef22021 100644 --- a/packages/tools/src/tools/CrosshairsTool.ts +++ b/packages/tools/src/tools/CrosshairsTool.ts @@ -595,7 +595,7 @@ class CrosshairsTool extends AnnotationTool { hideElementCursor(element); - this._activateModify(element); + this._activateModify(element, evt); return filteredAnnotations[0]; }; @@ -661,7 +661,7 @@ class CrosshairsTool extends AnnotationTool { // from the camera variables of the viewports and of the slab thickness variable. // Remember that the translation and rotation operations operate on the camera // variables and not really on the handles. Similar for the slab thickness. - this._activateModify(element); + this._activateModify(element, evt); hideElementCursor(element); @@ -700,7 +700,7 @@ class CrosshairsTool extends AnnotationTool { const eventDetail = evt.detail; const { element } = eventDetail; annotation.highlighted = true; - this._activateModify(element); + this._activateModify(element, evt); hideElementCursor(element); @@ -2141,7 +2141,7 @@ class CrosshairsTool extends AnnotationTool { return true; }; - _activateModify = (element) => { + _activateModify = (element, evt?: EventTypes.InteractionEventType) => { this._syncVolumeListenersWithToolGroup(); this._recomputeToolCenterFromAbsoluteCameras({ emitEvent: false, @@ -2150,8 +2150,12 @@ class CrosshairsTool extends AnnotationTool { // mobile sometimes has lingering interaction even when touchEnd triggers // this check allows for multiple handles to be active which doesn't affect - // tool usage. - state.isInteractingWithTool = !this.configuration.mobile?.enabled; + // tool usage. The lock is skipped per touch gesture, not per device - + // mouse interactions on touch-capable (hybrid) hardware must keep it, + // or other active tools would process the same mouse drag. + state.isInteractingWithTool = !( + this._isTouchInteraction(evt) && this.configuration.mobile?.enabled + ); element.addEventListener(Events.MOUSE_UP, this._endCallback); element.addEventListener(Events.MOUSE_DRAG, this._dragCallback); @@ -2162,6 +2166,18 @@ class CrosshairsTool extends AnnotationTool { element.addEventListener(Events.TOUCH_TAP, this._endCallback); }; + private _isTouchInteraction(evt?: EventTypes.InteractionEventType): boolean { + const eventName = evt?.detail?.eventName; + return ( + eventName === Events.TOUCH_START || + eventName === Events.TOUCH_START_ACTIVATE || + eventName === Events.TOUCH_DRAG || + eventName === Events.TOUCH_END || + eventName === Events.TOUCH_TAP || + eventName === Events.TOUCH_PRESS + ); + } + _deactivateModify = (element) => { state.isInteractingWithTool = false; diff --git a/packages/tools/src/tools/TrackballRotateTool.ts b/packages/tools/src/tools/TrackballRotateTool.ts index 24b04acecb..4da226dc5c 100644 --- a/packages/tools/src/tools/TrackballRotateTool.ts +++ b/packages/tools/src/tools/TrackballRotateTool.ts @@ -74,13 +74,15 @@ class TrackballRotateTool extends BaseTool { // Clean up previous event listener document.removeEventListener('mouseup', this.cleanUp); document.removeEventListener('touchend', this.cleanUp); + document.removeEventListener('touchcancel', this.cleanUp); } this.cleanUp = () => { - // Both listener types are armed below; whichever fires first must - // clear the other so no stale once-listener lingers on document. + // All listener types are armed below; whichever fires first must + // clear the others so no stale once-listener lingers on document. document.removeEventListener('mouseup', this.cleanUp); document.removeEventListener('touchend', this.cleanUp); + document.removeEventListener('touchcancel', this.cleanUp); mapper.setSampleDistance(originalSampleDistance); viewport.render(); this._hasResolutionChanged = false; @@ -88,6 +90,9 @@ class TrackballRotateTool extends BaseTool { document.addEventListener('mouseup', this.cleanUp, { once: true }); document.addEventListener('touchend', this.cleanUp, { once: true }); + // OS-interrupted gestures (incoming call, notification shade) end in + // touchcancel, never touchend. + document.addEventListener('touchcancel', this.cleanUp, { once: true }); } return true; }; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 8b2338c4a9..ee9e063539 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -472,9 +472,16 @@ class VolumeCroppingTool extends BaseTool { if (this.cleanUp !== null) { // Clean up previous event listener document.removeEventListener('mouseup', this.cleanUp); + document.removeEventListener('touchend', this.cleanUp); + document.removeEventListener('touchcancel', this.cleanUp); } this.cleanUp = () => { + // All listener types are armed below; whichever fires first must + // clear the others so no stale once-listener lingers on document. + document.removeEventListener('mouseup', this.cleanUp); + document.removeEventListener('touchend', this.cleanUp); + document.removeEventListener('touchcancel', this.cleanUp); mapper.setSampleDistance(originalSampleDistance); // Reset cursor style @@ -503,6 +510,10 @@ class VolumeCroppingTool extends BaseTool { }; document.addEventListener('mouseup', this.cleanUp, { once: true }); + document.addEventListener('touchend', this.cleanUp, { once: true }); + // OS-interrupted gestures (incoming call, notification shade) end in + // touchcancel, never touchend. + document.addEventListener('touchcancel', this.cleanUp, { once: true }); } return true; diff --git a/packages/tools/src/tools/segmentation/BrushTool.ts b/packages/tools/src/tools/segmentation/BrushTool.ts index 6d6bcc9b19..59ee2d6975 100644 --- a/packages/tools/src/tools/segmentation/BrushTool.ts +++ b/packages/tools/src/tools/segmentation/BrushTool.ts @@ -462,8 +462,17 @@ class BrushTool extends LabelmapBaseTool { } private _clearCursor(element: HTMLDivElement): void { - const enabledElement = getEnabledElement(element); + // The cursor may have been rendered on linked viewports too; rerender + // all of them, not just the source viewport, so no stale circle remains. + const viewportIdsToRender = this._hoverData?.viewportIdsToRender; this._hoverData = undefined; + + if (viewportIdsToRender?.length) { + triggerAnnotationRenderForViewportUIDs(viewportIdsToRender); + return; + } + + const enabledElement = getEnabledElement(element); if (enabledElement) { triggerAnnotationRenderForViewportUIDs([enabledElement.viewport.id]); } diff --git a/utils/ExampleRunner/template-config.js b/utils/ExampleRunner/template-config.js index cd5c62f8cc..806a22a462 100644 --- a/utils/ExampleRunner/template-config.js +++ b/utils/ExampleRunner/template-config.js @@ -117,9 +117,10 @@ module.exports = { port: ${process.env.CS3D_PORT || 3000}, historyApiFallback: true, ${ - // CS3D_ALLOW_LAN=1 exposes the dev server on the local network so - // examples can be opened from a phone/tablet (touch testing). - process.env.CS3D_ALLOW_LAN + // CS3D_ALLOW_LAN=1 (exactly '1') exposes the dev server on the local + // network so examples can be opened from a phone/tablet (touch + // testing); any other value keeps the default localhost-only setup. + process.env.CS3D_ALLOW_LAN === '1' ? `host: '0.0.0.0', allowedHosts: 'all',` : `allowedHosts: [ From 1cf20f7da2915822b3bd73e65e1c74ffd54122e0 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Thu, 30 Jul 2026 09:15:10 -0400 Subject: [PATCH 3/3] PR fixes --- package.json | 2 + .../migration-guides/5x/1-migration-notes.md | 40 ++++++++++++ .../touch/touchStartListener.ts | 11 +++- .../store/filterMoveableAnnotationTools.ts | 4 +- .../store/filterToolsWithMoveableHandles.ts | 4 +- .../tools/src/tools/AdvancedMagnifyTool.ts | 6 +- packages/tools/src/tools/CrosshairsTool.ts | 12 ---- .../src/tools/annotation/ArrowAnnotateTool.ts | 5 +- .../tools/annotation/LivewireContourTool.ts | 36 ++--------- .../src/tools/annotation/SplineROITool.ts | 40 ++---------- packages/tools/src/tools/base/BaseTool.ts | 64 +++++++++++++++++++ .../tools/base/ContourSegmentationBaseTool.ts | 62 ++++++++++++++++++ .../tools/src/tools/segmentation/BrushTool.ts | 19 ++---- .../tools/segmentation/CircleScissorsTool.ts | 21 ------ .../tools/segmentation/LabelmapBaseTool.ts | 47 ++++++++++++++ .../segmentation/RectangleScissorsTool.ts | 21 ------ .../tools/segmentation/SphereScissorsTool.ts | 21 ------ .../tools/src/utilities/touch/constants.ts | 41 ++++++++++++ packages/tools/src/utilities/touch/index.ts | 7 ++ 19 files changed, 302 insertions(+), 161 deletions(-) create mode 100644 packages/tools/src/utilities/touch/constants.ts diff --git a/package.json b/package.json index 974e40e348..ad2aacbe28 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "clean": "pnpm -r run clean", "clean:deep": "pnpm -r run clean:deep", "example": "node ./utils/ExampleRunner/example-runner-cli.js", + "example:share": "cross-env CS3D_ALLOW_LAN=1 node ./utils/ExampleRunner/example-runner-cli.js", + "tunnel": "cloudflared tunnel --url http://localhost:3000", "all-examples": "node ./utils/ExampleRunner/build-all-examples-cli.js --fromRoot", "build-all-examples": "cross-env NODE_OPTIONS=--max_old_space_size=32896 node ./utils/ExampleRunner/build-all-examples-cli.js --build --fromRoot || (echo 'Build failed - likely due to memory constraints' && exit 1)", "serve-static-examples": "pnpm dlx serve .static-examples --listen 3333", diff --git a/packages/docs/docs/migration-guides/5x/1-migration-notes.md b/packages/docs/docs/migration-guides/5x/1-migration-notes.md index 8f02daea1b..7073b5d5a5 100644 --- a/packages/docs/docs/migration-guides/5x/1-migration-notes.md +++ b/packages/docs/docs/migration-guides/5x/1-migration-notes.md @@ -202,3 +202,43 @@ The legacy `moduleResolution: "node"` (a.k.a. `node10`) does **not** map a as `any` or fail to resolve. This is a **type-resolution** concern only — runtime behavior is unaffected — but if you see missing types, switch to `"bundler"`/`"node16"`/`"nodenext"`. + +## Viewport elements set `touch-action: none` + +### What Changed + +The rendering engine now sets `touch-action: none` on every element it enables +as a viewport, and restores the element's prior inline value when the viewport +is disabled. Previously this was left to the application. + +### Why This Matters + +Without `touch-action: none`, the browser claims viewport gestures before +Cornerstone sees them: a one-finger drag scrolls the page instead of running the +active tool, a two-finger pinch zooms the document rather than the image, and a +double-tap triggers the browser's own zoom. Touch tools cannot work on an +element the browser is still handling, which is why this is applied +unconditionally rather than behind a configuration flag — there is no +useful behavior to preserve on the other side of the switch. + +The visible consequence is that **dragging on a viewport no longer scrolls the +page** on touch devices. Applications that relied on a viewport being a valid +place to start a page scroll need to provide scrollable area around the +viewport instead. + +Two notes on scope: + +- Only viewport elements are affected. The rest of your layout is untouched. +- The value is applied inline, so it overrides a `touch-action` coming from a + CSS class for the duration that the viewport is enabled. On disable the + element's original inline value is restored, and any CSS-supplied value takes + effect again. + +### Migration Guidance + +- **Remove application-level workarounds.** If you set `touch-action: none` (or + attached `preventDefault` touch listeners) on viewport elements to get touch + tools working, that code is now redundant and can be deleted. +- **Check your scroll affordances on small screens.** If a page relied on + viewport drags to scroll, add padding, a scroll container, or a gutter outside + the viewport elements so the page remains scrollable on a phone or tablet. diff --git a/packages/tools/src/eventListeners/touch/touchStartListener.ts b/packages/tools/src/eventListeners/touch/touchStartListener.ts index f5d50fa8fa..88fd20cc02 100644 --- a/packages/tools/src/eventListeners/touch/touchStartListener.ts +++ b/packages/tools/src/eventListeners/touch/touchStartListener.ts @@ -18,6 +18,10 @@ import { getMeanTouchPoints, // getRotation } from '../../utilities/touch'; +import { + TOUCH_TAP_MAX_CANVAS_DISTANCE, + TOUCH_TAP_TOLERANCE_MS, +} from '../../utilities/touch/constants'; import { Settings } from '@cornerstonejs/core'; const runtimeSettings = Settings.getRuntimeSettings(); @@ -126,8 +130,11 @@ const defaultTapState: ITouchTapListenerState = { ], taps: 0, tapTimeout: null, - tapMaxDistance: 24, - tapToleranceMs: 300, + // Tools that commit a gesture on TOUCH_END need these same values to + // recognize the trailing tap echo, so they live in utilities/touch/constants + // (re-exposed as BaseTool statics) rather than being defined here. + tapMaxDistance: TOUCH_TAP_MAX_CANVAS_DISTANCE, + tapToleranceMs: TOUCH_TAP_TOLERANCE_MS, }; let state: ITouchStartListenerState = utilities.deepClone(defaultState); diff --git a/packages/tools/src/store/filterMoveableAnnotationTools.ts b/packages/tools/src/store/filterMoveableAnnotationTools.ts index 8a2c2cd3c6..f6f5274f0f 100644 --- a/packages/tools/src/store/filterMoveableAnnotationTools.ts +++ b/packages/tools/src/store/filterMoveableAnnotationTools.ts @@ -4,6 +4,7 @@ import type { ToolAnnotationPair, ToolAnnotationsPair, } from '../types/InternalToolTypes'; +import { MOUSE_PROXIMITY, TOUCH_PROXIMITY } from '../utilities/touch/constants'; /** * Filters an array of tools with annotations, returning the first annotation @@ -23,7 +24,8 @@ export default function filterMoveableAnnotationTools( canvasCoords: Types.Point2, interactionType = 'mouse' ): ToolAnnotationPair[] { - const proximity = interactionType === 'touch' ? 36 : 6; + const proximity = + interactionType === 'touch' ? TOUCH_PROXIMITY : MOUSE_PROXIMITY; // TODO - This could get pretty expensive pretty quickly. We don't want to fetch the camera // And do world to canvas on each coord. diff --git a/packages/tools/src/store/filterToolsWithMoveableHandles.ts b/packages/tools/src/store/filterToolsWithMoveableHandles.ts index 57bbed5a08..cd424b5ab7 100644 --- a/packages/tools/src/store/filterToolsWithMoveableHandles.ts +++ b/packages/tools/src/store/filterToolsWithMoveableHandles.ts @@ -4,6 +4,7 @@ import type { ToolAnnotationsPair, ToolsWithMoveableHandles, } from '../types/InternalToolTypes'; +import { MOUSE_PROXIMITY, TOUCH_PROXIMITY } from '../utilities/touch/constants'; /** * Filters an array of tools, returning only tools with moveable handles at the mouse location that are not locked @@ -20,7 +21,8 @@ export default function filterToolsWithMoveableHandles( canvasCoords: Types.Point2, interactionType = 'mouse' ): ToolsWithMoveableHandles[] { - const proximity = interactionType === 'touch' ? 36 : 6; + const proximity = + interactionType === 'touch' ? TOUCH_PROXIMITY : MOUSE_PROXIMITY; const toolsWithMoveableHandles = []; ToolAndAnnotations.forEach(({ tool, annotations }) => { diff --git a/packages/tools/src/tools/AdvancedMagnifyTool.ts b/packages/tools/src/tools/AdvancedMagnifyTool.ts index 251d0abfdf..cd846bdc84 100644 --- a/packages/tools/src/tools/AdvancedMagnifyTool.ts +++ b/packages/tools/src/tools/AdvancedMagnifyTool.ts @@ -500,8 +500,7 @@ class AdvancedMagnifyTool extends AnnotationTool { _pressModifyCallback = (evt: EventTypes.TouchPressEventType): void => { const { element, startPoints } = evt.detail; const annotation = this.editData.annotation as AdvancedMagnifyAnnotation; - // Same touch proximity filterMoveableAnnotationTools uses (36 vs 6). - const proximity = 36; + const proximity = AdvancedMagnifyTool.TOUCH_PROXIMITY; if ( !this.isPointNearTool(element, annotation, startPoints.canvas, proximity) @@ -794,8 +793,7 @@ class AdvancedMagnifyTool extends AnnotationTool { const { viewport } = enabledElement; const canvasPoint = startPoints.canvas; - // Same touch proximity filterMoveableAnnotationTools uses (36 vs 6). - const proximity = 36; + const proximity = AdvancedMagnifyTool.TOUCH_PROXIMITY; const annotations = (getAnnotations(this.getToolName(), element) ?? []) as AdvancedMagnifyAnnotation[]; diff --git a/packages/tools/src/tools/CrosshairsTool.ts b/packages/tools/src/tools/CrosshairsTool.ts index d4cef22021..aa0bf4133e 100644 --- a/packages/tools/src/tools/CrosshairsTool.ts +++ b/packages/tools/src/tools/CrosshairsTool.ts @@ -2166,18 +2166,6 @@ class CrosshairsTool extends AnnotationTool { element.addEventListener(Events.TOUCH_TAP, this._endCallback); }; - private _isTouchInteraction(evt?: EventTypes.InteractionEventType): boolean { - const eventName = evt?.detail?.eventName; - return ( - eventName === Events.TOUCH_START || - eventName === Events.TOUCH_START_ACTIVATE || - eventName === Events.TOUCH_DRAG || - eventName === Events.TOUCH_END || - eventName === Events.TOUCH_TAP || - eventName === Events.TOUCH_PRESS - ); - } - _deactivateModify = (element) => { state.isInteractingWithTool = false; diff --git a/packages/tools/src/tools/annotation/ArrowAnnotateTool.ts b/packages/tools/src/tools/annotation/ArrowAnnotateTool.ts index 12422abebd..dd0a54f0d9 100644 --- a/packages/tools/src/tools/annotation/ArrowAnnotateTool.ts +++ b/packages/tools/src/tools/annotation/ArrowAnnotateTool.ts @@ -455,14 +455,13 @@ class ArrowAnnotateTool extends AnnotationTool { touchTapCallback = (evt: EventTypes.TouchTapEventType) => { if (evt.detail.taps == 2) { - // Same touch proximity filterMoveableAnnotationTools uses (36 vs 6). - this.doubleClickCallback(evt, 36); + this.doubleClickCallback(evt, ArrowAnnotateTool.TOUCH_PROXIMITY); } }; doubleClickCallback = ( evt: EventTypes.TouchTapEventType, - proximity = 6 + proximity = ArrowAnnotateTool.MOUSE_PROXIMITY ): void => { const eventDetail = evt.detail; const { element } = eventDetail; diff --git a/packages/tools/src/tools/annotation/LivewireContourTool.ts b/packages/tools/src/tools/annotation/LivewireContourTool.ts index db32139b73..3f714c2151 100644 --- a/packages/tools/src/tools/annotation/LivewireContourTool.ts +++ b/packages/tools/src/tools/annotation/LivewireContourTool.ts @@ -42,17 +42,6 @@ import { getCalibratedLengthUnitsAndScale, throttle } from '../../utilities'; const CLICK_CLOSE_CURVE_SQR_DIST = 10 ** 2; // px -// Mirror the (non-configurable) tapMaxDistance and tapToleranceMs in -// eventListeners/touch/touchStartListener.ts. A gesture that travels further -// than tapMaxDistance never emits its own TOUCH_TAP, but one that ends within -// tapMaxDistance of an active tap chain's start is still counted into that -// chain's aggregated TOUCH_TAP. Committing such a gesture on TOUCH_END records -// where and when it happened so the chain's TOUCH_TAP echo can be ignored in -// _mouseDownCallback instead of double-committing the point or force-closing -// the path. -const TOUCH_TAP_MAX_CANVAS_DISTANCE = 24; -const TOUCH_TAP_TOLERANCE_MS = 300; - class LivewireContourTool extends ContourSegmentationBaseTool { public static toolName = 'LivewireContour'; @@ -78,8 +67,6 @@ class LivewireContourTool extends ContourSegmentationBaseTool { sliceToWorld?: (point: Types.Point2) => Types.Point3; originalPath?: Types.Point3[]; contourHoleProcessingEnabled?: boolean; - /** Where and when the last point was committed on TOUCH_END */ - touchEndCommit?: { canvasPoint: Types.Point2; time: number }; } | null; isDrawing: boolean; isHandleOutsideImage = false; @@ -561,17 +548,11 @@ class LivewireContourTool extends ContourSegmentationBaseTool { // is committed by the TOUCH_END path and still counted into the chain's // aggregated TOUCH_TAP; ignore that echo so the already-committed point // is not added again and the path is not force-closed. - if (doubleTap && this.editData.touchEndCommit) { - const { canvasPoint, time } = this.editData.touchEndCommit; - if ( - Date.now() - time <= TOUCH_TAP_TOLERANCE_MS * 2 && - math.point.distanceToPoint( - evt.detail.currentPoints.canvas, - canvasPoint - ) <= TOUCH_TAP_MAX_CANVAS_DISTANCE - ) { - return; - } + if ( + doubleTap && + this.isTouchTapEchoOfLiftCommit(evt.detail.currentPoints.canvas) + ) { + return; } const { @@ -748,14 +729,11 @@ class LivewireContourTool extends ContourSegmentationBaseTool { startPoints.canvas, currentPoints.canvas ); - if (dragDistance <= TOUCH_TAP_MAX_CANVAS_DISTANCE) { + if (dragDistance <= LivewireContourTool.TOUCH_TAP_MAX_CANVAS_DISTANCE) { return; } - this.editData.touchEndCommit = { - canvasPoint: currentPoints.canvas, - time: Date.now(), - }; + this.recordTouchLiftCommit(currentPoints.canvas); this._mouseDownCallback(evt); }; diff --git a/packages/tools/src/tools/annotation/SplineROITool.ts b/packages/tools/src/tools/annotation/SplineROITool.ts index 4fd3c88713..7d18441812 100644 --- a/packages/tools/src/tools/annotation/SplineROITool.ts +++ b/packages/tools/src/tools/annotation/SplineROITool.ts @@ -60,19 +60,6 @@ import { convertContourSegmentationAnnotation } from '../../utilities/contourSeg const SPLINE_MIN_POINTS = 3; const SPLINE_CLICK_CLOSE_CURVE_DIST = 10; -// Mirrors the (non-configurable) tapMaxDistance in -// eventListeners/touch/touchStartListener.ts. Gestures that stay within this -// distance are committed by the TOUCH_TAP path; longer drags are committed on -// TOUCH_END. A longer drag that ends near the anchor of an active tap chain -// is still counted as a tap by the listener, so lift-commits are recorded in -// editData and the trailing TOUCH_TAP is dropped in _mouseDownCallback. -const TOUCH_TAP_MAX_CANVAS_DISTANCE = 24; - -// Mirrors the (non-configurable) tapToleranceMs in -// eventListeners/touch/touchStartListener.ts. TOUCH_TAP fires one tolerance -// after the last touchend of a tap chain. -const TOUCH_TAP_TOLERANCE_MS = 300; - const DEFAULT_SPLINE_CONFIG = { resolution: 20, controlPointAdditionDistance: 6, @@ -125,8 +112,6 @@ class SplineROITool extends ContourSegmentationBaseTool { lastCanvasPoint?: Types.Point2; /** Close-curve snap distance (canvas px) for the preview, per input source */ closeCurveDistance?: number; - lastLiftCommitCanvasPoint?: Types.Point2; - lastLiftCommitTime?: number; contourHoleProcessingEnabled?: boolean; } | null; isDrawing: boolean; @@ -566,21 +551,11 @@ class SplineROITool extends ContourSegmentationBaseTool { // travelled beyond the tap distance. Such a drag was already committed on // TOUCH_END; drop the trailing TOUCH_TAP or it would add a duplicate // point and close the curve. - if (doubleTap && this.editData.lastLiftCommitCanvasPoint) { - const { lastLiftCommitCanvasPoint, lastLiftCommitTime } = this.editData; - const tapDistance = math.point.distanceToPoint( - evt.detail.currentPoints.canvas, - lastLiftCommitCanvasPoint - ); - // TOUCH_TAP is emitted one tap tolerance after the chain's last - // touchend, so a lift-commit that ended the chain surfaces here about - // TOUCH_TAP_TOLERANCE_MS later; double it to absorb timer jitter. - if ( - Date.now() - lastLiftCommitTime <= 2 * TOUCH_TAP_TOLERANCE_MS && - tapDistance <= TOUCH_TAP_MAX_CANVAS_DISTANCE - ) { - return; - } + if ( + doubleTap && + this.isTouchTapEchoOfLiftCommit(evt.detail.currentPoints.canvas) + ) { + return; } const { annotation, viewportIdsToRender } = this.editData; @@ -673,12 +648,11 @@ class SplineROITool extends ContourSegmentationBaseTool { startPoints.canvas, currentPoints.canvas ); - if (dragDistance <= TOUCH_TAP_MAX_CANVAS_DISTANCE) { + if (dragDistance <= SplineROITool.TOUCH_TAP_MAX_CANVAS_DISTANCE) { return; } - this.editData.lastLiftCommitCanvasPoint = currentPoints.canvas; - this.editData.lastLiftCommitTime = Date.now(); + this.recordTouchLiftCommit(currentPoints.canvas); this._mouseDownCallback(evt); }; diff --git a/packages/tools/src/tools/base/BaseTool.ts b/packages/tools/src/tools/base/BaseTool.ts index 59917b6ec6..5b23774278 100644 --- a/packages/tools/src/tools/base/BaseTool.ts +++ b/packages/tools/src/tools/base/BaseTool.ts @@ -7,9 +7,17 @@ import { } from '@cornerstonejs/core'; import type { Types } from '@cornerstonejs/core'; import ToolModes from '../../enums/ToolModes'; +import Events from '../../enums/Events'; +import { + MOUSE_PROXIMITY, + TOUCH_PROXIMITY, + TOUCH_TAP_MAX_CANVAS_DISTANCE, + TOUCH_TAP_TOLERANCE_MS, +} from '../../utilities/touch/constants'; import measurementTargetFilters from './measurementTargetFilters'; import type StrategyCallbacks from '../../enums/StrategyCallbacks'; import type { + EventTypes, InteractionTypes, ToolProps, PublicToolProps, @@ -45,6 +53,36 @@ abstract class BaseTool { */ public static activeCursorTool; + /** + * Canvas-pixel radius used to hit-test annotations and handles for touch + * interactions. A fingertip covers far more screen than a cursor hotspot, + * so touch targets are much larger than {@link BaseTool.MOUSE_PROXIMITY}. + * Tools that need to widen an in-draw target for touch should use this + * rather than restating the number. + */ + public static readonly TOUCH_PROXIMITY = TOUCH_PROXIMITY; + + /** + * Canvas-pixel radius used to hit-test annotations and handles for mouse + * interactions. + */ + public static readonly MOUSE_PROXIMITY = MOUSE_PROXIMITY; + + /** + * Maximum canvas-pixel distance a gesture may travel and still be counted + * as a tap by the touch start listener. Tools that commit a gesture on + * TOUCH_END need this to recognize the trailing TOUCH_TAP echo. + */ + public static readonly TOUCH_TAP_MAX_CANVAS_DISTANCE = + TOUCH_TAP_MAX_CANVAS_DISTANCE; + + /** + * Window in milliseconds within which successive taps are aggregated into a + * single multi-tap TOUCH_TAP. The tap is emitted one tolerance after the + * chain's last touchend. + */ + public static readonly TOUCH_TAP_TOLERANCE_MS = TOUCH_TAP_TOLERANCE_MS; + /** Supported Interaction Types - currently only Mouse */ public supportedInteractionTypes: InteractionTypes[]; /** @@ -246,6 +284,32 @@ abstract class BaseTool { * @param targetId - annotation targetId stored in the cached stats * @returns The image data for the target. */ + /** + * Whether an interaction event came in through the touch pipeline rather + * than the mouse pipeline. + * + * Note this is a property of the *gesture*, not of the device: hybrid + * hardware (touchscreen laptops, tablets with a mouse attached) delivers + * both, so tools must branch on the event rather than on a device probe + * such as `isMobile()`. + * + * @param evt - The interaction event, or undefined when a caller has no + * event to attribute (treated as not-touch). + */ + protected _isTouchInteraction( + evt?: EventTypes.InteractionEventType + ): boolean { + const eventName = evt?.detail?.eventName; + return ( + eventName === Events.TOUCH_START || + eventName === Events.TOUCH_START_ACTIVATE || + eventName === Events.TOUCH_DRAG || + eventName === Events.TOUCH_END || + eventName === Events.TOUCH_TAP || + eventName === Events.TOUCH_PRESS + ); + } + protected getTargetImageData( targetId: string ): Types.IImageData | Types.CPUIImageData { diff --git a/packages/tools/src/tools/base/ContourSegmentationBaseTool.ts b/packages/tools/src/tools/base/ContourSegmentationBaseTool.ts index 3cd8c072d4..b51f5b9f0b 100644 --- a/packages/tools/src/tools/base/ContourSegmentationBaseTool.ts +++ b/packages/tools/src/tools/base/ContourSegmentationBaseTool.ts @@ -1,4 +1,6 @@ import { getEnabledElement, utilities } from '@cornerstonejs/core'; +import type { Types } from '@cornerstonejs/core'; +import { distanceToPoint } from '../../utilities/math/point'; import type { Annotation, EventTypes, @@ -39,6 +41,13 @@ import { defaultSegmentationStateManager } from '../../stateManagement/segmentat abstract class ContourSegmentationBaseTool extends ContourBaseTool { static PreviewSegmentIndex = 255; + /** + * Where and when a touch gesture was committed on TOUCH_END, used to + * recognize and drop the trailing TOUCH_TAP echo. See + * {@link ContourSegmentationBaseTool.isTouchTapEchoOfLiftCommit}. + */ + private _touchLiftCommit?: { canvasPoint: Types.Point2; time: number }; + constructor(toolProps: PublicToolProps, defaultToolProps: ToolProps) { super(toolProps, defaultToolProps); if (this.configuration.interpolation?.enabled) { @@ -46,6 +55,59 @@ abstract class ContourSegmentationBaseTool extends ContourBaseTool { } } + /** + * Records that a point was committed by the TOUCH_END path. + * + * A drag travels beyond the tap distance and so never emits its own + * TOUCH_TAP, but if it *ends* within the tap distance of an active tap + * chain's anchor the listener still folds it into that chain's aggregated + * TOUCH_TAP. Point-placing contour tools therefore commit such a gesture on + * TOUCH_END and record it here, so the tap that arrives a tolerance later + * can be identified as an echo instead of committing the point a second + * time (or force-closing the contour). + */ + protected recordTouchLiftCommit(canvasPoint: Types.Point2): void { + this._touchLiftCommit = { canvasPoint, time: Date.now() }; + } + + /** + * Whether `canvasPoint` is the aggregated TOUCH_TAP echo of a gesture this + * tool already committed via {@link recordTouchLiftCommit}, in which case + * the caller should ignore the tap. + * + * The recorded commit is consumed (or discarded once it ages out) so a + * stale entry cannot suppress a legitimate tap on a later contour. + */ + protected isTouchTapEchoOfLiftCommit(canvasPoint: Types.Point2): boolean { + if (!this._touchLiftCommit) { + return false; + } + + const { canvasPoint: committedPoint, time } = this._touchLiftCommit; + // TOUCH_TAP is emitted one tap tolerance after the chain's last touchend, + // so a lift-commit that ended the chain surfaces about one tolerance + // later; double it to absorb timer jitter. + const withinTime = + Date.now() - time <= + 2 * ContourSegmentationBaseTool.TOUCH_TAP_TOLERANCE_MS; + + if (!withinTime) { + this._touchLiftCommit = undefined; + return false; + } + + const withinDistance = + distanceToPoint(canvasPoint, committedPoint) <= + ContourSegmentationBaseTool.TOUCH_TAP_MAX_CANVAS_DISTANCE; + + if (!withinDistance) { + return false; + } + + this._touchLiftCommit = undefined; + return true; + } + protected onSetToolConfiguration() { if (this.configuration.interpolation?.enabled) { InterpolationManager.addTool(this.getToolName()); diff --git a/packages/tools/src/tools/segmentation/BrushTool.ts b/packages/tools/src/tools/segmentation/BrushTool.ts index 59ee2d6975..6abab2c8ed 100644 --- a/packages/tools/src/tools/segmentation/BrushTool.ts +++ b/packages/tools/src/tools/segmentation/BrushTool.ts @@ -450,17 +450,6 @@ class BrushTool extends LabelmapBaseTool { this._refreshCursor(element, centerCanvas); } - private _isTouchInteraction(evt: EventTypes.InteractionEventType): boolean { - const { eventName } = evt.detail; - return ( - eventName === Events.TOUCH_START || - eventName === Events.TOUCH_START_ACTIVATE || - eventName === Events.TOUCH_DRAG || - eventName === Events.TOUCH_END || - eventName === Events.TOUCH_TAP - ); - } - private _clearCursor(element: HTMLDivElement): void { // The cursor may have been rendered on linked viewports too; rerender // all of them, not just the source viewport, so no stale circle remains. @@ -487,7 +476,7 @@ class BrushTool extends LabelmapBaseTool { // A second finger reclassifies the gesture (pinch zoom, multi-finger // scroll); stop painting and roll the stroke back rather than keep // drawing at the mean touch point. - this._cancelTouchStroke(element); + this._cancelTouchDraw(element); return; } @@ -591,8 +580,12 @@ class BrushTool extends LabelmapBaseTool { * multi-finger does not leave paint behind. The memo is committed only to * materialize its undo state and is discarded without reaching the history * stack. + * + * Overrides the base teardown rather than extending it: a brush stroke has + * already written to the labelmap, and the preview/lazy-edit state has no + * counterpart on the scissors tools. */ - private _cancelTouchStroke(element: HTMLDivElement): void { + protected override _cancelTouchDraw(element: HTMLDivElement): void { const { memo } = this; if (memo?.commitMemo?.()) { memo.restoreMemo(true); diff --git a/packages/tools/src/tools/segmentation/CircleScissorsTool.ts b/packages/tools/src/tools/segmentation/CircleScissorsTool.ts index 2cc7550d18..899d9e1866 100644 --- a/packages/tools/src/tools/segmentation/CircleScissorsTool.ts +++ b/packages/tools/src/tools/segmentation/CircleScissorsTool.ts @@ -282,27 +282,6 @@ class CircleScissorsTool extends LabelmapBaseTool { triggerAnnotationRenderForViewportIds(viewportIdsToRender); }; - /** - * Cancels an in-progress touch draw when the gesture becomes multi-finger: - * the rubber band lives only in editData (never in the annotation state), - * so teardown plus a render makes it disappear without applying anything. - */ - private _cancelTouchDraw(element: HTMLDivElement): void { - if (!this.isDrawing) { - return; - } - const viewportIdsToRender = this.editData?.viewportIdsToRender; - - this._deactivateDraw(element); - resetElementCursor(element); - this.editData = null; - this.isDrawing = false; - - if (viewportIdsToRender) { - triggerAnnotationRenderForViewportIds(viewportIdsToRender); - } - } - _endCallback = (evt: EventTypes.InteractionEventType) => { const eventDetail = evt.detail; const { element } = eventDetail; diff --git a/packages/tools/src/tools/segmentation/LabelmapBaseTool.ts b/packages/tools/src/tools/segmentation/LabelmapBaseTool.ts index cd321edd63..59e986f783 100644 --- a/packages/tools/src/tools/segmentation/LabelmapBaseTool.ts +++ b/packages/tools/src/tools/segmentation/LabelmapBaseTool.ts @@ -34,6 +34,8 @@ import { resolveLabelmapForSegment, } from '../../stateManagement/segmentation/helpers/labelmapSegmentationState'; import getViewportICamera from '../../utilities/getViewportICamera'; +import triggerAnnotationRenderForViewportIds from '../../utilities/triggerAnnotationRenderForViewportIds'; +import { resetElementCursor } from '../../cursors/elementCursor'; /** * A type for preview data/information, used to setup previews on hover, or @@ -133,6 +135,19 @@ export default class LabelmapBaseTool extends BaseTool { isDrag: false, }; + /** + * Whether a draw loop is currently in progress. Declared here so the shared + * touch-cancellation path can reason about it; the drawing tools narrow it. + */ + protected isDrawing?: boolean; + + /** + * In-progress draw state. Declared here only with the members the shared + * touch-cancellation path needs; the drawing tools narrow it to their own + * (richer) shape. + */ + protected editData?: { viewportIdsToRender?: string[] } | null; + protected memoMap: Map; protected acceptedMemoIds: Map< string, @@ -152,6 +167,38 @@ export default class LabelmapBaseTool extends BaseTool { }; } + /** + * Cancels an in-progress touch draw when the gesture turns out to be + * multi-finger (pinch zoom, multi-finger scroll). The rubber band of the + * scissors tools lives only in `editData` and is never written to the + * annotation state, so tearing the draw loop down and re-rendering makes it + * disappear without applying anything to the labelmap. + * + * Tools whose stroke has already written voxels (BrushTool) need to roll + * those writes back as well and therefore override this. + */ + protected _cancelTouchDraw(element: HTMLDivElement): void { + if (!this.isDrawing) { + return; + } + + const viewportIdsToRender = this.editData?.viewportIdsToRender; + + // Declared on the subclasses (BrushTool declares it `private`, so it + // cannot be widened to `protected` on this class). + ( + this as unknown as { _deactivateDraw: (element: HTMLDivElement) => void } + )._deactivateDraw(element); + + resetElementCursor(element); + this.editData = null; + this.isDrawing = false; + + if (viewportIdsToRender) { + triggerAnnotationRenderForViewportIds(viewportIdsToRender); + } + } + protected _historyRedoHandler(evt) { const { id, operationType } = evt.detail; diff --git a/packages/tools/src/tools/segmentation/RectangleScissorsTool.ts b/packages/tools/src/tools/segmentation/RectangleScissorsTool.ts index e3cbc81b46..35fd59d874 100644 --- a/packages/tools/src/tools/segmentation/RectangleScissorsTool.ts +++ b/packages/tools/src/tools/segmentation/RectangleScissorsTool.ts @@ -319,27 +319,6 @@ class RectangleScissorsTool extends LabelmapBaseTool { triggerAnnotationRenderForViewportIds(viewportIdsToRender); }; - /** - * Cancels an in-progress touch draw when the gesture becomes multi-finger: - * the rubber band lives only in editData (never in the annotation state), - * so teardown plus a render makes it disappear without applying anything. - */ - private _cancelTouchDraw(element: HTMLDivElement): void { - if (!this.isDrawing) { - return; - } - const viewportIdsToRender = this.editData?.viewportIdsToRender; - - this._deactivateDraw(element); - resetElementCursor(element); - this.editData = null; - this.isDrawing = false; - - if (viewportIdsToRender) { - triggerAnnotationRenderForViewportIds(viewportIdsToRender); - } - } - _endCallback = (evt: EventTypes.InteractionEventType) => { const eventDetail = evt.detail; const { element } = eventDetail; diff --git a/packages/tools/src/tools/segmentation/SphereScissorsTool.ts b/packages/tools/src/tools/segmentation/SphereScissorsTool.ts index 45a93a30b9..286b800208 100644 --- a/packages/tools/src/tools/segmentation/SphereScissorsTool.ts +++ b/packages/tools/src/tools/segmentation/SphereScissorsTool.ts @@ -250,27 +250,6 @@ class SphereScissorsTool extends LabelmapBaseTool { triggerAnnotationRenderForViewportIds(viewportIdsToRender); }; - /** - * Cancels an in-progress touch draw when the gesture becomes multi-finger: - * the rubber band lives only in editData (never in the annotation state), - * so teardown plus a render makes it disappear without applying anything. - */ - private _cancelTouchDraw(element: HTMLDivElement): void { - if (!this.isDrawing) { - return; - } - const viewportIdsToRender = this.editData?.viewportIdsToRender; - - this._deactivateDraw(element); - resetElementCursor(element); - this.editData = null; - this.isDrawing = false; - - if (viewportIdsToRender) { - triggerAnnotationRenderForViewportIds(viewportIdsToRender); - } - } - _endCallback = (evt: EventTypes.InteractionEventType) => { const eventDetail = evt.detail; const { element } = eventDetail; diff --git a/packages/tools/src/utilities/touch/constants.ts b/packages/tools/src/utilities/touch/constants.ts new file mode 100644 index 0000000000..c2c7df9ff5 --- /dev/null +++ b/packages/tools/src/utilities/touch/constants.ts @@ -0,0 +1,41 @@ +/** + * Tuning values shared by the touch event listeners, the store filters that + * hit-test annotations, and the tools that need to reason about the tap + * pipeline's timing. + * + * This module is intentionally dependency-free: it is imported by low-level + * infrastructure (`eventListeners/touch`, `store/filter*`) as well as by + * `BaseTool`, which re-exposes these as static members so tool authors can + * reach them as `BaseTool.TOUCH_PROXIMITY` without importing internals. + * Change a value here and every consumer follows. + */ + +/** + * Canvas-pixel radius used to hit-test annotations and handles for touch + * interactions. A fingertip covers far more screen than a cursor hotspot, so + * touch gets a much larger target than {@link MOUSE_PROXIMITY}. + */ +export const TOUCH_PROXIMITY = 36; + +/** + * Canvas-pixel radius used to hit-test annotations and handles for mouse + * interactions. + */ +export const MOUSE_PROXIMITY = 6; + +/** + * Maximum canvas-pixel distance a gesture may travel from its start and still + * be counted as a tap by the touch start listener. A gesture that travels + * further never emits its own TOUCH_TAP, but one that *ends* within this + * distance of an active tap chain's anchor is still folded into that chain's + * aggregated TOUCH_TAP. + */ +export const TOUCH_TAP_MAX_CANVAS_DISTANCE = 24; + +/** + * Window in milliseconds within which successive taps are aggregated into a + * single multi-tap TOUCH_TAP. TOUCH_TAP is emitted one tolerance after the + * last touchend of a chain, which is why tools that commit a gesture on + * TOUCH_END have to recognize and drop the trailing tap echo. + */ +export const TOUCH_TAP_TOLERANCE_MS = 300; diff --git a/packages/tools/src/utilities/touch/index.ts b/packages/tools/src/utilities/touch/index.ts index dc28b27f6c..3636f1a636 100644 --- a/packages/tools/src/utilities/touch/index.ts +++ b/packages/tools/src/utilities/touch/index.ts @@ -258,3 +258,10 @@ export { getDeltaRotation, isMobile, }; + +export { + TOUCH_PROXIMITY, + MOUSE_PROXIMITY, + TOUCH_TAP_MAX_CANVAS_DISTANCE, + TOUCH_TAP_TOLERANCE_MS, +} from './constants';