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 { NonDeletedExcalidrawElement } from "../element/types"; import { t } from "../i18n"; import { isSomeElementSelected } from "../scene"; import { exportToCanvas } from "../../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 { useAppProps } from "./App"; import { FilledButton } from "./FilledButton"; import { cloneJSON } from "../utils"; import { prepareElementsForExport } from "../data"; 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"]; }; const ImageExportModal = ({ appStateSnapshot, elementsSnapshot, files, actionManager, onExportImage, }: ImageExportModalProps) => { const hasSelection = isSomeElementSelected( elementsSnapshot, appStateSnapshot, ); const appProps = useAppProps(); const [projectName, setProjectName] = useState(appStateSnapshot.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 { 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((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, }) } startIcon={downloadIcon} > {t("imageExportDialog.button.exportToPng")} onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements, { exportingFrame, }) } startIcon={downloadIcon} > {t("imageExportDialog.button.exportToSvg")} {(probablySupportsClipboardBlob || isFirefox) && ( onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, { exportingFrame, }) } startIcon={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, }: { appState: UIAppState; elements: readonly NonDeletedExcalidrawElement[]; files: BinaryFiles; actionManager: ActionManager; onExportImage: AppClassProperties["onExportImage"]; onCloseRequest: () => void; }) => { // 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 ( ); };