diff --git a/src/components/ImageExportDialog.tsx b/src/components/ImageExportDialog.tsx
index 38c63fa2c..a53a88eb6 100644
--- a/src/components/ImageExportDialog.tsx
+++ b/src/components/ImageExportDialog.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useReducer, useRef } from "react";
+import React, { useEffect, useReducer, useRef, useState } from "react";
import clsx from "clsx";
import type { ActionManager } from "../actions/manager";
@@ -21,6 +21,7 @@ import {
FANCY_BACKGROUND_IMAGES,
} from "../constants";
+import { canvasToBlob } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import {
ExcalidrawElement,
@@ -28,7 +29,7 @@ import {
} from "../element/types";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
-import { getScaleToFit } from "../packages/utils";
+import { exportToCanvas, getScaleToFit } from "../packages/utils";
import { copyIcon, downloadIcon, helpIcon } from "./icons";
import { Dialog } from "./Dialog";
@@ -49,7 +50,6 @@ import {
import { getFancyBackgroundPadding } from "../scene/fancyBackground";
import { Select } from "./Select";
import { Bounds } from "../element/bounds";
-import { CanvasPreview } from "./ImageExportPreview";
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
@@ -61,6 +61,18 @@ const fancyBackgroundImageOptions = Object.entries(FANCY_BACKGROUND_IMAGES).map(
}),
);
+export const ErrorCanvasPreview = () => {
+ return (
+
+
{t("canvasError.cannotShowPreview")}
+
+ {t("canvasError.canvasTooBig")}
+
+
({t("canvasError.canvasTooBigTip")})
+
+ );
+};
+
type ImageExportModalProps = {
appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[];
@@ -216,6 +228,7 @@ const ImageExportModal = ({
const appProps = useAppProps();
const previewRef = useRef(null);
+ const [renderError, setRenderError] = useState(null);
// Upscale exported image when is smaller than preview
useEffect(() => {
@@ -268,17 +281,91 @@ const ImageExportModal = ({
actionManager,
]);
+ useEffect(() => {
+ const previewNode = previewRef.current;
+ if (!previewNode) {
+ return;
+ }
+ const maxWidth = previewNode.offsetWidth;
+ const maxHeight = previewNode.offsetHeight;
+
+ const maxWidthOrHeight = Math.min(maxWidth, maxHeight);
+
+ if (!maxWidth) {
+ return;
+ }
+
+ // when switching between solid/no background and image background, we clear the canvas to prevent flickering
+ const isExportWithFancyBackground =
+ appState.exportBackground && appState.fancyBackgroundImageKey !== "solid";
+
+ if (state.isExportWithFancyBackground !== isExportWithFancyBackground) {
+ const existingCanvas = previewNode.querySelector("canvas");
+ if (existingCanvas) {
+ const context = existingCanvas.getContext("2d");
+
+ context!.clearRect(0, 0, existingCanvas.width, existingCanvas.height);
+ }
+ dispatch({
+ type: "SET_IS_EXPORT_WITH_FANCY_BACKGROUND",
+ isExportWithFancyBackground,
+ });
+ }
+
+ exportToCanvas({
+ elements: state.exportedElements,
+ appState,
+ files,
+ exportPadding: DEFAULT_EXPORT_PADDING,
+ maxWidthOrHeight,
+ })
+ .then((canvas) => {
+ setRenderError(null);
+ // if converting to blob fails, there's some problem that will
+ // likely prevent preview and export (e.g. canvas too big)
+ return canvasToBlob(canvas).then(() => {
+ const existingCanvas = previewNode.querySelector("canvas");
+ if (!existingCanvas) {
+ previewNode.appendChild(canvas);
+ return;
+ }
+
+ existingCanvas.width = canvas.width;
+ existingCanvas.height = canvas.height;
+
+ const context = existingCanvas.getContext("2d");
+ context!.drawImage(canvas, 0, 0);
+ });
+
+ // Get the 2D rendering context of the existing canvas
+ })
+ .catch((error) => {
+ console.error(error);
+ setRenderError(error);
+ });
+ }, [
+ appState,
+ appState.exportBackground,
+ appState.fancyBackgroundImageKey,
+ files,
+ state.exportedElements,
+ state.isExportWithFancyBackground,
+ ]);
+
return (
{t("imageExportDialog.header")}
- Loading...
}>
-
-
+
+ {renderError && }
+
{t("imageExportDialog.header")}
diff --git a/src/components/ImageExportPreview.tsx b/src/components/ImageExportPreview.tsx
deleted file mode 100644
index 3e7081ffc..000000000
--- a/src/components/ImageExportPreview.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import clsx from "clsx";
-import { useEffect, useRef } from "react";
-import { DEFAULT_EXPORT_PADDING } from "../constants";
-import { canvasToBlob } from "../data/blob";
-import { NonDeletedExcalidrawElement } from "../element/types";
-import { useSuspendable } from "../hooks/useSuspendable";
-import { t } from "../i18n";
-import { exportToCanvas } from "../packages/utils";
-import { BinaryFiles, UIAppState } from "../types";
-
-const ErrorCanvasPreview = () => {
- return (
-
-
{t("canvasError.cannotShowPreview")}
-
- {t("canvasError.canvasTooBig")}
-
-
({t("canvasError.canvasTooBigTip")})
-
- );
-};
-
-type CanvasPreviewProps = {
- appState: UIAppState;
- files: BinaryFiles;
- elements: readonly NonDeletedExcalidrawElement[];
-};
-
-export const CanvasPreview = ({
- appState,
- files,
- elements,
-}: CanvasPreviewProps) => {
- const [canvasData, canvasError, canvasStatus, suspendCanvas, pendingPromise] =
- useSuspendable
();
-
- const previewRef = useRef(null);
- const canvasRef = useRef(null);
-
- useEffect(() => {
- const previewNode = previewRef.current;
- if (!previewNode) {
- return;
- }
- const maxWidth = previewNode.offsetWidth;
- const maxHeight = previewNode.offsetHeight;
-
- const maxWidthOrHeight = Math.min(maxWidth, maxHeight);
-
- if (!maxWidth) {
- return;
- }
-
- const promise = exportToCanvas({
- elements,
- appState,
- files,
- exportPadding: DEFAULT_EXPORT_PADDING,
- maxWidthOrHeight,
- }).then((canvas) => {
- // if converting to blob fails, there's some problem that will
- // likely prevent preview and export (e.g. canvas too big)
- return canvasToBlob(canvas).then(() => {
- return canvas;
- });
- });
-
- suspendCanvas(promise);
- }, [appState, files, elements, suspendCanvas]);
-
- useEffect(() => {
- if (!canvasData || !canvasRef.current) {
- return;
- }
-
- const canvas = canvasRef.current;
-
- canvas.width = canvasData.width;
- canvas.height = canvasData.height;
-
- const context = canvas.getContext("2d");
- context!.drawImage(canvasData, 0, 0);
- }, [canvasData]);
-
- if (canvasStatus === "pending" && pendingPromise) {
- throw pendingPromise;
- }
-
- if (canvasStatus === "rejected") {
- console.error(canvasError);
- return ;
- }
-
- return (
-
-
-
- );
-};
diff --git a/src/hooks/useSuspendable.ts b/src/hooks/useSuspendable.ts
deleted file mode 100644
index 434a463d2..000000000
--- a/src/hooks/useSuspendable.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { useReducer, useCallback, useRef } from "react";
-
-type Status = "idle" | "pending" | "resolved" | "rejected";
-
-type Action =
- | { type: "start" }
- | { type: "resolve"; payload: T }
- | { type: "reject"; payload: Error };
-
-type State = {
- status: Status;
- result: T | null;
- error: Error | null;
-};
-
-function reducer(state: State, action: Action): State {
- switch (action.type) {
- case "start":
- return { ...state, status: "pending" };
- case "resolve":
- return { status: "resolved", result: action.payload, error: null };
- case "reject":
- return { status: "rejected", result: null, error: action.payload };
- default:
- throw new Error("Unhandled action type");
- }
-}
-
-export function useSuspendable(): [
- T | null,
- Error | null,
- Status,
- (promise: Promise) => Promise,
- Promise | null,
-] {
- const [state, dispatch] = useReducer(reducer, {
- status: "idle",
- result: null,
- error: null,
- });
-
- const pendingPromise = useRef | null>(null);
-
- const suspend = useCallback((promise: Promise) => {
- pendingPromise.current = promise;
- dispatch({ type: "start" });
- return promise
- .then((data) => {
- dispatch({ type: "resolve", payload: data });
- })
- .catch((error) => {
- dispatch({ type: "reject", payload: error as Error });
- });
- }, []);
-
- return [
- state.result as T | null,
- state.error,
- state.status,
- suspend,
- pendingPromise.current,
- ];
-}