2
0
Эх сурвалжийг харах

Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage

Daniel J. Geiger 1 жил өмнө
parent
commit
dd4bf91128

+ 1 - 0
src/actions/actionToggleGridMode.tsx

@@ -15,6 +15,7 @@ export const actionToggleGridMode = register({
       appState: {
       appState: {
         ...appState,
         ...appState,
         gridSize: this.checked!(appState) ? null : GRID_SIZE,
         gridSize: this.checked!(appState) ? null : GRID_SIZE,
+        objectsSnapModeEnabled: false,
       },
       },
       commitToHistory: false,
       commitToHistory: false,
     };
     };

+ 28 - 0
src/actions/actionToggleObjectsSnapMode.tsx

@@ -0,0 +1,28 @@
+import { CODES, KEYS } from "../keys";
+import { register } from "./register";
+
+export const actionToggleObjectsSnapMode = register({
+  name: "objectsSnapMode",
+  viewMode: true,
+  trackEvent: {
+    category: "canvas",
+    predicate: (appState) => !appState.objectsSnapModeEnabled,
+  },
+  perform(elements, appState) {
+    return {
+      appState: {
+        ...appState,
+        objectsSnapModeEnabled: !this.checked!(appState),
+        gridSize: null,
+      },
+      commitToHistory: false,
+    };
+  },
+  checked: (appState) => appState.objectsSnapModeEnabled,
+  predicate: (elements, appState, appProps) => {
+    return typeof appProps.objectsSnapModeEnabled === "undefined";
+  },
+  contextItemLabel: "buttons.objectsSnapMode",
+  keyTest: (event) =>
+    !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
+});

+ 1 - 0
src/actions/index.ts

@@ -80,6 +80,7 @@ export {
 
 
 export { actionToggleGridMode } from "./actionToggleGridMode";
 export { actionToggleGridMode } from "./actionToggleGridMode";
 export { actionToggleZenMode } from "./actionToggleZenMode";
 export { actionToggleZenMode } from "./actionToggleZenMode";
+export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
 
 
 export { actionToggleStats } from "./actionToggleStats";
 export { actionToggleStats } from "./actionToggleStats";
 export { actionUnbindText, actionBindText } from "./actionBoundText";
 export { actionUnbindText, actionBindText } from "./actionBoundText";

+ 2 - 0
src/actions/shortcuts.ts

@@ -28,6 +28,7 @@ export type ShortcutName =
       | "ungroup"
       | "ungroup"
       | "gridMode"
       | "gridMode"
       | "zenMode"
       | "zenMode"
+      | "objectsSnapMode"
       | "stats"
       | "stats"
       | "addToLibrary"
       | "addToLibrary"
       | "viewMode"
       | "viewMode"
@@ -74,6 +75,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
   ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
   ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
   gridMode: [getShortcutKey("CtrlOrCmd+'")],
   gridMode: [getShortcutKey("CtrlOrCmd+'")],
   zenMode: [getShortcutKey("Alt+Z")],
   zenMode: [getShortcutKey("Alt+Z")],
+  objectsSnapMode: [getShortcutKey("Alt+S")],
   stats: [getShortcutKey("Alt+/")],
   stats: [getShortcutKey("Alt+/")],
   addToLibrary: [],
   addToLibrary: [],
   flipHorizontal: [getShortcutKey("Shift+H")],
   flipHorizontal: [getShortcutKey("Shift+H")],

+ 1 - 0
src/actions/types.ts

@@ -60,6 +60,7 @@ export type ActionName =
   | "pasteStyles"
   | "pasteStyles"
   | "gridMode"
   | "gridMode"
   | "zenMode"
   | "zenMode"
+  | "objectsSnapMode"
   | "stats"
   | "stats"
   | "changeStrokeColor"
   | "changeStrokeColor"
   | "changeBackgroundColor"
   | "changeBackgroundColor"

+ 9 - 0
src/appState.ts

@@ -99,6 +99,12 @@ export const getDefaultAppState = (): Omit<
     pendingImageElementId: null,
     pendingImageElementId: null,
     showHyperlinkPopup: false,
     showHyperlinkPopup: false,
     selectedLinearElement: null,
     selectedLinearElement: null,
+    snapLines: [],
+    originSnapOffset: {
+      x: 0,
+      y: 0,
+    },
+    objectsSnapModeEnabled: false,
   };
   };
 };
 };
 
 
@@ -208,6 +214,9 @@ const APP_STATE_STORAGE_CONF = (<
   pendingImageElementId: { browser: false, export: false, server: false },
   pendingImageElementId: { browser: false, export: false, server: false },
   showHyperlinkPopup: { browser: false, export: false, server: false },
   showHyperlinkPopup: { browser: false, export: false, server: false },
   selectedLinearElement: { browser: true, export: false, server: false },
   selectedLinearElement: { browser: true, export: false, server: false },
+  snapLines: { browser: false, export: false, server: false },
+  originSnapOffset: { browser: false, export: false, server: false },
+  objectsSnapModeEnabled: { browser: true, export: false, server: false },
 });
 });
 
 
 const _clearAppStateForStorage = <
 const _clearAppStateForStorage = <

+ 231 - 26
src/components/App.tsx

@@ -35,6 +35,7 @@ import {
   actionLink,
   actionLink,
   actionToggleElementLock,
   actionToggleElementLock,
   actionToggleLinearEditor,
   actionToggleLinearEditor,
+  actionToggleObjectsSnapMode,
 } from "../actions";
 } from "../actions";
 import { createRedoAction, createUndoAction } from "../actions/actionHistory";
 import { createRedoAction, createUndoAction } from "../actions/actionHistory";
 import { ActionManager } from "../actions/manager";
 import { ActionManager } from "../actions/manager";
@@ -228,6 +229,7 @@ import {
   FrameNameBoundsCache,
   FrameNameBoundsCache,
   SidebarName,
   SidebarName,
   SidebarTabName,
   SidebarTabName,
+  KeyboardModifiersObject,
 } from "../types";
 } from "../types";
 import {
 import {
   debounce,
   debounce,
@@ -352,6 +354,17 @@ import {
 import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
 import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
 import { jotaiStore } from "../jotai";
 import { jotaiStore } from "../jotai";
 import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
 import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
+import {
+  getSnapLinesAtPointer,
+  snapDraggedElements,
+  isActiveToolNonLinearSnappable,
+  snapNewElement,
+  snapResizingElements,
+  isSnappingEnabled,
+  getVisibleGaps,
+  getReferenceSnapPoints,
+  SnapCache,
+} from "../snapping";
 import { actionWrapTextInContainer } from "../actions/actionBoundText";
 import { actionWrapTextInContainer } from "../actions/actionBoundText";
 import BraveMeasureTextError from "./BraveMeasureTextError";
 import BraveMeasureTextError from "./BraveMeasureTextError";
 import { activeEyeDropperAtom } from "./EyeDropper";
 import { activeEyeDropperAtom } from "./EyeDropper";
@@ -500,6 +513,7 @@ class App extends React.Component<AppProps, AppState> {
       viewModeEnabled = false,
       viewModeEnabled = false,
       zenModeEnabled = false,
       zenModeEnabled = false,
       gridModeEnabled = false,
       gridModeEnabled = false,
+      objectsSnapModeEnabled = false,
       theme = defaultAppState.theme,
       theme = defaultAppState.theme,
       name = defaultAppState.name,
       name = defaultAppState.name,
     } = props;
     } = props;
@@ -510,6 +524,7 @@ class App extends React.Component<AppProps, AppState> {
       ...this.getCanvasOffsets(),
       ...this.getCanvasOffsets(),
       viewModeEnabled,
       viewModeEnabled,
       zenModeEnabled,
       zenModeEnabled,
+      objectsSnapModeEnabled,
       gridSize: gridModeEnabled ? GRID_SIZE : null,
       gridSize: gridModeEnabled ? GRID_SIZE : null,
       name,
       name,
       width: window.innerWidth,
       width: window.innerWidth,
@@ -1115,7 +1130,7 @@ class App extends React.Component<AppProps, AppState> {
             cursor: CURSOR_TYPE.MOVE,
             cursor: CURSOR_TYPE.MOVE,
             pointerEvents: this.state.viewModeEnabled
             pointerEvents: this.state.viewModeEnabled
               ? POINTER_EVENTS.disabled
               ? POINTER_EVENTS.disabled
-              : POINTER_EVENTS.inheritFromUI,
+              : POINTER_EVENTS.enabled,
           }}
           }}
           onPointerDown={(event) => this.handleCanvasPointerDown(event)}
           onPointerDown={(event) => this.handleCanvasPointerDown(event)}
           onWheel={(event) => this.handleWheel(event)}
           onWheel={(event) => this.handleWheel(event)}
@@ -1751,6 +1766,7 @@ class App extends React.Component<AppProps, AppState> {
     this.scene.destroy();
     this.scene.destroy();
     this.library.destroy();
     this.library.destroy();
     ShapeCache.destroy();
     ShapeCache.destroy();
+    SnapCache.destroy();
     clearTimeout(touchTimeout);
     clearTimeout(touchTimeout);
     isSomeElementSelected.clearCache();
     isSomeElementSelected.clearCache();
     selectGroupsForSelectedElements.clearCache();
     selectGroupsForSelectedElements.clearCache();
@@ -3150,15 +3166,21 @@ class App extends React.Component<AppProps, AppState> {
       this.onImageAction();
       this.onImageAction();
     }
     }
     if (nextActiveTool.type !== "selection") {
     if (nextActiveTool.type !== "selection") {
-      this.setState({
+      this.setState((prevState) => ({
         activeTool: nextActiveTool,
         activeTool: nextActiveTool,
         selectedElementIds: makeNextSelectedElementIds({}, this.state),
         selectedElementIds: makeNextSelectedElementIds({}, this.state),
         selectedGroupIds: {},
         selectedGroupIds: {},
         editingGroupId: null,
         editingGroupId: null,
+        snapLines: [],
+        originSnapOffset: null,
+      }));
+    } else {
+      this.setState({
+        activeTool: nextActiveTool,
+        snapLines: [],
+        originSnapOffset: null,
         activeEmbeddable: null,
         activeEmbeddable: null,
       });
       });
-    } else {
-      this.setState({ activeTool: nextActiveTool, activeEmbeddable: null });
     }
     }
   };
   };
 
 
@@ -3896,6 +3918,30 @@ class App extends React.Component<AppProps, AppState> {
     const scenePointer = viewportCoordsToSceneCoords(event, this.state);
     const scenePointer = viewportCoordsToSceneCoords(event, this.state);
     const { x: scenePointerX, y: scenePointerY } = scenePointer;
     const { x: scenePointerX, y: scenePointerY } = scenePointer;
 
 
+    if (
+      !this.state.draggingElement &&
+      isActiveToolNonLinearSnappable(this.state.activeTool.type)
+    ) {
+      const { originOffset, snapLines } = getSnapLinesAtPointer(
+        this.scene.getNonDeletedElements(),
+        this.state,
+        {
+          x: scenePointerX,
+          y: scenePointerY,
+        },
+        event,
+      );
+
+      this.setState({
+        snapLines,
+        originSnapOffset: originOffset,
+      });
+    } else if (!this.state.draggingElement) {
+      this.setState({
+        snapLines: [],
+      });
+    }
+
     if (
     if (
       this.state.editingLinearElement &&
       this.state.editingLinearElement &&
       !this.state.editingLinearElement.isDragging
       !this.state.editingLinearElement.isDragging
@@ -4366,6 +4412,10 @@ class App extends React.Component<AppProps, AppState> {
       this.setState({ contextMenu: null });
       this.setState({ contextMenu: null });
     }
     }
 
 
+    if (this.state.snapLines) {
+      this.setAppState({ snapLines: [] });
+    }
+
     this.updateGestureOnPointerDown(event);
     this.updateGestureOnPointerDown(event);
 
 
     // if dragging element is freedraw and another pointerdown event occurs
     // if dragging element is freedraw and another pointerdown event occurs
@@ -5650,6 +5700,52 @@ class App extends React.Component<AppProps, AppState> {
     });
     });
   };
   };
 
 
