schema: run v1->v2 frame migration on load/paste

This commit is contained in:
Ryan Di
2026-03-20 00:57:50 +11:00
parent 7b51a6ac54
commit 5bb3046dea
6 changed files with 140 additions and 9 deletions
+2
View File
@@ -4,6 +4,7 @@ import {
parseDataTransferEvent,
serializeAsClipboardJSON,
} from "./clipboard";
import { SCHEMA_VERSIONS } from "./data/schema";
import { API } from "./tests/helpers/api";
describe("parseClipboard()", () => {
@@ -55,6 +56,7 @@ describe("parseClipboard()", () => {
),
);
expect(clipboardData.elements).toEqual([rect]);
expect(clipboardData.schemaVersion).toBe(SCHEMA_VERSIONS.latest);
});
it("should parse valid excalidraw JSON if inside text/html", async () => {
+35 -7
View File
@@ -33,6 +33,7 @@ import {
ELEMENT_SHIFT_TRANSLATE_AMOUNT,
ELEMENT_TRANSLATE_AMOUNT,
EVENT,
EXPORT_DATA_TYPES,
FRAME_STYLE,
IMAGE_MIME_TYPES,
IMAGE_RENDER_TIMEOUT,
@@ -354,6 +355,11 @@ import {
import { exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restoreAppState, restoreElements } from "../data/restore";
import {
migrateElementsBySchema,
resolveSchemaVersion,
SCHEMA_VERSIONS,
} from "../data/schema";
import { getCenter, getDistance } from "../gesture";
import { History } from "../history";
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
@@ -2805,10 +2811,21 @@ class App extends React.Component<AppProps, AppState> {
},
};
}
const restoredElements = restoreElements(initialData?.elements, null, {
repairBindings: true,
deleteInvisibleElements: true,
});
const initialDataSchemaFallback =
initialData?.type === EXPORT_DATA_TYPES.excalidraw
? SCHEMA_VERSIONS.initial
: SCHEMA_VERSIONS.latest;
const restoredElements = restoreElements(
migrateElementsBySchema(
initialData?.elements,
resolveSchemaVersion(initialData?.schemaVersion, initialDataSchemaFallback),
),
null,
{
repairBindings: true,
deleteInvisibleElements: true,
},
);
let restoredAppState = restoreAppState(initialData?.appState, null);
const activeTool = restoredAppState.activeTool;
@@ -3573,6 +3590,9 @@ class App extends React.Component<AppProps, AppState> {
this.addElementsFromPasteOrLibrary({
elements,
files: data.files || null,
schemaVersion: data.programmaticAPI
? SCHEMA_VERSIONS.latest
: resolveSchemaVersion(data.schemaVersion, SCHEMA_VERSIONS.initial),
position:
this.editorInterface.formFactor === "desktop" ? "cursor" : "center",
retainSeed: isPlainPaste,
@@ -3716,13 +3736,21 @@ class App extends React.Component<AppProps, AppState> {
addElementsFromPasteOrLibrary = (opts: {
elements: readonly ExcalidrawElement[];
files: BinaryFiles | null;
schemaVersion?: number;
position: { clientX: number; clientY: number } | "cursor" | "center";
retainSeed?: boolean;
fitToContent?: boolean;
}) => {
const elements = restoreElements(opts.elements, null, {
deleteInvisibleElements: true,
});
const elements = restoreElements(
migrateElementsBySchema(
opts.elements,
resolveSchemaVersion(opts.schemaVersion, SCHEMA_VERSIONS.latest),
),
null,
{
deleteInvisibleElements: true,
},
);
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const elementsCenterX = distance(minX, maxX) / 2;
+14 -1
View File
@@ -24,6 +24,11 @@ import {
restoreElements,
restoreLibraryItems,
} from "./restore";
import {
migrateElementsBySchema,
resolveSchemaVersion,
SCHEMA_VERSIONS,
} from "./schema";
import type { AppState, DataURL, LibraryItem } from "../types";
@@ -157,10 +162,18 @@ export const loadSceneOrLibraryFromBlob = async (
throw error;
}
if (isValidExcalidrawData(data)) {
const schemaVersion = resolveSchemaVersion(
data.schemaVersion,
SCHEMA_VERSIONS.initial,
);
const migratedElements = migrateElementsBySchema(
data.elements,
schemaVersion,
);
return {
type: MIME_TYPES.excalidraw,
data: {
elements: restoreElements(data.elements, localElements, {
elements: restoreElements(migratedElements, localElements, {
repairBindings: true,
deleteInvisibleElements: true,
}),
+50
View File
@@ -0,0 +1,50 @@
import { DEFAULT_ELEMENT_PROPS } from "@excalidraw/common";
import { API } from "../tests/helpers/api";
import {
migrateElementsBySchema,
resolveSchemaVersion,
SCHEMA_VERSIONS,
} from "./schema";
describe("schema migration", () => {
it("should migrate legacy frame backgrounds to transparent", () => {
const frame = API.createElement({
type: "frame",
backgroundColor: "#ffc9c9",
});
frame.backgroundEnabled = true;
const migrated = migrateElementsBySchema([frame], SCHEMA_VERSIONS.initial)!;
expect(migrated[0].backgroundColor).toBe(DEFAULT_ELEMENT_PROPS.backgroundColor);
expect((migrated[0] as any).backgroundEnabled).toBe(false);
});
it("should keep latest-schema frame backgrounds unchanged", () => {
const frame = API.createElement({
type: "frame",
backgroundColor: "#ffc9c9",
});
frame.backgroundEnabled = true;
const migrated = migrateElementsBySchema(
[frame],
SCHEMA_VERSIONS.latest,
)!;
expect(migrated[0].backgroundColor).toBe("#ffc9c9");
expect((migrated[0] as any).backgroundEnabled).toBe(true);
});
it("should resolve invalid schema versions using fallback", () => {
expect(resolveSchemaVersion(undefined, SCHEMA_VERSIONS.initial)).toBe(
SCHEMA_VERSIONS.initial,
);
expect(resolveSchemaVersion(0, SCHEMA_VERSIONS.latest)).toBe(
SCHEMA_VERSIONS.latest,
);
expect(resolveSchemaVersion(2, SCHEMA_VERSIONS.initial)).toBe(2);
});
});
+38
View File
@@ -1,5 +1,43 @@
import { DEFAULT_ELEMENT_PROPS } from "@excalidraw/common";
import type { ExcalidrawElement } from "@excalidraw/element/types";
export const SCHEMA_VERSIONS = {
initial: 1,
frameBackgrounds: 2,
latest: 2,
} as const;
export const resolveSchemaVersion = (
schemaVersion: number | undefined,
fallbackVersion: number,
) => {
if (
Number.isInteger(schemaVersion) &&
(schemaVersion as number) >= SCHEMA_VERSIONS.initial
) {
return schemaVersion as number;
}
return fallbackVersion;
};
export const migrateElementsBySchema = (
elements: readonly ExcalidrawElement[] | null | undefined,
schemaVersion: number,
) => {
if (!elements || schemaVersion >= SCHEMA_VERSIONS.frameBackgrounds) {
return elements;
}
return elements.map((element) => {
if (element.type !== "frame") {
return element;
}
return {
...element,
backgroundColor: DEFAULT_ELEMENT_PROPS.backgroundColor,
backgroundEnabled: false,
} as ExcalidrawElement;
});
};
@@ -240,7 +240,7 @@ exports[`exportToSvg > with elements that have a link 1`] = `
`;
exports[`exportToSvg > with exportEmbedScene 1`] = `
"<!-- svg-source:excalidraw --><metadata><!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1WW2vbMFx1MDAxOH3vrzDaa1llJ2nSvGXrLoWxwTIorOxBtT7bwrLkSnIuXHUwMDBi+e+T5MVyvLLnNYtcdTAwMDOG7350dD6c3UVcdTAwMTQhs61cdTAwMDHNI1x1MDAwNJuUcEZcdTAwMTVZo0vnX4HSTFxuXHUwMDFiSrytZaNSn1lcdTAwMThTz6+uuLRcdTAwMDWF1GY+wlx1MDAxOLdFwKFcdTAwMDJhtE17sHZcdTAwMTTt/NtGXHUwMDE4daWre/X0ZZGVTNDkKa2mn25cdTAwMTdcdTAwMWa++1KftLE543jc2Vs3fTTt7DWjprC+XHUwMDE4485XXHUwMDAwy1x1MDAwYjNwXHUwMDEykXOHNXi0UbKEt5JL5YC8wv5cdKNcdTAwMWZJWuZKNoKGnHhCyGNcdTAwMTZyMsb50mx5y1x1MDAwMkmLRlx1MDAwMVx1MDAxYUy4P0BcdTAwMWP4uzotLcuhyo7MXHUwMDBiXHUwMDAxWlx1MDAxZtXImqTMbFx1MDAwN6dy+Oo76tn9XHUwMDExUClSwZ2jVzSc91x1MDAxYlx1MDAwYvq78VHAclx1MDAwZo5oRHrH11x1MDAwMNRPXHUwMDFix9eT6VxynnWRoIM4wUPvZym8JuJ4NsN4nEyvw1x1MDAxOH1r1WB824xwXHKBaofsXVDKXHUwMDExuqampC1cbmxwJsphnlVf+Uzvg5opI5VcdTAwMTRcdTAwMTR5//7yrMV/XYvx6WpcdTAwMTE4Z7WGl6HFXHUwMDE43O//1mJyulo0sDG9i5PCLNlPXHUwMDE36Z3Bed+TinHH8yS0cKW2hVQsZ4Lw6LjXwf3t72nOWnCWO+JcdTAwMTCHrFx1MDAxN7LcXHUwMDE5Zv9TdGEj61x1MDAxME0tKsJcdTAwMDSoP6/U8lx1MDAwMFx1MDAxZju5v05cdTAwMDJm0lx1MDAxOPlcdTAwMTV0e0TPyHlcdF/IXHUwMDEyjs5L2C1hXHUwMDAwfkpLaN9eKIjU9dJYYm24XUm0YrB+84zoM/+4L6lfYSd6cLe021/sf1x1MDAwMVSoRdMifQ==<!-- payload-end --></metadata><defs><style class="style-fonts">
"<!-- svg-source:excalidraw --><metadata><!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1WW2/TMFx1MDAxNH7fr4jM68SctF1L31xu4zJcdIFEXHUwMDExk0A8ePFJYsWxM9vphar/fbZD4zRMPLPSVIp0vnP15+8o3V1EXHUwMDExMttcdTAwMWHQPEKwSVx0Z1SRNbp0+FxulGZSWFfibZ1cdTAwMTZQkW9DVDYq9fmFMfX86opLW6aQ2sxHXHUwMDE447ZcdTAwMTRwqEBcdTAwMThtw35YO4p2/m09jLrU1Z16+LzISiZo8pBW0483i/fffapcdTAwMGba2JhxPO7sres+mnb2mlFTWCzGuMNcbmB5YVx1MDAwNiBcdTAwMTE5d7NcdTAwMDZEXHUwMDFiJUt4I7lUbpBcdTAwMTfYP6H1PUnLXFzJRtBcdTAwMTBcdTAwMTNPXGK5z0JMxjhfmi1vWSBp0ShAg1x1MDAwZXeHXHUwMDExXHUwMDA3eJenpeU+ZNmWeSFA66NcdTAwMWNZk5SZ7eBUbr76lnp2f4apXHUwMDE0qeDW0StcdTAwMWHO+4VcdTAwMDX9XfjIYblcdTAwMDdHNFwiveNrXHUwMDAw6ruN4+vJ9Fx1MDAxNZ51nqCOOMFD9JNcdTAwMTReXHUwMDEzcTybYTxOptehjb6xajC+bEa4hkC1m+xtUMrRdE1NSZtcdTAwMTTY4EyUwzirvvKJ2lx1MDAwN41TRiopKPL4/vKsxX9di/Hpalx1MDAxMThntYbnocVcdTAwMTjc7//WYnK6WjSwMb2Lk8Is2S/n6Z3Boe9IxbjjeVx1MDAxMkq4VFtCKpYzQXh0XFzrXHUwMDAwf/17mLNcdTAwMTac5Y44xCHruSx3htn/XHUwMDE0ndvIOnhTO1x1MDAxNWFcdTAwMDLUn1dqeYBcdTAwMGad3F8mYWbSXHUwMDE4+Vx1MDAwNXR7RM/IeVx0n8lcdTAwMTKOzkvYLWFcdTAwMTj8lJbQvr1QXHUwMDEwqeulscRad7uSaMVg/fpcdNFn/nFfUr/CTvTgbmm3v9g/XHUwMDAyTFx1MDAxNkxwIn0=<!-- payload-end --></metadata><defs><style class="style-fonts">
@font-face { font-family: Excalifont; src: url(data:font/woff2;base64,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); }
@font-face { font-family: Excalifont; src: url(data:font/woff2;base64,d09GMgABAAAAAAHcAA0AAAAAA9gAAAGMAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGx4cNAZgAAQRCAoAKgsEAAE2AiQDBAQgBYMYByAbHQPIrgp4MjSeIQLqpl4jnPFQwveqm205gmrZevZ2/8kKGaJ6ictBuCwMMnsKo8hC4RwaLBrJ7dXaFrFk0Re1e/Dk3t9wCUVrYAiZkgiZ5rGDVFdY04wBF4APOCzXHr/ljrNIDqLAY/slkQf0SyjxLNBaAq8Z1AJqYW5j0GjbckRLQhoJCgwYoyfY3q33QS5DrSAwYB4hFruNve4WoN2On4P29GEFWsC+nVM/UvSlVwIcsWycI/5gbqfUhE/9Q2P7UppTOAVeIR4TYIocELVKIEACZJgXCJ5GXl0o+esbFP3g+763DcDPx6/60MrnBaBPAsGPUDRC/8+FGIqGUSBA+NIJOwTWe13HRCIrgEvfRhj1RjLiE42eG7I5DIpVNuqkNNhxwaItTI2srRz4dfHGjhZoO0O8nb2pilFYQKhenFaycLUxsYcoAQRyoRBEnPvcgfysPq+npClt8UxLyvHWjaudqbGJA+TCckNECBGBGFforuTs0M4CUMbCAvp+P4in4o864XECREBtFQAAAAA=); }
@font-face { font-family: Nunito; src: url(data:font/woff2;base64,d09GMgABAAAAAAIsAA4AAAAABLQAAAHYAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGx4cLgZgP1NUQVREAAQRCAoAKgsEAAE2AiQDBAQgBYQkByAb5wPIrgp4Mt6IIcJZFNCfWmZY6KzqazTt6AiVHCFawzJ7V0CQBKoSQQEpFKrCVxiSFbqqqsaTevKs/q7s6uBYmujMyA9wxb6a7XnEOcNnDLgG4nW8PoPzHzYpS2uqp51pL3eB/xZoxIllCQc80B9o4j/4xMbxQB+j+SC3hsm6JmI8RMaHj+aJApW6ZbkXlg4vXSE5FECg0og6LzxP9pOarug4tF1RLpbHeZqLX0pIt2mfy3pNG6eyGaRIjrnrr/gv2c//yGdjpJ/7DuJLin5eIZRLaObBMM/NpYpuXJ8z3SE088mEFANcCARESfwChCioAwESsVxBgeyxp6+vZ3Xzv8uz7Ae8tRk+p6RUTPOR7BmlEgh+STsAimNuKibilyluhBNe5/wCACSBKrcykVfgyb+RYcK/TGq9yMyCt3bOskSnR1FqTLMSVBsMGLQVltDKKw2IYA+wcGxHJCINY9mDOCaN4IZEo1ChY4xNg8DaB0RxDqnbMNjXJJRzF9iInqahtm67M8dOHFvXaYbn+wrGxKG3YZI+2V4CW58ovdfV1tFHXFJJiPH7T1FAJxEgYo5BKkA5iDIVQluOqZYWhQapGF6TAFhaFAAoTBIZsCFHi/0oV3gpVKwbAA==); }