safe conversion between line, sharp, curved, and elbow

This commit is contained in:
Ryan Di 2025-04-23 17:59:03 +10:00
parent c90cdb7b74
commit 7541fadf9c
6 changed files with 466 additions and 249 deletions

View file

@ -3,12 +3,16 @@ import {
randomInteger,
getUpdatedTimestamp,
toBrandedType,
isDevEnv,
ROUNDNESS,
} from "@excalidraw/common";
// TODO: remove direct dependency on the scene, should be passed in or injected instead
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import Scene from "@excalidraw/excalidraw/scene/Scene";
import type { AppClassProperties } from "@excalidraw/excalidraw/types";
import type { Radians } from "@excalidraw/math";
import type { Mutable } from "@excalidraw/common/utility-types";
@ -16,9 +20,26 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./ShapeCache";
import { updateElbowArrowPoints } from "./elbowArrow";
import { isElbowArrow } from "./typeChecks";
import {
isCurvedArrow,
isElbowArrow,
isSharpArrow,
isUsingAdaptiveRadius,
} from "./typeChecks";
import type { ExcalidrawElement, NonDeletedSceneElementsMap } from "./types";
import type {
ConvertibleGenericTypes,
ConvertibleLinearTypes,
ExcalidrawArrowElement,
ExcalidrawDiamondElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawLinearElement,
ExcalidrawRectangleElement,
ExcalidrawSelectionElement,
NonDeletedSceneElementsMap,
} from "./types";
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
@ -212,3 +233,211 @@ export const bumpVersion = <T extends Mutable<ExcalidrawElement>>(
element.updated = getUpdatedTimestamp();
return element;
};
// Declare the constant array with a read-only type so that its values can only be one of the valid union.
export const CONVERTIBLE_GENERIC_TYPES: readonly ConvertibleGenericTypes[] = [
"rectangle",
"diamond",
"ellipse",
];
const ELBOW_ARROW_SPECIFIC_PROPERTIES: Array<
keyof ExcalidrawElbowArrowElement
> = ["elbowed", "fixedSegments", "startIsSpecial", "endIsSpecial"];
const ARROW_TO_LINE_CLEAR_PROPERTIES: Array<keyof ExcalidrawArrowElement> = [
"startArrowhead",
"endArrowhead",
"startBinding",
"endBinding",
];
export const CONVERTIBLE_LINEAR_TYPES: readonly ConvertibleLinearTypes[] = [
"line",
"sharpArrow",
"curvedArrow",
"elbowArrow",
];
type NewElementType = ConvertibleGenericTypes | ConvertibleLinearTypes;
export const isConvertibleGenericType = (
elementType: string,
): elementType is ConvertibleGenericTypes =>
CONVERTIBLE_GENERIC_TYPES.includes(elementType as ConvertibleGenericTypes);
export const isConvertibleLinearType = (
elementType: string,
): elementType is ConvertibleLinearTypes =>
elementType === "arrow" ||
CONVERTIBLE_LINEAR_TYPES.includes(elementType as ConvertibleLinearTypes);
/**
* Converts an element to a new type, adding or removing properties as needed
* so that the element object is always valid.
*
* Valid conversions at this point:
* - switching between generic elements
* e.g. rectangle -> diamond
* - switching between linear elements
* e.g. elbow arrow -> line
*/
export const convertElementType = <
TElement extends Mutable<
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>
>,
>(
element: TElement,
newType: NewElementType,
app: AppClassProperties,
informMutation = true,
): ExcalidrawElement => {
if (!isValidConversion(element.type, newType)) {
if (isDevEnv()) {
throw Error(`Invalid conversion from ${element.type} to ${newType}.`);
}
return element;
}
const startType = isSharpArrow(element)
? "sharpArrow"
: isCurvedArrow(element)
? "curvedArrow"
: isElbowArrow(element)
? "elbowArrow"
: element.type;
if (element.type === newType) {
return element;
}
ShapeCache.delete(element);
const update = () => {
(element as any).version++;
(element as any).versionNonce = randomInteger();
(element as any).updated = getUpdatedTimestamp();
if (informMutation) {
app.scene.triggerUpdate();
}
};
if (
isConvertibleGenericType(startType) &&
isConvertibleGenericType(newType)
) {
(element as any).type = newType;
if (newType === "diamond" && element.roundness) {
(element as any).roundness = {
type: isUsingAdaptiveRadius(newType)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
};
}
update();
switch (element.type) {
case "rectangle":
return element as ExcalidrawRectangleElement;
case "diamond":
return element as ExcalidrawDiamondElement;
case "ellipse":
return element as ExcalidrawEllipseElement;
}
}
if (isConvertibleLinearType(element.type)) {
if (newType === "line") {
for (const key of ELBOW_ARROW_SPECIFIC_PROPERTIES) {
delete (element as any)[key];
}
for (const key of ARROW_TO_LINE_CLEAR_PROPERTIES) {
if (key in element) {
(element as any)[key] = null;
}
}
(element as any).type = newType;
}
if (newType === "sharpArrow") {
if (startType === "elbowArrow") {
// drop elbow arrow specific properties
for (const key of ELBOW_ARROW_SPECIFIC_PROPERTIES) {
delete (element as any)[key];
}
}
(element as any).type = "arrow";
(element as any).elbowed = false;
(element as any).roundness = null;
(element as any).startArrowhead = app.state.currentItemStartArrowhead;
(element as any).endArrowhead = app.state.currentItemEndArrowhead;
}
if (newType === "curvedArrow") {
if (startType === "elbowArrow") {
// drop elbow arrow specific properties
for (const key of ELBOW_ARROW_SPECIFIC_PROPERTIES) {
delete (element as any)[key];
}
}
(element as any).type = "arrow";
(element as any).elbowed = false;
(element as any).roundness = {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
};
(element as any).startArrowhead = app.state.currentItemStartArrowhead;
(element as any).endArrowhead = app.state.currentItemEndArrowhead;
}
if (newType === "elbowArrow") {
(element as any).type = "arrow";
(element as any).elbowed = true;
(element as any).fixedSegments = null;
(element as any).startIsSpecial = null;
(element as any).endIsSpecial = null;
}
update();
switch (newType) {
case "line":
return element as ExcalidrawLinearElement;
case "sharpArrow":
return element as ExcalidrawArrowElement;
case "curvedArrow":
return element as ExcalidrawArrowElement;
case "elbowArrow":
return element as ExcalidrawElbowArrowElement;
}
}
return element;
};
const isValidConversion = (
startType: string,
targetType: NewElementType,
): startType is NewElementType => {
if (
isConvertibleGenericType(startType) &&
isConvertibleGenericType(targetType)
) {
return true;
}
if (
isConvertibleLinearType(startType) &&
isConvertibleLinearType(targetType)
) {
return true;
}
// NOTE: add more conversions when needed
return false;
};

