2020-12-03 17:03:02 +02:00
|
|
|
import React, { useEffect, useRef, useState } from "react";
|
2020-11-04 18:50:53 +01:00
|
|
|
import { render, unmountComponentAtNode } from "react-dom";
|
2020-02-04 11:50:18 +01:00
|
|
|
import { probablySupportsClipboardBlob } from "../clipboard";
|
2020-10-28 20:52:53 +01:00
|
|
|
import { canvasToBlob } from "../data/blob";
|
2020-12-03 17:03:02 +02:00
|
|
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
2020-10-28 20:52:53 +01:00
|
|
|
import { CanvasError } from "../errors";
|
2020-12-03 17:03:02 +02:00
|
|
|
import { t } from "../i18n";
|
2022-06-21 20:03:23 +05:00
|
|
|
import { useDevice } from "./App";
|
2020-12-03 17:03:02 +02:00
|
|
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
2021-05-30 15:31:12 +01:00
|
|
|
import { exportToCanvas } from "../scene/export";
|
2021-10-21 22:05:48 +02:00
|
|
|
import { AppState, BinaryFiles } from "../types";
|
2020-12-03 17:03:02 +02:00
|
|
|
import { Dialog } from "./Dialog";
|
2021-05-25 21:37:14 +02:00
|
|
|
import { clipboard, exportImage } from "./icons";
|
2020-12-03 17:03:02 +02:00
|
|
|
import Stack from "./Stack";
|
|
|
|
import { ToolButton } from "./ToolButton";
|
2021-05-25 21:37:14 +02:00
|
|
|
import "./ExportDialog.scss";
|
|
|
|
import OpenColor from "open-color";
|
|
|
|
import { CheckboxItem } from "./CheckboxItem";
|
2021-05-30 15:31:12 +01:00
|
|
|
import { DEFAULT_EXPORT_PADDING } from "../constants";
|
2021-10-07 13:19:40 +02:00
|
|
|
import { nativeFileSystemSupported } from "../data/filesystem";
|
2022-03-28 14:46:40 +02:00
|
|
|
import { ActionManager } from "../actions/manager";
|
2020-01-17 15:43:24 +01:00
|
|
|
|
2021-02-24 19:52:17 +05:30
|
|
|
const supportsContextFilters =
|
|
|
|
"filter" in document.createElement("canvas").getContext("2d")!;
|
|
|
|
|
2020-10-28 20:52:53 +01:00
|
|
|
export const ErrorCanvasPreview = () => {
|
|
|
|
return (
|
|
|
|
<div>
|
|
|
|
<h3>{t("canvasError.cannotShowPreview")}</h3>
|
|
|
|
<p>
|
|
|
|
<span>{t("canvasError.canvasTooBig")}</span>
|
|
|
|
</p>
|
|
|
|
<em>({t("canvasError.canvasTooBigTip")})</em>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
2020-11-04 18:50:53 +01:00
|
|
|
const renderPreview = (
|
|
|
|
content: HTMLCanvasElement | Error,
|
|
|
|
previewNode: HTMLDivElement,
|
|
|
|
) => {
|
|
|
|
unmountComponentAtNode(previewNode);
|
|
|
|
previewNode.innerHTML = "";
|
|
|
|
if (content instanceof HTMLCanvasElement) {
|
|
|
|
previewNode.appendChild(content);
|
|
|
|
} else {
|
|
|
|
render(<ErrorCanvasPreview />, previewNode);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2020-03-07 10:20:38 -05:00
|
|
|
export type ExportCB = (
|
2020-04-08 09:49:52 -07:00
|
|
|
elements: readonly NonDeletedExcalidrawElement[],
|
2020-01-24 12:04:54 +02:00
|
|
|
scale?: number,
|
2020-01-22 18:27:44 +02:00
|
|
|
) => void;
|
2020-01-17 15:43:24 +01:00
|
|
|
|
2021-05-25 21:37:14 +02:00
|
|
|
const ExportButton: React.FC<{
|
|
|
|
color: keyof OpenColor;
|
|
|
|
onClick: () => void;
|
|
|
|
title: string;
|
|
|
|
shade?: number;
|
|
|
|
}> = ({ 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>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
const ImageExportModal = ({
|
2020-01-15 20:42:02 +05:00
|
|
|
elements,
|
|
|
|
appState,
|
2021-10-21 22:05:48 +02:00
|
|
|
files,
|
2021-05-30 15:31:12 +01:00
|
|
|
exportPadding = DEFAULT_EXPORT_PADDING,
|
2020-01-15 20:42:02 +05:00
|
|
|
actionManager,
|
|
|
|
onExportToPng,
|
2020-01-28 12:25:13 -08:00
|
|
|
onExportToSvg,
|
2020-01-20 00:56:19 -05:00
|
|
|
onExportToClipboard,
|
2020-01-15 20:42:02 +05:00
|
|
|
}: {
|
|
|
|
appState: AppState;
|
2020-04-08 09:49:52 -07:00
|
|
|
elements: readonly NonDeletedExcalidrawElement[];
|
2021-10-21 22:05:48 +02:00
|
|
|
files: BinaryFiles;
|
2020-01-15 20:42:02 +05:00
|
|
|
exportPadding?: number;
|
2022-03-28 14:46:40 +02:00
|
|
|
actionManager: ActionManager;
|
2020-01-17 15:43:24 +01:00
|
|
|
onExportToPng: ExportCB;
|
2020-01-28 12:25:13 -08:00
|
|
|
onExportToSvg: ExportCB;
|
2020-01-17 15:43:24 +01:00
|
|
|
onExportToClipboard: ExportCB;
|
2020-01-25 14:11:26 -08:00
|
|
|
onCloseRequest: () => void;
|
2020-05-20 16:21:37 +03:00
|
|
|
}) => {
|
2020-03-08 10:20:55 -07:00
|
|
|
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
2020-01-15 20:42:02 +05:00
|
|
|
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
2020-01-24 20:02:55 +00:00
|
|
|
const previewRef = useRef<HTMLDivElement>(null);
|
2021-05-26 21:44:54 +02:00
|
|
|
const { exportBackground, viewBackgroundColor } = appState;
|
2020-01-15 20:42:02 +05:00
|
|
|
|
|
|
|
const exportedElements = exportSelected
|
2021-12-16 21:14:03 +05:30
|
|
|
? getSelectedElements(elements, appState, true)
|
2020-01-15 20:42:02 +05:00
|
|
|
: elements;
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
setExportSelected(someElementIsSelected);
|
|
|
|
}, [someElementIsSelected]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
2020-01-24 20:02:55 +00:00
|
|
|
const previewNode = previewRef.current;
|
2020-10-28 20:52:53 +01:00
|
|
|
if (!previewNode) {
|
|
|
|
return;
|
|
|
|
}
|
2021-10-21 22:05:48 +02:00
|
|
|
exportToCanvas(exportedElements, appState, files, {
|
|
|
|
exportBackground,
|
|
|
|
viewBackgroundColor,
|
|
|
|
exportPadding,
|
|
|
|
})
|
|
|
|
.then((canvas) => {
|
|
|
|
// 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(() => {
|
2020-11-04 18:50:53 +01:00
|
|
|
renderPreview(canvas, previewNode);
|
2020-10-28 20:52:53 +01:00
|
|
|
});
|
2021-10-21 22:05:48 +02:00
|
|
|
})
|
|
|
|
.catch((error) => {
|
|
|
|
console.error(error);
|
|
|
|
renderPreview(new CanvasError(), previewNode);
|
|
|
|
});
|
2020-01-15 20:42:02 +05:00
|
|
|
}, [
|
2020-03-08 10:20:55 -07:00
|
|
|
appState,
|
2021-10-21 22:05:48 +02:00
|
|
|
files,
|
2020-01-15 20:42:02 +05:00
|
|
|
exportedElements,
|
|
|
|
exportBackground,
|
|
|
|
exportPadding,
|
2020-01-17 15:43:24 +01:00
|
|
|
viewBackgroundColor,
|
2020-01-15 20:42:02 +05:00
|
|
|
]);
|
|
|
|
|
2020-01-25 14:11:26 -08:00
|
|
|
return (
|
2020-04-08 21:31:40 +09:00
|
|
|
<div className="ExportDialog">
|
2020-11-04 18:50:53 +01:00
|
|
|
<div className="ExportDialog__preview" ref={previewRef} />
|
2021-02-24 19:52:17 +05:30
|
|
|
{supportsContextFilters &&
|
|
|
|
actionManager.renderAction("exportWithDarkMode")}
|
2021-05-25 21:37:14 +02:00
|
|
|
<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>
|
|
|
|
)}
|
|
|
|
{actionManager.renderAction("changeExportEmbedScene")}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
|
2021-05-30 15:31:12 +01:00
|
|
|
<Stack.Row gap={2}>
|
|
|
|
{actionManager.renderAction("changeExportScale")}
|
2021-05-25 21:37:14 +02:00
|
|
|
</Stack.Row>
|
|
|
|
<p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
|
|
|
|
</div>
|
|
|
|
<div
|
|
|
|
style={{
|
|
|
|
display: "flex",
|
|
|
|
alignItems: "center",
|
|
|
|
justifyContent: "center",
|
|
|
|
margin: ".6em 0",
|
|
|
|
}}
|
|
|
|
>
|
2021-10-07 13:19:40 +02:00
|
|
|
{!nativeFileSystemSupported &&
|
|
|
|
actionManager.renderAction("changeProjectName")}
|
2021-05-25 21:37:14 +02:00
|
|
|
</div>
|
|
|
|
<Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
|
|
|
|
<ExportButton
|
|
|
|
color="indigo"
|
|
|
|
title={t("buttons.exportToPng")}
|
|
|
|
aria-label={t("buttons.exportToPng")}
|
2021-05-30 15:31:12 +01:00
|
|
|
onClick={() => onExportToPng(exportedElements)}
|
2021-05-25 21:37:14 +02:00
|
|
|
>
|
|
|
|
PNG
|
|
|
|
</ExportButton>
|
|
|
|
<ExportButton
|
|
|
|
color="red"
|
|
|
|
title={t("buttons.exportToSvg")}
|
|
|
|
aria-label={t("buttons.exportToSvg")}
|
2021-05-30 15:31:12 +01:00
|
|
|
onClick={() => onExportToSvg(exportedElements)}
|
2021-05-25 21:37:14 +02:00
|
|
|
>
|
|
|
|
SVG
|
|
|
|
</ExportButton>
|
|
|
|
{probablySupportsClipboardBlob && (
|
|
|
|
<ExportButton
|
|
|
|
title={t("buttons.copyPngToClipboard")}
|
2021-05-30 15:31:12 +01:00
|
|
|
onClick={() => onExportToClipboard(exportedElements)}
|
2021-05-25 21:37:14 +02:00
|
|
|
color="gray"
|
|
|
|
shade={7}
|
|
|
|
>
|
|
|
|
{clipboard}
|
|
|
|
</ExportButton>
|
2020-03-15 13:26:52 -04:00
|
|
|
)}
|
2021-05-25 21:37:14 +02:00
|
|
|
</Stack.Row>
|
2020-01-25 14:11:26 -08:00
|
|
|
</div>
|
|
|
|
);
|
2020-05-20 16:21:37 +03:00
|
|
|
};
|
2020-01-25 14:11:26 -08:00
|
|
|
|
2021-05-25 21:37:14 +02:00
|
|
|
export const ImageExportDialog = ({
|
2020-01-25 14:11:26 -08:00
|
|
|
elements,
|
|
|
|
appState,
|
2021-10-21 22:05:48 +02:00
|
|
|
files,
|
2021-05-30 15:31:12 +01:00
|
|
|
exportPadding = DEFAULT_EXPORT_PADDING,
|
2020-01-25 14:11:26 -08:00
|
|
|
actionManager,
|
|
|
|
onExportToPng,
|
2020-01-28 12:25:13 -08:00
|
|
|
onExportToSvg,
|
2020-01-25 14:11:26 -08:00
|
|
|
onExportToClipboard,
|
|
|
|
}: {
|
|
|
|
appState: AppState;
|
2020-04-08 09:49:52 -07:00
|
|
|
elements: readonly NonDeletedExcalidrawElement[];
|
2021-10-21 22:05:48 +02:00
|
|
|
files: BinaryFiles;
|
2020-01-25 14:11:26 -08:00
|
|
|
exportPadding?: number;
|
2022-03-28 14:46:40 +02:00
|
|
|
actionManager: ActionManager;
|
2020-01-25 14:11:26 -08:00
|
|
|
onExportToPng: ExportCB;
|
2020-01-28 12:25:13 -08:00
|
|
|
onExportToSvg: ExportCB;
|
2020-01-25 14:11:26 -08:00
|
|
|
onExportToClipboard: ExportCB;
|
2020-05-20 16:21:37 +03:00
|
|
|
}) => {
|
2020-01-25 14:11:26 -08:00
|
|
|
const [modalIsShown, setModalIsShown] = useState(false);
|
2020-01-25 19:37:58 -03:00
|
|
|
|
|
|
|
const handleClose = React.useCallback(() => {
|
|
|
|
setModalIsShown(false);
|
|
|
|
}, []);
|
2020-01-15 20:42:02 +05:00
|
|
|
|
|
|
|
return (
|
|
|
|
<>
|
2020-01-25 14:52:03 -03:00
|
|
|
<ToolButton
|
2020-12-03 12:03:29 +02:00
|
|
|
onClick={() => {
|
|
|
|
setModalIsShown(true);
|
|
|
|
}}
|
2021-05-25 21:37:14 +02:00
|
|
|
data-testid="image-export-button"
|
|
|
|
icon={exportImage}
|
2020-01-15 20:42:02 +05:00
|
|
|
type="button"
|
2021-05-25 21:37:14 +02:00
|
|
|
aria-label={t("buttons.exportImage")}
|
2022-06-21 20:03:23 +05:00
|
|
|
showAriaLabel={useDevice().isMobile}
|
2021-05-25 21:37:14 +02:00
|
|
|
title={t("buttons.exportImage")}
|
2020-01-15 20:42:02 +05:00
|
|
|
/>
|
|
|
|
{modalIsShown && (
|
2021-05-25 21:37:14 +02:00
|
|
|
<Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}>
|
|
|
|
<ImageExportModal
|
2020-01-25 14:11:26 -08:00
|
|
|
elements={elements}
|
|
|
|
appState={appState}
|
2021-10-21 22:05:48 +02:00
|
|
|
files={files}
|
2020-01-25 14:11:26 -08:00
|
|
|
exportPadding={exportPadding}
|
|
|
|
actionManager={actionManager}
|
|
|
|
onExportToPng={onExportToPng}
|
2020-01-28 12:25:13 -08:00
|
|
|
onExportToSvg={onExportToSvg}
|
2020-01-25 14:11:26 -08:00
|
|
|
onExportToClipboard={onExportToClipboard}
|
2020-01-25 19:37:58 -03:00
|
|
|
onCloseRequest={handleClose}
|
2020-01-25 14:11:26 -08:00
|
|
|
/>
|
2020-03-13 15:32:47 -04:00
|
|
|
</Dialog>
|
2020-01-15 20:42:02 +05:00
|
|
|
)}
|
|
|
|
</>
|
|
|
|
);
|
2020-05-20 16:21:37 +03:00
|
|
|
};
|