actionExport.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  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 { useDeviceType } 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, app) => {
  124. const fileHandleExists = !!appState.fileHandle;
  125. try {
  126. const { fileHandle } = isImageFileHandle(appState.fileHandle)
  127. ? await resaveAsImageWithScene(elements, appState, app.files)
  128. : await saveAsJSON(elements, appState, app.files);
  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: any) {
  145. if (error?.name !== "AbortError") {
  146. console.error(error);
  147. } else {
  148. console.warn(error);
  149. }
  150. return { commitToHistory: false };
  151. }
  152. },
  153. keyTest: (event) =>
  154. event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
  155. PanelComponent: ({ updateData, appState }) => (
  156. <ActiveFile
  157. onSave={() => updateData(null)}
  158. fileName={appState.fileHandle?.name}
  159. />
  160. ),
  161. });
  162. export const actionSaveFileToDisk = register({
  163. name: "saveFileToDisk",
  164. perform: async (elements, appState, value, app) => {
  165. try {
  166. const { fileHandle } = await saveAsJSON(
  167. elements,
  168. {
  169. ...appState,
  170. fileHandle: null,
  171. },
  172. app.files,
  173. );
  174. return { commitToHistory: false, appState: { ...appState, fileHandle } };
  175. } catch (error: any) {
  176. if (error?.name !== "AbortError") {
  177. console.error(error);
  178. } else {
  179. console.warn(error);
  180. }
  181. return { commitToHistory: false };
  182. }
  183. },
  184. keyTest: (event) =>
  185. event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
  186. PanelComponent: ({ updateData }) => (
  187. <ToolButton
  188. type="button"
  189. icon={saveAs}
  190. title={t("buttons.saveAs")}
  191. aria-label={t("buttons.saveAs")}
  192. showAriaLabel={useDeviceType().isMobile}
  193. hidden={!nativeFileSystemSupported}
  194. onClick={() => updateData(null)}
  195. data-testid="save-as-button"
  196. />
  197. ),
  198. });
  199. export const actionLoadScene = register({
  200. name: "loadScene",
  201. perform: async (elements, appState, _, app) => {
  202. try {
  203. const {
  204. elements: loadedElements,
  205. appState: loadedAppState,
  206. files,
  207. } = await loadFromJSON(appState, elements);
  208. return {
  209. elements: loadedElements,
  210. appState: loadedAppState,
  211. files,
  212. commitToHistory: true,
  213. };
  214. } catch (error: any) {
  215. if (error?.name === "AbortError") {
  216. console.warn(error);
  217. return false;
  218. }
  219. return {
  220. elements,
  221. appState: { ...appState, errorMessage: error.message },
  222. files: app.files,
  223. commitToHistory: false,
  224. };
  225. }
  226. },
  227. keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
  228. PanelComponent: ({ updateData, appState }) => (
  229. <ToolButton
  230. type="button"
  231. icon={load}
  232. title={t("buttons.load")}
  233. aria-label={t("buttons.load")}
  234. showAriaLabel={useDeviceType().isMobile}
  235. onClick={updateData}
  236. data-testid="load-button"
  237. />
  238. ),
  239. });
  240. export const actionExportWithDarkMode = register({
  241. name: "exportWithDarkMode",
  242. perform: (_elements, appState, value) => {
  243. return {
  244. appState: { ...appState, exportWithDarkMode: value },
  245. commitToHistory: false,
  246. };
  247. },
  248. PanelComponent: ({ appState, updateData }) => (
  249. <div
  250. style={{
  251. display: "flex",
  252. justifyContent: "flex-end",
  253. marginTop: "-45px",
  254. marginBottom: "10px",
  255. }}
  256. >
  257. <DarkModeToggle
  258. value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
  259. onChange={(theme: Theme) => {
  260. updateData(theme === THEME.DARK);
  261. }}
  262. title={t("labels.toggleExportColorScheme")}
  263. />
  264. </div>
  265. ),
  266. });