ExportDialog.tsx 8.3 KB

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