feat: rewrite preview to use React.Suspense
This commit is contained in:
parent
22fde9d808
commit
cd021716f1
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useReducer, useRef, useState } from "react";
|
import React, { useEffect, useReducer, useRef } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import type { ActionManager } from "../actions/manager";
|
import type { ActionManager } from "../actions/manager";
|
||||||
@ -21,7 +21,6 @@ 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,
|
||||||
@ -29,7 +28,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 { exportToCanvas, getScaleToFit } from "../packages/utils";
|
import { getScaleToFit } from "../packages/utils";
|
||||||
|
|
||||||
import { copyIcon, downloadIcon, helpIcon } from "./icons";
|
import { copyIcon, downloadIcon, helpIcon } from "./icons";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
@ -50,6 +49,7 @@ 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,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 = {
|
type ImageExportModalProps = {
|
||||||
appState: UIAppState;
|
appState: UIAppState;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
@ -228,7 +216,6 @@ 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(() => {
|
||||||
@ -281,91 +268,17 @@ 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">
|
||||||
<div
|
<React.Suspense fallback={<div>Loading...</div>}>
|
||||||
className={clsx("ImageExportModal__preview__canvas", {
|
<CanvasPreview
|
||||||
"ImageExportModal__preview__canvas--img-bcg":
|
appState={appState}
|
||||||
appState.exportBackground &&
|
files={files}
|
||||||
appState.fancyBackgroundImageKey !== "solid",
|
elements={state.exportedElements}
|
||||||
})}
|
/>
|
||||||
ref={previewRef}
|
</React.Suspense>
|
||||||
>
|
|
||||||
{renderError && <ErrorCanvasPreview />}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ImageExportModal__settings">
|
<div className="ImageExportModal__settings">
|
||||||
<h3>{t("imageExportDialog.header")}</h3>
|
<h3>{t("imageExportDialog.header")}</h3>
|
||||||
|
106
src/components/ImageExportPreview.tsx
Normal file
106
src/components/ImageExportPreview.tsx
Normal file
@ -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
src/hooks/useSuspendable.ts
Normal file
63
src/hooks/useSuspendable.ts
Normal file
@ -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,
|
||||||
|
];
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user