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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -40,7 +40,7 @@ import {
VerticalAlign, VerticalAlign,
} from "../element/types"; } from "../element/types";
import { MarkOptional } from "../utility-types"; import { MarkOptional } from "../utility-types";
import { assertNever, getFontString } from "../utils"; import { assertNever, cloneJSON, getFontString } from "../utils";
import { getSizeFromPoints } from "../points"; import { getSizeFromPoints } from "../points";
import { randomId } from "../random"; 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. // 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 endPointIndex = linearElement.points.length - 1;
const delta = 0.5; 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 // left to right so shift the arrow towards right
if ( if (
linearElement.points[endPointIndex][0] > linearElement.points[endPointIndex][0] >
@ -439,9 +440,7 @@ export const convertToExcalidrawElements = (
if (!elementsSkeleton) { if (!elementsSkeleton) {
return []; return [];
} }
const elements: ExcalidrawElementSkeleton[] = JSON.parse( const elements = cloneJSON(elementsSkeleton);
JSON.stringify(elementsSkeleton),
);
const elementStore = new ElementStore(); const elementStore = new ElementStore();
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>(); const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
const oldToNewElementIdMap = new Map<string, string>(); const oldToNewElementIdMap = new Map<string, string>();

View File

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

View File

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

View File

@ -201,24 +201,52 @@ export const groupByFrames = (elements: readonly ExcalidrawElement[]) => {
for (const element of elements) { for (const element of elements) {
const frameId = isFrameElement(element) ? element.id : element.frameId; const frameId = isFrameElement(element) ? element.id : element.frameId;
if (frameId && !frameElementsMap.has(frameId)) { if (frameId && !frameElementsMap.has(frameId)) {
frameElementsMap.set(frameId, getFrameElements(elements, frameId)); frameElementsMap.set(frameId, getFrameChildren(elements, frameId));
} }
} }
return frameElementsMap; return frameElementsMap;
}; };
export const getFrameElements = ( export const getFrameChildren = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
frameId: string, frameId: string,
) => allElements.filter((element) => element.frameId === frameId); ) => 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 = ( export const getElementsInResizingFrame = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameElement,
appState: AppState, appState: AppState,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const prevElementsInFrame = getFrameElements(allElements, frame.id); const prevElementsInFrame = getFrameChildren(allElements, frame.id);
const nextElementsInFrame = new Set<ExcalidrawElement>(prevElementsInFrame); const nextElementsInFrame = new Set<ExcalidrawElement>(prevElementsInFrame);
const elementsCompletelyInFrame = new Set([ const elementsCompletelyInFrame = new Set([
@ -449,7 +477,7 @@ export const removeAllElementsFromFrame = (
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameElement,
appState: AppState, appState: AppState,
) => { ) => {
const elementsInFrame = getFrameElements(allElements, frame.id); const elementsInFrame = getFrameChildren(allElements, frame.id);
return removeElementsFromFrame(allElements, elementsInFrame, appState); return removeElementsFromFrame(allElements, elementsInFrame, appState);
}; };

View File

@ -4,7 +4,11 @@ import {
} from "../scene/export"; } from "../scene/export";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { AppState, BinaryFiles } from "../types"; import { AppState, BinaryFiles } from "../types";
import { ExcalidrawElement, NonDeleted } from "../element/types"; import {
ExcalidrawElement,
ExcalidrawFrameElement,
NonDeleted,
} from "../element/types";
import { restore } from "../data/restore"; import { restore } from "../data/restore";
import { MIME_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
import { encodePngMetadata } from "../data/image"; import { encodePngMetadata } from "../data/image";
@ -14,24 +18,6 @@ import {
copyTextToSystemClipboard, copyTextToSystemClipboard,
copyToClipboard, copyToClipboard,
} from "../clipboard"; } 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 }; export { MIME_TYPES };
@ -40,6 +26,7 @@ type ExportOpts = {
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>; appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
files: BinaryFiles | null; files: BinaryFiles | null;
maxWidthOrHeight?: number; maxWidthOrHeight?: number;
exportingFrame?: ExcalidrawFrameElement | null;
getDimensions?: ( getDimensions?: (
width: number, width: number,
height: number, height: number,
@ -53,6 +40,7 @@ export const exportToCanvas = ({
maxWidthOrHeight, maxWidthOrHeight,
getDimensions, getDimensions,
exportPadding, exportPadding,
exportingFrame,
}: ExportOpts & { }: ExportOpts & {
exportPadding?: number; exportPadding?: number;
}) => { }) => {
@ -63,10 +51,10 @@ export const exportToCanvas = ({
); );
const { exportBackground, viewBackgroundColor } = restoredAppState; const { exportBackground, viewBackgroundColor } = restoredAppState;
return _exportToCanvas( return _exportToCanvas(
passElementsSafely(restoredElements), restoredElements,
{ ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 }, { ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
files || {}, files || {},
{ exportBackground, exportPadding, viewBackgroundColor }, { exportBackground, exportPadding, viewBackgroundColor, exportingFrame },
(width: number, height: number) => { (width: number, height: number) => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
@ -135,10 +123,8 @@ export const exportToBlob = async (
}; };
} }
const canvas = await exportToCanvas({ const canvas = await exportToCanvas(opts);
...opts,
elements: passElementsSafely(opts.elements),
});
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8; quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -179,6 +165,7 @@ export const exportToSvg = async ({
files = {}, files = {},
exportPadding, exportPadding,
renderEmbeddables, renderEmbeddables,
exportingFrame,
}: Omit<ExportOpts, "getDimensions"> & { }: Omit<ExportOpts, "getDimensions"> & {
exportPadding?: number; exportPadding?: number;
renderEmbeddables?: boolean; renderEmbeddables?: boolean;
@ -194,20 +181,10 @@ export const exportToSvg = async ({
exportPadding, exportPadding,
}; };
return _exportToSvg( return _exportToSvg(restoredElements, exportAppState, files, {
passElementsSafely(restoredElements), exportingFrame,
exportAppState, renderEmbeddables,
files, });
{
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 ( export const exportToClipboard = async (

View File

@ -6,13 +6,14 @@ import type {
} from "../element/types"; } from "../element/types";
import { import {
isArrowElement, isArrowElement,
isExcalidrawElement,
isFreeDrawElement, isFreeDrawElement,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { isValueInRange, rotatePoint } from "../math"; import { isValueInRange, rotatePoint } from "../math";
import type { Point } from "../types"; import type { Point } from "../types";
import { Bounds } from "../element/bounds"; import { Bounds, getElementBounds } from "../element/bounds";
type Element = NonDeletedExcalidrawElement; type Element = NonDeletedExcalidrawElement;
type Elements = readonly NonDeletedExcalidrawElement[]; type Elements = readonly NonDeletedExcalidrawElement[];
@ -146,7 +147,7 @@ export const elementsOverlappingBBox = ({
errorMargin = 0, errorMargin = 0,
}: { }: {
elements: Elements; elements: Elements;
bounds: Bounds; bounds: Bounds | ExcalidrawElement;
/** safety offset. Defaults to 0. */ /** safety offset. Defaults to 0. */
errorMargin?: number; errorMargin?: number;
/** /**
@ -156,6 +157,9 @@ export const elementsOverlappingBBox = ({
**/ **/
type: "overlap" | "contain" | "inside"; type: "overlap" | "contain" | "inside";
}) => { }) => {
if (isExcalidrawElement(bounds)) {
bounds = getElementBounds(bounds);
}
const adjustedBBox: Bounds = [ const adjustedBBox: Bounds = [
bounds[0] - errorMargin, bounds[0] - errorMargin,
bounds[1] - 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 type { RoughSVG } from "roughjs/bin/svg";
import { StaticCanvasRenderConfig } from "../scene/types"; 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 { getCornerRadius, isPathALoop, isRightAngle } from "../math";
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { import {
@ -589,11 +595,7 @@ export const renderElement = (
) => { ) => {
switch (element.type) { switch (element.type) {
case "frame": { case "frame": {
if ( if (appState.frameRendering.enabled && appState.frameRendering.outline) {
!renderConfig.isExporting &&
appState.frameRendering.enabled &&
appState.frameRendering.outline
) {
context.save(); context.save();
context.translate( context.translate(
element.x + appState.scrollX, element.x + appState.scrollX,
@ -601,7 +603,7 @@ export const renderElement = (
); );
context.fillStyle = "rgba(0, 0, 200, 0.04)"; 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; context.strokeStyle = FRAME_STYLE.strokeColor;
if (FRAME_STYLE.radius && context.roundRect) { if (FRAME_STYLE.radius && context.roundRect) {
@ -841,10 +843,13 @@ const maybeWrapNodesInFrameClipPath = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
root: SVGElement, root: SVGElement,
nodes: SVGElement[], nodes: SVGElement[],
exportedFrameId?: string | null, frameRendering: AppState["frameRendering"],
) => { ) => {
if (!frameRendering.enabled || !frameRendering.clip) {
return null;
}
const frame = getContainingFrame(element); const frame = getContainingFrame(element);
if (frame && frame.id === exportedFrameId) { if (frame) {
const g = root.ownerDocument!.createElementNS(SVG_NS, "g"); const g = root.ownerDocument!.createElementNS(SVG_NS, "g");
g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`); g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`);
nodes.forEach((node) => g.appendChild(node)); nodes.forEach((node) => g.appendChild(node));
@ -861,9 +866,11 @@ export const renderElementToSvg = (
files: BinaryFiles, files: BinaryFiles,
offsetX: number, offsetX: number,
offsetY: number, offsetY: number,
exportWithDarkMode?: boolean, renderConfig: {
exportingFrameId?: string | null, exportWithDarkMode: boolean;
renderEmbeddables?: boolean, renderEmbeddables: boolean;
frameRendering: AppState["frameRendering"];
},
) => { ) => {
const offset = { x: offsetX, y: offsetY }; const offset = { x: offsetX, y: offsetY };
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
@ -897,6 +904,13 @@ export const renderElementToSvg = (
root = anchorTag; root = anchorTag;
} }
const addToRoot = (node: SVGElement, element: ExcalidrawElement) => {
if (isTestEnv()) {
node.setAttribute("data-id", element.id);
}
root.appendChild(node);
};
const opacity = const opacity =
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000; ((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
@ -931,10 +945,10 @@ export const renderElementToSvg = (
element, element,
root, root,
[node], [node],
exportingFrameId, renderConfig.frameRendering,
); );
g ? root.appendChild(g) : root.appendChild(node); addToRoot(g || node, element);
break; break;
} }
case "embeddable": { case "embeddable": {
@ -957,7 +971,7 @@ export const renderElementToSvg = (
offsetY || 0 offsetY || 0
}) rotate(${degree} ${cx} ${cy})`, }) rotate(${degree} ${cx} ${cy})`,
); );
root.appendChild(node); addToRoot(node, element);
const label: ExcalidrawElement = const label: ExcalidrawElement =
createPlaceholderEmbeddableLabel(element); createPlaceholderEmbeddableLabel(element);
@ -968,9 +982,7 @@ export const renderElementToSvg = (
files, files,
label.x + offset.x - element.x, label.x + offset.x - element.x,
label.y + offset.y - element.y, label.y + offset.y - element.y,
exportWithDarkMode, renderConfig,
exportingFrameId,
renderEmbeddables,
); );
// render embeddable element + iframe // render embeddable element + iframe
@ -999,7 +1011,10 @@ export const renderElementToSvg = (
// if rendering embeddables explicitly disabled or // if rendering embeddables explicitly disabled or
// embedding documents via srcdoc (which doesn't seem to work for SVGs) // embedding documents via srcdoc (which doesn't seem to work for SVGs)
// replace with a link instead // 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"); const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
anchorTag.setAttribute("href", normalizeLink(element.link || "")); anchorTag.setAttribute("href", normalizeLink(element.link || ""));
anchorTag.setAttribute("target", "_blank"); anchorTag.setAttribute("target", "_blank");
@ -1033,8 +1048,7 @@ export const renderElementToSvg = (
embeddableNode.appendChild(foreignObject); embeddableNode.appendChild(foreignObject);
} }
addToRoot(embeddableNode, element);
root.appendChild(embeddableNode);
break; break;
} }
case "line": case "line":
@ -1119,12 +1133,13 @@ export const renderElementToSvg = (
element, element,
root, root,
[group, maskPath], [group, maskPath],
exportingFrameId, renderConfig.frameRendering,
); );
if (g) { if (g) {
addToRoot(g, element);
root.appendChild(g); root.appendChild(g);
} else { } else {
root.appendChild(group); addToRoot(group, element);
root.append(maskPath); root.append(maskPath);
} }
break; break;
@ -1158,10 +1173,10 @@ export const renderElementToSvg = (
element, element,
root, root,
[node], [node],
exportingFrameId, renderConfig.frameRendering,
); );
g ? root.appendChild(g) : root.appendChild(node); addToRoot(g || node, element);
break; break;
} }
case "image": { case "image": {
@ -1191,7 +1206,10 @@ export const renderElementToSvg = (
use.setAttribute("href", `#${symbolId}`); use.setAttribute("href", `#${symbolId}`);
// in dark theme, revert the image color filter // 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); use.setAttribute("filter", IMAGE_INVERT_FILTER);
} }
@ -1227,14 +1245,39 @@ export const renderElementToSvg = (
element, element,
root, root,
[g], [g],
exportingFrameId, renderConfig.frameRendering,
); );
clipG ? root.appendChild(clipG) : root.appendChild(g); addToRoot(clipG || g, element);
} }
break; break;
} }
// frames are not rendered and only acts as a container // frames are not rendered and only acts as a container
case "frame": { 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; break;
} }
default: { default: {
@ -1288,10 +1331,10 @@ export const renderElementToSvg = (
element, element,
root, root,
[node], [node],
exportingFrameId, renderConfig.frameRendering,
); );
g ? root.appendChild(g) : root.appendChild(node); addToRoot(g || node, element);
} else { } else {
// @ts-ignore // @ts-ignore
throw new Error(`Unimplemented type ${element.type}`); throw new Error(`Unimplemented type ${element.type}`);

View File

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

View File

@ -66,16 +66,29 @@ class Scene {
private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>(); private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>();
private static sceneMapById = new Map<string, 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 (isIdKey(elementKey)) {
if (!mapElementIds) {
return;
}
// for cases where we don't have access to the element object // for cases where we don't have access to the element object
// (e.g. restore serialized appState with id references) // (e.g. restore serialized appState with id references)
this.sceneMapById.set(elementKey, scene); this.sceneMapById.set(elementKey, scene);
} else { } else {
this.sceneMapByElement.set(elementKey, scene); this.sceneMapByElement.set(elementKey, scene);
// if mapping element objects, also cache the id string when later if (!mapElementIds) {
// looking up by id alone // if mapping element objects, also cache the id string when later
this.sceneMapById.set(elementKey.id, scene); // looking up by id alone
this.sceneMapById.set(elementKey.id, scene);
}
} }
} }
@ -217,7 +230,10 @@ class Scene {
return didChange; return didChange;
} }
replaceAllElements(nextElements: readonly ExcalidrawElement[]) { replaceAllElements(
nextElements: readonly ExcalidrawElement[],
mapElementIds = true,
) {
this.elements = nextElements; this.elements = nextElements;
const nextFrames: ExcalidrawFrameElement[] = []; const nextFrames: ExcalidrawFrameElement[] = [];
this.elementsMap.clear(); this.elementsMap.clear();

View File

@ -1,24 +1,144 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { NonDeletedExcalidrawElement } from "../element/types"; import {
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { import {
Bounds, Bounds,
getCommonBounds, getCommonBounds,
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "../element/bounds"; } from "../element/bounds";
import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene"; import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene";
import { distance, isOnlyExportingSingleFrame } from "../utils"; import { distance, getFontString } from "../utils";
import { AppState, BinaryFiles } from "../types"; 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 { getDefaultAppState } from "../appState";
import { serializeAsJSON } from "../data/json"; import { serializeAsJSON } from "../data/json";
import { import {
getInitializedImageElements, getInitializedImageElements,
updateImageCache, updateImageCache,
} from "../element/image"; } 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"; import Scene from "./Scene";
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`; 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 ( export const exportToCanvas = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
@ -27,10 +147,12 @@ export const exportToCanvas = async (
exportBackground, exportBackground,
exportPadding = DEFAULT_EXPORT_PADDING, exportPadding = DEFAULT_EXPORT_PADDING,
viewBackgroundColor, viewBackgroundColor,
exportingFrame,
}: { }: {
exportBackground: boolean; exportBackground: boolean;
exportPadding?: number; exportPadding?: number;
viewBackgroundColor: string; viewBackgroundColor: string;
exportingFrame?: ExcalidrawFrameElement | null;
}, },
createCanvas: ( createCanvas: (
width: number, width: number,
@ -42,7 +164,26 @@ export const exportToCanvas = async (
return { canvas, scale: appState.exportScale }; 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); const { canvas, scale = 1 } = createCanvas(width, height);
@ -50,25 +191,27 @@ export const exportToCanvas = async (
const { imageCache } = await updateImageCache({ const { imageCache } = await updateImageCache({
imageCache: new Map(), imageCache: new Map(),
fileIds: getInitializedImageElements(elements).map( fileIds: getInitializedImageElements(nextElements).map(
(element) => element.fileId, (element) => element.fileId,
), ),
files, files,
}); });
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
renderStaticScene({ renderStaticScene({
canvas, canvas,
rc: rough.canvas(canvas), rc: rough.canvas(canvas),
elements, elements: nextElements,
visibleElements: elements, visibleElements: nextElements,
scale, scale,
appState: { appState: {
...appState, ...appState,
frameRendering: getFrameRenderingConfig(
exportingFrame ?? null,
appState.frameRendering ?? null,
),
viewBackgroundColor: exportBackground ? viewBackgroundColor : null, viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding), scrollX: -minX + exportPadding,
scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding), scrollY: -minY + exportPadding,
zoom: defaultAppState.zoom, zoom: defaultAppState.zoom,
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
theme: appState.exportWithDarkMode ? "dark" : "light", theme: appState.exportWithDarkMode ? "dark" : "light",
@ -80,6 +223,8 @@ export const exportToCanvas = async (
}, },
}); });
tempScene.destroy();
return canvas; return canvas;
}; };
@ -92,35 +237,65 @@ export const exportToSvg = async (
viewBackgroundColor: string; viewBackgroundColor: string;
exportWithDarkMode?: boolean; exportWithDarkMode?: boolean;
exportEmbedScene?: boolean; exportEmbedScene?: boolean;
renderFrame?: boolean; frameRendering?: AppState["frameRendering"];
}, },
files: BinaryFiles | null, files: BinaryFiles | null,
opts?: { opts?: {
serializeAsJSON?: () => string;
renderEmbeddables?: boolean; renderEmbeddables?: boolean;
exportingFrame?: ExcalidrawFrameElement | null;
}, },
): Promise<SVGSVGElement> => { ): Promise<SVGSVGElement> => {
const { const tempScene = __createSceneForElementsHack__(elements);
elements = tempScene.getNonDeletedElements();
let {
exportPadding = DEFAULT_EXPORT_PADDING, exportPadding = DEFAULT_EXPORT_PADDING,
viewBackgroundColor, viewBackgroundColor,
exportScale = 1, exportScale = 1,
exportEmbedScene, exportEmbedScene,
} = appState; } = 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 = ""; let metadata = "";
// we need to serialize the "original" elements before we put them through
// the tempScene hack which duplicates and regenerates ids
if (exportEmbedScene) { if (exportEmbedScene) {
try { try {
metadata = await ( metadata = await (
await import(/* webpackChunkName: "image" */ "../../src/data/image") await import(/* webpackChunkName: "image" */ "../../src/data/image")
).encodeSvgMetadata({ ).encodeSvgMetadata({
text: opts?.serializeAsJSON // when embedding scene, we want to embed the origionally supplied
? opts?.serializeAsJSON?.() // elements which don't contain the temp frame labels.
: serializeAsJSON(elements, appState, files || {}, "local"), // 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) { } catch (error: any) {
console.error(error); 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 // initialize SVG root
const svgRoot = document.createElementNS(SVG_NS, "svg"); const svgRoot = document.createElementNS(SVG_NS, "svg");
@ -148,33 +323,23 @@ export const exportToSvg = async (
assetPath = `${assetPath}/dist/excalidraw-assets/`; assetPath = `${assetPath}/dist/excalidraw-assets/`;
} }
// do not apply clipping when we're exporting the whole scene const offsetX = -minX + exportPadding;
const isExportingWholeCanvas = const offsetY = -minY + exportPadding;
Scene.getScene(elements[0])?.getNonDeletedElements()?.length ===
elements.length;
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements); const frameElements = getFrameElements(elements);
const offsetX = -minX + (onlyExportingSingleFrame ? 0 : exportPadding);
const offsetY = -minY + (onlyExportingSingleFrame ? 0 : exportPadding);
const exportingFrame =
isExportingWholeCanvas || !onlyExportingSingleFrame
? undefined
: elements.find((element) => element.type === "frame");
let exportingFrameClipPath = ""; let exportingFrameClipPath = "";
if (exportingFrame) { for (const frame of frameElements) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(exportingFrame); const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame);
const cx = (x2 - x1) / 2 - (exportingFrame.x - x1); const cx = (x2 - x1) / 2 - (frame.x - x1);
const cy = (y2 - y1) / 2 - (exportingFrame.y - y1); const cy = (y2 - y1) / 2 - (frame.y - y1);
exportingFrameClipPath = `<clipPath id=${exportingFrame.id}> exportingFrameClipPath += `<clipPath id=${frame.id}>
<rect transform="translate(${exportingFrame.x + offsetX} ${ <rect transform="translate(${frame.x + offsetX} ${
exportingFrame.y + offsetY frame.y + offsetY
}) rotate(${exportingFrame.angle} ${cx} ${cy})" }) rotate(${frame.angle} ${cx} ${cy})"
width="${exportingFrame.width}" width="${frame.width}"
height="${exportingFrame.height}" height="${frame.height}"
> >
</rect> </rect>
</clipPath>`; </clipPath>`;
@ -193,6 +358,10 @@ export const exportToSvg = async (
font-family: "Cascadia"; font-family: "Cascadia";
src: url("${assetPath}Cascadia.woff2"); src: url("${assetPath}Cascadia.woff2");
} }
@font-face {
font-family: "Assistant";
src: url("${assetPath}Assistant-Regular.woff2");
}
</style> </style>
${exportingFrameClipPath} ${exportingFrameClipPath}
</defs> </defs>
@ -210,14 +379,19 @@ export const exportToSvg = async (
} }
const rsvg = rough.svg(svgRoot); const rsvg = rough.svg(svgRoot);
renderSceneToSvg(elements, rsvg, svgRoot, files || {}, { renderSceneToSvg(nextElements, rsvg, svgRoot, files || {}, {
offsetX, offsetX,
offsetY, offsetY,
exportWithDarkMode: appState.exportWithDarkMode, exportWithDarkMode: appState.exportWithDarkMode ?? false,
exportingFrameId: exportingFrame?.id || null, renderEmbeddables: opts?.renderEmbeddables ?? false,
renderEmbeddables: opts?.renderEmbeddables, frameRendering: getFrameRenderingConfig(
exportingFrame ?? null,
appState.frameRendering ?? null,
),
}); });
tempScene.destroy();
return svgRoot; return svgRoot;
}; };
@ -226,36 +400,9 @@ const getCanvasSize = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number, exportPadding: number,
): Bounds => { ): 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 [minX, minY, maxX, maxY] = getCommonBounds(elements);
const width = const width = distance(minX, maxX) + exportPadding * 2;
distance(minX, maxX) + (onlyExportingSingleFrame ? 0 : exportPadding * 2); const height = distance(minY, maxY) + exportPadding * 2;
const height =
distance(minY, maxY) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
return [minX, minY, width, height]; return [minX, minY, width, height];
}; };

View File

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

View File

@ -14,8 +14,12 @@ exports[`export > exporting svg containing transformed images > svg export outpu
font-family: \\"Cascadia\\"; font-family: \\"Cascadia\\";
src: url(\\"https://excalidraw.com/Cascadia.woff2\\"); src: url(\\"https://excalidraw.com/Cascadia.woff2\\");
} }
@font-face {
font-family: \\"Assistant\\";
src: url(\\"https://excalidraw.com/Assistant-Regular.woff2\\");
}
</style> </style>
</defs> </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 { KEYS } from "../keys";
import { getBoundTextElementPosition } from "../element/textElement"; import { getBoundTextElementPosition } from "../element/textElement";
import { createPasteEvent } from "../clipboard"; import { createPasteEvent } from "../clipboard";
import { cloneJSON } from "../utils";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@ -206,16 +207,14 @@ const checkElementsBoundingBox = async (
}; };
const checkHorizontalFlip = async (toleranceInPx: number = 0.00001) => { 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); h.app.actionManager.executeAction(actionFlipHorizontal);
const newElement = h.elements[0]; const newElement = h.elements[0];
await checkElementsBoundingBox(originalElement, newElement, toleranceInPx); await checkElementsBoundingBox(originalElement, newElement, toleranceInPx);
}; };
const checkTwoPointsLineHorizontalFlip = async () => { const checkTwoPointsLineHorizontalFlip = async () => {
const originalElement = JSON.parse( const originalElement = cloneJSON(h.elements[0]) as ExcalidrawLinearElement;
JSON.stringify(h.elements[0]),
) as ExcalidrawLinearElement;
h.app.actionManager.executeAction(actionFlipHorizontal); h.app.actionManager.executeAction(actionFlipHorizontal);
const newElement = h.elements[0] as ExcalidrawLinearElement; const newElement = h.elements[0] as ExcalidrawLinearElement;
await waitFor(() => { await waitFor(() => {
@ -239,9 +238,7 @@ const checkTwoPointsLineHorizontalFlip = async () => {
}; };
const checkTwoPointsLineVerticalFlip = async () => { const checkTwoPointsLineVerticalFlip = async () => {
const originalElement = JSON.parse( const originalElement = cloneJSON(h.elements[0]) as ExcalidrawLinearElement;
JSON.stringify(h.elements[0]),
) as ExcalidrawLinearElement;
h.app.actionManager.executeAction(actionFlipVertical); h.app.actionManager.executeAction(actionFlipVertical);
const newElement = h.elements[0] as ExcalidrawLinearElement; const newElement = h.elements[0] as ExcalidrawLinearElement;
await waitFor(() => { await waitFor(() => {
@ -268,7 +265,7 @@ const checkRotatedHorizontalFlip = async (
expectedAngle: number, expectedAngle: number,
toleranceInPx: number = 0.00001, 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(actionFlipHorizontal);
const newElement = h.elements[0]; const newElement = h.elements[0];
await waitFor(() => { await waitFor(() => {
@ -281,7 +278,7 @@ const checkRotatedVerticalFlip = async (
expectedAngle: number, expectedAngle: number,
toleranceInPx: number = 0.00001, toleranceInPx: number = 0.00001,
) => { ) => {
const originalElement = JSON.parse(JSON.stringify(h.elements[0])); const originalElement = cloneJSON(h.elements[0]);
h.app.actionManager.executeAction(actionFlipVertical); h.app.actionManager.executeAction(actionFlipVertical);
const newElement = h.elements[0]; const newElement = h.elements[0];
await waitFor(() => { await waitFor(() => {
@ -291,7 +288,7 @@ const checkRotatedVerticalFlip = async (
}; };
const checkVerticalFlip = async (toleranceInPx: number = 0.00001) => { 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); h.app.actionManager.executeAction(actionFlipVertical);
@ -300,7 +297,7 @@ const checkVerticalFlip = async (toleranceInPx: number = 0.00001) => {
}; };
const checkVerticalHorizontalFlip = 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(actionFlipHorizontal);
h.app.actionManager.executeAction(actionFlipVertical); h.app.actionManager.executeAction(actionFlipVertical);

View File

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

View File

@ -92,7 +92,10 @@ describe("exportToSvg", () => {
expect(passedOptionsWhenDefault).toMatchSnapshot(); 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({ await utils.exportToSvg({
...diagramFactory({ ...diagramFactory({
overrides: { appState: void 0 }, overrides: { appState: void 0 },

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,10 @@ import {
ellipseFixture, ellipseFixture,
rectangleWithLinkFixture, rectangleWithLinkFixture,
} from "../fixtures/elementFixture"; } 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", () => { describe("exportToSvg", () => {
window.EXCALIDRAW_ASSET_PATH = "/"; window.EXCALIDRAW_ASSET_PATH = "/";
@ -127,3 +131,280 @@ describe("exportToSvg", () => {
expect(svgElement.innerHTML).toMatchSnapshot(); 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 = ( export const assertNever = (
value: never, value: never,
message: string, message: string | null,
softAssert?: boolean, softAssert?: boolean,
): never => { ): never => {
if (!message) {
return value;
}
if (softAssert) { if (softAssert) {
console.error(message); console.error(message);
return value; return value;
@ -931,3 +938,5 @@ export const isMemberOf = <T extends string>(
? collection.includes(value as T) ? collection.includes(value as T)
: collection.hasOwnProperty(value); : collection.hasOwnProperty(value);
}; };
export const cloneJSON = <T>(obj: T): T => JSON.parse(JSON.stringify(obj));