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 <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
zsviczian 2025-05-26 11:14:55 +02:00 committed by GitHub
parent 4dc205537c
commit 87c87a9fb1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 555 additions and 121 deletions

View File

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

View File

@ -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, [ LinearElementEditor.deletePoints(element, app, [points.length - 1]);
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 * expected in various parts of the codebase.
* for the potential normalization. *
* Also returns normalized x and y coords to account for the normalization
* of the points.
*/ */
static getNormalizedPoints(element: ExcalidrawLinearElement): { static getNormalizeElementPointsAndCoords(element: ExcalidrawLinearElement) {
points: LocalPoint[]; const { points, offsetX, offsetY } = getNormalizedPoints(element);
x: number;
y: number;
} {
const { points } = element;
const offsetX = points[0][0];
const offsetY = points[0][1];
return { return {
points: points.map((p) => { points,
return pointFrom(p[0] - offsetX, p[1] - offsetY);
}),
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; const isUncommittedPoint =
let offsetY = 0; 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 // break polygon if deleting start/end point
// positions of the rest with respect to it if (
if (isDeletingOriginPoint) { isPolygon &&
const firstNonDeletedPoint = element.points.find((point, idx) => { // 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.filter((_, idx) => {
return !pointIndices.includes(idx); return !pointIndices.includes(idx);
}); });
if (firstNonDeletedPoint) {
offsetX = firstNonDeletedPoint[0];
offsetY = firstNonDeletedPoint[1];
}
}
const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => { if (isUncommittedPoint && isLineElement(element) && element.polygon) {
if (!pointIndices.includes(idx)) { nextPoints[0] = pointFrom(
acc.push( nextPoints[nextPoints.length - 1][0],
!acc.length nextPoints[nextPoints.length - 1][1],
? pointFrom(0, 0)
: pointFrom(p[0] - offsetX, p[1] - offsetY),
); );
} }
return acc;
}, []); const {
points: normalizedPoints,
offsetX,
offsetY,
} = getNormalizedPoints({ points: nextPoints });
LinearElementEditor._updatePoints( LinearElementEditor._updatePoints(
element, element,
scene, app.scene,
nextPoints, 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 nextPoints = [...element.points, ...addedPoints];
const offsetY = 0;
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], const [offsetX, offsetY] = updatedOriginPoint;
deltaY - points[0][1],
);
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], x: element.x + rotatedOffset[0],
y: element.y + rotated[1], y: element.y + rotatedOffset[1],
}); });
} }
} }

View File

@ -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>(

View File

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

View File

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

View File

@ -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 &

View File

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

View File

@ -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) { if (isLoop && (isLineElement(element) || isFreeDrawElement(element))) {
const linePoints = element.points; const linePoints = element.points;
const firstPoint = linePoints[0]; const firstPoint = linePoints[0];
scene.mutateElement(element, { const points: LocalPoint[] = linePoints.map((p, index) =>
points: linePoints.map((p, index) =>
index === linePoints.length - 1 index === linePoints.length - 1
? pointFrom(firstPoint[0], firstPoint[1]) ? pointFrom(firstPoint[0], firstPoint[1])
: p, : p,
), );
if (isLineElement(element)) {
scene.mutateElement(element, {
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 &&

View File

@ -1,19 +1,29 @@
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element";
import {
import { isElbowArrow, isLinearElement } from "@excalidraw/element"; 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" }}
/>
);
},
});

View File

@ -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) => { perform: (elements, appState, value, app) => {
if (!value.currentItemBackgroundColor) {
return { return {
...(value.currentItemBackgroundColor && {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
backgroundColor: value.currentItemBackgroundColor,
}),
),
}),
appState: { appState: {
...appState, ...appState,
...value, ...value,
}, },
captureUpdate: !!value.currentItemBackgroundColor captureUpdate: CaptureUpdateAction.EVENTUALLY,
? CaptureUpdateAction.IMMEDIATELY };
: 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,
...toggleLinePolygonState(el, true),
});
}
return el;
});
} else {
nextElements = changeProperty(elements, appState, (el) =>
newElementWith(el, {
backgroundColor: value.currentItemBackgroundColor,
}),
);
}
return {
elements: nextElements,
appState: {
...appState,
...value,
},
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>
); );

View File

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

View File

@ -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 {

View File

@ -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>

View File

@ -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,

View File

@ -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>

View File

@ -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>,

View File

@ -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 = {

View File

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

View File

@ -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",

View File

@ -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);
if (fill) {
context.fill(); context.fill();
}
if (stroke) { if (stroke) {
context.stroke(); context.stroke();
} }

View File

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

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,