feat(editor): support frame backgrounds
This commit is contained in:
@@ -78,7 +78,12 @@ import type {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const shouldTestInside = (element: ExcalidrawElement) => {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
isRTL,
|
isRTL,
|
||||||
getVerticalOffset,
|
getVerticalOffset,
|
||||||
invariant,
|
invariant,
|
||||||
|
isTransparent,
|
||||||
applyDarkModeFilter,
|
applyDarkModeFilter,
|
||||||
isSafari,
|
isSafari,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
@@ -78,6 +79,7 @@ import type {
|
|||||||
ExcalidrawFrameLikeElement,
|
ExcalidrawFrameLikeElement,
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
|
ExcalidrawFrameElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||||
@@ -777,6 +779,45 @@ export const renderSelectionElement = (
|
|||||||
context.restore();
|
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 = (
|
export const renderElement = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
elementsMap: RenderableElementsMap,
|
elementsMap: RenderableElementsMap,
|
||||||
@@ -808,7 +849,6 @@ export const renderElement = (
|
|||||||
element.x + appState.scrollX,
|
element.x + appState.scrollX,
|
||||||
element.y + appState.scrollY,
|
element.y + appState.scrollY,
|
||||||
);
|
);
|
||||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
|
||||||
|
|
||||||
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
|
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
|
||||||
context.strokeStyle =
|
context.strokeStyle =
|
||||||
|
|||||||
@@ -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", () => {
|
describe("hitElementItself cache", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// reset cache
|
// reset cache
|
||||||
|
|||||||
@@ -109,6 +109,21 @@ describe("element locking", () => {
|
|||||||
expect(crossHatchButton).toBe(null);
|
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", () => {
|
it("should highlight common stroke width of selected elements", () => {
|
||||||
const rect1 = API.createElement({
|
const rect1 = API.createElement({
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
|
|||||||
@@ -444,7 +444,12 @@ export const actionChangeBackgroundColor = register<
|
|||||||
(element) => element.backgroundColor,
|
(element) => element.backgroundColor,
|
||||||
true,
|
true,
|
||||||
(hasSelection) =>
|
(hasSelection) =>
|
||||||
!hasSelection ? appState.currentItemBackgroundColor : null,
|
!hasSelection
|
||||||
|
? appState.activeTool.type === "frame"
|
||||||
|
? // background default shouldn't apply to new frames
|
||||||
|
"transparent"
|
||||||
|
: appState.currentItemBackgroundColor
|
||||||
|
: null,
|
||||||
)}
|
)}
|
||||||
onChange={(color) =>
|
onChange={(color) =>
|
||||||
updateData({ currentItemBackgroundColor: color })
|
updateData({ currentItemBackgroundColor: color })
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
isArrowElement,
|
isArrowElement,
|
||||||
hasStrokeColor,
|
hasStrokeColor,
|
||||||
toolIsArrow,
|
toolIsArrow,
|
||||||
|
isFrameElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -129,8 +130,11 @@ export const canChangeBackgroundColor = (
|
|||||||
targetElements: ExcalidrawElement[],
|
targetElements: ExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
|
// frame tool shouldn't allow to set background until frame is created
|
||||||
hasBackground(appState.activeTool.type) ||
|
hasBackground(appState.activeTool.type) ||
|
||||||
targetElements.some((element) => hasBackground(element.type))
|
targetElements.some(
|
||||||
|
(element) => hasBackground(element.type) || isFrameElement(element),
|
||||||
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ import {
|
|||||||
handleFocusPointPointerUp,
|
handleFocusPointPointerUp,
|
||||||
maybeHandleArrowPointlikeDrag,
|
maybeHandleArrowPointlikeDrag,
|
||||||
getUncroppedWidthAndHeight,
|
getUncroppedWidthAndHeight,
|
||||||
|
isFrameElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
||||||
@@ -5037,6 +5038,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (
|
if (
|
||||||
event.key === KEYS.G &&
|
event.key === KEYS.G &&
|
||||||
(hasBackground(this.state.activeTool.type) ||
|
(hasBackground(this.state.activeTool.type) ||
|
||||||
|
this.state.activeTool.type === "frame" ||
|
||||||
|
selectedElements.some((element) => isFrameElement(element)) ||
|
||||||
selectedElements.some((element) => hasBackground(element.type)))
|
selectedElements.some((element) => hasBackground(element.type)))
|
||||||
) {
|
) {
|
||||||
this.setState({ openPopup: "elementBackground" });
|
this.setState({ openPopup: "elementBackground" });
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
getTargetFrame,
|
getTargetFrame,
|
||||||
isInvisiblySmallElement,
|
isInvisiblySmallElement,
|
||||||
renderElement,
|
renderElement,
|
||||||
|
renderFrameBackground,
|
||||||
shouldApplyFrameClip,
|
shouldApplyFrameClip,
|
||||||
|
isFrameElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
|
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(
|
renderElement(
|
||||||
newElement,
|
newElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
|||||||
@@ -4,21 +4,23 @@ import {
|
|||||||
THEME,
|
THEME,
|
||||||
throttleRAF,
|
throttleRAF,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import { isElementLink } from "@excalidraw/element";
|
import { isElementLink, isFrameElement } from "@excalidraw/element";
|
||||||
import { createPlaceholderEmbeddableLabel } from "@excalidraw/element";
|
import { createPlaceholderEmbeddableLabel } from "@excalidraw/element";
|
||||||
import { getBoundTextElement } from "@excalidraw/element";
|
import { getBoundTextElement } from "@excalidraw/element";
|
||||||
import {
|
import {
|
||||||
isEmbeddableElement,
|
isEmbeddableElement,
|
||||||
|
isFrameLikeElement,
|
||||||
isIframeLikeElement,
|
isIframeLikeElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
import {
|
import {
|
||||||
elementOverlapsWithFrame,
|
elementOverlapsWithFrame,
|
||||||
|
getContainingFrame,
|
||||||
getTargetFrame,
|
getTargetFrame,
|
||||||
shouldApplyFrameClip,
|
shouldApplyFrameClip,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { renderElement } from "@excalidraw/element";
|
import { renderElement, renderFrameBackground } from "@excalidraw/element";
|
||||||
|
|
||||||
import { getElementAbsoluteCoords } from "@excalidraw/element";
|
import { getElementAbsoluteCoords } from "@excalidraw/element";
|
||||||
|
|
||||||
@@ -276,6 +278,39 @@ const _renderStaticScene = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const groupsToBeAddedToFrame = new Set<string>();
|
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) => {
|
visibleElements.forEach((element) => {
|
||||||
if (
|
if (
|
||||||
@@ -297,11 +332,19 @@ const _renderStaticScene = ({
|
|||||||
|
|
||||||
const inFrameGroupsMap = new Map<string, boolean>();
|
const inFrameGroupsMap = new Map<string, boolean>();
|
||||||
|
|
||||||
|
if (renderConfig.exportingFrame) {
|
||||||
|
maybeRenderFrameBackground(renderConfig.exportingFrame);
|
||||||
|
}
|
||||||
|
|
||||||
// Paint visible elements
|
// Paint visible elements
|
||||||
visibleElements
|
visibleElements
|
||||||
.filter((el) => !isIframeLikeElement(el))
|
.filter((el) => !isIframeLikeElement(el))
|
||||||
.forEach((element) => {
|
.forEach((element) => {
|
||||||
try {
|
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;
|
const frameId = element.frameId || appState.frameToHighlight?.id;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
isRTL,
|
isRTL,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
getVerticalOffset,
|
getVerticalOffset,
|
||||||
|
isTransparent,
|
||||||
applyDarkModeFilter,
|
applyDarkModeFilter,
|
||||||
MIME_TYPES,
|
MIME_TYPES,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
@@ -23,6 +24,7 @@ import { getBoundTextElement, getContainerElement } from "@excalidraw/element";
|
|||||||
import { getLineHeightInPx } from "@excalidraw/element";
|
import { getLineHeightInPx } from "@excalidraw/element";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
|
isFrameLikeElement,
|
||||||
isIframeLikeElement,
|
isIframeLikeElement,
|
||||||
isInitializedImageElement,
|
isInitializedImageElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
@@ -38,6 +40,7 @@ import { getElementAbsoluteCoords } from "@excalidraw/element";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
|
ExcalidrawFrameLikeElement,
|
||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
@@ -717,11 +720,76 @@ export const renderSceneToSvg = (
|
|||||||
return;
|
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
|
// render elements
|
||||||
elements
|
elements
|
||||||
.filter((el) => !isIframeLikeElement(el))
|
.filter((el) => !isIframeLikeElement(el))
|
||||||
.forEach((element) => {
|
.forEach((element) => {
|
||||||
if (!element.isDeleted) {
|
if (!element.isDeleted) {
|
||||||
|
maybeRenderFrameBackground(element);
|
||||||
if (
|
if (
|
||||||
isTextElement(element) &&
|
isTextElement(element) &&
|
||||||
element.containerId &&
|
element.containerId &&
|
||||||
|
|||||||
@@ -169,6 +169,20 @@ const prepareElementsForRender = ({
|
|||||||
return nextElements;
|
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 (
|
export const exportToCanvas = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
@@ -216,6 +230,14 @@ export const exportToCanvas = async (
|
|||||||
exportWithDarkMode: appState.exportWithDarkMode,
|
exportWithDarkMode: appState.exportWithDarkMode,
|
||||||
frameRendering,
|
frameRendering,
|
||||||
});
|
});
|
||||||
|
const elementsForLookup = addExportingFrameToElements(
|
||||||
|
elementsForRender,
|
||||||
|
exportingFrame,
|
||||||
|
);
|
||||||
|
const allElementsForLookup = addExportingFrameToElements(
|
||||||
|
elements,
|
||||||
|
exportingFrame,
|
||||||
|
);
|
||||||
|
|
||||||
if (exportingFrame) {
|
if (exportingFrame) {
|
||||||
exportPadding = 0;
|
exportPadding = 0;
|
||||||
@@ -242,10 +264,10 @@ export const exportToCanvas = async (
|
|||||||
canvas,
|
canvas,
|
||||||
rc: rough.canvas(canvas),
|
rc: rough.canvas(canvas),
|
||||||
elementsMap: toBrandedType<RenderableElementsMap>(
|
elementsMap: toBrandedType<RenderableElementsMap>(
|
||||||
arrayToMap(elementsForRender),
|
arrayToMap(elementsForLookup),
|
||||||
),
|
),
|
||||||
allElementsMap: toBrandedType<NonDeletedSceneElementsMap>(
|
allElementsMap: toBrandedType<NonDeletedSceneElementsMap>(
|
||||||
arrayToMap(syncInvalidIndices(elements)),
|
arrayToMap(syncInvalidIndices(allElementsForLookup)),
|
||||||
),
|
),
|
||||||
visibleElements: elementsForRender,
|
visibleElements: elementsForRender,
|
||||||
scale,
|
scale,
|
||||||
@@ -261,6 +283,7 @@ export const exportToCanvas = async (
|
|||||||
},
|
},
|
||||||
renderConfig: {
|
renderConfig: {
|
||||||
canvasBackgroundColor: viewBackgroundColor,
|
canvasBackgroundColor: viewBackgroundColor,
|
||||||
|
exportingFrame,
|
||||||
imageCache,
|
imageCache,
|
||||||
renderGrid: false,
|
renderGrid: false,
|
||||||
isExporting: true,
|
isExporting: true,
|
||||||
@@ -325,6 +348,10 @@ export const exportToSvg = async (
|
|||||||
exportWithDarkMode,
|
exportWithDarkMode,
|
||||||
frameRendering,
|
frameRendering,
|
||||||
});
|
});
|
||||||
|
const elementsForLookup = addExportingFrameToElements(
|
||||||
|
elementsForRender,
|
||||||
|
exportingFrame,
|
||||||
|
);
|
||||||
|
|
||||||
if (exportingFrame) {
|
if (exportingFrame) {
|
||||||
exportPadding = 0;
|
exportPadding = 0;
|
||||||
@@ -386,10 +413,10 @@ export const exportToSvg = async (
|
|||||||
// frame clip paths
|
// frame clip paths
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const frameElements = getFrameLikeElements(elements);
|
const frameElements = getFrameLikeElements(elementsForLookup);
|
||||||
|
|
||||||
if (frameElements.length) {
|
if (frameElements.length) {
|
||||||
const elementsMap = arrayToMap(elements);
|
const elementsMap = arrayToMap(elementsForLookup);
|
||||||
|
|
||||||
for (const frame of frameElements) {
|
for (const frame of frameElements) {
|
||||||
const clipPath = svgRoot.ownerDocument.createElementNS(
|
const clipPath = svgRoot.ownerDocument.createElementNS(
|
||||||
@@ -472,7 +499,7 @@ export const exportToSvg = async (
|
|||||||
|
|
||||||
renderSceneToSvg(
|
renderSceneToSvg(
|
||||||
elementsForRender,
|
elementsForRender,
|
||||||
toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)),
|
toBrandedType<RenderableElementsMap>(arrayToMap(elementsForLookup)),
|
||||||
rsvg,
|
rsvg,
|
||||||
svgRoot,
|
svgRoot,
|
||||||
files || {},
|
files || {},
|
||||||
@@ -483,6 +510,7 @@ export const exportToSvg = async (
|
|||||||
exportWithDarkMode,
|
exportWithDarkMode,
|
||||||
renderEmbeddables,
|
renderEmbeddables,
|
||||||
frameRendering,
|
frameRendering,
|
||||||
|
exportingFrame,
|
||||||
canvasBackgroundColor: viewBackgroundColor,
|
canvasBackgroundColor: viewBackgroundColor,
|
||||||
embedsValidationStatus: renderEmbeddables
|
embedsValidationStatus: renderEmbeddables
|
||||||
? new Map(
|
? new Map(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { UserIdleState, EditorInterface } from "@excalidraw/common";
|
import type { UserIdleState, EditorInterface } from "@excalidraw/common";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
|
ExcalidrawFrameLikeElement,
|
||||||
NonDeletedElementsMap,
|
NonDeletedElementsMap,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
@@ -26,6 +27,7 @@ export type RenderableElementsMap = NonDeletedElementsMap &
|
|||||||
|
|
||||||
export type StaticCanvasRenderConfig = {
|
export type StaticCanvasRenderConfig = {
|
||||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||||
|
exportingFrame?: ExcalidrawFrameLikeElement | null;
|
||||||
// extra options passed to the renderer
|
// extra options passed to the renderer
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
imageCache: AppClassProperties["imageCache"];
|
imageCache: AppClassProperties["imageCache"];
|
||||||
@@ -46,6 +48,7 @@ export type SVGRenderConfig = {
|
|||||||
exportWithDarkMode: boolean;
|
exportWithDarkMode: boolean;
|
||||||
renderEmbeddables: boolean;
|
renderEmbeddables: boolean;
|
||||||
frameRendering: AppState["frameRendering"];
|
frameRendering: AppState["frameRendering"];
|
||||||
|
exportingFrame?: ExcalidrawFrameLikeElement | null;
|
||||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||||
embedsValidationStatus: EmbedsValidationStatus;
|
embedsValidationStatus: EmbedsValidationStatus;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ describe("exporting frames", () => {
|
|||||||
height: 100,
|
height: 100,
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
|
backgroundColor: "#ffc9c9",
|
||||||
});
|
});
|
||||||
const frameChild = API.createElement({
|
const frameChild = API.createElement({
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
@@ -311,11 +312,58 @@ describe("exporting frames", () => {
|
|||||||
expect(
|
expect(
|
||||||
svg.querySelector(`[data-id="${rectOverlapping.id}"]`),
|
svg.querySelector(`[data-id="${rectOverlapping.id}"]`),
|
||||||
).not.toBeNull();
|
).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("width")).toBe(frame.width.toString());
|
||||||
expect(svg.getAttribute("height")).toBe(frame.height.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 () => {
|
it("should filter non-overlapping elements when exporting a frame", async () => {
|
||||||
const frame = API.createElement({
|
const frame = API.createElement({
|
||||||
type: "frame",
|
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 () => {
|
it("should not export frame-overlapping elements belonging to different frame", async () => {
|
||||||
const frame1 = API.createElement({
|
const frame1 = API.createElement({
|
||||||
type: "frame",
|
type: "frame",
|
||||||
|
|||||||
Reference in New Issue
Block a user