From c22407b3aedb6234b8ad7ed1f76407e4e13a447d Mon Sep 17 00:00:00 2001 From: Mathias Krafft Date: Sat, 8 Mar 2025 16:06:38 +0100 Subject: [PATCH 1/7] 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 644949e7c8..02c5c15e7c 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 dc6d287828..938309c30f 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 0562736cd0..c691a5e0fc 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 ddda1e7d61..79284fd6eb 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 0000000000..051001f2fb --- /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 From 89a733120f2f1b7bac4618d1eb68f8589bf8053d Mon Sep 17 00:00:00 2001 From: Mathias Krafft Date: Fri, 28 Mar 2025 09:14:17 +0100 Subject: [PATCH 2/7] Segments detection --- packages/math/point.ts | 62 +++++- packages/utils/package.json | 1 - packages/utils/snapToShape.ts | 391 +++++++++++++++++++++++----------- 3 files changed, 321 insertions(+), 133 deletions(-) diff --git a/packages/math/point.ts b/packages/math/point.ts index 92df18773d..b741c4c8ee 100644 --- a/packages/math/point.ts +++ b/packages/math/point.ts @@ -1,4 +1,4 @@ -import { degreesToRadians } from "./angle"; +import { degreesToRadians, radiansToDegrees } from "./angle"; import type { LocalPoint, GlobalPoint, @@ -7,7 +7,7 @@ import type { Vector, } from "./types"; import { PRECISION } from "./utils"; -import { vectorFromPoint, vectorScale } from "./vector"; +import { vectorDot, vectorFromPoint, vectorScale } from "./vector"; /** * Create a properly typed Point instance from the X and Y coordinates. @@ -229,3 +229,61 @@ 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]), 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/package.json b/packages/utils/package.json index 79284fd6eb..ddda1e7d61 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -48,7 +48,6 @@ ] }, "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 index 051001f2fb..acbf39ab00 100644 --- a/packages/utils/snapToShape.ts +++ b/packages/utils/snapToShape.ts @@ -1,136 +1,267 @@ 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; + ExcalidrawArrowElement, + ExcalidrawDiamondElement, + ExcalidrawElement, + ExcalidrawEllipseElement, + ExcalidrawFreeDrawElement, + ExcalidrawLinearElement, + ExcalidrawRectangleElement, +} from "../excalidraw/element/types"; +import type { BoundingBox, Bounds } from "../excalidraw/element/bounds"; +import { getCenterForBounds, getCommonBoundingBox } from "../excalidraw/element/bounds"; +import { newArrowElement, newElement, newLinearElement } from "../excalidraw/element"; +import { angleBetween, GlobalPoint, LocalPoint, perpendicularDistance, pointDistance } from "@excalidraw/math"; +import { ROUNDNESS } from "@excalidraw/excalidraw/constants"; + +type Shape = + | ExcalidrawRectangleElement["type"] + | ExcalidrawEllipseElement["type"] + | ExcalidrawDiamondElement["type"] + | ExcalidrawArrowElement["type"] + | ExcalidrawLinearElement["type"] + | ExcalidrawFreeDrawElement["type"]; + +interface ShapeRecognitionResult { + type: Shape; + simplified: readonly LocalPoint[]; + boundingBox: BoundingBox; +} + +interface ShapeRecognitionOptions { + closedDistThreshPercent: number; // Max distance between stroke start/end to consider shape closed + cornerAngleThresh: number; // Angle (in degrees) below which a corner is considered "sharp" (for arrow detection) + rdpTolerancePercent: number; // RDP simplification tolerance (percentage of bounding box diagonal) + rectAngleThresh: number; // Angle (in degrees) to check for rectangle corners + rectOrientationThresh: number; // Angle difference (in degrees) to nearest 0/90 orientation to call it rectangle +} + +const DEFAULT_OPTIONS: ShapeRecognitionOptions = { + closedDistThreshPercent: 10, // distance between start/end < % of bounding box diagonal + cornerAngleThresh: 60, // <60° considered a sharp corner (possible arrow tip) + rdpTolerancePercent: 10, // percentage of bounding box diagonal + rectAngleThresh: 20, // <20° considered a sharp corner (rectangle) + rectOrientationThresh: 10, // +}; + + +/** + * 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, + opts: Partial = {}, +): ShapeRecognitionResult => { + const options = { ...DEFAULT_OPTIONS, ...opts }; + + 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", simplified: element.points, 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 }; + + const tolerance = pointDistance( + [boundingBox.minX, boundingBox.minY] as LocalPoint, + [boundingBox.maxX, boundingBox.maxY] as LocalPoint, + ) * options.rdpTolerancePercent / 100; + const simplified = simplifyRDP(element.points, tolerance); + console.log("Simplified points:", simplified); + + // Check if the original points form a closed shape + const start = element.points[0], end = element.points[element.points.length - 1]; + const closedDist = pointDistance(start, end); + const diag = Math.hypot(boundingBox.width, boundingBox.height); // diagonal of bounding box + const isClosed = closedDist < Math.max(10, diag * options.closedDistThreshPercent / 100); // e.g., threshold: 10px or % of size + console.log("Closed shape:", isClosed); + + let bestShape: Shape = 'freedraw'; // TODO: Should this even be possible in this mode? + + const boundingBoxCenter = getCenterForBounds([ + boundingBox.minX, + boundingBox.minY, + boundingBox.maxX, + boundingBox.maxY, + ] as Bounds); + + // **Line** (open shape with low deviation from a straight line) + if (!isClosed && simplified.length == 2) { + bestShape = 'line'; + } + + // **Arrow** (open shape with a sharp angle indicating an arrowhead) + if (!isClosed && simplified.length == 5) { + // The last two segments will make an arrowhead + console.log("Simplified points:", simplified); + const arrow_start = simplified[2], arrow_tip = simplified[3], arrow_end = simplified[4]; + const tipAngle = angleBetween(arrow_tip, arrow_start, arrow_end); // angle at the second-last point (potential arrow tip) + // Lengths of the last two segments + + const seg1Len = pointDistance(arrow_start, arrow_tip); + const seg2Len = pointDistance(arrow_tip, arrow_end); + // Length of the rest of the stroke (approx arrow shaft length) + const shaftLen = pointDistance(simplified[0], simplified[1]) + // Heuristic checks for arrowhead: sharp angle and short segments relative to shaft + console.log("Arrow tip angle:", tipAngle); + if (tipAngle > 30 && tipAngle < 150 && seg1Len < shaftLen * 0.8 && seg2Len < shaftLen * 0.8) { + bestShape = 'arrow'; } - - 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"; + } + + // **Rectangle or Diamond** (closed shape with 4 corners - RDP might include last point + if (isClosed && (simplified.length == 4 || simplified.length == 5)) { + const vertices = simplified.slice(); // copy + if (simplified.length === 5) { + vertices.pop(); // remove last point if RDP included it + } + + // Compute angles at each corner + console.log("Vertices:", vertices); + var angles = [] + for (let i = 0; i < vertices.length; i++) { + angles.push(angleBetween(vertices[i], vertices[(i + 1) % vertices.length], vertices[(i + 2) % vertices.length])); + } + + console.log("Angles:", angles); + console.log("Angles sum:", angles.reduce((a, b) => a + b, 0)); + + // All angles are sharp enough, so we can check for rectangle/diamond + if (angles.every(a => (a > options.rectAngleThresh && a < 180 - options.rectAngleThresh))) { + // Determine orientation by checking the slope of each segment + interface Segment { length: number; angleDeg: number; } + const segments: Segment[] = []; + for (let i = 0; i < 4; i++) { + const p1 = simplified[i]; + const p2 = simplified[(i + 1) % (simplified.length)]; + const dx = p2[0] - p1[0]; + const dy = p2[1] - p1[1]; + const length = Math.hypot(dx, dy); + // angle of segment in degrees from horizontal + let segAngle = (Math.atan2(dy, dx) * 180) / Math.PI; + if (segAngle < 0) segAngle += 360; + if (segAngle > 180) segAngle -= 180; // use [0,180] range for undirected line + segments.push({ length, angleDeg: segAngle }); + } + // Check for axis-aligned orientation + const hasAxisAlignedSide = segments.some(seg => { + const angle = seg.angleDeg; + const distToHoriz = Math.min(Math.abs(angle - 0), Math.abs(angle - 180)); + const distToVert = Math.abs(angle - 90); + return (distToHoriz < options.rectOrientationThresh) || (distToVert < options.rectOrientationThresh); + }); + if (hasAxisAlignedSide) { + bestShape = "rectangle"; + } else { + // Not near axis-aligned, likely a rotated shape -> diamond + bestShape = "diamond"; } - }; - - 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); + } else { + const aspectRatio = boundingBox.width && boundingBox.height ? Math.min(boundingBox.width, boundingBox.height) / Math.max(boundingBox.width, boundingBox.height) : 1; + // If aspect ratio ~1 (nearly square) and simplified has few corners, good for circle + if (aspectRatio > 0.8) { + // Measure radius variance + const cx = boundingBoxCenter[0]; + const cy = boundingBoxCenter[1]; + let totalDist = 0, maxDist = 0, minDist = Infinity; + for (const p of simplified) { + 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 / simplified.length; + const radiusVar = (maxDist - minDist) / (avgDist || 1); + // If variance in radius is small, shape is round + if (radiusVar < 0.3) { + bestShape = 'ellipse'; + } } - - // Add more shape conversions as needed - return freeDrawElement; - }; - \ No newline at end of file + } + + return { + type: bestShape, + simplified, + boundingBox + } as ShapeRecognitionResult; +}; + +/** + * Simplify a polyline using Ramer-Douglas-Peucker algorithm. + * @param points Array of points [x,y] representing the stroke. + * @param epsilon Tolerance for simplification (higher = more simplification). + * @returns Simplified list of points. + */ +function simplifyRDP(points: readonly LocalPoint[], epsilon: number): readonly LocalPoint[] { + if (points.length < 3) return points; + // Find the point with the maximum distance from the line between first and last + const first = points[0], last = points[points.length - 1]; + let index = -1; + let maxDist = 0; + for (let i = 1; i < points.length - 1; i++) { + // Perpendicular distance from points[i] to line (first-last) + 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); + } else { + // Not enough deviation, return straight line (keep only endpoints) + return [first, last]; + } +} + +/** + * 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, + endArrowhead: "arrow", // TODO: Get correct state + 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 + } +}; From 1919b3db1a8507e3ae7f00fcf00376fabfaef8f2 Mon Sep 17 00:00:00 2001 From: Mathias Krafft Date: Fri, 28 Mar 2025 09:33:11 +0100 Subject: [PATCH 3/7] Remove aspect ratio --- packages/utils/snapToShape.ts | 72 +++++++++++++++++------------------ 1 file changed, 34 insertions(+), 38 deletions(-) diff --git a/packages/utils/snapToShape.ts b/packages/utils/snapToShape.ts index acbf39ab00..ee9d51024d 100644 --- a/packages/utils/snapToShape.ts +++ b/packages/utils/snapToShape.ts @@ -28,19 +28,21 @@ interface ShapeRecognitionResult { } interface ShapeRecognitionOptions { - closedDistThreshPercent: number; // Max distance between stroke start/end to consider shape closed - cornerAngleThresh: number; // Angle (in degrees) below which a corner is considered "sharp" (for arrow detection) - rdpTolerancePercent: number; // RDP simplification tolerance (percentage of bounding box diagonal) - rectAngleThresh: number; // Angle (in degrees) to check for rectangle corners - rectOrientationThresh: number; // Angle difference (in degrees) to nearest 0/90 orientation to call it rectangle + shapeIsClosedPercentThreshold: number; // Max distance between stroke start/end to consider shape closed + arrowTipAngleThreshold: number; // Angle (in degrees) below which a corner is considered "sharp" (for arrow detection) + rdpTolerancePercent: number; // RDP simplification tolerance (percentage of bounding box diagonal) + rectangleCornersAngleThreshold: number; // Angle (in degrees) to check for rectangle corners + rectangleOrientationAngleThreshold: number; // Angle difference (in degrees) to nearest 0/90 orientation to call it rectangle + ellipseRadiusVarianceThreshold: number; // Variance in radius to consider a shape an ellipse } const DEFAULT_OPTIONS: ShapeRecognitionOptions = { - closedDistThreshPercent: 10, // distance between start/end < % of bounding box diagonal - cornerAngleThresh: 60, // <60° considered a sharp corner (possible arrow tip) - rdpTolerancePercent: 10, // percentage of bounding box diagonal - rectAngleThresh: 20, // <20° considered a sharp corner (rectangle) - rectOrientationThresh: 10, // + shapeIsClosedPercentThreshold: 20, + arrowTipAngleThreshold: 60, + rdpTolerancePercent: 10, + rectangleCornersAngleThreshold: 20, + rectangleOrientationAngleThreshold: 10, + ellipseRadiusVarianceThreshold: 0.5 }; @@ -67,16 +69,14 @@ export const recognizeShape = ( [boundingBox.maxX, boundingBox.maxY] as LocalPoint, ) * options.rdpTolerancePercent / 100; const simplified = simplifyRDP(element.points, tolerance); - console.log("Simplified points:", simplified); - // Check if the original points form a closed shape const start = element.points[0], end = element.points[element.points.length - 1]; const closedDist = pointDistance(start, end); - const diag = Math.hypot(boundingBox.width, boundingBox.height); // diagonal of bounding box - const isClosed = closedDist < Math.max(10, diag * options.closedDistThreshPercent / 100); // e.g., threshold: 10px or % of size - console.log("Closed shape:", isClosed); + const boundingBoxDiagonal = Math.hypot(boundingBox.width, boundingBox.height); + // e.g., threshold: 10px or % of size + const isClosed = closedDist < Math.max(10, boundingBoxDiagonal * options.shapeIsClosedPercentThreshold / 100); - let bestShape: Shape = 'freedraw'; // TODO: Should this even be possible in this mode? + let bestShape: Shape = 'freedraw'; const boundingBoxCenter = getCenterForBounds([ boundingBox.minX, @@ -127,7 +127,7 @@ export const recognizeShape = ( console.log("Angles sum:", angles.reduce((a, b) => a + b, 0)); // All angles are sharp enough, so we can check for rectangle/diamond - if (angles.every(a => (a > options.rectAngleThresh && a < 180 - options.rectAngleThresh))) { + if (angles.every(a => (a > options.rectangleCornersAngleThreshold && a < 180 - options.rectangleCornersAngleThreshold))) { // Determine orientation by checking the slope of each segment interface Segment { length: number; angleDeg: number; } const segments: Segment[] = []; @@ -148,7 +148,7 @@ export const recognizeShape = ( const angle = seg.angleDeg; const distToHoriz = Math.min(Math.abs(angle - 0), Math.abs(angle - 180)); const distToVert = Math.abs(angle - 90); - return (distToHoriz < options.rectOrientationThresh) || (distToVert < options.rectOrientationThresh); + return (distToHoriz < options.rectangleOrientationAngleThreshold) || (distToVert < options.rectangleOrientationAngleThreshold); }); if (hasAxisAlignedSide) { bestShape = "rectangle"; @@ -157,26 +157,22 @@ export const recognizeShape = ( bestShape = "diamond"; } } - } else { - const aspectRatio = boundingBox.width && boundingBox.height ? Math.min(boundingBox.width, boundingBox.height) / Math.max(boundingBox.width, boundingBox.height) : 1; - // If aspect ratio ~1 (nearly square) and simplified has few corners, good for circle - if (aspectRatio > 0.8) { - // Measure radius variance - const cx = boundingBoxCenter[0]; - const cy = boundingBoxCenter[1]; - let totalDist = 0, maxDist = 0, minDist = Infinity; - for (const p of simplified) { - 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 / simplified.length; - const radiusVar = (maxDist - minDist) / (avgDist || 1); - // If variance in radius is small, shape is round - if (radiusVar < 0.3) { - bestShape = 'ellipse'; - } + } else if (isClosed) { // **Ellipse** (closed shape with few corners) + // Measure radius variance + const cx = boundingBoxCenter[0]; + const cy = boundingBoxCenter[1]; + let totalDist = 0, maxDist = 0, minDist = Infinity; + for (const p of simplified) { + 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 / simplified.length; + const radiusVar = (maxDist - minDist) / (avgDist || 1); + // If variance in radius is small, shape is round + if (radiusVar < options.ellipseRadiusVarianceThreshold) { + bestShape = 'ellipse'; } } From 074445d3092145e3dec1c24e2bfe3667ddfc3a40 Mon Sep 17 00:00:00 2001 From: Mathias Krafft Date: Fri, 28 Mar 2025 10:50:19 +0100 Subject: [PATCH 4/7] Refactor --- packages/math/point.ts | 20 +- packages/utils/snapToShape.ts | 465 ++++++++++++++++++++++------------ 2 files changed, 318 insertions(+), 167 deletions(-) diff --git a/packages/math/point.ts b/packages/math/point.ts index b741c4c8ee..9d02f97ba7 100644 --- a/packages/math/point.ts +++ b/packages/math/point.ts @@ -241,20 +241,20 @@ export const isPointWithinBounds =

( * @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 =

( +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; + 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. diff --git a/packages/utils/snapToShape.ts b/packages/utils/snapToShape.ts index ee9d51024d..dbf5506c2f 100644 --- a/packages/utils/snapToShape.ts +++ b/packages/utils/snapToShape.ts @@ -27,182 +27,67 @@ interface ShapeRecognitionResult { boundingBox: BoundingBox; } -interface ShapeRecognitionOptions { - shapeIsClosedPercentThreshold: number; // Max distance between stroke start/end to consider shape closed - arrowTipAngleThreshold: number; // Angle (in degrees) below which a corner is considered "sharp" (for arrow detection) - rdpTolerancePercent: number; // RDP simplification tolerance (percentage of bounding box diagonal) - rectangleCornersAngleThreshold: number; // Angle (in degrees) to check for rectangle corners - rectangleOrientationAngleThreshold: number; // Angle difference (in degrees) to nearest 0/90 orientation to call it rectangle - ellipseRadiusVarianceThreshold: number; // Variance in radius to consider a shape an ellipse -} +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: ShapeRecognitionOptions = { +const DEFAULT_OPTIONS = { + // Max distance between stroke start/end (as % of bbox diagonal) to consider closed shapeIsClosedPercentThreshold: 20, - arrowTipAngleThreshold: 60, + // Min distance (px) to consider shape closed (takes precedence if larger than %) + shapeIsClosedDistanceThreshold: 10, + // RDP simplification tolerance (% of bbox diagonal) rdpTolerancePercent: 10, - rectangleCornersAngleThreshold: 20, + // 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, - ellipseRadiusVarianceThreshold: 0.5 -}; + // 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 -/** - * 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, - opts: Partial = {}, -): ShapeRecognitionResult => { - const options = { ...DEFAULT_OPTIONS, ...opts }; +// Options for shape recognition, allowing partial overrides +type ShapeRecognitionOptions = typeof DEFAULT_OPTIONS; +type PartialShapeRecognitionOptions = Partial; - 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", simplified: element.points, boundingBox }; - } - - const tolerance = pointDistance( - [boundingBox.minX, boundingBox.minY] as LocalPoint, - [boundingBox.maxX, boundingBox.maxY] as LocalPoint, - ) * options.rdpTolerancePercent / 100; - const simplified = simplifyRDP(element.points, tolerance); - - const start = element.points[0], end = element.points[element.points.length - 1]; - const closedDist = pointDistance(start, end); - const boundingBoxDiagonal = Math.hypot(boundingBox.width, boundingBox.height); - // e.g., threshold: 10px or % of size - const isClosed = closedDist < Math.max(10, boundingBoxDiagonal * options.shapeIsClosedPercentThreshold / 100); - - let bestShape: Shape = 'freedraw'; - - const boundingBoxCenter = getCenterForBounds([ - boundingBox.minX, - boundingBox.minY, - boundingBox.maxX, - boundingBox.maxY, - ] as Bounds); - - // **Line** (open shape with low deviation from a straight line) - if (!isClosed && simplified.length == 2) { - bestShape = 'line'; - } - - // **Arrow** (open shape with a sharp angle indicating an arrowhead) - if (!isClosed && simplified.length == 5) { - // The last two segments will make an arrowhead - console.log("Simplified points:", simplified); - const arrow_start = simplified[2], arrow_tip = simplified[3], arrow_end = simplified[4]; - const tipAngle = angleBetween(arrow_tip, arrow_start, arrow_end); // angle at the second-last point (potential arrow tip) - // Lengths of the last two segments - - const seg1Len = pointDistance(arrow_start, arrow_tip); - const seg2Len = pointDistance(arrow_tip, arrow_end); - // Length of the rest of the stroke (approx arrow shaft length) - const shaftLen = pointDistance(simplified[0], simplified[1]) - // Heuristic checks for arrowhead: sharp angle and short segments relative to shaft - console.log("Arrow tip angle:", tipAngle); - if (tipAngle > 30 && tipAngle < 150 && seg1Len < shaftLen * 0.8 && seg2Len < shaftLen * 0.8) { - bestShape = 'arrow'; - } - } - - // **Rectangle or Diamond** (closed shape with 4 corners - RDP might include last point - if (isClosed && (simplified.length == 4 || simplified.length == 5)) { - const vertices = simplified.slice(); // copy - if (simplified.length === 5) { - vertices.pop(); // remove last point if RDP included it - } - - // Compute angles at each corner - console.log("Vertices:", vertices); - var angles = [] - for (let i = 0; i < vertices.length; i++) { - angles.push(angleBetween(vertices[i], vertices[(i + 1) % vertices.length], vertices[(i + 2) % vertices.length])); - } - - console.log("Angles:", angles); - console.log("Angles sum:", angles.reduce((a, b) => a + b, 0)); - - // All angles are sharp enough, so we can check for rectangle/diamond - if (angles.every(a => (a > options.rectangleCornersAngleThreshold && a < 180 - options.rectangleCornersAngleThreshold))) { - // Determine orientation by checking the slope of each segment - interface Segment { length: number; angleDeg: number; } - const segments: Segment[] = []; - for (let i = 0; i < 4; i++) { - const p1 = simplified[i]; - const p2 = simplified[(i + 1) % (simplified.length)]; - const dx = p2[0] - p1[0]; - const dy = p2[1] - p1[1]; - const length = Math.hypot(dx, dy); - // angle of segment in degrees from horizontal - let segAngle = (Math.atan2(dy, dx) * 180) / Math.PI; - if (segAngle < 0) segAngle += 360; - if (segAngle > 180) segAngle -= 180; // use [0,180] range for undirected line - segments.push({ length, angleDeg: segAngle }); - } - // Check for axis-aligned orientation - const hasAxisAlignedSide = segments.some(seg => { - const angle = seg.angleDeg; - const distToHoriz = Math.min(Math.abs(angle - 0), Math.abs(angle - 180)); - const distToVert = Math.abs(angle - 90); - return (distToHoriz < options.rectangleOrientationAngleThreshold) || (distToVert < options.rectangleOrientationAngleThreshold); - }); - if (hasAxisAlignedSide) { - bestShape = "rectangle"; - } else { - // Not near axis-aligned, likely a rotated shape -> diamond - bestShape = "diamond"; - } - } - } else if (isClosed) { // **Ellipse** (closed shape with few corners) - // Measure radius variance - const cx = boundingBoxCenter[0]; - const cy = boundingBoxCenter[1]; - let totalDist = 0, maxDist = 0, minDist = Infinity; - for (const p of simplified) { - 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 / simplified.length; - const radiusVar = (maxDist - minDist) / (avgDist || 1); - // If variance in radius is small, shape is round - if (radiusVar < options.ellipseRadiusVarianceThreshold) { - bestShape = 'ellipse'; - } - } - - return { - type: bestShape, - simplified, - boundingBox - } as ShapeRecognitionResult; -}; +interface Segment { + length: number; + angleDeg: number; // Angle in degrees [0, 180) representing the line's orientation +} /** * Simplify a polyline using Ramer-Douglas-Peucker algorithm. - * @param points Array of points [x,y] representing the stroke. - * @param epsilon Tolerance for simplification (higher = more simplification). - * @returns Simplified list of points. */ -function simplifyRDP(points: readonly LocalPoint[], epsilon: number): readonly LocalPoint[] { - if (points.length < 3) return points; - // Find the point with the maximum distance from the line between first and last - const first = points[0], last = points[points.length - 1]; +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++) { - // Perpendicular distance from points[i] to line (first-last) 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); @@ -210,11 +95,277 @@ function simplifyRDP(points: readonly LocalPoint[], epsilon: number): readonly L // Concatenate results (omit duplicate point at junction) return left.slice(0, -1).concat(right); } else { - // Not enough deviation, return straight line (keep only endpoints) + // 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"; + } else { + // 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 --- + let 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 */ From 452373d769d97d32a7b885f200101e198afbfdf2 Mon Sep 17 00:00:00 2001 From: Mathias Krafft Date: Tue, 1 Apr 2025 15:24:41 +0200 Subject: [PATCH 5/7] Fix tests --- packages/excalidraw/appState.ts | 220 +++++++++--------- .../__snapshots__/contextmenu.test.tsx.snap | 17 ++ .../tests/__snapshots__/history.test.tsx.snap | 58 +++++ .../regressionTests.test.tsx.snap | 52 +++++ .../packages/__snapshots__/utils.test.ts.snap | 1 + .../tests/__snapshots__/export.test.ts.snap | 1 + .../tests/__snapshots__/utils.test.ts.snap | 1 + 7 files changed, 240 insertions(+), 110 deletions(-) diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 26ce3b7c6f..c01af195d6 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -66,7 +66,7 @@ export const getDefaultAppState = (): Omit< gridStep: DEFAULT_GRID_STEP, gridModeEnabled: false, isBindingEnabled: true, - isShapeSnapEnabled: true, + isShapeSnapEnabled: false, defaultSidebarDockedPreference: false, isLoading: false, isResizing: false, @@ -141,113 +141,113 @@ const APP_STATE_STORAGE_CONF = (< T extends Record, >(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) => config)({ - showWelcomeScreen: { browser: true, export: false, server: false }, - theme: { browser: true, export: false, server: false }, - collaborators: { browser: false, export: false, server: false }, - currentChartType: { browser: true, export: false, server: false }, - currentItemBackgroundColor: { browser: true, export: false, server: false }, - currentItemEndArrowhead: { browser: true, export: false, server: false }, - currentItemFillStyle: { browser: true, export: false, server: false }, - currentItemFontFamily: { browser: true, export: false, server: false }, - currentItemFontSize: { browser: true, export: false, server: false }, - currentItemRoundness: { - browser: true, - export: false, - server: false, - }, - currentItemArrowType: { - browser: true, - export: false, - server: false, - }, - currentItemOpacity: { browser: true, export: false, server: false }, - currentItemRoughness: { browser: true, export: false, server: false }, - currentItemStartArrowhead: { browser: true, export: false, server: false }, - currentItemStrokeColor: { browser: true, export: false, server: false }, - currentItemStrokeStyle: { browser: true, export: false, server: false }, - currentItemStrokeWidth: { browser: true, export: false, server: false }, - currentItemTextAlign: { browser: true, export: false, server: false }, - currentHoveredFontFamily: { browser: false, export: false, server: false }, - cursorButton: { browser: true, export: false, server: false }, - activeEmbeddable: { browser: false, export: false, server: false }, - newElement: { browser: false, export: false, server: false }, - editingTextElement: { browser: false, export: false, server: false }, - editingGroupId: { browser: true, export: false, server: false }, - editingLinearElement: { browser: false, export: false, server: false }, - activeTool: { browser: true, export: false, server: false }, - penMode: { browser: true, export: false, server: false }, - penDetected: { browser: true, export: false, server: false }, - errorMessage: { browser: false, export: false, server: false }, - exportBackground: { browser: true, export: false, server: false }, - exportEmbedScene: { browser: true, export: false, server: false }, - exportScale: { browser: true, export: false, server: false }, - exportWithDarkMode: { browser: true, export: false, server: false }, - fileHandle: { browser: false, export: false, server: false }, - gridSize: { browser: true, export: true, server: true }, - gridStep: { browser: true, export: true, server: true }, - 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, - server: false, - }, - isLoading: { browser: false, export: false, server: false }, - isResizing: { browser: false, export: false, server: false }, - isRotating: { browser: false, export: false, server: false }, - lastPointerDownWith: { browser: true, export: false, server: false }, - multiElement: { browser: false, export: false, server: false }, - name: { browser: true, export: false, server: false }, - offsetLeft: { browser: false, export: false, server: false }, - offsetTop: { browser: false, export: false, server: false }, - contextMenu: { browser: false, export: false, server: false }, - openMenu: { browser: true, export: false, server: false }, - openPopup: { browser: false, export: false, server: false }, - openSidebar: { browser: true, export: false, server: false }, - openDialog: { browser: false, export: false, server: false }, - pasteDialog: { browser: false, export: false, server: false }, - previousSelectedElementIds: { browser: true, export: false, server: false }, - resizingElement: { browser: false, export: false, server: false }, - scrolledOutside: { browser: true, export: false, server: false }, - scrollX: { browser: true, export: false, server: false }, - scrollY: { browser: true, export: false, server: false }, - selectedElementIds: { browser: true, export: false, server: false }, - hoveredElementIds: { browser: false, export: false, server: false }, - selectedGroupIds: { browser: true, export: false, server: false }, - selectedElementsAreBeingDragged: { - browser: false, - export: false, - server: false, - }, - selectionElement: { browser: false, export: false, server: false }, - shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, - stats: { browser: true, export: false, server: false }, - startBoundElement: { browser: false, export: false, server: false }, - suggestedBindings: { browser: false, export: false, server: false }, - frameRendering: { browser: false, export: false, server: false }, - frameToHighlight: { browser: false, export: false, server: false }, - editingFrame: { browser: false, export: false, server: false }, - elementsToHighlight: { browser: false, export: false, server: false }, - toast: { browser: false, export: false, server: false }, - viewBackgroundColor: { browser: true, export: true, server: true }, - width: { browser: false, export: false, server: false }, - zenModeEnabled: { browser: true, export: false, server: false }, - zoom: { browser: true, export: false, server: false }, - viewModeEnabled: { browser: false, export: false, server: false }, - pendingImageElementId: { browser: false, export: false, server: false }, - showHyperlinkPopup: { browser: false, export: false, server: false }, - selectedLinearElement: { browser: true, export: false, server: false }, - snapLines: { browser: false, export: false, server: false }, - originSnapOffset: { browser: false, export: false, server: false }, - objectsSnapModeEnabled: { browser: true, export: false, server: false }, - userToFollow: { browser: false, export: false, server: false }, - followedBy: { browser: false, export: false, server: false }, - isCropping: { browser: false, export: false, server: false }, - croppingElementId: { browser: false, export: false, server: false }, - searchMatches: { browser: false, export: false, server: false }, -}); + showWelcomeScreen: { browser: true, export: false, server: false }, + theme: { browser: true, export: false, server: false }, + collaborators: { browser: false, export: false, server: false }, + currentChartType: { browser: true, export: false, server: false }, + currentItemBackgroundColor: { browser: true, export: false, server: false }, + currentItemEndArrowhead: { browser: true, export: false, server: false }, + currentItemFillStyle: { browser: true, export: false, server: false }, + currentItemFontFamily: { browser: true, export: false, server: false }, + currentItemFontSize: { browser: true, export: false, server: false }, + currentItemRoundness: { + browser: true, + export: false, + server: false, + }, + currentItemArrowType: { + browser: true, + export: false, + server: false, + }, + currentItemOpacity: { browser: true, export: false, server: false }, + currentItemRoughness: { browser: true, export: false, server: false }, + currentItemStartArrowhead: { browser: true, export: false, server: false }, + currentItemStrokeColor: { browser: true, export: false, server: false }, + currentItemStrokeStyle: { browser: true, export: false, server: false }, + currentItemStrokeWidth: { browser: true, export: false, server: false }, + currentItemTextAlign: { browser: true, export: false, server: false }, + currentHoveredFontFamily: { browser: false, export: false, server: false }, + cursorButton: { browser: true, export: false, server: false }, + activeEmbeddable: { browser: false, export: false, server: false }, + newElement: { browser: false, export: false, server: false }, + editingTextElement: { browser: false, export: false, server: false }, + editingGroupId: { browser: true, export: false, server: false }, + editingLinearElement: { browser: false, export: false, server: false }, + activeTool: { browser: true, export: false, server: false }, + penMode: { browser: true, export: false, server: false }, + penDetected: { browser: true, export: false, server: false }, + errorMessage: { browser: false, export: false, server: false }, + exportBackground: { browser: true, export: false, server: false }, + exportEmbedScene: { browser: true, export: false, server: false }, + exportScale: { browser: true, export: false, server: false }, + exportWithDarkMode: { browser: true, export: false, server: false }, + fileHandle: { browser: false, export: false, server: false }, + gridSize: { browser: true, export: true, server: true }, + gridStep: { browser: true, export: true, server: true }, + gridModeEnabled: { browser: true, export: true, server: true }, + height: { browser: false, export: false, server: false }, + isBindingEnabled: { browser: false, export: false, server: false }, + isShapeSnapEnabled: { browser: false, export: false, server: false }, + defaultSidebarDockedPreference: { + browser: true, + export: false, + server: false, + }, + isLoading: { browser: false, export: false, server: false }, + isResizing: { browser: false, export: false, server: false }, + isRotating: { browser: false, export: false, server: false }, + lastPointerDownWith: { browser: true, export: false, server: false }, + multiElement: { browser: false, export: false, server: false }, + name: { browser: true, export: false, server: false }, + offsetLeft: { browser: false, export: false, server: false }, + offsetTop: { browser: false, export: false, server: false }, + contextMenu: { browser: false, export: false, server: false }, + openMenu: { browser: true, export: false, server: false }, + openPopup: { browser: false, export: false, server: false }, + openSidebar: { browser: true, export: false, server: false }, + openDialog: { browser: false, export: false, server: false }, + pasteDialog: { browser: false, export: false, server: false }, + previousSelectedElementIds: { browser: true, export: false, server: false }, + resizingElement: { browser: false, export: false, server: false }, + scrolledOutside: { browser: true, export: false, server: false }, + scrollX: { browser: true, export: false, server: false }, + scrollY: { browser: true, export: false, server: false }, + selectedElementIds: { browser: true, export: false, server: false }, + hoveredElementIds: { browser: false, export: false, server: false }, + selectedGroupIds: { browser: true, export: false, server: false }, + selectedElementsAreBeingDragged: { + browser: false, + export: false, + server: false, + }, + selectionElement: { browser: false, export: false, server: false }, + shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, + stats: { browser: true, export: false, server: false }, + startBoundElement: { browser: false, export: false, server: false }, + suggestedBindings: { browser: false, export: false, server: false }, + frameRendering: { browser: false, export: false, server: false }, + frameToHighlight: { browser: false, export: false, server: false }, + editingFrame: { browser: false, export: false, server: false }, + elementsToHighlight: { browser: false, export: false, server: false }, + toast: { browser: false, export: false, server: false }, + viewBackgroundColor: { browser: true, export: true, server: true }, + width: { browser: false, export: false, server: false }, + zenModeEnabled: { browser: true, export: false, server: false }, + zoom: { browser: true, export: false, server: false }, + viewModeEnabled: { browser: false, export: false, server: false }, + pendingImageElementId: { browser: false, export: false, server: false }, + showHyperlinkPopup: { browser: false, export: false, server: false }, + selectedLinearElement: { browser: true, export: false, server: false }, + snapLines: { browser: false, export: false, server: false }, + originSnapOffset: { browser: false, export: false, server: false }, + objectsSnapModeEnabled: { browser: true, export: false, server: false }, + userToFollow: { browser: false, export: false, server: false }, + followedBy: { browser: false, export: false, server: false }, + isCropping: { browser: false, export: false, server: false }, + croppingElementId: { browser: false, export: false, server: false }, + searchMatches: { browser: false, export: false, server: false }, + }); const _clearAppStateForStorage = < ExportType extends "export" | "browser" | "server", @@ -257,8 +257,8 @@ const _clearAppStateForStorage = < ) => { type ExportableKeys = { [K in keyof typeof APP_STATE_STORAGE_CONF]: typeof APP_STATE_STORAGE_CONF[K][ExportType] extends true - ? K - : never; + ? K + : never; }[keyof typeof APP_STATE_STORAGE_CONF]; const stateForExport = {} as { [K in ExportableKeys]?: typeof appState[K] }; for (const key of Object.keys(appState) as (keyof typeof appState)[]) { diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 89629b93e8..009847d434 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -933,6 +933,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", @@ -1142,6 +1143,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", @@ -1361,6 +1363,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", @@ -1695,6 +1698,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", @@ -2029,6 +2033,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", @@ -2248,6 +2253,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", @@ -2491,6 +2497,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", @@ -2795,6 +2802,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", @@ -3167,6 +3175,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", @@ -3645,6 +3654,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", @@ -3971,6 +3981,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", @@ -4297,6 +4308,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", @@ -5577,6 +5589,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", @@ -6798,6 +6811,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", @@ -7736,6 +7750,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", @@ -8742,6 +8757,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", @@ -9730,6 +9746,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 3f523d005d..078a63d7a8 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -59,6 +59,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, @@ -658,6 +659,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, @@ -1165,6 +1167,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, @@ -1536,6 +1539,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, @@ -1908,6 +1912,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, @@ -2178,6 +2183,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, @@ -2617,6 +2623,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, @@ -2919,6 +2926,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, @@ -3206,6 +3214,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, @@ -3503,6 +3512,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, @@ -3792,6 +3802,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, @@ -4030,6 +4041,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, @@ -4292,6 +4304,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, @@ -4568,6 +4581,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, @@ -4802,6 +4816,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, @@ -5036,6 +5051,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, @@ -5268,6 +5284,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, @@ -5500,6 +5517,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, @@ -5762,6 +5780,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, @@ -6096,6 +6115,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, @@ -6524,6 +6544,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, @@ -6905,6 +6926,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, @@ -7227,6 +7249,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, @@ -7528,6 +7551,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, @@ -7760,6 +7784,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, @@ -8118,6 +8143,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, @@ -8476,6 +8502,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, @@ -8883,6 +8910,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, @@ -9173,6 +9201,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, @@ -9441,6 +9470,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, @@ -9708,6 +9738,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, @@ -9942,6 +9973,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, @@ -10246,6 +10278,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, @@ -10589,6 +10622,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, @@ -10827,6 +10861,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, @@ -11279,6 +11314,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, @@ -11536,6 +11572,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, @@ -11778,6 +11815,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, @@ -12022,6 +12060,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, @@ -12426,6 +12465,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, @@ -12676,6 +12716,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, @@ -12920,6 +12961,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, @@ -13164,6 +13206,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, @@ -13414,6 +13457,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, @@ -13749,6 +13793,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, @@ -13924,6 +13969,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, @@ -14215,6 +14261,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, @@ -14485,6 +14532,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, @@ -14763,6 +14811,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, @@ -14927,6 +14976,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "isLoading": false, "isResizing": false, "isRotating": false, + "isShapeSnapEnabled": false, "lastPointerDownWith": "mouse", "multiElement": null, "newElement": null, @@ -15624,6 +15674,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "isLoading": false, "isResizing": false, "isRotating": false, + "isShapeSnapEnabled": false, "lastPointerDownWith": "mouse", "multiElement": null, "newElement": null, @@ -16243,6 +16294,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "isLoading": false, "isResizing": false, "isRotating": false, + "isShapeSnapEnabled": false, "lastPointerDownWith": "mouse", "multiElement": null, "newElement": null, @@ -16862,6 +16914,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "isLoading": false, "isResizing": false, "isRotating": false, + "isShapeSnapEnabled": false, "lastPointerDownWith": "mouse", "multiElement": null, "newElement": null, @@ -17572,6 +17625,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "isLoading": false, "isResizing": false, "isRotating": false, + "isShapeSnapEnabled": false, "lastPointerDownWith": "mouse", "multiElement": null, "newElement": null, @@ -18319,6 +18373,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, @@ -18796,6 +18851,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, @@ -19321,6 +19377,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, @@ -19780,6 +19837,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 4e9c659d0f..d9265ae700 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -59,6 +59,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", @@ -474,6 +475,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", @@ -880,6 +882,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", @@ -1425,6 +1428,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", @@ -1629,6 +1633,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", @@ -2004,6 +2009,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", @@ -2242,6 +2248,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", @@ -2422,6 +2429,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", @@ -2742,6 +2750,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", @@ -2988,6 +2997,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", @@ -3231,6 +3241,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", @@ -3461,6 +3472,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", @@ -3717,6 +3729,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", @@ -4028,6 +4041,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", @@ -4450,6 +4464,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", @@ -4733,6 +4748,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", @@ -4986,6 +5002,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", @@ -5196,6 +5213,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", @@ -5395,6 +5413,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", @@ -5777,6 +5796,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", @@ -6067,6 +6087,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", @@ -6875,6 +6896,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", @@ -7205,6 +7227,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", @@ -7481,6 +7504,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", @@ -7715,6 +7739,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", @@ -7952,6 +7977,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", @@ -8132,6 +8158,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", @@ -8312,6 +8339,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", @@ -8492,6 +8520,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", @@ -8715,6 +8744,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", @@ -8937,6 +8967,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", @@ -9131,6 +9162,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", @@ -9354,6 +9386,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", @@ -9534,6 +9567,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", @@ -9756,6 +9790,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", @@ -9936,6 +9971,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", @@ -10130,6 +10166,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", @@ -10310,6 +10347,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", @@ -10818,6 +10856,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", @@ -11095,6 +11134,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", @@ -11221,6 +11261,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", @@ -11420,6 +11461,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", @@ -11731,6 +11773,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", @@ -12143,6 +12186,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", @@ -12756,6 +12800,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", @@ -12885,6 +12930,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", @@ -13469,6 +13515,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", @@ -13807,6 +13854,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", @@ -14072,6 +14120,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", @@ -14198,6 +14247,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", @@ -14577,6 +14627,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", @@ -14703,6 +14754,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/utils/tests/__snapshots__/export.test.ts.snap b/packages/utils/tests/__snapshots__/export.test.ts.snap index 54d4af4bc3..7ef5401418 100644 --- a/packages/utils/tests/__snapshots__/export.test.ts.snap +++ b/packages/utils/tests/__snapshots__/export.test.ts.snap @@ -59,6 +59,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", From 5ac50bdc88069f9018c5473a68b3ba06c45c642e Mon Sep 17 00:00:00 2001 From: Mathias Krafft Date: Tue, 1 Apr 2025 17:08:03 +0200 Subject: [PATCH 6/7] Toggle shapeSnap --- .../excalidraw/actions/actionProperties.tsx | 40 ++++ packages/excalidraw/actions/types.ts | 1 + packages/excalidraw/appState.ts | 218 +++++++++--------- packages/excalidraw/components/Actions.tsx | 9 +- packages/excalidraw/components/App.tsx | 12 + packages/excalidraw/components/icons.tsx | 17 ++ packages/excalidraw/locales/en.json | 3 + packages/utils/src/snapToShape.ts | 1 - 8 files changed, 188 insertions(+), 113 deletions(-) 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 152b9a0c7e..5effd385b0 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 c01af195d6..c40af7beea 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -141,113 +141,113 @@ const APP_STATE_STORAGE_CONF = (< T extends Record, >(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) => config)({ - showWelcomeScreen: { browser: true, export: false, server: false }, - theme: { browser: true, export: false, server: false }, - collaborators: { browser: false, export: false, server: false }, - currentChartType: { browser: true, export: false, server: false }, - currentItemBackgroundColor: { browser: true, export: false, server: false }, - currentItemEndArrowhead: { browser: true, export: false, server: false }, - currentItemFillStyle: { browser: true, export: false, server: false }, - currentItemFontFamily: { browser: true, export: false, server: false }, - currentItemFontSize: { browser: true, export: false, server: false }, - currentItemRoundness: { - browser: true, - export: false, - server: false, - }, - currentItemArrowType: { - browser: true, - export: false, - server: false, - }, - currentItemOpacity: { browser: true, export: false, server: false }, - currentItemRoughness: { browser: true, export: false, server: false }, - currentItemStartArrowhead: { browser: true, export: false, server: false }, - currentItemStrokeColor: { browser: true, export: false, server: false }, - currentItemStrokeStyle: { browser: true, export: false, server: false }, - currentItemStrokeWidth: { browser: true, export: false, server: false }, - currentItemTextAlign: { browser: true, export: false, server: false }, - currentHoveredFontFamily: { browser: false, export: false, server: false }, - cursorButton: { browser: true, export: false, server: false }, - activeEmbeddable: { browser: false, export: false, server: false }, - newElement: { browser: false, export: false, server: false }, - editingTextElement: { browser: false, export: false, server: false }, - editingGroupId: { browser: true, export: false, server: false }, - editingLinearElement: { browser: false, export: false, server: false }, - activeTool: { browser: true, export: false, server: false }, - penMode: { browser: true, export: false, server: false }, - penDetected: { browser: true, export: false, server: false }, - errorMessage: { browser: false, export: false, server: false }, - exportBackground: { browser: true, export: false, server: false }, - exportEmbedScene: { browser: true, export: false, server: false }, - exportScale: { browser: true, export: false, server: false }, - exportWithDarkMode: { browser: true, export: false, server: false }, - fileHandle: { browser: false, export: false, server: false }, - gridSize: { browser: true, export: true, server: true }, - gridStep: { browser: true, export: true, server: true }, - gridModeEnabled: { browser: true, export: true, server: true }, - height: { browser: false, export: false, server: false }, - isBindingEnabled: { browser: false, export: false, server: false }, - isShapeSnapEnabled: { browser: false, export: false, server: false }, - defaultSidebarDockedPreference: { - browser: true, - export: false, - server: false, - }, - isLoading: { browser: false, export: false, server: false }, - isResizing: { browser: false, export: false, server: false }, - isRotating: { browser: false, export: false, server: false }, - lastPointerDownWith: { browser: true, export: false, server: false }, - multiElement: { browser: false, export: false, server: false }, - name: { browser: true, export: false, server: false }, - offsetLeft: { browser: false, export: false, server: false }, - offsetTop: { browser: false, export: false, server: false }, - contextMenu: { browser: false, export: false, server: false }, - openMenu: { browser: true, export: false, server: false }, - openPopup: { browser: false, export: false, server: false }, - openSidebar: { browser: true, export: false, server: false }, - openDialog: { browser: false, export: false, server: false }, - pasteDialog: { browser: false, export: false, server: false }, - previousSelectedElementIds: { browser: true, export: false, server: false }, - resizingElement: { browser: false, export: false, server: false }, - scrolledOutside: { browser: true, export: false, server: false }, - scrollX: { browser: true, export: false, server: false }, - scrollY: { browser: true, export: false, server: false }, - selectedElementIds: { browser: true, export: false, server: false }, - hoveredElementIds: { browser: false, export: false, server: false }, - selectedGroupIds: { browser: true, export: false, server: false }, - selectedElementsAreBeingDragged: { - browser: false, - export: false, - server: false, - }, - selectionElement: { browser: false, export: false, server: false }, - shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, - stats: { browser: true, export: false, server: false }, - startBoundElement: { browser: false, export: false, server: false }, - suggestedBindings: { browser: false, export: false, server: false }, - frameRendering: { browser: false, export: false, server: false }, - frameToHighlight: { browser: false, export: false, server: false }, - editingFrame: { browser: false, export: false, server: false }, - elementsToHighlight: { browser: false, export: false, server: false }, - toast: { browser: false, export: false, server: false }, - viewBackgroundColor: { browser: true, export: true, server: true }, - width: { browser: false, export: false, server: false }, - zenModeEnabled: { browser: true, export: false, server: false }, - zoom: { browser: true, export: false, server: false }, - viewModeEnabled: { browser: false, export: false, server: false }, - pendingImageElementId: { browser: false, export: false, server: false }, - showHyperlinkPopup: { browser: false, export: false, server: false }, - selectedLinearElement: { browser: true, export: false, server: false }, - snapLines: { browser: false, export: false, server: false }, - originSnapOffset: { browser: false, export: false, server: false }, - objectsSnapModeEnabled: { browser: true, export: false, server: false }, - userToFollow: { browser: false, export: false, server: false }, - followedBy: { browser: false, export: false, server: false }, - isCropping: { browser: false, export: false, server: false }, - croppingElementId: { browser: false, export: false, server: false }, - searchMatches: { browser: false, export: false, server: false }, - }); + showWelcomeScreen: { browser: true, export: false, server: false }, + theme: { browser: true, export: false, server: false }, + collaborators: { browser: false, export: false, server: false }, + currentChartType: { browser: true, export: false, server: false }, + currentItemBackgroundColor: { browser: true, export: false, server: false }, + currentItemEndArrowhead: { browser: true, export: false, server: false }, + currentItemFillStyle: { browser: true, export: false, server: false }, + currentItemFontFamily: { browser: true, export: false, server: false }, + currentItemFontSize: { browser: true, export: false, server: false }, + currentItemRoundness: { + browser: true, + export: false, + server: false, + }, + currentItemArrowType: { + browser: true, + export: false, + server: false, + }, + currentItemOpacity: { browser: true, export: false, server: false }, + currentItemRoughness: { browser: true, export: false, server: false }, + currentItemStartArrowhead: { browser: true, export: false, server: false }, + currentItemStrokeColor: { browser: true, export: false, server: false }, + currentItemStrokeStyle: { browser: true, export: false, server: false }, + currentItemStrokeWidth: { browser: true, export: false, server: false }, + currentItemTextAlign: { browser: true, export: false, server: false }, + currentHoveredFontFamily: { browser: false, export: false, server: false }, + cursorButton: { browser: true, export: false, server: false }, + activeEmbeddable: { browser: false, export: false, server: false }, + newElement: { browser: false, export: false, server: false }, + editingTextElement: { browser: false, export: false, server: false }, + editingGroupId: { browser: true, export: false, server: false }, + editingLinearElement: { browser: false, export: false, server: false }, + activeTool: { browser: true, export: false, server: false }, + penMode: { browser: true, export: false, server: false }, + penDetected: { browser: true, export: false, server: false }, + errorMessage: { browser: false, export: false, server: false }, + exportBackground: { browser: true, export: false, server: false }, + exportEmbedScene: { browser: true, export: false, server: false }, + exportScale: { browser: true, export: false, server: false }, + exportWithDarkMode: { browser: true, export: false, server: false }, + fileHandle: { browser: false, export: false, server: false }, + gridSize: { browser: true, export: true, server: true }, + gridStep: { browser: true, export: true, server: true }, + 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, + server: false, + }, + isLoading: { browser: false, export: false, server: false }, + isResizing: { browser: false, export: false, server: false }, + isRotating: { browser: false, export: false, server: false }, + lastPointerDownWith: { browser: true, export: false, server: false }, + multiElement: { browser: false, export: false, server: false }, + name: { browser: true, export: false, server: false }, + offsetLeft: { browser: false, export: false, server: false }, + offsetTop: { browser: false, export: false, server: false }, + contextMenu: { browser: false, export: false, server: false }, + openMenu: { browser: true, export: false, server: false }, + openPopup: { browser: false, export: false, server: false }, + openSidebar: { browser: true, export: false, server: false }, + openDialog: { browser: false, export: false, server: false }, + pasteDialog: { browser: false, export: false, server: false }, + previousSelectedElementIds: { browser: true, export: false, server: false }, + resizingElement: { browser: false, export: false, server: false }, + scrolledOutside: { browser: true, export: false, server: false }, + scrollX: { browser: true, export: false, server: false }, + scrollY: { browser: true, export: false, server: false }, + selectedElementIds: { browser: true, export: false, server: false }, + hoveredElementIds: { browser: false, export: false, server: false }, + selectedGroupIds: { browser: true, export: false, server: false }, + selectedElementsAreBeingDragged: { + browser: false, + export: false, + server: false, + }, + selectionElement: { browser: false, export: false, server: false }, + shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, + stats: { browser: true, export: false, server: false }, + startBoundElement: { browser: false, export: false, server: false }, + suggestedBindings: { browser: false, export: false, server: false }, + frameRendering: { browser: false, export: false, server: false }, + frameToHighlight: { browser: false, export: false, server: false }, + editingFrame: { browser: false, export: false, server: false }, + elementsToHighlight: { browser: false, export: false, server: false }, + toast: { browser: false, export: false, server: false }, + viewBackgroundColor: { browser: true, export: true, server: true }, + width: { browser: false, export: false, server: false }, + zenModeEnabled: { browser: true, export: false, server: false }, + zoom: { browser: true, export: false, server: false }, + viewModeEnabled: { browser: false, export: false, server: false }, + pendingImageElementId: { browser: false, export: false, server: false }, + showHyperlinkPopup: { browser: false, export: false, server: false }, + selectedLinearElement: { browser: true, export: false, server: false }, + snapLines: { browser: false, export: false, server: false }, + originSnapOffset: { browser: false, export: false, server: false }, + objectsSnapModeEnabled: { browser: true, export: false, server: false }, + userToFollow: { browser: false, export: false, server: false }, + followedBy: { browser: false, export: false, server: false }, + isCropping: { browser: false, export: false, server: false }, + croppingElementId: { browser: false, export: false, server: false }, + searchMatches: { browser: false, export: false, server: false }, +}); const _clearAppStateForStorage = < ExportType extends "export" | "browser" | "server", @@ -257,8 +257,8 @@ const _clearAppStateForStorage = < ) => { type ExportableKeys = { [K in keyof typeof APP_STATE_STORAGE_CONF]: typeof APP_STATE_STORAGE_CONF[K][ExportType] extends true - ? K - : never; + ? K + : never; }[keyof typeof APP_STATE_STORAGE_CONF]; const stateForExport = {} as { [K in ExportableKeys]?: typeof appState[K] }; for (const key of Object.keys(appState) as (keyof typeof appState)[]) { diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index b692048673..dfea06fc34 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 9046c393d7..1b036821c9 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -8959,7 +8959,19 @@ class App extends React.Component { 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() diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index c6d7f94732..bb7d1143ac 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -1887,6 +1887,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 f14b797055..7f1b7da477 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/utils/src/snapToShape.ts b/packages/utils/src/snapToShape.ts index 00b1b2c31a..4707c4f4f6 100644 --- a/packages/utils/src/snapToShape.ts +++ b/packages/utils/src/snapToShape.ts @@ -390,7 +390,6 @@ export const convertToShape = ( return newArrowElement({ ...freeDrawElement, type: recognizedShape.type, - endArrowhead: "arrow", // TODO: Get correct state points: [ recognizedShape.simplified[0], recognizedShape.simplified[recognizedShape.simplified.length - 2] From 37412cd68ac4fd93b4dccd218da6600d843d773c Mon Sep 17 00:00:00 2001 From: Mathias Krafft Date: Mon, 7 Apr 2025 13:59:50 +0000 Subject: [PATCH 7/7] Fix linting --- packages/math/src/point.ts | 22 ++++++---- packages/utils/src/snapToShape.ts | 68 ++++++++++++++++++++----------- 2 files changed, 59 insertions(+), 31 deletions(-) diff --git a/packages/math/src/point.ts b/packages/math/src/point.ts index 923377dc08..2687ea9881 100644 --- a/packages/math/src/point.ts +++ b/packages/math/src/point.ts @@ -245,18 +245,20 @@ export const isPointWithinBounds =

( export const perpendicularDistance =

( p: P, start: P, - end: P): - number => { + 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 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. @@ -266,7 +268,7 @@ export const perpendicularDistance =

( * @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, @@ -276,8 +278,12 @@ export const angleBetween =

( const v2 = vectorFromPoint(p1, p2); // dot and cross product - const magnitude1 = Math.hypot(v1[0], v1[1]), magnitude2 = Math.hypot(v2[0], v2[1]); - if (magnitude1 === 0 || magnitude2 === 0) return 0 as Degrees; + 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); @@ -287,4 +293,4 @@ export const angleBetween =

( const rad = Math.acos(cos) as Radians; return radiansToDegrees(rad); -} +}; diff --git a/packages/utils/src/snapToShape.ts b/packages/utils/src/snapToShape.ts index 4707c4f4f6..9d900b78eb 100644 --- a/packages/utils/src/snapToShape.ts +++ b/packages/utils/src/snapToShape.ts @@ -1,3 +1,23 @@ +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, @@ -7,11 +27,6 @@ import type { ExcalidrawLinearElement, ExcalidrawRectangleElement, } from "@excalidraw/element/types"; -import type { BoundingBox, Bounds } from "@excalidraw/element/bounds"; -import { getCenterForBounds, getCommonBoundingBox } from "@excalidraw/element/bounds"; -import { newArrowElement, newElement, newLinearElement } from "@excalidraw/element/newElement"; -import { angleBetween, GlobalPoint, LocalPoint, perpendicularDistance, pointDistance } from "@excalidraw/math"; -import { ROUNDNESS } from "@excalidraw/common"; type Shape = | ExcalidrawRectangleElement["type"] @@ -53,7 +68,6 @@ const DEFAULT_OPTIONS = { 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; @@ -94,10 +108,9 @@ function simplifyRDP( const right = simplifyRDP(points.slice(index), epsilon); // Concatenate results (omit duplicate point at junction) return left.slice(0, -1).concat(right); - } else { - // Not enough deviation, return straight line segment (keep only endpoints) - return [first, last]; } + // Not enough deviation, return straight line segment (keep only endpoints) + return [first, last]; } /** @@ -238,7 +251,10 @@ function checkArrow( const tipAngle = angleBetween(arrowTip, arrowBase, arrowTailEnd); - if (tipAngle <= options.arrowMinTipAngle || tipAngle >= options.arrowMaxTipAngle) { + if ( + tipAngle <= options.arrowMinTipAngle || + tipAngle >= options.arrowMaxTipAngle + ) { return null; } @@ -296,10 +312,9 @@ function checkQuadrilateral( if (isAxisAligned(segments, options.rectangleOrientationAngleThreshold)) { return "rectangle"; - } else { - // Not axis-aligned, but quadrilateral => classify as diamond - return "diamond"; } + // Not axis-aligned, but quadrilateral => classify as diamond + return "diamond"; } /** Checks if the points form an ellipse shape. */ @@ -345,13 +360,18 @@ export const recognizeShape = ( } const boundingBoxDiagonal = Math.hypot(boundingBox.width, boundingBox.height); - const rdpTolerance = boundingBoxDiagonal * (options.rdpTolerancePercent / 100); + const rdpTolerance = + boundingBoxDiagonal * (options.rdpTolerancePercent / 100); const simplifiedPoints = simplifyRDP(points, rdpTolerance); - const isClosed = isShapeClosed(simplifiedPoints, boundingBoxDiagonal, options); + const isClosed = isShapeClosed( + simplifiedPoints, + boundingBoxDiagonal, + options, + ); // --- Shape check order matters here --- - let recognizedType: Shape = + const recognizedType: Shape = checkLine(simplifiedPoints, isClosed) ?? checkArrow(simplifiedPoints, isClosed, options) ?? checkQuadrilateral(simplifiedPoints, isClosed, options) ?? @@ -365,7 +385,6 @@ export const recognizeShape = ( }; }; - /** * Converts a freedraw element to the detected shape */ @@ -375,7 +394,9 @@ export const convertToShape = ( const recognizedShape = recognizeShape(freeDrawElement); switch (recognizedShape.type) { - case "rectangle": case "diamond": case "ellipse": { + case "rectangle": + case "diamond": + case "ellipse": { return newElement({ ...freeDrawElement, roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }, @@ -392,9 +413,9 @@ export const convertToShape = ( type: recognizedShape.type, points: [ recognizedShape.simplified[0], - recognizedShape.simplified[recognizedShape.simplified.length - 2] + recognizedShape.simplified[recognizedShape.simplified.length - 2], ], - roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS } + roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }, }); } case "line": { @@ -403,11 +424,12 @@ export const convertToShape = ( type: recognizedShape.type, points: [ recognizedShape.simplified[0], - recognizedShape.simplified[recognizedShape.simplified.length - 1] + recognizedShape.simplified[recognizedShape.simplified.length - 1], ], - roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS } + roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }, }); } - default: return freeDrawElement + default: + return freeDrawElement; } };