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

@ -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,
@ -610,4 +732,4 @@ const generateElbowArrowShape = (
d.push(`L ${points[points.length - 1][0]} ${points[points.length - 1][1]}`);
return d.join(" ");
};
};

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];
}
@ -1145,4 +1140,4 @@ export const doBoundsIntersect = (
const [minX2, minY2, maxX2, maxY2] = bounds2;
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
};
};

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;
};
/**
@ -116,4 +120,4 @@ const distanceToEllipseElement = (
pointRotateRads(p, center, -element.angle as Radians),
ellipse(center, element.width / 2, element.height / 2),
);
};
};

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"];
@ -532,4 +555,4 @@ export const newImageElement = (
scale: opts.scale ?? [1, 1],
crop: opts.crop ?? null,
};
};
};

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":
@ -1077,4 +1079,4 @@ function getSvgPathFromStroke(points: number[][]): string {
)
.join(" ")
.replace(TO_FIXED_PRECISION, "$1");
}
}

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
*/
@ -395,4 +411,4 @@ export const isPathALoop = (
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
}
return false;
};
};

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: {
@ -337,4 +348,4 @@ export const isBounds = (box: unknown): box is Bounds =>
typeof box[0] === "number" &&
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number";
typeof box[3] === "number";

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
@ -411,4 +420,4 @@ export type NonDeletedSceneElementsMap = Map<
export type ElementsMapOrArray =
| readonly ExcalidrawElement[]
| Readonly<ElementsMap>;
| Readonly<ElementsMap>;