Преглед изворни кода

feat: split `gridSize` from enabled state & support custom `gridStep` (#8364)

David Luzar пре 11 месеци
родитељ
комит
3cfcc7b489
31 измењених фајлова са 738 додато и 279 уклоњено
  1. 1 1
      dev-docs/docs/codebase/json-schema.mdx
  2. 2 1
      packages/excalidraw/actions/actionCanvas.tsx
  3. 3 3
      packages/excalidraw/actions/actionDuplicateSelection.tsx
  4. 4 5
      packages/excalidraw/actions/actionToggleGridMode.tsx
  5. 1 1
      packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx
  6. 7 1
      packages/excalidraw/appState.ts
  7. 41 36
      packages/excalidraw/components/App.tsx
  8. 1 1
      packages/excalidraw/components/LayerUI.tsx
  9. 67 0
      packages/excalidraw/components/Stats/CanvasGrid.tsx
  10. 2 1
      packages/excalidraw/components/Stats/DragInput.scss
  11. 39 23
      packages/excalidraw/components/Stats/DragInput.tsx
  12. 32 7
      packages/excalidraw/components/Stats/index.tsx
  13. 2 1
      packages/excalidraw/components/Stats/utils.ts
  14. 1 0
      packages/excalidraw/components/canvases/StaticCanvas.tsx
  15. 3 2
      packages/excalidraw/constants.ts
  16. 26 15
      packages/excalidraw/data/restore.ts
  17. 8 3
      packages/excalidraw/element/dragElements.ts
  18. 17 13
      packages/excalidraw/element/linearElementEditor.ts
  19. 12 2
      packages/excalidraw/math.ts
  20. 44 19
      packages/excalidraw/renderer/staticScene.ts
  21. 5 1
      packages/excalidraw/scene/index.ts
  22. 15 0
      packages/excalidraw/scene/normalize.ts
  23. 0 5
      packages/excalidraw/scene/zoom.ts
  24. 51 17
      packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
  25. 174 58
      packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
  26. 156 52
      packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
  27. 5 5
      packages/excalidraw/tests/excalidraw.test.tsx
  28. 1 1
      packages/excalidraw/tests/fixtures/diagramFixture.ts
  29. 13 2
      packages/excalidraw/types.ts
  30. 3 1
      packages/utils/__snapshots__/export.test.ts.snap
  31. 2 2
      packages/utils/utils.unmocked.test.ts

+ 1 - 1
dev-docs/docs/codebase/json-schema.mdx

@@ -43,7 +43,7 @@ When saving an Excalidraw scene locally to a file, the JSON file (`.excalidraw`)
 
   // editor state (canvas config, preferences, ...)
   "appState": {
-    "gridSize": null,
+    "gridSize": 20,
     "viewBackgroundColor": "#ffffff"
   },
 

+ 2 - 1
packages/excalidraw/actions/actionCanvas.tsx

@@ -105,6 +105,8 @@ export const actionClearCanvas = register({
         exportBackground: appState.exportBackground,
         exportEmbedScene: appState.exportEmbedScene,
         gridSize: appState.gridSize,
+        gridStep: appState.gridStep,
+        gridModeEnabled: appState.gridModeEnabled,
         stats: appState.stats,
         pasteDialog: appState.pasteDialog,
         activeTool:
@@ -294,7 +296,6 @@ export const zoomToFitBounds = ({
         appState.height / commonBoundsHeight,
       ) * clamp(viewportZoomFactor, 0.1, 1);
 
-    // Apply clamping to newZoomValue to be between 10% and 3000%
     newZoomValue = getNormalizedZoom(newZoomValue);
 
     let appStateWidth = appState.width;

+ 3 - 3
packages/excalidraw/actions/actionDuplicateSelection.tsx

@@ -15,7 +15,7 @@ import {
 import type { AppState } from "../types";
 import { fixBindingsAfterDuplication } from "../element/binding";
 import type { ActionResult } from "./types";
-import { GRID_SIZE } from "../constants";
+import { DEFAULT_GRID_SIZE } from "../constants";
 import {
   bindTextToShapeAfterDuplication,
   getBoundTextElement,
@@ -99,8 +99,8 @@ const duplicateElements = (
       groupIdMap,
       element,
       {
-        x: element.x + GRID_SIZE / 2,
-        y: element.y + GRID_SIZE / 2,
+        x: element.x + DEFAULT_GRID_SIZE / 2,
+        y: element.y + DEFAULT_GRID_SIZE / 2,
       },
     );
     duplicatedElementsMap.set(newElement.id, newElement);

+ 4 - 5
packages/excalidraw/actions/actionToggleGridMode.tsx

@@ -1,6 +1,5 @@
 import { CODES, KEYS } from "../keys";
 import { register } from "./register";
-import { GRID_SIZE } from "../constants";
 import type { AppState } from "../types";
 import { gridIcon } from "../components/icons";
 import { StoreAction } from "../store";
@@ -13,21 +12,21 @@ export const actionToggleGridMode = register({
   viewMode: true,
   trackEvent: {
     category: "canvas",
-    predicate: (appState) => !appState.gridSize,
+    predicate: (appState) => appState.gridModeEnabled,
   },
   perform(elements, appState) {
     return {
       appState: {
         ...appState,
-        gridSize: this.checked!(appState) ? null : GRID_SIZE,
+        gridModeEnabled: !this.checked!(appState),
         objectsSnapModeEnabled: false,
       },
       storeAction: StoreAction.NONE,
     };
   },
-  checked: (appState: AppState) => appState.gridSize !== null,
+  checked: (appState: AppState) => appState.gridModeEnabled,
   predicate: (element, appState, props) => {
-    return typeof props.gridModeEnabled === "undefined";
+    return props.gridModeEnabled === undefined;
   },
   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
 });

+ 1 - 1
packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx

@@ -17,7 +17,7 @@ export const actionToggleObjectsSnapMode = register({
       appState: {
         ...appState,
         objectsSnapModeEnabled: !this.checked!(appState),
-        gridSize: null,
+        gridModeEnabled: false,
       },
       storeAction: StoreAction.NONE,
     };

+ 7 - 1
packages/excalidraw/appState.ts

@@ -5,9 +5,11 @@ import {
   DEFAULT_FONT_FAMILY,
   DEFAULT_FONT_SIZE,
   DEFAULT_TEXT_ALIGN,
+  DEFAULT_GRID_SIZE,
   EXPORT_SCALES,
   STATS_PANELS,
   THEME,
+  DEFAULT_GRID_STEP,
 } from "./constants";
 import type { AppState, NormalizedZoomValue } from "./types";
 
@@ -59,7 +61,9 @@ export const getDefaultAppState = (): Omit<
     exportEmbedScene: false,
     exportWithDarkMode: false,
     fileHandle: null,
-    gridSize: null,
+    gridSize: DEFAULT_GRID_SIZE,
+    gridStep: DEFAULT_GRID_STEP,
+    gridModeEnabled: false,
     isBindingEnabled: true,
     defaultSidebarDockedPreference: false,
     isLoading: false,
@@ -174,6 +178,8 @@ const APP_STATE_STORAGE_CONF = (<
   exportWithDarkMode: { browser: true, export: false, server: false },
   fileHandle: { browser: false, export: false, server: false },
   gridSize: { browser: true, export: true, server: true },
+  gridStep: { browser: true, export: true, server: true },
+  gridModeEnabled: { browser: true, export: true, server: true },
   height: { browser: false, export: false, server: false },
   isBindingEnabled: { browser: false, export: false, server: false },
   defaultSidebarDockedPreference: {

+ 41 - 36
packages/excalidraw/components/App.tsx

@@ -60,7 +60,6 @@ import {
   ENV,
   EVENT,
   FRAME_STYLE,
-  GRID_SIZE,
   IMAGE_MIME_TYPES,
   IMAGE_RENDER_TIMEOUT,
   isBrave,
@@ -258,6 +257,7 @@ import type {
   UnsubscribeCallback,
   EmbedsValidationStatus,
   ElementsPendingErasure,
+  NullableGridSize,
 } from "../types";
 import {
   debounce,
@@ -661,7 +661,7 @@ class App extends React.Component<AppProps, AppState> {
       viewModeEnabled,
       zenModeEnabled,
       objectsSnapModeEnabled,
-      gridSize: gridModeEnabled ? GRID_SIZE : null,
+      gridModeEnabled: gridModeEnabled ?? defaultAppState.gridModeEnabled,
       name,
       width: window.innerWidth,
       height: window.innerHeight,
@@ -812,6 +812,18 @@ class App extends React.Component<AppProps, AppState> {
     }
   }
 
+  /**
+   * Returns gridSize taking into account `gridModeEnabled`.
+   * If disabled, returns null.
+   */
+  public getEffectiveGridSize = () => {
+    return (
+      this.props.gridModeEnabled ?? this.state.gridModeEnabled
+        ? this.state.gridSize
+        : null
+    ) as NullableGridSize;
+  };
+
   private getHTMLIFrameElement(
     element: ExcalidrawIframeLikeElement,
   ): HTMLIFrameElement | undefined {
@@ -1684,7 +1696,9 @@ class App extends React.Component<AppProps, AppState> {
                           renderConfig={{
                             imageCache: this.imageCache,
                             isExporting: false,
-                            renderGrid: true,
+                            renderGrid:
+                              this.props.gridModeEnabled ??
+                              this.state.gridModeEnabled,
                             canvasBackgroundColor:
                               this.state.viewBackgroundColor,
                             embedsValidationStatus: this.embedsValidationStatus,
@@ -2171,7 +2185,6 @@ class App extends React.Component<AppProps, AppState> {
     if (actionResult.appState || editingElement || this.state.contextMenu) {
       let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
       let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
-      let gridSize = actionResult?.appState?.gridSize || null;
       const theme =
         actionResult?.appState?.theme || this.props.theme || THEME.LIGHT;
       const name = actionResult?.appState?.name ?? this.state.name;
@@ -2185,10 +2198,6 @@ class App extends React.Component<AppProps, AppState> {
         zenModeEnabled = this.props.zenModeEnabled;
       }
 
-      if (typeof this.props.gridModeEnabled !== "undefined") {
-        gridSize = this.props.gridModeEnabled ? GRID_SIZE : null;
-      }
-
       editingElement = actionResult.appState?.editingElement || null;
 
       // make sure editingElement points to latest element reference
@@ -2220,7 +2229,6 @@ class App extends React.Component<AppProps, AppState> {
           editingElement,
           viewModeEnabled,
           zenModeEnabled,
-          gridSize,
           theme,
           name,
           errorMessage,
@@ -2777,12 +2785,6 @@ class App extends React.Component<AppProps, AppState> {
       this.setState({ theme: this.props.theme });
     }
 
-    if (prevProps.gridModeEnabled !== this.props.gridModeEnabled) {
-      this.setState({
-        gridSize: this.props.gridModeEnabled ? GRID_SIZE : null,
-      });
-    }
-
     this.excalidrawContainerRef.current?.classList.toggle(
       "theme--dark",
       this.state.theme === THEME.DARK,
@@ -3185,7 +3187,7 @@ class App extends React.Component<AppProps, AppState> {
     const dx = x - elementsCenterX;
     const dy = y - elementsCenterY;
 
-    const [gridX, gridY] = getGridPoint(dx, dy, this.state.gridSize);
+    const [gridX, gridY] = getGridPoint(dx, dy, this.getEffectiveGridSize());
 
     const newElements = duplicateElements(
       elements.map((element) => {
@@ -3570,7 +3572,10 @@ class App extends React.Component<AppProps, AppState> {
    * Zooms on canvas viewport center
    */
   zoomCanvas = (
-    /** decimal fraction between 0.1 (10% zoom) and 30 (3000% zoom) */
+    /**
+     * Decimal fraction, auto-clamped between MIN_ZOOM and MAX_ZOOM.
+     * 1 = 100% zoom, 2 = 200% zoom, 0.5 = 50% zoom
+     */
     value: number,
   ) => {
     this.setState({
@@ -4148,10 +4153,10 @@ class App extends React.Component<AppProps, AppState> {
           ? elbowArrow.startBinding || elbowArrow.endBinding
             ? 0
             : ELEMENT_TRANSLATE_AMOUNT
-          : (this.state.gridSize &&
+          : (this.getEffectiveGridSize() &&
               (event.shiftKey
                 ? ELEMENT_TRANSLATE_AMOUNT
-                : this.state.gridSize)) ||
+                : this.getEffectiveGridSize())) ||
             (event.shiftKey
               ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
               : ELEMENT_TRANSLATE_AMOUNT);
@@ -5496,7 +5501,7 @@ class App extends React.Component<AppProps, AppState> {
         event,
         scenePointerX,
         scenePointerY,
-        this.state,
+        this,
         this.scene.getNonDeletedElementsMap(),
       );
 
@@ -5586,7 +5591,7 @@ class App extends React.Component<AppProps, AppState> {
           scenePointerY,
           event[KEYS.CTRL_OR_CMD] || isElbowArrow(multiElement)
             ? null
-            : this.state.gridSize,
+            : this.getEffectiveGridSize(),
         );
 
         const [lastCommittedX, lastCommittedY] =
@@ -6553,7 +6558,7 @@ class App extends React.Component<AppProps, AppState> {
           origin.y,
           event[KEYS.CTRL_OR_CMD] || isElbowArrowOnly
             ? null
-            : this.state.gridSize,
+            : this.getEffectiveGridSize(),
         ),
       ),
       scrollbars: isOverScrollBars(
@@ -6730,7 +6735,7 @@ class App extends React.Component<AppProps, AppState> {
             this.state.editingLinearElement || this.state.selectedLinearElement;
           const ret = LinearElementEditor.handlePointerDown(
             event,
-            this.state,
+            this,
             this.store,
             pointerDownState.origin,
             linearElementEditor,
@@ -7093,7 +7098,7 @@ class App extends React.Component<AppProps, AppState> {
       sceneY,
       this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
         ? null
-        : this.state.gridSize,
+        : this.getEffectiveGridSize(),
     );
 
     const element = newIframeElement({
@@ -7133,7 +7138,7 @@ class App extends React.Component<AppProps, AppState> {
       sceneY,
       this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
         ? null
-        : this.state.gridSize,
+        : this.getEffectiveGridSize(),
     );
 
     const embedLink = getEmbedLink(link);
@@ -7186,7 +7191,7 @@ class App extends React.Component<AppProps, AppState> {
       sceneY,
       this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
         ? null
-        : this.state.gridSize,
+        : this.getEffectiveGridSize(),
     );
 
     const topLayerFrame = addToFrameUnderCursor
@@ -7283,7 +7288,7 @@ class App extends React.Component<AppProps, AppState> {
       const [gridX, gridY] = getGridPoint(
         pointerDownState.origin.x,
         pointerDownState.origin.y,
-        event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
+        event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
       );
 
       const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
@@ -7404,7 +7409,7 @@ class App extends React.Component<AppProps, AppState> {
       pointerDownState.origin.y,
       this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
         ? null
-        : this.state.gridSize,
+        : this.getEffectiveGridSize(),
     );
 
     const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
@@ -7462,7 +7467,7 @@ class App extends React.Component<AppProps, AppState> {
       pointerDownState.origin.y,
       this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
         ? null
-        : this.state.gridSize,
+        : this.getEffectiveGridSize(),
     );
 
     const constructorOpts = {
@@ -7598,7 +7603,7 @@ class App extends React.Component<AppProps, AppState> {
       const [gridX, gridY] = getGridPoint(
         pointerCoords.x,
         pointerCoords.y,
-        event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
+        event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
       );
 
       // for arrows/lines, don't start dragging until a given threshold
@@ -7645,7 +7650,7 @@ class App extends React.Component<AppProps, AppState> {
           const ret = LinearElementEditor.addMidpoint(
             this.state.selectedLinearElement,
             pointerCoords,
-            this.state,
+            this,
             !event[KEYS.CTRL_OR_CMD],
             elementsMap,
           );
@@ -7688,7 +7693,7 @@ class App extends React.Component<AppProps, AppState> {
 
         const didDrag = LinearElementEditor.handlePointDragging(
           event,
-          this.state,
+          this,
           pointerCoords.x,
           pointerCoords.y,
           (element, pointsSceneCoords) => {
@@ -7822,7 +7827,7 @@ class App extends React.Component<AppProps, AppState> {
               dragOffset,
               this.scene,
               snapOffset,
-              event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
+              event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
             );
 
           this.setState({
@@ -9794,7 +9799,7 @@ class App extends React.Component<AppProps, AppState> {
     let [gridX, gridY] = getGridPoint(
       pointerCoords.x,
       pointerCoords.y,
-      event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
+      event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
     );
 
     const image =
@@ -9898,7 +9903,7 @@ class App extends React.Component<AppProps, AppState> {
     let [resizeX, resizeY] = getGridPoint(
       pointerCoords.x - pointerDownState.resize.offset.x,
       pointerCoords.y - pointerDownState.resize.offset.y,
-      event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
+      event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
     );
 
     const frameElementsOffsetsMap = new Map<
@@ -9929,7 +9934,7 @@ class App extends React.Component<AppProps, AppState> {
       const [gridX, gridY] = getGridPoint(
         pointerCoords.x,
         pointerCoords.y,
-        event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
+        event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
       );
 
       const dragOffset = {

+ 1 - 1
packages/excalidraw/components/LayerUI.tsx

@@ -360,7 +360,7 @@ const LayerUI = ({
               )}
             {shouldShowStats && (
               <Stats
-                scene={app.scene}
+                app={app}
                 onClose={() => {
                   actionManager.executeAction(actionToggleStats);
                 }}

+ 67 - 0
packages/excalidraw/components/Stats/CanvasGrid.tsx

@@ -0,0 +1,67 @@
+import StatsDragInput from "./DragInput";
+import type Scene from "../../scene/Scene";
+import type { AppState } from "../../types";
+import { getStepSizedValue } from "./utils";
+import { getNormalizedGridStep } from "../../scene";
+
+interface PositionProps {
+  property: "gridStep";
+  scene: Scene;
+  appState: AppState;
+  setAppState: React.Component<any, AppState>["setState"];
+}
+
+const STEP_SIZE = 5;
+
+const CanvasGrid = ({
+  property,
+  scene,
+  appState,
+  setAppState,
+}: PositionProps) => {
+  return (
+    <StatsDragInput
+      label="Grid step"
+      sensitivity={8}
+      elements={[]}
+      dragInputCallback={({
+        nextValue,
+        instantChange,
+        shouldChangeByStepSize,
+        setInputValue,
+      }) => {
+        setAppState((state) => {
+          let nextGridStep;
+
+          if (nextValue) {
+            nextGridStep = nextValue;
+          } else if (instantChange) {
+            nextGridStep = shouldChangeByStepSize
+              ? getStepSizedValue(
+                  state.gridStep + STEP_SIZE * Math.sign(instantChange),
+                  STEP_SIZE,
+                )
+              : state.gridStep + instantChange;
+          }
+
+          if (!nextGridStep) {
+            setInputValue(state.gridStep);
+            return null;
+          }
+
+          nextGridStep = getNormalizedGridStep(nextGridStep);
+          setInputValue(nextGridStep);
+          return {
+            gridStep: nextGridStep,
+          };
+        });
+      }}
+      scene={scene}
+      value={appState.gridStep}
+      property={property}
+      appState={appState}
+    />
+  );
+};
+
+export default CanvasGrid;

+ 2 - 1
packages/excalidraw/components/Stats/DragInput.scss

@@ -18,7 +18,8 @@
     flex-shrink: 0;
     border: 1px solid var(--default-border-color);
     border-right: 0;
-    width: 2rem;
+    padding: 0 0.5rem 0 0.75rem;
+    min-width: 1rem;
     height: 2rem;
     box-sizing: border-box;
     color: var(--popup-text-color);

+ 39 - 23
packages/excalidraw/components/Stats/DragInput.tsx

@@ -29,6 +29,7 @@ export type DragInputCallbackType<
   nextValue?: number;
   property: P;
   originalAppState: AppState;
+  setInputValue: (value: number) => void;
 }) => void;
 
 interface StatsDragInputProps<
@@ -45,6 +46,8 @@ interface StatsDragInputProps<
   property: T;
   scene: Scene;
   appState: AppState;
+  /** how many px you need to drag to get 1 unit change */
+  sensitivity?: number;
 }
 
 const StatsDragInput = <
@@ -61,6 +64,7 @@ const StatsDragInput = <
   property,
   scene,
   appState,
+  sensitivity = 1,
 }: StatsDragInputProps<T, E>) => {
   const app = useApp();
   const inputRef = useRef<HTMLInputElement>(null);
@@ -126,6 +130,7 @@ const StatsDragInput = <
         nextValue: rounded,
         property,
         originalAppState: appState,
+        setInputValue: (value) => setInputValue(String(value)),
       });
       app.syncActionResult({ storeAction: StoreAction.CAPTURE });
     }
@@ -172,6 +177,8 @@ const StatsDragInput = <
         ref={labelRef}
         onPointerDown={(event) => {
           if (inputRef.current && editable) {
+            document.body.classList.add("excalidraw-cursor-resize");
+
             let startValue = Number(inputRef.current.value);
             if (isNaN(startValue)) {
               startValue = 0;
@@ -196,35 +203,43 @@ const StatsDragInput = <
 
             const originalAppState: AppState = cloneJSON(appState);
 
-            let accumulatedChange: number | null = null;
-
-            document.body.classList.add("excalidraw-cursor-resize");
+            let accumulatedChange = 0;
+            let stepChange = 0;
 
             const onPointerMove = (event: PointerEvent) => {
-              if (!accumulatedChange) {
-                accumulatedChange = 0;
-              }
-
               if (
                 lastPointer &&
                 originalElementsMap !== null &&
-                originalElements !== null &&
-                accumulatedChange !== null
+                originalElements !== null
               ) {
                 const instantChange = event.clientX - lastPointer.x;
-                accumulatedChange += instantChange;
-
-                dragInputCallback({
-                  accumulatedChange,
-                  instantChange,
-                  originalElements,
-                  originalElementsMap,
-                  shouldKeepAspectRatio: shouldKeepAspectRatio!!,
-                  shouldChangeByStepSize: event.shiftKey,
-                  property,
-                  scene,
-                  originalAppState,
-                });
+
+                if (instantChange !== 0) {
+                  stepChange += instantChange;
+
+                  if (Math.abs(stepChange) >= sensitivity) {
+                    stepChange =
+                      Math.sign(stepChange) *
+                      Math.floor(Math.abs(stepChange) / sensitivity);
+
+                    accumulatedChange += stepChange;
+
+                    dragInputCallback({
+                      accumulatedChange,
+                      instantChange: stepChange,
+                      originalElements,
+                      originalElementsMap,
+                      shouldKeepAspectRatio: shouldKeepAspectRatio!!,
+                      shouldChangeByStepSize: event.shiftKey,
+                      property,
+                      scene,
+                      originalAppState,
+                      setInputValue: (value) => setInputValue(String(value)),
+                    });
+
+                    stepChange = 0;
+                  }
+                }
               }
 
               lastPointer = {
@@ -246,7 +261,8 @@ const StatsDragInput = <
                 app.syncActionResult({ storeAction: StoreAction.CAPTURE });
 
                 lastPointer = null;
-                accumulatedChange = null;
+                accumulatedChange = 0;
+                stepChange = 0;
                 originalElements = null;
                 originalElementsMap = null;
 

+ 32 - 7
packages/excalidraw/components/Stats/index.tsx

@@ -2,7 +2,11 @@ import { useEffect, useMemo, useState, memo } from "react";
 import { getCommonBounds } from "../../element/bounds";
 import type { NonDeletedExcalidrawElement } from "../../element/types";
 import { t } from "../../i18n";
-import type { AppState, ExcalidrawProps } from "../../types";
+import type {
+  AppClassProperties,
+  AppState,
+  ExcalidrawProps,
+} from "../../types";
 import { CloseIcon } from "../icons";
 import { Island } from "../Island";
 import { throttle } from "lodash";
@@ -16,17 +20,17 @@ import MultiFontSize from "./MultiFontSize";
 import Position from "./Position";
 import MultiPosition from "./MultiPosition";
 import Collapsible from "./Collapsible";
-import type Scene from "../../scene/Scene";
 import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
 import { getAtomicUnits } from "./utils";
 import { STATS_PANELS } from "../../constants";
 import { isElbowArrow } from "../../element/typeChecks";
+import CanvasGrid from "./CanvasGrid";
 import clsx from "clsx";
 
 import "./Stats.scss";
 
 interface StatsProps {
-  scene: Scene;
+  app: AppClassProperties;
   onClose: () => void;
   renderCustomStats: ExcalidrawProps["renderCustomStats"];
 }
@@ -35,11 +39,13 @@ const STATS_TIMEOUT = 50;
 
 export const Stats = (props: StatsProps) => {
   const appState = useExcalidrawAppState();
-  const sceneNonce = props.scene.getSceneNonce() || 1;
-  const selectedElements = props.scene.getSelectedElements({
+  const sceneNonce = props.app.scene.getSceneNonce() || 1;
+  const selectedElements = props.app.scene.getSelectedElements({
     selectedElementIds: appState.selectedElementIds,
     includeBoundTextElement: false,
   });
+  const gridModeEnabled =
+    props.app.props.gridModeEnabled ?? appState.gridModeEnabled;
 
   return (
     <StatsInner
@@ -47,6 +53,7 @@ export const Stats = (props: StatsProps) => {
       appState={appState}
       sceneNonce={sceneNonce}
       selectedElements={selectedElements}
+      gridModeEnabled={gridModeEnabled}
     />
   );
 };
@@ -97,17 +104,20 @@ Stats.StatsRows = StatsRows;
 
 export const StatsInner = memo(
   ({
-    scene,
+    app,
     onClose,
     renderCustomStats,
     selectedElements,
     appState,
     sceneNonce,
+    gridModeEnabled,
   }: StatsProps & {
     sceneNonce: number;
     selectedElements: readonly NonDeletedExcalidrawElement[];
     appState: AppState;
+    gridModeEnabled: boolean;
   }) => {
+    const scene = app.scene;
     const elements = scene.getNonDeletedElements();
     const elementsMap = scene.getNonDeletedElementsMap();
     const setAppState = useExcalidrawSetAppState();
@@ -189,6 +199,19 @@ export const StatsInner = memo(
                 <div>{t("stats.height")}</div>
                 <div>{sceneDimension.height}</div>
               </StatsRow>
+              {gridModeEnabled && (
+                <>
+                  <StatsRow heading>Canvas</StatsRow>
+                  <StatsRow>
+                    <CanvasGrid
+                      property="gridStep"
+                      scene={scene}
+                      appState={appState}
+                      setAppState={setAppState}
+                    />
+                  </StatsRow>
+                </>
+              )}
             </StatsRows>
 
             {renderCustomStats?.(elements, appState)}
@@ -362,7 +385,9 @@ export const StatsInner = memo(
     return (
       prev.sceneNonce === next.sceneNonce &&
       prev.selectedElements === next.selectedElements &&
-      prev.appState.stats.panels === next.appState.stats.panels
+      prev.appState.stats.panels === next.appState.stats.panels &&
+      prev.gridModeEnabled === next.gridModeEnabled &&
+      prev.appState.gridStep === next.appState.gridStep
     );
   },
 );

+ 2 - 1
packages/excalidraw/components/Stats/utils.ts

@@ -41,7 +41,8 @@ export type StatsInputProperty =
   | "width"
   | "height"
   | "angle"
-  | "fontSize";
+  | "fontSize"
+  | "gridStep";
 
 export const SMALLEST_DELTA = 0.01;
 

+ 1 - 0
packages/excalidraw/components/canvases/StaticCanvas.tsx

@@ -101,6 +101,7 @@ const getRelevantAppStateProps = (
   exportScale: appState.exportScale,
   selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
   gridSize: appState.gridSize,
+  gridStep: appState.gridStep,
   frameRendering: appState.frameRendering,
   selectedElementIds: appState.selectedElementIds,
   frameToHighlight: appState.frameToHighlight,

+ 3 - 2
packages/excalidraw/constants.ts

@@ -179,7 +179,8 @@ export const COLOR_VOICE_CALL = "#a2f1a6";
 
 export const CANVAS_ONLY_ACTIONS = ["selectAll"];
 
-export const GRID_SIZE = 20; // TODO make it configurable?
+export const DEFAULT_GRID_SIZE = 20;
+export const DEFAULT_GRID_STEP = 5;
 
 export const IMAGE_MIME_TYPES = {
   svg: "image/svg+xml",
@@ -234,7 +235,7 @@ export const VERSION_TIMEOUT = 30000;
 export const SCROLL_TIMEOUT = 100;
 export const ZOOM_STEP = 0.1;
 export const MIN_ZOOM = 0.1;
-export const MAX_ZOOM = 30.0;
+export const MAX_ZOOM = 30;
 export const HYPERLINK_TOOLTIP_DELAY = 300;
 
 // Report a user inactive after IDLE_THRESHOLD milliseconds

+ 26 - 15
packages/excalidraw/data/restore.ts

@@ -10,12 +10,7 @@ import type {
   PointBinding,
   StrokeRoundness,
 } from "../element/types";
-import type {
-  AppState,
-  BinaryFiles,
-  LibraryItem,
-  NormalizedZoomValue,
-} from "../types";
+import type { AppState, BinaryFiles, LibraryItem } from "../types";
 import type { ImportedDataState, LegacyAppState } from "./types";
 import {
   getNonDeletedElements,
@@ -39,11 +34,17 @@ import {
   ROUNDNESS,
   DEFAULT_SIDEBAR,
   DEFAULT_ELEMENT_PROPS,
+  DEFAULT_GRID_SIZE,
+  DEFAULT_GRID_STEP,
 } from "../constants";
 import { getDefaultAppState } from "../appState";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { bumpVersion } from "../element/mutateElement";
-import { getUpdatedTimestamp, updateActiveTool } from "../utils";
+import {
+  getUpdatedTimestamp,
+  isFiniteNumber,
+  updateActiveTool,
+} from "../utils";
 import { arrayToMap } from "../utils";
 import type { MarkOptional, Mutable } from "../utility-types";
 import { detectLineHeight, getContainerElement } from "../element/textElement";
@@ -52,6 +53,11 @@ import { syncInvalidIndices } from "../fractionalIndex";
 import { getSizeFromPoints } from "../points";
 import { getLineHeight } from "../fonts";
 import { normalizeFixedPoint } from "../element/binding";
+import {
+  getNormalizedGridSize,
+  getNormalizedGridStep,
+  getNormalizedZoom,
+} from "../scene";
 
 type RestoredAppState = Omit<
   AppState,
@@ -614,19 +620,24 @@ export const restoreAppState = (
       locked: nextAppState.activeTool.locked ?? false,
     },
     // Migrates from previous version where appState.zoom was a number
-    zoom:
-      typeof appState.zoom === "number"
-        ? {
-            value: appState.zoom as NormalizedZoomValue,
-          }
-        : appState.zoom?.value
-        ? appState.zoom
-        : defaultAppState.zoom,
+    zoom: {
+      value: getNormalizedZoom(
+        isFiniteNumber(appState.zoom)
+          ? appState.zoom
+          : appState.zoom?.value ?? defaultAppState.zoom.value,
+      ),
+    },
     openSidebar:
       // string (legacy)
       typeof (appState.openSidebar as any as string) === "string"
         ? { name: DEFAULT_SIDEBAR.name }
         : nextAppState.openSidebar,
+    gridSize: getNormalizedGridSize(
+      isFiniteNumber(appState.gridSize) ? appState.gridSize : DEFAULT_GRID_SIZE,
+    ),
+    gridStep: getNormalizedGridStep(
+      isFiniteNumber(appState.gridStep) ? appState.gridStep : DEFAULT_GRID_STEP,
+    ),
   };
 };
 

+ 8 - 3
packages/excalidraw/element/dragElements.ts

@@ -4,7 +4,12 @@ import { getCommonBounds } from "./bounds";
 import { mutateElement } from "./mutateElement";
 import { getPerfectElementSize } from "./sizeHelpers";
 import type { NonDeletedExcalidrawElement } from "./types";
-import type { AppState, NormalizedZoomValue, PointerDownState } from "../types";
+import type {
+  AppState,
+  NormalizedZoomValue,
+  NullableGridSize,
+  PointerDownState,
+} from "../types";
 import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
 import { getGridPoint } from "../math";
 import type Scene from "../scene/Scene";
@@ -26,7 +31,7 @@ export const dragSelectedElements = (
     x: number;
     y: number;
   },
-  gridSize: AppState["gridSize"],
+  gridSize: NullableGridSize,
 ) => {
   if (
     _selectedElements.length === 1 &&
@@ -101,7 +106,7 @@ const calculateOffset = (
   commonBounds: Bounds,
   dragOffset: { x: number; y: number },
   snapOffset: { x: number; y: number },
-  gridSize: AppState["gridSize"],
+  gridSize: NullableGridSize,
 ): { x: number; y: number } => {
   const [x, y] = commonBounds;
   let nextX = x + dragOffset.x + snapOffset.x;

+ 17 - 13
packages/excalidraw/element/linearElementEditor.ts

@@ -36,6 +36,8 @@ import type {
   AppState,
   PointerCoords,
   InteractiveCanvasAppState,
+  AppClassProperties,
+  NullableGridSize,
 } from "../types";
 import { mutateElement } from "./mutateElement";
 
@@ -209,7 +211,7 @@ export class LinearElementEditor {
   /** @returns whether point was dragged */
   static handlePointDragging(
     event: PointerEvent,
-    appState: AppState,
+    app: AppClassProperties,
     scenePointerX: number,
     scenePointerY: number,
     maybeSuggestBinding: (
@@ -279,7 +281,7 @@ export class LinearElementEditor {
           elementsMap,
           referencePoint,
           [scenePointerX, scenePointerY],
-          event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
+          event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
         );
 
         LinearElementEditor.movePoints(
@@ -299,7 +301,7 @@ export class LinearElementEditor {
           elementsMap,
           scenePointerX - linearElementEditor.pointerOffset.x,
           scenePointerY - linearElementEditor.pointerOffset.y,
-          event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
+          event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
         );
 
         const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
@@ -315,7 +317,7 @@ export class LinearElementEditor {
                     elementsMap,
                     scenePointerX - linearElementEditor.pointerOffset.x,
                     scenePointerY - linearElementEditor.pointerOffset.y,
-                    event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
+                    event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
                   )
                 : ([
                     element.points[pointIndex][0] + deltaX,
@@ -695,7 +697,7 @@ export class LinearElementEditor {
 
   static handlePointerDown(
     event: React.PointerEvent<HTMLElement>,
-    appState: AppState,
+    app: AppClassProperties,
     store: Store,
     scenePointer: { x: number; y: number },
     linearElementEditor: LinearElementEditor,
@@ -705,6 +707,7 @@ export class LinearElementEditor {
     hitElement: NonDeleted<ExcalidrawElement> | null;
     linearElementEditor: LinearElementEditor | null;
   } {
+    const appState = app.state;
     const elementsMap = scene.getNonDeletedElementsMap();
     const elements = scene.getNonDeletedElements();
 
@@ -752,7 +755,7 @@ export class LinearElementEditor {
               elementsMap,
               scenePointer.x,
               scenePointer.y,
-              event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
+              event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
             ),
           ],
         });
@@ -876,9 +879,10 @@ export class LinearElementEditor {
     event: React.PointerEvent<HTMLCanvasElement>,
     scenePointerX: number,
     scenePointerY: number,
-    appState: AppState,
+    app: AppClassProperties,
     elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
   ): LinearElementEditor | null {
+    const appState = app.state;
     if (!appState.editingLinearElement) {
       return null;
     }
@@ -915,7 +919,7 @@ export class LinearElementEditor {
         elementsMap,
         lastCommittedPoint,
         [scenePointerX, scenePointerY],
-        event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
+        event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
       );
 
       newPoint = [
@@ -930,7 +934,7 @@ export class LinearElementEditor {
         scenePointerY - appState.editingLinearElement.pointerOffset.y,
         event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
           ? null
-          : appState.gridSize,
+          : app.getEffectiveGridSize(),
       );
     }
 
@@ -1065,7 +1069,7 @@ export class LinearElementEditor {
     elementsMap: ElementsMap,
     scenePointerX: number,
     scenePointerY: number,
-    gridSize: number | null,
+    gridSize: NullableGridSize,
   ): Point {
     const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@@ -1363,7 +1367,7 @@ export class LinearElementEditor {
   static addMidpoint(
     linearElementEditor: LinearElementEditor,
     pointerCoords: PointerCoords,
-    appState: AppState,
+    app: AppClassProperties,
     snapToGrid: boolean,
     elementsMap: ElementsMap,
   ) {
@@ -1388,7 +1392,7 @@ export class LinearElementEditor {
       elementsMap,
       pointerCoords.x,
       pointerCoords.y,
-      snapToGrid && !isElbowArrow(element) ? appState.gridSize : null,
+      snapToGrid && !isElbowArrow(element) ? app.getEffectiveGridSize() : null,
     );
     const points = [
       ...element.points.slice(0, segmentMidpoint.index!),
@@ -1485,7 +1489,7 @@ export class LinearElementEditor {
     elementsMap: ElementsMap,
     referencePoint: Point,
     scenePointer: Point,
-    gridSize: number | null,
+    gridSize: NullableGridSize,
   ) {
     const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
       element,

+ 12 - 2
packages/excalidraw/math.ts

@@ -1,4 +1,9 @@
-import type { NormalizedZoomValue, Point, Zoom } from "./types";
+import type {
+  NormalizedZoomValue,
+  NullableGridSize,
+  Point,
+  Zoom,
+} from "./types";
 import {
   DEFAULT_ADAPTIVE_RADIUS,
   LINE_CONFIRM_THRESHOLD,
@@ -275,7 +280,7 @@ const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
 export const getGridPoint = (
   x: number,
   y: number,
-  gridSize: number | null,
+  gridSize: NullableGridSize,
 ): [number, number] => {
   if (gridSize) {
     return [
@@ -703,3 +708,8 @@ export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
 export const clamp = (value: number, min: number, max: number) => {
   return Math.min(Math.max(value, min), max);
 };
+
+export const round = (value: number, precision: number) => {
+  const multiplier = Math.pow(10, precision);
+  return Math.round((value + Number.EPSILON) * multiplier) / multiplier;
+};

+ 44 - 19
packages/excalidraw/renderer/staticScene.ts

@@ -31,53 +31,77 @@ import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
 import { throttleRAF } from "../utils";
 import { getBoundTextElement } from "../element/textElement";
 
+const GridLineColor = {
+  Bold: "#dddddd",
+  Regular: "#e5e5e5",
+} as const;
+
 const strokeGrid = (
   context: CanvasRenderingContext2D,
+  /** grid cell pixel size */
   gridSize: number,
+  /** setting to 1 will disble bold lines */
+  gridStep: number,
   scrollX: number,
   scrollY: number,
   zoom: Zoom,
   width: number,
   height: number,
 ) => {
-  const BOLD_LINE_FREQUENCY = 5;
-
-  enum GridLineColor {
-    Bold = "#cccccc",
-    Regular = "#e5e5e5",
-  }
-
-  const offsetX =
-    -Math.round(zoom.value / gridSize) * gridSize + (scrollX % gridSize);
-  const offsetY =
-    -Math.round(zoom.value / gridSize) * gridSize + (scrollY % gridSize);
+  const offsetX = (scrollX % gridSize) - gridSize;
+  const offsetY = (scrollY % gridSize) - gridSize;
 
-  const lineWidth = Math.min(1 / zoom.value, 1);
+  const actualGridSize = gridSize * zoom.value;
 
   const spaceWidth = 1 / zoom.value;
-  const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
 
   context.save();
-  context.lineWidth = lineWidth;
 
+  // Offset rendering by 0.5 to ensure that 1px wide lines are crisp.
+  // We only do this when zoomed to 100% because otherwise the offset is
+  // fractional, and also visibly offsets the elements.
+  // We also do this per-axis, as each axis may already be offset by 0.5.
+  if (zoom.value === 1) {
+    context.translate(offsetX % 1 ? 0 : 0.5, offsetY % 1 ? 0 : 0.5);
+  }
+
+  // vertical lines
   for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) {
     const isBold =
-      Math.round(x - scrollX) % (BOLD_LINE_FREQUENCY * gridSize) === 0;
+      gridStep > 1 && Math.round(x - scrollX) % (gridStep * gridSize) === 0;
+    // don't render regular lines when zoomed out and they're barely visible
+    if (!isBold && actualGridSize < 10) {
+      continue;
+    }
+
+    const lineWidth = Math.min(1 / zoom.value, isBold ? 4 : 1);
+    context.lineWidth = lineWidth;
+    const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
+
     context.beginPath();
     context.setLineDash(isBold ? [] : lineDash);
     context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
     context.moveTo(x, offsetY - gridSize);
-    context.lineTo(x, offsetY + height + gridSize * 2);
+    context.lineTo(x, Math.ceil(offsetY + height + gridSize * 2));
     context.stroke();
   }
+
   for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) {
     const isBold =
-      Math.round(y - scrollY) % (BOLD_LINE_FREQUENCY * gridSize) === 0;
+      gridStep > 1 && Math.round(y - scrollY) % (gridStep * gridSize) === 0;
+    if (!isBold && actualGridSize < 10) {
+      continue;
+    }
+
+    const lineWidth = Math.min(1 / zoom.value, isBold ? 4 : 1);
+    context.lineWidth = lineWidth;
+    const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
+
     context.beginPath();
     context.setLineDash(isBold ? [] : lineDash);
     context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
     context.moveTo(offsetX - gridSize, y);
-    context.lineTo(offsetX + width + gridSize * 2, y);
+    context.lineTo(Math.ceil(offsetX + width + gridSize * 2), y);
     context.stroke();
   }
   context.restore();
@@ -199,10 +223,11 @@ const _renderStaticScene = ({
   context.scale(appState.zoom.value, appState.zoom.value);
 
   // Grid
-  if (renderGrid && appState.gridSize) {
+  if (renderGrid) {
     strokeGrid(
       context,
       appState.gridSize,
+      appState.gridStep,
       appState.scrollX,
       appState.scrollY,
       appState.zoom,

+ 5 - 1
packages/excalidraw/scene/index.ts

@@ -15,4 +15,8 @@ export {
   getElementAtPosition,
   getElementsAtPosition,
 } from "./comparisons";
-export { getNormalizedZoom } from "./zoom";
+export {
+  getNormalizedZoom,
+  getNormalizedGridSize,
+  getNormalizedGridStep,
+} from "./normalize";

+ 15 - 0
packages/excalidraw/scene/normalize.ts

@@ -0,0 +1,15 @@
+import { MAX_ZOOM, MIN_ZOOM } from "../constants";
+import { clamp, round } from "../math";
+import type { NormalizedZoomValue } from "../types";
+
+export const getNormalizedZoom = (zoom: number): NormalizedZoomValue => {
+  return clamp(round(zoom, 6), MIN_ZOOM, MAX_ZOOM) as NormalizedZoomValue;
+};
+
+export const getNormalizedGridSize = (gridStep: number) => {
+  return clamp(Math.round(gridStep), 1, 100);
+};
+
+export const getNormalizedGridStep = (gridStep: number) => {
+  return clamp(Math.round(gridStep), 1, 100);
+};

+ 0 - 5
packages/excalidraw/scene/zoom.ts

@@ -1,10 +1,5 @@
-import { MIN_ZOOM } from "../constants";
 import type { AppState, NormalizedZoomValue } from "../types";
 
-export const getNormalizedZoom = (zoom: number): NormalizedZoomValue => {
-  return Math.max(MIN_ZOOM, Math.min(zoom, 30)) as NormalizedZoomValue;
-};
-
 export const getStateForZoom = (
   {
     viewportX,

+ 51 - 17
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -831,7 +831,9 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -1034,7 +1036,9 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -1247,7 +1251,9 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -1575,7 +1581,9 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -1903,7 +1911,9 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -2116,7 +2126,9 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -2353,7 +2365,9 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -2651,7 +2665,9 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -3017,7 +3033,9 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -3489,7 +3507,9 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -3809,7 +3829,9 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -4129,7 +4151,9 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -5312,7 +5336,9 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -6436,7 +6462,9 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -7368,7 +7396,9 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -8277,7 +8307,9 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -9168,7 +9200,9 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
   "isLoading": false,

+ 174 - 58
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap

@@ -48,7 +48,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -647,7 +649,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -1150,7 +1154,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -1515,7 +1521,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -1881,7 +1889,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -2145,7 +2155,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -2582,7 +2594,9 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -2878,7 +2892,9 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -3159,7 +3175,9 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -3450,7 +3468,9 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -3733,7 +3753,9 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -3965,7 +3987,9 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -4221,7 +4245,9 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -4491,7 +4517,9 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -4719,7 +4747,9 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -4947,7 +4977,9 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -5173,7 +5205,9 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -5399,7 +5433,9 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -5655,7 +5691,9 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -5983,7 +6021,9 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -6405,7 +6445,9 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -6780,7 +6822,9 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -7096,7 +7140,9 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -7391,7 +7437,9 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -7617,7 +7665,9 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -7969,7 +8019,9 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -8321,7 +8373,9 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -8722,7 +8776,9 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -9006,7 +9062,9 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -9268,7 +9326,9 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -9529,7 +9589,9 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -9757,7 +9819,9 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -10055,7 +10119,9 @@ exports[`history > multiplayer undo/redo > should override remotely added points
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -10392,7 +10458,9 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -10624,7 +10692,9 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -11074,7 +11144,9 @@ exports[`history > multiplayer undo/redo > should update history entries after r
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -11325,7 +11397,9 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -11561,7 +11635,9 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -11799,7 +11875,9 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -12197,7 +12275,9 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -12441,7 +12521,9 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -12679,7 +12761,9 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -12917,7 +13001,9 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -13161,7 +13247,9 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -13490,7 +13578,9 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -13659,7 +13749,9 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -13944,7 +14036,9 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -14208,7 +14302,9 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -14480,7 +14576,9 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -14638,7 +14736,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -15331,7 +15431,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -15948,7 +16050,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -16565,7 +16669,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -17274,7 +17380,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -18021,7 +18129,9 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -18492,7 +18602,9 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -19011,7 +19123,9 @@ exports[`history > singleplayer undo/redo > should support element creation, del
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -19464,7 +19578,9 @@ exports[`history > singleplayer undo/redo > should support linear element creati
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
   "isLoading": false,

+ 156 - 52
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -48,7 +48,9 @@ exports[`given element A and group of elements B and given both are selected whe
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -457,7 +459,9 @@ exports[`given element A and group of elements B and given both are selected whe
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -857,7 +861,9 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": false,
   "isLoading": false,
@@ -1396,7 +1402,9 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -1594,7 +1602,9 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -1963,7 +1973,9 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -2197,7 +2209,9 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -2371,7 +2385,9 @@ exports[`regression tests > can drag element that covers another element, while
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -2685,7 +2701,9 @@ exports[`regression tests > change the properties of a shape > [end of test] app
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -2925,7 +2943,9 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -3162,7 +3182,9 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -3386,7 +3408,9 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -3636,7 +3660,9 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -3941,7 +3967,9 @@ exports[`regression tests > deleting last but one element in editing group shoul
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -4349,7 +4377,9 @@ exports[`regression tests > deselects group of selected elements on pointer down
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -4626,7 +4656,9 @@ exports[`regression tests > deselects group of selected elements on pointer up w
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -4873,7 +4905,9 @@ exports[`regression tests > deselects selected element on pointer down when poin
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -5077,7 +5111,9 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -5270,7 +5306,9 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -5646,7 +5684,9 @@ exports[`regression tests > drags selected elements from point inside common bou
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -5930,7 +5970,9 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -6732,7 +6774,9 @@ exports[`regression tests > given a group of selected elements with an element t
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -7056,7 +7100,9 @@ exports[`regression tests > given a selected element A and a not selected elemen
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -7326,7 +7372,9 @@ exports[`regression tests > given selected element A with lower z-index than uns
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -7554,7 +7602,9 @@ exports[`regression tests > given selected element A with lower z-index than uns
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -7785,7 +7835,9 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -7959,7 +8011,9 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -8133,7 +8187,9 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -8307,7 +8363,9 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -8523,7 +8581,9 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -8738,7 +8798,9 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -8926,7 +8988,9 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -9142,7 +9206,9 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -9316,7 +9382,9 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -9531,7 +9599,9 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -9705,7 +9775,9 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -9893,7 +9965,9 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -10067,7 +10141,9 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -10575,7 +10651,9 @@ exports[`regression tests > noop interaction after undo shouldn't create history
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -10846,7 +10924,9 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -10966,7 +11046,9 @@ exports[`regression tests > shift click on selected element should deselect it o
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -11159,7 +11241,9 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -11464,7 +11548,9 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -11870,7 +11956,9 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -12477,7 +12565,9 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -12600,7 +12690,9 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -13178,7 +13270,9 @@ exports[`regression tests > switches from group of selected elements to another
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -13510,7 +13604,9 @@ exports[`regression tests > switches selected element on pointer down > [end of
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -13769,7 +13865,9 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -13889,7 +13987,9 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -14262,7 +14362,9 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,
@@ -14382,7 +14484,9 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
   "isLoading": false,

+ 5 - 5
packages/excalidraw/tests/excalidraw.test.tsx

@@ -2,7 +2,7 @@ import React from "react";
 import { fireEvent, GlobalTestState, toggleMenu, render } from "./test-utils";
 import { Excalidraw, Footer, MainMenu } from "../index";
 import { queryByText, queryByTestId } from "@testing-library/react";
-import { GRID_SIZE, THEME } from "../constants";
+import { THEME } from "../constants";
 import { t } from "../i18n";
 import { useMemo } from "react";
 
@@ -91,7 +91,7 @@ describe("<Excalidraw/>", () => {
   describe("Test gridModeEnabled prop", () => {
     it('should show grid mode in context menu when gridModeEnabled is "undefined"', async () => {
       const { container } = await render(<Excalidraw />);
-      expect(h.state.gridSize).toBe(null);
+      expect(h.state.gridModeEnabled).toBe(false);
 
       expect(
         container.getElementsByClassName("disable-zen-mode--visible").length,
@@ -103,14 +103,14 @@ describe("<Excalidraw/>", () => {
       });
       const contextMenu = document.querySelector(".context-menu");
       fireEvent.click(queryByText(contextMenu as HTMLElement, "Toggle grid")!);
-      expect(h.state.gridSize).toBe(GRID_SIZE);
+      expect(h.state.gridModeEnabled).toBe(true);
     });
 
     it('should not show grid mode in context menu when gridModeEnabled is not "undefined"', async () => {
       const { container } = await render(
         <Excalidraw gridModeEnabled={false} />,
       );
-      expect(h.state.gridSize).toBe(null);
+      expect(h.state.gridModeEnabled).toBe(false);
 
       expect(
         container.getElementsByClassName("disable-zen-mode--visible").length,
@@ -122,7 +122,7 @@ describe("<Excalidraw/>", () => {
       });
       const contextMenu = document.querySelector(".context-menu");
       expect(queryByText(contextMenu as HTMLElement, "Show grid")).toBe(null);
-      expect(h.state.gridSize).toBe(null);
+      expect(h.state.gridModeEnabled).toBe(false);
     });
   });
 

+ 1 - 1
packages/excalidraw/tests/fixtures/diagramFixture.ts

@@ -12,7 +12,7 @@ export const diagramFixture = {
   elements: [diamondFixture, ellipseFixture, rectangleFixture],
   appState: {
     viewBackgroundColor: "#ffffff",
-    gridSize: null,
+    gridModeEnabled: false,
   },
   files: {},
 };

+ 13 - 2
packages/excalidraw/types.ts

@@ -40,7 +40,7 @@ import type { FileSystemHandle } from "./data/filesystem";
 import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
 import type { ContextMenuItems } from "./components/ContextMenu";
 import type { SnapLine } from "./snapping";
-import type { Merge, MaybePromise, ValueOf } from "./utility-types";
+import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types";
 import type { StoreActionType } from "./store";
 
 export type Point = Readonly<RoughPoint>;
@@ -176,6 +176,7 @@ export type StaticCanvasAppState = Readonly<
     exportScale: AppState["exportScale"];
     selectedElementsAreBeingDragged: AppState["selectedElementsAreBeingDragged"];
     gridSize: AppState["gridSize"];
+    gridStep: AppState["gridStep"];
     frameRendering: AppState["frameRendering"];
     currentHoveredFontFamily: AppState["currentHoveredFontFamily"];
   }
@@ -351,7 +352,10 @@ export interface AppState {
   toast: { message: string; closable?: boolean; duration?: number } | null;
   zenModeEnabled: boolean;
   theme: Theme;
-  gridSize: number | null;
+  /** grid cell px size */
+  gridSize: number;
+  gridStep: number;
+  gridModeEnabled: boolean;
   viewModeEnabled: boolean;
 
   /** top-most selected groups (i.e. does not include nested groups) */
@@ -615,6 +619,7 @@ export type AppProps = Merge<
  * in the app, eg Manager. Factored out into a separate type to keep DRY. */
 export type AppClassProperties = {
   props: AppProps;
+  state: AppState;
   interactiveCanvas: HTMLCanvasElement | null;
   /** static canvas */
   canvas: HTMLCanvasElement;
@@ -649,6 +654,7 @@ export type AppClassProperties = {
   getName: App["getName"];
   dismissLinearEditor: App["dismissLinearEditor"];
   flowChartCreator: App["flowChartCreator"];
+  getEffectiveGridSize: App["getEffectiveGridSize"];
 };
 
 export type PointerDownState = Readonly<{
@@ -831,3 +837,8 @@ export type EmbedsValidationStatus = Map<
 export type ElementsPendingErasure = Set<ExcalidrawElement["id"]>;
 
 export type PendingExcalidrawElements = ExcalidrawElement[];
+
+/** Runtime gridSize value. Null indicates disabled grid. */
+export type NullableGridSize =
+  | (AppState["gridSize"] & MakeBrand<"NullableGridSize">)
+  | null;

+ 3 - 1
packages/utils/__snapshots__/export.test.ts.snap

@@ -49,7 +49,9 @@ exports[`exportToSvg > with default arguments 1`] = `
     "outline": true,
   },
   "frameToHighlight": null,
-  "gridSize": null,
+  "gridModeEnabled": false,
+  "gridSize": 20,
+  "gridStep": 5,
   "isBindingEnabled": true,
   "isLoading": false,
   "isResizing": false,

+ 2 - 2
packages/utils/utils.unmocked.test.ts

@@ -19,7 +19,7 @@ describe("embedding scene data", () => {
         elements: sourceElements,
         appState: {
           viewBackgroundColor: "#ffffff",
-          gridSize: null,
+          gridModeEnabled: false,
           exportEmbedScene: true,
         },
         files: null,
@@ -50,7 +50,7 @@ describe("embedding scene data", () => {
         elements: sourceElements,
         appState: {
           viewBackgroundColor: "#ffffff",
-          gridSize: null,
+          gridModeEnabled: false,
           exportEmbedScene: true,
         },
         files: null,