From b9cfbc2077a89b54a6ab73c262e260da50b59bcc Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 6 Dec 2023 16:00:00 +0100 Subject: [PATCH] feat: add support for more UML arrowheads (#7391) --- src/actions/actionProperties.tsx | 143 ++++++++++---------- src/components/App.tsx | 2 + src/components/IconPicker.tsx | 27 +++- src/components/icons.tsx | 70 +++++++++- src/element/bounds.ts | 104 +++++++++++---- src/element/linearElementEditor.ts | 2 +- src/element/types.ts | 11 +- src/locales/en.json | 6 +- src/math.ts | 16 ++- src/renderer/renderElement.ts | 23 ++-- src/renderer/renderScene.ts | 28 +--- src/scene/Shape.ts | 202 +++++++++++++++++++---------- src/scene/ShapeCache.ts | 16 ++- src/scene/export.ts | 3 + src/scene/types.ts | 12 ++ 15 files changed, 449 insertions(+), 216 deletions(-) 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 // ---------------------------------------------------------------------------