Browse Source

feat: line polygons (#9477)

* Loop Lock/Unlock

* fixed condition. 4 line points are required for the action to be available

* extracted updateLoopLock to improve readability. Removed unnecessary SVG attributes

* lint + added loopLock to restore.ts

* added  loopLock to newElement, updated test snapshots

* lint

* dislocate enpoint when breaking the loop.

* change icon & turn into a state style button

* POC: auto-transform to polygon on bg set

* keep polygon icon constant

* do not split points on de-polygonizing & highlight overlapping points

* rewrite color picker to support no (mixed) colors & fix focus handling

* refactor

* tweak point rendering inside line editor

* do not disable polygon when creating new points via alt

* auto-enable polygon when aligning start/end points

* TBD: remove bg color when disabling polygon

* TBD: only show polygon button for enabled polygons

* fix polygon behavior when adding/removing/moving points within line editor

* convert to polygon when creating line

* labels tweak

* add to command palette

* loopLock -> polygon

* restore `polygon` state on type conversions

* update snapshots

* naming

* break polygon on restore/finalize if invalid & prevent creation

* snapshots

* fix: merge issue and forgotten debug

* snaps

* do not merge points for 3-point lines

---------

Co-authored-by: dwelle <[email protected]>
zsviczian 2 months ago
parent
commit
87c87a9fb1
26 changed files with 555 additions and 121 deletions
  1. 7 0
      packages/common/src/constants.ts
  2. 136 69
      packages/element/src/linearElementEditor.ts
  3. 16 1
      packages/element/src/newElement.ts
  4. 48 0
      packages/element/src/shapes.ts
  5. 32 0
      packages/element/src/typeChecks.ts
  6. 10 2
      packages/element/src/types.ts
  7. 1 5
      packages/excalidraw/actions/actionDeleteSelected.tsx
  8. 37 12
      packages/excalidraw/actions/actionFinalize.tsx
  9. 123 6
      packages/excalidraw/actions/actionLinearEditor.tsx
  10. 48 13
      packages/excalidraw/actions/actionProperties.tsx
  11. 1 0
      packages/excalidraw/actions/manager.tsx
  12. 6 1
      packages/excalidraw/actions/types.ts
  13. 2 0
      packages/excalidraw/components/ButtonIcon.tsx
  14. 1 0
      packages/excalidraw/components/CommandPalette/CommandPalette.tsx
  15. 15 0
      packages/excalidraw/components/icons.tsx
  16. 2 0
      packages/excalidraw/data/__snapshots__/transform.test.ts.snap
  17. 13 3
      packages/excalidraw/data/restore.ts
  18. 1 1
      packages/excalidraw/data/transform.ts
  19. 4 0
      packages/excalidraw/locales/en.json
  20. 5 2
      packages/excalidraw/renderer/helpers.ts
  21. 38 6
      packages/excalidraw/renderer/interactiveScene.ts
  22. 1 0
      packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap
  23. 1 0
      packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap
  24. 4 0
      packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
  25. 1 0
      packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap
  26. 2 0
      packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap

+ 7 - 0
packages/common/src/constants.ts

@@ -477,3 +477,10 @@ export enum UserIdleState {
   AWAY = "away",
   AWAY = "away",
   IDLE = "idle",
   IDLE = "idle",
 }
 }
+
+/**
+ * distance at which we merge points instead of adding a new merge-point
+ * when converting a line to a polygon (merge currently means overlaping
+ * the start and end points)
+ */
+export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;

+ 136 - 69
packages/element/src/linearElementEditor.ts

@@ -63,10 +63,13 @@ import {
   getControlPointsForBezierCurve,
   getControlPointsForBezierCurve,
   mapIntervalToBezierT,
   mapIntervalToBezierT,
   getBezierXY,
   getBezierXY,
+  toggleLinePolygonState,
 } from "./shapes";
 } from "./shapes";
 
 
 import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
 import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
 
 
+import { isLineElement } from "./typeChecks";
+
 import type { Scene } from "./Scene";
 import type { Scene } from "./Scene";
 
 
 import type { Bounds } from "./bounds";
 import type { Bounds } from "./bounds";
@@ -85,6 +88,35 @@ import type {
   PointsPositionUpdates,
   PointsPositionUpdates,
 } from "./types";
 } from "./types";
 
 
