Просмотр исходного кода

feat: element alignments - snapping (#6256)

Co-authored-by: Ryan Di <[email protected]>
Co-authored-by: dwelle <[email protected]>
hugofqt 1 год назад
Родитель
Сommit
4c35eba72d

+ 1 - 0
src/actions/actionToggleGridMode.tsx

@@ -15,6 +15,7 @@ export const actionToggleGridMode = register({
       appState: {
         ...appState,
         gridSize: this.checked!(appState) ? null : GRID_SIZE,
+        objectsSnapModeEnabled: 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 { actionToggleZenMode } from "./actionToggleZenMode";
+export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
 
 export { actionToggleStats } from "./actionToggleStats";
 export { actionUnbindText, actionBindText } from "./actionBoundText";

+ 2 - 0
src/actions/shortcuts.ts

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

+ 1 - 0
src/actions/types.ts

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

+ 9 - 0
src/appState.ts

@@ -99,6 +99,12 @@ export const getDefaultAppState = (): Omit<
     pendingImageElementId: null,
     showHyperlinkPopup: false,
     selectedLinearElement: null,
+    snapLines: [],
+    originSnapOffset: {
+      x: 0,
+      y: 0,
+    },
+    objectsSnapModeEnabled: false,
   };
 };
 
@@ -206,6 +212,9 @@ const APP_STATE_STORAGE_CONF = (<
   pendingImageElementId: { browser: false, export: false, server: false },
   showHyperlinkPopup: { browser: false, 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 = <

+ 230 - 25
src/components/App.tsx

@@ -35,6 +35,7 @@ import {
   actionLink,
   actionToggleElementLock,
   actionToggleLinearEditor,
+  actionToggleObjectsSnapMode,
 } from "../actions";
 import { createRedoAction, createUndoAction } from "../actions/actionHistory";
 import { ActionManager } from "../actions/manager";
@@ -228,6 +229,7 @@ import {
   FrameNameBoundsCache,
   SidebarName,
   SidebarTabName,
+  KeyboardModifiersObject,
 } from "../types";
 import {
   debounce,
@@ -342,6 +344,17 @@ import {
 import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
 import { jotaiStore } from "../jotai";
 import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
+import {
+  getSnapLinesAtPointer,
+  snapDraggedElements,
+  isActiveToolNonLinearSnappable,
+  snapNewElement,
+  snapResizingElements,
+  isSnappingEnabled,
+  getVisibleGaps,
+  getReferenceSnapPoints,
+  SnapCache,
+} from "../snapping";
 import { actionWrapTextInContainer } from "../actions/actionBoundText";
 import BraveMeasureTextError from "./BraveMeasureTextError";
 import { activeEyeDropperAtom } from "./EyeDropper";
@@ -490,6 +503,7 @@ class App extends React.Component<AppProps, AppState> {
       viewModeEnabled = false,
       zenModeEnabled = false,
       gridModeEnabled = false,
+      objectsSnapModeEnabled = false,
       theme = defaultAppState.theme,
       name = defaultAppState.name,
     } = props;
@@ -500,6 +514,7 @@ class App extends React.Component<AppProps, AppState> {
       ...this.getCanvasOffsets(),
       viewModeEnabled,
       zenModeEnabled,
+      objectsSnapModeEnabled,
       gridSize: gridModeEnabled ? GRID_SIZE : null,
       name,
       width: window.innerWidth,
@@ -1722,6 +1737,7 @@ class App extends React.Component<AppProps, AppState> {
     this.scene.destroy();
     this.library.destroy();
     ShapeCache.destroy();
+    SnapCache.destroy();
     clearTimeout(touchTimeout);
     isSomeElementSelected.clearCache();
     selectGroupsForSelectedElements.clearCache();
@@ -3120,15 +3136,21 @@ class App extends React.Component<AppProps, AppState> {
       this.onImageAction();
     }
     if (nextActiveTool.type !== "selection") {
-      this.setState({
+      this.setState((prevState) => ({
         activeTool: nextActiveTool,
         selectedElementIds: makeNextSelectedElementIds({}, this.state),
         selectedGroupIds: {},
         editingGroupId: null,
+        snapLines: [],
+        originSnapOffset: null,
+      }));
+    } else {
+      this.setState({
+        activeTool: nextActiveTool,
+        snapLines: [],
+        originSnapOffset: null,
         activeEmbeddable: null,
       });
-    } else {
-      this.setState({ activeTool: nextActiveTool, activeEmbeddable: null });
     }
   };
 
@@ -3865,6 +3887,30 @@ class App extends React.Component<AppProps, AppState> {
     const scenePointer = viewportCoordsToSceneCoords(event, this.state);
     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 (
       this.state.editingLinearElement &&
       !this.state.editingLinearElement.isDragging
@@ -4335,6 +4381,10 @@ class App extends React.Component<AppProps, AppState> {
       this.setState({ contextMenu: null });
     }
 
+    if (this.state.snapLines) {
+      this.setAppState({ snapLines: [] });
+    }
+
     this.updateGestureOnPointerDown(event);
 
     // if dragging element is freedraw and another pointerdown event occurs
@@ -5616,6 +5666,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(
     pointerDownState: PointerDownState,
   ): (event: KeyboardEvent) => void {
@@ -5845,33 +5941,62 @@ class App extends React.Component<AppProps, AppState> {
           !this.state.editingElement &&
           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
           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
           // able to select and interact with the text input
           !this.state.editingFrame &&
             dragSelectedElements(
               pointerDownState,
               selectedElements,
-              dragX,
-              dragY,
-              lockDirection,
-              dragDistanceX,
-              dragDistanceY,
+              dragOffset,
               this.state,
               this.scene,
+              snapOffset,
+              event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
             );
+
           this.maybeSuggestBindingForAll(selectedElements);
 
           // We duplicate the selected element if alt is pressed on pointer move
@@ -5912,15 +6037,21 @@ class App extends React.Component<AppProps, AppState> {
                   groupIdMap,
                   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, {
-                  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);
                 elementsToAppend.push(element);
                 oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
@@ -5946,6 +6077,8 @@ class App extends React.Component<AppProps, AppState> {
               oldIdToDuplicatedId,
             );
             this.scene.replaceAllElements(nextSceneElements);
+            this.maybeCacheVisibleGaps(event, selectedElements, true);
+            this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
           }
           return;
         }
@@ -6162,6 +6295,7 @@ class App extends React.Component<AppProps, AppState> {
         isResizing,
         isRotating,
       } = this.state;
+
       this.setState({
         isResizing: false,
         isRotating: false,
@@ -6176,8 +6310,14 @@ class App extends React.Component<AppProps, AppState> {
           multiElement || isTextElement(this.state.editingElement)
             ? this.state.editingElement
             : null,
+        snapLines: [],
+
+        originSnapOffset: null,
       });
 
+      SnapCache.setReferenceSnapPoints(null);
+      SnapCache.setVisibleGaps(null);
+
       this.savePointer(childEvent.clientX, childEvent.clientY, "up");
 
       this.setState({
@@ -7705,7 +7845,7 @@ class App extends React.Component<AppProps, AppState> {
         shouldResizeFromCenter(event),
       );
     } else {
-      const [gridX, gridY] = getGridPoint(
+      let [gridX, gridY] = getGridPoint(
         pointerCoords.x,
         pointerCoords.y,
         event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
@@ -7719,6 +7859,33 @@ class App extends React.Component<AppProps, AppState> {
           ? image.width / image.height
           : 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(
         draggingElement,
         this.state.activeTool.type,
@@ -7733,6 +7900,7 @@ class App extends React.Component<AppProps, AppState> {
           : shouldMaintainAspectRatio(event),
         shouldResizeFromCenter(event),
         aspectRatio,
+        this.state.originSnapOffset,
       );
 
       this.maybeSuggestBindingForAll([draggingElement]);
@@ -7774,7 +7942,7 @@ class App extends React.Component<AppProps, AppState> {
       activeEmbeddable: null,
     });
     const pointerCoords = pointerDownState.lastCoords;
-    const [resizeX, resizeY] = getGridPoint(
+    let [resizeX, resizeY] = getGridPoint(
       pointerCoords.x - pointerDownState.resize.offset.x,
       pointerCoords.y - pointerDownState.resize.offset.y,
       event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
@@ -7802,6 +7970,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 (
       transformElements(
         pointerDownState,
@@ -7817,6 +8020,7 @@ class App extends React.Component<AppProps, AppState> {
         resizeY,
         pointerDownState.resize.center.x,
         pointerDownState.resize.center.y,
+        this.state,
       )
     ) {
       this.maybeSuggestBindingForAll(selectedElements);
@@ -7904,6 +8108,7 @@ class App extends React.Component<AppProps, AppState> {
         actionUnlockAllElements,
         CONTEXT_MENU_SEPARATOR,
         actionToggleGridMode,
+        actionToggleObjectsSnapMode,
         actionToggleZenMode,
         actionToggleViewMode,
         actionToggleStats,

+ 4 - 0
src/components/HelpDialog.tsx

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

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

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

+ 14 - 1
src/element/bounds.ts

@@ -158,7 +158,7 @@ export const getElementAbsoluteCoords = (
   ];
 };
 
-/**
+/*
  * for a given element, `getElementLineSegments` returns line segments
  * that can be used for visual collision detection (useful for frames)
  * as opposed to bounding box collision detection
@@ -674,6 +674,19 @@ export const getCommonBounds = (
   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 = (
   element: ExcalidrawElement,
   nextWidth: number,

+ 43 - 33
src/element/dragElements.ts

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

+ 27 - 13
src/element/resizeElements.ts

@@ -41,7 +41,7 @@ import {
   MaybeTransformHandleType,
   TransformHandleDirection,
 } from "./transformHandles";
-import { Point, PointerDownState } from "../types";
+import { AppState, Point, PointerDownState } from "../types";
 import Scene from "../scene/Scene";
 import {
   getApproxMinLineWidth,
@@ -79,6 +79,7 @@ export const transformElements = (
   pointerY: number,
   centerX: number,
   centerY: number,
+  appState: AppState,
 ) => {
   if (selectedElements.length === 1) {
     const [element] = selectedElements;
@@ -466,8 +467,8 @@ export const resizeSingleElement = (
         boundTextElement.fontSize,
         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);
     }
   }
 
@@ -508,8 +509,11 @@ export const resizeSingleElement = (
     }
   }
 
+  const flipX = eleNewWidth < 0;
+  const flipY = eleNewHeight < 0;
+
   // Flip horizontally
-  if (eleNewWidth < 0) {
+  if (flipX) {
     if (transformHandleDirection.includes("e")) {
       newTopLeft[0] -= Math.abs(newBoundsWidth);
     }
@@ -517,8 +521,9 @@ export const resizeSingleElement = (
       newTopLeft[0] += Math.abs(newBoundsWidth);
     }
   }
+
   // Flip vertically
-  if (eleNewHeight < 0) {
+  if (flipY) {
     if (transformHandleDirection.includes("s")) {
       newTopLeft[1] -= Math.abs(newBoundsHeight);
     }
@@ -542,10 +547,20 @@ export const resizeSingleElement = (
   const rotatedNewCenter = rotatePoint(newCenter, startCenter, 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
   let rescaledElementPointsY;
   let rescaledPoints;
-
   if (isLinearElement(element) || isFreeDrawElement(element)) {
     rescaledElementPointsY = rescalePoints(
       1,
@@ -562,16 +577,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 = {
     width: Math.abs(eleNewWidth),
     height: Math.abs(eleNewHeight),
-    x: newOrigin[0],
-    y: newOrigin[1],
+    x: nextX,
+    y: nextY,
     points: rescaledPoints,
   };
 
@@ -680,6 +690,10 @@ export const resizeMultipleElements = (
   const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
     targetElements.map(({ orig }) => orig).concat(boundTextElements),
   );
+
+  // const originalHeight = maxY - minY;
+  // const originalWidth = maxX - minX;
+
   const direction = transformHandleType;
 
   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(`
         [
           85,
-          4.5,
+          4.999999999999986,
         ]
       `);
 
@@ -1002,8 +1002,8 @@ describe("textWysiwyg", () => {
       resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
       expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
         [
-          375,
-          -539,
+          374.99999999999994,
+          -535.0000000000001,
         ]
       `);
     });
@@ -1190,7 +1190,7 @@ describe("textWysiwyg", () => {
       editor.blur();
 
       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);
 
       mouse.select(rectangle);
@@ -1200,9 +1200,12 @@ describe("textWysiwyg", () => {
 
       await new Promise((r) => setTimeout(r, 0));
       editor.blur();
-      expect(rectangle.height).toBe(156);
+      expect(rectangle.height).toBeCloseTo(155, 8);
       // 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 () => {

+ 1 - 0
src/keys.ts

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

+ 1 - 0
src/locales/en.json

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

+ 41 - 1
src/math.test.ts

@@ -1,4 +1,4 @@
-import { rotate } from "./math";
+import { rangeIntersection, rangesOverlap, rotate } from "./math";
 
 describe("rotate", () => {
   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]);
   });
 });
+
+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.
   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;
+};

+ 3 - 0
src/renderer/renderScene.ts

@@ -67,6 +67,7 @@ import {
   EXTERNAL_LINK_IMG,
   getLinkHandleFromCoords,
 } from "../element/Hyperlink";
+import { renderSnaps } from "./renderSnaps";
 import {
   isEmbeddableElement,
   isFrameElement,
@@ -720,6 +721,8 @@ const _renderInteractiveScene = ({
     context.restore();
   }
 
+  renderSnaps(context, appState);
+
   // Reset zoom
   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,
 } from "../frame";
 import { isShallowEqual } from "../utils";
+import { isElementInViewport } from "../element/sizeHelpers";
 
 /**
  * Frames and their containing elements are not to be selected at the same time.
@@ -89,6 +90,26 @@ export const getElementsWithinSelection = (
   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
 export const isSomeElementSelected = (function () {
   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",
   "multiElement": null,
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetTop": 10,
   "openDialog": null,
   "openMenu": null,
   "openPopup": null,
   "openSidebar": null,
+  "originSnapOffset": {
+    "x": 0,
+    "y": 0,
+  },
   "pasteDialog": {
     "data": null,
     "shown": false,
@@ -363,6 +368,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "suggestedBindings": [],
   "theme": "light",
@@ -524,12 +530,14 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetTop": 10,
   "openDialog": null,
   "openMenu": null,
   "openPopup": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
     "data": null,
     "shown": false,
@@ -553,6 +561,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "suggestedBindings": [],
   "theme": "light",
@@ -723,12 +732,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetTop": 10,
   "openDialog": null,
   "openMenu": null,
   "openPopup": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
     "data": null,
     "shown": false,
@@ -752,6 +763,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "suggestedBindings": [],
   "theme": "light",
@@ -1096,12 +1108,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetTop": 10,
   "openDialog": null,
   "openMenu": null,
   "openPopup": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
     "data": null,
     "shown": false,
@@ -1125,6 +1139,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "suggestedBindings": [],
   "theme": "light",
@@ -1469,12 +1484,14 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetTop": 10,
   "openDialog": null,
   "openMenu": null,
   "openPopup": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
     "data": null,
     "shown": false,
@@ -1498,6 +1515,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "suggestedBindings": [],
   "theme": "light",
@@ -1668,12 +1686,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetTop": 10,
   "openDialog": null,
   "openMenu": null,
   "openPopup": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
     "data": null,
     "shown": false,
@@ -1695,6 +1715,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "suggestedBindings": [],
   "theme": "light",
@@ -1904,12 +1925,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetTop": 10,
   "openDialog": null,
   "openMenu": null,
   "openPopup": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
     "data": null,
     "shown": false,
@@ -1933,6 +1956,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "suggestedBindings": [],
   "theme": "light",
@@ -2205,12 +2229,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetTop": 10,
   "openDialog": null,
   "openMenu": null,
   "openPopup": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
     "data": null,
     "shown": false,
@@ -2239,6 +2265,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "suggestedBindings": [],
   "theme": "light",
@@ -2594,12 +2621,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetTop": 10,
   "openDialog": null,
   "openMenu": null,
   "openPopup": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
     "data": null,
     "shown": false,
@@ -2623,6 +2652,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "suggestedBindings": [],
   "theme": "light",
@@ -3473,12 +3503,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetTop": 10,
   "openDialog": null,
   "openMenu": null,
   "openPopup": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
     "data": null,
     "shown": false,
@@ -3502,6 +3534,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "suggestedBindings": [],
   "theme": "light",
@@ -3846,12 +3879,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetTop": 10,
   "openDialog": null,
   "openMenu": null,
   "openPopup": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
     "data": null,
     "shown": false,
@@ -3875,6 +3910,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "suggestedBindings": [],
   "theme": "light",
@@ -4219,12 +4255,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetTop": 10,
   "openDialog": null,
   "openMenu": null,
   "openPopup": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
     "data": null,
     "shown": false,
@@ -4251,6 +4289,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "suggestedBindings": [],
   "theme": "light",
@@ -4951,12 +4990,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetTop": 10,
   "openDialog": null,
   "openMenu": null,
   "openPopup": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
     "data": null,
     "shown": false,
@@ -4983,6 +5024,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "suggestedBindings": [],
   "theme": "light",
@@ -5531,12 +5573,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetTop": 10,
   "openDialog": null,
   "openMenu": null,
   "openPopup": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
     "data": null,
     "shown": false,
@@ -5565,6 +5609,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "suggestedBindings": [],
   "theme": "light",
@@ -5950,6 +5995,19 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
         },
         "viewMode": true,
       },
+      {
+        "checked": [Function],
+        "contextItemLabel": "buttons.objectsSnapMode",
+        "keyTest": [Function],
+        "name": "objectsSnapMode",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": {
+          "category": "canvas",
+          "predicate": [Function],
+        },
+        "viewMode": true,
+      },
       {
         "checked": [Function],
         "contextItemLabel": "buttons.zenMode",
@@ -6035,12 +6093,17 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetTop": 10,
   "openDialog": null,
   "openMenu": null,
   "openPopup": null,
   "openSidebar": null,
+  "originSnapOffset": {
+    "x": 0,
+    "y": 0,
+  },
   "pasteDialog": {
     "data": null,
     "shown": false,
@@ -6062,6 +6125,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "suggestedBindings": [],
   "theme": "light",
@@ -6431,12 +6495,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetTop": 10,
   "openDialog": null,
   "openMenu": null,
   "openPopup": null,
   "openSidebar": null,
+  "originSnapOffset": null,
   "pasteDialog": {
     "data": null,
     "shown": false,
@@ -6460,6 +6526,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "suggestedBindings": [],
   "theme": "light",
@@ -6805,12 +6872,17 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
+  "objectsSnapModeEnabled": false,
   "offsetLeft": 20,
   "offsetTop": 10,
   "openDialog": null,
   "openMenu": null,
   "openPopup": null,
   "openSidebar": null,
+  "originSnapOffset": {
+    "x": 0,
+    "y": 0,
+  },
   "pasteDialog": {
     "data": null,
     "shown": false,
@@ -6834,6 +6906,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": true,
+  "snapLines": [],
   "startBoundElement": null,
   "suggestedBindings": [],
   "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",
       "zenMode",
       "viewMode",
+      "objectsSnapMode",
       "stats",
     ];
 

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

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

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

@@ -84,7 +84,7 @@ describe("move element", () => {
     // select the second rectangles
     new Pointer("mouse").clickOn(rectB);
 
-    expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(24);
     expect(renderStaticScene).toHaveBeenCalledTimes(20);
     expect(h.state.selectionElement).toBeNull();
     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,
     });
 
-    expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(11);
     expect(renderStaticScene).toHaveBeenCalledTimes(10);
     expect(h.elements.length).toEqual(1);
 
@@ -153,8 +153,7 @@ describe("multi point mode in linear elements", () => {
     fireEvent.keyDown(document, {
       key: KEYS.ENTER,
     });
-
-    expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(11);
     expect(renderStaticScene).toHaveBeenCalledTimes(10);
     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",
   "multiElement": null,
   "name": "name",
+  "objectsSnapModeEnabled": false,
   "openDialog": null,
   "openMenu": null,
   "openPopup": null,
   "openSidebar": null,
+  "originSnapOffset": {
+    "x": 0,
+    "y": 0,
+  },
   "pasteDialog": {
     "data": null,
     "shown": false,
@@ -80,6 +85,7 @@ exports[`exportToSvg > with default arguments 1`] = `
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": false,
+  "snapLines": [],
   "startBoundElement": null,
   "suggestedBindings": [],
   "theme": "light",

+ 19 - 0
src/types.ts

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

Некоторые файлы не были показаны из-за большого количества измененных файлов