From fc58e51ab3882b52bad628b51c641958a1d2421f Mon Sep 17 00:00:00 2001 From: Giacomo Debidda Date: Wed, 28 Oct 2020 20:52:53 +0100 Subject: [PATCH] Show error message when canvas to export is too big (#1256) (#2210) Co-authored-by: dwelle --- src/clipboard.ts | 24 ++----- src/components/App.tsx | 8 +-- src/components/ExportDialog.tsx | 66 +++++++++++++---- src/components/LayerUI.tsx | 29 ++++---- src/components/TopErrorBoundary.tsx | 2 +- src/css/styles.scss | 106 ++++++++++++++-------------- src/data/blob.ts | 23 ++++++ src/data/index.ts | 37 +++++----- src/errors.ts | 11 +++ src/locales/en.json | 5 ++ 10 files changed, 193 insertions(+), 118 deletions(-) create mode 100644 src/errors.ts diff --git a/src/clipboard.ts b/src/clipboard.ts index e9bff75b..625b9f1f 100644 --- a/src/clipboard.ts +++ b/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; diff --git a/src/components/App.tsx b/src/components/App.tsx index e927c7b1..8c057c35 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1022,12 +1022,12 @@ class App extends React.Component { 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 { } }; - 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, diff --git a/src/components/ExportDialog.tsx b/src/components/ExportDialog.tsx index 72d0218f..08cedbbd 100644 --- a/src/components/ExportDialog.tsx +++ b/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 ( +
+

{t("canvasError.cannotShowPreview")}

+

+ {t("canvasError.canvasTooBig")} +

+ ({t("canvasError.canvasTooBigTip")}) +
+ ); +}; + 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(null); const previewRef = useRef(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 (
-
+
+ {previewError && } +
diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 65703745..7a7c50ea 100644 --- a/src/components/LayerUI.tsx +++ b/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 }); + } } } }} diff --git a/src/components/TopErrorBoundary.tsx b/src/components/TopErrorBoundary.tsx index 71c2e040..c2a35c02 100644 --- a/src/components/TopErrorBoundary.tsx +++ b/src/components/TopErrorBoundary.tsx @@ -73,7 +73,7 @@ export class TopErrorBoundary extends React.Component< private errorSplash() { return ( -
+
{t("errorSplash.headingMain_pre")} diff --git a/src/css/styles.scss b/src/css/styles.scss index 84cefdc4..730d7e07 100644 --- a/src/css/styles.scss +++ b/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; + } + } +} diff --git a/src/data/blob.ts b/src/data/blob.ts index 8b6305d9..dcbf431c 100644 --- a/src/data/blob.ts +++ b/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 => { + 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); + } + }); +}; diff --git a/src/data/index.ts b/src/data/index.ts index 9df28cc3..a79d1dd8 100644 --- a/src/data/index.ts +++ b/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), - }); - } + 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"], - }); - } + 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, { diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 00000000..bba8007f --- /dev/null +++ b/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; + } +} diff --git a/src/locales/en.json b/src/locales/en.json index 8d7adf9d..b56a20f6 100644 --- a/src/locales/en.json +++ b/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.",