+/**
+ * Normalizes line points so that the start point is at [0,0]. This is
+ * expected in various parts of the codebase.
+ *
+ * Also returns the offsets - [0,0] if no normalization needed.
+ *
+ * @private
+ */
+const getNormalizedPoints = ({
+  points,
+}: {
+  points: ExcalidrawLinearElement["points"];
+}): {
+  points: LocalPoint[];
+  offsetX: number;
+  offsetY: number;
+} => {
+  const offsetX = points[0][0];
+  const offsetY = points[0][1];
+
+  return {
+    points: points.map((p) => {
+      return pointFrom(p[0] - offsetX, p[1] - offsetY);
+    }),
+    offsetX,
+    offsetY,
+  };
+};
+
 export class LinearElementEditor {
 export class LinearElementEditor {
   public readonly elementId: ExcalidrawElement["id"] & {
   public readonly elementId: ExcalidrawElement["id"] & {
     _brand: "excalidrawLinearElementId";
     _brand: "excalidrawLinearElementId";
@@ -127,7 +159,11 @@ export class LinearElementEditor {
     };
     };
     if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
     if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
       console.error("Linear element is not normalized", Error().stack);
       console.error("Linear element is not normalized", Error().stack);
-      LinearElementEditor.normalizePoints(element, elementsMap);
+      mutateElement(
+        element,
+        elementsMap,
+        LinearElementEditor.getNormalizeElementPointsAndCoords(element),
+      );
     }
     }
     this.selectedPointsIndices = null;
     this.selectedPointsIndices = null;
     this.lastUncommittedPoint = null;
     this.lastUncommittedPoint = null;
@@ -459,6 +495,18 @@ export class LinearElementEditor {
           selectedPoint === element.points.length - 1
           selectedPoint === element.points.length - 1
         ) {
         ) {
           if (isPathALoop(element.points, appState.zoom.value)) {
           if (isPathALoop(element.points, appState.zoom.value)) {
+            if (isLineElement(element)) {
+              scene.mutateElement(
+                element,
+                {
+                  ...toggleLinePolygonState(element, true),
+                },
+                {
+                  informMutation: false,
+                  isDragging: false,
+                },
+              );
+            }
             LinearElementEditor.movePoints(
             LinearElementEditor.movePoints(
               element,
               element,
               scene,
               scene,
@@ -946,9 +994,7 @@ export class LinearElementEditor {
 
 
     if (!event.altKey) {
     if (!event.altKey) {
       if (lastPoint === lastUncommittedPoint) {
       if (lastPoint === lastUncommittedPoint) {
-        LinearElementEditor.deletePoints(element, app.scene, [
-          points.length - 1,
-        ]);
+        LinearElementEditor.deletePoints(element, app, [points.length - 1]);
       }
       }
       return {
       return {
         ...appState.editingLinearElement,
         ...appState.editingLinearElement,
@@ -999,7 +1045,7 @@ export class LinearElementEditor {
         ]),
         ]),
       );
       );
     } else {
     } else {
-      LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]);
+      LinearElementEditor.addPoints(element, app.scene, [newPoint]);
     }
     }
     return {
     return {
       ...appState.editingLinearElement,
       ...appState.editingLinearElement,
@@ -1142,40 +1188,23 @@ export class LinearElementEditor {
 
 
   /**
   /**
    * Normalizes line points so that the start point is at [0,0]. This is
    * Normalizes line points so that the start point is at [0,0]. This is
-   * expected in various parts of the codebase. Also returns new x/y to account
-   * for the potential normalization.
+   * expected in various parts of the codebase.
+   *
+   * Also returns normalized x and y coords to account for the normalization
+   * of the points.
    */
    */
-  static getNormalizedPoints(element: ExcalidrawLinearElement): {
-    points: LocalPoint[];
-    x: number;
-    y: number;
-  } {
-    const { points } = element;
-
-    const offsetX = points[0][0];
-    const offsetY = points[0][1];
+  static getNormalizeElementPointsAndCoords(element: ExcalidrawLinearElement) {
+    const { points, offsetX, offsetY } = getNormalizedPoints(element);
 
 
     return {
     return {
-      points: points.map((p) => {
-        return pointFrom(p[0] - offsetX, p[1] - offsetY);
-      }),
+      points,
       x: element.x + offsetX,
       x: element.x + offsetX,
       y: element.y + offsetY,
       y: element.y + offsetY,
     };
     };
   }
   }
+
   // element-mutating methods
   // element-mutating methods
   // ---------------------------------------------------------------------------
   // ---------------------------------------------------------------------------
-  static normalizePoints(
-    element: NonDeleted<ExcalidrawLinearElement>,
-    elementsMap: ElementsMap,
-  ) {
-    mutateElement(
-      element,
-      elementsMap,
-      LinearElementEditor.getNormalizedPoints(element),
-    );
-  }
-
   static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
   static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
     invariant(
     invariant(
       appState.editingLinearElement,
       appState.editingLinearElement,
@@ -1254,41 +1283,47 @@ export class LinearElementEditor {
 
 
   static deletePoints(
   static deletePoints(
     element: NonDeleted<ExcalidrawLinearElement>,
     element: NonDeleted<ExcalidrawLinearElement>,
-    scene: Scene,
+    app: AppClassProperties,
     pointIndices: readonly number[],
     pointIndices: readonly number[],
   ) {
   ) {
-    let offsetX = 0;
-    let offsetY = 0;
+    const isUncommittedPoint =
+      app.state.editingLinearElement?.lastUncommittedPoint ===
+      element.points[element.points.length - 1];
 
 
-    const isDeletingOriginPoint = pointIndices.includes(0);
+    const isPolygon = isLineElement(element) && element.polygon;
 
 
-    // if deleting first point, make the next to be [0,0] and recalculate
-    // positions of the rest with respect to it
-    if (isDeletingOriginPoint) {
-      const firstNonDeletedPoint = element.points.find((point, idx) => {
-        return !pointIndices.includes(idx);
-      });
-      if (firstNonDeletedPoint) {
-        offsetX = firstNonDeletedPoint[0];
-        offsetY = firstNonDeletedPoint[1];
-      }
+    // break polygon if deleting start/end point
+    if (
+      isPolygon &&
+      // don't disable polygon if cleaning up uncommitted point
+      !isUncommittedPoint &&
+      (pointIndices.includes(0) ||
+        pointIndices.includes(element.points.length - 1))
+    ) {
+      app.scene.mutateElement(element, { polygon: false });
     }
     }
 
 
-    const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
-      if (!pointIndices.includes(idx)) {
-        acc.push(
-          !acc.length
-            ? pointFrom(0, 0)
-            : pointFrom(p[0] - offsetX, p[1] - offsetY),
-        );
-      }
-      return acc;
-    }, []);
+    const nextPoints = element.points.filter((_, idx) => {
+      return !pointIndices.includes(idx);
+    });
+
+    if (isUncommittedPoint && isLineElement(element) && element.polygon) {
+      nextPoints[0] = pointFrom(
+        nextPoints[nextPoints.length - 1][0],
+        nextPoints[nextPoints.length - 1][1],
+      );
+    }
+
+    const {
+      points: normalizedPoints,
+      offsetX,
+      offsetY,
+    } = getNormalizedPoints({ points: nextPoints });
 
 
     LinearElementEditor._updatePoints(
     LinearElementEditor._updatePoints(
       element,
       element,
-      scene,
-      nextPoints,
+      app.scene,
+      normalizedPoints,
       offsetX,
       offsetX,
       offsetY,
       offsetY,
     );
     );
@@ -1297,16 +1332,27 @@ export class LinearElementEditor {
   static addPoints(
   static addPoints(
     element: NonDeleted<ExcalidrawLinearElement>,
     element: NonDeleted<ExcalidrawLinearElement>,
     scene: Scene,
     scene: Scene,
-    targetPoints: { point: LocalPoint }[],
+    addedPoints: LocalPoint[],
   ) {
   ) {
-    const offsetX = 0;
-    const offsetY = 0;
+    const nextPoints = [...element.points, ...addedPoints];
+
+    if (isLineElement(element) && element.polygon) {
+      nextPoints[0] = pointFrom(
+        nextPoints[nextPoints.length - 1][0],
+        nextPoints[nextPoints.length - 1][1],
+      );
+    }
+
+    const {
+      points: normalizedPoints,
+      offsetX,
+      offsetY,
+    } = getNormalizedPoints({ points: nextPoints });
 
 
-    const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
     LinearElementEditor._updatePoints(
     LinearElementEditor._updatePoints(
       element,
       element,
       scene,
       scene,
-      nextPoints,
+      normalizedPoints,
       offsetX,
       offsetX,
       offsetY,
       offsetY,
     );
     );
@@ -1323,17 +1369,37 @@ export class LinearElementEditor {
   ) {
   ) {
     const { points } = element;
     const { points } = element;
 
 
+    // if polygon, move start and end points together
+    if (isLineElement(element) && element.polygon) {
+      const firstPointUpdate = pointUpdates.get(0);
+      const lastPointUpdate = pointUpdates.get(points.length - 1);
+
+      if (firstPointUpdate) {
+        pointUpdates.set(points.length - 1, {
+          point: pointFrom(
+            firstPointUpdate.point[0],
+            firstPointUpdate.point[1],
+          ),
+          isDragging: firstPointUpdate.isDragging,
+        });
+      } else if (lastPointUpdate) {
+        pointUpdates.set(0, {
+          point: pointFrom(lastPointUpdate.point[0], lastPointUpdate.point[1]),
+          isDragging: lastPointUpdate.isDragging,
+        });
+      }
+    }
+
     // in case we're moving start point, instead of modifying its position
     // in case we're moving start point, instead of modifying its position
     // which would break the invariant of it being at [0,0], we move
     // which would break the invariant of it being at [0,0], we move
     // all the other points in the opposite direction by delta to
     // all the other points in the opposite direction by delta to
     // offset it. We do the same with actual element.x/y position, so
     // offset it. We do the same with actual element.x/y position, so
     // this hacks are completely transparent to the user.
     // this hacks are completely transparent to the user.
-    const [deltaX, deltaY] =
+
+    const updatedOriginPoint =
       pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0);
       pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0);
-    const [offsetX, offsetY] = pointFrom<LocalPoint>(
-      deltaX - points[0][0],
-      deltaY - points[0][1],
-    );
+
+    const [offsetX, offsetY] = updatedOriginPoint;
 
 
     const nextPoints = isElbowArrow(element)
     const nextPoints = isElbowArrow(element)
       ? [
       ? [
@@ -1503,6 +1569,7 @@ export class LinearElementEditor {
         isDragging: options?.isDragging ?? false,
         isDragging: options?.isDragging ?? false,
       });
       });
     } else {
     } else {
+      // TODO do we need to get precise coords here just to calc centers?
       const nextCoords = getElementPointsCoords(element, nextPoints);
       const nextCoords = getElementPointsCoords(element, nextPoints);
       const prevCoords = getElementPointsCoords(element, element.points);
       const prevCoords = getElementPointsCoords(element, element.points);
       const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
       const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
@@ -1511,7 +1578,7 @@ export class LinearElementEditor {
       const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
       const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
       const dX = prevCenterX - nextCenterX;
       const dX = prevCenterX - nextCenterX;
       const dY = prevCenterY - nextCenterY;
       const dY = prevCenterY - nextCenterY;
-      const rotated = pointRotateRads(
+      const rotatedOffset = pointRotateRads(
         pointFrom(offsetX, offsetY),
         pointFrom(offsetX, offsetY),
         pointFrom(dX, dY),
         pointFrom(dX, dY),
         element.angle,
         element.angle,
@@ -1519,8 +1586,8 @@ export class LinearElementEditor {
       scene.mutateElement(element, {
       scene.mutateElement(element, {
         ...otherUpdates,
         ...otherUpdates,
         points: nextPoints,
         points: nextPoints,
-        x: element.x + rotated[0],
-        y: element.y + rotated[1],
+        x: element.x + rotatedOffset[0],
+        y: element.y + rotatedOffset[1],
       });
       });
     }
     }
   }
   }

+ 16 - 1
packages/element/src/newElement.ts

@@ -25,6 +25,8 @@ import { getBoundTextMaxWidth } from "./textElement";
 import { normalizeText, measureText } from "./textMeasurements";
 import { normalizeText, measureText } from "./textMeasurements";
 import { wrapText } from "./textWrapping";
 import { wrapText } from "./textWrapping";
 
 
+import { isLineElement } from "./typeChecks";
+
 import type {
 import type {
   ExcalidrawElement,
   ExcalidrawElement,
   ExcalidrawImageElement,
   ExcalidrawImageElement,
@@ -45,6 +47,7 @@ import type {
   ElementsMap,
   ElementsMap,
   ExcalidrawArrowElement,
   ExcalidrawArrowElement,
   ExcalidrawElbowArrowElement,
   ExcalidrawElbowArrowElement,
+  ExcalidrawLineElement,
 } from "./types";
 } from "./types";
 
 
 export type ElementConstructorOpts = MarkOptional<
 export type ElementConstructorOpts = MarkOptional<
@@ -457,9 +460,10 @@ export const newLinearElement = (
   opts: {
   opts: {
     type: ExcalidrawLinearElement["type"];
     type: ExcalidrawLinearElement["type"];
     points?: ExcalidrawLinearElement["points"];
     points?: ExcalidrawLinearElement["points"];
+    polygon?: ExcalidrawLineElement["polygon"];
   } & ElementConstructorOpts,
   } & ElementConstructorOpts,
 ): NonDeleted<ExcalidrawLinearElement> => {
 ): NonDeleted<ExcalidrawLinearElement> => {
-  return {
+  const element = {
     ..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
     ..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
     points: opts.points || [],
     points: opts.points || [],
     lastCommittedPoint: null,
     lastCommittedPoint: null,
@@ -468,6 +472,17 @@ export const newLinearElement = (
     startArrowhead: null,
     startArrowhead: null,
     endArrowhead: null,
     endArrowhead: null,
   };
   };
+
+  if (isLineElement(element)) {
+    const lineElement: NonDeleted<ExcalidrawLineElement> = {
+      ...element,
+      polygon: opts.polygon ?? false,
+    };
+
+    return lineElement;
+  }
+
+  return element;
 };
 };
 
 
 export const newArrowElement = <T extends boolean>(
 export const newArrowElement = <T extends boolean>(

+ 48 - 0
packages/element/src/shapes.ts

@@ -5,6 +5,7 @@ import {
   ROUNDNESS,
   ROUNDNESS,
   invariant,
   invariant,
   elementCenterPoint,
   elementCenterPoint,
+  LINE_POLYGON_POINT_MERGE_DISTANCE,
 } from "@excalidraw/common";
 } from "@excalidraw/common";
 import {
 import {
   isPoint,
   isPoint,
@@ -35,10 +36,13 @@ import { ShapeCache } from "./ShapeCache";
 
 
 import { getElementAbsoluteCoords, type Bounds } from "./bounds";
 import { getElementAbsoluteCoords, type Bounds } from "./bounds";
 
 
+import { canBecomePolygon } from "./typeChecks";
+
 import type {
 import type {
   ElementsMap,
   ElementsMap,
   ExcalidrawElement,
   ExcalidrawElement,
   ExcalidrawLinearElement,
   ExcalidrawLinearElement,
+  ExcalidrawLineElement,
   NonDeleted,
   NonDeleted,
 } from "./types";
 } from "./types";
 
 
@@ -396,3 +400,47 @@ export const isPathALoop = (
   }
   }
   return false;
   return false;
 };
 };
+
+export const toggleLinePolygonState = (
+  element: ExcalidrawLineElement,
+  nextPolygonState: boolean,
+): {
+  polygon: ExcalidrawLineElement["polygon"];
+  points: ExcalidrawLineElement["points"];
+} | null => {
+  const updatedPoints = [...element.points];
+
+  if (nextPolygonState) {
+    if (!canBecomePolygon(element.points)) {
+      return null;
+    }
+
+    const firstPoint = updatedPoints[0];
+    const lastPoint = updatedPoints[updatedPoints.length - 1];
+
+    const distance = Math.hypot(
+      firstPoint[0] - lastPoint[0],
+      firstPoint[1] - lastPoint[1],
+    );
+
+    if (
+      distance > LINE_POLYGON_POINT_MERGE_DISTANCE ||
+      updatedPoints.length < 4
+    ) {
+      updatedPoints.push(pointFrom(firstPoint[0], firstPoint[1]));
+    } else {
+      updatedPoints[updatedPoints.length - 1] = pointFrom(
+        firstPoint[0],
+        firstPoint[1],
+      );
+    }
+  }
+
+  // TODO: satisfies ElementUpdate<ExcalidrawLineElement>
+  const ret = {
+    polygon: nextPolygonState,
+    points: updatedPoints,
+  };
+
+  return ret;
+};

+ 32 - 0
packages/element/src/typeChecks.ts

@@ -1,5 +1,7 @@
 import { ROUNDNESS, assertNever } from "@excalidraw/common";
 import { ROUNDNESS, assertNever } from "@excalidraw/common";
 
 
+import { pointsEqual } from "@excalidraw/math";
+
 import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
 import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
 
 
 import type { MarkNonNullable } from "@excalidraw/common/utility-types";
 import type { MarkNonNullable } from "@excalidraw/common/utility-types";
@@ -25,6 +27,7 @@ import type {
   ExcalidrawMagicFrameElement,
   ExcalidrawMagicFrameElement,
   ExcalidrawArrowElement,
   ExcalidrawArrowElement,
   ExcalidrawElbowArrowElement,
   ExcalidrawElbowArrowElement,
+  ExcalidrawLineElement,
   PointBinding,
   PointBinding,
   FixedPointBinding,
   FixedPointBinding,
   ExcalidrawFlowchartNodeElement,
   ExcalidrawFlowchartNodeElement,
@@ -108,6 +111,12 @@ export const isLinearElement = (
   return element != null && isLinearElementType(element.type);
   return element != null && isLinearElementType(element.type);
 };
 };
 
 
+export const isLineElement = (
+  element?: ExcalidrawElement | null,
+): element is ExcalidrawLineElement => {
+  return element != null && element.type === "line";
+};
+
 export const isArrowElement = (
 export const isArrowElement = (
   element?: ExcalidrawElement | null,
   element?: ExcalidrawElement | null,
 ): element is ExcalidrawArrowElement => {
 ): element is ExcalidrawArrowElement => {
@@ -372,3 +381,26 @@ export const getLinearElementSubType = (
   }
   }
   return "line";
   return "line";
 };
 };
+
+/**
+ * Checks if current element points meet all the conditions for polygon=true
+ * (this isn't a element type check, for that use isLineElement).
+ *
+ * If you want to check if points *can* be turned into a polygon, use
+ *  canBecomePolygon(points).
+ */
+export const isValidPolygon = (
+  points: ExcalidrawLineElement["points"],
+): boolean => {
+  return points.length > 3 && pointsEqual(points[0], points[points.length - 1]);
+};
+
+export const canBecomePolygon = (
+  points: ExcalidrawLineElement["points"],
+): boolean => {
+  return (
+    points.length > 3 ||
+    // 3-point polygons can't have all points in a single line
+    (points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
+  );
+};

+ 10 - 2
packages/element/src/types.ts

@@ -296,8 +296,10 @@ export type FixedPointBinding = Merge<
   }
   }
 >;
 >;
 
 
+type Index = number;
+
 export type PointsPositionUpdates = Map<
 export type PointsPositionUpdates = Map<
-  number,
+  Index,
   { point: LocalPoint; isDragging?: boolean }
   { point: LocalPoint; isDragging?: boolean }
 >;
 >;
 
 
@@ -326,10 +328,16 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
     endArrowhead: Arrowhead | null;
     endArrowhead: Arrowhead | null;
   }>;
   }>;
 
 
+export type ExcalidrawLineElement = ExcalidrawLinearElement &
+  Readonly<{
+    type: "line";
+    polygon: boolean;
+  }>;
+
 export type FixedSegment = {
 export type FixedSegment = {
   start: LocalPoint;
   start: LocalPoint;
   end: LocalPoint;
   end: LocalPoint;
-  index: number;
+  index: Index;
 };
 };
 
 
 export type ExcalidrawArrowElement = ExcalidrawLinearElement &
 export type ExcalidrawArrowElement = ExcalidrawLinearElement &

+ 1 - 5
packages/excalidraw/actions/actionDeleteSelected.tsx

@@ -258,11 +258,7 @@ export const actionDeleteSelected = register({
           : endBindingElement,
           : endBindingElement,
       };
       };
 
 
-      LinearElementEditor.deletePoints(
-        element,
-        app.scene,
-        selectedPointsIndices,
-      );
+      LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
 
 
       return {
       return {
         elements,
         elements,

+ 37 - 12
packages/excalidraw/actions/actionFinalize.tsx

@@ -5,9 +5,14 @@ import {
   bindOrUnbindLinearElement,
   bindOrUnbindLinearElement,
   isBindingEnabled,
   isBindingEnabled,
 } from "@excalidraw/element/binding";
 } from "@excalidraw/element/binding";
-import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
+import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
 
 
-import { isBindingElement, isLinearElement } from "@excalidraw/element";
+import {
+  isBindingElement,
+  isFreeDrawElement,
+  isLinearElement,
+  isLineElement,
+} from "@excalidraw/element";
 
 
 import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
 import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
 import { isPathALoop } from "@excalidraw/element";
 import { isPathALoop } from "@excalidraw/element";
@@ -16,6 +21,7 @@ import { isInvisiblySmallElement } from "@excalidraw/element";
 
 
 import { CaptureUpdateAction } from "@excalidraw/element";
 import { CaptureUpdateAction } from "@excalidraw/element";
 
 
+import type { LocalPoint } from "@excalidraw/math";
 import type {
 import type {
   ExcalidrawElement,
   ExcalidrawElement,
   ExcalidrawLinearElement,
   ExcalidrawLinearElement,
@@ -93,6 +99,12 @@ export const actionFinalize = register({
             scene,
             scene,
           );
           );
         }
         }
+        if (isLineElement(element) && !isValidPolygon(element.points)) {
+          scene.mutateElement(element, {
+            polygon: false,
+          });
+        }
+
         return {
         return {
           elements:
           elements:
             element.points.length < 2 || isInvisiblySmallElement(element)
             element.points.length < 2 || isInvisiblySmallElement(element)
@@ -166,25 +178,38 @@ export const actionFinalize = register({
         newElements = newElements.filter((el) => el.id !== element!.id);
         newElements = newElements.filter((el) => el.id !== element!.id);
       }
       }
 
 
-      if (isLinearElement(element) || element.type === "freedraw") {
+      if (isLinearElement(element) || isFreeDrawElement(element)) {
         // If the multi point line closes the loop,
         // If the multi point line closes the loop,
         // set the last point to first point.
         // set the last point to first point.
         // This ensures that loop remains closed at different scales.
         // This ensures that loop remains closed at different scales.
         const isLoop = isPathALoop(element.points, appState.zoom.value);
         const isLoop = isPathALoop(element.points, appState.zoom.value);
-        if (element.type === "line" || element.type === "freedraw") {
-          if (isLoop) {
-            const linePoints = element.points;
-            const firstPoint = linePoints[0];
+
+        if (isLoop && (isLineElement(element) || isFreeDrawElement(element))) {
+          const linePoints = element.points;
+          const firstPoint = linePoints[0];
+          const points: LocalPoint[] = linePoints.map((p, index) =>
+            index === linePoints.length - 1
+              ? pointFrom(firstPoint[0], firstPoint[1])
+              : p,
+          );
+          if (isLineElement(element)) {
             scene.mutateElement(element, {
             scene.mutateElement(element, {
-              points: linePoints.map((p, index) =>
-                index === linePoints.length - 1
-                  ? pointFrom(firstPoint[0], firstPoint[1])
-                  : p,
-              ),
+              points,
+              polygon: true,
+            });
+          } else {
+            scene.mutateElement(element, {
+              points,
             });
             });
           }
           }
         }
         }
 
 
+        if (isLineElement(element) && !isValidPolygon(element.points)) {
+          scene.mutateElement(element, {
+            polygon: false,
+          });
+        }
+
         if (
         if (
           isBindingElement(element) &&
           isBindingElement(element) &&
           !isLoop &&
           !isLoop &&

+ 123 - 6
packages/excalidraw/actions/actionLinearEditor.tsx

@@ -1,19 +1,29 @@
 import { LinearElementEditor } from "@excalidraw/element";
 import { LinearElementEditor } from "@excalidraw/element";
-
-import { isElbowArrow, isLinearElement } from "@excalidraw/element";
-
+import {
+  isElbowArrow,
+  isLinearElement,
+  isLineElement,
+} from "@excalidraw/element";
 import { arrayToMap } from "@excalidraw/common";
 import { arrayToMap } from "@excalidraw/common";
 
 
 import { CaptureUpdateAction } from "@excalidraw/element";
 import { CaptureUpdateAction } from "@excalidraw/element";
 
 
-import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
+import type {
+  ExcalidrawLinearElement,
+  ExcalidrawLineElement,
+} from "@excalidraw/element/types";
 
 
 import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
 import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
 import { ToolButton } from "../components/ToolButton";
 import { ToolButton } from "../components/ToolButton";
-import { lineEditorIcon } from "../components/icons";
-
+import { lineEditorIcon, polygonIcon } from "../components/icons";
 import { t } from "../i18n";
 import { t } from "../i18n";
 
 
+import { ButtonIcon } from "../components/ButtonIcon";
+
+import { newElementWith } from "../../element/src/mutateElement";
+
+import { toggleLinePolygonState } from "../../element/src/shapes";
+
 import { register } from "./register";
 import { register } from "./register";
 
 
 export const actionToggleLinearEditor = register({
 export const actionToggleLinearEditor = register({
@@ -83,3 +93,110 @@ export const actionToggleLinearEditor = register({
     );
     );
   },
   },
 });
 });
+
+export const actionTogglePolygon = register({
+  name: "togglePolygon",
+  category: DEFAULT_CATEGORIES.elements,
+  icon: polygonIcon,
+  keywords: ["loop"],
+  label: (elements, appState, app) => {
+    const selectedElements = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+    });
+
+    const allPolygons = !selectedElements.some(
+      (element) => !isLineElement(element) || !element.polygon,
+    );
+
+    return allPolygons
+      ? "labels.polygon.breakPolygon"
+      : "labels.polygon.convertToPolygon";
+  },
+  trackEvent: {
+    category: "element",
+  },
+  predicate: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+    });
+
+    return (
+      selectedElements.length > 0 &&
+      selectedElements.every(
+        (element) => isLineElement(element) && element.points.length >= 4,
+      )
+    );
+  },
+  perform(elements, appState, _, app) {
+    const selectedElements = app.scene.getSelectedElements(appState);
+
+    if (selectedElements.some((element) => !isLineElement(element))) {
+      return false;
+    }
+
+    const targetElements = selectedElements as ExcalidrawLineElement[];
+
+    // if one element not a polygon, convert all to polygon
+    const nextPolygonState = targetElements.some((element) => !element.polygon);
+
+    const targetElementsMap = arrayToMap(targetElements);
+
+    return {
+      elements: elements.map((element) => {
+        if (!targetElementsMap.has(element.id) || !isLineElement(element)) {
+          return element;
+        }
+
+        return newElementWith(element, {
+          backgroundColor: nextPolygonState
+            ? element.backgroundColor
+            : "transparent",
+          ...toggleLinePolygonState(element, nextPolygonState),
+        });
+      }),
+      appState,
+      captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+    };
+  },
+  PanelComponent: ({ appState, updateData, app }) => {
+    const selectedElements = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+    });
+
+    if (
+      selectedElements.length === 0 ||
+      selectedElements.some(
+        (element) =>
+          !isLineElement(element) ||
+          // only show polygon button if every selected element is already
+          // a polygon, effectively showing this button only to allow for
+          // disabling the polygon state
+          !element.polygon ||
+          element.points.length < 3,
+      )
+    ) {
+      return null;
+    }
+
+    const allPolygon = selectedElements.every(
+      (element) => isLineElement(element) && element.polygon,
+    );
+
+    const label = t(
+      allPolygon
+        ? "labels.polygon.breakPolygon"
+        : "labels.polygon.convertToPolygon",
+    );
+
+    return (
+      <ButtonIcon
+        icon={polygonIcon}
+        title={label}
+        aria-label={label}
+        active={allPolygon}
+        onClick={() => updateData(null)}
+        style={{ marginLeft: "auto" }}
+      />
+    );
+  },
+});

+ 48 - 13
packages/excalidraw/actions/actionProperties.tsx

@@ -20,10 +20,11 @@ import {
   getShortcutKey,
   getShortcutKey,
   tupleToCoors,
   tupleToCoors,
   getLineHeight,
   getLineHeight,
+  isTransparent,
   reduceToCommonValue,
   reduceToCommonValue,
 } from "@excalidraw/common";
 } from "@excalidraw/common";
 
 
-import { getNonDeletedElements } from "@excalidraw/element";
+import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
 
 
 import {
 import {
   bindLinearElement,
   bindLinearElement,
@@ -47,6 +48,7 @@ import {
   isBoundToContainer,
   isBoundToContainer,
   isElbowArrow,
   isElbowArrow,
   isLinearElement,
   isLinearElement,
+  isLineElement,
   isTextElement,
   isTextElement,
   isUsingAdaptiveRadius,
   isUsingAdaptiveRadius,
 } from "@excalidraw/element";
 } from "@excalidraw/element";
@@ -136,6 +138,8 @@ import {
   isSomeElementSelected,
   isSomeElementSelected,
 } from "../scene";
 } from "../scene";
 
 
+import { toggleLinePolygonState } from "../../element/src/shapes";
+
 import { register } from "./register";
 import { register } from "./register";
 
 
 import type { AppClassProperties, AppState, Primitive } from "../types";
 import type { AppClassProperties, AppState, Primitive } from "../types";
@@ -349,22 +353,52 @@ export const actionChangeBackgroundColor = register({
   name: "changeBackgroundColor",
   name: "changeBackgroundColor",
   label: "labels.changeBackground",
   label: "labels.changeBackground",
   trackEvent: false,
   trackEvent: false,
-  perform: (elements, appState, value) => {
-    return {
-      ...(value.currentItemBackgroundColor && {
-        elements: changeProperty(elements, appState, (el) =>
-          newElementWith(el, {
+  perform: (elements, appState, value, app) => {
+    if (!value.currentItemBackgroundColor) {
+      return {
+        appState: {
+          ...appState,
+          ...value,
+        },
+        captureUpdate: CaptureUpdateAction.EVENTUALLY,
+      };
+    }
+
+    let nextElements;
+
+    const selectedElements = app.scene.getSelectedElements(appState);
+    const shouldEnablePolygon =
+      !isTransparent(value.currentItemBackgroundColor) &&
+      selectedElements.every(
+        (el) => isLineElement(el) && canBecomePolygon(el.points),
+      );
+
+    if (shouldEnablePolygon) {
+      const selectedElementsMap = arrayToMap(selectedElements);
+      nextElements = elements.map((el) => {
+        if (selectedElementsMap.has(el.id) && isLineElement(el)) {
+          return newElementWith(el, {
             backgroundColor: value.currentItemBackgroundColor,
             backgroundColor: value.currentItemBackgroundColor,
-          }),
-        ),
-      }),
+            ...toggleLinePolygonState(el, true),
+          });
+        }
+        return el;
+      });
+    } else {
+      nextElements = changeProperty(elements, appState, (el) =>
+        newElementWith(el, {
+          backgroundColor: value.currentItemBackgroundColor,
+        }),
+      );
+    }
+
+    return {
+      elements: nextElements,
       appState: {
       appState: {
         ...appState,
         ...appState,
         ...value,
         ...value,
       },
       },
-      captureUpdate: !!value.currentItemBackgroundColor
-        ? CaptureUpdateAction.IMMEDIATELY
-        : CaptureUpdateAction.EVENTUALLY,
+      captureUpdate: CaptureUpdateAction.IMMEDIATELY,
     };
     };
   },
   },
   PanelComponent: ({ elements, appState, updateData, app }) => (
   PanelComponent: ({ elements, appState, updateData, app }) => (
@@ -1373,7 +1407,7 @@ export const actionChangeRoundness = register({
       captureUpdate: CaptureUpdateAction.IMMEDIATELY,
       captureUpdate: CaptureUpdateAction.IMMEDIATELY,
     };
     };
   },
   },
-  PanelComponent: ({ elements, appState, updateData, app }) => {
+  PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
     const targetElements = getTargetElements(
     const targetElements = getTargetElements(
       getNonDeletedElements(elements),
       getNonDeletedElements(elements),
       appState,
       appState,
@@ -1417,6 +1451,7 @@ export const actionChangeRoundness = register({
             )}
             )}
             onChange={(value) => updateData(value)}
             onChange={(value) => updateData(value)}
           />
           />
+          {renderAction("togglePolygon")}
         </div>
         </div>
       </fieldset>
       </fieldset>
     );
     );

+ 1 - 0
packages/excalidraw/actions/manager.tsx

@@ -179,6 +179,7 @@ export class ActionManager {
           appProps={this.app.props}
           appProps={this.app.props}
           app={this.app}
           app={this.app}
           data={data}
           data={data}
+          renderAction={this.renderAction}
         />
         />
       );
       );
     }
     }

+ 6 - 1
packages/excalidraw/actions/types.ts

@@ -142,7 +142,8 @@ export type ActionName =
   | "cropEditor"
   | "cropEditor"
   | "wrapSelectionInFrame"
   | "wrapSelectionInFrame"
   | "toggleLassoTool"
   | "toggleLassoTool"
-  | "toggleShapeSwitch";
+  | "toggleShapeSwitch"
+  | "togglePolygon";
 
 
 export type PanelComponentProps = {
 export type PanelComponentProps = {
   elements: readonly ExcalidrawElement[];
   elements: readonly ExcalidrawElement[];
@@ -151,6 +152,10 @@ export type PanelComponentProps = {
   appProps: ExcalidrawProps;
   appProps: ExcalidrawProps;
   data?: Record<string, any>;
   data?: Record<string, any>;
   app: AppClassProperties;
   app: AppClassProperties;
+  renderAction: (
+    name: ActionName,
+    data?: PanelComponentProps["data"],
+  ) => React.JSX.Element | null;
 };
 };
 
 
 export interface Action {
 export interface Action {

+ 2 - 0
packages/excalidraw/components/ButtonIcon.tsx

@@ -15,6 +15,7 @@ interface ButtonIconProps {
   /** include standalone style (could interfere with parent styles) */
   /** include standalone style (could interfere with parent styles) */
   standalone?: boolean;
   standalone?: boolean;
   onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
   onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
+  style?: React.CSSProperties;
 }
 }
 
 
 export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
 export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
@@ -30,6 +31,7 @@ export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
         data-testid={testId}
         data-testid={testId}
         className={clsx(className, { standalone, active })}
         className={clsx(className, { standalone, active })}
         onClick={onClick}
         onClick={onClick}
+        style={props.style}
       >
       >
         {icon}
         {icon}
       </button>
       </button>

+ 1 - 0
packages/excalidraw/components/CommandPalette/CommandPalette.tsx

@@ -293,6 +293,7 @@ function CommandPaletteInner({
         actionManager.actions.decreaseFontSize,
         actionManager.actions.decreaseFontSize,
         actionManager.actions.toggleLinearEditor,
         actionManager.actions.toggleLinearEditor,
         actionManager.actions.cropEditor,
         actionManager.actions.cropEditor,
+        actionManager.actions.togglePolygon,
         actionLink,
         actionLink,
         actionCopyElementLink,
         actionCopyElementLink,
         actionLinkToElement,
         actionLinkToElement,

+ 15 - 0
packages/excalidraw/components/icons.tsx

@@ -129,6 +129,21 @@ export const PinIcon = createIcon(
   tablerIconProps,
   tablerIconProps,
 );
 );
 
 
+export const polygonIcon = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M12 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
+    <path d="M19 8m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
+    <path d="M5 11m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
+    <path d="M15 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
+    <path d="M6.5 9.5l3.5 -3" />
+    <path d="M14 5.5l3 1.5" />
+    <path d="M18.5 10l-2.5 7" />
+    <path d="M13.5 17.5l-7 -5" />
+  </g>,
+  tablerIconProps,
+);
+
 // tabler-icons: lock-open (via Figma)
 // tabler-icons: lock-open (via Figma)
 export const UnlockedIcon = createIcon(
 export const UnlockedIcon = createIcon(
   <g>
   <g>

+ 2 - 0
packages/excalidraw/data/__snapshots__/transform.test.ts.snap

@@ -948,6 +948,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
       0,
       0,
     ],
     ],
   ],
   ],
+  "polygon": false,
   "roughness": 1,
   "roughness": 1,
   "roundness": null,
   "roundness": null,
   "seed": Any<Number>,
   "seed": Any<Number>,
@@ -995,6 +996,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
       0,
       0,
     ],
     ],
   ],
   ],
