From 6ec259a0e71174651bae95d4628138bf6fd68742 Mon Sep 17 00:00:00 2001
From: kj_sh604
Date: Sun, 15 Mar 2026 16:19:35 -0400
Subject: refactor: packages/
---
.../excalidraw/components/ImageExportDialog.tsx | 407 +++++++++++++++++++++
1 file changed, 407 insertions(+)
create mode 100644 packages/excalidraw/components/ImageExportDialog.tsx
(limited to 'packages/excalidraw/components/ImageExportDialog.tsx')
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 (
+
+
{t("canvasError.cannotShowPreview")}
+
+ {t("canvasError.canvasTooBig")}
+
+
({t("canvasError.canvasTooBigTip")})
+
+ );
+};
+
+type ImageExportModalProps = {
+ appStateSnapshot: Readonly;
+ 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(null);
+ const [renderError, setRenderError] = useState(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 (
+
+
{t("imageExportDialog.header")}
+
+
+ {renderError && }
+
+
+ {!nativeFileSystemSupported && (
+ {
+ setProjectName(event.target.value);
+ actionManager.executeAction(
+ actionChangeProjectName,
+ "ui",
+ event.target.value,
+ );
+ }}
+ />
+ )}
+
+
+
+
{t("imageExportDialog.header")}
+ {hasSelection && (
+
+ {
+ setExportSelectionOnly(checked);
+ }}
+ />
+
+ )}
+
+ {
+ setExportWithBackground(checked);
+ actionManager.executeAction(
+ actionChangeExportBackground,
+ "ui",
+ checked,
+ );
+ }}
+ />
+
+ {supportsContextFilters && (
+
+ {
+ setExportDarkMode(checked);
+ actionManager.executeAction(
+ actionExportWithDarkMode,
+ "ui",
+ checked,
+ );
+ }}
+ />
+
+ )}
+
+ {
+ setEmbedScene(checked);
+ actionManager.executeAction(
+ actionChangeExportEmbedScene,
+ "ui",
+ checked,
+ );
+ }}
+ />
+
+
+ {
+ setExportScale(scale);
+ actionManager.executeAction(actionChangeExportScale, "ui", scale);
+ }}
+ choices={EXPORT_SCALES.map((scale) => ({
+ value: scale,
+ label: `${scale}\u00d7`,
+ }))}
+ />
+
+
+
+
+ onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements, {
+ exportingFrame,
+ })
+ }
+ icon={downloadIcon}
+ >
+ {t("imageExportDialog.button.exportToPng")}
+
+
+ onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements, {
+ exportingFrame,
+ })
+ }
+ icon={downloadIcon}
+ >
+ {t("imageExportDialog.button.exportToSvg")}
+
+ {(probablySupportsClipboardBlob || isFirefox) && (
+ {
+ await onExportImage(
+ EXPORT_IMAGE_TYPES.clipboard,
+ exportedElements,
+ {
+ exportingFrame,
+ },
+ );
+ onCopy();
+ }}
+ icon={copyIcon}
+ >
+ {t("imageExportDialog.button.copyPngToClipboard")}
+
+ )}
+
+
+
+ );
+};
+
+type ExportSettingProps = {
+ label: string;
+ children: React.ReactNode;
+ tooltip?: string;
+ name?: string;
+};
+
+const ExportSetting = ({
+ label,
+ children,
+ tooltip,
+ name,
+}: ExportSettingProps) => {
+ return (
+
+
+
+ {children}
+
+
+ );
+};
+
+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 (
+
+ );
+};
--
cgit v1.2.3