+  private maybeCacheReferenceSnapPoints(
+    event: KeyboardModifiersObject,
+    selectedElements: ExcalidrawElement[],
+    recomputeAnyways: boolean = false,
+  ) {
+    if (
+      isSnappingEnabled({
+        event,
+        appState: this.state,
+        selectedElements,
+      }) &&
+      (recomputeAnyways || !SnapCache.getReferenceSnapPoints())
+    ) {
+      SnapCache.setReferenceSnapPoints(
+        getReferenceSnapPoints(
+          this.scene.getNonDeletedElements(),
+          selectedElements,
+          this.state,
+        ),
+      );
+    }
+  }
+
+  private maybeCacheVisibleGaps(
+    event: KeyboardModifiersObject,
+    selectedElements: ExcalidrawElement[],
+    recomputeAnyways: boolean = false,
+  ) {
+    if (
+      isSnappingEnabled({
+        event,
+        appState: this.state,
+        selectedElements,
+      }) &&
+      (recomputeAnyways || !SnapCache.getVisibleGaps())
+    ) {
+      SnapCache.setVisibleGaps(
+        getVisibleGaps(
+          this.scene.getNonDeletedElements(),
+          selectedElements,
+          this.state,
+        ),
+      );
+    }
+  }
+
   private onKeyDownFromPointerDownHandler(
   private onKeyDownFromPointerDownHandler(
     pointerDownState: PointerDownState,
     pointerDownState: PointerDownState,
   ): (event: KeyboardEvent) => void {
   ): (event: KeyboardEvent) => void {
@@ -5879,33 +5975,62 @@ class App extends React.Component<AppProps, AppState> {
           !this.state.editingElement &&
           !this.state.editingElement &&
           this.state.activeEmbeddable?.state !== "active"
           this.state.activeEmbeddable?.state !== "active"
         ) {
         ) {
-          const [dragX, dragY] = getGridPoint(
-            pointerCoords.x - pointerDownState.drag.offset.x,
-            pointerCoords.y - pointerDownState.drag.offset.y,
-            event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
-          );
+          const dragOffset = {
+            x: pointerCoords.x - pointerDownState.origin.x,
+            y: pointerCoords.y - pointerDownState.origin.y,
+          };
 
 
-          const [dragDistanceX, dragDistanceY] = [
-            Math.abs(pointerCoords.x - pointerDownState.origin.x),
-            Math.abs(pointerCoords.y - pointerDownState.origin.y),
+          const originalElements = [
+            ...pointerDownState.originalElements.values(),
           ];
           ];
 
 
           // We only drag in one direction if shift is pressed
           // We only drag in one direction if shift is pressed
           const lockDirection = event.shiftKey;
           const lockDirection = event.shiftKey;
+
+          if (lockDirection) {
+            const distanceX = Math.abs(dragOffset.x);
+            const distanceY = Math.abs(dragOffset.y);
+
+            const lockX = lockDirection && distanceX < distanceY;
+            const lockY = lockDirection && distanceX > distanceY;
+
+            if (lockX) {
+              dragOffset.x = 0;
+            }
+
+            if (lockY) {
+              dragOffset.y = 0;
+            }
+          }
+
+          // Snap cache *must* be synchronously popuplated before initial drag,
+          // otherwise the first drag even will not snap, causing a jump before
+          // it snaps to its position if previously snapped already.
+          this.maybeCacheVisibleGaps(event, selectedElements);
+          this.maybeCacheReferenceSnapPoints(event, selectedElements);
+
+          const { snapOffset, snapLines } = snapDraggedElements(
+            getSelectedElements(originalElements, this.state),
+            dragOffset,
+            this.state,
+            event,
+          );
+
+          this.setState({ snapLines });
+
           // when we're editing the name of a frame, we want the user to be
           // when we're editing the name of a frame, we want the user to be
           // able to select and interact with the text input
           // able to select and interact with the text input
           !this.state.editingFrame &&
           !this.state.editingFrame &&
             dragSelectedElements(
             dragSelectedElements(
               pointerDownState,
               pointerDownState,
               selectedElements,
               selectedElements,
-              dragX,
-              dragY,
-              lockDirection,
-              dragDistanceX,
-              dragDistanceY,
+              dragOffset,
               this.state,
               this.state,
               this.scene,
               this.scene,
+              snapOffset,
+              event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
             );
             );
+
           this.maybeSuggestBindingForAll(selectedElements);
           this.maybeSuggestBindingForAll(selectedElements);
 
 
           // We duplicate the selected element if alt is pressed on pointer move
           // We duplicate the selected element if alt is pressed on pointer move
@@ -5946,15 +6071,21 @@ class App extends React.Component<AppProps, AppState> {
                   groupIdMap,
                   groupIdMap,
                   element,
                   element,
                 );
                 );
-                const [originDragX, originDragY] = getGridPoint(
-                  pointerDownState.origin.x - pointerDownState.drag.offset.x,
-                  pointerDownState.origin.y - pointerDownState.drag.offset.y,
-                  event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
-                );
+                const origElement = pointerDownState.originalElements.get(
+                  element.id,
+                )!;
                 mutateElement(duplicatedElement, {
                 mutateElement(duplicatedElement, {
-                  x: duplicatedElement.x + (originDragX - dragX),
-                  y: duplicatedElement.y + (originDragY - dragY),
+                  x: origElement.x,
+                  y: origElement.y,
                 });
                 });
+
+                // put duplicated element to pointerDownState.originalElements
+                // so that we can snap to the duplicated element without releasing
+                pointerDownState.originalElements.set(
+                  duplicatedElement.id,
+                  duplicatedElement,
+                );
+
                 nextElements.push(duplicatedElement);
                 nextElements.push(duplicatedElement);
                 elementsToAppend.push(element);
                 elementsToAppend.push(element);
                 oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
                 oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
@@ -5980,6 +6111,8 @@ class App extends React.Component<AppProps, AppState> {
               oldIdToDuplicatedId,
               oldIdToDuplicatedId,
             );
             );
             this.scene.replaceAllElements(nextSceneElements);
             this.scene.replaceAllElements(nextSceneElements);
+            this.maybeCacheVisibleGaps(event, selectedElements, true);
+            this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
           }
           }
           return;
           return;
         }
         }
@@ -6196,6 +6329,7 @@ class App extends React.Component<AppProps, AppState> {
         isResizing,
         isResizing,
         isRotating,
         isRotating,
       } = this.state;
       } = this.state;
+
       this.setState({
       this.setState({
         isResizing: false,
         isResizing: false,
         isRotating: false,
         isRotating: false,
@@ -6210,8 +6344,14 @@ class App extends React.Component<AppProps, AppState> {
           multiElement || isTextElement(this.state.editingElement)
           multiElement || isTextElement(this.state.editingElement)
             ? this.state.editingElement
             ? this.state.editingElement
             : null,
             : null,
+        snapLines: [],
+
+        originSnapOffset: null,
       });
       });
 
 
+      SnapCache.setReferenceSnapPoints(null);
+      SnapCache.setVisibleGaps(null);
+
       this.savePointer(childEvent.clientX, childEvent.clientY, "up");
       this.savePointer(childEvent.clientX, childEvent.clientY, "up");
 
 
       this.setState({
       this.setState({
@@ -7739,7 +7879,7 @@ class App extends React.Component<AppProps, AppState> {
         shouldResizeFromCenter(event),
         shouldResizeFromCenter(event),
       );
       );
     } else {
     } else {
-      const [gridX, gridY] = getGridPoint(
+      let [gridX, gridY] = getGridPoint(
         pointerCoords.x,
         pointerCoords.x,
         pointerCoords.y,
         pointerCoords.y,
         event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
         event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
@@ -7753,6 +7893,33 @@ class App extends React.Component<AppProps, AppState> {
           ? image.width / image.height
           ? image.width / image.height
           : null;
           : null;
 
 
+      this.maybeCacheReferenceSnapPoints(event, [draggingElement]);
+
+      const { snapOffset, snapLines } = snapNewElement(
+        draggingElement,
+        this.state,
+        event,
+        {
+          x:
+            pointerDownState.originInGrid.x +
+            (this.state.originSnapOffset?.x ?? 0),
+          y:
+            pointerDownState.originInGrid.y +
+            (this.state.originSnapOffset?.y ?? 0),
+        },
+        {
+          x: gridX - pointerDownState.originInGrid.x,
+          y: gridY - pointerDownState.originInGrid.y,
+        },
+      );
+
+      gridX += snapOffset.x;
+      gridY += snapOffset.y;
+
+      this.setState({
+        snapLines,
+      });
+
       dragNewElement(
       dragNewElement(
         draggingElement,
         draggingElement,
         this.state.activeTool.type,
         this.state.activeTool.type,
@@ -7767,6 +7934,7 @@ class App extends React.Component<AppProps, AppState> {
           : shouldMaintainAspectRatio(event),
           : shouldMaintainAspectRatio(event),
         shouldResizeFromCenter(event),
         shouldResizeFromCenter(event),
         aspectRatio,
         aspectRatio,
+        this.state.originSnapOffset,
       );
       );
 
 
       this.maybeSuggestBindingForAll([draggingElement]);
       this.maybeSuggestBindingForAll([draggingElement]);
@@ -7808,7 +7976,7 @@ class App extends React.Component<AppProps, AppState> {
       activeEmbeddable: null,
       activeEmbeddable: null,
     });
     });
     const pointerCoords = pointerDownState.lastCoords;
     const pointerCoords = pointerDownState.lastCoords;
-    const [resizeX, resizeY] = getGridPoint(
+    let [resizeX, resizeY] = getGridPoint(
       pointerCoords.x - pointerDownState.resize.offset.x,
       pointerCoords.x - pointerDownState.resize.offset.x,
       pointerCoords.y - pointerDownState.resize.offset.y,
       pointerCoords.y - pointerDownState.resize.offset.y,
       event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
       event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
@@ -7836,6 +8004,41 @@ class App extends React.Component<AppProps, AppState> {
       });
       });
     });
     });
 
 
+    // check needed for avoiding flickering when a key gets pressed
+    // during dragging
+    if (!this.state.selectedElementsAreBeingDragged) {
+      const [gridX, gridY] = getGridPoint(
+        pointerCoords.x,
+        pointerCoords.y,
+        event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
+      );
+
+      const dragOffset = {
+        x: gridX - pointerDownState.originInGrid.x,
+        y: gridY - pointerDownState.originInGrid.y,
+      };
+
+      const originalElements = [...pointerDownState.originalElements.values()];
+
+      this.maybeCacheReferenceSnapPoints(event, selectedElements);
+
+      const { snapOffset, snapLines } = snapResizingElements(
+        selectedElements,
+        getSelectedElements(originalElements, this.state),
+        this.state,
+        event,
+        dragOffset,
+        transformHandleType,
+      );
+
+      resizeX += snapOffset.x;
+      resizeY += snapOffset.y;
+
+      this.setState({
+        snapLines,
+      });
+    }
+
     if (
     if (
       transformElements(
       transformElements(
         pointerDownState,
         pointerDownState,
@@ -7851,6 +8054,7 @@ class App extends React.Component<AppProps, AppState> {
         resizeY,
         resizeY,
         pointerDownState.resize.center.x,
         pointerDownState.resize.center.x,
         pointerDownState.resize.center.y,
         pointerDownState.resize.center.y,
+        this.state,
       )
       )
     ) {
     ) {
       this.maybeSuggestBindingForAll(selectedElements);
       this.maybeSuggestBindingForAll(selectedElements);
@@ -7961,6 +8165,7 @@ class App extends React.Component<AppProps, AppState> {
         actionUnlockAllElements,
         actionUnlockAllElements,
         CONTEXT_MENU_SEPARATOR,
         CONTEXT_MENU_SEPARATOR,
         actionToggleGridMode,
         actionToggleGridMode,
+        actionToggleObjectsSnapMode,
         actionToggleZenMode,
         actionToggleZenMode,
         actionToggleViewMode,
         actionToggleViewMode,
         actionToggleStats,
         actionToggleStats,

+ 4 - 0
src/components/HelpDialog.tsx

@@ -258,6 +258,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
               label={t("buttons.zenMode")}
               label={t("buttons.zenMode")}
               shortcuts={[getShortcutKey("Alt+Z")]}
               shortcuts={[getShortcutKey("Alt+Z")]}
             />
             />
+            <Shortcut
+              label={t("buttons.objectsSnapMode")}
+              shortcuts={[getShortcutKey("Alt+S")]}
+            />
             <Shortcut
             <Shortcut
               label={t("labels.showGrid")}
               label={t("labels.showGrid")}
               shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
               shortcuts={[getShortcutKey("CtrlOrCmd+'")]}

+ 2 - 0
src/components/canvases/InteractiveCanvas.tsx

@@ -193,6 +193,8 @@ const getRelevantAppStateProps = (
   showHyperlinkPopup: appState.showHyperlinkPopup,
   showHyperlinkPopup: appState.showHyperlinkPopup,
   collaborators: appState.collaborators, // Necessary for collab. sessions
   collaborators: appState.collaborators, // Necessary for collab. sessions
   activeEmbeddable: appState.activeEmbeddable,
   activeEmbeddable: appState.activeEmbeddable,
+  snapLines: appState.snapLines,
+  zenModeEnabled: appState.zenModeEnabled,
 });
 });
 
 
 const areEqual = (
 const areEqual = (

+ 14 - 1
src/element/bounds.ts

@@ -158,7 +158,7 @@ export const getElementAbsoluteCoords = (
   ];
   ];
 };
 };
 
 
-/**
+/*
  * for a given element, `getElementLineSegments` returns line segments
  * for a given element, `getElementLineSegments` returns line segments
  * that can be used for visual collision detection (useful for frames)
  * that can be used for visual collision detection (useful for frames)
  * as opposed to bounding box collision detection
  * as opposed to bounding box collision detection
@@ -674,6 +674,19 @@ export const getCommonBounds = (
   return [minX, minY, maxX, maxY];
   return [minX, minY, maxX, maxY];
 };
 };
 
 
+export const getDraggedElementsBounds = (
+  elements: ExcalidrawElement[],
+  dragOffset: { x: number; y: number },
+) => {
+  const [minX, minY, maxX, maxY] = getCommonBounds(elements);
+  return [
+    minX + dragOffset.x,
+    minY + dragOffset.y,
+    maxX + dragOffset.x,
+    maxY + dragOffset.y,
+  ];
+};
+
 export const getResizedElementAbsoluteCoords = (
 export const getResizedElementAbsoluteCoords = (
   element: ExcalidrawElement,
   element: ExcalidrawElement,
   nextWidth: number,
   nextWidth: number,

+ 43 - 33
src/element/dragElements.ts

@@ -6,23 +6,22 @@ import { NonDeletedExcalidrawElement } from "./types";
 import { AppState, PointerDownState } from "../types";
 import { AppState, PointerDownState } from "../types";
 import { getBoundTextElement } from "./textElement";
 import { getBoundTextElement } from "./textElement";
 import { isSelectedViaGroup } from "../groups";
 import { isSelectedViaGroup } from "../groups";
+import { getGridPoint } from "../math";
 import Scene from "../scene/Scene";
 import Scene from "../scene/Scene";
 import { isFrameElement } from "./typeChecks";
 import { isFrameElement } from "./typeChecks";
 
 
 export const dragSelectedElements = (
 export const dragSelectedElements = (
   pointerDownState: PointerDownState,
   pointerDownState: PointerDownState,
   selectedElements: NonDeletedExcalidrawElement[],
   selectedElements: NonDeletedExcalidrawElement[],
-  pointerX: number,
-  pointerY: number,
-  lockDirection: boolean = false,
-  distanceX: number = 0,
-  distanceY: number = 0,
+  offset: { x: number; y: number },
   appState: AppState,
   appState: AppState,
   scene: Scene,
   scene: Scene,
+  snapOffset: {
+    x: number;
+    y: number;
+  },
+  gridSize: AppState["gridSize"],
 ) => {
 ) => {
-  const [x1, y1] = getCommonBounds(selectedElements);
-  const offset = { x: pointerX - x1, y: pointerY - y1 };
-
   // we do not want a frame and its elements to be selected at the same time
   // we do not want a frame and its elements to be selected at the same time
   // but when it happens (due to some bug), we want to avoid updating element
   // but when it happens (due to some bug), we want to avoid updating element
   // in the frame twice, hence the use of set
   // in the frame twice, hence the use of set
@@ -44,12 +43,11 @@ export const dragSelectedElements = (
 
 
   elementsToUpdate.forEach((element) => {
   elementsToUpdate.forEach((element) => {
     updateElementCoords(
     updateElementCoords(
-      lockDirection,
-      distanceX,
-      distanceY,
       pointerDownState,
       pointerDownState,
       element,
       element,
       offset,
       offset,
+      snapOffset,
+      gridSize,
     );
     );
     // update coords of bound text only if we're dragging the container directly
     // update coords of bound text only if we're dragging the container directly
     // (we don't drag the group that it's part of)
     // (we don't drag the group that it's part of)
@@ -69,12 +67,11 @@ export const dragSelectedElements = (
         (!textElement.frameId || !frames.includes(textElement.frameId))
         (!textElement.frameId || !frames.includes(textElement.frameId))
       ) {
       ) {
         updateElementCoords(
         updateElementCoords(
-          lockDirection,
-          distanceX,
-          distanceY,
           pointerDownState,
           pointerDownState,
           textElement,
           textElement,
           offset,
           offset,
+          snapOffset,
+          gridSize,
         );
         );
       }
       }
     }
     }
@@ -85,31 +82,40 @@ export const dragSelectedElements = (
 };
 };
 
 
 const updateElementCoords = (
 const updateElementCoords = (
-  lockDirection: boolean,
-  distanceX: number,
-  distanceY: number,
   pointerDownState: PointerDownState,
   pointerDownState: PointerDownState,
   element: NonDeletedExcalidrawElement,
   element: NonDeletedExcalidrawElement,
-  offset: { x: number; y: number },
+  dragOffset: { x: number; y: number },
+  snapOffset: { x: number; y: number },
+  gridSize: AppState["gridSize"],
 ) => {
 ) => {
-  let x: number;
-  let y: number;
-  if (lockDirection) {
-    const lockX = lockDirection && distanceX < distanceY;
-    const lockY = lockDirection && distanceX > distanceY;
-    const original = pointerDownState.originalElements.get(element.id);
-    x = lockX && original ? original.x : element.x + offset.x;
-    y = lockY && original ? original.y : element.y + offset.y;
-  } else {
-    x = element.x + offset.x;
-    y = element.y + offset.y;
+  const originalElement =
+    pointerDownState.originalElements.get(element.id) ?? element;
+
+  let nextX = originalElement.x + dragOffset.x + snapOffset.x;
+  let nextY = originalElement.y + dragOffset.y + snapOffset.y;
+
+  if (snapOffset.x === 0 || snapOffset.y === 0) {
+    const [nextGridX, nextGridY] = getGridPoint(
+      originalElement.x + dragOffset.x,
+      originalElement.y + dragOffset.y,
+      gridSize,
+    );
+
+    if (snapOffset.x === 0) {
+      nextX = nextGridX;
+    }
+
+    if (snapOffset.y === 0) {
+      nextY = nextGridY;
+    }
   }
   }
 
 
   mutateElement(element, {
   mutateElement(element, {
-    x,
-    y,
+    x: nextX,
+    y: nextY,
   });
   });
 };
 };
+
 export const getDragOffsetXY = (
 export const getDragOffsetXY = (
   selectedElements: NonDeletedExcalidrawElement[],
   selectedElements: NonDeletedExcalidrawElement[],
   x: number,
   x: number,
@@ -133,6 +139,10 @@ export const dragNewElement = (
   /** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
   /** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
       true */
       true */
   widthAspectRatio?: number | null,
   widthAspectRatio?: number | null,
