From 5bb3046dea04beeef44e6820215834d31c47f66b Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Fri, 20 Mar 2026 00:57:50 +1100 Subject: [PATCH] schema: run v1->v2 frame migration on load/paste --- packages/excalidraw/clipboard.test.ts | 2 + packages/excalidraw/components/App.tsx | 42 +++++++++++++--- packages/excalidraw/data/blob.ts | 15 +++++- packages/excalidraw/data/schema.test.ts | 50 +++++++++++++++++++ packages/excalidraw/data/schema.ts | 38 ++++++++++++++ .../scene/__snapshots__/export.test.ts.snap | 2 +- 6 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 packages/excalidraw/data/schema.test.ts diff --git a/packages/excalidraw/clipboard.test.ts b/packages/excalidraw/clipboard.test.ts index 2115c3eff2..5a132c1a79 100644 --- a/packages/excalidraw/clipboard.test.ts +++ b/packages/excalidraw/clipboard.test.ts @@ -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 () => { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index d0ad28409f..34d5a108c5 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -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 { }, }; } - 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 { 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 { 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; diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index c520c33b1c..086bfc4494 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -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, }), diff --git a/packages/excalidraw/data/schema.test.ts b/packages/excalidraw/data/schema.test.ts new file mode 100644 index 0000000000..1b82247204 --- /dev/null +++ b/packages/excalidraw/data/schema.test.ts @@ -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); + }); +}); + diff --git a/packages/excalidraw/data/schema.ts b/packages/excalidraw/data/schema.ts index 696b64fbf7..57ae127f7c 100644 --- a/packages/excalidraw/data/schema.ts +++ b/packages/excalidraw/data/schema.ts @@ -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; + }); +}; diff --git a/packages/excalidraw/tests/scene/__snapshots__/export.test.ts.snap b/packages/excalidraw/tests/scene/__snapshots__/export.test.ts.snap index 180a14e6fc..dca004aea8 100644 --- a/packages/excalidraw/tests/scene/__snapshots__/export.test.ts.snap +++ b/packages/excalidraw/tests/scene/__snapshots__/export.test.ts.snap @@ -240,7 +240,7 @@ exports[`exportToSvg > with elements that have a link 1`] = ` `; exports[`exportToSvg > with exportEmbedScene 1`] = ` -"eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1WW2vbMFx1MDAxOH3vrzDaa1llJ2nSvGXrLoWxwTIorOxBtT7bwrLkSnIuXHUwMDBi+e+T5MVyvLLnNYtcdTAwMDOG7350dD6c3UVcdTAwMTQhs61cdTAwMDHNI1x1MDAwNJuUcEZcdTAwMTVZo0vnX4HSTFxuXHUwMDFiSrytZaNSn1lcdTAwMThTz6+uuLRcdTAwMDWF1GY+wlx1MDAxOLdFwKFcdTAwMDJhtE17sHZcdTAwMTTt/NtGXHUwMDE4daWre/X0ZZGVTNDkKa2mn25cdTAwMTdcdTAwMWa++1KftLE543jc2Vs3fTTt7DWjprC+XHUwMDE4485XXHUwMDAwy1x1MDAwYjNwXHUwMDEykXOHNXi0UbKEt5JL5YC8wv5cdKNcdTAwMWZJWuZKNoKGnHhCyGNcdTAwMTZyMsb50mx5y1x1MDAwMkmLRlx1MDAwMVx1MDAxYUy4P0BcdTAwMWP4uzotLcuhyo7MXHUwMDBiXHUwMDAxWlx1MDAxZtXImqTMbFx1MDAwN6dy+Oo76tn9XHUwMDExUClSwZ2jVzSc91x1MDAxYlx1MDAwYvq78VHAclx1MDAwZo5oRHrH11x1MDAwMNRPXHUwMDFix9eT6VxynnWRoIM4wUPvZym8JuJ4NsN4nEyvw1x1MDAxOH1r1WB824xwXHKBaofsXVDKXHUwMDExuqampC1cbmxwJsphnlVf+Uzvg5opI5VcdTAwMTRcdTAwMTR5//7yrMV/XYvx6WpcdTAwMTE4Z7WGl6HFXHUwMDE43O//1mJyulo0sDG9i5PCLNlPXHUwMDE36Z3Bed+TinHH8yS0cKW2hVQsZ4Lw6LjXwf3t72nOWnCWO+JcdTAwMTCHrFx1MDAxN7LcXHUwMDE5Zv9TdGEj61x1MDAxME0tKsJcdTAwMDSoP6/U8lx1MDAwMFx1MDAxZju5v05cdTAwMDJm0lx1MDAxOPlcdTAwMTV0e0TPyHlcdF/IXHUwMDEyjs5L2C1hXHUwMDAwfkpLaN9eKIjU9dJYYm24XUm0YrB+84zoM/+4L6lfYSd6cLe021/sf1x1MDAwMVSoRdMifQ==