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
co-authored by dwelle
parent 4dc205537c
commit 87c87a9fb1
26 changed files with 555 additions and 121 deletions
@@ -258,11 +258,7 @@ export const actionDeleteSelected = register({
: endBindingElement,
};
LinearElementEditor.deletePoints(
element,
app.scene,
selectedPointsIndices,
);
LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
return {
elements,
+37 -12
View File
@@ -5,9 +5,14 @@ import {
bindOrUnbindLinearElement,
isBindingEnabled,
} 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 { isPathALoop } from "@excalidraw/element";
@@ -16,6 +21,7 @@ import { isInvisiblySmallElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@@ -93,6 +99,12 @@ export const actionFinalize = register({
scene,
);
}
if (isLineElement(element) && !isValidPolygon(element.points)) {
scene.mutateElement(element, {
polygon: false,
});
}
return {
elements:
element.points.length < 2 || isInvisiblySmallElement(element)
@@ -166,25 +178,38 @@ export const actionFinalize = register({
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,
// set the last point to first point.
// This ensures that loop remains closed at different scales.
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, {
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 (
isBindingElement(element) &&
!isLoop &&
@@ -1,19 +1,29 @@
import { LinearElementEditor } from "@excalidraw/element";
import { isElbowArrow, isLinearElement } from "@excalidraw/element";
import {
isElbowArrow,
isLinearElement,
isLineElement,
} from "@excalidraw/element";
import { arrayToMap } from "@excalidraw/common";
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 { ToolButton } from "../components/ToolButton";
import { lineEditorIcon } from "../components/icons";
import { lineEditorIcon, polygonIcon } from "../components/icons";
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";
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" }}
/>
);
},
});
@@ -20,10 +20,11 @@ import {
getShortcutKey,
tupleToCoors,
getLineHeight,
isTransparent,
reduceToCommonValue,
} from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
import {
bindLinearElement,
@@ -47,6 +48,7 @@ import {
isBoundToContainer,
isElbowArrow,
isLinearElement,
isLineElement,
isTextElement,
isUsingAdaptiveRadius,
} from "@excalidraw/element";
@@ -136,6 +138,8 @@ import {
isSomeElementSelected,
} from "../scene";
import { toggleLinePolygonState } from "../../element/src/shapes";
import { register } from "./register";
import type { AppClassProperties, AppState, Primitive } from "../types";
@@ -349,22 +353,52 @@ export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor",
label: "labels.changeBackground",
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,
}),
),
}),
...toggleLinePolygonState(el, true),
});
}
return el;
});
} else {
nextElements = changeProperty(elements, appState, (el) =>
newElementWith(el, {
backgroundColor: value.currentItemBackgroundColor,
}),
);
}
return {
elements: nextElements,
appState: {
...appState,
...value,
},
captureUpdate: !!value.currentItemBackgroundColor
? CaptureUpdateAction.IMMEDIATELY
: CaptureUpdateAction.EVENTUALLY,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
@@ -1373,7 +1407,7 @@ export const actionChangeRoundness = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
appState,
@@ -1417,6 +1451,7 @@ export const actionChangeRoundness = register({
)}
onChange={(value) => updateData(value)}
/>
{renderAction("togglePolygon")}
</div>
</fieldset>
);
+1
View File
@@ -179,6 +179,7 @@ export class ActionManager {
appProps={this.app.props}
app={this.app}
data={data}
renderAction={this.renderAction}
/>
);
}
+6 -1
View File
@@ -142,7 +142,8 @@ export type ActionName =
| "cropEditor"
| "wrapSelectionInFrame"
| "toggleLassoTool"
| "toggleShapeSwitch";
| "toggleShapeSwitch"
| "togglePolygon";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
@@ -151,6 +152,10 @@ export type PanelComponentProps = {
appProps: ExcalidrawProps;
data?: Record<string, any>;
app: AppClassProperties;
renderAction: (
name: ActionName,
data?: PanelComponentProps["data"],
) => React.JSX.Element | null;
};
export interface Action {
@@ -15,6 +15,7 @@ interface ButtonIconProps {
/** include standalone style (could interfere with parent styles) */
standalone?: boolean;
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
style?: React.CSSProperties;
}
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
@@ -30,6 +31,7 @@ export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
data-testid={testId}
className={clsx(className, { standalone, active })}
onClick={onClick}
style={props.style}
>
{icon}
</button>
@@ -293,6 +293,7 @@ function CommandPaletteInner({
actionManager.actions.decreaseFontSize,
actionManager.actions.toggleLinearEditor,
actionManager.actions.cropEditor,
actionManager.actions.togglePolygon,
actionLink,
actionCopyElementLink,
actionLinkToElement,
+15
View File
@@ -129,6 +129,21 @@ export const PinIcon = createIcon(
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)
export const UnlockedIcon = createIcon(
<g>
@@ -948,6 +948,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
0,
],
],
"polygon": false,
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
@@ -995,6 +996,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
0,
],
],
"polygon": false,
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
+13 -3
View File
@@ -18,7 +18,7 @@ import {
normalizeLink,
getLineHeight,
} from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
import { getNonDeletedElements, isValidPolygon } from "@excalidraw/element";
import { normalizeFixedPoint } from "@excalidraw/element";
import {
updateElbowArrowPoints,
@@ -34,6 +34,7 @@ import {
isElbowArrow,
isFixedPointBinding,
isLinearElement,
isLineElement,
isTextElement,
isUsingAdaptiveRadius,
} from "@excalidraw/element";
@@ -323,7 +324,8 @@ const restoreElement = (
: element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) {
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
({ points, x, y } =
LinearElementEditor.getNormalizeElementPointsAndCoords(element));
}
return restoreElementWithProperties(element, {
@@ -339,6 +341,13 @@ const restoreElement = (
points,
x,
y,
...(isLineElement(element)
? {
polygon: isValidPolygon(element.points)
? element.polygon ?? false
: false,
}
: {}),
...getSizeFromPoints(points),
});
case "arrow": {
@@ -351,7 +360,8 @@ const restoreElement = (
: element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) {
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
({ points, x, y } =
LinearElementEditor.getNormalizeElementPointsAndCoords(element));
}
const base = {
+1 -1
View File
@@ -466,7 +466,7 @@ const bindLinearElementToElement = (
Object.assign(
linearElement,
LinearElementEditor.getNormalizedPoints({
LinearElementEditor.getNormalizeElementPointsAndCoords({
...linearElement,
points: newPoints,
}),
+4
View File
@@ -141,6 +141,10 @@
"edit": "Edit line",
"editArrow": "Edit arrow"
},
"polygon": {
"breakPolygon": "Break polygon",
"convertToPolygon": "Convert to polygon"
},
"elementLock": {
"lock": "Lock",
"unlock": "Unlock",
+5 -2
View File
@@ -31,11 +31,14 @@ export const fillCircle = (
cx: number,
cy: number,
radius: number,
stroke = true,
stroke: boolean,
fill = true,
) => {
context.beginPath();
context.arc(cx, cy, radius, 0, Math.PI * 2);
context.fill();
if (fill) {
context.fill();
}
if (stroke) {
context.stroke();
}
@@ -1,5 +1,6 @@
import {
pointFrom,
pointsEqual,
type GlobalPoint,
type LocalPoint,
type Radians,
@@ -28,6 +29,7 @@ import {
isFrameLikeElement,
isImageElement,
isLinearElement,
isLineElement,
isTextElement,
} from "@excalidraw/element";
@@ -161,7 +163,8 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
point: Point,
radius: number,
isSelected: boolean,
isPhantomPoint = false,
isPhantomPoint: boolean,
isOverlappingPoint: boolean,
) => {
context.strokeStyle = "#5e5ad8";
context.setLineDash([]);
@@ -176,8 +179,11 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
context,
point[0],
point[1],
radius / appState.zoom.value,
(isOverlappingPoint
? radius * (appState.editingLinearElement ? 1.5 : 2)
: radius) / appState.zoom.value,
!isPhantomPoint,
!isOverlappingPoint || isSelected,
);
};
@@ -253,7 +259,7 @@ const renderBindingHighlightForSuggestedPointBinding = (
index,
elementsMap,
);
fillCircle(context, x, y, threshold);
fillCircle(context, x, y, threshold, true);
});
};
@@ -442,15 +448,39 @@ const renderLinearPointHandles = (
const radius = appState.editingLinearElement
? POINT_HANDLE_SIZE
: POINT_HANDLE_SIZE / 2;
const _isElbowArrow = isElbowArrow(element);
const _isLineElement = isLineElement(element);
points.forEach((point, idx) => {
if (isElbowArrow(element) && idx !== 0 && idx !== points.length - 1) {
if (_isElbowArrow && idx !== 0 && idx !== points.length - 1) {
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 =
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
renderSingleLinearPoint(context, appState, point, radius, isSelected);
renderSingleLinearPoint(
context,
appState,
point,
radius,
isSelected,
false,
isOverlappingPoint,
);
});
// Rendering segment mid points
@@ -477,6 +507,7 @@ const renderLinearPointHandles = (
POINT_HANDLE_SIZE / 2,
false,
!fixedSegments.includes(idx + 1),
false,
);
}
});
@@ -500,6 +531,7 @@ const renderLinearPointHandles = (
POINT_HANDLE_SIZE / 2,
false,
true,
false,
);
}
});
@@ -526,7 +558,7 @@ const renderTransformHandles = (
context.strokeStyle = renderConfig.selectionColor;
}
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
} else if (context.roundRect) {
context.beginPath();
@@ -153,6 +153,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
50,
],
],
"polygon": false,
"roughness": 1,
"roundness": {
"type": 2,
@@ -93,6 +93,7 @@ exports[`multi point mode in linear elements > line 3`] = `
110,
],
],
"polygon": false,
"roughness": 1,
"roundness": {
"type": 2,
@@ -6492,6 +6492,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
10,
],
],
"polygon": false,
"roughness": 1,
"roundness": {
"type": 2,
@@ -6716,6 +6717,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
10,
],
],
"polygon": false,
"roughness": 1,
"roundness": {
"type": 2,
@@ -8954,6 +8956,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
10,
],
],
"polygon": false,
"roughness": 1,
"roundness": {
"type": 2,
@@ -9773,6 +9776,7 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
10,
],
],
"polygon": false,
"roughness": 1,
"roundness": {
"type": 2,
@@ -79,6 +79,7 @@ exports[`select single element on the scene > arrow escape 1`] = `
50,
],
],
"polygon": false,
"roughness": 1,
"roundness": {
"type": 2,
@@ -240,6 +240,7 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
100,
],
],
"polygon": false,
"roughness": 1,
"roundness": {
"type": 2,
@@ -289,6 +290,7 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
100,
],
],
"polygon": false,
"roughness": 1,
"roundness": {
"type": 2,