Kaynağa Gözat

feat: render frames on export (#7210)

David Luzar 1 yıl önce
ebeveyn
işleme
864c0b3ea8

+ 5 - 6
excalidraw-app/tests/reconciliation.test.ts

@@ -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,
           ),
         ),

+ 24 - 20
src/actions/actionClipboard.tsx

@@ -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,
     });
+
+    const { exportedElements, exportingFrame } = prepareElementsForExport(
+      elements,
+      appState,
+      true,
+    );
     try {
-      await exportCanvas(
-        "clipboard",
-        selectedElements.length
-          ? selectedElements
-          : getNonDeletedElements(elements),
-        appState,
-        app.files,
-        appState,
-      );
+      await exportCanvas("clipboard", exportedElements, appState, app.files, {
+        ...appState,
+        exportingFrame,
+      });
       return {
         appState: {
           ...appState,

+ 3 - 3
src/actions/actionDuplicateSelection.tsx

@@ -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([

+ 2 - 2
src/actions/actionFrame.ts

@@ -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));

+ 13 - 5
src/actions/actionStyles.ts

@@ -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,

+ 28 - 26
src/components/App.tsx

@@ -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,
         );

+ 69 - 34
src/components/ImageExportDialog.tsx

@@ -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 appProps = useAppProps();
-  const [projectName, setProjectName] = useState(appState.name);
-
-  const someElementIsSelected = isSomeElementSelected(elements, appState);
+  const hasSelection = isSomeElementSelected(
+    elementsSnapshot,
+    appStateSnapshot,
+  );
 
-  const [exportSelected, setExportSelected] = useState(someElementIsSelected);
+  const appProps = useAppProps();
+  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(
+    appStateSnapshot.exportEmbedScene,
   );
-  const [embedScene, setEmbedScene] = useState(appState.exportEmbedScene);
-  const [exportScale, setExportScale] = useState(appState.exportScale);
+  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}

+ 4 - 1
src/components/LayerUI.tsx

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

+ 7 - 1
src/constants.ts

@@ -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";

+ 66 - 2
src/data/index.ts

@@ -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") {

+ 2 - 1
src/data/library.ts

@@ -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

+ 12 - 12
src/data/resave.ts

@@ -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,
-    {
-      exportBackground,
-      viewBackgroundColor,
-      name,
-      fileHandle,
-    },
+    false,
   );
 
+  await exportCanvas(fileHandleType, exportedElements, appState, files, {
+    exportBackground,
+    viewBackgroundColor,
+    name,
+    fileHandle,
+    exportingFrame,
+  });
+
   return { fileHandle };
 };

+ 4 - 5
src/data/transform.ts

@@ -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>();

+ 1 - 1
src/element/embeddable.ts

@@ -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)) {

+ 27 - 11
src/element/typeChecks.ts

@@ -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 = (

+ 32 - 4
src/frame.ts

@@ -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);
 };
 

+ 16 - 39
src/packages/utils.ts

@@ -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,
-    {
-      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"),
-    },
-  );
+  return _exportToSvg(restoredElements, exportAppState, files, {
+    exportingFrame,
+    renderEmbeddables,
+  });
 };
 
 export const exportToClipboard = async (

+ 6 - 2
src/packages/withinBounds.ts

@@ -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,

+ 73 - 30
src/renderer/renderElement.ts

@@ -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}`);

+ 24 - 30
src/renderer/renderScene.ts

@@ -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.enabled &&
+          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.enabled &&
+          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);

+ 21 - 5
src/scene/Scene.ts

@@ -66,16 +66,29 @@ 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 mapping element objects, also cache the id string when later
-      // looking up by id alone
-      this.sceneMapById.set(elementKey.id, 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);
+      }
     }
   }
 
@@ -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();

+ 222 - 75
src/scene/export.ts

@@ -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);
-
-    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}"
+  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=${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];
 };

+ 2 - 2
src/scene/selection.ts

@@ -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),
         );
       }

+ 5 - 1
src/tests/__snapshots__/export.test.tsx.snap

@@ -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>"
 `;

+ 8 - 11
src/tests/flip.test.tsx

@@ -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);

+ 3 - 0
src/tests/helpers/api.ts

@@ -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!;
 

+ 4 - 1
src/tests/packages/utils.test.ts

@@ -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 },

Dosya farkı çok büyük olduğundan ihmal edildi
+ 10 - 0
src/tests/scene/__snapshots__/export.test.ts.snap


+ 281 - 0
src/tests/scene/export.test.ts

@@ -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(),
+      );
+    });
+  });
+});

+ 10 - 1
src/utils.ts

@@ -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));

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor