Initial commit

This commit is contained in:
Mathias Krafft 2025-03-08 16:06:38 +01:00 committed by GitHub
parent 4ec812bc18
commit c22407b3ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 171 additions and 0 deletions

View file

@ -65,6 +65,7 @@ export const getDefaultAppState = (): Omit<
gridStep: DEFAULT_GRID_STEP, gridStep: DEFAULT_GRID_STEP,
gridModeEnabled: false, gridModeEnabled: false,
isBindingEnabled: true, isBindingEnabled: true,
isShapeSnapEnabled: true,
defaultSidebarDockedPreference: false, defaultSidebarDockedPreference: false,
isLoading: false, isLoading: false,
isResizing: false, isResizing: false,
@ -186,6 +187,7 @@ const APP_STATE_STORAGE_CONF = (<
gridModeEnabled: { browser: true, export: true, server: true }, gridModeEnabled: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false }, height: { browser: false, export: false, server: false },
isBindingEnabled: { 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: { defaultSidebarDockedPreference: {
browser: true, browser: true,
export: false, export: false,

View file

@ -299,6 +299,7 @@ import {
maybeParseEmbedSrc, maybeParseEmbedSrc,
getEmbedLink, getEmbedLink,
} from "../element/embeddable"; } from "../element/embeddable";
import { convertToShape } from "@excalidraw/utils/snapToShape";
import type { ContextMenuItems } from "./ContextMenu"; import type { ContextMenuItems } from "./ContextMenu";
import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu"; import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
import LayerUI from "./LayerUI"; import LayerUI from "./LayerUI";
@ -8989,6 +8990,22 @@ class App extends React.Component<AppProps, AppState> {
lastCommittedPoint: pointFrom<LocalPoint>(dx, dy), lastCommittedPoint: pointFrom<LocalPoint>(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); this.actionManager.executeAction(actionFinalize);
return; return;

View file

@ -315,6 +315,7 @@ export interface AppState {
currentHoveredFontFamily: FontFamilyValues | null; currentHoveredFontFamily: FontFamilyValues | null;
currentItemRoundness: StrokeRoundness; currentItemRoundness: StrokeRoundness;
currentItemArrowType: "sharp" | "round" | "elbow"; currentItemArrowType: "sharp" | "round" | "elbow";
isShapeSnapEnabled: boolean;
viewBackgroundColor: string; viewBackgroundColor: string;
scrollX: number; scrollX: number;
scrollY: number; scrollY: number;
@ -894,3 +895,17 @@ export type Offsets = Partial<{
bottom: number; bottom: number;
left: number; left: number;
}>; }>;
export type ShapeDetectionType =
| "rectangle"
| "ellipse"
| "diamond"
| "arrow"
| "line"
| "freedraw";
export interface ShapeDetectionResult {
type: ShapeDetectionType;
points: readonly (readonly [number, number])[];
confidence: number;
}

View file

@ -48,6 +48,7 @@
] ]
}, },
"dependencies": { "dependencies": {
"@amaplex-software/shapeit": "0.1.6",
"@braintree/sanitize-url": "6.0.2", "@braintree/sanitize-url": "6.0.2",
"@excalidraw/laser-pointer": "1.3.1", "@excalidraw/laser-pointer": "1.3.1",
"browser-fs-access": "0.29.1", "browser-fs-access": "0.29.1",

View file

@ -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;
};