Browse Source

Revert "feat: rewrite preview to use React.Suspense"

This reverts commit cd021716f1b0a6fbf2b87427dd91e1644ab308ed.
Arnošt Pleskot 1 year ago
parent
commit
7e7d3e0514

+ 97 - 10
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 clsx from "clsx";
 
 
 import type { ActionManager } from "../actions/manager";
 import type { ActionManager } from "../actions/manager";
@@ -21,6 +21,7 @@ import {
   FANCY_BACKGROUND_IMAGES,
   FANCY_BACKGROUND_IMAGES,
 } from "../constants";
 } from "../constants";
 
 
+import { canvasToBlob } from "../data/blob";
 import { nativeFileSystemSupported } from "../data/filesystem";
 import { nativeFileSystemSupported } from "../data/filesystem";
 import {
 import {
   ExcalidrawElement,
   ExcalidrawElement,
@@ -28,7 +29,7 @@ import {
 } from "../element/types";
 } from "../element/types";
 import { t } from "../i18n";
 import { t } from "../i18n";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
-import { getScaleToFit } from "../packages/utils";
+import { exportToCanvas, getScaleToFit } from "../packages/utils";
 
 
 import { copyIcon, downloadIcon, helpIcon } from "./icons";
 import { copyIcon, downloadIcon, helpIcon } from "./icons";
 import { Dialog } from "./Dialog";
 import { Dialog } from "./Dialog";
@@ -49,7 +50,6 @@ import {
 import { getFancyBackgroundPadding } from "../scene/fancyBackground";
 import { getFancyBackgroundPadding } from "../scene/fancyBackground";
 import { Select } from "./Select";
 import { Select } from "./Select";
 import { Bounds } from "../element/bounds";
 import { Bounds } from "../element/bounds";
-import { CanvasPreview } from "./ImageExportPreview";
 
 
 const supportsContextFilters =
 const supportsContextFilters =
   "filter" in document.createElement("canvas").getContext("2d")!;
   "filter" in document.createElement("canvas").getContext("2d")!;
@@ -61,6 +61,18 @@ const fancyBackgroundImageOptions = Object.entries(FANCY_BACKGROUND_IMAGES).map(
   }),
   }),
 );
 );
 
 
+export const ErrorCanvasPreview = () => {
+  return (
+    <div>
+      <h3>{t("canvasError.cannotShowPreview")}</h3>
+      <p>
+        <span>{t("canvasError.canvasTooBig")}</span>
+      </p>
+      <em>({t("canvasError.canvasTooBigTip")})</em>
+    </div>
+  );
+};
+
 type ImageExportModalProps = {
 type ImageExportModalProps = {
   appState: UIAppState;
   appState: UIAppState;
   elements: readonly NonDeletedExcalidrawElement[];
   elements: readonly NonDeletedExcalidrawElement[];
@@ -216,6 +228,7 @@ const ImageExportModal = ({
   const appProps = useAppProps();
   const appProps = useAppProps();
 
 
   const previewRef = useRef<HTMLDivElement>(null);
   const previewRef = useRef<HTMLDivElement>(null);
+  const [renderError, setRenderError] = useState<Error | null>(null);
 
 
   // Upscale exported image when is smaller than preview
   // Upscale exported image when is smaller than preview
   useEffect(() => {
   useEffect(() => {
@@ -268,17 +281,91 @@ const ImageExportModal = ({
     actionManager,
     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 (
   return (
     <div className="ImageExportModal">
     <div className="ImageExportModal">
       <h3>{t("imageExportDialog.header")}</h3>
       <h3>{t("imageExportDialog.header")}</h3>
       <div className="ImageExportModal__preview">
       <div className="ImageExportModal__preview">
-        <React.Suspense fallback={<div>Loading...</div>}>
-          <CanvasPreview
-            appState={appState}
-            files={files}
-            elements={state.exportedElements}
-          />
-        </React.Suspense>
+        <div
+          className={clsx("ImageExportModal__preview__canvas", {
+            "ImageExportModal__preview__canvas--img-bcg":
+              appState.exportBackground &&
+              appState.fancyBackgroundImageKey !== "solid",
+          })}
+          ref={previewRef}
+        >
+          {renderError && <ErrorCanvasPreview />}
+        </div>
       </div>
       </div>
       <div className="ImageExportModal__settings">
       <div className="ImageExportModal__settings">
         <h3>{t("imageExportDialog.header")}</h3>
         <h3>{t("imageExportDialog.header")}</h3>

+ 0 - 106
src/components/ImageExportPreview.tsx

@@ -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 (
-    <div>
-      <h3>{t("canvasError.cannotShowPreview")}</h3>
-      <p>
-        <span>{t("canvasError.canvasTooBig")}</span>
-      </p>
-      <em>({t("canvasError.canvasTooBigTip")})</em>
-    </div>
-  );
-};
-
-type CanvasPreviewProps = {
-  appState: UIAppState;
-  files: BinaryFiles;
-  elements: readonly NonDeletedExcalidrawElement[];
-};
-
-export const CanvasPreview = ({
-  appState,
-  files,
-  elements,
-}: CanvasPreviewProps) => {
-  const [canvasData, canvasError, canvasStatus, suspendCanvas, pendingPromise] =
-    useSuspendable<HTMLCanvasElement>();
-
-  const previewRef = useRef<HTMLDivElement>(null);
-  const canvasRef = useRef<HTMLCanvasElement>(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 <ErrorCanvasPreview />;
-  }
-
-  return (
-    <div
-      className={clsx("ImageExportModal__preview__canvas", {
-        "ImageExportModal__preview__canvas--img-bcg":
-          appState.exportBackground &&
-          appState.fancyBackgroundImageKey !== "solid",
-      })}
-      ref={previewRef}
-    >
-      <canvas ref={canvasRef} />
-    </div>
-  );
-};

+ 0 - 63
src/hooks/useSuspendable.ts

@@ -1,63 +0,0 @@
-import { useReducer, useCallback, useRef } from "react";
-
-type Status = "idle" | "pending" | "resolved" | "rejected";
-
-type Action<T> =
-  | { type: "start" }
-  | { type: "resolve"; payload: T }
-  | { type: "reject"; payload: Error };
-
-type State<T> = {
-  status: Status;
-  result: T | null;
-  error: Error | null;
-};
-
-function reducer<T>(state: State<T>, action: Action<T>): State<T> {
-  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>(): [
-  T | null,
-  Error | null,
-  Status,
-  (promise: Promise<T>) => Promise<void>,
-  Promise<T> | null,
-] {
-  const [state, dispatch] = useReducer(reducer, {
-    status: "idle",
-    result: null,
-    error: null,
-  });
-
-  const pendingPromise = useRef<Promise<T> | null>(null);
-
-  const suspend = useCallback((promise: Promise<T>) => {
-    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,
-  ];
-}