diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton.mdx
index b633236aad..e1dd071eac 100644
--- a/dev-docs/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton.mdx
+++ b/dev-docs/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton.mdx
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
type: "arrow",
x: 450,
y: 20,
- startArrowhead: "dot",
+ startArrowhead: "circle",
endArrowhead: "triangle",
strokeColor: "#1971c2",
strokeWidth: 2,
diff --git a/packages/element/src/arrowheads.ts b/packages/element/src/arrowheads.ts
new file mode 100644
index 0000000000..7e76b2b3bf
--- /dev/null
+++ b/packages/element/src/arrowheads.ts
@@ -0,0 +1,32 @@
+import type { Arrowhead, AnyArrowhead } from "./types";
+
+export const normalizeArrowhead = (
+ arrowhead: AnyArrowhead | null | undefined,
+): Arrowhead | null => {
+ switch (arrowhead) {
+ case undefined:
+ case null:
+ return null;
+ case "dot":
+ return "circle";
+ case "crowfoot_one":
+ return "cardinality_one";
+ case "crowfoot_many":
+ return "cardinality_many";
+ case "crowfoot_one_or_many":
+ return "cardinality_one_or_many";
+ default:
+ return arrowhead;
+ }
+};
+
+export const getArrowheadForPicker = (
+ arrowhead: AnyArrowhead | null | undefined,
+): Arrowhead | null => {
+ const normalizedArrowhead = normalizeArrowhead(arrowhead);
+ if (normalizedArrowhead === null) {
+ return null;
+ }
+
+ return normalizedArrowhead;
+};
diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts
index 0daa80f15d..a73a7d28bb 100644
--- a/packages/element/src/bounds.ts
+++ b/packages/element/src/bounds.ts
@@ -709,6 +709,9 @@ const getFreeDrawElementAbsoluteCoords = (
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
};
+const CARDINALITY_MARKER_SIZE = 20;
+const CROWFOOT_ARROWHEAD_SIZE = 15;
+
/** @returns number in pixels */
export const getArrowheadSize = (arrowhead: Arrowhead): number => {
switch (arrowhead) {
@@ -717,10 +720,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
case "diamond":
case "diamond_outline":
return 12;
- case "crowfoot_many":
- case "crowfoot_one":
- case "crowfoot_one_or_many":
- return 20;
+ case "cardinality_many":
+ case "cardinality_one_or_many":
+ case "cardinality_zero_or_many":
+ return CROWFOOT_ARROWHEAD_SIZE;
+ case "cardinality_one":
+ case "cardinality_exactly_one":
+ case "cardinality_zero_or_one":
+ return CARDINALITY_MARKER_SIZE;
default:
return 15;
}
@@ -743,7 +750,12 @@ export const getArrowheadPoints = (
shape: Drawable[],
position: "start" | "end",
arrowhead: Arrowhead,
+ offsetMultiplier = 0,
) => {
+ if (arrowhead === null) {
+ return null;
+ }
+
if (shape.length < 1) {
return null;
}
@@ -824,29 +836,30 @@ export const getArrowheadPoints = (
const lengthMultiplier =
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
const minSize = Math.min(size, length * lengthMultiplier);
- const xs = x2 - nx * minSize;
- const ys = y2 - ny * minSize;
+ const tx = x2 - nx * minSize * offsetMultiplier;
+ const ty = y2 - ny * minSize * offsetMultiplier;
+ const xs = tx - nx * minSize;
+ const ys = ty - ny * minSize;
- if (
- arrowhead === "dot" ||
- arrowhead === "circle" ||
- arrowhead === "circle_outline"
- ) {
- const diameter = Math.hypot(ys - y2, xs - x2) + element.strokeWidth - 2;
- return [x2, y2, diameter];
+ if (arrowhead === "circle" || arrowhead === "circle_outline") {
+ const diameter = Math.hypot(ys - ty, xs - tx) + element.strokeWidth - 2;
+ return [tx, ty, diameter];
}
const angle = getArrowheadAngle(arrowhead);
- if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
+ if (
+ arrowhead === "cardinality_many" ||
+ arrowhead === "cardinality_one_or_many"
+ ) {
// swap (xs, ys) with (x2, y2)
const [x3, y3] = pointRotateRads(
- pointFrom(x2, y2),
+ pointFrom(tx, ty),
pointFrom(xs, ys),
degreesToRadians(-angle as Degrees),
);
const [x4, y4] = pointRotateRads(
- pointFrom(x2, y2),
+ pointFrom(tx, ty),
pointFrom(xs, ys),
degreesToRadians(angle),
);
@@ -856,12 +869,12 @@ export const getArrowheadPoints = (
// Return points
const [x3, y3] = pointRotateRads(
pointFrom(xs, ys),
- pointFrom(x2, y2),
+ pointFrom(tx, ty),
((-angle * Math.PI) / 180) as Radians,
);
const [x4, y4] = pointRotateRads(
pointFrom(xs, ys),
- pointFrom(x2, y2),
+ pointFrom(tx, ty),
degreesToRadians(angle),
);
@@ -874,9 +887,9 @@ export const getArrowheadPoints = (
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = pointRotateRads(
- pointFrom(x2 + minSize * 2, y2),
- pointFrom(x2, y2),
- Math.atan2(py - y2, px - x2) as Radians,
+ pointFrom(tx + minSize * 2, ty),
+ pointFrom(tx, ty),
+ Math.atan2(py - ty, px - tx) as Radians,
);
} else {
const [px, py] =
@@ -885,16 +898,16 @@ export const getArrowheadPoints = (
: [0, 0];
[ox, oy] = pointRotateRads(
- pointFrom(x2 - minSize * 2, y2),
- pointFrom(x2, y2),
- Math.atan2(y2 - py, x2 - px) as Radians,
+ pointFrom(tx - minSize * 2, ty),
+ pointFrom(tx, ty),
+ Math.atan2(ty - py, tx - px) as Radians,
);
}
- return [x2, y2, x3, y3, ox, oy, x4, y4];
+ return [tx, ty, x3, y3, ox, oy, x4, y4];
}
- return [x2, y2, x3, y3, x4, y4];
+ return [tx, ty, x3, y3, x4, y4];
};
// TODO reuse shape.ts
diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts
index 1ca1c1a289..c55537d451 100644
--- a/packages/element/src/index.ts
+++ b/packages/element/src/index.ts
@@ -99,3 +99,4 @@ export * from "./typeChecks";
export * from "./utils";
export * from "./zindex";
export * from "./arrows/helpers";
+export * from "./arrowheads";
diff --git a/packages/element/src/shape.ts b/packages/element/src/shape.ts
index 176455bdf9..9bef1329af 100644
--- a/packages/element/src/shape.ts
+++ b/packages/element/src/shape.ts
@@ -69,10 +69,10 @@ import type {
NonDeletedExcalidrawElement,
ExcalidrawSelectionElement,
ExcalidrawLinearElement,
- Arrowhead,
ExcalidrawFreeDrawElement,
ElementsMap,
ExcalidrawLineElement,
+ Arrowhead,
} from "./types";
import type { Drawable, Options } from "roughjs/bin/core";
@@ -296,6 +296,82 @@ const modifyIframeLikeForRoughOptions = (
return element;
};
+const generateArrowheadCardinalityOne = (
+ generator: RoughGenerator,
+ arrowheadPoints: number[] | null,
+ lineOptions: Options,
+) => {
+ if (arrowheadPoints === null) {
+ return [];
+ }
+
+ const [, , x3, y3, x4, y4] = arrowheadPoints;
+
+ return [generator.line(x3, y3, x4, y4, lineOptions)];
+};
+
+const generateArrowheadLinesToTip = (
+ generator: RoughGenerator,
+ arrowheadPoints: number[] | null,
+ lineOptions: Options,
+) => {
+ if (arrowheadPoints === null) {
+ return [];
+ }
+
+ const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
+
+ return [
+ generator.line(x3, y3, x2, y2, lineOptions),
+ generator.line(x4, y4, x2, y2, lineOptions),
+ ];
+};
+
+const getArrowheadLineOptions = (
+ element: ExcalidrawLinearElement,
+ options: Options,
+) => {
+ const lineOptions = { ...options };
+
+ if (element.strokeStyle === "dotted") {
+ // for dotted arrows caps, reduce gap to make it more legible
+ const dash = getDashArrayDotted(element.strokeWidth - 1);
+ lineOptions.strokeLineDash = [dash[0], dash[1] - 1];
+ } else {
+ // for solid/dashed, keep solid arrow cap
+ delete lineOptions.strokeLineDash;
+ }
+ lineOptions.roughness = Math.min(1, lineOptions.roughness || 0);
+
+ return lineOptions;
+};
+
+const generateArrowheadOutlineCircle = (
+ generator: RoughGenerator,
+ options: Options,
+ strokeColor: string,
+ arrowheadPoints: number[] | null,
+ fill: string,
+ diameterScale = 1,
+) => {
+ if (arrowheadPoints === null) {
+ return [];
+ }
+
+ const [x, y, diameter] = arrowheadPoints;
+ const circleOptions = {
+ ...options,
+ fill,
+ fillStyle: "solid" as const,
+ stroke: strokeColor,
+ roughness: Math.min(0.5, options.roughness || 0),
+ };
+
+ delete circleOptions.strokeLineDash;
+
+ return [generator.circle(x, y, diameter * diameterScale, circleOptions)];
+};
+
const getArrowheadShapes = (
element: ExcalidrawLinearElement,
shape: Drawable[],
@@ -306,63 +382,54 @@ const getArrowheadShapes = (
canvasBackgroundColor: string,
isDarkMode: boolean,
) => {
- const arrowheadPoints = getArrowheadPoints(
- element,
- shape,
- position,
- arrowhead,
- );
-
- if (arrowheadPoints === null) {
+ if (arrowhead === null) {
return [];
}
- const generateCrowfootOne = (
- arrowheadPoints: number[] | null,
- options: Options,
- ) => {
- if (arrowheadPoints === null) {
- return [];
- }
-
- const [, , x3, y3, x4, y4] = arrowheadPoints;
-
- return [generator.line(x3, y3, x4, y4, options)];
- };
-
const strokeColor = isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
+ const backgroundFillColor = isDarkMode
+ ? applyDarkModeFilter(canvasBackgroundColor)
+ : canvasBackgroundColor;
+ const cardinalityOneOrManyOffset = -0.25;
+ const cardinalityZeroCircleScale = 0.8;
switch (arrowhead) {
- case "dot":
case "circle":
case "circle_outline": {
- const [x, y, diameter] = arrowheadPoints;
-
- // always use solid stroke for arrowhead
- delete options.strokeLineDash;
-
- return [
- generator.circle(x, y, diameter, {
- ...options,
- fill:
- arrowhead === "circle_outline"
- ? canvasBackgroundColor
- : strokeColor,
-
- fillStyle: "solid",
- stroke: strokeColor,
- roughness: Math.min(0.5, options.roughness || 0),
- }),
- ];
+ return generateArrowheadOutlineCircle(
+ generator,
+ options,
+ strokeColor,
+ getArrowheadPoints(element, shape, position, arrowhead),
+ arrowhead === "circle_outline" ? backgroundFillColor : strokeColor,
+ );
}
case "triangle":
case "triangle_outline": {
+ const arrowheadPoints = getArrowheadPoints(
+ element,
+ shape,
+ position,
+ arrowhead,
+ );
+
+ if (arrowheadPoints === null) {
+ return [];
+ }
+
const [x, y, x2, y2, x3, y3] = arrowheadPoints;
+ const triangleOptions = {
+ ...options,
+ fill:
+ arrowhead === "triangle_outline" ? backgroundFillColor : strokeColor,
+ fillStyle: "solid" as const,
+ roughness: Math.min(1, options.roughness || 0),
+ };
// always use solid stroke for arrowhead
- delete options.strokeLineDash;
+ delete triangleOptions.strokeLineDash;
return [
generator.polygon(
@@ -372,24 +439,34 @@ const getArrowheadShapes = (
[x3, y3],
[x, y],
],
- {
- ...options,
- fill:
- arrowhead === "triangle_outline"
- ? canvasBackgroundColor
- : strokeColor,
- fillStyle: "solid",
- roughness: Math.min(1, options.roughness || 0),
- },
+ triangleOptions,
),
];
}
case "diamond":
case "diamond_outline": {
+ const arrowheadPoints = getArrowheadPoints(
+ element,
+ shape,
+ position,
+ arrowhead,
+ );
+
+ if (arrowheadPoints === null) {
+ return [];
+ }
+
const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints;
+ const diamondOptions = {
+ ...options,
+ fill:
+ arrowhead === "diamond_outline" ? backgroundFillColor : strokeColor,
+ fillStyle: "solid" as const,
+ roughness: Math.min(1, options.roughness || 0),
+ };
// always use solid stroke for arrowhead
- delete options.strokeLineDash;
+ delete diamondOptions.strokeLineDash;
return [
generator.polygon(
@@ -400,46 +477,106 @@ const getArrowheadShapes = (
[x4, y4],
[x, y],
],
- {
- ...options,
- fill:
- arrowhead === "diamond_outline"
- ? canvasBackgroundColor
- : strokeColor,
- fillStyle: "solid",
- roughness: Math.min(1, options.roughness || 0),
- },
+ diamondOptions,
+ ),
+ ];
+ }
+ case "cardinality_one":
+ return generateArrowheadCardinalityOne(
+ generator,
+ getArrowheadPoints(element, shape, position, arrowhead),
+ getArrowheadLineOptions(element, options),
+ );
+ case "cardinality_many":
+ return generateArrowheadLinesToTip(
+ generator,
+ getArrowheadPoints(element, shape, position, arrowhead),
+ getArrowheadLineOptions(element, options),
+ );
+ case "cardinality_one_or_many": {
+ const lineOptions = getArrowheadLineOptions(element, options);
+
+ return [
+ ...generateArrowheadLinesToTip(
+ generator,
+ getArrowheadPoints(element, shape, position, "cardinality_many"),
+ lineOptions,
+ ),
+ ...generateArrowheadCardinalityOne(
+ generator,
+ getArrowheadPoints(
+ element,
+ shape,
+ position,
+ "cardinality_one",
+ cardinalityOneOrManyOffset,
+ ),
+ lineOptions,
+ ),
+ ];
+ }
+ case "cardinality_exactly_one": {
+ const lineOptions = getArrowheadLineOptions(element, options);
+
+ return [
+ ...generateArrowheadCardinalityOne(
+ generator,
+ getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
+ lineOptions,
+ ),
+ ...generateArrowheadCardinalityOne(
+ generator,
+ getArrowheadPoints(element, shape, position, "cardinality_one"),
+ lineOptions,
+ ),
+ ];
+ }
+ case "cardinality_zero_or_one": {
+ const lineOptions = getArrowheadLineOptions(element, options);
+
+ return [
+ ...generateArrowheadOutlineCircle(
+ generator,
+ options,
+ strokeColor,
+ getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
+ backgroundFillColor,
+ cardinalityZeroCircleScale,
+ ),
+ ...generateArrowheadCardinalityOne(
+ generator,
+ getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
+ lineOptions,
+ ),
+ ];
+ }
+ case "cardinality_zero_or_many": {
+ const lineOptions = getArrowheadLineOptions(element, options);
+
+ return [
+ ...generateArrowheadLinesToTip(
+ generator,
+ getArrowheadPoints(element, shape, position, "cardinality_many"),
+ lineOptions,
+ ),
+ ...generateArrowheadOutlineCircle(
+ generator,
+ options,
+ strokeColor,
+ getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
+ backgroundFillColor,
+ cardinalityZeroCircleScale,
),
];
}
- case "crowfoot_one":
- return generateCrowfootOne(arrowheadPoints, options);
case "bar":
case "arrow":
- case "crowfoot_many":
- case "crowfoot_one_or_many":
default: {
- const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
-
- if (element.strokeStyle === "dotted") {
- // for dotted arrows caps, reduce gap to make it more legible
- const dash = getDashArrayDotted(element.strokeWidth - 1);
- options.strokeLineDash = [dash[0], dash[1] - 1];
- } else {
- // for solid/dashed, keep solid arrow cap
- delete options.strokeLineDash;
- }
- options.roughness = Math.min(1, options.roughness || 0);
- return [
- generator.line(x3, y3, x2, y2, options),
- generator.line(x4, y4, x2, y2, options),
- ...(arrowhead === "crowfoot_one_or_many"
- ? generateCrowfootOne(
- getArrowheadPoints(element, shape, position, "crowfoot_one"),
- options,
- )
- : []),
- ];
+ return generateArrowheadLinesToTip(
+ generator,
+ getArrowheadPoints(element, shape, position, arrowhead),
+ getArrowheadLineOptions(element, options),
+ );
}
}
};
diff --git a/packages/element/src/types.ts b/packages/element/src/types.ts
index 58e4469706..8d280f3141 100644
--- a/packages/element/src/types.ts
+++ b/packages/element/src/types.ts
@@ -303,19 +303,32 @@ export type PointsPositionUpdates = Map<
{ point: LocalPoint; isDragging?: boolean }
>;
+export type CardinalityArrowhead =
+ | "cardinality_one"
+ | "cardinality_many"
+ | "cardinality_one_or_many"
+ | "cardinality_exactly_one"
+ | "cardinality_zero_or_one"
+ | "cardinality_zero_or_many";
+
+export type ArrowheadLegacy =
+ | "dot"
+ | "crowfoot_one"
+ | "crowfoot_many"
+ | "crowfoot_one_or_many";
+
export type Arrowhead =
| "arrow"
| "bar"
- | "dot" // legacy. Do not use for new elements.
| "circle"
| "circle_outline"
| "triangle"
| "triangle_outline"
| "diamond"
| "diamond_outline"
- | "crowfoot_one"
- | "crowfoot_many"
- | "crowfoot_one_or_many";
+ | CardinalityArrowhead;
+
+export type AnyArrowhead = Arrowhead | ArrowheadLegacy;
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{
diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx
index b7ed8974f1..dceeee0e4c 100644
--- a/packages/excalidraw/actions/actionExport.tsx
+++ b/packages/excalidraw/actions/actionExport.tsx
@@ -399,7 +399,7 @@ export const actionSaveFileToDisk = register({
appState: {
openDialog: null,
fileHandle: savedFileHandle,
- toast: { message: t("toast.fileSaved") },
+ toast: { message: t("toast.fileSaved"), duration: 3000 },
},
};
} catch (error: any) {
diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx
index 93fe0ada1a..bca2237940 100644
--- a/packages/excalidraw/actions/actionProperties.tsx
+++ b/packages/excalidraw/actions/actionProperties.tsx
@@ -36,6 +36,7 @@ import {
import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
+import { getArrowheadForPicker } from "@excalidraw/element";
import {
getBoundTextElement,
@@ -124,9 +125,12 @@ import {
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
- ArrowheadCrowfootIcon,
- ArrowheadCrowfootOneIcon,
- ArrowheadCrowfootOneOrManyIcon,
+ ArrowheadCardinalityExactlyOneIcon,
+ ArrowheadCardinalityManyIcon,
+ ArrowheadCardinalityOneIcon,
+ ArrowheadCardinalityOneOrManyIcon,
+ ArrowheadCardinalityZeroOrManyIcon,
+ ArrowheadCardinalityZeroOrOneIcon,
} from "../components/icons";
import { Fonts } from "../fonts";
@@ -1550,80 +1554,117 @@ export const actionChangeRoundness = register<"sharp" | "round">({
});
const getArrowheadOptions = (flip: boolean) => {
- return [
- {
- value: null,
- text: t("labels.arrowhead_none"),
- keyBinding: "q",
- icon: ,
- },
- {
- value: "arrow",
- text: t("labels.arrowhead_arrow"),
- keyBinding: "w",
- icon: ,
- },
- {
- value: "triangle",
- text: t("labels.arrowhead_triangle"),
- icon: ,
- keyBinding: "e",
- },
- {
- value: "triangle_outline",
- text: t("labels.arrowhead_triangle_outline"),
- icon: ,
- keyBinding: "r",
- },
- {
- value: "circle",
- text: t("labels.arrowhead_circle"),
- keyBinding: "a",
- icon: ,
- },
- {
- value: "circle_outline",
- text: t("labels.arrowhead_circle_outline"),
- keyBinding: "s",
- icon: ,
- },
- {
- value: "diamond",
- text: t("labels.arrowhead_diamond"),
- icon: ,
- keyBinding: "d",
- },
- {
- value: "diamond_outline",
- text: t("labels.arrowhead_diamond_outline"),
- icon: ,
- keyBinding: "f",
- },
- {
- value: "bar",
- text: t("labels.arrowhead_bar"),
- keyBinding: "z",
- icon: ,
- },
- {
- value: "crowfoot_one",
- text: t("labels.arrowhead_crowfoot_one"),
- icon: ,
- keyBinding: "x",
- },
- {
- value: "crowfoot_many",
- text: t("labels.arrowhead_crowfoot_many"),
- icon: ,
- keyBinding: "c",
- },
- {
- value: "crowfoot_one_or_many",
- text: t("labels.arrowhead_crowfoot_one_or_many"),
- icon: ,
- keyBinding: "v",
- },
- ] as const;
+ return {
+ visibleSections: [
+ {
+ name: "default",
+ options: [
+ {
+ value: null,
+ text: t("labels.arrowhead_none"),
+ keyBinding: "q",
+ icon: ,
+ },
+ {
+ value: "arrow",
+ text: t("labels.arrowhead_arrow"),
+ keyBinding: "w",
+ icon: ,
+ },
+ {
+ value: "triangle",
+ text: t("labels.arrowhead_triangle"),
+ icon: ,
+ keyBinding: "e",
+ },
+ {
+ value: "triangle_outline",
+ text: t("labels.arrowhead_triangle_outline"),
+ icon: ,
+ keyBinding: "r",
+ },
+ ],
+ },
+ ],
+ hiddenSections: [
+ {
+ name: "default",
+ options: [
+ {
+ value: "circle",
+ text: t("labels.arrowhead_circle"),
+ keyBinding: "a",
+ icon: ,
+ },
+ {
+ value: "circle_outline",
+ text: t("labels.arrowhead_circle_outline"),
+ keyBinding: "s",
+ icon: ,
+ },
+ {
+ value: "diamond",
+ text: t("labels.arrowhead_diamond"),
+ icon: ,
+ keyBinding: "d",
+ },
+ {
+ value: "diamond_outline",
+ text: t("labels.arrowhead_diamond_outline"),
+ icon: ,
+ keyBinding: "f",
+ },
+ {
+ value: "bar",
+ text: t("labels.arrowhead_bar"),
+ keyBinding: "z",
+ icon: ,
+ },
+ ],
+ },
+ {
+ name: t("labels.cardinality"),
+ options: [
+ {
+ value: "cardinality_one",
+ text: t("labels.arrowhead_cardinality_one"),
+ icon: ,
+ keyBinding: "x",
+ },
+ {
+ value: "cardinality_many",
+ text: t("labels.arrowhead_cardinality_many"),
+ icon: ,
+ keyBinding: "c",
+ },
+ {
+ value: "cardinality_one_or_many",
+ text: t("labels.arrowhead_cardinality_one_or_many"),
+ icon: ,
+ keyBinding: "v",
+ },
+ {
+ value: "cardinality_exactly_one",
+ text: t("labels.arrowhead_cardinality_exactly_one"),
+ icon: ,
+ keyBinding: null,
+ },
+ {
+ value: "cardinality_zero_or_one",
+ text: t("labels.arrowhead_cardinality_zero_or_one"),
+ icon: ,
+ keyBinding: null,
+ },
+ {
+ value: "cardinality_zero_or_many",
+ text: t("labels.arrowhead_cardinality_zero_or_many"),
+ icon: ,
+ keyBinding: null,
+ },
+ ],
+ },
+ ],
+ } as const;
};
export const actionChangeArrowhead = register<{
@@ -1667,45 +1708,52 @@ export const actionChangeArrowhead = register<{
},
PanelComponent: ({ elements, appState, updateData, app }) => {
const isRTL = getLanguage().rtl;
+ const startArrowheadOptions = useMemo(
+ () => getArrowheadOptions(!isRTL),
+ [isRTL],
+ );
+ const endArrowheadOptions = useMemo(
+ () => getArrowheadOptions(!!isRTL),
+ [isRTL],
+ );
return (
diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx
index 4ee4141cd0..ab2f4c708c 100644
--- a/packages/excalidraw/components/App.tsx
+++ b/packages/excalidraw/components/App.tsx
@@ -3703,7 +3703,7 @@ class App extends React.Component {
if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) {
const api = await import("@excalidraw/mermaid-to-excalidraw");
try {
- const { elements: skeletonElements, files } =
+ const { elements: skeletonElements, files = {} } =
await api.parseMermaidToExcalidraw(data.text);
const elements = convertToExcalidrawElements(skeletonElements, {
diff --git a/packages/excalidraw/components/IconPicker.scss b/packages/excalidraw/components/IconPicker.scss
index 80413e041d..2e9493f4b3 100644
--- a/packages/excalidraw/components/IconPicker.scss
+++ b/packages/excalidraw/components/IconPicker.scss
@@ -6,7 +6,7 @@
padding: 0.5rem;
background: var(--popup-bg-color);
border: 0 solid color.adjust(#fff, $alpha: -0.75);
- box-shadow: var(--shadow-island);
+ box-shadow: var(--shadow-island-stronger);
border-radius: 4px;
position: absolute;
:root[dir="rtl"] & {
@@ -14,6 +14,13 @@
}
}
+ .picker-sections,
+ .picker-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
.picker-container button,
.picker button {
position: relative;
@@ -62,7 +69,13 @@
.picker-collapsible {
font-size: 0.75rem;
- padding: 0.5rem 0;
+ padding: 0;
+ color: var(--text-primary-color);
+ }
+
+ .picker-section-label {
+ font-size: 0.75rem;
+ color: var(--text-primary-color);
}
.picker-keybinding {
diff --git a/packages/excalidraw/components/IconPicker.tsx b/packages/excalidraw/components/IconPicker.tsx
index 0d644ca7e9..ad1b86625e 100644
--- a/packages/excalidraw/components/IconPicker.tsx
+++ b/packages/excalidraw/components/IconPicker.tsx
@@ -1,6 +1,6 @@
import { Popover } from "radix-ui";
import clsx from "clsx";
-import React, { useEffect } from "react";
+import React, { useEffect, useMemo } from "react";
import { isArrowKey, KEYS } from "@excalidraw/common";
@@ -15,6 +15,8 @@ import "./IconPicker.scss";
import type { JSX } from "react";
const moreOptionsAtom = atom(false);
+const PICKER_COLUMNS = 4;
+const DEFAULT_SECTION_NAME = "default";
type Option = {
value: T;
@@ -23,28 +25,74 @@ type Option = {
keyBinding: string | null;
};
+type PickerSection = {
+ name: string;
+ options: readonly Option[];
+};
+
+const flattenOptions = (sections: readonly PickerSection[]) =>
+ sections.flatMap((section) => section.options);
+
+const findOption = (
+ sections: readonly PickerSection[],
+ predicate: (option: Option) => boolean,
+) => {
+ for (const section of sections) {
+ const option = section.options.find(predicate);
+ if (option) {
+ return option;
+ }
+ }
+
+ return null;
+};
+
+const hasOption = (
+ sections: readonly PickerSection[],
+ predicate: (option: Option) => boolean,
+) => sections.some((section) => section.options.some(predicate));
+
+const getNavigationRows = (sections: readonly PickerSection[]) =>
+ sections.flatMap((section) =>
+ Array.from(
+ { length: Math.ceil(section.options.length / PICKER_COLUMNS) },
+ (_, index) =>
+ section.options.slice(
+ index * PICKER_COLUMNS,
+ index * PICKER_COLUMNS + PICKER_COLUMNS,
+ ),
+ ),
+ );
+
function Picker({
- options,
+ visibleSections,
+ hiddenSections = [],
value,
label,
onChange,
onClose,
- numberOfOptionsToAlwaysShow = options.length,
}: {
label: string;
value: T;
- options: readonly Option[];
+ visibleSections: readonly PickerSection[];
+ hiddenSections?: readonly PickerSection[];
onChange: (value: T) => void;
onClose: () => void;
- numberOfOptionsToAlwaysShow?: number;
}) {
const editorInterface = useEditorInterface();
const { container } = useExcalidrawContainer();
+ const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
+ const allSections = [...visibleSections, ...hiddenSections];
+ const allOptions = flattenOptions(allSections);
+ const navigationRows = getNavigationRows([
+ ...visibleSections,
+ ...(showMoreOptions ? hiddenSections : []),
+ ]);
const handleKeyDown = (event: React.KeyboardEvent) => {
- const pressedOption = options.find(
+ const pressedOption = allOptions.find(
(option) => option.keyBinding === event.key.toLowerCase(),
- )!;
+ );
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
// Keybinding navigation
@@ -52,17 +100,17 @@ function Picker({
event.preventDefault();
} else if (event.key === KEYS.TAB) {
- const index = options.findIndex((option) => option.value === value);
+ const index = allOptions.findIndex((option) => option.value === value);
const nextIndex = event.shiftKey
- ? (options.length + index - 1) % options.length
- : (index + 1) % options.length;
- onChange(options[nextIndex].value);
+ ? (allOptions.length + index - 1) % allOptions.length
+ : (index + 1) % allOptions.length;
+ onChange(allOptions[nextIndex].value);
} else if (isArrowKey(event.key)) {
// Arrow navigation
const isRTL = getLanguage().rtl;
- const index = options.findIndex((option) => option.value === value);
+ const index = allOptions.findIndex((option) => option.value === value);
if (index !== -1) {
- const length = options.length;
+ const length = allOptions.length;
let nextIndex = index;
switch (event.key) {
@@ -76,18 +124,60 @@ function Picker({
break;
// Go the next row
case KEYS.ARROW_DOWN: {
- nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length;
+ const currentRowIndex = navigationRows.findIndex((row) =>
+ row.some((option) => option.value === value),
+ );
+ const currentRow = navigationRows[currentRowIndex];
+
+ if (currentRowIndex !== -1 && currentRow) {
+ const column = currentRow.findIndex(
+ (option) => option.value === value,
+ );
+ const nextRow =
+ navigationRows[(currentRowIndex + 1) % navigationRows.length];
+ const nextOption =
+ nextRow[Math.min(column, nextRow.length - 1)] ??
+ allOptions[index];
+
+ onChange(nextOption.value);
+ event.preventDefault();
+ event.nativeEvent.stopImmediatePropagation();
+ event.stopPropagation();
+ return;
+ }
break;
}
// Go the previous row
case KEYS.ARROW_UP: {
- nextIndex =
- (length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
+ const currentRowIndex = navigationRows.findIndex((row) =>
+ row.some((option) => option.value === value),
+ );
+ const currentRow = navigationRows[currentRowIndex];
+
+ if (currentRowIndex !== -1 && currentRow) {
+ const column = currentRow.findIndex(
+ (option) => option.value === value,
+ );
+ const previousRow =
+ navigationRows[
+ (navigationRows.length + currentRowIndex - 1) %
+ navigationRows.length
+ ];
+ const previousOption =
+ previousRow[Math.min(column, previousRow.length - 1)] ??
+ allOptions[index];
+
+ onChange(previousOption.value);
+ event.preventDefault();
+ event.nativeEvent.stopImmediatePropagation();
+ event.stopPropagation();
+ return;
+ }
break;
}
}
- onChange(options[nextIndex].value);
+ onChange(allOptions[nextIndex].value);
}
event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
@@ -99,38 +189,29 @@ function Picker({
event.stopPropagation();
};
- const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
-
- const alwaysVisibleOptions = React.useMemo(
- () => options.slice(0, numberOfOptionsToAlwaysShow),
- [options, numberOfOptionsToAlwaysShow],
- );
- const moreOptions = React.useMemo(
- () => options.slice(numberOfOptionsToAlwaysShow),
- [options, numberOfOptionsToAlwaysShow],
- );
-
useEffect(() => {
- if (!alwaysVisibleOptions.some((option) => option.value === value)) {
+ if (hasOption(hiddenSections, (option) => option.value === value)) {
setShowMoreOptions(true);
}
- }, [value, alwaysVisibleOptions, setShowMoreOptions]);
+ }, [value, hiddenSections, setShowMoreOptions]);
- const renderOptions = (options: Option[]) => {
+ const renderOptions = (options: readonly Option[]) => {
return (
- {options.map((option, i) => (
+ {options.map((option) => (
);
@@ -192,49 +291,45 @@ function Picker({
export function IconPicker({
value,
label,
- options,
+ visibleSections,
+ hiddenSections,
onChange,
- group = "",
- numberOfOptionsToAlwaysShow,
}: {
label: string;
value: T;
- options: readonly {
- value: T;
- text: string;
- icon: JSX.Element;
- keyBinding: string | null;
- }[];
+ visibleSections: readonly PickerSection[];
+ hiddenSections?: readonly PickerSection[];
onChange: (value: T) => void;
- numberOfOptionsToAlwaysShow?: number;
- group?: string;
}) {
const [isActive, setActive] = React.useState(false);
- const rPickerButton = React.useRef(null);
+ const selectedOption = useMemo(
+ () =>
+ findOption(visibleSections, (option) => option.value === value) ??
+ findOption(hiddenSections ?? [], (option) => option.value === value),
+ [visibleSections, hiddenSections, value],
+ );
return (
setActive(open)}>
setActive(!isActive)}
- ref={rPickerButton}
className={isActive ? "active" : ""}
>
- {options.find((option) => option.value === value)?.icon}
+ {selectedOption?.icon}
{isActive && (
{
setActive(false);
}}
- numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
/>
)}
diff --git a/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx b/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx
index 5f98637f47..4bf3f17767 100644
--- a/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx
+++ b/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx
@@ -236,15 +236,40 @@ const MermaidToExcalidraw = ({
(
- {el}
+
+ {el}
+
)}
sequenceLink={(el) => (
-
+
{el}
)}
classLink={(el) => (
- {el}
+
+ {el}
+
+ )}
+ erdLink={(el) => (
+
+ {el}
+
)}
/>
diff --git a/packages/excalidraw/components/TTDDialog/common.ts b/packages/excalidraw/components/TTDDialog/common.ts
index d5bac53169..e02bafc1f6 100644
--- a/packages/excalidraw/components/TTDDialog/common.ts
+++ b/packages/excalidraw/components/TTDDialog/common.ts
@@ -94,7 +94,7 @@ export const convertMermaidToExcalidraw = async ({
}
}
- const { elements, files } = ret;
+ const { elements, files = {} } = ret;
setError(null);
data.current = {
diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx
index 4a1691863f..7e2400728d 100644
--- a/packages/excalidraw/components/icons.tsx
+++ b/packages/excalidraw/components/icons.tsx
@@ -69,6 +69,11 @@ const modifiedTablerIconProps: Opts = {
strokeLinejoin: "round",
} as const;
+const arrowheadPreviewIconProps: Opts = {
+ width: 40,
+ height: 20,
+} as const;
+
// -----------------------------------------------------------------------------
// tabler-icons: present
@@ -1291,16 +1296,17 @@ export const ArrowheadNoneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
-
-
-
+
+
,
- tablerIconProps,
+ arrowheadPreviewIconProps,
),
);
@@ -1312,57 +1318,12 @@ export const ArrowheadArrowIcon = React.memo(
stroke="currentColor"
strokeWidth={2}
fill="none"
+ strokeLinecap="round"
+ strokeLinejoin="round"
>
-
-
+
,
- { width: 40, height: 20 },
- ),
-);
-
-export const ArrowheadCircleIcon = React.memo(
- ({ flip = false }: { flip?: boolean }) =>
- createIcon(
-
-
-
- ,
- { width: 40, height: 20 },
- ),
-);
-
-export const ArrowheadCircleOutlineIcon = React.memo(
- ({ flip = false }: { flip?: boolean }) =>
- createIcon(
-
-
-
- ,
- { width: 40, height: 20 },
- ),
-);
-
-export const ArrowheadBarIcon = React.memo(
- ({ flip = false }: { flip?: boolean }) =>
- createIcon(
-
-
- ,
- { width: 40, height: 20 },
+ arrowheadPreviewIconProps,
),
);
@@ -1373,11 +1334,12 @@ export const ArrowheadTriangleIcon = React.memo(
stroke="currentColor"
fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
+ strokeLinejoin="round"
>
-
-
+
+
,
- { width: 40, height: 20 },
+ arrowheadPreviewIconProps,
),
);
@@ -1390,12 +1352,43 @@ export const ArrowheadTriangleOutlineIcon = React.memo(
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2}
strokeLinejoin="round"
+ strokeLinecap="round"
>
-
-
+
+
,
+ arrowheadPreviewIconProps,
+ ),
+);
- { width: 40, height: 20 },
+export const ArrowheadCircleIcon = React.memo(
+ ({ flip = false }: { flip?: boolean }) =>
+ createIcon(
+
+
+
+ ,
+ arrowheadPreviewIconProps,
+ ),
+);
+
+export const ArrowheadCircleOutlineIcon = React.memo(
+ ({ flip = false }: { flip?: boolean }) =>
+ createIcon(
+
+
+
+ ,
+ arrowheadPreviewIconProps,
),
);
@@ -1407,12 +1400,11 @@ export const ArrowheadDiamondIcon = React.memo(
fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round"
- strokeWidth={2}
>
-
-
+
+
,
- { width: 40, height: 20 },
+ arrowheadPreviewIconProps,
),
);
@@ -1425,15 +1417,32 @@ export const ArrowheadDiamondOutlineIcon = React.memo(
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round"
strokeWidth={2}
+ strokeLinecap="round"
>
-
-
+
+
,
- { width: 40, height: 20 },
+ arrowheadPreviewIconProps,
),
);
-export const ArrowheadCrowfootIcon = React.memo(
+export const ArrowheadBarIcon = React.memo(
+ ({ flip = false }: { flip?: boolean }) =>
+ createIcon(
+
+
+ ,
+ arrowheadPreviewIconProps,
+ ),
+);
+
+export const ArrowheadCardinalityOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
-
+
,
- { width: 40, height: 20 },
+ arrowheadPreviewIconProps,
),
);
-export const ArrowheadCrowfootOneIcon = React.memo(
+export const ArrowheadCardinalityManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
-
+
,
- { width: 40, height: 20 },
+ arrowheadPreviewIconProps,
),
);
-export const ArrowheadCrowfootOneOrManyIcon = React.memo(
+export const ArrowheadCardinalityOneOrManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
-
+
,
- { width: 40, height: 20 },
+ arrowheadPreviewIconProps,
+ ),
+);
+
+export const ArrowheadCardinalityExactlyOneIcon = React.memo(
+ ({ flip = false }: { flip?: boolean }) =>
+ createIcon(
+
+
+ ,
+ arrowheadPreviewIconProps,
+ ),
+);
+
+export const ArrowheadCardinalityZeroOrOneIcon = React.memo(
+ ({ flip = false }: { flip?: boolean }) =>
+ createIcon(
+
+
+
+ ,
+ arrowheadPreviewIconProps,
+ ),
+);
+
+export const ArrowheadCardinalityZeroOrManyIcon = React.memo(
+ ({ flip = false }: { flip?: boolean }) =>
+ createIcon(
+
+
+
+ ,
+ arrowheadPreviewIconProps,
),
);
diff --git a/packages/excalidraw/css/theme.scss b/packages/excalidraw/css/theme.scss
index 9d741bdc3e..36899229ac 100644
--- a/packages/excalidraw/css/theme.scss
+++ b/packages/excalidraw/css/theme.scss
@@ -34,9 +34,10 @@
--popup-text-color: #000;
--popup-text-inverted-color: #fff;
--select-highlight-color: #{$color-blue-5};
- --shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
- 0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
- 0px 7px 14px 0px rgba(0, 0, 0, 0.05);
+ --shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
+ 0px 0px 3px 0px rgba(0, 0, 0, 0.08), 0px 7px 14px 0px rgba(0, 0, 0, 0.05);
+ --shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
+ 0px 0px 3px 0px rgba(0, 0, 0, 0.08), 0px 7px 14px 0px rgb(0 0 0 / 18%);
--button-hover-bg: var(--color-surface-high);
--button-active-bg: var(--color-surface-high);
@@ -210,9 +211,6 @@
--popup-text-color: #{$color-gray-4};
--popup-text-inverted-color: #2c2c2c;
--select-highlight-color: #{$color-blue-4};
- --shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
- 0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
- 0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts
index b77f010bd8..4550338edf 100644
--- a/packages/excalidraw/data/restore.ts
+++ b/packages/excalidraw/data/restore.ts
@@ -22,6 +22,7 @@ import {
import {
calculateFixedPointForNonElbowArrowBinding,
getNonDeletedElements,
+ normalizeArrowhead,
isPointInElement,
isValidPolygon,
projectFixedPointOntoDiagonal,
@@ -426,7 +427,8 @@ export const restoreElement = (
// @ts-ignore LEGACY type
// eslint-disable-next-line no-fallthrough
case "draw":
- const { startArrowhead = null, endArrowhead = null } = element;
+ const startArrowhead = normalizeArrowhead(element.startArrowhead);
+ const endArrowhead = normalizeArrowhead(element.endArrowhead);
let x = element.x;
let y = element.y;
let points = // migrate old arrow model to new one
@@ -458,7 +460,11 @@ export const restoreElement = (
...getSizeFromPoints(points),
});
case "arrow": {
- const { startArrowhead = null, endArrowhead = "arrow" } = element;
+ const startArrowhead = normalizeArrowhead(element.startArrowhead);
+ const endArrowhead =
+ element.endArrowhead === undefined
+ ? "arrow"
+ : normalizeArrowhead(element.endArrowhead);
const x: number | undefined = element.x;
const y: number | undefined = element.y;
const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json
index bfdb476bba..b0a8761de4 100644
--- a/packages/excalidraw/locales/en.json
+++ b/packages/excalidraw/locales/en.json
@@ -53,7 +53,14 @@
"arrowhead_crowfoot_many": "Crow's foot (many)",
"arrowhead_crowfoot_one": "Crow's foot (one)",
"arrowhead_crowfoot_one_or_many": "Crow's foot (one or many)",
+ "arrowhead_cardinality_one": "Cardinality (one)",
+ "arrowhead_cardinality_many": "Cardinality (many)",
+ "arrowhead_cardinality_one_or_many": "Cardinality (one or many)",
+ "arrowhead_cardinality_exactly_one": "Cardinality (exactly one)",
+ "arrowhead_cardinality_zero_or_one": "Cardinality (zero or one)",
+ "arrowhead_cardinality_zero_or_many": "Cardinality (zero or many)",
"more_options": "More options",
+ "cardinality": "Cardinality",
"arrowtypes": "Arrow type",
"arrowtype_sharp": "Sharp arrow",
"arrowtype_round": "Curved arrow",
@@ -624,7 +631,7 @@
"mermaid": {
"title": "Mermaid to Excalidraw",
"button": "Insert",
- "description": "Currently only Flowchart, Sequence, and Class Diagrams are supported. The other types will be rendered as image in Excalidraw.",
+ "description": "Currently only Flowchart, Sequence, Class, and Entity Relationship Diagrams are supported. The other types will be rendered as image in Excalidraw.",
"syntax": "Mermaid Syntax",
"preview": "Preview",
"label": "Mermaid",
@@ -654,7 +661,7 @@
"placeholder": {
"title": "Let's design your diagram",
"description": "Describe the diagram you want to create, and we'll generate it for you.",
- "hint": "At the moment we know Flowchart, Sequence, and Class diagrams."
+ "hint": "At the moment we know Flowchart, Sequence, Class, and Entity Relationship diagrams."
},
"preview": "Preview",
"insert": "Insert",
diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json
index f60ea96634..b29cede9b4 100644
--- a/packages/excalidraw/package.json
+++ b/packages/excalidraw/package.json
@@ -88,7 +88,7 @@
"@excalidraw/element": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/math": "0.18.0",
- "@excalidraw/mermaid-to-excalidraw": "2.0.0-rc4",
+ "@excalidraw/mermaid-to-excalidraw": "2.1.0",
"@excalidraw/random-username": "1.1.0",
"browser-fs-access": "0.38.0",
"canvas-roundrect-polyfill": "0.0.1",
diff --git a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap
index 294f32f86d..7882f0724c 100644
--- a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap
+++ b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`Test > should open mermaid popup when active tool is mermaid 1`] = `"Mermaid to Excalidraw
Currently only
Flowchart,
Sequence, and
Class Diagrams are supported. The other types will be rendered as image in Excalidraw.
"`;
+exports[`Test > should open mermaid popup when active tool is mermaid 1`] = `""`;
exports[`Test > should show error in preview when mermaid library throws error 1`] = `
"flowchart TD
diff --git a/packages/excalidraw/tests/data/restore.test.ts b/packages/excalidraw/tests/data/restore.test.ts
index 4b06601bb2..bd11424b6f 100644
--- a/packages/excalidraw/tests/data/restore.test.ts
+++ b/packages/excalidraw/tests/data/restore.test.ts
@@ -200,6 +200,26 @@ describe("restoreElements", () => {
});
});
+ it("should normalize legacy crowfoot arrowheads on restore", () => {
+ const arrowElement = API.createElement({
+ type: "arrow",
+ });
+
+ const restoredArrow = restore.restoreElements(
+ [
+ {
+ ...arrowElement,
+ startArrowhead: "crowfoot_one",
+ endArrowhead: "crowfoot_one_or_many",
+ } as any,
+ ],
+ null,
+ )[0] as ExcalidrawLinearElement;
+
+ expect(restoredArrow.startArrowhead).toBe("cardinality_one");
+ expect(restoredArrow.endArrowhead).toBe("cardinality_one_or_many");
+ });
+
it("should strip element if restore fails", () => {
const rect1 = API.createElement({
type: "rectangle",
diff --git a/yarn.lock b/yarn.lock
index f3a53e391e..2bc70c4afd 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1531,10 +1531,10 @@
resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
-"@excalidraw/mermaid-to-excalidraw@2.0.0-rc4":
- version "2.0.0-rc4"
- resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.0.0-rc4.tgz#9d38568de8e403fefb6a162efa71e024ea1f7e03"
- integrity sha512-92efu7VTYF6appGKgbDJAZEM/YXICpYPYOfl+j1E9SVilIFCJEAg7xH2lt/c44WFtffG+rWKrYwf9KUrPYy1Qw==
+"@excalidraw/mermaid-to-excalidraw@2.1.0":
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.1.0.tgz#a5b9cf87c3185558cda7f9687d87b9937f452358"
+ integrity sha512-RMd+c2b7WzzUjhERMpKwp8PhF2/XlHDjr/zK+Gxfp8K9sVlafPYJ5OEa/GkN6edi2rBUXRfW+41WdO6L56b6Kw==
dependencies:
"@excalidraw/markdown-to-text" "0.1.2"
"@mermaid-js/parser" "^0.6.3"