mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: add support for regular polygon shape with customizable sides
This commit is contained in:
parent
2a0d15799c
commit
c8d38e87b0
24 changed files with 329 additions and 67 deletions
|
@ -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(" ");
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
};
|
||||
};
|
|
@ -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),
|
||||
);
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
};
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
};
|
|
@ -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";
|
|
@ -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>;
|
Loading…
Add table
Add a link
Reference in a new issue