feat: add support for regular polygon shape with customizable sides

This commit is contained in:
Ayesha Imran 2025-04-28 12:45:54 +05:00
parent 2a0d15799c
commit c8d38e87b0
24 changed files with 329 additions and 67 deletions

View file

@ -432,12 +432,12 @@ export const TOOL_TYPE = {
freedraw: "freedraw", freedraw: "freedraw",
text: "text", text: "text",
image: "image", image: "image",
regularPolygon: "regularPolygon",
eraser: "eraser", eraser: "eraser",
hand: "hand", hand: "hand",
laser: "laser",
frame: "frame", frame: "frame",
magicframe: "magicframe", magicframe: "magicframe",
embeddable: "embeddable",
laser: "laser",
} as const; } as const;
export const EDITOR_LS_KEYS = { export const EDITOR_LS_KEYS = {

View file

@ -28,6 +28,7 @@ import type {
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
Arrowhead, Arrowhead,
ExcalidrawRegularPolygonElement,
} from "./types"; } from "./types";
import type { Drawable, Options } from "roughjs/bin/core"; import type { Drawable, Options } from "roughjs/bin/core";
@ -108,6 +109,14 @@ export const generateRoughOptions = (
} }
return options; return options;
} }
case "regularPolygon": {
options.fillStyle = element.fillStyle;
options.fill = isTransparent(element.backgroundColor)
? undefined
: element.backgroundColor;
// Add any specific options for polygons if needed, otherwise just return
return options;
}
case "line": case "line":
case "freedraw": { case "freedraw": {
if (isPathALoop(element.points)) { if (isPathALoop(element.points)) {
@ -127,6 +136,50 @@ export const generateRoughOptions = (
} }
}; };
/**
* Returns the points for a regular polygon with the specified number of sides,
* centered within the element's bounds.
*/
export const getRegularPolygonPoints = (
element: ExcalidrawElement,
sides: number = 6
): [number, number][] => {
// Minimum number of sides for a polygon is 3
if (sides < 3) {
sides = 3;
}
const width = element.width;
const height = element.height;
// Center of the element
const cx = width / 2;
const cy = height / 2;
// Use the smaller dimension to ensure polygon fits within the element bounds
const radius = Math.min(width, height) / 2;
// Calculate points for the regular polygon
const points: [number, number][] = [];
// For regular polygons, we want to start from the top (angle = -π/2)
// so that polygons like hexagons have a flat top
const startAngle = -Math.PI / 2;
for (let i = 0; i < sides; i++) {
// Calculate angle for this vertex
const angle = startAngle + (2 * Math.PI * i) / sides;
// Calculate x and y for this vertex
const x = cx + radius * Math.cos(angle);
const y = cy + radius * Math.sin(angle);
points.push([x, y]);
}
return points;
};
const modifyIframeLikeForRoughOptions = ( const modifyIframeLikeForRoughOptions = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
isExporting: boolean, isExporting: boolean,
@ -537,6 +590,75 @@ export const _generateElementShape = (
// `element.canvas` on rerenders // `element.canvas` on rerenders
return shape; return shape;
} }
case "regularPolygon": {
let shape: ElementShapes[typeof element.type];
const points = getRegularPolygonPoints(
element,
(element as ExcalidrawRegularPolygonElement).sides
);
if (element.roundness) {
// For rounded corners, we create a path with smooth corners
// using quadratic Bézier curves, similar to the diamond shape
const options = generateRoughOptions(element, true);
// Calculate appropriate corner radius based on element size
const radius = getCornerRadius(
Math.min(element.width, element.height) / 4,
element
);
const pathData: string[] = [];
// Process each vertex to create rounded corners between edges
for (let i = 0; i < points.length; i++) {
const current = points[i];
const next = points[(i + 1) % points.length];
const prev = points[(i - 1 + points.length) % points.length];
// Calculate vectors to previous and next points
const toPrev = [prev[0] - current[0], prev[1] - current[1]];
const toNext = [next[0] - current[0], next[1] - current[1]];
// Normalize vectors and calculate corner points
const toPrevLength = Math.sqrt(toPrev[0] * toPrev[0] + toPrev[1] * toPrev[1]);
const toNextLength = Math.sqrt(toNext[0] * toNext[0] + toNext[1] * toNext[1]);
// Move inward from vertex toward previous point (limited by half the distance)
const prevCorner = [
current[0] + (toPrev[0] / toPrevLength) * Math.min(radius, toPrevLength / 2),
current[1] + (toPrev[1] / toPrevLength) * Math.min(radius, toPrevLength / 2)
];
// Move inward from vertex toward next point (limited by half the distance)
const nextCorner = [
current[0] + (toNext[0] / toNextLength) * Math.min(radius, toNextLength / 2),
current[1] + (toNext[1] / toNextLength) * Math.min(radius, toNextLength / 2)
];
// First point needs a move command, others need line commands
if (i === 0) {
pathData.push(`M ${nextCorner[0]} ${nextCorner[1]}`);
} else {
// Draw line to the corner coming from previous point
pathData.push(`L ${prevCorner[0]} ${prevCorner[1]}`);
// Draw a quadratic curve around the current vertex to the corner going to next point
pathData.push(`Q ${current[0]} ${current[1]}, ${nextCorner[0]} ${nextCorner[1]}`);
}
}
// Close the path to create a complete shape
pathData.push("Z");
shape = generator.path(pathData.join(" "), options);
} else {
// For non-rounded corners, use the simple polygon generator
shape = generator.polygon(points, generateRoughOptions(element));
}
return shape;
}
default: { default: {
assertNever( assertNever(
element, element,

View file

@ -1,11 +1,6 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { import { rescalePoints, arrayToMap, invariant } from "@excalidraw/common";
rescalePoints,
arrayToMap,
invariant,
sizeOf,
} from "@excalidraw/common";
import { import {
degreesToRadians, degreesToRadians,
@ -62,7 +57,7 @@ import type {
ElementsMap, ElementsMap,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
ElementsMapOrArray, ExcalidrawRegularPolygonElement,
} from "./types"; } from "./types";
import type { Drawable, Op } from "roughjs/bin/core"; import type { Drawable, Op } from "roughjs/bin/core";
import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { Point as RoughPoint } from "roughjs/bin/geometry";
@ -944,10 +939,10 @@ export const getElementBounds = (
}; };
export const getCommonBounds = ( export const getCommonBounds = (
elements: ElementsMapOrArray, elements: readonly ExcalidrawElement[],
elementsMap?: ElementsMap, elementsMap?: ElementsMap,
): Bounds => { ): Bounds => {
if (!sizeOf(elements)) { if (!elements.length) {
return [0, 0, 0, 0]; return [0, 0, 0, 0];
} }

View file

@ -39,7 +39,11 @@ export const distanceToBindableElement = (
return distanceToDiamondElement(element, p); return distanceToDiamondElement(element, p);
case "ellipse": case "ellipse":
return distanceToEllipseElement(element, p); return distanceToEllipseElement(element, p);
case "regularPolygon":
// For regularPolygon, use the same distance calculation as rectangle
return distanceToRectanguloidElement(element, p);
} }
return Infinity;
}; };
/** /**

View file

@ -46,6 +46,7 @@ import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
FixedSegment, FixedSegment,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawRegularPolygonElement,
} from "./types"; } from "./types";
export type ElementConstructorOpts = MarkOptional< export type ElementConstructorOpts = MarkOptional<
@ -471,6 +472,28 @@ export const newLinearElement = (
}; };
}; };
export const newRegularPolygonElement = (
opts: {
type: "regularPolygon";
sides?: number;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawRegularPolygonElement> => {
// create base element
const base = _newElementBase<ExcalidrawRegularPolygonElement>(
"regularPolygon",
opts,
);
// set default size if none provided
const width = opts.width ?? 100;
const height = opts.height ?? 100;
return {
...base,
sides: opts.sides ?? 6, // default to hexagon
width,
height,
};
};
export const newArrowElement = <T extends boolean>( export const newArrowElement = <T extends boolean>(
opts: { opts: {
type: ExcalidrawArrowElement["type"]; type: ExcalidrawArrowElement["type"];

View file

@ -394,6 +394,7 @@ const drawElementOnCanvas = (
appState: StaticCanvasAppState, appState: StaticCanvasAppState,
) => { ) => {
switch (element.type) { switch (element.type) {
case "regularPolygon":
case "rectangle": case "rectangle":
case "iframe": case "iframe":
case "embeddable": case "embeddable":
@ -806,6 +807,7 @@ export const renderElement = (
break; break;
} }
case "regularPolygon":
case "rectangle": case "rectangle":
case "diamond": case "diamond":
case "ellipse": case "ellipse":

View file

@ -40,6 +40,7 @@ import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
NonDeleted, NonDeleted,
ExcalidrawRegularPolygonElement,
} from "./types"; } from "./types";
/** /**
@ -95,7 +96,12 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
shouldTestInside(element), shouldTestInside(element),
); );
} }
} case "regularPolygon":
return getPolygonShape(element as any);
}
// Add this throw to fix the "no return statement" error
throw new Error(`Unsupported element type: ${(element as any).type}`);
}; };
export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>( export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
@ -282,6 +288,16 @@ export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
); );
}; };
/**
* Returns geometric shape for regular polygon
*/
export const getRegularPolygonShapePoints = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawRegularPolygonElement,
): GeometricShape<Point> => {
// We'll use the same shape calculation as other polygon-like elements
return getPolygonShape<Point>(element as any);
};
/** /**
* Get the axis-aligned bounding box for a given element * Get the axis-aligned bounding box for a given element
*/ */

View file

@ -28,6 +28,8 @@ import type {
PointBinding, PointBinding,
FixedPointBinding, FixedPointBinding,
ExcalidrawFlowchartNodeElement, ExcalidrawFlowchartNodeElement,
ExcalidrawRegularPolygonElement,
} from "./types"; } from "./types";
export const isInitializedImageElement = ( export const isInitializedImageElement = (
@ -159,6 +161,7 @@ export const isBindableElement = (
element.type === "embeddable" || element.type === "embeddable" ||
element.type === "frame" || element.type === "frame" ||
element.type === "magicframe" || element.type === "magicframe" ||
element.type === "regularPolygon" ||
(element.type === "text" && !element.containerId)) (element.type === "text" && !element.containerId))
); );
}; };
@ -175,10 +178,17 @@ export const isRectanguloidElement = (
element.type === "embeddable" || element.type === "embeddable" ||
element.type === "frame" || element.type === "frame" ||
element.type === "magicframe" || element.type === "magicframe" ||
element.type === "regularPolygon" ||
(element.type === "text" && !element.containerId)) (element.type === "text" && !element.containerId))
); );
}; };
export const isRegularPolygonElement = (
element: unknown
): element is ExcalidrawRegularPolygonElement => {
return element != null && (element as any).type === "regularPolygon";
};
// TODO: Remove this when proper distance calculation is introduced // TODO: Remove this when proper distance calculation is introduced
// @see binding.ts:distanceToBindableElement() // @see binding.ts:distanceToBindableElement()
export const isRectangularElement = ( export const isRectangularElement = (
@ -231,7 +241,8 @@ export const isExcalidrawElement = (
case "frame": case "frame":
case "magicframe": case "magicframe":
case "image": case "image":
case "selection": { case "selection":
case "regularPolygon": {
return true; return true;
} }
default: { default: {

View file

@ -165,6 +165,12 @@ export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
name: string | null; name: string | null;
}; };
export type ExcalidrawRegularPolygonElement = _ExcalidrawElementBase & {
type: "regularPolygon";
/** Number of sides in the regular polygon */
sides: number;
};
export type ExcalidrawMagicFrameElement = _ExcalidrawElementBase & { export type ExcalidrawMagicFrameElement = _ExcalidrawElementBase & {
type: "magicframe"; type: "magicframe";
name: string | null; name: string | null;
@ -195,7 +201,8 @@ export type ExcalidrawRectanguloidElement =
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawIframeLikeElement | ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement | ExcalidrawFrameLikeElement
| ExcalidrawEmbeddableElement; | ExcalidrawEmbeddableElement
| ExcalidrawRegularPolygonElement;
/** /**
* ExcalidrawElement should be JSON serializable and (eventually) contain * ExcalidrawElement should be JSON serializable and (eventually) contain
@ -212,7 +219,8 @@ export type ExcalidrawElement =
| ExcalidrawFrameElement | ExcalidrawFrameElement
| ExcalidrawMagicFrameElement | ExcalidrawMagicFrameElement
| ExcalidrawIframeElement | ExcalidrawIframeElement
| ExcalidrawEmbeddableElement; | ExcalidrawEmbeddableElement
| ExcalidrawRegularPolygonElement;
export type ExcalidrawNonSelectionElement = Exclude< export type ExcalidrawNonSelectionElement = Exclude<
ExcalidrawElement, ExcalidrawElement,
@ -264,7 +272,8 @@ export type ExcalidrawBindableElement =
| ExcalidrawIframeElement | ExcalidrawIframeElement
| ExcalidrawEmbeddableElement | ExcalidrawEmbeddableElement
| ExcalidrawFrameElement | ExcalidrawFrameElement
| ExcalidrawMagicFrameElement; | ExcalidrawMagicFrameElement
| ExcalidrawRegularPolygonElement;
export type ExcalidrawTextContainer = export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement | ExcalidrawRectangleElement

View file

@ -2,8 +2,6 @@ import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks"; import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks";
import { arrayToMap } from "@excalidraw/common";
import type { ExcalidrawLinearElement } from "@excalidraw/element/types"; import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"; import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
@ -52,7 +50,7 @@ export const actionToggleLinearEditor = register({
const editingLinearElement = const editingLinearElement =
appState.editingLinearElement?.elementId === selectedElement.id appState.editingLinearElement?.elementId === selectedElement.id
? null ? null
: new LinearElementEditor(selectedElement, arrayToMap(elements)); : new LinearElementEditor(selectedElement);
return { return {
appState: { appState: {
...appState, ...appState,
@ -82,3 +80,39 @@ export const actionToggleLinearEditor = register({
); );
}, },
}); });
export const actionChangeRegularPolygonSides = register({
name: "changeRegularPolygonSides",
label: "labels.regularPolygonSides",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: elements.map((el) =>
el.type === "regularPolygon"
? { ...el, sides: value }
: el
),
appState,
commitToHistory: true,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, updateData }) => {
const selected = elements.find((el) => el.type === "regularPolygon");
if (!selected) return null;
// Type guard for ExcalidrawRegularPolygonElement
if (selected.type !== "regularPolygon") return null;
return (
<fieldset>
<legend>{t("labels.regularPolygonSides") /* Add this key to your translation files, e.g., "Number of sides" */}</legend>
<input
type="range"
min={3}
max={12}
value={selected.sides}
onChange={(e) => updateData(Number(e.target.value))}
/>
<span>{selected.sides}</span>
</fieldset>
);
},
});

