Pārlūkot izejas kodu

Show error message when canvas to export is too big (#1256) (#2210)

Co-authored-by: dwelle <luzar.david@gmail.com>
Giacomo Debidda 4 gadi atpakaļ
vecāks
revīzija
fc58e51ab3

+ 7 - 17
src/clipboard.ts

@@ -11,6 +11,7 @@ import {
   VALID_SPREADSHEET,
   MALFORMED_SPREADSHEET,
 } from "./charts";
+import { canvasToBlob } from "./data/blob";
 
 const TYPE_ELEMENTS = "excalidraw/elements";
 
@@ -157,23 +158,12 @@ export const parseClipboard = async (
   }
 };
 
-export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) =>
-  new Promise((resolve, reject) => {
-    try {
-      canvas.toBlob(async (blob) => {
-        try {
-          await navigator.clipboard.write([
-            new window.ClipboardItem({ "image/png": blob }),
-          ]);
-          resolve();
-        } catch (error) {
-          reject(error);
-        }
-      });
-    } catch (error) {
-      reject(error);
-    }
-  });
+export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
+  const blob = await canvasToBlob(canvas);
+  await navigator.clipboard.write([
+    new window.ClipboardItem({ "image/png": blob }),
+  ]);
+};
 
 export const copyTextToSystemClipboard = async (text: string | null) => {
   let copied = false;

+ 4 - 4
src/components/App.tsx

@@ -1022,12 +1022,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     copyToClipboard(this.scene.getElements(), this.state);
   };
 
