diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index 1ca1c1a289..bc76dc91f5 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -83,6 +83,7 @@ export * from "./positionElementsOnGrid"; export * from "./renderElement"; export * from "./resizeElements"; export * from "./resizeTest"; +export * from "./schema"; export * from "./Scene"; export * from "./selection"; export * from "./shape"; diff --git a/packages/element/src/mutateElement.ts b/packages/element/src/mutateElement.ts index c45c6df08c..76a71db160 100644 --- a/packages/element/src/mutateElement.ts +++ b/packages/element/src/mutateElement.ts @@ -11,6 +11,7 @@ import type { Mutable } from "@excalidraw/common/utility-types"; import { ShapeCache } from "./shape"; import { updateElbowArrowPoints } from "./elbowArrow"; +import { ensureSchemaStateForElementType } from "./schema"; import { isElbowArrow } from "./typeChecks"; @@ -137,6 +138,10 @@ export const mutateElement = >( element.version = updates.version ?? element.version + 1; element.versionNonce = updates.versionNonce ?? randomInteger(); element.updated = getUpdatedTimestamp(); + element.schemaState = ensureSchemaStateForElementType( + element.schemaState, + element.type, + ) as TElement["schemaState"]; return element; }; @@ -166,13 +171,21 @@ export const newElementWith = ( return element; } - return { + const updatedElement = { ...element, ...updates, version: updates.version ?? element.version + 1, versionNonce: updates.versionNonce ?? randomInteger(), updated: getUpdatedTimestamp(), }; + + return { + ...updatedElement, + schemaState: ensureSchemaStateForElementType( + updatedElement.schemaState, + updatedElement.type, + ), + }; }; /** diff --git a/packages/element/src/newElement.ts b/packages/element/src/newElement.ts index 3e9f06d12e..541efefedd 100644 --- a/packages/element/src/newElement.ts +++ b/packages/element/src/newElement.ts @@ -20,6 +20,7 @@ import { getElementAbsoluteCoords, getResizedElementAbsoluteCoords, } from "./bounds"; +import { ensureSchemaStateForElementType } from "./schema"; import { newElementWith } from "./mutateElement"; import { getBoundTextMaxWidth } from "./textElement"; import { normalizeText, measureText } from "./textMeasurements"; @@ -50,8 +51,6 @@ import type { ExcalidrawLineElement, } from "./types"; -const ELEMENT_SCHEMA_VERSION = 2; - export type ElementConstructorOpts = MarkOptional< Omit, | "width" @@ -72,6 +71,7 @@ export type ElementConstructorOpts = MarkOptional< | "roughness" | "strokeWidth" | "roundness" + | "schemaState" | "locked" | "opacity" | "customData" @@ -146,7 +146,7 @@ const _newElementBase = ( roundness, seed: rest.seed ?? randomInteger(), version: rest.version || 1, - schemaVersion: rest.schemaVersion ?? ELEMENT_SCHEMA_VERSION, + schemaState: ensureSchemaStateForElementType(rest.schemaState, type), versionNonce: rest.versionNonce ?? 0, isDeleted: false as false, boundElements, diff --git a/packages/element/src/schema.ts b/packages/element/src/schema.ts new file mode 100644 index 0000000000..a9584276cb --- /dev/null +++ b/packages/element/src/schema.ts @@ -0,0 +1,91 @@ +/** + * Shared schema primitives used by element types and higher-level migrations. + */ +export const SCHEMA_INITIAL_TRACK_VERSION = 1 as const; + +/** Core namespace reserved for built-in Excalidraw migrations. */ +export const SCHEMA_CORE_NAMESPACE = "core" as const; +export type SchemaNamespace = typeof SCHEMA_CORE_NAMESPACE | `host.${string}`; + +/** + * A schema track is an independent version line: + * - core tracks: "excalidraw.*" + * - host tracks: "host.." + */ +export type SchemaTrack = `excalidraw.${string}` | `host.${string}.${string}`; +export type ElementSchemaState = Readonly<{ + tracks: Readonly>; +}>; + +/** Core frame track id used by the frame background migration. */ +export const CORE_FRAME_SCHEMA_TRACK = "excalidraw.shape.frame" as const; + +/** Latest core track versions supported by this build. */ +export const CORE_SUPPORTED_TRACKS = { + [CORE_FRAME_SCHEMA_TRACK]: 2, +} as const; + +const getRequiredCoreTracksForElementType = (type: string) => { + if (type === "frame") { + return { + [CORE_FRAME_SCHEMA_TRACK]: CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK], + } as const; + } + + return {} as const; +}; + +const isValidTrackVersion = (version: unknown): version is number => + typeof version === "number" && + Number.isInteger(version) && + version >= SCHEMA_INITIAL_TRACK_VERSION; + +/** + * Ensures an element schema state is normalized and satisfies type defaults. + * Required core tracks are only ever bumped forward (never downgraded). + */ +export const ensureSchemaStateForElementType = ( + schemaState: ElementSchemaState | undefined, + type: string, +): ElementSchemaState => { + const requiredTracks = getRequiredCoreTracksForElementType(type); + const currentTracks = schemaState?.tracks || {}; + const nextTracks: Record = {}; + let didChange = !schemaState; + + for (const [track, version] of Object.entries( + currentTracks as Record, + )) { + if (isValidTrackVersion(version)) { + nextTracks[track] = version; + continue; + } + nextTracks[track] = SCHEMA_INITIAL_TRACK_VERSION; + didChange = true; + } + + for (const [track, requiredVersion] of Object.entries(requiredTracks)) { + const currentVersion = nextTracks[track]; + if ( + !isValidTrackVersion(currentVersion) || + currentVersion < requiredVersion + ) { + nextTracks[track] = requiredVersion; + didChange = true; + } + } + + if (!didChange) { + return schemaState!; + } + + return { tracks: nextTracks }; +}; + +/** + * Default schema state for newly created elements. + * New frames are created at the latest supported frame track version. + */ +export const getDefaultSchemaStateForElementType = ( + type: string, +): ElementSchemaState => ensureSchemaStateForElementType(undefined, type); diff --git a/packages/element/src/types.ts b/packages/element/src/types.ts index 29ce6063d9..4698c553b2 100644 --- a/packages/element/src/types.ts +++ b/packages/element/src/types.ts @@ -15,6 +15,8 @@ import type { ValueOf, } from "@excalidraw/common/utility-types"; +import type { ElementSchemaState } from "./schema"; + export type ChartType = "bar" | "line"; export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag"; export type FontFamilyKeys = keyof typeof FONT_FAMILY; @@ -58,8 +60,8 @@ type _ExcalidrawElementBase = Readonly<{ /** Integer that is sequentially incremented on each change. Used to reconcile elements during collaboration or when saving to server. */ version: number; - /** Schema version hint used for per-element migration on restore. */ - schemaVersion?: number; + /** Per-track schema state used by migrations during restore. */ + schemaState: ElementSchemaState; /** Random integer that is regenerated on each change. Used for deterministic reconciliation of updates during collaboration, in case the versions (see above) are identical. */