瀏覽代碼

feat: rewrite preview to use React.Suspense

Arnošt Pleskot 1 年之前
父節點
當前提交
cd021716f1
共有 3 個文件被更改,包括 179 次插入97 次删除
  1. 10 97
      src/components/ImageExportDialog.tsx
  2. 106 0
      src/components/ImageExportPreview.tsx
  3. 63 0
      src/hooks/useSuspendable.ts

+ 10 - 97
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 (
-    <div>
-      <h3>{t("canvasError.cannotShowPreview")}</h3>
-      <p>
-        <span>{t("canvasError.canvasTooBig")}</span>
-      </p>
-      <em>({t("canvasError.canvasTooBigTip")})</em>
-    </div>
-  );
-};
-
 type ImageExportModalProps = {
   appState: UIAppState;
   elements: readonly NonDeletedExcalidrawElement[];
@@ -228,7 +216,6 @@ const ImageExportModal = ({
   const appProps = useAppProps();
 
   const previewRef = useRef<HTMLDivElement>(null);
-  const [renderError, setRenderError] = useState<Error | null>(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 (
     <div className="ImageExportModal">
       <h3>{t("imageExportDialog.header")}</h3>
       <div className="ImageExportModal__preview">
-        <div
-          className={clsx("ImageExportModal__preview__canvas", {
-            "ImageExportModal__preview__canvas--img-bcg":
-              appState.exportBackground &&
-              appState.fancyBackgroundImageKey !== "solid",
-          })}
-          ref={previewRef}
-        >
-          {renderError && <ErrorCanvasPreview />}
-        </div>
+        <React.Suspense fallback={<div>Loading...</div>}>
+          <CanvasPreview
+            appState={appState}
+            files={files}
+            elements={state.exportedElements}
+          />
+        </React.Suspense>
       </div>
       <div className="ImageExportModal__settings">
         <h3>{t("imageExportDialog.header")}</h3>

+ 106 - 0
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 (
+    <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>
+  );
+};

+ 63 - 0
src/hooks/useSuspendable.ts

@@ -0,0 +1,63 @@
+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,
+  ];
+}