ExportDialog.tsx 8.0 KB

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