import { ExcalidrawElement, ExcalidrawSelectionElement, ExcalidrawTextElement, FontFamilyValues, StrokeRoundness, } from "../element/types"; import { AppState, BinaryFiles, LibraryItem, NormalizedZoomValue, } from "../types"; import { ImportedDataState, LegacyAppState } from "./types"; import { getNonDeletedElements, getNormalizedDimensions, isInvisiblySmallElement, refreshTextDimensions, } from "../element"; import { isTextElement, isUsingAdaptiveRadius } from "../element/typeChecks"; import { randomId } from "../random"; import { DEFAULT_FONT_FAMILY, DEFAULT_TEXT_ALIGN, DEFAULT_VERTICAL_ALIGN, PRECEDING_ELEMENT_KEY, FONT_FAMILY, ROUNDNESS, } 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"; 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, }; export type RestoredDataState = { elements: ExcalidrawElement[]; 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 restoreElementWithProperties = < T extends Required> & { customData?: ExcalidrawElement["customData"]; /** @deprecated */ boundElementIds?: readonly ExcalidrawElement["id"][]; /** @deprecated */ strokeSharpness?: StrokeRoundness; /** metadata that may be present in elements during collaboration */ [PRECEDING_ELEMENT_KEY]?: string; }, K extends Pick, keyof ExcalidrawElement>>, >( element: T, extra: Pick< T, // This extra Pick ensure no excess properties are passed. // @ts-ignore TS complains here but type checks the call sites fine. keyof K > & Partial>, ): T => { const base: Pick & { [PRECEDING_ELEMENT_KEY]?: string; } = { 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, isDeleted: element.isDeleted ?? false, id: element.id || randomId(), fillStyle: element.fillStyle || "hachure", strokeWidth: element.strokeWidth || 1, strokeStyle: element.strokeStyle ?? "solid", roughness: element.roughness ?? 1, opacity: element.opacity == null ? 100 : element.opacity, angle: element.angle || 0, x: extra.x ?? element.x ?? 0, y: extra.y ?? element.y ?? 0, strokeColor: element.strokeColor, backgroundColor: element.backgroundColor, width: element.width || 0, height: element.height || 0, seed: element.seed ?? 1, groupIds: element.groupIds ?? [], 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 ?? null, locked: element.locked ?? false, }; if ("customData" in element) { base.customData = element.customData; } if (PRECEDING_ELEMENT_KEY in element) { base[PRECEDING_ELEMENT_KEY] = element[PRECEDING_ELEMENT_KEY]; } return { ...base, ...getNormalizedDimensions(base), ...extra, } as unknown as T; }; const restoreElement = ( element: Exclude, refreshDimensions = false, ): typeof element | null => { switch (element.type) { case "text": let fontSize = element.fontSize; let fontFamily = element.fontFamily; if ("font" in element) { const [fontPx, _fontFamily]: [string, string] = ( element as any ).font.split(" "); fontSize = parseInt(fontPx, 10); fontFamily = getFontFamilyByName(_fontFamily); } element = restoreElementWithProperties(element, { fontSize, fontFamily, text: element.text ?? "", baseline: element.baseline, textAlign: element.textAlign || DEFAULT_TEXT_ALIGN, verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, containerId: element.containerId ?? null, originalText: element.originalText || element.text, }); if (refreshDimensions) { element = { ...element, ...refreshTextDimensions(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], }); case "line": // @ts-ignore LEGACY type // eslint-disable-next-line no-fallthrough case "draw": case "arrow": { const { startArrowhead = null, endArrowhead = element.type === "arrow" ? "arrow" : 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 ? [ [0, 0], [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 ExcalidrawElement["type"] | "draw") === "draw" ? "line" : element.type, startBinding: element.startBinding, endBinding: element.endBinding, lastCommittedPoint: null, startArrowhead, endArrowhead, points, x, y, }); } // generic elements case "ellipse": return restoreElementWithProperties(element, {}); case "rectangle": return restoreElementWithProperties(element, {}); case "diamond": return restoreElementWithProperties(element, {}); // 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. } }; /** * Repairs contaienr 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, elementsMap: Map>, ) => { 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(); container.boundElements = boundElements.reduce( ( acc: Mutable>, binding, ) => { const boundElement = elementsMap.get(binding.id); if (boundElement && !boundIds.has(binding.id)) { if ( isTextElement(boundElement) && // being slightly conservative here, preserving existing containerId // if defined, lest boundElements is stale !boundElement.containerId ) { (boundElement as Mutable).containerId = container.id; } acc.push(binding); boundIds.add(binding.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, elementsMap: Map>, ) => { const container = boundElement.containerId ? elementsMap.get(boundElement.containerId) : null; if (!container) { boundElement.containerId = null; 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; } }; export const restoreElements = ( elements: ImportedDataState["elements"], /** NOTE doesn't serve for reconciliation */ localElements: readonly ExcalidrawElement[] | null | undefined, refreshDimensions = false, ): ExcalidrawElement[] => { const localElementsMap = localElements ? arrayToMap(localElements) : null; const restoredElements = (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, refreshDimensions, ); if (migratedElement) { const localElement = localElementsMap?.get(element.id); if (localElement && localElement.version > migratedElement.version) { migratedElement = bumpVersion(migratedElement, localElement.version); } elements.push(migratedElement); } } return elements; }, [] as ExcalidrawElement[]); // repair binding. Mutates elements. const restoredElementsMap = arrayToMap(restoredElements); for (const element of restoredElements) { if (isTextElement(element) && element.containerId) { repairBoundElement(element, restoredElementsMap); } else if (element.boundElements) { repairContainerElement(element, restoredElementsMap); } } return restoredElements; }; const coalesceAppStateValue = < T extends keyof ReturnType, >( key: T, appState: Exclude, defaultAppState: ReturnType, ) => { 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, defaultAppState: ReturnType, ) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]]; } = { isLibraryOpen: (appState, defaultAppState) => { return [ "openSidebar", "isLibraryOpen" in appState ? appState.isLibraryOpen ? "library" : null : coalesceAppStateValue("openSidebar", appState, defaultAppState), ]; }, isLibraryMenuDocked: (appState, defaultAppState) => { return [ "isSidebarDocked", appState.isLibraryMenuDocked ?? coalesceAppStateValue("isSidebarDocked", appState, defaultAppState), ]; }, }; export const restoreAppState = ( appState: ImportedDataState["appState"], localAppState: Partial | 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" }, ), lastActiveToolBeforeEraser: null, locked: nextAppState.activeTool.locked ?? false, }, // Migrates from previous version where appState.zoom was a number zoom: typeof appState.zoom === "number" ? { value: appState.zoom as NormalizedZoomValue, } : appState.zoom || defaultAppState.zoom, // when sidebar docked and user left it open in last session, // keep it open. If not docked, keep it closed irrespective of last state. openSidebar: nextAppState.openSidebar === "library" ? nextAppState.isSidebarDocked ? "library" : null : nextAppState.openSidebar, }; }; export const restore = ( data: Pick | 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 | null | undefined, localElements: readonly ExcalidrawElement[] | null | undefined, ): RestoredDataState => { return { elements: restoreElements(data?.elements, localElements), 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; };