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 {