diff --git a/README.md b/README.md index 21e2716d0..c5f7f5cd4 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,121 @@ -
- - Excalidraw logo: Sketch handrawn like diagrams. - -

Virtual whiteboard for sketching hand-drawn like diagrams.
Collaborative and end-to-end encrypted.

-

- - Follow Excalidraw on Twitter - - - Chat with us on Discord - -

+ + + + Excalidraw + + + +

+ Excalidraw Editor | + Blog | + Documentation | + Excalidraw+ +

+ +
+

+ An open source virtual hand-drawn style whiteboard.
+ Collaborative and end-to-end encrypted.
+
+

-## Try now +
+

+ + Excalidraw is released under the MIT license. + + + PRs welcome! + + + Chat on Discord + + + Follow Excalidraw on Twitter + +

-Visit [excalidraw.com](https://excalidraw.com) to start sketching. +
+
+ + Product showcase + +
+

+ Create beautiful hand-drawn like diagrams, wireframes, or whatever you like. +

+
+
+
-## Community +## Features -For latest updates, follow us on [twitter](https://twitter.com/excalidraw). If you need help or want to chat, join us on [Discord](https://discord.gg/UexuTaE). For releases and deep dives, check out our [blog](https://blog.excalidraw.com). Report bugs on [GitHub](https://github.com/excalidraw/excalidraw/issues). +The Excalidraw editor (npm package) supports: -## Supporting Excalidraw +- 💯 Free & open-source. +- 🎨 Infinite, canvas-based whiteboard. +- ✍️ Hand-drawn like style. +- 🌓 Dark mode. +- 🏗️ Customizable. +- 📷 Image support. +- 😀 Shape libraries support. +- 👅 Localization (i18n) support. +- 🖼️ Export to PNG, SVG & clipboard. +- 💾 Open format - export drawings as an `.excalidraw` json file. +- ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser... +- ➡️ Arrow-binding & labeled arrows. +- 🔙 Undo / Redo. +- 🔍 Zoom and panning support. -If you like the project, you can become a sponsor at [Open Collective](https://opencollective.com/excalidraw). +## Excalidraw.com + +The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/maielo/new-readme/src/excalidraw-app) is part of this repository as well, and the app features: + +- 📡 PWA support (works offline). +- 🤼 Real-time collaboration. +- 🔒 End-to-end encryption. +- 💾 Local-first support (autosaves to the browser). +- 🔗 Shareable links (export to a readonly link you can share with others). + +We'll be adding these features as drop-in plugins for the npm package in the future. + +## Quick start + +Install the [Excalidraw npm package](https://www.npmjs.com/package/@excalidraw/excalidraw): + +``` +npm install react react-dom @excalidraw/excalidraw +``` + +or via yarn + +``` +yarn add react react-dom @excalidraw/excalidraw +``` + +Don't forget to check out our [Documentation](https://docs.excalidraw.com)! + +## Contributing + +- Missing something or found a bug? [Report here](https://github.com/excalidraw/excalidraw/issues). +- Want to contribute? Check out our [contribution guide](https://docs.excalidraw.com/docs/introduction/contributing) or let us know on [Discord](https://discord.gg/UexuTaE). +- Want to help with translations? See the [translation guide](https://docs.excalidraw.com/docs/introduction/contributing#translating). + +## Integrations + +- [VScode extension](https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor) +- [npm package](https://www.npmjs.com/package/@excalidraw/excalidraw) + +## Who's integrating Excalidraw + +[Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/) • and many others + +## Sponsors & support + +If you like the project, you can become a sponsor at [Open Collective](https://opencollective.com/excalidraw) or use [Excalidraw+](https://plus.excalidraw.com/). + +## Thank you for supporting Excalidraw [](https://opencollective.com/excalidraw/tiers/sponsors/0/website) [](https://opencollective.com/excalidraw/tiers/sponsors/1/website) [](https://opencollective.com/excalidraw/tiers/sponsors/2/website) [](https://opencollective.com/excalidraw/tiers/sponsors/3/website) [](https://opencollective.com/excalidraw/tiers/sponsors/4/website) [](https://opencollective.com/excalidraw/tiers/sponsors/5/website) [](https://opencollective.com/excalidraw/tiers/sponsors/6/website) [](https://opencollective.com/excalidraw/tiers/sponsors/7/website) [](https://opencollective.com/excalidraw/tiers/sponsors/8/website) [](https://opencollective.com/excalidraw/tiers/sponsors/9/website) [](https://opencollective.com/excalidraw/tiers/sponsors/10/website) @@ -32,13 +124,3 @@ If you like the project, you can become a sponsor at [Open Collective](https://o Last but not least, we're thankful to these companies for offering their services for free: [![Vercel](./.github/assets/vercel.svg)](https://vercel.com) [![Sentry](./.github/assets/sentry.svg)](https://sentry.io) [![Crowdin](./.github/assets/crowdin.svg)](https://crowdin.com) - -## Developers - -You can integrate Excalidraw into your app by installing our [npm component](https://npmjs.com/package/@excalidraw/excalidraw). - -Visit our documentation on [https://docs.excalidraw.com](https://docs.excalidraw.com). - -## Who's integrating Excalidraw - -[Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/) diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/utils/restore.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/utils/restore.mdx index 809c15a1b..198626eec 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/utils/restore.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/utils/restore.mdx @@ -53,7 +53,7 @@ Parameter `refreshDimensions` indicates whether we should also `recalculate` tex **_Signature_**
-restoreElements(
+restore(
   data: ImportedDataState,
  localAppState: Partial<AppState> | null | undefined,
  localElements: ExcalidrawElement[] | null | undefined
): DataState diff --git a/dev-docs/docs/@excalidraw/excalidraw/integration.mdx b/dev-docs/docs/@excalidraw/excalidraw/integration.mdx index 1fdcb8d8a..7080c32e9 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/integration.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/integration.mdx @@ -34,14 +34,16 @@ function App() { Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`. +The following worfklow shows one way how to render Excalidraw on Next.js. We'll add more detailed and alternative Next.js examples, soon. + ```jsx showLineNumbers import { useState, useEffect } from "react"; export default function App() { - const [Comp, setComp] = useState(null); + const [Excalidraw, setExcalidraw] = useState(null); useEffect(() => { - import("@excalidraw/excalidraw").then((comp) => setComp(comp.default)); + import("@excalidraw/excalidraw").then((comp) => setExcalidraw(comp.Excalidraw)); }, []); - return <>{Comp && }; + return <>{Excalidraw && }; } ``` diff --git a/dev-docs/docs/introduction/contributing.mdx b/dev-docs/docs/introduction/contributing.mdx index 5e133ac8f..d384c97a1 100644 --- a/dev-docs/docs/introduction/contributing.mdx +++ b/dev-docs/docs/introduction/contributing.mdx @@ -20,7 +20,7 @@ Pull requests are welcome. For major changes, please [open an issue](https://git ### Option 2 - CodeSandbox -1. Go to https://codesandbox.io/s/github/excalidraw/excalidraw +1. Go to https://codesandbox.io/p/github/excalidraw/excalidraw 1. Connect your GitHub account 1. Go to Git tab on left side 1. Tap on `Fork Sandbox` diff --git a/dev-docs/docusaurus.config.js b/dev-docs/docusaurus.config.js index e24901f2e..390c619af 100644 --- a/dev-docs/docusaurus.config.js +++ b/dev-docs/docusaurus.config.js @@ -132,6 +132,11 @@ const config = { tableOfContents: { maxHeadingLevel: 4, }, + algolia: { + appId: "8FEAOD28DI", + apiKey: "4b07cca33ff2d2919bc95ff98f148e9e", + indexName: "excalidraw", + }, }), themes: ["@docusaurus/theme-live-codeblock"], plugins: ["docusaurus-plugin-sass"], diff --git a/src/clients.ts b/src/clients.ts index e31b73eb9..9e1e6e144 100644 --- a/src/clients.ts +++ b/src/clients.ts @@ -21,7 +21,7 @@ export const getClientColors = (clientId: string, appState: AppState) => { }; export const getClientInitials = (userName?: string | null) => { - if (!userName) { + if (!userName?.trim()) { return "?"; } return userName.trim()[0].toUpperCase(); diff --git a/src/components/App.tsx b/src/components/App.tsx index 2cf3efb35..96ba9bf0a 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -227,6 +227,7 @@ import { setEraserCursor, updateActiveTool, getShortcutKey, + isTransparent, } from "../utils"; import { ContextMenu, @@ -884,7 +885,7 @@ class App extends React.Component { }, }; } - const scene = restore(initialData, null, null); + const scene = restore(initialData, null, null, { repairBindings: true }); scene.appState = { ...scene.appState, theme: this.props.theme || scene.appState.theme, @@ -2827,7 +2828,15 @@ class App extends React.Component { sceneY, ); if (container) { - if (isArrowElement(container) || hasBoundTextElement(container)) { + if ( + isArrowElement(container) || + hasBoundTextElement(container) || + !isTransparent(container.backgroundColor) || + isHittingElementNotConsideringBoundingBox(container, this.state, [ + sceneX, + sceneY, + ]) + ) { const midPoint = getContainerCenter(container, this.state); sceneX = midPoint.x; diff --git a/src/data/blob.ts b/src/data/blob.ts index b5ca1dcad..473042b56 100644 --- a/src/data/blob.ts +++ b/src/data/blob.ts @@ -156,6 +156,7 @@ export const loadSceneOrLibraryFromBlob = async ( }, localAppState, localElements, + { repairBindings: true }, ), }; } else if (isValidLibrary(data)) { diff --git a/src/data/restore.ts b/src/data/restore.ts index d902b9c09..63fb567c8 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -344,7 +344,7 @@ export const restoreElements = ( elements: ImportedDataState["elements"], /** NOTE doesn't serve for reconciliation */ localElements: readonly ExcalidrawElement[] | null | undefined, - refreshDimensions = false, + opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined, ): ExcalidrawElement[] => { const localElementsMap = localElements ? arrayToMap(localElements) : null; const restoredElements = (elements || []).reduce((elements, element) => { @@ -353,7 +353,7 @@ export const restoreElements = ( if (element.type !== "selection" && !isInvisiblySmallElement(element)) { let migratedElement: ExcalidrawElement | null = restoreElement( element, - refreshDimensions, + opts?.refreshDimensions, ); if (migratedElement) { const localElement = localElementsMap?.get(element.id); @@ -366,6 +366,10 @@ export const restoreElements = ( return elements; }, [] as ExcalidrawElement[]); + if (!opts?.repairBindings) { + return restoredElements; + } + // repair binding. Mutates elements. const restoredElementsMap = arrayToMap(restoredElements); for (const element of restoredElements) { @@ -508,9 +512,10 @@ export const restore = ( */ localAppState: Partial | null | undefined, localElements: readonly ExcalidrawElement[] | null | undefined, + elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean }, ): RestoredDataState => { return { - elements: restoreElements(data?.elements, localElements), + elements: restoreElements(data?.elements, localElements, elementsConfig), appState: restoreAppState(data?.appState, localAppState || null), files: data?.files || {}, }; diff --git a/src/element/textWysiwyg.test.tsx b/src/element/textWysiwyg.test.tsx index 5acece111..cf6911b16 100644 --- a/src/element/textWysiwyg.test.tsx +++ b/src/element/textWysiwyg.test.tsx @@ -465,14 +465,21 @@ describe("textWysiwyg", () => { }); }); - it("should bind text to container when double clicked on center of filled container", async () => { + it("should bind text to container when double clicked inside filled container", async () => { + const rectangle = API.createElement({ + type: "rectangle", + x: 10, + y: 20, + width: 90, + height: 75, + backgroundColor: "red", + }); + h.elements = [rectangle]; + expect(h.elements.length).toBe(1); expect(h.elements[0].id).toBe(rectangle.id); - mouse.doubleClickAt( - rectangle.x + rectangle.width / 2, - rectangle.y + rectangle.height / 2, - ); + mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10); expect(h.elements.length).toBe(2); const text = h.elements[1] as ExcalidrawTextElementWithContainer; @@ -506,24 +513,37 @@ describe("textWysiwyg", () => { }); h.elements = [rectangle]; + mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10); + expect(h.elements.length).toBe(2); + let text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(text.type).toBe("text"); + expect(text.containerId).toBe(null); + mouse.down(); + let editor = document.querySelector( + ".excalidraw-textEditorContainer > textarea", + ) as HTMLTextAreaElement; + await new Promise((r) => setTimeout(r, 0)); + editor.blur(); + mouse.doubleClickAt( rectangle.x + rectangle.width / 2, rectangle.y + rectangle.height / 2, ); - expect(h.elements.length).toBe(2); + expect(h.elements.length).toBe(3); - const text = h.elements[1] as ExcalidrawTextElementWithContainer; + text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.type).toBe("text"); expect(text.containerId).toBe(rectangle.id); + mouse.down(); - const editor = document.querySelector( + editor = document.querySelector( ".excalidraw-textEditorContainer > textarea", ) as HTMLTextAreaElement; fireEvent.change(editor, { target: { value: "Hello World!" } }); - await new Promise((r) => setTimeout(r, 0)); editor.blur(); + expect(rectangle.boundElements).toStrictEqual([ { id: text.id, type: "text" }, ]); @@ -553,6 +573,43 @@ describe("textWysiwyg", () => { ]); }); + it("should bind text to container when double clicked on container stroke", async () => { + const rectangle = API.createElement({ + type: "rectangle", + x: 10, + y: 20, + width: 90, + height: 75, + strokeWidth: 4, + }); + h.elements = [rectangle]; + + expect(h.elements.length).toBe(1); + expect(h.elements[0].id).toBe(rectangle.id); + + mouse.doubleClickAt(rectangle.x + 2, rectangle.y + 2); + expect(h.elements.length).toBe(2); + + const text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(text.type).toBe("text"); + expect(text.containerId).toBe(rectangle.id); + expect(rectangle.boundElements).toStrictEqual([ + { id: text.id, type: "text" }, + ]); + mouse.down(); + const editor = document.querySelector( + ".excalidraw-textEditorContainer > textarea", + ) as HTMLTextAreaElement; + + fireEvent.change(editor, { target: { value: "Hello World!" } }); + + await new Promise((r) => setTimeout(r, 0)); + editor.blur(); + expect(rectangle.boundElements).toStrictEqual([ + { id: text.id, type: "text" }, + ]); + }); + it("shouldn't bind to non-text-bindable containers", async () => { const freedraw = API.createElement({ type: "freedraw", diff --git a/src/element/typeChecks.ts b/src/element/typeChecks.ts index 72088e727..a6b6cb2db 100644 --- a/src/element/typeChecks.ts +++ b/src/element/typeChecks.ts @@ -137,7 +137,7 @@ export const isExcalidrawElement = (element: any): boolean => { export const hasBoundTextElement = ( element: ExcalidrawElement | null, -): element is ExcalidrawBindableElement => { +): element is MarkNonNullable => { return ( isBindableElement(element) && !!element.boundElements?.some(({ type }) => type === "text") diff --git a/src/excalidraw-app/collab/Collab.tsx b/src/excalidraw-app/collab/Collab.tsx index 1a996f032..22f748773 100644 --- a/src/excalidraw-app/collab/Collab.tsx +++ b/src/excalidraw-app/collab/Collab.tsx @@ -610,7 +610,7 @@ class Collab extends PureComponent { const localElements = this.getSceneElementsIncludingDeleted(); const appState = this.excalidrawAPI.getAppState(); - remoteElements = restoreElements(remoteElements, null, false); + remoteElements = restoreElements(remoteElements, null); const reconciledElements = _reconcileElements( localElements, diff --git a/src/excalidraw-app/collab/RoomDialog.tsx b/src/excalidraw-app/collab/RoomDialog.tsx index d4adf9771..2c6949aac 100644 --- a/src/excalidraw-app/collab/RoomDialog.tsx +++ b/src/excalidraw-app/collab/RoomDialog.tsx @@ -144,7 +144,7 @@ const RoomDialog = ({ onUsernameChange(event.target.value)} onKeyPress={(event) => event.key === "Enter" && handleClose()} diff --git a/src/excalidraw-app/data/index.ts b/src/excalidraw-app/data/index.ts index a74c439f1..393c51580 100644 --- a/src/excalidraw-app/data/index.ts +++ b/src/excalidraw-app/data/index.ts @@ -263,9 +263,12 @@ export const loadScene = async ( await importFromBackend(id, privateKey), localDataState?.appState, localDataState?.elements, + { repairBindings: true }, ); } else { - data = restore(localDataState || null, null, null); + data = restore(localDataState || null, null, null, { + repairBindings: true, + }); } return { diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index c4df92e73..a73a21db2 100644 --- a/src/excalidraw-app/index.tsx +++ b/src/excalidraw-app/index.tsx @@ -365,7 +365,7 @@ const ExcalidrawWrapper = () => { if (data.scene) { excalidrawAPI.updateScene({ ...data.scene, - ...restore(data.scene, null, null), + ...restore(data.scene, null, null, { repairBindings: true }), commitToHistory: true, }); } diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index a90bb9ce7..2e1d62e41 100644 --- a/src/packages/excalidraw/CHANGELOG.md +++ b/src/packages/excalidraw/CHANGELOG.md @@ -11,6 +11,24 @@ The change should be grouped under one of the below section and must contain PR Please add the latest change on the top under the correct section. --> +## Unreleased + +### Features + +- [`restoreElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils/restore#restoreelements) API now takes an optional parameter `opts` which currently supports the below attributes + +```js +{ refreshDimensions?: boolean, repairBindings?: boolean } +``` + +The same `opts` param has been added to [`restore`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils/restore#restore) API as well. + +For more details refer to the [docs](https://docs.excalidraw.com) + +#### BREAKING CHANGE + +- The optional parameter `refreshDimensions` in [`restoreElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils/restore#restoreelements) has been removed and can be enabled via `opts` + ## 0.14.2 (2023-02-01) ### Features diff --git a/src/tests/clients.test.ts b/src/tests/clients.test.ts index f3fd174b4..a6a6901b1 100644 --- a/src/tests/clients.test.ts +++ b/src/tests/clients.test.ts @@ -36,4 +36,9 @@ describe("getClientInitials", () => { result = getClientInitials(null); expect(result).toBe("?"); }); + + it('returns "?" when value is blank', () => { + const result = getClientInitials(" "); + expect(result).toBe("?"); + }); }); diff --git a/src/tests/data/restore.test.ts b/src/tests/data/restore.test.ts index ef0f1a115..afd5ef918 100644 --- a/src/tests/data/restore.test.ts +++ b/src/tests/data/restore.test.ts @@ -534,7 +534,7 @@ describe("restore", () => { }); describe("repairing bindings", () => { - it("should repair container boundElements", () => { + it("should repair container boundElements when repair is true", () => { const container = API.createElement({ type: "rectangle", boundElements: [], @@ -546,11 +546,28 @@ describe("repairing bindings", () => { expect(container.boundElements).toEqual([]); - const restoredElements = restore.restoreElements( + let restoredElements = restore.restoreElements( [container, boundElement], null, ); + expect(restoredElements).toEqual([ + expect.objectContaining({ + id: container.id, + boundElements: [], + }), + expect.objectContaining({ + id: boundElement.id, + containerId: container.id, + }), + ]); + + restoredElements = restore.restoreElements( + [container, boundElement], + null, + { repairBindings: true }, + ); + expect(restoredElements).toEqual([ expect.objectContaining({ id: container.id, @@ -563,7 +580,7 @@ describe("repairing bindings", () => { ]); }); - it("should repair containerId of boundElements", () => { + it("should repair containerId of boundElements when repair is true", () => { const boundElement = API.createElement({ type: "text", containerId: null, @@ -573,11 +590,28 @@ describe("repairing bindings", () => { boundElements: [{ type: boundElement.type, id: boundElement.id }], }); - const restoredElements = restore.restoreElements( + let restoredElements = restore.restoreElements( [container, boundElement], null, ); + expect(restoredElements).toEqual([ + expect.objectContaining({ + id: container.id, + boundElements: [{ type: boundElement.type, id: boundElement.id }], + }), + expect.objectContaining({ + id: boundElement.id, + containerId: null, + }), + ]); + + restoredElements = restore.restoreElements( + [container, boundElement], + null, + { repairBindings: true }, + ); + expect(restoredElements).toEqual([ expect.objectContaining({ id: container.id, @@ -620,7 +654,7 @@ describe("repairing bindings", () => { ]); }); - it("should remove bindings of deleted elements from boundElements", () => { + it("should remove bindings of deleted elements from boundElements when repair is true", () => { const container = API.createElement({ type: "rectangle", boundElements: [], @@ -642,6 +676,8 @@ describe("repairing bindings", () => { type: invisibleBoundElement.type, id: invisibleBoundElement.id, }; + expect(container.boundElements).toEqual([]); + const nonExistentBinding = { type: "text", id: "non-existent" }; // @ts-ignore container.boundElements = [ @@ -650,17 +686,28 @@ describe("repairing bindings", () => { nonExistentBinding, ]; - expect(container.boundElements).toEqual([ - obsoleteBinding, - invisibleBinding, - nonExistentBinding, - ]); - - const restoredElements = restore.restoreElements( + let restoredElements = restore.restoreElements( [container, invisibleBoundElement, boundElement], null, ); + expect(restoredElements).toEqual([ + expect.objectContaining({ + id: container.id, + boundElements: [obsoleteBinding, invisibleBinding, nonExistentBinding], + }), + expect.objectContaining({ + id: boundElement.id, + containerId: container.id, + }), + ]); + + restoredElements = restore.restoreElements( + [container, invisibleBoundElement, boundElement], + null, + { repairBindings: true }, + ); + expect(restoredElements).toEqual([ expect.objectContaining({ id: container.id, @@ -673,7 +720,7 @@ describe("repairing bindings", () => { ]); }); - it("should remove containerId if container not exists", () => { + it("should remove containerId if container not exists when repair is true", () => { const boundElement = API.createElement({ type: "text", containerId: "non-existent", @@ -684,11 +731,28 @@ describe("repairing bindings", () => { isDeleted: true, }); - const restoredElements = restore.restoreElements( + let restoredElements = restore.restoreElements( [boundElement, boundElementDeleted], null, ); + expect(restoredElements).toEqual([ + expect.objectContaining({ + id: boundElement.id, + containerId: "non-existent", + }), + expect.objectContaining({ + id: boundElementDeleted.id, + containerId: "non-existent", + }), + ]); + + restoredElements = restore.restoreElements( + [boundElement, boundElementDeleted], + null, + { repairBindings: true }, + ); + expect(restoredElements).toEqual([ expect.objectContaining({ id: boundElement.id,