|
@@ -1,25 +1,39 @@
|
|
|
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 { canvasToBlob } from "../data/blob";
|
|
|
-import { NonDeletedExcalidrawElement } from "../element/types";
|
|
|
-import { t } from "../i18n";
|
|
|
-import { getSelectedElements, isSomeElementSelected } from "../scene";
|
|
|
-import { AppClassProperties, BinaryFiles, UIAppState } from "../types";
|
|
|
-import { Dialog } from "./Dialog";
|
|
|
-import { clipboard } from "./icons";
|
|
|
-import Stack from "./Stack";
|
|
|
-import OpenColor from "open-color";
|
|
|
-import { CheckboxItem } from "./CheckboxItem";
|
|
|
import {
|
|
|
DEFAULT_EXPORT_PADDING,
|
|
|
EXPORT_IMAGE_TYPES,
|
|
|
isFirefox,
|
|
|
+ EXPORT_SCALES,
|
|
|
} from "../constants";
|
|
|
+
|
|
|
+import { canvasToBlob } from "../data/blob";
|
|
|
import { nativeFileSystemSupported } from "../data/filesystem";
|
|
|
-import { ActionManager } from "../actions/manager";
|
|
|
+import { NonDeletedExcalidrawElement } from "../element/types";
|
|
|
+import { t } from "../i18n";
|
|
|
+import { getSelectedElements, isSomeElementSelected } from "../scene";
|
|
|
import { exportToCanvas } from "../packages/utils";
|
|
|
|
|
|
-import "./ExportDialog.scss";
|
|
|
+import { copyIcon, downloadIcon, helpIcon } from "./icons";
|
|
|
+import { Button } from "./Button";
|
|
|
+import { Dialog } from "./Dialog";
|
|
|
+import { RadioGroup } from "./RadioGroup";
|
|
|
+import { Switch } from "./Switch";
|
|
|
+import { Tooltip } from "./Tooltip";
|
|
|
+
|
|
|
+import "./ImageExportDialog.scss";
|
|
|
+import { useAppProps } from "./App";
|
|
|
|
|
|
const supportsContextFilters =
|
|
|
"filter" in document.createElement("canvas").getContext("2d")!;
|
|
@@ -36,50 +50,36 @@ export const ErrorCanvasPreview = () => {
|
|
|
);
|
|
|
};
|
|
|
|
|
|
-export type ExportCB = (
|
|
|
- elements: readonly NonDeletedExcalidrawElement[],
|
|
|
- scale?: number,
|
|
|
-) => void;
|
|
|
-
|
|
|
-const ExportButton: React.FC<{
|
|
|
- color: keyof OpenColor;
|
|
|
- onClick: () => void;
|
|
|
- title: string;
|
|
|
- shade?: number;
|
|
|
- children?: React.ReactNode;
|
|
|
-}> = ({ children, title, onClick, color, shade = 6 }) => {
|
|
|
- return (
|
|
|
- <button
|
|
|
- className="ExportDialog-imageExportButton"
|
|
|
- style={{
|
|
|
- ["--button-color" as any]: OpenColor[color][shade],
|
|
|
- ["--button-color-darker" as any]: OpenColor[color][shade + 1],
|
|
|
- ["--button-color-darkest" as any]: OpenColor[color][shade + 2],
|
|
|
- }}
|
|
|
- title={title}
|
|
|
- aria-label={title}
|
|
|
- onClick={onClick}
|
|
|
- >
|
|
|
- {children}
|
|
|
- </button>
|
|
|
- );
|
|
|
+type ImageExportModalProps = {
|
|
|
+ appState: UIAppState;
|
|
|
+ elements: readonly NonDeletedExcalidrawElement[];
|
|
|
+ files: BinaryFiles;
|
|
|
+ actionManager: ActionManager;
|
|
|
+ onExportImage: AppClassProperties["onExportImage"];
|
|
|
};
|
|
|
|
|
|
const ImageExportModal = ({
|
|
|
- elements,
|
|
|
appState,
|
|
|
+ elements,
|
|
|
files,
|
|
|
actionManager,
|
|
|
onExportImage,
|
|
|
-}: {
|
|
|
- appState: UIAppState;
|
|
|
- elements: readonly NonDeletedExcalidrawElement[];
|
|
|
- files: BinaryFiles;
|
|
|
- actionManager: ActionManager;
|
|
|
- onExportImage: AppClassProperties["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 [exportDarkMode, setExportDarkMode] = useState(
|
|
|
+ appState.exportWithDarkMode,
|
|
|
+ );
|
|
|
+ const [embedScene, setEmbedScene] = useState(appState.exportEmbedScene);
|
|
|
+ const [exportScale, setExportScale] = useState(appState.exportScale);
|
|
|
+
|
|
|
const previewRef = useRef<HTMLDivElement>(null);
|
|
|
const [renderError, setRenderError] = useState<Error | null>(null);
|
|
|
|
|
@@ -93,6 +93,7 @@ const ImageExportModal = ({
|
|
|
return;
|
|
|
}
|
|
|
const maxWidth = previewNode.offsetWidth;
|
|
|
+ const maxHeight = previewNode.offsetHeight;
|
|
|
if (!maxWidth) {
|
|
|
return;
|
|
|
}
|
|
@@ -101,7 +102,7 @@ const ImageExportModal = ({
|
|
|
appState,
|
|
|
files,
|
|
|
exportPadding: DEFAULT_EXPORT_PADDING,
|
|
|
- maxWidthOrHeight: maxWidth,
|
|
|
+ maxWidthOrHeight: Math.max(maxWidth, maxHeight),
|
|
|
})
|
|
|
.then((canvas) => {
|
|
|
setRenderError(null);
|
|
@@ -118,89 +119,190 @@ const ImageExportModal = ({
|
|
|
}, [appState, files, exportedElements]);
|
|
|
|
|
|
return (
|
|
|
- <div className="ExportDialog">
|
|
|
- <div className="ExportDialog__preview" ref={previewRef}>
|
|
|
- {renderError && <ErrorCanvasPreview />}
|
|
|
- </div>
|
|
|
- {supportsContextFilters &&
|
|
|
- actionManager.renderAction("exportWithDarkMode")}
|
|
|
- <div style={{ display: "grid", gridTemplateColumns: "1fr" }}>
|
|
|
- <div
|
|
|
- style={{
|
|
|
- display: "grid",
|
|
|
- gridTemplateColumns: "repeat(auto-fit, minmax(190px, 1fr))",
|
|
|
- // dunno why this is needed, but when the items wrap it creates
|
|
|
- // an overflow
|
|
|
- overflow: "hidden",
|
|
|
- }}
|
|
|
- >
|
|
|
- {actionManager.renderAction("changeExportBackground")}
|
|
|
- {someElementIsSelected && (
|
|
|
- <CheckboxItem
|
|
|
- checked={exportSelected}
|
|
|
- onChange={(checked) => setExportSelected(checked)}
|
|
|
- >
|
|
|
- {t("labels.onlySelected")}
|
|
|
- </CheckboxItem>
|
|
|
+ <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" }}
|
|
|
+ disabled={
|
|
|
+ typeof appProps.name !== "undefined" || appState.viewModeEnabled
|
|
|
+ }
|
|
|
+ onChange={(event) => {
|
|
|
+ setProjectName(event.target.value);
|
|
|
+ actionManager.executeAction(
|
|
|
+ actionChangeProjectName,
|
|
|
+ "ui",
|
|
|
+ event.target.value,
|
|
|
+ );
|
|
|
+ }}
|
|
|
+ />
|
|
|
)}
|
|
|
- {actionManager.renderAction("changeExportEmbedScene")}
|
|
|
</div>
|
|
|
</div>
|
|
|
- <div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
|
|
|
- <Stack.Row gap={2}>
|
|
|
- {actionManager.renderAction("changeExportScale")}
|
|
|
- </Stack.Row>
|
|
|
- <p style={{ marginLeft: "1em", userSelect: "none" }}>
|
|
|
- {t("buttons.scale")}
|
|
|
- </p>
|
|
|
- </div>
|
|
|
- <div
|
|
|
- style={{
|
|
|
- display: "flex",
|
|
|
- alignItems: "center",
|
|
|
- justifyContent: "center",
|
|
|
- margin: ".6em 0",
|
|
|
- }}
|
|
|
- >
|
|
|
- {!nativeFileSystemSupported &&
|
|
|
- actionManager.renderAction("changeProjectName")}
|
|
|
- </div>
|
|
|
- <Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
|
|
|
- <ExportButton
|
|
|
- color="indigo"
|
|
|
- title={t("buttons.exportToPng")}
|
|
|
- aria-label={t("buttons.exportToPng")}
|
|
|
- onClick={() =>
|
|
|
- onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements)
|
|
|
- }
|
|
|
+ <div className="ImageExportModal__settings">
|
|
|
+ <h3>{t("imageExportDialog.header")}</h3>
|
|
|
+ {someElementIsSelected && (
|
|
|
+ <ExportSetting
|
|
|
+ label={t("imageExportDialog.label.onlySelected")}
|
|
|
+ name="exportOnlySelected"
|
|
|
+ >
|
|
|
+ <Switch
|
|
|
+ name="exportOnlySelected"
|
|
|
+ checked={exportSelected}
|
|
|
+ onChange={(checked) => {
|
|
|
+ setExportSelected(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"
|
|
|
>
|
|
|
- PNG
|
|
|
- </ExportButton>
|
|
|
- <ExportButton
|
|
|
- color="red"
|
|
|
- title={t("buttons.exportToSvg")}
|
|
|
- aria-label={t("buttons.exportToSvg")}
|
|
|
- onClick={() =>
|
|
|
- onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements)
|
|
|
- }
|
|
|
+ <Switch
|
|
|
+ name="exportEmbedSwitch"
|
|
|
+ checked={embedScene}
|
|
|
+ onChange={(checked) => {
|
|
|
+ setEmbedScene(checked);
|
|
|
+ actionManager.executeAction(
|
|
|
+ actionChangeExportEmbedScene,
|
|
|
+ "ui",
|
|
|
+ checked,
|
|
|
+ );
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </ExportSetting>
|
|
|
+ <ExportSetting
|
|
|
+ label={t("imageExportDialog.label.scale")}
|
|
|
+ name="exportScale"
|
|
|
>
|
|
|
- SVG
|
|
|
- </ExportButton>
|
|
|
- {/* firefox supports clipboard API under a flag,
|
|
|
- so let's throw and tell people what they can do */}
|
|
|
- {(probablySupportsClipboardBlob || isFirefox) && (
|
|
|
- <ExportButton
|
|
|
- title={t("buttons.copyPngToClipboard")}
|
|
|
- onClick={() =>
|
|
|
- onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements)
|
|
|
+ <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">
|
|
|
+ <Button
|
|
|
+ className="ImageExportModal__settings__buttons__button"
|
|
|
+ title={t("imageExportDialog.title.exportToPng")}
|
|
|
+ aria-label={t("imageExportDialog.title.exportToPng")}
|
|
|
+ onSelect={() =>
|
|
|
+ onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements)
|
|
|
+ }
|
|
|
+ >
|
|
|
+ {downloadIcon} {t("imageExportDialog.button.exportToPng")}
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ className="ImageExportModal__settings__buttons__button"
|
|
|
+ title={t("imageExportDialog.title.exportToSvg")}
|
|
|
+ aria-label={t("imageExportDialog.title.exportToSvg")}
|
|
|
+ onSelect={() =>
|
|
|
+ onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements)
|
|
|
}
|
|
|
- color="gray"
|
|
|
- shade={7}
|
|
|
>
|
|
|
- {clipboard}
|
|
|
- </ExportButton>
|
|
|
+ {downloadIcon} {t("imageExportDialog.button.exportToSvg")}
|
|
|
+ </Button>
|
|
|
+ {(probablySupportsClipboardBlob || isFirefox) && (
|
|
|
+ <Button
|
|
|
+ className="ImageExportModal__settings__buttons__button"
|
|
|
+ title={t("imageExportDialog.title.copyPngToClipboard")}
|
|
|
+ aria-label={t("imageExportDialog.title.copyPngToClipboard")}
|
|
|
+ onSelect={() =>
|
|
|
+ onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements)
|
|
|
+ }
|
|
|
+ >
|
|
|
+ {copyIcon} {t("imageExportDialog.button.copyPngToClipboard")}
|
|
|
+ </Button>
|
|
|
+ )}
|
|
|
+ </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>
|
|
|
)}
|
|
|
- </Stack.Row>
|
|
|
+ </label>
|
|
|
+ <div className="ImageExportModal__settings__setting__content">
|
|
|
+ {children}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
);
|
|
|
};
|
|
@@ -225,7 +327,7 @@ export const ImageExportDialog = ({
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
- <Dialog onCloseRequest={onCloseRequest} title={t("buttons.exportImage")}>
|
|
|
+ <Dialog onCloseRequest={onCloseRequest} size="wide" title={false}>
|
|
|
<ImageExportModal
|
|
|
elements={elements}
|
|
|
appState={appState}
|