Compare commits

...

34 Commits

Author SHA1 Message Date
Ryan Di 692802f8a6 fix: handle missing schemaState in migration 2026-03-25 20:10:43 +11:00
Ryan Di 91981159d2 feat: wire schema migration plugins end-to-end 2026-03-25 19:19:13 +11:00
Ryan Di b871d4ceb3 test: update coverage and snapshots for per-track schemaState 2026-03-25 18:04:22 +11:00
Ryan Di 16cf593978 refactor: replace global schema version with per-track migration 2026-03-25 18:04:13 +11:00
Ryan Di 1d35cb406b refactor: switch elements to per-track schemaState 2026-03-25 18:04:04 +11:00
Ryan Di 205d90592a test: refresh snapshots for schema changes 2026-03-23 17:30:23 +11:00
Ryan Di 08af0964f2 test: cover element schema migration flow 2026-03-23 17:30:16 +11:00
Ryan Di 0e4ae079ac refactor: remove payload-level schema version 2026-03-23 17:30:11 +11:00
Ryan Di 11ba6784aa refactor: migrate elements by schema in restore 2026-03-23 17:30:05 +11:00
Ryan Di 16838ea792 test: cover collab field preservation 2026-03-20 19:55:40 +11:00
Ryan Di 8168d46f87 chore: harden local schema hints and tests 2026-03-20 19:33:01 +11:00
Ryan Di eda7c8d6e9 fix: keep schema hints across clipboard roundtrips 2026-03-20 19:00:54 +11:00
Ryan Di b818b3fe04 chore: keep schema migration rules in code 2026-03-20 18:17:00 +11:00
Ryan Di 67967c05fa refactor: add schema migration helper APIs 2026-03-20 18:15:03 +11:00
Ryan Di 6c9914049e docs: jot down schema migration rules 2026-03-20 18:03:43 +11:00
Ryan Di 1e113e4a3b fix: wire schema scopes at import boundaries 2026-03-20 18:03:36 +11:00
Ryan Di ec458d92e3 chore: set up scoped schema migrations 2026-03-20 18:03:22 +11:00
Ryan Di b3b9b26979 fix: smooth out schema-version edges 2026-03-20 14:03:22 +11:00
Ryan Di 21d26b1afe schema: add legacy frame edge test and refresh snapshots 2026-03-20 01:11:05 +11:00
Ryan Di 6d3eb16531 schema: remove frame bg guard flag from runtime 2026-03-20 01:06:00 +11:00
Ryan Di 5bb3046dea schema: run v1->v2 frame migration on load/paste 2026-03-20 00:57:50 +11:00
Ryan Di 7b51a6ac54 schema: add payload schemaVersion plumbing 2026-03-20 00:26:08 +11:00
Ryan Di d1cbab855d pass full bg payload in actionProperties 2026-03-18 20:08:56 +11:00
Ryan Di 40158fa0a0 update test snapshots 2026-03-18 20:02:58 +11:00
Ryan Di d1144b4779 frame bg: normalize false flag to transparent 2026-03-18 19:59:17 +11:00
Ryan Di c4925dc5b9 frame bg: treat non-boolean legacy flag as missing 2026-03-18 19:55:52 +11:00
Ryan Di 48bc930c09 frame bg: drop legacy color if flag missing 2026-03-18 19:53:24 +11:00
Ryan Di 858d1d4cce frame bg: make legacy scenes opt-in 2026-03-18 19:49:46 +11:00
Ryan Di 3f405ab833 fix: align svg frame bg with canvas 2026-03-18 19:41:14 +11:00
Ryan Di 8d003a1d21 perf: tidy svg frame bg render pass 2026-03-18 19:38:53 +11:00
Ryan Di 2b3871856e perf: tidy frame bg render pass 2026-03-18 19:36:41 +11:00
Ryan Di a7281de157 fix: skip unsupported style updates 2026-03-18 19:22:48 +11:00
dwelle f944f1f7aa feat(editor): support frame backgrounds 2026-02-22 23:08:46 +01:00
David Luzar 4c3d037f9c feat(editor): allow clicking on links and embeds with laser tool (#10797)
Co-authored-by: Anvi <anvikudaraya417@gmail.com>
Co-authored-by: Chris Tangonan <ctangonan123@gmail.com>
2026-02-19 11:45:01 +01:00
50 changed files with 4893 additions and 352 deletions
+6 -2
View File
@@ -266,7 +266,7 @@ const initializeScene = async (opts: {
repairBindings: true,
deleteInvisibleElements: true,
}),
localDataState?.elements,
scene.elements,
),
appState: restoreAppState(
imported.appState,
@@ -551,7 +551,11 @@ const ExcalidrawWrapper = () => {
const username = importUsernameFromLocalStorage();
setLangCode(getPreferredLanguage());
excalidrawAPI.updateScene({
...localDataState,
elements: restoreElements(localDataState?.elements, null, {
repairBindings: true,
deleteInvisibleElements: true,
}),
appState: restoreAppState(localDataState?.appState, null),
captureUpdate: CaptureUpdateAction.NEVER,
});
LibraryIndexedDBAdapter.load().then((data) => {
+3 -1
View File
@@ -86,9 +86,11 @@ const saveDataStateToLocalStorage = (
_appState.openSidebar = null;
}
const persistedElements = getNonDeletedElements(elements);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(getNonDeletedElements(elements)),
JSON.stringify(persistedElements),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
+125 -14
View File
@@ -69,6 +69,114 @@ vi.mock("socket.io-client", () => {
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
*/
describe("collaboration", () => {
it("should preserve future element fields across collab reconciliation", async () => {
await render(<ExcalidrawApp />);
const frame = API.createElement({
type: "frame",
id: "A",
width: 100,
height: 100,
x: 0,
y: 0,
backgroundColor: "#ff0000",
});
const frameWithFutureFields = {
...frame,
schemaState: {
tracks: {
...frame.schemaState.tracks,
"host.myapp.frame": 1,
},
},
futureField: "keep-me",
} as typeof frame & {
schemaState: { tracks: Record<string, number> };
futureField: string;
};
API.updateScene({
elements: [frameWithFutureFields],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
await waitFor(() => {
expect((h.elements[0] as any).futureField).toBe("keep-me");
expect((h.elements[0] as any).schemaState).toEqual(
frameWithFutureFields.schemaState,
);
expect(h.elements[0].backgroundColor).toBe("#ff0000");
});
const remoteMovedFrame = newElementWith(h.elements[0] as any, {
x: 120,
y: 80,
});
const reconciled = (window.collab as any)._reconcileElements([
remoteMovedFrame,
]);
expect(reconciled[0]).toEqual(
expect.objectContaining({
x: 120,
y: 80,
backgroundColor: "#ff0000",
}),
);
expect((reconciled[0] as any).futureField).toBe("keep-me");
expect((reconciled[0] as any).schemaState).toEqual(
frameWithFutureFields.schemaState,
);
});
it("should preserve future element fields on local edits before broadcast", async () => {
await render(<ExcalidrawApp />);
const rect = API.createElement({
type: "rectangle",
id: "A",
width: 100,
height: 100,
x: 0,
y: 0,
});
const rectWithFutureFields = {
...rect,
schemaState: {
tracks: {
...rect.schemaState.tracks,
"host.myapp.rect": 1,
},
},
futureField: { value: "keep-me" },
} as typeof rect & {
schemaState: { tracks: Record<string, number> };
futureField: { value: string };
};
API.updateScene({
elements: [rectWithFutureFields],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
const locallyEdited = newElementWith(h.elements[0] as any, { x: 200 });
API.updateScene({
elements: [locallyEdited],
captureUpdate: CaptureUpdateAction.NEVER,
});
await waitFor(() => {
expect((h.elements[0] as any).futureField).toEqual({ value: "keep-me" });
expect((h.elements[0] as any).schemaState).toEqual(
rectWithFutureFields.schemaState,
);
expect(h.elements[0]).toEqual(expect.objectContaining({ x: 200 }));
});
});
it("should emit two ephemeral increments even though updates get batched", async () => {
const durableIncrements: DurableIncrement[] = [];
const ephemeralIncrements: EphemeralIncrement[] = [];
@@ -83,14 +191,18 @@ describe("collaboration", () => {
}
});
// eslint-disable-next-line dot-notation
expect(h.store["scheduledMicroActions"].length).toBe(0);
expect(durableIncrements.length).toBe(0);
expect(ephemeralIncrements.length).toBe(0);
// Ensure this test starts from a deterministic scene regardless of previous
// test state restored from persistence.
API.updateScene({
elements: [],
captureUpdate: CaptureUpdateAction.NEVER,
});
const durableBaseline = durableIncrements.length;
const ephemeralBaseline = ephemeralIncrements.length;
const rectProps = {
type: "rectangle",
id: "A",
height: 200,
width: 100,
x: 0,
@@ -105,8 +217,7 @@ describe("collaboration", () => {
});
await waitFor(() => {
// expect(commitSpy).toHaveBeenCalledTimes(1);
expect(durableIncrements.length).toBe(1);
expect(durableIncrements.length).toBe(durableBaseline + 1);
});
// simulate two batched remote updates
@@ -130,13 +241,13 @@ describe("collaboration", () => {
// altough the updates get batched,
// we expect two ephemeral increments for each update,
// and each such update should have the expected change
expect(ephemeralIncrements.length).toBe(2);
expect(ephemeralIncrements[0].change.elements.A).toEqual(
expect.objectContaining({ x: 100 }),
);
expect(ephemeralIncrements[1].change.elements.A).toEqual(
expect.objectContaining({ x: 200 }),
);
expect(ephemeralIncrements.length).toBe(ephemeralBaseline + 2);
expect(
ephemeralIncrements[ephemeralBaseline].change.elements[rect.id],
).toEqual(expect.objectContaining({ x: 100 }));
expect(
ephemeralIncrements[ephemeralBaseline + 1].change.elements[rect.id],
).toEqual(expect.objectContaining({ x: 200 }));
// eslint-disable-next-line dot-notation
expect(h.store["scheduledMicroActions"].length).toBe(0);
});
@@ -27,6 +27,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#66a80f",
"strokeStyle": "solid",
@@ -64,6 +67,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#9c36b5",
"strokeStyle": "solid",
@@ -116,6 +122,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
],
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
@@ -177,6 +186,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
],
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
@@ -223,6 +235,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -266,6 +281,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"originalText": "HEYYYYY",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#c2255c",
"strokeStyle": "solid",
@@ -312,6 +330,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"originalText": "Whats up ?",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -372,6 +393,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
],
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
@@ -419,6 +443,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"originalText": "HELLO WORLD!!",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -479,6 +506,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
],
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
@@ -526,6 +556,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"originalText": "HELLO WORLD!!",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -566,6 +599,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -603,6 +639,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -660,6 +699,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
],
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
@@ -707,6 +749,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"originalText": "HELLO WORLD!!",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -753,6 +798,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"originalText": "HEYYYYY",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -799,6 +847,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"originalText": "WHATS UP ?",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -834,6 +885,9 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -879,6 +933,9 @@ exports[`Test Transform > should transform linear elements 1`] = `
],
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
@@ -926,6 +983,9 @@ exports[`Test Transform > should transform linear elements 2`] = `
],
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": "dot",
"startBinding": null,
@@ -973,6 +1033,9 @@ exports[`Test Transform > should transform linear elements 3`] = `
"polygon": false,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
@@ -1020,6 +1083,9 @@ exports[`Test Transform > should transform linear elements 4`] = `
"polygon": false,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
@@ -1054,6 +1120,9 @@ exports[`Test Transform > should transform regular shapes 1`] = `
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1086,6 +1155,9 @@ exports[`Test Transform > should transform regular shapes 2`] = `
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1118,6 +1190,9 @@ exports[`Test Transform > should transform regular shapes 3`] = `
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1150,6 +1225,9 @@ exports[`Test Transform > should transform regular shapes 4`] = `
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1182,6 +1260,9 @@ exports[`Test Transform > should transform regular shapes 5`] = `
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "dotted",
@@ -1214,6 +1295,9 @@ exports[`Test Transform > should transform regular shapes 6`] = `
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1971c2",
"strokeStyle": "dashed",
@@ -1252,6 +1336,9 @@ exports[`Test Transform > should transform text element 1`] = `
"originalText": "HELLO WORLD!",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1293,6 +1380,9 @@ exports[`Test Transform > should transform text element 2`] = `
"originalText": "STYLED HELLO WORLD!",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#5f3dc4",
"strokeStyle": "solid",
@@ -1339,6 +1429,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1378,6 +1471,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1421,6 +1517,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1468,6 +1567,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1527,6 +1629,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"roundness": {
"type": 2,
},
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
@@ -1591,6 +1696,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"roundness": {
"type": 2,
},
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
@@ -1640,6 +1748,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "B",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1683,6 +1794,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "A",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1726,6 +1840,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "Alice",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1769,6 +1886,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "Bob",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1810,6 +1930,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "How are you?",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1851,6 +1974,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "Friendship",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1904,6 +2030,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
],
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
@@ -1956,6 +2085,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
],
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
@@ -2008,6 +2140,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
],
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
@@ -2060,6 +2195,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
],
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
@@ -2100,6 +2238,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "LABELED ARROW",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -2141,6 +2282,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "STYLED LABELED ARROW",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#099268",
"strokeStyle": "solid",
@@ -2182,6 +2326,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "ANOTHER STYLED LABELLED ARROW",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1098ad",
"strokeStyle": "solid",
@@ -2224,6 +2371,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "ANOTHER STYLED LABELLED ARROW",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#099268",
"strokeStyle": "solid",
@@ -2265,6 +2415,9 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -2302,6 +2455,9 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -2339,6 +2495,9 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -2376,6 +2535,9 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -2413,6 +2575,9 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#c2255c",
"strokeStyle": "solid",
@@ -2450,6 +2615,9 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#f08c00",
"strokeStyle": "solid",
@@ -2488,6 +2656,9 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "RECTANGLE TEXT CONTAINER",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -2529,6 +2700,9 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "ELLIPSE TEXT CONTAINER",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -2572,6 +2746,9 @@ exports[`Test Transform > should transform to text containers when label provide
TEXT CONTAINER",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -2615,6 +2792,9 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "STYLED DIAMOND TEXT CONTAINER",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#099268",
"strokeStyle": "solid",
@@ -2657,6 +2837,9 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#c2255c",
"strokeStyle": "solid",
@@ -2700,6 +2883,9 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "STYLED ELLIPSE TEXT CONTAINER",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#c2255c",
"strokeStyle": "solid",
+6 -1
View File
@@ -78,7 +78,12 @@ import type {
} from "./types";
export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") {
if (
element.type === "arrow" ||
// frame elements should ignore inside hit test even if background is not
// transparent, so we can select children easily
isFrameLikeElement(element)
) {
return false;
}
+1
View File
@@ -83,6 +83,7 @@ export * from "./positionElementsOnGrid";
export * from "./renderElement";
export * from "./resizeElements";
export * from "./resizeTest";
export * from "./schema";
export * from "./Scene";
export * from "./selection";
export * from "./shape";
+14 -1
View File
@@ -11,6 +11,7 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./shape";
import { updateElbowArrowPoints } from "./elbowArrow";
import { ensureSchemaStateForElementType } from "./schema";
import { isElbowArrow } from "./typeChecks";
@@ -137,6 +138,10 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element.version = updates.version ?? element.version + 1;
element.versionNonce = updates.versionNonce ?? randomInteger();
element.updated = getUpdatedTimestamp();
element.schemaState = ensureSchemaStateForElementType(
element.schemaState,
element.type,
) as TElement["schemaState"];
return element;
};
@@ -166,13 +171,21 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return element;
}
return {
const updatedElement = {
...element,
...updates,
version: updates.version ?? element.version + 1,
versionNonce: updates.versionNonce ?? randomInteger(),
updated: getUpdatedTimestamp(),
};
return {
...updatedElement,
schemaState: ensureSchemaStateForElementType(
updatedElement.schemaState,
updatedElement.type,
),
};
};
/**
+3
View File
@@ -20,6 +20,7 @@ import {
getElementAbsoluteCoords,
getResizedElementAbsoluteCoords,
} from "./bounds";
import { ensureSchemaStateForElementType } from "./schema";
import { newElementWith } from "./mutateElement";
import { getBoundTextMaxWidth } from "./textElement";
import { normalizeText, measureText } from "./textMeasurements";
@@ -70,6 +71,7 @@ export type ElementConstructorOpts = MarkOptional<
| "roughness"
| "strokeWidth"
| "roundness"
| "schemaState"
| "locked"
| "opacity"
| "customData"
@@ -144,6 +146,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
roundness,
seed: rest.seed ?? randomInteger(),
version: rest.version || 1,
schemaState: ensureSchemaStateForElementType(rest.schemaState, type),
versionNonce: rest.versionNonce ?? 0,
isDeleted: false as false,
boundElements,
+41 -1
View File
@@ -22,6 +22,7 @@ import {
isRTL,
getVerticalOffset,
invariant,
isTransparent,
applyDarkModeFilter,
isSafari,
} from "@excalidraw/common";
@@ -78,6 +79,7 @@ import type {
ExcalidrawFrameLikeElement,
NonDeletedSceneElementsMap,
ElementsMap,
ExcalidrawFrameElement,
} from "./types";
import type { RoughCanvas } from "roughjs/bin/canvas";
@@ -777,6 +779,45 @@ export const renderSelectionElement = (
context.restore();
};
export const renderFrameBackground = (
frame: ExcalidrawFrameElement,
context: CanvasRenderingContext2D,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
opts?: {
roundCorners?: boolean;
},
) => {
if (isTransparent(frame.backgroundColor)) {
return;
}
context.save();
context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY);
context.fillStyle =
appState.theme === THEME.DARK
? applyDarkModeFilter(frame.backgroundColor)
: frame.backgroundColor;
const shouldRoundCorners = opts?.roundCorners ?? true;
if (shouldRoundCorners && FRAME_STYLE.radius && context.roundRect) {
context.beginPath();
context.roundRect(
0,
0,
frame.width,
frame.height,
FRAME_STYLE.radius / appState.zoom.value,
);
context.fill();
context.closePath();
} else {
context.fillRect(0, 0, frame.width, frame.height);
}
context.restore();
};
export const renderElement = (
element: NonDeletedExcalidrawElement,
elementsMap: RenderableElementsMap,
@@ -808,7 +849,6 @@ export const renderElement = (
element.x + appState.scrollX,
element.y + appState.scrollY,
);
context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle =
+91
View File
@@ -0,0 +1,91 @@
/**
* Shared schema primitives used by element types and higher-level migrations.
*/
export const SCHEMA_INITIAL_TRACK_VERSION = 1 as const;
/** Core namespace reserved for built-in Excalidraw migrations. */
export const SCHEMA_CORE_NAMESPACE = "core" as const;
export type SchemaNamespace = typeof SCHEMA_CORE_NAMESPACE | `host.${string}`;
/**
* A schema track is an independent version line:
* - core tracks: "excalidraw.*"
* - host tracks: "host.<appId>.<track>"
*/
export type SchemaTrack = `excalidraw.${string}` | `host.${string}.${string}`;
export type ElementSchemaState = Readonly<{
tracks: Readonly<Record<string, number>>;
}>;
/** Core frame track id used by the frame background migration. */
export const CORE_FRAME_SCHEMA_TRACK = "excalidraw.shape.frame" as const;
/** Latest core track versions supported by this build. */
export const CORE_SUPPORTED_TRACKS = {
[CORE_FRAME_SCHEMA_TRACK]: 2,
} as const;
const getRequiredCoreTracksForElementType = (type: string) => {
if (type === "frame") {
return {
[CORE_FRAME_SCHEMA_TRACK]: CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
} as const;
}
return {} as const;
};
const isValidTrackVersion = (version: unknown): version is number =>
typeof version === "number" &&
Number.isInteger(version) &&
version >= SCHEMA_INITIAL_TRACK_VERSION;
/**
* Ensures an element schema state is normalized and satisfies type defaults.
* Required core tracks are only ever bumped forward (never downgraded).
*/
export const ensureSchemaStateForElementType = (
schemaState: ElementSchemaState | undefined,
type: string,
): ElementSchemaState => {
const requiredTracks = getRequiredCoreTracksForElementType(type);
const currentTracks = schemaState?.tracks || {};
const nextTracks: Record<string, number> = {};
let didChange = !schemaState;
for (const [track, version] of Object.entries(
currentTracks as Record<string, unknown>,
)) {
if (isValidTrackVersion(version)) {
nextTracks[track] = version;
continue;
}
nextTracks[track] = SCHEMA_INITIAL_TRACK_VERSION;
didChange = true;
}
for (const [track, requiredVersion] of Object.entries(requiredTracks)) {
const currentVersion = nextTracks[track];
if (
!isValidTrackVersion(currentVersion) ||
currentVersion < requiredVersion
) {
nextTracks[track] = requiredVersion;
didChange = true;
}
}
if (!didChange) {
return schemaState!;
}
return { tracks: nextTracks };
};
/**
* Default schema state for newly created elements.
* New frames are created at the latest supported frame track version.
*/
export const getDefaultSchemaStateForElementType = (
type: string,
): ElementSchemaState => ensureSchemaStateForElementType(undefined, type);
+4
View File
@@ -15,6 +15,8 @@ import type {
ValueOf,
} from "@excalidraw/common/utility-types";
import type { ElementSchemaState } from "./schema";
export type ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
export type FontFamilyKeys = keyof typeof FONT_FAMILY;
@@ -58,6 +60,8 @@ type _ExcalidrawElementBase = Readonly<{
/** Integer that is sequentially incremented on each change. Used to reconcile
elements during collaboration or when saving to server. */
version: number;
/** Per-track schema state used by migrations during restore. */
schemaState: ElementSchemaState;
/** Random integer that is regenerated on each change.
Used for deterministic reconciliation of updates during collaboration,
in case the versions (see above) are identical. */
+47
View File
@@ -38,6 +38,53 @@ describe("check rotated elements can be hit:", () => {
});
});
describe("frame hit testing", () => {
it.each(["transparent", "#ffffff"])(
"does not hit frame inside regardless of background color (%s)",
(backgroundColor) => {
const element = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor,
});
const elementsMap = arrayToMap([element]);
expect(
hitElementItself({
point: pointFrom<GlobalPoint>(50, 50),
element,
threshold: 10,
elementsMap,
}),
).toBe(false);
},
);
it("hits frame outline", () => {
const element = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
});
const elementsMap = arrayToMap([element]);
expect(
hitElementItself({
point: pointFrom<GlobalPoint>(0, 50),
element,
threshold: 1,
elementsMap,
}),
).toBe(true);
});
});
describe("hitElementItself cache", () => {
beforeEach(async () => {
// reset cache
+5 -1
View File
@@ -271,7 +271,11 @@ export const actionLoadScene = register({
elements: loadedElements,
appState: loadedAppState,
files,
} = await loadFromJSON(appState, elements);
} = await loadFromJSON(
appState,
elements,
app.getSchemaMigrationRegistry(),
);
return {
elements: loadedElements,
appState: loadedAppState,
@@ -6,11 +6,23 @@ import {
FONT_FAMILY,
STROKE_WIDTH,
} from "@excalidraw/common";
import {
CORE_FRAME_SCHEMA_TRACK,
CORE_SUPPORTED_TRACKS,
} from "@excalidraw/element";
import { Excalidraw } from "../index";
import { API } from "../tests/helpers/api";
import { UI } from "../tests/helpers/ui";
import { render } from "../tests/test-utils";
import { act, render } from "../tests/test-utils";
import {
actionChangeBackgroundColor,
actionChangeRoundness,
actionChangeStrokeWidth,
} from "./actionProperties";
const { h } = window;
describe("element locking", () => {
beforeEach(async () => {
@@ -109,6 +121,21 @@ describe("element locking", () => {
expect(crossHatchButton).toBe(null);
});
it("should show background color picker for selected frame", () => {
const frame = API.createElement({
type: "frame",
});
API.setElements([frame]);
API.setSelectedElements([frame]);
expect(
queryByTestId(
document.body,
`color-top-pick-${DEFAULT_ELEMENT_BACKGROUND_PICKS[0]}`,
),
).not.toBe(null);
});
it("should highlight common stroke width of selected elements", () => {
const rect1 = API.createElement({
type: "rectangle",
@@ -169,5 +196,77 @@ describe("element locking", () => {
"active",
);
});
it("should not update text background when changing background in mixed frame selection", () => {
const frame = API.createElement({
type: "frame",
schemaState: { tracks: {} },
});
const text = API.createElement({
type: "text",
backgroundColor: COLOR_PALETTE.transparent,
});
API.setElements([text, frame]);
API.setSelectedElements([text, frame]);
act(() => {
h.app.actionManager.executeAction(actionChangeBackgroundColor, "ui", {
viewBackgroundColor: h.state.viewBackgroundColor,
currentItemBackgroundColor: "#ffc9c9",
});
});
expect(API.getElement(frame).backgroundColor).toBe("#ffc9c9");
expect(API.getElement(text).backgroundColor).toBe(
COLOR_PALETTE.transparent,
);
expect(
API.getElement(frame).schemaState.tracks[CORE_FRAME_SCHEMA_TRACK],
).toBe(CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK]);
});
it("should not update frame stroke width when changing stroke width in mixed selection", () => {
const frame = API.createElement({
type: "frame",
});
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
API.setElements([rect, frame]);
API.setSelectedElements([rect, frame]);
const originalFrameStrokeWidth = API.getElement(frame).strokeWidth;
act(() => {
h.app.actionManager.executeAction(
actionChangeStrokeWidth,
"ui",
STROKE_WIDTH.extraBold,
);
});
expect(API.getElement(rect).strokeWidth).toBe(STROKE_WIDTH.extraBold);
expect(API.getElement(frame).strokeWidth).toBe(originalFrameStrokeWidth);
});
it("should not update frame roundness when changing roundness in mixed selection", () => {
const frame = API.createElement({
type: "frame",
});
const rect = API.createElement({
type: "rectangle",
roundness: null,
});
API.setElements([rect, frame]);
API.setSelectedElements([rect, frame]);
act(() => {
h.app.actionManager.executeAction(actionChangeRoundness, "ui", "round");
});
expect(API.getElement(rect).roundness).not.toBe(null);
expect(API.getElement(frame).roundness).toBe(null);
});
});
});
@@ -45,6 +45,7 @@ import {
import {
isArrowElement,
isBoundToContainer,
isFrameElement,
isElbowArrow,
isLinearElement,
isLineElement,
@@ -52,7 +53,13 @@ import {
isUsingAdaptiveRadius,
} from "@excalidraw/element";
import { hasStrokeColor } from "@excalidraw/element";
import {
canChangeRoundness,
hasBackground,
hasStrokeColor,
hasStrokeStyle,
hasStrokeWidth,
} from "@excalidraw/element";
import {
updateElbowArrowPoints,
@@ -409,11 +416,18 @@ export const actionChangeBackgroundColor = register<
return el;
});
} else {
nextElements = changeProperty(elements, appState, (el) =>
newElementWith(el, {
backgroundColor: value.currentItemBackgroundColor,
}),
);
nextElements = changeProperty(elements, appState, (el) => {
if (isFrameElement(el)) {
return newElementWith(el, {
backgroundColor: value.currentItemBackgroundColor,
});
}
return hasBackground(el.type)
? newElementWith(el, {
backgroundColor: value.currentItemBackgroundColor,
})
: el;
});
}
return {
@@ -444,7 +458,12 @@ export const actionChangeBackgroundColor = register<
(element) => element.backgroundColor,
true,
(hasSelection) =>
!hasSelection ? appState.currentItemBackgroundColor : null,
!hasSelection
? appState.activeTool.type === "frame"
? // background default shouldn't apply to new frames
"transparent"
: appState.currentItemBackgroundColor
: null,
)}
onChange={(color) =>
updateData({ currentItemBackgroundColor: color })
@@ -471,11 +490,13 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
})`,
);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
fillStyle: value,
}),
),
elements: changeProperty(elements, appState, (el) => {
return hasBackground(el.type)
? newElementWith(el, {
fillStyle: value,
})
: el;
}),
appState: { ...appState, currentItemFillStyle: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
@@ -548,11 +569,13 @@ export const actionChangeStrokeWidth = register<
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeWidth: value,
}),
),
elements: changeProperty(elements, appState, (el) => {
return hasStrokeWidth(el.type)
? newElementWith(el, {
strokeWidth: value,
})
: el;
}),
appState: { ...appState, currentItemStrokeWidth: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
@@ -604,12 +627,14 @@ export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
seed: randomInteger(),
roughness: value,
}),
),
elements: changeProperty(elements, appState, (el) => {
return hasStrokeStyle(el.type)
? newElementWith(el, {
seed: randomInteger(),
roughness: value,
})
: el;
}),
appState: { ...appState, currentItemRoughness: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
@@ -660,11 +685,13 @@ export const actionChangeStrokeStyle = register<
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeStyle: value,
}),
),
elements: changeProperty(elements, appState, (el) => {
return hasStrokeStyle(el.type)
? newElementWith(el, {
strokeStyle: value,
})
: el;
}),
appState: { ...appState, currentItemStrokeStyle: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
@@ -1476,7 +1503,7 @@ export const actionChangeRoundness = register<"sharp" | "round">({
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) => {
if (isElbowArrow(el)) {
if (isElbowArrow(el) || !canChangeRoundness(el.type)) {
return el;
}
+71 -3
View File
@@ -54,7 +54,13 @@ describe("parseClipboard()", () => {
}),
),
);
expect(clipboardData.elements).toEqual([rect]);
expect(clipboardData.elements).toEqual([
expect.objectContaining({
id: rect.id,
type: rect.type,
schemaState: rect.schemaState,
}),
]);
});
it("should parse valid excalidraw JSON if inside text/html", async () => {
@@ -73,7 +79,13 @@ describe("parseClipboard()", () => {
}),
),
);
expect(clipboardData.elements).toEqual([rect]);
expect(clipboardData.elements).toEqual([
expect.objectContaining({
id: rect.id,
type: rect.type,
schemaState: rect.schemaState,
}),
]);
// -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard(
@@ -85,10 +97,66 @@ describe("parseClipboard()", () => {
}),
),
);
expect(clipboardData.elements).toEqual([rect]);
expect(clipboardData.elements).toEqual([
expect.objectContaining({
id: rect.id,
type: rect.type,
schemaState: rect.schemaState,
}),
]);
// -------------------------------------------------------------------------
});
it("should preserve per-element schema on clipboard payload", async () => {
const rect = API.createElement({ type: "rectangle" });
const clipboardPayload = JSON.parse(
serializeAsClipboardJSON({ elements: [rect], files: null }),
);
const clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/plain": JSON.stringify(clipboardPayload),
},
}),
),
);
expect(clipboardData.elements?.[0]).toEqual(
expect.objectContaining({
id: rect.id,
schemaState: rect.schemaState,
}),
);
});
it("should not upcast legacy elements to latest schema on clipboard serialize", async () => {
const rect = API.createElement({ type: "rectangle" });
const legacyRect = { ...(rect as any) };
delete legacyRect.schemaState;
const clipboardPayload = JSON.parse(
serializeAsClipboardJSON({
elements: [legacyRect as typeof rect],
files: null,
}),
);
expect(clipboardPayload.elements[0]).not.toHaveProperty("schemaState");
const clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/plain": JSON.stringify(clipboardPayload),
},
}),
),
);
expect(clipboardData.elements?.[0]).not.toHaveProperty("schemaState");
});
it("should parse <image> `src` urls out of text/html", async () => {
let clipboardData;
// -------------------------------------------------------------------------
+4 -1
View File
@@ -79,7 +79,10 @@ export const probablySupportsClipboardBlob =
const clipboardContainsElements = (
contents: any,
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
): contents is {
elements: ExcalidrawElement[];
files?: BinaryFiles;
} => {
if (
[
EXPORT_DATA_TYPES.excalidraw,
+5 -1
View File
@@ -20,6 +20,7 @@ import {
isArrowElement,
hasStrokeColor,
toolIsArrow,
isFrameElement,
} from "@excalidraw/element";
import type {
@@ -129,8 +130,11 @@ export const canChangeBackgroundColor = (
targetElements: ExcalidrawElement[],
) => {
return (
// frame tool shouldn't allow to set background until frame is created
hasBackground(appState.activeTool.type) ||
targetElements.some((element) => hasBackground(element.type))
targetElements.some(
(element) => hasBackground(element.type) || isFrameElement(element),
)
);
};
+160 -92
View File
@@ -256,6 +256,7 @@ import {
handleFocusPointPointerUp,
maybeHandleArrowPointlikeDrag,
getUncroppedWidthAndHeight,
isFrameElement,
} from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -353,6 +354,7 @@ import {
import { exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restoreAppState, restoreElements } from "../data/restore";
import { createSchemaMigrationRegistry } from "../data/schema";
import { getCenter, getDistance } from "../gesture";
import { History } from "../history";
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
@@ -442,10 +444,7 @@ import { searchItemInFocusAtom } from "./SearchMenu";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas } from "./canvases";
import NewElementCanvas from "./canvases/NewElementCanvas";
import {
isPointHittingLink,
isPointHittingLinkIcon,
} from "./hyperlink/helpers";
import { isPointHittingLink } from "./hyperlink/helpers";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import { Toast } from "./Toast";
@@ -462,6 +461,7 @@ import type {
import type { ClipboardData, PastedMixedContent } from "../clipboard";
import type { ExportedElements } from "../data";
import type { SchemaMigrationRegistry } from "../data/schema";
import type { ContextMenuItems } from "./ContextMenu";
import type { FileSystemHandle } from "../data/filesystem";
@@ -608,6 +608,7 @@ class App extends React.Component<AppProps, AppState> {
public fonts: Fonts;
public renderer: Renderer;
public visibleElements: readonly NonDeletedExcalidrawElement[];
private schemaMigrationRegistry: SchemaMigrationRegistry;
private resizeObserver: ResizeObserver | undefined;
public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined;
@@ -724,6 +725,9 @@ class App extends React.Component<AppProps, AppState> {
this.stylesPanelMode = deriveStylesPanelMode(this.editorInterface);
this.id = nanoid();
this.schemaMigrationRegistry = createSchemaMigrationRegistry(
props.schemaPlugins,
);
this.library = new Library(this);
this.actionManager = new ActionManager(
this.syncActionResult,
@@ -770,6 +774,7 @@ class App extends React.Component<AppProps, AppState> {
setCursor: this.setCursor,
resetCursor: this.resetCursor,
getEditorInterface: () => this.editorInterface,
getSchemaMigrationRegistry: this.getSchemaMigrationRegistry,
updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb),
@@ -1210,12 +1215,99 @@ class App extends React.Component<AppProps, AppState> {
return this.iFrameRefs.get(element.id);
}
private handleEmbeddableCenterClick(element: ExcalidrawIframeLikeElement) {
private handleIframeLikeElementHover = ({
hitElement,
scenePointer,
moveEvent,
}: {
hitElement: NonDeleted<ExcalidrawElement> | null;
scenePointer: { x: number; y: number };
moveEvent: React.PointerEvent<HTMLCanvasElement>;
}): boolean => {
if (
this.state.activeEmbeddable?.element === element &&
hitElement &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
moveEvent,
scenePointer.x,
scenePointer.y,
)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
this.setState({
activeEmbeddable: { element: hitElement, state: "hover" },
});
return true;
} else if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
}
return false;
};
/** @returns true if iframe-like element click handled */
private handleIframeLikeCenterClick(): boolean {
if (!this.lastPointerDownEvent || !this.lastPointerUpEvent) {
return false;
}
const scenePointerStart = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerDownEvent.clientX,
clientY: this.lastPointerDownEvent.clientY,
},
this.state,
);
const scenePointerEnd = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerUpEvent.clientX,
clientY: this.lastPointerUpEvent.clientY,
},
this.state,
);
const hitElementStart = this.getElementAtPosition(
scenePointerStart.x,
scenePointerStart.y,
);
const hitElementEnd = this.getElementAtPosition(
scenePointerEnd.x,
scenePointerEnd.y,
);
if (
!hitElementStart ||
!hitElementEnd ||
hitElementStart !== hitElementEnd ||
this.lastPointerUpEvent.timeStamp - this.lastPointerDownEvent.timeStamp >
300 ||
gesture.pointers.size > 1 ||
!isIframeLikeElement(hitElementStart) ||
!isIframeLikeElement(hitElementEnd) ||
!this.isIframeLikeElementCenter(
hitElementStart,
this.lastPointerUpEvent,
scenePointerStart.x,
scenePointerStart.y,
) ||
!this.isIframeLikeElementCenter(
hitElementEnd,
this.lastPointerUpEvent,
scenePointerEnd.x,
scenePointerEnd.y,
)
) {
return false;
}
const iframeLikeElement = hitElementEnd;
if (
this.state.activeEmbeddable?.element === iframeLikeElement &&
this.state.activeEmbeddable?.state === "active"
) {
return;
return true;
}
// The delay serves two purposes
@@ -1226,31 +1318,34 @@ class App extends React.Component<AppProps, AppState> {
// in fullscreen mode
setTimeout(() => {
this.setState({
activeEmbeddable: { element, state: "active" },
selectedElementIds: { [element.id]: true },
activeEmbeddable: { element: iframeLikeElement, state: "active" },
selectedElementIds: { [iframeLikeElement.id]: true },
newElement: null,
selectionElement: null,
});
}, 100);
if (isIframeElement(element)) {
return;
if (isIframeElement(iframeLikeElement)) {
return true;
}
const iframe = this.getHTMLIFrameElement(element);
const iframe = this.getHTMLIFrameElement(iframeLikeElement);
if (!iframe?.contentWindow) {
return;
return true;
}
if (iframe.src.includes("youtube")) {
const state = YOUTUBE_VIDEO_STATES.get(element.id);
const state = YOUTUBE_VIDEO_STATES.get(iframeLikeElement.id);
if (!state) {
YOUTUBE_VIDEO_STATES.set(element.id, YOUTUBE_STATES.UNSTARTED);
YOUTUBE_VIDEO_STATES.set(
iframeLikeElement.id,
YOUTUBE_STATES.UNSTARTED,
);
iframe.contentWindow.postMessage(
JSON.stringify({
event: "listening",
id: element.id,
id: iframeLikeElement.id,
}),
"*",
);
@@ -1287,6 +1382,8 @@ class App extends React.Component<AppProps, AppState> {
"*",
);
}
return true;
}
private isIframeLikeElementCenter(
@@ -2230,6 +2327,10 @@ class App extends React.Component<AppProps, AppState> {
return this.scene.getNonDeletedElements();
};
public getSchemaMigrationRegistry = () => {
return this.schemaMigrationRegistry;
};
public onInsertElements = (elements: readonly ExcalidrawElement[]) => {
this.addElementsFromPasteOrLibrary({
elements,
@@ -2718,6 +2819,7 @@ class App extends React.Component<AppProps, AppState> {
const restoredElements = restoreElements(initialData?.elements, null, {
repairBindings: true,
deleteInvisibleElements: true,
schemaMigrationRegistry: this.schemaMigrationRegistry,
});
let restoredAppState = restoreAppState(initialData?.appState, null);
const activeTool = restoredAppState.activeTool;
@@ -3129,6 +3231,12 @@ class App extends React.Component<AppProps, AppState> {
}
componentDidUpdate(prevProps: AppProps, prevState: AppState) {
if (prevProps.schemaPlugins !== this.props.schemaPlugins) {
this.schemaMigrationRegistry = createSchemaMigrationRegistry(
this.props.schemaPlugins,
);
}
this.updateEmbeddables();
const elements = this.scene.getElementsIncludingDeleted();
const elementsMap = this.scene.getElementsMapIncludingDeleted();
@@ -3632,6 +3740,7 @@ class App extends React.Component<AppProps, AppState> {
}) => {
const elements = restoreElements(opts.elements, null, {
deleteInvisibleElements: true,
schemaMigrationRegistry: this.schemaMigrationRegistry,
});
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
@@ -4948,6 +5057,8 @@ class App extends React.Component<AppProps, AppState> {
if (
event.key === KEYS.G &&
(hasBackground(this.state.activeTool.type) ||
this.state.activeTool.type === "frame" ||
selectedElements.some((element) => isFrameElement(element)) ||
selectedElements.some((element) => hasBackground(element.type)))
) {
this.setState({ openPopup: "elementBackground" });
@@ -6622,10 +6733,14 @@ class App extends React.Component<AppProps, AppState> {
}
}
const hasDeselectedButton = Boolean(event.buttons);
const isPressingAnyButton = Boolean(event.buttons);
const isLaserTool = this.state.activeTool.type === "laser";
if (
hasDeselectedButton ||
(this.state.activeTool.type !== "selection" &&
isPressingAnyButton ||
// checking against laser so that if you mouseover with a laser tool
// over a link/embeddable, we change the cursor
(!isLaserTool &&
this.state.activeTool.type !== "selection" &&
this.state.activeTool.type !== "lasso" &&
this.state.activeTool.type !== "text" &&
this.state.activeTool.type !== "eraser")
@@ -6745,6 +6860,14 @@ class App extends React.Component<AppProps, AppState> {
);
} else {
hideHyperlinkToolip();
if (isLaserTool) {
this.handleIframeLikeElementHover({
hitElement,
scenePointer,
moveEvent: event,
});
return;
}
if (
hitElement &&
(hitElement.link || isEmbeddableElement(hitElement)) &&
@@ -6777,24 +6900,15 @@ class App extends React.Component<AppProps, AppState> {
!hitElement?.locked
) {
if (
hitElement &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
!this.handleIframeLikeElementHover({
hitElement,
event,
scenePointerX,
scenePointerY,
)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
this.setState({
activeEmbeddable: { element: hitElement, state: "hover" },
});
} else if (
!hitElement ||
// Elbow arrows can only be moved when unconnected
!isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding)
scenePointer,
moveEvent: event,
}) &&
(!hitElement ||
// Elbow arrows can only be moved when unconnected
!isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding))
) {
if (
this.state.activeTool.type !== "lasso" ||
@@ -6802,9 +6916,6 @@ class App extends React.Component<AppProps, AppState> {
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
}
}
}
} else {
@@ -7456,26 +7567,9 @@ class App extends React.Component<AppProps, AppState> {
x: scenePointerX,
y: scenePointerY,
};
const clicklength =
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
if (this.editorInterface.formFactor === "phone" && clicklength < 300) {
const hitElement = this.getElementAtPosition(
scenePointer.x,
scenePointer.y,
);
if (
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
event,
scenePointer.x,
scenePointer.y,
)
) {
this.handleEmbeddableCenterClick(hitElement);
return;
}
if (this.handleIframeLikeCenterClick()) {
return;
}
if (this.editorInterface.isTouchScreen) {
@@ -7496,20 +7590,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement &&
!this.state.selectedElementIds[this.hitLinkElement.id]
) {
if (
clicklength < 300 &&
isIframeLikeElement(this.hitLinkElement) &&
!isPointHittingLinkIcon(
this.hitLinkElement,
this.scene.getNonDeletedElementsMap(),
this.state,
pointFrom(scenePointer.x, scenePointer.y),
)
) {
this.handleEmbeddableCenterClick(this.hitLinkElement);
} else {
this.redirectToLink(event, this.editorInterface.isTouchScreen);
}
this.redirectToLink(event, this.editorInterface.isTouchScreen);
} else if (this.state.viewModeEnabled) {
this.setState({
activeEmbeddable: null,
@@ -10852,25 +10933,6 @@ class App extends React.Component<AppProps, AppState> {
suggestedBinding: null,
});
}
if (
hitElement &&
this.lastPointerUpEvent &&
this.lastPointerDownEvent &&
this.lastPointerUpEvent.timeStamp -
this.lastPointerDownEvent.timeStamp <
300 &&
gesture.pointers.size <= 1 &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
this.lastPointerUpEvent,
pointerDownState.origin.x,
pointerDownState.origin.y,
)
) {
this.handleEmbeddableCenterClick(hitElement);
}
});
}
@@ -11385,6 +11447,7 @@ class App extends React.Component<AppProps, AppState> {
this.state,
this.scene.getElementsIncludingDeleted(),
fileHandle,
this.schemaMigrationRegistry,
);
this.syncActionResult({
...scene,
@@ -11431,7 +11494,11 @@ class App extends React.Component<AppProps, AppState> {
);
// legacy library dataTransfer format
} else if (excalidrawLibrary_data) {
libraryItems = parseLibraryJSON(excalidrawLibrary_data);
libraryItems = parseLibraryJSON(
excalidrawLibrary_data,
"unpublished",
this.schemaMigrationRegistry,
);
}
if (libraryItems?.length) {
libraryItems = libraryItems.map((item) => ({
@@ -11501,6 +11568,7 @@ class App extends React.Component<AppProps, AppState> {
this.state,
elements,
fileHandle,
this.schemaMigrationRegistry,
);
} catch (error: any) {
const imageSceneDataError = error instanceof ImageSceneDataError;
+15 -2
View File
@@ -28,6 +28,7 @@ import {
import type { AppState, DataURL, LibraryItem } from "../types";
import type { FileSystemHandle } from "browser-fs-access";
import type { SchemaMigrationRegistry } from "./schema";
import type { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File): Promise<string> => {
@@ -141,6 +142,7 @@ export const loadSceneOrLibraryFromBlob = async (
localElements: readonly ExcalidrawElement[] | null,
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemHandle | null,
schemaMigrationRegistry?: SchemaMigrationRegistry,
) => {
const contents = await parseFileContents(blob);
let data;
@@ -163,6 +165,7 @@ export const loadSceneOrLibraryFromBlob = async (
elements: restoreElements(data.elements, localElements, {
repairBindings: true,
deleteInvisibleElements: true,
schemaMigrationRegistry,
}),
appState: restoreAppState(
{
@@ -200,12 +203,14 @@ export const loadFromBlob = async (
localElements: readonly ExcalidrawElement[] | null,
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemHandle | null,
schemaMigrationRegistry?: SchemaMigrationRegistry,
) => {
const ret = await loadSceneOrLibraryFromBlob(
blob,
localAppState,
localElements,
fileHandle,
schemaMigrationRegistry,
);
if (ret.type !== MIME_TYPES.excalidraw) {
throw new Error("Error: invalid file");
@@ -216,20 +221,28 @@ export const loadFromBlob = async (
export const parseLibraryJSON = (
json: string,
defaultStatus: LibraryItem["status"] = "unpublished",
schemaMigrationRegistry?: SchemaMigrationRegistry,
) => {
const data: ImportedLibraryData | undefined = JSON.parse(json);
if (!isValidLibrary(data)) {
throw new Error("Invalid library");
}
const libraryItems = data.libraryItems || data.library;
return restoreLibraryItems(libraryItems, defaultStatus);
return restoreLibraryItems(libraryItems, defaultStatus, {
schemaMigrationRegistry,
});
};
export const loadLibraryFromBlob = async (
blob: Blob,
defaultStatus: LibraryItem["status"] = "unpublished",
schemaMigrationRegistry?: SchemaMigrationRegistry,
) => {
return parseLibraryJSON(await parseFileContents(blob), defaultStatus);
return parseLibraryJSON(
await parseFileContents(blob),
defaultStatus,
schemaMigrationRegistry,
);
};
export const canvasToBlob = async (
+9 -1
View File
@@ -14,6 +14,7 @@ import { isImageFileHandle, loadFromBlob } from "./blob";
import { fileOpen, fileSave } from "./filesystem";
import type { AppState, BinaryFiles, LibraryItems } from "../types";
import type { SchemaMigrationRegistry } from "./schema";
import type {
ExportedDataState,
ImportedDataState,
@@ -93,6 +94,7 @@ export const saveAsJSON = async (
export const loadFromJSON = async (
localAppState: AppState,
localElements: readonly ExcalidrawElement[] | null,
schemaMigrationRegistry?: SchemaMigrationRegistry,
) => {
const file = await fileOpen({
description: "Excalidraw files",
@@ -100,7 +102,13 @@ export const loadFromJSON = async (
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
// extensions: ["json", "excalidraw", "png", "svg"],
});
return loadFromBlob(file, localAppState, localElements, file.handle);
return loadFromBlob(
file,
localAppState,
localElements,
file.handle,
schemaMigrationRegistry,
);
};
export const isValidExcalidrawData = (data?: {
+120 -63
View File
@@ -35,6 +35,7 @@ import { loadLibraryFromBlob } from "./blob";
import { restoreLibraryItems } from "./restore";
import type App from "../components/App";
import type { SchemaMigrationRegistry } from "./schema";
import type {
LibraryItems,
@@ -65,9 +66,9 @@ type LibraryUpdate = {
updatedItems: Map<LibraryItem["id"], LibraryItem>;
};
// an object so that we can later add more properties to it without breaking,
// such as schema version
export type LibraryPersistedData = { libraryItems: LibraryItems };
export type LibraryPersistedData = {
libraryItems: LibraryItems;
};
const onLibraryUpdateEmitter = new Emitter<
[update: LibraryUpdate, libraryItems: LibraryItems]
@@ -99,7 +100,9 @@ export interface LibraryMigrationAdapter {
* loads data from legacy data source. Returns `null` if no data is
* to be migrated.
*/
load(): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>;
load(): MaybePromise<{
libraryItems: LibraryItems_anyVersion;
} | null>;
/** clears entire storage afterwards */
clear(): MaybePromise<void>;
@@ -314,9 +317,15 @@ class Library {
let nextItems;
if (source instanceof Blob) {
nextItems = await loadLibraryFromBlob(source, defaultStatus);
nextItems = await loadLibraryFromBlob(
source,
defaultStatus,
this.app.getSchemaMigrationRegistry(),
);
} else {
nextItems = restoreLibraryItems(source, defaultStatus);
nextItems = restoreLibraryItems(source, defaultStatus, {
schemaMigrationRegistry: this.app.getSchemaMigrationRegistry(),
});
}
if (
!prompt ||
@@ -549,12 +558,17 @@ class AdapterTransaction {
adapter: LibraryPersistenceAdapter,
source: LibraryAdatapterSource,
_queue = true,
schemaMigrationRegistry?: SchemaMigrationRegistry,
): Promise<LibraryItems> {
const task = () =>
new Promise<LibraryItems>(async (resolve, reject) => {
try {
const data = await adapter.load({ source });
resolve(restoreLibraryItems(data?.libraryItems || [], "published"));
resolve(
restoreLibraryItems(data?.libraryItems || [], "published", {
schemaMigrationRegistry,
}),
);
} catch (error: any) {
reject(error);
}
@@ -569,22 +583,36 @@ class AdapterTransaction {
static run = async <T>(
adapter: LibraryPersistenceAdapter,
schemaMigrationRegistry: SchemaMigrationRegistry | undefined,
fn: (transaction: AdapterTransaction) => Promise<T>,
) => {
const transaction = new AdapterTransaction(adapter);
const transaction = new AdapterTransaction(
adapter,
schemaMigrationRegistry,
);
return AdapterTransaction.queue.push(() => fn(transaction));
};
// ------------------
private adapter: LibraryPersistenceAdapter;
private schemaMigrationRegistry: SchemaMigrationRegistry | undefined;
constructor(adapter: LibraryPersistenceAdapter) {
constructor(
adapter: LibraryPersistenceAdapter,
schemaMigrationRegistry: SchemaMigrationRegistry | undefined,
) {
this.adapter = adapter;
this.schemaMigrationRegistry = schemaMigrationRegistry;
}
getLibraryItems(source: LibraryAdatapterSource) {
return AdapterTransaction.getLibraryItems(this.adapter, source, false);
return AdapterTransaction.getLibraryItems(
this.adapter,
source,
false,
this.schemaMigrationRegistry,
);
}
}
@@ -607,68 +635,73 @@ export const getLibraryItemsHash = (items: LibraryItems) => {
const persistLibraryUpdate = async (
adapter: LibraryPersistenceAdapter,
update: LibraryUpdate,
schemaMigrationRegistry: SchemaMigrationRegistry | undefined,
): Promise<LibraryItems> => {
try {
librarySaveCounter++;
return await AdapterTransaction.run(adapter, async (transaction) => {
const nextLibraryItemsMap = arrayToMap(
await transaction.getLibraryItems("save"),
);
return await AdapterTransaction.run(
adapter,
schemaMigrationRegistry,
async (transaction) => {
const nextLibraryItemsMap = arrayToMap(
await transaction.getLibraryItems("save"),
);
for (const [id] of update.deletedItems) {
nextLibraryItemsMap.delete(id);
}
const addedItems: LibraryItem[] = [];
// we want to merge current library items with the ones stored in the
// DB so that we don't lose any elements that for some reason aren't
// in the current editor library, which could happen when:
//
// 1. we haven't received an update deleting some elements
// (in which case it's still better to keep them in the DB lest
// it was due to a different reason)
// 2. we keep a single DB for all active editors, but the editors'
// libraries aren't synced or there's a race conditions during
// syncing
// 3. some other race condition, e.g. during init where emit updates
// for partial updates (e.g. you install a 3rd party library and
// init from DB only after — we emit events for both updates)
for (const [id, item] of update.addedItems) {
if (nextLibraryItemsMap.has(id)) {
// replace item with latest version
// TODO we could prefer the newer item instead
nextLibraryItemsMap.set(id, item);
} else {
// we want to prepend the new items with the ones that are already
// in DB to preserve the ordering we do in editor (newly added
// items are added to the beginning)
addedItems.push(item);
for (const [id] of update.deletedItems) {
nextLibraryItemsMap.delete(id);
}
}
// replace existing items with their updated versions
if (update.updatedItems) {
for (const [id, item] of update.updatedItems) {
nextLibraryItemsMap.set(id, item);
const addedItems: LibraryItem[] = [];
// we want to merge current library items with the ones stored in the
// DB so that we don't lose any elements that for some reason aren't
// in the current editor library, which could happen when:
//
// 1. we haven't received an update deleting some elements
// (in which case it's still better to keep them in the DB lest
// it was due to a different reason)
// 2. we keep a single DB for all active editors, but the editors'
// libraries aren't synced or there's a race conditions during
// syncing
// 3. some other race condition, e.g. during init where emit updates
// for partial updates (e.g. you install a 3rd party library and
// init from DB only after — we emit events for both updates)
for (const [id, item] of update.addedItems) {
if (nextLibraryItemsMap.has(id)) {
// replace item with latest version
// TODO we could prefer the newer item instead
nextLibraryItemsMap.set(id, item);
} else {
// we want to prepend the new items with the ones that are already
// in DB to preserve the ordering we do in editor (newly added
// items are added to the beginning)
addedItems.push(item);
}
}
}
const nextLibraryItems = addedItems.concat(
Array.from(nextLibraryItemsMap.values()),
);
// replace existing items with their updated versions
if (update.updatedItems) {
for (const [id, item] of update.updatedItems) {
nextLibraryItemsMap.set(id, item);
}
}
const version = getLibraryItemsHash(nextLibraryItems);
const nextLibraryItems = addedItems.concat(
Array.from(nextLibraryItemsMap.values()),
);
if (version !== lastSavedLibraryItemsHash) {
await adapter.save({ libraryItems: nextLibraryItems });
}
const version = getLibraryItemsHash(nextLibraryItems);
lastSavedLibraryItemsHash = version;
if (version !== lastSavedLibraryItemsHash) {
await adapter.save({ libraryItems: nextLibraryItems });
}
return nextLibraryItems;
});
lastSavedLibraryItemsHash = version;
return nextLibraryItems;
},
);
} finally {
librarySaveCounter--;
}
@@ -852,16 +885,24 @@ export const useHandleLibrary = (
.then(async (libraryData) => {
let restoredData: LibraryItems | null = null;
try {
const schemaMigrationRegistry =
optsRef.current.excalidrawAPI?.getSchemaMigrationRegistry();
// if no library data to migrate, assume no migration needed
// and skip persisting to new data store, as well as well
// clearing the old store via `migrationAdapter.clear()`
if (!libraryData) {
return AdapterTransaction.getLibraryItems(adapter, "load");
return AdapterTransaction.getLibraryItems(
adapter,
"load",
true,
schemaMigrationRegistry,
);
}
restoredData = restoreLibraryItems(
libraryData.libraryItems || [],
"published",
{ schemaMigrationRegistry },
);
// we don't queue this operation because it's running inside
@@ -869,6 +910,7 @@ export const useHandleLibrary = (
const nextItems = await persistLibraryUpdate(
adapter,
createLibraryUpdate([], restoredData),
schemaMigrationRegistry,
);
try {
await migrationAdapter.clear();
@@ -891,12 +933,23 @@ export const useHandleLibrary = (
.catch((error: any) => {
console.error(`error during library migration: ${error.message}`);
// as a default, load latest library from current data source
return AdapterTransaction.getLibraryItems(adapter, "load");
return AdapterTransaction.getLibraryItems(
adapter,
"load",
true,
optsRef.current.excalidrawAPI?.getSchemaMigrationRegistry(),
);
}),
);
} else {
initDataPromise.resolve(
promiseTry(AdapterTransaction.getLibraryItems, adapter, "load"),
promiseTry(
AdapterTransaction.getLibraryItems,
adapter,
"load",
true,
optsRef.current.excalidrawAPI?.getSchemaMigrationRegistry(),
),
);
}
@@ -958,7 +1011,11 @@ export const useHandleLibrary = (
lastSavedLibraryItemsHash !==
getLibraryItemsHash(nextLibraryItems)
) {
await persistLibraryUpdate(adapter, update);
await persistLibraryUpdate(
adapter,
update,
optsRef.current.excalidrawAPI?.getSchemaMigrationRegistry(),
);
}
}
} catch (error: any) {
+46 -16
View File
@@ -82,6 +82,10 @@ import {
getNormalizedZoom,
} from "../scene";
import { migrateElements } from "./schema";
import type { SchemaMigrationRegistry } from "./schema";
import type {
AppState,
BinaryFiles,
@@ -243,7 +247,7 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
};
const restoreElementWithProperties = <
T extends Required<Omit<ExcalidrawElement, "customData">> & {
T extends Omit<ExcalidrawElement, "customData"> & {
customData?: ExcalidrawElement["customData"];
/** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][];
@@ -285,6 +289,10 @@ const restoreElementWithProperties = <
width: element.width || 0,
height: element.height || 0,
seed: element.seed ?? 1,
schemaState:
element.schemaState && typeof element.schemaState === "object"
? element.schemaState
: { tracks: {} },
groupIds: element.groupIds ?? [],
frameId: element.frameId ?? null,
roundness: element.roundness
@@ -505,6 +513,9 @@ export const restoreElement = (
case "embeddable":
return restoreElementWithProperties(element, {});
case "magicframe":
return restoreElementWithProperties(element, {
name: element.name ?? null,
});
case "frame":
return restoreElementWithProperties(element, {
name: element.name ?? null,
@@ -633,17 +644,25 @@ export const restoreElements = <T extends ExcalidrawElement>(
refreshDimensions?: boolean;
repairBindings?: boolean;
deleteInvisibleElements?: boolean;
schemaMigrationRegistry?: SchemaMigrationRegistry;
}
| undefined,
): CombineBrandsIfNeeded<T, OrderedExcalidrawElement> => {
const migratedTargetElements = migrateElements(
targetElements as readonly ExcalidrawElement[] | undefined | null,
{
schemaMigrationRegistry: opts?.schemaMigrationRegistry,
},
) as readonly T[] | undefined | null;
// used to detect duplicate top-level element ids
const existingIds = new Set<string>();
const targetElementsMap = arrayToMap(targetElements || []);
const targetElementsMap = arrayToMap(migratedTargetElements || []);
const existingElementsMap = existingElements
? arrayToMap(existingElements)
: null;
const restoredElements = syncInvalidIndices(
(targetElements || []).reduce((elements, element) => {
(migratedTargetElements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements,
// and causing issues if retained
if (element.type === "selection") {
@@ -953,10 +972,14 @@ export const restoreAppState = (
};
};
const restoreLibraryItem = (libraryItem: LibraryItem) => {
const restoreLibraryItem = (
libraryItem: LibraryItem,
opts?: { schemaMigrationRegistry?: SchemaMigrationRegistry },
) => {
const elements = restoreElements(
getNonDeletedElements(libraryItem.elements),
null,
{ schemaMigrationRegistry: opts?.schemaMigrationRegistry },
);
return elements.length ? { ...libraryItem, elements } : null;
};
@@ -964,17 +987,21 @@ const restoreLibraryItem = (libraryItem: LibraryItem) => {
export const restoreLibraryItems = (
libraryItems: ImportedDataState["libraryItems"] = [],
defaultStatus: LibraryItem["status"],
opts?: { schemaMigrationRegistry?: SchemaMigrationRegistry },
) => {
const restoredItems: LibraryItem[] = [];
for (const item of libraryItems) {
// migrate older libraries
if (Array.isArray(item)) {
const restoredItem = restoreLibraryItem({
status: defaultStatus,
elements: item,
id: randomId(),
created: Date.now(),
});
const restoredItem = restoreLibraryItem(
{
status: defaultStatus,
elements: item,
id: randomId(),
created: Date.now(),
},
opts,
);
if (restoredItem) {
restoredItems.push(restoredItem);
}
@@ -983,12 +1010,15 @@ export const restoreLibraryItems = (
LibraryItem,
"id" | "status" | "created"
>;
const restoredItem = restoreLibraryItem({
..._item,
id: _item.id || randomId(),
status: _item.status || defaultStatus,
created: _item.created || Date.now(),
});
const restoredItem = restoreLibraryItem(
{
..._item,
id: _item.id || randomId(),
status: _item.status || defaultStatus,
created: _item.created || Date.now(),
},
opts,
);
if (restoredItem) {
restoredItems.push(restoredItem);
}
+408
View File
@@ -0,0 +1,408 @@
import { DEFAULT_ELEMENT_PROPS } from "@excalidraw/common";
import { API } from "../tests/helpers/api";
import {
CORE_FRAME_SCHEMA_TRACK,
createSchemaMigrationRegistry,
type SchemaPlugin,
type SchemaMigration,
CORE_SUPPORTED_TRACKS,
migrateElements,
resolveTrackVersion,
SCHEMA_INITIAL_TRACK_VERSION,
SCHEMA_MIGRATIONS,
validateSchemaMigrations,
validateSchemaPlugins,
} from "./schema";
describe("schema migration", () => {
it("should migrate legacy frame backgrounds to transparent", () => {
const frame = {
...API.createElement({
type: "frame",
backgroundColor: "#ffc9c9",
}),
schemaState: { tracks: {} },
};
const migrated = migrateElements([frame])!;
expect(migrated[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor,
);
expect(migrated[0].schemaState.tracks[CORE_FRAME_SCHEMA_TRACK]).toBe(
CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
);
});
it("should handle elements without schemaState", () => {
const frameWithoutSchemaState = {
...API.createElement({
type: "frame",
backgroundColor: "#ffc9c9",
}),
schemaState: undefined,
} as any;
const textWithoutSchemaState = {
...API.createElement({
type: "text",
text: "",
}),
schemaState: undefined,
} as any;
const migrated = migrateElements([
frameWithoutSchemaState,
textWithoutSchemaState,
])!;
expect(migrated[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor,
);
expect(migrated[0].schemaState.tracks[CORE_FRAME_SCHEMA_TRACK]).toBe(
CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
);
expect(migrated[1].schemaState).toEqual({ tracks: {} });
});
it("should keep latest-track frame backgrounds unchanged", () => {
const frame = {
...API.createElement({
type: "frame",
backgroundColor: "#ffc9c9",
}),
schemaState: {
tracks: {
[CORE_FRAME_SCHEMA_TRACK]:
CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
},
},
};
const migrated = migrateElements([frame])!;
expect(migrated[0].backgroundColor).toBe("#ffc9c9");
expect(migrated[0].schemaState.tracks[CORE_FRAME_SCHEMA_TRACK]).toBe(
CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
);
});
it("should normalize legacy frame backgrounds", () => {
const frame = {
...API.createElement({
type: "frame",
backgroundColor: "#a5d8ff",
}),
schemaState: {
tracks: {
[CORE_FRAME_SCHEMA_TRACK]: SCHEMA_INITIAL_TRACK_VERSION,
},
},
};
const migrated = migrateElements([frame])!;
expect(migrated[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor,
);
});
it("should resolve invalid track versions to initial", () => {
expect(resolveTrackVersion(undefined)).toBe(SCHEMA_INITIAL_TRACK_VERSION);
expect(resolveTrackVersion(0)).toBe(SCHEMA_INITIAL_TRACK_VERSION);
expect(resolveTrackVersion(2)).toBe(2);
});
it("should have a valid migration registry configuration", () => {
expect(validateSchemaMigrations(SCHEMA_MIGRATIONS)).toEqual([]);
});
it("should reject invalid migration metadata", () => {
const invalidMigrations: SchemaMigration[] = [
{
id: "",
namespace: "core",
track: CORE_FRAME_SCHEMA_TRACK,
toVersion: 2.1,
title: "",
description: " ",
targetTypes: [],
apply: (element) => element,
},
{
id: "dup",
namespace: "core",
track: CORE_FRAME_SCHEMA_TRACK,
toVersion: 2.1,
title: "duplicate",
description: "duplicate version",
targetTypes: ["frame"],
apply: (element) => element,
},
{
id: "dup",
namespace: "core",
track: CORE_FRAME_SCHEMA_TRACK,
toVersion: 3,
title: "duplicate id",
description: "duplicate id",
targetTypes: ["frame"],
apply: (element) => element,
},
];
const errors = validateSchemaMigrations(invalidMigrations);
expect(errors.length).toBeGreaterThan(0);
expect(errors.join("\n")).toContain("integer version");
expect(errors.join("\n")).toContain("title must be non-empty");
expect(errors.join("\n")).toContain("non-empty description");
expect(errors.join("\n")).toContain("Duplicate schema migration id");
expect(errors.join("\n")).toContain("at least one target type");
});
it("should reject versions at or below initial", () => {
const errors = validateSchemaMigrations([
{
id: "invalid-start",
namespace: "core",
track: CORE_FRAME_SCHEMA_TRACK,
toVersion: SCHEMA_INITIAL_TRACK_VERSION,
title: "invalid start",
description: "bad version",
targetTypes: ["frame"],
apply: (element) => element,
},
]);
expect(errors.join("\n")).toContain("must be greater than 1");
});
it("should reject core track/version mismatch", () => {
const errors = validateSchemaMigrations([
{
id: "frame-v3",
namespace: "core",
track: CORE_FRAME_SCHEMA_TRACK,
toVersion: CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK] + 1,
title: "future migration",
description: "future migration for test",
targetTypes: ["frame"],
apply: (element) => element,
},
]);
expect(errors.join("\n")).toContain(
`Core supported track "${CORE_FRAME_SCHEMA_TRACK}" (${CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK]}) must match last migration version`,
);
});
it("should reject undeclared core tracks", () => {
const errors = validateSchemaMigrations([
{
id: "unknown-core-track",
namespace: "core",
track: "excalidraw.shape.unknown",
toVersion: 2,
title: "unknown core track",
description: "should require supported-track declaration",
targetTypes: ["rectangle"],
apply: (element) => element,
},
]);
expect(errors.join("\n")).toContain(
"must be declared in CORE_SUPPORTED_TRACKS",
);
});
it("should reject invalid plugin metadata", () => {
const errors = validateSchemaPlugins([
{
id: "",
migrations: [],
},
{
id: "dup",
migrations: [],
},
{
id: "dup",
migrations: [],
},
{
id: "core-overwrite",
migrations: [
{
id: "bad.core.migration",
namespace: "core",
track: CORE_FRAME_SCHEMA_TRACK,
toVersion: 2,
title: "bad",
description: "bad",
targetTypes: ["frame"],
apply: (element) => element,
},
],
},
]);
expect(errors.join("\n")).toContain("Schema plugin id must be non-empty");
expect(errors.join("\n")).toContain("Duplicate schema plugin id found");
expect(errors.join("\n")).toContain("cannot declare core migrations");
});
it("should not depend on temporary fields during migration", () => {
const frame = {
...API.createElement({
type: "frame",
backgroundColor: "#a5d8ff",
}),
schemaState: { tracks: {} },
};
const withTempField = {
...frame,
backgroundEnabled: false,
} as typeof frame & { backgroundEnabled: boolean };
const migratedBase = migrateElements([frame])!;
const migratedWithTempField = migrateElements([withTempField])!;
expect(migratedBase[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor,
);
expect(migratedWithTempField[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor,
);
});
it("should use per-element track hints", () => {
const frame = API.createElement({
type: "frame",
backgroundColor: "#ff0000",
});
const frameFromModernSource = {
...frame,
schemaState: {
tracks: {
[CORE_FRAME_SCHEMA_TRACK]:
CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
},
},
};
const migrated = migrateElements([frameFromModernSource])!;
expect(migrated[0].backgroundColor).toBe("#ff0000");
});
it("should migrate mixed-hint elements individually", () => {
const legacyFrame = {
...API.createElement({
type: "frame",
backgroundColor: "#ff0000",
}),
schemaState: { tracks: {} },
};
const modernFrame = API.createElement({
type: "frame",
backgroundColor: "#00ff00",
});
const modernFrameWithTrack = {
...modernFrame,
schemaState: {
tracks: {
[CORE_FRAME_SCHEMA_TRACK]:
CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
},
},
};
const migrated = migrateElements([legacyFrame, modernFrameWithTrack])!;
expect(migrated[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor,
);
expect(migrated[1].backgroundColor).toBe("#00ff00");
});
it("should preserve higher-than-supported track versions", () => {
const frame = API.createElement({
type: "frame",
backgroundColor: "#ff0000",
});
const futureFrame = {
...frame,
schemaState: {
tracks: {
[CORE_FRAME_SCHEMA_TRACK]:
CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK] + 1,
},
},
};
const migrated = migrateElements([futureFrame])!;
expect(migrated[0].schemaState.tracks[CORE_FRAME_SCHEMA_TRACK]).toBe(
CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK] + 1,
);
expect(migrated[0].backgroundColor).toBe("#ff0000");
});
it("should normalize invalid schema state and preserve unknown tracks", () => {
const rect = {
...API.createElement({ type: "rectangle" }),
schemaState: {
tracks: {
"host.myapp.card": 4,
[CORE_FRAME_SCHEMA_TRACK]: 0,
},
},
};
const migrated = migrateElements([rect])!;
expect(migrated[0].schemaState.tracks[CORE_FRAME_SCHEMA_TRACK]).toBe(
SCHEMA_INITIAL_TRACK_VERSION,
);
expect(migrated[0].schemaState.tracks["host.myapp.card"]).toBe(4);
});
it("should not run plugin migrations unless plugins are provided", () => {
const rect = API.createElement({
type: "rectangle",
backgroundColor: "#ffd8a8",
});
const plugin: SchemaPlugin = {
id: "myapp",
migrations: [
{
id: "host.myapp.rect.normalize.v2",
namespace: "host.myapp",
track: "host.myapp.rectangle",
toVersion: 2,
title: "normalize rect background",
description: "plugin migration for testing",
targetTypes: ["rectangle"],
apply: (element) =>
element.type === "rectangle"
? { ...element, backgroundColor: "#12b886" }
: element,
},
],
};
const migratedWithoutPlugin = migrateElements([rect])!;
const migratedWithPlugin = migrateElements([rect], {
schemaMigrationRegistry: createSchemaMigrationRegistry([plugin]),
})!;
expect(migratedWithoutPlugin[0].backgroundColor).toBe("#ffd8a8");
expect(
migratedWithoutPlugin[0].schemaState.tracks["host.myapp.rectangle"],
).toBe(undefined);
expect(migratedWithPlugin[0].backgroundColor).toBe("#12b886");
expect(
migratedWithPlugin[0].schemaState.tracks["host.myapp.rectangle"],
).toBe(2);
});
});
+443
View File
@@ -0,0 +1,443 @@
import {
CORE_FRAME_SCHEMA_TRACK,
SCHEMA_CORE_NAMESPACE,
SCHEMA_INITIAL_TRACK_VERSION,
CORE_SUPPORTED_TRACKS,
} from "@excalidraw/element/schema";
import type { SchemaNamespace, SchemaTrack } from "@excalidraw/element/schema";
import type { ExcalidrawElement } from "@excalidraw/element/types";
export {
CORE_FRAME_SCHEMA_TRACK,
CORE_SUPPORTED_TRACKS,
SCHEMA_CORE_NAMESPACE,
SCHEMA_INITIAL_TRACK_VERSION,
};
export type { SchemaNamespace, SchemaTrack };
/**
* Schema migration flow:
* 0) Compile schema config from core migrations + optional host plugins.
* - validate plugin metadata
* - validate migration ordering/metadata
* - derive per-track supported versions for this registry
* 1) Normalize element.schemaState.tracks (invalid/missing -> initial track version).
* 2) Iterate compiled migrations in declaration order.
* 3) For matching element types, apply only forward migrations that are
* supported by the current registry config (never re-run, never downgrade).
* 4) Stamp migrated track versions back onto each element.
*/
/** One migration step for a single track version bump. */
export type SchemaMigration = {
/** Stable unique id for validation and debugging. */
id: string;
/** Owner of the migration: core or a host namespace. */
namespace: SchemaNamespace;
/** Version line this migration belongs to. */
track: SchemaTrack;
/** Target version reached after applying this migration. */
toVersion: number;
/** Human-readable metadata for maintainers/reviewers. */
title: string;
description: string;
/** Which element types this migration may transform ("*" = all). */
targetTypes: readonly ExcalidrawElement["type"][] | "*";
/** Pure transform for a single element. */
apply: (element: ExcalidrawElement) => ExcalidrawElement;
};
/**
* Optional host-provided migration bundle.
* Plugins are additive and may only declare host namespace migrations.
*/
export type SchemaPlugin = {
/** Stable plugin id for diagnostics. */
id: string;
/** Host migration steps merged with core migrations into one registry. */
migrations: readonly SchemaMigration[];
};
/** Default plugin registry (intentionally empty in core). */
export const SCHEMA_PLUGINS: readonly SchemaPlugin[] = [];
export type SchemaMigrationRegistry = Readonly<{
/** Fully validated core + host migrations used for this run. */
migrations: readonly SchemaMigration[];
/** Latest supported version for each known track in this run. */
supportedTrackVersions: Readonly<Record<string, number>>;
}>;
export const SCHEMA_MIGRATIONS: readonly SchemaMigration[] = [
{
id: "core.frame.background.normalize.v2",
namespace: SCHEMA_CORE_NAMESPACE,
track: CORE_FRAME_SCHEMA_TRACK,
toVersion: CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
title: "Normalize legacy frame backgrounds",
description:
"Frames saved before frame track v2 must render without visible fill, so normalize backgroundColor to transparent on restore.",
targetTypes: ["frame"],
apply: (element) => {
if (element.type !== "frame") {
return element;
}
return {
...element,
backgroundColor: "transparent",
};
},
},
];
export const resolveTrackVersion = (trackVersion: unknown) => {
if (
Number.isInteger(trackVersion) &&
(trackVersion as number) >= SCHEMA_INITIAL_TRACK_VERSION
) {
return trackVersion as number;
}
return SCHEMA_INITIAL_TRACK_VERSION;
};
const normalizeSchemaTracks = (tracks: unknown) => {
if (!tracks || typeof tracks !== "object") {
return {} as Record<string, number>;
}
return Object.entries(tracks as Record<string, unknown>).reduce<
Record<string, number>
>((acc, [track, version]) => {
const normalizedVersion = resolveTrackVersion(version);
if (normalizedVersion >= SCHEMA_INITIAL_TRACK_VERSION) {
acc[track] = normalizedVersion;
}
return acc;
}, {});
};
const normalizeElementSchemaState = (
element: ExcalidrawElement,
): ExcalidrawElement["schemaState"] => {
const tracks = normalizeSchemaTracks(
(
element as ExcalidrawElement & {
schemaState?: ExcalidrawElement["schemaState"];
}
).schemaState?.tracks,
);
return {
tracks,
};
};
const ensureElementSchemaState = (element: ExcalidrawElement) => {
const normalizedSchemaState = normalizeElementSchemaState(element);
// Fast path: avoid reallocating when element already has normalized state.
if (element.schemaState === normalizedSchemaState) {
return element;
}
if (
element.schemaState &&
Object.keys(element.schemaState?.tracks || {}).length ===
Object.keys(normalizedSchemaState.tracks).length &&
Object.entries(normalizedSchemaState.tracks).every(
([track, version]) => element.schemaState?.tracks?.[track] === version,
)
) {
return element;
}
return {
...element,
schemaState: normalizedSchemaState,
};
};
const getTrackVersion = (element: ExcalidrawElement, track: SchemaTrack) => {
return resolveTrackVersion(element.schemaState.tracks[track]);
};
const withTrackVersion = (
element: ExcalidrawElement,
track: SchemaTrack,
version: number,
) => {
if (element.schemaState.tracks[track] === version) {
return element;
}
return {
...element,
schemaState: {
...element.schemaState,
tracks: {
...element.schemaState.tracks,
[track]: version,
},
},
};
};
const migrationMatchesElementType = (
migration: SchemaMigration,
element: ExcalidrawElement,
) => {
return (
migration.targetTypes === "*" ||
migration.targetTypes.includes(element.type)
);
};
export const validateSchemaMigrations = (
migrations: readonly SchemaMigration[],
) => {
const errors: string[] = [];
const seenIds = new Set<string>();
const previousVersionByTrack = new Map<string, number>();
for (const migration of migrations) {
if (!migration.id.trim()) {
errors.push("Migration id must be non-empty.");
}
if (seenIds.has(migration.id)) {
errors.push(`Duplicate schema migration id found: ${migration.id}.`);
}
seenIds.add(migration.id);
if (!migration.title.trim()) {
errors.push(`Migration "${migration.id}" title must be non-empty.`);
}
if (!migration.description.trim()) {
errors.push(
`Migration "${migration.id}" must include a non-empty description.`,
);
}
if (!Number.isInteger(migration.toVersion)) {
errors.push(`Migration "${migration.id}" must use an integer version.`);
}
if (migration.toVersion <= SCHEMA_INITIAL_TRACK_VERSION) {
errors.push(
`Migration "${migration.id}" version must be greater than ${SCHEMA_INITIAL_TRACK_VERSION}.`,
);
}
if (
migration.targetTypes !== "*" &&
(!migration.targetTypes.length ||
migration.targetTypes.some((type) => !type))
) {
errors.push(
`Migration "${migration.id}" must declare at least one target type.`,
);
}
const trackKey = `${migration.namespace}|${migration.track}`;
const previousVersion =
previousVersionByTrack.get(trackKey) ?? SCHEMA_INITIAL_TRACK_VERSION;
if (migration.toVersion <= previousVersion) {
errors.push(
`Migration "${migration.id}" must be ordered by increasing version within ${trackKey}.`,
);
}
previousVersionByTrack.set(trackKey, migration.toVersion);
if (
migration.namespace === SCHEMA_CORE_NAMESPACE &&
!migration.track.startsWith("excalidraw.")
) {
errors.push(
`Core migration "${migration.id}" must use an excalidraw.* track.`,
);
}
if (
migration.namespace === SCHEMA_CORE_NAMESPACE &&
!(migration.track in CORE_SUPPORTED_TRACKS)
) {
errors.push(
`Core migration "${migration.id}" track "${migration.track}" must be declared in CORE_SUPPORTED_TRACKS.`,
);
}
if (
migration.namespace !== SCHEMA_CORE_NAMESPACE &&
!migration.track.startsWith(`${migration.namespace}.`)
) {
errors.push(
`Host migration "${migration.id}" track must use namespace prefix ${migration.namespace}.`,
);
}
}
for (const [track, supportedVersion] of Object.entries(
CORE_SUPPORTED_TRACKS,
)) {
const migrationTrackKey = `${SCHEMA_CORE_NAMESPACE}|${track}`;
const lastDeclaredVersion =
previousVersionByTrack.get(migrationTrackKey) ??
SCHEMA_INITIAL_TRACK_VERSION;
if (lastDeclaredVersion !== supportedVersion) {
errors.push(
`Core supported track "${track}" (${supportedVersion}) must match last migration version (${lastDeclaredVersion}).`,
);
}
}
return errors;
};
export const validateSchemaPlugins = (plugins: readonly SchemaPlugin[]) => {
const errors: string[] = [];
const seenIds = new Set<string>();
for (const plugin of plugins) {
if (!plugin.id.trim()) {
errors.push("Schema plugin id must be non-empty.");
}
if (seenIds.has(plugin.id)) {
errors.push(`Duplicate schema plugin id found: ${plugin.id}.`);
}
seenIds.add(plugin.id);
for (const migration of plugin.migrations) {
if (migration.namespace === SCHEMA_CORE_NAMESPACE) {
errors.push(
`Schema plugin "${plugin.id}" cannot declare core migrations ("${migration.id}").`,
);
}
}
}
return errors;
};
const collectPluginMigrations = (plugins: readonly SchemaPlugin[]) =>
plugins.flatMap((plugin) => plugin.migrations);
/**
* Builds the registry "latest version" map:
* - core tracks come from CORE_SUPPORTED_TRACKS
* - host tracks are inferred from provided plugin migrations
*/
const getSupportedTrackVersions = (
migrations: readonly SchemaMigration[],
): Readonly<Record<string, number>> => {
const supportedTrackVersions: Record<string, number> = {
...CORE_SUPPORTED_TRACKS,
};
for (const migration of migrations) {
if (migration.namespace === SCHEMA_CORE_NAMESPACE) {
continue;
}
const currentSupportedVersion =
supportedTrackVersions[migration.track] ?? SCHEMA_INITIAL_TRACK_VERSION;
if (migration.toVersion > currentSupportedVersion) {
supportedTrackVersions[migration.track] = migration.toVersion;
}
}
return supportedTrackVersions;
};
export const createSchemaMigrationRegistry = (
plugins: readonly SchemaPlugin[] = SCHEMA_PLUGINS,
): SchemaMigrationRegistry => {
const pluginErrors = validateSchemaPlugins(plugins);
if (pluginErrors.length) {
throw new Error(
`Invalid schema plugin configuration:\n${pluginErrors.join("\n")}`,
);
}
const migrations = [
...SCHEMA_MIGRATIONS,
...collectPluginMigrations(plugins),
] as const;
const migrationErrors = validateSchemaMigrations(migrations);
if (migrationErrors.length) {
throw new Error(
`Invalid schema migration configuration:\n${migrationErrors.join("\n")}`,
);
}
return {
migrations,
supportedTrackVersions: getSupportedTrackVersions(migrations),
};
};
const CORE_SCHEMA_MIGRATION_REGISTRY = createSchemaMigrationRegistry();
/** Uses cached core config by default, recompiles when plugins are provided. */
const resolveSchemaMigrationRegistry = (
schemaMigrationRegistry: SchemaMigrationRegistry | undefined,
) => schemaMigrationRegistry || CORE_SCHEMA_MIGRATION_REGISTRY;
const migrateElement = (
element: ExcalidrawElement,
schemaMigrationRegistry: SchemaMigrationRegistry,
) => {
// Always migrate from a normalized per-element schema state.
let migratedElement = ensureElementSchemaState(element);
for (const migration of schemaMigrationRegistry.migrations) {
if (!migrationMatchesElementType(migration, migratedElement)) {
continue;
}
const currentTrackVersion = getTrackVersion(
migratedElement,
migration.track,
);
const supportedTrackVersion =
schemaMigrationRegistry.supportedTrackVersions[migration.track] ??
currentTrackVersion;
// Never re-run or downgrade.
if (currentTrackVersion >= migration.toVersion) {
continue;
}
// Preserve future data: ignore migrations newer than what this app supports.
if (migration.toVersion > supportedTrackVersion) {
continue;
}
// Apply transform, then stamp the element's track version.
migratedElement = withTrackVersion(
migration.apply(migratedElement),
migration.track,
migration.toVersion,
);
}
return migratedElement;
};
export const migrateElements = (
elements: readonly ExcalidrawElement[] | null | undefined,
opts?: {
schemaMigrationRegistry?: SchemaMigrationRegistry;
},
) => {
if (!elements) {
return elements;
}
const schemaMigrationRegistry = resolveSchemaMigrationRegistry(
opts?.schemaMigrationRegistry,
);
return elements.map((element) =>
migrateElement(element, schemaMigrationRegistry),
);
};
+1 -1
View File
@@ -30,7 +30,7 @@ export class HistoryDelta extends StoreDelta {
// as we always need to end up with a new version due to collaboration,
// approaching each undo / redo as a new user action
{
excludedProperties: new Set(["version", "versionNonce"]),
excludedProperties: new Set(["version", "versionNonce", "schemaState"]),
},
);
+2
View File
@@ -56,6 +56,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled,
showDeprecatedFonts,
renderScrollbars,
schemaPlugins,
} = props;
const canvasActions = props.UIOptions?.canvasActions;
@@ -149,6 +150,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled={aiEnabled !== false}
showDeprecatedFonts={showDeprecatedFonts}
renderScrollbars={renderScrollbars}
schemaPlugins={schemaPlugins}
>
{children}
</App>
@@ -4,7 +4,9 @@ import {
getTargetFrame,
isInvisiblySmallElement,
renderElement,
renderFrameBackground,
shouldApplyFrameClip,
isFrameElement,
} from "@excalidraw/element";
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
@@ -67,6 +69,14 @@ const _renderNewElementScene = ({
}
}
if (
isFrameElement(newElement) &&
appState.frameRendering.enabled &&
appState.frameRendering.outline
) {
renderFrameBackground(newElement, context, appState);
}
renderElement(
newElement,
elementsMap,
+134 -79
View File
@@ -4,26 +4,29 @@ import {
THEME,
throttleRAF,
} from "@excalidraw/common";
import { isElementLink } from "@excalidraw/element";
import { isElementLink, isFrameElement } from "@excalidraw/element";
import { createPlaceholderEmbeddableLabel } from "@excalidraw/element";
import { getBoundTextElement } from "@excalidraw/element";
import {
isEmbeddableElement,
isFrameLikeElement,
isIframeLikeElement,
isTextElement,
} from "@excalidraw/element";
import {
elementOverlapsWithFrame,
getContainingFrame,
getTargetFrame,
shouldApplyFrameClip,
} from "@excalidraw/element";
import { renderElement } from "@excalidraw/element";
import { renderElement, renderFrameBackground } from "@excalidraw/element";
import { getElementAbsoluteCoords } from "@excalidraw/element";
import type {
ElementsMap,
ExcalidrawFrameElement,
ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
@@ -276,6 +279,15 @@ const _renderStaticScene = ({
}
const groupsToBeAddedToFrame = new Set<string>();
// iframe-like elements are rendered in a separate top-layer pass.
const nonIframeVisibleElements = visibleElements.filter(
(el) => !isIframeLikeElement(el),
);
// Frame background to render right before a given element id.
const frameBackgroundByElementId = new Map<
NonDeletedExcalidrawElement["id"],
ExcalidrawFrameElement
>();
visibleElements.forEach((element) => {
if (
@@ -297,92 +309,135 @@ const _renderStaticScene = ({
const inFrameGroupsMap = new Map<string, boolean>();
if (
appState.frameRendering.enabled &&
(appState.frameRendering.outline || renderConfig.exportingFrame)
) {
// Precompute where each frame background should be emitted to avoid
// re-resolving containing frames during the paint loop.
const renderedFrameBackgrounds = new Set<string>();
if (
renderConfig.exportingFrame &&
isFrameElement(renderConfig.exportingFrame)
) {
renderFrameBackground(renderConfig.exportingFrame, context, appState, {
roundCorners: false,
});
renderedFrameBackgrounds.add(renderConfig.exportingFrame.id);
}
const maybeQueueFrameBackground = (
element: NonDeletedExcalidrawElement | ExcalidrawFrameLikeElement,
) => {
const frame = isFrameLikeElement(element)
? element
: getContainingFrame(element, elementsMap);
if (!isFrameElement(frame) || renderedFrameBackgrounds.has(frame.id)) {
return;
}
frameBackgroundByElementId.set(element.id, frame);
renderedFrameBackgrounds.add(frame.id);
};
nonIframeVisibleElements.forEach((element) => {
maybeQueueFrameBackground(element);
});
}
// Paint visible elements
visibleElements
.filter((el) => !isIframeLikeElement(el))
.forEach((element) => {
try {
const frameId = element.frameId || appState.frameToHighlight?.id;
nonIframeVisibleElements.forEach((element) => {
try {
const frameBackground = frameBackgroundByElementId.get(element.id);
if (frameBackground) {
renderFrameBackground(frameBackground, context, appState, {
roundCorners:
!renderConfig.exportingFrame ||
frameBackground.id !== renderConfig.exportingFrame.id,
});
}
const frameId = element.frameId || appState.frameToHighlight?.id;
if (
isTextElement(element) &&
element.containerId &&
elementsMap.has(element.containerId)
) {
// will be rendered with the container
return;
}
context.save();
if (
frameId &&
appState.frameRendering.enabled &&
appState.frameRendering.clip
) {
const frame = getTargetFrame(element, elementsMap, appState);
if (
isTextElement(element) &&
element.containerId &&
elementsMap.has(element.containerId)
) {
// will be rendered with the container
return;
}
context.save();
if (
frameId &&
appState.frameRendering.enabled &&
appState.frameRendering.clip
) {
const frame = getTargetFrame(element, elementsMap, appState);
if (
frame &&
shouldApplyFrameClip(
element,
frame,
appState,
elementsMap,
inFrameGroupsMap,
)
) {
frameClip(frame, context, renderConfig, appState);
}
renderElement(
frame &&
shouldApplyFrameClip(
element,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
frame,
appState,
);
} else {
renderElement(
element,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
inFrameGroupsMap,
)
) {
frameClip(frame, context, renderConfig, appState);
}
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
renderElement(
boundTextElement,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
}
context.restore();
if (!isExporting) {
renderLinkIcon(element, context, appState, elementsMap);
}
} catch (error: any) {
console.error(
error,
element.id,
element.x,
element.y,
element.width,
element.height,
renderElement(
element,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
} else {
renderElement(
element,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
}
});
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
renderElement(
boundTextElement,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
}
context.restore();
if (!isExporting) {
renderLinkIcon(element, context, appState, elementsMap);
}
} catch (error: any) {
console.error(
error,
element.id,
element.x,
element.y,
element.width,
element.height,
);
}
});
// render embeddables on top
visibleElements
+129 -34
View File
@@ -8,6 +8,7 @@ import {
isRTL,
isTestEnv,
getVerticalOffset,
isTransparent,
applyDarkModeFilter,
MIME_TYPES,
} from "@excalidraw/common";
@@ -23,6 +24,8 @@ import { getBoundTextElement, getContainerElement } from "@excalidraw/element";
import { getLineHeightInPx } from "@excalidraw/element";
import {
isArrowElement,
isFrameElement,
isFrameLikeElement,
isIframeLikeElement,
isInitializedImageElement,
isTextElement,
@@ -38,6 +41,8 @@ import { getElementAbsoluteCoords } from "@excalidraw/element";
import type {
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawFrameLikeElement,
ExcalidrawTextElementWithContainer,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
@@ -717,50 +722,140 @@ export const renderSceneToSvg = (
return;
}
// render elements
elements
.filter((el) => !isIframeLikeElement(el))
.forEach((element) => {
if (!element.isDeleted) {
if (
isTextElement(element) &&
element.containerId &&
elementsMap.has(element.containerId)
) {
// will be rendered with the container
return;
}
const nonIframeElements = elements.filter((el) => !isIframeLikeElement(el));
const frameBackgroundByElementId = new Map<
ExcalidrawElement["id"],
ExcalidrawFrameElement
>();
try {
const renderFrameBackgroundNode = (frame: ExcalidrawFrameElement) => {
if (
!frame ||
!frame.backgroundColor ||
isTransparent(frame.backgroundColor)
) {
return;
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
const cx = (x2 - x1) / 2 - (frame.x - x1);
const cy = (y2 - y1) / 2 - (frame.y - y1);
const degree = (180 * frame.angle) / Math.PI;
const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
rect.setAttribute(
"transform",
`translate(${frame.x + renderConfig.offsetX} ${
frame.y + renderConfig.offsetY
}) rotate(${degree} ${cx} ${cy})`,
);
rect.setAttribute("width", `${frame.width}px`);
rect.setAttribute("height", `${frame.height}px`);
const isDirectlyExportedFrame =
!!renderConfig.exportingFrame &&
frame.id === renderConfig.exportingFrame.id;
if (!isDirectlyExportedFrame) {
rect.setAttribute("rx", FRAME_STYLE.radius.toString());
rect.setAttribute("ry", FRAME_STYLE.radius.toString());
}
rect.setAttribute(
"fill",
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(frame.backgroundColor)
: frame.backgroundColor,
);
rect.setAttribute("stroke", "none");
svgRoot.appendChild(rect);
};
if (
renderConfig.frameRendering.enabled &&
(renderConfig.frameRendering.outline || renderConfig.exportingFrame)
) {
const renderedFrameBackgrounds = new Set<string>();
if (
renderConfig.exportingFrame &&
isFrameElement(renderConfig.exportingFrame)
) {
renderFrameBackgroundNode(renderConfig.exportingFrame);
renderedFrameBackgrounds.add(renderConfig.exportingFrame.id);
}
const maybeQueueFrameBackground = (
element: NonDeletedExcalidrawElement | ExcalidrawFrameLikeElement,
) => {
const frame = isFrameLikeElement(element)
? element
: getContainingFrame(element, elementsMap);
if (
!isFrameElement(frame) ||
renderedFrameBackgrounds.has(frame.id) ||
!frame.backgroundColor ||
isTransparent(frame.backgroundColor)
) {
return;
}
frameBackgroundByElementId.set(element.id, frame);
renderedFrameBackgrounds.add(frame.id);
};
nonIframeElements.forEach((element) => {
if (!element.isDeleted) {
maybeQueueFrameBackground(element);
}
});
}
// render elements
nonIframeElements.forEach((element) => {
if (!element.isDeleted) {
const frameBackground = frameBackgroundByElementId.get(element.id);
if (frameBackground) {
renderFrameBackgroundNode(frameBackground);
}
if (
isTextElement(element) &&
element.containerId &&
elementsMap.has(element.containerId)
) {
// will be rendered with the container
return;
}
try {
renderElementToSvg(
element,
elementsMap,
rsvg,
svgRoot,
files,
element.x + renderConfig.offsetX,
element.y + renderConfig.offsetY,
renderConfig,
);
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
renderElementToSvg(
element,
boundTextElement,
elementsMap,
rsvg,
svgRoot,
files,
element.x + renderConfig.offsetX,
element.y + renderConfig.offsetY,
boundTextElement.x + renderConfig.offsetX,
boundTextElement.y + renderConfig.offsetY,
renderConfig,
);
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
renderElementToSvg(
boundTextElement,
elementsMap,
rsvg,
svgRoot,
files,
boundTextElement.x + renderConfig.offsetX,
boundTextElement.y + renderConfig.offsetY,
renderConfig,
);
}
} catch (error: any) {
console.error(error);
}
} catch (error: any) {
console.error(error);
}
});
}
});
// render embeddables on top
elements
+33 -5
View File
@@ -169,6 +169,20 @@ const prepareElementsForRender = ({
return nextElements;
};
const addExportingFrameToElements = (
elements: readonly ExcalidrawElement[],
exportingFrame?: ExcalidrawFrameLikeElement | null,
) => {
if (
!exportingFrame ||
elements.some((element) => element.id === exportingFrame.id)
) {
return elements;
}
return [...elements, exportingFrame];
};
export const exportToCanvas = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
@@ -216,6 +230,14 @@ export const exportToCanvas = async (
exportWithDarkMode: appState.exportWithDarkMode,
frameRendering,
});
const elementsForLookup = addExportingFrameToElements(
elementsForRender,
exportingFrame,
);
const allElementsForLookup = addExportingFrameToElements(
elements,
exportingFrame,
);
if (exportingFrame) {
exportPadding = 0;
@@ -242,10 +264,10 @@ export const exportToCanvas = async (
canvas,
rc: rough.canvas(canvas),
elementsMap: toBrandedType<RenderableElementsMap>(
arrayToMap(elementsForRender),
arrayToMap(elementsForLookup),
),
allElementsMap: toBrandedType<NonDeletedSceneElementsMap>(
arrayToMap(syncInvalidIndices(elements)),
arrayToMap(syncInvalidIndices(allElementsForLookup)),
),
visibleElements: elementsForRender,
scale,
@@ -261,6 +283,7 @@ export const exportToCanvas = async (
},
renderConfig: {
canvasBackgroundColor: viewBackgroundColor,
exportingFrame,
imageCache,
renderGrid: false,
isExporting: true,
@@ -325,6 +348,10 @@ export const exportToSvg = async (
exportWithDarkMode,
frameRendering,
});
const elementsForLookup = addExportingFrameToElements(
elementsForRender,
exportingFrame,
);
if (exportingFrame) {
exportPadding = 0;
@@ -386,10 +413,10 @@ export const exportToSvg = async (
// frame clip paths
// ---------------------------------------------------------------------------
const frameElements = getFrameLikeElements(elements);
const frameElements = getFrameLikeElements(elementsForLookup);
if (frameElements.length) {
const elementsMap = arrayToMap(elements);
const elementsMap = arrayToMap(elementsForLookup);
for (const frame of frameElements) {
const clipPath = svgRoot.ownerDocument.createElementNS(
@@ -472,7 +499,7 @@ export const exportToSvg = async (
renderSceneToSvg(
elementsForRender,
toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)),
toBrandedType<RenderableElementsMap>(arrayToMap(elementsForLookup)),
rsvg,
svgRoot,
files || {},
@@ -483,6 +510,7 @@ export const exportToSvg = async (
exportWithDarkMode,
renderEmbeddables,
frameRendering,
exportingFrame,
canvasBackgroundColor: viewBackgroundColor,
embedsValidationStatus: renderEmbeddables
? new Map(
+3
View File
@@ -1,6 +1,7 @@
import type { UserIdleState, EditorInterface } from "@excalidraw/common";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeletedElementsMap,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
@@ -26,6 +27,7 @@ export type RenderableElementsMap = NonDeletedElementsMap &
export type StaticCanvasRenderConfig = {
canvasBackgroundColor: AppState["viewBackgroundColor"];
exportingFrame?: ExcalidrawFrameLikeElement | null;
// extra options passed to the renderer
// ---------------------------------------------------------------------------
imageCache: AppClassProperties["imageCache"];
@@ -46,6 +48,7 @@ export type SVGRenderConfig = {
exportWithDarkMode: boolean;
renderEmbeddables: boolean;
frameRendering: AppState["frameRendering"];
exportingFrame?: ExcalidrawFrameLikeElement | null;
canvasBackgroundColor: AppState["viewBackgroundColor"];
embedsValidationStatus: EmbedsValidationStatus;
/**
@@ -1020,6 +1020,9 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1054,6 +1057,9 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1216,6 +1222,9 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1271,6 +1280,9 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -1431,6 +1443,9 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1014066025,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1463,6 +1478,9 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1518,6 +1536,9 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -1572,6 +1593,9 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -1613,10 +1637,16 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"id0": {
"deleted": {
"index": "a2",
"schemaState": {
"tracks": {},
},
"version": 4,
},
"inserted": {
"index": "a0",
"schemaState": {
"tracks": {},
},
"version": 3,
},
},
@@ -1765,6 +1795,9 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1014066025,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1797,6 +1830,9 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1852,6 +1888,9 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -1906,6 +1945,9 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -1947,10 +1989,16 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"id0": {
"deleted": {
"index": "a2",
"schemaState": {
"tracks": {},
},
"version": 4,
},
"inserted": {
"index": "a0",
"schemaState": {
"tracks": {},
},
"version": 3,
},
},
@@ -2101,6 +2149,9 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -2156,6 +2207,9 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -2314,6 +2368,9 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -2369,6 +2426,9 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -2406,10 +2466,16 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"id0": {
"deleted": {
"isDeleted": true,
"schemaState": {
"tracks": {},
},
"version": 4,
},
"inserted": {
"isDeleted": false,
"schemaState": {
"tracks": {},
},
"version": 3,
},
},
@@ -2560,6 +2626,9 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -2592,6 +2661,9 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1014066025,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -2647,6 +2719,9 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -2701,6 +2776,9 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -2868,6 +2946,9 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -2902,6 +2983,9 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1014066025,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -2957,6 +3041,9 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -3011,6 +3098,9 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -3072,10 +3162,16 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"groupIds": [
"id9",
],
"schemaState": {
"tracks": {},
},
"version": 4,
},
"inserted": {
"groupIds": [],
"schemaState": {
"tracks": {},
},
"version": 3,
},
},
@@ -3084,10 +3180,16 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"groupIds": [
"id9",
],
"schemaState": {
"tracks": {},
},
"version": 4,
},
"inserted": {
"groupIds": [],
"schemaState": {
"tracks": {},
},
"version": 3,
},
},
@@ -3238,6 +3340,9 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"opacity": 60,
"roughness": 2,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#e03131",
"strokeStyle": "dotted",
@@ -3270,6 +3375,9 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"opacity": 60,
"roughness": 2,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1898319239,
"strokeColor": "#e03131",
"strokeStyle": "dotted",
@@ -3325,6 +3433,9 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -3379,6 +3490,9 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -3411,10 +3525,16 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"updated": {
"id3": {
"deleted": {
"schemaState": {
"tracks": {},
},
"strokeColor": "#e03131",
"version": 4,
},
"inserted": {
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"version": 3,
},
@@ -3437,10 +3557,16 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"id3": {
"deleted": {
"backgroundColor": "#a5d8ff",
"schemaState": {
"tracks": {},
},
"version": 5,
},
"inserted": {
"backgroundColor": "transparent",
"schemaState": {
"tracks": {},
},
"version": 4,
},
},
@@ -3462,10 +3588,16 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"id3": {
"deleted": {
"fillStyle": "cross-hatch",
"schemaState": {
"tracks": {},
},
"version": 6,
},
"inserted": {
"fillStyle": "solid",
"schemaState": {
"tracks": {},
},
"version": 5,
},
},
@@ -3486,10 +3618,16 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"updated": {
"id3": {
"deleted": {
"schemaState": {
"tracks": {},
},
"strokeStyle": "dotted",
"version": 7,
},
"inserted": {
"schemaState": {
"tracks": {},
},
"strokeStyle": "solid",
"version": 6,
},
@@ -3512,10 +3650,16 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"id3": {
"deleted": {
"roughness": 2,
"schemaState": {
"tracks": {},
},
"version": 8,
},
"inserted": {
"roughness": 1,
"schemaState": {
"tracks": {},
},
"version": 7,
},
},
@@ -3537,10 +3681,16 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"id3": {
"deleted": {
"opacity": 60,
"schemaState": {
"tracks": {},
},
"version": 9,
},
"inserted": {
"opacity": 100,
"schemaState": {
"tracks": {},
},
"version": 8,
},
},
@@ -3573,6 +3723,9 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"fillStyle": "cross-hatch",
"opacity": 60,
"roughness": 2,
"schemaState": {
"tracks": {},
},
"strokeColor": "#e03131",
"strokeStyle": "dotted",
"version": 4,
@@ -3582,6 +3735,9 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"fillStyle": "solid",
"opacity": 100,
"roughness": 1,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"version": 3,
@@ -3732,6 +3888,9 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 238820263,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -3764,6 +3923,9 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -3819,6 +3981,9 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -3873,6 +4038,9 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -3906,10 +4074,16 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"id3": {
"deleted": {
"index": "Zz",
"schemaState": {
"tracks": {},
},
"version": 4,
},
"inserted": {
"index": "a1",
"schemaState": {
"tracks": {},
},
"version": 3,
},
},
@@ -4058,6 +4232,9 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1014066025,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -4090,6 +4267,9 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -4145,6 +4325,9 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -4199,6 +4382,9 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -4232,10 +4418,16 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"id3": {
"deleted": {
"index": "Zz",
"schemaState": {
"tracks": {},
},
"version": 4,
},
"inserted": {
"index": "a1",
"schemaState": {
"tracks": {},
},
"version": 3,
},
},
@@ -4387,6 +4579,9 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -4419,6 +4614,9 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 238820263,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -4474,6 +4672,9 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -4528,6 +4729,9 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -4589,10 +4793,16 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"groupIds": [
"id9",
],
"schemaState": {
"tracks": {},
},
"version": 4,
},
"inserted": {
"groupIds": [],
"schemaState": {
"tracks": {},
},
"version": 3,
},
},
@@ -4601,10 +4811,16 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"groupIds": [
"id9",
],
"schemaState": {
"tracks": {},
},
"version": 4,
},
"inserted": {
"groupIds": [],
"schemaState": {
"tracks": {},
},
"version": 3,
},
},
@@ -4632,24 +4848,36 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"id0": {
"deleted": {
"groupIds": [],
"schemaState": {
"tracks": {},
},
"version": 5,
},
"inserted": {
"groupIds": [
"id9",
],
"schemaState": {
"tracks": {},
},
"version": 4,
},
},
"id3": {
"deleted": {
"groupIds": [],
"schemaState": {
"tracks": {},
},
"version": 5,
},
"inserted": {
"groupIds": [
"id9",
],
"schemaState": {
"tracks": {},
},
"version": 4,
},
},
@@ -5675,6 +5903,9 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -5707,6 +5938,9 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 400692809,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -5762,6 +5996,9 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -5816,6 +6053,9 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -6899,6 +7139,9 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -6933,6 +7176,9 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 238820263,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -6988,6 +7234,9 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -7042,6 +7291,9 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -7125,10 +7377,16 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"groupIds": [
"id12",
],
"schemaState": {
"tracks": {},
},
"version": 4,
},
"inserted": {
"groupIds": [],
"schemaState": {
"tracks": {},
},
"version": 3,
},
},
@@ -7137,10 +7395,16 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"groupIds": [
"id12",
],
"schemaState": {
"tracks": {},
},
"version": 4,
},
"inserted": {
"groupIds": [],
"schemaState": {
"tracks": {},
},
"version": 3,
},
},
@@ -9830,6 +10094,9 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -9862,6 +10129,9 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -9894,6 +10164,9 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -9955,6 +10228,9 @@ exports[`contextMenu element > shows context menu for element > [end of test] un
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -35,6 +35,9 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"roundness": {
"type": 2,
},
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"startArrowhead": null,
"startBinding": null,
@@ -71,6 +74,9 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -105,6 +111,9 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -150,6 +159,9 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"polygon": false,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"startArrowhead": null,
"startBinding": null,
@@ -186,6 +198,9 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`export > export svg-embedded scene > svg-embdedded scene export output 1`] = `
"<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36"><!-- svg-source:excalidraw --><metadata><!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nHVTS27bMFx1MDAxMN33XHUwMDE0grItXHUwMDEy2UW68C7NXHUwMDA3zVwiXdRcdTAwMDW6KLpgxLE0ME1cdTAwMTLkKLZrXHUwMDE4yDG661x1MDAxNXOEXGZpVTTlRFx1MDAwMlxi8M3vzZvh7kNRlLS1UM6KXHUwMDEyNrVQKJ1Yl1x1MDAxZlx1MDAwM/5cdTAwMDTOo9Fsmsa7N52ro2dLZGdcdTAwMTdcdTAwMTfKcEBrPM0+VVV1XGJcdTAwMDJcdTAwMDUr0OTZ7Vx1MDAxN9+LYlx1MDAxN0+2oFxmoVfRLVx1MDAwMv/rXHUwMDEybCihXHUwMDFihqrhts1ua5TUMjL5PEAtYNNSjlx03SjIXHUwMDAyPTmzhGujjFx1MDAwYlx1MDAxNc8mXHUwMDEw/lT0UdTLxplOy8GHnNDeXG7HzSS/XHUwMDA1KjWnbczOerBa5ajGz57idIS/XHUwMDE3xUWbVoNcdTAwMGaCTVx1MDAwNtRYUSOF5idV6lwiMLT3Mmr7O3FyYlx1MDAwNfdBXFzdKTXAqCVsxmBssa+WXHUwMDE5PIDMXHUwMDE4pOGfYN+MrnN50d/w3CmmWFxi5SFcdFx1MDAxYlxu3qadyIp2VlxuXHUwMDFh1VWol2M/3rPlXHUwMDFiuePesKIv//4+XHUwMDFmjchomuOfQHBaZeidWKFcbppeZimuXHUwMDE0NqHPUsHiaNTcLCHv92AmY5O15nxcdTAwMDI1uFPhjcNcdTAwMDa1UD/epCc6Mt/BXHUwMDFmXGKS6+C4c/g6bPP59DJcdTAwMWH2fMZZl8LaObFebD28Kd5cdTAwMDeUo1ZcdTAwMGZcdTAwMTiBTW1G6MFIuNXiUY11LJ9cdTAwMTDWX07X/2xcdTAwMTG/nng/godOXHUwMDExznnUNfFcdTAwMWWEee5cdTAwMDK/feTHb1x1MDAwM3po/1xua2IoWiJ9<!-- payload-end --></metadata><defs><style class="style-fonts">
"<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36"><!-- svg-source:excalidraw --><metadata><!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nHVTwW7bMFxmve8rXGb3OrROhu6QW7t1WFx1MDAwZt1hXHUwMDE5sMOwg2oxNmFFXHUwMDEyJLpJXHUwMDE2XHUwMDA06Gfstl/sJ5RSXFwrcrtcdTAwMThcYqBHinzke9q/K4qSdlx1MDAxNspFUcK2XHUwMDE2XG6lXHUwMDEzm/J9wFx1MDAxZsB5NJpD83j2pnd1zGyJ7OLiQlx1MDAxOb7QXHUwMDFhT4tcdTAwMGZVVVx1MDAxZC+BgjVo8pz2i89FsY//XHUwMDFjQVx1MDAxOa5exbRcYrz0JdhSQrdcZlXjaZedNiipZWT2cYRawKalXHUwMDFjXHUwMDEzulGQXfTkTFx1MDAwN5+MMi50PJtB+FLTe1F3jTO9lmNcdTAwMGU5ob1cdTAwMTWOh0l5K1RqSbtYnffB2yonPX5cdTAwMGVcdTAwMTTnXHUwMDEz/H+3uGnTavBhYbNcdTAwMTE1VtRIYfhZlaZcYlxm7a2Mu/2dODmxhtuwXFzdKzXCqCVsp2BcdTAwMWNx6JZcdTAwMDU8gMxcdTAwMTgk8Vx1MDAxM+brXHUwMDE21mJJgsIkL8JcdTAwMTZxV3VcdTAwMTdq7lx1MDAwZlx1MDAwM3iYXHUwMDE2+mZ0nWuC/jObhWLflVBcdTAwMWWSXHUwMDFhgeVNMlLGtLdS0ISsQt1N89ic3Vx1MDAxYrWj2ViGp39/XHUwMDFmT3Q1mpb4J1x1MDAxMJxXXHUwMDE5+kWsUVx1MDAwNSEus1x1MDAxMldcbpuwnFLB6sRcdTAwMWY8LCE/ijFMxqZozfVcdTAwMDRqcK/VMlx1MDAwZVx1MDAxYtRC/XiTnujJfFx1MDAwN38kSK6H08nh6/hcdTAwMDTO55cxXHUwMDEwVIhcdTAwMDYphbW5XmxcIpSTUY9cdTAwMTiBTWNG6M5IuNHiXk33WD4gbK5fv5mzVfxccsRcdTAwMDdcdO56RbhkqWtiXHUwMDFmjC6JXHUwMDBlXHRcdTAwMGZcblx1MDAwNujwXGZOizVKIn0=<!-- payload-end --></metadata><defs><style class="style-fonts">
</style></defs><rect x="0" y="0" width="36" height="36" fill="#ffffff"></rect><g transform="translate(10 10) rotate(0 8 8)" data-id="A"><text x="0" y="17.619999999999997" font-family="Excalifont, Xiaolai, sans-serif, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">😀</text></g></svg>"
`;
File diff suppressed because it is too large Load Diff
@@ -18,6 +18,9 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -50,6 +53,9 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1505387817,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -82,6 +88,9 @@ exports[`move element > rectangle 5`] = `
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -119,6 +128,9 @@ exports[`move element > rectangles with binding arrow 5`] = `
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -156,6 +168,9 @@ exports[`move element > rectangles with binding arrow 6`] = `
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1116226695,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -211,6 +226,9 @@ exports[`move element > rectangles with binding arrow 7`] = `
"roundness": {
"type": 2,
},
"schemaState": {
"tracks": {},
},
"seed": 23633383,
"startArrowhead": null,
"startBinding": {
@@ -37,6 +37,9 @@ exports[`multi point mode in linear elements > arrow 3`] = `
"roundness": {
"type": 2,
},
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"startArrowhead": null,
"startBinding": null,
@@ -88,6 +91,9 @@ exports[`multi point mode in linear elements > line 3`] = `
"polygon": false,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"startArrowhead": null,
"startBinding": null,
File diff suppressed because it is too large Load Diff
@@ -33,6 +33,9 @@ exports[`select single element on the scene > arrow 1`] = `
"roundness": {
"type": 2,
},
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"startArrowhead": null,
"startBinding": null,
@@ -80,6 +83,9 @@ exports[`select single element on the scene > arrow escape 1`] = `
"polygon": false,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"startArrowhead": null,
"startBinding": null,
@@ -114,6 +120,9 @@ exports[`select single element on the scene > diamond 1`] = `
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -146,6 +155,9 @@ exports[`select single element on the scene > ellipse 1`] = `
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -178,6 +190,9 @@ exports[`select single element on the scene > rectangle 1`] = `
"opacity": 100,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -31,6 +31,9 @@ exports[`repairing bindings > should strip arrow binding if repair throws 1`] =
],
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
@@ -78,6 +81,9 @@ exports[`restoreElements > should restore arrow element correctly 1`] = `
],
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
@@ -118,6 +124,9 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
"roundness": {
"type": 3,
},
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "red",
"strokeStyle": "dashed",
@@ -156,6 +165,9 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
"roundness": {
"type": 3,
},
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "red",
"strokeStyle": "dashed",
@@ -194,6 +206,9 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
"roundness": {
"type": 3,
},
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "red",
"strokeStyle": "dashed",
@@ -237,6 +252,9 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = `
"pressures": [],
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"simulatePressure": true,
"strokeColor": "#1e1e1e",
@@ -283,6 +301,9 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
"polygon": false,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
@@ -330,6 +351,9 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
"polygon": false,
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
@@ -370,6 +394,9 @@ exports[`restoreElements > should restore text element correctly passing value f
"originalText": "text",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -412,6 +439,9 @@ exports[`restoreElements > should restore text element correctly with unknown fo
"originalText": "",
"roughness": 1,
"roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
+111 -1
View File
@@ -1,7 +1,12 @@
import { pointFrom } from "@excalidraw/math";
import { vi } from "vitest";
import { DEFAULT_SIDEBAR, FONT_FAMILY, ROUNDNESS } from "@excalidraw/common";
import {
DEFAULT_ELEMENT_PROPS,
DEFAULT_SIDEBAR,
FONT_FAMILY,
ROUNDNESS,
} from "@excalidraw/common";
import { newElementWith } from "@excalidraw/element";
import * as sizeHelpers from "@excalidraw/element";
@@ -19,8 +24,10 @@ import type { NormalizedZoomValue } from "@excalidraw/excalidraw/types";
import { API } from "../helpers/api";
import * as restore from "../../data/restore";
import { createSchemaMigrationRegistry } from "../../data/schema";
import { getDefaultAppState } from "../../appState";
import type { SchemaPlugin } from "../../data/schema";
import type { ImportedDataState } from "../../data/types";
describe("restoreElements", () => {
@@ -81,6 +88,109 @@ describe("restoreElements", () => {
).toEqual([expect.objectContaining({ isDeleted: true })]);
});
it("should restore frame element", () => {
const frame = API.createElement({
type: "frame",
backgroundColor: "#ffc9c9",
});
const restoredFrame = restore.restoreElements(
[frame],
null,
)[0] as ExcalidrawElement;
expect(restoredFrame.type).toBe("frame");
expect(restoredFrame.backgroundColor).toBe("#ffc9c9");
});
it("should restore library frame backgrounds by default", () => {
const frame = API.createElement({
type: "frame",
backgroundColor: "#a5d8ff",
});
const restoredLibraryItems = restore.restoreLibraryItems(
[
{
id: "library-item-1",
status: "published",
elements: [frame],
created: Date.now(),
},
],
"published",
);
expect(
(restoredLibraryItems[0].elements[0] as ExcalidrawElement)
.backgroundColor,
).toBe("#a5d8ff");
});
it("should normalize legacy library frame backgrounds when schema is old", () => {
const frame = API.createElement({
type: "frame",
backgroundColor: "#a5d8ff",
});
const legacyFrame = {
...frame,
schemaState: { tracks: {} },
};
const restoredLibraryItems = restore.restoreLibraryItems(
[
{
id: "library-item-1",
status: "published",
elements: [legacyFrame],
created: Date.now(),
},
],
"published",
);
expect(
(restoredLibraryItems[0].elements[0] as ExcalidrawElement)
.backgroundColor,
).toBe(DEFAULT_ELEMENT_PROPS.backgroundColor);
});
it("should apply schema plugins on restore boundaries when provided", () => {
const rect = API.createElement({
type: "rectangle",
backgroundColor: "#ffd8a8",
});
const plugin: SchemaPlugin = {
id: "myapp",
migrations: [
{
id: "host.myapp.rect.normalize.v2",
namespace: "host.myapp",
track: "host.myapp.rectangle",
toVersion: 2,
title: "normalize rectangle background",
description: "plugin migration for restore test",
targetTypes: ["rectangle"],
apply: (element) =>
element.type === "rectangle"
? { ...element, backgroundColor: "#12b886" }
: element,
},
],
};
const restoredWithoutPlugin = restore.restoreElements([rect], null);
const restoredWithPlugin = restore.restoreElements([rect], null, {
schemaMigrationRegistry: createSchemaMigrationRegistry([plugin]),
});
expect(restoredWithoutPlugin[0].backgroundColor).toBe("#ffd8a8");
expect(restoredWithPlugin[0].backgroundColor).toBe("#12b886");
expect(
restoredWithPlugin[0].schemaState.tracks["host.myapp.rectangle"],
).toBe(2);
});
it("should restore text element correctly passing value for each attribute", () => {
const textElement = API.createElement({
type: "text",
+3
View File
@@ -24,6 +24,9 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
index: null,
seed: 1041657908,
version: 120,
schemaState: {
tracks: {},
},
versionNonce: 1188004276,
isDeleted: false,
boundElements: null,
+3
View File
@@ -188,6 +188,7 @@ export class API {
roundness?: ExcalidrawGenericElement["roundness"];
roughness?: ExcalidrawGenericElement["roughness"];
opacity?: ExcalidrawGenericElement["opacity"];
schemaState?: ExcalidrawGenericElement["schemaState"];
// text props
text?: T extends "text" ? ExcalidrawTextElement["text"] : never;
fontSize?: T extends "text" ? ExcalidrawTextElement["fontSize"] : never;
@@ -246,6 +247,7 @@ export class API {
| "groupIds"
| "link"
| "updated"
| "schemaState"
> = {
seed: 1,
x,
@@ -276,6 +278,7 @@ export class API {
opacity: rest.opacity ?? appState.currentItemOpacity,
boundElements: rest.boundElements ?? null,
locked: rest.locked ?? false,
...(rest.schemaState ? { schemaState: rest.schemaState } : {}),
};
switch (type) {
case "rectangle":
+109
View File
@@ -0,0 +1,109 @@
import { vi } from "vitest";
import { CURSOR_TYPE } from "@excalidraw/common";
import { getElementAbsoluteCoords } from "@excalidraw/element";
import { Excalidraw } from "../index";
import { getLinkHandleFromCoords } from "../components/hyperlink/helpers";
import { API } from "./helpers/api";
import { Pointer } from "./helpers/ui";
import { act, GlobalTestState, render, waitFor } from "./test-utils";
import type { ExcalidrawProps } from "../types";
describe("laser tool interactions", () => {
const h = window.h;
const mouse = new Pointer("mouse");
it("opens links while using the laser tool", async () => {
const onLinkOpenSpy = vi.fn();
const onLinkOpen: NonNullable<ExcalidrawProps["onLinkOpen"]> = (
...args
) => {
onLinkOpenSpy(...args);
args[1].preventDefault();
};
await render(<Excalidraw onLinkOpen={onLinkOpen} />);
const linkedRect = API.createElement({
type: "rectangle",
x: 20,
y: 20,
width: 120,
height: 90,
});
API.setElements([linkedRect]);
API.updateElement(linkedRect, {
link: "https://example.com",
});
act(() => {
h.app.setActiveTool({ type: "laser" });
});
const elementsMap = h.app.scene.getNonDeletedElementsMap();
const currentRect = API.getElement(linkedRect);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(currentRect, elementsMap);
const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
[x1, y1, x2, y2],
currentRect.angle,
h.state,
);
const iconCenterX = linkX + linkWidth / 2;
const iconCenterY = linkY + linkHeight / 2;
mouse.moveTo(iconCenterX, iconCenterY);
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
CURSOR_TYPE.POINTER,
);
mouse.clickAt(iconCenterX, iconCenterY);
expect(onLinkOpenSpy).toHaveBeenCalledTimes(1);
});
it("activates embeddables on center click while using the laser tool", async () => {
await render(<Excalidraw />);
const embeddable = API.createElement({
type: "embeddable",
x: 40,
y: 40,
width: 300,
height: 180,
});
API.setElements([embeddable]);
API.updateElement(embeddable, {
link: "https://www.youtube.com/watch?v=gkGMXY0wekg",
});
act(() => {
h.app.setActiveTool({ type: "laser" });
});
const handleIframeLikeCenterClickSpy = vi.spyOn(
h.app as unknown as {
handleIframeLikeCenterClick: () => void;
},
"handleIframeLikeCenterClick",
);
const centerX = embeddable.x + embeddable.width / 2;
const centerY = embeddable.y + embeddable.height / 2;
mouse.moveTo(centerX, centerY);
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
CURSOR_TYPE.POINTER,
);
mouse.clickAt(centerX, centerY);
expect(handleIframeLikeCenterClickSpy).toHaveBeenCalled();
await waitFor(() => {
expect(h.state.activeEmbeddable?.element.id).toBe(embeddable.id);
expect(h.state.activeEmbeddable?.state).toBe("active");
});
handleIframeLikeCenterClickSpy.mockRestore();
});
});
+3
View File
@@ -359,6 +359,7 @@ describe("Basic lasso selection tests", () => {
...e,
angle: e.angle as Radians,
index: null,
schemaState: { tracks: {} },
} as ExcalidrawElement),
);
@@ -1044,6 +1045,7 @@ describe("Special cases", () => {
...e,
index: null,
angle: e.angle as Radians,
schemaState: { tracks: {} },
})) as ExcalidrawElement[];
h.elements = elements;
@@ -1763,6 +1765,7 @@ describe("Special cases", () => {
...e,
index: null,
angle: e.angle as Radians,
schemaState: { tracks: {} },
})) as ExcalidrawElement[];
h.elements = elements;
@@ -167,6 +167,12 @@ describe("library", () => {
});
});
it("should serialize library payload", () => {
const serialized = serializeLibraryAsJSON([]);
const parsed = JSON.parse(serialized);
expect(parsed.type).toBe("excalidrawlib");
});
// NOTE: mocked to test logic, not actual drag&drop via UI
it("drop library item onto canvas", async () => {
expect(h.elements).toEqual([]);
@@ -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/TMFx1MDAxNH7fr4jCK2JJ2q6lb4VxmYRAokiTmPbgxSeJVcfObKdcdTAwMTeq/nd8nDZOwsRcdTAwMDNPXGaaSpF8rt/5/Fx1MDAxZKX7iyBcYs2ugnBcdTAwMWWEsE1cdGdUkU34XHUwMDEy7WtQmklhXYk7a1mr1EVcdTAwMTbGVPPLSy5tQiG1mY+iKGqSgENcdMJoXHUwMDFidmfPQbB3b+thXHUwMDE0U9e36vHLXCJbMUGTx7ScfrpefPjuUl3Q1saM43F73mH30bQ9b1x1MDAxODWFtcVR1NpcbmB5YVx1MDAwNkZcInKOWL1FXHUwMDFiJVfwVnKpXHUwMDEwyIvIPb71XHUwMDAzSVe5krWgPiaeXHUwMDEw8pD5mIxxvjQ73rBA0qJWXHUwMDEwXHUwMDBlOtyeIFx1MDAwZextnpaWZZ9lW+aFXHUwMDAwrXs5slwiKTO7wVSIr7qhjt17j0qRXHUwMDEybpBeUXPeLSzosXDPYblcdTAwMDckOiSd8TVcdTAwMDB13cbx1WT6Opq1XHUwMDFlr4M46dCZXHUwMDE2UJKlIVx1MDAwNoc63TLKSVlcIrHn/nA0XHUwMDFlhqU+S+GEXHUwMDE0x7NZXHUwMDE0jZPplcemr62EjMOSXHUwMDExrsHfXHUwMDBmjvPOy6s3Ul1R0iR5XG45XHUwMDEzq2GclezqidqnXHUwMDE1oIyUUtDwolx1MDAwM/ss4L9YwPFZwD1cdTAwMDFcdTAwMDPnrNLwPFx1MDAwNFx1MDAxY1x1MDAwM/7+b1x1MDAwMSdnXHUwMDAx91x1MDAwNGxgazq3LYVZslx1MDAxZujpXGaO1vekZFx1MDAxYy9n4ktgqi0hXHUwMDE1y5kgPOjXOpm//T5cZk9cdTAwMGLOcmQ75JB1XFyWO8PsX57WbWTlvalFRZhcdTAwMDD1q1x1MDAwZSxcdTAwMGbwsd2RV4nHTGojv4JuRnSMnDf3mWzu6Ly5XHLmP91cXFx1MDAwZvxf2lxc+3bqXG5JVfVvOFxcM9i8eWJTMvfgN9vdNm5cblx1MDAxY0Vw+Fx0stl5kyJ9<!-- payload-end --></metadata><defs><style class="style-fonts">
@font-face { font-family: Excalifont; src: url(data:font/woff2;base64,d09GMgABAAAAAAf0AA4AAAAADbQAAAegAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhYbgjgcNAZgAHwRCAqPYItcCxoAATYCJAMwBCAFgxgHIBujClGUblKX7Edh3Pg8OJbt5MlDFPwWk6FmJ08terdayeDx9O8Fa5eunTJ1ykAUGuAvgOF5bPfuH0nXrvQrNkFoNlERBLIQDd/mj2kP7fdg8avL4M/sm4cbu4L2i1VQVjShz/+90/YE3IJFmOC+f0Z1Xhs3bVxiJZZGa1IgY5sHCJ2EM2iBndkLWsZvFxCAhRBGCknKEOQenHCpmE2fVQTl1atzWyjvzs3aQPnWd20PJQZeG3mQp1lnLimFBAhOKFIQSrGa+vOB69gLCRkjLr4NIjd5rXiz8hhu+LIe9DZCgkJjmZEy34MkhXNBJ8GhNMMnJUgmSYA0AowAwT2zRor05iZAeN/80c4UkYlgSTOaKgOQCUOSE5F+z6+DXqndDTk4CEHP2v3/aKw7lXQXgAUAQP5+kgIpTYMHT9MEjIyWxJ5V5jAzgnrNtNBKWx101tXee86rA5O1f1fiIzEtJsS4GBMjYlikIhEvwwXDjYgiApUFdjgCFAA1m2GorwgdA5a6fzxxX0ei+Eqw6MspHfbmte7eLSh2FSEREgnySedVN+VfsbKAVQygCrGALXgDQoSA0kZqfB2OiyyaZolc/i3tnXswySlmifL9eo1ReyWQcYadAK/DD9c80uaI4pamykJM2hp/MF2uj+teZm9LiRlrltgBlcVYlWVWf+WPTpVJj40Kz9Gvi5p21Zcov8bKEL+5Uirz+j2Hjzr+99zosad2XtbpfsbKgyIQ3RJHxrTH1ysQQAjTUaii13B+OTtxrFoLubjpMYtit2uul6DdSPvKaQl/9FkppthYCXEvjoEdStVVjti2fUQf34q9YjychmIsxCAjKkj0GW4mIhlX1snPOEVRVq4UMRQwkzJNhK143IsBOlghz9meJAGf4apbAmYRMj2hsj5X1OGDPuIKXoOyN8yIMYbeusXTa9f4a+1UvYG9FgQxzoQBUoSPD5/ezAuQIP8s8IyVrUGrwSxC95WPAdlx3EkkQq5yNjcaKAAZ4LEHheIMvgHV3fe6X41/6rzCiMBwSxrnvl9RcR0gK5fy4jDVigBySy99iwlppT9aTlWIMSMKBx9WkbupSbZXNC/CKUC6z53jA0mS7340zruutV7xYguW6p/01tYHOQZQ7ptAagFiLAzFWqyWUwN8/eNGSyn2elPguvF9At0XUNlNv4t202nq+EnkKvdW6aLVlJUxhpXYUqMsn5svp9eoTpvKKf6BY8QwAkaJWYSggEuximEYiCVE0TjN5ojkVoznAP0GytsVT1SesrOn/s8LsMpVpXtSc6To4sT6XIl1q0jH96rd5/g5vmi35+y6eeYPNqjBGHtX0nKFGywfPr19aO9Gh/Q1sWZGuqckOoOJmlK2pSPTSgm/46h389ap+VmOnxsPdspP82Xyy2l7u6i2908pVjRkDbEddOKOprOPJ/94eJ59fi6jDtB6VbzuwXb5puJcqcKnUeM72fFnIlssAv/Z6uMq2s1Jd8qpd7DZo538w5PG7cHm2TU2TD41PjYiaUSikYspa/kjJ84AdgqXsT226/L+fW6j99QmJZlWN99tfcdbUPO17tLu8+gVw73h3OfmurdS9BCa5twNfokJhx7/agK3fHC9rXVewbT0kZG9vXqRBOtS6xGOZ5dC3pwleZIyidL1le+xC3btOlwJkXqGESosoDUG2TqcuE/6OcolnOykkpKdD+5l7+JODWBpAycPke+STDMRWidsWCLvfQJbKM7JpnkLG1WV73qjg6eZ1EMVF1NgG5rTaZF2r7tJNs2VzabNrTQfhSOtgxRxRLP5mldJB6/b6R5LWk9ss9nNUGyTTx8iytdCS9xlAlvtN/1hTrYObLVe5T8zLWNk2oko/bq0dv1cvjXrGC5k+pfYm+L1MtXCFsSzKDTY/dhu66JJWqlfkIxw7T5ufjDBa4pCy7ObPC0cojzlZMlCh2VjKwfX8qy5cpyRt1hp6DeKs/UZjc6zfGxC0rJHl1IUtIm2p7Tg+6VKn8vbfWR9fHljiebwTXPXTQ13HmoSbMssDsa32NnSfmmoH92xuU/E7U2qS8vWYFD9D73VJodqvWU8l7tB+b9ZdPiXvC2qoboerfoamG78+oOX7uWn1/WJy87rJPSHcVmUO7sN0ypzcG5zdfzU8k/NSh5OCbHvUSJlOk2xGrkmubA+nV7YtUCdoJk2vZ/dNSvrAkFnE0toKlC7rHllSrrOzt4Yb91LaLga9TtY+etg/uCpE8rVr604yv7YaE2x450wAACIfUvVNvFrrWXyNylLvwSAR70CtQDwePGbPuLQ/32Y10wCAClKi8AXKzralCj+/e0Dwri9RW2ArkwEqBW+cYJzghDkKyuPEB0FElKG8DIXEQkASS4s/2PSN2DGs1Bi6eh9EhVoZDAH0NblyEbw9tsoChtttEDDbQxvDTZWKm+YcYcDOj01qtdWK8110F5XIQq+nMJuwnpSopmb7KJVWbUgQqhwkyQxlOd76aglFYEKoxAJ8OeFkYPjBPTuQejsbXStQgY5klp1cr1LoQHSUa8StKqVbKnrXAqNAgg8wm1EB6RBL7ejLWnSmSI9hGJQZdAW2trTXRJoBsJm6M5VNQlFM3yJ/7EAAAA=); }
@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==); }
@@ -279,6 +279,7 @@ describe("exporting frames", () => {
height: 100,
x: 0,
y: 0,
backgroundColor: "#ffc9c9",
});
const frameChild = API.createElement({
type: "rectangle",
@@ -311,11 +312,58 @@ describe("exporting frames", () => {
expect(
svg.querySelector(`[data-id="${rectOverlapping.id}"]`),
).not.toBeNull();
// exporting frame background should still be rendered
const frameBackgroundNode = svg.querySelector('rect[fill="#ffc9c9"]');
expect(frameBackgroundNode).not.toBeNull();
expect(frameBackgroundNode).not.toHaveAttribute("rx");
expect(frameBackgroundNode).not.toHaveAttribute("ry");
expect(
frameBackgroundNode!.compareDocumentPosition(
svg.querySelector(`[data-id="${frameChild.id}"]`)!,
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(svg.getAttribute("width")).toBe(frame.width.toString());
expect(svg.getAttribute("height")).toBe(frame.height.toString());
});
it("should not render SVG frame background for empty/transparent exporting frame bg", async () => {
const transparentFrame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
backgroundColor: "transparent",
});
const emptyBgFrame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
backgroundColor: "",
});
const transparentSvg = await exportToSvg({
elements: [transparentFrame],
files: null,
exportPadding: 0,
exportingFrame: transparentFrame,
});
const emptyBgSvg = await exportToSvg({
elements: [emptyBgFrame],
files: null,
exportPadding: 0,
exportingFrame: emptyBgFrame,
});
expect(
transparentSvg.querySelector('rect[fill="transparent"]'),
).toBeNull();
expect(emptyBgSvg.querySelector('rect[fill=""]')).toBeNull();
});
it("should filter non-overlapping elements when exporting a frame", async () => {
const frame = API.createElement({
type: "frame",
@@ -469,6 +517,103 @@ describe("exporting frames", () => {
);
});
it("should render frame with its background color", async () => {
const frame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
backgroundColor: "#ffc9c9",
});
const frameChild = API.createElement({
type: "rectangle",
width: 50,
height: 50,
x: 10,
y: 10,
frameId: frame.id,
});
const { exportedElements, exportingFrame } = prepareElementsForExport(
[frameChild, frame],
{
selectedElementIds: {},
},
false,
);
const svg = await exportToSvg({
elements: exportedElements,
files: null,
exportPadding: 0,
exportingFrame,
});
const frameSvgNode = svg.querySelector(`[data-id="${frame.id}"]`);
const childSvgNode = svg.querySelector(`[data-id="${frameChild.id}"]`);
const frameBackgroundNode = svg.querySelector('rect[fill="#ffc9c9"]');
expect(frameSvgNode).not.toBeNull();
expect(childSvgNode).not.toBeNull();
expect(frameBackgroundNode).not.toBeNull();
expect(frameSvgNode).toHaveAttribute("fill", "none");
expect(frameBackgroundNode).toHaveAttribute(
"rx",
FRAME_STYLE.radius.toString(),
);
expect(frameBackgroundNode).toHaveAttribute(
"ry",
FRAME_STYLE.radius.toString(),
);
expect(
frameBackgroundNode!.compareDocumentPosition(childSvgNode!) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
childSvgNode!.compareDocumentPosition(frameSvgNode!) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("should not render magicframe background", async () => {
const magicframe = API.createElement({
type: "magicframe",
width: 100,
height: 100,
x: 0,
y: 0,
backgroundColor: "#ffc9c9",
});
const frameChild = API.createElement({
type: "rectangle",
width: 50,
height: 50,
x: 10,
y: 10,
frameId: magicframe.id,
});
const { exportedElements, exportingFrame } = prepareElementsForExport(
[frameChild, magicframe],
{
selectedElementIds: {},
},
false,
);
const svg = await exportToSvg({
elements: exportedElements,
files: null,
exportPadding: 0,
exportingFrame,
});
expect(svg.querySelector(`[data-id="${magicframe.id}"]`)).not.toBeNull();
expect(svg.querySelector('rect[fill="#ffc9c9"]')).toBeNull();
});
it("should not export frame-overlapping elements belonging to different frame", async () => {
const frame1 = API.createElement({
type: "frame",
+8
View File
@@ -58,6 +58,7 @@ import type { FileSystemHandle } from "./data/filesystem";
import type { ContextMenuItems } from "./components/ContextMenu";
import type { SnapLine } from "./snapping";
import type { ImportedDataState } from "./data/types";
import type { SchemaMigrationRegistry, SchemaPlugin } from "./data/schema";
import type { Language } from "./i18n";
import type { isOverScrollBars } from "./scene/scrollbars";
@@ -640,6 +641,11 @@ export interface ExcalidrawProps {
aiEnabled?: boolean;
showDeprecatedFonts?: boolean;
renderScrollbars?: boolean;
/**
* Optional host-provided schema migration plugins.
* Applied on restore/import boundaries when provided.
*/
schemaPlugins?: readonly SchemaPlugin[];
}
export type SceneData = {
@@ -758,6 +764,7 @@ export type AppClassProperties = {
getEditorUIOffsets: App["getEditorUIOffsets"];
visibleElements: App["visibleElements"];
excalidrawContainerValue: App["excalidrawContainerValue"];
getSchemaMigrationRegistry: () => SchemaMigrationRegistry;
onPointerUpEmitter: App["onPointerUpEmitter"];
updateEditorAtom: App["updateEditorAtom"];
@@ -867,6 +874,7 @@ export interface ExcalidrawImperativeAPI {
resetCursor: InstanceType<typeof App>["resetCursor"];
toggleSidebar: InstanceType<typeof App>["toggleSidebar"];
getEditorInterface: () => EditorInterface;
getSchemaMigrationRegistry: () => SchemaMigrationRegistry;
/**
* Disables rendering of frames (including element clipping), but currently
* the frames are still interactive in edit mode. As such, this API should be