aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/ImageExportDialog.tsx
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/components/ImageExportDialog.tsx
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/components/ImageExportDialog.tsx')
-rw-r--r--packages/excalidraw/components/ImageExportDialog.tsx407
1 files changed, 407 insertions, 0 deletions
diff --git a/packages/excalidraw/components/ImageExportDialog.tsx b/packages/excalidraw/components/ImageExportDialog.tsx
new file mode 100644
index 0000000..d06f4a8
--- /dev/null
+++ b/packages/excalidraw/components/ImageExportDialog.tsx
@@ -0,0 +1,407 @@
+import React, { useEffect, useRef, useState } from "react";
+
+import type { ActionManager } from "../actions/manager";
+import type { AppClassProperties, BinaryFiles, UIAppState } from "../types";
+
+import {
+ actionExportWithDarkMode,
+ actionChangeExportBackground,
+ actionChangeExportEmbedScene,
+ actionChangeExportScale,
+ actionChangeProjectName,
+} from "../actions/actionExport";
+import { probablySupportsClipboardBlob } from "../clipboard";
+import {
+ DEFAULT_EXPORT_PADDING,
+ EXPORT_IMAGE_TYPES,
+ isFirefox,
+ EXPORT_SCALES,
+} from "../constants";
+
+import { canvasToBlob } from "../data/blob";
+import { nativeFileSystemSupported } from "../data/filesystem";
+import type { NonDeletedExcalidrawElement } from "../element/types";
+import { t } from "../i18n";
+import { isSomeElementSelected } from "../scene";
+import { exportToCanvas } from "@excalidraw/utils/export";
+
+import { copyIcon, downloadIcon, helpIcon } from "./icons";
+import { Dialog } from "./Dialog";
+import { RadioGroup } from "./RadioGroup";
+import { Switch } from "./Switch";
+import { Tooltip } from "./Tooltip";
+
+import "./ImageExportDialog.scss";
+import { FilledButton } from "./FilledButton";
+import { cloneJSON } from "../utils";
+import { prepareElementsForExport } from "../data";
+import { useCopyStatus } from "../hooks/useCopiedIndicator";
+
+const supportsContextFilters =
+ "filter" in document.createElement("canvas").getContext("2d")!;
+
+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 = {
+ appStateSnapshot: Readonly<UIAppState>;
+ elementsSnapshot: readonly NonDeletedExcalidrawElement[];
+ files: BinaryFiles;
+ actionManager: ActionManager;
+ onExportImage: AppClassProperties["onExportImage"];
+ name: string;
+};
+
+const ImageExportModal = ({
+ appStateSnapshot,
+ elementsSnapshot,
+ files,
+ actionManager,
+ onExportImage,
+ name,
+}: ImageExportModalProps) => {
+ const hasSelection = isSomeElementSelected(
+ elementsSnapshot,
+ appStateSnapshot,
+ );
+
+ const [projectName, setProjectName] = useState(name);
+ const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection);
+ const [exportWithBackground, setExportWithBackground] = useState(
+ appStateSnapshot.exportBackground,
+ );
+ const [exportDarkMode, setExportDarkMode] = useState(
+ appStateSnapshot.exportWithDarkMode,
+ );
+ const [embedScene, setEmbedScene] = useState(
+ appStateSnapshot.exportEmbedScene,
+ );
+ const [exportScale, setExportScale] = useState(appStateSnapshot.exportScale);
+
+ const previewRef = useRef<HTMLDivElement>(null);
+ const [renderError, setRenderError] = useState<Error | null>(null);
+
+ const { onCopy, copyStatus, resetCopyStatus } = useCopyStatus();
+
+ useEffect(() => {
+ // if user changes setting right after export to clipboard, reset the status
+ // so they don't have to wait for the timeout to click the button again
+ resetCopyStatus();
+ }, [
+ projectName,
+ exportWithBackground,
+ exportDarkMode,
+ exportScale,
+ embedScene,
+ resetCopyStatus,
+ ]);
+
+ const { exportedElements, exportingFrame } = prepareElementsForExport(
+ elementsSnapshot,
+ appStateSnapshot,
+ exportSelectionOnly,
+ );
+
+ useEffect(() => {
+ const previewNode = previewRef.current;
+ if (!previewNode) {
+ return;
+ }
+ const maxWidth = previewNode.offsetWidth;
+ const maxHeight = previewNode.offsetHeight;
+ if (!maxWidth) {
+ return;
+ }
+
+ exportToCanvas({
+ elements: exportedElements,
+ appState: {
+ ...appStateSnapshot,
+ name: projectName,
+ exportBackground: exportWithBackground,
+ exportWithDarkMode: exportDarkMode,
+ exportScale,
+ exportEmbedScene: embedScene,
+ },
+ files,
+ exportPadding: DEFAULT_EXPORT_PADDING,
+ maxWidthOrHeight: Math.max(maxWidth, maxHeight),
+ exportingFrame,
+ })
+ .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(() => {
+ previewNode.replaceChildren(canvas);
+ })
+ .catch((e) => {
+ if (e.name === "CANVAS_POSSIBLY_TOO_BIG") {
+ throw new Error(t("canvasError.canvasTooBig"));
+ }
+ throw e;
+ });
+ })
+ .catch((error) => {
+ console.error(error);
+ setRenderError(error);
+ });
+ }, [
+ appStateSnapshot,
+ files,
+ exportedElements,
+ exportingFrame,
+ projectName,
+ exportWithBackground,
+ exportDarkMode,
+ exportScale,
+ embedScene,
+ ]);
+
+ return (
+ <div className="ImageExportModal">
+ <h3>{t("imageExportDialog.header")}</h3>
+ <div className="ImageExportModal__preview">
+ <div className="ImageExportModal__preview__canvas" ref={previewRef}>
+ {renderError && <ErrorCanvasPreview />}
+ </div>
+ <div className="ImageExportModal__preview__filename">
+ {!nativeFileSystemSupported && (
+ <input
+ type="text"
+ className="TextInput"
+ value={projectName}
+ style={{ width: "30ch" }}
+ onChange={(event) => {
+ setProjectName(event.target.value);
+ actionManager.executeAction(
+ actionChangeProjectName,
+ "ui",
+ event.target.value,
+ );
+ }}
+ />
+ )}
+ </div>
+ </div>
+ <div className="ImageExportModal__settings">
+ <h3>{t("imageExportDialog.header")}</h3>
+ {hasSelection && (
+ <ExportSetting
+ label={t("imageExportDialog.label.onlySelected")}
+ name="exportOnlySelected"
+ >
+ <Switch
+ name="exportOnlySelected"
+ checked={exportSelectionOnly}
+ onChange={(checked) => {
+ setExportSelectionOnly(checked);
+ }}
+ />
+ </ExportSetting>
+ )}
+ <ExportSetting
+ label={t("imageExportDialog.label.withBackground")}
+ name="exportBackgroundSwitch"
+ >
+ <Switch
+ name="exportBackgroundSwitch"
+ checked={exportWithBackground}
+ onChange={(checked) => {
+ setExportWithBackground(checked);
+ actionManager.executeAction(
+ actionChangeExportBackground,
+ "ui",
+ checked,
+ );
+ }}
+ />
+ </ExportSetting>
+ {supportsContextFilters && (
+ <ExportSetting
+ label={t("imageExportDialog.label.darkMode")}
+ name="exportDarkModeSwitch"
+ >
+ <Switch
+ name="exportDarkModeSwitch"
+ checked={exportDarkMode}
+ onChange={(checked) => {
+ setExportDarkMode(checked);
+ actionManager.executeAction(
+ actionExportWithDarkMode,
+ "ui",
+ checked,
+ );
+ }}
+ />
+ </ExportSetting>
+ )}
+ <ExportSetting
+ label={t("imageExportDialog.label.embedScene")}
+ tooltip={t("imageExportDialog.tooltip.embedScene")}
+ name="exportEmbedSwitch"
+ >
+ <Switch
+ name="exportEmbedSwitch"
+ checked={embedScene}
+ onChange={(checked) => {
+ setEmbedScene(checked);
+ actionManager.executeAction(
+ actionChangeExportEmbedScene,
+ "ui",
+ checked,
+ );
+ }}
+ />
+ </ExportSetting>
+ <ExportSetting
+ label={t("imageExportDialog.label.scale")}
+ name="exportScale"
+ >
+ <RadioGroup
+ name="exportScale"
+ value={exportScale}
+ onChange={(scale) => {
+ setExportScale(scale);
+ actionManager.executeAction(actionChangeExportScale, "ui", scale);
+ }}
+ choices={EXPORT_SCALES.map((scale) => ({
+ value: scale,
+ label: `${scale}\u00d7`,
+ }))}
+ />
+ </ExportSetting>
+
+ <div className="ImageExportModal__settings__buttons">
+ <FilledButton
+ className="ImageExportModal__settings__buttons__button"
+ label={t("imageExportDialog.title.exportToPng")}
+ onClick={() =>
+ onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements, {
+ exportingFrame,
+ })
+ }
+ icon={downloadIcon}
+ >
+ {t("imageExportDialog.button.exportToPng")}
+ </FilledButton>
+ <FilledButton
+ className="ImageExportModal__settings__buttons__button"
+ label={t("imageExportDialog.title.exportToSvg")}
+ onClick={() =>
+ onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements, {
+ exportingFrame,
+ })
+ }
+ icon={downloadIcon}
+ >
+ {t("imageExportDialog.button.exportToSvg")}
+ </FilledButton>
+ {(probablySupportsClipboardBlob || isFirefox) && (
+ <FilledButton
+ className="ImageExportModal__settings__buttons__button"
+ label={t("imageExportDialog.title.copyPngToClipboard")}
+ status={copyStatus}
+ onClick={async () => {
+ await onExportImage(
+ EXPORT_IMAGE_TYPES.clipboard,
+ exportedElements,
+ {
+ exportingFrame,
+ },
+ );
+ onCopy();
+ }}
+ icon={copyIcon}
+ >
+ {t("imageExportDialog.button.copyPngToClipboard")}
+ </FilledButton>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+};
+
+type ExportSettingProps = {
+ label: string;
+ children: React.ReactNode;
+ tooltip?: string;
+ name?: string;
+};
+
+const ExportSetting = ({
+ label,
+ children,
+ tooltip,
+ name,
+}: ExportSettingProps) => {
+ return (
+ <div className="ImageExportModal__settings__setting" title={label}>
+ <label
+ htmlFor={name}
+ className="ImageExportModal__settings__setting__label"
+ >
+ {label}
+ {tooltip && (
+ <Tooltip label={tooltip} long={true}>
+ {helpIcon}
+ </Tooltip>
+ )}
+ </label>
+ <div className="ImageExportModal__settings__setting__content">
+ {children}
+ </div>
+ </div>
+ );
+};
+
+export const ImageExportDialog = ({
+ elements,
+ appState,
+ files,
+ actionManager,
+ onExportImage,
+ onCloseRequest,
+ name,
+}: {
+ appState: UIAppState;
+ elements: readonly NonDeletedExcalidrawElement[];
+ files: BinaryFiles;
+ actionManager: ActionManager;
+ onExportImage: AppClassProperties["onExportImage"];
+ onCloseRequest: () => void;
+ name: string;
+}) => {
+ // we need to take a snapshot so that the exported state can't be modified
+ // while the dialog is open
+ const [{ appStateSnapshot, elementsSnapshot }] = useState(() => {
+ return {
+ appStateSnapshot: cloneJSON(appState),
+ elementsSnapshot: cloneJSON(elements),
+ };
+ });
+
+ return (
+ <Dialog onCloseRequest={onCloseRequest} size="wide" title={false}>
+ <ImageExportModal
+ elementsSnapshot={elementsSnapshot}
+ appStateSnapshot={appStateSnapshot}
+ files={files}
+ actionManager={actionManager}
+ onExportImage={onExportImage}
+ name={name}
+ />
+ </Dialog>
+ );
+};