This document outlines the implementation plan for adding support for regular n-sided polygons to Excalidraw. Users will be able to create polygons with 3-10 sides, controlled via a slider with preset buttons.
Key Requirements:
- New element type:
"polygon" - Regular polygons (equilateral, inscribed in bounding box)
- Slider range: 3-10 sides
- 4 preset buttons: Triangle (3), Square (4), Pentagon (5), Hexagon (6)
- New tool in the toolbar (separate from diamond)
- Same styling features as diamond (roundness, fill, stroke)
- Default: 3 sides (triangle)
File: packages/element/src/types.ts
Elements are defined as TypeScript types extending _ExcalidrawElementBase:
export type ExcalidrawDiamondElement = _ExcalidrawElementBase & {
type: "diamond";
};The base element contains all common properties:
- Positioning:
id,x,y,width,height,angle - Styling:
strokeColor,backgroundColor,fillStyle,strokeWidth,strokeStyle,roundness,roughness,opacity - Metadata:
seed,version,versionNonce,boundElements,groupIds
Union Types:
ExcalidrawGenericElement- Basic shapes (selection, rectangle, diamond, ellipse)ExcalidrawElement- All element typesExcalidrawBindableElement- Elements that arrows can bind to
File: packages/element/src/shape.ts
The ShapeCache class manages rendered shapes with theme-aware caching. The _generateElementShape() function (line 630) handles rendering for each element type.
Diamond rendering pattern (lines 693-738):
case "diamond": {
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
if (element.roundness) {
// SVG path with Bezier curves for rounded corners
shape = generator.path(`M ${topX + verticalRadius}...`, options);
} else {
// rough.js polygon with 4 points
shape = generator.polygon(
[[topX, topY], [rightX, rightY], [bottomX, bottomY], [leftX, leftY]],
options
);
}
}Key insight: generator.polygon() accepts an array of [x, y] points - perfect for n-sided polygons.
File: packages/element/src/bounds.ts (line 525)
export const getDiamondPoints = (element: ExcalidrawElement) => {
const topX = Math.floor(element.width / 2) + 1;
const topY = 0;
const rightX = element.width;
const rightY = Math.floor(element.height / 2) + 1;
// ... returns 8 numbers for 4 points
};For regular n-sided polygons, we'll calculate points inscribed in an ellipse defined by width/2 and height/2 radii.
File: packages/element/src/collision.ts
The intersectElementWithLineSegment() function (line 340) dispatches based on element type:
case "diamond":
return intersectDiamondWithLineSegment(element, elementsMap, line, offset);Diamond collision uses deconstructDiamondElement() to get line segments and curves, then tests intersections.
File: packages/element/src/utils.ts (line 340)
deconstructDiamondElement() returns:
- Line segments for sides
- Bezier curves for rounded corners
File: packages/element/src/typeChecks.ts
Type guards validate element types:
export const isBindableElement = (element) => {
return element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse" || ...
};Multiple functions need updating to include "polygon".
File: packages/element/src/comparisons.ts
These predicates determine which properties apply to each element type:
export const hasBackground = (type) =>
type === "rectangle" || type === "diamond" || type === "ellipse" || ...
export const hasStrokeWidth = (type) =>
type === "rectangle" || type === "diamond" || type === "ellipse" || ...
export const canChangeRoundness = (type) =>
type === "rectangle" || type === "diamond" || ...File: packages/excalidraw/components/shapes.tsx
export const SHAPES = [
{ icon: RectangleIcon, value: "rectangle", key: KEYS.R, numericKey: KEYS["2"], fillable: true },
{ icon: DiamondIcon, value: "diamond", key: KEYS.D, numericKey: KEYS["3"], fillable: true },
{ icon: EllipseIcon, value: "ellipse", key: KEYS.O, numericKey: KEYS["4"], fillable: true },
// ...
];File: packages/excalidraw/appState.ts
Default values for new elements:
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemRoundness: "round",
// ... etcFile: packages/excalidraw/actions/actionProperties.tsx
Actions control element properties. Example - stroke width action (lines 545-583):
export const actionChangeStrokeWidth = register({
name: "changeStrokeWidth",
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, { strokeWidth: value })
),
appState: { ...appState, currentItemStrokeWidth: value },
};
},
PanelComponent: ({ elements, appState, updateData }) => {
return (
<StrokeWidthSlider
value={value}
onChange={(newValue) => updateData(newValue)}
/>
);
},
});File: packages/excalidraw/components/StrokeWidthSlider.tsx
export const StrokeWidthSlider = ({ value, onChange }) => {
return (
<div className="stroke-width-slider">
<div className="stroke-width-presets">
<button onClick={() => onChange(PRESET_1)}>1</button>
<button onClick={() => onChange(PRESET_2)}>2</button>
// ...
</div>
<div className="range-wrapper">
<input
type="range"
min={MIN} max={MAX} step={STEP}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
<div className="value-bubble">{value}</div>
</div>
</div>
);
};File: packages/element/src/types.ts
// Add after ExcalidrawEllipseElement (line 98)
export type ExcalidrawPolygonElement = _ExcalidrawElementBase & {
type: "polygon";
sides: number; // Number of sides (3-10)
};
// Update ExcalidrawGenericElement to include polygon
export type ExcalidrawGenericElement =
| ExcalidrawSelectionElement
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawPolygonElement; // ADD THIS
// Update ExcalidrawBindableElement (around line 259)
export type ExcalidrawBindableElement =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawPolygonElement // ADD THIS
| ExcalidrawTextElement
| ...
// Update ExcalidrawTextContainer (around line 270)
export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawPolygonElement // ADD THIS
| ExcalidrawArrowElement;
// Update ExcalidrawFlowchartNodeElement (if polygons should be flowchart nodes)
export type ExcalidrawFlowchartNodeElement =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawPolygonElement; // ADD THIS
// Update ConvertibleGenericTypes
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse" | "polygon";File: packages/common/src/constants.ts
// Add to TOOL_TYPE (around line 449)
export const TOOL_TYPE = {
selection: "selection",
// ...existing...
polygon: "polygon", // ADD THIS
} as const;
// Add polygon sides constants (new section)
export const POLYGON_SIDES = {
min: 3,
max: 10,
default: 3,
triangle: 3,
square: 4,
pentagon: 5,
hexagon: 6,
} as const;File: packages/element/src/typeChecks.ts
// Add new type guard (after isEllipseElement if it exists, or create new)
export const isPolygonElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawPolygonElement => {
return element != null && element.type === "polygon";
};
// Update isBindableElement (line 177)
export const isBindableElement = (element, includeLocked = true) => {
return (
element != null &&
(!element.locked || includeLocked === true) &&
(element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse" ||
element.type === "polygon" || // ADD THIS
element.type === "image" ||
// ... rest
)
);
};
// Update isTextBindableContainer (line 230)
export const isTextBindableContainer = (element, includeLocked = true) => {
return (
element != null &&
(!element.locked || includeLocked === true) &&
(element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse" ||
element.type === "polygon" || // ADD THIS
isArrowElement(element))
);
};
// Update isExcalidrawElement switch statement (line 251)
case "polygon": // ADD THIS CASE
case "text":
case "diamond":
// ... etc
// Update isFlowchartNodeElement (line 274)
export const isFlowchartNodeElement = (element) => {
return (
element.type === "rectangle" ||
element.type === "ellipse" ||
element.type === "diamond" ||
element.type === "polygon" // ADD THIS
);
};
// Update isUsingProportionalRadius (line 314)
export const isUsingProportionalRadius = (type: string) =>
type === "line" || type === "arrow" || type === "diamond" || type === "polygon";File: packages/element/src/comparisons.ts
export const hasBackground = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "iframe" ||
type === "embeddable" ||
type === "ellipse" ||
type === "diamond" ||
type === "polygon" || // ADD THIS
type === "line" ||
type === "freedraw";
export const hasStrokeColor = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "ellipse" ||
type === "diamond" ||
type === "polygon" || // ADD THIS
// ... rest
export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "iframe" ||
type === "embeddable" ||
type === "ellipse" ||
type === "diamond" ||
type === "polygon" || // ADD THIS
// ... rest
export const hasStrokeStyle = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "iframe" ||
type === "embeddable" ||
type === "ellipse" ||
type === "diamond" ||
type === "polygon" || // ADD THIS
// ... rest
export const canChangeRoundness = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "iframe" ||
type === "embeddable" ||
type === "line" ||
type === "diamond" ||
type === "polygon" || // ADD THIS
type === "image";
// Add new predicate for polygon sides
export const hasPolygonSides = (type: ElementOrToolType) =>
type === "polygon";File: packages/element/src/bounds.ts
/**
* Calculate vertices of a regular n-sided polygon inscribed in the element's bounding box.
* The polygon is inscribed in an ellipse defined by width/2 and height/2.
*
* @param element - The polygon element
* @returns Array of [x, y] coordinate pairs for each vertex
*/
export const getPolygonPoints = (
element: ExcalidrawPolygonElement
): [number, number][] => {
const { width, height, sides } = element;
// Center of the bounding box (in element-local coordinates)
const cx = width / 2;
const cy = height / 2;
// Radii of the inscribing ellipse
const rx = width / 2;
const ry = height / 2;
const points: [number, number][] = [];
// Start from top (-90 degrees / -π/2) so first vertex is at top
const startAngle = -Math.PI / 2;
const angleStep = (2 * Math.PI) / sides;
for (let i = 0; i < sides; i++) {
const angle = startAngle + i * angleStep;
// +1 offset to avoid 0 values that cause rough.js errors
const x = Math.floor(cx + rx * Math.cos(angle)) + 1;
const y = Math.floor(cy + ry * Math.sin(angle)) + 1;
points.push([x, y]);
}
return points;
};File: packages/element/src/shape.ts
Add import at top:
import {
// ... existing imports
getPolygonPoints,
} from "./bounds";Add case in _generateElementShape() function (after the diamond case, around line 739):
case "polygon": {
let shape: ElementShapes[typeof element.type];
const points = getPolygonPoints(element);
if (element.roundness) {
// Generate SVG path with rounded corners using quadratic Bezier curves
const radius = getCornerRadius(
Math.min(element.width, element.height) / element.sides,
element
);
// Build path with rounded corners
let path = "";
const n = points.length;
for (let i = 0; i < n; i++) {
const curr = points[i];
const next = points[(i + 1) % n];
const prev = points[(i - 1 + n) % n];
// Calculate direction vectors
const toPrev = [prev[0] - curr[0], prev[1] - curr[1]];
const toNext = [next[0] - curr[0], next[1] - curr[1]];
// Normalize
const lenPrev = Math.sqrt(toPrev[0] ** 2 + toPrev[1] ** 2);
const lenNext = Math.sqrt(toNext[0] ** 2 + toNext[1] ** 2);
const unitPrev = [toPrev[0] / lenPrev, toPrev[1] / lenPrev];
const unitNext = [toNext[0] / lenNext, toNext[1] / lenNext];
// Offset points for the rounded corner
const offsetDist = Math.min(radius, lenPrev / 2, lenNext / 2);
const startPt = [
curr[0] + unitPrev[0] * offsetDist,
curr[1] + unitPrev[1] * offsetDist,
];
const endPt = [
curr[0] + unitNext[0] * offsetDist,
curr[1] + unitNext[1] * offsetDist,
];
if (i === 0) {
path += `M ${startPt[0]} ${startPt[1]} `;
} else {
path += `L ${startPt[0]} ${startPt[1]} `;
}
// Quadratic curve through the corner
path += `Q ${curr[0]} ${curr[1]}, ${endPt[0]} ${endPt[1]} `;
}
path += "Z";
shape = generator.path(path, generateRoughOptions(element, true, isDarkMode));
} else {
// Simple polygon without rounded corners
shape = generator.polygon(
points as [number, number][],
generateRoughOptions(element, false, isDarkMode)
);
}
return shape;
}File: packages/element/src/collision.ts
Add import:
import { getPolygonPoints } from "./bounds";Add to intersectElementWithLineSegment() switch statement (around line 364):
case "polygon":
return intersectPolygonWithLineSegment(
element,
elementsMap,
line,
offset,
onlyFirst,
);Add the intersection function:
const intersectPolygonWithLineSegment = (
element: ExcalidrawPolygonElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>,
offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => {
const center = elementCenterPoint(element, elementsMap);
// Rotate the line to the inverse direction to simulate the rotated polygon
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
const [sides, corners] = deconstructPolygonElement(element, offset);
const intersections: GlobalPoint[] = [];
lineIntersections(
sides,
rotatedIntersector,
intersections,
center,
element.angle,
onlyFirst,
);
if (onlyFirst && intersections.length > 0) {
return intersections;
}
curveIntersections(
corners,
rotatedIntersector,
intersections,
center,
element.angle,
onlyFirst,
);
return intersections;
};Update getCornerPointsForElement() (around line 693):
if (element.type === "polygon") {
const points = getPolygonPoints(element);
const corners: GlobalPoint[] = points.map(([px, py]) =>
pointFrom(x + px, y + py)
);
return corners.map((corner) => pointRotateRads(corner, center, angle));
}File: packages/element/src/utils.ts
Add the deconstruction function:
import { getPolygonPoints } from "./bounds";
/**
* Deconstructs a polygon element into line segments and curves.
*/
export function deconstructPolygonElement(
element: ExcalidrawPolygonElement,
offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
const points = getPolygonPoints(element);
const n = points.length;
// Convert to global points
const globalPoints: GlobalPoint[] = points.map(([px, py]) =>
pointFrom(element.x + px, element.y + py)
);
const radius = element.roundness
? getCornerRadius(Math.min(element.width, element.height) / element.sides, element)
: Math.min(element.width, element.height) * 0.01;
const sides: LineSegment<GlobalPoint>[] = [];
const corners: Curve<GlobalPoint>[] = [];
for (let i = 0; i < n; i++) {
const curr = globalPoints[i];
const next = globalPoints[(i + 1) % n];
const prev = globalPoints[(i - 1 + n) % n];
// Calculate unit vectors
const toPrev = [prev[0] - curr[0], prev[1] - curr[1]];
const toNext = [next[0] - curr[0], next[1] - curr[1]];
const lenPrev = Math.sqrt(toPrev[0] ** 2 + toPrev[1] ** 2);
const lenNext = Math.sqrt(toNext[0] ** 2 + toNext[1] ** 2);
const unitPrev = [toPrev[0] / lenPrev, toPrev[1] / lenPrev];
const unitNext = [toNext[0] / lenNext, toNext[1] / lenNext];
const offsetDist = Math.min(radius, lenPrev / 2, lenNext / 2);
const cornerStart = pointFrom<GlobalPoint>(
curr[0] + unitPrev[0] * offsetDist,
curr[1] + unitPrev[1] * offsetDist
);
const cornerEnd = pointFrom<GlobalPoint>(
curr[0] + unitNext[0] * offsetDist,
curr[1] + unitNext[1] * offsetDist
);
// Add curve at corner
corners.push(curve(cornerStart, curr, curr, cornerEnd));
}
// Build sides between corner end points
for (let i = 0; i < n; i++) {
const currCornerEnd = corners[i][3]; // End of current corner
const nextCornerStart = corners[(i + 1) % n][0]; // Start of next corner
sides.push(lineSegment(currCornerEnd, nextCornerStart));
}
setElementShapesCacheEntry(element, [sides, corners], offset);
return [sides, corners];
}File: packages/element/src/newElement.ts
Add import:
import type { ExcalidrawPolygonElement } from "./types";Add creation function:
export const newPolygonElement = (
opts: ElementConstructorOpts & {
sides?: number;
},
): NonDeleted<ExcalidrawPolygonElement> => {
return {
..._newElementBase<ExcalidrawPolygonElement>("polygon", opts),
sides: opts.sides ?? 3, // Default to triangle
};
};Export from packages/element/src/index.ts:
export { newPolygonElement } from "./newElement";
export { isPolygonElement } from "./typeChecks";
export { getPolygonPoints } from "./bounds";File: packages/excalidraw/components/PolygonSidesSlider.tsx (NEW FILE)
import React, { useEffect, useRef } from "react";
import { POLYGON_SIDES } from "@excalidraw/common";
import { t } from "../i18n";
import "./PolygonSidesSlider.scss";
export type PolygonSidesSliderProps = {
value: number;
onChange: (value: number) => void;
testId?: string;
};
export const PolygonSidesSlider = ({
value,
onChange,
testId,
}: PolygonSidesSliderProps) => {
const rangeRef = useRef<HTMLInputElement>(null);
const valueRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (rangeRef.current && valueRef.current) {
const rangeElement = rangeRef.current;
const valueElement = valueRef.current;
const inputWidth = rangeElement.offsetWidth;
const thumbWidth = 16;
const percentage =
(value - POLYGON_SIDES.min) / (POLYGON_SIDES.max - POLYGON_SIDES.min);
const position = percentage * (inputWidth - thumbWidth) + thumbWidth / 2;
valueElement.style.left = `${position}px`;
const percentFill = percentage * 100;
rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${percentFill}%, var(--button-bg) ${percentFill}%, var(--button-bg) 100%)`;
}
}, [value]);
return (
<div className="polygon-sides-slider">
<div className="polygon-sides-presets">
<button
type="button"
className={`preset-button ${value === POLYGON_SIDES.triangle ? "active" : ""}`}
onClick={() => onChange(POLYGON_SIDES.triangle)}
title={t("labels.triangle")}
>
3
</button>
<button
type="button"
className={`preset-button ${value === POLYGON_SIDES.square ? "active" : ""}`}
onClick={() => onChange(POLYGON_SIDES.square)}
title={t("labels.square")}
>
4
</button>
<button
type="button"
className={`preset-button ${value === POLYGON_SIDES.pentagon ? "active" : ""}`}
onClick={() => onChange(POLYGON_SIDES.pentagon)}
title={t("labels.pentagon")}
>
5
</button>
<button
type="button"
className={`preset-button ${value === POLYGON_SIDES.hexagon ? "active" : ""}`}
onClick={() => onChange(POLYGON_SIDES.hexagon)}
title={t("labels.hexagon")}
>
6
</button>
</div>
<div className="range-wrapper">
<input
ref={rangeRef}
type="range"
min={POLYGON_SIDES.min}
max={POLYGON_SIDES.max}
step={1}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="range-input"
data-testid={testId}
/>
<div className="value-bubble" ref={valueRef}>
{value}
</div>
</div>
</div>
);
};File: packages/excalidraw/components/PolygonSidesSlider.scss (NEW FILE)
.polygon-sides-slider {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
.polygon-sides-presets {
display: flex;
gap: 0.25rem;
.preset-button {
flex: 1;
padding: 0.25rem 0.5rem;
border: 1px solid var(--button-border);
border-radius: var(--border-radius-md);
background: var(--button-bg);
color: var(--text-primary-color);
cursor: pointer;
font-size: 0.75rem;
transition: all 0.15s ease;
&:hover {
background: var(--button-hover-bg);
}
&.active {
background: var(--color-primary);
color: var(--color-on-primary);
border-color: var(--color-primary);
}
}
}
.range-wrapper {
position: relative;
display: flex;
align-items: center;
.range-input {
width: 100%;
height: 6px;
border-radius: 3px;
appearance: none;
background: var(--button-bg);
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-slider-thumb);
cursor: pointer;
border: 2px solid var(--color-surface-high);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
&::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-slider-thumb);
cursor: pointer;
border: 2px solid var(--color-surface-high);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
}
.value-bubble {
position: absolute;
top: -1.5rem;
transform: translateX(-50%);
background: var(--color-surface-high);
color: var(--text-primary-color);
padding: 0.125rem 0.375rem;
border-radius: var(--border-radius-sm);
font-size: 0.625rem;
pointer-events: none;
white-space: nowrap;
}
}
}File: packages/excalidraw/actions/actionProperties.tsx
Add import:
import { PolygonSidesSlider } from "../components/PolygonSidesSlider";
import { isPolygonElement } from "@excalidraw/element";
import { hasPolygonSides } from "@excalidraw/element/comparisons";Add the action (after actionChangeStrokeWidth):
export const actionChangePolygonSides = register<number>({
name: "changePolygonSides",
label: "labels.polygonSides",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(
elements,
appState,
(el) => {
if (isPolygonElement(el)) {
return newElementWith(el, { sides: value });
}
return el;
},
),
appState: { ...appState, currentItemPolygonSides: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
const value = getFormValue(
elements,
app,
(element) => isPolygonElement(element) ? element.sides : null,
(element) => isPolygonElement(element),
(hasSelection) =>
hasSelection ? null : appState.currentItemPolygonSides,
);
return (
<fieldset>
<legend>{t("labels.polygonSides")}</legend>
<PolygonSidesSlider
value={value ?? appState.currentItemPolygonSides ?? 3}
onChange={(newValue) => updateData(newValue)}
testId="polygonSides-slider"
/>
</fieldset>
);
},
});Register the action in packages/excalidraw/actions/index.ts.
File: packages/excalidraw/appState.ts
Add to getDefaultAppState():
currentItemPolygonSides: POLYGON_SIDES.default,Add to APP_STATE_STORAGE_CONF:
currentItemPolygonSides: { browser: true, export: false, server: false },File: packages/excalidraw/types.ts
Add to AppState interface:
currentItemPolygonSides: number;File: packages/excalidraw/components/icons.tsx
// Add polygon icon (hexagon shape represents "polygon")
export const PolygonIcon = createIcon(
<g strokeWidth="1.5">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 3l8 4.5v9l-8 4.5l-8 -4.5v-9l8 -4.5" />
</g>,
tablerIconProps,
);File: packages/excalidraw/components/shapes.tsx
import { PolygonIcon } from "./icons";
export const SHAPES = [
// ... existing shapes
{
icon: PolygonIcon,
value: "polygon",
key: KEYS.G, // 'G' for polygon (P is taken by pen)
numericKey: null, // Or assign next available
fillable: true,
},
// ... rest of shapes
];File: packages/excalidraw/components/Actions.tsx
Add import:
import { hasPolygonSides } from "@excalidraw/element/comparisons";In SelectedShapeActions component, add after stroke width section:
{(hasPolygonSides(appState.activeTool.type) ||
targetElements.some((element) => hasPolygonSides(element.type))) &&
renderAction("changePolygonSides")}File: packages/excalidraw/components/App.tsx
Find the element creation logic and add polygon handling:
import { newPolygonElement } from "@excalidraw/element";
// In the element creation section (search for newRectangleElement or similar)
case "polygon":
return newPolygonElement({
...commonElementProps,
sides: this.state.currentItemPolygonSides,
});File: packages/excalidraw/locales/en.json
{
"labels": {
"polygonSides": "Sides",
"triangle": "Triangle",
"square": "Square",
"pentagon": "Pentagon",
"hexagon": "Hexagon",
"polygon": "Polygon"
},
"toolBar": {
"polygon": "Polygon"
}
}File: packages/utils/src/shape.ts
Update getPolygonShape() to handle polygon elements:
export const getPolygonShape = <Point extends GlobalPoint | LocalPoint>(
element: RectangularElement | ExcalidrawPolygonElement
): GeometricShape<Point> => {
const { angle, width, height, x, y } = element;
const cx = x + width / 2;
const cy = y + height / 2;
const center: Point = pointFrom(cx, cy);
let data: Polygon<Point>;
if (element.type === "polygon") {
const points = getPolygonPoints(element);
data = polygon(
...points.map(([px, py]) =>
pointRotateRads(pointFrom(x + px, y + py), center, angle)
)
) as Polygon<Point>;
} else if (element.type === "diamond") {
// ... existing diamond logic
} else {
// ... existing rectangle logic
}
return { type: "polygon", data };
};File: packages/element/tests/polygon.test.ts (NEW FILE)
import { getPolygonPoints } from "../src/bounds";
import type { ExcalidrawPolygonElement } from "../src/types";
describe("getPolygonPoints", () => {
const createMockPolygon = (
width: number,
height: number,
sides: number
): ExcalidrawPolygonElement => ({
type: "polygon",
id: "test-polygon",
x: 0,
y: 0,
width,
height,
sides,
// ... other required base properties
} as ExcalidrawPolygonElement);
it("should return 3 points for a triangle", () => {
const polygon = createMockPolygon(100, 100, 3);
const points = getPolygonPoints(polygon);
expect(points.length).toBe(3);
});
it("should return 6 points for a hexagon", () => {
const polygon = createMockPolygon(100, 100, 6);
const points = getPolygonPoints(polygon);
expect(points.length).toBe(6);
});
it("should place first vertex at top center", () => {
const polygon = createMockPolygon(100, 100, 4);
const points = getPolygonPoints(polygon);
// First point should be at top center (x=50, y near 0)
expect(points[0][0]).toBeCloseTo(51, 0); // +1 offset
expect(points[0][1]).toBe(1); // Top + 1 offset
});
it("should have all points within bounding box", () => {
const polygon = createMockPolygon(100, 80, 5);
const points = getPolygonPoints(polygon);
points.forEach(([x, y]) => {
expect(x).toBeGreaterThanOrEqual(0);
expect(x).toBeLessThanOrEqual(101); // width + 1 offset
expect(y).toBeGreaterThanOrEqual(0);
expect(y).toBeLessThanOrEqual(81); // height + 1 offset
});
});
it("should handle non-square bounding boxes", () => {
const polygon = createMockPolygon(200, 100, 4);
const points = getPolygonPoints(polygon);
// Should inscribe in ellipse, not circle
const maxX = Math.max(...points.map(p => p[0]));
const maxY = Math.max(...points.map(p => p[1]));
expect(maxX).toBeGreaterThan(maxY);
});
});File: packages/excalidraw/tests/polygonSidesSlider.test.tsx (NEW FILE)
import React from "react";
import { POLYGON_SIDES } from "@excalidraw/common";
import { Excalidraw } from "../index";
import { API } from "./helpers/api";
import { Pointer, UI } from "./helpers/ui";
import { act, fireEvent, render, screen } from "./test-utils";
const { h } = window;
const mouse = new Pointer("mouse");
describe("Polygon Sides Slider", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
afterEach(async () => {
await act(async () => {});
});
describe("Slider Control", () => {
it("should update polygon sides when slider value changes", async () => {
UI.clickTool("polygon");
mouse.down(10, 10);
mouse.up(100, 100);
const element = h.elements[0];
expect(element.type).toBe("polygon");
expect(element.sides).toBe(3); // Default
const slider = screen.getByTestId("polygonSides-slider");
fireEvent.change(slider, { target: { value: "6" } });
const updatedElement = h.elements[0];
expect(updatedElement.sides).toBe(6);
});
it("should respect min (3) and max (10) bounds", async () => {
UI.clickTool("polygon");
mouse.down(10, 10);
mouse.up(100, 100);
const slider = screen.getByTestId("polygonSides-slider") as HTMLInputElement;
expect(slider.min).toBe("3");
expect(slider.max).toBe("10");
});
});
describe("Preset Buttons", () => {
it("should set sides to 3 when triangle preset clicked", async () => {
API.setAppState({ currentItemPolygonSides: 6 });
UI.clickTool("polygon");
mouse.down(10, 10);
mouse.up(100, 100);
const triangleButton = screen.getByTitle("Triangle");
fireEvent.click(triangleButton);
expect(h.elements[0].sides).toBe(POLYGON_SIDES.triangle);
});
it("should set sides to 5 when pentagon preset clicked", async () => {
UI.clickTool("polygon");
mouse.down(10, 10);
mouse.up(100, 100);
const pentagonButton = screen.getByTitle("Pentagon");
fireEvent.click(pentagonButton);
expect(h.elements[0].sides).toBe(POLYGON_SIDES.pentagon);
});
it("should highlight active preset button", async () => {
API.setAppState({ currentItemPolygonSides: POLYGON_SIDES.hexagon });
UI.clickTool("polygon");
mouse.down(10, 10);
mouse.up(100, 100);
const hexagonButton = screen.getByTitle("Hexagon");
expect(hexagonButton.classList.contains("active")).toBe(true);
});
});
describe("App State", () => {
it("should update currentItemPolygonSides when slider changes", async () => {
expect(h.state.currentItemPolygonSides).toBe(3); // Default
UI.clickTool("polygon");
mouse.down(10, 10);
mouse.up(100, 100);
const slider = screen.getByTestId("polygonSides-slider");
fireEvent.change(slider, { target: { value: "8" } });
expect(h.state.currentItemPolygonSides).toBe(8);
});
it("should create new polygons with current sides setting", async () => {
API.setAppState({ currentItemPolygonSides: 7 });
UI.clickTool("polygon");
mouse.down(10, 10);
mouse.up(100, 100);
expect(h.elements[0].sides).toBe(7);
});
});
describe("Element Styling", () => {
it("should support all fill styles", async () => {
UI.clickTool("polygon");
mouse.down(10, 10);
mouse.up(100, 100);
// Verify fill style can be changed
const fillStyles = ["hachure", "cross-hatch", "solid", "zigzag"];
for (const style of fillStyles) {
API.setAppState({ currentItemFillStyle: style });
// Verify element accepts the fill style
expect(h.elements[0].fillStyle).toBeDefined();
}
});
it("should support roundness", async () => {
UI.clickTool("polygon");
mouse.down(10, 10);
mouse.up(100, 100);
// Enable roundness
// (implementation depends on how roundness action works)
expect(h.elements[0].roundness).toBeDefined();
});
});
});File: packages/element/tests/polygonCollision.test.ts (NEW FILE)
import { hitElementItself } from "../src/collision";
import type { ExcalidrawPolygonElement } from "../src/types";
describe("Polygon Collision Detection", () => {
const createMockPolygon = (
x: number,
y: number,
width: number,
height: number,
sides: number
): ExcalidrawPolygonElement => ({
type: "polygon",
id: "test-polygon",
x, y, width, height, sides,
angle: 0,
backgroundColor: "#ffffff",
// ... other required properties
} as ExcalidrawPolygonElement);
it("should detect hit inside polygon", () => {
const polygon = createMockPolygon(0, 0, 100, 100, 6);
const center = { x: 50, y: 50 };
const hit = hitElementItself({
point: [center.x, center.y],
element: polygon,
threshold: 10,
elementsMap: new Map(),
});
expect(hit).toBe(true);
});
it("should not detect hit outside polygon", () => {
const polygon = createMockPolygon(0, 0, 100, 100, 6);
const outside = { x: 200, y: 200 };
const hit = hitElementItself({
point: [outside.x, outside.y],
element: polygon,
threshold: 10,
elementsMap: new Map(),
});
expect(hit).toBe(false);
});
it("should detect hit on polygon edge", () => {
const polygon = createMockPolygon(0, 0, 100, 100, 4);
// Point on top edge
const edgePoint = { x: 50, y: 5 };
const hit = hitElementItself({
point: [edgePoint.x, edgePoint.y],
element: polygon,
threshold: 10,
elementsMap: new Map(),
});
expect(hit).toBe(true);
});
});File: packages/element/tests/polygonRendering.test.ts (NEW FILE)
import { ShapeCache } from "../src/shape";
import type { ExcalidrawPolygonElement } from "../src/types";
describe("Polygon Rendering", () => {
it("should generate shape for triangle", () => {
const triangle: ExcalidrawPolygonElement = {
type: "polygon",
sides: 3,
// ... other properties
} as ExcalidrawPolygonElement;
const shape = ShapeCache.generateElementShape(triangle, null);
expect(shape).toBeDefined();
});
it("should generate shape for polygon with roundness", () => {
const roundedPolygon: ExcalidrawPolygonElement = {
type: "polygon",
sides: 5,
roundness: { type: 2 },
// ... other properties
} as ExcalidrawPolygonElement;
const shape = ShapeCache.generateElementShape(roundedPolygon, null);
expect(shape).toBeDefined();
});
it("should cache generated shapes", () => {
const polygon: ExcalidrawPolygonElement = {
type: "polygon",
sides: 6,
// ... other properties
} as ExcalidrawPolygonElement;
const shape1 = ShapeCache.generateElementShape(polygon, null);
const shape2 = ShapeCache.get(polygon, null);
expect(shape1).toBe(shape2);
});
});# Run all tests
yarn test:update
# Run specific polygon tests
yarn test --grep "Polygon"
# Run type checking
yarn test:typecheck
# Run with coverage
yarn test --coveragepackages/excalidraw/components/PolygonSidesSlider.tsxpackages/excalidraw/components/PolygonSidesSlider.scsspackages/element/tests/polygon.test.tspackages/excalidraw/tests/polygonSidesSlider.test.tsxpackages/element/tests/polygonCollision.test.ts
packages/element/src/types.ts- AddExcalidrawPolygonElementtypepackages/common/src/constants.ts- AddPOLYGON_SIDESconstantspackages/element/src/typeChecks.ts- AddisPolygonElementand update type guardspackages/element/src/comparisons.ts- AddhasPolygonSidesand update predicatespackages/element/src/bounds.ts- AddgetPolygonPointsfunctionpackages/element/src/shape.ts- Add polygon rendering casepackages/element/src/collision.ts- Add polygon collision detectionpackages/element/src/utils.ts- AdddeconstructPolygonElementpackages/element/src/newElement.ts- AddnewPolygonElementpackages/element/src/index.ts- Export new functionspackages/excalidraw/actions/actionProperties.tsx- AddactionChangePolygonSidespackages/excalidraw/actions/index.ts- Register new actionpackages/excalidraw/appState.ts- AddcurrentItemPolygonSidespackages/excalidraw/types.ts- UpdateAppStateinterfacepackages/excalidraw/components/icons.tsx- AddPolygonIconpackages/excalidraw/components/shapes.tsx- Add polygon to SHAPESpackages/excalidraw/components/Actions.tsx- Add polygon sides slider renderingpackages/excalidraw/components/App.tsx- Handle polygon element creationpackages/excalidraw/locales/en.json- Add i18n labelspackages/utils/src/shape.ts- UpdategetPolygonShape
-
Phase 1: Core Types (Steps 1-4)
- Define types, constants, type guards, comparisons
-
Phase 2: Geometry (Steps 5-7)
- Point calculation, rendering, collision detection
-
Phase 3: Element Creation (Step 8)
- New element helper function
-
Phase 4: UI Components (Steps 9-14)
- Slider component, action, icon, toolbar, Actions panel
-
Phase 5: Integration (Steps 15-17)
- App.tsx changes, i18n, utility updates
-
Phase 6: Testing (Part 3)
- Unit tests, integration tests, manual testing
- The polygon shape inscribes in an ellipse (not a circle) to handle non-square bounding boxes
- First vertex is always at the top center for visual consistency
- Roundness uses proportional radius (same as diamond) for corner curves
- The slider uses the same pattern as StrokeWidthSlider for UI consistency
- All existing shape features (fill, stroke, roundness, opacity) work automatically through the base element system