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";
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({
+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);
});
@@ -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 <image> `src` urls out of text/html", async () => {
let clipboardData;
// -------------------------------------------------------------------------
+9 -3
View File
@@ -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,
};
+17 -18
View File
@@ -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<AppProps, AppState> {
elements,
position: "center",
files: null,
schemaVersion: SCHEMA_VERSIONS.latest,
schemaVersionSource: SCHEMA_VERSIONS.latest,
migrationScope: "api",
});
};
@@ -2824,10 +2824,10 @@ class App extends React.Component<AppProps, AppState> {
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<AppProps, AppState> {
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<AppProps, AppState> {
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<AppProps, AppState> {
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<AppProps, AppState> {
elements: distributeLibraryItemsOnSquareGrid(libraryItems),
position: event,
files: null,
schemaVersion: SCHEMA_VERSIONS.latest,
schemaVersionSource: SCHEMA_VERSIONS.latest,
migrationScope: "library",
});
}
@@ -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,
+8 -7
View File
@@ -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,
},
);
};
+9 -6
View File
@@ -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
+10 -6
View File
@@ -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);
}
+38
View File
@@ -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,
);
});
});
+60 -20
View File
@@ -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);