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 { import {
ArrowheadArrowIcon, ArrowheadArrowIcon,
ArrowheadBarIcon, ArrowheadBarIcon,
ArrowheadDotIcon, ArrowheadCircleIcon,
ArrowheadTriangleIcon, ArrowheadTriangleIcon,
ArrowheadNoneIcon, ArrowheadNoneIcon,
StrokeStyleDashedIcon, StrokeStyleDashedIcon,
@ -45,6 +45,10 @@ import {
TextAlignCenterIcon, TextAlignCenterIcon,
TextAlignRightIcon, TextAlignRightIcon,
FillZigZagIcon, FillZigZagIcon,
ArrowheadTriangleOutlineIcon,
ArrowheadCircleOutlineIcon,
ArrowheadDiamondIcon,
ArrowheadDiamondOutlineIcon,
} from "../components/icons"; } from "../components/icons";
import { import {
DEFAULT_FONT_FAMILY, 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({ export const actionChangeArrowhead = register({
name: "changeArrowhead", name: "changeArrowhead",
trackEvent: false, trackEvent: false,
@ -1059,38 +1134,7 @@ export const actionChangeArrowhead = register({
<div className="iconSelectList buttonList"> <div className="iconSelectList buttonList">
<IconPicker <IconPicker
label="arrowhead_start" label="arrowhead_start"
options={[ options={getArrowheadOptions(!isRTL)}
{
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",
},
]}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
appState, appState,
@ -1106,38 +1150,7 @@ export const actionChangeArrowhead = register({
<IconPicker <IconPicker
label="arrowhead_end" label="arrowhead_end"
group="arrowheads" group="arrowheads"
options={[ options={getArrowheadOptions(!!isRTL)}
{
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",
},
]}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
appState, appState,

View File

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

View File

@ -15,7 +15,12 @@ function Picker<T>({
}: { }: {
label: string; label: string;
value: T; 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; onChange: (value: T) => void;
onClose: () => void; onClose: () => void;
}) { }) {
@ -110,9 +115,11 @@ function Picker<T>({
(event.currentTarget as HTMLButtonElement).focus(); (event.currentTarget as HTMLButtonElement).focus();
onChange(option.value); onChange(option.value);
}} }}
title={`${option.text}${option.keyBinding.toUpperCase()}`} title={`${option.text} ${
option.keyBinding && `${option.keyBinding.toUpperCase()}`
}`}
aria-label={option.text || "none"} aria-label={option.text || "none"}
aria-keyshortcuts={option.keyBinding} aria-keyshortcuts={option.keyBinding || undefined}
key={option.text} key={option.text}
ref={(el) => { ref={(el) => {
if (el && i === 0) { if (el && i === 0) {
@ -127,7 +134,9 @@ function Picker<T>({
}} }}
> >
{option.icon} {option.icon}
{option.keyBinding && (
<span className="picker-keybinding">{option.keyBinding}</span> <span className="picker-keybinding">{option.keyBinding}</span>
)}
</button> </button>
))} ))}
</div> </div>
@ -144,7 +153,13 @@ export function IconPicker<T>({
}: { }: {
label: string; label: string;
value: T; 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; onChange: (value: T) => void;
group?: string; group?: string;
}) { }) {
@ -173,7 +188,7 @@ export function IconPicker<T>({
{...(isRTL ? { right: 5.5 } : { left: -5.5 })} {...(isRTL ? { right: 5.5 } : { left: -5.5 })}
> >
<Picker <Picker
options={options} options={options.filter((opt) => opt.showInPicker !== false)}
value={value} value={value}
label={label} label={label}
onChange={onChange} 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 }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <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( export const ArrowheadBarIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( 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( export const FontSizeSmallIcon = createIcon(
<> <>
<g clipPath="url(#a)"> <g clipPath="url(#a)">

View File

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

View File

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

View File

@ -223,7 +223,16 @@ export type PointBinding = {
gap: number; 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 & export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{

View File

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

View File

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

View File

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

View File

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

View File

@ -145,6 +145,126 @@ const modifyIframeLikeForRoughOptions = (
return element; 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. * Generates the roughjs shape for given element.
* *
@ -155,7 +275,10 @@ const modifyIframeLikeForRoughOptions = (
export const _generateElementShape = ( export const _generateElementShape = (
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>, element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
generator: RoughGenerator, generator: RoughGenerator,
isExporting: boolean = false, {
isExporting,
canvasBackgroundColor,
}: { isExporting: boolean; canvasBackgroundColor: string },
): Drawable | Drawable[] | null => { ): Drawable | Drawable[] | null => {
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
@ -276,83 +399,15 @@ export const _generateElementShape = (
if (element.type === "arrow") { if (element.type === "arrow") {
const { startArrowhead = null, endArrowhead = "arrow" } = element; 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) { if (startArrowhead !== null) {
const shapes = getArrowheadShapes( const shapes = getArrowheadShapes(
element, element,
shape, shape,
"start", "start",
startArrowhead, startArrowhead,
generator,
options,
canvasBackgroundColor,
); );
shape.push(...shapes); shape.push(...shapes);
} }
@ -367,6 +422,9 @@ export const _generateElementShape = (
shape, shape,
"end", "end",
endArrowhead, endArrowhead,
generator,
options,
canvasBackgroundColor,
); );
shape.push(...shapes); shape.push(...shapes);
} }

View File

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

View File

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

View File

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