diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index 98e3d495b..a2e059d57 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -11,6 +11,7 @@ import { annotation, Types as ToolTypes, SplineContourSegmentationTool, + cancelActiveManipulations, } from '@cornerstonejs/tools'; import { SegmentInfo, @@ -306,6 +307,20 @@ function commandsModule({ commandsManager.run('setDisplaySetsForViewports', { viewportsToUpdate: updatedViewports }); }, + /** + * Cancels any in-progress annotation manipulation (e.g. drawing a Spline, + * Livewire or PlanarFreehand contour) on the active viewport. Reached on + * Escape via the `cancelActiveOperation` command. `cancelActiveManipulations` + * invokes the `cancel` method of each active/passive tool that has an + * in-progress annotation, so it is a no-op when nothing is being drawn. + */ + cancelMeasurement: () => { + const element = _getActiveViewportEnabledElement()?.viewport?.element; + if (element) { + cancelActiveManipulations(element); + } + }, + hydrateSecondaryDisplaySet: async ({ displaySet, viewportId }) => { if (!displaySet) { return; @@ -2028,6 +2043,24 @@ function commandsModule({ rejectPreview: () => { actions._handlePreviewAction('reject'); }, + /** + * Generic Escape handler. A single Escape press should discard whatever the + * user has in progress, but that can be one of two unrelated things: a + * provisional segmentation preview, or an annotation being drawn. Rather + * than bind both `rejectPreview` and `cancelMeasurement` to `esc` (Mousetrap + * keeps only one handler per key, so the second silently shadows the first), + * this command orchestrates both single-purpose commands. Each is a no-op + * when its state is not active, so running both is safe and order-independent. + */ + cancelActiveOperation: () => { + try { + actions.rejectPreview(); + } catch (error) { + console.debug('Error rejecting active preview', error); + } finally { + actions.cancelMeasurement(); + } + }, clearMarkersForMarkerLabelmap: () => { const { viewport } = _getActiveViewportEnabledElement(); const toolGroup = cornerstoneTools.ToolGroupManager.getToolGroupForViewport(viewport.id); @@ -2550,6 +2583,9 @@ function commandsModule({ removeMeasurement: { commandFn: actions.removeMeasurement, }, + cancelMeasurement: { + commandFn: actions.cancelMeasurement, + }, toggleLockMeasurement: { commandFn: actions.toggleLockMeasurement, }, @@ -2798,6 +2834,7 @@ function commandsModule({ toggleSegmentSelect: actions.toggleSegmentSelect, acceptPreview: actions.acceptPreview, rejectPreview: actions.rejectPreview, + cancelActiveOperation: actions.cancelActiveOperation, toggleUseCenterSegmentIndex: actions.toggleUseCenterSegmentIndex, toggleLabelmapAssist: actions.toggleLabelmapAssist, interpolateScrollForMarkerLabelmap: actions.interpolateScrollForMarkerLabelmap, diff --git a/extensions/cornerstone/src/initMeasurementService.ts b/extensions/cornerstone/src/initMeasurementService.ts index 7837aa8d4..84f370dc7 100644 --- a/extensions/cornerstone/src/initMeasurementService.ts +++ b/extensions/cornerstone/src/initMeasurementService.ts @@ -531,10 +531,8 @@ const connectMeasurementServiceToTools = ({ } // Cancel any active tool manipulation (e.g., Spline/Livewire) to avoid leaving the tool // in a drawing state after deleting a not completed measurement, which can block viewport interactivity. - const element = getActiveViewportEnabledElement(viewportGridService)?.viewport?.element; - if (element) { - cancelActiveManipulations(element); - } + commandsManager.run('cancelMeasurement'); + const removedAnnotation = annotation.state.getAnnotation(removedMeasurementId); removeAnnotation(removedMeasurementId); // Ensure `removedAnnotation` is available before triggering the memo, diff --git a/platform/core/src/defaults/hotkeyBindings.ts b/platform/core/src/defaults/hotkeyBindings.ts index 87d0b6750..0c5249b4e 100644 --- a/platform/core/src/defaults/hotkeyBindings.ts +++ b/platform/core/src/defaults/hotkeyBindings.ts @@ -134,8 +134,8 @@ const bindings = [ isEditable: true, }, { - commandName: 'cancelMeasurement', - label: 'Cancel Measurement', + commandName: 'cancelActiveOperation', + label: 'Cancel', keys: ['esc'], }, { @@ -172,11 +172,6 @@ const bindings = [ label: 'Accept Preview', keys: ['enter'], }, - { - commandName: 'rejectPreview', - label: 'Reject Preview', - keys: ['esc'], - }, { commandName: 'undo', label: 'Undo', diff --git a/platform/docs/docs/migration-guide/3p12-to-3p13/escape-cancel-hotkey.md b/platform/docs/docs/migration-guide/3p12-to-3p13/escape-cancel-hotkey.md new file mode 100644 index 000000000..badc2be36 --- /dev/null +++ b/platform/docs/docs/migration-guide/3p12-to-3p13/escape-cancel-hotkey.md @@ -0,0 +1,89 @@ +--- +sidebar_position: 7 +sidebar_label: Escape / cancelActiveOperation hotkey +title: Escape hotkey consolidation +--- + +# Escape hotkey – single `cancelActiveOperation` command + +The default `Escape` hotkey behavior has been consolidated. Previously the +`esc` key was bound to **two** separate commands in the default hotkey +bindings — `cancelMeasurement` and `rejectPreview`. Because the underlying +hotkey library (Mousetrap) keeps only **one** handler per key, the +later-registered binding (`rejectPreview`) silently shadowed the other, so +`cancelMeasurement` never ran and an in-progress Spline/Livewire/PlanarFreehand +contour (or any annotation being drawn) could not be cancelled with `Escape`. + +`Escape` is now bound to a single generic command, **`cancelActiveOperation`**, +which orchestrates the two single-purpose commands. Each is a no-op when its +state is not active, so one `Escape` press consistently: + +- rejects a provisional segmentation **preview** (via `rejectPreview`), and +- cancels an **in-progress annotation** being drawn (via `cancelMeasurement`). + +## Change + +| 3.12 (old) | 3.13 (new) | +|----------------------------------------------|-----------------------------------------| +| `esc` → `cancelMeasurement` **and** `esc` → `rejectPreview` (the latter shadowed the former) | `esc` → `cancelActiveOperation` | +| `enter` → `acceptPreview` | `enter` → `acceptPreview` (unchanged) | + +New command (cornerstone extension, `CORNERSTONE` context): + +```ts +// extensions/cornerstone/src/commandsModule.ts +cancelActiveOperation: () => { + actions.rejectPreview(); // discard a provisional segmentation preview + actions.cancelMeasurement(); // cancel an in-progress annotation draw +}, +``` + +Both `rejectPreview` and `cancelMeasurement` remain available as standalone +commands; only their default `esc` hotkey bindings changed. + +## Migration + +Most applications need no changes — the default behavior simply works as +expected now. + +**If you provide a custom `ohif.hotkeyBindings` customization** and want the +same consolidated behavior, bind `esc` to the new command and remove the old +duplicate `esc` entries: + +**Before (3.12):** + +```ts +'ohif.hotkeyBindings': [ + // ... + { commandName: 'cancelMeasurement', label: 'Cancel Measurement', keys: ['esc'] }, + { commandName: 'acceptPreview', label: 'Accept Preview', keys: ['enter'] }, + { commandName: 'rejectPreview', label: 'Reject Preview', keys: ['esc'] }, +]; +``` + +**After (3.13):** + +```ts +'ohif.hotkeyBindings': [ + // ... + { commandName: 'cancelActiveOperation', label: 'Cancel', keys: ['esc'] }, + { commandName: 'acceptPreview', label: 'Accept Preview', keys: ['enter'] }, +]; +``` + +**If you ran the `cancelMeasurement` or `rejectPreview` command directly** (for +example from a toolbar button or another command), no change is required — both +commands still exist with the same behavior. + +> **User key remaps:** the per-user hotkey overrides stored in `localStorage` +> (`user-preferred-keys`) are keyed by command hash. A user who had personally +> remapped the old `cancelMeasurement` or `rejectPreview` key will fall back to +> the new `esc` default for `cancelActiveOperation`, since those old command +> hashes no longer exist in the defaults. + +## Reason + +Binding two separate commands to the same key is inherently fragile — only the +last registered binding survives. Routing `Escape` through a single +orchestrating command removes the shadowing bug, keeps each underlying command +single-purpose, and makes the "discard whatever is in progress" intent explicit. diff --git a/platform/ui-next/src/components/OHIFToolSettings/ToolSettings.tsx b/platform/ui-next/src/components/OHIFToolSettings/ToolSettings.tsx index 91726936b..2ebfeda51 100644 --- a/platform/ui-next/src/components/OHIFToolSettings/ToolSettings.tsx +++ b/platform/ui-next/src/components/OHIFToolSettings/ToolSettings.tsx @@ -217,7 +217,10 @@ const renderSelectSetting = option => { key={option.id} >
{renderLabelWithTooltip(option.name, option.tooltip)}
-
+