-  private copyToClipboardAsPng = () => {
+  private copyToClipboardAsPng = async () => {
     const elements = this.scene.getElements();
 
     const selectedElements = getSelectedElements(elements, this.state);
     try {
-      exportCanvas(
+      await exportCanvas(
         "clipboard",
         selectedElements.length ? selectedElements : elements,
         this.state,
@@ -1040,13 +1040,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     }
   };
 
-  private copyToClipboardAsSvg = () => {
+  private copyToClipboardAsSvg = async () => {
     const selectedElements = getSelectedElements(
       this.scene.getElements(),
       this.state,
     );
     try {
-      exportCanvas(
+      await exportCanvas(
         "clipboard-svg",
         selectedElements.length ? selectedElements : this.scene.getElements(),
         this.state,

+ 54 - 12
src/components/ExportDialog.tsx

@@ -15,10 +15,24 @@ import { probablySupportsClipboardBlob } from "../clipboard";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
 import useIsMobile from "../is-mobile";
 import { Dialog } from "./Dialog";
+import { canvasToBlob } from "../data/blob";
+import { CanvasError } from "../errors";
 
 const scales = [1, 2, 3];
 const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
 
+export const ErrorCanvasPreview = () => {
+  return (
+    <div>
+      <h3>{t("canvasError.cannotShowPreview")}</h3>
+      <p>
+        <span>{t("canvasError.canvasTooBig")}</span>
+      </p>
+      <em>({t("canvasError.canvasTooBigTip")})</em>
+    </div>
+  );
+};
+
 export type ExportCB = (
   elements: readonly NonDeletedExcalidrawElement[],
   scale?: number,
@@ -47,6 +61,7 @@ const ExportModal = ({
   const someElementIsSelected = isSomeElementSelected(elements, appState);
   const [scale, setScale] = useState(defaultScale);
   const [exportSelected, setExportSelected] = useState(someElementIsSelected);
+  const [previewError, setPreviewError] = useState<Error | null>(null);
   const previewRef = useRef<HTMLDivElement>(null);
   const {
     exportBackground,
@@ -64,17 +79,42 @@ const ExportModal = ({
 
   useEffect(() => {
     const previewNode = previewRef.current;
-    const canvas = exportToCanvas(exportedElements, appState, {
-      exportBackground,
-      viewBackgroundColor,
-      exportPadding,
-      scale,
-      shouldAddWatermark,
-    });
-    previewNode?.appendChild(canvas);
-    return () => {
-      previewNode?.removeChild(canvas);
-    };
+    if (!previewNode) {
+      return;
+    }
+    try {
+      const canvas = exportToCanvas(exportedElements, appState, {
+        exportBackground,
+        viewBackgroundColor,
+        exportPadding,
+        scale,
+        shouldAddWatermark,
+      });
+
+      let isRemoved = false;
+      // if converting to blob fails, there's some problem that will
+      // likely prevent preview and export (e.g. canvas too big)
+      canvasToBlob(canvas)
+        .then(() => {
+          if (isRemoved) {
+            return;
+          }
+          setPreviewError(null);
+          previewNode.appendChild(canvas);
+        })
+        .catch((error) => {
+          console.error(error);
+          setPreviewError(new CanvasError());
+        });
+
+      return () => {
+        isRemoved = true;
+        canvas.remove();
+      };
+    } catch (error) {
+      console.error(error);
+      setPreviewError(new CanvasError());
+    }
   }, [
     appState,
     exportedElements,
@@ -87,7 +127,9 @@ const ExportModal = ({
 
   return (
     <div className="ExportDialog">
-      <div className="ExportDialog__preview" ref={previewRef}></div>
+      <div className="ExportDialog__preview" ref={previewRef}>
+        {previewError && <ErrorCanvasPreview />}
+      </div>
       <Stack.Col gap={2} align="center">
         <div className="ExportDialog__actions">
           <Stack.Row gap={2}>

+ 16 - 13
src/components/LayerUI.tsx

@@ -315,18 +315,18 @@ const LayerUI = ({
       scale,
     ) => {
       if (canvas) {
-        try {
-          await exportCanvas(type, exportedElements, appState, canvas, {
-            exportBackground: appState.exportBackground,
-            name: appState.name,
-            viewBackgroundColor: appState.viewBackgroundColor,
-            scale,
-            shouldAddWatermark: appState.shouldAddWatermark,
+        await exportCanvas(type, exportedElements, appState, canvas, {
+          exportBackground: appState.exportBackground,
+          name: appState.name,
+          viewBackgroundColor: appState.viewBackgroundColor,
+          scale,
+          shouldAddWatermark: appState.shouldAddWatermark,
+        })
+          .catch(muteFSAbortError)
+          .catch((error) => {
+            console.error(error);
+            setAppState({ errorMessage: error.message });
           });
-        } catch (error) {
-          console.error(error);
-          setAppState({ errorMessage: error.message });
-        }
       }
     };
     return (
@@ -351,8 +351,11 @@ const LayerUI = ({
                 appState,
               );
             } catch (error) {
-              console.error(error);
-              setAppState({ errorMessage: error.message });
+              if (error.name !== "AbortError") {
+                const { width, height } = canvas;
+                console.error(error, { width, height });
+                setAppState({ errorMessage: error.message });
+              }
             }
           }
         }}

+ 1 - 1
src/components/TopErrorBoundary.tsx

@@ -73,7 +73,7 @@ export class TopErrorBoundary extends React.Component<
 
   private errorSplash() {
     return (
-      <div className="ErrorSplash">
+      <div className="ErrorSplash excalidraw">
         <div className="ErrorSplash-messageContainer">
           <div className="ErrorSplash-paragraph bigger align-center">
             {t("errorSplash.headingMain_pre")}

+ 53 - 53
src/css/styles.scss

@@ -301,59 +301,6 @@
     max-height: calc(100vh - 236px);
   }
 
-  .ErrorSplash {
-    min-height: 100vh;
-    padding: 20px 0;
-    overflow: auto;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    user-select: text;
-
-    .ErrorSplash-messageContainer {
-      display: flex;
-      flex-direction: column;
-      align-items: center;
-      justify-content: center;
-
-      padding: 40px;
-      background-color: $oc-red-1;
-      border: 3px solid $oc-red-9;
-    }
-
-    .ErrorSplash-paragraph {
-      margin: 15px 0;
-      max-width: 600px;
-
-      &.align-center {
-        text-align: center;
-      }
-    }
-
-    .bigger,
-    .bigger button {
-      font-size: 1.1em;
-    }
-
-    .smaller,
-    .smaller button {
-      font-size: 0.9em;
-    }
-
-    .ErrorSplash-details {
-      display: flex;
-      flex-direction: column;
-      align-items: flex-start;
-
-      textarea {
-        width: 100%;
-        margin: 10px 0;
-        font-family: "Cascadia";
-        font-size: 0.8em;
-      }
-    }
-  }
-
   .dropdown-select {
     height: 1.5rem;
     padding: 0;
@@ -502,3 +449,56 @@
     }
   }
 }
+
+.ErrorSplash.excalidraw {
+  min-height: 100vh;
+  padding: 20px 0;
+  overflow: auto;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  user-select: text;
+
+  .ErrorSplash-messageContainer {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+
+    padding: 40px;
+    background-color: $oc-red-1;
+    border: 3px solid $oc-red-9;
+  }
+
+  .ErrorSplash-paragraph {
+    margin: 15px 0;
+    max-width: 600px;
+
+    &.align-center {
+      text-align: center;
+    }
+  }
+
+  .bigger,
+  .bigger button {
+    font-size: 1.1em;
+  }
+
+  .smaller,
+  .smaller button {
+    font-size: 0.9em;
+  }
+
+  .ErrorSplash-details {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+
+    textarea {
+      width: 100%;
+      margin: 10px 0;
+      font-family: "Cascadia";
+      font-size: 0.8em;
+    }
+  }
+}

+ 23 - 0
src/data/blob.ts

@@ -5,6 +5,7 @@ import { AppState } from "../types";
 import { LibraryData, ImportedDataState } from "./types";
 import { calculateScrollCenter } from "../scene";
 import { MIME_TYPES } from "../constants";
+import { CanvasError } from "../errors";
 
 export const parseFileContents = async (blob: Blob | File) => {
   let contents: string;
@@ -109,3 +110,25 @@ export const loadLibraryFromBlob = async (blob: Blob) => {
   }
   return data;
 };
+
+export const canvasToBlob = async (
+  canvas: HTMLCanvasElement,
+): Promise<Blob> => {
+  return new Promise((resolve, reject) => {
+    try {
+      canvas.toBlob((blob) => {
+        if (!blob) {
+          return reject(
+            new CanvasError(
+              t("canvasError.canvasTooBig"),
+              "CANVAS_POSSIBLY_TOO_BIG",
+            ),
+          );
+        }
+        resolve(blob);
+      });
+    } catch (error) {
+      reject(error);
+    }
+  });
+};

+ 20 - 19
src/data/index.ts

@@ -19,6 +19,7 @@ import { serializeAsJSON } from "./json";
 import { ExportType } from "../scene/types";
 import { restore } from "./restore";
 import { ImportedDataState } from "./types";
+import { canvasToBlob } from "./blob";
 
 export { loadFromBlob } from "./blob";
 export { saveAsJSON, loadFromJSON } from "./json";
@@ -337,28 +338,28 @@ export const exportCanvas = async (
 
   if (type === "png") {
     const fileName = `${name}.png`;
-    tempCanvas.toBlob(async (blob) => {
-      if (blob) {
-        if (appState.exportEmbedScene) {
-          blob = await (
-            await import(/* webpackChunkName: "image" */ "./image")
-          ).encodePngMetadata({
-            blob,
-            metadata: serializeAsJSON(elements, appState),
-          });
-        }
-
-        await fileSave(blob, {
-          fileName: fileName,
-          extensions: [".png"],
-        });
-      }
+    let blob = await canvasToBlob(tempCanvas);
+    if (appState.exportEmbedScene) {
+      blob = await (
+        await import(/* webpackChunkName: "image" */ "./image")
+      ).encodePngMetadata({
+        blob,
+        metadata: serializeAsJSON(elements, appState),
+      });
+    }
+
+    await fileSave(blob, {
+      fileName: fileName,
+      extensions: [".png"],
     });
   } else if (type === "clipboard") {
     try {
-      copyCanvasToClipboardAsPng(tempCanvas);
-    } catch {
-      window.alert(t("alerts.couldNotCopyToClipboard"));
+      await copyCanvasToClipboardAsPng(tempCanvas);
+    } catch (error) {
+      if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
+        throw error;
+      }
+      throw new Error(t("alerts.couldNotCopyToClipboard"));
     }
   } else if (type === "backend") {
     exportToBackend(elements, {

+ 11 - 0
src/errors.ts

@@ -0,0 +1,11 @@
+type CANVAS_ERROR_NAMES = "CANVAS_ERROR" | "CANVAS_POSSIBLY_TOO_BIG";
+export class CanvasError extends Error {
+  constructor(
+    message: string = "Couldn't export canvas.",
+    name: CANVAS_ERROR_NAMES = "CANVAS_ERROR",
+  ) {
+    super();
+    this.name = name;
+    this.message = message;
+  }
+}

+ 5 - 0
src/locales/en.json

@@ -150,6 +150,11 @@
     "lineEditor_pointSelected": "Press Delete to remove point, CtrlOrCmd+D to duplicate, or drag to move",
     "lineEditor_nothingSelected": "Select a point to move or remove, or hold Alt and click to add new points"
   },
+  "canvasError": {
+    "cannotShowPreview": "Cannot show preview",
+    "canvasTooBig": "The canvas may be too big.",
+    "canvasTooBigTip": "Tip: try moving the farthest elements a bit closer together."
+  },
   "errorSplash": {
     "headingMain_pre": "Encountered an error. Try ",
     "headingMain_button": "reloading the page.",