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",
text: "text",
image: "image",
regularPolygon: "regularPolygon",
eraser: "eraser",
hand: "hand",
laser: "laser",
frame: "frame",
magicframe: "magicframe",
embeddable: "embeddable",
laser: "laser",
} as const;
export const EDITOR_LS_KEYS = {

View file

@ -28,6 +28,7 @@ import type {
ExcalidrawSelectionElement,
ExcalidrawLinearElement,
Arrowhead,
ExcalidrawRegularPolygonElement,
} from "./types";
import type { Drawable, Options } from "roughjs/bin/core";
@ -108,6 +109,14 @@ export const generateRoughOptions = (
}
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 "freedraw": {
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 = (
element: NonDeletedExcalidrawElement,
isExporting: boolean,
@ -537,6 +590,75 @@ export const _generateElementShape = (
// `element.canvas` on rerenders
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: {
assertNever(
element,

View file

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

View file

@ -39,7 +39,11 @@ export const distanceToBindableElement = (
return distanceToDiamondElement(element, p);
case "ellipse":
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,
FixedSegment,
ExcalidrawElbowArrowElement,
ExcalidrawRegularPolygonElement,
} from "./types";
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>(
opts: {
type: ExcalidrawArrowElement["type"];

View file

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

View file

@ -40,6 +40,7 @@ import type {
ExcalidrawElement,
ExcalidrawLinearElement,
NonDeleted,
ExcalidrawRegularPolygonElement,
} from "./types";
/**
@ -95,7 +96,12 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
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>(
@ -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
*/

View file

@ -28,6 +28,8 @@ import type {
PointBinding,
FixedPointBinding,
ExcalidrawFlowchartNodeElement,
ExcalidrawRegularPolygonElement,
} from "./types";
export const isInitializedImageElement = (
@ -159,6 +161,7 @@ export const isBindableElement = (
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe" ||
element.type === "regularPolygon" ||
(element.type === "text" && !element.containerId))
);
};
@ -175,10 +178,17 @@ export const isRectanguloidElement = (
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe" ||
element.type === "regularPolygon" ||
(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
// @see binding.ts:distanceToBindableElement()
export const isRectangularElement = (
@ -231,7 +241,8 @@ export const isExcalidrawElement = (
case "frame":
case "magicframe":
case "image":
case "selection": {
case "selection":
case "regularPolygon": {
return true;
}
default: {

View file

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

View file

@ -2,8 +2,6 @@ import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks";
import { arrayToMap } from "@excalidraw/common";
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
@ -52,7 +50,7 @@ export const actionToggleLinearEditor = register({
const editingLinearElement =
appState.editingLinearElement?.elementId === selectedElement.id
? null
: new LinearElementEditor(selectedElement, arrayToMap(elements));
: new LinearElementEditor(selectedElement);
return {
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"
| "cropEditor"
| "wrapSelectionInFrame"
| "toggleLassoTool";
| "toggleLassoTool"
| "changeRegularPolygonSides";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];

View file

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

View file

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

View file

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

View file

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

View file

@ -308,6 +308,17 @@ export const DiamondIcon = createIcon(
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
export const EllipseIcon = createIcon(
<g strokeWidth="1.5">
@ -920,7 +931,7 @@ export const shield = 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 },
);

View file

@ -11,6 +11,7 @@ import {
TextIcon,
ImageIcon,
EraserIcon,
RegularPolygonIcon,
} from "./icons";
export const SHAPES = [
@ -77,6 +78,13 @@ export const SHAPES = [
numericKey: KEYS["9"],
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,
value: "eraser",

View file

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

View file

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

View file

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

View file

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

View file

@ -17,6 +17,7 @@ import {
newLinearElement,
newMagicFrameElement,
newTextElement,
newRegularPolygonElement,
} from "@excalidraw/element/newElement";
import { isLinearElementType } from "@excalidraw/element/typeChecks";
@ -361,10 +362,19 @@ export class API {
case "magicframe":
element = newMagicFrameElement({ ...base, width, height });
break;
case "regularPolygon":
element = newRegularPolygonElement({
type: type as "regularPolygon",
...base,
});
break;
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(
type,
`API.createElement: unimplemented element type ${type}}`,
`API.createElement: unimplemented element type ${type as string}}`,
);
break;
}

View file

@ -5,7 +5,7 @@ import { TOOL_TYPE } from "@excalidraw/common";
import type { ToolType } from "@excalidraw/excalidraw/types";
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}`);
};

View file

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