diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/components/ImageExportDialog.tsx | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/components/ImageExportDialog.tsx')
| -rw-r--r-- | packages/excalidraw/components/ImageExportDialog.tsx | 407 |
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> + ); +}; |
