123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550 |
- 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";
- import oc from "open-color";
- 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,
- hand: 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<Omit<ExcalidrawElement, "customData">> & {
- 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<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">>,
- ): T => {
- const base: Pick<T, keyof ExcalidrawElement> & {
- [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 || oc.black,
- backgroundColor: element.backgroundColor || "transparent",
- 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<ExcalidrawElement, ExcalidrawSelectionElement>,
- 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<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;
- }
- };
- 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<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]]];
- } = {
- 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<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:
- 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<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,
- ): 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;
- };
|