+  "polygon": false,
   "roughness": 1,
   "roughness": 1,
   "roundness": null,
   "roundness": null,
   "seed": Any<Number>,
   "seed": Any<Number>,

+ 13 - 3
packages/excalidraw/data/restore.ts

@@ -18,7 +18,7 @@ import {
   normalizeLink,
   normalizeLink,
   getLineHeight,
   getLineHeight,
 } from "@excalidraw/common";
 } from "@excalidraw/common";
-import { getNonDeletedElements } from "@excalidraw/element";
+import { getNonDeletedElements, isValidPolygon } from "@excalidraw/element";
 import { normalizeFixedPoint } from "@excalidraw/element";
 import { normalizeFixedPoint } from "@excalidraw/element";
 import {
 import {
   updateElbowArrowPoints,
   updateElbowArrowPoints,
@@ -34,6 +34,7 @@ import {
   isElbowArrow,
   isElbowArrow,
   isFixedPointBinding,
   isFixedPointBinding,
   isLinearElement,
   isLinearElement,
+  isLineElement,
   isTextElement,
   isTextElement,
   isUsingAdaptiveRadius,
   isUsingAdaptiveRadius,
 } from "@excalidraw/element";
 } from "@excalidraw/element";
@@ -323,7 +324,8 @@ const restoreElement = (
           : element.points;
           : element.points;
 
 
       if (points[0][0] !== 0 || points[0][1] !== 0) {
       if (points[0][0] !== 0 || points[0][1] !== 0) {
-        ({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
+        ({ points, x, y } =
+          LinearElementEditor.getNormalizeElementPointsAndCoords(element));
       }
       }
 
 
       return restoreElementWithProperties(element, {
       return restoreElementWithProperties(element, {
@@ -339,6 +341,13 @@ const restoreElement = (
         points,
         points,
         x,
         x,
         y,
         y,
+        ...(isLineElement(element)
+          ? {
+              polygon: isValidPolygon(element.points)
+                ? element.polygon ?? false
+                : false,
+            }
+          : {}),
         ...getSizeFromPoints(points),
         ...getSizeFromPoints(points),
       });
       });
     case "arrow": {
     case "arrow": {
@@ -351,7 +360,8 @@ const restoreElement = (
           : element.points;
           : element.points;
 
 
       if (points[0][0] !== 0 || points[0][1] !== 0) {
       if (points[0][0] !== 0 || points[0][1] !== 0) {
-        ({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
+        ({ points, x, y } =
+          LinearElementEditor.getNormalizeElementPointsAndCoords(element));
       }
       }
 
 
       const base = {
       const base = {

+ 1 - 1
packages/excalidraw/data/transform.ts

@@ -466,7 +466,7 @@ const bindLinearElementToElement = (
 
 
   Object.assign(
   Object.assign(
     linearElement,
     linearElement,
-    LinearElementEditor.getNormalizedPoints({
+    LinearElementEditor.getNormalizeElementPointsAndCoords({
       ...linearElement,
       ...linearElement,
       points: newPoints,
       points: newPoints,
     }),
     }),

+ 4 - 0
packages/excalidraw/locales/en.json

@@ -141,6 +141,10 @@
       "edit": "Edit line",
       "edit": "Edit line",
       "editArrow": "Edit arrow"
       "editArrow": "Edit arrow"
     },
     },
+    "polygon": {
+      "breakPolygon": "Break polygon",
+      "convertToPolygon": "Convert to polygon"
+    },
     "elementLock": {
     "elementLock": {
       "lock": "Lock",
       "lock": "Lock",
       "unlock": "Unlock",
       "unlock": "Unlock",

+ 5 - 2
packages/excalidraw/renderer/helpers.ts

@@ -31,11 +31,14 @@ export const fillCircle = (
   cx: number,
   cx: number,
   cy: number,
   cy: number,
   radius: number,
   radius: number,
-  stroke = true,
+  stroke: boolean,
+  fill = true,
 ) => {
 ) => {
   context.beginPath();
   context.beginPath();
   context.arc(cx, cy, radius, 0, Math.PI * 2);
   context.arc(cx, cy, radius, 0, Math.PI * 2);
-  context.fill();
+  if (fill) {
+    context.fill();
+  }
   if (stroke) {
   if (stroke) {
     context.stroke();
     context.stroke();
   }
   }

+ 38 - 6
packages/excalidraw/renderer/interactiveScene.ts

@@ -1,5 +1,6 @@
 import {
 import {
   pointFrom,
   pointFrom,
+  pointsEqual,
   type GlobalPoint,
   type GlobalPoint,
   type LocalPoint,
   type LocalPoint,
   type Radians,
   type Radians,
@@ -28,6 +29,7 @@ import {
   isFrameLikeElement,
   isFrameLikeElement,
   isImageElement,
   isImageElement,
   isLinearElement,
   isLinearElement,
+  isLineElement,
   isTextElement,
   isTextElement,
 } from "@excalidraw/element";
 } from "@excalidraw/element";
 
 
@@ -161,7 +163,8 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
   point: Point,
   point: Point,
   radius: number,
   radius: number,
   isSelected: boolean,
   isSelected: boolean,
-  isPhantomPoint = false,
+  isPhantomPoint: boolean,
+  isOverlappingPoint: boolean,
 ) => {
 ) => {
   context.strokeStyle = "#5e5ad8";
   context.strokeStyle = "#5e5ad8";
   context.setLineDash([]);
   context.setLineDash([]);
@@ -176,8 +179,11 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
     context,
     context,
     point[0],
     point[0],
     point[1],
     point[1],
-    radius / appState.zoom.value,
+    (isOverlappingPoint
+      ? radius * (appState.editingLinearElement ? 1.5 : 2)
+      : radius) / appState.zoom.value,
     !isPhantomPoint,
     !isPhantomPoint,
+    !isOverlappingPoint || isSelected,
   );
   );
 };
 };
 
 
@@ -253,7 +259,7 @@ const renderBindingHighlightForSuggestedPointBinding = (
       index,
       index,
       elementsMap,
       elementsMap,
     );
     );
-    fillCircle(context, x, y, threshold);
+    fillCircle(context, x, y, threshold, true);
   });
   });
 };
 };
 
 
@@ -442,15 +448,39 @@ const renderLinearPointHandles = (
   const radius = appState.editingLinearElement
   const radius = appState.editingLinearElement
     ? POINT_HANDLE_SIZE
     ? POINT_HANDLE_SIZE
     : POINT_HANDLE_SIZE / 2;
     : POINT_HANDLE_SIZE / 2;
+
+  const _isElbowArrow = isElbowArrow(element);
+  const _isLineElement = isLineElement(element);
+
   points.forEach((point, idx) => {
   points.forEach((point, idx) => {
-    if (isElbowArrow(element) && idx !== 0 && idx !== points.length - 1) {
+    if (_isElbowArrow && idx !== 0 && idx !== points.length - 1) {
       return;
       return;
     }
     }
 
 
+    const isOverlappingPoint =
+      idx > 0 &&
+      (idx !== points.length - 1 ||
+        appState.editingLinearElement ||
+        !_isLineElement ||
+        !element.polygon) &&
+      pointsEqual(
+        point,
+        idx === points.length - 1 ? points[0] : points[idx - 1],
+        2 / appState.zoom.value,
+      );
+
     const isSelected =
     const isSelected =
       !!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
       !!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
 
 
-    renderSingleLinearPoint(context, appState, point, radius, isSelected);
+    renderSingleLinearPoint(
+      context,
+      appState,
+      point,
+      radius,
+      isSelected,
+      false,
+      isOverlappingPoint,
+    );
   });
   });
 
 
   // Rendering segment mid points
   // Rendering segment mid points
@@ -477,6 +507,7 @@ const renderLinearPointHandles = (
           POINT_HANDLE_SIZE / 2,
           POINT_HANDLE_SIZE / 2,
           false,
           false,
           !fixedSegments.includes(idx + 1),
           !fixedSegments.includes(idx + 1),
+          false,
         );
         );
       }
       }
     });
     });
