restore.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. import {
  2. ExcalidrawElement,
  3. ExcalidrawSelectionElement,
  4. FontFamilyValues,
  5. } from "../element/types";
  6. import {
  7. AppState,
  8. BinaryFiles,
  9. LibraryItem,
  10. NormalizedZoomValue,
  11. } from "../types";
  12. import { ImportedDataState } from "./types";
  13. import { getNormalizedDimensions, isInvisiblySmallElement } from "../element";
  14. import { isLinearElementType } from "../element/typeChecks";
  15. import { randomId } from "../random";
  16. import {
  17. DEFAULT_FONT_FAMILY,
  18. DEFAULT_TEXT_ALIGN,
  19. DEFAULT_VERTICAL_ALIGN,
  20. FONT_FAMILY,
  21. } from "../constants";
  22. import { getDefaultAppState } from "../appState";
  23. import { LinearElementEditor } from "../element/linearElementEditor";
  24. import { bumpVersion } from "../element/mutateElement";
  25. import { getUpdatedTimestamp } from "../utils";
  26. import { arrayToMap } from "../utils";
  27. type RestoredAppState = Omit<
  28. AppState,
  29. "offsetTop" | "offsetLeft" | "width" | "height"
  30. >;
  31. export const AllowedExcalidrawActiveTools: Record<
  32. AppState["activeTool"]["type"],
  33. boolean
  34. > = {
  35. selection: true,
  36. text: true,
  37. rectangle: true,
  38. diamond: true,
  39. ellipse: true,
  40. line: true,
  41. image: true,
  42. arrow: true,
  43. freedraw: true,
  44. eraser: false,
  45. };
  46. export type RestoredDataState = {
  47. elements: ExcalidrawElement[];
  48. appState: RestoredAppState;
  49. files: BinaryFiles;
  50. };
  51. const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
  52. if (Object.keys(FONT_FAMILY).includes(fontFamilyName)) {
  53. return FONT_FAMILY[
  54. fontFamilyName as keyof typeof FONT_FAMILY
  55. ] as FontFamilyValues;
  56. }
  57. return DEFAULT_FONT_FAMILY;
  58. };
  59. const restoreElementWithProperties = <
  60. T extends ExcalidrawElement,
  61. K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
  62. >(
  63. element: Required<T> & {
  64. /** @deprecated */
  65. boundElementIds?: readonly ExcalidrawElement["id"][];
  66. },
  67. extra: Pick<
  68. T,
  69. // This extra Pick<T, keyof K> ensure no excess properties are passed.
  70. // @ts-ignore TS complains here but type checks the call sites fine.
  71. keyof K
  72. > &
  73. Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>,
  74. ): T => {
  75. const base: Pick<T, keyof ExcalidrawElement> = {
  76. type: extra.type || element.type,
  77. // all elements must have version > 0 so getSceneVersion() will pick up
  78. // newly added elements
  79. version: element.version || 1,
  80. versionNonce: element.versionNonce ?? 0,
  81. isDeleted: element.isDeleted ?? false,
  82. id: element.id || randomId(),
  83. fillStyle: element.fillStyle || "hachure",
  84. strokeWidth: element.strokeWidth || 1,
  85. strokeStyle: element.strokeStyle ?? "solid",
  86. roughness: element.roughness ?? 1,
  87. opacity: element.opacity == null ? 100 : element.opacity,
  88. angle: element.angle || 0,
  89. x: extra.x ?? element.x ?? 0,
  90. y: extra.y ?? element.y ?? 0,
  91. strokeColor: element.strokeColor,
  92. backgroundColor: element.backgroundColor,
  93. width: element.width || 0,
  94. height: element.height || 0,
  95. seed: element.seed ?? 1,
  96. groupIds: element.groupIds ?? [],
  97. strokeSharpness:
  98. element.strokeSharpness ??
  99. (isLinearElementType(element.type) ? "round" : "sharp"),
  100. boundElements: element.boundElementIds
  101. ? element.boundElementIds.map((id) => ({ type: "arrow", id }))
  102. : element.boundElements ?? [],
  103. updated: element.updated ?? getUpdatedTimestamp(),
  104. link: element.link ?? null,
  105. locked: element.locked ?? false,
  106. };
  107. return {
  108. ...base,
  109. ...getNormalizedDimensions(base),
  110. ...extra,
  111. } as unknown as T;
  112. };
  113. const restoreElement = (
  114. element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
  115. ): typeof element | null => {
  116. switch (element.type) {
  117. case "text":
  118. let fontSize = element.fontSize;
  119. let fontFamily = element.fontFamily;
  120. if ("font" in element) {
  121. const [fontPx, _fontFamily]: [string, string] = (
  122. element as any
  123. ).font.split(" ");
  124. fontSize = parseInt(fontPx, 10);
  125. fontFamily = getFontFamilyByName(_fontFamily);
  126. }
  127. return restoreElementWithProperties(element, {
  128. fontSize,
  129. fontFamily,
  130. text: element.text ?? "",
  131. baseline: element.baseline,
  132. textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
  133. verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
  134. containerId: element.containerId ?? null,
  135. originalText: element.originalText || element.text,
  136. });
  137. case "freedraw": {
  138. return restoreElementWithProperties(element, {
  139. points: element.points,
  140. lastCommittedPoint: null,
  141. simulatePressure: element.simulatePressure,
  142. pressures: element.pressures,
  143. });
  144. }
  145. case "image":
  146. return restoreElementWithProperties(element, {
  147. status: element.status || "pending",
  148. fileId: element.fileId,
  149. scale: element.scale || [1, 1],
  150. });
  151. case "line":
  152. // @ts-ignore LEGACY type
  153. // eslint-disable-next-line no-fallthrough
  154. case "draw":
  155. case "arrow": {
  156. const {
  157. startArrowhead = null,
  158. endArrowhead = element.type === "arrow" ? "arrow" : null,
  159. } = element;
  160. let x = element.x;
  161. let y = element.y;
  162. let points = // migrate old arrow model to new one
  163. !Array.isArray(element.points) || element.points.length < 2
  164. ? [
  165. [0, 0],
  166. [element.width, element.height],
  167. ]
  168. : element.points;
  169. if (points[0][0] !== 0 || points[0][1] !== 0) {
  170. ({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
  171. }
  172. return restoreElementWithProperties(element, {
  173. type:
  174. (element.type as ExcalidrawElement["type"] | "draw") === "draw"
  175. ? "line"
  176. : element.type,
  177. startBinding: element.startBinding,
  178. endBinding: element.endBinding,
  179. lastCommittedPoint: null,
  180. startArrowhead,
  181. endArrowhead,
  182. points,
  183. x,
  184. y,
  185. });
  186. }
  187. // generic elements
  188. case "ellipse":
  189. return restoreElementWithProperties(element, {});
  190. case "rectangle":
  191. return restoreElementWithProperties(element, {});
  192. case "diamond":
  193. return restoreElementWithProperties(element, {});
  194. // Don't use default case so as to catch a missing an element type case.
  195. // We also don't want to throw, but instead return void so we filter
  196. // out these unsupported elements from the restored array.
  197. }
  198. };
  199. export const restoreElements = (
  200. elements: ImportedDataState["elements"],
  201. /** NOTE doesn't serve for reconciliation */
  202. localElements: readonly ExcalidrawElement[] | null | undefined,
  203. ): ExcalidrawElement[] => {
  204. const localElementsMap = localElements ? arrayToMap(localElements) : null;
  205. return (elements || []).reduce((elements, element) => {
  206. // filtering out selection, which is legacy, no longer kept in elements,
  207. // and causing issues if retained
  208. if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
  209. let migratedElement: ExcalidrawElement | null = restoreElement(element);
  210. if (migratedElement) {
  211. const localElement = localElementsMap?.get(element.id);
  212. if (localElement && localElement.version > migratedElement.version) {
  213. migratedElement = bumpVersion(migratedElement, localElement.version);
  214. }
  215. elements.push(migratedElement);
  216. }
  217. }
  218. return elements;
  219. }, [] as ExcalidrawElement[]);
  220. };
  221. export const restoreAppState = (
  222. appState: ImportedDataState["appState"],
  223. localAppState: Partial<AppState> | null | undefined,
  224. ): RestoredAppState => {
  225. appState = appState || {};
  226. const defaultAppState = getDefaultAppState();
  227. const nextAppState = {} as typeof defaultAppState;
  228. for (const [key, defaultValue] of Object.entries(defaultAppState) as [
  229. keyof typeof defaultAppState,
  230. any,
  231. ][]) {
  232. const suppliedValue = appState[key];
  233. const localValue = localAppState ? localAppState[key] : undefined;
  234. (nextAppState as any)[key] =
  235. suppliedValue !== undefined
  236. ? suppliedValue
  237. : localValue !== undefined
  238. ? localValue
  239. : defaultValue;
  240. }
  241. return {
  242. ...nextAppState,
  243. cursorButton: localAppState?.cursorButton || "up",
  244. // reset on fresh restore so as to hide the UI button if penMode not active
  245. penDetected:
  246. localAppState?.penDetected ??
  247. (appState.penMode ? appState.penDetected ?? false : false),
  248. activeTool: {
  249. lastActiveToolBeforeEraser: null,
  250. locked: nextAppState.activeTool.locked ?? false,
  251. type: AllowedExcalidrawActiveTools[nextAppState.activeTool.type]
  252. ? nextAppState.activeTool.type ?? "selection"
  253. : "selection",
  254. },
  255. // Migrates from previous version where appState.zoom was a number
  256. zoom:
  257. typeof appState.zoom === "number"
  258. ? {
  259. value: appState.zoom as NormalizedZoomValue,
  260. }
  261. : appState.zoom || defaultAppState.zoom,
  262. };
  263. };
  264. export const restore = (
  265. data: ImportedDataState | null,
  266. /**
  267. * Local AppState (`this.state` or initial state from localStorage) so that we
  268. * don't overwrite local state with default values (when values not
  269. * explicitly specified).
  270. * Supply `null` if you can't get access to it.
  271. */
  272. localAppState: Partial<AppState> | null | undefined,
  273. localElements: readonly ExcalidrawElement[] | null | undefined,
  274. ): RestoredDataState => {
  275. return {
  276. elements: restoreElements(data?.elements, localElements),
  277. appState: restoreAppState(data?.appState, localAppState || null),
  278. files: data?.files || {},
  279. };
  280. };
  281. export const restoreLibraryItems = (
  282. libraryItems: NonOptional<ImportedDataState["libraryItems"]>,
  283. defaultStatus: LibraryItem["status"],
  284. ) => {
  285. const restoredItems: LibraryItem[] = [];
  286. for (const item of libraryItems) {
  287. // migrate older libraries
  288. if (Array.isArray(item)) {
  289. restoredItems.push({
  290. status: defaultStatus,
  291. elements: item,
  292. id: randomId(),
  293. created: Date.now(),
  294. });
  295. } else {
  296. const _item = item as MarkOptional<LibraryItem, "id" | "status">;
  297. restoredItems.push({
  298. ..._item,
  299. id: _item.id || randomId(),
  300. status: _item.status || defaultStatus,
  301. created: _item.created || Date.now(),
  302. });
  303. }
  304. }
  305. return restoredItems;
  306. };