feat: add support for more UML arrowheads (#7391)
This commit is contained in:
parent
a04cc707c3
commit
b9cfbc2077
@ -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,
|
||||
|
@ -1556,6 +1556,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
imageCache: this.imageCache,
|
||||
isExporting: false,
|
||||
renderGrid: true,
|
||||
canvasBackgroundColor:
|
||||
this.state.viewBackgroundColor,
|
||||
}}
|
||||
/>
|
||||
<InteractiveCanvas
|
||||
|
@ -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}
|
||||
|
@ -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)">
|
||||
|
@ -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];
|
||||
};
|
||||
|
||||
|
@ -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]);
|
||||
|
@ -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<{
|
||||
|
@ -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\"",
|
||||
|
16
src/math.ts
16
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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
Loading…
x
Reference in New Issue
Block a user