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