aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/data/restore.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/data/restore.ts')
-rw-r--r--packages/excalidraw/data/restore.ts813
1 files changed, 813 insertions, 0 deletions
diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts
new file mode 100644
index 0000000..c4e45b0
--- /dev/null
+++ b/packages/excalidraw/data/restore.ts
@@ -0,0 +1,813 @@
+import type {
+ ExcalidrawArrowElement,
+ ExcalidrawElbowArrowElement,
+ ExcalidrawElement,
+ ExcalidrawElementType,
+ ExcalidrawLinearElement,
+ ExcalidrawSelectionElement,
+ ExcalidrawTextElement,
+ FixedPointBinding,
+ FontFamilyValues,
+ NonDeletedSceneElementsMap,
+ OrderedExcalidrawElement,
+ PointBinding,
+ StrokeRoundness,
+} from "../element/types";
+import type { AppState, BinaryFiles, LibraryItem } from "../types";
+import type { ImportedDataState, LegacyAppState } from "./types";
+import {
+ getNonDeletedElements,
+ getNormalizedDimensions,
+ isInvisiblySmallElement,
+ refreshTextDimensions,
+} from "../element";
+import {
+ isArrowElement,
+ isElbowArrow,
+ isFixedPointBinding,
+ isLinearElement,
+ isTextElement,
+ isUsingAdaptiveRadius,
+} from "../element/typeChecks";
+import { randomId } from "../random";
+import {
+ DEFAULT_FONT_FAMILY,
+ DEFAULT_TEXT_ALIGN,
+ DEFAULT_VERTICAL_ALIGN,
+ FONT_FAMILY,
+ ROUNDNESS,
+ DEFAULT_SIDEBAR,
+ DEFAULT_ELEMENT_PROPS,
+ DEFAULT_GRID_SIZE,
+ DEFAULT_GRID_STEP,
+} from "../constants";
+import { getDefaultAppState } from "../appState";
+import { LinearElementEditor } from "../element/linearElementEditor";
+import { bumpVersion } from "../element/mutateElement";
+import { getUpdatedTimestamp, updateActiveTool } from "../utils";
+import { arrayToMap } from "../utils";
+import type { MarkOptional, Mutable } from "../utility-types";
+import { getContainerElement } from "../element/textElement";
+import { normalizeLink } from "./url";
+import { syncInvalidIndices } from "../fractionalIndex";
+import { getSizeFromPoints } from "../points";
+import { getLineHeight } from "../fonts";
+import { normalizeFixedPoint } from "../element/binding";
+import {
+ getNormalizedGridSize,
+ getNormalizedGridStep,
+ getNormalizedZoom,
+} from "../scene";
+import type { LocalPoint, Radians } from "@excalidraw/math";
+import { isFiniteNumber, pointFrom } from "@excalidraw/math";
+import { detectLineHeight } from "../element/textMeasurements";
+import {
+ updateElbowArrowPoints,
+ validateElbowPoints,
+} from "../element/elbowArrow";
+
+type RestoredAppState = Omit<
+ AppState,
+ "offsetTop" | "offsetLeft" | "width" | "height"
+>;
+
+export const AllowedExcalidrawActiveTools: Record<
+ AppState["activeTool"]["type"],
+ boolean
+> = {
+ selection: true,
+ text: true,
+ rectangle: true,
+ diamond: true,
+ ellipse: true,
+ line: true,
+ image: true,
+ arrow: true,
+ freedraw: true,
+ eraser: false,
+ custom: true,
+ frame: true,
+ embeddable: true,
+ hand: true,
+ laser: false,
+ magicframe: false,
+};
+
+export type RestoredDataState = {
+ elements: OrderedExcalidrawElement[];
+ appState: RestoredAppState;
+ files: BinaryFiles;
+};
+
+const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
+ if (Object.keys(FONT_FAMILY).includes(fontFamilyName)) {
+ return FONT_FAMILY[
+ fontFamilyName as keyof typeof FONT_FAMILY
+ ] as FontFamilyValues;
+ }
+ return DEFAULT_FONT_FAMILY;
+};
+
+const repairBinding = <T extends ExcalidrawLinearElement>(
+ element: T,
+ binding: PointBinding | FixedPointBinding | null,
+): T extends ExcalidrawElbowArrowElement
+ ? FixedPointBinding | null
+ : PointBinding | FixedPointBinding | null => {
+ if (!binding) {
+ return null;
+ }
+
+ const focus = binding.focus || 0;
+
+ if (isElbowArrow(element)) {
+ const fixedPointBinding:
+ | ExcalidrawElbowArrowElement["startBinding"]
+ | ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding)
+ ? {
+ ...binding,
+ focus,
+ fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
+ }
+ : null;
+
+ return fixedPointBinding;
+ }
+
+ return {
+ ...binding,
+ focus,
+ } as T extends ExcalidrawElbowArrowElement
+ ? FixedPointBinding | null
+ : PointBinding | FixedPointBinding | null;
+};
+
+const restoreElementWithProperties = <
+ T extends Required<Omit<ExcalidrawElement, "customData">> & {
+ customData?: ExcalidrawElement["customData"];
+ /** @deprecated */
+ boundElementIds?: readonly ExcalidrawElement["id"][];
+ /** @deprecated */
+ strokeSharpness?: StrokeRoundness;
+ },
+ K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
+>(
+ element: T,
+ extra: Pick<
+ T,
+ // This extra Pick<T, keyof K> ensure no excess properties are passed.
+ // @ts-ignore TS complains here but type checks the call sites fine.
+ keyof K
+ > &
+ Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
+): T => {
+ const base: Pick<T, keyof ExcalidrawElement> = {
+ type: extra.type || element.type,
+ // all elements must have version > 0 so getSceneVersion() will pick up
+ // newly added elements
+ version: element.version || 1,
+ versionNonce: element.versionNonce ?? 0,
+ index: element.index ?? null,
+ isDeleted: element.isDeleted ?? false,
+ id: element.id || randomId(),
+ fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle,
+ strokeWidth: element.strokeWidth || DEFAULT_ELEMENT_PROPS.strokeWidth,
+ strokeStyle: element.strokeStyle ?? DEFAULT_ELEMENT_PROPS.strokeStyle,
+ roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness,
+ opacity:
+ element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity,
+ angle: element.angle || (0 as Radians),
+ x: extra.x ?? element.x ?? 0,
+ y: extra.y ?? element.y ?? 0,
+ strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor,
+ backgroundColor:
+ element.backgroundColor || DEFAULT_ELEMENT_PROPS.backgroundColor,
+ width: element.width || 0,
+ height: element.height || 0,
+ seed: element.seed ?? 1,
+ groupIds: element.groupIds ?? [],
+ frameId: element.frameId ?? null,
+ roundness: element.roundness
+ ? element.roundness
+ : element.strokeSharpness === "round"
+ ? {
+ // for old elements that would now use adaptive radius algo,
+ // use legacy algo instead
+ type: isUsingAdaptiveRadius(element.type)
+ ? ROUNDNESS.LEGACY
+ : ROUNDNESS.PROPORTIONAL_RADIUS,
+ }
+ : null,
+ boundElements: element.boundElementIds
+ ? element.boundElementIds.map((id) => ({ type: "arrow", id }))
+ : element.boundElements ?? [],
+ updated: element.updated ?? getUpdatedTimestamp(),
+ link: element.link ? normalizeLink(element.link) : null,
+ locked: element.locked ?? false,
+ };
+
+ if ("customData" in element || "customData" in extra) {
+ base.customData =
+ "customData" in extra ? extra.customData : element.customData;
+ }
+
+ return {
+ // spread the original element properties to not lose unknown ones
+ // for forward-compatibility
+ ...element,
+ // normalized properties
+ ...base,
+ ...getNormalizedDimensions(base),
+ ...extra,
+ } as unknown as T;
+};
+
+const restoreElement = (
+ element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
+): typeof element | null => {
+ element = { ...element };
+
+ switch (element.type) {
+ case "text":
+ // temp fix: cleanup legacy obsidian-excalidraw attribute else it'll
+ // conflict when porting between the apps
+ delete (element as any).rawText;
+
+ let fontSize = element.fontSize;
+ let fontFamily = element.fontFamily;
+ if ("font" in element) {
+ const [fontPx, _fontFamily]: [string, string] = (
+ element as any
+ ).font.split(" ");
+ fontSize = parseFloat(fontPx);
+ fontFamily = getFontFamilyByName(_fontFamily);
+ }
+ const text = (typeof element.text === "string" && element.text) || "";
+
+ // line-height might not be specified either when creating elements
+ // programmatically, or when importing old diagrams.
+ // For the latter we want to detect the original line height which
+ // will likely differ from our per-font fixed line height we now use,
+ // to maintain backward compatibility.
+ const lineHeight =
+ element.lineHeight ||
+ (element.height
+ ? // detect line-height from current element height and font-size
+ detectLineHeight(element)
+ : // no element height likely means programmatic use, so default
+ // to a fixed line height
+ getLineHeight(element.fontFamily));
+ element = restoreElementWithProperties(element, {
+ fontSize,
+ fontFamily,
+ text,
+ textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
+ verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
+ containerId: element.containerId ?? null,
+ originalText: element.originalText || text,
+ autoResize: element.autoResize ?? true,
+ lineHeight,
+ });
+
+ // if empty text, mark as deleted. We keep in array
+ // for data integrity purposes (collab etc.)
+ if (!text && !element.isDeleted) {
+ element = { ...element, originalText: text, isDeleted: true };
+ element = bumpVersion(element);
+ }
+
+ return element;
+ case "freedraw": {
+ return restoreElementWithProperties(element, {
+ points: element.points,
+ lastCommittedPoint: null,
+ simulatePressure: element.simulatePressure,
+ pressures: element.pressures,
+ });
+ }
+ case "image":
+ return restoreElementWithProperties(element, {
+ status: element.status || "pending",
+ fileId: element.fileId,
+ scale: element.scale || [1, 1],
+ crop: element.crop ?? null,
+ });
+ case "line":
+ // @ts-ignore LEGACY type
+ // eslint-disable-next-line no-fallthrough
+ case "draw":
+ const { startArrowhead = null, endArrowhead = null } = element;
+ let x = element.x;
+ let y = element.y;
+ let points = // migrate old arrow model to new one
+ !Array.isArray(element.points) || element.points.length < 2
+ ? [pointFrom(0, 0), pointFrom(element.width, element.height)]
+ : element.points;
+
+ if (points[0][0] !== 0 || points[0][1] !== 0) {
+ ({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
+ }
+
+ return restoreElementWithProperties(element, {
+ type:
+ (element.type as ExcalidrawElementType | "draw") === "draw"
+ ? "line"
+ : element.type,
+ startBinding: repairBinding(element, element.startBinding),
+ endBinding: repairBinding(element, element.endBinding),
+ lastCommittedPoint: null,
+ startArrowhead,
+ endArrowhead,
+ points,
+ x,
+ y,
+ ...getSizeFromPoints(points),
+ });
+ case "arrow": {
+ const { startArrowhead = null, endArrowhead = "arrow" } = element;
+ let x: number | undefined = element.x;
+ let y: number | undefined = element.y;
+ let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
+ !Array.isArray(element.points) || element.points.length < 2
+ ? [pointFrom(0, 0), pointFrom(element.width, element.height)]
+ : element.points;
+
+ if (points[0][0] !== 0 || points[0][1] !== 0) {
+ ({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
+ }
+
+ const base = {
+ type: element.type,
+ startBinding: repairBinding(element, element.startBinding),
+ endBinding: repairBinding(element, element.endBinding),
+ lastCommittedPoint: null,
+ startArrowhead,
+ endArrowhead,
+ points,
+ x,
+ y,
+ elbowed: (element as ExcalidrawArrowElement).elbowed,
+ ...getSizeFromPoints(points),
+ } as const;
+
+ // TODO: Separate arrow from linear element
+ return isElbowArrow(element)
+ ? restoreElementWithProperties(element as ExcalidrawElbowArrowElement, {
+ ...base,
+ elbowed: true,
+ startBinding: repairBinding(element, element.startBinding),
+ endBinding: repairBinding(element, element.endBinding),
+ fixedSegments: element.fixedSegments,
+ startIsSpecial: element.startIsSpecial,
+ endIsSpecial: element.endIsSpecial,
+ })
+ : restoreElementWithProperties(element as ExcalidrawArrowElement, base);
+ }
+
+ // generic elements
+ case "ellipse":
+ case "rectangle":
+ case "diamond":
+ case "iframe":
+ case "embeddable":
+ return restoreElementWithProperties(element, {});
+ case "magicframe":
+ case "frame":
+ return restoreElementWithProperties(element, {
+ name: element.name ?? null,
+ });
+
+ // Don't use default case so as to catch a missing an element type case.
+ // We also don't want to throw, but instead return void so we filter
+ // out these unsupported elements from the restored array.
+ }
+ return null;
+};
+
+/**
+ * Repairs container element's boundElements array by removing duplicates and
+ * fixing containerId of bound elements if not present. Also removes any
+ * bound elements that do not exist in the elements array.
+ *
+ * NOTE mutates elements.
+ */
+const repairContainerElement = (
+ container: Mutable<ExcalidrawElement>,
+ elementsMap: Map<string, Mutable<ExcalidrawElement>>,
+) => {
+ if (container.boundElements) {
+ // copy because we're not cloning on restore, and we don't want to mutate upstream
+ const boundElements = container.boundElements.slice();
+
+ // dedupe bindings & fix boundElement.containerId if not set already
+ const boundIds = new Set<ExcalidrawElement["id"]>();
+ container.boundElements = boundElements.reduce(
+ (
+ acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
+ binding,
+ ) => {
+ const boundElement = elementsMap.get(binding.id);
+ if (boundElement && !boundIds.has(binding.id)) {
+ boundIds.add(binding.id);
+
+ if (boundElement.isDeleted) {
+ return acc;
+ }
+
+ acc.push(binding);
+
+ if (
+ isTextElement(boundElement) &&
+ // being slightly conservative here, preserving existing containerId
+ // if defined, lest boundElements is stale
+ !boundElement.containerId
+ ) {
+ (boundElement as Mutable<ExcalidrawTextElement>).containerId =
+ container.id;
+ }
+ }
+ return acc;
+ },
+ [],
+ );
+ }
+};
+
+/**
+ * Repairs target bound element's container's boundElements array,
+ * or removes contaienrId if container does not exist.
+ *
+ * NOTE mutates elements.
+ */
+const repairBoundElement = (
+ boundElement: Mutable<ExcalidrawTextElement>,
+ elementsMap: Map<string, Mutable<ExcalidrawElement>>,
+) => {
+ const container = boundElement.containerId
+ ? elementsMap.get(boundElement.containerId)
+ : null;
+
+ if (!container) {
+ boundElement.containerId = null;
+ return;
+ }
+
+ if (boundElement.isDeleted) {
+ return;
+ }
+
+ if (
+ container.boundElements &&
+ !container.boundElements.find((binding) => binding.id === boundElement.id)
+ ) {
+ // copy because we're not cloning on restore, and we don't want to mutate upstream
+ const boundElements = (
+ container.boundElements || (container.boundElements = [])
+ ).slice();
+ boundElements.push({ type: "text", id: boundElement.id });
+ container.boundElements = boundElements;
+ }
+};
+
+/**
+ * Remove an element's frameId if its containing frame is non-existent
+ *
+ * NOTE mutates elements.
+ */
+const repairFrameMembership = (
+ element: Mutable<ExcalidrawElement>,
+ elementsMap: Map<string, Mutable<ExcalidrawElement>>,
+) => {
+ if (element.frameId) {
+ const containingFrame = elementsMap.get(element.frameId);
+
+ if (!containingFrame) {
+ element.frameId = null;
+ }
+ }
+};
+
+export const restoreElements = (
+ elements: ImportedDataState["elements"],
+ /** NOTE doesn't serve for reconciliation */
+ localElements: readonly ExcalidrawElement[] | null | undefined,
+ opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
+): OrderedExcalidrawElement[] => {
+ // used to detect duplicate top-level element ids
+ const existingIds = new Set<string>();
+ const localElementsMap = localElements ? arrayToMap(localElements) : null;
+ const restoredElements = syncInvalidIndices(
+ (elements || []).reduce((elements, element) => {
+ // filtering out selection, which is legacy, no longer kept in elements,
+ // and causing issues if retained
+ if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
+ let migratedElement: ExcalidrawElement | null = restoreElement(element);
+ if (migratedElement) {
+ const localElement = localElementsMap?.get(element.id);
+ if (localElement && localElement.version > migratedElement.version) {
+ migratedElement = bumpVersion(
+ migratedElement,
+ localElement.version,
+ );
+ }
+ if (existingIds.has(migratedElement.id)) {
+ migratedElement = { ...migratedElement, id: randomId() };
+ }
+ existingIds.add(migratedElement.id);
+
+ elements.push(migratedElement);
+ }
+ }
+ return elements;
+ }, [] as ExcalidrawElement[]),
+ );
+
+ if (!opts?.repairBindings) {
+ return restoredElements;
+ }
+
+ // repair binding. Mutates elements.
+ const restoredElementsMap = arrayToMap(restoredElements);
+ for (const element of restoredElements) {
+ if (element.frameId) {
+ repairFrameMembership(element, restoredElementsMap);
+ }
+
+ if (isTextElement(element) && element.containerId) {
+ repairBoundElement(element, restoredElementsMap);
+ } else if (element.boundElements) {
+ repairContainerElement(element, restoredElementsMap);
+ }
+
+ if (opts.refreshDimensions && isTextElement(element)) {
+ Object.assign(
+ element,
+ refreshTextDimensions(
+ element,
+ getContainerElement(element, restoredElementsMap),
+ restoredElementsMap,
+ ),
+ );
+ }
+
+ if (isLinearElement(element)) {
+ if (
+ element.startBinding &&
+ (!restoredElementsMap.has(element.startBinding.elementId) ||
+ !isArrowElement(element))
+ ) {
+ (element as Mutable<ExcalidrawLinearElement>).startBinding = null;
+ }
+ if (
+ element.endBinding &&
+ (!restoredElementsMap.has(element.endBinding.elementId) ||
+ !isArrowElement(element))
+ ) {
+ (element as Mutable<ExcalidrawLinearElement>).endBinding = null;
+ }
+ }
+ }
+
+ // NOTE (mtolmacs): Temporary fix for extremely large arrows
+ // Need to iterate again so we have attached text nodes in elementsMap
+ return restoredElements.map((element) => {
+ if (
+ isElbowArrow(element) &&
+ element.startBinding == null &&
+ element.endBinding == null &&
+ !validateElbowPoints(element.points)
+ ) {
+ return {
+ ...element,
+ ...updateElbowArrowPoints(
+ element,
+ restoredElementsMap as NonDeletedSceneElementsMap,
+ {
+ points: [
+ pointFrom<LocalPoint>(0, 0),
+ element.points[element.points.length - 1],
+ ],
+ },
+ ),
+ index: element.index,
+ };
+ }
+
+ if (
+ isElbowArrow(element) &&
+ element.startBinding &&
+ element.endBinding &&
+ element.startBinding.elementId === element.endBinding.elementId &&
+ element.points.length > 1 &&
+ element.points.some(
+ ([rx, ry]) => Math.abs(rx) > 1e6 || Math.abs(ry) > 1e6,
+ )
+ ) {
+ console.error("Fixing self-bound elbow arrow", element.id);
+ const boundElement = restoredElementsMap.get(
+ element.startBinding.elementId,
+ );
+ if (!boundElement) {
+ console.error(
+ "Bound element not found",
+ element.startBinding.elementId,
+ );
+ return element;
+ }
+
+ return {
+ ...element,
+ x: boundElement.x + boundElement.width / 2,
+ y: boundElement.y - 5,
+ width: boundElement.width,
+ height: boundElement.height,
+ points: [
+ pointFrom<LocalPoint>(0, 0),
+ pointFrom<LocalPoint>(0, -10),
+ pointFrom<LocalPoint>(boundElement.width / 2 + 5, -10),
+ pointFrom<LocalPoint>(
+ boundElement.width / 2 + 5,
+ boundElement.height / 2 + 5,
+ ),
+ ],
+ };
+ }
+
+ return element;
+ });
+};
+
+const coalesceAppStateValue = <
+ T extends keyof ReturnType<typeof getDefaultAppState>,
+>(
+ key: T,
+ appState: Exclude<ImportedDataState["appState"], null | undefined>,
+ defaultAppState: ReturnType<typeof getDefaultAppState>,
+) => {
+ const value = appState[key];
+ // NOTE the value! assertion is needed in TS 4.5.5 (fixed in newer versions)
+ return value !== undefined ? value! : defaultAppState[key];
+};
+
+const LegacyAppStateMigrations: {
+ [K in keyof LegacyAppState]: (
+ ImportedDataState: Exclude<ImportedDataState["appState"], null | undefined>,
+ defaultAppState: ReturnType<typeof getDefaultAppState>,
+ ) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]];
+} = {
+ isSidebarDocked: (appState, defaultAppState) => {
+ return [
+ "defaultSidebarDockedPreference",
+ appState.isSidebarDocked ??
+ coalesceAppStateValue(
+ "defaultSidebarDockedPreference",
+ appState,
+ defaultAppState,
+ ),
+ ];
+ },
+};
+
+export const restoreAppState = (
+ appState: ImportedDataState["appState"],
+ localAppState: Partial<AppState> | null | undefined,
+): RestoredAppState => {
+ appState = appState || {};
+ const defaultAppState = getDefaultAppState();
+ const nextAppState = {} as typeof defaultAppState;
+
+ // first, migrate all legacy AppState properties to new ones. We do it
+ // in one go before migrate the rest of the properties in case the new ones
+ // depend on checking any other key (i.e. they are coupled)
+ for (const legacyKey of Object.keys(
+ LegacyAppStateMigrations,
+ ) as (keyof typeof LegacyAppStateMigrations)[]) {
+ if (legacyKey in appState) {
+ const [nextKey, nextValue] = LegacyAppStateMigrations[legacyKey](
+ appState,
+ defaultAppState,
+ );
+ (nextAppState as any)[nextKey] = nextValue;
+ }
+ }
+
+ for (const [key, defaultValue] of Object.entries(defaultAppState) as [
+ keyof typeof defaultAppState,
+ any,
+ ][]) {
+ // if AppState contains a legacy key, prefer that one and migrate its
+ // value to the new one
+ const suppliedValue = appState[key];
+
+ const localValue = localAppState ? localAppState[key] : undefined;
+ (nextAppState as any)[key] =
+ suppliedValue !== undefined
+ ? suppliedValue
+ : localValue !== undefined
+ ? localValue
+ : defaultValue;
+ }
+
+ return {
+ ...nextAppState,
+ cursorButton: localAppState?.cursorButton || "up",
+ // reset on fresh restore so as to hide the UI button if penMode not active
+ penDetected:
+ localAppState?.penDetected ??
+ (appState.penMode ? appState.penDetected ?? false : false),
+ activeTool: {
+ ...updateActiveTool(
+ defaultAppState,
+ nextAppState.activeTool.type &&
+ AllowedExcalidrawActiveTools[nextAppState.activeTool.type]
+ ? nextAppState.activeTool
+ : { type: "selection" },
+ ),
+ lastActiveTool: null,
+ locked: nextAppState.activeTool.locked ?? false,
+ },
+ // Migrates from previous version where appState.zoom was a number
+ zoom: {
+ value: getNormalizedZoom(
+ isFiniteNumber(appState.zoom)
+ ? appState.zoom
+ : appState.zoom?.value ?? defaultAppState.zoom.value,
+ ),
+ },
+ openSidebar:
+ // string (legacy)
+ typeof (appState.openSidebar as any as string) === "string"
+ ? { name: DEFAULT_SIDEBAR.name }
+ : nextAppState.openSidebar,
+ gridSize: getNormalizedGridSize(
+ isFiniteNumber(appState.gridSize) ? appState.gridSize : DEFAULT_GRID_SIZE,
+ ),
+ gridStep: getNormalizedGridStep(
+ isFiniteNumber(appState.gridStep) ? appState.gridStep : DEFAULT_GRID_STEP,
+ ),
+ editingFrame: null,
+ };
+};
+
+export const restore = (
+ data: Pick<ImportedDataState, "appState" | "elements" | "files"> | null,
+ /**
+ * Local AppState (`this.state` or initial state from localStorage) so that we
+ * don't overwrite local state with default values (when values not
+ * explicitly specified).
+ * Supply `null` if you can't get access to it.
+ */
+ localAppState: Partial<AppState> | null | undefined,
+ localElements: readonly ExcalidrawElement[] | null | undefined,
+ elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean },
+): RestoredDataState => {
+ return {
+ elements: restoreElements(data?.elements, localElements, elementsConfig),
+ appState: restoreAppState(data?.appState, localAppState || null),
+ files: data?.files || {},
+ };
+};
+
+const restoreLibraryItem = (libraryItem: LibraryItem) => {
+ const elements = restoreElements(
+ getNonDeletedElements(libraryItem.elements),
+ null,
+ );
+ return elements.length ? { ...libraryItem, elements } : null;
+};
+
+export const restoreLibraryItems = (
+ libraryItems: ImportedDataState["libraryItems"] = [],
+ defaultStatus: LibraryItem["status"],
+) => {
+ const restoredItems: LibraryItem[] = [];
+ for (const item of libraryItems) {
+ // migrate older libraries
+ if (Array.isArray(item)) {
+ const restoredItem = restoreLibraryItem({
+ status: defaultStatus,
+ elements: item,
+ id: randomId(),
+ created: Date.now(),
+ });
+ if (restoredItem) {
+ restoredItems.push(restoredItem);
+ }
+ } else {
+ const _item = item as MarkOptional<
+ LibraryItem,
+ "id" | "status" | "created"
+ >;
+ const restoredItem = restoreLibraryItem({
+ ..._item,
+ id: _item.id || randomId(),
+ status: _item.status || defaultStatus,
+ created: _item.created || Date.now(),
+ });
+ if (restoredItem) {
+ restoredItems.push(restoredItem);
+ }
+ }
+ }
+ return restoredItems;
+};