diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json
index 381f2b67f8..ecc83526b8 100644
--- a/packages/excalidraw/locales/en.json
+++ b/packages/excalidraw/locales/en.json
@@ -103,6 +103,9 @@
"loadingScene": "Loading sceneā¦",
"loadScene": "Load scene from file",
"align": "Align",
+ "shapeSnap": "Snap to shapes",
+ "shapeSnapDisable": "Disable snap to shapes",
+ "shapeSnapEnable": "Enable snap to shapes",
"alignTop": "Align top",
"alignBottom": "Align bottom",
"alignLeft": "Align left",
diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
index 349dd9e648..112db3726e 100644
--- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
+++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
@@ -934,6 +934,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -1144,6 +1145,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -1364,6 +1366,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -1699,6 +1702,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -2034,6 +2038,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -2254,6 +2259,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -2498,6 +2504,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -2803,6 +2810,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -3176,6 +3184,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -3655,6 +3664,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -3982,6 +3992,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -4309,6 +4320,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -5590,6 +5602,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -6812,6 +6825,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -7747,6 +7761,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -8754,6 +8769,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -9743,6 +9759,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
index 9ffb97128a..76adbc44b9 100644
--- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
+++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
@@ -60,6 +60,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -660,6 +661,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -1168,6 +1170,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -1540,6 +1543,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -1913,6 +1917,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -2184,6 +2189,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -2624,6 +2630,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -2927,6 +2934,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -3215,6 +3223,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -3513,6 +3522,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -3803,6 +3813,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -4042,6 +4053,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -4305,6 +4317,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -4582,6 +4595,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -4817,6 +4831,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -5052,6 +5067,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -5285,6 +5301,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -5518,6 +5535,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -5781,6 +5799,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -6116,6 +6135,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -6545,6 +6565,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -6927,6 +6948,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -7250,6 +7272,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -7552,6 +7575,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -7785,6 +7809,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -8144,6 +8169,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -8503,6 +8529,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -8911,6 +8938,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -9202,6 +9230,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -9471,6 +9500,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -9739,6 +9769,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -9974,6 +10005,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -10279,6 +10311,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -10623,6 +10656,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -10862,6 +10896,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -11315,6 +11350,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -11573,6 +11609,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -11816,6 +11853,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -12061,6 +12099,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -12466,6 +12505,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -12717,6 +12757,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -12962,6 +13003,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -13207,6 +13249,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -13458,6 +13501,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -13794,6 +13838,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -13970,6 +14015,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -14262,6 +14308,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -14533,6 +14580,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -14812,6 +14860,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -14977,6 +15026,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -15675,6 +15725,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -16295,6 +16346,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -16915,6 +16967,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -17626,6 +17679,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -18374,6 +18428,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -18852,6 +18907,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -19378,6 +19434,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
@@ -19838,6 +19895,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"newElement": null,
diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
index 319287792c..a3e5aef3f5 100644
--- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
+++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
@@ -60,6 +60,7 @@ exports[`given element A and group of elements B and given both are selected whe
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -476,6 +477,7 @@ exports[`given element A and group of elements B and given both are selected whe
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -883,6 +885,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -1429,6 +1432,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -1634,6 +1638,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -2010,6 +2015,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -2249,6 +2255,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -2430,6 +2437,7 @@ exports[`regression tests > can drag element that covers another element, while
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -2751,6 +2759,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -2998,6 +3007,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -3242,6 +3252,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -3473,6 +3484,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -3730,6 +3742,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -4042,6 +4055,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -4465,6 +4479,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -4749,6 +4764,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -5003,6 +5019,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -5214,6 +5231,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -5414,6 +5432,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -5797,6 +5816,7 @@ exports[`regression tests > drags selected elements from point inside common bou
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -6088,6 +6108,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -6897,6 +6918,7 @@ exports[`regression tests > given a group of selected elements with an element t
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -7228,6 +7250,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -7505,6 +7528,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -7740,6 +7764,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -7978,6 +8003,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -8159,6 +8185,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -8340,6 +8367,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -8521,6 +8549,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -8745,6 +8774,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -8968,6 +8998,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -9163,6 +9194,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -9387,6 +9419,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -9568,6 +9601,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -9791,6 +9825,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -9972,6 +10007,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -10167,6 +10203,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -10348,6 +10385,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -10857,6 +10895,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -11135,6 +11174,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "touch",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -11262,6 +11302,7 @@ exports[`regression tests > shift click on selected element should deselect it o
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -11462,6 +11503,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -11774,6 +11816,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -12187,6 +12230,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -12801,6 +12845,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -12931,6 +12976,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -13516,6 +13562,7 @@ exports[`regression tests > switches from group of selected elements to another
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -13855,6 +13902,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -14121,6 +14169,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "touch",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -14248,6 +14297,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -14628,6 +14678,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@@ -14755,6 +14806,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
diff --git a/packages/excalidraw/tests/packages/__snapshots__/utils.test.ts.snap b/packages/excalidraw/tests/packages/__snapshots__/utils.test.ts.snap
index 610d97eb32..de8c7724cb 100644
--- a/packages/excalidraw/tests/packages/__snapshots__/utils.test.ts.snap
+++ b/packages/excalidraw/tests/packages/__snapshots__/utils.test.ts.snap
@@ -53,6 +53,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "name",
diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts
index 717993b436..2a889d8407 100644
--- a/packages/excalidraw/types.ts
+++ b/packages/excalidraw/types.ts
@@ -333,6 +333,7 @@ export interface AppState {
currentHoveredFontFamily: FontFamilyValues | null;
currentItemRoundness: StrokeRoundness;
currentItemArrowType: "sharp" | "round" | "elbow";
+ isShapeSnapEnabled: boolean;
viewBackgroundColor: string;
scrollX: number;
scrollY: number;
@@ -912,3 +913,17 @@ export type Offsets = Partial<{
bottom: number;
left: number;
}>;
+
+export type ShapeDetectionType =
+ | "rectangle"
+ | "ellipse"
+ | "diamond"
+ | "arrow"
+ | "line"
+ | "freedraw";
+
+export interface ShapeDetectionResult {
+ type: ShapeDetectionType;
+ points: readonly (readonly [number, number])[];
+ confidence: number;
+}
diff --git a/packages/math/src/point.ts b/packages/math/src/point.ts
index b6054a10a3..2687ea9881 100644
--- a/packages/math/src/point.ts
+++ b/packages/math/src/point.ts
@@ -1,6 +1,6 @@
-import { degreesToRadians } from "./angle";
+import { degreesToRadians, radiansToDegrees } from "./angle";
import { PRECISION } from "./utils";
-import { vectorFromPoint, vectorScale } from "./vector";
+import { vectorDot, vectorFromPoint, vectorScale } from "./vector";
import type {
LocalPoint,
@@ -230,3 +230,67 @@ export const isPointWithinBounds = (
q[1] >= Math.min(p[1], r[1])
);
};
+
+/**
+ * Calculates the perpendicular distance from a point to a line segment defined by two endpoints.
+ *
+ * If the segment is of zero length, the function returns the distance from the point to the start.
+ *
+ * @typeParam P - The point type, restricted to LocalPoint or GlobalPoint.
+ * @param p - The point from which the perpendicular distance is measured.
+ * @param start - The starting point of the line segment.
+ * @param end - The ending point of the line segment.
+ * @returns The perpendicular distance from point p to the line segment defined by start and end.
+ */
+export const perpendicularDistance =
(
+ p: P,
+ start: P,
+ end: P,
+): number => {
+ const dx = end[0] - start[0];
+ const dy = end[1] - start[1];
+ if (dx === 0 && dy === 0) {
+ return Math.hypot(p[0] - start[0], p[1] - start[1]);
+ }
+ // Equation of line distance
+ const numerator = Math.abs(
+ dy * p[0] - dx * p[1] + end[0] * start[1] - end[1] * start[0],
+ );
+ const denom = Math.hypot(dx, dy);
+ return numerator / denom;
+};
+
+/** * Calculates the angle between three points in degrees.
+ * The angle is calculated at the first point (p0) using the second (p1) and third (p2) points.
+ * The angle is measured in degrees and is always positive.
+ * The function uses the dot product and the arccosine function to calculate the angle. * The result is clamped to the range [-1, 1] to avoid precision errors.
+ * @param p0 The first point used to form the angle.
+ * @param p1 The vertex point where the angle is calculated.
+ * @param p2 The second point used to form the angle.
+ * @returns The angle in degrees between the three points.
+ **/
+export const angleBetween =
(
+ p0: P,
+ p1: P,
+ p2: P,
+): Degrees => {
+ const v1 = vectorFromPoint(p0, p1);
+ const v2 = vectorFromPoint(p1, p2);
+
+ // dot and cross product
+ const magnitude1 = Math.hypot(v1[0], v1[1]);
+ const magnitude2 = Math.hypot(v2[0], v2[1]);
+
+ if (magnitude1 === 0 || magnitude2 === 0) {
+ return 0 as Degrees;
+ }
+
+ const dot = vectorDot(v1, v2);
+
+ let cos = dot / (magnitude1 * magnitude2);
+ // Clamp cos to [-1,1] to avoid precision errors
+ cos = Math.max(-1, Math.min(1, cos));
+ const rad = Math.acos(cos) as Radians;
+
+ return radiansToDegrees(rad);
+};
diff --git a/packages/utils/src/snapToShape.ts b/packages/utils/src/snapToShape.ts
new file mode 100644
index 0000000000..9d900b78eb
--- /dev/null
+++ b/packages/utils/src/snapToShape.ts
@@ -0,0 +1,435 @@
+import {
+ getCenterForBounds,
+ getCommonBoundingBox,
+} from "@excalidraw/element/bounds";
+import {
+ newArrowElement,
+ newElement,
+ newLinearElement,
+} from "@excalidraw/element/newElement";
+
+import {
+ angleBetween,
+ perpendicularDistance,
+ pointDistance,
+} from "@excalidraw/math";
+import { ROUNDNESS } from "@excalidraw/common";
+
+import type { LocalPoint } from "@excalidraw/math";
+
+import type { BoundingBox, Bounds } from "@excalidraw/element/bounds";
+import type {
+ ExcalidrawArrowElement,
+ ExcalidrawDiamondElement,
+ ExcalidrawElement,
+ ExcalidrawEllipseElement,
+ ExcalidrawFreeDrawElement,
+ ExcalidrawLinearElement,
+ ExcalidrawRectangleElement,
+} from "@excalidraw/element/types";
+
+type Shape =
+ | ExcalidrawRectangleElement["type"]
+ | ExcalidrawEllipseElement["type"]
+ | ExcalidrawDiamondElement["type"]
+ | ExcalidrawArrowElement["type"]
+ | ExcalidrawLinearElement["type"]
+ | ExcalidrawFreeDrawElement["type"];
+
+interface ShapeRecognitionResult {
+ type: Shape;
+ simplified: readonly LocalPoint[];
+ boundingBox: BoundingBox;
+}
+
+const QUADRILATERAL_SIDES = 4;
+const QUADRILATERAL_MIN_POINTS = 4; // RDP simplified vertices
+const QUADRILATERAL_MAX_POINTS = 5; // RDP might include closing point
+const ARROW_EXPECTED_POINTS = 5; // RDP simplified vertices for arrow shape
+const LINE_EXPECTED_POINTS = 2; // RDP simplified vertices for line shape
+
+const DEFAULT_OPTIONS = {
+ // Max distance between stroke start/end (as % of bbox diagonal) to consider closed
+ shapeIsClosedPercentThreshold: 20,
+ // Min distance (px) to consider shape closed (takes precedence if larger than %)
+ shapeIsClosedDistanceThreshold: 10,
+ // RDP simplification tolerance (% of bbox diagonal)
+ rdpTolerancePercent: 10,
+ // Arrow specific thresholds
+ arrowMinTipAngle: 30, // Min angle degrees for the tip
+ arrowMaxTipAngle: 150, // Max angle degrees for the tip
+ arrowHeadMaxShaftRatio: 0.8, // Max length ratio of arrowhead segment to shaft
+ // Quadrilateral specific thresholds
+ rectangleMinCornerAngle: 20, // Min deviation from 180 degrees for a valid corner
+ rectangleMaxCornerAngle: 160, // Max deviation from 0 degrees for a valid corner
+ // Angle difference (degrees) to nearest 0/90 orientation to classify as rectangle
+ rectangleOrientationAngleThreshold: 10,
+ // Max variance in radius (normalized) to consider a shape an ellipse
+ ellipseRadiusVarianceThreshold: 0.5,
+} as const; // Use 'as const' for stricter typing of default values
+
+// Options for shape recognition, allowing partial overrides
+type ShapeRecognitionOptions = typeof DEFAULT_OPTIONS;
+type PartialShapeRecognitionOptions = Partial;
+
+interface Segment {
+ length: number;
+ angleDeg: number; // Angle in degrees [0, 180) representing the line's orientation
+}
+
+/**
+ * Simplify a polyline using Ramer-Douglas-Peucker algorithm.
+ */
+function simplifyRDP(
+ points: readonly LocalPoint[],
+ epsilon: number,
+): readonly LocalPoint[] {
+ if (points.length < 3) {
+ return points;
+ }
+
+ const first = points[0];
+ const last = points[points.length - 1];
+ let index = -1;
+ let maxDist = 0;
+
+ // Find the point with the maximum distance from the line segment between first and last
+ for (let i = 1; i < points.length - 1; i++) {
+ const dist = perpendicularDistance(points[i], first, last);
+ if (dist > maxDist) {
+ maxDist = dist;
+ index = i;
+ }
+ }
+
+ // If max distance is greater than epsilon, recursively simplify
+ if (maxDist > epsilon && index !== -1) {
+ const left = simplifyRDP(points.slice(0, index + 1), epsilon);
+ const right = simplifyRDP(points.slice(index), epsilon);
+ // Concatenate results (omit duplicate point at junction)
+ return left.slice(0, -1).concat(right);
+ }
+ // Not enough deviation, return straight line segment (keep only endpoints)
+ return [first, last];
+}
+
+/**
+ * Calculates the properties (length, angle) of segments in a polygon.
+ */
+function calculateSegments(vertices: readonly LocalPoint[]): Segment[] {
+ const segments: Segment[] = [];
+ const numVertices = vertices.length;
+ for (let i = 0; i < numVertices; i++) {
+ const p1 = vertices[i];
+ // Ensure wrapping for the last segment connecting back to the start
+ const p2 = vertices[(i + 1) % numVertices];
+ const dx = p2[0] - p1[0];
+ const dy = p2[1] - p1[1];
+ const length = Math.hypot(dx, dy);
+
+ // Calculate angle in degrees [0, 360)
+ let angleRad = Math.atan2(dy, dx);
+ if (angleRad < 0) {
+ angleRad += 2 * Math.PI;
+ }
+ let angleDeg = (angleRad * 180) / Math.PI;
+
+ // Normalize angle to [0, 180) for undirected line orientation
+ if (angleDeg >= 180) {
+ angleDeg -= 180;
+ }
+
+ segments.push({ length, angleDeg });
+ }
+ return segments;
+}
+
+/**
+ * Checks if the shape is closed based on the distance between start and end points.
+ */
+function isShapeClosed(
+ points: readonly LocalPoint[],
+ boundingBoxDiagonal: number,
+ options: ShapeRecognitionOptions,
+): boolean {
+ const start = points[0];
+ const end = points[points.length - 1];
+ const closedDist = pointDistance(start, end);
+ const closedThreshold = Math.max(
+ options.shapeIsClosedDistanceThreshold,
+ boundingBoxDiagonal * (options.shapeIsClosedPercentThreshold / 100),
+ );
+ return closedDist < closedThreshold;
+}
+
+/**
+ * Checks if a quadrilateral is likely axis-aligned based on its segment angles.
+ */
+function isAxisAligned(
+ segments: Segment[],
+ orientationThreshold: number,
+): boolean {
+ return segments.some((seg) => {
+ const angle = seg.angleDeg;
+ // Distance to horizontal (0 or 180 degrees)
+ const distToHoriz = Math.min(angle, 180 - angle);
+ // Distance to vertical (90 degrees)
+ const distToVert = Math.abs(angle - 90);
+ return (
+ distToHoriz < orientationThreshold || distToVert < orientationThreshold
+ );
+ });
+}
+
+/**
+ * Calculates the variance of the distance from points to a center point.
+ * Returns a normalized variance value (0 = perfectly round).
+ */
+function calculateRadiusVariance(
+ points: readonly LocalPoint[],
+ boundingBox: BoundingBox,
+): number {
+ if (points.length === 0) {
+ return 0; // Or handle as an error/special case
+ }
+
+ const [cx, cy] = getCenterForBounds([
+ boundingBox.minX,
+ boundingBox.minY,
+ boundingBox.maxX,
+ boundingBox.maxY,
+ ] as Bounds);
+
+ let totalDist = 0;
+ let maxDist = 0;
+ let minDist = Infinity;
+
+ for (const p of points) {
+ const d = Math.hypot(p[0] - cx, p[1] - cy);
+ totalDist += d;
+ maxDist = Math.max(maxDist, d);
+ minDist = Math.min(minDist, d);
+ }
+
+ const avgDist = totalDist / points.length;
+
+ // Avoid division by zero if avgDist is 0 (e.g., all points are at the center)
+ if (avgDist === 0) {
+ return 0;
+ }
+
+ const radiusVariance = (maxDist - minDist) / avgDist;
+ return radiusVariance;
+}
+
+/** Checks if the points form a straight line segment. */
+function checkLine(
+ points: readonly LocalPoint[],
+ isClosed: boolean,
+): Shape | null {
+ if (!isClosed && points.length === LINE_EXPECTED_POINTS) {
+ return "line";
+ }
+ return null;
+}
+
+/** Checks if the points form an arrow shape. */
+function checkArrow(
+ points: readonly LocalPoint[],
+ isClosed: boolean,
+ options: ShapeRecognitionOptions,
+): Shape | null {
+ if (isClosed || points.length !== ARROW_EXPECTED_POINTS) {
+ return null;
+ }
+
+ const shaftStart = points[0];
+ const shaftEnd = points[1]; // Assuming RDP simplifies shaft to 2 points
+ const arrowBase = points[2];
+ const arrowTip = points[3];
+ const arrowTailEnd = points[4];
+
+ const tipAngle = angleBetween(arrowTip, arrowBase, arrowTailEnd);
+
+ if (
+ tipAngle <= options.arrowMinTipAngle ||
+ tipAngle >= options.arrowMaxTipAngle
+ ) {
+ return null;
+ }
+
+ const headSegment1Len = pointDistance(arrowBase, arrowTip);
+ const headSegment2Len = pointDistance(arrowTip, arrowTailEnd);
+ const shaftLen = pointDistance(shaftStart, shaftEnd); // Approx shaft length
+
+ // Heuristic: Arrowhead segments should be significantly shorter than the shaft
+ const isHeadShortEnough =
+ headSegment1Len < shaftLen * options.arrowHeadMaxShaftRatio &&
+ headSegment2Len < shaftLen * options.arrowHeadMaxShaftRatio;
+
+ return isHeadShortEnough ? "arrow" : null;
+}
+
+/** Checks if the points form a rectangle or diamond shape. */
+function checkQuadrilateral(
+ points: readonly LocalPoint[],
+ isClosed: boolean,
+ options: ShapeRecognitionOptions,
+): Shape | null {
+ if (
+ !isClosed ||
+ points.length < QUADRILATERAL_MIN_POINTS ||
+ points.length > QUADRILATERAL_MAX_POINTS
+ ) {
+ return null;
+ }
+
+ // Take the first 4 points as vertices (RDP might add 5th closing point)
+ const vertices = points.slice(0, QUADRILATERAL_SIDES);
+ // console.log("Vertices (Quad Check):", vertices);
+
+ // Calculate internal angles
+ const angles: number[] = [];
+ for (let i = 0; i < QUADRILATERAL_SIDES; i++) {
+ const p1 = vertices[i];
+ const p2 = vertices[(i + 1) % QUADRILATERAL_SIDES];
+ const p3 = vertices[(i + 2) % QUADRILATERAL_SIDES];
+
+ angles.push(angleBetween(p1, p2, p3));
+ }
+
+ const allCornersAreValid = angles.every(
+ (a) =>
+ a > options.rectangleMinCornerAngle &&
+ a < options.rectangleMaxCornerAngle,
+ );
+
+ if (!allCornersAreValid) {
+ return null;
+ }
+
+ const segments = calculateSegments(vertices);
+
+ if (isAxisAligned(segments, options.rectangleOrientationAngleThreshold)) {
+ return "rectangle";
+ }
+ // Not axis-aligned, but quadrilateral => classify as diamond
+ return "diamond";
+}
+
+/** Checks if the points form an ellipse shape. */
+function checkEllipse(
+ points: readonly LocalPoint[],
+ isClosed: boolean,
+ boundingBox: BoundingBox,
+ options: ShapeRecognitionOptions,
+): Shape | null {
+ if (!isClosed) {
+ return null;
+ }
+
+ // Need a minimum number of points for it to be an ellipse
+ if (points.length < QUADRILATERAL_MAX_POINTS) {
+ return null;
+ }
+
+ const radiusVariance = calculateRadiusVariance(points, boundingBox);
+
+ return radiusVariance < options.ellipseRadiusVarianceThreshold
+ ? "ellipse"
+ : null;
+}
+
+/**
+ * Recognizes common shapes from free-draw input points.
+ * @param element The freedraw element to analyze.
+ * @param opts Optional overrides for recognition thresholds.
+ * @returns Information about the recognized shape.
+ */
+export const recognizeShape = (
+ element: ExcalidrawFreeDrawElement,
+ opts: PartialShapeRecognitionOptions = {},
+): ShapeRecognitionResult => {
+ const options = { ...DEFAULT_OPTIONS, ...opts };
+ const { points } = element;
+ const boundingBox = getCommonBoundingBox([element]);
+
+ // Need at least a few points to recognize a shape
+ if (!points || points.length < 3) {
+ return { type: "freedraw", simplified: points, boundingBox };
+ }
+
+ const boundingBoxDiagonal = Math.hypot(boundingBox.width, boundingBox.height);
+ const rdpTolerance =
+ boundingBoxDiagonal * (options.rdpTolerancePercent / 100);
+ const simplifiedPoints = simplifyRDP(points, rdpTolerance);
+
+ const isClosed = isShapeClosed(
+ simplifiedPoints,
+ boundingBoxDiagonal,
+ options,
+ );
+
+ // --- Shape check order matters here ---
+ const recognizedType: Shape =
+ checkLine(simplifiedPoints, isClosed) ??
+ checkArrow(simplifiedPoints, isClosed, options) ??
+ checkQuadrilateral(simplifiedPoints, isClosed, options) ??
+ checkEllipse(simplifiedPoints, isClosed, boundingBox, options) ??
+ "freedraw"; // Default if no other shape matches
+
+ return {
+ type: recognizedType,
+ simplified: simplifiedPoints,
+ boundingBox,
+ };
+};
+
+/**
+ * Converts a freedraw element to the detected shape
+ */
+export const convertToShape = (
+ freeDrawElement: ExcalidrawFreeDrawElement,
+): ExcalidrawElement => {
+ const recognizedShape = recognizeShape(freeDrawElement);
+
+ switch (recognizedShape.type) {
+ case "rectangle":
+ case "diamond":
+ case "ellipse": {
+ return newElement({
+ ...freeDrawElement,
+ roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
+ type: recognizedShape.type,
+ x: recognizedShape.boundingBox.minX,
+ y: recognizedShape.boundingBox.minY,
+ width: recognizedShape.boundingBox.width!,
+ height: recognizedShape.boundingBox.height!,
+ });
+ }
+ case "arrow": {
+ return newArrowElement({
+ ...freeDrawElement,
+ type: recognizedShape.type,
+ points: [
+ recognizedShape.simplified[0],
+ recognizedShape.simplified[recognizedShape.simplified.length - 2],
+ ],
+ roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
+ });
+ }
+ case "line": {
+ return newLinearElement({
+ ...freeDrawElement,
+ type: recognizedShape.type,
+ points: [
+ recognizedShape.simplified[0],
+ recognizedShape.simplified[recognizedShape.simplified.length - 1],
+ ],
+ roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
+ });
+ }
+ default:
+ return freeDrawElement;
+ }
+};
diff --git a/packages/utils/tests/__snapshots__/export.test.ts.snap b/packages/utils/tests/__snapshots__/export.test.ts.snap
index 91108a6004..2609e3f27f 100644
--- a/packages/utils/tests/__snapshots__/export.test.ts.snap
+++ b/packages/utils/tests/__snapshots__/export.test.ts.snap
@@ -60,6 +60,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "name",
diff --git a/packages/utils/tests/__snapshots__/utils.test.ts.snap b/packages/utils/tests/__snapshots__/utils.test.ts.snap
index fdcb71295c..3abde252ae 100644
--- a/packages/utils/tests/__snapshots__/utils.test.ts.snap
+++ b/packages/utils/tests/__snapshots__/utils.test.ts.snap
@@ -53,6 +53,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"isLoading": false,
"isResizing": false,
"isRotating": false,
+ "isShapeSnapEnabled": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "name",