Repository: chartjs/chartjs-plugin-zoom
Pull request #555 (2021-06-30 11:22:55)

Add modifierKey option for drag-to-zoom
Resolves: #535
Closes: #556

Sean-Stellingwerff: Haha @kurkle, are you reading my mind?! Haha, I was working on the exact same feature today and noticed we have a similar solution. Great stuff, because I could really use this feature too 👍🏻 

Seeing as I finished my implementation too and I noticed we had a differences, I decided to add my PR as well so we can compare them if needed / it helps and take the best of both. Whichever one is merged is fine by me!
kurkle: | Filename | Size | Change |
| :--- | :---: | :---: |
| `dist/chartjs-plugin-zoom.esm.js` | 7.45 kB | +91 B (+1%) |
| `dist/chartjs-plugin-zoom.js` | 7.7 kB | +91 B (+1%) |
| `dist/chartjs-plugin-zoom.min.js` | 4.49 kB | +60 B (+1%) |

Patch:
diff --git a/docs/guide/options.md b/docs/guide/options.md
index eb4f99b..f45a50e 100644
--- a/docs/guide/options.md
+++ b/docs/guide/options.md
@@ -65,7 +65,7 @@ const chart = new Chart('id', {
 | ---- | -----| ------- | -----------
 | `enabled` | `boolean` | `false` | Enable zooming via mouse wheel
 | `speed` | `number` | `0.1` | Factor of zoom speed via mouse wheel
-| `modifierKey` | `'ctrl'`\|`'alt'`\|`'shift'`\|`'meta'` | `null` |  Modifier key required for zooming with mouse
+| `modifierKey` | `'ctrl'`\|`'alt'`\|`'shift'`\|`'meta'` | `null` |  Modifier key required for zooming via mouse wheel
 
 #### Drag options
 
@@ -76,6 +76,7 @@ const chart = new Chart('id', {
 | `borderColor` | `Color` | `'rgba(225,225,225)'` | Stroke color
 | `borderWidth` | `number` | `0` | Stroke width
 | `threshold` | `number` | `0` | Minimal zoom distance required before actually applying zoom
+| `modifierKey` | `'ctrl'`\|`'alt'`\|`'shift'`\|`'meta'` | `null` |  Modifier key required for drag-to-zoom
 
 #### Pinch options
 
diff --git a/src/hammer.js b/src/hammer.js
index 69327f3..197b236 100644
--- a/src/hammer.js
+++ b/src/hammer.js
@@ -2,20 +2,21 @@ import {callback as call} from 'chart.js/helpers';
 import Hammer from 'hammerjs';
 import {pan, zoom} from './core';
 import {getState} from './state';
-import {directionEnabled, getEnabledScalesByPoint} from './utils';
+import {directionEnabled, getEnabledScalesByPoint, getModifierKey, keyNotPressed, keyPressed} from './utils';
 
 function createEnabler(chart, state) {
   return function(recognizer, event) {
-    const panOptions = state.options.pan;
+    const {pan: panOptions, zoom: zoomOptions = {}} = state.options;
     if (!panOptions || !panOptions.enabled) {
       return false;
     }
-    if (!event || !event.srcEvent) { // Sometimes Hammer queries this with a null event.
+    const srcEvent = event && event.srcEvent;
+    if (!srcEvent) { // Sometimes Hammer queries this with a null event.
       return true;
     }
-    const modifierKey = panOptions.modifierKey;
-    const requireModifier = modifierKey && (event.pointerType === 'mouse');
-    if (!state.panning && requireModifier && !event.srcEvent[modifierKey + 'Key']) {
+    if (!state.panning && event.pointerType === 'mouse' && (
+      keyNotPressed(getModifierKey(panOptions), srcEvent) || keyPressed(getModifierKey(zoomOptions), srcEvent))
+    ) {
       call(panOptions.onPanRejected, [{chart, event}]);
       return false;
     }
diff --git a/src/handlers.js b/src/handlers.js
index ec49d63..b1fea2f 100644
--- a/src/handlers.js
+++ b/src/handlers.js
@@ -1,4 +1,4 @@
-import {directionEnabled, debounce} from './utils';
+import {directionEnabled, debounce, keyNotPressed, getModifierKey, keyPressed} from './utils';
 import {zoom, zoomRect} from './core';
 import {callback as call} from 'chart.js/helpers';
 import {getState} from './state';
@@ -46,9 +46,8 @@ function zoomStart(chart, event, zoomOptions) {
 
 export function mouseDown(chart, event) {
   const state = getState(chart);
-  const {pan: panOptions, zoom: zoomOptions} = state.options;
-  const panKey = panOptions && panOptions.modifierKey;
-  if (panKey && event[panKey + 'Key']) {
+  const {pan: panOptions, zoom: zoomOptions = {}} = state.options;
+  if (keyPressed(getModifierKey(panOptions), event) || keyNotPressed(getModifierKey(zoomOptions.drag), event)) {
     return call(zoomOptions.onZoomRejected, [{chart, event}]);
   }
 
@@ -119,10 +118,9 @@ export function mouseUp(chart, event) {
 }
 
 function wheelPreconditions(chart, event, zoomOptions) {
-  const {wheel: wheelOptions, onZoomRejected} = zoomOptions;
   // Before preventDefault, check if the modifier key required and pressed
-  if (wheelOptions.modifierKey && !event[wheelOptions.modifierKey + 'Key']) {
-    call(onZoomRejected, [{chart, event}]);
+  if (keyNotPressed(getModifierKey(zoomOptions.wheel), event)) {
+    call(zoomOptions.onZoomRejected, [{chart, event}]);
     return;
   }
 
diff --git a/src/plugin.js b/src/plugin.js
index 5d98b1d..3338f76 100644
--- a/src/plugin.js
+++ b/src/plugin.js
@@ -25,7 +25,8 @@ export default {
         modifierKey: null
       },
       drag: {
-        enabled: false
+        enabled: false,
+        modifierKey: null
       },
       pinch: {
         enabled: false
diff --git a/src/utils.js b/src/utils.js
index ce4eb03..cce065f 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -1,5 +1,9 @@
 import {each} from 'chart.js/helpers';
 
+export const getModifierKey = opts => opts && opts.enabled && opts.modifierKey;
+export const keyPressed = (key, event) => key && event[key + 'Key'];
+export const keyNotPressed = (key, event) => key && !event[key + 'Key'];
+
 /**
  * @param {string|function} mode can be 'x', 'y' or 'xy'
  * @param {string} dir can be 'x' or 'y'
diff --git a/types/options.d.ts b/types/options.d.ts
index eccdaad..6fe4b2e 100644
--- a/types/options.d.ts
+++ b/types/options.d.ts
@@ -47,6 +47,11 @@ export interface DragOptions {
    * Background color of the drag area
    */
   backgroundColor?: Color;
+
+  /**
+   * Modifier key required for drag-to-zoom
+   */
+  modifierKey?: Key;
 }
 
 export interface PinchOptions {


Test patch:
diff --git a/test/specs/defaults.spec.js b/test/specs/defaults.spec.js
index 066f387..5e016a3 100644
--- a/test/specs/defaults.spec.js
+++ b/test/specs/defaults.spec.js
@@ -13,7 +13,8 @@ describe('defaults', function() {
         modifierKey: null
       },
       drag: {
-        enabled: false
+        enabled: false,
+        modifierKey: null
       },
       pinch: {
         enabled: false
diff --git a/test/specs/zoom.spec.js b/test/specs/zoom.spec.js
index f8c58ba..af78d29 100644
--- a/test/specs/zoom.spec.js
+++ b/test/specs/zoom.spec.js
@@ -346,6 +346,7 @@ describe('zoom', function() {
               plugins: {
                 zoom: {
                   pan: {
+                    enabled: true,
                     modifierKey: key,
                   },
                   zoom: {
@@ -396,6 +397,75 @@ describe('zoom', function() {
     }
   });
 
+  describe('drag with modifierKey', function() {
+    for (const key of ['ctrl', 'alt', 'shift', 'meta']) {
+      for (const pressed of [true, false]) {
+        let chart, scaleX, scaleY;
+        it(`should ${pressed ? '' : 'not '}change ${pressed ? 'with' : 'without'} key ${key}`, async function() {
+          const rejectedSpy = jasmine.createSpy('wheelFailed');
+          chart = window.acquireChart({
+            type: 'line',
+            data,
+            options: {
+              scales: {
+                x: {
+                  type: 'linear',
+                  min: 0,
+                  max: 10
+                },
+                y: {
+                  type: 'linear'
+                }
+              },
+              plugins: {
+                zoom: {
+                  zoom: {
+                    drag: {
+                      enabled: true,
+                      modifierKey: key,
+                    },
+                    mode: 'x',
+                    onZoomRejected: rejectedSpy
+                  }
+                }
+              }
+            }
+          });
+
+          scaleX = chart.scales.x;
+          scaleY = chart.scales.y;
+
+          const oldMinX = scaleX.options.min;
+          const oldMaxX = scaleX.options.max;
+
+          const pt = {
+            x: scaleX.getPixelForValue(1.5),
+            y: scaleY.getPixelForValue(1.1),
+          };
+          const pt2 = {x: pt.x + 20, y: pt.y + 20};
+          const init = {};
+          if (pressed) {
+            init[key + 'Key'] = true;
+          }
+
+          jasmine.dispatchEvent(chart, 'mousedown', pt, init);
+          jasmine.dispatchEvent(chart, 'mousemove', pt2, init);
+          jasmine.dispatchEvent(chart, 'mouseup', pt2, init);
+
+          if (pressed) {
+            expect(scaleX.options.min).not.toEqual(oldMinX);
+            expect(scaleX.options.max).not.toEqual(oldMaxX);
+            expect(rejectedSpy).not.toHaveBeenCalled();
+          } else {
+            expect(scaleX.options.min).toEqual(oldMinX);
+            expect(scaleX.options.max).toEqual(oldMaxX);
+            expect(rejectedSpy).toHaveBeenCalled();
+          }
+        });
+      }
+    }
+  });
+
   describe('with overScaleMode = y and mode = xy', function() {
     const config = {
       type: 'line',