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

This commit is contained in:
David Luzar 2023-12-06 16:00:00 +01:00 committed by GitHub
parent a04cc707c3
commit b9cfbc2077
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 449 additions and 216 deletions

View File

@ -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,

View File

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

View File

@ -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}

View File

@ -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)">

View File

@ -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];
};

View File

@ -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]);

View File

@ -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<{

View File

@ -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\"",

View File

@ -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;
}

View File

@ -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,

View File

@ -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) {

View File

@ -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);
}

View File

@ -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;

View File

@ -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();

View File

@ -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
// ---------------------------------------------------------------------------