diff --git a/docs/VISUAL-REGRESSION-TESTING.md b/docs/VISUAL-REGRESSION-TESTING.md index 260e8b1..b6c38e2 100644 --- a/docs/VISUAL-REGRESSION-TESTING.md +++ b/docs/VISUAL-REGRESSION-TESTING.md @@ -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 | diff --git a/src/export-svg/renderers.ts b/src/export-svg/renderers.ts index d361db6..02fcc40 100644 --- a/src/export-svg/renderers.ts +++ b/src/export-svg/renderers.ts @@ -8,6 +8,7 @@ import { escapeXml, FONT_FAMILY, FRAME_STYLE, + generateElbowArrowShape, getCornerRadius, getRoughOptions, isPathALoop, @@ -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( diff --git a/src/export.ts b/src/export.ts index f5d66bb..cd94a9c 100644 --- a/src/export.ts +++ b/src/export.ts @@ -19,6 +19,7 @@ import { type ColorTransform, FONT_FAMILY, FRAME_STYLE, + generateElbowArrowShape, getCornerRadius, getRoughOptions, isPathALoop, @@ -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], diff --git a/src/shared.ts b/src/shared.ts index 0cc50fb..07a0a03 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -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 diff --git a/tests/visual/baselines/elbow-arrows--svg.png b/tests/visual/baselines/elbow-arrows--svg.png new file mode 100644 index 0000000..7a6b128 Binary files /dev/null and b/tests/visual/baselines/elbow-arrows--svg.png differ diff --git a/tests/visual/baselines/elbow-arrows.png b/tests/visual/baselines/elbow-arrows.png new file mode 100644 index 0000000..2c67fb8 Binary files /dev/null and b/tests/visual/baselines/elbow-arrows.png differ diff --git a/tests/visual/fixtures/elbow-arrows.excalidraw b/tests/visual/fixtures/elbow-arrows.excalidraw new file mode 100644 index 0000000..697ca9c --- /dev/null +++ b/tests/visual/fixtures/elbow-arrows.excalidraw @@ -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" + } +}