diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx
index d17a87e3..ea4bb071 100644
--- a/src/actions/actionProperties.tsx
+++ b/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: ,
+ },
+ {
+ value: "bar",
+ text: t("labels.arrowhead_bar"),
+ keyBinding: "e",
+ icon: ,
+ },
+ {
+ value: "dot",
+ text: t("labels.arrowhead_circle"),
+ keyBinding: null,
+ icon: ,
+ showInPicker: false,
+ },
+ {
+ value: "circle",
+ text: t("labels.arrowhead_circle"),
+ keyBinding: "r",
+ icon: ,
+ showInPicker: false,
+ },
+ {
+ value: "circle_outline",
+ text: t("labels.arrowhead_circle_outline"),
+ keyBinding: null,
+ icon: ,
+ showInPicker: false,
+ },
+ {
+ value: "triangle",
+ text: t("labels.arrowhead_triangle"),
+ icon: ,
+ keyBinding: "t",
+ },
+ {
+ value: "triangle_outline",
+ text: t("labels.arrowhead_triangle_outline"),
+ icon: ,
+ keyBinding: null,
+ showInPicker: false,
+ },
+ {
+ value: "diamond",
+ text: t("labels.arrowhead_diamond"),
+ icon: ,
+ keyBinding: null,
+ showInPicker: false,
+ },
+ {
+ value: "diamond_outline",
+ text: t("labels.arrowhead_diamond_outline"),
+ icon: ,
+ keyBinding: null,
+ showInPicker: false,
+ },
+ ] as const;
+};
+
export const actionChangeArrowhead = register({
name: "changeArrowhead",
trackEvent: false,
@@ -1059,38 +1134,7 @@ export const actionChangeArrowhead = register({
,
- keyBinding: "w",
- },
- {
- value: "bar",
- text: t("labels.arrowhead_bar"),
- icon:
,
- keyBinding: "e",
- },
- {
- value: "dot",
- text: t("labels.arrowhead_dot"),
- icon:
,
- keyBinding: "r",
- },
- {
- value: "triangle",
- text: t("labels.arrowhead_triangle"),
- icon:
,
- keyBinding: "t",
- },
- ]}
+ options={getArrowheadOptions(!isRTL)}
value={getFormValue
(
elements,
appState,
@@ -1106,38 +1150,7 @@ export const actionChangeArrowhead = register({
,
- },
- {
- value: "bar",
- text: t("labels.arrowhead_bar"),
- keyBinding: "e",
- icon: ,
- },
- {
- value: "dot",
- text: t("labels.arrowhead_dot"),
- keyBinding: "r",
- icon: ,
- },
- {
- value: "triangle",
- text: t("labels.arrowhead_triangle"),
- icon: ,
- keyBinding: "t",
- },
- ]}
+ options={getArrowheadOptions(!!isRTL)}
value={getFormValue(
elements,
appState,
diff --git a/src/components/App.tsx b/src/components/App.tsx
index 2c3f7a6e..13296472 100644
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -1556,6 +1556,8 @@ class App extends React.Component {
imageCache: this.imageCache,
isExporting: false,
renderGrid: true,
+ canvasBackgroundColor:
+ this.state.viewBackgroundColor,
}}
/>
({
}: {
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({
(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({
}}
>
{option.icon}
- {option.keyBinding}
+ {option.keyBinding && (
+ {option.keyBinding}
+ )}
))}
@@ -144,7 +153,13 @@ export function IconPicker({
}: {
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({
{...(isRTL ? { right: 5.5 } : { left: -5.5 })}
>
opt.showInPicker !== false)}
value={value}
label={label}
onChange={onChange}
diff --git a/src/components/icons.tsx b/src/components/icons.tsx
index 3449ccda..58664e16 100644
--- a/src/components/icons.tsx
+++ b/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(
+ createIcon(
+
+
+
+ ,
+ { 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(
+
+
+
+ ,
+
+ { width: 40, height: 20 },
+ ),
+);
+
+export const ArrowheadDiamondIcon = React.memo(
+ ({ flip = false }: { flip?: boolean }) =>
+ createIcon(
+
+
+
+ ,
+ { width: 40, height: 20 },
+ ),
+);
+
+export const ArrowheadDiamondOutlineIcon = React.memo(
+ ({ flip = false }: { flip?: boolean }) =>
+ createIcon(
+
+
+
+ ,
+ { width: 40, height: 20 },
+ ),
+);
+
export const FontSizeSmallIcon = createIcon(
<>
diff --git a/src/element/bounds.ts b/src/element/bounds.ts
index b0d33cfc..f8d8223f 100644
--- a/src/element/bounds.ts
+++ b/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];
};
diff --git a/src/element/linearElementEditor.ts b/src/element/linearElementEditor.ts
index 9ee490b3..bf64ee73 100644
--- a/src/element/linearElementEditor.ts
+++ b/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]);
diff --git a/src/element/types.ts b/src/element/types.ts
index b863fb42..38be1bda 100644
--- a/src/element/types.ts
+++ b/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<{
diff --git a/src/locales/en.json b/src/locales/en.json
index 8b4a1df2..95c6eb2e 100644
--- a/src/locales/en.json
+++ b/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\"",
diff --git a/src/math.ts b/src/math.ts
index a56b97a7..8c0fb0eb 100644
--- a/src/math.ts
+++ b/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,
endPoint: Point,
) => {
- const shape = ShapeCache.generateElementShape(element);
+ const shape = ShapeCache.generateElementShape(element, null);
if (!shape) {
return null;
}
diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts
index 24eaf64b..2617d469 100644
--- a/src/renderer/renderElement.ts
+++ b/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,
diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts
index 671e90d0..c41d59bd 100644
--- a/src/renderer/renderScene.ts
+++ b/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) {
diff --git a/src/scene/Shape.ts b/src/scene/Shape.ts
index 4d928e94..190f7562 100644
--- a/src/scene/Shape.ts
+++ b/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,
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);
}
diff --git a/src/scene/ShapeCache.ts b/src/scene/ShapeCache.ts
index ded1b88f..e5a08c1f 100644
--- a/src/scene/ShapeCache.ts
+++ b/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,
>(
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;
diff --git a/src/scene/export.ts b/src/scene/export.ts
index 54ede380..f2074826 100644
--- a/src/scene/export.ts
+++ b/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();
diff --git a/src/scene/types.ts b/src/scene/types.ts
index dc709a22..b4320866 100644
--- a/src/scene/types.ts
+++ b/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
// ---------------------------------------------------------------------------