actionExport.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import { trackEvent } from "../analytics";
  2. import { load, questionCircle, saveAs } from "../components/icons";
  3. import { ProjectName } from "../components/ProjectName";
  4. import { ToolButton } from "../components/ToolButton";
  5. import "../components/ToolIcon.scss";
  6. import { Tooltip } from "../components/Tooltip";
  7. import { DarkModeToggle } from "../components/DarkModeToggle";
  8. import { loadFromJSON, saveAsJSON } from "../data";
  9. import { resaveAsImageWithScene } from "../data/resave";
  10. import { t } from "../i18n";
  11. import { useIsMobile } from "../components/App";
  12. import { KEYS } from "../keys";
  13. import { register } from "./register";
  14. import { CheckboxItem } from "../components/CheckboxItem";
  15. import { getExportSize } from "../scene/export";
  16. import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
  17. import { getSelectedElements, isSomeElementSelected } from "../scene";
  18. import { getNonDeletedElements } from "../element";
  19. import { ActiveFile } from "../components/ActiveFile";
  20. import { isImageFileHandle } from "../data/blob";
  21. import { nativeFileSystemSupported } from "../data/filesystem";
  22. import { Theme } from "../element/types";
  23. export const actionChangeProjectName = register({
  24. name: "changeProjectName",
  25. perform: (_elements, appState, value) => {
  26. trackEvent("change", "title");
  27. return { appState: { ...appState, name: value }, commitToHistory: false };
  28. },
  29. PanelComponent: ({ appState, updateData, appProps }) => (
  30. <ProjectName
  31. label={t("labels.fileTitle")}
  32. value={appState.name || "Unnamed"}
  33. onChange={(name: string) => updateData(name)}
  34. isNameEditable={
  35. typeof appProps.name === "undefined" && !appState.viewModeEnabled
  36. }
  37. />
  38. ),
  39. });
  40. export const actionChangeExportScale = register({
  41. name: "changeExportScale",
  42. perform: (_elements, appState, value) => {
  43. return {
  44. appState: { ...appState, exportScale: value },
  45. commitToHistory: false,
  46. };
  47. },
  48. PanelComponent: ({ elements: allElements, appState, updateData }) => {
  49. const elements = getNonDeletedElements(allElements);
  50. const exportSelected = isSomeElementSelected(elements, appState);
  51. const exportedElements = exportSelected
  52. ? getSelectedElements(elements, appState)
  53. : elements;
  54. return (
  55. <>
  56. {EXPORT_SCALES.map((s) => {
  57. const [width, height] = getExportSize(
  58. exportedElements,
  59. DEFAULT_EXPORT_PADDING,
  60. s,
  61. );
  62. const scaleButtonTitle = `${t(
  63. "buttons.scale",
  64. )} ${s}x (${width}x${height})`;
  65. return (
  66. <ToolButton
  67. key={s}
  68. size="small"
  69. type="radio"
  70. icon={`${s}x`}
  71. name="export-canvas-scale"
  72. title={scaleButtonTitle}
  73. aria-label={scaleButtonTitle}
  74. id="export-canvas-scale"
  75. checked={s === appState.exportScale}
  76. onChange={() => updateData(s)}
  77. />
  78. );
  79. })}
  80. </>
  81. );
  82. },
  83. });
  84. export const actionChangeExportBackground = register({
  85. name: "changeExportBackground",
  86. perform: (_elements, appState, value) => {
  87. return {
  88. appState: { ...appState, exportBackground: value },
  89. commitToHistory: false,
  90. };
  91. },
  92. PanelComponent: ({ appState, updateData }) => (
  93. <CheckboxItem
  94. checked={appState.exportBackground}
  95. onChange={(checked) => updateData(checked)}
  96. >
  97. {t("labels.withBackground")}
  98. </CheckboxItem>
  99. ),
  100. });
  101. export const actionChangeExportEmbedScene = register({
  102. name: "changeExportEmbedScene",
  103. perform: (_elements, appState, value) => {
  104. return {
  105. appState: { ...appState, exportEmbedScene: value },
  106. commitToHistory: false,
  107. };
  108. },
  109. PanelComponent: ({ appState, updateData }) => (
  110. <CheckboxItem
  111. checked={appState.exportEmbedScene}
  112. onChange={(checked) => updateData(checked)}
  113. >
  114. {t("labels.exportEmbedScene")}
  115. <Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
  116. <div className="excalidraw-tooltip-icon">{questionCircle}</div>
  117. </Tooltip>
  118. </CheckboxItem>
  119. ),
  120. });
  121. export const actionSaveToActiveFile = register({
  122. name: "saveToActiveFile",
  123. perform: async (elements, appState, value) => {
  124. const fileHandleExists = !!appState.fileHandle;
  125. try {
  126. const { fileHandle } = isImageFileHandle(appState.fileHandle)
  127. ? await resaveAsImageWithScene(elements, appState)
  128. : await saveAsJSON(elements, appState);
  129. return {
  130. commitToHistory: false,
  131. appState: {
  132. ...appState,
  133. fileHandle,
  134. toastMessage: fileHandleExists
  135. ? fileHandle?.name
  136. ? t("toast.fileSavedToFilename").replace(
  137. "{filename}",
  138. `"${fileHandle.name}"`,
  139. )
  140. : t("toast.fileSaved")
  141. : null,
  142. },
  143. };
  144. } catch (error) {
  145. if (error?.name !== "AbortError") {
  146. console.error(error);
  147. }
  148. return { commitToHistory: false };
  149. }
  150. },
  151. keyTest: (event) =>
  152. event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
  153. PanelComponent: ({ updateData, appState }) => (
  154. <ActiveFile
  155. onSave={() => updateData(null)}
  156. fileName={appState.fileHandle?.name}
  157. />
  158. ),
  159. });
  160. export const actionSaveFileToDisk = register({
  161. name: "saveFileToDisk",
  162. perform: async (elements, appState, value) => {
  163. try {
  164. const { fileHandle } = await saveAsJSON(elements, {
  165. ...appState,
  166. fileHandle: null,
  167. });
  168. return { commitToHistory: false, appState: { ...appState, fileHandle } };
  169. } catch (error) {
  170. if (error?.name !== "AbortError") {
  171. console.error(error);
  172. }
  173. return { commitToHistory: false };
  174. }
  175. },
  176. keyTest: (event) =>
  177. event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
  178. PanelComponent: ({ updateData }) => (
  179. <ToolButton
  180. type="button"
  181. icon={saveAs}
  182. title={t("buttons.saveAs")}
  183. aria-label={t("buttons.saveAs")}
  184. showAriaLabel={useIsMobile()}
  185. hidden={!nativeFileSystemSupported}
  186. onClick={() => updateData(null)}
  187. data-testid="save-as-button"
  188. />
  189. ),
  190. });
  191. export const actionLoadScene = register({
  192. name: "loadScene",
  193. perform: async (elements, appState) => {
  194. try {
  195. const {
  196. elements: loadedElements,
  197. appState: loadedAppState,
  198. } = await loadFromJSON(appState, elements);
  199. return {
  200. elements: loadedElements,
  201. appState: loadedAppState,
  202. commitToHistory: true,
  203. };
  204. } catch (error) {
  205. if (error?.name === "AbortError") {
  206. return false;
  207. }
  208. return {
  209. elements,
  210. appState: { ...appState, errorMessage: error.message },
  211. commitToHistory: false,
  212. };
  213. }
  214. },
  215. keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
  216. PanelComponent: ({ updateData, appState }) => (
  217. <ToolButton
  218. type="button"
  219. icon={load}
  220. title={t("buttons.load")}
  221. aria-label={t("buttons.load")}
  222. showAriaLabel={useIsMobile()}
  223. onClick={updateData}
  224. data-testid="load-button"
  225. />
  226. ),
  227. });
  228. export const actionExportWithDarkMode = register({
  229. name: "exportWithDarkMode",
  230. perform: (_elements, appState, value) => {
  231. return {
  232. appState: { ...appState, exportWithDarkMode: value },
  233. commitToHistory: false,
  234. };
  235. },
  236. PanelComponent: ({ appState, updateData }) => (
  237. <div
  238. style={{
  239. display: "flex",
  240. justifyContent: "flex-end",
  241. marginTop: "-45px",
  242. marginBottom: "10px",
  243. }}
  244. >
  245. <DarkModeToggle
  246. value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
  247. onChange={(theme: Theme) => {
  248. updateData(theme === THEME.DARK);
  249. }}
  250. title={t("labels.toggleExportColorScheme")}
  251. />
  252. </div>
  253. ),
  254. });