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}
>