+  originOffset: {
+    x: number;
+    y: number;
+  } | null = null,
 ) => {
 ) => {
   if (shouldMaintainAspectRatio && draggingElement.type !== "selection") {
   if (shouldMaintainAspectRatio && draggingElement.type !== "selection") {
     if (widthAspectRatio) {
     if (widthAspectRatio) {
@@ -173,8 +183,8 @@ export const dragNewElement = (
 
 
   if (width !== 0 && height !== 0) {
   if (width !== 0 && height !== 0) {
     mutateElement(draggingElement, {
     mutateElement(draggingElement, {
-      x: newX,
-      y: newY,
+      x: newX + (originOffset?.x ?? 0),
+      y: newY + (originOffset?.y ?? 0),
       width,
       width,
       height,
       height,
     });
     });

+ 27 - 13
src/element/resizeElements.ts

@@ -41,7 +41,7 @@ import {
   MaybeTransformHandleType,
   MaybeTransformHandleType,
   TransformHandleDirection,
   TransformHandleDirection,
 } from "./transformHandles";
 } from "./transformHandles";
-import { Point, PointerDownState } from "../types";
+import { AppState, Point, PointerDownState } from "../types";
 import Scene from "../scene/Scene";
 import Scene from "../scene/Scene";
 import {
 import {
   getApproxMinLineWidth,
   getApproxMinLineWidth,
@@ -79,6 +79,7 @@ export const transformElements = (
   pointerY: number,
   pointerY: number,
   centerX: number,
   centerX: number,
   centerY: number,
   centerY: number,
+  appState: AppState,
 ) => {
 ) => {
   if (selectedElements.length === 1) {
   if (selectedElements.length === 1) {
     const [element] = selectedElements;
     const [element] = selectedElements;
@@ -462,8 +463,8 @@ export const resizeSingleElement = (
         boundTextElement.fontSize,
         boundTextElement.fontSize,
         boundTextElement.lineHeight,
         boundTextElement.lineHeight,
       );
       );
-      eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth));
-      eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight));
+      eleNewWidth = Math.max(eleNewWidth, minWidth);
+      eleNewHeight = Math.max(eleNewHeight, minHeight);
     }
     }
   }
   }
 
 
@@ -504,8 +505,11 @@ export const resizeSingleElement = (
     }
     }
   }
   }
 
 
+  const flipX = eleNewWidth < 0;
+  const flipY = eleNewHeight < 0;
+
   // Flip horizontally
   // Flip horizontally
-  if (eleNewWidth < 0) {
+  if (flipX) {
     if (transformHandleDirection.includes("e")) {
     if (transformHandleDirection.includes("e")) {
       newTopLeft[0] -= Math.abs(newBoundsWidth);
       newTopLeft[0] -= Math.abs(newBoundsWidth);
     }
     }
@@ -513,8 +517,9 @@ export const resizeSingleElement = (
       newTopLeft[0] += Math.abs(newBoundsWidth);
       newTopLeft[0] += Math.abs(newBoundsWidth);
     }
     }
   }
   }
+
   // Flip vertically
   // Flip vertically
