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:
@@ -258,11 +258,7 @@ export const actionDeleteSelected = register({
|
||||
: endBindingElement,
|
||||
};
|
||||
|
||||
LinearElementEditor.deletePoints(
|
||||
element,
|
||||
app.scene,
|
||||
selectedPointsIndices,
|
||||
);
|
||||
LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
|
||||
|
||||
return {
|
||||
elements,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -179,6 +179,7 @@ export class ActionManager {
|
||||
appProps={this.app.props}
|
||||
app={this.app}
|
||||
data={data}
|
||||
renderAction={this.renderAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user