fix(editor): cardinal direction arrows with label are invisible in exported SVG (#11441)

fix: arrows with bound text labels missing from SVG export

Axis-aligned (horizontal/vertical) arrows with a bound label vanished from SVG exports. The label-gap mask defaulted to objectBoundingBox units, whose region collapses to zero area for a zero-size bounding box, masking out the whole line. Pin the mask to userSpaceOnUse with an explicit user-space region (the coords already used by the visible rect).

Fixes #11439
This commit is contained in:
KrishhnaT
2026-06-07 22:49:44 +05:30
committed by GitHub
parent 647a264a48
commit 61fe15a51d
2 changed files with 51 additions and 0 deletions
@@ -291,6 +291,14 @@ const renderElementToSvg = (
);
offsetX = offsetX || 0;
offsetY = offsetY || 0;
// Pin the mask to user space; the default maskUnits="objectBoundingBox"
// collapses to zero area for axis-aligned arrows (zero-size bbox),
// hiding the whole line from SVG exports (#11439).
maskPath.setAttribute("maskUnits", "userSpaceOnUse");
maskPath.setAttribute("x", "0");
maskPath.setAttribute("y", "0");
maskPath.setAttribute("width", `${element.width + 100 + offsetX}`);
maskPath.setAttribute("height", `${element.height + 100 + offsetY}`);
maskRectVisible.setAttribute("x", "0");
maskRectVisible.setAttribute("y", "0");
maskRectVisible.setAttribute("fill", "#fff");
@@ -6,12 +6,16 @@ import {
FRAME_STYLE,
} from "@excalidraw/common";
import { pointFrom } from "@excalidraw/math";
import type {
ExcalidrawTextElement,
FractionalIndex,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import type { LocalPoint } from "@excalidraw/math";
import { prepareElementsForExport } from "../../data";
import * as exportUtils from "../../scene/export";
import {
@@ -192,6 +196,45 @@ describe("exportToSvg", () => {
);
expect(svgElement.innerHTML).toMatchSnapshot();
});
// #11439: a perfectly horizontal/vertical arrow has a zero-size bounding box.
// The bound-text "gap" mask must use userSpaceOnUse units, otherwise its
// objectBoundingBox region collapses to zero area and the whole arrow line
// disappears from the SVG export (only the label remains).
it("keeps a horizontal arrow with a bound label visible (#11439)", async () => {
const arrow = API.createElement({
type: "arrow",
id: "arrow-11439",
width: 200,
height: 0,
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(200, 0)],
boundElements: [{ type: "text", id: "label-11439" }],
});
const label = API.createElement({
type: "text",
id: "label-11439",
text: "label",
width: 50,
height: 20,
containerId: "arrow-11439",
});
const svgElement = await exportUtils.exportToSvg(
[arrow, label] as NonDeletedExcalidrawElement[],
DEFAULT_OPTIONS,
null,
);
const mask = svgElement.querySelector("mask");
expect(mask).not.toBeNull();
expect(mask?.getAttribute("maskUnits")).toBe("userSpaceOnUse");
// a degenerate (objectBoundingBox) region would be zero-area here
expect(Number(mask?.getAttribute("width"))).toBeGreaterThan(0);
expect(Number(mask?.getAttribute("height"))).toBeGreaterThan(0);
// the masked arrow group still renders its line (not clipped away)
expect(svgElement.querySelector("g[mask] path")).not.toBeNull();
});
});
describe("exporting frames", () => {