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 (
{t("labels.arrowheads")}
( elements, app, (element) => isLinearElement(element) && canHaveArrowheads(element.type) - ? element.startArrowhead + ? getArrowheadForPicker(element.startArrowhead) : appState.currentItemStartArrowhead, true, (hasSelection) => hasSelection ? null : appState.currentItemStartArrowhead, )} onChange={(value) => updateData({ position: "start", type: value })} - numberOfOptionsToAlwaysShow={4} /> ( elements, app, (element) => isLinearElement(element) && canHaveArrowheads(element.type) - ? element.endArrowhead + ? getArrowheadForPicker(element.endArrowhead) : appState.currentItemEndArrowhead, true, (hasSelection) => hasSelection ? null : appState.currentItemEndArrowhead, )} onChange={(value) => updateData({ position: "end", type: value })} - numberOfOptionsToAlwaysShow={4} />
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`] = `""`; +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"