From eda7c8d6e949dba9b28e3059b6f8d714c0eea534 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Fri, 20 Mar 2026 19:00:54 +1100 Subject: [PATCH] fix: keep schema hints across clipboard roundtrips --- excalidraw-app/App.tsx | 25 +++--- packages/excalidraw/clipboard.test.ts | 50 +++++++++++- packages/excalidraw/clipboard.ts | 12 ++- packages/excalidraw/components/App.tsx | 35 ++++---- .../excalidraw/components/TTDDialog/common.ts | 2 +- packages/excalidraw/data/blob.ts | 15 ++-- packages/excalidraw/data/library.ts | 15 ++-- packages/excalidraw/data/restore.ts | 16 ++-- packages/excalidraw/data/schema.test.ts | 38 +++++++++ packages/excalidraw/data/schema.ts | 80 ++++++++++++++----- 10 files changed, 211 insertions(+), 77 deletions(-) diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 393b163c11..5d86cfc64a 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -55,7 +55,6 @@ import { } from "@excalidraw/excalidraw/data/restore"; import { migrateSceneElements, - resolveSchemaVersion, SCHEMA_VERSIONS, } from "@excalidraw/excalidraw/data/schema"; import { newElementWith } from "@excalidraw/element"; @@ -244,10 +243,10 @@ const initializeScene = async (opts: { elements: restoreElements( migrateSceneElements( localDataState?.elements, - resolveSchemaVersion( - localDataState?.schemaVersion, - SCHEMA_VERSIONS.initial, - ), + { + payloadSchemaVersion: localDataState?.schemaVersion, + fallbackVersion: SCHEMA_VERSIONS.initial, + }, ), null, { @@ -280,10 +279,10 @@ const initializeScene = async (opts: { restoreElements( migrateSceneElements( imported.elements, - resolveSchemaVersion( - imported.schemaVersion, - SCHEMA_VERSIONS.initial, - ), + { + payloadSchemaVersion: imported.schemaVersion, + fallbackVersion: SCHEMA_VERSIONS.initial, + }, ), null, { @@ -576,10 +575,10 @@ const ExcalidrawWrapper = () => { const username = importUsernameFromLocalStorage(); const migratedElements = migrateSceneElements( localDataState?.elements, - resolveSchemaVersion( - localDataState?.schemaVersion, - SCHEMA_VERSIONS.initial, - ), + { + payloadSchemaVersion: localDataState?.schemaVersion, + fallbackVersion: SCHEMA_VERSIONS.initial, + }, ); setLangCode(getPreferredLanguage()); excalidrawAPI.updateScene({ diff --git a/packages/excalidraw/clipboard.test.ts b/packages/excalidraw/clipboard.test.ts index 5a132c1a79..00faea4adb 100644 --- a/packages/excalidraw/clipboard.test.ts +++ b/packages/excalidraw/clipboard.test.ts @@ -55,7 +55,13 @@ describe("parseClipboard()", () => { }), ), ); - expect(clipboardData.elements).toEqual([rect]); + expect(clipboardData.elements).toEqual([ + expect.objectContaining({ + id: rect.id, + type: rect.type, + schemaVersion: SCHEMA_VERSIONS.latest, + }), + ]); expect(clipboardData.schemaVersion).toBe(SCHEMA_VERSIONS.latest); }); @@ -75,7 +81,13 @@ describe("parseClipboard()", () => { }), ), ); - expect(clipboardData.elements).toEqual([rect]); + expect(clipboardData.elements).toEqual([ + expect.objectContaining({ + id: rect.id, + type: rect.type, + schemaVersion: SCHEMA_VERSIONS.latest, + }), + ]); // ------------------------------------------------------------------------- json = serializeAsClipboardJSON({ elements: [rect], files: null }); clipboardData = await parseClipboard( @@ -87,10 +99,42 @@ describe("parseClipboard()", () => { }), ), ); - expect(clipboardData.elements).toEqual([rect]); + expect(clipboardData.elements).toEqual([ + expect.objectContaining({ + id: rect.id, + type: rect.type, + schemaVersion: SCHEMA_VERSIONS.latest, + }), + ]); // ------------------------------------------------------------------------- }); + it("should preserve per-element schema when payload schema is missing", async () => { + const rect = API.createElement({ type: "rectangle" }); + const clipboardPayload = JSON.parse( + serializeAsClipboardJSON({ elements: [rect], files: null }), + ); + delete clipboardPayload.schemaVersion; + + const clipboardData = await parseClipboard( + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/plain": JSON.stringify(clipboardPayload), + }, + }), + ), + ); + + expect(clipboardData.schemaVersion).toBeUndefined(); + expect(clipboardData.elements?.[0]).toEqual( + expect.objectContaining({ + id: rect.id, + schemaVersion: SCHEMA_VERSIONS.latest, + }), + ); + }); + it("should parse `src` urls out of text/html", async () => { let clipboardData; // ------------------------------------------------------------------------- diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index e7011a9b1d..be5db302ef 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -45,7 +45,9 @@ import { SCHEMA_VERSIONS } from "./data/schema"; type ElementsClipboard = { type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; schemaVersion: number; - elements: readonly NonDeletedExcalidrawElement[]; + elements: readonly (NonDeletedExcalidrawElement & { + schemaVersion?: number; + })[]; files: BinaryFiles | undefined; }; @@ -187,18 +189,22 @@ export const serializeAsClipboardJSON = ({ type: EXPORT_DATA_TYPES.excalidrawClipboard, schemaVersion: SCHEMA_VERSIONS.latest, elements: elements.map((element) => { + const elementWithSchema = { + ...element, + schemaVersion: SCHEMA_VERSIONS.latest, + }; if ( getContainingFrame(element, elementsMap) && !framesToCopy.has(getContainingFrame(element, elementsMap)!) ) { - const copiedElement = deepCopyElement(element); + const copiedElement = deepCopyElement(elementWithSchema); mutateElement(copiedElement, elementsMap, { frameId: null, }); return copiedElement; } - return element; + return elementWithSchema; }), files: files ? _files : undefined, }; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 4309b23610..42ce0b2ae7 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -360,8 +360,8 @@ import { migrateClipboardElements, migrateLibraryElements, migrateSceneElements, - resolveSchemaVersion, SCHEMA_VERSIONS, + type SchemaVersionSource, type SchemaMigrationScope, } from "../data/schema"; import { getCenter, getDistance } from "../gesture"; @@ -2335,7 +2335,7 @@ class App extends React.Component { elements, position: "center", files: null, - schemaVersion: SCHEMA_VERSIONS.latest, + schemaVersionSource: SCHEMA_VERSIONS.latest, migrationScope: "api", }); }; @@ -2824,10 +2824,10 @@ class App extends React.Component { const restoredElements = restoreElements( migrateSceneElements( initialData?.elements, - resolveSchemaVersion( - initialData?.schemaVersion, - initialDataSchemaFallback, - ), + { + payloadSchemaVersion: initialData?.schemaVersion, + fallbackVersion: initialDataSchemaFallback, + }, ), null, { @@ -3599,9 +3599,12 @@ class App extends React.Component { this.addElementsFromPasteOrLibrary({ elements, files: data.files || null, - schemaVersion: data.programmaticAPI + schemaVersionSource: data.programmaticAPI ? SCHEMA_VERSIONS.latest - : resolveSchemaVersion(data.schemaVersion, SCHEMA_VERSIONS.initial), + : { + payloadSchemaVersion: data.schemaVersion, + fallbackVersion: SCHEMA_VERSIONS.initial, + }, migrationScope: data.programmaticAPI ? "api" : "clipboard", position: this.editorInterface.formFactor === "desktop" ? "cursor" : "center", @@ -3629,7 +3632,7 @@ class App extends React.Component { this.addElementsFromPasteOrLibrary({ elements, files, - schemaVersion: SCHEMA_VERSIONS.latest, + schemaVersionSource: SCHEMA_VERSIONS.latest, migrationScope: "api", position: this.editorInterface.formFactor === "desktop" ? "cursor" : "center", @@ -3748,22 +3751,18 @@ class App extends React.Component { addElementsFromPasteOrLibrary = (opts: { elements: readonly ExcalidrawElement[]; files: BinaryFiles | null; - schemaVersion?: number; + schemaVersionSource: SchemaVersionSource; migrationScope: SchemaMigrationScope; position: { clientX: number; clientY: number } | "cursor" | "center"; retainSeed?: boolean; fitToContent?: boolean; }) => { - const schemaVersion = resolveSchemaVersion( - opts.schemaVersion, - SCHEMA_VERSIONS.latest, - ); const migratedElements = opts.migrationScope === "clipboard" - ? migrateClipboardElements(opts.elements, schemaVersion) + ? migrateClipboardElements(opts.elements, opts.schemaVersionSource) : opts.migrationScope === "library" - ? migrateLibraryElements(opts.elements, schemaVersion) - : migrateAPIElements(opts.elements, schemaVersion); + ? migrateLibraryElements(opts.elements, opts.schemaVersionSource) + : migrateAPIElements(opts.elements, opts.schemaVersionSource); const elements = restoreElements( migratedElements, @@ -11540,7 +11539,7 @@ class App extends React.Component { elements: distributeLibraryItemsOnSquareGrid(libraryItems), position: event, files: null, - schemaVersion: SCHEMA_VERSIONS.latest, + schemaVersionSource: SCHEMA_VERSIONS.latest, migrationScope: "library", }); } diff --git a/packages/excalidraw/components/TTDDialog/common.ts b/packages/excalidraw/components/TTDDialog/common.ts index 63b0c0d5f4..2db863120b 100644 --- a/packages/excalidraw/components/TTDDialog/common.ts +++ b/packages/excalidraw/components/TTDDialog/common.ts @@ -149,7 +149,7 @@ export const insertToEditor = ({ app.addElementsFromPasteOrLibrary({ elements: newElements, files, - schemaVersion: SCHEMA_VERSIONS.latest, + schemaVersionSource: SCHEMA_VERSIONS.latest, migrationScope: "api", position: "center", fitToContent: true, diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index 7c50dabbd6..31a62d63c4 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -26,7 +26,6 @@ import { } from "./restore"; import { migrateSceneElements, - resolveSchemaVersion, SCHEMA_VERSIONS, } from "./schema"; @@ -162,13 +161,12 @@ export const loadSceneOrLibraryFromBlob = async ( throw error; } if (isValidExcalidrawData(data)) { - const schemaVersion = resolveSchemaVersion( - data.schemaVersion, - SCHEMA_VERSIONS.initial, - ); const migratedElements = migrateSceneElements( data.elements, - schemaVersion, + { + payloadSchemaVersion: data.schemaVersion, + fallbackVersion: SCHEMA_VERSIONS.initial, + }, ); return { type: MIME_TYPES.excalidraw, @@ -238,7 +236,10 @@ export const parseLibraryJSON = ( return restoreLibraryItems( libraryItems, defaultStatus, - resolveSchemaVersion(data.schemaVersion, SCHEMA_VERSIONS.initial), + { + payloadSchemaVersion: data.schemaVersion, + fallbackVersion: SCHEMA_VERSIONS.initial, + }, ); }; diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts index 0d0ca23e46..99fccceb7d 100644 --- a/packages/excalidraw/data/library.ts +++ b/packages/excalidraw/data/library.ts @@ -33,7 +33,7 @@ import { t } from "../i18n"; import { loadLibraryFromBlob } from "./blob"; import { restoreLibraryItems } from "./restore"; -import { resolveSchemaVersion, SCHEMA_VERSIONS } from "./schema"; +import { SCHEMA_VERSIONS } from "./schema"; import type App from "../components/App"; @@ -564,7 +564,10 @@ class AdapterTransaction { restoreLibraryItems( data?.libraryItems || [], "published", - resolveSchemaVersion(data?.schemaVersion, SCHEMA_VERSIONS.initial), + { + payloadSchemaVersion: data?.schemaVersion, + fallbackVersion: SCHEMA_VERSIONS.initial, + }, ), ); } catch (error: any) { @@ -877,10 +880,10 @@ export const useHandleLibrary = ( restoredData = restoreLibraryItems( libraryData.libraryItems || [], "published", - resolveSchemaVersion( - libraryData.schemaVersion, - SCHEMA_VERSIONS.initial, - ), + { + payloadSchemaVersion: libraryData.schemaVersion, + fallbackVersion: SCHEMA_VERSIONS.initial, + }, ); // we don't queue this operation because it's running inside diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 365620ca69..abd8bd218e 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -81,7 +81,11 @@ import { getNormalizedGridStep, getNormalizedZoom, } from "../scene"; -import { migrateLibraryElements, SCHEMA_VERSIONS } from "./schema"; +import { + migrateLibraryElements, + type SchemaVersionSource, + SCHEMA_VERSIONS, +} from "./schema"; import type { AppState, @@ -959,12 +963,12 @@ export const restoreAppState = ( const restoreLibraryItem = ( libraryItem: LibraryItem, - schemaVersion: number, + schemaVersionSource: SchemaVersionSource, ) => { const elements = restoreElements( migrateLibraryElements( getNonDeletedElements(libraryItem.elements), - schemaVersion, + schemaVersionSource, ), null, ); @@ -974,7 +978,7 @@ const restoreLibraryItem = ( export const restoreLibraryItems = ( libraryItems: ImportedDataState["libraryItems"] = [], defaultStatus: LibraryItem["status"], - schemaVersion: number = SCHEMA_VERSIONS.latest, + schemaVersionSource: SchemaVersionSource = SCHEMA_VERSIONS.latest, ) => { const restoredItems: LibraryItem[] = []; for (const item of libraryItems) { @@ -985,7 +989,7 @@ export const restoreLibraryItems = ( elements: item, id: randomId(), created: Date.now(), - }, schemaVersion); + }, schemaVersionSource); if (restoredItem) { restoredItems.push(restoredItem); } @@ -999,7 +1003,7 @@ export const restoreLibraryItems = ( id: _item.id || randomId(), status: _item.status || defaultStatus, created: _item.created || Date.now(), - }, schemaVersion); + }, schemaVersionSource); if (restoredItem) { restoredItems.push(restoredItem); } diff --git a/packages/excalidraw/data/schema.test.ts b/packages/excalidraw/data/schema.test.ts index f3e56d51b5..dd3337ea22 100644 --- a/packages/excalidraw/data/schema.test.ts +++ b/packages/excalidraw/data/schema.test.ts @@ -160,4 +160,42 @@ describe("schema migration", () => { DEFAULT_ELEMENT_PROPS.backgroundColor, ); }); + + it("should use per-element schema when payload schema is missing", () => { + const frame = API.createElement({ + type: "frame", + backgroundColor: "#ff0000", + }); + const frameFromModernSource = { + ...frame, + schemaVersion: SCHEMA_VERSIONS.latest, + } as typeof frame & { schemaVersion: number }; + + const migrated = migrateClipboardElements([frameFromModernSource], { + payloadSchemaVersion: undefined, + fallbackVersion: SCHEMA_VERSIONS.initial, + })!; + + expect(migrated[0].backgroundColor).toBe("#ff0000"); + }); + + it("should prefer payload schema over per-element schema", () => { + const frame = API.createElement({ + type: "frame", + backgroundColor: "#ff0000", + }); + const frameFromModernSource = { + ...frame, + schemaVersion: SCHEMA_VERSIONS.latest, + } as typeof frame & { schemaVersion: number }; + + const migrated = migrateClipboardElements([frameFromModernSource], { + payloadSchemaVersion: SCHEMA_VERSIONS.initial, + fallbackVersion: SCHEMA_VERSIONS.latest, + })!; + + expect(migrated[0].backgroundColor).toBe( + DEFAULT_ELEMENT_PROPS.backgroundColor, + ); + }); }); diff --git a/packages/excalidraw/data/schema.ts b/packages/excalidraw/data/schema.ts index f53f7be51f..222c32800d 100644 --- a/packages/excalidraw/data/schema.ts +++ b/packages/excalidraw/data/schema.ts @@ -76,6 +76,15 @@ export const resolveSchemaVersion = ( return fallbackVersion; }; +const isValidSchemaVersion = ( + schemaVersion: number | undefined, +): schemaVersion is number => { + return ( + Number.isInteger(schemaVersion) && + (schemaVersion as number) >= SCHEMA_VERSIONS.initial + ); +}; + export const validateSchemaMigrations = ( migrations: readonly SchemaMigration[], ) => { @@ -171,38 +180,69 @@ export const migrateElementsBySchema = ( ); }; +export type SchemaVersionSource = + | number + | { + payloadSchemaVersion?: number; + fallbackVersion: number; + }; + +const migrateElementsByScope = ( + elements: readonly ExcalidrawElement[] | null | undefined, + scope: SchemaMigrationScope, + source: SchemaVersionSource, +) => { + if (!elements) { + return elements; + } + + if (typeof source === "number") { + return migrateElementsBySchema(elements, { + schemaVersion: source, + scope, + }); + } + + if (isValidSchemaVersion(source.payloadSchemaVersion)) { + return migrateElementsBySchema(elements, { + schemaVersion: source.payloadSchemaVersion, + scope, + }); + } + + return elements.map((element) => { + const schemaVersion = resolveSchemaVersion( + (element as ExcalidrawElement & { schemaVersion?: number }).schemaVersion, + source.fallbackVersion, + ); + const migratedElement = migrateElementsBySchema([element], { + schemaVersion, + scope, + }); + return migratedElement?.[0] || element; + }); +}; + export const migrateSceneElements = ( elements: readonly ExcalidrawElement[] | null | undefined, - schemaVersion: number, + source: SchemaVersionSource, ) => - migrateElementsBySchema(elements, { - schemaVersion, - scope: "scene", - }); + migrateElementsByScope(elements, "scene", source); export const migrateLibraryElements = ( elements: readonly ExcalidrawElement[] | null | undefined, - schemaVersion: number, + source: SchemaVersionSource, ) => - migrateElementsBySchema(elements, { - schemaVersion, - scope: "library", - }); + migrateElementsByScope(elements, "library", source); export const migrateClipboardElements = ( elements: readonly ExcalidrawElement[] | null | undefined, - schemaVersion: number, + source: SchemaVersionSource, ) => - migrateElementsBySchema(elements, { - schemaVersion, - scope: "clipboard", - }); + migrateElementsByScope(elements, "clipboard", source); export const migrateAPIElements = ( elements: readonly ExcalidrawElement[] | null | undefined, - schemaVersion: number, + source: SchemaVersionSource, ) => - migrateElementsBySchema(elements, { - schemaVersion, - scope: "api", - }); + migrateElementsByScope(elements, "api", source);