-  if (eleNewHeight < 0) {
+  if (flipY) {
     if (transformHandleDirection.includes("s")) {
     if (transformHandleDirection.includes("s")) {
       newTopLeft[1] -= Math.abs(newBoundsHeight);
       newTopLeft[1] -= Math.abs(newBoundsHeight);
     }
     }
@@ -538,10 +543,20 @@ export const resizeSingleElement = (
   const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
   const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
   newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
   newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
 
 
+  // For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
+  // So we need to readjust (x,y) to be where the first point should be
+  const newOrigin = [...newTopLeft];
+  const linearElementXOffset = stateAtResizeStart.x - newBoundsX1;
+  const linearElementYOffset = stateAtResizeStart.y - newBoundsY1;
+  newOrigin[0] += linearElementXOffset;
+  newOrigin[1] += linearElementYOffset;
+
+  const nextX = newOrigin[0];
+  const nextY = newOrigin[1];
+
   // Readjust points for linear elements
   // Readjust points for linear elements
   let rescaledElementPointsY;
   let rescaledElementPointsY;
   let rescaledPoints;
   let rescaledPoints;
-
   if (isLinearElement(element) || isFreeDrawElement(element)) {
   if (isLinearElement(element) || isFreeDrawElement(element)) {
     rescaledElementPointsY = rescalePoints(
     rescaledElementPointsY = rescalePoints(
       1,
       1,
@@ -558,16 +573,11 @@ export const resizeSingleElement = (
     );
     );
   }
   }
 
 
-  // For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
-  // So we need to readjust (x,y) to be where the first point should be
-  const newOrigin = [...newTopLeft];
-  newOrigin[0] += stateAtResizeStart.x - newBoundsX1;
-  newOrigin[1] += stateAtResizeStart.y - newBoundsY1;
   const resizedElement = {
   const resizedElement = {
     width: Math.abs(eleNewWidth),
     width: Math.abs(eleNewWidth),
     height: Math.abs(eleNewHeight),
     height: Math.abs(eleNewHeight),
-    x: newOrigin[0],
-    y: newOrigin[1],
+    x: nextX,
+    y: nextY,
     points: rescaledPoints,
     points: rescaledPoints,
   };
   };
 
 
@@ -676,6 +686,10 @@ export const resizeMultipleElements = (
   const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
   const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
     targetElements.map(({ orig }) => orig).concat(boundTextElements),
     targetElements.map(({ orig }) => orig).concat(boundTextElements),
   );
   );
+
+  // const originalHeight = maxY - minY;
+  // const originalWidth = maxX - minX;
+
   const direction = transformHandleType;
   const direction = transformHandleType;
 
 
   const mapDirectionsToAnchors: Record<typeof direction, Point> = {
   const mapDirectionsToAnchors: Record<typeof direction, Point> = {

+ 9 - 6
src/element/textWysiwyg.test.tsx

@@ -957,7 +957,7 @@ describe("textWysiwyg", () => {
       expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
       expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
         [
         [
           85,
           85,
-          4.5,
+          4.999999999999986,
         ]
         ]
       `);
       `);
 
 
@@ -1002,8 +1002,8 @@ describe("textWysiwyg", () => {
       resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
       resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
       expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
       expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
         [
         [
-          375,
-          -539,
+          374.99999999999994,
+          -535.0000000000001,
         ]
         ]
       `);
       `);
     });
     });
@@ -1190,7 +1190,7 @@ describe("textWysiwyg", () => {
       editor.blur();
       editor.blur();
 
 
       resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
       resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
-      expect(rectangle.height).toBe(156);
+      expect(rectangle.height).toBeCloseTo(155, 8);
       expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
       expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
 
 
       mouse.select(rectangle);
       mouse.select(rectangle);
@@ -1200,9 +1200,12 @@ describe("textWysiwyg", () => {
 
 
       await new Promise((r) => setTimeout(r, 0));
       await new Promise((r) => setTimeout(r, 0));
       editor.blur();
       editor.blur();
-      expect(rectangle.height).toBe(156);
+      expect(rectangle.height).toBeCloseTo(155, 8);
       // cache updated again
       // cache updated again
-      expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(156);
+      expect(getOriginalContainerHeightFromCache(rectangle.id)).toBeCloseTo(
+        155,
+        8,
+      );
     });
     });
 
 
     it("should reset the container height cache when font properties updated", async () => {
     it("should reset the container height cache when font properties updated", async () => {

+ 4 - 4
src/frame.test.tsx

@@ -177,7 +177,7 @@ describe("adding elements to frames", () => {
         expectEqualIds([rect2, frame]);
         expectEqualIds([rect2, frame]);
       });
       });
 
 
-      it("should add elements", async () => {
+      it.skip("should add elements", async () => {
         h.elements = [rect2, rect3, frame];
         h.elements = [rect2, rect3, frame];
 
 
         func(frame, rect2);
         func(frame, rect2);
@@ -188,7 +188,7 @@ describe("adding elements to frames", () => {
         expectEqualIds([rect3, rect2, frame]);
         expectEqualIds([rect3, rect2, frame]);
       });
       });
 
 
-      it("should add elements when there are other other elements in between", async () => {
+      it.skip("should add elements when there are other other elements in between", async () => {
         h.elements = [rect1, rect2, rect4, rect3, frame];
         h.elements = [rect1, rect2, rect4, rect3, frame];
 
 
         func(frame, rect2);
         func(frame, rect2);
@@ -199,7 +199,7 @@ describe("adding elements to frames", () => {
         expectEqualIds([rect1, rect4, rect3, rect2, frame]);
         expectEqualIds([rect1, rect4, rect3, rect2, frame]);
       });
       });
 
 
-      it("should add elements when there are other elements in between and the order is reversed", async () => {
+      it.skip("should add elements when there are other elements in between and the order is reversed", async () => {
         h.elements = [rect3, rect4, rect2, rect1, frame];
         h.elements = [rect3, rect4, rect2, rect1, frame];
 
 
         func(frame, rect2);
         func(frame, rect2);
@@ -234,7 +234,7 @@ describe("adding elements to frames", () => {
         expectEqualIds([rect1, rect2, rect3, frame, rect4]);
         expectEqualIds([rect1, rect2, rect3, frame, rect4]);
       });
       });
 
 
-      it("should add elements when there are other elements in between and the order is reversed", async () => {
+      it.skip("should add elements when there are other elements in between and the order is reversed", async () => {
         h.elements = [rect3, rect4, frame, rect2, rect1];
         h.elements = [rect3, rect4, frame, rect2, rect1];
 
 
         func(frame, rect2);
         func(frame, rect2);

+ 67 - 65
src/frame.ts

@@ -14,7 +14,7 @@ import {
   getBoundTextElement,
   getBoundTextElement,
   getContainerElement,
   getContainerElement,
 } from "./element/textElement";
 } from "./element/textElement";
-import { arrayToMap, findIndex } from "./utils";
+import { arrayToMap } from "./utils";
 import { mutateElement } from "./element/mutateElement";
 import { mutateElement } from "./element/mutateElement";
 import { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
 import { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
 import { getElementsWithinSelection, getSelectedElements } from "./scene";
 import { getElementsWithinSelection, getSelectedElements } from "./scene";
@@ -457,85 +457,87 @@ export const addElementsToFrame = (
   elementsToAdd: NonDeletedExcalidrawElement[],
   elementsToAdd: NonDeletedExcalidrawElement[],
   frame: ExcalidrawFrameElement,
   frame: ExcalidrawFrameElement,
 ) => {
 ) => {
-  const _elementsToAdd: ExcalidrawElement[] = [];
+  const currTargetFrameChildrenMap = new Map(
+    allElements.reduce(
+      (acc: [ExcalidrawElement["id"], ExcalidrawElement][], element) => {
+        if (element.frameId === frame.id) {
+          acc.push([element.id, element]);
+        }
+        return acc;
+      },
+      [],
+    ),
+  );
+
+  const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
 
 
-  for (const element of elementsToAdd) {
-    _elementsToAdd.push(element);
+  const finalElementsToAdd: ExcalidrawElement[] = [];
+
+  // - add bound text elements if not already in the array
+  // - filter out elements that are already in the frame
+  for (const element of omitGroupsContainingFrames(
+    allElements,
+    elementsToAdd,
+  )) {
+    if (!currTargetFrameChildrenMap.has(element.id)) {
+      finalElementsToAdd.push(element);
+    }
 
 
     const boundTextElement = getBoundTextElement(element);
     const boundTextElement = getBoundTextElement(element);
-    if (boundTextElement) {
-      _elementsToAdd.push(boundTextElement);
+    if (
+      boundTextElement &&
+      !suppliedElementsToAddSet.has(boundTextElement.id) &&
+      !currTargetFrameChildrenMap.has(boundTextElement.id)
+    ) {
+      finalElementsToAdd.push(boundTextElement);
     }
     }
   }
   }
 
 
-  const allElementsIndex = allElements.reduce(
-    (acc: Record<string, number>, element, index) => {
-      acc[element.id] = index;
-      return acc;
-    },
-    {},
-  );
+  const finalElementsToAddSet = new Set(finalElementsToAdd.map((el) => el.id));
 
 
-  const frameIndex = allElementsIndex[frame.id];
-  // need to be calculated before the mutation below occurs
-  const leftFrameBoundaryIndex = findIndex(
-    allElements,
-    (e) => e.frameId === frame.id,
-  );
+  const nextElements: ExcalidrawElement[] = [];
 
 
-  const existingFrameChildren = allElements.filter(
-    (element) => element.frameId === frame.id,
-  );
+  const processedElements = new Set<ExcalidrawElement["id"]>();
 
 
-  const addedFrameChildren_left: ExcalidrawElement[] = [];
-  const addedFrameChildren_right: ExcalidrawElement[] = [];
+  for (const element of allElements) {
+    if (processedElements.has(element.id)) {
+      continue;
+    }
 
 
-  for (const element of omitGroupsContainingFrames(
-    allElements,
-    _elementsToAdd,
-  )) {
-    if (element.frameId !== frame.id && !isFrameElement(element)) {
-      if (allElementsIndex[element.id] > frameIndex) {
-        addedFrameChildren_right.push(element);
-      } else {
-        addedFrameChildren_left.push(element);
-      }
+    processedElements.add(element.id);
 
 
-      mutateElement(
-        element,
-        {
-          frameId: frame.id,
-        },
-        false,
-      );
+    if (
+      finalElementsToAddSet.has(element.id) ||
+      (element.frameId && element.frameId === frame.id)
+    ) {
+      // will be added in bulk once we process target frame
+      continue;
     }
     }
-  }
 
 
-  const frameElement = allElements[frameIndex];
-  const nextFrameChildren = addedFrameChildren_left
-    .concat(existingFrameChildren)
-    .concat(addedFrameChildren_right);
-
-  const nextFrameChildrenMap = nextFrameChildren.reduce(
-    (acc: Record<string, boolean>, element) => {
-      acc[element.id] = true;
-      return acc;
-    },
-    {},
-  );
-
-  const nextOtherElements_left = allElements
-    .slice(0, leftFrameBoundaryIndex >= 0 ? leftFrameBoundaryIndex : frameIndex)
-    .filter((element) => !nextFrameChildrenMap[element.id]);
+    // target frame
+    if (element.id === frame.id) {
+      const currFrameChildren = getFrameElements(allElements, frame.id);
+      currFrameChildren.forEach((child) => {
+        processedElements.add(child.id);
+      });
+      // console.log(currFrameChildren, finalElementsToAdd, element);
+      nextElements.push(...currFrameChildren, ...finalElementsToAdd, element);
+      continue;
+    }
 
 
-  const nextOtherElement_right = allElements
-    .slice(frameIndex + 1)
-    .filter((element) => !nextFrameChildrenMap[element.id]);
+    // console.log("(2)", element.frameId);
+    nextElements.push(element);
+  }
 
 
-  const nextElements = nextOtherElements_left
-    .concat(nextFrameChildren)
-    .concat([frameElement])
-    .concat(nextOtherElement_right);
+  for (const element of finalElementsToAdd) {
+    mutateElement(
+      element,
+      {
+        frameId: frame.id,
+      },
+      false,
+    );
+  }
 
 
   return nextElements;
   return nextElements;
 };
 };

+ 1 - 0
src/keys.ts

@@ -21,6 +21,7 @@ export const CODES = {
   V: "KeyV",
   V: "KeyV",
   Z: "KeyZ",
   Z: "KeyZ",
   R: "KeyR",
   R: "KeyR",
+  S: "KeyS",
 } as const;
 } as const;
 
 
 export const KEYS = {
 export const KEYS = {

+ 1 - 0
src/locales/en.json

@@ -164,6 +164,7 @@
     "darkMode": "Dark mode",
     "darkMode": "Dark mode",
     "lightMode": "Light mode",
     "lightMode": "Light mode",
     "zenMode": "Zen mode",
     "zenMode": "Zen mode",
+    "objectsSnapMode": "Snap to objects",
     "exitZenMode": "Exit zen mode",
     "exitZenMode": "Exit zen mode",
     "cancel": "Cancel",
     "cancel": "Cancel",
     "clear": "Clear",
     "clear": "Clear",

+ 41 - 1
src/math.test.ts

@@ -1,4 +1,4 @@
-import { rotate } from "./math";
+import { rangeIntersection, rangesOverlap, rotate } from "./math";
 
 
 describe("rotate", () => {
 describe("rotate", () => {
   it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
   it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
@@ -13,3 +13,43 @@ describe("rotate", () => {
     expect(res2).toEqual([x1, x2]);
     expect(res2).toEqual([x1, x2]);
   });
   });
 });
 });
+
+describe("range overlap", () => {
+  it("should overlap when range a contains range b", () => {
+    expect(rangesOverlap([1, 4], [2, 3])).toBe(true);
+    expect(rangesOverlap([1, 4], [1, 4])).toBe(true);
+    expect(rangesOverlap([1, 4], [1, 3])).toBe(true);
+    expect(rangesOverlap([1, 4], [2, 4])).toBe(true);
+  });
+
+  it("should overlap when range b contains range a", () => {
+    expect(rangesOverlap([2, 3], [1, 4])).toBe(true);
+    expect(rangesOverlap([1, 3], [1, 4])).toBe(true);
+    expect(rangesOverlap([2, 4], [1, 4])).toBe(true);
+  });
+
+  it("should overlap when range a and b intersect", () => {
+    expect(rangesOverlap([1, 4], [2, 5])).toBe(true);
+  });
+});
+
+describe("range intersection", () => {
+  it("should intersect completely with itself", () => {
+    expect(rangeIntersection([1, 4], [1, 4])).toEqual([1, 4]);
+  });
+
+  it("should intersect irrespective of order", () => {
+    expect(rangeIntersection([1, 4], [2, 3])).toEqual([2, 3]);
+    expect(rangeIntersection([2, 3], [1, 4])).toEqual([2, 3]);
+    expect(rangeIntersection([1, 4], [3, 5])).toEqual([3, 4]);
+    expect(rangeIntersection([3, 5], [1, 4])).toEqual([3, 4]);
+  });
+
+  it("should intersect at the edge", () => {
+    expect(rangeIntersection([1, 4], [4, 5])).toEqual([4, 4]);
+  });
+
+  it("should not intersect", () => {
+    expect(rangeIntersection([1, 4], [5, 7])).toEqual(null);
+  });
+});

+ 33 - 0
src/math.ts

@@ -472,3 +472,36 @@ export const isRightAngle = (angle: number) => {
   // angle, which we can check with modulo after rounding.
   // angle, which we can check with modulo after rounding.
   return Math.round((angle / Math.PI) * 10000) % 5000 === 0;
   return Math.round((angle / Math.PI) * 10000) % 5000 === 0;
 };
 };
+
+// Given two ranges, return if the two ranges overlap with each other
+// e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5]
+export const rangesOverlap = (
+  [a0, a1]: [number, number],
+  [b0, b1]: [number, number],
+) => {
+  if (a0 <= b0) {
+    return a1 >= b0;
+  }
+
+  if (a0 >= b0) {
+    return b1 >= a0;
+  }
+
+  return false;
+};
+
+// Given two ranges,return ther intersection of the two ranges if any
+// e.g. the intersection of [1, 3] and [2, 4] is [2, 3]
+export const rangeIntersection = (
+  rangeA: [number, number],
+  rangeB: [number, number],
+): [number, number] | null => {
+  const rangeStart = Math.max(rangeA[0], rangeB[0]);
+  const rangeEnd = Math.min(rangeA[1], rangeB[1]);
+
+  if (rangeStart <= rangeEnd) {
+    return [rangeStart, rangeEnd];
+  }
+
+  return null;
+};

+ 16 - 0
src/packages/excalidraw/CHANGELOG.md

@@ -11,6 +11,22 @@ The change should be grouped under one of the below section and must contain PR
 Please add the latest change on the top under the correct section.
 Please add the latest change on the top under the correct section.
 -->
 -->
 
 
+## 0.16.1 (2023-09-21)
+
+## Excalidraw Library
+
+**_This section lists the updates made to the excalidraw library and will not affect the integration._**
+
+### Fixes
+
+- More eye-droper fixes [#7019](https://github.com/excalidraw/excalidraw/pull/7019)
+
+### Refactor
+
+- Move excalidraw-app outside src [#6987](https://github.com/excalidraw/excalidraw/pull/6987)
+
+---
+
 ## 0.16.0 (2023-09-19)
 ## 0.16.0 (2023-09-19)
 
 
 - Add a `subtype` attribute to `ExcalidrawElement` to allow self-contained extensions of any `ExcalidrawElement` type. Implement MathJax support on stem.excalidraw.com as a `math` subtype of `ExcalidrawTextElement`. Both standard Latex input and simplified AsciiMath input are supported. [#6037](https://github.com/excalidraw/excalidraw/pull/6037).
 - Add a `subtype` attribute to `ExcalidrawElement` to allow self-contained extensions of any `ExcalidrawElement` type. Implement MathJax support on stem.excalidraw.com as a `math` subtype of `ExcalidrawTextElement`. Both standard Latex input and simplified AsciiMath input are supported. [#6037](https://github.com/excalidraw/excalidraw/pull/6037).

+ 1 - 1
src/packages/excalidraw/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@excalidraw/excalidraw",
   "name": "@excalidraw/excalidraw",
-  "version": "0.16.0",
+  "version": "0.16.1",
   "main": "main.js",
   "main": "main.js",
   "types": "types/packages/excalidraw/index.d.ts",
   "types": "types/packages/excalidraw/index.d.ts",
   "files": [
   "files": [

+ 7 - 0
src/polyfill.ts

@@ -22,5 +22,12 @@ const polyfill = () => {
       configurable: true,
       configurable: true,
     });
     });
   }
   }
+
+  if (!Element.prototype.replaceChildren) {
+    Element.prototype.replaceChildren = function (...nodes) {
+      this.innerHTML = "";
+      this.append(...nodes);
+    };
+  }
 };
 };
 export default polyfill;
 export default polyfill;

+ 3 - 0
src/renderer/renderScene.ts

@@ -67,6 +67,7 @@ import {
   EXTERNAL_LINK_IMG,
   EXTERNAL_LINK_IMG,
   getLinkHandleFromCoords,
   getLinkHandleFromCoords,
 } from "../element/Hyperlink";
 } from "../element/Hyperlink";
+import { renderSnaps } from "./renderSnaps";
 import {
 import {
   isEmbeddableElement,
   isEmbeddableElement,
   isFrameElement,
   isFrameElement,
@@ -720,6 +721,8 @@ const _renderInteractiveScene = ({
     context.restore();
     context.restore();
   }
   }
 
 
+  renderSnaps(context, appState);
+
   // Reset zoom
   // Reset zoom
   context.restore();
   context.restore();
 
 

+ 189 - 0
src/renderer/renderSnaps.ts

@@ -0,0 +1,189 @@
+import { PointSnapLine, PointerSnapLine } from "../snapping";
+import { InteractiveCanvasAppState, Point } from "../types";
+
+const SNAP_COLOR_LIGHT = "#ff6b6b";
+const SNAP_COLOR_DARK = "#ff0000";
+const SNAP_WIDTH = 1;
+const SNAP_CROSS_SIZE = 2;
+
+export const renderSnaps = (
+  context: CanvasRenderingContext2D,
+  appState: InteractiveCanvasAppState,
+) => {
+  if (!appState.snapLines.length) {
+    return;
+  }
+
+  // in dark mode, we need to adjust the color to account for color inversion.
+  // Don't change if zen mode, because we draw only crosses, we want the
+  // colors to be more visible
+  const snapColor =
+    appState.theme === "light" || appState.zenModeEnabled
+      ? SNAP_COLOR_LIGHT
+      : SNAP_COLOR_DARK;
+  // in zen mode make the cross more visible since we don't draw the lines
+  const snapWidth =
+    (appState.zenModeEnabled ? SNAP_WIDTH * 1.5 : SNAP_WIDTH) /
+    appState.zoom.value;
+
+  context.save();
+  context.translate(appState.scrollX, appState.scrollY);
+
+  for (const snapLine of appState.snapLines) {
+    if (snapLine.type === "pointer") {
+      context.lineWidth = snapWidth;
+      context.strokeStyle = snapColor;
+
+      drawPointerSnapLine(snapLine, context, appState);
+    } else if (snapLine.type === "gap") {
+      context.lineWidth = snapWidth;
+      context.strokeStyle = snapColor;
+
+      drawGapLine(
+        snapLine.points[0],
+        snapLine.points[1],
+        snapLine.direction,
+        appState,
+        context,
+      );
+    } else if (snapLine.type === "points") {
+      context.lineWidth = snapWidth;
+      context.strokeStyle = snapColor;
+      drawPointsSnapLine(snapLine, context, appState);
+    }
+  }
+
+  context.restore();
+};
+
+const drawPointsSnapLine = (
+  pointSnapLine: PointSnapLine,
+  context: CanvasRenderingContext2D,
+  appState: InteractiveCanvasAppState,
+) => {
+  if (!appState.zenModeEnabled) {
+    const firstPoint = pointSnapLine.points[0];
+    const lastPoint = pointSnapLine.points[pointSnapLine.points.length - 1];
+
+    drawLine(firstPoint, lastPoint, context);
+  }
+
+  for (const point of pointSnapLine.points) {
+    drawCross(point, appState, context);
+  }
+};
+
+const drawPointerSnapLine = (
+  pointerSnapLine: PointerSnapLine,
+  context: CanvasRenderingContext2D,
+  appState: InteractiveCanvasAppState,
+) => {
+  drawCross(pointerSnapLine.points[0], appState, context);
+  if (!appState.zenModeEnabled) {
+    drawLine(pointerSnapLine.points[0], pointerSnapLine.points[1], context);
+  }
+};
+
+const drawCross = (
+  [x, y]: Point,
+  appState: InteractiveCanvasAppState,
+  context: CanvasRenderingContext2D,
+) => {
+  context.save();
+  const size =
+    (appState.zenModeEnabled ? SNAP_CROSS_SIZE * 1.5 : SNAP_CROSS_SIZE) /
+    appState.zoom.value;
+  context.beginPath();
+
+  context.moveTo(x - size, y - size);
+  context.lineTo(x + size, y + size);
+
+  context.moveTo(x + size, y - size);
+  context.lineTo(x - size, y + size);
+
+  context.stroke();
+  context.restore();
+};
+
+const drawLine = (
+  from: Point,
+  to: Point,
+  context: CanvasRenderingContext2D,
+) => {
+  context.beginPath();
+  context.lineTo(...from);
+  context.lineTo(...to);
+  context.stroke();
+};
+
+const drawGapLine = (
+  from: Point,
+  to: Point,
+  direction: "horizontal" | "vertical",
+  appState: InteractiveCanvasAppState,
+  context: CanvasRenderingContext2D,
+) => {
+  // a horizontal gap snap line
+  // |–––––––||–––––––|
+  // ^    ^   ^       ^
+  // \    \   \       \
+  // (1)  (2) (3)     (4)
+
+  const FULL = 8 / appState.zoom.value;
+  const HALF = FULL / 2;
+  const QUARTER = FULL / 4;
+
+  if (direction === "horizontal") {
+    const halfPoint = [(from[0] + to[0]) / 2, from[1]];
+    // (1)
+    if (!appState.zenModeEnabled) {
+      drawLine([from[0], from[1] - FULL], [from[0], from[1] + FULL], context);
+    }
+
+    // (3)
+    drawLine(
+      [halfPoint[0] - QUARTER, halfPoint[1] - HALF],
+      [halfPoint[0] - QUARTER, halfPoint[1] + HALF],
+      context,
+    );
+    drawLine(
+      [halfPoint[0] + QUARTER, halfPoint[1] - HALF],
+      [halfPoint[0] + QUARTER, halfPoint[1] + HALF],
+      context,
+    );
+
+    if (!appState.zenModeEnabled) {
+      // (4)
+      drawLine([to[0], to[1] - FULL], [to[0], to[1] + FULL], context);
+
+      // (2)
+      drawLine(from, to, context);
+    }
+  } else {
+    const halfPoint = [from[0], (from[1] + to[1]) / 2];
+    // (1)
+    if (!appState.zenModeEnabled) {
+      drawLine([from[0] - FULL, from[1]], [from[0] + FULL, from[1]], context);
+    }
+
+    // (3)
+    drawLine(
+      [halfPoint[0] - HALF, halfPoint[1] - QUARTER],
+      [halfPoint[0] + HALF, halfPoint[1] - QUARTER],
+      context,
+    );
+    drawLine(
+      [halfPoint[0] - HALF, halfPoint[1] + QUARTER],
+      [halfPoint[0] + HALF, halfPoint[1] + QUARTER],
+      context,
+    );
+
+    if (!appState.zenModeEnabled) {
+      // (4)
+      drawLine([to[0] - FULL, to[1]], [to[0] + FULL, to[1]], context);
+
+      // (2)
+      drawLine(from, to, context);
+    }
+  }
+};

+ 21 - 0
src/scene/selection.ts

@@ -11,6 +11,7 @@ import {
   getFrameElements,
   getFrameElements,
 } from "../frame";
 } from "../frame";
 import { isShallowEqual } from "../utils";
 import { isShallowEqual } from "../utils";
+import { isElementInViewport } from "../element/sizeHelpers";
 
 
 /**
 /**
  * Frames and their containing elements are not to be selected at the same time.
  * Frames and their containing elements are not to be selected at the same time.
@@ -89,6 +90,26 @@ export const getElementsWithinSelection = (
   return elementsInSelection;
   return elementsInSelection;
 };
 };
 
 
+export const getVisibleAndNonSelectedElements = (
+  elements: readonly NonDeletedExcalidrawElement[],
+  selectedElements: readonly NonDeletedExcalidrawElement[],
+  appState: AppState,
+) => {
+  const selectedElementsSet = new Set(
+    selectedElements.map((element) => element.id),
+  );
+  return elements.filter((element) => {
+    const isVisible = isElementInViewport(
+      element,
+      appState.width,
+      appState.height,
+      appState,
+    );
+
+    return !selectedElementsSet.has(element.id) && isVisible;
+  });
+};
+
 // FIXME move this into the editor instance to keep utility methods stateless
 // FIXME move this into the editor instance to keep utility methods stateless
 export const isSomeElementSelected = (function () {
 export const isSomeElementSelected = (function () {
   let lastElements: readonly NonDeletedExcalidrawElement[] | null = null;
   let lastElements: readonly NonDeletedExcalidrawElement[] | null = null;

+ 1361 - 0
src/snapping.ts

@@ -0,0 +1,1361 @@
+import {
+  Bounds,
+  getCommonBounds,
+  getDraggedElementsBounds,
+  getElementAbsoluteCoords,
+} from "./element/bounds";
+import { MaybeTransformHandleType } from "./element/transformHandles";
+import { isBoundToContainer, isFrameElement } from "./element/typeChecks";
+import {
+  ExcalidrawElement,
+  NonDeletedExcalidrawElement,
+} from "./element/types";
+import { getMaximumGroups } from "./groups";
+import { KEYS } from "./keys";
+import { rangeIntersection, rangesOverlap, rotatePoint } from "./math";
+import { getVisibleAndNonSelectedElements } from "./scene/selection";
+import { AppState, KeyboardModifiersObject, Point } from "./types";
+
+const SNAP_DISTANCE = 8;
+
+// do not comput more gaps per axis than this limit
+// TODO increase or remove once we optimize
+const VISIBLE_GAPS_LIMIT_PER_AXIS = 99999;
+
+// snap distance with zoom value taken into consideration
+export const getSnapDistance = (zoomValue: number) => {
+  return SNAP_DISTANCE / zoomValue;
+};
+
+type Vector2D = {
+  x: number;
+  y: number;
+};
+
+type PointPair = [Point, Point];
+
+export type PointSnap = {
+  type: "point";
+  points: PointPair;
+  offset: number;
+};
+
+export type Gap = {
+  //  start side ↓     length
+  // ┌───────────┐◄───────────────►
+  // │           │-----------------┌───────────┐
+  // │  start    │       ↑         │           │
+  // │  element  │    overlap      │  end      │
+  // │           │       ↓         │  element  │
+  // └───────────┘-----------------│           │
+  //                               └───────────┘
+  //                               ↑ end side
+  startBounds: Bounds;
+  endBounds: Bounds;
+  startSide: [Point, Point];
+  endSide: [Point, Point];
+  overlap: [number, number];
+  length: number;
+};
+
+export type GapSnap = {
+  type: "gap";
+  direction:
+    | "center_horizontal"
+    | "center_vertical"
+    | "side_left"
+    | "side_right"
+    | "side_top"
+    | "side_bottom";
+  gap: Gap;
+  offset: number;
+};
+
+export type GapSnaps = GapSnap[];
+
+export type Snap = GapSnap | PointSnap;
+export type Snaps = Snap[];
+
+export type PointSnapLine = {
+  type: "points";
+  points: Point[];
+};
+
+export type PointerSnapLine = {
+  type: "pointer";
+  points: PointPair;
+  direction: "horizontal" | "vertical";
+};
+
+export type GapSnapLine = {
+  type: "gap";
+  direction: "horizontal" | "vertical";
+  points: PointPair;
+};
+
+export type SnapLine = PointSnapLine | GapSnapLine | PointerSnapLine;
+
+// -----------------------------------------------------------------------------
+
+export class SnapCache {
+  private static referenceSnapPoints: Point[] | null = null;
+
+  private static visibleGaps: {
+    verticalGaps: Gap[];
+    horizontalGaps: Gap[];
+  } | null = null;
+
+  public static setReferenceSnapPoints = (snapPoints: Point[] | null) => {
+    SnapCache.referenceSnapPoints = snapPoints;
+  };
+
+  public static getReferenceSnapPoints = () => {
+    return SnapCache.referenceSnapPoints;
+  };
+
+  public static setVisibleGaps = (
+    gaps: {
+      verticalGaps: Gap[];
+      horizontalGaps: Gap[];
+    } | null,
+  ) => {
+    SnapCache.visibleGaps = gaps;
+  };
+
+  public static getVisibleGaps = () => {
+    return SnapCache.visibleGaps;
+  };
+
+  public static destroy = () => {
+    SnapCache.referenceSnapPoints = null;
+    SnapCache.visibleGaps = null;
+  };
+}
+
+// -----------------------------------------------------------------------------
+
+export const isSnappingEnabled = ({
+  event,
+  appState,
+  selectedElements,
+}: {
+  appState: AppState;
+  event: KeyboardModifiersObject;
+  selectedElements: NonDeletedExcalidrawElement[];
+}) => {
+  if (event) {
+    return (
+      (appState.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
+      (!appState.objectsSnapModeEnabled &&
+        event[KEYS.CTRL_OR_CMD] &&
+        appState.gridSize === null)
+    );
+  }
+
+  // do not suggest snaps for an arrow to give way to binding
+  if (selectedElements.length === 1 && selectedElements[0].type === "arrow") {
+    return false;
+  }
+  return appState.objectsSnapModeEnabled;
+};
+
+export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => {
+  return Math.abs(a - b) <= precision;
+};
+
+export const getElementsCorners = (
+  elements: ExcalidrawElement[],
+  {
+    omitCenter,
+    boundingBoxCorners,
+    dragOffset,
+  }: {
+    omitCenter?: boolean;
+    boundingBoxCorners?: boolean;
+    dragOffset?: Vector2D;
+  } = {
+    omitCenter: false,
+    boundingBoxCorners: false,
+  },
+): Point[] => {
+  let result: Point[] = [];
+
+  if (elements.length === 1) {
+    const element = elements[0];
+
+    let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
+
+    if (dragOffset) {
+      x1 += dragOffset.x;
+      x2 += dragOffset.x;
+      cx += dragOffset.x;
+
+      y1 += dragOffset.y;
+      y2 += dragOffset.y;
+      cy += dragOffset.y;
+    }
+
+    const halfWidth = (x2 - x1) / 2;
+    const halfHeight = (y2 - y1) / 2;
+
+    if (
+      (element.type === "diamond" || element.type === "ellipse") &&
+      !boundingBoxCorners
+    ) {
+      const leftMid = rotatePoint(
+        [x1, y1 + halfHeight],
+        [cx, cy],
+        element.angle,
+      );
+      const topMid = rotatePoint([x1 + halfWidth, y1], [cx, cy], element.angle);
+      const rightMid = rotatePoint(
+        [x2, y1 + halfHeight],
+        [cx, cy],
+        element.angle,
+      );
+      const bottomMid = rotatePoint(
+        [x1 + halfWidth, y2],
+        [cx, cy],
+        element.angle,
+      );
+      const center: Point = [cx, cy];
+
+      result = omitCenter
+        ? [leftMid, topMid, rightMid, bottomMid]
+        : [leftMid, topMid, rightMid, bottomMid, center];
+    } else {
+      const topLeft = rotatePoint([x1, y1], [cx, cy], element.angle);
+      const topRight = rotatePoint([x2, y1], [cx, cy], element.angle);
+      const bottomLeft = rotatePoint([x1, y2], [cx, cy], element.angle);
+      const bottomRight = rotatePoint([x2, y2], [cx, cy], element.angle);
+      const center: Point = [cx, cy];
+
+      result = omitCenter
+        ? [topLeft, topRight, bottomLeft, bottomRight]
+        : [topLeft, topRight, bottomLeft, bottomRight, center];
+    }
+  } else if (elements.length > 1) {
+    const [minX, minY, maxX, maxY] = getDraggedElementsBounds(
+      elements,
+      dragOffset ?? { x: 0, y: 0 },
+    );
+    const width = maxX - minX;
+    const height = maxY - minY;
+
+    const topLeft: Point = [minX, minY];
+    const topRight: Point = [maxX, minY];
+    const bottomLeft: Point = [minX, maxY];
+    const bottomRight: Point = [maxX, maxY];
+    const center: Point = [minX + width / 2, minY + height / 2];
+
+    result = omitCenter
+      ? [topLeft, topRight, bottomLeft, bottomRight]
+      : [topLeft, topRight, bottomLeft, bottomRight, center];
+  }
+
+  return result.map((point) => [round(point[0]), round(point[1])] as Point);
+};
+
+const getReferenceElements = (
+  elements: readonly NonDeletedExcalidrawElement[],
+  selectedElements: NonDeletedExcalidrawElement[],
+  appState: AppState,
+) => {
+  const selectedFrames = selectedElements
+    .filter((element) => isFrameElement(element))
+    .map((frame) => frame.id);
+
+  return getVisibleAndNonSelectedElements(
+    elements,
+    selectedElements,
+    appState,
+  ).filter(
+    (element) => !(element.frameId && selectedFrames.includes(element.frameId)),
+  );
+};
+
+export const getVisibleGaps = (
+  elements: readonly NonDeletedExcalidrawElement[],
+  selectedElements: ExcalidrawElement[],
+  appState: AppState,
+) => {
+  const referenceElements: ExcalidrawElement[] = getReferenceElements(
+    elements,
+    selectedElements,
+    appState,
+  );
+
+  const referenceBounds = getMaximumGroups(referenceElements)
+    .filter(
+      (elementsGroup) =>
+        !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])),
+    )
+    .map(
+      (group) =>
+        getCommonBounds(group).map((bound) =>
+          round(bound),
+        ) as unknown as Bounds,
+    );
+
+  const horizontallySorted = referenceBounds.sort((a, b) => a[0] - b[0]);
+
+  const horizontalGaps: Gap[] = [];
+
+  let c = 0;
+
+  horizontal: for (let i = 0; i < horizontallySorted.length; i++) {
+    const startBounds = horizontallySorted[i];
+
+    for (let j = i + 1; j < horizontallySorted.length; j++) {
+      if (++c > VISIBLE_GAPS_LIMIT_PER_AXIS) {
+        break horizontal;
+      }
+
+      const endBounds = horizontallySorted[j];
+
+      const [, startMinY, startMaxX, startMaxY] = startBounds;
+      const [endMinX, endMinY, , endMaxY] = endBounds;
+
+      if (
+        startMaxX < endMinX &&
+        rangesOverlap([startMinY, startMaxY], [endMinY, endMaxY])
+      ) {
+        horizontalGaps.push({
+          startBounds,
+          endBounds,
+          startSide: [
+            [startMaxX, startMinY],
+            [startMaxX, startMaxY],
+          ],
+          endSide: [
+            [endMinX, endMinY],
+            [endMinX, endMaxY],
+          ],
+          length: endMinX - startMaxX,
+          overlap: rangeIntersection(
+            [startMinY, startMaxY],
+            [endMinY, endMaxY],
+          )!,
+        });
+      }
+    }
+  }
+
+  const verticallySorted = referenceBounds.sort((a, b) => a[1] - b[1]);
+
+  const verticalGaps: Gap[] = [];
+
+  c = 0;
+
+  vertical: for (let i = 0; i < verticallySorted.length; i++) {
+    const startBounds = verticallySorted[i];
+
+    for (let j = i + 1; j < verticallySorted.length; j++) {
+      if (++c > VISIBLE_GAPS_LIMIT_PER_AXIS) {
+        break vertical;
+      }
+      const endBounds = verticallySorted[j];
+
+      const [startMinX, , startMaxX, startMaxY] = startBounds;
+      const [endMinX, endMinY, endMaxX] = endBounds;
+
+      if (
+        startMaxY < endMinY &&
+        rangesOverlap([startMinX, startMaxX], [endMinX, endMaxX])
+      ) {
+        verticalGaps.push({
+          startBounds,
+          endBounds,
+          startSide: [
+            [startMinX, startMaxY],
+            [startMaxX, startMaxY],
+          ],
+          endSide: [
+            [endMinX, endMinY],
+            [endMaxX, endMinY],
+          ],
+          length: endMinY - startMaxY,
+          overlap: rangeIntersection(
+            [startMinX, startMaxX],
+            [endMinX, endMaxX],
+          )!,
+        });
+      }
+    }
+  }
+
+  return {
+    horizontalGaps,
+    verticalGaps,
+  };
+};
+
+const getGapSnaps = (
+  selectedElements: ExcalidrawElement[],
+  dragOffset: Vector2D,
+  appState: AppState,
+  event: KeyboardModifiersObject,
+  nearestSnapsX: Snaps,
+  nearestSnapsY: Snaps,
+  minOffset: Vector2D,
+) => {
+  if (!isSnappingEnabled({ appState, event, selectedElements })) {
+    return [];
+  }
+
+  if (selectedElements.length === 0) {
+    return [];
+  }
+
+  const visibleGaps = SnapCache.getVisibleGaps();
+
+  if (visibleGaps) {
+    const { horizontalGaps, verticalGaps } = visibleGaps;
+
+    const [minX, minY, maxX, maxY] = getDraggedElementsBounds(
+      selectedElements,
+      dragOffset,
+    ).map((bound) => round(bound));
+    const centerX = (minX + maxX) / 2;
+    const centerY = (minY + maxY) / 2;
+
+    for (const gap of horizontalGaps) {
+      if (!rangesOverlap([minY, maxY], gap.overlap)) {
+        continue;
+      }
+
+      // center gap
+      const gapMidX = gap.startSide[0][0] + gap.length / 2;
+      const centerOffset = round(gapMidX - centerX);
+      const gapIsLargerThanSelection = gap.length > maxX - minX;
+
+      if (gapIsLargerThanSelection && Math.abs(centerOffset) <= minOffset.x) {
+        if (Math.abs(centerOffset) < minOffset.x) {
+          nearestSnapsX.length = 0;
+        }
+        minOffset.x = Math.abs(centerOffset);
+
+        const snap: GapSnap = {
+          type: "gap",
+          direction: "center_horizontal",
+          gap,
+          offset: centerOffset,
+        };
+
+        nearestSnapsX.push(snap);
+        continue;
+      }
+
+      // side gap, from the right
+      const [, , endMaxX] = gap.endBounds;
+      const distanceToEndElementX = minX - endMaxX;
+      const sideOffsetRight = round(gap.length - distanceToEndElementX);
+
+      if (Math.abs(sideOffsetRight) <= minOffset.x) {
+        if (Math.abs(sideOffsetRight) < minOffset.x) {
+          nearestSnapsX.length = 0;
+        }
+        minOffset.x = Math.abs(sideOffsetRight);
+
+        const snap: GapSnap = {
+          type: "gap",
+          direction: "side_right",
+          gap,
+          offset: sideOffsetRight,
+        };
+        nearestSnapsX.push(snap);
+        continue;
+      }
+
+      // side gap, from the left
+      const [startMinX, , ,] = gap.startBounds;
+      const distanceToStartElementX = startMinX - maxX;
+      const sideOffsetLeft = round(distanceToStartElementX - gap.length);
+
+      if (Math.abs(sideOffsetLeft) <= minOffset.x) {
+        if (Math.abs(sideOffsetLeft) < minOffset.x) {
+          nearestSnapsX.length = 0;
+        }
+        minOffset.x = Math.abs(sideOffsetLeft);
+
+        const snap: GapSnap = {
+          type: "gap",
+          direction: "side_left",
+          gap,
+          offset: sideOffsetLeft,
+        };
+        nearestSnapsX.push(snap);
+        continue;
+      }
+    }
+    for (const gap of verticalGaps) {
+      if (!rangesOverlap([minX, maxX], gap.overlap)) {
+        continue;
+      }
+
+      // center gap
+      const gapMidY = gap.startSide[0][1] + gap.length / 2;
+      const centerOffset = round(gapMidY - centerY);
+      const gapIsLargerThanSelection = gap.length > maxY - minY;
+
+      if (gapIsLargerThanSelection && Math.abs(centerOffset) <= minOffset.y) {
+        if (Math.abs(centerOffset) < minOffset.y) {
+          nearestSnapsY.length = 0;
+        }
+        minOffset.y = Math.abs(centerOffset);
+
+        const snap: GapSnap = {
+          type: "gap",
+          direction: "center_vertical",
+          gap,
+          offset: centerOffset,
+        };
+
+        nearestSnapsY.push(snap);
+        continue;
+      }
+
+      // side gap, from the top
+      const [, startMinY, ,] = gap.startBounds;
+      const distanceToStartElementY = startMinY - maxY;
+      const sideOffsetTop = round(distanceToStartElementY - gap.length);
+
+      if (Math.abs(sideOffsetTop) <= minOffset.y) {
+        if (Math.abs(sideOffsetTop) < minOffset.y) {
+          nearestSnapsY.length = 0;
+        }
+        minOffset.y = Math.abs(sideOffsetTop);
+
+        const snap: GapSnap = {
+          type: "gap",
+          direction: "side_top",
+          gap,
+          offset: sideOffsetTop,
+        };
+        nearestSnapsY.push(snap);
+        continue;
+      }
+
+      // side gap, from the bottom
+      const [, , , endMaxY] = gap.endBounds;
+      const distanceToEndElementY = round(minY - endMaxY);
+      const sideOffsetBottom = gap.length - distanceToEndElementY;
+
+      if (Math.abs(sideOffsetBottom) <= minOffset.y) {
+        if (Math.abs(sideOffsetBottom) < minOffset.y) {
+          nearestSnapsY.length = 0;
+        }
+        minOffset.y = Math.abs(sideOffsetBottom);
+
+        const snap: GapSnap = {
+          type: "gap",
+          direction: "side_bottom",
+          gap,
+          offset: sideOffsetBottom,
+        };
+        nearestSnapsY.push(snap);
+        continue;
+      }
+    }
+  }
+};
+
+export const getReferenceSnapPoints = (
+  elements: readonly NonDeletedExcalidrawElement[],
+  selectedElements: ExcalidrawElement[],
+  appState: AppState,
+) => {
+  const referenceElements = getReferenceElements(
+    elements,
+    selectedElements,
+    appState,
+  );
+
+  return getMaximumGroups(referenceElements)
+    .filter(
+      (elementsGroup) =>
+        !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])),
+    )
+    .flatMap((elementGroup) => getElementsCorners(elementGroup));
+};
+
+const getPointSnaps = (
+  selectedElements: ExcalidrawElement[],
+  selectionSnapPoints: Point[],
+  appState: AppState,
+  event: KeyboardModifiersObject,
+  nearestSnapsX: Snaps,
+  nearestSnapsY: Snaps,
+  minOffset: Vector2D,
+) => {
+  if (
+    !isSnappingEnabled({ appState, event, selectedElements }) ||
+    (selectedElements.length === 0 && selectionSnapPoints.length === 0)
+  ) {
+    return [];
+  }
+
+  const referenceSnapPoints = SnapCache.getReferenceSnapPoints();
+
+  if (referenceSnapPoints) {
+    for (const thisSnapPoint of selectionSnapPoints) {
+      for (const otherSnapPoint of referenceSnapPoints) {
+        const offsetX = otherSnapPoint[0] - thisSnapPoint[0];
+        const offsetY = otherSnapPoint[1] - thisSnapPoint[1];
+
+        if (Math.abs(offsetX) <= minOffset.x) {
+          if (Math.abs(offsetX) < minOffset.x) {
+            nearestSnapsX.length = 0;
+          }
+
+          nearestSnapsX.push({
+            type: "point",
+            points: [thisSnapPoint, otherSnapPoint],
+            offset: offsetX,
+          });
+
+          minOffset.x = Math.abs(offsetX);
+        }
+
+        if (Math.abs(offsetY) <= minOffset.y) {
+          if (Math.abs(offsetY) < minOffset.y) {
+            nearestSnapsY.length = 0;
+          }
+
+          nearestSnapsY.push({
+            type: "point",
+            points: [thisSnapPoint, otherSnapPoint],
+            offset: offsetY,
+          });
+
+          minOffset.y = Math.abs(offsetY);
+        }
+      }
+    }
+  }
+};
+
+export const snapDraggedElements = (
+  selectedElements: ExcalidrawElement[],
+  dragOffset: Vector2D,
+  appState: AppState,
+  event: KeyboardModifiersObject,
+) => {
+  if (
+    !isSnappingEnabled({ appState, event, selectedElements }) ||
+    selectedElements.length === 0
+  ) {
+    return {
+      snapOffset: {
+        x: 0,
+        y: 0,
+      },
+      snapLines: [],
+    };
+  }
+
+  dragOffset.x = round(dragOffset.x);
+  dragOffset.y = round(dragOffset.y);
+  const nearestSnapsX: Snaps = [];
+  const nearestSnapsY: Snaps = [];
+  const snapDistance = getSnapDistance(appState.zoom.value);
+  const minOffset = {
+    x: snapDistance,
+    y: snapDistance,
+  };
+
+  const selectionPoints = getElementsCorners(selectedElements, {
+    dragOffset,
+  });
+
+  // get the nearest horizontal and vertical point and gap snaps
+  getPointSnaps(
+    selectedElements,
+    selectionPoints,
+    appState,
+    event,
+    nearestSnapsX,
+    nearestSnapsY,
+    minOffset,
+  );
+
+  getGapSnaps(
+    selectedElements,
+    dragOffset,
+    appState,
+    event,
+    nearestSnapsX,
+    nearestSnapsY,
+    minOffset,
+  );
+
+  // using the nearest snaps to figure out how
+  // much the elements need to be offset to be snapped
+  // to some reference elements
+  const snapOffset = {
+    x: nearestSnapsX[0]?.offset ?? 0,
+    y: nearestSnapsY[0]?.offset ?? 0,
+  };
+
+  // once the elements are snapped
+  // and moved to the snapped position
+  // we want to use the element's snapped position
+  // to update nearest snaps so that we can create
+  // point and gap snap lines correctly without any shifting
+
+  minOffset.x = 0;
+  minOffset.y = 0;
+  nearestSnapsX.length = 0;
+  nearestSnapsY.length = 0;
+  const newDragOffset = {
+    x: round(dragOffset.x + snapOffset.x),
+    y: round(dragOffset.y + snapOffset.y),
+  };
+
+  getPointSnaps(
+    selectedElements,
+    getElementsCorners(selectedElements, {
+      dragOffset: newDragOffset,
+    }),
+    appState,
+    event,
+    nearestSnapsX,
+    nearestSnapsY,
+    minOffset,
+  );
+
+  getGapSnaps(
+    selectedElements,
+    newDragOffset,
+    appState,
+    event,
+    nearestSnapsX,
+    nearestSnapsY,
+    minOffset,
+  );
+
+  const pointSnapLines = createPointSnapLines(nearestSnapsX, nearestSnapsY);
+
+  const gapSnapLines = createGapSnapLines(
+    selectedElements,
+    newDragOffset,
+    [...nearestSnapsX, ...nearestSnapsY].filter(
+      (snap) => snap.type === "gap",
+    ) as GapSnap[],
+  );
+
+  return {
+    snapOffset,
+    snapLines: [...pointSnapLines, ...gapSnapLines],
+  };
+};
+
+const round = (x: number) => {
+  const decimalPlaces = 6;
+  return Math.round(x * 10 ** decimalPlaces) / 10 ** decimalPlaces;
+};
+
+const dedupePoints = (points: Point[]): Point[] => {
+  const map = new Map<string, Point>();
+
+  for (const point of points) {
+    const key = point.join(",");
+
+    if (!map.has(key)) {
+      map.set(key, point);
+    }
+  }
+
+  return Array.from(map.values());
+};
+
+const createPointSnapLines = (
+  nearestSnapsX: Snaps,
+  nearestSnapsY: Snaps,
+): PointSnapLine[] => {
+  const snapsX = {} as { [key: string]: Point[] };
+  const snapsY = {} as { [key: string]: Point[] };
+
+  if (nearestSnapsX.length > 0) {
+    for (const snap of nearestSnapsX) {
+      if (snap.type === "point") {
+        // key = thisPoint.x
+        const key = round(snap.points[0][0]);
+        if (!snapsX[key]) {
+          snapsX[key] = [];
+        }
+        snapsX[key].push(
+          ...snap.points.map(
+            (point) => [round(point[0]), round(point[1])] as Point,
+          ),
+        );
+      }
+    }
+  }
+
+  if (nearestSnapsY.length > 0) {
+    for (const snap of nearestSnapsY) {
+      if (snap.type === "point") {
+        // key = thisPoint.y
+        const key = round(snap.points[0][1]);
+        if (!snapsY[key]) {
+          snapsY[key] = [];
+        }
+        snapsY[key].push(
+          ...snap.points.map(
+            (point) => [round(point[0]), round(point[1])] as Point,
+          ),
+        );
+      }
+    }
+  }
+
+  return Object.entries(snapsX)
+    .map(([key, points]) => {
+      return {
+        type: "points",
+        points: dedupePoints(
+          points
+            .map((point) => {
+              return [Number(key), point[1]] as Point;
+            })
+            .sort((a, b) => a[1] - b[1]),
+        ),
+      } as PointSnapLine;
+    })
+    .concat(
+      Object.entries(snapsY).map(([key, points]) => {
+        return {
+          type: "points",
+          points: dedupePoints(
+            points
+              .map((point) => {
+                return [point[0], Number(key)] as Point;
+              })
+              .sort((a, b) => a[0] - b[0]),
+          ),
+        } as PointSnapLine;
+      }),
+    );
+};
+
+const dedupeGapSnapLines = (gapSnapLines: GapSnapLine[]) => {
+  const map = new Map<string, GapSnapLine>();
+
+  for (const gapSnapLine of gapSnapLines) {
+    const key = gapSnapLine.points
+      .flat()
+      .map((point) => [round(point)])
+      .join(",");
+
+    if (!map.has(key)) {
+      map.set(key, gapSnapLine);
+    }
+  }
+
+  return Array.from(map.values());
+};
+
+const createGapSnapLines = (
+  selectedElements: ExcalidrawElement[],
+  dragOffset: Vector2D,
+  gapSnaps: GapSnap[],
+): GapSnapLine[] => {
+  const [minX, minY, maxX, maxY] = getDraggedElementsBounds(
+    selectedElements,
+    dragOffset,
+  );
+
+  const gapSnapLines: GapSnapLine[] = [];
+
+  for (const gapSnap of gapSnaps) {
+    const [startMinX, startMinY, startMaxX, startMaxY] =
+      gapSnap.gap.startBounds;
+    const [endMinX, endMinY, endMaxX, endMaxY] = gapSnap.gap.endBounds;
+
+    const verticalIntersection = rangeIntersection(
+      [minY, maxY],
+      gapSnap.gap.overlap,
+    );
+
+    const horizontalGapIntersection = rangeIntersection(
+      [minX, maxX],
+      gapSnap.gap.overlap,
+    );
+
+    switch (gapSnap.direction) {
+      case "center_horizontal": {
+        if (verticalIntersection) {
+          const gapLineY =
+            (verticalIntersection[0] + verticalIntersection[1]) / 2;
+
+          gapSnapLines.push(
+            {
+              type: "gap",
+              direction: "horizontal",
+              points: [
+                [gapSnap.gap.startSide[0][0], gapLineY],
+                [minX, gapLineY],
+              ],
+            },
+            {
+              type: "gap",
+              direction: "horizontal",
+              points: [
+                [maxX, gapLineY],
+                [gapSnap.gap.endSide[0][0], gapLineY],
+              ],
+            },
+          );
+        }
+        break;
+      }
+      case "center_vertical": {
+        if (horizontalGapIntersection) {
+          const gapLineX =
+            (horizontalGapIntersection[0] + horizontalGapIntersection[1]) / 2;
+
+          gapSnapLines.push(
+            {
+              type: "gap",
+              direction: "vertical",
+              points: [
+                [gapLineX, gapSnap.gap.startSide[0][1]],
+                [gapLineX, minY],
+              ],
+            },
+            {
+              type: "gap",
+              direction: "vertical",
+              points: [
+                [gapLineX, maxY],
+                [gapLineX, gapSnap.gap.endSide[0][1]],
+              ],
+            },
+          );
+        }
+        break;
+      }
+      case "side_right": {
+        if (verticalIntersection) {
+          const gapLineY =
+            (verticalIntersection[0] + verticalIntersection[1]) / 2;
+
+          gapSnapLines.push(
+            {
+              type: "gap",
+              direction: "horizontal",
+              points: [
+                [startMaxX, gapLineY],
+                [endMinX, gapLineY],
+              ],
+            },
+            {
+              type: "gap",
+              direction: "horizontal",
+              points: [
+                [endMaxX, gapLineY],
+                [minX, gapLineY],
+              ],
+            },
+          );
+        }
+        break;
+      }
+      case "side_left": {
+        if (verticalIntersection) {
+          const gapLineY =
+            (verticalIntersection[0] + verticalIntersection[1]) / 2;
+
+          gapSnapLines.push(
+            {
+              type: "gap",
+              direction: "horizontal",
+              points: [
+                [maxX, gapLineY],
+                [startMinX, gapLineY],
+              ],
+            },
+            {
+              type: "gap",
+              direction: "horizontal",
+              points: [
+                [startMaxX, gapLineY],
+                [endMinX, gapLineY],
+              ],
+            },
+          );
+        }
+        break;
+      }
+      case "side_top": {
+        if (horizontalGapIntersection) {
+          const gapLineX =
+            (horizontalGapIntersection[0] + horizontalGapIntersection[1]) / 2;
+
+          gapSnapLines.push(
+            {
+              type: "gap",
+              direction: "vertical",
+              points: [
+                [gapLineX, maxY],
+                [gapLineX, startMinY],
+              ],
+            },
+            {
+              type: "gap",
+              direction: "vertical",
+              points: [
+                [gapLineX, startMaxY],
+                [gapLineX, endMinY],
+              ],
+            },
+          );
+        }
+        break;
+      }
+      case "side_bottom": {
+        if (horizontalGapIntersection) {
+          const gapLineX =
+            (horizontalGapIntersection[0] + horizontalGapIntersection[1]) / 2;
+
+          gapSnapLines.push(
+            {
+              type: "gap",
+              direction: "vertical",
+              points: [
+                [gapLineX, startMaxY],
+                [gapLineX, endMinY],
+              ],
+            },
+            {
+              type: "gap",
+              direction: "vertical",
+              points: [
+                [gapLineX, endMaxY],
+                [gapLineX, minY],
+              ],
+            },
+          );
+        }
+        break;
+      }
+    }
+  }
+
+  return dedupeGapSnapLines(
+    gapSnapLines.map((gapSnapLine) => {
+      return {
+        ...gapSnapLine,
+        points: gapSnapLine.points.map(
+          (point) => [round(point[0]), round(point[1])] as Point,
+        ) as PointPair,
+      };
+    }),
+  );
+};
+
+export const snapResizingElements = (
+  // use the latest elements to create snap lines
+  selectedElements: ExcalidrawElement[],
+  // while using the original elements to appy dragOffset to calculate snaps
+  selectedOriginalElements: ExcalidrawElement[],
+  appState: AppState,
+  event: KeyboardModifiersObject,
+  dragOffset: Vector2D,
+  transformHandle: MaybeTransformHandleType,
+) => {
+  if (
+    !isSnappingEnabled({ event, selectedElements, appState }) ||
+    selectedElements.length === 0 ||
+    (selectedElements.length === 1 &&
+      !areRoughlyEqual(selectedElements[0].angle, 0))
+  ) {
+    return {
+      snapOffset: { x: 0, y: 0 },
+      snapLines: [],
+    };
+  }
+
+  let [minX, minY, maxX, maxY] = getCommonBounds(selectedOriginalElements);
+
+  if (transformHandle) {
+    if (transformHandle.includes("e")) {
+      maxX += dragOffset.x;
+    } else if (transformHandle.includes("w")) {
+      minX += dragOffset.x;
+    }
+
+    if (transformHandle.includes("n")) {
+      minY += dragOffset.y;
+    } else if (transformHandle.includes("s")) {
+      maxY += dragOffset.y;
+    }
+  }
+
+  const selectionSnapPoints: Point[] = [];
+
+  if (transformHandle) {
+    switch (transformHandle) {
+      case "e": {
+        selectionSnapPoints.push([maxX, minY], [maxX, maxY]);
+        break;
+      }
+      case "w": {
+        selectionSnapPoints.push([minX, minY], [minX, maxY]);
+        break;
+      }
+      case "n": {
+        selectionSnapPoints.push([minX, minY], [maxX, minY]);
+        break;
+      }
+      case "s": {
+        selectionSnapPoints.push([minX, maxY], [maxX, maxY]);
+        break;
+      }
+      case "ne": {
+        selectionSnapPoints.push([maxX, minY]);
+        break;
+      }
+      case "nw": {
+        selectionSnapPoints.push([minX, minY]);
+        break;
+      }
+      case "se": {
+        selectionSnapPoints.push([maxX, maxY]);
+        break;
+      }
+      case "sw": {
+        selectionSnapPoints.push([minX, maxY]);
+        break;
+      }
+    }
+  }
+
+  const snapDistance = getSnapDistance(appState.zoom.value);
+
+  const minOffset = {
+    x: snapDistance,
+    y: snapDistance,
+  };
+
+  const nearestSnapsX: Snaps = [];
+  const nearestSnapsY: Snaps = [];
+
+  getPointSnaps(
+    selectedOriginalElements,
+    selectionSnapPoints,
+    appState,
+    event,
+    nearestSnapsX,
+    nearestSnapsY,
+    minOffset,
+  );
+
+  const snapOffset = {
+    x: nearestSnapsX[0]?.offset ?? 0,
+    y: nearestSnapsY[0]?.offset ?? 0,
+  };
+
+  // again, once snap offset is calculated
+  // reset to recompute for creating snap lines to be rendered
+  minOffset.x = 0;
+  minOffset.y = 0;
+  nearestSnapsX.length = 0;
+  nearestSnapsY.length = 0;
+
+  const [x1, y1, x2, y2] = getCommonBounds(selectedElements).map((bound) =>
+    round(bound),
+  );
+
+  const corners: Point[] = [
+    [x1, y1],
+    [x1, y2],
+    [x2, y1],
+    [x2, y2],
+  ];
+
+  getPointSnaps(
+    selectedElements,
+    corners,
+    appState,
+    event,
+    nearestSnapsX,
+    nearestSnapsY,
+    minOffset,
+  );
+
+  const pointSnapLines = createPointSnapLines(nearestSnapsX, nearestSnapsY);
+
+  return {
+    snapOffset,
+    snapLines: pointSnapLines,
+  };
+};
+
+export const snapNewElement = (
+  draggingElement: ExcalidrawElement,
+  appState: AppState,
+  event: KeyboardModifiersObject,
+  origin: Vector2D,
+  dragOffset: Vector2D,
+) => {
+  if (
+    !isSnappingEnabled({ event, selectedElements: [draggingElement], appState })
+  ) {
+    return {
+      snapOffset: { x: 0, y: 0 },
+      snapLines: [],
+    };
+  }
+
+  const selectionSnapPoints: Point[] = [
+    [origin.x + dragOffset.x, origin.y + dragOffset.y],
+  ];
+
+  const snapDistance = getSnapDistance(appState.zoom.value);
+
+  const minOffset = {
+    x: snapDistance,
+    y: snapDistance,
+  };
+
+  const nearestSnapsX: Snaps = [];
+  const nearestSnapsY: Snaps = [];
+
+  getPointSnaps(
+    [draggingElement],
+    selectionSnapPoints,
+    appState,
+    event,
+    nearestSnapsX,
+    nearestSnapsY,
+    minOffset,
+  );
+
+  const snapOffset = {
+    x: nearestSnapsX[0]?.offset ?? 0,
+    y: nearestSnapsY[0]?.offset ?? 0,
+  };
+
+  minOffset.x = 0;
+  minOffset.y = 0;
+  nearestSnapsX.length = 0;
+  nearestSnapsY.length = 0;
+
+  const corners = getElementsCorners([draggingElement], {
+    boundingBoxCorners: true,
+    omitCenter: true,
+  });
+
+  getPointSnaps(
+    [draggingElement],
+    corners,
+    appState,
+    event,
+    nearestSnapsX,
+    nearestSnapsY,
+    minOffset,
+  );
+
+  const pointSnapLines = createPointSnapLines(nearestSnapsX, nearestSnapsY);
+
+  return {
+    snapOffset,
+    snapLines: pointSnapLines,
+  };
+};
+
+export const getSnapLinesAtPointer = (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+  pointer: Vector2D,
+  event: KeyboardModifiersObject,
+) => {
+  if (!isSnappingEnabled({ event, selectedElements: [], appState })) {
+    return {
+      originOffset: { x: 0, y: 0 },
+      snapLines: [],
+    };
+  }
+
+  const referenceElements = getVisibleAndNonSelectedElements(
+    elements,
+    [],
+    appState,
+  );
+
+  const snapDistance = getSnapDistance(appState.zoom.value);
+
+  const minOffset = {
+    x: snapDistance,
+    y: snapDistance,
+  };
+
+  const horizontalSnapLines: PointerSnapLine[] = [];
+  const verticalSnapLines: PointerSnapLine[] = [];
+
+  for (const referenceElement of referenceElements) {
+    const corners = getElementsCorners([referenceElement]);
+
+    for (const corner of corners) {
+      const offsetX = corner[0] - pointer.x;
+
+      if (Math.abs(offsetX) <= Math.abs(minOffset.x)) {
+        if (Math.abs(offsetX) < Math.abs(minOffset.x)) {
+          verticalSnapLines.length = 0;
+        }
+
+        verticalSnapLines.push({
+          type: "pointer",
+          points: [corner, [corner[0], pointer.y]],
+          direction: "vertical",
+        });
+
+        minOffset.x = offsetX;
+      }
+
+      const offsetY = corner[1] - pointer.y;
+
+      if (Math.abs(offsetY) <= Math.abs(minOffset.y)) {
+        if (Math.abs(offsetY) < Math.abs(minOffset.y)) {
+          horizontalSnapLines.length = 0;
+        }
+
+        horizontalSnapLines.push({
+          type: "pointer",
+          points: [corner, [pointer.x, corner[1]]],
+          direction: "horizontal",
+        });
+
+        minOffset.y = offsetY;
+      }
+    }
+  }
+
+  return {
+    originOffset: {
+      x:
+        verticalSnapLines.length > 0
+          ? verticalSnapLines[0].points[0][0] - pointer.x
+          : 0,
+      y:
+        horizontalSnapLines.length > 0
+          ? horizontalSnapLines[0].points[0][1] - pointer.y
+          : 0,
+    },
+    snapLines: [...verticalSnapLines, ...horizontalSnapLines],
+  };
+};
+
+export const isActiveToolNonLinearSnappable = (
+  activeToolType: AppState["activeTool"]["type"],
+) => {
+  return (
+    activeToolType === "rectangle" ||
+    activeToolType === "ellipse" ||
+    activeToolType === "diamond" ||
+    activeToolType === "frame" ||
+    activeToolType === "image"
+  );
+};

+ 73 - 0
src/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -331,12 +331,17 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetLeft": 20,
   "offsetTop": 10,
   "offsetTop": 10,
   "openDialog": null,
   "openDialog": null,
   "openMenu": null,
   "openMenu": null,
   "openPopup": null,
   "openPopup": null,
   "openSidebar": null,
   "openSidebar": null,
+  "originSnapOffset": {
+    "x": 0,
+    "y": 0,
+  },
   "pasteDialog": {
   "pasteDialog": {
     "data": null,
     "data": null,
     "shown": false,
     "shown": false,
@@ -363,6 +368,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": true,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "startBoundElement": null,
   "suggestedBindings": [],
   "suggestedBindings": [],
   "theme": "light",
   "theme": "light",
@@ -524,12 +530,14 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetLeft": 20,
   "offsetTop": 10,
   "offsetTop": 10,
   "openDialog": null,
   "openDialog": null,
   "openMenu": null,
   "openMenu": null,
   "openPopup": null,
   "openPopup": null,
   "openSidebar": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
   "pasteDialog": {
     "data": null,
     "data": null,
     "shown": false,
     "shown": false,
@@ -553,6 +561,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": true,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "startBoundElement": null,
   "suggestedBindings": [],
   "suggestedBindings": [],
   "theme": "light",
   "theme": "light",
@@ -723,12 +732,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetLeft": 20,
   "offsetTop": 10,
   "offsetTop": 10,
   "openDialog": null,
   "openDialog": null,
   "openMenu": null,
   "openMenu": null,
   "openPopup": null,
   "openPopup": null,
   "openSidebar": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
   "pasteDialog": {
     "data": null,
     "data": null,
     "shown": false,
     "shown": false,
@@ -752,6 +763,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": true,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "startBoundElement": null,
   "suggestedBindings": [],
   "suggestedBindings": [],
   "theme": "light",
   "theme": "light",
@@ -1096,12 +1108,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetLeft": 20,
   "offsetTop": 10,
   "offsetTop": 10,
   "openDialog": null,
   "openDialog": null,
   "openMenu": null,
   "openMenu": null,
   "openPopup": null,
   "openPopup": null,
   "openSidebar": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
   "pasteDialog": {
     "data": null,
     "data": null,
     "shown": false,
     "shown": false,
@@ -1125,6 +1139,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": true,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "startBoundElement": null,
   "suggestedBindings": [],
   "suggestedBindings": [],
   "theme": "light",
   "theme": "light",
@@ -1469,12 +1484,14 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetLeft": 20,
   "offsetTop": 10,
   "offsetTop": 10,
   "openDialog": null,
   "openDialog": null,
   "openMenu": null,
   "openMenu": null,
   "openPopup": null,
   "openPopup": null,
   "openSidebar": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
   "pasteDialog": {
     "data": null,
     "data": null,
     "shown": false,
     "shown": false,
@@ -1498,6 +1515,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": true,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "startBoundElement": null,
   "suggestedBindings": [],
   "suggestedBindings": [],
   "theme": "light",
   "theme": "light",
@@ -1668,12 +1686,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetLeft": 20,
   "offsetTop": 10,
   "offsetTop": 10,
   "openDialog": null,
   "openDialog": null,
   "openMenu": null,
   "openMenu": null,
   "openPopup": null,
   "openPopup": null,
   "openSidebar": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
   "pasteDialog": {
     "data": null,
     "data": null,
     "shown": false,
     "shown": false,
@@ -1695,6 +1715,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": true,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "startBoundElement": null,
   "suggestedBindings": [],
   "suggestedBindings": [],
   "theme": "light",
   "theme": "light",
@@ -1904,12 +1925,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetLeft": 20,
   "offsetTop": 10,
   "offsetTop": 10,
   "openDialog": null,
   "openDialog": null,
   "openMenu": null,
   "openMenu": null,
   "openPopup": null,
   "openPopup": null,
   "openSidebar": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
   "pasteDialog": {
     "data": null,
     "data": null,
     "shown": false,
     "shown": false,
@@ -1933,6 +1956,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": true,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "startBoundElement": null,
   "suggestedBindings": [],
   "suggestedBindings": [],
   "theme": "light",
   "theme": "light",
@@ -2205,12 +2229,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetLeft": 20,
   "offsetTop": 10,
   "offsetTop": 10,
   "openDialog": null,
   "openDialog": null,
   "openMenu": null,
   "openMenu": null,
   "openPopup": null,
   "openPopup": null,
   "openSidebar": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
   "pasteDialog": {
     "data": null,
     "data": null,
     "shown": false,
     "shown": false,
@@ -2239,6 +2265,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": true,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "startBoundElement": null,
   "suggestedBindings": [],
   "suggestedBindings": [],
   "theme": "light",
   "theme": "light",
@@ -2594,12 +2621,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetLeft": 20,
   "offsetTop": 10,
   "offsetTop": 10,
   "openDialog": null,
   "openDialog": null,
   "openMenu": null,
   "openMenu": null,
   "openPopup": null,
   "openPopup": null,
   "openSidebar": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
   "pasteDialog": {
     "data": null,
     "data": null,
     "shown": false,
     "shown": false,
@@ -2623,6 +2652,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": true,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "startBoundElement": null,
   "suggestedBindings": [],
   "suggestedBindings": [],
   "theme": "light",
   "theme": "light",
@@ -3473,12 +3503,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetLeft": 20,
   "offsetTop": 10,
   "offsetTop": 10,
   "openDialog": null,
   "openDialog": null,
   "openMenu": null,
   "openMenu": null,
   "openPopup": null,
   "openPopup": null,
   "openSidebar": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
   "pasteDialog": {
     "data": null,
     "data": null,
     "shown": false,
     "shown": false,
@@ -3502,6 +3534,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": true,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "startBoundElement": null,
   "suggestedBindings": [],
   "suggestedBindings": [],
   "theme": "light",
   "theme": "light",
@@ -3846,12 +3879,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetLeft": 20,
   "offsetTop": 10,
   "offsetTop": 10,
   "openDialog": null,
   "openDialog": null,
   "openMenu": null,
   "openMenu": null,
   "openPopup": null,
   "openPopup": null,
   "openSidebar": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
   "pasteDialog": {
     "data": null,
     "data": null,
     "shown": false,
     "shown": false,
@@ -3875,6 +3910,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": true,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "startBoundElement": null,
   "suggestedBindings": [],
   "suggestedBindings": [],
   "theme": "light",
   "theme": "light",
@@ -4219,12 +4255,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetLeft": 20,
   "offsetTop": 10,
   "offsetTop": 10,
   "openDialog": null,
   "openDialog": null,
   "openMenu": null,
   "openMenu": null,
   "openPopup": null,
   "openPopup": null,
   "openSidebar": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
   "pasteDialog": {
     "data": null,
     "data": null,
     "shown": false,
     "shown": false,
@@ -4251,6 +4289,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": true,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "startBoundElement": null,
   "suggestedBindings": [],
   "suggestedBindings": [],
   "theme": "light",
   "theme": "light",
@@ -4951,12 +4990,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetLeft": 20,
   "offsetTop": 10,
   "offsetTop": 10,
   "openDialog": null,
   "openDialog": null,
   "openMenu": null,
   "openMenu": null,
   "openPopup": null,
   "openPopup": null,
   "openSidebar": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
   "pasteDialog": {
     "data": null,
     "data": null,
     "shown": false,
     "shown": false,
@@ -4983,6 +5024,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": true,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "startBoundElement": null,
   "suggestedBindings": [],
   "suggestedBindings": [],
   "theme": "light",
   "theme": "light",
@@ -5531,12 +5573,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetLeft": 20,
   "offsetTop": 10,
   "offsetTop": 10,
   "openDialog": null,
   "openDialog": null,
   "openMenu": null,
   "openMenu": null,
   "openPopup": null,
   "openPopup": null,
   "openSidebar": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
   "pasteDialog": {
     "data": null,
     "data": null,
     "shown": false,
     "shown": false,
@@ -5565,6 +5609,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": true,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "startBoundElement": null,
   "suggestedBindings": [],
   "suggestedBindings": [],
   "theme": "light",
   "theme": "light",
@@ -5950,6 +5995,19 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
         },
         },
         "viewMode": true,
         "viewMode": true,
       },
       },
+      {
+        "checked": [Function],
+        "contextItemLabel": "buttons.objectsSnapMode",
+        "keyTest": [Function],
+        "name": "objectsSnapMode",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": {
+          "category": "canvas",
+          "predicate": [Function],
+        },
+        "viewMode": true,
+      },
       {
       {
         "checked": [Function],
         "checked": [Function],
         "contextItemLabel": "buttons.zenMode",
         "contextItemLabel": "buttons.zenMode",
@@ -6035,12 +6093,17 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetLeft": 20,
   "offsetTop": 10,
   "offsetTop": 10,
   "openDialog": null,
   "openDialog": null,
   "openMenu": null,
   "openMenu": null,
   "openPopup": null,
   "openPopup": null,
   "openSidebar": null,
   "openSidebar": null,
+  "originSnapOffset": {
+    "x": 0,
+    "y": 0,
+  },
   "pasteDialog": {
   "pasteDialog": {
     "data": null,
     "data": null,
     "shown": false,
     "shown": false,
@@ -6062,6 +6125,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": true,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "startBoundElement": null,
   "suggestedBindings": [],
   "suggestedBindings": [],
   "theme": "light",
   "theme": "light",
@@ -6431,12 +6495,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetLeft": 20,
   "offsetTop": 10,
   "offsetTop": 10,
   "openDialog": null,
   "openDialog": null,
   "openMenu": null,
   "openMenu": null,
   "openPopup": null,
   "openPopup": null,
   "openSidebar": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
   "pasteDialog": {
     "data": null,
     "data": null,
     "shown": false,
     "shown": false,
@@ -6460,6 +6526,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": true,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "startBoundElement": null,
   "suggestedBindings": [],
   "suggestedBindings": [],
   "theme": "light",
   "theme": "light",
@@ -6805,12 +6872,17 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetLeft": 20,
   "offsetTop": 10,
   "offsetTop": 10,
   "openDialog": null,
   "openDialog": null,
   "openMenu": null,
   "openMenu": null,
   "openPopup": null,
   "openPopup": null,
   "openSidebar": null,
   "openSidebar": null,
+  "originSnapOffset": {
+    "x": 0,
+    "y": 0,
+  },
   "pasteDialog": {
   "pasteDialog": {
     "data": null,
     "data": null,
     "shown": false,
     "shown": false,
@@ -6834,6 +6906,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": true,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "startBoundElement": null,
   "suggestedBindings": [],
   "suggestedBindings": [],
   "theme": "light",
   "theme": "light",

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 131 - 0
src/tests/__snapshots__/regressionTests.test.tsx.snap


+ 1 - 0
src/tests/contextmenu.test.tsx

@@ -87,6 +87,7 @@ describe("contextMenu element", () => {
       "gridMode",
       "gridMode",
       "zenMode",
       "zenMode",
       "viewMode",
       "viewMode",
+      "objectsSnapMode",
       "stats",
       "stats",
     ];
     ];
 
 

+ 4 - 4
src/tests/linearElementEditor.test.tsx

@@ -1048,14 +1048,14 @@ describe("Test Linear Elements", () => {
         .toMatchInlineSnapshot(`
         .toMatchInlineSnapshot(`
           {
           {
             "height": 130,
             "height": 130,
-            "width": 367,
+            "width": 366.11716195150507,
           }
           }
         `);
         `);
 
 
       expect(getBoundTextElementPosition(container, textElement))
       expect(getBoundTextElementPosition(container, textElement))
         .toMatchInlineSnapshot(`
         .toMatchInlineSnapshot(`
           {
           {
-            "x": 272,
+            "x": 271.11716195150507,
             "y": 45,
             "y": 45,
           }
           }
         `);
         `);
@@ -1069,9 +1069,9 @@ describe("Test Linear Elements", () => {
           [
           [
             20,
             20,
             35,
             35,
-            502,
+            501.11716195150507,
             95,
             95,
-            205.9061448421403,
+            205.4589377083102,
             52.5,
             52.5,
           ]
           ]
         `);
         `);

+ 1 - 1
src/tests/move.test.tsx

@@ -84,7 +84,7 @@ describe("move element", () => {
     // select the second rectangles
     // select the second rectangles
     new Pointer("mouse").clickOn(rectB);
     new Pointer("mouse").clickOn(rectB);
 
 
-    expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(24);
     expect(renderStaticScene).toHaveBeenCalledTimes(20);
     expect(renderStaticScene).toHaveBeenCalledTimes(20);
     expect(h.state.selectionElement).toBeNull();
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(3);
     expect(h.elements.length).toEqual(3);

+ 2 - 3
src/tests/multiPointCreate.test.tsx

@@ -110,7 +110,7 @@ describe("multi point mode in linear elements", () => {
       key: KEYS.ENTER,
       key: KEYS.ENTER,
     });
     });
 
 
-    expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(11);
     expect(renderStaticScene).toHaveBeenCalledTimes(10);
     expect(renderStaticScene).toHaveBeenCalledTimes(10);
     expect(h.elements.length).toEqual(1);
     expect(h.elements.length).toEqual(1);
 
 
@@ -153,8 +153,7 @@ describe("multi point mode in linear elements", () => {
     fireEvent.keyDown(document, {
     fireEvent.keyDown(document, {
       key: KEYS.ENTER,
       key: KEYS.ENTER,
     });
     });
-
-    expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(11);
     expect(renderStaticScene).toHaveBeenCalledTimes(10);
     expect(renderStaticScene).toHaveBeenCalledTimes(10);
     expect(h.elements.length).toEqual(1);
     expect(h.elements.length).toEqual(1);
 
 

+ 6 - 0
src/tests/packages/__snapshots__/utils.test.ts.snap

@@ -55,10 +55,15 @@ exports[`exportToSvg > with default arguments 1`] = `
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "name",
   "name": "name",
+  "objectsSnapModeEnabled": false,
   "openDialog": null,
   "openDialog": null,
   "openMenu": null,
   "openMenu": null,
   "openPopup": null,
   "openPopup": null,
   "openSidebar": null,
   "openSidebar": null,
+  "originSnapOffset": {
+    "x": 0,
+    "y": 0,
+  },
   "pasteDialog": {
   "pasteDialog": {
     "data": null,
     "data": null,
     "shown": false,
     "shown": false,
@@ -80,6 +85,7 @@ exports[`exportToSvg > with default arguments 1`] = `
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": false,
   "showWelcomeScreen": false,
+  "snapLines": [],
   "startBoundElement": null,
   "startBoundElement": null,
   "suggestedBindings": [],
   "suggestedBindings": [],
   "theme": "light",
   "theme": "light",

+ 19 - 0
src/types.ts

@@ -41,6 +41,7 @@ import {
 import type { FileSystemHandle } from "./data/filesystem";
 import type { FileSystemHandle } from "./data/filesystem";
 import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
 import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
 import { ContextMenuItems } from "./components/ContextMenu";
 import { ContextMenuItems } from "./components/ContextMenu";
+import { SnapLine } from "./snapping";
 import { Merge, ForwardRef, ValueOf } from "./utility-types";
 import { Merge, ForwardRef, ValueOf } from "./utility-types";
 
 
 export type Point = Readonly<RoughPoint>;
 export type Point = Readonly<RoughPoint>;
@@ -157,6 +158,9 @@ export type InteractiveCanvasAppState = Readonly<
     showHyperlinkPopup: AppState["showHyperlinkPopup"];
     showHyperlinkPopup: AppState["showHyperlinkPopup"];
     // Collaborators
     // Collaborators
     collaborators: AppState["collaborators"];
     collaborators: AppState["collaborators"];
+    // SnapLines
+    snapLines: AppState["snapLines"];
+    zenModeEnabled: AppState["zenModeEnabled"];
   }
   }
 >;
 >;
 
 
@@ -298,6 +302,13 @@ export type AppState = {
   pendingImageElementId: ExcalidrawImageElement["id"] | null;
   pendingImageElementId: ExcalidrawImageElement["id"] | null;
   showHyperlinkPopup: false | "info" | "editor";
   showHyperlinkPopup: false | "info" | "editor";
   selectedLinearElement: LinearElementEditor | null;
   selectedLinearElement: LinearElementEditor | null;
+
+  snapLines: SnapLine[];
+  originSnapOffset: {
+    x: number;
+    y: number;
+  } | null;
+  objectsSnapModeEnabled: boolean;
 };
 };
 
 
 export type UIAppState = Omit<
 export type UIAppState = Omit<
@@ -411,6 +422,7 @@ export interface ExcalidrawProps {
   viewModeEnabled?: boolean;
   viewModeEnabled?: boolean;
   zenModeEnabled?: boolean;
   zenModeEnabled?: boolean;
   gridModeEnabled?: boolean;
   gridModeEnabled?: boolean;
+  objectsSnapModeEnabled?: boolean;
   libraryReturnUrl?: string;
   libraryReturnUrl?: string;
   theme?: Theme;
   theme?: Theme;
   name?: string;
   name?: string;
@@ -669,3 +681,10 @@ export type FrameNameBoundsCache = {
     }
     }
   >;
   >;
 };
 };
+
+export type KeyboardModifiersObject = {
+  ctrlKey: boolean;
+  shiftKey: boolean;
+  altKey: boolean;
+  metaKey: boolean;
+};

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно