mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
safe conversion between line, sharp, curved, and elbow
This commit is contained in:
parent
c90cdb7b74
commit
7541fadf9c
6 changed files with 466 additions and 249 deletions
|
@ -3,12 +3,16 @@ import {
|
||||||
randomInteger,
|
randomInteger,
|
||||||
getUpdatedTimestamp,
|
getUpdatedTimestamp,
|
||||||
toBrandedType,
|
toBrandedType,
|
||||||
|
isDevEnv,
|
||||||
|
ROUNDNESS,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
// TODO: remove direct dependency on the scene, should be passed in or injected instead
|
// TODO: remove direct dependency on the scene, should be passed in or injected instead
|
||||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||||
import Scene from "@excalidraw/excalidraw/scene/Scene";
|
import Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||||
|
|
||||||
|
import type { AppClassProperties } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
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 { ShapeCache } from "./ShapeCache";
|
||||||
|
|
||||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
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<
|
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||||
Partial<TElement>,
|
Partial<TElement>,
|
||||||
|
@ -212,3 +233,211 @@ export const bumpVersion = <T extends Mutable<ExcalidrawElement>>(
|
||||||
element.updated = getUpdatedTimestamp();
|
element.updated = getUpdatedTimestamp();
|
||||||
return element;
|
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;
|
||||||
|
};
|
||||||
|
|
|
@ -119,6 +119,20 @@ export const isElbowArrow = (
|
||||||
return isArrowElement(element) && element.elbowed;
|
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 = (
|
export const isLinearElementType = (
|
||||||
elementType: ElementOrToolType,
|
elementType: ElementOrToolType,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
|
@ -338,76 +352,3 @@ export const isBounds = (box: unknown): box is Bounds =>
|
||||||
typeof box[1] === "number" &&
|
typeof box[1] === "number" &&
|
||||||
typeof box[2] === "number" &&
|
typeof box[2] === "number" &&
|
||||||
typeof box[3] === "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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
|
@ -413,5 +413,9 @@ export type ElementsMapOrArray =
|
||||||
| readonly ExcalidrawElement[]
|
| readonly ExcalidrawElement[]
|
||||||
| Readonly<ElementsMap>;
|
| Readonly<ElementsMap>;
|
||||||
|
|
||||||
export type GenericSwitchableToolType = "rectangle" | "ellipse" | "diamond";
|
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
|
||||||
export type LinearSwitchableToolType = "line" | "arrow";
|
export type ConvertibleLinearTypes =
|
||||||
|
| "line"
|
||||||
|
| "sharpArrow"
|
||||||
|
| "curvedArrow"
|
||||||
|
| "elbowArrow";
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { getSwitchableTypeFromElements } from "@excalidraw/element/typeChecks";
|
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { shapeSwitchAtom } from "../components/ShapeSwitch";
|
import {
|
||||||
|
getSwitchableTypeFromElements,
|
||||||
|
shapeSwitchAtom,
|
||||||
|
} from "../components/ShapeSwitch";
|
||||||
import { editorJotaiStore } from "../editor-jotai";
|
import { editorJotaiStore } from "../editor-jotai";
|
||||||
import { CaptureUpdateAction } from "../store";
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
|
|
|
@ -166,7 +166,6 @@ import {
|
||||||
isFlowchartNodeElement,
|
isFlowchartNodeElement,
|
||||||
isBindableElement,
|
isBindableElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
getSwitchableTypeFromElements,
|
|
||||||
} from "@excalidraw/element/typeChecks";
|
} from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -327,7 +326,7 @@ import type {
|
||||||
MagicGenerationData,
|
MagicGenerationData,
|
||||||
ExcalidrawNonSelectionElement,
|
ExcalidrawNonSelectionElement,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
GenericSwitchableToolType,
|
ConvertibleGenericTypes,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { ValueOf } from "@excalidraw/common/utility-types";
|
import type { ValueOf } from "@excalidraw/common/utility-types";
|
||||||
|
@ -464,6 +463,7 @@ import { isOverScrollBars } from "../scene/scrollbars";
|
||||||
import { isMaybeMermaidDefinition } from "../mermaid";
|
import { isMaybeMermaidDefinition } from "../mermaid";
|
||||||
|
|
||||||
import ShapeSwitch, {
|
import ShapeSwitch, {
|
||||||
|
getSwitchableTypeFromElements,
|
||||||
shapeSwitchAtom,
|
shapeSwitchAtom,
|
||||||
shapeSwitchFontSizeAtom,
|
shapeSwitchFontSizeAtom,
|
||||||
switchShapes,
|
switchShapes,
|
||||||
|
@ -4193,7 +4193,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
...editorJotaiStore.get(shapeSwitchFontSizeAtom),
|
...editorJotaiStore.get(shapeSwitchFontSizeAtom),
|
||||||
[element.id]: {
|
[element.id]: {
|
||||||
fontSize: boundText.fontSize,
|
fontSize: boundText.fontSize,
|
||||||
elementType: element.type as GenericSwitchableToolType,
|
elementType: element.type as ConvertibleGenericTypes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,10 +5,11 @@ import { updateElbowArrowPoints } from "@excalidraw/element/elbowArrow";
|
||||||
import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math";
|
import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getSwitchableTypeFromElements,
|
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
|
isCurvedArrow,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isUsingAdaptiveRadius,
|
isLinearElement,
|
||||||
|
isSharpArrow,
|
||||||
} from "@excalidraw/element/typeChecks";
|
} from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -29,23 +30,31 @@ import { getFontString, updateActiveTool } from "@excalidraw/common";
|
||||||
|
|
||||||
import { measureText } from "@excalidraw/element/textMeasurements";
|
import { measureText } from "@excalidraw/element/textMeasurements";
|
||||||
|
|
||||||
import { ShapeCache } from "@excalidraw/element/ShapeCache";
|
|
||||||
|
|
||||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||||
|
|
||||||
|
import {
|
||||||
|
convertElementType,
|
||||||
|
CONVERTIBLE_GENERIC_TYPES,
|
||||||
|
CONVERTIBLE_LINEAR_TYPES,
|
||||||
|
isConvertibleGenericType,
|
||||||
|
isConvertibleLinearType,
|
||||||
|
} from "@excalidraw/element/mutateElement";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
ConvertibleGenericTypes,
|
||||||
|
ConvertibleLinearTypes,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawDiamondElement,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
|
ExcalidrawEllipseElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
|
ExcalidrawRectangleElement,
|
||||||
ExcalidrawTextContainer,
|
ExcalidrawTextContainer,
|
||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
FixedSegment,
|
FixedSegment,
|
||||||
GenericSwitchableToolType,
|
|
||||||
LinearSwitchableToolType,
|
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { mutateElement, ROUNDNESS, sceneCoordsToViewportCoords } from "..";
|
import { mutateElement, sceneCoordsToViewportCoords } from "..";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { atom, editorJotaiStore, useAtom } from "../editor-jotai";
|
import { atom, editorJotaiStore, useAtom } from "../editor-jotai";
|
||||||
|
@ -53,11 +62,13 @@ import { atom, editorJotaiStore, useAtom } from "../editor-jotai";
|
||||||
import "./ShapeSwitch.scss";
|
import "./ShapeSwitch.scss";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import {
|
import {
|
||||||
ArrowIcon,
|
|
||||||
DiamondIcon,
|
DiamondIcon,
|
||||||
|
elbowArrowIcon,
|
||||||
EllipseIcon,
|
EllipseIcon,
|
||||||
LineIcon,
|
LineIcon,
|
||||||
RectangleIcon,
|
RectangleIcon,
|
||||||
|
roundArrowIcon,
|
||||||
|
sharpArrowIcon,
|
||||||
} from "./icons";
|
} from "./icons";
|
||||||
|
|
||||||
import type App from "./App";
|
import type App from "./App";
|
||||||
|
@ -65,10 +76,6 @@ import type App from "./App";
|
||||||
const GAP_HORIZONTAL = 8;
|
const GAP_HORIZONTAL = 8;
|
||||||
const GAP_VERTICAL = 10;
|
const GAP_VERTICAL = 10;
|
||||||
|
|
||||||
export const GENERIC_SWITCHABLE_SHAPES = ["rectangle", "diamond", "ellipse"];
|
|
||||||
|
|
||||||
export const LINEAR_SWITCHABLE_SHAPES = ["line", "arrow"];
|
|
||||||
|
|
||||||
export const shapeSwitchAtom = atom<{
|
export const shapeSwitchAtom = atom<{
|
||||||
type: "panel";
|
type: "panel";
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
@ -76,7 +83,7 @@ export const shapeSwitchAtom = atom<{
|
||||||
export const shapeSwitchFontSizeAtom = atom<{
|
export const shapeSwitchFontSizeAtom = atom<{
|
||||||
[id: string]: {
|
[id: string]: {
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
elementType: GenericSwitchableToolType;
|
elementType: ConvertibleGenericTypes;
|
||||||
};
|
};
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
@ -142,7 +149,9 @@ const Panel = ({
|
||||||
(element) => element.type === genericElements[0].type,
|
(element) => element.type === genericElements[0].type,
|
||||||
)
|
)
|
||||||
: linear
|
: linear
|
||||||
? linearElements.every((element) => element.type === linearElements[0].type)
|
? linearElements.every(
|
||||||
|
(element) => getArrowType(element) === getArrowType(linearElements[0]),
|
||||||
|
)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const [panelPosition, setPanelPosition] = useState({ x: 0, y: 0 });
|
const [panelPosition, setPanelPosition] = useState({ x: 0, y: 0 });
|
||||||
|
@ -188,16 +197,18 @@ const Panel = ({
|
||||||
setPanelPosition({ x, y });
|
setPanelPosition({ x, y });
|
||||||
}, [genericElements, linearElements, app.scene, app.state]);
|
}, [genericElements, linearElements, app.scene, app.state]);
|
||||||
|
|
||||||
const SHAPES: [string, string, ReactNode][] = linear
|
const SHAPES: [string, ReactNode][] = linear
|
||||||
? [
|
? [
|
||||||
["arrow", "5", ArrowIcon],
|
["line", LineIcon],
|
||||||
["line", "6", LineIcon],
|
["sharpArrow", sharpArrowIcon],
|
||||||
|
["curvedArrow", roundArrowIcon],
|
||||||
|
["elbowArrow", elbowArrowIcon],
|
||||||
]
|
]
|
||||||
: generic
|
: generic
|
||||||
? [
|
? [
|
||||||
["rectangle", "2", RectangleIcon],
|
["rectangle", RectangleIcon],
|
||||||
["diamond", "3", DiamondIcon],
|
["diamond", DiamondIcon],
|
||||||
["ellipse", "4", EllipseIcon],
|
["ellipse", EllipseIcon],
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
@ -215,16 +226,16 @@ const Panel = ({
|
||||||
}}
|
}}
|
||||||
className="ShapeSwitch__Panel"
|
className="ShapeSwitch__Panel"
|
||||||
>
|
>
|
||||||
{SHAPES.map(([type, shortcut, icon]) => {
|
{SHAPES.map(([type, icon]) => {
|
||||||
const isSelected =
|
const isSelected =
|
||||||
sameType &&
|
sameType &&
|
||||||
((generic && genericElements[0].type === type) ||
|
((generic && genericElements[0].type === type) ||
|
||||||
(linear && linearElements[0].type === type));
|
(linear && getArrowType(linearElements[0]) === type));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
className="Shape"
|
className="Shape"
|
||||||
key={`${elements[0].version}_${type}`}
|
key={`${elements[0].id}${elements[0].version}_${type}`}
|
||||||
type="radio"
|
type="radio"
|
||||||
icon={icon}
|
icon={icon}
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
|
@ -238,11 +249,11 @@ const Panel = ({
|
||||||
trackEvent("shape-switch", type, "ui");
|
trackEvent("shape-switch", type, "ui");
|
||||||
}
|
}
|
||||||
switchShapes(app, {
|
switchShapes(app, {
|
||||||
generic: GENERIC_SWITCHABLE_SHAPES.includes(type),
|
generic: isConvertibleGenericType(type),
|
||||||
linear: LINEAR_SWITCHABLE_SHAPES.includes(type),
|
linear: isConvertibleLinearType(type),
|
||||||
nextType: type as
|
nextType: type as
|
||||||
| GenericSwitchableToolType
|
| ConvertibleGenericTypes
|
||||||
| LinearSwitchableToolType,
|
| ConvertibleLinearTypes,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -312,7 +323,7 @@ export const switchShapes = (
|
||||||
}: {
|
}: {
|
||||||
generic?: boolean;
|
generic?: boolean;
|
||||||
linear?: boolean;
|
linear?: boolean;
|
||||||
nextType?: GenericSwitchableToolType | LinearSwitchableToolType;
|
nextType?: ConvertibleGenericTypes | ConvertibleLinearTypes;
|
||||||
direction?: "left" | "right";
|
direction?: "left" | "right";
|
||||||
} = {},
|
} = {},
|
||||||
): boolean => {
|
): boolean => {
|
||||||
|
@ -341,165 +352,108 @@ export const switchShapes = (
|
||||||
);
|
);
|
||||||
|
|
||||||
const index = sameType
|
const index = sameType
|
||||||
? GENERIC_SWITCHABLE_SHAPES.indexOf(
|
? CONVERTIBLE_GENERIC_TYPES.indexOf(
|
||||||
selectedGenericSwitchableElements[0].type,
|
selectedGenericSwitchableElements[0].type,
|
||||||
)
|
)
|
||||||
: -1;
|
: -1;
|
||||||
|
|
||||||
nextType =
|
nextType =
|
||||||
nextType ??
|
nextType ??
|
||||||
(GENERIC_SWITCHABLE_SHAPES[
|
CONVERTIBLE_GENERIC_TYPES[
|
||||||
(index + GENERIC_SWITCHABLE_SHAPES.length + advancement) %
|
(index + CONVERTIBLE_GENERIC_TYPES.length + advancement) %
|
||||||
GENERIC_SWITCHABLE_SHAPES.length
|
CONVERTIBLE_GENERIC_TYPES.length
|
||||||
] as GenericSwitchableToolType);
|
];
|
||||||
|
|
||||||
selectedGenericSwitchableElements.forEach((element) => {
|
if (nextType && isConvertibleGenericType(nextType)) {
|
||||||
ShapeCache.delete(element);
|
for (const element of selectedGenericSwitchableElements) {
|
||||||
|
convertElementType(element, nextType, app, false);
|
||||||
|
|
||||||
mutateElement(
|
const boundText = getBoundTextElement(
|
||||||
element,
|
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
const boundText = getBoundTextElement(
|
|
||||||
element,
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
if (boundText) {
|
|
||||||
if (
|
|
||||||
editorJotaiStore.get(shapeSwitchFontSizeAtom)?.[element.id]
|
|
||||||
?.elementType === nextType
|
|
||||||
) {
|
|
||||||
mutateElement(
|
|
||||||
boundText,
|
|
||||||
{
|
|
||||||
fontSize:
|
|
||||||
editorJotaiStore.get(shapeSwitchFontSizeAtom)?.[element.id]
|
|
||||||
?.fontSize ?? boundText.fontSize,
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
adjustBoundTextSize(
|
|
||||||
element as ExcalidrawTextContainer,
|
|
||||||
boundText,
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
app.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
}
|
if (boundText) {
|
||||||
});
|
if (
|
||||||
|
editorJotaiStore.get(shapeSwitchFontSizeAtom)?.[element.id]
|
||||||
|
?.elementType === nextType
|
||||||
|
) {
|
||||||
|
mutateElement(
|
||||||
|
boundText,
|
||||||
|
{
|
||||||
|
fontSize:
|
||||||
|
editorJotaiStore.get(shapeSwitchFontSizeAtom)?.[element.id]
|
||||||
|
?.fontSize ?? boundText.fontSize,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
app.setState((prevState) => {
|
adjustBoundTextSize(
|
||||||
return {
|
element as ExcalidrawTextContainer,
|
||||||
selectedElementIds,
|
boundText,
|
||||||
activeTool: updateActiveTool(prevState, {
|
app.scene.getNonDeletedElementsMap(),
|
||||||
type: "selection",
|
);
|
||||||
}),
|
}
|
||||||
};
|
}
|
||||||
});
|
|
||||||
|
app.setState((prevState) => {
|
||||||
|
return {
|
||||||
|
selectedElementIds,
|
||||||
|
activeTool: updateActiveTool(prevState, {
|
||||||
|
type: "selection",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (linear) {
|
if (linear) {
|
||||||
const selectedLinearSwitchableElements =
|
const selectedLinearSwitchableElements = getLinearSwitchableElements(
|
||||||
getLinearSwitchableElements(selectedElements);
|
selectedElements,
|
||||||
|
) as ExcalidrawLinearElement[];
|
||||||
|
|
||||||
|
const arrowType = getArrowType(selectedLinearSwitchableElements[0]);
|
||||||
const sameType = selectedLinearSwitchableElements.every(
|
const sameType = selectedLinearSwitchableElements.every(
|
||||||
(element) => element.type === selectedLinearSwitchableElements[0].type,
|
(element) => getArrowType(element) === arrowType,
|
||||||
);
|
);
|
||||||
const index = sameType
|
|
||||||
? LINEAR_SWITCHABLE_SHAPES.indexOf(
|
const index = sameType ? CONVERTIBLE_LINEAR_TYPES.indexOf(arrowType) : -1;
|
||||||
selectedLinearSwitchableElements[0].type,
|
|
||||||
)
|
|
||||||
: -1;
|
|
||||||
nextType =
|
nextType =
|
||||||
nextType ??
|
nextType ??
|
||||||
(LINEAR_SWITCHABLE_SHAPES[
|
CONVERTIBLE_LINEAR_TYPES[
|
||||||
(index + LINEAR_SWITCHABLE_SHAPES.length + advancement) %
|
(index + CONVERTIBLE_LINEAR_TYPES.length + advancement) %
|
||||||
LINEAR_SWITCHABLE_SHAPES.length
|
CONVERTIBLE_LINEAR_TYPES.length
|
||||||
] as LinearSwitchableToolType);
|
];
|
||||||
|
|
||||||
selectedLinearSwitchableElements.forEach((element) => {
|
if (nextType && isConvertibleLinearType(nextType)) {
|
||||||
ShapeCache.delete(element);
|
for (const element of selectedLinearSwitchableElements) {
|
||||||
|
convertElementType(element, nextType, app, false);
|
||||||
|
|
||||||
// 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)) {
|
if (isElbowArrow(element)) {
|
||||||
mutateElement(
|
const nextPoints = convertLineToElbow(element);
|
||||||
element as ExcalidrawLinearElement,
|
|
||||||
|
const fixedSegments: FixedSegment[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < nextPoints.length - 1; i++) {
|
||||||
|
fixedSegments.push({
|
||||||
|
start: nextPoints[i],
|
||||||
|
end: nextPoints[i + 1],
|
||||||
|
index: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = updateElbowArrowPoints(
|
||||||
|
element,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
{
|
{
|
||||||
type: "line",
|
points: nextPoints,
|
||||||
startArrowhead: null,
|
fixedSegments,
|
||||||
endArrowhead: null,
|
|
||||||
startBinding: null,
|
|
||||||
endBinding: null,
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
{
|
|
||||||
propertiesToDrop: [
|
|
||||||
"elbowed",
|
|
||||||
"startIsSpecial",
|
|
||||||
"endIsSpecial",
|
|
||||||
"fixedSegments",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
mutateElement(element, updates, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (isElbowArrow(element)) {
|
|
||||||
const nextPoints = convertLineToElbow(element);
|
|
||||||
|
|
||||||
const fixedSegments: FixedSegment[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < nextPoints.length - 1; i++) {
|
|
||||||
fixedSegments.push({
|
|
||||||
start: nextPoints[i],
|
|
||||||
end: nextPoints[i + 1],
|
|
||||||
index: i,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const updates = updateElbowArrowPoints(
|
|
||||||
element,
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
{
|
|
||||||
points: nextPoints,
|
|
||||||
fixedSegments,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
mutateElement(element, updates, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const firstElement = selectedLinearSwitchableElements[0];
|
const firstElement = selectedLinearSwitchableElements[0];
|
||||||
|
|
||||||
app.setState((prevState) => ({
|
app.setState((prevState) => ({
|
||||||
|
@ -517,21 +471,109 @@ export const switchShapes = (
|
||||||
return true;
|
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[]) =>
|
const getGenericSwitchableElements = (elements: ExcalidrawElement[]) =>
|
||||||
elements.filter((element) =>
|
elements.filter((element) => isConvertibleGenericType(element.type)) as Array<
|
||||||
GENERIC_SWITCHABLE_SHAPES.includes(element.type),
|
| ExcalidrawRectangleElement
|
||||||
);
|
| ExcalidrawDiamondElement
|
||||||
|
| ExcalidrawEllipseElement
|
||||||
|
>;
|
||||||
|
|
||||||
const getLinearSwitchableElements = (elements: ExcalidrawElement[]) =>
|
const getLinearSwitchableElements = (elements: ExcalidrawElement[]) =>
|
||||||
elements.filter(
|
elements.filter(
|
||||||
(element) =>
|
(element) =>
|
||||||
LINEAR_SWITCHABLE_SHAPES.includes(element.type) &&
|
isLinearElement(element) &&
|
||||||
!(
|
!(
|
||||||
isArrowElement(element) &&
|
isArrowElement(element) &&
|
||||||
(element.startBinding !== null || element.endBinding !== null)
|
(element.startBinding !== null || element.endBinding !== null)
|
||||||
) &&
|
) &&
|
||||||
(!element.boundElements || element.boundElements.length === 0),
|
(!element.boundElements || element.boundElements.length === 0),
|
||||||
);
|
) as ExcalidrawLinearElement[];
|
||||||
|
|
||||||
const convertLineToElbow = (line: ExcalidrawLinearElement) => {
|
const convertLineToElbow = (line: ExcalidrawLinearElement) => {
|
||||||
const linePoints = sanitizePoints(line.points);
|
const linePoints = sanitizePoints(line.points);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue