actionExport.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. import { load, questionCircle, saveAs } from "../components/icons";
  2. import { ProjectName } from "../components/ProjectName";
  3. import { ToolButton } from "../components/ToolButton";
  4. import "../components/ToolIcon.scss";
  5. import { Tooltip } from "../components/Tooltip";
  6. import { DarkModeToggle } from "../components/DarkModeToggle";
  7. import { loadFromJSON, saveAsJSON } from "../data";
  8. import { resaveAsImageWithScene } from "../data/resave";
  9. import { t } from "../i18n";
  10. import { useDeviceType } from "../components/App";
  11. import { KEYS } from "../keys";
  12. import { register } from "./register";
  13. import { CheckboxItem } from "../components/CheckboxItem";
  14. import { getExportSize } from "../scene/export";
  15. import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
  16. import { getSelectedElements, isSomeElementSelected } from "../scene";
  17. import { getNonDeletedElements } from "../element";
  18. import { ActiveFile } from "../components/ActiveFile";
  19. import { isImageFileHandle } from "../data/blob";
  20. import { nativeFileSystemSupported } from "../data/filesystem";
  21. import { Theme } from "../element/types";
  22. export const actionChangeProjectName = register({
  23. name: "changeProjectName",
  24. trackEvent: false,
  25. perform: (_elements, appState, value) => {
  26. return { appState: { ...appState, name: value }, commitToHistory: false };
  27. },
  28. PanelComponent: ({ appState, updateData, appProps }) => (
  29. <ProjectName
  30. label={t("labels.fileTitle")}
  31. value={appState.name || "Unnamed"}
  32. onChange={(name: string) => updateData(name)}
  33. isNameEditable={
  34. typeof appProps.name === "undefined" && !appState.viewModeEnabled
  35. }
  36. />
  37. ),
  38. });
  39. export const actionChangeExportScale = register({
  40. name: "changeExportScale",
  41. trackEvent: { category: "export", action: "scale" },
  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. trackEvent: { category: "export", action: "toggleBackground" },
  87. perform: (_elements, appState, value) => {
  88. return {
  89. appState: { ...appState, exportBackground: value },
  90. commitToHistory: false,
  91. };
  92. },
  93. PanelComponent: ({ appState, updateData }) => (
  94. <CheckboxItem
  95. checked={appState.exportBackground}
  96. onChange={(checked) => updateData(checked)}
  97. >
  98. {t("labels.withBackground")}
  99. </CheckboxItem>
  100. ),
  101. });
  102. export const actionChangeExportEmbedScene = register({
  103. name: "changeExportEmbedScene",
  104. trackEvent: { category: "export", action: "embedScene" },
  105. perform: (_elements, appState, value) => {
  106. return {
  107. appState: { ...appState, exportEmbedScene: value },
  108. commitToHistory: false,
  109. };
  110. },
  111. PanelComponent: ({ appState, updateData }) => (
  112. <CheckboxItem
  113. checked={appState.exportEmbedScene}
  114. onChange={(checked) => updateData(checked)}
  115. >
  116. {t("labels.exportEmbedScene")}
  117. <Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
  118. <div className="excalidraw-tooltip-icon">{questionCircle}</div>
  119. </Tooltip>
  120. </CheckboxItem>
  121. ),
  122. });
  123. export const actionSaveToActiveFile = register({
  124. name: "saveToActiveFile",
  125. trackEvent: { category: "export" },
  126. perform: async (elements, appState, value, app) => {
  127. const fileHandleExists = !!appState.fileHandle;
  128. try {
  129. const { fileHandle } = isImageFileHandle(appState.fileHandle)
  130. ? await resaveAsImageWithScene(elements, appState, app.files)
  131. : await saveAsJSON(elements, appState, app.files);
  132. return {
  133. commitToHistory: false,
  134. appState: {
  135. ...appState,
  136. fileHandle,
  137. toastMessage: fileHandleExists
  138. ? fileHandle?.name
  139. ? t("toast.fileSavedToFilename").replace(
  140. "{filename}",
  141. `"${fileHandle.name}"`,
  142. )
  143. : t("toast.fileSaved")
  144. : null,
  145. },
  146. };
  147. } catch (error: any) {
  148. if (error?.name !== "AbortError") {
  149. console.error(error);
  150. } else {
  151. console.warn(error);
  152. }
  153. return { commitToHistory: false };
  154. }
  155. },
  156. keyTest: (event) =>
  157. event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
  158. PanelComponent: ({ updateData, appState }) => (
  159. <ActiveFile
  160. onSave={() => updateData(null)}
  161. fileName={appState.fileHandle?.name}
  162. />
  163. ),
  164. });
  165. export const actionSaveFileToDisk = register({
  166. name: "saveFileToDisk",
  167. trackEvent: { category: "export" },
  168. perform: async (elements, appState, value, app) => {
  169. try {
  170. const { fileHandle } = await saveAsJSON(
  171. elements,
  172. {
  173. ...appState,
  174. fileHandle: null,
  175. },
  176. app.files,
  177. );
  178. return { commitToHistory: false, appState: { ...appState, fileHandle } };
  179. } catch (error: any) {
  180. if (error?.name !== "AbortError") {
  181. console.error(error);
  182. } else {
  183. console.warn(error);
  184. }
  185. return { commitToHistory: false };
  186. }
  187. },
  188. keyTest: (event) =>
  189. event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
  190. PanelComponent: ({ updateData }) => (
  191. <ToolButton
  192. type="button"
  193. icon={saveAs}
  194. title={t("buttons.saveAs")}
  195. aria-label={t("buttons.saveAs")}
  196. showAriaLabel={useDeviceType().isMobile}
  197. hidden={!nativeFileSystemSupported}
  198. onClick={() => updateData(null)}
  199. data-testid="save-as-button"
  200. />
  201. ),
  202. });
  203. export const actionLoadScene = register({
  204. name: "loadScene",
  205. trackEvent: { category: "export" },
  206. perform: async (elements, appState, _, app) => {
  207. try {
  208. const {
  209. elements: loadedElements,
  210. appState: loadedAppState,
  211. files,
  212. } = await loadFromJSON(appState, elements);
  213. return {
  214. elements: loadedElements,
  215. appState: loadedAppState,
  216. files,
  217. commitToHistory: true,
  218. };
  219. } catch (error: any) {
  220. if (error?.name === "AbortError") {
  221. console.warn(error);
  222. return false;
  223. }
  224. return {
  225. elements,
  226. appState: { ...appState, errorMessage: error.message },
  227. files: app.files,
  228. commitToHistory: false,
  229. };
  230. }
  231. },
  232. keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
  233. PanelComponent: ({ updateData, appState }) => (
  234. <ToolButton
  235. type="button"
  236. icon={load}
  237. title={t("buttons.load")}
  238. aria-label={t("buttons.load")}
  239. showAriaLabel={useDeviceType().isMobile}
  240. onClick={updateData}
  241. data-testid="load-button"
  242. />
  243. ),
  244. });
  245. export const actionExportWithDarkMode = register({
  246. name: "exportWithDarkMode",
  247. trackEvent: { category: "export", action: "toggleTheme" },
  248. perform: (_elements, appState, value) => {
  249. return {
  250. appState: { ...appState, exportWithDarkMode: value },
  251. commitToHistory: false,
  252. };
  253. },
  254. PanelComponent: ({ appState, updateData }) => (
  255. <div
  256. style={{
  257. display: "flex",
  258. justifyContent: "flex-end",
  259. marginTop: "-45px",
  260. marginBottom: "10px",
  261. }}
  262. >
  263. <DarkModeToggle
  264. value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
  265. onChange={(theme: Theme) => {
  266. updateData(theme === THEME.DARK);
  267. }}
  268. title={t("labels.toggleExportColorScheme")}
  269. />
  270. </div>
  271. ),
  272. });