diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 5a309b6775..0ad1b518f8 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -121,6 +121,8 @@ import { ArrowheadCrowfootIcon, ArrowheadCrowfootOneIcon, ArrowheadCrowfootOneOrManyIcon, + snapShapeEnabledIcon, + snapShapeDisabledIcon, } from "../components/icons"; import { Fonts } from "../fonts"; @@ -1818,3 +1820,41 @@ export const actionChangeArrowType = register({ ); }, }); + +export const actionToggleShapeSnap = register({ + name: "toggleShapeSnap", + label: "Toggle Snap to Shape", + trackEvent: false, + perform: (elements, appState) => { + return { + elements, + appState: { + ...appState, + isShapeSnapEnabled: !appState.isShapeSnapEnabled, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + PanelComponent: ({ appState, updateData }) => ( +
+ {t("labels.shapeSnap")} + updateData(value)} + /> +
+ ), +}); diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index c63a122e04..33987f4649 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -125,6 +125,7 @@ export type ActionName = | "toggleLinearEditor" | "toggleEraserTool" | "toggleHandTool" + | "toggleShapeSnap" | "selectAllElementsInFrame" | "removeAllElementsFromFrame" | "updateFrameRendering" diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index a75745f2a2..bcf37868ca 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -67,6 +67,7 @@ export const getDefaultAppState = (): Omit< gridStep: DEFAULT_GRID_STEP, gridModeEnabled: false, isBindingEnabled: true, + isShapeSnapEnabled: false, defaultSidebarDockedPreference: false, isLoading: false, isResizing: false, @@ -188,6 +189,7 @@ const APP_STATE_STORAGE_CONF = (< gridModeEnabled: { browser: true, export: true, server: true }, height: { browser: false, export: false, server: false }, isBindingEnabled: { browser: false, export: false, server: false }, + isShapeSnapEnabled: { browser: true, export: false, server: false }, defaultSidebarDockedPreference: { browser: true, export: false, diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 3a7df37a85..156ff425f1 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -169,9 +169,12 @@ export const SelectedShapeActions = ({ renderAction("changeStrokeWidth")} {(appState.activeTool.type === "freedraw" || - targetElements.some((element) => element.type === "freedraw")) && - renderAction("changeStrokeShape")} - + targetElements.some((element) => element.type === "freedraw")) && ( + <> + {renderAction("changeStrokeShape")} + {renderAction("toggleShapeSnap")} + + )} {(hasStrokeStyle(appState.activeTool.type) || targetElements.some((element) => hasStrokeStyle(element.type))) && ( <> diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 276cde0274..9931cf38b6 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -19,6 +19,7 @@ import { } from "@excalidraw/math"; import { isPointInShape } from "@excalidraw/utils/collision"; import { getSelectionBoxShape } from "@excalidraw/utils/shape"; +import { convertToShape } from "@excalidraw/utils/snapToShape"; import { COLOR_PALETTE, @@ -9039,6 +9040,34 @@ class App extends React.Component { lastCommittedPoint: pointFrom(dx, dy), }); + if (this.state.isShapeSnapEnabled) { + const detectedElement = convertToShape(newElement); + + if (detectedElement !== newElement) { + if (detectedElement.type === "arrow") { + mutateElement( + detectedElement, + { + startArrowhead: this.state.currentItemStartArrowhead, + endArrowhead: this.state.currentItemEndArrowhead, + }, + // TODO: Make arrows bind to nearby elements if possible + ); + } + + this.scene.replaceAllElements([ + ...this.scene + .getElementsIncludingDeleted() + .filter((el) => el.id !== newElement.id), + detectedElement, + ]); + + this.setState({ + selectedElementIds: { [detectedElement.id]: true }, + }); + } + } + this.actionManager.executeAction(actionFinalize); return; diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index f3808a69d0..70d6a9a6d4 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -1902,6 +1902,23 @@ export const eyeClosedIcon = createIcon( tablerIconProps, ); +export const snapShapeEnabledIcon = createIcon( + + + + , + tablerIconProps, +); + +export const snapShapeDisabledIcon = createIcon( + + + + + , + tablerIconProps, +); + export const brainIcon = createIcon( 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",