Переглянути джерело

grid support (1st iteration) (#1788)

Co-authored-by: dwelle <luzar.david@gmail.com>
Daishi Kato 4 роки тому
батько
коміт
baa8fb6c14

+ 2 - 0
src/appState.ts

@@ -53,6 +53,7 @@ export const getDefaultAppState = (): AppState => {
     shouldCacheIgnoreZoom: false,
     showShortcutsDialog: false,
     zenModeEnabled: false,
+    gridSize: null,
     editingGroupId: null,
     selectedGroupIds: {},
   };
@@ -81,5 +82,6 @@ export const clearAppStateForLocalStorage = (appState: AppState) => {
 export const cleanAppStateForExport = (appState: AppState) => {
   return {
     viewBackgroundColor: appState.viewBackgroundColor,
+    gridSize: appState.gridSize,
   };
 };

+ 114 - 61
src/components/App.tsx

@@ -27,6 +27,9 @@ import {
   getResizeArrowDirection,
   getResizeHandlerFromCoords,
   isNonDeletedElement,
+  dragSelectedElements,
+  getDragOffsetXY,
+  dragNewElement,
 } from "../element";
 import {
   getElementsWithinSelection,
@@ -54,7 +57,7 @@ import { renderScene } from "../renderer";
 import { AppState, GestureEvent, Gesture } from "../types";
 import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
 
-import { distance2d, isPathALoop } from "../math";
+import { distance2d, isPathALoop, getGridPoint } from "../math";
 
 import {
   isWritableElement,
@@ -72,6 +75,7 @@ import {
   isArrowKey,
   getResizeCenterPointKey,
   getResizeWithSidesSameLengthKey,
+  getRotateWithDiscreteAngleKey,
 } from "../keys";
 
 import { findShapeByKey, shapesShortcutKeys } from "../shapes";
@@ -109,6 +113,7 @@ import {
   EVENT,
   ENV,
   CANVAS_ONLY_ACTIONS,
+  GRID_SIZE,
 } from "../constants";
 import {
   INITAL_SCENE_UPDATE_TIMEOUT,
@@ -834,6 +839,12 @@ class App extends React.Component<any, AppState> {
     });
   };
 
+  toggleGridMode = () => {
+    this.setState({
+      gridSize: this.state.gridSize ? null : GRID_SIZE,
+    });
+  };
+
   private destroySocketClient = () => {
     this.setState({
       isCollaborating: false,
@@ -1173,6 +1184,10 @@ class App extends React.Component<any, AppState> {
       this.toggleZenMode();
     }
 
+    if (event[KEYS.CTRL_OR_CMD] && event.keyCode === KEYS.GRID_KEY_CODE) {
+      this.toggleGridMode();
+    }
+
     if (event.code === "KeyC" && event.altKey && event.shiftKey) {
       this.copyToClipboardAsPng();
       event.preventDefault();
@@ -1186,9 +1201,12 @@ class App extends React.Component<any, AppState> {
     const shape = findShapeByKey(event.key);
 
     if (isArrowKey(event.key)) {
-      const step = event.shiftKey
-        ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
-        : ELEMENT_TRANSLATE_AMOUNT;
+      const step =
+        (this.state.gridSize &&
+          (event.shiftKey ? ELEMENT_TRANSLATE_AMOUNT : this.state.gridSize)) ||
+        (event.shiftKey
+          ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
+          : ELEMENT_TRANSLATE_AMOUNT);
       globalSceneState.replaceAllElements(
         globalSceneState.getElementsIncludingDeleted().map((el) => {
           if (this.state.selectedElementIds[el.id]) {
@@ -2013,6 +2031,11 @@ class App extends React.Component<any, AppState> {
 
     const originX = x;
     const originY = y;
+    const [originGridX, originGridY] = getGridPoint(
+      originX,
+      originY,
+      this.state.gridSize,
+    );
 
     type ResizeTestType = ReturnType<typeof resizeTest>;
     let resizeHandle: ResizeTestType = false;
@@ -2023,6 +2046,7 @@ class App extends React.Component<any, AppState> {
     let resizeArrowDirection: "origin" | "end" = "origin";
     let isResizingElements = false;
     let draggingOccurred = false;
+    let dragOffsetXY: [number, number] = [0, 0];
     let hitElement: ExcalidrawElement | null = null;
     let hitElementWasAddedToSelection = false;
 
@@ -2106,6 +2130,20 @@ class App extends React.Component<any, AppState> {
           hitElement ||
           getElementAtPosition(elements, this.state, x, y, this.state.zoom);
 
+        if (hitElement && isNonDeletedElement(hitElement)) {
+          if (this.state.selectedElementIds[hitElement.id]) {
+            dragOffsetXY = getDragOffsetXY(selectedElements, x, y);
+          } else if (event.shiftKey) {
+            dragOffsetXY = getDragOffsetXY(
+              [...selectedElements, hitElement],
+              x,
+              y,
+            );
+          } else {
+            dragOffsetXY = getDragOffsetXY([hitElement], x, y);
+          }
+        }
+
         // clear selection if shift is not clicked
         if (
           !(hitElement && this.state.selectedElementIds[hitElement.id]) &&
@@ -2260,10 +2298,15 @@ class App extends React.Component<any, AppState> {
         });
         document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
       } else {
+        const [gridX, gridY] = getGridPoint(
+          x,
+          y,
+          this.state.elementType === "draw" ? null : this.state.gridSize,
+        );
         const element = newLinearElement({
           type: this.state.elementType,
-          x: x,
-          y: y,
+          x: gridX,
+          y: gridY,
           strokeColor: this.state.currentItemStrokeColor,
           backgroundColor: this.state.currentItemBackgroundColor,
           fillStyle: this.state.currentItemFillStyle,
@@ -2291,10 +2334,11 @@ class App extends React.Component<any, AppState> {
         });
       }
     } else {
+      const [gridX, gridY] = getGridPoint(x, y, this.state.gridSize);
       const element = newElement({
         type: this.state.elementType,
-        x: x,
-        y: y,
+        x: gridX,
+        y: gridY,
         strokeColor: this.state.currentItemStrokeColor,
         backgroundColor: this.state.currentItemBackgroundColor,
         fillStyle: this.state.currentItemFillStyle,
@@ -2356,6 +2400,7 @@ class App extends React.Component<any, AppState> {
         this.canvas,
         window.devicePixelRatio,
       );
+      const [gridX, gridY] = getGridPoint(x, y, this.state.gridSize);
 
       // for arrows/lines, don't start dragging until a given threshold
       //  to ensure we don't create a 2-point arrow by mistake when
@@ -2380,15 +2425,22 @@ class App extends React.Component<any, AppState> {
           isResizing: resizeHandle && resizeHandle !== "rotation",
           isRotating: resizeHandle === "rotation",
         });
+        const [resizeX, resizeY] = getGridPoint(
+          x - resizeOffsetXY[0],
+          y - resizeOffsetXY[1],
+          this.state.gridSize,
+        );
         if (
           resizeElements(
             resizeHandle,
             setResizeHandle,
             selectedElements,
             resizeArrowDirection,
-            event,
-            x - resizeOffsetXY[0],
-            y - resizeOffsetXY[1],
+            getRotateWithDiscreteAngleKey(event),
+            getResizeWithSidesSameLengthKey(event),
+            getResizeCenterPointKey(event),
+            resizeX,
+            resizeY,
           )
         ) {
           return;
@@ -2421,21 +2473,12 @@ class App extends React.Component<any, AppState> {
           this.state,
         );
         if (selectedElements.length > 0) {
-          const { x, y } = viewportCoordsToSceneCoords(
-            event,
-            this.state,
-            this.canvas,
-            window.devicePixelRatio,
+          const [dragX, dragY] = getGridPoint(
+            x - dragOffsetXY[0],
+            y - dragOffsetXY[1],
+            this.state.gridSize,
           );
-
-          selectedElements.forEach((element) => {
-            mutateElement(element, {
-              x: element.x + x - lastX,
-              y: element.y + y - lastY,
-            });
-          });
-          lastX = x;
-          lastY = y;
+          dragSelectedElements(selectedElements, dragX, dragY);
 
           // We duplicate the selected element if alt is pressed on pointer move
           if (event.altKey && !selectedElementWasDuplicated) {
@@ -2460,9 +2503,14 @@ class App extends React.Component<any, AppState> {
                   groupIdMap,
                   element,
                 );
+                const [originDragX, originDragY] = getGridPoint(
+                  originX - dragOffsetXY[0],
+                  originY - dragOffsetXY[1],
+                  this.state.gridSize,
+                );
                 mutateElement(duplicatedElement, {
-                  x: duplicatedElement.x + (originX - lastX),
-                  y: duplicatedElement.y + (originY - lastY),
+                  x: duplicatedElement.x + (originDragX - dragX),
+                  y: duplicatedElement.y + (originDragY - dragY),
                 });
                 nextElements.push(duplicatedElement);
                 elementsToAppend.push(element);
@@ -2486,16 +2534,20 @@ class App extends React.Component<any, AppState> {
         return;
       }
 
-      let width = distance(originX, x);
-      let height = distance(originY, y);
-
       if (isLinearElement(draggingElement)) {
         draggingOccurred = true;
         const points = draggingElement.points;
-        let dx = x - draggingElement.x;
-        let dy = y - draggingElement.y;
+        let dx: number;
+        let dy: number;
+        if (draggingElement.type === "draw") {
+          dx = x - draggingElement.x;
+          dy = y - draggingElement.y;
+        } else {
+          dx = gridX - draggingElement.x;
+          dy = gridY - draggingElement.y;
+        }
 
-        if (event.shiftKey && points.length === 2) {
+        if (getRotateWithDiscreteAngleKey(event) && points.length === 2) {
           ({ width: dx, height: dy } = getPerfectElementSize(
             this.state.elementType,
             dx,
@@ -2516,35 +2568,32 @@ class App extends React.Component<any, AppState> {
             });
           }
         }
+      } else if (draggingElement.type === "selection") {
+        dragNewElement(
+          draggingElement,
+          this.state.elementType,
+          originX,
+          originY,
+          x,
+          y,
+          distance(originX, x),
+          distance(originY, y),
+          getResizeWithSidesSameLengthKey(event),
+          getResizeCenterPointKey(event),
+        );
       } else {
-        if (getResizeWithSidesSameLengthKey(event)) {
-          ({ width, height } = getPerfectElementSize(
-            this.state.elementType,
-            width,
-            y < originY ? -height : height,
-          ));
-
-          if (height < 0) {
-            height = -height;
-          }
-        }
-
-        let newX = x < originX ? originX - width : originX;
-        let newY = y < originY ? originY - height : originY;
-
-        if (getResizeCenterPointKey(event)) {
-          width += width;
-          height += height;
-          newX = originX - width / 2;
-          newY = originY - height / 2;
-        }
-
-        mutateElement(draggingElement, {
-          x: newX,
-          y: newY,
-          width: width,
-          height: height,
-        });
+        dragNewElement(
+          draggingElement,
+          this.state.elementType,
+          originGridX,
+          originGridY,
+          gridX,
+          gridY,
+          distance(originGridX, gridX),
+          distance(originGridY, gridY),
+          getResizeWithSidesSameLengthKey(event),
+          getResizeCenterPointKey(event),
+        );
       }
 
       if (this.state.elementType === "selection") {
@@ -2857,6 +2906,10 @@ class App extends React.Component<any, AppState> {
           ...this.actionManager.getContextMenuItems((action) =>
             CANVAS_ONLY_ACTIONS.includes(action.name),
           ),
+          {
+            label: t("labels.toggleGridMode"),
+            action: this.toggleGridMode,
+          },
         ],
         top: event.clientY,
         left: event.clientX,

+ 4 - 0
src/components/ShortcutsDialog.tsx

@@ -247,6 +247,10 @@ export const ShortcutsDialog = ({ onClose }: { onClose?: () => void }) => {
                 label={t("buttons.toggleZenMode")}
                 shortcuts={[getShortcutKey("Alt+Z")]}
               />
+              <Shortcut
+                label={t("buttons.toggleGridMode")}
+                shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
+              />
             </ShortcutIsland>
           </Column>
           <Column>

+ 2 - 0
src/constants.ts

@@ -68,3 +68,5 @@ export const FONT_FAMILY = {
 } as const;
 
 export const CANVAS_ONLY_ACTIONS = ["selectAll"];
+
+export const GRID_SIZE = 20; // TODO make it configurable?

+ 72 - 0
src/element/dragElements.ts

@@ -0,0 +1,72 @@
+import { NonDeletedExcalidrawElement } from "./types";
+import { getCommonBounds } from "./bounds";
+import { mutateElement } from "./mutateElement";
+import { SHAPES } from "../shapes";
+import { getPerfectElementSize } from "./sizeHelpers";
+
+export const dragSelectedElements = (
+  selectedElements: NonDeletedExcalidrawElement[],
+  pointerX: number,
+  pointerY: number,
+) => {
+  const [x1, y1] = getCommonBounds(selectedElements);
+  selectedElements.forEach((element) => {
+    mutateElement(element, {
+      x: pointerX + element.x - x1,
+      y: pointerY + element.y - y1,
+    });
+  });
+};
+
+export const getDragOffsetXY = (
+  selectedElements: NonDeletedExcalidrawElement[],
+  x: number,
+  y: number,
+): [number, number] => {
+  const [x1, y1] = getCommonBounds(selectedElements);
+  return [x - x1, y - y1];
+};
+
+export const dragNewElement = (
+  draggingElement: NonDeletedExcalidrawElement,
+  elementType: typeof SHAPES[number]["value"],
+  originX: number,
+  originY: number,
+  x: number,
+  y: number,
+  width: number,
+  height: number,
+  isResizeWithSidesSameLength: boolean,
+  isResizeCenterPoint: boolean,
+) => {
+  if (isResizeWithSidesSameLength) {
+    ({ width, height } = getPerfectElementSize(
+      elementType,
+      width,
+      y < originY ? -height : height,
+    ));
+
+    if (height < 0) {
+      height = -height;
+    }
+  }
+
+  let newX = x < originX ? originX - width : originX;
+  let newY = y < originY ? originY - height : originY;
+
+  if (isResizeCenterPoint) {
+    width += width;
+    height += height;
+    newX = originX - width / 2;
+    newY = originY - height / 2;
+  }
+
+  if (width !== 0 && height !== 0) {
+    mutateElement(draggingElement, {
+      x: newX,
+      y: newY,
+      width: width,
+      height: height,
+    });
+  }
+};

+ 5 - 0
src/element/index.ts

@@ -38,6 +38,11 @@ export {
   getResizeOffsetXY,
   getResizeArrowDirection,
 } from "./resizeElements";
+export {
+  dragSelectedElements,
+  getDragOffsetXY,
+  dragNewElement,
+} from "./dragElements";
 export { isTextElement, isExcalidrawElement } from "./typeChecks";
 export { textWysiwyg } from "./textWysiwyg";
 export { redrawTextBoundingBox } from "./textElement";

+ 28 - 15
src/element/resizeElements.ts

@@ -21,10 +21,6 @@ import {
   getCursorForResizingElement,
   normalizeResizeHandle,
 } from "./resizeTest";
-import {
-  getResizeCenterPointKey,
-  getResizeWithSidesSameLengthKey,
-} from "../keys";
 import { measureText, getFontString } from "../utils";
 
 type ResizeTestType = ReturnType<typeof resizeTest>;
@@ -34,14 +30,21 @@ export const resizeElements = (
   setResizeHandle: (nextResizeHandle: ResizeTestType) => void,
   selectedElements: NonDeletedExcalidrawElement[],
   resizeArrowDirection: "origin" | "end",
-  event: PointerEvent, // XXX we want to make it independent?
+  isRotateWithDiscreteAngle: boolean,
+  isResizeWithSidesSameLength: boolean,
+  isResizeCenterPoint: boolean,
   pointerX: number,
   pointerY: number,
 ) => {
   if (selectedElements.length === 1) {
     const [element] = selectedElements;
     if (resizeHandle === "rotation") {
-      rotateSingleElement(element, pointerX, pointerY, event.shiftKey);
+      rotateSingleElement(
+        element,
+        pointerX,
+        pointerY,
+        isRotateWithDiscreteAngle,
+      );
     } else if (
       isLinearElement(element) &&
       element.points.length === 2 &&
@@ -53,7 +56,7 @@ export const resizeElements = (
       resizeSingleTwoPointElement(
         element,
         resizeArrowDirection,
-        event.shiftKey,
+        isRotateWithDiscreteAngle,
         pointerX,
         pointerY,
       );
@@ -67,7 +70,7 @@ export const resizeElements = (
       resizeSingleTextElement(
         element,
         resizeHandle,
-        getResizeCenterPointKey(event),
+        isResizeCenterPoint,
         pointerX,
         pointerY,
       );
@@ -75,8 +78,8 @@ export const resizeElements = (
       resizeSingleElement(
         element,
         resizeHandle,
-        getResizeWithSidesSameLengthKey(event),
-        getResizeCenterPointKey(event),
+        isResizeWithSidesSameLength,
+        isResizeCenterPoint,
         pointerX,
         pointerY,
       );
@@ -114,13 +117,13 @@ const rotateSingleElement = (
   element: NonDeletedExcalidrawElement,
   pointerX: number,
   pointerY: number,
-  isAngleLocking: boolean,
+  isRotateWithDiscreteAngle: boolean,
 ) => {
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
   const cx = (x1 + x2) / 2;
   const cy = (y1 + y2) / 2;
   let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
-  if (isAngleLocking) {
+  if (isRotateWithDiscreteAngle) {
     angle += SHIFT_LOCKING_ANGLE / 2;
     angle -= angle % SHIFT_LOCKING_ANGLE;
   }
@@ -133,14 +136,14 @@ const rotateSingleElement = (
 const resizeSingleTwoPointElement = (
   element: NonDeleted<ExcalidrawLinearElement>,
   resizeArrowDirection: "origin" | "end",
-  isAngleLocking: boolean,
+  isRotateWithDiscreteAngle: boolean,
   pointerX: number,
   pointerY: number,
 ) => {
   const pointOrigin = element.points[0]; // can assume always [0, 0]?
   const pointEnd = element.points[1];
   if (resizeArrowDirection === "end") {
-    if (isAngleLocking) {
+    if (isRotateWithDiscreteAngle) {
       const { width, height } = getPerfectElementSize(
         element.type,
         pointerX - element.x,
@@ -162,7 +165,7 @@ const resizeSingleTwoPointElement = (
     }
   } else {
     // resizeArrowDirection === "origin"
-    if (isAngleLocking) {
+    if (isRotateWithDiscreteAngle) {
       const { width, height } = getPerfectElementSize(
         element.type,
         element.x + pointEnd[0] - pointOrigin[0] - pointerX,
@@ -232,6 +235,16 @@ const measureFontSizeFromWH = (
   if (metrics.width - nextWidth < 1 && metrics.height - nextHeight < 1) {
     return { size: nextFontSize, baseline: metrics.baseline };
   }
+  // third measurement
+  scale *= 0.99; // just heuristics
+  nextFontSize = element.fontSize * scale;
+  metrics = measureText(
+    element.text,
+    getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
+  );
+  if (metrics.width - nextWidth < 1 && metrics.height - nextHeight < 1) {
+    return { size: nextFontSize, baseline: metrics.baseline };
+  }
   return null;
 };
 

+ 4 - 0
src/keys.ts

@@ -16,6 +16,7 @@ export const KEYS = {
   F_KEY_CODE: 70,
   ALT_KEY_CODE: 18,
   Z_KEY_CODE: 90,
+  GRID_KEY_CODE: 222,
   G_KEY_CODE: 71,
 } as const;
 
@@ -32,3 +33,6 @@ export const getResizeCenterPointKey = (event: MouseEvent | KeyboardEvent) =>
 
 export const getResizeWithSidesSameLengthKey = (event: MouseEvent) =>
   event.shiftKey;
+
+export const getRotateWithDiscreteAngleKey = (event: MouseEvent) =>
+  event.shiftKey;

+ 4 - 2
src/locales/en.json

@@ -63,7 +63,8 @@
     "madeWithExcalidraw": "Made with Excalidraw",
     "group": "Group selection",
     "ungroup": "Ungroup selection",
-    "collaborators": "Collaborators"
+    "collaborators": "Collaborators",
+    "toggleGridMode": "Toggle grid mode"
   },
   "buttons": {
     "clearReset": "Reset the canvas",
@@ -91,7 +92,8 @@
     "createNewRoom": "Create new room",
     "toggleFullScreen": "Toggle full screen",
     "toggleZenMode": "Toggle zen mode",
-    "exitZenMode": "Exit zen mode"
+    "exitZenMode": "Exit zen mode",
+    "toggleGridMode": "Toggle grid mode"
   },
   "alerts": {
     "clearReset": "This will clear the whole canvas. Are you sure?",

+ 14 - 0
src/math.ts

@@ -340,3 +340,17 @@ const doIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
 
   return false;
 };
+
+export const getGridPoint = (
+  x: number,
+  y: number,
+  gridSize: number | null,
+): [number, number] => {
+  if (gridSize) {
+    return [
+      Math.round(x / gridSize) * gridSize,
+      Math.round(y / gridSize) * gridSize,
+    ];
+  }
+  return [x, y];
+};

+ 39 - 0
src/renderer/renderScene.ts

@@ -74,6 +74,29 @@ const strokeCircle = (
   context.stroke();
 };
 
+const renderGrid = (
+  context: CanvasRenderingContext2D,
+  gridSize: number,
+  offsetX: number,
+  offsetY: number,
+  width: number,
+  height: number,
+) => {
+  const origStrokeStyle = context.strokeStyle;
+  context.strokeStyle = "rgba(0,0,0,0.1)";
+  context.beginPath();
+  for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) {
+    context.moveTo(x, offsetY - gridSize);
+    context.lineTo(x, offsetY + height + gridSize * 2);
+  }
+  for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) {
+    context.moveTo(offsetX - gridSize, y);
+    context.lineTo(offsetX + width + gridSize * 2, y);
+  }
+  context.stroke();
+  context.strokeStyle = origStrokeStyle;
+};
+
 const renderLinearPointHandles = (
   context: CanvasRenderingContext2D,
   appState: AppState,
@@ -167,6 +190,22 @@ export const renderScene = (
   context.translate(zoomTranslationX, zoomTranslationY);
   context.scale(sceneState.zoom, sceneState.zoom);
 
+  // Grid
+  if (appState.gridSize) {
+    renderGrid(
+      context,
+      appState.gridSize,
+      -Math.ceil(zoomTranslationX / sceneState.zoom / appState.gridSize) *
+        appState.gridSize +
+        (sceneState.scrollX % appState.gridSize),
+      -Math.ceil(zoomTranslationY / sceneState.zoom / appState.gridSize) *
+        appState.gridSize +
+        (sceneState.scrollY % appState.gridSize),
+      normalizedCanvasWidth / sceneState.zoom,
+      normalizedCanvasHeight / sceneState.zoom,
+    );
+  }
+
   // Paint visible elements
   const visibleElements = elements.filter((element) =>
     isVisibleElement(

+ 52 - 0
src/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -24,6 +24,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -421,6 +422,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -627,6 +629,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -750,6 +753,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -1009,6 +1013,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -1170,6 +1175,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -1369,6 +1375,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -1574,6 +1581,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -1880,6 +1888,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -2272,6 +2281,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -4057,6 +4067,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -4180,6 +4191,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -4303,6 +4315,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -4426,6 +4439,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -4571,6 +4585,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -4716,6 +4731,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -4861,6 +4877,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -5006,6 +5023,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -5129,6 +5147,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -5252,6 +5271,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -5397,6 +5417,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -5520,6 +5541,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -5665,6 +5687,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -6302,6 +6325,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -6508,6 +6532,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -6572,6 +6597,7 @@ Object {
   "elementType": "rectangle",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -6634,6 +6660,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -7453,6 +7480,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -7849,6 +7877,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -8162,6 +8191,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -8396,6 +8426,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -8555,6 +8586,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -9323,6 +9355,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -9992,6 +10025,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -10566,6 +10600,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -11049,6 +11084,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -11488,6 +11524,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -11842,6 +11879,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -12115,6 +12153,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -12311,6 +12350,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -13130,6 +13170,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -13848,6 +13889,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -14469,6 +14511,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -14997,6 +15040,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -15267,6 +15311,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -15329,6 +15374,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -15452,6 +15498,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -15514,6 +15561,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -16165,6 +16213,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -16229,6 +16278,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -16654,6 +16704,7 @@ Object {
   "elementType": "text",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,
@@ -16727,6 +16778,7 @@ Object {
   "elementType": "selection",
   "errorMessage": null,
   "exportBackground": true,
+  "gridSize": null,
   "isCollaborating": false,
   "isLoading": false,
   "isResizing": false,

+ 2 - 2
src/tests/regressionTests.test.tsx

@@ -859,10 +859,10 @@ describe("regression tests", () => {
     fireEvent.contextMenu(canvas, { button: 2, clientX: 1, clientY: 1 });
     const contextMenu = document.querySelector(".context-menu");
     const options = contextMenu?.querySelectorAll(".context-menu-option");
-    const expectedOptions = ["Select all"];
+    const expectedOptions = ["Select all", "Toggle grid mode"];
 
     expect(contextMenu).not.toBeNull();
-    expect(options?.length).toBe(1);
+    expect(options?.length).toBe(2);
     expect(options?.item(0).textContent).toBe(expectedOptions[0]);
   });
 

+ 1 - 0
src/types.ts

@@ -72,6 +72,7 @@ export type AppState = {
   shouldCacheIgnoreZoom: boolean;
   showShortcutsDialog: boolean;
   zenModeEnabled: boolean;
+  gridSize: number | null;
 
   /** top-most selected groups (i.e. does not include nested groups) */
   selectedGroupIds: { [groupId: string]: boolean };