fix: keep schema hints across clipboard roundtrips

This commit is contained in:
Ryan Di
2026-03-20 19:00:54 +11:00
parent b818b3fe04
commit eda7c8d6e9
10 changed files with 211 additions and 77 deletions
+12 -13
View File
@@ -55,7 +55,6 @@ import {
} from "@excalidraw/excalidraw/data/restore"; } from "@excalidraw/excalidraw/data/restore";
import { import {
migrateSceneElements, migrateSceneElements,
resolveSchemaVersion,
SCHEMA_VERSIONS, SCHEMA_VERSIONS,
} from "@excalidraw/excalidraw/data/schema"; } from "@excalidraw/excalidraw/data/schema";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element";
@@ -244,10 +243,10 @@ const initializeScene = async (opts: {
elements: restoreElements( elements: restoreElements(
migrateSceneElements( migrateSceneElements(
localDataState?.elements, localDataState?.elements,
resolveSchemaVersion( {
localDataState?.schemaVersion, payloadSchemaVersion: localDataState?.schemaVersion,
SCHEMA_VERSIONS.initial, fallbackVersion: SCHEMA_VERSIONS.initial,
), },
), ),
null, null,
{ {
@@ -280,10 +279,10 @@ const initializeScene = async (opts: {
restoreElements( restoreElements(
migrateSceneElements( migrateSceneElements(
imported.elements, imported.elements,
resolveSchemaVersion( {
imported.schemaVersion, payloadSchemaVersion: imported.schemaVersion,
SCHEMA_VERSIONS.initial, fallbackVersion: SCHEMA_VERSIONS.initial,
), },
), ),
null, null,
{ {
@@ -576,10 +575,10 @@ const ExcalidrawWrapper = () => {
const username = importUsernameFromLocalStorage(); const username = importUsernameFromLocalStorage();
const migratedElements = migrateSceneElements( const migratedElements = migrateSceneElements(
localDataState?.elements, localDataState?.elements,
resolveSchemaVersion( {
localDataState?.schemaVersion, payloadSchemaVersion: localDataState?.schemaVersion,
SCHEMA_VERSIONS.initial, fallbackVersion: SCHEMA_VERSIONS.initial,
), },
); );
setLangCode(getPreferredLanguage()); setLangCode(getPreferredLanguage());
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
+47 -3
View File
@@ -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); 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 }); json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard( 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 <image> `src` urls out of text/html", async () => { it("should parse <image> `src` urls out of text/html", async () => {
let clipboardData; let clipboardData;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
+9 -3
View File
@@ -45,7 +45,9 @@ import { SCHEMA_VERSIONS } from "./data/schema";
type ElementsClipboard = { type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
schemaVersion: number; schemaVersion: number;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly (NonDeletedExcalidrawElement & {
schemaVersion?: number;
})[];
files: BinaryFiles | undefined; files: BinaryFiles | undefined;
}; };
@@ -187,18 +189,22 @@ export const serializeAsClipboardJSON = ({
type: EXPORT_DATA_TYPES.excalidrawClipboard, type: EXPORT_DATA_TYPES.excalidrawClipboard,
schemaVersion: SCHEMA_VERSIONS.latest, schemaVersion: SCHEMA_VERSIONS.latest,
elements: elements.map((element) => { elements: elements.map((element) => {
const elementWithSchema = {
...element,
schemaVersion: SCHEMA_VERSIONS.latest,
};
if ( if (
getContainingFrame(element, elementsMap) && getContainingFrame(element, elementsMap) &&
!framesToCopy.has(getContainingFrame(element, elementsMap)!) !framesToCopy.has(getContainingFrame(element, elementsMap)!)
) { ) {
const copiedElement = deepCopyElement(element); const copiedElement = deepCopyElement(elementWithSchema);
mutateElement(copiedElement, elementsMap, { mutateElement(copiedElement, elementsMap, {
frameId: null, frameId: null,
}); });
return copiedElement; return copiedElement;
} }
return element; return elementWithSchema;
}), }),
files: files ? _files : undefined, files: files ? _files : undefined,
}; };
+17 -18
View File
@@ -360,8 +360,8 @@ import {
migrateClipboardElements, migrateClipboardElements,
migrateLibraryElements, migrateLibraryElements,
migrateSceneElements, migrateSceneElements,
resolveSchemaVersion,
SCHEMA_VERSIONS, SCHEMA_VERSIONS,
type SchemaVersionSource,
type SchemaMigrationScope, type SchemaMigrationScope,
} from "../data/schema"; } from "../data/schema";
import { getCenter, getDistance } from "../gesture"; import { getCenter, getDistance } from "../gesture";
@@ -2335,7 +2335,7 @@ class App extends React.Component<AppProps, AppState> {
elements, elements,
position: "center", position: "center",
files: null, files: null,
schemaVersion: SCHEMA_VERSIONS.latest, schemaVersionSource: SCHEMA_VERSIONS.latest,
migrationScope: "api", migrationScope: "api",
}); });
}; };
@@ -2824,10 +2824,10 @@ class App extends React.Component<AppProps, AppState> {
const restoredElements = restoreElements( const restoredElements = restoreElements(
migrateSceneElements( migrateSceneElements(
initialData?.elements, initialData?.elements,
resolveSchemaVersion( {
initialData?.schemaVersion, payloadSchemaVersion: initialData?.schemaVersion,
initialDataSchemaFallback, fallbackVersion: initialDataSchemaFallback,
), },
), ),
null, null,
{ {
@@ -3599,9 +3599,12 @@ class App extends React.Component<AppProps, AppState> {
this.addElementsFromPasteOrLibrary({ this.addElementsFromPasteOrLibrary({
elements, elements,
files: data.files || null, files: data.files || null,
schemaVersion: data.programmaticAPI schemaVersionSource: data.programmaticAPI
? SCHEMA_VERSIONS.latest ? SCHEMA_VERSIONS.latest
: resolveSchemaVersion(data.schemaVersion, SCHEMA_VERSIONS.initial), : {
payloadSchemaVersion: data.schemaVersion,
fallbackVersion: SCHEMA_VERSIONS.initial,
},
migrationScope: data.programmaticAPI ? "api" : "clipboard", migrationScope: data.programmaticAPI ? "api" : "clipboard",
position: position:
this.editorInterface.formFactor === "desktop" ? "cursor" : "center", this.editorInterface.formFactor === "desktop" ? "cursor" : "center",
@@ -3629,7 +3632,7 @@ class App extends React.Component<AppProps, AppState> {
this.addElementsFromPasteOrLibrary({ this.addElementsFromPasteOrLibrary({
elements, elements,
files, files,
schemaVersion: SCHEMA_VERSIONS.latest, schemaVersionSource: SCHEMA_VERSIONS.latest,
migrationScope: "api", migrationScope: "api",
position: position:
this.editorInterface.formFactor === "desktop" ? "cursor" : "center", this.editorInterface.formFactor === "desktop" ? "cursor" : "center",
@@ -3748,22 +3751,18 @@ class App extends React.Component<AppProps, AppState> {
addElementsFromPasteOrLibrary = (opts: { addElementsFromPasteOrLibrary = (opts: {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
files: BinaryFiles | null; files: BinaryFiles | null;
schemaVersion?: number; schemaVersionSource: SchemaVersionSource;
migrationScope: SchemaMigrationScope; migrationScope: SchemaMigrationScope;
position: { clientX: number; clientY: number } | "cursor" | "center"; position: { clientX: number; clientY: number } | "cursor" | "center";
retainSeed?: boolean; retainSeed?: boolean;
fitToContent?: boolean; fitToContent?: boolean;
}) => { }) => {
const schemaVersion = resolveSchemaVersion(
opts.schemaVersion,
SCHEMA_VERSIONS.latest,
);
const migratedElements = const migratedElements =
opts.migrationScope === "clipboard" opts.migrationScope === "clipboard"
? migrateClipboardElements(opts.elements, schemaVersion) ? migrateClipboardElements(opts.elements, opts.schemaVersionSource)
: opts.migrationScope === "library" : opts.migrationScope === "library"
? migrateLibraryElements(opts.elements, schemaVersion) ? migrateLibraryElements(opts.elements, opts.schemaVersionSource)
: migrateAPIElements(opts.elements, schemaVersion); : migrateAPIElements(opts.elements, opts.schemaVersionSource);
const elements = restoreElements( const elements = restoreElements(
migratedElements, migratedElements,
@@ -11540,7 +11539,7 @@ class App extends React.Component<AppProps, AppState> {
elements: distributeLibraryItemsOnSquareGrid(libraryItems), elements: distributeLibraryItemsOnSquareGrid(libraryItems),
position: event, position: event,
files: null, files: null,
schemaVersion: SCHEMA_VERSIONS.latest, schemaVersionSource: SCHEMA_VERSIONS.latest,
migrationScope: "library", migrationScope: "library",
}); });
} }
@@ -149,7 +149,7 @@ export const insertToEditor = ({
app.addElementsFromPasteOrLibrary({ app.addElementsFromPasteOrLibrary({
elements: newElements, elements: newElements,
files, files,
schemaVersion: SCHEMA_VERSIONS.latest, schemaVersionSource: SCHEMA_VERSIONS.latest,
migrationScope: "api", migrationScope: "api",
position: "center", position: "center",
fitToContent: true, fitToContent: true,
+8 -7
View File
@@ -26,7 +26,6 @@ import {
} from "./restore"; } from "./restore";
import { import {
migrateSceneElements, migrateSceneElements,
resolveSchemaVersion,
SCHEMA_VERSIONS, SCHEMA_VERSIONS,
} from "./schema"; } from "./schema";
@@ -162,13 +161,12 @@ export const loadSceneOrLibraryFromBlob = async (
throw error; throw error;
} }
if (isValidExcalidrawData(data)) { if (isValidExcalidrawData(data)) {
const schemaVersion = resolveSchemaVersion(
data.schemaVersion,
SCHEMA_VERSIONS.initial,
);
const migratedElements = migrateSceneElements( const migratedElements = migrateSceneElements(
data.elements, data.elements,
schemaVersion, {
payloadSchemaVersion: data.schemaVersion,
fallbackVersion: SCHEMA_VERSIONS.initial,
},
); );
return { return {
type: MIME_TYPES.excalidraw, type: MIME_TYPES.excalidraw,
@@ -238,7 +236,10 @@ export const parseLibraryJSON = (
return restoreLibraryItems( return restoreLibraryItems(
libraryItems, libraryItems,
defaultStatus, defaultStatus,
resolveSchemaVersion(data.schemaVersion, SCHEMA_VERSIONS.initial), {
payloadSchemaVersion: data.schemaVersion,
fallbackVersion: SCHEMA_VERSIONS.initial,
},
); );
}; };
+9 -6
View File
@@ -33,7 +33,7 @@ import { t } from "../i18n";
import { loadLibraryFromBlob } from "./blob"; import { loadLibraryFromBlob } from "./blob";
import { restoreLibraryItems } from "./restore"; import { restoreLibraryItems } from "./restore";
import { resolveSchemaVersion, SCHEMA_VERSIONS } from "./schema"; import { SCHEMA_VERSIONS } from "./schema";
import type App from "../components/App"; import type App from "../components/App";
@@ -564,7 +564,10 @@ class AdapterTransaction {
restoreLibraryItems( restoreLibraryItems(
data?.libraryItems || [], data?.libraryItems || [],
"published", "published",
resolveSchemaVersion(data?.schemaVersion, SCHEMA_VERSIONS.initial), {
payloadSchemaVersion: data?.schemaVersion,
fallbackVersion: SCHEMA_VERSIONS.initial,
},
), ),
); );
} catch (error: any) { } catch (error: any) {
@@ -877,10 +880,10 @@ export const useHandleLibrary = (
restoredData = restoreLibraryItems( restoredData = restoreLibraryItems(
libraryData.libraryItems || [], libraryData.libraryItems || [],
"published", "published",
resolveSchemaVersion( {
libraryData.schemaVersion, payloadSchemaVersion: libraryData.schemaVersion,
SCHEMA_VERSIONS.initial, fallbackVersion: SCHEMA_VERSIONS.initial,
), },
); );
// we don't queue this operation because it's running inside // we don't queue this operation because it's running inside
+10 -6
View File
@@ -81,7 +81,11 @@ import {
getNormalizedGridStep, getNormalizedGridStep,
getNormalizedZoom, getNormalizedZoom,
} from "../scene"; } from "../scene";
import { migrateLibraryElements, SCHEMA_VERSIONS } from "./schema"; import {
migrateLibraryElements,
type SchemaVersionSource,
SCHEMA_VERSIONS,
} from "./schema";
import type { import type {
AppState, AppState,
@@ -959,12 +963,12 @@ export const restoreAppState = (
const restoreLibraryItem = ( const restoreLibraryItem = (
libraryItem: LibraryItem, libraryItem: LibraryItem,
schemaVersion: number, schemaVersionSource: SchemaVersionSource,
) => { ) => {
const elements = restoreElements( const elements = restoreElements(
migrateLibraryElements( migrateLibraryElements(
getNonDeletedElements(libraryItem.elements), getNonDeletedElements(libraryItem.elements),
schemaVersion, schemaVersionSource,
), ),
null, null,
); );
@@ -974,7 +978,7 @@ const restoreLibraryItem = (
export const restoreLibraryItems = ( export const restoreLibraryItems = (
libraryItems: ImportedDataState["libraryItems"] = [], libraryItems: ImportedDataState["libraryItems"] = [],
defaultStatus: LibraryItem["status"], defaultStatus: LibraryItem["status"],
schemaVersion: number = SCHEMA_VERSIONS.latest, schemaVersionSource: SchemaVersionSource = SCHEMA_VERSIONS.latest,
) => { ) => {
const restoredItems: LibraryItem[] = []; const restoredItems: LibraryItem[] = [];
for (const item of libraryItems) { for (const item of libraryItems) {
@@ -985,7 +989,7 @@ export const restoreLibraryItems = (
elements: item, elements: item,
id: randomId(), id: randomId(),
created: Date.now(), created: Date.now(),
}, schemaVersion); }, schemaVersionSource);
if (restoredItem) { if (restoredItem) {
restoredItems.push(restoredItem); restoredItems.push(restoredItem);
} }
@@ -999,7 +1003,7 @@ export const restoreLibraryItems = (
id: _item.id || randomId(), id: _item.id || randomId(),
status: _item.status || defaultStatus, status: _item.status || defaultStatus,
created: _item.created || Date.now(), created: _item.created || Date.now(),
}, schemaVersion); }, schemaVersionSource);
if (restoredItem) { if (restoredItem) {
restoredItems.push(restoredItem); restoredItems.push(restoredItem);
} }
+38
View File
@@ -160,4 +160,42 @@ describe("schema migration", () => {
DEFAULT_ELEMENT_PROPS.backgroundColor, 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,
);
});
}); });
+60 -20
View File
@@ -76,6 +76,15 @@ export const resolveSchemaVersion = (
return fallbackVersion; return fallbackVersion;
}; };
const isValidSchemaVersion = (
schemaVersion: number | undefined,
): schemaVersion is number => {
return (
Number.isInteger(schemaVersion) &&
(schemaVersion as number) >= SCHEMA_VERSIONS.initial
);
};
export const validateSchemaMigrations = ( export const validateSchemaMigrations = (
migrations: readonly SchemaMigration[], 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 = ( export const migrateSceneElements = (
elements: readonly ExcalidrawElement[] | null | undefined, elements: readonly ExcalidrawElement[] | null | undefined,
schemaVersion: number, source: SchemaVersionSource,
) => ) =>
migrateElementsBySchema(elements, { migrateElementsByScope(elements, "scene", source);
schemaVersion,
scope: "scene",
});
export const migrateLibraryElements = ( export const migrateLibraryElements = (
elements: readonly ExcalidrawElement[] | null | undefined, elements: readonly ExcalidrawElement[] | null | undefined,
schemaVersion: number, source: SchemaVersionSource,
) => ) =>
migrateElementsBySchema(elements, { migrateElementsByScope(elements, "library", source);
schemaVersion,
scope: "library",
});
export const migrateClipboardElements = ( export const migrateClipboardElements = (
elements: readonly ExcalidrawElement[] | null | undefined, elements: readonly ExcalidrawElement[] | null | undefined,
schemaVersion: number, source: SchemaVersionSource,
) => ) =>
migrateElementsBySchema(elements, { migrateElementsByScope(elements, "clipboard", source);
schemaVersion,
scope: "clipboard",
});
export const migrateAPIElements = ( export const migrateAPIElements = (
elements: readonly ExcalidrawElement[] | null | undefined, elements: readonly ExcalidrawElement[] | null | undefined,
schemaVersion: number, source: SchemaVersionSource,
) => ) =>
migrateElementsBySchema(elements, { migrateElementsByScope(elements, "api", source);
schemaVersion,
scope: "api",
});