ExportDialog.tsx 7.6 KB

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