Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/VISUAL-REGRESSION-TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ The test runner automatically creates a dark mode variant for any fixture named
| `opacity` | Elements at 100%, 60%, 30% opacity; semi-transparent text |
| `all-fonts` | All 7 supported font families (Excalifont, Nunito, Lilita One, Comic Shanns, Virgil, Cascadia, Liberation Sans) |
| `colored-arrows` | Elbow/curved/straight arrows with non-transparent backgroundColor; verifies arrow paths are not filled |
| `elbow-arrows` | Elbow arrows (elbowed: true) with L/Z/U shapes rendered as straight segments with rounded corners; includes regular curved arrow for regression |
| `combine-horizontal` | Combine command: horizontal layout of basic-shapes + arrows-lines |
| `combine-vertical` | Combine command: vertical layout of basic-shapes + arrows-lines |
| `combine-labels` | Combine command: horizontal layout with --labels flag |
Expand Down
7 changes: 7 additions & 0 deletions src/export-svg/renderers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
escapeXml,
FONT_FAMILY,
FRAME_STYLE,
generateElbowArrowShape,
getCornerRadius,
getRoughOptions,
isPathALoop,
Expand Down Expand Up @@ -119,6 +120,12 @@ function svgLine(
([px, py]) => [x + px, y + py] as [number, number],
);

if (element.elbowed) {
const pathStr = generateElbowArrowShape(transformed, 16);
const drawable = gen.path(pathStr, options);
return svgPathsToMarkup(gen.toPaths(drawable), options.strokeLineDash);
}

const drawable =
transformed.length === 2
? gen.line(
Expand Down
7 changes: 7 additions & 0 deletions src/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type ColorTransform,
FONT_FAMILY,
FRAME_STYLE,
generateElbowArrowShape,
getCornerRadius,
getRoughOptions,
isPathALoop,
Expand Down Expand Up @@ -125,6 +126,12 @@ function renderLine(
([px, py]) => [x + px, y + py] as [number, number],
);

if (element.elbowed) {
const pathStr = generateElbowArrowShape(transformedPoints, 16);
rc.path(pathStr, options);
return;
}

if (transformedPoints.length === 2) {
rc.line(
transformedPoints[0][0],
Expand Down
74 changes: 74 additions & 0 deletions src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,80 @@ export function prepareExport(
};
}

// ---------------------------------------------------------------------------
// Elbow arrow shape generation
// ---------------------------------------------------------------------------
// Ported from excalidraw/packages/element/src/shape.ts
// Generates SVG path for elbow arrows with straight segments and small rounded corners.

function pointDistance(a: [number, number], b: [number, number]): number {
return Math.hypot(a[0] - b[0], a[1] - b[1]);
}

export function generateElbowArrowShape(
points: [number, number][],
radius: number,
): string {
const subpoints: [number, number][] = [];

for (let i = 1; i < points.length - 1; i++) {
const prev = points[i - 1];
const next = points[i + 1];
const point = points[i];

const prevIsHorizontal =
Math.abs(point[1] - prev[1]) < Math.abs(point[0] - prev[0]);
const nextIsHorizontal =
Math.abs(next[1] - point[1]) < Math.abs(next[0] - point[0]);

const corner = Math.min(
radius,
pointDistance(point, next) / 2,
pointDistance(point, prev) / 2,
);

// Approach subpoint (coming from prev)
if (prevIsHorizontal) {
subpoints.push([
point[0] + (prev[0] < point[0] ? -corner : corner),
point[1],
]);
} else {
subpoints.push([
point[0],
point[1] + (prev[1] < point[1] ? -corner : corner),
]);
}

// Corner control point
subpoints.push([point[0], point[1]]);

// Departure subpoint (going to next)
if (nextIsHorizontal) {
subpoints.push([
point[0] + (next[0] < point[0] ? -corner : corner),
point[1],
]);
} else {
subpoints.push([
point[0],
point[1] + (next[1] < point[1] ? -corner : corner),
]);
}
}

const d = [`M ${points[0][0]} ${points[0][1]}`];
for (let i = 0; i < subpoints.length; i += 3) {
d.push(`L ${subpoints[i][0]} ${subpoints[i][1]}`);
d.push(
`Q ${subpoints[i + 1][0]} ${subpoints[i + 1][1]}, ${subpoints[i + 2][0]} ${subpoints[i + 2][1]}`,
);
}
d.push(`L ${points[points.length - 1][0]} ${points[points.length - 1][1]}`);

return d.join(" ");
}

/** Escape XML special characters for SVG attribute values */
export function escapeXml(str: string): string {
return str
Expand Down
Binary file added tests/visual/baselines/elbow-arrows--svg.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/visual/baselines/elbow-arrows.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
93 changes: 93 additions & 0 deletions tests/visual/fixtures/elbow-arrows.excalidraw
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
{
"type": "excalidraw",
"version": 2,
"source": "visual-test",
"elements": [
{
"id": "elbow-L",
"type": "arrow",
"x": 50,
"y": 50,
"width": 200,
"height": 150,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "hachure",
"strokeWidth": 2,
"roughness": 1,
"opacity": 100,
"seed": 9001,
"angle": 0,
"elbowed": true,
"points": [[0, 0], [200, 0], [200, 150]],
"startArrowhead": null,
"endArrowhead": "arrow",
"isDeleted": false
},
{
"id": "elbow-Z",
"type": "arrow",
"x": 50,
"y": 280,
"width": 300,
"height": 100,
"strokeColor": "#e03131",
"backgroundColor": "transparent",
"fillStyle": "hachure",
"strokeWidth": 2,
"roughness": 1,
"opacity": 100,
"seed": 9002,
"angle": 0,
"elbowed": true,
"points": [[0, 0], [100, 0], [100, 100], [300, 100]],
"startArrowhead": "arrow",
"endArrowhead": "arrow",
"isDeleted": false
},
{
"id": "elbow-U",
"type": "arrow",
"x": 50,
"y": 450,
"width": 200,
"height": 120,
"strokeColor": "#1971c2",
"backgroundColor": "transparent",
"fillStyle": "hachure",
"strokeWidth": 4,
"roughness": 1,
"opacity": 100,
"seed": 9003,
"angle": 0,
"elbowed": true,
"points": [[0, 0], [0, 120], [200, 120], [200, 0]],
"startArrowhead": null,
"endArrowhead": "arrow",
"isDeleted": false
},
{
"id": "regular-curved",
"type": "arrow",
"x": 350,
"y": 50,
"width": 200,
"height": 150,
"strokeColor": "#2f9e44",
"backgroundColor": "transparent",
"fillStyle": "hachure",
"strokeWidth": 2,
"roughness": 1,
"opacity": 100,
"seed": 9004,
"angle": 0,
"points": [[0, 0], [100, 75], [200, 150]],
"startArrowhead": null,
"endArrowhead": "arrow",
"isDeleted": false
}
],
"appState": {
"viewBackgroundColor": "#ffffff"
}
}