From c22407b3aedb6234b8ad7ed1f76407e4e13a447d Mon Sep 17 00:00:00 2001 From: Mathias Krafft Date: Sat, 8 Mar 2025 16:06:38 +0100 Subject: [PATCH] Initial commit --- packages/excalidraw/appState.ts | 2 + packages/excalidraw/components/App.tsx | 17 ++++ packages/excalidraw/types.ts | 15 +++ packages/utils/package.json | 1 + packages/utils/snapToShape.ts | 136 +++++++++++++++++++++++++ 5 files changed, 171 insertions(+) create mode 100644 packages/utils/snapToShape.ts diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 644949e7c..02c5c15e7 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -65,6 +65,7 @@ export const getDefaultAppState = (): Omit< gridStep: DEFAULT_GRID_STEP, gridModeEnabled: false, isBindingEnabled: true, + isShapeSnapEnabled: true, defaultSidebarDockedPreference: false, isLoading: false, isResizing: false, @@ -186,6 +187,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: true, server: true }, // Add shape snapping config defaultSidebarDockedPreference: { browser: true, export: false, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index dc6d28782..938309c30 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -299,6 +299,7 @@ import { maybeParseEmbedSrc, getEmbedLink, } from "../element/embeddable"; +import { convertToShape } from "@excalidraw/utils/snapToShape"; import type { ContextMenuItems } from "./ContextMenu"; import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu"; import LayerUI from "./LayerUI"; @@ -8989,6 +8990,22 @@ class App extends React.Component { lastCommittedPoint: pointFrom(dx, dy), }); + if (this.state.isShapeSnapEnabled) { + const detectedElement = convertToShape(newElement); + if (detectedElement !== newElement) { + 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/types.ts b/packages/excalidraw/types.ts index 0562736cd..c691a5e0f 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -315,6 +315,7 @@ export interface AppState { currentHoveredFontFamily: FontFamilyValues | null; currentItemRoundness: StrokeRoundness; currentItemArrowType: "sharp" | "round" | "elbow"; + isShapeSnapEnabled: boolean; viewBackgroundColor: string; scrollX: number; scrollY: number; @@ -894,3 +895,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/utils/package.json b/packages/utils/package.json index ddda1e7d6..79284fd6e 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -48,6 +48,7 @@ ] }, "dependencies": { + "@amaplex-software/shapeit": "0.1.6", "@braintree/sanitize-url": "6.0.2", "@excalidraw/laser-pointer": "1.3.1", "browser-fs-access": "0.29.1", diff --git a/packages/utils/snapToShape.ts b/packages/utils/snapToShape.ts new file mode 100644 index 000000000..051001f2f --- /dev/null +++ b/packages/utils/snapToShape.ts @@ -0,0 +1,136 @@ +import type { + ExcalidrawArrowElement, + ExcalidrawDiamondElement, + ExcalidrawElement, + ExcalidrawEllipseElement, + ExcalidrawFreeDrawElement, + ExcalidrawLinearElement, + ExcalidrawRectangleElement, + } from "../excalidraw/element/types"; + import type { BoundingBox } from "../excalidraw/element/bounds"; + import { getCommonBoundingBox } from "../excalidraw/element/bounds"; + import { newElement } from "../excalidraw/element"; + // @ts-ignore + import shapeit from "@amaplex-software/shapeit"; + + type Shape = + | ExcalidrawRectangleElement["type"] + | ExcalidrawEllipseElement["type"] + | ExcalidrawDiamondElement["type"] + // | ExcalidrawArrowElement["type"] + // | ExcalidrawLinearElement["type"] + | ExcalidrawFreeDrawElement["type"]; + + interface ShapeRecognitionResult { + type: Shape; + confidence: number; + boundingBox: BoundingBox; + } + + /** + * Recognizes common shapes from free-draw input + * @param element The freedraw element to analyze + * @returns Information about the recognized shape, or null if no shape is recognized + */ + export const recognizeShape = ( + element: ExcalidrawFreeDrawElement, + ): ShapeRecognitionResult => { + const boundingBox = getCommonBoundingBox([element]); + + // We need at least a few points to recognize a shape + if (!element.points || element.points.length < 3) { + return { type: "freedraw", confidence: 1, boundingBox }; + } + + console.log("Recognizing shape from points:", element.points); + + const shapethat = shapeit.new({ + atlas: {}, + output: {}, + thresholds: {}, + }); + + const shape = shapethat(element.points); + + console.log("Shape recognized:", shape); + + const mappedShape = (name: string): Shape => { + switch (name) { + case "rectangle": + return "rectangle"; + case "square": + return "rectangle"; + case "circle": + return "ellipse"; + case "open polygon": + return "diamond"; + default: + return "freedraw"; + } + }; + + const recognizedShape: ShapeRecognitionResult = { + type: mappedShape(shape.name), + confidence: 0.8, + boundingBox, + }; + + return recognizedShape; + }; + + /** + * Creates a new element based on the recognized shape from a freedraw element + * @param freedrawElement The original freedraw element + * @param recognizedShape The recognized shape information + * @returns A new element of the recognized shape type + */ + export const createElementFromRecognizedShape = ( + freedrawElement: ExcalidrawFreeDrawElement, + recognizedShape: ShapeRecognitionResult, + ): ExcalidrawElement => { + if (!recognizedShape.type || recognizedShape.type === "freedraw") { + return freedrawElement; + } + + // if (recognizedShape.type === "rectangle") { + return newElement({ + ...freedrawElement, + type: recognizedShape.type, + x: recognizedShape.boundingBox.minX, + y: recognizedShape.boundingBox.minY, + width: recognizedShape.boundingBox.width!, + height: recognizedShape.boundingBox.height!, + }); + }; + + /** + * Determines if shape recognition should be applied based on app state + * @param element The freedraw element to potentially snap + * @param minConfidence The minimum confidence level required to apply snapping + * @returns Whether to apply shape snapping + */ + export const shouldApplyShapeSnapping = ( + recognizedShape: ShapeRecognitionResult, + minConfidence: number = 0.75, + ): boolean => { + return ( + !!recognizedShape.type && (recognizedShape.confidence || 0) >= minConfidence + ); + }; + + /** + * Converts a freedraw element to the detected shape + */ + export const convertToShape = ( + freeDrawElement: ExcalidrawFreeDrawElement, + ): ExcalidrawElement => { + const recognizedShape = recognizeShape(freeDrawElement); + + if (shouldApplyShapeSnapping(recognizedShape)) { + return createElementFromRecognizedShape(freeDrawElement, recognizedShape); + } + + // Add more shape conversions as needed + return freeDrawElement; + }; + \ No newline at end of file