import { clamp, isFiniteNumber, isValidPoint, pointFrom, } from "@excalidraw/math"; import { colorToHex, COLOR_TOP_PICKS_SLOTS, type CombineBrandsIfNeeded, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, DEFAULT_STROKE_STREAMLINE, DEFAULT_TEXT_ALIGN, DEFAULT_VERTICAL_ALIGN, FONT_FAMILY, ROUNDNESS, DEFAULT_SIDEBAR, DEFAULT_ELEMENT_PROPS, DEFAULT_GRID_SIZE, DEFAULT_GRID_STEP, randomId, getUpdatedTimestamp, updateActiveTool, arrayToMap, getSizeFromPoints, normalizeLink, getLineHeight, STROKE_WIDTH, STROKE_WIDTH_KEYS, type StrokeWidthKey, isTransparent, } from "@excalidraw/common"; import { calculateFixedPointForNonElbowArrowBinding, getNonDeletedElements, normalizeArrowhead, isPointInElement, isValidPolygon, projectFixedPointOntoDiagonal, isNonDeletedElement, } from "@excalidraw/element"; import { normalizeFixedPoint } from "@excalidraw/element"; import { updateElbowArrowPoints, validateElbowPoints, } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element"; import { bumpVersion, getStickyNoteLayout, isStickyNoteBoundText, normalizeStickyNote, normalizeStickyNoteBackgroundColor, normalizeStickyNoteFontSize, normalizeStickyNoteStrokeColor, } from "@excalidraw/element"; import { getBoundTextElement, getContainerElement } from "@excalidraw/element"; import { isStickyNoteElement } from "@excalidraw/element"; import { detectLineHeight } from "@excalidraw/element"; import { isArrowBoundToElement, isArrowElement, isElbowArrow, isLinearElement, isLineElement, isTextElement, isUsingAdaptiveRadius, } from "@excalidraw/element"; import { normalizeBoundElementsOrder, syncInvalidIndices, syncMovedIndices, } from "@excalidraw/element"; import { refreshTextDimensions } from "@excalidraw/element"; import { getNormalizedDimensions } from "@excalidraw/element"; import { isInvisiblySmallElement } from "@excalidraw/element"; import type { LocalPoint, Radians } from "@excalidraw/math"; import type { ElementsMap, ElementsMapOrArray, ExcalidrawArrowElement, ExcalidrawBindableElement, ExcalidrawElbowArrowElement, ExcalidrawElement, ExcalidrawLinearElement, ExcalidrawSelectionElement, ExcalidrawTextElement, FixedPointBinding, FontFamilyValues, NonDeleted, NonDeletedSceneElementsMap, OrderedExcalidrawElement, StrokeVariability, StrokeRoundness, } from "@excalidraw/element/types"; import type { MarkOptional, Mutable } from "@excalidraw/common/utility-types"; import { getDefaultAppState } from "../appState"; import { getNormalizedGridSize, getNormalizedGridStep, getNormalizedZoom, } from "../scene"; import type { AppState, BinaryFiles, LibraryItem, NormalizedZoomValue, } from "../types"; import type { ImportedDataState, LegacyAppState } from "./types"; type RestoredAppState = Omit< AppState, "offsetTop" | "offsetLeft" | "width" | "height" >; const MAX_LINEAR_PX = 75_000; // Last resort fix for extremely large linear elements (lines / arrows), which // would otherwise freeze the editor while rendering — e.g. a dotted or dashed // stroke spanning a huge distance generates an enormous dash array. // https://github.com/excalidraw/excalidraw/issues/11497 const handleOversizedLinearElements = ( element: T, ): T => { if (element.width <= MAX_LINEAR_PX && element.height <= MAX_LINEAR_PX) { return element; } const label = element.type === "arrow" ? `${isElbowArrow(element) ? "elbow" : "simple"} arrow` : element.type; console.error( `Removing extremely large ${label} ${element.id} (width: ${element.width}, height: ${element.height}, x: ${element.x}, y: ${element.y})`, ); return { ...element, x: 0, y: 0, width: 100, height: 100, points: [pointFrom(0, 0), pointFrom(100, 100)], isDeleted: true, }; }; const restoreLinearElementPoints = ( points: unknown, width: unknown, height: unknown, ): LocalPoint[] => { const restoredPoints = Array.isArray(points) ? points.reduce((acc, point) => { if (isValidPoint(point)) { acc.push(pointFrom(point[0], point[1])); } return acc; }, []) : []; return restoredPoints.length < 2 ? [ pointFrom(0, 0), pointFrom( isFiniteNumber(width) ? width : 0, isFiniteNumber(height) ? height : 0, ), ] : restoredPoints; }; const restoreFreedrawPoints = ( points: unknown, pressures: unknown, ): { points: LocalPoint[]; pressures: number[]; } => { if (!Array.isArray(points)) { return { points: [], pressures: [], }; } const pressureValues: readonly unknown[] = Array.isArray(pressures) ? pressures : []; const restoredPoints: LocalPoint[] = []; const restoredPressures: number[] = []; points.forEach((point, index) => { if (isValidPoint(point)) { restoredPoints.push(pointFrom(point[0], point[1])); if (index in pressureValues) { const pressure = pressureValues[index]; restoredPressures.push(isFiniteNumber(pressure) ? pressure : 0.5); } } }); return { points: restoredPoints, pressures: restoredPressures, }; }; export const AllowedExcalidrawActiveTools: Record< AppState["activeTool"]["type"], boolean > = { selection: true, lasso: true, text: true, rectangle: true, diamond: true, ellipse: true, line: true, image: true, arrow: true, freedraw: true, stickynote: true, eraser: false, custom: true, frame: true, embeddable: true, hand: true, laser: false, autoshape: false, magicframe: false, bucketfill: true, }; export type RestoredDataState = { elements: OrderedExcalidrawElement[]; appState: RestoredAppState; files: BinaryFiles; }; const ALLOWED_STROKE_VARIABILITIES = new Set([ "constant", "variable", ]); const restoreStrokeVariability = ( variability: unknown, defaultValue: StrokeVariability, ): StrokeVariability => { return typeof variability === "string" && ALLOWED_STROKE_VARIABILITIES.has(variability as StrokeVariability) ? (variability as StrokeVariability) : defaultValue; }; const getStrokeWidthKey = (strokeWidth: unknown): StrokeWidthKey | null => { return isFiniteNumber(strokeWidth) ? STROKE_WIDTH_KEYS.find((key) => STROKE_WIDTH[key] === strokeWidth) ?? null : null; }; const restoreFreedrawStrokeOptions = ( strokeOptions: unknown, ): { variability: StrokeVariability; streamline: number } => { const options = strokeOptions && typeof strokeOptions === "object" ? (strokeOptions as { variability?: unknown; streamline?: unknown }) : null; return { variability: restoreStrokeVariability(options?.variability, "variable"), streamline: isFiniteNumber(options?.streamline) ? options?.streamline : DEFAULT_STROKE_STREAMLINE, }; }; 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 = ( element: T, binding: FixedPointBinding | null, targetElementsMap: Readonly, /** used for context (arrow bindings) */ existingElementsMap: Readonly | null | undefined, startOrEnd: "start" | "end", ): FixedPointBinding | null => { try { if (!binding) { return null; } // --------------------------------------------------------------------------- // elbow arrows // --------------------------------------------------------------------------- if (isElbowArrow(element)) { const fixedPointBinding: | ExcalidrawElbowArrowElement["startBinding"] | ExcalidrawElbowArrowElement["endBinding"] = { ...binding, fixedPoint: normalizeFixedPoint(binding.fixedPoint), mode: binding.mode || "orbit", }; return fixedPointBinding; } // --------------------------------------------------------------------------- // simple arrows // --------------------------------------------------------------------------- // binding schema v2 // --------------------------------------------------------------------------- if (binding.mode) { // if latest binding schema, don't check if binding.elementId exists // (it's done in a separate pass) if (binding.elementId) { return { elementId: binding.elementId, mode: binding.mode, fixedPoint: normalizeFixedPoint(binding.fixedPoint), } as FixedPointBinding | null; } return null; } // binding schema v1 (legacy) -> attempt to migrate to v2 // --------------------------------------------------------------------------- const targetBoundElement = targetElementsMap.get(binding.elementId) as | ExcalidrawBindableElement | undefined; const boundElement = targetBoundElement || (existingElementsMap?.get(binding.elementId) as | ExcalidrawBindableElement | undefined); const elementsMap = targetBoundElement ? targetElementsMap : existingElementsMap; // migrating legacy focus point bindings if (boundElement && elementsMap) { const p = LinearElementEditor.getPointAtIndexGlobalCoordinates( element, startOrEnd === "start" ? 0 : element.points.length - 1, elementsMap, ); const mode = isPointInElement(p, boundElement, elementsMap) ? "inside" : "orbit"; if (!isNonDeletedElement(element)) { console.error("[NONDELETED][INVARIANT] Restoring a deleted element"); } const safeElement = { ...element, startBinding: element.startBinding?.elementId ? { ...element.startBinding, mode, fixedPoint: normalizeFixedPoint(element.startBinding.fixedPoint), } : null, endBinding: element.endBinding?.elementId ? { ...element.endBinding, mode, fixedPoint: normalizeFixedPoint(element.endBinding.fixedPoint), } : null, }; const focusPoint = mode === "inside" ? p : projectFixedPointOntoDiagonal( safeElement, p, boundElement, startOrEnd, elementsMap, { value: 1 as NormalizedZoomValue }, ) || p; const { fixedPoint } = calculateFixedPointForNonElbowArrowBinding( safeElement as NonDeleted, boundElement as NonDeleted, startOrEnd, elementsMap, focusPoint, ); return { mode, elementId: binding.elementId, fixedPoint, }; } console.error( `Could not repair binding for element "${boundElement?.id}" out of (${elementsMap?.size}) elements`, ); } catch (error) { console.error("Error repairing binding:", error); } return null; }; const restoreElementWithProperties = < T extends Required> & { customData?: ExcalidrawElement["customData"]; /** @deprecated */ boundElementIds?: readonly ExcalidrawElement["id"][]; /** @deprecated */ strokeSharpness?: StrokeRoundness; }, 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 = { 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(), created: element.created ?? null, 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; } const ret = { // spread the original element properties to not lose unknown ones // for forward-compatibility ...element, // normalized properties ...base, ...getNormalizedDimensions(base), ...extra, } as unknown as T; // strip legacy props (migrated in previous steps) delete ret.strokeSharpness; delete ret.boundElementIds; return ret; }; export const restoreElement = ( /** element to be restored */ element: Exclude, /** all elements to be restored */ targetElementsMap: Readonly, /** used for additional context */ existingElementsMap: Readonly | null | undefined, opts?: { deleteInvisibleElements?: boolean; }, ): 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); } if (!isFiniteNumber(fontSize)) { fontSize = DEFAULT_FONT_SIZE; } 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, labelPosition: isFiniteNumber(element.labelPosition) ? clamp(element.labelPosition, 0, 1) : null, // only meaningful for sticky note labels; reconciled against the // container in `restoreStickyNotes` once bindings are repaired baseFontSize: isFiniteNumber(element.baseFontSize) ? normalizeStickyNoteFontSize(element.baseFontSize) : null, }); // if empty text, mark as deleted. We keep in array // for data integrity purposes (collab etc.) if (opts?.deleteInvisibleElements && !text && !element.isDeleted) { // TODO: we should not do this since it breaks sync / versioning when we exchange / apply just deltas and restore the elements (deletion isn't recorded) element = { ...element, originalText: text, isDeleted: true }; element = bumpVersion(element); } return element; case "freedraw": { const { points, pressures } = restoreFreedrawPoints( element.points, element.pressures, ); return restoreElementWithProperties(element, { points, simulatePressure: element.simulatePressure, strokeOptions: restoreFreedrawStrokeOptions(element.strokeOptions), 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 = normalizeArrowhead(element.startArrowhead); const endArrowhead = normalizeArrowhead(element.endArrowhead); let x = element.x; let y = element.y; let points = restoreLinearElementPoints( element.points, element.width, element.height, ); if (points[0][0] !== 0 || points[0][1] !== 0) { ({ points, x, y } = LinearElementEditor.getNormalizeElementPointsAndCoords({ ...element, points, x: x ?? 0, y: y ?? 0, } as ExcalidrawLinearElement)); } const restoredLine = restoreElementWithProperties(element, { type: "line", startBinding: null, endBinding: null, startArrowhead, endArrowhead, points, x, y, ...(isLineElement(element) ? { polygon: isValidPolygon(points) ? element.polygon ?? false : false, } : {}), ...getSizeFromPoints(points), }); return handleOversizedLinearElements(restoredLine); case "arrow": { const startArrowhead = normalizeArrowhead(element.startArrowhead); const endArrowhead = element.endArrowhead === undefined ? "arrow" : normalizeArrowhead(element.endArrowhead); const x = element.x as number | undefined; const y = element.y as number | undefined; const points = restoreLinearElementPoints( element.points, element.width, element.height, ); const elementWithRestoredPoints = { ...element, points, x: x ?? 0, y: y ?? 0, } as ExcalidrawArrowElement; const base = { type: element.type, startBinding: repairBinding( elementWithRestoredPoints, element.startBinding, targetElementsMap, existingElementsMap, "start", ), endBinding: repairBinding( elementWithRestoredPoints, element.endBinding, targetElementsMap, existingElementsMap, "end", ), startArrowhead, endArrowhead, points, x: x ?? 0, y: y ?? 0, elbowed: (element as ExcalidrawArrowElement).elbowed, ...getSizeFromPoints(points), }; // TODO: Separate arrow from linear element const restoredElement = isElbowArrow(element) ? restoreElementWithProperties(element as ExcalidrawElbowArrowElement, { ...base, elbowed: true, fixedSegments: element.fixedSegments?.length && base.points.length >= 4 ? element.fixedSegments : null, startIsSpecial: element.startIsSpecial, endIsSpecial: element.endIsSpecial, }) : restoreElementWithProperties(element as ExcalidrawArrowElement, base); const normalizedRestoredElement = { ...restoredElement, ...LinearElementEditor.getNormalizeElementPointsAndCoords( restoredElement, ), }; return handleOversizedLinearElements(normalizedRestoredElement); } // generic elements case "ellipse": case "rectangle": case "diamond": case "iframe": case "embeddable": return restoreElementWithProperties(element, {}); case "stickynote": return normalizeStickyNote( restoreElementWithProperties(element, { baseHeight: element.baseHeight ?? (element as typeof element & { maxHeight?: number }).maxHeight ?? element.height, }), ); 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, 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)) { 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).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, elementsMap: Map>, ) => { const container = boundElement.containerId ? elementsMap.get(boundElement.containerId) : null; (boundElement as Mutable).angle = ( isArrowElement(container) ? 0 : container?.angle ?? 0 ) as Radians; 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; } }; /** * Places bound text directly after its container while preserving the * container's fractional index. * * NOTE mutates indices of reordered bound text elements. */ const repairBoundTextElementOrder = ( elements: readonly ExcalidrawElement[], ) => { const originalPositions = new Map( elements.map((element, index) => [element.id, index]), ); const normalizedElements = normalizeBoundElementsOrder(elements); const reorderedBoundTextElements = normalizedElements.filter( (element, index) => isTextElement(element) && element.containerId && originalPositions.get(element.id) !== index, ); return reorderedBoundTextElements.length ? syncMovedIndices( normalizedElements, arrayToMap(reorderedBoundTextElements), ) : normalizedElements; }; /** * Remove an element's frameId if its containing frame is non-existent * * NOTE mutates elements. */ const repairFrameMembership = ( element: Mutable, elementsMap: Map>, ) => { if (element.frameId) { const containingFrame = elementsMap.get(element.frameId); if (!containingFrame) { element.frameId = null; } } }; /** * Sticky note invariants that need both halves of the pair present, so they * run after binding repair. Mutates elements (like the repair helpers). * - a label's `baseFontSize` is meaningful only while bound to a sticky note: * seeded from `fontSize` when missing, cleared everywhere else * - a sticky label's stroke is never transparent (it is the visible text) * - a note's stroke — its ink, which the footer paints with — equals its label's * - with `refreshDimensions`, the note and its label are refitted together */ const restoreStickyNotes = ( elements: readonly ExcalidrawElement[], elementsMap: ElementsMap, opts: { refreshDimensions: boolean }, ) => { for (const element of elements) { if (!isTextElement(element) || element.isDeleted) { continue; } if (isStickyNoteBoundText(element, elementsMap)) { const container = elementsMap.get(element.containerId!); // one ink per note: a transparent label takes the note's color; // otherwise the label — the visible text — wins over a note that // drifted (edit-mode coloring on older builds) const strokeColor = normalizeStickyNoteStrokeColor( isTransparent(element.strokeColor) ? container?.strokeColor : element.strokeColor, ); Object.assign(element, { baseFontSize: normalizeStickyNoteFontSize( element.baseFontSize ?? element.fontSize, ), strokeColor, }); if (container && container.strokeColor !== strokeColor) { Object.assign(container, { strokeColor }); } } else if (element.baseFontSize != null) { Object.assign(element, { baseFontSize: null }); } } if (opts.refreshDimensions) { for (const element of elements) { if (!isStickyNoteElement(element) || element.isDeleted) { continue; } const textElement = getBoundTextElement(element, elementsMap); const layout = getStickyNoteLayout(element, textElement); Object.assign(element, layout.container); if (textElement && layout.text) { Object.assign(textElement, layout.text); } } } }; export const restoreElements = ( targetElements: readonly T[] | undefined | null, /** used for additional context (e.g. repairing arrow bindings) */ existingElements: Readonly | null | undefined, opts?: | { refreshDimensions?: boolean; repairBindings?: boolean; deleteInvisibleElements?: boolean; } | undefined, ): CombineBrandsIfNeeded => { // used to detect duplicate top-level element ids const existingIds = new Set(); const targetElementsMap = arrayToMap(targetElements || []); const existingElementsMap = existingElements ? arrayToMap(existingElements) : null; const restoredElements = syncInvalidIndices( (targetElements || []).reduce((elements, element) => { // filtering out selection, which is legacy, no longer kept in elements, // and causing issues if retained if (element.type === "selection") { return elements; } let migratedElement: ExcalidrawElement | null; try { migratedElement = restoreElement( element, targetElementsMap, existingElementsMap, { deleteInvisibleElements: opts?.deleteInvisibleElements, }, ); } catch (error) { console.error("Error restoring element:", error); migratedElement = null; } if (migratedElement) { const localElement = existingElementsMap?.get(element.id); const shouldMarkAsDeleted = opts?.deleteInvisibleElements && isInvisiblySmallElement(element); if (shouldMarkAsDeleted) { migratedElement = bumpVersion(migratedElement, localElement?.version); } if (shouldMarkAsDeleted) { migratedElement = { ...migratedElement, isDeleted: true }; } 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 as CombineBrandsIfNeeded< T, OrderedExcalidrawElement >; } // 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) && // sticky labels are refitted together with their note below !isStickyNoteBoundText(element, restoredElementsMap) ) { 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).startBinding = null; } if ( element.endBinding && (!restoredElementsMap.has(element.endBinding.elementId) || !isArrowElement(element)) ) { (element as Mutable).endBinding = null; } } } restoreStickyNotes(restoredElements, restoredElementsMap, { refreshDimensions: !!opts.refreshDimensions, }); const repairedElements = repairBoundTextElementOrder(restoredElements); // NOTE (mtolmacs): Temporary fix for invalid/self-bound elbow arrows // Need to iterate again so we have attached text nodes in elementsMap return repairedElements.map((element) => { if ( isElbowArrow(element) && !isArrowBoundToElement(element) && !validateElbowPoints(element.points) ) { return { ...element, ...updateElbowArrowPoints( element, restoredElementsMap as NonDeletedSceneElementsMap, { points: [ pointFrom(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(0, 0), pointFrom(0, -10), pointFrom(boundElement.width / 2 + 5, -10), pointFrom( boundElement.width / 2 + 5, boundElement.height / 2 + 5, ), ], }; } return element; }) as CombineBrandsIfNeeded; }; /** * When replacing elements that may exist locally, this bumps their versions * to the local version + 1. Mainly for later reconciliation to work properly. * * See https://github.com/excalidraw/excalidraw/issues/3795 * * Generally use this on editor boundaries (importing from file etc.), though * it does not apply universally (e.g. we don't want to do this for collab * updates). */ export const bumpElementVersions = ( targetElements: readonly T[], localElements: Readonly | null | undefined, ) => { const localElementsMap = localElements ? arrayToMap(localElements) : null; return targetElements.map((element) => { const localElement = localElementsMap?.get(element.id); if ( localElement && (localElement.version > element.version || // same versions but different versionNonce means different edits // (this often means the element was bumped during restore e.g. due // to re-indexing, and the original element was modified elsewhere // and supplied as localElements) (localElement.version === element.version && localElement.versionNonce !== element.versionNonce)) ) { return bumpVersion(element, localElement.version); } return element; }); }; 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]]]; } = { isSidebarDocked: (appState, defaultAppState) => { return [ "defaultSidebarDockedPreference", appState.isSidebarDocked ?? coalesceAppStateValue( "defaultSidebarDockedPreference", appState, defaultAppState, ), ]; }, }; const restoreColorTopPicksList = (value: unknown): readonly string[] | null => { if (!Array.isArray(value)) { return null; } // keyed by normalized color value so notation variants (`#fff` vs // `#ffffff` vs `white`) dedupe, while the value keeps the original // notation — normalizing the output would break e.g. `transparent` // (→ `#00000000`), which the picker matches by literal value const colors = new Map(); for (const color of value) { if (typeof color !== "string") { continue; } const normalized = colorToHex(color) ?? color.toLowerCase(); if (!colors.has(normalized)) { colors.set(normalized, color); } // the strip layout fits exactly this many slots — longer lists (hostile // or hand-edited storage) would overflow the properties island if (colors.size >= COLOR_TOP_PICKS_SLOTS) { break; } } return colors.size ? [...colors.values()] : null; }; 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; } const boxSelectionMode = appState.boxSelectionMode ?? localAppState?.boxSelectionMode; if (boxSelectionMode !== undefined) { nextAppState.boxSelectionMode = boxSelectionMode; } // drop malformed persisted custom top picks (imported data is untrusted) nextAppState.colorTopPicks = { elementStroke: restoreColorTopPicksList( nextAppState.colorTopPicks?.elementStroke, ), elementBackground: restoreColorTopPicksList( nextAppState.colorTopPicks?.elementBackground, ), bucketFill: restoreColorTopPicksList( nextAppState.colorTopPicks?.bucketFill, ), stickyNoteStroke: restoreColorTopPicksList( nextAppState.colorTopPicks?.stickyNoteStroke, ), stickyNoteBackground: restoreColorTopPicksList( nextAppState.colorTopPicks?.stickyNoteBackground, ), }; // legacy if ((appState as any).currentItemStrokeWidth !== undefined) { nextAppState.currentItemStrokeWidthKey = getStrokeWidthKey((appState as any).currentItemStrokeWidth) ?? defaultAppState.currentItemStrokeWidthKey; } 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, ), currentItemStickynoteStrokeColor: normalizeStickyNoteStrokeColor( nextAppState.currentItemStickynoteStrokeColor, ), currentItemStickynoteBackgroundColor: normalizeStickyNoteBackgroundColor( nextAppState.currentItemStickynoteBackgroundColor, ), editingFrame: null, }; }; const restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => { const elements = getNonDeletedElements( restoreElements(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; };