feat(editor): ERD arrowheads and diagrams (#10940)

This commit is contained in:
David Luzar
2026-03-11 22:00:31 +01:00
committed by GitHub
parent 92d25446d6
commit 816c81c12e
21 changed files with 834 additions and 367 deletions
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
type: "arrow", type: "arrow",
x: 450, x: 450,
y: 20, y: 20,
startArrowhead: "dot", startArrowhead: "circle",
endArrowhead: "triangle", endArrowhead: "triangle",
strokeColor: "#1971c2", strokeColor: "#1971c2",
strokeWidth: 2, strokeWidth: 2,
+32
View File
@@ -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;
};
+39 -26
View File
@@ -709,6 +709,9 @@ const getFreeDrawElementAbsoluteCoords = (
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2]; 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 */ /** @returns number in pixels */
export const getArrowheadSize = (arrowhead: Arrowhead): number => { export const getArrowheadSize = (arrowhead: Arrowhead): number => {
switch (arrowhead) { switch (arrowhead) {
@@ -717,10 +720,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
case "diamond": case "diamond":
case "diamond_outline": case "diamond_outline":
return 12; return 12;
case "crowfoot_many": case "cardinality_many":
case "crowfoot_one": case "cardinality_one_or_many":
case "crowfoot_one_or_many": case "cardinality_zero_or_many":
return 20; return CROWFOOT_ARROWHEAD_SIZE;
case "cardinality_one":
case "cardinality_exactly_one":
case "cardinality_zero_or_one":
return CARDINALITY_MARKER_SIZE;
default: default:
return 15; return 15;
} }
@@ -743,7 +750,12 @@ export const getArrowheadPoints = (
shape: Drawable[], shape: Drawable[],
position: "start" | "end", position: "start" | "end",
arrowhead: Arrowhead, arrowhead: Arrowhead,
offsetMultiplier = 0,
) => { ) => {
if (arrowhead === null) {
return null;
}
if (shape.length < 1) { if (shape.length < 1) {
return null; return null;
} }
@@ -824,29 +836,30 @@ export const getArrowheadPoints = (
const lengthMultiplier = const lengthMultiplier =
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5; arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
const minSize = Math.min(size, length * lengthMultiplier); const minSize = Math.min(size, length * lengthMultiplier);
const xs = x2 - nx * minSize; const tx = x2 - nx * minSize * offsetMultiplier;
const ys = y2 - ny * minSize; const ty = y2 - ny * minSize * offsetMultiplier;
const xs = tx - nx * minSize;
const ys = ty - ny * minSize;
if ( if (arrowhead === "circle" || arrowhead === "circle_outline") {
arrowhead === "dot" || const diameter = Math.hypot(ys - ty, xs - tx) + element.strokeWidth - 2;
arrowhead === "circle" || return [tx, ty, diameter];
arrowhead === "circle_outline"
) {
const diameter = Math.hypot(ys - y2, xs - x2) + element.strokeWidth - 2;
return [x2, y2, diameter];
} }
const angle = getArrowheadAngle(arrowhead); 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) // swap (xs, ys) with (x2, y2)
const [x3, y3] = pointRotateRads( const [x3, y3] = pointRotateRads(
pointFrom(x2, y2), pointFrom(tx, ty),
pointFrom(xs, ys), pointFrom(xs, ys),
degreesToRadians(-angle as Degrees), degreesToRadians(-angle as Degrees),
); );
const [x4, y4] = pointRotateRads( const [x4, y4] = pointRotateRads(
pointFrom(x2, y2), pointFrom(tx, ty),
pointFrom(xs, ys), pointFrom(xs, ys),
degreesToRadians(angle), degreesToRadians(angle),
); );
@@ -856,12 +869,12 @@ export const getArrowheadPoints = (
// Return points // Return points
const [x3, y3] = pointRotateRads( const [x3, y3] = pointRotateRads(
pointFrom(xs, ys), pointFrom(xs, ys),
pointFrom(x2, y2), pointFrom(tx, ty),
((-angle * Math.PI) / 180) as Radians, ((-angle * Math.PI) / 180) as Radians,
); );
const [x4, y4] = pointRotateRads( const [x4, y4] = pointRotateRads(
pointFrom(xs, ys), pointFrom(xs, ys),
pointFrom(x2, y2), pointFrom(tx, ty),
degreesToRadians(angle), degreesToRadians(angle),
); );
@@ -874,9 +887,9 @@ export const getArrowheadPoints = (
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0]; const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(x2 + minSize * 2, y2), pointFrom(tx + minSize * 2, ty),
pointFrom(x2, y2), pointFrom(tx, ty),
Math.atan2(py - y2, px - x2) as Radians, Math.atan2(py - ty, px - tx) as Radians,
); );
} else { } else {
const [px, py] = const [px, py] =
@@ -885,16 +898,16 @@ export const getArrowheadPoints = (
: [0, 0]; : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(x2 - minSize * 2, y2), pointFrom(tx - minSize * 2, ty),
pointFrom(x2, y2), pointFrom(tx, ty),
Math.atan2(y2 - py, x2 - px) as Radians, 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 // TODO reuse shape.ts
+1
View File
@@ -99,3 +99,4 @@ export * from "./typeChecks";
export * from "./utils"; export * from "./utils";
export * from "./zindex"; export * from "./zindex";
export * from "./arrows/helpers"; export * from "./arrows/helpers";
export * from "./arrowheads";
+223 -86
View File
@@ -69,10 +69,10 @@ import type {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ElementsMap, ElementsMap,
ExcalidrawLineElement, ExcalidrawLineElement,
Arrowhead,
} from "./types"; } from "./types";
import type { Drawable, Options } from "roughjs/bin/core"; import type { Drawable, Options } from "roughjs/bin/core";
@@ -296,6 +296,82 @@ const modifyIframeLikeForRoughOptions = (
return element; 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 = ( const getArrowheadShapes = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
shape: Drawable[], shape: Drawable[],
@@ -306,63 +382,54 @@ const getArrowheadShapes = (
canvasBackgroundColor: string, canvasBackgroundColor: string,
isDarkMode: boolean, isDarkMode: boolean,
) => { ) => {
const arrowheadPoints = getArrowheadPoints( if (arrowhead === null) {
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return []; 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 const strokeColor = isDarkMode
? applyDarkModeFilter(element.strokeColor) ? applyDarkModeFilter(element.strokeColor)
: element.strokeColor; : element.strokeColor;
const backgroundFillColor = isDarkMode
? applyDarkModeFilter(canvasBackgroundColor)
: canvasBackgroundColor;
const cardinalityOneOrManyOffset = -0.25;
const cardinalityZeroCircleScale = 0.8;
switch (arrowhead) { switch (arrowhead) {
case "dot":
case "circle": case "circle":
case "circle_outline": { case "circle_outline": {
const [x, y, diameter] = arrowheadPoints; return generateArrowheadOutlineCircle(
generator,
// always use solid stroke for arrowhead options,
delete options.strokeLineDash; strokeColor,
getArrowheadPoints(element, shape, position, arrowhead),
return [ arrowhead === "circle_outline" ? backgroundFillColor : strokeColor,
generator.circle(x, y, diameter, { );
...options,
fill:
arrowhead === "circle_outline"
? canvasBackgroundColor
: strokeColor,
fillStyle: "solid",
stroke: strokeColor,
roughness: Math.min(0.5, options.roughness || 0),
}),
];
} }
case "triangle": case "triangle":
case "triangle_outline": { case "triangle_outline": {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return [];
}
const [x, y, x2, y2, x3, y3] = arrowheadPoints; 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 // always use solid stroke for arrowhead
delete options.strokeLineDash; delete triangleOptions.strokeLineDash;
return [ return [
generator.polygon( generator.polygon(
@@ -372,24 +439,34 @@ const getArrowheadShapes = (
[x3, y3], [x3, y3],
[x, y], [x, y],
], ],
{ triangleOptions,
...options,
fill:
arrowhead === "triangle_outline"
? canvasBackgroundColor
: strokeColor,
fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0),
},
), ),
]; ];
} }
case "diamond": case "diamond":
case "diamond_outline": { 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 [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 // always use solid stroke for arrowhead
delete options.strokeLineDash; delete diamondOptions.strokeLineDash;
return [ return [
generator.polygon( generator.polygon(
@@ -400,46 +477,106 @@ const getArrowheadShapes = (
[x4, y4], [x4, y4],
[x, y], [x, y],
], ],
{ diamondOptions,
...options, ),
fill: ];
arrowhead === "diamond_outline" }
? canvasBackgroundColor case "cardinality_one":
: strokeColor, return generateArrowheadCardinalityOne(
fillStyle: "solid", generator,
roughness: Math.min(1, options.roughness || 0), 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 "bar":
case "arrow": case "arrow":
case "crowfoot_many":
case "crowfoot_one_or_many":
default: { default: {
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints; return generateArrowheadLinesToTip(
generator,
if (element.strokeStyle === "dotted") { getArrowheadPoints(element, shape, position, arrowhead),
// for dotted arrows caps, reduce gap to make it more legible getArrowheadLineOptions(element, options),
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,
)
: []),
];
} }
} }
}; };
+17 -4
View File
@@ -303,19 +303,32 @@ export type PointsPositionUpdates = Map<
{ point: LocalPoint; isDragging?: boolean } { 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 = export type Arrowhead =
| "arrow" | "arrow"
| "bar" | "bar"
| "dot" // legacy. Do not use for new elements.
| "circle" | "circle"
| "circle_outline" | "circle_outline"
| "triangle" | "triangle"
| "triangle_outline" | "triangle_outline"
| "diamond" | "diamond"
| "diamond_outline" | "diamond_outline"
| "crowfoot_one" | CardinalityArrowhead;
| "crowfoot_many"
| "crowfoot_one_or_many"; export type AnyArrowhead = Arrowhead | ArrowheadLegacy;
export type ExcalidrawLinearElement = _ExcalidrawElementBase & export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
+1 -1
View File
@@ -399,7 +399,7 @@ export const actionSaveFileToDisk = register({
appState: { appState: {
openDialog: null, openDialog: null,
fileHandle: savedFileHandle, fileHandle: savedFileHandle,
toast: { message: t("toast.fileSaved") }, toast: { message: t("toast.fileSaved"), duration: 3000 },
}, },
}; };
} catch (error: any) { } catch (error: any) {
+132 -84
View File
@@ -36,6 +36,7 @@ import {
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element";
import { getArrowheadForPicker } from "@excalidraw/element";
import { import {
getBoundTextElement, getBoundTextElement,
@@ -124,9 +125,12 @@ import {
sharpArrowIcon, sharpArrowIcon,
roundArrowIcon, roundArrowIcon,
elbowArrowIcon, elbowArrowIcon,
ArrowheadCrowfootIcon, ArrowheadCardinalityExactlyOneIcon,
ArrowheadCrowfootOneIcon, ArrowheadCardinalityManyIcon,
ArrowheadCrowfootOneOrManyIcon, ArrowheadCardinalityOneIcon,
ArrowheadCardinalityOneOrManyIcon,
ArrowheadCardinalityZeroOrManyIcon,
ArrowheadCardinalityZeroOrOneIcon,
} from "../components/icons"; } from "../components/icons";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
@@ -1550,80 +1554,117 @@ export const actionChangeRoundness = register<"sharp" | "round">({
}); });
const getArrowheadOptions = (flip: boolean) => { const getArrowheadOptions = (flip: boolean) => {
return [ return {
{ visibleSections: [
value: null, {
text: t("labels.arrowhead_none"), name: "default",
keyBinding: "q", options: [
icon: <ArrowheadNoneIcon flip={flip} />, {
}, value: null,
{ text: t("labels.arrowhead_none"),
value: "arrow", keyBinding: "q",
text: t("labels.arrowhead_arrow"), icon: <ArrowheadNoneIcon flip={flip} />,
keyBinding: "w", },
icon: <ArrowheadArrowIcon flip={flip} />, {
}, value: "arrow",
{ text: t("labels.arrowhead_arrow"),
value: "triangle", keyBinding: "w",
text: t("labels.arrowhead_triangle"), icon: <ArrowheadArrowIcon flip={flip} />,
icon: <ArrowheadTriangleIcon flip={flip} />, },
keyBinding: "e", {
}, value: "triangle",
{ text: t("labels.arrowhead_triangle"),
value: "triangle_outline", icon: <ArrowheadTriangleIcon flip={flip} />,
text: t("labels.arrowhead_triangle_outline"), keyBinding: "e",
icon: <ArrowheadTriangleOutlineIcon flip={flip} />, },
keyBinding: "r", {
}, value: "triangle_outline",
{ text: t("labels.arrowhead_triangle_outline"),
value: "circle", icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
text: t("labels.arrowhead_circle"), keyBinding: "r",
keyBinding: "a", },
icon: <ArrowheadCircleIcon flip={flip} />, ],
}, },
{ ],
value: "circle_outline", hiddenSections: [
text: t("labels.arrowhead_circle_outline"), {
keyBinding: "s", name: "default",
icon: <ArrowheadCircleOutlineIcon flip={flip} />, options: [
}, {
{ value: "circle",
value: "diamond", text: t("labels.arrowhead_circle"),
text: t("labels.arrowhead_diamond"), keyBinding: "a",
icon: <ArrowheadDiamondIcon flip={flip} />, icon: <ArrowheadCircleIcon flip={flip} />,
keyBinding: "d", },
}, {
{ value: "circle_outline",
value: "diamond_outline", text: t("labels.arrowhead_circle_outline"),
text: t("labels.arrowhead_diamond_outline"), keyBinding: "s",
icon: <ArrowheadDiamondOutlineIcon flip={flip} />, icon: <ArrowheadCircleOutlineIcon flip={flip} />,
keyBinding: "f", },
}, {
{ value: "diamond",
value: "bar", text: t("labels.arrowhead_diamond"),
text: t("labels.arrowhead_bar"), icon: <ArrowheadDiamondIcon flip={flip} />,
keyBinding: "z", keyBinding: "d",
icon: <ArrowheadBarIcon flip={flip} />, },
}, {
{ value: "diamond_outline",
value: "crowfoot_one", text: t("labels.arrowhead_diamond_outline"),
text: t("labels.arrowhead_crowfoot_one"), icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
icon: <ArrowheadCrowfootOneIcon flip={flip} />, keyBinding: "f",
keyBinding: "x", },
}, {
{ value: "bar",
value: "crowfoot_many", text: t("labels.arrowhead_bar"),
text: t("labels.arrowhead_crowfoot_many"), keyBinding: "z",
icon: <ArrowheadCrowfootIcon flip={flip} />, icon: <ArrowheadBarIcon flip={flip} />,
keyBinding: "c", },
}, ],
{ },
value: "crowfoot_one_or_many", {
text: t("labels.arrowhead_crowfoot_one_or_many"), name: t("labels.cardinality"),
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />, options: [
keyBinding: "v", {
}, value: "cardinality_one",
] as const; text: t("labels.arrowhead_cardinality_one"),
icon: <ArrowheadCardinalityOneIcon flip={flip} />,
keyBinding: "x",
},
{
value: "cardinality_many",
text: t("labels.arrowhead_cardinality_many"),
icon: <ArrowheadCardinalityManyIcon flip={flip} />,
keyBinding: "c",
},
{
value: "cardinality_one_or_many",
text: t("labels.arrowhead_cardinality_one_or_many"),
icon: <ArrowheadCardinalityOneOrManyIcon flip={flip} />,
keyBinding: "v",
},
{
value: "cardinality_exactly_one",
text: t("labels.arrowhead_cardinality_exactly_one"),
icon: <ArrowheadCardinalityExactlyOneIcon flip={flip} />,
keyBinding: null,
},
{
value: "cardinality_zero_or_one",
text: t("labels.arrowhead_cardinality_zero_or_one"),
icon: <ArrowheadCardinalityZeroOrOneIcon flip={flip} />,
keyBinding: null,
},
{
value: "cardinality_zero_or_many",
text: t("labels.arrowhead_cardinality_zero_or_many"),
icon: <ArrowheadCardinalityZeroOrManyIcon flip={flip} />,
keyBinding: null,
},
],
},
],
} as const;
}; };
export const actionChangeArrowhead = register<{ export const actionChangeArrowhead = register<{
@@ -1667,45 +1708,52 @@ export const actionChangeArrowhead = register<{
}, },
PanelComponent: ({ elements, appState, updateData, app }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
const startArrowheadOptions = useMemo(
() => getArrowheadOptions(!isRTL),
[isRTL],
);
const endArrowheadOptions = useMemo(
() => getArrowheadOptions(!!isRTL),
[isRTL],
);
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.arrowheads")}</legend> <legend>{t("labels.arrowheads")}</legend>
<div className="iconSelectList buttonList"> <div className="iconSelectList buttonList">
<IconPicker <IconPicker
visibleSections={startArrowheadOptions.visibleSections}
hiddenSections={startArrowheadOptions.hiddenSections}
label="arrowhead_start" label="arrowhead_start"
options={getArrowheadOptions(!isRTL)}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
app, app,
(element) => (element) =>
isLinearElement(element) && canHaveArrowheads(element.type) isLinearElement(element) && canHaveArrowheads(element.type)
? element.startArrowhead ? getArrowheadForPicker(element.startArrowhead)
: appState.currentItemStartArrowhead, : appState.currentItemStartArrowhead,
true, true,
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemStartArrowhead, hasSelection ? null : appState.currentItemStartArrowhead,
)} )}
onChange={(value) => updateData({ position: "start", type: value })} onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4}
/> />
<IconPicker <IconPicker
visibleSections={endArrowheadOptions.visibleSections}
hiddenSections={endArrowheadOptions.hiddenSections}
label="arrowhead_end" label="arrowhead_end"
group="arrowheads"
options={getArrowheadOptions(!!isRTL)}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
app, app,
(element) => (element) =>
isLinearElement(element) && canHaveArrowheads(element.type) isLinearElement(element) && canHaveArrowheads(element.type)
? element.endArrowhead ? getArrowheadForPicker(element.endArrowhead)
: appState.currentItemEndArrowhead, : appState.currentItemEndArrowhead,
true, true,
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemEndArrowhead, hasSelection ? null : appState.currentItemEndArrowhead,
)} )}
onChange={(value) => updateData({ position: "end", type: value })} onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4}
/> />
</div> </div>
</fieldset> </fieldset>
+1 -1
View File
@@ -3703,7 +3703,7 @@ class App extends React.Component<AppProps, AppState> {
if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) { if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) {
const api = await import("@excalidraw/mermaid-to-excalidraw"); const api = await import("@excalidraw/mermaid-to-excalidraw");
try { try {
const { elements: skeletonElements, files } = const { elements: skeletonElements, files = {} } =
await api.parseMermaidToExcalidraw(data.text); await api.parseMermaidToExcalidraw(data.text);
const elements = convertToExcalidrawElements(skeletonElements, { const elements = convertToExcalidrawElements(skeletonElements, {
+15 -2
View File
@@ -6,7 +6,7 @@
padding: 0.5rem; padding: 0.5rem;
background: var(--popup-bg-color); background: var(--popup-bg-color);
border: 0 solid color.adjust(#fff, $alpha: -0.75); border: 0 solid color.adjust(#fff, $alpha: -0.75);
box-shadow: var(--shadow-island); box-shadow: var(--shadow-island-stronger);
border-radius: 4px; border-radius: 4px;
position: absolute; position: absolute;
:root[dir="rtl"] & { :root[dir="rtl"] & {
@@ -14,6 +14,13 @@
} }
} }
.picker-sections,
.picker-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.picker-container button, .picker-container button,
.picker button { .picker button {
position: relative; position: relative;
@@ -62,7 +69,13 @@
.picker-collapsible { .picker-collapsible {
font-size: 0.75rem; 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 { .picker-keybinding {
+161 -66
View File
@@ -1,6 +1,6 @@
import { Popover } from "radix-ui"; import { Popover } from "radix-ui";
import clsx from "clsx"; import clsx from "clsx";
import React, { useEffect } from "react"; import React, { useEffect, useMemo } from "react";
import { isArrowKey, KEYS } from "@excalidraw/common"; import { isArrowKey, KEYS } from "@excalidraw/common";
@@ -15,6 +15,8 @@ import "./IconPicker.scss";
import type { JSX } from "react"; import type { JSX } from "react";
const moreOptionsAtom = atom(false); const moreOptionsAtom = atom(false);
const PICKER_COLUMNS = 4;
const DEFAULT_SECTION_NAME = "default";
type Option<T> = { type Option<T> = {
value: T; value: T;
@@ -23,28 +25,74 @@ type Option<T> = {
keyBinding: string | null; keyBinding: string | null;
}; };
type PickerSection<T> = {
name: string;
options: readonly Option<T>[];
};
const flattenOptions = <T,>(sections: readonly PickerSection<T>[]) =>
sections.flatMap((section) => section.options);
const findOption = <T,>(
sections: readonly PickerSection<T>[],
predicate: (option: Option<T>) => boolean,
) => {
for (const section of sections) {
const option = section.options.find(predicate);
if (option) {
return option;
}
}
return null;
};
const hasOption = <T,>(
sections: readonly PickerSection<T>[],
predicate: (option: Option<T>) => boolean,
) => sections.some((section) => section.options.some(predicate));
const getNavigationRows = <T,>(sections: readonly PickerSection<T>[]) =>
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<T>({ function Picker<T>({
options, visibleSections,
hiddenSections = [],
value, value,
label, label,
onChange, onChange,
onClose, onClose,
numberOfOptionsToAlwaysShow = options.length,
}: { }: {
label: string; label: string;
value: T; value: T;
options: readonly Option<T>[]; visibleSections: readonly PickerSection<T>[];
hiddenSections?: readonly PickerSection<T>[];
onChange: (value: T) => void; onChange: (value: T) => void;
onClose: () => void; onClose: () => void;
numberOfOptionsToAlwaysShow?: number;
}) { }) {
const editorInterface = useEditorInterface(); const editorInterface = useEditorInterface();
const { container } = useExcalidrawContainer(); 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 handleKeyDown = (event: React.KeyboardEvent) => {
const pressedOption = options.find( const pressedOption = allOptions.find(
(option) => option.keyBinding === event.key.toLowerCase(), (option) => option.keyBinding === event.key.toLowerCase(),
)!; );
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) { if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
// Keybinding navigation // Keybinding navigation
@@ -52,17 +100,17 @@ function Picker<T>({
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.TAB) { } 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 const nextIndex = event.shiftKey
? (options.length + index - 1) % options.length ? (allOptions.length + index - 1) % allOptions.length
: (index + 1) % options.length; : (index + 1) % allOptions.length;
onChange(options[nextIndex].value); onChange(allOptions[nextIndex].value);
} else if (isArrowKey(event.key)) { } else if (isArrowKey(event.key)) {
// Arrow navigation // Arrow navigation
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
const index = options.findIndex((option) => option.value === value); const index = allOptions.findIndex((option) => option.value === value);
if (index !== -1) { if (index !== -1) {
const length = options.length; const length = allOptions.length;
let nextIndex = index; let nextIndex = index;
switch (event.key) { switch (event.key) {
@@ -76,18 +124,60 @@ function Picker<T>({
break; break;
// Go the next row // Go the next row
case KEYS.ARROW_DOWN: { 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; break;
} }
// Go the previous row // Go the previous row
case KEYS.ARROW_UP: { case KEYS.ARROW_UP: {
nextIndex = const currentRowIndex = navigationRows.findIndex((row) =>
(length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length; 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; break;
} }
} }
onChange(options[nextIndex].value); onChange(allOptions[nextIndex].value);
} }
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) { } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
@@ -99,38 +189,29 @@ function Picker<T>({
event.stopPropagation(); 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(() => { useEffect(() => {
if (!alwaysVisibleOptions.some((option) => option.value === value)) { if (hasOption(hiddenSections, (option) => option.value === value)) {
setShowMoreOptions(true); setShowMoreOptions(true);
} }
}, [value, alwaysVisibleOptions, setShowMoreOptions]); }, [value, hiddenSections, setShowMoreOptions]);
const renderOptions = (options: Option<T>[]) => { const renderOptions = (options: readonly Option<T>[]) => {
return ( return (
<div className="picker-content"> <div className="picker-content">
{options.map((option, i) => ( {options.map((option) => (
<button <button
type="button" type="button"
className={clsx("picker-option", { className={clsx("picker-option", {
active: value === option.value, active: value === option.value,
})} })}
onClick={(event) => { onClick={() => {
onChange(option.value); onChange(option.value);
}} }}
title={`${option.text} ${ title={
option.keyBinding && `— ${option.keyBinding.toUpperCase()}` option.keyBinding
}`} ? `${option.text} — ${option.keyBinding.toUpperCase()}`
: option.text
}
aria-label={option.text || "none"} aria-label={option.text || "none"}
aria-keyshortcuts={option.keyBinding || undefined} aria-keyshortcuts={option.keyBinding || undefined}
key={option.text} key={option.text}
@@ -153,6 +234,20 @@ function Picker<T>({
); );
}; };
const renderSections = (sections: readonly PickerSection<T>[]) =>
sections.map((section, index) =>
section.name === DEFAULT_SECTION_NAME ? (
<React.Fragment key={`${section.name}-${index}`}>
{renderOptions(section.options)}
</React.Fragment>
) : (
<div className="picker-section" key={`${section.name}-${index}`}>
<div className="picker-section-label">{section.name}</div>
{renderOptions(section.options)}
</div>
),
);
const isMobile = editorInterface.formFactor === "phone"; const isMobile = editorInterface.formFactor === "phone";
return ( return (
@@ -170,20 +265,24 @@ function Picker<T>({
aria-modal="true" aria-modal="true"
aria-label={label} aria-label={label}
> >
{renderOptions(alwaysVisibleOptions)} <div className="picker-sections">
{renderSections(visibleSections)}
{moreOptions.length > 0 && ( {hiddenSections.length > 0 && (
<Collapsible <Collapsible
label={t("labels.more_options")} label={t("labels.more_options")}
open={showMoreOptions} open={showMoreOptions}
openTrigger={() => { openTrigger={() => {
setShowMoreOptions((value) => !value); setShowMoreOptions((value) => !value);
}} }}
className="picker-collapsible" className="picker-collapsible"
> >
{renderOptions(moreOptions)} <div className="picker-sections">
</Collapsible> {renderSections(hiddenSections)}
)} </div>
</Collapsible>
)}
</div>
</div> </div>
</Popover.Content> </Popover.Content>
); );
@@ -192,49 +291,45 @@ function Picker<T>({
export function IconPicker<T>({ export function IconPicker<T>({
value, value,
label, label,
options, visibleSections,
hiddenSections,
onChange, onChange,
group = "",
numberOfOptionsToAlwaysShow,
}: { }: {
label: string; label: string;
value: T; value: T;
options: readonly { visibleSections: readonly PickerSection<T>[];
value: T; hiddenSections?: readonly PickerSection<T>[];
text: string;
icon: JSX.Element;
keyBinding: string | null;
}[];
onChange: (value: T) => void; onChange: (value: T) => void;
numberOfOptionsToAlwaysShow?: number;
group?: string;
}) { }) {
const [isActive, setActive] = React.useState(false); const [isActive, setActive] = React.useState(false);
const rPickerButton = React.useRef<any>(null); const selectedOption = useMemo(
() =>
findOption(visibleSections, (option) => option.value === value) ??
findOption(hiddenSections ?? [], (option) => option.value === value),
[visibleSections, hiddenSections, value],
);
return ( return (
<div> <div>
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}> <Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
<Popover.Trigger <Popover.Trigger
name={group}
type="button" type="button"
aria-label={label} aria-label={label}
onClick={() => setActive(!isActive)} onClick={() => setActive(!isActive)}
ref={rPickerButton}
className={isActive ? "active" : ""} className={isActive ? "active" : ""}
> >
{options.find((option) => option.value === value)?.icon} {selectedOption?.icon}
</Popover.Trigger> </Popover.Trigger>
{isActive && ( {isActive && (
<Picker <Picker
options={options} visibleSections={visibleSections}
hiddenSections={hiddenSections}
value={value} value={value}
label={label} label={label}
onChange={onChange} onChange={onChange}
onClose={() => { onClose={() => {
setActive(false); setActive(false);
}} }}
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
/> />
)} )}
</Popover.Root> </Popover.Root>
@@ -236,15 +236,40 @@ const MermaidToExcalidraw = ({
<Trans <Trans
i18nKey="mermaid.description" i18nKey="mermaid.description"
flowchartLink={(el) => ( flowchartLink={(el) => (
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a> <a
href="https://mermaid.js.org/syntax/flowchart.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)} )}
sequenceLink={(el) => ( sequenceLink={(el) => (
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> <a
href="https://mermaid.js.org/syntax/sequenceDiagram.html"
target="_blank"
rel="noreferrer"
>
{el} {el}
</a> </a>
)} )}
classLink={(el) => ( classLink={(el) => (
<a href="https://mermaid.js.org/syntax/classDiagram.html">{el}</a> <a
href="https://mermaid.js.org/syntax/classDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)}
erdLink={(el) => (
<a
href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)} )}
/> />
</div> </div>
@@ -94,7 +94,7 @@ export const convertMermaidToExcalidraw = async ({
} }
} }
const { elements, files } = ret; const { elements, files = {} } = ret;
setError(null); setError(null);
data.current = { data.current = {
+135 -76
View File
@@ -69,6 +69,11 @@ const modifiedTablerIconProps: Opts = {
strokeLinejoin: "round", strokeLinejoin: "round",
} as const; } as const;
const arrowheadPreviewIconProps: Opts = {
width: 40,
height: 20,
} as const;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// tabler-icons: present // tabler-icons: present
@@ -1291,16 +1296,17 @@ export const ArrowheadNoneIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
transform={flip ? "translate(24, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
stroke="currentColor" stroke="currentColor"
opacity={0.3} opacity={0.3}
strokeWidth={2} strokeWidth={2}
fill="none"
strokeLinecap="round"
> >
<path d="M12 12l-9 0" /> <path d="M7,11 H19" />
<path d="M21 9l-6 6" /> <path d="M25,6 L33,16 M33,6 L25,16" />
<path d="M21 15l-6 -6" />
</g>, </g>,
tablerIconProps, arrowheadPreviewIconProps,
), ),
); );
@@ -1312,57 +1318,12 @@ export const ArrowheadArrowIcon = React.memo(
stroke="currentColor" stroke="currentColor"
strokeWidth={2} strokeWidth={2}
fill="none" fill="none"
strokeLinecap="round"
strokeLinejoin="round"
> >
<path d="M34 10H6M34 10L27 5M34 10L27 15" /> <path d="M7,11 H33 M23,5 L33,11 L23,17" />
<path d="M27.5 5L34.5 10L27.5 15" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
),
);
export const ArrowheadCircleIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
>
<path d="M32 10L6 10" strokeWidth={2} />
<circle r="4" transform="matrix(-1 0 0 1 30 10)" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadCircleOutlineIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2}
>
<path d="M26 10L6 10" />
<circle r="4" transform="matrix(-1 0 0 1 30 10)" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadBarIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}>
<path
d="M34 10H5.99996M34 10L34 5M34 10L34 15"
stroke="currentColor"
strokeWidth={2}
fill="none"
/>
</g>,
{ width: 40, height: 20 },
), ),
); );
@@ -1373,11 +1334,12 @@ export const ArrowheadTriangleIcon = React.memo(
stroke="currentColor" stroke="currentColor"
fill="currentColor" fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round"
> >
<path d="M32 10L6 10" strokeWidth={2} /> <path d="M7,11 H23" strokeWidth={2} strokeLinecap="round" />
<path d="M27.5 5.5L34.5 10L27.5 14.5L27.5 5.5" /> <path d="M23,5 L35,11 L23,17 Z" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
@@ -1390,12 +1352,43 @@ export const ArrowheadTriangleOutlineIcon = React.memo(
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2} strokeWidth={2}
strokeLinejoin="round" strokeLinejoin="round"
strokeLinecap="round"
> >
<path d="M6,9.5H27" /> <path d="M7,11 H23" />
<path d="M27,5L34,10L27,14Z" fill="none" /> <path d="M23,5 L35,11 L23,17 Z" />
</g>, </g>,
arrowheadPreviewIconProps,
),
);
{ width: 40, height: 20 }, export const ArrowheadCircleIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
>
<path d="M7,11 H25" strokeWidth={2} strokeLinecap="round" />
<circle cx="29" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCircleOutlineIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2}
>
<path d="M7,11 H25" strokeLinecap="round" />
<circle cx="29" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
), ),
); );
@@ -1407,12 +1400,11 @@ export const ArrowheadDiamondIcon = React.memo(
fill="currentColor" fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2}
> >
<path d="M6,9.5H20" /> <path d="M7,11 H21" strokeWidth={2} strokeLinecap="round" />
<path d="M27,5L34,10L27,14L20,9.5Z" /> <path d="M21,11 L28,5 L35,11 L28,17 Z" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
@@ -1425,15 +1417,32 @@ export const ArrowheadDiamondOutlineIcon = React.memo(
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
strokeLinecap="round"
> >
<path d="M6,9.5H20" /> <path d="M7,11 H21" />
<path d="M27,5L34,10L27,14L20,9.5Z" /> <path d="M21,11 L28,5 L35,11 L28,17 Z" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
export const ArrowheadCrowfootIcon = React.memo( export const ArrowheadBarIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
stroke="currentColor"
strokeWidth={2}
fill="none"
strokeLinecap="round"
>
<path d="M11,11 H31 M31,5 V17" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
@@ -1443,13 +1452,13 @@ export const ArrowheadCrowfootIcon = React.memo(
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
> >
<path d="M34,10 H6 M15,10 L7,5 M15,10 L7,15" /> <path d="M35,11 H7 M15,5 V17" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
export const ArrowheadCrowfootOneIcon = React.memo( export const ArrowheadCardinalityManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
@@ -1459,13 +1468,13 @@ export const ArrowheadCrowfootOneIcon = React.memo(
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
> >
<path d="M34,10 H6 M15,10 L15,15 L15,5" /> <path d="M35,11 H7 M15,11 L7,5 M15,11 L7,17" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
export const ArrowheadCrowfootOneOrManyIcon = React.memo( export const ArrowheadCardinalityOneOrManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
@@ -1475,9 +1484,59 @@ export const ArrowheadCrowfootOneOrManyIcon = React.memo(
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
> >
<path d="M34,10 H6 M15,10 L15,16 L15,4 M15,10 L7,5 M15,10 L7,15" /> <path d="M35,11 H7 M23,5 V17 M15,11 L7,5 M15,11 L7,17" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityExactlyOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M35,11 H7 M15,5 V17 M7,5 V17" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityZeroOrOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M35,11 H19 M11,11 H7 M7,5 V17" />
<circle cx="15" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityZeroOrManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M35,11 H27 M19,11 H7 M15,11 L7,5 M15,11 L7,17" />
<circle cx="23" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
), ),
); );
+4 -6
View File
@@ -34,9 +34,10 @@
--popup-text-color: #000; --popup-text-color: #000;
--popup-text-inverted-color: #fff; --popup-text-inverted-color: #fff;
--select-highlight-color: #{$color-blue-5}; --select-highlight-color: #{$color-blue-5};
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17), --shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08), 0px 0px 3px 0px rgba(0, 0, 0, 0.08), 0px 7px 14px 0px rgba(0, 0, 0, 0.05);
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-hover-bg: var(--color-surface-high);
--button-active-bg: var(--color-surface-high); --button-active-bg: var(--color-surface-high);
@@ -210,9 +211,6 @@
--popup-text-color: #{$color-gray-4}; --popup-text-color: #{$color-gray-4};
--popup-text-inverted-color: #2c2c2c; --popup-text-inverted-color: #2c2c2c;
--select-highlight-color: #{$color-blue-4}; --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), --modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198), 0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
+8 -2
View File
@@ -22,6 +22,7 @@ import {
import { import {
calculateFixedPointForNonElbowArrowBinding, calculateFixedPointForNonElbowArrowBinding,
getNonDeletedElements, getNonDeletedElements,
normalizeArrowhead,
isPointInElement, isPointInElement,
isValidPolygon, isValidPolygon,
projectFixedPointOntoDiagonal, projectFixedPointOntoDiagonal,
@@ -426,7 +427,8 @@ export const restoreElement = (
// @ts-ignore LEGACY type // @ts-ignore LEGACY type
// eslint-disable-next-line no-fallthrough // eslint-disable-next-line no-fallthrough
case "draw": case "draw":
const { startArrowhead = null, endArrowhead = null } = element; const startArrowhead = normalizeArrowhead(element.startArrowhead);
const endArrowhead = normalizeArrowhead(element.endArrowhead);
let x = element.x; let x = element.x;
let y = element.y; let y = element.y;
let points = // migrate old arrow model to new one let points = // migrate old arrow model to new one
@@ -458,7 +460,11 @@ export const restoreElement = (
...getSizeFromPoints(points), ...getSizeFromPoints(points),
}); });
case "arrow": { 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 x: number | undefined = element.x;
const y: number | undefined = element.y; const y: number | undefined = element.y;
const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
+9 -2
View File
@@ -53,7 +53,14 @@
"arrowhead_crowfoot_many": "Crow's foot (many)", "arrowhead_crowfoot_many": "Crow's foot (many)",
"arrowhead_crowfoot_one": "Crow's foot (one)", "arrowhead_crowfoot_one": "Crow's foot (one)",
"arrowhead_crowfoot_one_or_many": "Crow's foot (one or many)", "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", "more_options": "More options",
"cardinality": "Cardinality",
"arrowtypes": "Arrow type", "arrowtypes": "Arrow type",
"arrowtype_sharp": "Sharp arrow", "arrowtype_sharp": "Sharp arrow",
"arrowtype_round": "Curved arrow", "arrowtype_round": "Curved arrow",
@@ -624,7 +631,7 @@
"mermaid": { "mermaid": {
"title": "Mermaid to Excalidraw", "title": "Mermaid to Excalidraw",
"button": "Insert", "button": "Insert",
"description": "Currently only <flowchartLink>Flowchart</flowchartLink>,<sequenceLink> Sequence, </sequenceLink> and <classLink>Class </classLink>Diagrams are supported. The other types will be rendered as image in Excalidraw.", "description": "Currently only <flowchartLink>Flowchart</flowchartLink>, <sequenceLink>Sequence</sequenceLink>, <classLink>Class</classLink>, and <erdLink>Entity Relationship</erdLink> Diagrams are supported. The other types will be rendered as image in Excalidraw.",
"syntax": "Mermaid Syntax", "syntax": "Mermaid Syntax",
"preview": "Preview", "preview": "Preview",
"label": "Mermaid", "label": "Mermaid",
@@ -654,7 +661,7 @@
"placeholder": { "placeholder": {
"title": "Let's design your diagram", "title": "Let's design your diagram",
"description": "Describe the diagram you want to create, and we'll generate it for you.", "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", "preview": "Preview",
"insert": "Insert", "insert": "Insert",
+1 -1
View File
@@ -88,7 +88,7 @@
"@excalidraw/element": "0.18.0", "@excalidraw/element": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1", "@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/math": "0.18.0", "@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", "@excalidraw/random-username": "1.1.0",
"browser-fs-access": "0.38.0", "browser-fs-access": "0.38.0",
"canvas-roundrect-polyfill": "0.0.1", "canvas-roundrect-polyfill": "0.0.1",
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html">Flowchart</a>,<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> Sequence, </a> and <a href="https://mermaid.js.org/syntax/classDiagram.html">Class </a>Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"`; exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html" target="_blank" rel="noreferrer">Flowchart</a>, <a href="https://mermaid.js.org/syntax/sequenceDiagram.html" target="_blank" rel="noreferrer">Sequence</a>, <a href="https://mermaid.js.org/syntax/classDiagram.html" target="_blank" rel="noreferrer">Class</a>, and <a href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html" target="_blank" rel="noreferrer">Entity Relationship</a> Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"`;
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = ` exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `
"flowchart TD "flowchart TD
@@ -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", () => { it("should strip element if restore fails", () => {
const rect1 = API.createElement({ const rect1 = API.createElement({
type: "rectangle", type: "rectangle",
+4 -4
View File
@@ -1531,10 +1531,10 @@
resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb" resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
"@excalidraw/mermaid-to-excalidraw@2.0.0-rc4": "@excalidraw/mermaid-to-excalidraw@2.1.0":
version "2.0.0-rc4" version "2.1.0"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.0.0-rc4.tgz#9d38568de8e403fefb6a162efa71e024ea1f7e03" resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.1.0.tgz#a5b9cf87c3185558cda7f9687d87b9937f452358"
integrity sha512-92efu7VTYF6appGKgbDJAZEM/YXICpYPYOfl+j1E9SVilIFCJEAg7xH2lt/c44WFtffG+rWKrYwf9KUrPYy1Qw== integrity sha512-RMd+c2b7WzzUjhERMpKwp8PhF2/XlHDjr/zK+Gxfp8K9sVlafPYJ5OEa/GkN6edi2rBUXRfW+41WdO6L56b6Kw==
dependencies: dependencies:
"@excalidraw/markdown-to-text" "0.1.2" "@excalidraw/markdown-to-text" "0.1.2"
"@mermaid-js/parser" "^0.6.3" "@mermaid-js/parser" "^0.6.3"