ExportDialog.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import "./ExportDialog.scss";
  2. import React, { useState, useEffect, useRef } from "react";
  3. import { ToolButton } from "./ToolButton";
  4. import { clipboard, exportFile, link } from "./icons";
  5. import { NonDeletedExcalidrawElement } from "../element/types";
  6. import { AppState } from "../types";
  7. import { exportToCanvas, getExportSize } from "../scene/export";
  8. import { ActionsManagerInterface } from "../actions/types";
  9. import Stack from "./Stack";
  10. import { t } from "../i18n";
  11. import { probablySupportsClipboardBlob } from "../clipboard";
  12. import { getSelectedElements, isSomeElementSelected } from "../scene";
  13. import useIsMobile from "../is-mobile";
  14. import { Dialog } from "./Dialog";
  15. import { canvasToBlob } from "../data/blob";
  16. import { CanvasError } from "../errors";
  17. const scales = [1, 2, 3];
  18. const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
  19. export const ErrorCanvasPreview = () => {
  20. return (
  21. <div>
  22. <h3>{t("canvasError.cannotShowPreview")}</h3>
  23. <p>
  24. <span>{t("canvasError.canvasTooBig")}</span>
  25. </p>
  26. <em>({t("canvasError.canvasTooBigTip")})</em>
  27. </div>
  28. );
  29. };
  30. export type ExportCB = (
  31. elements: readonly NonDeletedExcalidrawElement[],
  32. scale?: number,
  33. ) => void;
  34. const ExportModal = ({
  35. elements,
  36. appState,
  37. exportPadding = 10,
  38. actionManager,
  39. onExportToPng,
  40. onExportToSvg,
  41. onExportToClipboard,
  42. onExportToBackend,
  43. }: {
  44. appState: AppState;
  45. elements: readonly NonDeletedExcalidrawElement[];
  46. exportPadding?: number;
  47. actionManager: ActionsManagerInterface;
  48. onExportToPng: ExportCB;
  49. onExportToSvg: ExportCB;
  50. onExportToClipboard: ExportCB;
  51. onExportToBackend: ExportCB;
  52. onCloseRequest: () => void;
  53. }) => {
  54. const someElementIsSelected = isSomeElementSelected(elements, appState);
  55. const [scale, setScale] = useState(defaultScale);
  56. const [exportSelected, setExportSelected] = useState(someElementIsSelected);
  57. const [previewError, setPreviewError] = useState<Error | null>(null);
  58. const previewRef = useRef<HTMLDivElement>(null);
  59. const {
  60. exportBackground,
  61. viewBackgroundColor,
  62. shouldAddWatermark,
  63. } = appState;
  64. const exportedElements = exportSelected
  65. ? getSelectedElements(elements, appState)
  66. : elements;
  67. useEffect(() => {
  68. setExportSelected(someElementIsSelected);
  69. }, [someElementIsSelected]);
  70. useEffect(() => {
  71. const previewNode = previewRef.current;
  72. if (!previewNode) {
  73. return;
  74. }
  75. try {
  76. const canvas = exportToCanvas(exportedElements, appState, {
  77. exportBackground,
  78. viewBackgroundColor,
  79. exportPadding,
  80. scale,
  81. shouldAddWatermark,
  82. });
  83. let isRemoved = false;
  84. // if converting to blob fails, there's some problem that will
  85. // likely prevent preview and export (e.g. canvas too big)
  86. canvasToBlob(canvas)
  87. .then(() => {
  88. if (isRemoved) {
  89. return;
  90. }
  91. setPreviewError(null);
  92. previewNode.appendChild(canvas);
  93. })
  94. .catch((error) => {
  95. console.error(error);
  96. setPreviewError(new CanvasError());
  97. });
  98. return () => {
  99. isRemoved = true;
  100. canvas.remove();
  101. };
  102. } catch (error) {
  103. console.error(error);
  104. setPreviewError(new CanvasError());
  105. }
  106. }, [
  107. appState,
  108. exportedElements,
  109. exportBackground,
  110. exportPadding,
  111. viewBackgroundColor,
  112. scale,
  113. shouldAddWatermark,
  114. ]);
  115. return (
  116. <div className="ExportDialog">
  117. <div className="ExportDialog__preview" ref={previewRef}>
  118. {previewError && <ErrorCanvasPreview />}
  119. </div>
  120. <Stack.Col gap={2} align="center">
  121. <div className="ExportDialog__actions">
  122. <Stack.Row gap={2}>
  123. <ToolButton
  124. type="button"
  125. label="PNG"
  126. title={t("buttons.exportToPng")}
  127. aria-label={t("buttons.exportToPng")}
  128. onClick={() => onExportToPng(exportedElements, scale)}
  129. />
  130. <ToolButton
  131. type="button"
  132. label="SVG"
  133. title={t("buttons.exportToSvg")}
  134. aria-label={t("buttons.exportToSvg")}
  135. onClick={() => onExportToSvg(exportedElements, scale)}
  136. />
  137. {probablySupportsClipboardBlob && (
  138. <ToolButton
  139. type="button"
  140. icon={clipboard}
  141. title={t("buttons.copyPngToClipboard")}
  142. aria-label={t("buttons.copyPngToClipboard")}
  143. onClick={() => onExportToClipboard(exportedElements, scale)}
  144. />
  145. )}
  146. <ToolButton
  147. type="button"
  148. icon={link}
  149. title={t("buttons.getShareableLink")}
  150. aria-label={t("buttons.getShareableLink")}
  151. onClick={() => onExportToBackend(exportedElements)}
  152. />
  153. </Stack.Row>
  154. <div className="ExportDialog__name">
  155. {actionManager.renderAction("changeProjectName")}
  156. </div>
  157. <Stack.Row gap={2}>
  158. {scales.map((s) => {
  159. const [width, height] = getExportSize(
  160. exportedElements,
  161. exportPadding,
  162. shouldAddWatermark,
  163. s,
  164. );
  165. const scaleButtonTitle = `${t(
  166. "buttons.scale",
  167. )} ${s}x (${width}x${height})`;
  168. return (
  169. <ToolButton
  170. key={s}
  171. size="s"
  172. type="radio"
  173. icon={`${s}x`}
  174. name="export-canvas-scale"
  175. title={scaleButtonTitle}
  176. aria-label={scaleButtonTitle}
  177. id="export-canvas-scale"
  178. checked={s === scale}
  179. onChange={() => setScale(s)}
  180. />
  181. );
  182. })}
  183. </Stack.Row>
  184. </div>
  185. {actionManager.renderAction("changeExportBackground")}
  186. {someElementIsSelected && (
  187. <div>
  188. <label>
  189. <input
  190. type="checkbox"
  191. checked={exportSelected}
  192. onChange={(event) =>
  193. setExportSelected(event.currentTarget.checked)
  194. }
  195. />{" "}
  196. {t("labels.onlySelected")}
  197. </label>
  198. </div>
  199. )}
  200. {actionManager.renderAction("changeExportEmbedScene")}
  201. {actionManager.renderAction("changeShouldAddWatermark")}
  202. </Stack.Col>
  203. </div>
  204. );
  205. };
  206. export const ExportDialog = ({
  207. elements,
  208. appState,
  209. exportPadding = 10,
  210. actionManager,
  211. onExportToPng,
  212. onExportToSvg,
  213. onExportToClipboard,
  214. onExportToBackend,
  215. }: {
  216. appState: AppState;
  217. elements: readonly NonDeletedExcalidrawElement[];
  218. exportPadding?: number;
  219. actionManager: ActionsManagerInterface;
  220. onExportToPng: ExportCB;
  221. onExportToSvg: ExportCB;
  222. onExportToClipboard: ExportCB;
  223. onExportToBackend: ExportCB;
  224. }) => {
  225. const [modalIsShown, setModalIsShown] = useState(false);
  226. const triggerButton = useRef<HTMLButtonElement>(null);
  227. const handleClose = React.useCallback(() => {
  228. setModalIsShown(false);
  229. triggerButton.current?.focus();
  230. }, []);
  231. return (
  232. <>
  233. <ToolButton
  234. onClick={() => setModalIsShown(true)}
  235. icon={exportFile}
  236. type="button"
  237. aria-label={t("buttons.export")}
  238. showAriaLabel={useIsMobile()}
  239. title={t("buttons.export")}
  240. ref={triggerButton}
  241. />
  242. {modalIsShown && (
  243. <Dialog
  244. maxWidth={800}
  245. onCloseRequest={handleClose}
  246. title={t("buttons.export")}
  247. >
  248. <ExportModal
  249. elements={elements}
  250. appState={appState}
  251. exportPadding={exportPadding}
  252. actionManager={actionManager}
  253. onExportToPng={onExportToPng}
  254. onExportToSvg={onExportToSvg}
  255. onExportToClipboard={onExportToClipboard}
  256. onExportToBackend={onExportToBackend}
  257. onCloseRequest={handleClose}
  258. />
  259. </Dialog>
  260. )}
  261. </>
  262. );
  263. };