ExportDialog.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. import "./ExportDialog.css";
  2. import React, { useState, useEffect, useRef } from "react";
  3. import { Modal } from "./Modal";
  4. import { ToolButton } from "./ToolButton";
  5. import { clipboard, exportFile, downloadFile, svgFile, link } from "./icons";
  6. import { Island } from "./Island";
  7. import { ExcalidrawElement } from "../element/types";
  8. import { AppState } from "../types";
  9. import { exportToCanvas } from "../scene/export";
  10. import { ActionsManagerInterface, UpdaterFn } from "../actions/types";
  11. import Stack from "./Stack";
  12. import { useTranslation } from "react-i18next";
  13. import { KEYS } from "../keys";
  14. const probablySupportsClipboard =
  15. "toBlob" in HTMLCanvasElement.prototype &&
  16. "clipboard" in navigator &&
  17. "write" in navigator.clipboard &&
  18. "ClipboardItem" in window;
  19. const scales = [1, 2, 3];
  20. const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
  21. type ExportCB = (
  22. elements: readonly ExcalidrawElement[],
  23. scale?: number,
  24. ) => void;
  25. function ExportModal({
  26. elements,
  27. appState,
  28. exportPadding = 10,
  29. actionManager,
  30. syncActionResult,
  31. onExportToPng,
  32. onExportToSvg,
  33. onExportToClipboard,
  34. onExportToBackend,
  35. onCloseRequest,
  36. }: {
  37. appState: AppState;
  38. elements: readonly ExcalidrawElement[];
  39. exportPadding?: number;
  40. actionManager: ActionsManagerInterface;
  41. syncActionResult: UpdaterFn;
  42. onExportToPng: ExportCB;
  43. onExportToSvg: ExportCB;
  44. onExportToClipboard: ExportCB;
  45. onExportToBackend: ExportCB;
  46. onCloseRequest: () => void;
  47. }) {
  48. const { t } = useTranslation();
  49. const someElementIsSelected = elements.some(element => element.isSelected);
  50. const [scale, setScale] = useState(defaultScale);
  51. const [exportSelected, setExportSelected] = useState(someElementIsSelected);
  52. const previewRef = useRef<HTMLDivElement>(null);
  53. const { exportBackground, viewBackgroundColor } = appState;
  54. const pngButton = useRef<HTMLButtonElement>(null);
  55. const closeButton = useRef<HTMLButtonElement>(null);
  56. const onlySelectedInput = useRef<HTMLInputElement>(null);
  57. const exportedElements = exportSelected
  58. ? elements.filter(element => element.isSelected)
  59. : elements;
  60. useEffect(() => {
  61. setExportSelected(someElementIsSelected);
  62. }, [someElementIsSelected]);
  63. useEffect(() => {
  64. const previewNode = previewRef.current;
  65. const canvas = exportToCanvas(exportedElements, {
  66. exportBackground,
  67. viewBackgroundColor,
  68. exportPadding,
  69. scale,
  70. });
  71. previewNode?.appendChild(canvas);
  72. return () => {
  73. previewNode?.removeChild(canvas);
  74. };
  75. }, [
  76. exportedElements,
  77. exportBackground,
  78. exportPadding,
  79. viewBackgroundColor,
  80. scale,
  81. ]);
  82. useEffect(() => {
  83. pngButton.current?.focus();
  84. }, []);
  85. function handleKeyDown(e: React.KeyboardEvent) {
  86. if (e.key === KEYS.TAB) {
  87. const { activeElement } = document;
  88. if (e.shiftKey) {
  89. if (activeElement === pngButton.current) {
  90. closeButton.current?.focus();
  91. e.preventDefault();
  92. }
  93. } else {
  94. if (activeElement === closeButton.current) {
  95. pngButton.current?.focus();
  96. e.preventDefault();
  97. }
  98. if (activeElement === onlySelectedInput.current) {
  99. closeButton.current?.focus();
  100. e.preventDefault();
  101. }
  102. }
  103. }
  104. }
  105. return (
  106. <div className="ExportDialog__dialog" onKeyDown={handleKeyDown}>
  107. <Island padding={4}>
  108. <button
  109. className="ExportDialog__close"
  110. onClick={onCloseRequest}
  111. aria-label={t("buttons.close")}
  112. ref={closeButton}
  113. >
  114. </button>
  115. <h2 id="export-title">{t("buttons.export")}</h2>
  116. <div className="ExportDialog__preview" ref={previewRef}></div>
  117. <div className="ExportDialog__actions">
  118. <Stack.Col gap={1}>
  119. <Stack.Row gap={2}>
  120. <ToolButton
  121. type="button"
  122. icon={downloadFile}
  123. title={t("buttons.exportToPng")}
  124. aria-label={t("buttons.exportToPng")}
  125. onClick={() => onExportToPng(exportedElements, scale)}
  126. ref={pngButton}
  127. />
  128. <ToolButton
  129. type="button"
  130. icon={svgFile}
  131. title={t("buttons.exportToSvg")}
  132. aria-label={t("buttons.exportToSvg")}
  133. onClick={() => onExportToSvg(exportedElements, scale)}
  134. />
  135. {probablySupportsClipboard && (
  136. <ToolButton
  137. type="button"
  138. icon={clipboard}
  139. title={t("buttons.copyToClipboard")}
  140. aria-label={t("buttons.copyToClipboard")}
  141. onClick={() => onExportToClipboard(exportedElements, scale)}
  142. />
  143. )}
  144. <ToolButton
  145. type="button"
  146. icon={link}
  147. title={t("buttons.getShareableLink")}
  148. aria-label={t("buttons.getShareableLink")}
  149. onClick={() => onExportToBackend(exportedElements)}
  150. />
  151. </Stack.Row>
  152. </Stack.Col>
  153. {actionManager.renderAction(
  154. "changeProjectName",
  155. elements,
  156. appState,
  157. syncActionResult,
  158. t,
  159. )}
  160. <Stack.Col gap={1}>
  161. <div className="ExportDialog__scales">
  162. <Stack.Row gap={2} align="baseline">
  163. {scales.map(s => (
  164. <ToolButton
  165. key={s}
  166. size="s"
  167. type="radio"
  168. icon={"x" + s}
  169. name="export-canvas-scale"
  170. aria-label={`Scale ${s} x`}
  171. id="export-canvas-scale"
  172. checked={scale === s}
  173. onChange={() => setScale(s)}
  174. />
  175. ))}
  176. </Stack.Row>
  177. </div>
  178. {actionManager.renderAction(
  179. "changeExportBackground",
  180. elements,
  181. appState,
  182. syncActionResult,
  183. t,
  184. )}
  185. {someElementIsSelected && (
  186. <div>
  187. <label>
  188. <input
  189. type="checkbox"
  190. checked={exportSelected}
  191. onChange={e => setExportSelected(e.currentTarget.checked)}
  192. ref={onlySelectedInput}
  193. />{" "}
  194. {t("labels.onlySelected")}
  195. </label>
  196. </div>
  197. )}
  198. </Stack.Col>
  199. </div>
  200. </Island>
  201. </div>
  202. );
  203. }
  204. export function ExportDialog({
  205. elements,
  206. appState,
  207. exportPadding = 10,
  208. actionManager,
  209. syncActionResult,
  210. onExportToPng,
  211. onExportToSvg,
  212. onExportToClipboard,
  213. onExportToBackend,
  214. }: {
  215. appState: AppState;
  216. elements: readonly ExcalidrawElement[];
  217. exportPadding?: number;
  218. actionManager: ActionsManagerInterface;
  219. syncActionResult: UpdaterFn;
  220. onExportToPng: ExportCB;
  221. onExportToSvg: ExportCB;
  222. onExportToClipboard: ExportCB;
  223. onExportToBackend: ExportCB;
  224. }) {
  225. const { t } = useTranslation();
  226. const [modalIsShown, setModalIsShown] = useState(false);
  227. const triggerButton = useRef<HTMLButtonElement>(null);
  228. const handleClose = React.useCallback(() => {
  229. setModalIsShown(false);
  230. triggerButton.current?.focus();
  231. }, []);
  232. return (
  233. <>
  234. <ToolButton
  235. onClick={() => setModalIsShown(true)}
  236. icon={exportFile}
  237. type="button"
  238. aria-label={t("buttons.export")}
  239. title={t("buttons.export")}
  240. ref={triggerButton}
  241. />
  242. {modalIsShown && (
  243. <Modal
  244. maxWidth={640}
  245. onCloseRequest={handleClose}
  246. labelledBy="export-title"
  247. >
  248. <ExportModal
  249. elements={elements}
  250. appState={appState}
  251. exportPadding={exportPadding}
  252. actionManager={actionManager}
  253. syncActionResult={syncActionResult}
  254. onExportToPng={onExportToPng}
  255. onExportToSvg={onExportToSvg}
  256. onExportToClipboard={onExportToClipboard}
  257. onExportToBackend={onExportToBackend}
  258. onCloseRequest={handleClose}
  259. />
  260. </Modal>
  261. )}
  262. </>
  263. );
  264. }