View file

@ -140,7 +140,8 @@ export type ActionName =
| "linkToElement" | "linkToElement"
| "cropEditor" | "cropEditor"
| "wrapSelectionInFrame" | "wrapSelectionInFrame"
| "toggleLassoTool"; | "toggleLassoTool"
| "changeRegularPolygonSides";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];

View file

@ -136,6 +136,7 @@ import {
newLinearElement, newLinearElement,
newTextElement, newTextElement,
refreshTextDimensions, refreshTextDimensions,
newRegularPolygonElement,
} from "@excalidraw/element/newElement"; } from "@excalidraw/element/newElement";
import { import {
@ -6622,7 +6623,8 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
this.createFrameElementOnPointerDown( this.createFrameElementOnPointerDown(
pointerDownState, pointerDownState,
this.state.activeTool.type, // Ensure only frame or magicframe types are passed
this.state.activeTool.type as "frame" | "magicframe",
); );
} else if (this.state.activeTool.type === "laser") { } else if (this.state.activeTool.type === "laser") {
this.laserTrails.startPath( this.laserTrails.startPath(
@ -6630,11 +6632,12 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.lastCoords.y, pointerDownState.lastCoords.y,
); );
} else if ( } else if (
this.state.activeTool.type !== "eraser" && ["selection", "rectangle", "diamond", "ellipse", "embeddable", "regularPolygon", "text", "image", "lasso"].includes(
this.state.activeTool.type !== "hand" this.state.activeTool.type
)
) { ) {
this.createGenericElementOnPointerDown( this.createGenericElementOnPointerDown(
this.state.activeTool.type, this.state.activeTool.type as "selection" | "rectangle" | "diamond" | "ellipse" | "embeddable" | "regularPolygon",
pointerDownState, pointerDownState,
); );
} }
@ -7820,7 +7823,10 @@ class App extends React.Component<AppProps, AppState> {
| "diamond" | "diamond"
| "ellipse" | "ellipse"
| "iframe" | "iframe"
| "embeddable", | "embeddable"
| "regularPolygon",
specificType?: string,
simulatePressure?: boolean
) { ) {
return this.state.currentItemRoundness === "round" return this.state.currentItemRoundness === "round"
? { ? {
@ -7832,22 +7838,15 @@ class App extends React.Component<AppProps, AppState> {
} }
private createGenericElementOnPointerDown = ( private createGenericElementOnPointerDown = (
elementType: ExcalidrawGenericElement["type"] | "embeddable", elementType: ExcalidrawGenericElement["type"] | "embeddable" | "regularPolygon",
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
): void => { ): void => {
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
pointerDownState.origin.x, pointerDownState.origin.x,
pointerDownState.origin.y, pointerDownState.origin.y,
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD] this.getEffectiveGridSize(),
? null
: this.getEffectiveGridSize(),
); );
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
x: gridX,
y: gridY,
});
const baseElementAttributes = { const baseElementAttributes = {
x: gridX, x: gridX,
y: gridY, y: gridY,
@ -7858,10 +7857,13 @@ class App extends React.Component<AppProps, AppState> {
strokeStyle: this.state.currentItemStrokeStyle, strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness, roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity, opacity: this.state.currentItemOpacity,
roundness: this.getCurrentItemRoundness(elementType), roundness: this.state.currentItemRoundness
? {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, };
} as const;
let element; let element;
if (elementType === "embeddable") { if (elementType === "embeddable") {
@ -7869,6 +7871,12 @@ class App extends React.Component<AppProps, AppState> {
type: "embeddable", type: "embeddable",
...baseElementAttributes, ...baseElementAttributes,
}); });
} else if (elementType === "regularPolygon") {
element = newRegularPolygonElement({
type: "regularPolygon",
...baseElementAttributes,
sides: 6, // Default to hexagon
});
} else { } else {
element = newElement({ element = newElement({
type: elementType, type: elementType,

View file

@ -484,7 +484,7 @@ function CommandPaletteInner({
const command: CommandPaletteItem = { const command: CommandPaletteItem = {
label: t(`toolBar.${value}`), label: t(`toolBar.${value}`),
category: DEFAULT_CATEGORIES.tools, category: DEFAULT_CATEGORIES.tools,
shortcut, shortcut: shortcut === null ? undefined : String(shortcut),
icon, icon,
keywords: ["toolbar"], keywords: ["toolbar"],
viewMode: false, viewMode: false,

View file

@ -151,6 +151,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("toolBar.rectangle")} label={t("toolBar.rectangle")}
shortcuts={[KEYS.R, KEYS["2"]]} shortcuts={[KEYS.R, KEYS["2"]]}
/> />
<Shortcut
label={t("toolBar.regularPolygon")}
shortcuts={["6"]}
/>
<Shortcut <Shortcut
label={t("toolBar.diamond")} label={t("toolBar.diamond")}
shortcuts={[KEYS.D, KEYS["3"]]} shortcuts={[KEYS.D, KEYS["3"]]}

View file

@ -296,7 +296,9 @@ export const StatsInner = memo(
> >
{appState.croppingElementId {appState.croppingElementId
? t("labels.imageCropping") ? t("labels.imageCropping")
: t(`element.${singleElement.type}`)} : singleElement && singleElement.type
? singleElement.type.charAt(0).toUpperCase() + singleElement.type.slice(1)
: ""}
</StatsRow> </StatsRow>
<StatsRow> <StatsRow>

View file

@ -308,6 +308,17 @@ export const DiamondIcon = createIcon(
tablerIconProps, tablerIconProps,
); );
export const RegularPolygonIcon = createIcon(
// Hexagon points - centered in a 24x24 viewport
<polygon
points="12,3 20.8,7.5 20.8,16.5 12,21 3.2,16.5 3.2,7.5"
stroke="currentColor"
strokeWidth="1.5"
fill="none"
/>,
tablerIconProps, // Use tablerIconProps for consistency
);
// tabler-icons: circle // tabler-icons: circle
export const EllipseIcon = createIcon( export const EllipseIcon = createIcon(
<g strokeWidth="1.5"> <g strokeWidth="1.5">
@ -920,7 +931,7 @@ export const shield = createIcon(
); );
export const file = createIcon( export const file = createIcon(
"M369.9 97.9L286 14C277 5 264.8-.1 252.1-.1H48C21.5 0 0 21.5 0 48v416c0 26.5 21.5 48 48 48h288c26.5 0 48-21.5 48-48V131.9c0-12.7-5.1-25-14.1-34zM332.1 128H256V51.9l76.1 76.1zM48 464V48h160v104c0 13.3 10.7 24 24 24h104v288H48zm32-48h224V288l-23.5-23.5c-4.7-4.7-12.3-4.7-17 0L176 352l-39.5-39.5c-4.7-4.7-12.3-4.7-17 0L80 352v64zm48-240c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48z", "M369.9 97.9L286 14C277 5 264.8-.1 252.1-.1H48C21.5 0 0 21.5 0 48v416c0 26.5 21.5 48 48 48h288c26.5 0 48-21.5 48-48V131.9c0-12.7-5.1-25-14.1-34zM332.1 128H256V51.9l76.1 76.1zM48 464V48h160v104c0 13.3 10.7 24 24 24h104v288H48zm48-48h224V288l-23.5-23.5c-4.7-4.7-12.3-4.7-17 0L176 352l-39.5-39.5c-4.7-4.7-12.3-4.7-17 0L80 352v64zm48-240c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48z",
{ width: 384, height: 512 }, { width: 384, height: 512 },
); );

View file

@ -11,6 +11,7 @@ import {
TextIcon, TextIcon,
ImageIcon, ImageIcon,
EraserIcon, EraserIcon,
RegularPolygonIcon,
} from "./icons"; } from "./icons";
export const SHAPES = [ export const SHAPES = [
@ -77,6 +78,13 @@ export const SHAPES = [
numericKey: KEYS["9"], numericKey: KEYS["9"],
fillable: false, fillable: false,
}, },
{
icon: RegularPolygonIcon,
value: "regularPolygon",
key: null, // TODO: Assign a unique letter key if needed
numericKey: null, // Removed conflicting key
fillable: true,
},
{ {
icon: EraserIcon, icon: EraserIcon,
value: "eraser", value: "eraser",

View file

@ -102,6 +102,7 @@ export const AllowedExcalidrawActiveTools: Record<
hand: true, hand: true,
laser: false, laser: false,
magicframe: false, magicframe: false,
regularPolygon: true,
}; };
export type RestoredDataState = { export type RestoredDataState = {

View file

@ -126,6 +126,7 @@
"increaseFontSize": "Increase font size", "increaseFontSize": "Increase font size",
"unbindText": "Unbind text", "unbindText": "Unbind text",
"bindText": "Bind text to the container", "bindText": "Bind text to the container",
"regularPolygonSides": "Number of sides",
"createContainerFromText": "Wrap text in a container", "createContainerFromText": "Wrap text in a container",
"link": { "link": {
"edit": "Edit link", "edit": "Edit link",
@ -283,6 +284,7 @@
"ellipse": "Ellipse", "ellipse": "Ellipse",
"arrow": "Arrow", "arrow": "Arrow",
"line": "Line", "line": "Line",
"regularPolygon": "Regular Polygon",
"freedraw": "Draw", "freedraw": "Draw",
"text": "Text", "text": "Text",
"library": "Library", "library": "Library",

View file

@ -150,6 +150,7 @@ const renderElementToSvg = (
// this should not happen // this should not happen
throw new Error("Selection rendering is not supported for SVG"); throw new Error("Selection rendering is not supported for SVG");
} }
case "regularPolygon": // <-- Add this line
case "rectangle": case "rectangle":
case "diamond": case "diamond":
case "ellipse": { case "ellipse": {

View file

@ -130,14 +130,12 @@ export type ScrollBars = {
y: number; y: number;
width: number; width: number;
height: number; height: number;
deltaMultiplier: number;
} | null; } | null;
vertical: { vertical: {
x: number; x: number;
y: number; y: number;
width: number; width: number;
height: number; height: number;
deltaMultiplier: number;
} | null; } | null;
}; };
@ -156,4 +154,5 @@ export type ElementShapes = {
image: null; image: null;
frame: null; frame: null;
magicframe: null; magicframe: null;
regularPolygon: Drawable;
}; };

View file

@ -17,6 +17,7 @@ import {
newLinearElement, newLinearElement,
newMagicFrameElement, newMagicFrameElement,
newTextElement, newTextElement,
newRegularPolygonElement,
} from "@excalidraw/element/newElement"; } from "@excalidraw/element/newElement";
import { isLinearElementType } from "@excalidraw/element/typeChecks"; import { isLinearElementType } from "@excalidraw/element/typeChecks";
@ -361,10 +362,19 @@ export class API {
case "magicframe": case "magicframe":
element = newMagicFrameElement({ ...base, width, height }); element = newMagicFrameElement({ ...base, width, height });
break; break;
case "regularPolygon":
element = newRegularPolygonElement({
type: type as "regularPolygon",
...base,
});
break;
default: default:
// This check ensures all TOOL_TYPE values are handled.
// If a new tool type is added without updating this switch,
// TypeScript will error here.
assertNever( assertNever(
type, type,
`API.createElement: unimplemented element type ${type}}`, `API.createElement: unimplemented element type ${type as string}}`,
); );
break; break;
} }

View file

@ -5,7 +5,7 @@ import { TOOL_TYPE } from "@excalidraw/common";
import type { ToolType } from "@excalidraw/excalidraw/types"; import type { ToolType } from "@excalidraw/excalidraw/types";
const _getAllByToolName = (container: HTMLElement, tool: ToolType | "lock") => { const _getAllByToolName = (container: HTMLElement, tool: ToolType | "lock") => {
const toolTitle = tool === "lock" ? "lock" : TOOL_TYPE[tool]; const toolTitle = tool === "lock" ? "lock" : TOOL_TYPE[tool as keyof typeof TOOL_TYPE];
return queries.getAllByTestId(container, `toolbar-${toolTitle}`); return queries.getAllByTestId(container, `toolbar-${toolTitle}`);
}; };

View file

@ -138,6 +138,7 @@ export type ToolType =
| "selection" | "selection"
| "lasso" | "lasso"
| "rectangle" | "rectangle"
| "regularPolygon"
| "diamond" | "diamond"
| "ellipse" | "ellipse"
| "arrow" | "arrow"
@ -601,7 +602,6 @@ export interface ExcalidrawProps {
) => JSX.Element | null; ) => JSX.Element | null;
aiEnabled?: boolean; aiEnabled?: boolean;
showDeprecatedFonts?: boolean; showDeprecatedFonts?: boolean;
renderScrollbars?: boolean;
} }
export type SceneData = { export type SceneData = {
@ -784,7 +784,6 @@ export type UnsubscribeCallback = () => void;
export interface ExcalidrawImperativeAPI { export interface ExcalidrawImperativeAPI {
updateScene: InstanceType<typeof App>["updateScene"]; updateScene: InstanceType<typeof App>["updateScene"];
mutateElement: InstanceType<typeof App>["mutateElement"];
updateLibrary: InstanceType<typeof Library>["updateLibrary"]; updateLibrary: InstanceType<typeof Library>["updateLibrary"];
resetScene: InstanceType<typeof App>["resetScene"]; resetScene: InstanceType<typeof App>["resetScene"];
getSceneElementsIncludingDeleted: InstanceType< getSceneElementsIncludingDeleted: InstanceType<