restore.ts 19 KB

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