feat(editor): support frame backgrounds

This commit is contained in:
dwelle
2026-02-22 23:08:46 +01:00
parent 4c3d037f9c
commit f944f1f7aa
13 changed files with 390 additions and 11 deletions
+6 -1
View File
@@ -78,7 +78,12 @@ import type {
} from "./types";
export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") {
if (
element.type === "arrow" ||
// frame elements should ignore inside hit test even if background is not
// transparent, so we can select children easily
isFrameLikeElement(element)
) {
return false;
}
+41 -1
View File
@@ -22,6 +22,7 @@ import {
isRTL,
getVerticalOffset,
invariant,
isTransparent,
applyDarkModeFilter,
isSafari,
} from "@excalidraw/common";
@@ -78,6 +79,7 @@ import type {
ExcalidrawFrameLikeElement,
NonDeletedSceneElementsMap,
ElementsMap,
ExcalidrawFrameElement,
} from "./types";
import type { RoughCanvas } from "roughjs/bin/canvas";
@@ -777,6 +779,45 @@ export const renderSelectionElement = (
context.restore();
};
export const renderFrameBackground = (
frame: ExcalidrawFrameElement,
context: CanvasRenderingContext2D,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
opts?: {
roundCorners?: boolean;
},
) => {
if (isTransparent(frame.backgroundColor)) {
return;
}
context.save();
context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY);
context.fillStyle =
appState.theme === THEME.DARK
? applyDarkModeFilter(frame.backgroundColor)
: frame.backgroundColor;
const shouldRoundCorners = opts?.roundCorners ?? true;
if (shouldRoundCorners && FRAME_STYLE.radius && context.roundRect) {
context.beginPath();
context.roundRect(
0,
0,
frame.width,
frame.height,
FRAME_STYLE.radius / appState.zoom.value,
);
context.fill();
context.closePath();
} else {
context.fillRect(0, 0, frame.width, frame.height);
}
context.restore();
};
export const renderElement = (
element: NonDeletedExcalidrawElement,
elementsMap: RenderableElementsMap,
@@ -808,7 +849,6 @@ export const renderElement = (
element.x + appState.scrollX,
element.y + appState.scrollY,
);
context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle =
+47
View File
@@ -38,6 +38,53 @@ describe("check rotated elements can be hit:", () => {
});
});
describe("frame hit testing", () => {
it.each(["transparent", "#ffffff"])(
"does not hit frame inside regardless of background color (%s)",
(backgroundColor) => {
const element = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor,
});
const elementsMap = arrayToMap([element]);
expect(
hitElementItself({
point: pointFrom<GlobalPoint>(50, 50),
element,
threshold: 10,
elementsMap,
}),
).toBe(false);
},
);
it("hits frame outline", () => {
const element = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
});
const elementsMap = arrayToMap([element]);
expect(
hitElementItself({
point: pointFrom<GlobalPoint>(0, 50),
element,
threshold: 1,
elementsMap,
}),
).toBe(true);
});
});
describe("hitElementItself cache", () => {
beforeEach(async () => {
// reset cache
@@ -109,6 +109,21 @@ describe("element locking", () => {
expect(crossHatchButton).toBe(null);
});
it("should show background color picker for selected frame", () => {
const frame = API.createElement({
type: "frame",
});
API.setElements([frame]);
API.setSelectedElements([frame]);
expect(
queryByTestId(
document.body,
`color-top-pick-${DEFAULT_ELEMENT_BACKGROUND_PICKS[0]}`,
),
).not.toBe(null);
});
it("should highlight common stroke width of selected elements", () => {
const rect1 = API.createElement({
type: "rectangle",
@@ -444,7 +444,12 @@ export const actionChangeBackgroundColor = register<
(element) => element.backgroundColor,
true,
(hasSelection) =>
!hasSelection ? appState.currentItemBackgroundColor : null,
!hasSelection
? appState.activeTool.type === "frame"
? // background default shouldn't apply to new frames
"transparent"
: appState.currentItemBackgroundColor
: null,
)}
onChange={(color) =>
updateData({ currentItemBackgroundColor: color })
+5 -1
View File
@@ -20,6 +20,7 @@ import {
isArrowElement,
hasStrokeColor,
toolIsArrow,
isFrameElement,
} from "@excalidraw/element";
import type {
@@ -129,8 +130,11 @@ export const canChangeBackgroundColor = (
targetElements: ExcalidrawElement[],
) => {
return (
// frame tool shouldn't allow to set background until frame is created
hasBackground(appState.activeTool.type) ||
targetElements.some((element) => hasBackground(element.type))
targetElements.some(
(element) => hasBackground(element.type) || isFrameElement(element),
)
);
};
+3
View File
@@ -256,6 +256,7 @@ import {
handleFocusPointPointerUp,
maybeHandleArrowPointlikeDrag,
getUncroppedWidthAndHeight,
isFrameElement,
} from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -5037,6 +5038,8 @@ class App extends React.Component<AppProps, AppState> {
if (
event.key === KEYS.G &&
(hasBackground(this.state.activeTool.type) ||
this.state.activeTool.type === "frame" ||
selectedElements.some((element) => isFrameElement(element)) ||
selectedElements.some((element) => hasBackground(element.type)))
) {
this.setState({ openPopup: "elementBackground" });
@@ -4,7 +4,9 @@ import {
getTargetFrame,
isInvisiblySmallElement,
renderElement,
renderFrameBackground,
shouldApplyFrameClip,
isFrameElement,
} from "@excalidraw/element";
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
@@ -67,6 +69,14 @@ const _renderNewElementScene = ({
}
}
if (
isFrameElement(newElement) &&
appState.frameRendering.enabled &&
appState.frameRendering.outline
) {
renderFrameBackground(newElement, context, appState);
}
renderElement(
newElement,
elementsMap,
+45 -2
View File
@@ -4,21 +4,23 @@ import {
THEME,
throttleRAF,
} from "@excalidraw/common";
import { isElementLink } from "@excalidraw/element";
import { isElementLink, isFrameElement } from "@excalidraw/element";
import { createPlaceholderEmbeddableLabel } from "@excalidraw/element";
import { getBoundTextElement } from "@excalidraw/element";
import {
isEmbeddableElement,
isFrameLikeElement,
isIframeLikeElement,
isTextElement,
} from "@excalidraw/element";
import {
elementOverlapsWithFrame,
getContainingFrame,
getTargetFrame,
shouldApplyFrameClip,
} from "@excalidraw/element";
import { renderElement } from "@excalidraw/element";
import { renderElement, renderFrameBackground } from "@excalidraw/element";
import { getElementAbsoluteCoords } from "@excalidraw/element";
@@ -276,6 +278,39 @@ const _renderStaticScene = ({
}
const groupsToBeAddedToFrame = new Set<string>();
const renderedFrameBackgrounds = new Set<string>();
const maybeRenderFrameBackground = (
element: NonDeletedExcalidrawElement | ExcalidrawFrameLikeElement,
) => {
if (
!appState.frameRendering.enabled ||
(!appState.frameRendering.outline && !renderConfig.exportingFrame)
) {
return;
}
const frame =
renderConfig.exportingFrame ||
(isFrameLikeElement(element)
? element
: getContainingFrame(element, elementsMap));
if (!isFrameElement(frame)) {
return;
}
if (!frame || renderedFrameBackgrounds.has(frame.id)) {
return;
}
renderFrameBackground(frame, context, appState, {
roundCorners:
!renderConfig.exportingFrame ||
frame.id !== renderConfig.exportingFrame.id,
});
renderedFrameBackgrounds.add(frame.id);
};
visibleElements.forEach((element) => {
if (
@@ -297,11 +332,19 @@ const _renderStaticScene = ({
const inFrameGroupsMap = new Map<string, boolean>();
if (renderConfig.exportingFrame) {
maybeRenderFrameBackground(renderConfig.exportingFrame);
}
// Paint visible elements
visibleElements
.filter((el) => !isIframeLikeElement(el))
.forEach((element) => {
try {
// TODO: optimize (currently we call this func for each element because
// children come before their frames and we neeed to render the frame
// background at the bottom)
maybeRenderFrameBackground(element);
const frameId = element.frameId || appState.frameToHighlight?.id;
if (
@@ -8,6 +8,7 @@ import {
isRTL,
isTestEnv,
getVerticalOffset,
isTransparent,
applyDarkModeFilter,
MIME_TYPES,
} from "@excalidraw/common";
@@ -23,6 +24,7 @@ import { getBoundTextElement, getContainerElement } from "@excalidraw/element";
import { getLineHeightInPx } from "@excalidraw/element";
import {
isArrowElement,
isFrameLikeElement,
isIframeLikeElement,
isInitializedImageElement,
isTextElement,
@@ -38,6 +40,7 @@ import { getElementAbsoluteCoords } from "@excalidraw/element";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
ExcalidrawTextElementWithContainer,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
@@ -717,11 +720,76 @@ export const renderSceneToSvg = (
return;
}
const renderedFrameBackgrounds = new Set<string>();
const maybeRenderFrameBackground = (
element: NonDeletedExcalidrawElement | ExcalidrawFrameLikeElement,
) => {
if (
!renderConfig.frameRendering.enabled ||
(!renderConfig.frameRendering.outline && !renderConfig.exportingFrame)
) {
return;
}
const frame =
renderConfig.exportingFrame ||
(isFrameLikeElement(element)
? element
: getContainingFrame(element, elementsMap));
if (
!frame ||
renderedFrameBackgrounds.has(frame.id) ||
!frame.backgroundColor ||
isTransparent(frame.backgroundColor)
) {
return;
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
const cx = (x2 - x1) / 2 - (frame.x - x1);
const cy = (y2 - y1) / 2 - (frame.y - y1);
const degree = (180 * frame.angle) / Math.PI;
const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
rect.setAttribute(
"transform",
`translate(${frame.x + renderConfig.offsetX} ${
frame.y + renderConfig.offsetY
}) rotate(${degree} ${cx} ${cy})`,
);
rect.setAttribute("width", `${frame.width}px`);
rect.setAttribute("height", `${frame.height}px`);
const isDirectlyExportedFrame =
!!renderConfig.exportingFrame &&
frame.id === renderConfig.exportingFrame.id;
if (!isDirectlyExportedFrame) {
rect.setAttribute("rx", FRAME_STYLE.radius.toString());
rect.setAttribute("ry", FRAME_STYLE.radius.toString());
}
rect.setAttribute(
"fill",
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(frame.backgroundColor)
: frame.backgroundColor,
);
rect.setAttribute("stroke", "none");
svgRoot.appendChild(rect);
renderedFrameBackgrounds.add(frame.id);
};
if (renderConfig.exportingFrame) {
maybeRenderFrameBackground(renderConfig.exportingFrame);
}
// render elements
elements
.filter((el) => !isIframeLikeElement(el))
.forEach((element) => {
if (!element.isDeleted) {
maybeRenderFrameBackground(element);
if (
isTextElement(element) &&
element.containerId &&
+33 -5
View File
@@ -169,6 +169,20 @@ const prepareElementsForRender = ({
return nextElements;
};
const addExportingFrameToElements = (
elements: readonly ExcalidrawElement[],
exportingFrame?: ExcalidrawFrameLikeElement | null,
) => {
if (
!exportingFrame ||
elements.some((element) => element.id === exportingFrame.id)
) {
return elements;
}
return [...elements, exportingFrame];
};
export const exportToCanvas = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
@@ -216,6 +230,14 @@ export const exportToCanvas = async (
exportWithDarkMode: appState.exportWithDarkMode,
frameRendering,
});
const elementsForLookup = addExportingFrameToElements(
elementsForRender,
exportingFrame,
);
const allElementsForLookup = addExportingFrameToElements(
elements,
exportingFrame,
);
if (exportingFrame) {
exportPadding = 0;
@@ -242,10 +264,10 @@ export const exportToCanvas = async (
canvas,
rc: rough.canvas(canvas),
elementsMap: toBrandedType<RenderableElementsMap>(
arrayToMap(elementsForRender),
arrayToMap(elementsForLookup),
),
allElementsMap: toBrandedType<NonDeletedSceneElementsMap>(
arrayToMap(syncInvalidIndices(elements)),
arrayToMap(syncInvalidIndices(allElementsForLookup)),
),
visibleElements: elementsForRender,
scale,
@@ -261,6 +283,7 @@ export const exportToCanvas = async (
},
renderConfig: {
canvasBackgroundColor: viewBackgroundColor,
exportingFrame,
imageCache,
renderGrid: false,
isExporting: true,
@@ -325,6 +348,10 @@ export const exportToSvg = async (
exportWithDarkMode,
frameRendering,
});
const elementsForLookup = addExportingFrameToElements(
elementsForRender,
exportingFrame,
);
if (exportingFrame) {
exportPadding = 0;
@@ -386,10 +413,10 @@ export const exportToSvg = async (
// frame clip paths
// ---------------------------------------------------------------------------
const frameElements = getFrameLikeElements(elements);
const frameElements = getFrameLikeElements(elementsForLookup);
if (frameElements.length) {
const elementsMap = arrayToMap(elements);
const elementsMap = arrayToMap(elementsForLookup);
for (const frame of frameElements) {
const clipPath = svgRoot.ownerDocument.createElementNS(
@@ -472,7 +499,7 @@ export const exportToSvg = async (
renderSceneToSvg(
elementsForRender,
toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)),
toBrandedType<RenderableElementsMap>(arrayToMap(elementsForLookup)),
rsvg,
svgRoot,
files || {},
@@ -483,6 +510,7 @@ export const exportToSvg = async (
exportWithDarkMode,
renderEmbeddables,
frameRendering,
exportingFrame,
canvasBackgroundColor: viewBackgroundColor,
embedsValidationStatus: renderEmbeddables
? new Map(
+3
View File
@@ -1,6 +1,7 @@
import type { UserIdleState, EditorInterface } from "@excalidraw/common";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeletedElementsMap,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
@@ -26,6 +27,7 @@ export type RenderableElementsMap = NonDeletedElementsMap &
export type StaticCanvasRenderConfig = {
canvasBackgroundColor: AppState["viewBackgroundColor"];
exportingFrame?: ExcalidrawFrameLikeElement | null;
// extra options passed to the renderer
// ---------------------------------------------------------------------------
imageCache: AppClassProperties["imageCache"];
@@ -46,6 +48,7 @@ export type SVGRenderConfig = {
exportWithDarkMode: boolean;
renderEmbeddables: boolean;
frameRendering: AppState["frameRendering"];
exportingFrame?: ExcalidrawFrameLikeElement | null;
canvasBackgroundColor: AppState["viewBackgroundColor"];
embedsValidationStatus: EmbedsValidationStatus;
/**
@@ -279,6 +279,7 @@ describe("exporting frames", () => {
height: 100,
x: 0,
y: 0,
backgroundColor: "#ffc9c9",
});
const frameChild = API.createElement({
type: "rectangle",
@@ -311,11 +312,58 @@ describe("exporting frames", () => {
expect(
svg.querySelector(`[data-id="${rectOverlapping.id}"]`),
).not.toBeNull();
// exporting frame background should still be rendered
const frameBackgroundNode = svg.querySelector('rect[fill="#ffc9c9"]');
expect(frameBackgroundNode).not.toBeNull();
expect(frameBackgroundNode).not.toHaveAttribute("rx");
expect(frameBackgroundNode).not.toHaveAttribute("ry");
expect(
frameBackgroundNode!.compareDocumentPosition(
svg.querySelector(`[data-id="${frameChild.id}"]`)!,
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(svg.getAttribute("width")).toBe(frame.width.toString());
expect(svg.getAttribute("height")).toBe(frame.height.toString());
});
it("should not render SVG frame background for empty/transparent exporting frame bg", async () => {
const transparentFrame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
backgroundColor: "transparent",
});
const emptyBgFrame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
backgroundColor: "",
});
const transparentSvg = await exportToSvg({
elements: [transparentFrame],
files: null,
exportPadding: 0,
exportingFrame: transparentFrame,
});
const emptyBgSvg = await exportToSvg({
elements: [emptyBgFrame],
files: null,
exportPadding: 0,
exportingFrame: emptyBgFrame,
});
expect(
transparentSvg.querySelector('rect[fill="transparent"]'),
).toBeNull();
expect(emptyBgSvg.querySelector('rect[fill=""]')).toBeNull();
});
it("should filter non-overlapping elements when exporting a frame", async () => {
const frame = API.createElement({
type: "frame",
@@ -469,6 +517,66 @@ describe("exporting frames", () => {
);
});
it("should render frame with its background color", async () => {
const frame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
backgroundColor: "#ffc9c9",
});
const frameChild = API.createElement({
type: "rectangle",
width: 50,
height: 50,
x: 10,
y: 10,
frameId: frame.id,
});
const { exportedElements, exportingFrame } = prepareElementsForExport(
[frameChild, frame],
{
selectedElementIds: {},
},
false,
);
const svg = await exportToSvg({
elements: exportedElements,
files: null,
exportPadding: 0,
exportingFrame,
});
const frameSvgNode = svg.querySelector(`[data-id="${frame.id}"]`);
const childSvgNode = svg.querySelector(`[data-id="${frameChild.id}"]`);
const frameBackgroundNode = svg.querySelector('rect[fill="#ffc9c9"]');
expect(frameSvgNode).not.toBeNull();
expect(childSvgNode).not.toBeNull();
expect(frameBackgroundNode).not.toBeNull();
expect(frameSvgNode).toHaveAttribute("fill", "none");
expect(frameBackgroundNode).toHaveAttribute(
"rx",
FRAME_STYLE.radius.toString(),
);
expect(frameBackgroundNode).toHaveAttribute(
"ry",
FRAME_STYLE.radius.toString(),
);
expect(
frameBackgroundNode!.compareDocumentPosition(childSvgNode!) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
childSvgNode!.compareDocumentPosition(frameSvgNode!) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("should not export frame-overlapping elements belonging to different frame", async () => {
const frame1 = API.createElement({
type: "frame",