View file

@ -119,6 +119,20 @@ export const isElbowArrow = (
return isArrowElement(element) && element.elbowed;
};
export const isSharpArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawArrowElement => {
return isArrowElement(element) && !element.elbowed && !element.roundness;
};
export const isCurvedArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawArrowElement => {
return (
isArrowElement(element) && !element.elbowed && element.roundness !== null
);
};
export const isLinearElementType = (
elementType: ElementOrToolType,
): boolean => {
@ -338,76 +352,3 @@ export const isBounds = (box: unknown): box is Bounds =>
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number";
export const getSwitchableTypeFromElements = (
elements: ExcalidrawElement[],
):
| {
generic: true;
linear: false;
}
| {
linear: true;
generic: false;
}
| {
generic: false;
linear: false;
} => {
if (elements.length === 0) {
return {
generic: false,
linear: false,
};
}
let onlyLinear = true;
for (const element of elements) {
if (
element.type === "rectangle" ||
element.type === "ellipse" ||
element.type === "diamond"
) {
return {
generic: true,
linear: false,
};
}
if (element.type !== "arrow" && element.type !== "line") {
onlyLinear = false;
}
}
if (onlyLinear) {
// check at least some linear element is switchable
// for a linear to be swtichable:
// - no labels
// - not bound to anything
let linear = true;
for (const element of elements) {
if (
isArrowElement(element) &&
(element.startBinding !== null || element.endBinding !== null)
) {
linear = false;
} else if (element.boundElements && element.boundElements.length > 0) {
linear = false;
} else {
linear = true;
break;
}
}
return {
linear,
generic: false,
};
}
return {
generic: false,
linear: false,
};
};

View file

@ -413,5 +413,9 @@ export type ElementsMapOrArray =
| readonly ExcalidrawElement[]
| Readonly<ElementsMap>;
export type GenericSwitchableToolType = "rectangle" | "ellipse" | "diamond";
export type LinearSwitchableToolType = "line" | "arrow";
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
export type ConvertibleLinearTypes =
| "line"
| "sharpArrow"
| "curvedArrow"
| "elbowArrow";

View file

@ -1,8 +1,9 @@
import { getSwitchableTypeFromElements } from "@excalidraw/element/typeChecks";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { shapeSwitchAtom } from "../components/ShapeSwitch";
import {
getSwitchableTypeFromElements,
shapeSwitchAtom,
} from "../components/ShapeSwitch";
import { editorJotaiStore } from "../editor-jotai";
import { CaptureUpdateAction } from "../store";

View file

@ -166,7 +166,6 @@ import {
isFlowchartNodeElement,
isBindableElement,
isTextElement,
getSwitchableTypeFromElements,
} from "@excalidraw/element/typeChecks";
import {
@ -327,7 +326,7 @@ import type {
MagicGenerationData,
ExcalidrawNonSelectionElement,
ExcalidrawArrowElement,
GenericSwitchableToolType,
ConvertibleGenericTypes,
} from "@excalidraw/element/types";
import type { ValueOf } from "@excalidraw/common/utility-types";
@ -464,6 +463,7 @@ import { isOverScrollBars } from "../scene/scrollbars";
import { isMaybeMermaidDefinition } from "../mermaid";
import ShapeSwitch, {
getSwitchableTypeFromElements,
shapeSwitchAtom,
shapeSwitchFontSizeAtom,
switchShapes,
@ -4193,7 +4193,7 @@ class App extends React.Component<AppProps, AppState> {
...editorJotaiStore.get(shapeSwitchFontSizeAtom),
[element.id]: {
fontSize: boundText.fontSize,
elementType: element.type as GenericSwitchableToolType,
elementType: element.type as ConvertibleGenericTypes,
},
});
}

View file

@ -5,10 +5,11 @@ import { updateElbowArrowPoints } from "@excalidraw/element/elbowArrow";
import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math";
import {
getSwitchableTypeFromElements,
isArrowElement,
isCurvedArrow,
isElbowArrow,
isUsingAdaptiveRadius,
isLinearElement,
isSharpArrow,
} from "@excalidraw/element/typeChecks";
import {
@ -29,23 +30,31 @@ import { getFontString, updateActiveTool } from "@excalidraw/common";
import { measureText } from "@excalidraw/element/textMeasurements";
import { ShapeCache } from "@excalidraw/element/ShapeCache";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import {
convertElementType,
CONVERTIBLE_GENERIC_TYPES,
CONVERTIBLE_LINEAR_TYPES,
isConvertibleGenericType,
isConvertibleLinearType,
} from "@excalidraw/element/mutateElement";
import type {
ConvertibleGenericTypes,
ConvertibleLinearTypes,
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawLinearElement,
ExcalidrawRectangleElement,
ExcalidrawTextContainer,
ExcalidrawTextElementWithContainer,
FixedSegment,
GenericSwitchableToolType,
LinearSwitchableToolType,
} from "@excalidraw/element/types";
import { mutateElement, ROUNDNESS, sceneCoordsToViewportCoords } from "..";
import { mutateElement, sceneCoordsToViewportCoords } from "..";
import { getSelectedElements } from "../scene";
import { trackEvent } from "../analytics";
import { atom, editorJotaiStore, useAtom } from "../editor-jotai";
@ -53,11 +62,13 @@ import { atom, editorJotaiStore, useAtom } from "../editor-jotai";
import "./ShapeSwitch.scss";
import { ToolButton } from "./ToolButton";
import {
ArrowIcon,
DiamondIcon,
elbowArrowIcon,
EllipseIcon,
LineIcon,
RectangleIcon,
roundArrowIcon,
sharpArrowIcon,
} from "./icons";
import type App from "./App";
@ -65,10 +76,6 @@ import type App from "./App";
const GAP_HORIZONTAL = 8;
const GAP_VERTICAL = 10;
export const GENERIC_SWITCHABLE_SHAPES = ["rectangle", "diamond", "ellipse"];
export const LINEAR_SWITCHABLE_SHAPES = ["line", "arrow"];
export const shapeSwitchAtom = atom<{
type: "panel";
} | null>(null);
@ -76,7 +83,7 @@ export const shapeSwitchAtom = atom<{
export const shapeSwitchFontSizeAtom = atom<{
[id: string]: {
fontSize: number;
elementType: GenericSwitchableToolType;
elementType: ConvertibleGenericTypes;
};
} | null>(null);
@ -142,7 +149,9 @@ const Panel = ({
(element) => element.type === genericElements[0].type,
)
: linear
? linearElements.every((element) => element.type === linearElements[0].type)
? linearElements.every(
(element) => getArrowType(element) === getArrowType(linearElements[0]),
)
: false;
const [panelPosition, setPanelPosition] = useState({ x: 0, y: 0 });
@ -188,16 +197,18 @@ const Panel = ({
setPanelPosition({ x, y });
}, [genericElements, linearElements, app.scene, app.state]);
const SHAPES: [string, string, ReactNode][] = linear
const SHAPES: [string, ReactNode][] = linear
? [
["arrow", "5", ArrowIcon],
["line", "6", LineIcon],
["line", LineIcon],
["sharpArrow", sharpArrowIcon],
["curvedArrow", roundArrowIcon],
["elbowArrow", elbowArrowIcon],
]
: generic
? [
["rectangle", "2", RectangleIcon],
["diamond", "3", DiamondIcon],
["ellipse", "4", EllipseIcon],
["rectangle", RectangleIcon],
["diamond", DiamondIcon],
["ellipse", EllipseIcon],
]
: [];
@ -215,16 +226,16 @@ const Panel = ({
}}
className="ShapeSwitch__Panel"
>
{SHAPES.map(([type, shortcut, icon]) => {
{SHAPES.map(([type, icon]) => {
const isSelected =
sameType &&
((generic && genericElements[0].type === type) ||
(linear && linearElements[0].type === type));
(linear && getArrowType(linearElements[0]) === type));
return (
<ToolButton
className="Shape"
key={`${elements[0].version}_${type}`}
key={`${elements[0].id}${elements[0].version}_${type}`}
type="radio"
icon={icon}
checked={isSelected}
@ -238,11 +249,11 @@ const Panel = ({
trackEvent("shape-switch", type, "ui");
}
switchShapes(app, {
generic: GENERIC_SWITCHABLE_SHAPES.includes(type),
linear: LINEAR_SWITCHABLE_SHAPES.includes(type),
generic: isConvertibleGenericType(type),
linear: isConvertibleLinearType(type),
nextType: type as
| GenericSwitchableToolType
| LinearSwitchableToolType,
| ConvertibleGenericTypes
| ConvertibleLinearTypes,
});
}}
/>
@ -312,7 +323,7 @@ export const switchShapes = (
}: {
generic?: boolean;
linear?: boolean;
nextType?: GenericSwitchableToolType | LinearSwitchableToolType;
nextType?: ConvertibleGenericTypes | ConvertibleLinearTypes;
direction?: "left" | "right";
} = {},
): boolean => {
@ -341,37 +352,21 @@ export const switchShapes = (
);
const index = sameType
? GENERIC_SWITCHABLE_SHAPES.indexOf(
? CONVERTIBLE_GENERIC_TYPES.indexOf(
selectedGenericSwitchableElements[0].type,
)
: -1;
nextType =
nextType ??
(GENERIC_SWITCHABLE_SHAPES[
(index + GENERIC_SWITCHABLE_SHAPES.length + advancement) %
GENERIC_SWITCHABLE_SHAPES.length
] as GenericSwitchableToolType);
CONVERTIBLE_GENERIC_TYPES[
(index + CONVERTIBLE_GENERIC_TYPES.length + advancement) %
CONVERTIBLE_GENERIC_TYPES.length
];
selectedGenericSwitchableElements.forEach((element) => {
ShapeCache.delete(element);
mutateElement(
element,
{
type: nextType as GenericSwitchableToolType,
roundness:
nextType === "diamond" && element.roundness
? {
type: isUsingAdaptiveRadius(nextType)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
value: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: element.roundness,
},
false,
);
if (nextType && isConvertibleGenericType(nextType)) {
for (const element of selectedGenericSwitchableElements) {
convertElementType(element, nextType, app, false);
const boundText = getBoundTextElement(
element,
@ -399,7 +394,7 @@ export const switchShapes = (
app.scene.getNonDeletedElementsMap(),
);
}
});
}
app.setState((prevState) => {
return {
@ -410,71 +405,29 @@ export const switchShapes = (
};
});
}
}
if (linear) {
const selectedLinearSwitchableElements =
getLinearSwitchableElements(selectedElements);
const selectedLinearSwitchableElements = getLinearSwitchableElements(
selectedElements,
) as ExcalidrawLinearElement[];
const arrowType = getArrowType(selectedLinearSwitchableElements[0]);
const sameType = selectedLinearSwitchableElements.every(
(element) => element.type === selectedLinearSwitchableElements[0].type,
(element) => getArrowType(element) === arrowType,
);
const index = sameType
? LINEAR_SWITCHABLE_SHAPES.indexOf(
selectedLinearSwitchableElements[0].type,
)
: -1;
const index = sameType ? CONVERTIBLE_LINEAR_TYPES.indexOf(arrowType) : -1;
nextType =
nextType ??
(LINEAR_SWITCHABLE_SHAPES[
(index + LINEAR_SWITCHABLE_SHAPES.length + advancement) %
LINEAR_SWITCHABLE_SHAPES.length
] as LinearSwitchableToolType);
CONVERTIBLE_LINEAR_TYPES[
(index + CONVERTIBLE_LINEAR_TYPES.length + advancement) %
CONVERTIBLE_LINEAR_TYPES.length
];
selectedLinearSwitchableElements.forEach((element) => {
ShapeCache.delete(element);
// TODO: maybe add a separate function for safe type conversion
// without overloading mutateElement
if (nextType === "arrow") {
mutateElement(
element as ExcalidrawArrowElement,
{
type: "arrow",
startArrowhead: app.state.currentItemStartArrowhead,
endArrowhead: app.state.currentItemEndArrowhead,
startBinding: null,
endBinding: null,
...(app.state.currentItemArrowType === "elbow"
? { elbowed: true }
: {}),
},
false,
);
}
if (nextType === "line") {
if (isElbowArrow(element)) {
mutateElement(
element as ExcalidrawLinearElement,
{
type: "line",
startArrowhead: null,
endArrowhead: null,
startBinding: null,
endBinding: null,
},
false,
{
propertiesToDrop: [
"elbowed",
"startIsSpecial",
"endIsSpecial",
"fixedSegments",
],
},
);
}
}
if (nextType && isConvertibleLinearType(nextType)) {
for (const element of selectedLinearSwitchableElements) {
convertElementType(element, nextType, app, false);
if (isElbowArrow(element)) {
const nextPoints = convertLineToElbow(element);
@ -499,7 +452,8 @@ export const switchShapes = (
);
mutateElement(element, updates, false);
}
});
}
}
const firstElement = selectedLinearSwitchableElements[0];
app.setState((prevState) => ({
@ -517,21 +471,109 @@ export const switchShapes = (
return true;
};
export const getSwitchableTypeFromElements = (
elements: ExcalidrawElement[],
):
| {
generic: true;
linear: false;
}
| {
linear: true;
generic: false;
}
| {
generic: false;
linear: false;
} => {
if (elements.length === 0) {
return {
generic: false,
linear: false,
};
}
let onlyLinear = true;
for (const element of elements) {
if (
element.type === "rectangle" ||
element.type === "ellipse" ||
element.type === "diamond"
) {
return {
generic: true,
linear: false,
};
}
if (element.type !== "arrow" && element.type !== "line") {
onlyLinear = false;
}
}
if (onlyLinear) {
// check at least some linear element is switchable
// for a linear to be swtichable:
// - no labels
// - not bound to anything
let linear = true;
for (const element of elements) {
if (
isArrowElement(element) &&
(element.startBinding !== null || element.endBinding !== null)
) {
linear = false;
} else if (element.boundElements && element.boundElements.length > 0) {
linear = false;
} else {
linear = true;
break;
}
}
return {
linear,
generic: false,
};
}
return {
generic: false,
linear: false,
};
};
const getArrowType = (element: ExcalidrawLinearElement) => {
if (isSharpArrow(element)) {
return "sharpArrow";
}
if (isCurvedArrow(element)) {
return "curvedArrow";
}
if (isElbowArrow(element)) {
return "elbowArrow";
}
return "line";
};
const getGenericSwitchableElements = (elements: ExcalidrawElement[]) =>
elements.filter((element) =>
GENERIC_SWITCHABLE_SHAPES.includes(element.type),
);
elements.filter((element) => isConvertibleGenericType(element.type)) as Array<
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
>;
const getLinearSwitchableElements = (elements: ExcalidrawElement[]) =>
elements.filter(
(element) =>
LINEAR_SWITCHABLE_SHAPES.includes(element.type) &&
isLinearElement(element) &&
!(
isArrowElement(element) &&
(element.startBinding !== null || element.endBinding !== null)
) &&
(!element.boundElements || element.boundElements.length === 0),
);
) as ExcalidrawLinearElement[];
const convertLineToElbow = (line: ExcalidrawLinearElement) => {
const linePoints = sanitizePoints(line.points);