data.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. import rough from "roughjs/bin/rough";
  2. import { ExcalidrawElement } from "../element/types";
  3. import { getElementAbsoluteCoords } from "../element";
  4. import { getDefaultAppState } from "../appState";
  5. import { renderScene } from "../renderer";
  6. import { AppState } from "../types";
  7. import { ExportType } from "./types";
  8. import nanoid from "nanoid";
  9. const LOCAL_STORAGE_KEY = "excalidraw";
  10. const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
  11. // TODO: Defined globally, since file handles aren't yet serializable.
  12. // Once `FileSystemFileHandle` can be serialized, make this
  13. // part of `AppState`.
  14. (window as any).handle = null;
  15. function saveFile(name: string, data: string) {
  16. // create a temporary <a> elem which we'll use to download the image
  17. const link = document.createElement("a");
  18. link.setAttribute("download", name);
  19. link.setAttribute("href", data);
  20. link.click();
  21. // clean up
  22. link.remove();
  23. }
  24. async function saveFileNative(name: string, data: Blob) {
  25. const options = {
  26. type: "saveFile",
  27. accepts: [
  28. {
  29. description: `Excalidraw ${
  30. data.type === "image/png" ? "image" : "file"
  31. }`,
  32. extensions: [data.type.split("/")[1]],
  33. mimeTypes: [data.type]
  34. }
  35. ]
  36. };
  37. try {
  38. let handle;
  39. if (data.type === "application/json") {
  40. // For Excalidraw files (i.e., `application/json` files):
  41. // If it exists, write back to a previously opened file.
  42. // Else, create a new file.
  43. if ((window as any).handle) {
  44. handle = (window as any).handle;
  45. } else {
  46. handle = await (window as any).chooseFileSystemEntries(options);
  47. (window as any).handle = handle;
  48. }
  49. } else {
  50. // For image export files (i.e., `image/png` files):
  51. // Always create a new file.
  52. handle = await (window as any).chooseFileSystemEntries(options);
  53. }
  54. const writer = await handle.createWriter();
  55. await writer.truncate(0);
  56. await writer.write(0, data, data.type);
  57. await writer.close();
  58. } catch (err) {
  59. if (err.name !== "AbortError") {
  60. console.error(err.name, err.message);
  61. }
  62. throw err;
  63. }
  64. }
  65. interface DataState {
  66. elements: readonly ExcalidrawElement[];
  67. appState: AppState;
  68. }
  69. export async function saveAsJSON(
  70. elements: readonly ExcalidrawElement[],
  71. appState: AppState
  72. ) {
  73. const serialized = JSON.stringify({
  74. version: 1,
  75. source: window.location.origin,
  76. elements: elements.map(({ shape, ...el }) => el),
  77. appState: appState
  78. });
  79. const name = `${appState.name}.json`;
  80. if ("chooseFileSystemEntries" in window) {
  81. await saveFileNative(
  82. name,
  83. new Blob([serialized], { type: "application/json" })
  84. );
  85. } else {
  86. saveFile(
  87. name,
  88. "data:text/plain;charset=utf-8," + encodeURIComponent(serialized)
  89. );
  90. }
  91. }
  92. export async function loadFromJSON() {
  93. const updateAppState = (contents: string) => {
  94. const defaultAppState = getDefaultAppState();
  95. let elements = [];
  96. let appState = defaultAppState;
  97. try {
  98. const data = JSON.parse(contents);
  99. elements = data.elements || [];
  100. appState = { ...defaultAppState, ...data.appState };
  101. } catch (e) {
  102. // Do nothing because elements array is already empty
  103. }
  104. return { elements, appState };
  105. };
  106. if ("chooseFileSystemEntries" in window) {
  107. try {
  108. (window as any).handle = await (window as any).chooseFileSystemEntries({
  109. accepts: [
  110. {
  111. description: "Excalidraw files",
  112. extensions: ["json"],
  113. mimeTypes: ["application/json"]
  114. }
  115. ]
  116. });
  117. const file = await (window as any).handle.getFile();
  118. const contents = await file.text();
  119. const { elements, appState } = updateAppState(contents);
  120. return new Promise<DataState>(resolve => {
  121. resolve(restore(elements, appState));
  122. });
  123. } catch (err) {
  124. if (err.name !== "AbortError") {
  125. console.error(err.name, err.message);
  126. }
  127. throw err;
  128. }
  129. } else {
  130. const input = document.createElement("input");
  131. const reader = new FileReader();
  132. input.type = "file";
  133. input.accept = ".json";
  134. input.onchange = () => {
  135. if (!input.files!.length) {
  136. alert("A file was not selected.");
  137. return;
  138. }
  139. reader.readAsText(input.files![0], "utf8");
  140. };
  141. input.click();
  142. return new Promise<DataState>(resolve => {
  143. reader.onloadend = () => {
  144. if (reader.readyState === FileReader.DONE) {
  145. const { elements, appState } = updateAppState(
  146. reader.result as string
  147. );
  148. resolve(restore(elements, appState));
  149. }
  150. };
  151. });
  152. }
  153. }
  154. export function getExportCanvasPreview(
  155. elements: readonly ExcalidrawElement[],
  156. {
  157. exportBackground,
  158. exportPadding = 10,
  159. viewBackgroundColor
  160. }: {
  161. exportBackground: boolean;
  162. exportPadding?: number;
  163. viewBackgroundColor: string;
  164. }
  165. ) {
  166. // calculate smallest area to fit the contents in
  167. let subCanvasX1 = Infinity;
  168. let subCanvasX2 = 0;
  169. let subCanvasY1 = Infinity;
  170. let subCanvasY2 = 0;
  171. elements.forEach(element => {
  172. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  173. subCanvasX1 = Math.min(subCanvasX1, x1);
  174. subCanvasY1 = Math.min(subCanvasY1, y1);
  175. subCanvasX2 = Math.max(subCanvasX2, x2);
  176. subCanvasY2 = Math.max(subCanvasY2, y2);
  177. });
  178. function distance(x: number, y: number) {
  179. return Math.abs(x > y ? x - y : y - x);
  180. }
  181. const tempCanvas = document.createElement("canvas");
  182. tempCanvas.width = distance(subCanvasX1, subCanvasX2) + exportPadding * 2;
  183. tempCanvas.height = distance(subCanvasY1, subCanvasY2) + exportPadding * 2;
  184. renderScene(
  185. elements,
  186. rough.canvas(tempCanvas),
  187. tempCanvas,
  188. {
  189. viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
  190. scrollX: 0,
  191. scrollY: 0
  192. },
  193. {
  194. offsetX: -subCanvasX1 + exportPadding,
  195. offsetY: -subCanvasY1 + exportPadding,
  196. renderScrollbars: false,
  197. renderSelection: false
  198. }
  199. );
  200. return tempCanvas;
  201. }
  202. export async function exportCanvas(
  203. type: ExportType,
  204. elements: readonly ExcalidrawElement[],
  205. canvas: HTMLCanvasElement,
  206. {
  207. exportBackground,
  208. exportPadding = 10,
  209. viewBackgroundColor,
  210. name
  211. }: {
  212. exportBackground: boolean;
  213. exportPadding?: number;
  214. viewBackgroundColor: string;
  215. scrollX: number;
  216. scrollY: number;
  217. name: string;
  218. }
  219. ) {
  220. if (!elements.length) return window.alert("Cannot export empty canvas.");
  221. // calculate smallest area to fit the contents in
  222. let subCanvasX1 = Infinity;
  223. let subCanvasX2 = 0;
  224. let subCanvasY1 = Infinity;
  225. let subCanvasY2 = 0;
  226. elements.forEach(element => {
  227. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  228. subCanvasX1 = Math.min(subCanvasX1, x1);
  229. subCanvasY1 = Math.min(subCanvasY1, y1);
  230. subCanvasX2 = Math.max(subCanvasX2, x2);
  231. subCanvasY2 = Math.max(subCanvasY2, y2);
  232. });
  233. function distance(x: number, y: number) {
  234. return Math.abs(x > y ? x - y : y - x);
  235. }
  236. const tempCanvas = document.createElement("canvas");
  237. tempCanvas.style.display = "none";
  238. document.body.appendChild(tempCanvas);
  239. tempCanvas.width = distance(subCanvasX1, subCanvasX2) + exportPadding * 2;
  240. tempCanvas.height = distance(subCanvasY1, subCanvasY2) + exportPadding * 2;
  241. renderScene(
  242. elements,
  243. rough.canvas(tempCanvas),
  244. tempCanvas,
  245. {
  246. viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
  247. scrollX: 0,
  248. scrollY: 0
  249. },
  250. {
  251. offsetX: -subCanvasX1 + exportPadding,
  252. offsetY: -subCanvasY1 + exportPadding,
  253. renderScrollbars: false,
  254. renderSelection: false
  255. }
  256. );
  257. if (type === "png") {
  258. const fileName = `${name}.png`;
  259. if ("chooseFileSystemEntries" in window) {
  260. tempCanvas.toBlob(async blob => {
  261. if (blob) {
  262. await saveFileNative(fileName, blob);
  263. }
  264. });
  265. } else {
  266. saveFile(fileName, tempCanvas.toDataURL("image/png"));
  267. }
  268. } else if (type === "clipboard") {
  269. try {
  270. tempCanvas.toBlob(async function(blob) {
  271. try {
  272. await navigator.clipboard.write([
  273. new window.ClipboardItem({ "image/png": blob })
  274. ]);
  275. } catch (err) {
  276. window.alert("Couldn't copy to clipboard. Try using Chrome browser.");
  277. }
  278. });
  279. } catch (err) {
  280. window.alert("Couldn't copy to clipboard. Try using Chrome browser.");
  281. }
  282. }
  283. // clean up the DOM
  284. if (tempCanvas !== canvas) tempCanvas.remove();
  285. }
  286. function restore(
  287. savedElements: readonly ExcalidrawElement[],
  288. savedState: AppState
  289. ): DataState {
  290. return {
  291. elements: savedElements.map(element => ({
  292. ...element,
  293. id: element.id || nanoid(),
  294. fillStyle: element.fillStyle || "hachure",
  295. strokeWidth: element.strokeWidth || 1,
  296. roughness: element.roughness || 1,
  297. opacity:
  298. element.opacity === null || element.opacity === undefined
  299. ? 100
  300. : element.opacity
  301. })),
  302. appState: savedState
  303. };
  304. }
  305. export function restoreFromLocalStorage() {
  306. const savedElements = localStorage.getItem(LOCAL_STORAGE_KEY);
  307. const savedState = localStorage.getItem(LOCAL_STORAGE_KEY_STATE);
  308. let elements = [];
  309. if (savedElements) {
  310. try {
  311. elements = JSON.parse(savedElements).map(
  312. ({ shape, ...element }: ExcalidrawElement) => element
  313. );
  314. } catch (e) {
  315. // Do nothing because elements array is already empty
  316. }
  317. }
  318. let appState = null;
  319. if (savedState) {
  320. try {
  321. appState = JSON.parse(savedState);
  322. } catch (e) {
  323. // Do nothing because appState is already null
  324. }
  325. }
  326. return restore(elements, appState);
  327. }
  328. export function saveToLocalStorage(
  329. elements: readonly ExcalidrawElement[],
  330. state: AppState
  331. ) {
  332. localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(elements));
  333. localStorage.setItem(LOCAL_STORAGE_KEY_STATE, JSON.stringify(state));
  334. }