restore.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. import {
  2. ExcalidrawElement,
  3. ExcalidrawSelectionElement,
  4. ExcalidrawTextElement,
  5. FontFamilyValues,
  6. StrokeRoundness,
  7. } from "../element/types";
  8. import {
  9. AppState,
  10. BinaryFiles,
  11. LibraryItem,
  12. NormalizedZoomValue,
  13. } from "../types";
  14. import { ImportedDataState, LegacyAppState } from "./types";
  15. import {
  16. getNonDeletedElements,
  17. getNormalizedDimensions,
  18. isInvisiblySmallElement,
  19. refreshTextDimensions,
  20. } from "../element";
  21. import { isTextElement, isUsingAdaptiveRadius } from "../element/typeChecks";
  22. import { randomId } from "../random";
  23. import {
  24. DEFAULT_FONT_FAMILY,
  25. DEFAULT_TEXT_ALIGN,
  26. DEFAULT_VERTICAL_ALIGN,
  27. PRECEDING_ELEMENT_KEY,
  28. FONT_FAMILY,
  29. ROUNDNESS,
  30. } from "../constants";
  31. import { getDefaultAppState } from "../appState";
  32. import { LinearElementEditor } from "../element/linearElementEditor";
  33. import { bumpVersion } from "../element/mutateElement";
  34. import { getUpdatedTimestamp, updateActiveTool } from "../utils";
  35. import { arrayToMap } from "../utils";
  36. import oc from "open-color";
  37. type RestoredAppState = Omit<
  38. AppState,
  39. "offsetTop" | "offsetLeft" | "width" | "height"
  40. >;
  41. export const AllowedExcalidrawActiveTools: Record<
  42. AppState["activeTool"]["type"],
  43. boolean
  44. > = {
  45. selection: true,
  46. text: true,
  47. rectangle: true,
  48. diamond: true,
  49. ellipse: true,
  50. line: true,
  51. image: true,
  52. arrow: true,
  53. freedraw: true,
  54. eraser: false,
  55. custom: true,
  56. hand: true,
  57. };
  58. export type RestoredDataState = {
  59. elements: ExcalidrawElement[];
  60. appState: RestoredAppState;
  61. files: BinaryFiles;
  62. };
  63. const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
  64. if (Object.keys(FONT_FAMILY).includes(fontFamilyName)) {
  65. return FONT_FAMILY[
  66. fontFamilyName as keyof typeof FONT_FAMILY
  67. ] as FontFamilyValues;
  68. }
  69. return DEFAULT_FONT_FAMILY;
  70. };
  71. const restoreElementWithProperties = <
  72. T extends Required<Omit<ExcalidrawElement, "customData">> & {
  73. customData?: ExcalidrawElement["customData"];
  74. /** @deprecated */
  75. boundElementIds?: readonly ExcalidrawElement["id"][];
  76. /** @deprecated */
  77. strokeSharpness?: StrokeRoundness;
  78. /** metadata that may be present in elements during collaboration */
  79. [PRECEDING_ELEMENT_KEY]?: string;
  80. },
  81. K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
  82. >(
  83. element: T,
  84. extra: Pick<
  85. T,
  86. // This extra Pick<T, keyof K> ensure no excess properties are passed.
  87. // @ts-ignore TS complains here but type checks the call sites fine.
  88. keyof K
  89. > &
  90. Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>,
  91. ): T => {
  92. const base: Pick<T, keyof ExcalidrawElement> & {
  93. [PRECEDING_ELEMENT_KEY]?: string;
  94. } = {
  95. type: extra.type || element.type,
  96. // all elements must have version > 0 so getSceneVersion() will pick up
  97. // newly added elements
  98. version: element.version || 1,
  99. versionNonce: element.versionNonce ?? 0,
  100. isDeleted: element.isDeleted ?? false,
  101. id: element.id || randomId(),
  102. fillStyle: element.fillStyle || "hachure",
  103. strokeWidth: element.strokeWidth || 1,
  104. strokeStyle: element.strokeStyle ?? "solid",
  105. roughness: element.roughness ?? 1,
  106. opacity: element.opacity == null ? 100 : element.opacity,
  107. angle: element.angle || 0,
  108. x: extra.x ?? element.x ?? 0,
  109. y: extra.y ?? element.y ?? 0,
  110. strokeColor: element.strokeColor || oc.black,
  111. backgroundColor: element.backgroundColor || "transparent",
  112. width: element.width || 0,
  113. height: element.height || 0,
  114. seed: element.seed ?? 1,
  115. groupIds: element.groupIds ?? [],
  116. roundness: element.roundness
  117. ? element.roundness
  118. : element.strokeSharpness === "round"
  119. ? {
  120. // for old elements that would now use adaptive radius algo,
  121. // use legacy algo instead
  122. type: isUsingAdaptiveRadius(element.type)
  123. ? ROUNDNESS.LEGACY
  124. : ROUNDNESS.PROPORTIONAL_RADIUS,
  125. }
  126. : null,
  127. boundElements: element.boundElementIds
  128. ? element.boundElementIds.map((id) => ({ type: "arrow", id }))
  129. : element.boundElements ?? [],
  130. updated: element.updated ?? getUpdatedTimestamp(),
  131. link: element.link ?? null,
  132. locked: element.locked ?? false,
  133. };
  134. if ("customData" in element) {
  135. base.customData = element.customData;
  136. }
  137. if (PRECEDING_ELEMENT_KEY in element) {
  138. base[PRECEDING_ELEMENT_KEY] = element[PRECEDING_ELEMENT_KEY];
  139. }
  140. return {
  141. ...base,
  142. ...getNormalizedDimensions(base),
  143. ...extra,
  144. } as unknown as T;
  145. };
  146. const restoreElement = (
  147. element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
  148. refreshDimensions = false,
  149. ): typeof element | null => {
  150. switch (element.type) {
  151. case "text":
  152. let fontSize = element.fontSize;
  153. let fontFamily = element.fontFamily;
  154. if ("font" in element) {
  155. const [fontPx, _fontFamily]: [string, string] = (
  156. element as any
  157. ).font.split(" ");
  158. fontSize = parseInt(fontPx, 10);
  159. fontFamily = getFontFamilyByName(_fontFamily);
  160. }
  161. element = restoreElementWithProperties(element, {
  162. fontSize,
  163. fontFamily,
  164. text: element.text ?? "",
  165. baseline: element.baseline,
  166. textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
  167. verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
  168. containerId: element.containerId ?? null,
  169. originalText: element.originalText || element.text,
  170. });
  171. if (refreshDimensions) {
  172. element = { ...element, ...refreshTextDimensions(element) };
  173. }
  174. return element;
  175. case "freedraw": {
  176. return restoreElementWithProperties(element, {
  177. points: element.points,
  178. lastCommittedPoint: null,
  179. simulatePressure: element.simulatePressure,
  180. pressures: element.pressures,
  181. });
  182. }
  183. case "image":
  184. return restoreElementWithProperties(element, {
  185. status: element.status || "pending",
  186. fileId: element.fileId,
  187. scale: element.scale || [1, 1],
  188. });
  189. case "line":
  190. // @ts-ignore LEGACY type
  191. // eslint-disable-next-line no-fallthrough
  192. case "draw":
  193. case "arrow": {
  194. const {
  195. startArrowhead = null,
  196. endArrowhead = element.type === "arrow" ? "arrow" : null,
  197. } = element;
  198. let x = element.x;
  199. let y = element.y;
  200. let points = // migrate old arrow model to new one
  201. !Array.isArray(element.points) || element.points.length < 2
  202. ? [
  203. [0, 0],
  204. [element.width, element.height],
  205. ]
  206. : element.points;
  207. if (points[0][0] !== 0 || points[0][1] !== 0) {
  208. ({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
  209. }
  210. return restoreElementWithProperties(element, {
  211. type:
  212. (element.type as ExcalidrawElement["type"] | "draw") === "draw"
  213. ? "line"
  214. : element.type,
  215. startBinding: element.startBinding,
  216. endBinding: element.endBinding,
  217. lastCommittedPoint: null,
  218. startArrowhead,
  219. endArrowhead,
  220. points,
  221. x,
  222. y,
  223. });
  224. }
  225. // generic elements
  226. case "ellipse":
  227. return restoreElementWithProperties(element, {});
  228. case "rectangle":
  229. return restoreElementWithProperties(element, {});
  230. case "diamond":
  231. return restoreElementWithProperties(element, {});
  232. // Don't use default case so as to catch a missing an element type case.
  233. // We also don't want to throw, but instead return void so we filter
  234. // out these unsupported elements from the restored array.
  235. }
  236. };
  237. /**
  238. * Repairs contaienr element's boundElements array by removing duplicates and
  239. * fixing containerId of bound elements if not present. Also removes any
  240. * bound elements that do not exist in the elements array.
  241. *
  242. * NOTE mutates elements.
  243. */
  244. const repairContainerElement = (
  245. container: Mutable<ExcalidrawElement>,
  246. elementsMap: Map<string, Mutable<ExcalidrawElement>>,
  247. ) => {
  248. if (container.boundElements) {
  249. // copy because we're not cloning on restore, and we don't want to mutate upstream
  250. const boundElements = container.boundElements.slice();
  251. // dedupe bindings & fix boundElement.containerId if not set already
  252. const boundIds = new Set<ExcalidrawElement["id"]>();
  253. container.boundElements = boundElements.reduce(
  254. (
  255. acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
  256. binding,
  257. ) => {
  258. const boundElement = elementsMap.get(binding.id);
  259. if (boundElement && !boundIds.has(binding.id)) {
  260. boundIds.add(binding.id);
  261. if (boundElement.isDeleted) {
  262. return acc;
  263. }
  264. acc.push(binding);
  265. if (
  266. isTextElement(boundElement) &&
  267. // being slightly conservative here, preserving existing containerId
  268. // if defined, lest boundElements is stale
  269. !boundElement.containerId
  270. ) {
  271. (boundElement as Mutable<ExcalidrawTextElement>).containerId =
  272. container.id;
  273. }
  274. }
  275. return acc;
  276. },
  277. [],
  278. );
  279. }
  280. };
  281. /**
  282. * Repairs target bound element's container's boundElements array,
  283. * or removes contaienrId if container does not exist.
  284. *
  285. * NOTE mutates elements.
  286. */
  287. const repairBoundElement = (
  288. boundElement: Mutable<ExcalidrawTextElement>,
  289. elementsMap: Map<string, Mutable<ExcalidrawElement>>,
  290. ) => {
  291. const container = boundElement.containerId
  292. ? elementsMap.get(boundElement.containerId)
  293. : null;
  294. if (!container) {
  295. boundElement.containerId = null;
  296. return;
  297. }
  298. if (boundElement.isDeleted) {
  299. return;
  300. }
  301. if (
  302. container.boundElements &&
  303. !container.boundElements.find((binding) => binding.id === boundElement.id)
  304. ) {
  305. // copy because we're not cloning on restore, and we don't want to mutate upstream
  306. const boundElements = (
  307. container.boundElements || (container.boundElements = [])
  308. ).slice();
  309. boundElements.push({ type: "text", id: boundElement.id });
  310. container.boundElements = boundElements;
  311. }
  312. };
  313. export const restoreElements = (
  314. elements: ImportedDataState["elements"],
  315. /** NOTE doesn't serve for reconciliation */
  316. localElements: readonly ExcalidrawElement[] | null | undefined,
  317. refreshDimensions = false,
  318. ): ExcalidrawElement[] => {
  319. const localElementsMap = localElements ? arrayToMap(localElements) : null;
  320. const restoredElements = (elements || []).reduce((elements, element) => {
  321. // filtering out selection, which is legacy, no longer kept in elements,
  322. // and causing issues if retained
  323. if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
  324. let migratedElement: ExcalidrawElement | null = restoreElement(
  325. element,
  326. refreshDimensions,
  327. );
  328. if (migratedElement) {
  329. const localElement = localElementsMap?.get(element.id);
  330. if (localElement && localElement.version > migratedElement.version) {
  331. migratedElement = bumpVersion(migratedElement, localElement.version);
  332. }
  333. elements.push(migratedElement);
  334. }
  335. }
  336. return elements;
  337. }, [] as ExcalidrawElement[]);
  338. // repair binding. Mutates elements.
  339. const restoredElementsMap = arrayToMap(restoredElements);
  340. for (const element of restoredElements) {
  341. if (isTextElement(element) && element.containerId) {
  342. repairBoundElement(element, restoredElementsMap);
  343. } else if (element.boundElements) {
  344. repairContainerElement(element, restoredElementsMap);
  345. }
  346. }
  347. return restoredElements;
  348. };
  349. const coalesceAppStateValue = <
  350. T extends keyof ReturnType<typeof getDefaultAppState>,
  351. >(
  352. key: T,
  353. appState: Exclude<ImportedDataState["appState"], null | undefined>,
  354. defaultAppState: ReturnType<typeof getDefaultAppState>,
  355. ) => {
  356. const value = appState[key];
  357. // NOTE the value! assertion is needed in TS 4.5.5 (fixed in newer versions)
  358. return value !== undefined ? value! : defaultAppState[key];
  359. };
  360. const LegacyAppStateMigrations: {
  361. [K in keyof LegacyAppState]: (
  362. ImportedDataState: Exclude<ImportedDataState["appState"], null | undefined>,
  363. defaultAppState: ReturnType<typeof getDefaultAppState>,
  364. ) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]];
  365. } = {
  366. isLibraryOpen: (appState, defaultAppState) => {
  367. return [
  368. "openSidebar",
  369. "isLibraryOpen" in appState
  370. ? appState.isLibraryOpen
  371. ? "library"
  372. : null
  373. : coalesceAppStateValue("openSidebar", appState, defaultAppState),
  374. ];
  375. },
  376. isLibraryMenuDocked: (appState, defaultAppState) => {
  377. return [
  378. "isSidebarDocked",
  379. appState.isLibraryMenuDocked ??
  380. coalesceAppStateValue("isSidebarDocked", appState, defaultAppState),
  381. ];
  382. },
  383. };
  384. export const restoreAppState = (
  385. appState: ImportedDataState["appState"],
  386. localAppState: Partial<AppState> | null | undefined,
  387. ): RestoredAppState => {
  388. appState = appState || {};
  389. const defaultAppState = getDefaultAppState();
  390. const nextAppState = {} as typeof defaultAppState;
  391. // first, migrate all legacy AppState properties to new ones. We do it
  392. // in one go before migrate the rest of the properties in case the new ones
  393. // depend on checking any other key (i.e. they are coupled)
  394. for (const legacyKey of Object.keys(
  395. LegacyAppStateMigrations,
  396. ) as (keyof typeof LegacyAppStateMigrations)[]) {
  397. if (legacyKey in appState) {
  398. const [nextKey, nextValue] = LegacyAppStateMigrations[legacyKey](
  399. appState,
  400. defaultAppState,
  401. );
  402. (nextAppState as any)[nextKey] = nextValue;
  403. }
  404. }
  405. for (const [key, defaultValue] of Object.entries(defaultAppState) as [
  406. keyof typeof defaultAppState,
  407. any,
  408. ][]) {
  409. // if AppState contains a legacy key, prefer that one and migrate its
  410. // value to the new one
  411. const suppliedValue = appState[key];
  412. const localValue = localAppState ? localAppState[key] : undefined;
  413. (nextAppState as any)[key] =
  414. suppliedValue !== undefined
  415. ? suppliedValue
  416. : localValue !== undefined
  417. ? localValue
  418. : defaultValue;
  419. }
  420. return {
  421. ...nextAppState,
  422. cursorButton: localAppState?.cursorButton || "up",
  423. // reset on fresh restore so as to hide the UI button if penMode not active
  424. penDetected:
  425. localAppState?.penDetected ??
  426. (appState.penMode ? appState.penDetected ?? false : false),
  427. activeTool: {
  428. ...updateActiveTool(
  429. defaultAppState,
  430. nextAppState.activeTool.type &&
  431. AllowedExcalidrawActiveTools[nextAppState.activeTool.type]
  432. ? nextAppState.activeTool
  433. : { type: "selection" },
  434. ),
  435. lastActiveTool: null,
  436. locked: nextAppState.activeTool.locked ?? false,
  437. },
  438. // Migrates from previous version where appState.zoom was a number
  439. zoom:
  440. typeof appState.zoom === "number"
  441. ? {
  442. value: appState.zoom as NormalizedZoomValue,
  443. }
  444. : appState.zoom || defaultAppState.zoom,
  445. // when sidebar docked and user left it open in last session,
  446. // keep it open. If not docked, keep it closed irrespective of last state.
  447. openSidebar:
  448. nextAppState.openSidebar === "library"
  449. ? nextAppState.isSidebarDocked
  450. ? "library"
  451. : null
  452. : nextAppState.openSidebar,
  453. };
  454. };
  455. export const restore = (
  456. data: Pick<ImportedDataState, "appState" | "elements" | "files"> | null,
  457. /**
  458. * Local AppState (`this.state` or initial state from localStorage) so that we
  459. * don't overwrite local state with default values (when values not
  460. * explicitly specified).
  461. * Supply `null` if you can't get access to it.
  462. */
  463. localAppState: Partial<AppState> | null | undefined,
  464. localElements: readonly ExcalidrawElement[] | null | undefined,
  465. ): RestoredDataState => {
  466. return {
  467. elements: restoreElements(data?.elements, localElements),
  468. appState: restoreAppState(data?.appState, localAppState || null),
  469. files: data?.files || {},
  470. };
  471. };
  472. const restoreLibraryItem = (libraryItem: LibraryItem) => {
  473. const elements = restoreElements(
  474. getNonDeletedElements(libraryItem.elements),
  475. null,
  476. );
  477. return elements.length ? { ...libraryItem, elements } : null;
  478. };
  479. export const restoreLibraryItems = (
  480. libraryItems: ImportedDataState["libraryItems"] = [],
  481. defaultStatus: LibraryItem["status"],
  482. ) => {
  483. const restoredItems: LibraryItem[] = [];
  484. for (const item of libraryItems) {
  485. // migrate older libraries
  486. if (Array.isArray(item)) {
  487. const restoredItem = restoreLibraryItem({
  488. status: defaultStatus,
  489. elements: item,
  490. id: randomId(),
  491. created: Date.now(),
  492. });
  493. if (restoredItem) {
  494. restoredItems.push(restoredItem);
  495. }
  496. } else {
  497. const _item = item as MarkOptional<
  498. LibraryItem,
  499. "id" | "status" | "created"
  500. >;
  501. const restoredItem = restoreLibraryItem({
  502. ..._item,
  503. id: _item.id || randomId(),
  504. status: _item.status || defaultStatus,
  505. created: _item.created || Date.now(),
  506. });
  507. if (restoredItem) {
  508. restoredItems.push(restoredItem);
  509. }
  510. }
  511. }
  512. return restoredItems;
  513. };