restore.ts 11 KB

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