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, + ]; +}