Selaa lähdekoodia

feat: add support for more UML arrowheads (#7391)

David Luzar 1 vuosi sitten
vanhempi
commit
b9cfbc2077

+ 78 - 65
src/actions/actionProperties.tsx

@@ -15,7 +15,7 @@ import { IconPicker } from "../components/IconPicker";
 import {
   ArrowheadArrowIcon,
   ArrowheadBarIcon,
-  ArrowheadDotIcon,
+  ArrowheadCircleIcon,
   ArrowheadTriangleIcon,
   ArrowheadNoneIcon,
   StrokeStyleDashedIcon,
@@ -45,6 +45,10 @@ import {
   TextAlignCenterIcon,
   TextAlignRightIcon,
   FillZigZagIcon,
+  ArrowheadTriangleOutlineIcon,
+  ArrowheadCircleOutlineIcon,
+  ArrowheadDiamondIcon,
+  ArrowheadDiamondOutlineIcon,
 } from "../components/icons";
 import {
   DEFAULT_FONT_FAMILY,
@@ -1013,6 +1017,77 @@ export const actionChangeRoundness = register({
   },
 });
 
+const getArrowheadOptions = (flip: boolean) => {
+  return [
+    {
+      value: null,
+      text: t("labels.arrowhead_none"),
+      keyBinding: "q",
+      icon: ArrowheadNoneIcon,
+    },
+    {
+      value: "arrow",
+      text: t("labels.arrowhead_arrow"),
+      keyBinding: "w",
+      icon: <ArrowheadArrowIcon flip={flip} />,
+    },
+    {
+      value: "bar",
+      text: t("labels.arrowhead_bar"),
+      keyBinding: "e",
+      icon: <ArrowheadBarIcon flip={flip} />,
+    },
+    {
+      value: "dot",
+      text: t("labels.arrowhead_circle"),
+      keyBinding: null,
+      icon: <ArrowheadCircleIcon flip={flip} />,
+      showInPicker: false,
+    },
+    {
+      value: "circle",
+      text: t("labels.arrowhead_circle"),
+      keyBinding: "r",
+      icon: <ArrowheadCircleIcon flip={flip} />,
+      showInPicker: false,
+    },
+    {
+      value: "circle_outline",
+      text: t("labels.arrowhead_circle_outline"),
+      keyBinding: null,
+      icon: <ArrowheadCircleOutlineIcon flip={flip} />,
+      showInPicker: false,
+    },
+    {
+      value: "triangle",
+      text: t("labels.arrowhead_triangle"),
+      icon: <ArrowheadTriangleIcon flip={flip} />,
+      keyBinding: "t",
+    },
+    {
+      value: "triangle_outline",
+      text: t("labels.arrowhead_triangle_outline"),
+      icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
+      keyBinding: null,
+      showInPicker: false,
+    },
+    {
+      value: "diamond",
+      text: t("labels.arrowhead_diamond"),
+      icon: <ArrowheadDiamondIcon flip={flip} />,
+      keyBinding: null,
+      showInPicker: false,
+    },
+    {
+      value: "diamond_outline",
+      text: t("labels.arrowhead_diamond_outline"),
+      icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
+      keyBinding: null,
+      showInPicker: false,
+    },
+  ] as const;
+};
+
 export const actionChangeArrowhead = register({
   name: "changeArrowhead",
   trackEvent: false,
@@ -1059,38 +1134,7 @@ export const actionChangeArrowhead = register({
         <div className="iconSelectList buttonList">
           <IconPicker
             label="arrowhead_start"
-            options={[
-              {
-                value: null,
-                text: t("labels.arrowhead_none"),
-                icon: ArrowheadNoneIcon,
-                keyBinding: "q",
-              },
-              {
-                value: "arrow",
-                text: t("labels.arrowhead_arrow"),
-                icon: <ArrowheadArrowIcon flip={!isRTL} />,
-                keyBinding: "w",
-              },
-              {
-                value: "bar",
-                text: t("labels.arrowhead_bar"),
-                icon: <ArrowheadBarIcon flip={!isRTL} />,
-                keyBinding: "e",
-              },
-              {
-                value: "dot",
-                text: t("labels.arrowhead_dot"),
-                icon: <ArrowheadDotIcon flip={!isRTL} />,
-                keyBinding: "r",
-              },
-              {
-                value: "triangle",
-                text: t("labels.arrowhead_triangle"),
-                icon: <ArrowheadTriangleIcon flip={!isRTL} />,
-                keyBinding: "t",
-              },
-            ]}
+            options={getArrowheadOptions(!isRTL)}
             value={getFormValue<Arrowhead | null>(
               elements,
               appState,
@@ -1106,38 +1150,7 @@ export const actionChangeArrowhead = register({
           <IconPicker
             label="arrowhead_end"
             group="arrowheads"
-            options={[
-              {
-                value: null,
-                text: t("labels.arrowhead_none"),
-                keyBinding: "q",
-                icon: ArrowheadNoneIcon,
-              },
-              {
-                value: "arrow",
-                text: t("labels.arrowhead_arrow"),
-                keyBinding: "w",
-                icon: <ArrowheadArrowIcon flip={isRTL} />,
-              },
-              {
-                value: "bar",
-                text: t("labels.arrowhead_bar"),
-                keyBinding: "e",
-                icon: <ArrowheadBarIcon flip={isRTL} />,
-              },
-              {
-                value: "dot",
-                text: t("labels.arrowhead_dot"),
-                keyBinding: "r",
-                icon: <ArrowheadDotIcon flip={isRTL} />,
-              },
-              {
-                value: "triangle",
-                text: t("labels.arrowhead_triangle"),
-                icon: <ArrowheadTriangleIcon flip={isRTL} />,
-                keyBinding: "t",
-              },
-            ]}
+            options={getArrowheadOptions(!!isRTL)}
             value={getFormValue<Arrowhead | null>(
               elements,
               appState,

+ 2 - 0
src/components/App.tsx

@@ -1556,6 +1556,8 @@ class App extends React.Component<AppProps, AppState> {
                             imageCache: this.imageCache,
                             isExporting: false,
                             renderGrid: true,
+                            canvasBackgroundColor:
+                              this.state.viewBackgroundColor,
                           }}
                         />
                         <InteractiveCanvas

+ 21 - 6
src/components/IconPicker.tsx

@@ -15,7 +15,12 @@ function Picker<T>({
 }: {
   label: string;
   value: T;
-  options: { value: T; text: string; icon: JSX.Element; keyBinding: string }[];
+  options: {
+    value: T;
+    text: string;
+    icon: JSX.Element;
+    keyBinding: string | null;
+  }[];
   onChange: (value: T) => void;
   onClose: () => void;
 }) {
@@ -110,9 +115,11 @@ function Picker<T>({
               (event.currentTarget as HTMLButtonElement).focus();
               onChange(option.value);
             }}
-            title={`${option.text} — ${option.keyBinding.toUpperCase()}`}
+            title={`${option.text} ${
+              option.keyBinding && `— ${option.keyBinding.toUpperCase()}`
+            }`}
             aria-label={option.text || "none"}
-            aria-keyshortcuts={option.keyBinding}
+            aria-keyshortcuts={option.keyBinding || undefined}
             key={option.text}
             ref={(el) => {
               if (el && i === 0) {
@@ -127,7 +134,9 @@ function Picker<T>({
             }}
           >
             {option.icon}
-            <span className="picker-keybinding">{option.keyBinding}</span>
+            {option.keyBinding && (
+              <span className="picker-keybinding">{option.keyBinding}</span>
+            )}
           </button>
         ))}
       </div>
@@ -144,7 +153,13 @@ export function IconPicker<T>({
 }: {
   label: string;
   value: T;
-  options: { value: T; text: string; icon: JSX.Element; keyBinding: string }[];
+  options: readonly {
+    value: T;
+    text: string;
+    icon: JSX.Element;
+    keyBinding: string | null;
+    showInPicker?: boolean;
+  }[];
   onChange: (value: T) => void;
   group?: string;
 }) {
@@ -173,7 +188,7 @@ export function IconPicker<T>({
               {...(isRTL ? { right: 5.5 } : { left: -5.5 })}
             >
               <Picker
-                options={options}
+                options={options.filter((opt) => opt.showInPicker !== false)}
                 value={value}
                 label={label}
                 onChange={onChange}

+ 69 - 1
src/components/icons.tsx

@@ -1281,7 +1281,7 @@ export const ArrowheadArrowIcon = React.memo(
     ),
 );
 
-export const ArrowheadDotIcon = React.memo(
+export const ArrowheadCircleIcon = React.memo(
   ({ flip = false }: { flip?: boolean }) =>
     createIcon(
       <g
@@ -1296,6 +1296,22 @@ export const ArrowheadDotIcon = React.memo(
     ),
 );
 
+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(
@@ -1326,6 +1342,58 @@ export const ArrowheadTriangleIcon = React.memo(
     ),
 );
 
+export const ArrowheadTriangleOutlineIcon = React.memo(
+  ({ flip = false }: { flip?: boolean }) =>
+    createIcon(
+      <g
+        stroke="currentColor"
+        fill="none"
+        transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
+        strokeWidth={2}
+        strokeLinejoin="round"
+      >
+        <path d="M6,9.5H27" />
+        <path d="M27,5L34,10L27,14Z" fill="none" />
+      </g>,
+
+      { width: 40, height: 20 },
+    ),
+);
+
+export const ArrowheadDiamondIcon = React.memo(
+  ({ flip = false }: { flip?: boolean }) =>
+    createIcon(
+      <g
+        stroke="currentColor"
+        fill="currentColor"
+        transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
+        strokeLinejoin="round"
+        strokeWidth={2}
+      >
+        <path d="M6,9.5H20" />
+        <path d="M27,5L34,10L27,14L20,9.5Z" />
+      </g>,
+      { width: 40, height: 20 },
+    ),
+);
+
+export const ArrowheadDiamondOutlineIcon = 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="M6,9.5H20" />
+        <path d="M27,5L34,10L27,14L20,9.5Z" />
+      </g>,
+      { width: 40, height: 20 },
+    ),
+);
+
 export const FontSizeSmallIcon = createIcon(
   <>
     <g clipPath="url(#a)">

+ 79 - 25
src/element/bounds.ts

@@ -484,6 +484,31 @@ const getFreeDrawElementAbsoluteCoords = (
   return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
 };
 
+/** @returns number in pixels */
+export const getArrowheadSize = (arrowhead: Arrowhead): number => {
+  switch (arrowhead) {
+    case "arrow":
+      return 25;
+    case "diamond":
+    case "diamond_outline":
+      return 12;
+    default:
+      return 15;
+  }
+};
+
+/** @returns number in degrees */
+export const getArrowheadAngle = (arrowhead: Arrowhead): number => {
+  switch (arrowhead) {
+    case "bar":
+      return 90;
+    case "arrow":
+      return 20;
+    default:
+      return 25;
+  }
+};
+
 export const getArrowheadPoints = (
   element: ExcalidrawLinearElement,
   shape: Drawable[],
@@ -536,53 +561,82 @@ export const getArrowheadPoints = (
   const nx = (x2 - x1) / distance;
   const ny = (y2 - y1) / distance;
 
-  const size = {
-    arrow: 30,
-    bar: 15,
-    dot: 15,
-    triangle: 15,
-  }[arrowhead]; // pixels (will differ for each arrowhead)
+  const size = getArrowheadSize(arrowhead);
 
   let length = 0;
 
-  if (arrowhead === "arrow") {
+  {
     // Length for -> arrows is based on the length of the last section
-    const [cx, cy] = element.points[element.points.length - 1];
+    const [cx, cy] =
+      position === "end"
+        ? element.points[element.points.length - 1]
+        : element.points[0];
     const [px, py] =
       element.points.length > 1
-        ? element.points[element.points.length - 2]
+        ? position === "end"
+          ? element.points[element.points.length - 2]
+          : element.points[1]
         : [0, 0];
 
     length = Math.hypot(cx - px, cy - py);
-  } else {
-    // Length for other arrowhead types is based on the total length of the line
-    for (let i = 0; i < element.points.length; i++) {
-      const [px, py] = element.points[i - 1] || [0, 0];
-      const [cx, cy] = element.points[i];
-      length += Math.hypot(cx - px, cy - py);
-    }
   }
 
   // Scale down the arrowhead until we hit a certain size so that it doesn't look weird.
   // This value is selected by minimizing a minimum size with the last segment of the arrowhead
-  const minSize = Math.min(size, length / 2);
+  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;
 
-  if (arrowhead === "dot") {
-    const r = Math.hypot(ys - y2, xs - x2) + element.strokeWidth;
-    return [x2, y2, r];
+  if (
+    arrowhead === "dot" ||
+    arrowhead === "circle" ||
+    arrowhead === "circle_outline"
+  ) {
+    const diameter = Math.hypot(ys - y2, xs - x2) + element.strokeWidth - 2;
+    return [x2, y2, diameter];
   }
 
-  const angle = {
-    arrow: 20,
-    bar: 90,
-    triangle: 25,
-  }[arrowhead]; // degrees
+  const angle = getArrowheadAngle(arrowhead);
 
   // Return points
   const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);
   const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);
+
+  if (arrowhead === "diamond" || arrowhead === "diamond_outline") {
+    // point opposite to the arrowhead point
+    let ox;
+    let oy;
+
+    if (position === "start") {
+      const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
+
+      [ox, oy] = rotate(
+        x2 + minSize * 2,
+        y2,
+        x2,
+        y2,
+        Math.atan2(py - y2, px - x2),
+      );
+    } else {
+      const [px, py] =
+        element.points.length > 1
+          ? element.points[element.points.length - 2]
+          : [0, 0];
+
+      [ox, oy] = rotate(
+        x2 - minSize * 2,
+        y2,
+        x2,
+        y2,
+        Math.atan2(y2 - py, x2 - px),
+      );
+    }
+
+    return [x2, y2, x3, y3, ox, oy, x4, y4];
+  }
+
   return [x2, y2, x3, y3, x4, y4];
 };
 

+ 1 - 1
src/element/linearElementEditor.ts

@@ -1444,7 +1444,7 @@ export class LinearElementEditor {
       x2 = maxX + element.x;
       y2 = maxY + element.y;
     } else {
-      const shape = ShapeCache.generateElementShape(element);
+      const shape = ShapeCache.generateElementShape(element, null);
 
       // first element is always the curve
       const ops = getCurvePathOps(shape[0]);

+ 10 - 1
src/element/types.ts

@@ -223,7 +223,16 @@ export type PointBinding = {
   gap: number;
 };
 
-export type Arrowhead = "arrow" | "bar" | "dot" | "triangle";
+export type Arrowhead =
+  | "arrow"
+  | "bar"
+  | "dot" // legacy. Do not use for new elements.
+  | "circle"
+  | "circle_outline"
+  | "triangle"
+  | "triangle_outline"
+  | "diamond"
+  | "diamond_outline";
 
 export type ExcalidrawLinearElement = _ExcalidrawElementBase &
   Readonly<{

+ 5 - 1
src/locales/en.json

@@ -38,8 +38,12 @@
     "arrowhead_none": "None",
     "arrowhead_arrow": "Arrow",
     "arrowhead_bar": "Bar",
-    "arrowhead_dot": "Dot",
+    "arrowhead_circle": "Circle",
+    "arrowhead_circle_outline": "Circle (outline)",
     "arrowhead_triangle": "Triangle",
+    "arrowhead_triangle_outline": "Triangle (outline)",
+    "arrowhead_diamond": "Diamond",
+    "arrowhead_diamond_outline": "Diamond (outline)",
     "fontSize": "Font size",
     "fontFamily": "Font family",
     "addWatermark": "Add \"Made with Excalidraw\"",

+ 9 - 7
src/math.ts

@@ -15,18 +15,20 @@ import { Mutable } from "./utility-types";
 import { ShapeCache } from "./scene/ShapeCache";
 
 export const rotate = (
-  x1: number,
-  y1: number,
-  x2: number,
-  y2: number,
+  // target point to rotate
+  x: number,
+  y: number,
+  // point to rotate against
+  cx: number,
+  cy: number,
   angle: number,
 ): [number, number] =>
   // 𝑎′𝑥=(𝑎𝑥−𝑐𝑥)cos𝜃−(𝑎𝑦−𝑐𝑦)sin𝜃+𝑐𝑥
   // 𝑎′𝑦=(𝑎𝑥−𝑐𝑥)sin𝜃+(𝑎𝑦−𝑐𝑦)cos𝜃+𝑐𝑦.
   // https://math.stackexchange.com/questions/2204520/how-do-i-rotate-a-line-segment-in-a-specific-point-on-the-line
   [
-    (x1 - x2) * Math.cos(angle) - (y1 - y2) * Math.sin(angle) + x2,
-    (x1 - x2) * Math.sin(angle) + (y1 - y2) * Math.cos(angle) + y2,
+    (x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx,
+    (x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy,
   ];
 
 export const rotatePoint = (
@@ -303,7 +305,7 @@ export const getControlPointsForBezierCurve = (
   element: NonDeleted<ExcalidrawLinearElement>,
   endPoint: Point,
 ) => {
-  const shape = ShapeCache.generateElementShape(element);
+  const shape = ShapeCache.generateElementShape(element, null);
   if (!shape) {
     return null;
   }

+ 11 - 12
src/renderer/renderElement.ts

@@ -20,7 +20,7 @@ import type { RoughCanvas } from "roughjs/bin/canvas";
 import type { Drawable } from "roughjs/bin/core";
 import type { RoughSVG } from "roughjs/bin/svg";
 
-import { StaticCanvasRenderConfig } from "../scene/types";
+import { SVGRenderConfig, StaticCanvasRenderConfig } from "../scene/types";
 import {
   distance,
   getFontString,
@@ -638,7 +638,7 @@ export const renderElement = (
       // TODO investigate if we can do this in situ. Right now we need to call
       // beforehand because math helpers (such as getElementAbsoluteCoords)
       // rely on existing shapes
-      ShapeCache.generateElementShape(element);
+      ShapeCache.generateElementShape(element, null);
 
       if (renderConfig.isExporting) {
         const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
@@ -680,7 +680,7 @@ export const renderElement = (
       // TODO investigate if we can do this in situ. Right now we need to call
       // beforehand because math helpers (such as getElementAbsoluteCoords)
       // rely on existing shapes
-      ShapeCache.generateElementShape(element, renderConfig.isExporting);
+      ShapeCache.generateElementShape(element, renderConfig);
       if (renderConfig.isExporting) {
         const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
         const cx = (x1 + x2) / 2 + appState.scrollX;
@@ -876,11 +876,7 @@ export const renderElementToSvg = (
   files: BinaryFiles,
   offsetX: number,
   offsetY: number,
-  renderConfig: {
-    exportWithDarkMode: boolean;
-    renderEmbeddables: boolean;
-    frameRendering: AppState["frameRendering"];
-  },
+  renderConfig: SVGRenderConfig,
 ) => {
   const offset = { x: offsetX, y: offsetY };
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
@@ -933,7 +929,7 @@ export const renderElementToSvg = (
     case "rectangle":
     case "diamond":
     case "ellipse": {
-      const shape = ShapeCache.generateElementShape(element);
+      const shape = ShapeCache.generateElementShape(element, null);
       const node = roughSVGDrawWithPrecision(
         rsvg,
         shape,
@@ -964,7 +960,7 @@ export const renderElementToSvg = (
     case "iframe":
     case "embeddable": {
       // render placeholder rectangle
-      const shape = ShapeCache.generateElementShape(element, true);
+      const shape = ShapeCache.generateElementShape(element, renderConfig);
       const node = roughSVGDrawWithPrecision(
         rsvg,
         shape,
@@ -1113,7 +1109,7 @@ export const renderElementToSvg = (
       }
       group.setAttribute("stroke-linecap", "round");
 
-      const shapes = ShapeCache.generateElementShape(element);
+      const shapes = ShapeCache.generateElementShape(element, renderConfig);
       shapes.forEach((shape) => {
         const node = roughSVGDrawWithPrecision(
           rsvg,
@@ -1156,7 +1152,10 @@ export const renderElementToSvg = (
       break;
     }
     case "freedraw": {
-      const backgroundFillShape = ShapeCache.generateElementShape(element);
+      const backgroundFillShape = ShapeCache.generateElementShape(
+        element,
+        renderConfig,
+      );
       const node = backgroundFillShape
         ? roughSVGDrawWithPrecision(
             rsvg,

+ 6 - 22
src/renderer/renderScene.ts

@@ -30,6 +30,7 @@ import { roundRect } from "./roundRect";
 import {
   InteractiveCanvasRenderConfig,
   InteractiveSceneRenderConfig,
+  SVGRenderConfig,
   StaticCanvasRenderConfig,
   StaticSceneRenderConfig,
 } from "../scene/types";
@@ -1448,29 +1449,12 @@ export const renderSceneToSvg = (
   rsvg: RoughSVG,
   svgRoot: SVGElement,
   files: BinaryFiles,
-  {
-    offsetX = 0,
-    offsetY = 0,
-    exportWithDarkMode,
-    renderEmbeddables,
-    frameRendering,
-  }: {
-    offsetX?: number;
-    offsetY?: number;
-    exportWithDarkMode: boolean;
-    renderEmbeddables: boolean;
-    frameRendering: AppState["frameRendering"];
-  },
+  renderConfig: SVGRenderConfig,
 ) => {
   if (!svgRoot) {
     return;
   }
 
-  const renderConfig = {
-    exportWithDarkMode,
-    renderEmbeddables,
-    frameRendering,
-  };
   // render elements
   elements
     .filter((el) => !isIframeLikeOrItsLabel(el))
@@ -1482,8 +1466,8 @@ export const renderSceneToSvg = (
             rsvg,
             svgRoot,
             files,
-            element.x + offsetX,
-            element.y + offsetY,
+            element.x + renderConfig.offsetX,
+            element.y + renderConfig.offsetY,
             renderConfig,
           );
         } catch (error: any) {
@@ -1503,8 +1487,8 @@ export const renderSceneToSvg = (
             rsvg,
             svgRoot,
             files,
-            element.x + offsetX,
-            element.y + offsetY,
+            element.x + renderConfig.offsetX,
+            element.y + renderConfig.offsetY,
             renderConfig,
           );
         } catch (error: any) {

+ 130 - 72
src/scene/Shape.ts

@@ -145,6 +145,126 @@ const modifyIframeLikeForRoughOptions = (
   return element;
 };
 
+const getArrowheadShapes = (
+  element: ExcalidrawLinearElement,
+  shape: Drawable[],
+  position: "start" | "end",
+  arrowhead: Arrowhead,
+  generator: RoughGenerator,
+  options: Options,
+  canvasBackgroundColor: string,
+) => {
+  const arrowheadPoints = getArrowheadPoints(
+    element,
+    shape,
+    position,
+    arrowhead,
+  );
+
+  if (arrowheadPoints === null) {
+    return [];
+  }
+
+  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
+              : element.strokeColor,
+
+          fillStyle: "solid",
+          stroke: element.strokeColor,
+          roughness: Math.min(0.5, options.roughness || 0),
+        }),
+      ];
+    }
+    case "triangle":
+    case "triangle_outline": {
+      const [x, y, x2, y2, x3, y3] = arrowheadPoints;
+
+      // always use solid stroke for arrowhead
+      delete options.strokeLineDash;
+
+      return [
+        generator.polygon(
+          [
+            [x, y],
+            [x2, y2],
+            [x3, y3],
+            [x, y],
+          ],
+          {
+            ...options,
+            fill:
+              arrowhead === "triangle_outline"
+                ? canvasBackgroundColor
+                : element.strokeColor,
+            fillStyle: "solid",
+            roughness: Math.min(1, options.roughness || 0),
+          },
+        ),
+      ];
+    }
+    case "diamond":
+    case "diamond_outline": {
+      const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints;
+
+      // always use solid stroke for arrowhead
+      delete options.strokeLineDash;
+
+      return [
+        generator.polygon(
+          [
+            [x, y],
+            [x2, y2],
+            [x3, y3],
+            [x4, y4],
+            [x, y],
+          ],
+          {
+            ...options,
+            fill:
+              arrowhead === "diamond_outline"
+                ? canvasBackgroundColor
+                : element.strokeColor,
+            fillStyle: "solid",
+            roughness: Math.min(1, options.roughness || 0),
+          },
+        ),
+      ];
+    }
+    case "bar":
+    case "arrow":
+    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),
+      ];
+    }
+  }
+};
+
 /**
  * Generates the roughjs shape for given element.
  *
@@ -155,7 +275,10 @@ const modifyIframeLikeForRoughOptions = (
 export const _generateElementShape = (
   element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
   generator: RoughGenerator,
-  isExporting: boolean = false,
+  {
+    isExporting,
+    canvasBackgroundColor,
+  }: { isExporting: boolean; canvasBackgroundColor: string },
 ): Drawable | Drawable[] | null => {
   switch (element.type) {
     case "rectangle":
@@ -276,83 +399,15 @@ export const _generateElementShape = (
       if (element.type === "arrow") {
         const { startArrowhead = null, endArrowhead = "arrow" } = element;
 
-        const getArrowheadShapes = (
-          element: ExcalidrawLinearElement,
-          shape: Drawable[],
-          position: "start" | "end",
-          arrowhead: Arrowhead,
-        ) => {
-          const arrowheadPoints = getArrowheadPoints(
-            element,
-            shape,
-            position,
-            arrowhead,
-          );
-
-          if (arrowheadPoints === null) {
-            return [];
-          }
-
-          // Other arrowheads here...
-          if (arrowhead === "dot") {
-            const [x, y, r] = arrowheadPoints;
-
-            return [
-              generator.circle(x, y, r, {
-                ...options,
-                fill: element.strokeColor,
-                fillStyle: "solid",
-                stroke: "none",
-              }),
-            ];
-          }
-
-          if (arrowhead === "triangle") {
-            const [x, y, x2, y2, x3, y3] = arrowheadPoints;
-
-            // always use solid stroke for triangle arrowhead
-            delete options.strokeLineDash;
-
-            return [
-              generator.polygon(
-                [
-                  [x, y],
-                  [x2, y2],
-                  [x3, y3],
-                  [x, y],
-                ],
-                {
-                  ...options,
-                  fill: element.strokeColor,
-                  fillStyle: "solid",
-                },
-              ),
-            ];
-          }
-
-          // Arrow arrowheads
-          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;
-          }
-          return [
-            generator.line(x3, y3, x2, y2, options),
-            generator.line(x4, y4, x2, y2, options),
-          ];
-        };
-
         if (startArrowhead !== null) {
           const shapes = getArrowheadShapes(
             element,
             shape,
             "start",
             startArrowhead,
+            generator,
+            options,
+            canvasBackgroundColor,
           );
           shape.push(...shapes);
         }
@@ -367,6 +422,9 @@ export const _generateElementShape = (
             shape,
             "end",
             endArrowhead,
+            generator,
+            options,
+            canvasBackgroundColor,
           );
           shape.push(...shapes);
         }

+ 13 - 3
src/scene/ShapeCache.ts

@@ -7,6 +7,8 @@ import {
 import { elementWithCanvasCache } from "../renderer/renderElement";
 import { _generateElementShape } from "./Shape";
 import { ElementShape, ElementShapes } from "./types";
+import { COLOR_PALETTE } from "../colors";
+import { AppState } from "../types";
 
 export class ShapeCache {
   private static rg = new RoughGenerator();
@@ -46,10 +48,15 @@ export class ShapeCache {
     T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
   >(
     element: T,
-    isExporting = false,
+    renderConfig: {
+      isExporting: boolean;
+      canvasBackgroundColor: AppState["viewBackgroundColor"];
+    } | null,
   ) => {
     // when exporting, always regenerated to guarantee the latest shape
-    const cachedShape = isExporting ? undefined : ShapeCache.get(element);
+    const cachedShape = renderConfig?.isExporting
+      ? undefined
+      : ShapeCache.get(element);
 
     // `null` indicates no rc shape applicable for this element type,
     // but it's considered a valid cache value (= do not regenerate)
@@ -62,7 +69,10 @@ export class ShapeCache {
     const shape = _generateElementShape(
       element,
       ShapeCache.rg,
-      isExporting,
+      renderConfig || {
+        isExporting: false,
+        canvasBackgroundColor: COLOR_PALETTE.white,
+      },
     ) as T["type"] extends keyof ElementShapes
       ? ElementShapes[T["type"]]
       : Drawable | null;

+ 3 - 0
src/scene/export.ts

@@ -262,6 +262,7 @@ export const exportToCanvas = async (
       theme: appState.exportWithDarkMode ? "dark" : "light",
     },
     renderConfig: {
+      canvasBackgroundColor: viewBackgroundColor,
       imageCache,
       renderGrid: false,
       isExporting: true,
@@ -429,9 +430,11 @@ export const exportToSvg = async (
   renderSceneToSvg(elementsForRender, rsvg, svgRoot, files || {}, {
     offsetX,
     offsetY,
+    isExporting: true,
     exportWithDarkMode,
     renderEmbeddables: opts?.renderEmbeddables ?? false,
     frameRendering,
+    canvasBackgroundColor: viewBackgroundColor,
   });
 
   tempScene.destroy();

+ 12 - 0
src/scene/types.ts

@@ -6,11 +6,13 @@ import {
 } from "../element/types";
 import {
   AppClassProperties,
+  AppState,
   InteractiveCanvasAppState,
   StaticCanvasAppState,
 } from "../types";
 
 export type StaticCanvasRenderConfig = {
+  canvasBackgroundColor: AppState["viewBackgroundColor"];
   // extra options passed to the renderer
   // ---------------------------------------------------------------------------
   imageCache: AppClassProperties["imageCache"];
@@ -20,6 +22,16 @@ export type StaticCanvasRenderConfig = {
   isExporting: boolean;
 };
 
+export type SVGRenderConfig = {
+  offsetX: number;
+  offsetY: number;
+  isExporting: boolean;
+  exportWithDarkMode: boolean;
+  renderEmbeddables: boolean;
+  frameRendering: AppState["frameRendering"];
+  canvasBackgroundColor: AppState["viewBackgroundColor"];
+};
+
 export type InteractiveCanvasRenderConfig = {
   // collab-related state
   // ---------------------------------------------------------------------------