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