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

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Giacomo Debidda 2020-10-28 20:52:53 +01:00 committed by GitHub
parent 5c26bd19d7
commit fc58e51ab3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 193 additions and 118 deletions

View File

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

View File

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

View File

@ -15,10 +15,24 @@ import { probablySupportsClipboardBlob } from "../clipboard";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import useIsMobile from "../is-mobile"; import useIsMobile from "../is-mobile";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { canvasToBlob } from "../data/blob";
import { CanvasError } from "../errors";
const scales = [1, 2, 3]; const scales = [1, 2, 3];
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1; 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 = ( export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
scale?: number, scale?: number,
@ -47,6 +61,7 @@ const ExportModal = ({
const someElementIsSelected = isSomeElementSelected(elements, appState); const someElementIsSelected = isSomeElementSelected(elements, appState);
const [scale, setScale] = useState(defaultScale); const [scale, setScale] = useState(defaultScale);
const [exportSelected, setExportSelected] = useState(someElementIsSelected); const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const [previewError, setPreviewError] = useState<Error | null>(null);
const previewRef = useRef<HTMLDivElement>(null); const previewRef = useRef<HTMLDivElement>(null);
const { const {
exportBackground, exportBackground,
@ -64,6 +79,10 @@ const ExportModal = ({
useEffect(() => { useEffect(() => {
const previewNode = previewRef.current; const previewNode = previewRef.current;
if (!previewNode) {
return;
}
try {
const canvas = exportToCanvas(exportedElements, appState, { const canvas = exportToCanvas(exportedElements, appState, {
exportBackground, exportBackground,
viewBackgroundColor, viewBackgroundColor,
@ -71,10 +90,31 @@ const ExportModal = ({
scale, scale,
shouldAddWatermark, shouldAddWatermark,
}); });
previewNode?.appendChild(canvas);
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 () => { return () => {
previewNode?.removeChild(canvas); isRemoved = true;
canvas.remove();
}; };
} catch (error) {
console.error(error);
setPreviewError(new CanvasError());
}
}, [ }, [
appState, appState,
exportedElements, exportedElements,
@ -87,7 +127,9 @@ const ExportModal = ({
return ( return (
<div className="ExportDialog"> <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"> <Stack.Col gap={2} align="center">
<div className="ExportDialog__actions"> <div className="ExportDialog__actions">
<Stack.Row gap={2}> <Stack.Row gap={2}>

View File

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

View File

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

View File

@ -301,59 +301,6 @@
max-height: calc(100vh - 236px); 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 { .dropdown-select {
height: 1.5rem; height: 1.5rem;
padding: 0; 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;
}
}
}

View File

@ -5,6 +5,7 @@ import { AppState } from "../types";
import { LibraryData, ImportedDataState } from "./types"; import { LibraryData, ImportedDataState } from "./types";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { MIME_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
import { CanvasError } from "../errors";
export const parseFileContents = async (blob: Blob | File) => { export const parseFileContents = async (blob: Blob | File) => {
let contents: string; let contents: string;
@ -109,3 +110,25 @@ export const loadLibraryFromBlob = async (blob: Blob) => {
} }
return data; 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);
}
});
};

View File

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

11
src/errors.ts Normal file
View File

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

View File

@ -150,6 +150,11 @@
"lineEditor_pointSelected": "Press Delete to remove point, CtrlOrCmd+D to duplicate, or drag to move", "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" "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": { "errorSplash": {
"headingMain_pre": "Encountered an error. Try ", "headingMain_pre": "Encountered an error. Try ",
"headingMain_button": "reloading the page.", "headingMain_button": "reloading the page.",