Skip to content

Latest commit

 

History

History
1516 lines (1221 loc) · 41.1 KB

File metadata and controls

1516 lines (1221 loc) · 41.1 KB

Custom N-Sided Polygon Implementation Plan

Overview

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)

Part 1: Current Technical Architecture

1.1 Element Type System

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 types
  • ExcalidrawBindableElement - Elements that arrows can bind to

1.2 Shape Rendering (Rough.js)

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.

1.3 Point Calculation

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.

1.4 Collision Detection

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

1.5 Type Guards

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".

1.6 Comparisons (Property Predicates)

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" || ...

1.7 Tool Configuration

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 },
  // ...
];

1.8 App State Management

File: packages/excalidraw/appState.ts

Default values for new elements:

currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemRoundness: "round",
// ... etc

1.9 Actions System

File: 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)}
      />
    );
  },
});

1.10 Slider Component Pattern

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>
  );
};

Part 2: Detailed Implementation Steps

Step 1: Define the Polygon Element Type

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";

Step 2: Add Constants

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;

Step 3: Add Type Guards

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";

Step 4: Add Comparison Predicates

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";

Step 5: Add Point Calculation Function

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;
};

Step 6: Add Shape Rendering

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;
}

Step 7: Add Collision Detection

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];
}

Step 8: Add Element Creation Helper

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";

Step 9: Create Polygon Sides Slider Component

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;
    }
  }
}

Step 10: Add Action for Changing Polygon Sides

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.

Step 11: Update App State

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;

Step 12: Add Polygon Icon

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,
);

Step 13: Add Tool to Toolbar

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
];

Step 14: Update Actions.tsx to Show Polygon Sides Slider

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")}

Step 15: Handle Element Creation in App.tsx

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,
  });

Step 16: Add i18n Labels

File: packages/excalidraw/locales/en.json

{
  "labels": {
    "polygonSides": "Sides",
    "triangle": "Triangle",
    "square": "Square",
    "pentagon": "Pentagon",
    "hexagon": "Hexagon",
    "polygon": "Polygon"
  },
  "toolBar": {
    "polygon": "Polygon"
  }
}

Step 17: Update Shape Utility Functions

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 };
};

Part 3: Testing Strategy

3.1 Unit Tests for Point Calculation

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);
  });
});

3.2 Integration Tests for Slider Component

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();
    });
  });
});

3.3 Collision Detection Tests

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);
  });
});

3.4 Rendering Tests

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);
  });
});

3.5 Test Commands

# 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 --coverage

Summary: Files to Modify/Create

New Files

  1. packages/excalidraw/components/PolygonSidesSlider.tsx
  2. packages/excalidraw/components/PolygonSidesSlider.scss
  3. packages/element/tests/polygon.test.ts
  4. packages/excalidraw/tests/polygonSidesSlider.test.tsx
  5. packages/element/tests/polygonCollision.test.ts

Modified Files

  1. packages/element/src/types.ts - Add ExcalidrawPolygonElement type
  2. packages/common/src/constants.ts - Add POLYGON_SIDES constants
  3. packages/element/src/typeChecks.ts - Add isPolygonElement and update type guards
  4. packages/element/src/comparisons.ts - Add hasPolygonSides and update predicates
  5. packages/element/src/bounds.ts - Add getPolygonPoints function
  6. packages/element/src/shape.ts - Add polygon rendering case
  7. packages/element/src/collision.ts - Add polygon collision detection
  8. packages/element/src/utils.ts - Add deconstructPolygonElement
  9. packages/element/src/newElement.ts - Add newPolygonElement
  10. packages/element/src/index.ts - Export new functions
  11. packages/excalidraw/actions/actionProperties.tsx - Add actionChangePolygonSides
  12. packages/excalidraw/actions/index.ts - Register new action
  13. packages/excalidraw/appState.ts - Add currentItemPolygonSides
  14. packages/excalidraw/types.ts - Update AppState interface
  15. packages/excalidraw/components/icons.tsx - Add PolygonIcon
  16. packages/excalidraw/components/shapes.tsx - Add polygon to SHAPES
  17. packages/excalidraw/components/Actions.tsx - Add polygon sides slider rendering
  18. packages/excalidraw/components/App.tsx - Handle polygon element creation
  19. packages/excalidraw/locales/en.json - Add i18n labels
  20. packages/utils/src/shape.ts - Update getPolygonShape

Implementation Order (Recommended)

  1. Phase 1: Core Types (Steps 1-4)

    • Define types, constants, type guards, comparisons
  2. Phase 2: Geometry (Steps 5-7)

    • Point calculation, rendering, collision detection
  3. Phase 3: Element Creation (Step 8)

    • New element helper function
  4. Phase 4: UI Components (Steps 9-14)

    • Slider component, action, icon, toolbar, Actions panel
  5. Phase 5: Integration (Steps 15-17)

    • App.tsx changes, i18n, utility updates
  6. Phase 6: Testing (Part 3)

    • Unit tests, integration tests, manual testing

Notes

  • 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