import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import clsx from "clsx";
import type { ActionManager } from "../actions/manager";
import type { AppClassProperties, BinaryFiles, UIAppState } from "../types";
import {
actionExportWithDarkMode,
actionChangeExportBackground,
actionChangeExportEmbedScene,
actionChangeExportScale,
actionChangeProjectName,
actionChangeFancyBackgroundImageUrl,
} from "../actions/actionExport";
import { probablySupportsClipboardBlob } from "../clipboard";
import {
DEFAULT_EXPORT_PADDING,
EXPORT_IMAGE_TYPES,
isFirefox,
EXPORT_SCALES,
FANCY_BACKGROUND_IMAGES,
} from "../constants";
import { canvasToBlob } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas, getScaleToFit } from "../packages/utils";
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 { getCommonBounds } from "../element";
import {
convertToExportPadding,
defaultExportScale,
distance,
isBackgroundImageKey,
} from "../utils";
import { getFancyBackgroundPadding } from "../scene/fancyBackground";
import { Select } from "./Select";
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
const fancyBackgroundImageOptions = Object.entries(FANCY_BACKGROUND_IMAGES).map(
([value, { label }]) => ({
value,
label,
}),
);
export const ErrorCanvasPreview = () => {
return (
{t("canvasError.cannotShowPreview")}
{t("canvasError.canvasTooBig")}
({t("canvasError.canvasTooBigTip")})
);
};
type ImageExportModalProps = {
appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
actionManager: ActionManager;
onExportImage: AppClassProperties["onExportImage"];
};
const ImageExportModal = ({
appState,
elements,
files,
actionManager,
onExportImage,
}: ImageExportModalProps) => {
const appProps = useAppProps();
const [projectName, setProjectName] = useState(appState.name);
const someElementIsSelected = isSomeElementSelected(elements, appState);
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const [exportWithBackground, setExportWithBackground] = useState(
appState.exportBackground,
);
const [exportBackgroundImage, setExportBackgroundImage] = useState<
keyof typeof FANCY_BACKGROUND_IMAGES
>(appState.fancyBackgroundImageKey);
const [exportDarkMode, setExportDarkMode] = useState(
appState.exportWithDarkMode,
);
const [embedScene, setEmbedScene] = useState(appState.exportEmbedScene);
const [exportScale, setExportScale] = useState(appState.exportScale);
const [exportBaseScale, setExportBaseScale] = useState(appState.exportScale);
const previewRef = useRef(null);
const [renderError, setRenderError] = useState(null);
const exportedElements = useMemo(
() =>
exportSelected
? getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
})
: elements,
[exportSelected, elements],
);
const updateAllScales = useCallback(
(scale: number) => {
actionManager.executeAction(actionChangeExportScale, "ui", scale);
setExportScale(scale);
setExportBaseScale(scale);
},
[actionManager, setExportScale, setExportBaseScale],
);
// Upscale exported image when is smaller than preview
useEffect(() => {
if (
exportedElements.length > 0 &&
exportWithBackground &&
exportBackgroundImage !== "solid"
) {
const previewNode = previewRef.current;
if (!previewNode) {
return;
}
const [minX, minY, maxX, maxY] = getCommonBounds(exportedElements);
const maxWidth = previewNode.offsetWidth;
const maxHeight = previewNode.offsetHeight;
const padding = getFancyBackgroundPadding(
convertToExportPadding(DEFAULT_EXPORT_PADDING),
true,
);
const scale =
Math.floor(
(getScaleToFit(
{
width: distance(minX, maxX) + padding[1] + padding[3],
height: distance(minY, maxY) + padding[0] + padding[2],
},
{ width: maxWidth, height: maxHeight },
) +
Number.EPSILON) *
100,
) / 100;
if (scale > 1) {
if (scale !== exportBaseScale) {
updateAllScales(scale);
}
} else {
updateAllScales(defaultExportScale);
}
} else if (exportBaseScale !== defaultExportScale) {
updateAllScales(defaultExportScale);
}
}, [
exportBackgroundImage,
exportWithBackground,
exportBaseScale,
updateAllScales,
exportedElements,
exportSelected,
]);
useEffect(() => {
const previewNode = previewRef.current;
if (!previewNode) {
return;
}
const maxWidth = previewNode.offsetWidth;
const maxHeight = previewNode.offsetHeight;
const maxWidthOrHeight = Math.min(maxWidth, maxHeight);
if (!maxWidth) {
return;
}
exportToCanvas({
elements: exportedElements,
appState,
files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight,
})
.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);
});
}, [
appState,
appState.exportBackground,
appState.fancyBackgroundImageKey,
files,
exportedElements,
]);
return (
{t("imageExportDialog.header")}
{t("imageExportDialog.header")}
{!nativeFileSystemSupported && (
{
setProjectName(event.target.value);
actionManager.executeAction(
actionChangeProjectName,
"ui",
event.target.value,
);
}}
/>
)}
{someElementIsSelected && (
{
setExportSelected(checked);
}}
/>
)}
{exportWithBackground && (
{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 * exportBaseScale,
label: `${scale}\u00d7`,
}))}
/>
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements)
}
startIcon={downloadIcon}
>
{t("imageExportDialog.button.exportToPng")}
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements)
}
startIcon={downloadIcon}
>
{t("imageExportDialog.button.exportToSvg")}
{(probablySupportsClipboardBlob || isFirefox) && (
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements)
}
startIcon={copyIcon}
>
{t("imageExportDialog.button.copyPngToClipboard")}
)}
);
};
type ExportSettingProps = {
label: string;
children: React.ReactNode;
tooltip?: string;
name?: string;
multipleInputs?: boolean;
};
const ExportSetting = ({
label,
children,
tooltip,
name,
multipleInputs,
}: 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;
}) => {
if (appState.openDialog !== "imageExport") {
return null;
}
return (
);
};