ExportDialog.tsx 7.3 KB

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