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