feat: render frames on export (#7210)

This commit is contained in:
David Luzar 2023-11-09 17:00:21 +01:00 committed by GitHub
parent a9a6f8eafb
commit 864c0b3ea8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 989 additions and 332 deletions

View File

@ -8,6 +8,7 @@ import {
} from "../../excalidraw-app/collab/reconciliation";
import { randomInteger } from "../../src/random";
import { AppState } from "../../src/types";
import { cloneJSON } from "../../src/utils";
type Id = string;
type ElementLike = {
@ -93,8 +94,6 @@ const cleanElements = (elements: ReconciledElements) => {
});
};
const cloneDeep = (data: any) => JSON.parse(JSON.stringify(data));
const test = <U extends `${string}:${"L" | "R"}`>(
local: (Id | ElementLike)[],
remote: (Id | ElementLike)[],
@ -115,15 +114,15 @@ const test = <U extends `${string}:${"L" | "R"}`>(
"remote reconciliation",
);
const __local = cleanElements(cloneDeep(_remote));
const __remote = addParents(cleanElements(cloneDeep(remoteReconciled)));
const __local = cleanElements(cloneJSON(_remote) as ReconciledElements);
const __remote = addParents(cleanElements(cloneJSON(remoteReconciled)));
if (bidirectional) {
try {
expect(
cleanElements(
reconcileElements(
cloneDeep(__local),
cloneDeep(__remote),
cloneJSON(__local),
cloneJSON(__remote),
{} as AppState,
),
),

View File

@ -9,8 +9,8 @@ import {
readSystemClipboard,
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { exportCanvas } from "../data/index";
import { getNonDeletedElements, isTextElement } from "../element";
import { exportCanvas, prepareElementsForExport } from "../data/index";
import { isTextElement } from "../element";
import { t } from "../i18n";
import { isFirefox } from "../constants";
@ -122,20 +122,23 @@ export const actionCopyAsSvg = register({
commitToHistory: false,
};
}
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const { exportedElements, exportingFrame } = prepareElementsForExport(
elements,
appState,
true,
);
try {
await exportCanvas(
"clipboard-svg",
selectedElements.length
? selectedElements
: getNonDeletedElements(elements),
exportedElements,
appState,
app.files,
appState,
{
...appState,
exportingFrame,
},
);
return {
commitToHistory: false,
@ -171,16 +174,17 @@ export const actionCopyAsPng = register({
includeBoundTextElement: true,
includeElementsInFrames: true,
});
try {
await exportCanvas(
"clipboard",
selectedElements.length
? selectedElements
: getNonDeletedElements(elements),
appState,
app.files,
const { exportedElements, exportingFrame } = prepareElementsForExport(
elements,
appState,
true,
);
try {
await exportCanvas("clipboard", exportedElements, appState, app.files, {
...appState,
exportingFrame,
});
return {
appState: {
...appState,

View File

@ -25,7 +25,7 @@ import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons";
import {
bindElementsToFramesAfterDuplication,
getFrameElements,
getFrameChildren,
} from "../frame";
import {
excludeElementsInFramesFromSelection,
@ -155,7 +155,7 @@ const duplicateElements = (
groupId,
).flatMap((element) =>
isFrameElement(element)
? [...getFrameElements(elements, element.id), element]
? [...getFrameChildren(elements, element.id), element]
: [element],
);
@ -181,7 +181,7 @@ const duplicateElements = (
continue;
}
if (isElementAFrame) {
const elementsInFrame = getFrameElements(sortedElements, element.id);
const elementsInFrame = getFrameChildren(sortedElements, element.id);
elementsWithClones.push(
...markAsProcessed([

View File

@ -1,7 +1,7 @@
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame";
import { getFrameElements } from "../frame";
import { getFrameChildren } from "../frame";
import { KEYS } from "../keys";
import { AppClassProperties, AppState } from "../types";
import { updateActiveTool } from "../utils";
@ -21,7 +21,7 @@ export const actionSelectAllElementsInFrame = register({
const selectedFrame = app.scene.getSelectedElements(appState)[0];
if (selectedFrame && selectedFrame.type === "frame") {
const elementsInFrame = getFrameElements(
const elementsInFrame = getFrameChildren(
getNonDeletedElements(elements),
selectedFrame.id,
).filter((element) => !(element.type === "text" && element.containerId));

View File

@ -21,8 +21,10 @@ import {
canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement,
isFrameElement,
isArrowElement,
} from "../element/typeChecks";
import { getSelectedElements } from "../scene";
import { ExcalidrawTextElement } from "../element/types";
// `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}";
@ -99,16 +101,19 @@ export const actionPasteStyles = register({
if (isTextElement(newElement)) {
const fontSize =
elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE;
(elementStylesToCopyFrom as ExcalidrawTextElement).fontSize ||
DEFAULT_FONT_SIZE;
const fontFamily =
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY;
(elementStylesToCopyFrom as ExcalidrawTextElement).fontFamily ||
DEFAULT_FONT_FAMILY;
newElement = newElementWith(newElement, {
fontSize,
fontFamily,
textAlign:
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
(elementStylesToCopyFrom as ExcalidrawTextElement).textAlign ||
DEFAULT_TEXT_ALIGN,
lineHeight:
elementStylesToCopyFrom.lineHeight ||
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
getDefaultLineHeight(fontFamily),
});
let container = null;
@ -123,7 +128,10 @@ export const actionPasteStyles = register({
redrawTextBoundingBox(newElement, container);
}
if (newElement.type === "arrow") {
if (
newElement.type === "arrow" &&
isArrowElement(elementStylesToCopyFrom)
) {
newElement = newElementWith(newElement, {
startArrowhead: elementStylesToCopyFrom.startArrowhead,
endArrowhead: elementStylesToCopyFrom.endArrowhead,

View File

@ -87,7 +87,7 @@ import {
ZOOM_STEP,
POINTER_EVENTS,
} from "../constants";
import { exportCanvas, loadFromBlob } from "../data";
import { ExportedElements, exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restore, restoreElements } from "../data/restore";
import {
@ -317,7 +317,7 @@ import { shouldShowBoundingBox } from "../element/transformHandles";
import { actionUnlockAllElements } from "../actions/actionElementLock";
import { Fonts } from "../scene/Fonts";
import {
getFrameElements,
getFrameChildren,
isCursorInFrame,
bindElementsToFramesAfterDuplication,
addElementsToFrame,
@ -1048,12 +1048,6 @@ class App extends React.Component<AppProps, AppState> {
this.state,
);
const { x: x2 } = sceneCoordsToViewportCoords(
{ sceneX: f.x + f.width, sceneY: f.y + f.height },
this.state,
);
const FRAME_NAME_GAP = 20;
const FRAME_NAME_EDIT_PADDING = 6;
const reset = () => {
@ -1098,13 +1092,12 @@ class App extends React.Component<AppProps, AppState> {
boxShadow: "inset 0 0 0 1px var(--color-primary)",
fontFamily: "Assistant",
fontSize: "14px",
transform: `translateY(-${FRAME_NAME_EDIT_PADDING}px)`,
transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`,
color: "var(--color-gray-80)",
overflow: "hidden",
maxWidth: `${Math.min(
x2 - x1 - FRAME_NAME_EDIT_PADDING,
document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING,
)}px`,
maxWidth: `${
document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING
}px`,
}}
size={frameNameInEdit.length + 1 || 1}
dir="auto"
@ -1126,19 +1119,26 @@ class App extends React.Component<AppProps, AppState> {
key={f.id}
style={{
position: "absolute",
top: `${y1 - FRAME_NAME_GAP - this.state.offsetTop}px`,
left: `${
x1 -
this.state.offsetLeft -
(this.state.editingFrame === f.id ? FRAME_NAME_EDIT_PADDING : 0)
// Positioning from bottom so that we don't to either
// calculate text height or adjust using transform (which)
// messes up input position when editing the frame name.
// This makes the positioning deterministic and we can calculate
// the same position when rendering to canvas / svg.
bottom: `${
this.state.height +
FRAME_STYLE.nameOffsetY -
y1 +
this.state.offsetTop
}px`,
left: `${x1 - this.state.offsetLeft}px`,
zIndex: 2,
fontSize: "14px",
fontSize: FRAME_STYLE.nameFontSize,
color: isDarkTheme
? "var(--color-gray-60)"
: "var(--color-gray-50)",
? FRAME_STYLE.nameColorDarkTheme
: FRAME_STYLE.nameColorLightTheme,
lineHeight: FRAME_STYLE.nameLineHeight,
width: "max-content",
maxWidth: `${x2 - x1 + FRAME_NAME_EDIT_PADDING * 2}px`,
maxWidth: `${f.width}px`,
overflow: f.id === this.state.editingFrame ? "visible" : "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
@ -1370,7 +1370,8 @@ class App extends React.Component<AppProps, AppState> {
public onExportImage = async (
type: keyof typeof EXPORT_IMAGE_TYPES,
elements: readonly NonDeletedExcalidrawElement[],
elements: ExportedElements,
opts: { exportingFrame: ExcalidrawFrameElement | null },
) => {
trackEvent("export", type, "ui");
const fileHandle = await exportCanvas(
@ -1382,6 +1383,7 @@ class App extends React.Component<AppProps, AppState> {
exportBackground: this.state.exportBackground,
name: this.state.name,
viewBackgroundColor: this.state.viewBackgroundColor,
exportingFrame: opts.exportingFrame,
},
)
.catch(muteFSAbortError)
@ -5330,7 +5332,7 @@ class App extends React.Component<AppProps, AppState> {
// if hitElement is frame, deselect all of its elements if they are selected
if (hitElement.type === "frame") {
getFrameElements(
getFrameChildren(
previouslySelectedElements,
hitElement.id,
).forEach((element) => {
@ -8194,7 +8196,7 @@ class App extends React.Component<AppProps, AppState> {
>();
selectedFrames.forEach((frame) => {
const elementsInFrame = getFrameElements(
const elementsInFrame = getFrameChildren(
this.scene.getNonDeletedElements(),
frame.id,
);
@ -8264,7 +8266,7 @@ class App extends React.Component<AppProps, AppState> {
const elementsToHighlight = new Set<ExcalidrawElement>();
selectedFrames.forEach((frame) => {
const elementsInFrame = getFrameElements(
const elementsInFrame = getFrameChildren(
this.scene.getNonDeletedElements(),
frame.id,
);

View File

@ -22,7 +22,7 @@ import { canvasToBlob } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../packages/utils";
import { copyIcon, downloadIcon, helpIcon } from "./icons";
@ -34,6 +34,8 @@ import { Tooltip } from "./Tooltip";
import "./ImageExportDialog.scss";
import { useAppProps } from "./App";
import { FilledButton } from "./FilledButton";
import { cloneJSON } from "../utils";
import { prepareElementsForExport } from "../data";
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
@ -51,44 +53,47 @@ export const ErrorCanvasPreview = () => {
};
type ImageExportModalProps = {
appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[];
appStateSnapshot: Readonly<UIAppState>;
elementsSnapshot: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
actionManager: ActionManager;
onExportImage: AppClassProperties["onExportImage"];
};
const ImageExportModal = ({
appState,
elements,
appStateSnapshot,
elementsSnapshot,
files,
actionManager,
onExportImage,
}: ImageExportModalProps) => {
const hasSelection = isSomeElementSelected(
elementsSnapshot,
appStateSnapshot,
);
const appProps = useAppProps();
const [projectName, setProjectName] = useState(appState.name);
const someElementIsSelected = isSomeElementSelected(elements, appState);
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const [projectName, setProjectName] = useState(appStateSnapshot.name);
const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection);
const [exportWithBackground, setExportWithBackground] = useState(
appState.exportBackground,
appStateSnapshot.exportBackground,
);
const [exportDarkMode, setExportDarkMode] = useState(
appState.exportWithDarkMode,
appStateSnapshot.exportWithDarkMode,
);
const [embedScene, setEmbedScene] = useState(appState.exportEmbedScene);
const [exportScale, setExportScale] = useState(appState.exportScale);
const [embedScene, setEmbedScene] = useState(
appStateSnapshot.exportEmbedScene,
);
const [exportScale, setExportScale] = useState(appStateSnapshot.exportScale);
const previewRef = useRef<HTMLDivElement>(null);
const [renderError, setRenderError] = useState<Error | null>(null);
const exportedElements = exportSelected
? getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
})
: elements;
const { exportedElements, exportingFrame } = prepareElementsForExport(
elementsSnapshot,
appStateSnapshot,
exportSelectionOnly,
);
useEffect(() => {
const previewNode = previewRef.current;
@ -102,10 +107,18 @@ const ImageExportModal = ({
}
exportToCanvas({
elements: exportedElements,
appState,
appState: {
...appStateSnapshot,
name: projectName,
exportBackground: exportWithBackground,
exportWithDarkMode: exportDarkMode,
exportScale,
exportEmbedScene: embedScene,
},
files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
})
.then((canvas) => {
setRenderError(null);
@ -119,7 +132,17 @@ const ImageExportModal = ({
console.error(error);
setRenderError(error);
});
}, [appState, files, exportedElements]);
}, [
appStateSnapshot,
files,
exportedElements,
exportingFrame,
projectName,
exportWithBackground,
exportDarkMode,
exportScale,
embedScene,
]);
return (
<div className="ImageExportModal">
@ -136,7 +159,8 @@ const ImageExportModal = ({
value={projectName}
style={{ width: "30ch" }}
disabled={
typeof appProps.name !== "undefined" || appState.viewModeEnabled
typeof appProps.name !== "undefined" ||
appStateSnapshot.viewModeEnabled
}
onChange={(event) => {
setProjectName(event.target.value);
@ -152,16 +176,16 @@ const ImageExportModal = ({
</div>
<div className="ImageExportModal__settings">
<h3>{t("imageExportDialog.header")}</h3>
{someElementIsSelected && (
{hasSelection && (
<ExportSetting
label={t("imageExportDialog.label.onlySelected")}
name="exportOnlySelected"
>
<Switch
name="exportOnlySelected"
checked={exportSelected}
checked={exportSelectionOnly}
onChange={(checked) => {
setExportSelected(checked);
setExportSelectionOnly(checked);
}}
/>
</ExportSetting>
@ -243,7 +267,9 @@ const ImageExportModal = ({
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.exportToPng")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements)
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements, {
exportingFrame,
})
}
startIcon={downloadIcon}
>
@ -253,7 +279,9 @@ const ImageExportModal = ({
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.exportToSvg")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements)
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements, {
exportingFrame,
})
}
startIcon={downloadIcon}
>
@ -264,7 +292,9 @@ const ImageExportModal = ({
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.copyPngToClipboard")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements)
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, {
exportingFrame,
})
}
startIcon={copyIcon}
>
@ -325,15 +355,20 @@ export const ImageExportDialog = ({
onExportImage: AppClassProperties["onExportImage"];
onCloseRequest: () => void;
}) => {
if (appState.openDialog !== "imageExport") {
return null;
}
// we need to take a snapshot so that the exported state can't be modified
// while the dialog is open
const [{ appStateSnapshot, elementsSnapshot }] = useState(() => {
return {
appStateSnapshot: cloneJSON(appState),
elementsSnapshot: cloneJSON(elements),
};
});
return (
<Dialog onCloseRequest={onCloseRequest} size="wide" title={false}>
<ImageExportModal
elements={elements}
appState={appState}
elementsSnapshot={elementsSnapshot}
appStateSnapshot={appStateSnapshot}
files={files}
actionManager={actionManager}
onExportImage={onExportImage}

View File

@ -161,7 +161,10 @@ const LayerUI = ({
};
const renderImageExportDialog = () => {
if (!UIOptions.canvasActions.saveAsImage) {
if (
!UIOptions.canvasActions.saveAsImage ||
appState.openDialog !== "imageExport"
) {
return null;
}

View File

@ -105,6 +105,7 @@ export const FONT_FAMILY = {
Virgil: 1,
Helvetica: 2,
Cascadia: 3,
Assistant: 4,
};
export const THEME = {
@ -114,13 +115,18 @@ export const THEME = {
export const FRAME_STYLE = {
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
strokeWidth: 1 as ExcalidrawElement["strokeWidth"],
strokeWidth: 2 as ExcalidrawElement["strokeWidth"],
strokeStyle: "solid" as ExcalidrawElement["strokeStyle"],
fillStyle: "solid" as ExcalidrawElement["fillStyle"],
roughness: 0 as ExcalidrawElement["roughness"],
roundness: null as ExcalidrawElement["roundness"],
backgroundColor: "transparent" as ExcalidrawElement["backgroundColor"],
radius: 8,
nameOffsetY: 3,
nameColorLightTheme: "#999999",
nameColorDarkTheme: "#7a7a7a",
nameFontSize: 14,
nameLineHeight: 1.25,
};
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";

View File

@ -3,11 +3,19 @@ import {
copyTextToSystemClipboard,
} from "../clipboard";
import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
import { NonDeletedExcalidrawElement } from "../element/types";
import { getNonDeletedElements, isFrameElement } from "../element";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { t } from "../i18n";
import { elementsOverlappingBBox } from "../packages/withinBounds";
import { isSomeElementSelected, getSelectedElements } from "../scene";
import { exportToCanvas, exportToSvg } from "../scene/export";
import { ExportType } from "../scene/types";
import { AppState, BinaryFiles } from "../types";
import { cloneJSON } from "../utils";
import { canvasToBlob } from "./blob";
import { fileSave, FileSystemHandle } from "./filesystem";
import { serializeAsJSON } from "./json";
@ -15,9 +23,61 @@ import { serializeAsJSON } from "./json";
export { loadFromBlob } from "./blob";
export { loadFromJSON, saveAsJSON } from "./json";
export type ExportedElements = readonly NonDeletedExcalidrawElement[] & {
_brand: "exportedElements";
};
export const prepareElementsForExport = (
elements: readonly ExcalidrawElement[],
{ selectedElementIds }: Pick<AppState, "selectedElementIds">,
exportSelectionOnly: boolean,
) => {
elements = getNonDeletedElements(elements);
const isExportingSelection =
exportSelectionOnly &&
isSomeElementSelected(elements, { selectedElementIds });
let exportingFrame: ExcalidrawFrameElement | null = null;
let exportedElements = isExportingSelection
? getSelectedElements(
elements,
{ selectedElementIds },
{
includeBoundTextElement: true,
},
)
: elements;
if (isExportingSelection) {
if (exportedElements.length === 1 && isFrameElement(exportedElements[0])) {
exportingFrame = exportedElements[0];
exportedElements = elementsOverlappingBBox({
elements,
bounds: exportingFrame,
type: "overlap",
});
} else if (exportedElements.length > 1) {
exportedElements = getSelectedElements(
elements,
{ selectedElementIds },
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
}
}
return {
exportingFrame,
exportedElements: cloneJSON(exportedElements) as ExportedElements,
};
};
export const exportCanvas = async (
type: Omit<ExportType, "backend">,
elements: readonly NonDeletedExcalidrawElement[],
elements: ExportedElements,
appState: AppState,
files: BinaryFiles,
{
@ -26,12 +86,14 @@ export const exportCanvas = async (
viewBackgroundColor,
name,
fileHandle = null,
exportingFrame = null,
}: {
exportBackground: boolean;
exportPadding?: number;
viewBackgroundColor: string;
name: string;
fileHandle?: FileSystemHandle | null;
exportingFrame: ExcalidrawFrameElement | null;
},
) => {
if (elements.length === 0) {
@ -49,6 +111,7 @@ export const exportCanvas = async (
exportEmbedScene: appState.exportEmbedScene && type === "svg",
},
files,
{ exportingFrame },
);
if (type === "svg") {
return await fileSave(
@ -70,6 +133,7 @@ export const exportCanvas = async (
exportBackground,
viewBackgroundColor,
exportPadding,
exportingFrame,
});
if (type === "png") {

View File

@ -23,6 +23,7 @@ import {
LIBRARY_SIDEBAR_TAB,
} from "../constants";
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
import { cloneJSON } from "../utils";
export const libraryItemsAtom = atom<{
status: "loading" | "loaded";
@ -31,7 +32,7 @@ export const libraryItemsAtom = atom<{
}>({ status: "loaded", isInitialized: true, libraryItems: [] });
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
JSON.parse(JSON.stringify(libraryItems));
cloneJSON(libraryItems);
/**
* checks if library item does not exist already in current library

View File

@ -1,7 +1,6 @@
import { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles } from "../types";
import { exportCanvas } from ".";
import { getNonDeletedElements } from "../element";
import { exportCanvas, prepareElementsForExport } from ".";
import { getFileHandleType, isImageFileHandleType } from "./blob";
export const resaveAsImageWithScene = async (
@ -23,18 +22,19 @@ export const resaveAsImageWithScene = async (
exportEmbedScene: true,
};
await exportCanvas(
fileHandleType,
getNonDeletedElements(elements),
const { exportedElements, exportingFrame } = prepareElementsForExport(
elements,
appState,
files,
{
false,
);
await exportCanvas(fileHandleType, exportedElements, appState, files, {
exportBackground,
viewBackgroundColor,
name,
fileHandle,
},
);
exportingFrame,
});
return { fileHandle };
};

View File

@ -40,7 +40,7 @@ import {
VerticalAlign,
} from "../element/types";
import { MarkOptional } from "../utility-types";
import { assertNever, getFontString } from "../utils";
import { assertNever, cloneJSON, getFontString } from "../utils";
import { getSizeFromPoints } from "../points";
import { randomId } from "../random";
@ -368,7 +368,8 @@ const bindLinearElementToElement = (
// Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.
const endPointIndex = linearElement.points.length - 1;
const delta = 0.5;
const newPoints = JSON.parse(JSON.stringify(linearElement.points));
const newPoints = cloneJSON(linearElement.points) as [number, number][];
// left to right so shift the arrow towards right
if (
linearElement.points[endPointIndex][0] >
@ -439,9 +440,7 @@ export const convertToExcalidrawElements = (
if (!elementsSkeleton) {
return [];
}
const elements: ExcalidrawElementSkeleton[] = JSON.parse(
JSON.stringify(elementsSkeleton),
);
const elements = cloneJSON(elementsSkeleton);
const elementStore = new ElementStore();
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
const oldToNewElementIdMap = new Map<string, string>();

View File

@ -200,7 +200,7 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
return { link, aspectRatio, type };
};
export const isEmbeddableOrFrameLabel = (
export const isEmbeddableOrLabel = (
element: NonDeletedExcalidrawElement,
): Boolean => {
if (isEmbeddableElement(element)) {

View File

@ -1,6 +1,7 @@
import { ROUNDNESS } from "../constants";
import { AppState } from "../types";
import { MarkNonNullable } from "../utility-types";
import { assertNever } from "../utils";
import {
ExcalidrawElement,
ExcalidrawTextElement,
@ -140,17 +141,32 @@ export const isTextBindableContainer = (
);
};
export const isExcalidrawElement = (element: any): boolean => {
return (
element?.type === "text" ||
element?.type === "diamond" ||
element?.type === "rectangle" ||
element?.type === "embeddable" ||
element?.type === "ellipse" ||
element?.type === "arrow" ||
element?.type === "freedraw" ||
element?.type === "line"
);
export const isExcalidrawElement = (
element: any,
): element is ExcalidrawElement => {
const type: ExcalidrawElement["type"] | undefined = element?.type;
if (!type) {
return false;
}
switch (type) {
case "text":
case "diamond":
case "rectangle":
case "embeddable":
case "ellipse":
case "arrow":
case "freedraw":
case "line":
case "frame":
case "image":
case "selection": {
return true;
}
default: {
assertNever(type, null);
return false;
}
}
};
export const hasBoundTextElement = (

View File

@ -201,24 +201,52 @@ export const groupByFrames = (elements: readonly ExcalidrawElement[]) => {
for (const element of elements) {
const frameId = isFrameElement(element) ? element.id : element.frameId;
if (frameId && !frameElementsMap.has(frameId)) {
frameElementsMap.set(frameId, getFrameElements(elements, frameId));
frameElementsMap.set(frameId, getFrameChildren(elements, frameId));
}
}
return frameElementsMap;
};
export const getFrameElements = (
export const getFrameChildren = (
allElements: ExcalidrawElementsIncludingDeleted,
frameId: string,
) => allElements.filter((element) => element.frameId === frameId);
export const getFrameElements = (
allElements: ExcalidrawElementsIncludingDeleted,
): ExcalidrawFrameElement[] => {
return allElements.filter((element) =>
isFrameElement(element),
) as ExcalidrawFrameElement[];
};
/**
* Returns ExcalidrawFrameElements and non-frame-children elements.
*
* Considers children as root elements if they point to a frame parent
* non-existing in the elements set.
*
* Considers non-frame bound elements (container or arrow labels) as root.
*/
export const getRootElements = (
allElements: ExcalidrawElementsIncludingDeleted,
) => {
const frameElements = arrayToMap(getFrameElements(allElements));
return allElements.filter(
(element) =>
frameElements.has(element.id) ||
!element.frameId ||
!frameElements.has(element.frameId),
);
};
export const getElementsInResizingFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
frame: ExcalidrawFrameElement,
appState: AppState,
): ExcalidrawElement[] => {
const prevElementsInFrame = getFrameElements(allElements, frame.id);
const prevElementsInFrame = getFrameChildren(allElements, frame.id);
const nextElementsInFrame = new Set<ExcalidrawElement>(prevElementsInFrame);
const elementsCompletelyInFrame = new Set([
@ -449,7 +477,7 @@ export const removeAllElementsFromFrame = (
frame: ExcalidrawFrameElement,
appState: AppState,
) => {
const elementsInFrame = getFrameElements(allElements, frame.id);
const elementsInFrame = getFrameChildren(allElements, frame.id);
return removeElementsFromFrame(allElements, elementsInFrame, appState);
};

View File

@ -4,7 +4,11 @@ import {
} from "../scene/export";
import { getDefaultAppState } from "../appState";
import { AppState, BinaryFiles } from "../types";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
NonDeleted,
} from "../element/types";
import { restore } from "../data/restore";
import { MIME_TYPES } from "../constants";
import { encodePngMetadata } from "../data/image";
@ -14,24 +18,6 @@ import {
copyTextToSystemClipboard,
copyToClipboard,
} from "../clipboard";
import Scene from "../scene/Scene";
import { duplicateElements } from "../element/newElement";
// getContainerElement and getBoundTextElement and potentially other helpers
// depend on `Scene` which will not be available when these pure utils are
// called outside initialized Excalidraw editor instance or even if called
// from inside Excalidraw if the elements were never cached by Scene (e.g.
// for library elements).
//
// As such, before passing the elements down, we need to initialize a custom
// Scene instance and assign them to it.
//
// FIXME This is a super hacky workaround and we'll need to rewrite this soon.
const passElementsSafely = (elements: readonly ExcalidrawElement[]) => {
const scene = new Scene();
scene.replaceAllElements(duplicateElements(elements));
return scene.getNonDeletedElements();
};
export { MIME_TYPES };
@ -40,6 +26,7 @@ type ExportOpts = {
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
files: BinaryFiles | null;
maxWidthOrHeight?: number;
exportingFrame?: ExcalidrawFrameElement | null;
getDimensions?: (
width: number,
height: number,
@ -53,6 +40,7 @@ export const exportToCanvas = ({
maxWidthOrHeight,
getDimensions,
exportPadding,
exportingFrame,
}: ExportOpts & {
exportPadding?: number;
}) => {
@ -63,10 +51,10 @@ export const exportToCanvas = ({
);
const { exportBackground, viewBackgroundColor } = restoredAppState;
return _exportToCanvas(
passElementsSafely(restoredElements),
restoredElements,
{ ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
files || {},
{ exportBackground, exportPadding, viewBackgroundColor },
{ exportBackground, exportPadding, viewBackgroundColor, exportingFrame },
(width: number, height: number) => {
const canvas = document.createElement("canvas");
@ -135,10 +123,8 @@ export const exportToBlob = async (
};
}
const canvas = await exportToCanvas({
...opts,
elements: passElementsSafely(opts.elements),
});
const canvas = await exportToCanvas(opts);
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
return new Promise((resolve, reject) => {
@ -179,6 +165,7 @@ export const exportToSvg = async ({
files = {},
exportPadding,
renderEmbeddables,
exportingFrame,
}: Omit<ExportOpts, "getDimensions"> & {
exportPadding?: number;
renderEmbeddables?: boolean;
@ -194,20 +181,10 @@ export const exportToSvg = async ({
exportPadding,
};
return _exportToSvg(
passElementsSafely(restoredElements),
exportAppState,
files,
{
return _exportToSvg(restoredElements, exportAppState, files, {
exportingFrame,
renderEmbeddables,
// NOTE as long as we're using the Scene hack, we need to ensure
// we pass the original, uncloned elements when serializing
// so that we keep ids stable. Hence adding the serializeAsJSON helper
// support into the downstream exportToSvg function.
serializeAsJSON: () =>
serializeAsJSON(restoredElements, exportAppState, files || {}, "local"),
},
);
});
};
export const exportToClipboard = async (

View File

@ -6,13 +6,14 @@ import type {
} from "../element/types";
import {
isArrowElement,
isExcalidrawElement,
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "../element/typeChecks";
import { isValueInRange, rotatePoint } from "../math";
import type { Point } from "../types";
import { Bounds } from "../element/bounds";
import { Bounds, getElementBounds } from "../element/bounds";
type Element = NonDeletedExcalidrawElement;
type Elements = readonly NonDeletedExcalidrawElement[];
@ -146,7 +147,7 @@ export const elementsOverlappingBBox = ({
errorMargin = 0,
}: {
elements: Elements;
bounds: Bounds;
bounds: Bounds | ExcalidrawElement;
/** safety offset. Defaults to 0. */
errorMargin?: number;
/**
@ -156,6 +157,9 @@ export const elementsOverlappingBBox = ({
**/
type: "overlap" | "contain" | "inside";
}) => {
if (isExcalidrawElement(bounds)) {
bounds = getElementBounds(bounds);
}
const adjustedBBox: Bounds = [
bounds[0] - errorMargin,
bounds[1] - errorMargin,

View File

@ -20,7 +20,13 @@ import type { Drawable } from "roughjs/bin/core";
import type { RoughSVG } from "roughjs/bin/svg";
import { StaticCanvasRenderConfig } from "../scene/types";
import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
import {
distance,
getFontString,
getFontFamilyString,
isRTL,
isTestEnv,
} from "../utils";
import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
import rough from "roughjs/bin/rough";
import {
@ -589,11 +595,7 @@ export const renderElement = (
) => {
switch (element.type) {
case "frame": {
if (
!renderConfig.isExporting &&
appState.frameRendering.enabled &&
appState.frameRendering.outline
) {
if (appState.frameRendering.enabled && appState.frameRendering.outline) {
context.save();
context.translate(
element.x + appState.scrollX,
@ -601,7 +603,7 @@ export const renderElement = (
);
context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = 2 / appState.zoom.value;
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = FRAME_STYLE.strokeColor;
if (FRAME_STYLE.radius && context.roundRect) {
@ -841,10 +843,13 @@ const maybeWrapNodesInFrameClipPath = (
element: NonDeletedExcalidrawElement,
root: SVGElement,
nodes: SVGElement[],
exportedFrameId?: string | null,
frameRendering: AppState["frameRendering"],
) => {
if (!frameRendering.enabled || !frameRendering.clip) {
return null;
}
const frame = getContainingFrame(element);
if (frame && frame.id === exportedFrameId) {
if (frame) {
const g = root.ownerDocument!.createElementNS(SVG_NS, "g");
g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`);
nodes.forEach((node) => g.appendChild(node));
@ -861,9 +866,11 @@ export const renderElementToSvg = (
files: BinaryFiles,
offsetX: number,
offsetY: number,
exportWithDarkMode?: boolean,
exportingFrameId?: string | null,
renderEmbeddables?: boolean,
renderConfig: {
exportWithDarkMode: boolean;
renderEmbeddables: boolean;
frameRendering: AppState["frameRendering"];
},
) => {
const offset = { x: offsetX, y: offsetY };
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
@ -897,6 +904,13 @@ export const renderElementToSvg = (
root = anchorTag;
}
const addToRoot = (node: SVGElement, element: ExcalidrawElement) => {
if (isTestEnv()) {
node.setAttribute("data-id", element.id);
}
root.appendChild(node);
};
const opacity =
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
@ -931,10 +945,10 @@ export const renderElementToSvg = (
element,
root,
[node],
exportingFrameId,
renderConfig.frameRendering,
);
g ? root.appendChild(g) : root.appendChild(node);
addToRoot(g || node, element);
break;
}
case "embeddable": {
@ -957,7 +971,7 @@ export const renderElementToSvg = (
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
root.appendChild(node);
addToRoot(node, element);
const label: ExcalidrawElement =
createPlaceholderEmbeddableLabel(element);
@ -968,9 +982,7 @@ export const renderElementToSvg = (
files,
label.x + offset.x - element.x,
label.y + offset.y - element.y,
exportWithDarkMode,
exportingFrameId,
renderEmbeddables,
renderConfig,
);
// render embeddable element + iframe
@ -999,7 +1011,10 @@ export const renderElementToSvg = (
// if rendering embeddables explicitly disabled or
// embedding documents via srcdoc (which doesn't seem to work for SVGs)
// replace with a link instead
if (renderEmbeddables === false || embedLink?.type === "document") {
if (
renderConfig.renderEmbeddables === false ||
embedLink?.type === "document"
) {
const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
anchorTag.setAttribute("href", normalizeLink(element.link || ""));
anchorTag.setAttribute("target", "_blank");
@ -1033,8 +1048,7 @@ export const renderElementToSvg = (
embeddableNode.appendChild(foreignObject);
}
root.appendChild(embeddableNode);
addToRoot(embeddableNode, element);
break;
}
case "line":
@ -1119,12 +1133,13 @@ export const renderElementToSvg = (
element,
root,
[group, maskPath],
exportingFrameId,
renderConfig.frameRendering,
);
if (g) {
addToRoot(g, element);
root.appendChild(g);
} else {
root.appendChild(group);
addToRoot(group, element);
root.append(maskPath);
}
break;
@ -1158,10 +1173,10 @@ export const renderElementToSvg = (
element,
root,
[node],
exportingFrameId,
renderConfig.frameRendering,
);
g ? root.appendChild(g) : root.appendChild(node);
addToRoot(g || node, element);
break;
}
case "image": {
@ -1191,7 +1206,10 @@ export const renderElementToSvg = (
use.setAttribute("href", `#${symbolId}`);
// in dark theme, revert the image color filter
if (exportWithDarkMode && fileData.mimeType !== MIME_TYPES.svg) {
if (
renderConfig.exportWithDarkMode &&
fileData.mimeType !== MIME_TYPES.svg
) {
use.setAttribute("filter", IMAGE_INVERT_FILTER);
}
@ -1227,14 +1245,39 @@ export const renderElementToSvg = (
element,
root,
[g],
exportingFrameId,
renderConfig.frameRendering,
);
clipG ? root.appendChild(clipG) : root.appendChild(g);
addToRoot(clipG || g, element);
}
break;
}
// frames are not rendered and only acts as a container
case "frame": {
if (
renderConfig.frameRendering.enabled &&
renderConfig.frameRendering.outline
) {
const rect = document.createElementNS(SVG_NS, "rect");
rect.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
rect.setAttribute("width", `${element.width}px`);
rect.setAttribute("height", `${element.height}px`);
// Rounded corners
rect.setAttribute("rx", FRAME_STYLE.radius.toString());
rect.setAttribute("ry", FRAME_STYLE.radius.toString());
rect.setAttribute("fill", "none");
rect.setAttribute("stroke", FRAME_STYLE.strokeColor);
rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString());
addToRoot(rect, element);
}
break;
}
default: {
@ -1288,10 +1331,10 @@ export const renderElementToSvg = (
element,
root,
[node],
exportingFrameId,
renderConfig.frameRendering,
);
g ? root.appendChild(g) : root.appendChild(node);
addToRoot(g || node, element);
} else {
// @ts-ignore
throw new Error(`Unimplemented type ${element.type}`);

View File

@ -60,7 +60,7 @@ import {
TransformHandles,
TransformHandleType,
} from "../element/transformHandles";
import { throttleRAF, isOnlyExportingSingleFrame } from "../utils";
import { throttleRAF } from "../utils";
import { UserIdleState } from "../types";
import { FRAME_STYLE, THEME_FILTER } from "../constants";
import {
@ -74,7 +74,7 @@ import {
isLinearElement,
} from "../element/typeChecks";
import {
isEmbeddableOrFrameLabel,
isEmbeddableOrLabel,
createPlaceholderEmbeddableLabel,
} from "../element/embeddable";
import {
@ -369,7 +369,7 @@ const frameClip = (
) => {
context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY);
context.beginPath();
if (context.roundRect && !renderConfig.isExporting) {
if (context.roundRect) {
context.roundRect(
0,
0,
@ -963,20 +963,15 @@ const _renderStaticScene = ({
// Paint visible elements
visibleElements
.filter((el) => !isEmbeddableOrFrameLabel(el))
.filter((el) => !isEmbeddableOrLabel(el))
.forEach((element) => {
try {
// - when exporting the whole canvas, we DO NOT apply clipping
// - when we are exporting a particular frame, apply clipping
// if the containing frame is not selected, apply clipping
const frameId = element.frameId || appState.frameToHighlight?.id;
if (
frameId &&
((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
(!renderConfig.isExporting &&
appState.frameRendering.enabled &&
appState.frameRendering.clip))
appState.frameRendering.clip
) {
context.save();
@ -1001,7 +996,7 @@ const _renderStaticScene = ({
// render embeddables on top
visibleElements
.filter((el) => isEmbeddableOrFrameLabel(el))
.filter((el) => isEmbeddableOrLabel(el))
.forEach((element) => {
try {
const render = () => {
@ -1027,10 +1022,8 @@ const _renderStaticScene = ({
if (
frameId &&
((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
(!renderConfig.isExporting &&
appState.frameRendering.enabled &&
appState.frameRendering.clip))
appState.frameRendering.clip
) {
context.save();
@ -1298,7 +1291,7 @@ const renderFrameHighlight = (
const height = y2 - y1;
context.strokeStyle = "rgb(0,118,255)";
context.lineWidth = (FRAME_STYLE.strokeWidth * 2) / appState.zoom.value;
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.save();
context.translate(appState.scrollX, appState.scrollY);
@ -1454,24 +1447,29 @@ export const renderSceneToSvg = (
{
offsetX = 0,
offsetY = 0,
exportWithDarkMode = false,
exportingFrameId = null,
exportWithDarkMode,
renderEmbeddables,
frameRendering,
}: {
offsetX?: number;
offsetY?: number;
exportWithDarkMode?: boolean;
exportingFrameId?: string | null;
renderEmbeddables?: boolean;
} = {},
exportWithDarkMode: boolean;
renderEmbeddables: boolean;
frameRendering: AppState["frameRendering"];
},
) => {
if (!svgRoot) {
return;
}
const renderConfig = {
exportWithDarkMode,
renderEmbeddables,
frameRendering,
};
// render elements
elements
.filter((el) => !isEmbeddableOrFrameLabel(el))
.filter((el) => !isEmbeddableOrLabel(el))
.forEach((element) => {
if (!element.isDeleted) {
try {
@ -1482,9 +1480,7 @@ export const renderSceneToSvg = (
files,
element.x + offsetX,
element.y + offsetY,
exportWithDarkMode,
exportingFrameId,
renderEmbeddables,
renderConfig,
);
} catch (error: any) {
console.error(error);
@ -1505,9 +1501,7 @@ export const renderSceneToSvg = (
files,
element.x + offsetX,
element.y + offsetY,
exportWithDarkMode,
exportingFrameId,
renderEmbeddables,
renderConfig,
);
} catch (error: any) {
console.error(error);

View File

@ -66,18 +66,31 @@ class Scene {
private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>();
private static sceneMapById = new Map<string, Scene>();
static mapElementToScene(elementKey: ElementKey, scene: Scene) {
static mapElementToScene(
elementKey: ElementKey,
scene: Scene,
/**
* needed because of frame exporting hack.
* elementId:Scene mapping will be removed completely, soon.
*/
mapElementIds = true,
) {
if (isIdKey(elementKey)) {
if (!mapElementIds) {
return;
}
// for cases where we don't have access to the element object
// (e.g. restore serialized appState with id references)
this.sceneMapById.set(elementKey, scene);
} else {
this.sceneMapByElement.set(elementKey, scene);
if (!mapElementIds) {
// if mapping element objects, also cache the id string when later
// looking up by id alone
this.sceneMapById.set(elementKey.id, scene);
}
}
}
static getScene(elementKey: ElementKey): Scene | null {
if (isIdKey(elementKey)) {
@ -217,7 +230,10 @@ class Scene {
return didChange;
}
replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
replaceAllElements(
nextElements: readonly ExcalidrawElement[],
mapElementIds = true,
) {
this.elements = nextElements;
const nextFrames: ExcalidrawFrameElement[] = [];
this.elementsMap.clear();

View File

@ -1,24 +1,144 @@
import rough from "roughjs/bin/rough";
import { NonDeletedExcalidrawElement } from "../element/types";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import {
Bounds,
getCommonBounds,
getElementAbsoluteCoords,
} from "../element/bounds";
import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene";
import { distance, isOnlyExportingSingleFrame } from "../utils";
import { distance, getFontString } from "../utils";
import { AppState, BinaryFiles } from "../types";
import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
import {
DEFAULT_EXPORT_PADDING,
FRAME_STYLE,
SVG_NS,
THEME_FILTER,
} from "../constants";
import { getDefaultAppState } from "../appState";
import { serializeAsJSON } from "../data/json";
import {
getInitializedImageElements,
updateImageCache,
} from "../element/image";
import { elementsOverlappingBBox } from "../packages/withinBounds";
import { getFrameElements, getRootElements } from "../frame";
import { isFrameElement, newTextElement } from "../element";
import { Mutable } from "../utility-types";
import { newElementWith } from "../element/mutateElement";
import Scene from "./Scene";
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
// getContainerElement and getBoundTextElement and potentially other helpers
// depend on `Scene` which will not be available when these pure utils are
// called outside initialized Excalidraw editor instance or even if called
// from inside Excalidraw if the elements were never cached by Scene (e.g.
// for library elements).
//
// As such, before passing the elements down, we need to initialize a custom
// Scene instance and assign them to it.
//
// FIXME This is a super hacky workaround and we'll need to rewrite this soon.
const __createSceneForElementsHack__ = (
elements: readonly ExcalidrawElement[],
) => {
const scene = new Scene();
// we can't duplicate elements to regenerate ids because we need the
// orig ids when embedding. So we do another hack of not mapping element
// ids to Scene instances so that we don't override the editor elements
// mapping
scene.replaceAllElements(elements, false);
return scene;
};
const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
if (element.width <= maxWidth) {
return element;
}
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
ctx.font = getFontString({
fontFamily: element.fontFamily,
fontSize: element.fontSize,
});
let text = element.text;
const metrics = ctx.measureText(text);
if (metrics.width > maxWidth) {
// we iterate from the right, removing characters one by one instead
// of bulding the string up. This assumes that it's more likely
// your frame names will overflow by not that many characters
// (if ever), so it sohuld be faster this way.
for (let i = text.length; i > 0; i--) {
const newText = `${text.slice(0, i)}...`;
if (ctx.measureText(newText).width <= maxWidth) {
text = newText;
break;
}
}
}
return newElementWith(element, { text, width: maxWidth });
};
/**
* When exporting frames, we need to render frame labels which are currently
* being rendered in DOM when editing. Adding the labels as regular text
* elements seems like a simple hack. In the future we'll want to move to
* proper canvas rendering, even within editor (instead of DOM).
*/
const addFrameLabelsAsTextElements = (
elements: readonly NonDeletedExcalidrawElement[],
opts: Pick<AppState, "exportWithDarkMode">,
) => {
const nextElements: NonDeletedExcalidrawElement[] = [];
let frameIdx = 0;
for (const element of elements) {
if (isFrameElement(element)) {
frameIdx++;
let textElement: Mutable<ExcalidrawTextElement> = newTextElement({
x: element.x,
y: element.y - FRAME_STYLE.nameOffsetY,
fontFamily: 4,
fontSize: FRAME_STYLE.nameFontSize,
lineHeight:
FRAME_STYLE.nameLineHeight as ExcalidrawTextElement["lineHeight"],
strokeColor: opts.exportWithDarkMode
? FRAME_STYLE.nameColorDarkTheme
: FRAME_STYLE.nameColorLightTheme,
text: element.name || `Frame ${frameIdx}`,
});
textElement.y -= textElement.height;
textElement = truncateText(textElement, element.width);
nextElements.push(textElement);
}
nextElements.push(element);
}
return nextElements;
};
const getFrameRenderingConfig = (
exportingFrame: ExcalidrawFrameElement | null,
frameRendering: AppState["frameRendering"] | null,
): AppState["frameRendering"] => {
frameRendering = frameRendering || getDefaultAppState().frameRendering;
return {
enabled: exportingFrame ? true : frameRendering.enabled,
outline: exportingFrame ? false : frameRendering.outline,
name: exportingFrame ? false : frameRendering.name,
clip: exportingFrame ? true : frameRendering.clip,
};
};
export const exportToCanvas = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
@ -27,10 +147,12 @@ export const exportToCanvas = async (
exportBackground,
exportPadding = DEFAULT_EXPORT_PADDING,
viewBackgroundColor,
exportingFrame,
}: {
exportBackground: boolean;
exportPadding?: number;
viewBackgroundColor: string;
exportingFrame?: ExcalidrawFrameElement | null;
},
createCanvas: (
width: number,
@ -42,7 +164,26 @@ export const exportToCanvas = async (
return { canvas, scale: appState.exportScale };
},
) => {
const [minX, minY, width, height] = getCanvasSize(elements, exportPadding);
const tempScene = __createSceneForElementsHack__(elements);
elements = tempScene.getNonDeletedElements();
let nextElements: ExcalidrawElement[];
if (exportingFrame) {
exportPadding = 0;
nextElements = elementsOverlappingBBox({
elements,
bounds: exportingFrame,
type: "overlap",
});
} else {
nextElements = addFrameLabelsAsTextElements(elements, appState);
}
const [minX, minY, width, height] = getCanvasSize(
exportingFrame ? [exportingFrame] : getRootElements(nextElements),
exportPadding,
);
const { canvas, scale = 1 } = createCanvas(width, height);
@ -50,25 +191,27 @@ export const exportToCanvas = async (
const { imageCache } = await updateImageCache({
imageCache: new Map(),
fileIds: getInitializedImageElements(elements).map(
fileIds: getInitializedImageElements(nextElements).map(
(element) => element.fileId,
),
files,
});
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
renderStaticScene({
canvas,
rc: rough.canvas(canvas),
elements,
visibleElements: elements,
elements: nextElements,
visibleElements: nextElements,
scale,
appState: {
...appState,
frameRendering: getFrameRenderingConfig(
exportingFrame ?? null,
appState.frameRendering ?? null,
),
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding),
scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding),
scrollX: -minX + exportPadding,
scrollY: -minY + exportPadding,
zoom: defaultAppState.zoom,
shouldCacheIgnoreZoom: false,
theme: appState.exportWithDarkMode ? "dark" : "light",
@ -80,6 +223,8 @@ export const exportToCanvas = async (
},
});
tempScene.destroy();
return canvas;
};
@ -92,35 +237,65 @@ export const exportToSvg = async (
viewBackgroundColor: string;
exportWithDarkMode?: boolean;
exportEmbedScene?: boolean;
renderFrame?: boolean;
frameRendering?: AppState["frameRendering"];
},
files: BinaryFiles | null,
opts?: {
serializeAsJSON?: () => string;
renderEmbeddables?: boolean;
exportingFrame?: ExcalidrawFrameElement | null;
},
): Promise<SVGSVGElement> => {
const {
const tempScene = __createSceneForElementsHack__(elements);
elements = tempScene.getNonDeletedElements();
let {
exportPadding = DEFAULT_EXPORT_PADDING,
viewBackgroundColor,
exportScale = 1,
exportEmbedScene,
} = appState;
const { exportingFrame = null } = opts || {};
let nextElements: ExcalidrawElement[] = [];
if (exportingFrame) {
exportPadding = 0;
nextElements = elementsOverlappingBBox({
elements,
bounds: exportingFrame,
type: "overlap",
});
} else {
nextElements = addFrameLabelsAsTextElements(elements, {
exportWithDarkMode: appState.exportWithDarkMode ?? false,
});
}
let metadata = "";
// we need to serialize the "original" elements before we put them through
// the tempScene hack which duplicates and regenerates ids
if (exportEmbedScene) {
try {
metadata = await (
await import(/* webpackChunkName: "image" */ "../../src/data/image")
).encodeSvgMetadata({
text: opts?.serializeAsJSON
? opts?.serializeAsJSON?.()
: serializeAsJSON(elements, appState, files || {}, "local"),
// when embedding scene, we want to embed the origionally supplied
// elements which don't contain the temp frame labels.
// But it also requires that the exportToSvg is being supplied with
// only the elements that we're exporting, and no extra.
text: serializeAsJSON(elements, appState, files || {}, "local"),
});
} catch (error: any) {
console.error(error);
}
}
const [minX, minY, width, height] = getCanvasSize(elements, exportPadding);
const [minX, minY, width, height] = getCanvasSize(
exportingFrame ? [exportingFrame] : getRootElements(nextElements),
exportPadding,
);
// initialize SVG root
const svgRoot = document.createElementNS(SVG_NS, "svg");
@ -148,33 +323,23 @@ export const exportToSvg = async (
assetPath = `${assetPath}/dist/excalidraw-assets/`;
}
// do not apply clipping when we're exporting the whole scene
const isExportingWholeCanvas =
Scene.getScene(elements[0])?.getNonDeletedElements()?.length ===
elements.length;
const offsetX = -minX + exportPadding;
const offsetY = -minY + exportPadding;
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
const offsetX = -minX + (onlyExportingSingleFrame ? 0 : exportPadding);
const offsetY = -minY + (onlyExportingSingleFrame ? 0 : exportPadding);
const exportingFrame =
isExportingWholeCanvas || !onlyExportingSingleFrame
? undefined
: elements.find((element) => element.type === "frame");
const frameElements = getFrameElements(elements);
let exportingFrameClipPath = "";
if (exportingFrame) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(exportingFrame);
const cx = (x2 - x1) / 2 - (exportingFrame.x - x1);
const cy = (y2 - y1) / 2 - (exportingFrame.y - y1);
for (const frame of frameElements) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame);
const cx = (x2 - x1) / 2 - (frame.x - x1);
const cy = (y2 - y1) / 2 - (frame.y - y1);
exportingFrameClipPath = `<clipPath id=${exportingFrame.id}>
<rect transform="translate(${exportingFrame.x + offsetX} ${
exportingFrame.y + offsetY
}) rotate(${exportingFrame.angle} ${cx} ${cy})"
width="${exportingFrame.width}"
height="${exportingFrame.height}"
exportingFrameClipPath += `<clipPath id=${frame.id}>
<rect transform="translate(${frame.x + offsetX} ${
frame.y + offsetY
}) rotate(${frame.angle} ${cx} ${cy})"
width="${frame.width}"
height="${frame.height}"
>
</rect>
</clipPath>`;
@ -193,6 +358,10 @@ export const exportToSvg = async (
font-family: "Cascadia";
src: url("${assetPath}Cascadia.woff2");
}
@font-face {
font-family: "Assistant";
src: url("${assetPath}Assistant-Regular.woff2");
}
</style>
${exportingFrameClipPath}
</defs>
@ -210,14 +379,19 @@ export const exportToSvg = async (
}
const rsvg = rough.svg(svgRoot);
renderSceneToSvg(elements, rsvg, svgRoot, files || {}, {
renderSceneToSvg(nextElements, rsvg, svgRoot, files || {}, {
offsetX,
offsetY,
exportWithDarkMode: appState.exportWithDarkMode,
exportingFrameId: exportingFrame?.id || null,
renderEmbeddables: opts?.renderEmbeddables,
exportWithDarkMode: appState.exportWithDarkMode ?? false,
renderEmbeddables: opts?.renderEmbeddables ?? false,
frameRendering: getFrameRenderingConfig(
exportingFrame ?? null,
appState.frameRendering ?? null,
),
});
tempScene.destroy();
return svgRoot;
};
@ -226,36 +400,9 @@ const getCanvasSize = (
elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number,
): Bounds => {
// we should decide if we are exporting the whole canvas
// if so, we are not clipping elements in the frame
// and therefore, we should not do anything special
const isExportingWholeCanvas =
Scene.getScene(elements[0])?.getNonDeletedElements()?.length ===
elements.length;
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
if (!isExportingWholeCanvas || onlyExportingSingleFrame) {
const frames = elements.filter((element) => element.type === "frame");
const exportedFrameIds = frames.reduce((acc, frame) => {
acc[frame.id] = true;
return acc;
}, {} as Record<string, true>);
// elements in a frame do not affect the canvas size if we're not exporting
// the whole canvas
elements = elements.filter(
(element) => !exportedFrameIds[element.frameId ?? ""],
);
}
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const width =
distance(minX, maxX) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
const height =
distance(minY, maxY) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
const width = distance(minX, maxX) + exportPadding * 2;
const height = distance(minY, maxY) + exportPadding * 2;
return [minX, minY, width, height];
};

View File

@ -8,7 +8,7 @@ import { isBoundToContainer } from "../element/typeChecks";
import {
elementOverlapsWithFrame,
getContainingFrame,
getFrameElements,
getFrameChildren,
} from "../frame";
import { isShallowEqual } from "../utils";
import { isElementInViewport } from "../element/sizeHelpers";
@ -191,7 +191,7 @@ export const getSelectedElements = (
const elementsToInclude: ExcalidrawElement[] = [];
selectedElements.forEach((element) => {
if (element.type === "frame") {
getFrameElements(elements, element.id).forEach((e) =>
getFrameChildren(elements, element.id).forEach((e) =>
elementsToInclude.push(e),
);
}

View File

@ -14,8 +14,12 @@ exports[`export > exporting svg containing transformed images > svg export outpu
font-family: \\"Cascadia\\";
src: url(\\"https://excalidraw.com/Cascadia.woff2\\");
}
@font-face {
font-family: \\"Assistant\\";
src: url(\\"https://excalidraw.com/Assistant-Regular.woff2\\");
}
</style>
</defs>
<g transform=\\"translate(30.710678118654755 30.710678118654755) rotate(315 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\"></use></g><g transform=\\"translate(130.71067811865476 30.710678118654755) rotate(45 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, 1) translate(-50 0)\\"></use></g><g transform=\\"translate(30.710678118654755 130.71067811865476) rotate(45 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\" transform=\\"scale(1, -1) translate(0 -100)\\"></use></g><g transform=\\"translate(130.71067811865476 130.71067811865476) rotate(315 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, -1) translate(-50 -50)\\"></use></g></svg>"
<g transform=\\"translate(30.710678118654755 30.710678118654755) rotate(315 50 50)\\" data-id=\\"id1\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\"></use></g><g transform=\\"translate(130.71067811865476 30.710678118654755) rotate(45 25 25)\\" data-id=\\"id2\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, 1) translate(-50 0)\\"></use></g><g transform=\\"translate(30.710678118654755 130.71067811865476) rotate(45 50 50)\\" data-id=\\"id3\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\" transform=\\"scale(1, -1) translate(0 -100)\\"></use></g><g transform=\\"translate(130.71067811865476 130.71067811865476) rotate(315 25 25)\\" data-id=\\"id4\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, -1) translate(-50 -50)\\"></use></g></svg>"
`;

View File

@ -27,6 +27,7 @@ import * as blob from "../data/blob";
import { KEYS } from "../keys";
import { getBoundTextElementPosition } from "../element/textElement";
import { createPasteEvent } from "../clipboard";
import { cloneJSON } from "../utils";
const { h } = window;
const mouse = new Pointer("mouse");
@ -206,16 +207,14 @@ const checkElementsBoundingBox = async (
};
const checkHorizontalFlip = async (toleranceInPx: number = 0.00001) => {
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
const originalElement = cloneJSON(h.elements[0]);
h.app.actionManager.executeAction(actionFlipHorizontal);
const newElement = h.elements[0];
await checkElementsBoundingBox(originalElement, newElement, toleranceInPx);
};
const checkTwoPointsLineHorizontalFlip = async () => {
const originalElement = JSON.parse(
JSON.stringify(h.elements[0]),
) as ExcalidrawLinearElement;
const originalElement = cloneJSON(h.elements[0]) as ExcalidrawLinearElement;
h.app.actionManager.executeAction(actionFlipHorizontal);
const newElement = h.elements[0] as ExcalidrawLinearElement;
await waitFor(() => {
@ -239,9 +238,7 @@ const checkTwoPointsLineHorizontalFlip = async () => {
};
const checkTwoPointsLineVerticalFlip = async () => {
const originalElement = JSON.parse(
JSON.stringify(h.elements[0]),
) as ExcalidrawLinearElement;
const originalElement = cloneJSON(h.elements[0]) as ExcalidrawLinearElement;
h.app.actionManager.executeAction(actionFlipVertical);
const newElement = h.elements[0] as ExcalidrawLinearElement;
await waitFor(() => {
@ -268,7 +265,7 @@ const checkRotatedHorizontalFlip = async (
expectedAngle: number,
toleranceInPx: number = 0.00001,
) => {
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
const originalElement = cloneJSON(h.elements[0]);
h.app.actionManager.executeAction(actionFlipHorizontal);
const newElement = h.elements[0];
await waitFor(() => {
@ -281,7 +278,7 @@ const checkRotatedVerticalFlip = async (
expectedAngle: number,
toleranceInPx: number = 0.00001,
) => {
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
const originalElement = cloneJSON(h.elements[0]);
h.app.actionManager.executeAction(actionFlipVertical);
const newElement = h.elements[0];
await waitFor(() => {
@ -291,7 +288,7 @@ const checkRotatedVerticalFlip = async (
};
const checkVerticalFlip = async (toleranceInPx: number = 0.00001) => {
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
const originalElement = cloneJSON(h.elements[0]);
h.app.actionManager.executeAction(actionFlipVertical);
@ -300,7 +297,7 @@ const checkVerticalFlip = async (toleranceInPx: number = 0.00001) => {
};
const checkVerticalHorizontalFlip = async (toleranceInPx: number = 0.00001) => {
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
const originalElement = cloneJSON(h.elements[0]);
h.app.actionManager.executeAction(actionFlipHorizontal);
h.app.actionManager.executeAction(actionFlipVertical);

View File

@ -6,6 +6,7 @@ import {
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
FileId,
ExcalidrawFrameElement,
} from "../../element/types";
import { newElement, newTextElement, newLinearElement } from "../../element";
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
@ -136,6 +137,8 @@ export class API {
? ExcalidrawTextElement
: T extends "image"
? ExcalidrawImageElement
: T extends "frame"
? ExcalidrawFrameElement
: ExcalidrawGenericElement => {
let element: Mutable<ExcalidrawElement> = null!;

View File

@ -92,7 +92,10 @@ describe("exportToSvg", () => {
expect(passedOptionsWhenDefault).toMatchSnapshot();
});
it("with deleted elements", async () => {
// FIXME the utils.exportToSvg no longer filters out deleted elements.
// It's already supposed to be passed non-deleted elements by we're not
// type-checking for it correctly.
it.skip("with deleted elements", async () => {
await utils.exportToSvg({
...diagramFactory({
overrides: { appState: void 0 },

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,10 @@ import {
ellipseFixture,
rectangleWithLinkFixture,
} from "../fixtures/elementFixture";
import { API } from "../helpers/api";
import { exportToCanvas, exportToSvg } from "../../packages/utils";
import { FRAME_STYLE } from "../../constants";
import { prepareElementsForExport } from "../../data";
describe("exportToSvg", () => {
window.EXCALIDRAW_ASSET_PATH = "/";
@ -127,3 +131,280 @@ describe("exportToSvg", () => {
expect(svgElement.innerHTML).toMatchSnapshot();
});
});
describe("exporting frames", () => {
const getFrameNameHeight = (exportType: "canvas" | "svg") => {
const height =
FRAME_STYLE.nameFontSize * FRAME_STYLE.nameLineHeight +
FRAME_STYLE.nameOffsetY;
// canvas truncates dimensions to integers
if (exportType === "canvas") {
return Math.trunc(height);
}
return height;
};
// a few tests with exportToCanvas (where we can't inspect elements)
// ---------------------------------------------------------------------------
describe("exportToCanvas", () => {
it("exporting canvas with a single frame shouldn't crop if not exporting frame directly", async () => {
const elements = [
API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
}),
API.createElement({
type: "rectangle",
width: 100,
height: 100,
x: 100,
y: 0,
}),
];
const canvas = await exportToCanvas({
elements,
files: null,
exportPadding: 0,
});
expect(canvas.width).toEqual(200);
expect(canvas.height).toEqual(100 + getFrameNameHeight("canvas"));
});
it("exporting canvas with a single frame should crop when exporting frame directly", async () => {
const frame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
});
const elements = [
frame,
API.createElement({
type: "rectangle",
width: 100,
height: 100,
x: 100,
y: 0,
}),
];
const canvas = await exportToCanvas({
elements,
files: null,
exportPadding: 0,
exportingFrame: frame,
});
expect(canvas.width).toEqual(frame.width);
expect(canvas.height).toEqual(frame.height);
});
});
// exportToSvg (so we can test for element existence)
// ---------------------------------------------------------------------------
describe("exportToSvg", () => {
it("exporting frame should include overlapping elements, but crop to frame", async () => {
const frame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
});
const frameChild = API.createElement({
type: "rectangle",
width: 100,
height: 100,
x: 0,
y: 50,
frameId: frame.id,
});
const rectOverlapping = API.createElement({
type: "rectangle",
width: 100,
height: 100,
x: 50,
y: 0,
});
const svg = await exportToSvg({
elements: [rectOverlapping, frame, frameChild],
files: null,
exportPadding: 0,
exportingFrame: frame,
});
// frame itself isn't exported
expect(svg.querySelector(`[data-id="${frame.id}"]`)).toBeNull();
// frame child is exported
expect(svg.querySelector(`[data-id="${frameChild.id}"]`)).not.toBeNull();
// overlapping element is exported
expect(
svg.querySelector(`[data-id="${rectOverlapping.id}"]`),
).not.toBeNull();
expect(svg.getAttribute("width")).toBe(frame.width.toString());
expect(svg.getAttribute("height")).toBe(frame.height.toString());
});
it("should filter non-overlapping elements when exporting a frame", async () => {
const frame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
});
const frameChild = API.createElement({
type: "rectangle",
width: 100,
height: 100,
x: 0,
y: 50,
frameId: frame.id,
});
const elementOutside = API.createElement({
type: "rectangle",
width: 100,
height: 100,
x: 200,
y: 0,
});
const svg = await exportToSvg({
elements: [frameChild, frame, elementOutside],
files: null,
exportPadding: 0,
exportingFrame: frame,
});
// frame itself isn't exported
expect(svg.querySelector(`[data-id="${frame.id}"]`)).toBeNull();
// frame child is exported
expect(svg.querySelector(`[data-id="${frameChild.id}"]`)).not.toBeNull();
// non-overlapping element is not exported
expect(svg.querySelector(`[data-id="${elementOutside.id}"]`)).toBeNull();
expect(svg.getAttribute("width")).toBe(frame.width.toString());
expect(svg.getAttribute("height")).toBe(frame.height.toString());
});
it("should export multiple frames when selected, excluding overlapping elements", async () => {
const frame1 = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
});
const frame2 = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 200,
y: 0,
});
const frame1Child = API.createElement({
type: "rectangle",
width: 100,
height: 100,
x: 0,
y: 50,
frameId: frame1.id,
});
const frame2Child = API.createElement({
type: "rectangle",
width: 100,
height: 100,
x: 200,
y: 0,
frameId: frame2.id,
});
const frame2Overlapping = API.createElement({
type: "rectangle",
width: 100,
height: 100,
x: 350,
y: 0,
});
// low-level exportToSvg api expects elements to be pre-filtered, so let's
// use the filter we use in the editor
const { exportedElements, exportingFrame } = prepareElementsForExport(
[frame1Child, frame1, frame2Child, frame2, frame2Overlapping],
{
selectedElementIds: { [frame1.id]: true, [frame2.id]: true },
},
true,
);
const svg = await exportToSvg({
elements: exportedElements,
files: null,
exportPadding: 0,
exportingFrame,
});
// frames themselves should be exported when multiple frames selected
expect(svg.querySelector(`[data-id="${frame1.id}"]`)).not.toBeNull();
expect(svg.querySelector(`[data-id="${frame2.id}"]`)).not.toBeNull();
// children should be epxorted
expect(svg.querySelector(`[data-id="${frame1Child.id}"]`)).not.toBeNull();
expect(svg.querySelector(`[data-id="${frame2Child.id}"]`)).not.toBeNull();
// overlapping elements or non-overlapping elements should not be exported
expect(
svg.querySelector(`[data-id="${frame2Overlapping.id}"]`),
).toBeNull();
expect(svg.getAttribute("width")).toBe(
(frame2.x + frame2.width).toString(),
);
expect(svg.getAttribute("height")).toBe(
(frame2.y + frame2.height + getFrameNameHeight("svg")).toString(),
);
});
it("should render frame alone when not selected", async () => {
const frame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
});
// low-level exportToSvg api expects elements to be pre-filtered, so let's
// use the filter we use in the editor
const { exportedElements, exportingFrame } = prepareElementsForExport(
[frame],
{
selectedElementIds: {},
},
false,
);
const svg = await exportToSvg({
elements: exportedElements,
files: null,
exportPadding: 0,
exportingFrame,
});
// frame itself isn't exported
expect(svg.querySelector(`[data-id="${frame.id}"]`)).not.toBeNull();
expect(svg.getAttribute("width")).toBe(frame.width.toString());
expect(svg.getAttribute("height")).toBe(
(frame.height + getFrameNameHeight("svg")).toString(),
);
});
});
});

View File

@ -834,11 +834,18 @@ export const isOnlyExportingSingleFrame = (
);
};
/**
* supply `null` as message if non-never value is valid, you just need to
* typecheck against it
*/
export const assertNever = (
value: never,
message: string,
message: string | null,
softAssert?: boolean,
): never => {
if (!message) {
return value;
}
if (softAssert) {
console.error(message);
return value;
@ -931,3 +938,5 @@ export const isMemberOf = <T extends string>(
? collection.includes(value as T)
: collection.hasOwnProperty(value);
};
export const cloneJSON = <T>(obj: T): T => JSON.parse(JSON.stringify(obj));