diff --git a/src/components/ImageExportDialog.tsx b/src/components/ImageExportDialog.tsx
index a53a88eb6..38c63fa2c 100644
--- a/src/components/ImageExportDialog.tsx
+++ b/src/components/ImageExportDialog.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useReducer, useRef, useState } from "react";
+import React, { useEffect, useReducer, useRef } from "react";
import clsx from "clsx";
import type { ActionManager } from "../actions/manager";
@@ -21,7 +21,6 @@ import {
FANCY_BACKGROUND_IMAGES,
} from "../constants";
-import { canvasToBlob } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import {
ExcalidrawElement,
@@ -29,7 +28,7 @@ import {
} from "../element/types";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
-import { exportToCanvas, getScaleToFit } from "../packages/utils";
+import { getScaleToFit } from "../packages/utils";
import { copyIcon, downloadIcon, helpIcon } from "./icons";
import { Dialog } from "./Dialog";
@@ -50,6 +49,7 @@ 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,18 +61,6 @@ 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[];
@@ -228,7 +216,6 @@ const ImageExportModal = ({
const appProps = useAppProps();
const previewRef = useRef(null);
- const [renderError, setRenderError] = useState(null);
// Upscale exported image when is smaller than preview
useEffect(() => {
@@ -281,91 +268,17 @@ 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")}
-
- {renderError && }
-
+
Loading...}>
+
+
{t("imageExportDialog.header")}
diff --git a/src/components/ImageExportPreview.tsx b/src/components/ImageExportPreview.tsx
new file mode 100644
index 000000000..3e7081ffc
--- /dev/null
+++ b/src/components/ImageExportPreview.tsx
@@ -0,0 +1,106 @@
+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
new file mode 100644
index 000000000..434a463d2
--- /dev/null
+++ b/src/hooks/useSuspendable.ts
@@ -0,0 +1,63 @@
+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,
+ ];
+}