@@ -500,6 +531,7 @@ const renderLinearPointHandles = (
           POINT_HANDLE_SIZE / 2,
           POINT_HANDLE_SIZE / 2,
           false,
           false,
           true,
           true,
+          false,
         );
         );
       }
       }
     });
     });
@@ -526,7 +558,7 @@ const renderTransformHandles = (
         context.strokeStyle = renderConfig.selectionColor;
         context.strokeStyle = renderConfig.selectionColor;
       }
       }
       if (key === "rotation") {
       if (key === "rotation") {
-        fillCircle(context, x + width / 2, y + height / 2, width / 2);
+        fillCircle(context, x + width / 2, y + height / 2, width / 2, true);
         // prefer round corners if roundRect API is available
         // prefer round corners if roundRect API is available
       } else if (context.roundRect) {
       } else if (context.roundRect) {
         context.beginPath();
         context.beginPath();

+ 1 - 0
packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap

@@ -153,6 +153,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
       50,
       50,
     ],
     ],
   ],
   ],
+  "polygon": false,
   "roughness": 1,
   "roughness": 1,
   "roundness": {
   "roundness": {
     "type": 2,
     "type": 2,

+ 1 - 0
packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap

@@ -93,6 +93,7 @@ exports[`multi point mode in linear elements > line 3`] = `
       110,
       110,
     ],
     ],
   ],
   ],
+  "polygon": false,
   "roughness": 1,
   "roughness": 1,
   "roundness": {
   "roundness": {
     "type": 2,
     "type": 2,

+ 4 - 0
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -6492,6 +6492,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
                 10,
                 10,
               ],
               ],
             ],
             ],
+            "polygon": false,
             "roughness": 1,
             "roughness": 1,
             "roundness": {
             "roundness": {
               "type": 2,
               "type": 2,
@@ -6716,6 +6717,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
                 10,
                 10,
               ],
               ],
             ],
             ],
+            "polygon": false,
             "roughness": 1,
             "roughness": 1,
             "roundness": {
             "roundness": {
               "type": 2,
               "type": 2,
@@ -8954,6 +8956,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
                 10,
                 10,
               ],
               ],
             ],
             ],
+            "polygon": false,
             "roughness": 1,
             "roughness": 1,
             "roundness": {
             "roundness": {
               "type": 2,
               "type": 2,
@@ -9773,6 +9776,7 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
                 10,
                 10,
               ],
               ],
             ],
             ],
+            "polygon": false,
             "roughness": 1,
             "roughness": 1,
             "roundness": {
             "roundness": {
               "type": 2,
               "type": 2,

+ 1 - 0
packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap

@@ -79,6 +79,7 @@ exports[`select single element on the scene > arrow escape 1`] = `
       50,
       50,
     ],
     ],
   ],
   ],
+  "polygon": false,
   "roughness": 1,
   "roughness": 1,
   "roundness": {
   "roundness": {
     "type": 2,
     "type": 2,

+ 2 - 0
packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap

@@ -240,6 +240,7 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
       100,
       100,
     ],
     ],
   ],
   ],
+  "polygon": false,
   "roughness": 1,
   "roughness": 1,
   "roundness": {
   "roundness": {
     "type": 2,
     "type": 2,
@@ -289,6 +290,7 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
       100,
       100,
     ],
     ],
   ],
   ],
+  "polygon": false,
   "roughness": 1,
   "roughness": 1,
   "roundness": {
   "roundness": {
     "type": 2,
     "type": 2,