refactor: switch elements to per-track schemaState

This commit is contained in:
Ryan Di
2026-03-25 18:04:04 +11:00
parent 205d90592a
commit 1d35cb406b
5 changed files with 113 additions and 6 deletions
+1
View File
@@ -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";
+14 -1
View File
@@ -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 = <TElement extends Mutable<ExcalidrawElement>>(
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 = <TElement extends ExcalidrawElement>(
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,
),
};
};
/**
+3 -3
View File
@@ -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<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
| "width"
@@ -72,6 +71,7 @@ export type ElementConstructorOpts = MarkOptional<
| "roughness"
| "strokeWidth"
| "roundness"
| "schemaState"
| "locked"
| "opacity"
| "customData"
@@ -146,7 +146,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
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,
+91
View File
@@ -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.<appId>.<track>"
*/
export type SchemaTrack = `excalidraw.${string}` | `host.${string}.${string}`;
export type ElementSchemaState = Readonly<{
tracks: Readonly<Record<string, number>>;
}>;
/** 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<string, number> = {};
let didChange = !schemaState;
for (const [track, version] of Object.entries(
currentTracks as Record<string, unknown>,
)) {
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);
+4 -2
View File
@@ -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. */