actionExport.tsx 8.6 KB

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