Procházet zdrojové kódy

Merge branch 'master' into fix-frame

# Conflicts:
#	src/actions/actionAlign.tsx
#	src/actions/actionDistribute.tsx
#	src/actions/actionFlip.ts
#	src/components/App.tsx
#	src/scene/selection.ts
dwelle před 2 roky
rodič
revize
a2978a4783
100 změnil soubory, kde provedl 5233 přidání a 832 odebrání
  1. 5 0
      .codesandbox/Dockerfile
  2. 9 2
      .codesandbox/tasks.json
  3. 20 11
      .env.development
  4. 10 10
      .env.production
  5. 2 2
      .github/workflows/autorelease-excalidraw.yml
  6. 2 2
      .github/workflows/autorelease-preview.yml
  7. 2 2
      .github/workflows/lint.yml
  8. 2 2
      .github/workflows/locales-coverage.yml
  9. 1 1
      .github/workflows/semantic-pr-title.yml
  10. 2 2
      .github/workflows/sentry-production.yml
  11. 30 0
      .github/workflows/size-limit.yml
  12. 26 0
      .github/workflows/test-coverage-pr.yml
  13. 2 2
      .github/workflows/test.yml
  14. 2 0
      .gitignore
  15. 1 1
      .nvmrc
  16. 1 1
      Dockerfile
  17. 11 1
      dev-docs/docs/@excalidraw/excalidraw/api/props/props.mdx
  18. 21 19
      dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx
  19. 13 0
      dev-docs/docs/@excalidraw/excalidraw/api/props/render-props.mdx
  20. 4 0
      dev-docs/docs/introduction/contributing.mdx
  21. 1 1
      dev-docs/package.json
  22. 13 13
      dev-docs/yarn.lock
  23. 9 15
      index.html
  24. 28 42
      package.json
  25. 20 0
      public/service-worker.js
  26. 0 0
      public/workbox/workbox-background-sync.prod.js
  27. 0 2
      public/workbox/workbox-broadcast-update.prod.js
  28. 0 2
      public/workbox/workbox-cacheable-response.prod.js
  29. 0 0
      public/workbox/workbox-core.prod.js
  30. 0 0
      public/workbox/workbox-expiration.prod.js
  31. 0 2
      public/workbox/workbox-navigation-preload.prod.js
  32. 0 2
      public/workbox/workbox-offline-ga.prod.js
  33. 0 0
      public/workbox/workbox-precaching.prod.js
  34. 0 2
      public/workbox/workbox-range-requests.prod.js
  35. 0 0
      public/workbox/workbox-routing.prod.js
  36. 0 0
      public/workbox/workbox-strategies.prod.js
  37. 0 2
      public/workbox/workbox-streams.prod.js
  38. 0 2
      public/workbox/workbox-sw.js
  39. 0 0
      public/workbox/workbox-window.prod.es5.mjs
  40. 0 0
      public/workbox/workbox-window.prod.mjs
  41. 0 0
      public/workbox/workbox-window.prod.umd.js
  42. 17 18
      src/actions/actionAddToLibrary.ts
  43. 31 34
      src/actions/actionAlign.tsx
  44. 15 24
      src/actions/actionBoundText.tsx
  45. 98 36
      src/actions/actionCanvas.tsx
  46. 24 30
      src/actions/actionClipboard.tsx
  47. 1 0
      src/actions/actionDeleteSelected.tsx
  48. 14 22
      src/actions/actionDistribute.tsx
  49. 20 16
      src/actions/actionDuplicateSelection.tsx
  50. 11 9
      src/actions/actionElementLock.ts
  51. 5 5
      src/actions/actionExport.tsx
  52. 8 9
      src/actions/actionFinalize.tsx
  53. 3 2
      src/actions/actionFlip.ts
  54. 20 28
      src/actions/actionFrame.ts
  55. 39 29
      src/actions/actionGroup.tsx
  56. 11 19
      src/actions/actionLinearEditor.ts
  57. 18 14
      src/actions/actionSelectAll.ts
  58. 2 0
      src/actions/manager.tsx
  59. 7 2
      src/actions/types.ts
  60. 1 1
      src/analytics.ts
  61. 4 2
      src/appState.ts
  62. 2 3
      src/charts.ts
  63. 5 0
      src/clipboard.ts
  64. 1 1
      src/colors.ts
  65. 85 35
      src/components/Actions.tsx
  66. 3 2
      src/components/App.test.tsx
  67. 483 217
      src/components/App.tsx
  68. 6 2
      src/components/ColorPicker/PickerColorList.tsx
  69. 9 3
      src/components/ContextMenu.tsx
  70. 5 5
      src/components/EyeDropper.tsx
  71. 10 13
      src/components/HintViewer.tsx
  72. 2 2
      src/components/JSONExportDialog.tsx
  73. 23 8
      src/components/LayerUI.tsx
  74. 8 5
      src/components/LibraryMenu.tsx
  75. 1 1
      src/components/LibraryMenuBrowseButton.tsx
  76. 5 0
      src/components/LibraryUnit.scss
  77. 14 6
      src/components/MobileMenu.tsx
  78. 1 1
      src/components/PublishLibrary.tsx
  79. 1 1
      src/components/Section.tsx
  80. 2 1
      src/components/Sidebar/Sidebar.test.tsx
  81. 1 1
      src/components/Sidebar/Sidebar.tsx
  82. 9 5
      src/components/Trans.test.tsx
  83. 2 2
      src/components/Trans.tsx
  84. 2 2
      src/components/__snapshots__/App.test.tsx.snap
  85. 226 0
      src/components/canvases/InteractiveCanvas.tsx
  86. 110 0
      src/components/canvases/StaticCanvas.tsx
  87. 4 0
      src/components/canvases/index.tsx
  88. 8 0
      src/components/icons.tsx
  89. 10 2
      src/components/main-menu/DefaultItems.tsx
  90. 17 1
      src/constants.ts
  91. 55 2
      src/css/styles.scss
  92. 2032 0
      src/data/__snapshots__/transform.test.ts.snap
  93. 1 5
      src/data/blob.ts
  94. 28 13
      src/data/restore.ts
  95. 706 0
      src/data/transform.test.ts
  96. 561 0
      src/data/transform.ts
  97. 30 0
      src/data/url.test.tsx
  98. 35 0
      src/data/url.ts
  99. 0 4
      src/element/Hyperlink.scss
  100. 144 46
      src/element/Hyperlink.tsx

+ 5 - 0
.codesandbox/Dockerfile

@@ -0,0 +1,5 @@
+FROM node:18-bullseye
+
+# Vite wants to open the browser using `open`, so we
+# need to install those utils.
+RUN apt update -y && apt install -y xdg-utils

+ 9 - 2
.codesandbox/tasks.json

@@ -27,7 +27,10 @@
     "start": {
       "name": "Start Excalidraw",
       "command": "yarn start",
-      "runAtStart": true
+      "runAtStart": true,
+      "preview": {
+        "port": 3000
+      }
     },
     "test": {
       "name": "Run Tests",
@@ -37,7 +40,11 @@
     "install-deps": {
       "name": "Install Dependencies",
       "command": "yarn install",
-      "restartOn": { "files": ["yarn.lock"] }
+      "restartOn": {
+        "files": ["yarn.lock"],
+        "branch": false,
+        "resume": false
+      }
     }
   }
 }

+ 20 - 11
.env.development

@@ -1,30 +1,39 @@
-REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
-REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
+VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
+VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
 
-REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
-REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
+VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
+VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
 
 # collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
-REACT_APP_WS_SERVER_URL=http://localhost:3002
+VITE_APP_WS_SERVER_URL=http://localhost:3002
 
 # set this only if using the collaboration workflow we use on excalidraw.com
-REACT_APP_PORTAL_URL=
+VITE_APP_PORTAL_URL=
 
-REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
+VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
 
 # put these in your .env.local, or make sure you don't commit!
 # must be lowercase `true` when turned on
 #
 # whether to enable Service Workers in development
-REACT_APP_DEV_ENABLE_SW=
+VITE_APP_DEV_ENABLE_SW=
 # whether to disable live reload / HMR. Usuaully what you want to do when
 # debugging Service Workers.
-REACT_APP_DEV_DISABLE_LIVE_RELOAD=
-REACT_APP_DISABLE_TRACKING=true
+VITE_APP_DEV_DISABLE_LIVE_RELOAD=
+VITE_APP_DISABLE_TRACKING=true
 
 FAST_REFRESH=false
 
+# The port the run the dev server
+VITE_APP_PORT=3000
+
 #Debug flags
 
 # To enable bounding box for text containers
-REACT_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=
+VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=
+
+# Set this flag to false if you want to open the overlay by default
+VITE_APP_COLLAPSE_OVERLAY=true
+
+# Set this flag to false to disable eslint
+VITE_APP_ENABLE_ESLINT=true

+ 10 - 10
.env.production

@@ -1,15 +1,15 @@
-REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
-REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
+VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
+VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
 
-REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
-REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
+VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
+VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
 
-REACT_APP_PORTAL_URL=https://portal.excalidraw.com
+VITE_APP_PORTAL_URL=https://portal.excalidraw.com
 # Fill to set socket server URL used for collaboration.
-# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
-REACT_APP_WS_SERVER_URL=
+# Meant for forks only: excalidraw.com uses custom VITE_APP_PORTAL_URL flow
+VITE_APP_WS_SERVER_URL=
 
-REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
+VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
 
-REACT_APP_PLUS_APP=https://app.excalidraw.com
-REACT_APP_DISABLE_TRACKING=
+VITE_APP_PLUS_APP=https://app.excalidraw.com
+VITE_APP_DISABLE_TRACKING=

+ 2 - 2
.github/workflows/autorelease-excalidraw.yml

@@ -12,10 +12,10 @@ jobs:
       - uses: actions/checkout@v2
         with:
           fetch-depth: 2
-      - name: Setup Node.js 14.x
+      - name: Setup Node.js 18.x
         uses: actions/setup-node@v2
         with:
-          node-version: 14.x
+          node-version: 18.x
       - name: Set up publish access
         run: |
           npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}

+ 2 - 2
.github/workflows/autorelease-preview.yml

@@ -32,10 +32,10 @@ jobs:
         with:
           ref: ${{ steps.sha.outputs.result }}
           fetch-depth: 2
-      - name: Setup Node.js 14.x
+      - name: Setup Node.js 18.x
         uses: actions/setup-node@v2
         with:
-          node-version: 14.x
+          node-version: 18.x
       - name: Set up publish access
         run: |
           npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}

+ 2 - 2
.github/workflows/lint.yml

@@ -9,10 +9,10 @@ jobs:
     steps:
       - uses: actions/checkout@v2
 
-      - name: Setup Node.js 14.x
+      - name: Setup Node.js 18.x
         uses: actions/setup-node@v2
         with:
-          node-version: 14.x
+          node-version: 18.x
 
       - name: Install and lint
         run: |

+ 2 - 2
.github/workflows/locales-coverage.yml

@@ -14,10 +14,10 @@ jobs:
         with:
           token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
 
-      - name: Setup Node.js 14.x
+      - name: Setup Node.js 18.x
         uses: actions/setup-node@v2
         with:
-          node-version: 14.x
+          node-version: 18.x
 
       - name: Create report file
         run: |

+ 1 - 1
.github/workflows/semantic-pr-title.yml

@@ -1,7 +1,7 @@
 name: Semantic PR title
 
 on:
-  pull_request_target:
+  pull_request:
     types:
       - opened
       - edited

+ 2 - 2
.github/workflows/sentry-production.yml

@@ -10,10 +10,10 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v2
-      - name: Setup Node.js 14.x
+      - name: Setup Node.js 18.x
         uses: actions/setup-node@v2
         with:
-          node-version: 14.x
+          node-version: 18.x
       - name: Install and build
         run: |
           yarn --frozen-lockfile

+ 30 - 0
.github/workflows/size-limit.yml

@@ -0,0 +1,30 @@
+name: "Bundle Size check @excalidraw/excalidraw"
+on:
+  pull_request:
+    branches:
+      - master
+jobs:
+  size:
+    runs-on: ubuntu-latest
+    env:
+      CI_JOB_NUMBER: 1
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v3
+      - name: Setup Node.js 18.x
+        uses: actions/setup-node@v3
+        with:
+          node-version: 18.x
+      - name: Install
+        run: yarn --frozen-lockfile
+      - name: Install in src/packages/excalidraw
+        run: yarn --frozen-lockfile
+        working-directory: src/packages/excalidraw
+        env:
+          CI: true
+      - uses: andresz1/size-limit-action@v1
+        with:
+          github_token: ${{ secrets.GITHUB_TOKEN }}
+          build_script: build:umd
+          skip_step: install
+          directory: src/packages/excalidraw

+ 26 - 0
.github/workflows/test-coverage-pr.yml

@@ -0,0 +1,26 @@
+name: Test Coverage PR
+on:
+  pull_request:
+
+jobs:
+  coverage:
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      pull-requests: write
+
+    steps:
+      - uses: actions/checkout@v2
+      - name: "Install Node"
+        uses: actions/setup-node@v2
+        with:
+          node-version: "18.x"
+      - name: "Install Deps"
+        run: yarn --frozen-lockfile
+      - name: "Test Coverage"
+        run: yarn test:coverage
+      - name: "Report Coverage"
+        if: always() # Also generate the report if tests are failing
+        uses: davelosert/vitest-coverage-report-action@v2
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}

+ 2 - 2
.github/workflows/test.yml

@@ -7,10 +7,10 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v2
-      - name: Setup Node.js 14.x
+      - name: Setup Node.js 18.x
         uses: actions/setup-node@v2
         with:
-          node-version: 14.x
+          node-version: 18.x
       - name: Install and test
         run: |
           yarn --frozen-lockfile

+ 2 - 0
.gitignore

@@ -26,3 +26,5 @@ src/packages/excalidraw/example/public/bundle.js
 src/packages/excalidraw/example/public/excalidraw-assets-dev
 src/packages/excalidraw/example/public/excalidraw.development.js
 coverage
+dev-dist
+html

+ 1 - 1
.nvmrc

@@ -1 +1 @@
-14
+18

+ 1 - 1
Dockerfile

@@ -1,4 +1,4 @@
-FROM node:14-alpine AS build
+FROM node:18 AS build
 
 WORKDIR /opt/node_app
 

+ 11 - 1
dev-docs/docs/@excalidraw/excalidraw/api/props/props.mdx

@@ -29,6 +29,8 @@ All `props` are *optional*.
 | [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
 | [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load |
 | [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
+| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
+| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
 
 ### Storing custom data on Excalidraw elements
 
@@ -215,7 +217,6 @@ Indicates whether to bind keyboard events to `document`. Disabled by default, me
 
 Enable this if you want Excalidraw to handle keyboard even if the component isn't focused (e.g. a user is interacting with the navbar, sidebar, or similar).
 
-
 ### autoFocus
 
 This prop indicates whether to `focus` the Excalidraw component on page load. Defaults to false.
@@ -228,3 +229,12 @@ Allows you to override `id` generation for files added on canvas (images). By de
 (file: File) => string | Promise<string>
 ```
 
+### validateEmbeddable
+
+```tsx
+validateEmbeddable?: boolean | string[] | RegExp | RegExp[] | ((link: string) => boolean | undefined)
+```
+
+This is an optional property. By default we support a handful of well-known sites. You may allow additional sites or disallow the default ones by supplying a custom validator. If you pass `true`, all URLs will be allowed. You can also supply a list of hostnames, RegExp (or list of RegExp objects), or a function. If the function returns `undefined`, the built-in validator will be used.
+
+Supplying a list of hostnames (with or without `www.`) is the preferred way to allow a specific list of domains.

+ 21 - 19
dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx

@@ -306,30 +306,32 @@ This is the history API. history.clear() will clear the history.
 
 ## scrollToContent
 
-<pre>
-  (<br />
-  {"  "}
-  target?:{" "}
-  <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
-    ExcalidrawElement
-  </a>{" "}
-  &#124;{" "}
-  <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
-    ExcalidrawElement
-  </a>
-  [],
-  <br />
-  {"  "}opts?: &#123; fitToContent?: boolean; animate?: boolean; duration?: number
-  &#125;
-  <br />) => void
-</pre>
+```tsx
+(
+  target?: ExcalidrawElement | ExcalidrawElement[],
+  opts?:
+      | {
+          fitToContent?: boolean;
+          animate?: boolean;
+          duration?: number;
+        }
+      | {
+          fitToViewport?: boolean;
+          viewportZoomFactor?: number;
+          animate?: boolean;
+          duration?: number;
+        }
+) => void
+```
 
 Scroll the nearest element out of the elements supplied to the center of the viewport. Defaults to the elements on the scene.
 
 | Attribute | type | default | Description |
 | --- | --- | --- | --- |
-| target | <code>ExcalidrawElement &#124; ExcalidrawElement[]</code> | All scene elements | The element(s) to scroll to. |
-| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. |
+| target | [ExcalidrawElement](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) &#124; [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) | All scene elements | The element(s) to scroll to. |
+| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. Note that the zoom range is between 10%-100%. |
+| opts.fitToViewport | boolean | false | Similar to fitToContent but the zoom range is not limited. If elements are smaller than the viewport, zoom will go above 100%. |
+| opts.viewportZoomFactor | number | 0.7 | when fitToViewport=true, how much screen should the content cover, between 0.1 (10%) and 1 (100%) |
 | opts.animate | boolean | false | Whether to animate between starting and ending position. Note that for larger scenes the animation may not be smooth due to performance issues. |
 | opts.duration | number | 500 | Duration of the animation if `opts.animate` is `true`. |
 

+ 13 - 0
dev-docs/docs/@excalidraw/excalidraw/api/props/render-props.mdx

@@ -121,3 +121,16 @@ function App() {
   );
 }
 ```
+
+## renderEmbeddable
+
+<pre>
+  (element: NonDeleted&lt;ExcalidrawEmbeddableElement&gt;, appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a>) => JSX.Element | null
+</pre>
+
+Allows you to replace the renderer for embeddable elements (which renders `<iframe>` elements).
+
+| Parameter | Type | Description |
+| --- | --- | --- |
+| `element` | `NonDeleted<ExcalidrawEmbeddableElement>` | The embeddable element to be rendered. |
+| `appState` | `AppState` | The current state of the UI. |

+ 4 - 0
dev-docs/docs/introduction/contributing.mdx

@@ -69,6 +69,10 @@ It's also a good idea to consider if your change should include additional tests
 
 Finally - always manually test your changes using the convenient staging environment deployed for each pull request. As much as local development attempts to replicate production, there can still be subtle differences in behavior. For larger features consider testing your change in multiple browsers as well.
 
+:::note
+Some checks, such as the `lint` and `test`, require approval from the maintainers to run. 
+They will appear as `Expected — Waiting for status to be reported` in the PR checks when they are waiting for approval.
+:::
 
 ## Translating
 

+ 1 - 1
dev-docs/package.json

@@ -18,7 +18,7 @@
     "@docusaurus/core": "2.2.0",
     "@docusaurus/preset-classic": "2.2.0",
     "@docusaurus/theme-live-codeblock": "2.2.0",
-    "@excalidraw/excalidraw": "0.15.2",
+    "@excalidraw/excalidraw": "0.15.3",
     "@mdx-js/react": "^1.6.22",
     "clsx": "^1.2.1",
     "docusaurus-plugin-sass": "0.2.3",

+ 13 - 13
dev-docs/yarn.lock

@@ -1631,10 +1631,10 @@
     url-loader "^4.1.1"
     webpack "^5.73.0"
 
-"@excalidraw/[email protected].2":
-  version "0.15.2"
-  resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.2.tgz#7dba4f6e10c52015a007efb75a9fc1afe598574c"
-  integrity sha512-rTI02kgWSTXiUdIkBxt9u/581F3eXcqQgJdIxmz54TFtG3ughoxO5fr4t7Fr2LZIturBPqfocQHGKZ0t2KLKgw==
+"@excalidraw/[email protected].3":
+  version "0.15.3"
+  resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.3.tgz#5dea570f76451adf68bc24d4bfdd67a375cfeab1"
+  integrity sha512-/gpY7fgMO/AEaFLWnPqzbY8H7ly+/zocFf7D0Is5sWNMD2mhult5tana12lXKLSJ6EAz7ubo1A7LajXzvJXJDA==
 
 "@hapi/hoek@^9.0.0":
   version "9.3.0"
@@ -6611,19 +6611,19 @@ [email protected]:
   integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
 
 semver@^5.4.1:
-  version "5.7.1"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
-  integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
+  version "5.7.2"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8"
+  integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
 
 semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0:
-  version "6.3.0"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
-  integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
+  version "6.3.1"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
+  integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
 
 semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7:
-  version "7.3.7"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
-  integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
+  version "7.5.4"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
+  integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
   dependencies:
     lru-cache "^6.0.0"
 

+ 9 - 15
public/index.html → index.html

@@ -78,8 +78,7 @@
       }
     </style>
     <!------------------------------------------------------------------------->
-
-    <% if (process.env.NODE_ENV === "production") { %>
+    <% if ("%PROD%" === "true") { %>
     <script>
       // Redirect Excalidraw+ users which have auto-redirect enabled.
       //
@@ -100,41 +99,35 @@
     </script>
     <% } %>
 
-    <link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
+    <link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
 
     <!-- Excalidraw version -->
     <meta name="version" content="{version}" />
 
     <link
       rel="preload"
-      href="Virgil.woff2"
+      href="/Virgil.woff2"
       as="font"
       type="font/woff2"
       crossorigin="anonymous"
     />
     <link
       rel="preload"
-      href="Cascadia.woff2"
+      href="/Cascadia.woff2"
       as="font"
       type="font/woff2"
       crossorigin="anonymous"
     />
 
-    <link
-      rel="manifest"
-      href="manifest.json"
-      style="--pwacompat-splash-font: 24px Virgil"
-    />
-
-    <link rel="stylesheet" href="fonts.css" type="text/css" />
-    <% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD==="true" ) { %>
+    <link rel="stylesheet" href="/fonts.css" type="text/css" />
+    <% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%"==="true" ) { %>
     <script>
       {
         const _WebSocket = window.WebSocket;
         window.WebSocket = function (url) {
           if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
             console.info(
-              "[!!!] Live reload is disabled via process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
+              "[!!!] Live reload is disabled via VITE_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
             );
           } else {
             return new _WebSocket(url);
@@ -200,7 +193,8 @@
       <h1 class="visually-hidden">Excalidraw</h1>
     </header>
     <div id="root"></div>
-    <% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
+    <script type="module" src="/src/index.tsx"></script>
+    <% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %>
     <!-- 100% privacy friendly analytics -->
     <script>
       // need to load this script dynamically bcs. of iframe embed tracking

+ 28 - 42
package.json

@@ -19,6 +19,7 @@
     ]
   },
   "dependencies": {
+    "@braintree/sanitize-url": "6.0.2",
     "@excalidraw/random-username": "1.0.0",
     "@radix-ui/react-popover": "1.0.3",
     "@radix-ui/react-tabs": "1.0.2",
@@ -31,6 +32,7 @@
     "canvas-roundrect-polyfill": "0.0.1",
     "clsx": "1.1.1",
     "cross-env": "7.0.3",
+    "eslint-plugin-react": "7.32.2",
     "fake-indexeddb": "3.1.7",
     "firebase": "8.3.3",
     "i18next-browser-languagedetector": "6.1.4",
@@ -50,26 +52,13 @@
     "pwacompat": "2.0.17",
     "react": "18.2.0",
     "react-dom": "18.2.0",
-    "react-scripts": "5.0.1",
     "roughjs": "4.5.2",
     "sass": "1.51.0",
     "socket.io-client": "2.3.1",
-    "tunnel-rat": "0.1.2",
-    "workbox-background-sync": "^6.5.4",
-    "workbox-broadcast-update": "^6.5.4",
-    "workbox-cacheable-response": "^6.5.4",
-    "workbox-core": "^6.5.4",
-    "workbox-expiration": "^6.5.4",
-    "workbox-google-analytics": "^6.5.4",
-    "workbox-navigation-preload": "^6.5.4",
-    "workbox-precaching": "^6.5.4",
-    "workbox-range-requests": "^6.5.4",
-    "workbox-routing": "^6.5.4",
-    "workbox-strategies": "^6.5.4",
-    "workbox-streams": "^6.5.4"
+    "tunnel-rat": "0.1.2"
   },
   "devDependencies": {
-    "@excalidraw/eslint-config": "1.0.0",
+    "@excalidraw/eslint-config": "1.0.3",
     "@excalidraw/prettier-config": "1.0.2",
     "@types/chai": "4.3.0",
     "@types/jest": "27.4.0",
@@ -80,48 +69,43 @@
     "@types/react-dom": "18.0.6",
     "@types/resize-observer-browser": "0.1.7",
     "@types/socket.io-client": "1.4.36",
+    "@vitejs/plugin-react": "3.1.0",
+    "@vitest/coverage-v8": "0.33.0",
+    "@vitest/ui": "0.32.2",
     "chai": "4.3.6",
     "dotenv": "16.0.1",
     "eslint-config-prettier": "8.5.0",
+    "eslint-config-react-app": "7.0.1",
     "eslint-plugin-prettier": "3.3.1",
     "http-server": "14.1.1",
     "husky": "7.0.4",
-    "jest-canvas-mock": "2.4.0",
+    "jsdom": "22.1.0",
     "lint-staged": "12.3.7",
     "pepjs": "0.5.3",
     "prettier": "2.6.2",
     "rewire": "6.0.0",
-    "typescript": "4.9.4"
+    "typescript": "4.9.4",
+    "vite": "4.4.2",
+    "vite-plugin-checker": "0.6.1",
+    "vite-plugin-ejs": "1.6.4",
+    "vite-plugin-pwa": "0.16.4",
+    "vite-plugin-svgr": "2.4.0",
+    "vitest": "0.34.1",
+    "vitest-canvas-mock": "0.3.2"
   },
   "engines": {
-    "node": ">=14.0.0"
+    "node": ">=18.0.0"
   },
   "homepage": ".",
-  "jest": {
-    "collectCoverageFrom": [
-      "src/**/*.{js,jsx,ts,tsx}"
-    ],
-    "coveragePathIgnorePatterns": [
-      "<rootDir>/locales",
-      "<rootDir>/src/packages/excalidraw/dist/",
-      "<rootDir>/src/packages/excalidraw/types",
-      "<rootDir>/src/packages/excalidraw/example"
-    ],
-    "transformIgnorePatterns": [
-      "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access|canvas-roundrect-polyfill)/)"
-    ],
-    "resetMocks": false
-  },
   "name": "excalidraw",
   "prettier": "@excalidraw/prettier-config",
   "private": true,
   "scripts": {
     "build-node": "node ./scripts/build-node.js",
-    "build:app:docker": "cross-env REACT_APP_DISABLE_SENTRY=true REACT_APP_DISABLE_TRACKING=true react-scripts build",
-    "build:app": "cross-env REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
+    "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
+    "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
     "build:version": "node ./scripts/build-version.js",
     "build": "yarn build:app && yarn build:version",
-    "eject": "react-scripts eject",
     "fix:code": "yarn test:code --fix",
     "fix:other": "yarn prettier --write",
     "fix": "yarn fix:other && yarn fix:code",
@@ -129,19 +113,21 @@
     "locales-coverage:description": "node scripts/locales-coverage-description.js",
     "prepare": "husky install",
     "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
-    "start": "react-scripts start",
+    "start": "vite",
     "start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
-    "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
-    "test:app": "react-scripts test --passWithNoTests",
+    "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
+    "test:app": "vitest --config vitest.config.ts",
     "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
-    "test:debug": "react-scripts --inspect-brk test --runInBand --no-cache",
     "test:other": "yarn prettier --list-different",
     "test:typecheck": "tsc",
-    "test:update": "yarn test:app --updateSnapshot --watchAll=false",
+    "test:update": "yarn test:app --update --watch=false",
     "test": "yarn test:app",
-    "test:coverage": "react-scripts test --passWithNoTests --coverage --watchAll",
+    "test:coverage": "vitest --coverage",
+    "test:coverage:watch": "vitest --coverage --watch",
+    "test:ui": "yarn test --ui",
     "autorelease": "node scripts/autorelease.js",
     "prerelease": "node scripts/prerelease.js",
+    "build:preview": "yarn build && vite preview --port 5000",
     "release": "node scripts/release.js"
   }
 }

+ 20 - 0
public/service-worker.js

@@ -0,0 +1,20 @@
+// Since we migrated to Vite, the service worker strategy changed, in CRA it was a custom service worker named service-worker.js and in Vite its sw.js handled by vite-plugin-pwa
+// Due to this the existing CRA users were not able to migrate to Vite or any new changes post Vite unless browser is hard refreshed
+// Hence adding a self destroying worker so all CRA service workers are destroyed and migrated to Vite
+// We should remove this code after sometime when we are confident that
+// all users have migrated to Vite
+
+self.addEventListener("install", () => {
+  self.skipWaiting();
+});
+
+self.addEventListener("activate", () => {
+  self.registration
+    .unregister()
+    .then(() => {
+      return self.clients.matchAll();
+    })
+    .then((clients) => {
+      clients.forEach((client) => client.navigate(client.url));
+    });
+});

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
public/workbox/workbox-background-sync.prod.js


+ 0 - 2
public/workbox/workbox-broadcast-update.prod.js

@@ -1,2 +0,0 @@
-this.workbox=this.workbox||{},this.workbox.broadcastUpdate=function(e,t){"use strict";try{self["workbox:broadcast-update:4.3.1"]&&_()}catch(e){}const s=(e,t,s)=>{return!s.some(s=>e.headers.has(s)&&t.headers.has(s))||s.every(s=>{const n=e.headers.has(s)===t.headers.has(s),a=e.headers.get(s)===t.headers.get(s);return n&&a})},n="workbox",a=1e4,i=["content-length","etag","last-modified"],o=async({channel:e,cacheName:t,url:s})=>{const n={type:"CACHE_UPDATED",meta:"workbox-broadcast-update",payload:{cacheName:t,updatedURL:s}};if(e)e.postMessage(n);else{const e=await clients.matchAll({type:"window"});for(const t of e)t.postMessage(n)}};class c{constructor({headersToCheck:e,channelName:t,deferNoticationTimeout:s}={}){this.t=e||i,this.s=t||n,this.i=s||a,this.o()}notifyIfUpdated({oldResponse:e,newResponse:t,url:n,cacheName:a,event:i}){if(!s(e,t,this.t)){const e=(async()=>{i&&i.request&&"navigate"===i.request.mode&&await this.h(i),await this.l({channel:this.u(),cacheName:a,url:n})})();if(i)try{i.waitUntil(e)}catch(e){}return e}}async l(e){await o(e)}u(){return"BroadcastChannel"in self&&!this.p&&(this.p=new BroadcastChannel(this.s)),this.p}h(e){if(!this.m.has(e)){const s=new t.Deferred;this.m.set(e,s);const n=setTimeout(()=>{s.resolve()},this.i);s.promise.then(()=>clearTimeout(n))}return this.m.get(e).promise}o(){this.m=new Map,self.addEventListener("message",e=>{if("WINDOW_READY"===e.data.type&&"workbox-window"===e.data.meta&&this.m.size>0){for(const e of this.m.values())e.resolve();this.m.clear()}})}}return e.BroadcastCacheUpdate=c,e.Plugin=class{constructor(e){this.l=new c(e)}cacheDidUpdate({cacheName:e,oldResponse:t,newResponse:s,request:n,event:a}){t&&this.l.notifyIfUpdated({cacheName:e,oldResponse:t,newResponse:s,event:a,url:n.url})}},e.broadcastUpdate=o,e.responsesAreSame=s,e}({},workbox.core._private);
-//# sourceMappingURL=workbox-broadcast-update.prod.js.map

+ 0 - 2
public/workbox/workbox-cacheable-response.prod.js

@@ -1,2 +0,0 @@
-this.workbox=this.workbox||{},this.workbox.cacheableResponse=function(t){"use strict";try{self["workbox:cacheable-response:4.3.1"]&&_()}catch(t){}class s{constructor(t={}){this.t=t.statuses,this.s=t.headers}isResponseCacheable(t){let s=!0;return this.t&&(s=this.t.includes(t.status)),this.s&&s&&(s=Object.keys(this.s).some(s=>t.headers.get(s)===this.s[s])),s}}return t.CacheableResponse=s,t.Plugin=class{constructor(t){this.i=new s(t)}cacheWillUpdate({response:t}){return this.i.isResponseCacheable(t)?t:null}},t}({});
-//# sourceMappingURL=workbox-cacheable-response.prod.js.map

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
public/workbox/workbox-core.prod.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
public/workbox/workbox-expiration.prod.js


+ 0 - 2
public/workbox/workbox-navigation-preload.prod.js

@@ -1,2 +0,0 @@
-this.workbox=this.workbox||{},this.workbox.navigationPreload=function(t){"use strict";try{self["workbox:navigation-preload:4.3.1"]&&_()}catch(t){}function e(){return Boolean(self.registration&&self.registration.navigationPreload)}return t.disable=function(){e()&&self.addEventListener("activate",t=>{t.waitUntil(self.registration.navigationPreload.disable().then(()=>{}))})},t.enable=function(t){e()&&self.addEventListener("activate",e=>{e.waitUntil(self.registration.navigationPreload.enable().then(()=>{t&&self.registration.navigationPreload.setHeaderValue(t)}))})},t.isSupported=e,t}({});
-//# sourceMappingURL=workbox-navigation-preload.prod.js.map

+ 0 - 2
public/workbox/workbox-offline-ga.prod.js

@@ -1,2 +0,0 @@
-this.workbox=this.workbox||{},this.workbox.googleAnalytics=function(e,t,o,n,a,c,w){"use strict";try{self["workbox:google-analytics:4.3.1"]&&_()}catch(e){}const r=/^\/(\w+\/)?collect/,s=e=>async({queue:t})=>{let o;for(;o=await t.shiftRequest();){const{request:n,timestamp:a}=o,c=new URL(n.url);try{const w="POST"===n.method?new URLSearchParams(await n.clone().text()):c.searchParams,r=a-(Number(w.get("qt"))||0),s=Date.now()-r;if(w.set("qt",s),e.parameterOverrides)for(const t of Object.keys(e.parameterOverrides)){const o=e.parameterOverrides[t];w.set(t,o)}"function"==typeof e.hitFilter&&e.hitFilter.call(null,w),await fetch(new Request(c.origin+c.pathname,{body:w.toString(),method:"POST",mode:"cors",credentials:"omit",headers:{"Content-Type":"text/plain"}}))}catch(e){throw await t.unshiftRequest(o),e}}},i=e=>{const t=({url:e})=>"www.google-analytics.com"===e.hostname&&r.test(e.pathname),o=new w.NetworkOnly({plugins:[e]});return[new n.Route(t,o,"GET"),new n.Route(t,o,"POST")]},l=e=>{const t=new c.NetworkFirst({cacheName:e});return new n.Route(({url:e})=>"www.google-analytics.com"===e.hostname&&"/analytics.js"===e.pathname,t,"GET")},m=e=>{const t=new c.NetworkFirst({cacheName:e});return new n.Route(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtag/js"===e.pathname,t,"GET")},u=e=>{const t=new c.NetworkFirst({cacheName:e});return new n.Route(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtm.js"===e.pathname,t,"GET")};return e.initialize=((e={})=>{const n=o.cacheNames.getGoogleAnalyticsName(e.cacheName),c=new t.Plugin("workbox-google-analytics",{maxRetentionTime:2880,onSync:s(e)}),w=[u(n),l(n),m(n),...i(c)],r=new a.Router;for(const e of w)r.registerRoute(e);r.addFetchListener()}),e}({},workbox.backgroundSync,workbox.core._private,workbox.routing,workbox.routing,workbox.strategies,workbox.strategies);
-//# sourceMappingURL=workbox-offline-ga.prod.js.map

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
public/workbox/workbox-precaching.prod.js


+ 0 - 2
public/workbox/workbox-range-requests.prod.js

@@ -1,2 +0,0 @@
-this.workbox=this.workbox||{},this.workbox.rangeRequests=function(e,n){"use strict";try{self["workbox:range-requests:4.3.1"]&&_()}catch(e){}async function t(e,t){try{if(206===t.status)return t;const s=e.headers.get("range");if(!s)throw new n.WorkboxError("no-range-header");const a=function(e){const t=e.trim().toLowerCase();if(!t.startsWith("bytes="))throw new n.WorkboxError("unit-must-be-bytes",{normalizedRangeHeader:t});if(t.includes(","))throw new n.WorkboxError("single-range-only",{normalizedRangeHeader:t});const s=/(\d*)-(\d*)/.exec(t);if(null===s||!s[1]&&!s[2])throw new n.WorkboxError("invalid-range-values",{normalizedRangeHeader:t});return{start:""===s[1]?null:Number(s[1]),end:""===s[2]?null:Number(s[2])}}(s),r=await t.blob(),i=function(e,t,s){const a=e.size;if(s>a||t<0)throw new n.WorkboxError("range-not-satisfiable",{size:a,end:s,start:t});let r,i;return null===t?(r=a-s,i=a):null===s?(r=t,i=a):(r=t,i=s+1),{start:r,end:i}}(r,a.start,a.end),o=r.slice(i.start,i.end),u=o.size,l=new Response(o,{status:206,statusText:"Partial Content",headers:t.headers});return l.headers.set("Content-Length",u),l.headers.set("Content-Range",`bytes ${i.start}-${i.end-1}/`+r.size),l}catch(e){return new Response("",{status:416,statusText:"Range Not Satisfiable"})}}return e.createPartialResponse=t,e.Plugin=class{async cachedResponseWillBeUsed({request:e,cachedResponse:n}){return n&&e.headers.has("range")?await t(e,n):n}},e}({},workbox.core._private);
-//# sourceMappingURL=workbox-range-requests.prod.js.map

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
public/workbox/workbox-routing.prod.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
public/workbox/workbox-strategies.prod.js


+ 0 - 2
public/workbox/workbox-streams.prod.js

@@ -1,2 +0,0 @@
-this.workbox=this.workbox||{},this.workbox.streams=function(e){"use strict";try{self["workbox:streams:4.3.1"]&&_()}catch(e){}function n(e){const n=e.map(e=>Promise.resolve(e).then(e=>(function(e){return e.body&&e.body.getReader?e.body.getReader():e.getReader?e.getReader():new Response(e).body.getReader()})(e)));let t,r;const s=new Promise((e,n)=>{t=e,r=n});let o=0;return{done:s,stream:new ReadableStream({pull(e){return n[o].then(e=>e.read()).then(r=>{if(r.done)return++o>=n.length?(e.close(),void t()):this.pull(e);e.enqueue(r.value)}).catch(e=>{throw r(e),e})},cancel(){t()}})}}function t(e={}){const n=new Headers(e);return n.has("content-type")||n.set("content-type","text/html"),n}function r(e,r){const{done:s,stream:o}=n(e),a=t(r);return{done:s,response:new Response(o,{headers:a})}}let s=void 0;function o(){if(void 0===s)try{new ReadableStream({start(){}}),s=!0}catch(e){s=!1}return s}return e.concatenate=n,e.concatenateToResponse=r,e.isSupported=o,e.strategy=function(e,n){return async({event:s,url:a,params:c})=>{if(o()){const{done:t,response:o}=r(e.map(e=>e({event:s,url:a,params:c})),n);return s.waitUntil(t),o}const i=await Promise.all(e.map(e=>e({event:s,url:a,params:c})).map(async e=>{const n=await e;return n instanceof Response?n.blob():n})),u=t(n);return new Response(new Blob(i),{headers:u})}},e}({});
-//# sourceMappingURL=workbox-streams.prod.js.map

+ 0 - 2
public/workbox/workbox-sw.js

@@ -1,2 +0,0 @@
-!function(){"use strict";try{self["workbox:sw:4.3.1"]&&_()}catch(t){}const t="https://storage.googleapis.com/workbox-cdn/releases/4.3.1",e={backgroundSync:"background-sync",broadcastUpdate:"broadcast-update",cacheableResponse:"cacheable-response",core:"core",expiration:"expiration",googleAnalytics:"offline-ga",navigationPreload:"navigation-preload",precaching:"precaching",rangeRequests:"range-requests",routing:"routing",strategies:"strategies",streams:"streams"};self.workbox=new class{constructor(){return this.v={},this.t={debug:"localhost"===self.location.hostname,modulePathPrefix:null,modulePathCb:null},this.s=this.t.debug?"dev":"prod",this.o=!1,new Proxy(this,{get(t,s){if(t[s])return t[s];const o=e[s];return o&&t.loadModule(`workbox-${o}`),t[s]}})}setConfig(t={}){if(this.o)throw new Error("Config must be set before accessing workbox.* modules");Object.assign(this.t,t),this.s=this.t.debug?"dev":"prod"}loadModule(t){const e=this.i(t);try{importScripts(e),this.o=!0}catch(s){throw console.error(`Unable to import module '${t}' from '${e}'.`),s}}i(e){if(this.t.modulePathCb)return this.t.modulePathCb(e,this.t.debug);let s=[t];const o=`${e}.${this.s}.js`,r=this.t.modulePathPrefix;return r&&""===(s=r.split("/"))[s.length-1]&&s.splice(s.length-1,1),s.push(o),s.join("/")}}}();
-//# sourceMappingURL=workbox-sw.js.map

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
public/workbox/workbox-window.prod.es5.mjs


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
public/workbox/workbox-window.prod.mjs


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
public/workbox/workbox-window.prod.umd.js


+ 17 - 18
src/actions/actionAddToLibrary.ts

@@ -1,30 +1,29 @@
 import { register } from "./register";
-import { getSelectedElements } from "../scene";
-import { getNonDeletedElements } from "../element";
 import { deepCopyElement } from "../element/newElement";
 import { randomId } from "../random";
 import { t } from "../i18n";
+import { LIBRARY_DISABLED_TYPES } from "../constants";
 
 export const actionAddToLibrary = register({
   name: "addToLibrary",
   trackEvent: { category: "element" },
   perform: (elements, appState, _, app) => {
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-      {
-        includeBoundTextElement: true,
-        includeElementsInFrames: true,
-      },
-    );
-    if (selectedElements.some((element) => element.type === "image")) {
-      return {
-        commitToHistory: false,
-        appState: {
-          ...appState,
-          errorMessage: "Support for adding images to the library coming soon!",
-        },
-      };
+    const selectedElements = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+      includeBoundTextElement: true,
+      includeElementsInFrames: true,
+    });
+
+    for (const type of LIBRARY_DISABLED_TYPES) {
+      if (selectedElements.some((element) => element.type === type)) {
+        return {
+          commitToHistory: false,
+          appState: {
+            ...appState,
+            errorMessage: t(`errors.libraryElementTypeError.${type}`),
+          },
+        };
+      }
     }
 
     return app.library

+ 31 - 34
src/actions/actionAlign.tsx

@@ -12,19 +12,18 @@ import { getNonDeletedElements } from "../element";
 import { ExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
 import { KEYS } from "../keys";
-import { getSelectedElements, isSomeElementSelected } from "../scene";
-import { AppState } from "../types";
+import { isSomeElementSelected } from "../scene";
+import { AppClassProperties, AppState } from "../types";
 import { arrayToMap, getShortcutKey } from "../utils";
 import { register } from "./register";
 
 const alignActionsPredicate = (
   elements: readonly ExcalidrawElement[],
   appState: AppState,
+  _: unknown,
+  app: AppClassProperties,
 ) => {
-  const selectedElements = getSelectedElements(
-    getNonDeletedElements(elements),
-    appState,
-  );
+  const selectedElements = app.scene.getSelectedElements(appState);
   return (
     selectedElements.length > 1 &&
     // TODO enable aligning frames when implemented properly
@@ -35,12 +34,10 @@ const alignActionsPredicate = (
 const alignSelectedElements = (
   elements: readonly ExcalidrawElement[],
   appState: Readonly<AppState>,
+  app: AppClassProperties,
   alignment: Alignment,
 ) => {
-  const selectedElements = getSelectedElements(
-    getNonDeletedElements(elements),
-    appState,
-  );
+  const selectedElements = app.scene.getSelectedElements(appState);
 
   const updatedElements = alignElements(selectedElements, alignment);
 
@@ -55,10 +52,10 @@ export const actionAlignTop = register({
   name: "alignTop",
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       appState,
-      elements: alignSelectedElements(elements, appState, {
+      elements: alignSelectedElements(elements, appState, app, {
         position: "start",
         axis: "y",
       }),
@@ -67,9 +64,9 @@ export const actionAlignTop = register({
   },
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
-      hidden={!alignActionsPredicate(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState, null, app)}
       type="button"
       icon={AlignTopIcon}
       onClick={() => updateData(null)}
@@ -86,10 +83,10 @@ export const actionAlignBottom = register({
   name: "alignBottom",
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       appState,
-      elements: alignSelectedElements(elements, appState, {
+      elements: alignSelectedElements(elements, appState, app, {
         position: "end",
         axis: "y",
       }),
@@ -98,9 +95,9 @@ export const actionAlignBottom = register({
   },
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
-      hidden={!alignActionsPredicate(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState, null, app)}
       type="button"
       icon={AlignBottomIcon}
       onClick={() => updateData(null)}
@@ -117,10 +114,10 @@ export const actionAlignLeft = register({
   name: "alignLeft",
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       appState,
-      elements: alignSelectedElements(elements, appState, {
+      elements: alignSelectedElements(elements, appState, app, {
         position: "start",
         axis: "x",
       }),
@@ -129,9 +126,9 @@ export const actionAlignLeft = register({
   },
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
-      hidden={!alignActionsPredicate(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState, null, app)}
       type="button"
       icon={AlignLeftIcon}
       onClick={() => updateData(null)}
@@ -148,10 +145,10 @@ export const actionAlignRight = register({
   name: "alignRight",
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       appState,
-      elements: alignSelectedElements(elements, appState, {
+      elements: alignSelectedElements(elements, appState, app, {
         position: "end",
         axis: "x",
       }),
@@ -160,9 +157,9 @@ export const actionAlignRight = register({
   },
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
-      hidden={!alignActionsPredicate(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState, null, app)}
       type="button"
       icon={AlignRightIcon}
       onClick={() => updateData(null)}
@@ -179,19 +176,19 @@ export const actionAlignVerticallyCentered = register({
   name: "alignVerticallyCentered",
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       appState,
-      elements: alignSelectedElements(elements, appState, {
+      elements: alignSelectedElements(elements, appState, app, {
         position: "center",
         axis: "y",
       }),
       commitToHistory: true,
     };
   },
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
-      hidden={!alignActionsPredicate(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState, null, app)}
       type="button"
       icon={CenterVerticallyIcon}
       onClick={() => updateData(null)}
@@ -206,19 +203,19 @@ export const actionAlignHorizontallyCentered = register({
   name: "alignHorizontallyCentered",
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       appState,
-      elements: alignSelectedElements(elements, appState, {
+      elements: alignSelectedElements(elements, appState, app, {
         position: "center",
         axis: "x",
       }),
       commitToHistory: true,
     };
   },
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
-      hidden={!alignActionsPredicate(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState, null, app)}
       type="button"
       icon={CenterHorizontallyIcon}
       onClick={() => updateData(null)}

+ 15 - 24
src/actions/actionBoundText.tsx

@@ -4,7 +4,7 @@ import {
   VERTICAL_ALIGN,
   TEXT_ALIGN,
 } from "../constants";
-import { getNonDeletedElements, isTextElement, newElement } from "../element";
+import { isTextElement, newElement } from "../element";
 import { mutateElement } from "../element/mutateElement";
 import {
   computeBoundTextPosition,
@@ -29,8 +29,8 @@ import {
   ExcalidrawTextContainer,
   ExcalidrawTextElement,
 } from "../element/types";
-import { getSelectedElements } from "../scene";
 import { AppState } from "../types";
+import { Mutable } from "../utility-types";
 import { getFontString } from "../utils";
 import { register } from "./register";
 
@@ -38,16 +38,13 @@ export const actionUnbindText = register({
   name: "unbindText",
   contextItemLabel: "labels.unbindText",
   trackEvent: { category: "element" },
-  predicate: (elements, appState) => {
-    const selectedElements = getSelectedElements(elements, appState);
+  predicate: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
 
     return selectedElements.some((element) => hasBoundTextElement(element));
   },
-  perform: (elements, appState) => {
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-    );
+  perform: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
     selectedElements.forEach((element) => {
       const boundTextElement = getBoundTextElement(element);
       if (boundTextElement) {
@@ -92,8 +89,8 @@ export const actionBindText = register({
   name: "bindText",
   contextItemLabel: "labels.bindText",
   trackEvent: { category: "element" },
-  predicate: (elements, appState) => {
-    const selectedElements = getSelectedElements(elements, appState);
+  predicate: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
 
     if (selectedElements.length === 2) {
       const textElement =
@@ -116,11 +113,8 @@ export const actionBindText = register({
     }
     return false;
   },
-  perform: (elements, appState) => {
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-    );
+  perform: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
 
     let textElement: ExcalidrawTextElement;
     let container: ExcalidrawTextContainer;
@@ -200,18 +194,15 @@ export const actionWrapTextInContainer = register({
   name: "wrapTextInContainer",
   contextItemLabel: "labels.createContainerFromText",
   trackEvent: { category: "element" },
-  predicate: (elements, appState) => {
-    const selectedElements = getSelectedElements(elements, appState);
+  predicate: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
     const areTextElements = selectedElements.every((el) => isTextElement(el));
     return selectedElements.length > 0 && areTextElements;
   },
-  perform: (elements, appState) => {
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-    );
+  perform: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
     let updatedElements: readonly ExcalidrawElement[] = elements.slice();
-    const containerIds: AppState["selectedElementIds"] = {};
+    const containerIds: Mutable<AppState["selectedElementIds"]> = {};
 
     for (const textElement of selectedElements) {
       if (isTextElement(textElement)) {

+ 98 - 36
src/actions/actionCanvas.tsx

@@ -6,7 +6,7 @@ import { getCommonBounds, getNonDeletedElements } from "../element";
 import { ExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
 import { CODES, KEYS } from "../keys";
-import { getNormalizedZoom, getSelectedElements } from "../scene";
+import { getNormalizedZoom } from "../scene";
 import { centerScrollOn } from "../scene/scroll";
 import { getStateForZoom } from "../scene/zoom";
 import { AppState, NormalizedZoomValue } from "../types";
@@ -20,7 +20,6 @@ import {
   isHandToolActive,
 } from "../appState";
 import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
-import { excludeElementsInFramesFromSelection } from "../scene/selection";
 import { Bounds } from "../element/bounds";
 
 export const actionChangeViewBackgroundColor = register({
@@ -226,52 +225,93 @@ const zoomValueToFitBoundsOnViewport = (
   return clampedZoomValueToFitElements as NormalizedZoomValue;
 };
 
-export const zoomToFitElements = (
-  elements: readonly ExcalidrawElement[],
-  appState: Readonly<AppState>,
-  zoomToSelection: boolean,
-) => {
-  const nonDeletedElements = getNonDeletedElements(elements);
-  const selectedElements = getSelectedElements(nonDeletedElements, appState);
+export const zoomToFit = ({
+  targetElements,
+  appState,
+  fitToViewport = false,
+  viewportZoomFactor = 0.7,
+}: {
+  targetElements: readonly ExcalidrawElement[];
+  appState: Readonly<AppState>;
+  /** whether to fit content to viewport (beyond >100%) */
+  fitToViewport: boolean;
+  /** zoom content to cover X of the viewport, when fitToViewport=true */
+  viewportZoomFactor?: number;
+}) => {
+  const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
+
+  const [x1, y1, x2, y2] = commonBounds;
+  const centerX = (x1 + x2) / 2;
+  const centerY = (y1 + y2) / 2;
+
+  let newZoomValue;
+  let scrollX;
+  let scrollY;
+
+  if (fitToViewport) {
+    const commonBoundsWidth = x2 - x1;
+    const commonBoundsHeight = y2 - y1;
 
-  const commonBounds =
-    zoomToSelection && selectedElements.length > 0
-      ? getCommonBounds(excludeElementsInFramesFromSelection(selectedElements))
-      : getCommonBounds(
-          excludeElementsInFramesFromSelection(nonDeletedElements),
-        );
+    newZoomValue =
+      Math.min(
+        appState.width / commonBoundsWidth,
+        appState.height / commonBoundsHeight,
+      ) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
 
-  const newZoom = {
-    value: zoomValueToFitBoundsOnViewport(commonBounds, {
+    // Apply clamping to newZoomValue to be between 10% and 3000%
+    newZoomValue = Math.min(
+      Math.max(newZoomValue, 0.1),
+      30.0,
+    ) as NormalizedZoomValue;
+
+    scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
+    scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
+  } else {
+    newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
       width: appState.width,
       height: appState.height,
-    }),
-  };
+    });
+
+    const centerScroll = centerScrollOn({
+      scenePoint: { x: centerX, y: centerY },
+      viewportDimensions: {
+        width: appState.width,
+        height: appState.height,
+      },
+      zoom: { value: newZoomValue },
+    });
+
+    scrollX = centerScroll.scrollX;
+    scrollY = centerScroll.scrollY;
+  }
 
-  const [x1, y1, x2, y2] = commonBounds;
-  const centerX = (x1 + x2) / 2;
-  const centerY = (y1 + y2) / 2;
   return {
     appState: {
       ...appState,
-      ...centerScrollOn({
-        scenePoint: { x: centerX, y: centerY },
-        viewportDimensions: {
-          width: appState.width,
-          height: appState.height,
-        },
-        zoom: newZoom,
-      }),
-      zoom: newZoom,
+      scrollX,
+      scrollY,
+      zoom: { value: newZoomValue },
     },
     commitToHistory: false,
   };
 };
 
-export const actionZoomToSelected = register({
-  name: "zoomToSelection",
+// Note, this action differs from actionZoomToFitSelection in that it doesn't
+// zoom beyond 100%. In other words, if the content is smaller than viewport
+// size, it won't be zoomed in.
+export const actionZoomToFitSelectionInViewport = register({
+  name: "zoomToFitSelectionInViewport",
   trackEvent: { category: "canvas" },
-  perform: (elements, appState) => zoomToFitElements(elements, appState, true),
+  perform: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
+    return zoomToFit({
+      targetElements: selectedElements.length ? selectedElements : elements,
+      appState,
+      fitToViewport: false,
+    });
+  },
+  // NOTE shift-2 should have been assigned actionZoomToFitSelection.
+  // TBD on how proceed
   keyTest: (event) =>
     event.code === CODES.TWO &&
     event.shiftKey &&
@@ -279,11 +319,31 @@ export const actionZoomToSelected = register({
     !event[KEYS.CTRL_OR_CMD],
 });
 
+export const actionZoomToFitSelection = register({
+  name: "zoomToFitSelection",
+  trackEvent: { category: "canvas" },
+  perform: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
+    return zoomToFit({
+      targetElements: selectedElements.length ? selectedElements : elements,
+      appState,
+      fitToViewport: true,
+    });
+  },
+  // NOTE this action should use shift-2 per figma, alas
+  keyTest: (event) =>
+    event.code === CODES.THREE &&
+    event.shiftKey &&
+    !event.altKey &&
+    !event[KEYS.CTRL_OR_CMD],
+});
+
 export const actionZoomToFit = register({
   name: "zoomToFit",
   viewMode: true,
   trackEvent: { category: "canvas" },
-  perform: (elements, appState) => zoomToFitElements(elements, appState, false),
+  perform: (elements, appState) =>
+    zoomToFit({ targetElements: elements, appState, fitToViewport: false }),
   keyTest: (event) =>
     event.code === CODES.ONE &&
     event.shiftKey &&
@@ -336,6 +396,7 @@ export const actionToggleEraserTool = register({
         ...appState,
         selectedElementIds: {},
         selectedGroupIds: {},
+        activeEmbeddable: null,
         activeTool,
       },
       commitToHistory: true,
@@ -362,7 +423,7 @@ export const actionToggleHandTool = register({
         type: "hand",
         lastActiveToolBeforeEraser: appState.activeTool,
       });
-      setCursor(app.canvas, CURSOR_TYPE.GRAB);
+      setCursor(app.interactiveCanvas, CURSOR_TYPE.GRAB);
     }
 
     return {
@@ -370,6 +431,7 @@ export const actionToggleHandTool = register({
         ...appState,
         selectedElementIds: {},
         selectedGroupIds: {},
+        activeEmbeddable: null,
         activeTool,
       },
       commitToHistory: true,

+ 24 - 30
src/actions/actionClipboard.tsx

@@ -7,7 +7,6 @@ import {
   probablySupportsClipboardWriteText,
 } from "../clipboard";
 import { actionDeleteSelected } from "./actionDeleteSelected";
-import { getSelectedElements } from "../scene/selection";
 import { exportCanvas } from "../data/index";
 import { getNonDeletedElements, isTextElement } from "../element";
 import { t } from "../i18n";
@@ -16,7 +15,8 @@ export const actionCopy = register({
   name: "copy",
   trackEvent: { category: "element" },
   perform: (elements, appState, _, app) => {
-    const elementsToCopy = getSelectedElements(elements, appState, {
+    const elementsToCopy = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
       includeBoundTextElement: true,
       includeElementsInFrames: true,
     });
@@ -75,14 +75,11 @@ export const actionCopyAsSvg = register({
         commitToHistory: false,
       };
     }
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-      {
-        includeBoundTextElement: true,
-        includeElementsInFrames: true,
-      },
-    );
+    const selectedElements = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+      includeBoundTextElement: true,
+      includeElementsInFrames: true,
+    });
     try {
       await exportCanvas(
         "clipboard-svg",
@@ -122,14 +119,11 @@ export const actionCopyAsPng = register({
         commitToHistory: false,
       };
     }
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-      {
-        includeBoundTextElement: true,
-        includeElementsInFrames: true,
-      },
-    );
+    const selectedElements = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+      includeBoundTextElement: true,
+      includeElementsInFrames: true,
+    });
     try {
       await exportCanvas(
         "clipboard",
@@ -177,14 +171,11 @@ export const actionCopyAsPng = register({
 export const copyText = register({
   name: "copyText",
   trackEvent: { category: "element" },
-  perform: (elements, appState) => {
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-      {
-        includeBoundTextElement: true,
-      },
-    );
+  perform: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+      includeBoundTextElement: true,
+    });
 
     const text = selectedElements
       .reduce((acc: string[], element) => {
@@ -199,12 +190,15 @@ export const copyText = register({
       commitToHistory: false,
     };
   },
-  predicate: (elements, appState) => {
+  predicate: (elements, appState, _, app) => {
     return (
       probablySupportsClipboardWriteText &&
-      getSelectedElements(elements, appState, {
-        includeBoundTextElement: true,
-      }).some(isTextElement)
+      app.scene
+        .getSelectedElements({
+          selectedElementIds: appState.selectedElementIds,
+          includeBoundTextElement: true,
+        })
+        .some(isTextElement)
     );
   },
   contextItemLabel: "labels.copyText",

+ 1 - 0
src/actions/actionDeleteSelected.tsx

@@ -158,6 +158,7 @@ export const actionDeleteSelected = register({
         ...nextAppState,
         activeTool: updateActiveTool(appState, { type: "selection" }),
         multiElement: null,
+        activeEmbeddable: null,
       },
       commitToHistory: isSomeElementSelected(
         getNonDeletedElements(elements),

+ 14 - 22
src/actions/actionDistribute.tsx

@@ -8,19 +8,13 @@ import { getNonDeletedElements } from "../element";
 import { ExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
 import { CODES, KEYS } from "../keys";
-import { getSelectedElements, isSomeElementSelected } from "../scene";
-import { AppState } from "../types";
+import { isSomeElementSelected } from "../scene";
+import { AppClassProperties, AppState } from "../types";
 import { arrayToMap, getShortcutKey } from "../utils";
 import { register } from "./register";
 
-const enableActionGroup = (
-  elements: readonly ExcalidrawElement[],
-  appState: AppState,
-) => {
-  const selectedElements = getSelectedElements(
-    getNonDeletedElements(elements),
-    appState,
-  );
+const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
+  const selectedElements = app.scene.getSelectedElements(appState);
   return (
     selectedElements.length > 1 &&
     // TODO enable distributing frames when implemented properly
@@ -31,12 +25,10 @@ const enableActionGroup = (
 const distributeSelectedElements = (
   elements: readonly ExcalidrawElement[],
   appState: Readonly<AppState>,
+  app: AppClassProperties,
   distribution: Distribution,
 ) => {
-  const selectedElements = getSelectedElements(
-    getNonDeletedElements(elements),
-    appState,
-  );
+  const selectedElements = app.scene.getSelectedElements(appState);
 
   const updatedElements = distributeElements(selectedElements, distribution);
 
@@ -50,10 +42,10 @@ const distributeSelectedElements = (
 export const distributeHorizontally = register({
   name: "distributeHorizontally",
   trackEvent: { category: "element" },
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       appState,
-      elements: distributeSelectedElements(elements, appState, {
+      elements: distributeSelectedElements(elements, appState, app, {
         space: "between",
         axis: "x",
       }),
@@ -62,9 +54,9 @@ export const distributeHorizontally = register({
   },
   keyTest: (event) =>
     !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
-      hidden={!enableActionGroup(elements, appState)}
+      hidden={!enableActionGroup(appState, app)}
       type="button"
       icon={DistributeHorizontallyIcon}
       onClick={() => updateData(null)}
@@ -80,10 +72,10 @@ export const distributeHorizontally = register({
 export const distributeVertically = register({
   name: "distributeVertically",
   trackEvent: { category: "element" },
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       appState,
-      elements: distributeSelectedElements(elements, appState, {
+      elements: distributeSelectedElements(elements, appState, app, {
         space: "between",
         axis: "y",
       }),
@@ -92,9 +84,9 @@ export const distributeVertically = register({
   },
   keyTest: (event) =>
     !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
-      hidden={!enableActionGroup(elements, appState)}
+      hidden={!enableActionGroup(appState, app)}
       type="button"
       icon={DistributeVerticallyIcon}
       onClick={() => updateData(null)}

+ 20 - 16
src/actions/actionDuplicateSelection.tsx

@@ -259,21 +259,25 @@ const duplicateElements = (
 
   return {
     elements: finalElements,
-    appState: selectGroupsForSelectedElements(
-      {
-        ...appState,
-        selectedGroupIds: {},
-        selectedElementIds: nextElementsToSelect.reduce(
-          (acc: Record<ExcalidrawElement["id"], true>, element) => {
-            if (!isBoundToContainer(element)) {
-              acc[element.id] = true;
-            }
-            return acc;
-          },
-          {},
-        ),
-      },
-      getNonDeletedElements(finalElements),
-    ),
+    appState: {
+      ...appState,
+      ...selectGroupsForSelectedElements(
+        {
+          editingGroupId: appState.editingGroupId,
+          selectedElementIds: nextElementsToSelect.reduce(
+            (acc: Record<ExcalidrawElement["id"], true>, element) => {
+              if (!isBoundToContainer(element)) {
+                acc[element.id] = true;
+              }
+              return acc;
+            },
+            {},
+          ),
+        },
+        getNonDeletedElements(finalElements),
+        appState,
+        null,
+      ),
+    },
   };
 };

+ 11 - 9
src/actions/actionElementLock.ts

@@ -1,7 +1,6 @@
 import { newElementWith } from "../element/mutateElement";
 import { ExcalidrawElement } from "../element/types";
 import { KEYS } from "../keys";
-import { getSelectedElements } from "../scene";
 import { arrayToMap } from "../utils";
 import { register } from "./register";
 
@@ -11,14 +10,15 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
 export const actionToggleElementLock = register({
   name: "toggleElementLock",
   trackEvent: { category: "element" },
-  predicate: (elements, appState) => {
-    const selectedElements = getSelectedElements(elements, appState);
+  predicate: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
     return !selectedElements.some(
       (element) => element.locked && element.frameId,
     );
   },
-  perform: (elements, appState) => {
-    const selectedElements = getSelectedElements(elements, appState, {
+  perform: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
       includeBoundTextElement: true,
       includeElementsInFrames: true,
     });
@@ -46,8 +46,9 @@ export const actionToggleElementLock = register({
       commitToHistory: true,
     };
   },
-  contextItemLabel: (elements, appState) => {
-    const selected = getSelectedElements(elements, appState, {
+  contextItemLabel: (elements, appState, app) => {
+    const selected = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
       includeBoundTextElement: false,
     });
     if (selected.length === 1 && selected[0].type !== "frame") {
@@ -60,12 +61,13 @@ export const actionToggleElementLock = register({
       ? "labels.elementLock.lockAll"
       : "labels.elementLock.unlockAll";
   },
-  keyTest: (event, appState, elements) => {
+  keyTest: (event, appState, elements, app) => {
     return (
       event.key.toLocaleLowerCase() === KEYS.L &&
       event[KEYS.CTRL_OR_CMD] &&
       event.shiftKey &&
-      getSelectedElements(elements, appState, {
+      app.scene.getSelectedElements({
+        selectedElementIds: appState.selectedElementIds,
         includeBoundTextElement: false,
       }).length > 0
     );

+ 5 - 5
src/actions/actionExport.tsx

@@ -65,7 +65,7 @@ export const actionChangeExportScale = register({
           );
 
           const scaleButtonTitle = `${t(
-            "buttons.scale",
+            "imageExportDialog.label.scale",
           )} ${s}x (${width}x${height})`;
 
           return (
@@ -102,7 +102,7 @@ export const actionChangeExportBackground = register({
       checked={appState.exportBackground}
       onChange={(checked) => updateData(checked)}
     >
-      {t("labels.withBackground")}
+      {t("imageExportDialog.label.withBackground")}
     </CheckboxItem>
   ),
 });
@@ -121,8 +121,8 @@ export const actionChangeExportEmbedScene = register({
       checked={appState.exportEmbedScene}
       onChange={(checked) => updateData(checked)}
     >
-      {t("labels.exportEmbedScene")}
-      <Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
+      {t("imageExportDialog.label.embedScene")}
+      <Tooltip label={t("imageExportDialog.tooltip.embedScene")} long={true}>
         <div className="excalidraw-tooltip-icon">{questionCircle}</div>
       </Tooltip>
     </CheckboxItem>
@@ -277,7 +277,7 @@ export const actionExportWithDarkMode = register({
         onChange={(theme: Theme) => {
           updateData(theme === THEME.DARK);
         }}
-        title={t("labels.toggleExportColorScheme")}
+        title={t("imageExportDialog.label.darkMode")}
       />
     </div>
   ),

+ 8 - 9
src/actions/actionFinalize.tsx

@@ -19,7 +19,12 @@ import { AppState } from "../types";
 export const actionFinalize = register({
   name: "finalize",
   trackEvent: false,
-  perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
+  perform: (
+    elements,
+    appState,
+    _,
+    { interactiveCanvas, focusContainer, scene },
+  ) => {
     if (appState.editingLinearElement) {
       const { elementId, startBindingElement, endBindingElement } =
         appState.editingLinearElement;
@@ -125,13 +130,6 @@ export const actionFinalize = register({
           { x, y },
         );
       }
-
-      if (
-        !appState.activeTool.locked &&
-        appState.activeTool.type !== "freedraw"
-      ) {
-        appState.selectedElementIds[multiPointElement.id] = true;
-      }
     }
 
     if (
@@ -139,7 +137,7 @@ export const actionFinalize = register({
         appState.activeTool.type !== "freedraw") ||
       !multiPointElement
     ) {
-      resetCursor(canvas);
+      resetCursor(interactiveCanvas);
     }
 
     let activeTool: AppState["activeTool"];
@@ -167,6 +165,7 @@ export const actionFinalize = register({
           multiPointElement
             ? appState.activeTool
             : activeTool,
+        activeEmbeddable: null,
         draggingElement: null,
         multiElement: null,
         editingElement: null,

+ 3 - 2
src/actions/actionFlip.ts

@@ -17,7 +17,7 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
 export const actionFlipHorizontal = register({
   name: "flipHorizontal",
   trackEvent: { category: "element" },
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       elements: flipSelectedElements(elements, appState, "horizontal"),
       appState,
@@ -31,11 +31,12 @@ export const actionFlipHorizontal = register({
 export const actionFlipVertical = register({
   name: "flipVertical",
   trackEvent: { category: "element" },
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       elements: updateFrameMembershipOfSelectedElements(
         flipSelectedElements(elements, appState, "vertical"),
         appState,
+        app,
       ),
       appState,
       commitToHistory: true,

+ 20 - 28
src/actions/actionFrame.ts

@@ -3,19 +3,12 @@ import { ExcalidrawElement } from "../element/types";
 import { removeAllElementsFromFrame } from "../frame";
 import { getFrameElements } from "../frame";
 import { KEYS } from "../keys";
-import { getSelectedElements } from "../scene";
-import { AppState } from "../types";
+import { AppClassProperties, AppState } from "../types";
 import { setCursorForShape, updateActiveTool } from "../utils";
 import { register } from "./register";
 
-const isSingleFrameSelected = (
-  elements: readonly ExcalidrawElement[],
-  appState: AppState,
-) => {
-  const selectedElements = getSelectedElements(
-    getNonDeletedElements(elements),
-    appState,
-  );
+const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
+  const selectedElements = app.scene.getSelectedElements(appState);
 
   return selectedElements.length === 1 && selectedElements[0].type === "frame";
 };
@@ -23,11 +16,8 @@ const isSingleFrameSelected = (
 export const actionSelectAllElementsInFrame = register({
   name: "selectAllElementsInFrame",
   trackEvent: { category: "canvas" },
-  perform: (elements, appState) => {
-    const selectedFrame = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-    )[0];
+  perform: (elements, appState, _, app) => {
+    const selectedFrame = app.scene.getSelectedElements(appState)[0];
 
     if (selectedFrame && selectedFrame.type === "frame") {
       const elementsInFrame = getFrameElements(
@@ -55,17 +45,15 @@ export const actionSelectAllElementsInFrame = register({
     };
   },
   contextItemLabel: "labels.selectAllElementsInFrame",
-  predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
+  predicate: (elements, appState, _, app) =>
+    isSingleFrameSelected(appState, app),
 });
 
 export const actionRemoveAllElementsFromFrame = register({
   name: "removeAllElementsFromFrame",
   trackEvent: { category: "history" },
-  perform: (elements, appState) => {
-    const selectedFrame = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-    )[0];
+  perform: (elements, appState, _, app) => {
+    const selectedFrame = app.scene.getSelectedElements(appState)[0];
 
     if (selectedFrame && selectedFrame.type === "frame") {
       return {
@@ -87,11 +75,12 @@ export const actionRemoveAllElementsFromFrame = register({
     };
   },
   contextItemLabel: "labels.removeAllElementsFromFrame",
-  predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
+  predicate: (elements, appState, _, app) =>
+    isSingleFrameSelected(appState, app),
 });
 
-export const actionToggleFrameRendering = register({
-  name: "toggleFrameRendering",
+export const actionupdateFrameRendering = register({
+  name: "updateFrameRendering",
   viewMode: true,
   trackEvent: { category: "canvas" },
   perform: (elements, appState) => {
@@ -99,13 +88,16 @@ export const actionToggleFrameRendering = register({
       elements,
       appState: {
         ...appState,
-        shouldRenderFrames: !appState.shouldRenderFrames,
+        frameRendering: {
+          ...appState.frameRendering,
+          enabled: !appState.frameRendering.enabled,
+        },
       },
       commitToHistory: false,
     };
   },
-  contextItemLabel: "labels.toggleFrameRendering",
-  checked: (appState: AppState) => appState.shouldRenderFrames,
+  contextItemLabel: "labels.updateFrameRendering",
+  checked: (appState: AppState) => appState.frameRendering.enabled,
 });
 
 export const actionSetFrameAsActiveTool = register({
@@ -116,7 +108,7 @@ export const actionSetFrameAsActiveTool = register({
       type: "frame",
     });
 
-    setCursorForShape(app.canvas, {
+    setCursorForShape(app.interactiveCanvas, {
       ...appState,
       activeTool: nextActiveTool,
     });

+ 39 - 29
src/actions/actionGroup.tsx

@@ -4,7 +4,7 @@ import { arrayToMap, getShortcutKey } from "../utils";
 import { register } from "./register";
 import { UngroupIcon, GroupIcon } from "../components/icons";
 import { newElementWith } from "../element/mutateElement";
-import { getSelectedElements, isSomeElementSelected } from "../scene";
+import { isSomeElementSelected } from "../scene";
 import {
   getSelectedGroupIds,
   selectGroup,
@@ -22,7 +22,7 @@ import {
   ExcalidrawFrameElement,
   ExcalidrawTextElement,
 } from "../element/types";
-import { AppState } from "../types";
+import { AppClassProperties, AppState } from "../types";
 import { isBoundToContainer } from "../element/typeChecks";
 import {
   getElementsInResizingFrame,
@@ -51,14 +51,12 @@ const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
 const enableActionGroup = (
   elements: readonly ExcalidrawElement[],
   appState: AppState,
+  app: AppClassProperties,
 ) => {
-  const selectedElements = getSelectedElements(
-    getNonDeletedElements(elements),
-    appState,
-    {
-      includeBoundTextElement: true,
-    },
-  );
+  const selectedElements = app.scene.getSelectedElements({
+    selectedElementIds: appState.selectedElementIds,
+    includeBoundTextElement: true,
+  });
   return (
     selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
   );
@@ -68,13 +66,10 @@ export const actionGroup = register({
   name: "group",
   trackEvent: { category: "element" },
   perform: (elements, appState, _, app) => {
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-      {
-        includeBoundTextElement: true,
-      },
-    );
+    const selectedElements = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+      includeBoundTextElement: true,
+    });
     if (selectedElements.length < 2) {
       // nothing to group
       return { appState, elements, commitToHistory: false };
@@ -154,22 +149,26 @@ export const actionGroup = register({
     ];
 
     return {
-      appState: selectGroup(
-        newGroupId,
-        { ...appState, selectedGroupIds: {} },
-        getNonDeletedElements(nextElements),
-      ),
+      appState: {
+        ...appState,
+        ...selectGroup(
+          newGroupId,
+          { ...appState, selectedGroupIds: {} },
+          getNonDeletedElements(nextElements),
+        ),
+      },
       elements: nextElements,
       commitToHistory: true,
     };
   },
   contextItemLabel: "labels.group",
-  predicate: (elements, appState) => enableActionGroup(elements, appState),
+  predicate: (elements, appState, _, app) =>
+    enableActionGroup(elements, appState, app),
   keyTest: (event) =>
     !event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
-      hidden={!enableActionGroup(elements, appState)}
+      hidden={!enableActionGroup(elements, appState, app)}
       type="button"
       icon={<GroupIcon theme={appState.theme} />}
       onClick={() => updateData(null)}
@@ -191,7 +190,7 @@ export const actionUngroup = register({
 
     let nextElements = [...elements];
 
-    const selectedElements = getSelectedElements(nextElements, appState);
+    const selectedElements = app.scene.getSelectedElements(appState);
     const frames = selectedElements
       .filter((element) => element.frameId)
       .map((element) =>
@@ -216,8 +215,10 @@ export const actionUngroup = register({
     });
 
     const updateAppState = selectGroupsForSelectedElements(
-      { ...appState, selectedGroupIds: {} },
+      appState,
       getNonDeletedElements(nextElements),
+      appState,
+      null,
     );
 
     frames.forEach((frame) => {
@@ -232,11 +233,20 @@ export const actionUngroup = register({
     });
 
     // remove binded text elements from selection
-    boundTextElementIds.forEach(
-      (id) => (updateAppState.selectedElementIds[id] = false),
+    updateAppState.selectedElementIds = Object.entries(
+      updateAppState.selectedElementIds,
+    ).reduce(
+      (acc: { [key: ExcalidrawElement["id"]]: true }, [id, selected]) => {
+        if (selected && !boundTextElementIds.includes(id)) {
+          acc[id] = true;
+        }
+        return acc;
+      },
+      {},
     );
+
     return {
-      appState: updateAppState,
+      appState: { ...appState, ...updateAppState },
       elements: nextElements,
       commitToHistory: true,
     };

+ 11 - 19
src/actions/actionLinearEditor.ts

@@ -1,8 +1,6 @@
-import { getNonDeletedElements } from "../element";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { isLinearElement } from "../element/typeChecks";
 import { ExcalidrawLinearElement } from "../element/types";
-import { getSelectedElements } from "../scene";
 import { register } from "./register";
 
 export const actionToggleLinearEditor = register({
@@ -10,21 +8,18 @@ export const actionToggleLinearEditor = register({
   trackEvent: {
     category: "element",
   },
-  predicate: (elements, appState) => {
-    const selectedElements = getSelectedElements(elements, appState);
+  predicate: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
     if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
       return true;
     }
     return false;
   },
   perform(elements, appState, _, app) {
-    const selectedElement = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-      {
-        includeBoundTextElement: true,
-      },
-    )[0] as ExcalidrawLinearElement;
+    const selectedElement = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+      includeBoundTextElement: true,
+    })[0] as ExcalidrawLinearElement;
 
     const editingLinearElement =
       appState.editingLinearElement?.elementId === selectedElement.id
@@ -38,14 +33,11 @@ export const actionToggleLinearEditor = register({
       commitToHistory: false,
     };
   },
-  contextItemLabel: (elements, appState) => {
-    const selectedElement = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-      {
-        includeBoundTextElement: true,
-      },
-    )[0] as ExcalidrawLinearElement;
+  contextItemLabel: (elements, appState, app) => {
+    const selectedElement = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+      includeBoundTextElement: true,
+    })[0] as ExcalidrawLinearElement;
     return appState.editingLinearElement?.elementId === selectedElement.id
       ? "labels.lineEditor.exit"
       : "labels.lineEditor.edit";

+ 18 - 14
src/actions/actionSelectAll.ts

@@ -28,20 +28,24 @@ export const actionSelectAll = register({
     }, {});
 
     return {
-      appState: selectGroupsForSelectedElements(
-        {
-          ...appState,
-          selectedLinearElement:
-            // single linear element selected
-            Object.keys(selectedElementIds).length === 1 &&
-            isLinearElement(elements[0])
-              ? new LinearElementEditor(elements[0], app.scene)
-              : null,
-          editingGroupId: null,
-          selectedElementIds,
-        },
-        getNonDeletedElements(elements),
-      ),
+      appState: {
+        ...appState,
+        ...selectGroupsForSelectedElements(
+          {
+            editingGroupId: null,
+            selectedElementIds,
+          },
+          getNonDeletedElements(elements),
+          appState,
+          app,
+        ),
+        selectedLinearElement:
+          // single linear element selected
+          Object.keys(selectedElementIds).length === 1 &&
+          isLinearElement(elements[0])
+            ? new LinearElementEditor(elements[0], app.scene)
+            : null,
+      },
       commitToHistory: true,
     };
   },

+ 2 - 0
src/actions/manager.tsx

@@ -90,6 +90,7 @@ export class ActionManager {
             event,
             this.getAppState(),
             this.getElementsIncludingDeleted(),
+            this.app,
           ),
       );
 
@@ -168,6 +169,7 @@ export class ActionManager {
           appState={this.getAppState()}
           updateData={updateData}
           appProps={this.app.props}
+          app={this.app}
           data={data}
         />
       );

+ 7 - 2
src/actions/types.ts

@@ -82,7 +82,8 @@ export type ActionName =
   | "zoomOut"
   | "resetZoom"
   | "zoomToFit"
-  | "zoomToSelection"
+  | "zoomToFitSelection"
+  | "zoomToFitSelectionInViewport"
   | "changeFontFamily"
   | "changeTextAlign"
   | "changeVerticalAlign"
@@ -118,8 +119,9 @@ export type ActionName =
   | "toggleHandTool"
   | "selectAllElementsInFrame"
   | "removeAllElementsFromFrame"
-  | "toggleFrameRendering"
+  | "updateFrameRendering"
   | "setFrameAsActiveTool"
+  | "setEmbeddableAsActiveTool"
   | "createContainerFromText"
   | "wrapTextInContainer";
 
@@ -129,6 +131,7 @@ export type PanelComponentProps = {
   updateData: (formData?: any) => void;
   appProps: ExcalidrawProps;
   data?: Record<string, any>;
+  app: AppClassProperties;
 };
 
 export interface Action {
@@ -140,12 +143,14 @@ export interface Action {
     event: React.KeyboardEvent | KeyboardEvent,
     appState: AppState,
     elements: readonly ExcalidrawElement[],
+    app: AppClassProperties,
   ) => boolean;
   contextItemLabel?:
     | string
     | ((
         elements: readonly ExcalidrawElement[],
         appState: Readonly<AppState>,
+        app: AppClassProperties,
       ) => string);
   predicate?: (
     elements: readonly ExcalidrawElement[],

+ 1 - 1
src/analytics.ts

@@ -11,7 +11,7 @@ export const trackEvent = (
     // Uncomment the next line to track locally
     // console.log("Track Event", { category, action, label, value });
 
-    if (typeof window === "undefined" || process.env.JEST_WORKER_ID) {
+    if (typeof window === "undefined" || import.meta.env.VITE_WORKER_ID) {
       return;
     }
 

+ 4 - 2
src/appState.ts

@@ -38,6 +38,7 @@ export const getDefaultAppState = (): Omit<
     currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
     currentItemTextAlign: DEFAULT_TEXT_ALIGN,
     cursorButton: "up",
+    activeEmbeddable: null,
     draggingElement: null,
     editingElement: null,
     editingGroupId: null,
@@ -84,7 +85,7 @@ export const getDefaultAppState = (): Omit<
     showStats: false,
     startBoundElement: null,
     suggestedBindings: [],
-    shouldRenderFrames: true,
+    frameRendering: { enabled: true, clip: true, name: true, outline: true },
     frameToHighlight: null,
     editingFrame: null,
     elementsToHighlight: null,
@@ -139,6 +140,7 @@ const APP_STATE_STORAGE_CONF = (<
   currentItemStrokeWidth: { browser: true, export: false, server: false },
   currentItemTextAlign: { browser: true, export: false, server: false },
   cursorButton: { browser: true, export: false, server: false },
+  activeEmbeddable: { browser: false, export: false, server: false },
   draggingElement: { browser: false, export: false, server: false },
   editingElement: { browser: false, export: false, server: false },
   editingGroupId: { browser: true, export: false, server: false },
@@ -191,7 +193,7 @@ const APP_STATE_STORAGE_CONF = (<
   showStats: { browser: true, export: false, server: false },
   startBoundElement: { browser: false, export: false, server: false },
   suggestedBindings: { browser: false, export: false, server: false },
-  shouldRenderFrames: { browser: false, export: false, server: false },
+  frameRendering: { browser: false, export: false, server: false },
   frameToHighlight: { browser: false, export: false, server: false },
   editingFrame: { browser: false, export: false, server: false },
   elementsToHighlight: { browser: false, export: false, server: false },

+ 2 - 3
src/charts.ts

@@ -6,7 +6,6 @@ import {
 import {
   DEFAULT_FONT_FAMILY,
   DEFAULT_FONT_SIZE,
-  ENV,
   VERTICAL_ALIGN,
 } from "./constants";
 import { newElement, newLinearElement, newTextElement } from "./element";
@@ -384,7 +383,7 @@ const chartTypeBar = (
       y,
       groupId,
       backgroundColor,
-      process.env.NODE_ENV === ENV.DEVELOPMENT,
+      import.meta.env.DEV,
     ),
   ];
 };
@@ -473,7 +472,7 @@ const chartTypeLine = (
       y,
       groupId,
       backgroundColor,
-      process.env.NODE_ENV === ENV.DEVELOPMENT,
+      import.meta.env.DEV,
     ),
     line,
     ...lines,

+ 5 - 0
src/clipboard.ts

@@ -24,6 +24,7 @@ export interface ClipboardData {
   files?: BinaryFiles;
   text?: string;
   errorMessage?: string;
+  programmaticAPI?: boolean;
 }
 
 let CLIPBOARD = "";
@@ -48,6 +49,7 @@ const clipboardContainsElements = (
     [
       EXPORT_DATA_TYPES.excalidraw,
       EXPORT_DATA_TYPES.excalidrawClipboard,
+      EXPORT_DATA_TYPES.excalidrawClipboardWithAPI,
     ].includes(contents?.type) &&
     Array.isArray(contents.elements)
   ) {
@@ -191,6 +193,8 @@ export const parseClipboard = async (
 
   try {
     const systemClipboardData = JSON.parse(systemClipboard);
+    const programmaticAPI =
+      systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
     if (clipboardContainsElements(systemClipboardData)) {
       return {
         elements: systemClipboardData.elements,
@@ -198,6 +202,7 @@ export const parseClipboard = async (
         text: isPlainPaste
           ? JSON.stringify(systemClipboardData.elements, null, 2)
           : undefined,
+        programmaticAPI,
       };
     }
   } catch (e) {}

+ 1 - 1
src/colors.ts

@@ -21,7 +21,7 @@ export type ColorPickerColor =
 export type ColorTuple = readonly [string, string, string, string, string];
 export type ColorPalette = Merge<
   Record<ColorPickerColor, ColorTuple>,
-  { black: string; white: string; transparent: string }
+  { black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
 >;
 
 // used general type instead of specific type (ColorPalette) to support custom colors

+ 85 - 35
src/components/Actions.tsx

@@ -36,7 +36,7 @@ import {
 
 import "./Actions.scss";
 import DropdownMenu from "./dropdownMenu/DropdownMenu";
-import { extraToolsIcon, frameToolIcon } from "./icons";
+import { EmbedIcon, extraToolsIcon, frameToolIcon } from "./icons";
 import { KEYS } from "../keys";
 
 export const SelectedShapeActions = ({
@@ -213,13 +213,13 @@ export const SelectedShapeActions = ({
 };
 
 export const ShapesSwitcher = ({
-  canvas,
+  interactiveCanvas,
   activeTool,
   setAppState,
   onImageAction,
   appState,
 }: {
-  canvas: HTMLCanvasElement | null;
+  interactiveCanvas: HTMLCanvasElement | null;
   activeTool: UIAppState["activeTool"];
   setAppState: React.Component<any, UIAppState>["setState"];
   onImageAction: (data: { pointerType: PointerType | null }) => void;
@@ -266,10 +266,11 @@ export const ShapesSwitcher = ({
               });
               setAppState({
                 activeTool: nextActiveTool,
+                activeEmbeddable: null,
                 multiElement: null,
                 selectedElementIds: {},
               });
-              setCursorForShape(canvas, {
+              setCursorForShape(interactiveCanvas, {
                 ...appState,
                 activeTool: nextActiveTool,
               });
@@ -283,39 +284,72 @@ export const ShapesSwitcher = ({
       <div className="App-toolbar__divider" />
       {/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
       {device.isMobile ? (
-        <ToolButton
-          className={clsx("Shape", { fillable: false })}
-          type="radio"
-          icon={frameToolIcon}
-          checked={activeTool.type === "frame"}
-          name="editor-current-shape"
-          title={`${capitalizeString(
-            t("toolBar.frame"),
-          )} — ${KEYS.F.toLocaleUpperCase()}`}
-          keyBindingLabel={KEYS.F.toLocaleUpperCase()}
-          aria-label={capitalizeString(t("toolBar.frame"))}
-          aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
-          data-testid={`toolbar-frame`}
-          onPointerDown={({ pointerType }) => {
-            if (!appState.penDetected && pointerType === "pen") {
+        <>
+          <ToolButton
+            className={clsx("Shape", { fillable: false })}
+            type="radio"
+            icon={frameToolIcon}
+            checked={activeTool.type === "frame"}
+            name="editor-current-shape"
+            title={`${capitalizeString(
+              t("toolBar.frame"),
+            )} — ${KEYS.F.toLocaleUpperCase()}`}
+            keyBindingLabel={KEYS.F.toLocaleUpperCase()}
+            aria-label={capitalizeString(t("toolBar.frame"))}
+            aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
+            data-testid={`toolbar-frame`}
+            onPointerDown={({ pointerType }) => {
+              if (!appState.penDetected && pointerType === "pen") {
+                setAppState({
+                  penDetected: true,
+                  penMode: true,
+                });
+              }
+            }}
+            onChange={({ pointerType }) => {
+              trackEvent("toolbar", "frame", "ui");
+              const nextActiveTool = updateActiveTool(appState, {
+                type: "frame",
+              });
               setAppState({
-                penDetected: true,
-                penMode: true,
+                activeTool: nextActiveTool,
+                multiElement: null,
+                selectedElementIds: {},
+                activeEmbeddable: null,
               });
-            }
-          }}
-          onChange={({ pointerType }) => {
-            trackEvent("toolbar", "frame", "ui");
-            const nextActiveTool = updateActiveTool(appState, {
-              type: "frame",
-            });
-            setAppState({
-              activeTool: nextActiveTool,
-              multiElement: null,
-              selectedElementIds: {},
-            });
-          }}
-        />
+            }}
+          />
+          <ToolButton
+            className={clsx("Shape", { fillable: false })}
+            type="radio"
+            icon={EmbedIcon}
+            checked={activeTool.type === "embeddable"}
+            name="editor-current-shape"
+            title={capitalizeString(t("toolBar.embeddable"))}
+            aria-label={capitalizeString(t("toolBar.embeddable"))}
+            data-testid={`toolbar-embeddable`}
+            onPointerDown={({ pointerType }) => {
+              if (!appState.penDetected && pointerType === "pen") {
+                setAppState({
+                  penDetected: true,
+                  penMode: true,
+                });
+              }
+            }}
+            onChange={({ pointerType }) => {
+              trackEvent("toolbar", "embeddable", "ui");
+              const nextActiveTool = updateActiveTool(appState, {
+                type: "embeddable",
+              });
+              setAppState({
+                activeTool: nextActiveTool,
+                multiElement: null,
+                selectedElementIds: {},
+                activeEmbeddable: null,
+              });
+            }}
+          />
+        </>
       ) : (
         <DropdownMenu open={isExtraToolsMenuOpen}>
           <DropdownMenu.Trigger
@@ -347,6 +381,22 @@ export const ShapesSwitcher = ({
             >
               {t("toolBar.frame")}
             </DropdownMenu.Item>
+            <DropdownMenu.Item
+              onSelect={() => {
+                const nextActiveTool = updateActiveTool(appState, {
+                  type: "embeddable",
+                });
+                setAppState({
+                  activeTool: nextActiveTool,
+                  multiElement: null,
+                  selectedElementIds: {},
+                });
+              }}
+              icon={EmbedIcon}
+              data-testid="toolbar-embeddable"
+            >
+              {t("toolBar.embeddable")}
+            </DropdownMenu.Item>
           </DropdownMenu.Content>
         </DropdownMenu>
       )}

+ 3 - 2
src/components/App.test.tsx

@@ -4,15 +4,16 @@ import { reseed } from "../random";
 import { render, queryByTestId } from "../tests/test-utils";
 
 import ExcalidrawApp from "../excalidraw-app";
+import { vi } from "vitest";
 
-const renderScene = jest.spyOn(Renderer, "renderScene");
+const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
 
 describe("Test <App/>", () => {
   beforeEach(async () => {
     // Unmount ReactDOM from root
     ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
     localStorage.clear();
-    renderScene.mockClear();
+    renderStaticScene.mockClear();
     reseed(7);
   });
 

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 483 - 217
src/components/App.tsx


+ 6 - 2
src/components/ColorPicker/PickerColorList.tsx

@@ -8,7 +8,7 @@ import {
 } from "./colorPickerUtils";
 import HotkeyLabel from "./HotkeyLabel";
 import { ColorPaletteCustom } from "../../colors";
-import { t } from "../../i18n";
+import { TranslationKeys, t } from "../../i18n";
 
 interface PickerColorListProps {
   palette: ColorPaletteCustom;
@@ -48,7 +48,11 @@ const PickerColorList = ({
           (Array.isArray(value) ? value[activeShade] : value) || "transparent";
 
         const keybinding = colorPickerHotkeyBindings[index];
-        const label = t(`colors.${key.replace(/\d+/, "")}`, null, "");
+        const label = t(
+          `colors.${key.replace(/\d+/, "")}` as unknown as TranslationKeys,
+          null,
+          "",
+        );
 
         return (
           <button

+ 9 - 3
src/components/ContextMenu.tsx

@@ -1,6 +1,6 @@
 import clsx from "clsx";
 import { Popover } from "./Popover";
-import { t } from "../i18n";
+import { t, TranslationKeys } from "../i18n";
 
 import "./ContextMenu.scss";
 import {
@@ -82,9 +82,15 @@ export const ContextMenu = React.memo(
             let label = "";
             if (item.contextItemLabel) {
               if (typeof item.contextItemLabel === "function") {
-                label = t(item.contextItemLabel(elements, appState));
+                label = t(
+                  item.contextItemLabel(
+                    elements,
+                    appState,
+                    actionManager.app,
+                  ) as unknown as TranslationKeys,
+                );
               } else {
-                label = t(item.contextItemLabel);
+                label = t(item.contextItemLabel as unknown as TranslationKeys);
               }
             }
 

+ 5 - 5
src/components/EyeDropper.tsx

@@ -8,9 +8,9 @@ import { mutateElement } from "../element/mutateElement";
 import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
 import { useOutsideClick } from "../hooks/useOutsideClick";
 import { KEYS } from "../keys";
-import { invalidateShapeForElement } from "../renderer/renderElement";
 import { getSelectedElements } from "../scene";
 import Scene from "../scene/Scene";
+import { ShapeCache } from "../scene/ShapeCache";
 import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
 
 import "./EyeDropper.scss";
@@ -58,7 +58,7 @@ export const EyeDropper: React.FC<{
       return;
     }
 
-    let currentColor = COLOR_PALETTE.black;
+    let currentColor: string = COLOR_PALETTE.black;
     let isHoldingPointerDown = false;
 
     const ctx = app.canvas.getContext("2d")!;
@@ -77,8 +77,8 @@ export const EyeDropper: React.FC<{
       colorPreviewDiv.style.left = `${clientX + 20}px`;
 
       const pixel = ctx.getImageData(
-        clientX * window.devicePixelRatio - appState.offsetLeft,
-        clientY * window.devicePixelRatio - appState.offsetTop,
+        (clientX - appState.offsetLeft) * window.devicePixelRatio,
+        (clientY - appState.offsetTop) * window.devicePixelRatio,
         1,
         1,
       ).data;
@@ -98,7 +98,7 @@ export const EyeDropper: React.FC<{
             },
             false,
           );
-          invalidateShapeForElement(element);
+          ShapeCache.delete(element);
         }
         Scene.getScene(
           metaStuffRef.current.selectedElements[0],

+ 10 - 13
src/components/HintViewer.tsx

@@ -1,7 +1,5 @@
 import { t } from "../i18n";
-import { NonDeletedExcalidrawElement } from "../element/types";
-import { getSelectedElements } from "../scene";
-import { Device, UIAppState } from "../types";
+import { AppClassProperties, Device, UIAppState } from "../types";
 import {
   isImageElement,
   isLinearElement,
@@ -15,17 +13,12 @@ import "./HintViewer.scss";
 
 interface HintViewerProps {
   appState: UIAppState;
-  elements: readonly NonDeletedExcalidrawElement[];
   isMobile: boolean;
   device: Device;
+  app: AppClassProperties;
 }
 
-const getHints = ({
-  appState,
-  elements,
-  isMobile,
-  device,
-}: HintViewerProps) => {
+const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
   const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
   const multiMode = appState.multiElement !== null;
 
@@ -51,11 +44,15 @@ const getHints = ({
     return t("hints.text");
   }
 
+  if (activeTool.type === "embeddable") {
+    return t("hints.embeddable");
+  }
+
   if (appState.activeTool.type === "image" && appState.pendingImageElementId) {
     return t("hints.placeImage");
   }
 
-  const selectedElements = getSelectedElements(elements, appState);
+  const selectedElements = app.scene.getSelectedElements(appState);
 
   if (
     isResizing &&
@@ -115,15 +112,15 @@ const getHints = ({
 
 export const HintViewer = ({
   appState,
-  elements,
   isMobile,
   device,
+  app,
 }: HintViewerProps) => {
   let hint = getHints({
     appState,
-    elements,
     isMobile,
     device,
+    app,
   });
   if (!hint) {
     return null;

+ 2 - 2
src/components/JSONExportDialog.tsx

@@ -34,7 +34,7 @@ const JSONExportModal = ({
   actionManager: ActionManager;
   onCloseRequest: () => void;
   exportOpts: ExportOpts;
-  canvas: HTMLCanvasElement | null;
+  canvas: HTMLCanvasElement;
 }) => {
   const { onExportToBackend } = exportOpts;
   return (
@@ -100,7 +100,7 @@ export const JSONExportDialog = ({
   files: BinaryFiles;
   actionManager: ActionManager;
   exportOpts: ExportOpts;
-  canvas: HTMLCanvasElement | null;
+  canvas: HTMLCanvasElement;
   setAppState: React.Component<any, UIAppState>["setState"];
 }) => {
   const handleClose = React.useCallback(() => {

+ 23 - 8
src/components/LayerUI.tsx

@@ -57,7 +57,8 @@ interface LayerUIProps {
   actionManager: ActionManager;
   appState: UIAppState;
   files: BinaryFiles;
-  canvas: HTMLCanvasElement | null;
+  canvas: HTMLCanvasElement;
+  interactiveCanvas: HTMLCanvasElement | null;
   setAppState: React.Component<any, AppState>["setState"];
   elements: readonly NonDeletedExcalidrawElement[];
   onLockToggle: () => void;
@@ -72,6 +73,7 @@ interface LayerUIProps {
   onExportImage: AppClassProperties["onExportImage"];
   renderWelcomeScreen: boolean;
   children?: React.ReactNode;
+  app: AppClassProperties;
 }
 
 const DefaultMainMenu: React.FC<{
@@ -116,6 +118,7 @@ const LayerUI = ({
   setAppState,
   elements,
   canvas,
+  interactiveCanvas,
   onLockToggle,
   onHandToolToggle,
   onPenModeToggle,
@@ -127,6 +130,7 @@ const LayerUI = ({
   onExportImage,
   renderWelcomeScreen,
   children,
+  app,
 }: LayerUIProps) => {
   const device = useDevice();
   const tunnels = useInitializeTunnels();
@@ -240,9 +244,9 @@ const LayerUI = ({
                       >
                         <HintViewer
                           appState={appState}
-                          elements={elements}
                           isMobile={device.isMobile}
                           device={device}
+                          app={app}
                         />
                         {heading}
                         <Stack.Row gap={1}>
@@ -270,7 +274,7 @@ const LayerUI = ({
 
                           <ShapesSwitcher
                             appState={appState}
-                            canvas={canvas}
+                            interactiveCanvas={interactiveCanvas}
                             activeTool={appState.activeTool}
                             setAppState={setAppState}
                             onImageAction={({ pointerType }) => {
@@ -399,8 +403,9 @@ const LayerUI = ({
           }
         />
       )}
-      {device.isMobile && !eyeDropperState && (
+      {device.isMobile && (
         <MobileMenu
+          app={app}
           appState={appState}
           elements={elements}
           actionManager={actionManager}
@@ -410,7 +415,7 @@ const LayerUI = ({
           onLockToggle={onLockToggle}
           onHandToolToggle={onHandToolToggle}
           onPenModeToggle={onPenModeToggle}
-          canvas={canvas}
+          interactiveCanvas={interactiveCanvas}
           onImageAction={onImageAction}
           renderTopRightUI={renderTopRightUI}
           renderCustomStats={renderCustomStats}
@@ -461,7 +466,7 @@ const LayerUI = ({
                 className="scroll-back-to-content"
                 onClick={() => {
                   setAppState((appState) => ({
-                    ...calculateScrollCenter(elements, appState, canvas),
+                    ...calculateScrollCenter(elements, appState),
                   }));
                 }}
               >
@@ -504,8 +509,18 @@ const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
     return false;
   }
 
-  const { canvas: _prevCanvas, appState: prevAppState, ...prev } = prevProps;
-  const { canvas: _nextCanvas, appState: nextAppState, ...next } = nextProps;
+  const {
+    canvas: _pC,
+    interactiveCanvas: _pIC,
+    appState: prevAppState,
+    ...prev
+  } = prevProps;
+  const {
+    canvas: _nC,
+    interactiveCanvas: _nIC,
+    appState: nextAppState,
+    ...next
+  } = nextProps;
 
   return (
     isShallowEqual(

+ 8 - 5
src/components/LibraryMenu.tsx

@@ -29,6 +29,7 @@ import "./LibraryMenu.scss";
 import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
 import { isShallowEqual } from "../utils";
 import { NonDeletedExcalidrawElement } from "../element/types";
+import { LIBRARY_DISABLED_TYPES } from "../constants";
 
 export const isLibraryMenuOpenAtom = atom(false);
 
@@ -68,11 +69,12 @@ export const LibraryMenuContent = ({
         libraryItems: LibraryItems,
       ) => {
         trackEvent("element", "addToLibrary", "ui");
-        if (processedElements.some((element) => element.type === "image")) {
-          return setAppState({
-            errorMessage:
-              "Support for adding images to the library coming soon!",
-          });
+        for (const type of LIBRARY_DISABLED_TYPES) {
+          if (processedElements.some((element) => element.type === type)) {
+            return setAppState({
+              errorMessage: t(`errors.libraryElementTypeError.${type}`),
+            });
+          }
         }
         const nextItems: LibraryItems = [
           {
@@ -197,6 +199,7 @@ export const LibraryMenu = () => {
     setAppState({
       selectedElementIds: {},
       selectedGroupIds: {},
+      activeEmbeddable: null,
     });
   }, [setAppState]);
 

+ 1 - 1
src/components/LibraryMenuBrowseButton.tsx

@@ -16,7 +16,7 @@ const LibraryMenuBrowseButton = ({
   return (
     <a
       className="library-menu-browse-button"
-      href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
+      href={`${import.meta.env.VITE_APP_LIBRARY_URL}?target=${
         window.name || "_blank"
       }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
         VERSIONS.excalidrawLibrary

+ 5 - 0
src/components/LibraryUnit.scss

@@ -12,6 +12,11 @@
     box-sizing: border-box;
     border-radius: var(--border-radius-lg);
 
+    svg {
+      // to prevent clicks on links and such
+      pointer-events: none;
+    }
+
     &--hover {
       border-color: var(--color-primary);
     }

+ 14 - 6
src/components/MobileMenu.tsx

@@ -1,5 +1,11 @@
 import React from "react";
-import { AppState, Device, ExcalidrawProps, UIAppState } from "../types";
+import {
+  AppClassProperties,
+  AppState,
+  Device,
+  ExcalidrawProps,
+  UIAppState,
+} from "../types";
 import { ActionManager } from "../actions/manager";
 import { t } from "../i18n";
 import Stack from "./Stack";
@@ -30,7 +36,7 @@ type MobileMenuProps = {
   onLockToggle: () => void;
   onHandToolToggle: () => void;
   onPenModeToggle: () => void;
-  canvas: HTMLCanvasElement | null;
+  interactiveCanvas: HTMLCanvasElement | null;
 
   onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
   renderTopRightUI?: (
@@ -41,6 +47,7 @@ type MobileMenuProps = {
   renderSidebars: () => JSX.Element | null;
   device: Device;
   renderWelcomeScreen: boolean;
+  app: AppClassProperties;
 };
 
 export const MobileMenu = ({
@@ -51,13 +58,14 @@ export const MobileMenu = ({
   onLockToggle,
   onHandToolToggle,
   onPenModeToggle,
-  canvas,
+  interactiveCanvas,
   onImageAction,
   renderTopRightUI,
   renderCustomStats,
   renderSidebars,
   device,
   renderWelcomeScreen,
+  app,
 }: MobileMenuProps) => {
   const {
     WelcomeScreenCenterTunnel,
@@ -77,7 +85,7 @@ export const MobileMenu = ({
                   <Stack.Row gap={1}>
                     <ShapesSwitcher
                       appState={appState}
-                      canvas={canvas}
+                      interactiveCanvas={interactiveCanvas}
                       activeTool={appState.activeTool}
                       setAppState={setAppState}
                       onImageAction={({ pointerType }) => {
@@ -119,9 +127,9 @@ export const MobileMenu = ({
         </Section>
         <HintViewer
           appState={appState}
-          elements={elements}
           isMobile={true}
           device={device}
+          app={app}
         />
       </FixedSideContainer>
     );
@@ -194,7 +202,7 @@ export const MobileMenu = ({
                   className="scroll-back-to-content"
                   onClick={() => {
                     setAppState((appState) => ({
-                      ...calculateScrollCenter(elements, appState, canvas),
+                      ...calculateScrollCenter(elements, appState),
                     }));
                   }}
                 >

+ 1 - 1
src/components/PublishLibrary.tsx

@@ -319,7 +319,7 @@ const PublishLibrary = ({
     formData.append("twitterHandle", libraryData.twitterHandle);
     formData.append("website", libraryData.website);
 
-    fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, {
+    fetch(`${import.meta.env.VITE_APP_LIBRARY_BACKEND}/submit`, {
       method: "post",
       body: formData,
     })

+ 1 - 1
src/components/Section.tsx

@@ -3,7 +3,7 @@ import { t } from "../i18n";
 import { useExcalidrawContainer } from "./App";
 
 export const Section: React.FC<{
-  heading: string;
+  heading: "canvasActions" | "selectedShapeActions" | "shapes";
   children?: React.ReactNode | ((heading: React.ReactNode) => React.ReactNode);
   className?: string;
 }> = ({ heading, children, ...props }) => {

+ 2 - 1
src/components/Sidebar/Sidebar.test.tsx

@@ -10,6 +10,7 @@ import {
   waitFor,
   withExcalidrawDimensions,
 } from "../../tests/test-utils";
+import { vi } from "vitest";
 
 export const assertSidebarDockButton = async <T extends boolean>(
   hasDockButton: T,
@@ -205,7 +206,7 @@ describe("Sidebar", () => {
     });
 
     it("<Sidebar.Header> should render close button", async () => {
-      const onStateChange = jest.fn();
+      const onStateChange = vi.fn();
       const CustomExcalidraw = () => {
         return (
           <Excalidraw

+ 1 - 1
src/components/Sidebar/Sidebar.tsx

@@ -53,7 +53,7 @@ export const SidebarInner = forwardRef(
     }: SidebarProps & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">,
     ref: React.ForwardedRef<HTMLDivElement>,
   ) => {
-    if (process.env.NODE_ENV === "development" && onDock && docked == null) {
+    if (import.meta.env.DEV && onDock && docked == null) {
       console.warn(
         "Sidebar: `docked` must be set when `onDock` is supplied for the sidebar to be user-dockable. To hide this message, either pass `docked` or remove `onDock`",
       );

+ 9 - 5
src/components/Trans.test.tsx

@@ -3,6 +3,7 @@ import { render } from "@testing-library/react";
 import fallbackLangData from "../locales/en.json";
 
 import Trans from "./Trans";
+import { TranslationKeys } from "../i18n";
 
 describe("Test <Trans/>", () => {
   it("should translate the the strings correctly", () => {
@@ -18,24 +19,27 @@ describe("Test <Trans/>", () => {
     const { getByTestId } = render(
       <>
         <div data-testid="test1">
-          <Trans i18nKey="transTest.key1" audience="world" />
+          <Trans
+            i18nKey={"transTest.key1" as unknown as TranslationKeys}
+            audience="world"
+          />
         </div>
         <div data-testid="test2">
           <Trans
-            i18nKey="transTest.key2"
+            i18nKey={"transTest.key2" as unknown as TranslationKeys}
             link={(el) => <a href="https://example.com">{el}</a>}
           />
         </div>
         <div data-testid="test3">
           <Trans
-            i18nKey="transTest.key3"
+            i18nKey={"transTest.key3" as unknown as TranslationKeys}
             link={(el) => <a href="https://example.com">{el}</a>}
             location="the button"
           />
         </div>
         <div data-testid="test4">
           <Trans
-            i18nKey="transTest.key4"
+            i18nKey={"transTest.key4" as unknown as TranslationKeys}
             link={(el) => <a href="https://example.com">{el}</a>}
             location="the button"
             bold={(el) => <strong>{el}</strong>}
@@ -43,7 +47,7 @@ describe("Test <Trans/>", () => {
         </div>
         <div data-testid="test5">
           <Trans
-            i18nKey="transTest.key5"
+            i18nKey={"transTest.key5" as unknown as TranslationKeys}
             connect-link={(el) => <a href="https://example.com">{el}</a>}
           />
         </div>

+ 2 - 2
src/components/Trans.tsx

@@ -1,6 +1,6 @@
 import React from "react";
 
-import { useI18n } from "../i18n";
+import { TranslationKeys, useI18n } from "../i18n";
 
 // Used for splitting i18nKey into tokens in Trans component
 // Example:
@@ -153,7 +153,7 @@ const Trans = ({
   children,
   ...props
 }: {
-  i18nKey: string;
+  i18nKey: TranslationKeys;
   [key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode);
 }) => {
   const { t } = useI18n();

+ 2 - 2
src/components/__snapshots__/App.test.tsx.snap

@@ -1,6 +1,6 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
-exports[`Test <App/> should show error modal when using brave and measureText API is not working 1`] = `
+exports[`Test <App/> > should show error modal when using brave and measureText API is not working 1`] = `
 <div
   data-testid="brave-measure-text-error"
 >

+ 226 - 0
src/components/canvases/InteractiveCanvas.tsx

@@ -0,0 +1,226 @@
+import React, { useEffect, useRef } from "react";
+import { renderInteractiveScene } from "../../renderer/renderScene";
+import {
+  isRenderThrottlingEnabled,
+  isShallowEqual,
+  sceneCoordsToViewportCoords,
+} from "../../utils";
+import { CURSOR_TYPE } from "../../constants";
+import { t } from "../../i18n";
+import type { DOMAttributes } from "react";
+import type { AppState, InteractiveCanvasAppState } from "../../types";
+import type {
+  InteractiveCanvasRenderConfig,
+  RenderInteractiveSceneCallback,
+} from "../../scene/types";
+import type { NonDeletedExcalidrawElement } from "../../element/types";
+
+type InteractiveCanvasProps = {
+  containerRef: React.RefObject<HTMLDivElement>;
+  canvas: HTMLCanvasElement | null;
+  elements: readonly NonDeletedExcalidrawElement[];
+  visibleElements: readonly NonDeletedExcalidrawElement[];
+  selectedElements: readonly NonDeletedExcalidrawElement[];
+  versionNonce: number | undefined;
+  selectionNonce: number | undefined;
+  scale: number;
+  appState: InteractiveCanvasAppState;
+  renderInteractiveSceneCallback: (
+    data: RenderInteractiveSceneCallback,
+  ) => void;
+  handleCanvasRef: (canvas: HTMLCanvasElement | null) => void;
+  onContextMenu: Exclude<
+    DOMAttributes<HTMLCanvasElement | HTMLDivElement>["onContextMenu"],
+    undefined
+  >;
+  onPointerMove: Exclude<
+    DOMAttributes<HTMLCanvasElement>["onPointerMove"],
+    undefined
+  >;
+  onPointerUp: Exclude<
+    DOMAttributes<HTMLCanvasElement>["onPointerUp"],
+    undefined
+  >;
+  onPointerCancel: Exclude<
+    DOMAttributes<HTMLCanvasElement>["onPointerCancel"],
+    undefined
+  >;
+  onTouchMove: Exclude<
+    DOMAttributes<HTMLCanvasElement>["onTouchMove"],
+    undefined
+  >;
+  onPointerDown: Exclude<
+    DOMAttributes<HTMLCanvasElement>["onPointerDown"],
+    undefined
+  >;
+  onDoubleClick: Exclude<
+    DOMAttributes<HTMLCanvasElement>["onDoubleClick"],
+    undefined
+  >;
+};
+
+const InteractiveCanvas = (props: InteractiveCanvasProps) => {
+  const isComponentMounted = useRef(false);
+
+  useEffect(() => {
+    if (!isComponentMounted.current) {
+      isComponentMounted.current = true;
+      return;
+    }
+
+    const cursorButton: {
+      [id: string]: string | undefined;
+    } = {};
+    const pointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] =
+      {};
+    const remoteSelectedElementIds: InteractiveCanvasRenderConfig["remoteSelectedElementIds"] =
+      {};
+    const pointerUsernames: { [id: string]: string } = {};
+    const pointerUserStates: { [id: string]: string } = {};
+
+    props.appState.collaborators.forEach((user, socketId) => {
+      if (user.selectedElementIds) {
+        for (const id of Object.keys(user.selectedElementIds)) {
+          if (!(id in remoteSelectedElementIds)) {
+            remoteSelectedElementIds[id] = [];
+          }
+          remoteSelectedElementIds[id].push(socketId);
+        }
+      }
+      if (!user.pointer) {
+        return;
+      }
+      if (user.username) {
+        pointerUsernames[socketId] = user.username;
+      }
+      if (user.userState) {
+        pointerUserStates[socketId] = user.userState;
+      }
+      pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
+        {
+          sceneX: user.pointer.x,
+          sceneY: user.pointer.y,
+        },
+        props.appState,
+      );
+      cursorButton[socketId] = user.button;
+    });
+
+    const selectionColor =
+      (props.containerRef?.current &&
+        getComputedStyle(props.containerRef.current).getPropertyValue(
+          "--color-selection",
+        )) ||
+      "#6965db";
+
+    renderInteractiveScene(
+      {
+        canvas: props.canvas,
+        elements: props.elements,
+        visibleElements: props.visibleElements,
+        selectedElements: props.selectedElements,
+        scale: window.devicePixelRatio,
+        appState: props.appState,
+        renderConfig: {
+          remotePointerViewportCoords: pointerViewportCoords,
+          remotePointerButton: cursorButton,
+          remoteSelectedElementIds,
+          remotePointerUsernames: pointerUsernames,
+          remotePointerUserStates: pointerUserStates,
+          selectionColor,
+          renderScrollbars: false,
+        },
+        callback: props.renderInteractiveSceneCallback,
+      },
+      isRenderThrottlingEnabled(),
+    );
+  });
+
+  return (
+    <canvas
+      className="excalidraw__canvas interactive"
+      style={{
+        width: props.appState.width,
+        height: props.appState.height,
+        cursor: props.appState.viewModeEnabled
+          ? CURSOR_TYPE.GRAB
+          : CURSOR_TYPE.AUTO,
+      }}
+      width={props.appState.width * props.scale}
+      height={props.appState.height * props.scale}
+      ref={props.handleCanvasRef}
+      onContextMenu={props.onContextMenu}
+      onPointerMove={props.onPointerMove}
+      onPointerUp={props.onPointerUp}
+      onPointerCancel={props.onPointerCancel}
+      onTouchMove={props.onTouchMove}
+      onPointerDown={props.onPointerDown}
+      onDoubleClick={
+        props.appState.viewModeEnabled ? undefined : props.onDoubleClick
+      }
+    >
+      {t("labels.drawingCanvas")}
+    </canvas>
+  );
+};
+
+const getRelevantAppStateProps = (
+  appState: AppState,
+): InteractiveCanvasAppState => ({
+  zoom: appState.zoom,
+  scrollX: appState.scrollX,
+  scrollY: appState.scrollY,
+  width: appState.width,
+  height: appState.height,
+  viewModeEnabled: appState.viewModeEnabled,
+  editingGroupId: appState.editingGroupId,
+  editingLinearElement: appState.editingLinearElement,
+  selectedElementIds: appState.selectedElementIds,
+  frameToHighlight: appState.frameToHighlight,
+  offsetLeft: appState.offsetLeft,
+  offsetTop: appState.offsetTop,
+  theme: appState.theme,
+  pendingImageElementId: appState.pendingImageElementId,
+  selectionElement: appState.selectionElement,
+  selectedGroupIds: appState.selectedGroupIds,
+  selectedLinearElement: appState.selectedLinearElement,
+  multiElement: appState.multiElement,
+  isBindingEnabled: appState.isBindingEnabled,
+  suggestedBindings: appState.suggestedBindings,
+  isRotating: appState.isRotating,
+  elementsToHighlight: appState.elementsToHighlight,
+  openSidebar: appState.openSidebar,
+  showHyperlinkPopup: appState.showHyperlinkPopup,
+  collaborators: appState.collaborators, // Necessary for collab. sessions
+  activeEmbeddable: appState.activeEmbeddable,
+});
+
+const areEqual = (
+  prevProps: InteractiveCanvasProps,
+  nextProps: InteractiveCanvasProps,
+) => {
+  // This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation
+  if (
+    prevProps.selectionNonce !== nextProps.selectionNonce ||
+    prevProps.versionNonce !== nextProps.versionNonce ||
+    prevProps.scale !== nextProps.scale ||
+    // we need to memoize on element arrays because they may have renewed
+    // even if versionNonce didn't change (e.g. we filter elements out based
+    // on appState)
+    prevProps.elements !== nextProps.elements ||
+    prevProps.visibleElements !== nextProps.visibleElements ||
+    prevProps.selectedElements !== nextProps.selectedElements
+  ) {
+    return false;
+  }
+
+  // Comparing the interactive appState for changes in case of some edge cases
+  return isShallowEqual(
+    // asserting AppState because we're being passed the whole AppState
+    // but resolve to only the InteractiveCanvas-relevant props
+    getRelevantAppStateProps(prevProps.appState as AppState),
+    getRelevantAppStateProps(nextProps.appState as AppState),
+  );
+};
+
+export default React.memo(InteractiveCanvas, areEqual);

+ 110 - 0
src/components/canvases/StaticCanvas.tsx

@@ -0,0 +1,110 @@
+import React, { useEffect, useRef } from "react";
+import { RoughCanvas } from "roughjs/bin/canvas";
+import { renderStaticScene } from "../../renderer/renderScene";
+import { isRenderThrottlingEnabled, isShallowEqual } from "../../utils";
+import type { AppState, StaticCanvasAppState } from "../../types";
+import type { StaticCanvasRenderConfig } from "../../scene/types";
+import type { NonDeletedExcalidrawElement } from "../../element/types";
+
+type StaticCanvasProps = {
+  canvas: HTMLCanvasElement;
+  rc: RoughCanvas;
+  elements: readonly NonDeletedExcalidrawElement[];
+  visibleElements: readonly NonDeletedExcalidrawElement[];
+  versionNonce: number | undefined;
+  selectionNonce: number | undefined;
+  scale: number;
+  appState: StaticCanvasAppState;
+  renderConfig: StaticCanvasRenderConfig;
+};
+
+const StaticCanvas = (props: StaticCanvasProps) => {
+  const wrapperRef = useRef<HTMLDivElement>(null);
+  const isComponentMounted = useRef(false);
+
+  useEffect(() => {
+    const wrapper = wrapperRef.current;
+    if (!wrapper) {
+      return;
+    }
+
+    const canvas = props.canvas;
+
+    if (!isComponentMounted.current) {
+      isComponentMounted.current = true;
+
+      wrapper.replaceChildren(canvas);
+      canvas.classList.add("excalidraw__canvas", "static");
+    }
+
+    canvas.style.width = `${props.appState.width}px`;
+    canvas.style.height = `${props.appState.height}px`;
+    canvas.width = props.appState.width * props.scale;
+    canvas.height = props.appState.height * props.scale;
+
+    renderStaticScene(
+      {
+        canvas,
+        rc: props.rc,
+        scale: props.scale,
+        elements: props.elements,
+        visibleElements: props.visibleElements,
+        appState: props.appState,
+        renderConfig: props.renderConfig,
+      },
+      isRenderThrottlingEnabled(),
+    );
+  });
+
+  return <div className="excalidraw__canvas-wrapper" ref={wrapperRef} />;
+};
+
+const getRelevantAppStateProps = (
+  appState: AppState,
+): StaticCanvasAppState => ({
+  zoom: appState.zoom,
+  scrollX: appState.scrollX,
+  scrollY: appState.scrollY,
+  width: appState.width,
+  height: appState.height,
+  viewModeEnabled: appState.viewModeEnabled,
+  offsetLeft: appState.offsetLeft,
+  offsetTop: appState.offsetTop,
+  theme: appState.theme,
+  pendingImageElementId: appState.pendingImageElementId,
+  shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
+  viewBackgroundColor: appState.viewBackgroundColor,
+  exportScale: appState.exportScale,
+  selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
+  gridSize: appState.gridSize,
+  frameRendering: appState.frameRendering,
+  selectedElementIds: appState.selectedElementIds,
+  frameToHighlight: appState.frameToHighlight,
+  editingGroupId: appState.editingGroupId,
+});
+
+const areEqual = (
+  prevProps: StaticCanvasProps,
+  nextProps: StaticCanvasProps,
+) => {
+  if (
+    prevProps.versionNonce !== nextProps.versionNonce ||
+    prevProps.scale !== nextProps.scale ||
+    // we need to memoize on element arrays because they may have renewed
+    // even if versionNonce didn't change (e.g. we filter elements out based
+    // on appState)
+    prevProps.elements !== nextProps.elements ||
+    prevProps.visibleElements !== nextProps.visibleElements
+  ) {
+    return false;
+  }
+
+  return isShallowEqual(
+    // asserting AppState because we're being passed the whole AppState
+    // but resolve to only the StaticCanvas-relevant props
+    getRelevantAppStateProps(prevProps.appState as AppState),
+    getRelevantAppStateProps(nextProps.appState as AppState),
+  );
+};
+
+export default React.memo(StaticCanvas, areEqual);

+ 4 - 0
src/components/canvases/index.tsx

@@ -0,0 +1,4 @@
+import InteractiveCanvas from "./InteractiveCanvas";
+import StaticCanvas from "./StaticCanvas";
+
+export { InteractiveCanvas, StaticCanvas };

+ 8 - 0
src/components/icons.tsx

@@ -396,6 +396,14 @@ export const TrashIcon = createIcon(
   modifiedTablerIconProps,
 );
 
+export const EmbedIcon = createIcon(
+  <g strokeWidth="1.25">
+    <polyline points="12 16 18 10 12 4" />
+    <polyline points="8 4 2 10 8 16" />
+  </g>,
+  modifiedTablerIconProps,
+);
+
 export const DuplicateIcon = createIcon(
   <g strokeWidth="1.25">
     <path d="M14.375 6.458H8.958a2.5 2.5 0 0 0-2.5 2.5v5.417a2.5 2.5 0 0 0 2.5 2.5h5.417a2.5 2.5 0 0 0 2.5-2.5V8.958a2.5 2.5 0 0 0-2.5-2.5Z" />

+ 10 - 2
src/components/main-menu/DefaultItems.tsx

@@ -4,6 +4,7 @@ import {
   useExcalidrawSetAppState,
   useExcalidrawActionManager,
   useExcalidrawElements,
+  useAppProps,
 } from "../App";
 import {
   ExportIcon,
@@ -198,13 +199,20 @@ export const ChangeCanvasBackground = () => {
   const { t } = useI18n();
   const appState = useUIAppState();
   const actionManager = useExcalidrawActionManager();
+  const appProps = useAppProps();
 
-  if (appState.viewModeEnabled) {
+  if (
+    appState.viewModeEnabled ||
+    !appProps.UIOptions.canvasActions.changeViewBackgroundColor
+  ) {
     return null;
   }
   return (
     <div style={{ marginTop: "0.5rem" }}>
-      <div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
+      <div
+        data-testid="canvas-background-label"
+        style={{ fontSize: ".75rem", marginBottom: ".5rem" }}
+      >
         {t("labels.canvasBackground")}
       </div>
       <div style={{ padding: "0 0.625rem" }}>

+ 17 - 1
src/constants.ts

@@ -71,8 +71,18 @@ export enum EVENT {
   // custom events
   EXCALIDRAW_LINK = "excalidraw-link",
   MENU_ITEM_SELECT = "menu.itemSelect",
+  MESSAGE = "message",
 }
 
+export const YOUTUBE_STATES = {
+  UNSTARTED: -1,
+  ENDED: 0,
+  PLAYING: 1,
+  PAUSED: 2,
+  BUFFERING: 3,
+  CUED: 5,
+} as const;
+
 export const ENV = {
   TEST: "test",
   DEVELOPMENT: "development",
@@ -92,7 +102,7 @@ export const FONT_FAMILY = {
 export const THEME = {
   LIGHT: "light",
   DARK: "dark",
-};
+} as const;
 
 export const FRAME_STYLE = {
   strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
@@ -107,6 +117,7 @@ export const FRAME_STYLE = {
 
 export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
 
+export const MIN_FONT_SIZE = 1;
 export const DEFAULT_FONT_SIZE = 20;
 export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
 export const DEFAULT_TEXT_ALIGN = "left";
@@ -153,6 +164,7 @@ export const EXPORT_DATA_TYPES = {
   excalidraw: "excalidraw",
   excalidrawClipboard: "excalidraw/clipboard",
   excalidrawLibrary: "excalidrawlib",
+  excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
 } as const;
 
 export const EXPORT_SOURCE =
@@ -229,6 +241,8 @@ export const VERSIONS = {
 } as const;
 
 export const BOUND_TEXT_PADDING = 5;
+export const ARROW_LABEL_WIDTH_FRACTION = 0.7;
+export const ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO = 11;
 
 export const VERTICAL_ALIGN = {
   TOP: "top",
@@ -300,3 +314,5 @@ export const DEFAULT_SIDEBAR = {
   name: "default",
   defaultTab: LIBRARY_SIDEBAR_TAB,
 } as const;
+
+export const LIBRARY_DISABLED_TYPES = new Set(["embeddable", "image"] as const);

+ 55 - 2
src/css/styles.scss

@@ -3,8 +3,9 @@
 
 :root {
   --zIndex-canvas: 1;
-  --zIndex-wysiwyg: 2;
-  --zIndex-layerUI: 3;
+  --zIndex-interactiveCanvas: 2;
+  --zIndex-wysiwyg: 3;
+  --zIndex-layerUI: 4;
 
   --zIndex-modal: 1000;
   --zIndex-popup: 1001;
@@ -69,14 +70,36 @@
 
     z-index: var(--zIndex-canvas);
 
+    &.interactive {
+      z-index: var(--zIndex-interactiveCanvas);
+    }
+
     // Remove the main canvas from document flow to avoid resizeObserver
     // feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379)
   }
 
+  &__canvas-wrapper,
+  &__canvas.static {
+    pointer-events: none;
+  }
+
   &__canvas {
     position: absolute;
   }
 
+  &__embeddable {
+    width: 100%;
+    height: 100%;
+    border: 0;
+  }
+
+  &__embeddable-container {
+    position: absolute;
+    z-index: 2;
+    transform-origin: top left;
+    pointer-events: none;
+  }
+
   &.theme--dark {
     // The percentage is inspired by
     // https://material.io/design/color/dark-theme.html#properties, which
@@ -661,3 +684,33 @@
     }
   }
 }
+
+.excalidraw__embeddable-container {
+  .excalidraw__embeddable-container__inner {
+    overflow: hidden;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: var(--embeddable-radius);
+  }
+
+  .excalidraw__embeddable__outer {
+    width: 100%;
+    height: 100%;
+    & > * {
+      border-radius: var(--embeddable-radius);
+    }
+  }
+
+  .excalidraw__embeddable-hint {
+    position: absolute;
+    z-index: 1;
+    background: rgba(0, 0, 0, 0.5);
+    padding: 1rem 1.6rem;
+    border-radius: 12px;
+    color: #fff;
+    font-weight: bold;
+    letter-spacing: 0.6px;
+    font-family: "Assistant";
+  }
+}

+ 2032 - 0
src/data/__snapshots__/transform.test.ts.snap

@@ -0,0 +1,2032 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 1`] = `
+{
+  "angle": 0,
+  "backgroundColor": "#d8f5a2",
+  "boundElements": [
+    {
+      "id": "id40",
+      "type": "arrow",
+    },
+    {
+      "id": "id41",
+      "type": "arrow",
+    },
+  ],
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 300,
+  "id": Any<String>,
+  "isDeleted": false,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#66a80f",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "ellipse",
+  "updated": 1,
+  "version": 3,
+  "versionNonce": Any<Number>,
+  "width": 300,
+  "x": 630,
+  "y": 316,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 2`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": [
+    {
+      "id": "id41",
+      "type": "arrow",
+    },
+  ],
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 100,
+  "id": Any<String>,
+  "isDeleted": false,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#9c36b5",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "diamond",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "width": 140,
+  "x": 96,
+  "y": 400,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 3`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": null,
+  "endArrowhead": "arrow",
+  "endBinding": {
+    "elementId": "ellipse-1",
+    "focus": -0.008153707962747813,
+    "gap": 1,
+  },
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 35,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lastCommittedPoint": null,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "points": [
+    [
+      0,
+      0,
+    ],
+    [
+      395,
+      35,
+    ],
+  ],
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "startArrowhead": null,
+  "startBinding": {
+    "elementId": "id42",
+    "focus": -0.08139534883720931,
+    "gap": 1,
+  },
+  "strokeColor": "#1864ab",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "arrow",
+  "updated": 1,
+  "version": 3,
+  "versionNonce": Any<Number>,
+  "width": 395,
+  "x": 247,
+  "y": 420,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 4`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": null,
+  "endArrowhead": "arrow",
+  "endBinding": {
+    "elementId": "ellipse-1",
+    "focus": 0.10666666666666667,
+    "gap": 3.834326468444573,
+  },
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 0,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lastCommittedPoint": null,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "points": [
+    [
+      0,
+      0,
+    ],
+    [
+      400,
+      0,
+    ],
+  ],
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "startArrowhead": null,
+  "startBinding": {
+    "elementId": "diamond-1",
+    "focus": 0,
+    "gap": 1,
+  },
+  "strokeColor": "#e67700",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "arrow",
+  "updated": 1,
+  "version": 3,
+  "versionNonce": Any<Number>,
+  "width": 400,
+  "x": 227,
+  "y": 450,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 5`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": [
+    {
+      "id": "id40",
+      "type": "arrow",
+    },
+  ],
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 300,
+  "id": Any<String>,
+  "isDeleted": false,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "rectangle",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "width": 300,
+  "x": -53,
+  "y": 270,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 1`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": [
+    {
+      "id": "id43",
+      "type": "arrow",
+    },
+  ],
+  "containerId": null,
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 25,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "HEYYYYY",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#c2255c",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "HEYYYYY",
+  "textAlign": "left",
+  "type": "text",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "top",
+  "width": 70,
+  "x": 185,
+  "y": 226.5,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 2`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": [
+    {
+      "id": "id43",
+      "type": "arrow",
+    },
+  ],
+  "containerId": null,
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 25,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "Whats up ?",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "Whats up ?",
+  "textAlign": "left",
+  "type": "text",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "top",
+  "width": 100,
+  "x": 560,
+  "y": 226.5,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 3`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": [
+    {
+      "id": "id44",
+      "type": "text",
+    },
+  ],
+  "endArrowhead": "arrow",
+  "endBinding": {
+    "elementId": "text-2",
+    "focus": 0,
+    "gap": 5,
+  },
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 0,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lastCommittedPoint": null,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "points": [
+    [
+      0,
+      0,
+    ],
+    [
+      300,
+      0,
+    ],
+  ],
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "startArrowhead": null,
+  "startBinding": {
+    "elementId": "text-1",
+    "focus": 0,
+    "gap": 1,
+  },
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "arrow",
+  "updated": 1,
+  "version": 3,
+  "versionNonce": Any<Number>,
+  "width": 300,
+  "x": 255,
+  "y": 239,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 4`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": null,
+  "containerId": "id43",
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 25,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "HELLO WORLD!!",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "HELLO WORLD!!",
+  "textAlign": "center",
+  "type": "text",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "middle",
+  "width": 130,
+  "x": 340,
+  "y": 226.5,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 1`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": [
+    {
+      "id": "id33",
+      "type": "text",
+    },
+  ],
+  "endArrowhead": "arrow",
+  "endBinding": {
+    "elementId": "id35",
+    "focus": 0,
+    "gap": 1,
+  },
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 0,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lastCommittedPoint": null,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "points": [
+    [
+      0,
+      0,
+    ],
+    [
+      300,
+      0,
+    ],
+  ],
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "startArrowhead": null,
+  "startBinding": {
+    "elementId": "id34",
+    "focus": 0,
+    "gap": 1,
+  },
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "arrow",
+  "updated": 1,
+  "version": 3,
+  "versionNonce": Any<Number>,
+  "width": 300,
+  "x": 255,
+  "y": 239,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": null,
+  "containerId": "id32",
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 25,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "HELLO WORLD!!",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "HELLO WORLD!!",
+  "textAlign": "center",
+  "type": "text",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "middle",
+  "width": 130,
+  "x": 340,
+  "y": 226.5,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 3`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": [
+    {
+      "id": "id32",
+      "type": "arrow",
+    },
+  ],
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 100,
+  "id": Any<String>,
+  "isDeleted": false,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "rectangle",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "width": 100,
+  "x": 155,
+  "y": 189,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 4`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": [
+    {
+      "id": "id32",
+      "type": "arrow",
+    },
+  ],
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 100,
+  "id": Any<String>,
+  "isDeleted": false,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "ellipse",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "width": 100,
+  "x": 555,
+  "y": 189,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 1`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": [
+    {
+      "id": "id37",
+      "type": "text",
+    },
+  ],
+  "endArrowhead": "arrow",
+  "endBinding": {
+    "elementId": "id39",
+    "focus": 0,
+    "gap": 1,
+  },
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 0,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lastCommittedPoint": null,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "points": [
+    [
+      0,
+      0,
+    ],
+    [
+      300,
+      0,
+    ],
+  ],
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "startArrowhead": null,
+  "startBinding": {
+    "elementId": "id38",
+    "focus": 0,
+    "gap": 1,
+  },
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "arrow",
+  "updated": 1,
+  "version": 3,
+  "versionNonce": Any<Number>,
+  "width": 300,
+  "x": 255,
+  "y": 239,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 2`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": null,
+  "containerId": "id36",
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 25,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "HELLO WORLD!!",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "HELLO WORLD!!",
+  "textAlign": "center",
+  "type": "text",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "middle",
+  "width": 130,
+  "x": 340,
+  "y": 226.5,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 3`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": [
+    {
+      "id": "id36",
+      "type": "arrow",
+    },
+  ],
+  "containerId": null,
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 25,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "HEYYYYY",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "HEYYYYY",
+  "textAlign": "left",
+  "type": "text",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "top",
+  "width": 70,
+  "x": 185,
+  "y": 226.5,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 4`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": [
+    {
+      "id": "id36",
+      "type": "arrow",
+    },
+  ],
+  "containerId": null,
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 25,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "WHATS UP ?",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "WHATS UP ?",
+  "textAlign": "left",
+  "type": "text",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "top",
+  "width": 100,
+  "x": 555,
+  "y": 226.5,
+}
+`;
+
+exports[`Test Transform > should not allow duplicate ids 1`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": null,
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 200,
+  "id": "rect-1",
+  "isDeleted": false,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "rectangle",
+  "updated": 1,
+  "version": 1,
+  "versionNonce": Any<Number>,
+  "width": 100,
+  "x": 300,
+  "y": 100,
+}
+`;
+
+exports[`Test Transform > should transform linear elements 1`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": null,
+  "endArrowhead": "arrow",
+  "endBinding": null,
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 0,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lastCommittedPoint": null,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "points": [
+    [
+      0,
+      0,
+    ],
+    [
+      300,
+      0,
+    ],
+  ],
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "startArrowhead": null,
+  "startBinding": null,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "arrow",
+  "updated": 1,
+  "version": 1,
+  "versionNonce": Any<Number>,
+  "width": 300,
+  "x": 100,
+  "y": 20,
+}
+`;
+
+exports[`Test Transform > should transform linear elements 2`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": null,
+  "endArrowhead": "triangle",
+  "endBinding": null,
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 0,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lastCommittedPoint": null,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "points": [
+    [
+      0,
+      0,
+    ],
+    [
+      300,
+      0,
+    ],
+  ],
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "startArrowhead": "dot",
+  "startBinding": null,
+  "strokeColor": "#1971c2",
+  "strokeStyle": "solid",
+  "strokeWidth": 2,
+  "type": "arrow",
+  "updated": 1,
+  "version": 1,
+  "versionNonce": Any<Number>,
+  "width": 300,
+  "x": 450,
+  "y": 20,
+}
+`;
+
+exports[`Test Transform > should transform linear elements 3`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": null,
+  "endArrowhead": null,
+  "endBinding": null,
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 0,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lastCommittedPoint": null,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "points": [
+    [
+      0,
+      0,
+    ],
+    [
+      300,
+      0,
+    ],
+  ],
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "startArrowhead": null,
+  "startBinding": null,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "line",
+  "updated": 1,
+  "version": 1,
+  "versionNonce": Any<Number>,
+  "width": 300,
+  "x": 100,
+  "y": 60,
+}
+`;
+
+exports[`Test Transform > should transform linear elements 4`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": null,
+  "endArrowhead": null,
+  "endBinding": null,
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 0,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lastCommittedPoint": null,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "points": [
+    [
+      0,
+      0,
+    ],
+    [
+      300,
+      0,
+    ],
+  ],
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "startArrowhead": null,
+  "startBinding": null,
+  "strokeColor": "#2f9e44",
+  "strokeStyle": "dotted",
+  "strokeWidth": 2,
+  "type": "line",
+  "updated": 1,
+  "version": 1,
+  "versionNonce": Any<Number>,
+  "width": 300,
+  "x": 450,
+  "y": 60,
+}
+`;
+
+exports[`Test Transform > should transform regular shapes 1`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": null,
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 100,
+  "id": Any<String>,
+  "isDeleted": false,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "rectangle",
+  "updated": 1,
+  "version": 1,
+  "versionNonce": Any<Number>,
+  "width": 100,
+  "x": 100,
+  "y": 100,
+}
+`;
+
+exports[`Test Transform > should transform regular shapes 2`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": null,
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 100,
+  "id": Any<String>,
+  "isDeleted": false,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "ellipse",
+  "updated": 1,
+  "version": 1,
+  "versionNonce": Any<Number>,
+  "width": 100,
+  "x": 100,
+  "y": 250,
+}
+`;
+
+exports[`Test Transform > should transform regular shapes 3`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": null,
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 100,
+  "id": Any<String>,
+  "isDeleted": false,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "diamond",
+  "updated": 1,
+  "version": 1,
+  "versionNonce": Any<Number>,
+  "width": 100,
+  "x": 100,
+  "y": 400,
+}
+`;
+
+exports[`Test Transform > should transform regular shapes 4`] = `
+{
+  "angle": 0,
+  "backgroundColor": "#c0eb75",
+  "boundElements": null,
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 100,
+  "id": Any<String>,
+  "isDeleted": false,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 2,
+  "type": "rectangle",
+  "updated": 1,
+  "version": 1,
+  "versionNonce": Any<Number>,
+  "width": 200,
+  "x": 300,
+  "y": 100,
+}
+`;
+
+exports[`Test Transform > should transform regular shapes 5`] = `
+{
+  "angle": 0,
+  "backgroundColor": "#ffc9c9",
+  "boundElements": null,
+  "fillStyle": "solid",
+  "frameId": null,
+  "groupIds": [],
+  "height": 100,
+  "id": Any<String>,
+  "isDeleted": false,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "dotted",
+  "strokeWidth": 2,
+  "type": "ellipse",
+  "updated": 1,
+  "version": 1,
+  "versionNonce": Any<Number>,
+  "width": 200,
+  "x": 300,
+  "y": 250,
+}
+`;
+
+exports[`Test Transform > should transform regular shapes 6`] = `
+{
+  "angle": 0,
+  "backgroundColor": "#a5d8ff",
+  "boundElements": null,
+  "fillStyle": "cross-hatch",
+  "frameId": null,
+  "groupIds": [],
+  "height": 100,
+  "id": Any<String>,
+  "isDeleted": false,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1971c2",
+  "strokeStyle": "dashed",
+  "strokeWidth": 2,
+  "type": "diamond",
+  "updated": 1,
+  "version": 1,
+  "versionNonce": Any<Number>,
+  "width": 200,
+  "x": 300,
+  "y": 400,
+}
+`;
+
+exports[`Test Transform > should transform text element 1`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": null,
+  "containerId": null,
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 25,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "HELLO WORLD!",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "HELLO WORLD!",
+  "textAlign": "left",
+  "type": "text",
+  "updated": 1,
+  "version": 1,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "top",
+  "width": 120,
+  "x": 100,
+  "y": 100,
+}
+`;
+
+exports[`Test Transform > should transform text element 2`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": null,
+  "containerId": null,
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 25,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "STYLED HELLO WORLD!",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#5f3dc4",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "STYLED HELLO WORLD!",
+  "textAlign": "left",
+  "type": "text",
+  "updated": 1,
+  "version": 1,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "top",
+  "width": 190,
+  "x": 100,
+  "y": 150,
+}
+`;
+
+exports[`Test Transform > should transform to labelled arrows when label provided for arrows 1`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": [
+    {
+      "id": "id28",
+      "type": "text",
+    },
+  ],
+  "endArrowhead": "arrow",
+  "endBinding": null,
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 0,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lastCommittedPoint": null,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "points": [
+    [
+      0,
+      0,
+    ],
+    [
+      300,
+      0,
+    ],
+  ],
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "startArrowhead": null,
+  "startBinding": null,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "arrow",
+  "updated": 1,
+  "version": 1,
+  "versionNonce": Any<Number>,
+  "width": 300,
+  "x": 100,
+  "y": 100,
+}
+`;
+
+exports[`Test Transform > should transform to labelled arrows when label provided for arrows 2`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": [
+    {
+      "id": "id29",
+      "type": "text",
+    },
+  ],
+  "endArrowhead": "arrow",
+  "endBinding": null,
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 0,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lastCommittedPoint": null,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "points": [
+    [
+      0,
+      0,
+    ],
+    [
+      300,
+      0,
+    ],
+  ],
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "startArrowhead": null,
+  "startBinding": null,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "arrow",
+  "updated": 1,
+  "version": 1,
+  "versionNonce": Any<Number>,
+  "width": 300,
+  "x": 100,
+  "y": 200,
+}
+`;
+
+exports[`Test Transform > should transform to labelled arrows when label provided for arrows 3`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": [
+    {
+      "id": "id30",
+      "type": "text",
+    },
+  ],
+  "endArrowhead": "arrow",
+  "endBinding": null,
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 130,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lastCommittedPoint": null,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "points": [
+    [
+      0,
+      0,
+    ],
+    [
+      300,
+      0,
+    ],
+  ],
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "startArrowhead": null,
+  "startBinding": null,
+  "strokeColor": "#1098ad",
+  "strokeStyle": "solid",
+  "strokeWidth": 2,
+  "type": "arrow",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "width": 300,
+  "x": 100,
+  "y": 300,
+}
+`;
+
+exports[`Test Transform > should transform to labelled arrows when label provided for arrows 4`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": [
+    {
+      "id": "id31",
+      "type": "text",
+    },
+  ],
+  "endArrowhead": "arrow",
+  "endBinding": null,
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 130,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lastCommittedPoint": null,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "points": [
+    [
+      0,
+      0,
+    ],
+    [
+      300,
+      0,
+    ],
+  ],
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "startArrowhead": null,
+  "startBinding": null,
+  "strokeColor": "#1098ad",
+  "strokeStyle": "solid",
+  "strokeWidth": 2,
+  "type": "arrow",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "width": 300,
+  "x": 100,
+  "y": 400,
+}
+`;
+
+exports[`Test Transform > should transform to labelled arrows when label provided for arrows 5`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": null,
+  "containerId": "id24",
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 25,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "LABELED ARROW",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "LABELED ARROW",
+  "textAlign": "center",
+  "type": "text",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "middle",
+  "width": 130,
+  "x": 185,
+  "y": 87.5,
+}
+`;
+
+exports[`Test Transform > should transform to labelled arrows when label provided for arrows 6`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": null,
+  "containerId": "id25",
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 25,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "STYLED LABELED ARROW",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#099268",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "STYLED LABELED ARROW",
+  "textAlign": "center",
+  "type": "text",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "middle",
+  "width": 200,
+  "x": 150,
+  "y": 187.5,
+}
+`;
+
+exports[`Test Transform > should transform to labelled arrows when label provided for arrows 7`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": null,
+  "containerId": "id26",
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 50,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "ANOTHER STYLED LABELLED ARROW",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1098ad",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "ANOTHER STYLED 
+LABELLED ARROW",
+  "textAlign": "center",
+  "type": "text",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "middle",
+  "width": 150,
+  "x": 175,
+  "y": 275,
+}
+`;
+
+exports[`Test Transform > should transform to labelled arrows when label provided for arrows 8`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": null,
+  "containerId": "id27",
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 50,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "ANOTHER STYLED LABELLED ARROW",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#099268",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "ANOTHER STYLED 
+LABELLED ARROW",
+  "textAlign": "center",
+  "type": "text",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "middle",
+  "width": 150,
+  "x": 175,
+  "y": 375,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 1`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": [
+    {
+      "id": "id18",
+      "type": "text",
+    },
+  ],
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 35,
+  "id": Any<String>,
+  "isDeleted": false,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "rectangle",
+  "updated": 1,
+  "version": 3,
+  "versionNonce": Any<Number>,
+  "width": 250,
+  "x": 100,
+  "y": 100,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 2`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": [
+    {
+      "id": "id19",
+      "type": "text",
+    },
+  ],
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 85,
+  "id": Any<String>,
+  "isDeleted": false,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "ellipse",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "width": 200,
+  "x": 500,
+  "y": 100,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 3`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": [
+    {
+      "id": "id20",
+      "type": "text",
+    },
+  ],
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 170,
+  "id": Any<String>,
+  "isDeleted": false,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "diamond",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "width": 280,
+  "x": 100,
+  "y": 150,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 4`] = `
+{
+  "angle": 0,
+  "backgroundColor": "#fff3bf",
+  "boundElements": [
+    {
+      "id": "id21",
+      "type": "text",
+    },
+  ],
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 120,
+  "id": Any<String>,
+  "isDeleted": false,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 2,
+  "type": "diamond",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "width": 300,
+  "x": 100,
+  "y": 400,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 5`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "boundElements": [
+    {
+      "id": "id22",
+      "type": "text",
+    },
+  ],
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 85,
+  "id": Any<String>,
+  "isDeleted": false,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#c2255c",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "rectangle",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "width": 200,
+  "x": 500,
+  "y": 300,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 6`] = `
+{
+  "angle": 0,
+  "backgroundColor": "#ffec99",
+  "boundElements": [
+    {
+      "id": "id23",
+      "type": "text",
+    },
+  ],
+  "fillStyle": "hachure",
+  "frameId": null,
+  "groupIds": [],
+  "height": 120,
+  "id": Any<String>,
+  "isDeleted": false,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#f08c00",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "type": "ellipse",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "width": 200,
+  "x": 500,
+  "y": 500,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 7`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": null,
+  "containerId": "id12",
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 25,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "RECTANGLE TEXT CONTAINER",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "RECTANGLE TEXT CONTAINER",
+  "textAlign": "center",
+  "type": "text",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "middle",
+  "width": 240,
+  "x": 105,
+  "y": 105,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 8`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": null,
+  "containerId": "id13",
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 50,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "ELLIPSE TEXT CONTAINER",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "ELLIPSE TEXT 
+CONTAINER",
+  "textAlign": "center",
+  "type": "text",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "middle",
+  "width": 130,
+  "x": 534.7893218813452,
+  "y": 117.44796179957173,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 9`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": null,
+  "containerId": "id14",
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 75,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "DIAMOND
+TEXT CONTAINER",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#1e1e1e",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "DIAMOND
+TEXT 
+CONTAINER",
+  "textAlign": "center",
+  "type": "text",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "middle",
+  "width": 90,
+  "x": 195,
+  "y": 197.5,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 10`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": null,
+  "containerId": "id15",
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 50,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "STYLED DIAMOND TEXT CONTAINER",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#099268",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "STYLED DIAMOND
+TEXT CONTAINER",
+  "textAlign": "center",
+  "type": "text",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "middle",
+  "width": 140,
+  "x": 180,
+  "y": 435,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 11`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": null,
+  "containerId": "id16",
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 75,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#c2255c",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "TOP LEFT ALIGNED 
+RECTANGLE TEXT 
+CONTAINER",
+  "textAlign": "left",
+  "type": "text",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "top",
+  "width": 170,
+  "x": 505,
+  "y": 305,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 12`] = `
+{
+  "angle": 0,
+  "backgroundColor": "transparent",
+  "baseline": 0,
+  "boundElements": null,
+  "containerId": "id17",
+  "fillStyle": "hachure",
+  "fontFamily": 1,
+  "fontSize": 20,
+  "frameId": null,
+  "groupIds": [],
+  "height": 75,
+  "id": Any<String>,
+  "isDeleted": false,
+  "lineHeight": 1.25,
+  "link": null,
+  "locked": false,
+  "opacity": 100,
+  "originalText": "STYLED ELLIPSE TEXT CONTAINER",
+  "roughness": 1,
+  "roundness": null,
+  "seed": Any<Number>,
+  "strokeColor": "#c2255c",
+  "strokeStyle": "solid",
+  "strokeWidth": 1,
+  "text": "STYLED 
+ELLIPSE TEXT 
+CONTAINER",
+  "textAlign": "center",
+  "type": "text",
+  "updated": 1,
+  "version": 2,
+  "versionNonce": Any<Number>,
+  "verticalAlign": "middle",
+  "width": 130,
+  "x": 534.7893218813452,
+  "y": 522.5735931288071,
+}
+`;

+ 1 - 5
src/data/blob.ts

@@ -144,11 +144,7 @@ export const loadSceneOrLibraryFromBlob = async (
               fileHandle: fileHandle || blob.handle || null,
               ...cleanAppStateForExport(data.appState || {}),
               ...(localAppState
-                ? calculateScrollCenter(
-                    data.elements || [],
-                    localAppState,
-                    null,
-                  )
+                ? calculateScrollCenter(data.elements || [], localAppState)
                 : {}),
             },
             files: data.files,

+ 28 - 13
src/data/restore.ts

@@ -3,6 +3,7 @@ import {
   ExcalidrawSelectionElement,
   ExcalidrawTextElement,
   FontFamilyValues,
+  PointBinding,
   StrokeRoundness,
 } from "../element/types";
 import {
@@ -28,6 +29,7 @@ import {
   FONT_FAMILY,
   ROUNDNESS,
   DEFAULT_SIDEBAR,
+  DEFAULT_ELEMENT_PROPS,
 } from "../constants";
 import { getDefaultAppState } from "../appState";
 import { LinearElementEditor } from "../element/linearElementEditor";
@@ -40,7 +42,7 @@ import {
   getDefaultLineHeight,
   measureBaseline,
 } from "../element/textElement";
-import { COLOR_PALETTE } from "../colors";
+import { normalizeLink } from "./url";
 
 type RestoredAppState = Omit<
   AppState,
@@ -63,6 +65,7 @@ export const AllowedExcalidrawActiveTools: Record<
   eraser: false,
   custom: true,
   frame: true,
+  embeddable: true,
   hand: true,
 };
 
@@ -81,6 +84,13 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
   return DEFAULT_FONT_FAMILY;
 };
 
+const repairBinding = (binding: PointBinding | null) => {
+  if (!binding) {
+    return null;
+  }
+  return { ...binding, focus: binding.focus || 0 };
+};
+
 const restoreElementWithProperties = <
   T extends Required<Omit<ExcalidrawElement, "customData">> & {
     customData?: ExcalidrawElement["customData"];
@@ -112,16 +122,18 @@ const restoreElementWithProperties = <
     versionNonce: element.versionNonce ?? 0,
     isDeleted: element.isDeleted ?? false,
     id: element.id || randomId(),
-    fillStyle: element.fillStyle || "hachure",
-    strokeWidth: element.strokeWidth || 1,
-    strokeStyle: element.strokeStyle ?? "solid",
-    roughness: element.roughness ?? 1,
-    opacity: element.opacity == null ? 100 : element.opacity,
+    fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle,
+    strokeWidth: element.strokeWidth || DEFAULT_ELEMENT_PROPS.strokeWidth,
+    strokeStyle: element.strokeStyle ?? DEFAULT_ELEMENT_PROPS.strokeStyle,
+    roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness,
+    opacity:
+      element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity,
     angle: element.angle || 0,
     x: extra.x ?? element.x ?? 0,
     y: extra.y ?? element.y ?? 0,
-    strokeColor: element.strokeColor || COLOR_PALETTE.black,
-    backgroundColor: element.backgroundColor || COLOR_PALETTE.transparent,
+    strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor,
+    backgroundColor:
+      element.backgroundColor || DEFAULT_ELEMENT_PROPS.backgroundColor,
     width: element.width || 0,
     height: element.height || 0,
     seed: element.seed ?? 1,
@@ -142,7 +154,7 @@ const restoreElementWithProperties = <
       ? element.boundElementIds.map((id) => ({ type: "arrow", id }))
       : element.boundElements ?? [],
     updated: element.updated ?? getUpdatedTimestamp(),
-    link: element.link ?? null,
+    link: element.link ? normalizeLink(element.link) : null,
     locked: element.locked ?? false,
   };
 
@@ -236,7 +248,6 @@ const restoreElement = (
         startArrowhead = null,
         endArrowhead = element.type === "arrow" ? "arrow" : null,
       } = element;
-
       let x = element.x;
       let y = element.y;
       let points = // migrate old arrow model to new one
@@ -256,8 +267,8 @@ const restoreElement = (
           (element.type as ExcalidrawElement["type"] | "draw") === "draw"
             ? "line"
             : element.type,
-        startBinding: element.startBinding,
-        endBinding: element.endBinding,
+        startBinding: repairBinding(element.startBinding),
+        endBinding: repairBinding(element.endBinding),
         lastCommittedPoint: null,
         startArrowhead,
         endArrowhead,
@@ -274,6 +285,10 @@ const restoreElement = (
       return restoreElementWithProperties(element, {});
     case "diamond":
       return restoreElementWithProperties(element, {});
+    case "embeddable":
+      return restoreElementWithProperties(element, {
+        validated: null,
+      });
     case "frame":
       return restoreElementWithProperties(element, {
         name: element.name ?? null,
@@ -396,7 +411,6 @@ export const restoreElements = (
 ): ExcalidrawElement[] => {
   // used to detect duplicate top-level element ids
   const existingIds = new Set<string>();
-
   const localElementsMap = localElements ? arrayToMap(localElements) : null;
   const restoredElements = (elements || []).reduce((elements, element) => {
     // filtering out selection, which is legacy, no longer kept in elements,
@@ -415,6 +429,7 @@ export const restoreElements = (
           migratedElement = { ...migratedElement, id: randomId() };
         }
         existingIds.add(migratedElement.id);
+
         elements.push(migratedElement);
       }
     }

+ 706 - 0
src/data/transform.test.ts

@@ -0,0 +1,706 @@
+import { vi } from "vitest";
+import {
+  ExcalidrawElementSkeleton,
+  convertToExcalidrawElements,
+} from "./transform";
+import { ExcalidrawArrowElement } from "../element/types";
+
+describe("Test Transform", () => {
+  it("should transform regular shapes", () => {
+    const elements = [
+      {
+        type: "rectangle",
+        x: 100,
+        y: 100,
+      },
+      {
+        type: "ellipse",
+        x: 100,
+        y: 250,
+      },
+      {
+        type: "diamond",
+        x: 100,
+        y: 400,
+      },
+      {
+        type: "rectangle",
+        x: 300,
+        y: 100,
+        width: 200,
+        height: 100,
+        backgroundColor: "#c0eb75",
+        strokeWidth: 2,
+      },
+      {
+        type: "ellipse",
+        x: 300,
+        y: 250,
+        width: 200,
+        height: 100,
+        backgroundColor: "#ffc9c9",
+        strokeStyle: "dotted",
+        fillStyle: "solid",
+        strokeWidth: 2,
+      },
+      {
+        type: "diamond",
+        x: 300,
+        y: 400,
+        width: 200,
+        height: 100,
+        backgroundColor: "#a5d8ff",
+        strokeColor: "#1971c2",
+        strokeStyle: "dashed",
+        fillStyle: "cross-hatch",
+        strokeWidth: 2,
+      },
+    ];
+
+    convertToExcalidrawElements(
+      elements as ExcalidrawElementSkeleton[],
+    ).forEach((ele) => {
+      expect(ele).toMatchSnapshot({
+        seed: expect.any(Number),
+        versionNonce: expect.any(Number),
+        id: expect.any(String),
+      });
+    });
+  });
+
+  it("should transform text element", () => {
+    const elements = [
+      {
+        type: "text",
+        x: 100,
+        y: 100,
+        text: "HELLO WORLD!",
+      },
+      {
+        type: "text",
+        x: 100,
+        y: 150,
+        text: "STYLED HELLO WORLD!",
+        fontSize: 20,
+        strokeColor: "#5f3dc4",
+      },
+    ];
+    convertToExcalidrawElements(
+      elements as ExcalidrawElementSkeleton[],
+    ).forEach((ele) => {
+      expect(ele).toMatchSnapshot({
+        seed: expect.any(Number),
+        versionNonce: expect.any(Number),
+        id: expect.any(String),
+      });
+    });
+  });
+
+  it("should transform linear elements", () => {
+    const elements = [
+      {
+        type: "arrow",
+        x: 100,
+        y: 20,
+      },
+      {
+        type: "arrow",
+        x: 450,
+        y: 20,
+        startArrowhead: "dot",
+        endArrowhead: "triangle",
+        strokeColor: "#1971c2",
+        strokeWidth: 2,
+      },
+      {
+        type: "line",
+        x: 100,
+        y: 60,
+      },
+      {
+        type: "line",
+        x: 450,
+        y: 60,
+        strokeColor: "#2f9e44",
+        strokeWidth: 2,
+        strokeStyle: "dotted",
+      },
+    ];
+    const excaldrawElements = convertToExcalidrawElements(
+      elements as ExcalidrawElementSkeleton[],
+    );
+
+    expect(excaldrawElements.length).toBe(4);
+
+    excaldrawElements.forEach((ele) => {
+      expect(ele).toMatchSnapshot({
+        seed: expect.any(Number),
+        versionNonce: expect.any(Number),
+        id: expect.any(String),
+      });
+    });
+  });
+
+  it("should transform to text containers when label provided", () => {
+    const elements = [
+      {
+        type: "rectangle",
+        x: 100,
+        y: 100,
+        label: {
+          text: "RECTANGLE TEXT CONTAINER",
+        },
+      },
+      {
+        type: "ellipse",
+        x: 500,
+        y: 100,
+        width: 200,
+        label: {
+          text: "ELLIPSE TEXT CONTAINER",
+        },
+      },
+      {
+        type: "diamond",
+        x: 100,
+        y: 150,
+        width: 280,
+        label: {
+          text: "DIAMOND\nTEXT CONTAINER",
+        },
+      },
+      {
+        type: "diamond",
+        x: 100,
+        y: 400,
+        width: 300,
+        backgroundColor: "#fff3bf",
+        strokeWidth: 2,
+        label: {
+          text: "STYLED DIAMOND TEXT CONTAINER",
+          strokeColor: "#099268",
+          fontSize: 20,
+        },
+      },
+      {
+        type: "rectangle",
+        x: 500,
+        y: 300,
+        width: 200,
+        strokeColor: "#c2255c",
+        label: {
+          text: "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER",
+          textAlign: "left",
+          verticalAlign: "top",
+          fontSize: 20,
+        },
+      },
+      {
+        type: "ellipse",
+        x: 500,
+        y: 500,
+        strokeColor: "#f08c00",
+        backgroundColor: "#ffec99",
+        width: 200,
+        label: {
+          text: "STYLED ELLIPSE TEXT CONTAINER",
+          strokeColor: "#c2255c",
+        },
+      },
+    ];
+    const excaldrawElements = convertToExcalidrawElements(
+      elements as ExcalidrawElementSkeleton[],
+    );
+
+    expect(excaldrawElements.length).toBe(12);
+
+    excaldrawElements.forEach((ele) => {
+      expect(ele).toMatchSnapshot({
+        seed: expect.any(Number),
+        versionNonce: expect.any(Number),
+        id: expect.any(String),
+      });
+    });
+  });
+
+  it("should transform to labelled arrows when label provided for arrows", () => {
+    const elements = [
+      {
+        type: "arrow",
+        x: 100,
+        y: 100,
+        label: {
+          text: "LABELED ARROW",
+        },
+      },
+      {
+        type: "arrow",
+        x: 100,
+        y: 200,
+        label: {
+          text: "STYLED LABELED ARROW",
+          strokeColor: "#099268",
+          fontSize: 20,
+        },
+      },
+      {
+        type: "arrow",
+        x: 100,
+        y: 300,
+        strokeColor: "#1098ad",
+        strokeWidth: 2,
+        label: {
+          text: "ANOTHER STYLED LABELLED ARROW",
+        },
+      },
+      {
+        type: "arrow",
+        x: 100,
+        y: 400,
+        strokeColor: "#1098ad",
+        strokeWidth: 2,
+        label: {
+          text: "ANOTHER STYLED LABELLED ARROW",
+          strokeColor: "#099268",
+        },
+      },
+    ];
+    const excaldrawElements = convertToExcalidrawElements(
+      elements as ExcalidrawElementSkeleton[],
+    );
+
+    expect(excaldrawElements.length).toBe(8);
+
+    excaldrawElements.forEach((ele) => {
+      expect(ele).toMatchSnapshot({
+        seed: expect.any(Number),
+        versionNonce: expect.any(Number),
+        id: expect.any(String),
+      });
+    });
+  });
+
+  describe("Test arrow bindings", () => {
+    it("should bind arrows to shapes when start / end provided without ids", () => {
+      const elements = [
+        {
+          type: "arrow",
+          x: 255,
+          y: 239,
+          label: {
+            text: "HELLO WORLD!!",
+          },
+          start: {
+            type: "rectangle",
+          },
+          end: {
+            type: "ellipse",
+          },
+        },
+      ];
+      const excaldrawElements = convertToExcalidrawElements(
+        elements as ExcalidrawElementSkeleton[],
+      );
+
+      expect(excaldrawElements.length).toBe(4);
+      const [arrow, text, rectangle, ellipse] = excaldrawElements;
+      expect(arrow).toMatchObject({
+        type: "arrow",
+        x: 255,
+        y: 239,
+        boundElements: [{ id: text.id, type: "text" }],
+        startBinding: {
+          elementId: rectangle.id,
+          focus: 0,
+          gap: 1,
+        },
+        endBinding: {
+          elementId: ellipse.id,
+          focus: 0,
+        },
+      });
+
+      expect(text).toMatchObject({
+        x: 340,
+        y: 226.5,
+        type: "text",
+        text: "HELLO WORLD!!",
+        containerId: arrow.id,
+      });
+
+      expect(rectangle).toMatchObject({
+        x: 155,
+        y: 189,
+        type: "rectangle",
+        boundElements: [
+          {
+            id: arrow.id,
+            type: "arrow",
+          },
+        ],
+      });
+
+      expect(ellipse).toMatchObject({
+        x: 555,
+        y: 189,
+        type: "ellipse",
+        boundElements: [
+          {
+            id: arrow.id,
+            type: "arrow",
+          },
+        ],
+      });
+
+      excaldrawElements.forEach((ele) => {
+        expect(ele).toMatchSnapshot({
+          seed: expect.any(Number),
+          versionNonce: expect.any(Number),
+          id: expect.any(String),
+        });
+      });
+    });
+
+    it("should bind arrows to text when start / end provided without ids", () => {
+      const elements = [
+        {
+          type: "arrow",
+          x: 255,
+          y: 239,
+          label: {
+            text: "HELLO WORLD!!",
+          },
+          start: {
+            type: "text",
+            text: "HEYYYYY",
+          },
+          end: {
+            type: "text",
+            text: "WHATS UP ?",
+          },
+        },
+      ];
+
+      const excaldrawElements = convertToExcalidrawElements(
+        elements as ExcalidrawElementSkeleton[],
+      );
+
+      expect(excaldrawElements.length).toBe(4);
+
+      const [arrow, text1, text2, text3] = excaldrawElements;
+
+      expect(arrow).toMatchObject({
+        type: "arrow",
+        x: 255,
+        y: 239,
+        boundElements: [{ id: text1.id, type: "text" }],
+        startBinding: {
+          elementId: text2.id,
+          focus: 0,
+          gap: 1,
+        },
+        endBinding: {
+          elementId: text3.id,
+          focus: 0,
+        },
+      });
+
+      expect(text1).toMatchObject({
+        x: 340,
+        y: 226.5,
+        type: "text",
+        text: "HELLO WORLD!!",
+        containerId: arrow.id,
+      });
+
+      expect(text2).toMatchObject({
+        x: 185,
+        y: 226.5,
+        type: "text",
+        boundElements: [
+          {
+            id: arrow.id,
+            type: "arrow",
+          },
+        ],
+      });
+
+      expect(text3).toMatchObject({
+        x: 555,
+        y: 226.5,
+        type: "text",
+        boundElements: [
+          {
+            id: arrow.id,
+            type: "arrow",
+          },
+        ],
+      });
+
+      excaldrawElements.forEach((ele) => {
+        expect(ele).toMatchSnapshot({
+          seed: expect.any(Number),
+          versionNonce: expect.any(Number),
+          id: expect.any(String),
+        });
+      });
+    });
+
+    it("should bind arrows to existing shapes when start / end provided with ids", () => {
+      const elements = [
+        {
+          type: "ellipse",
+          id: "ellipse-1",
+          strokeColor: "#66a80f",
+          x: 630,
+          y: 316,
+          width: 300,
+          height: 300,
+          backgroundColor: "#d8f5a2",
+        },
+        {
+          type: "diamond",
+          id: "diamond-1",
+          strokeColor: "#9c36b5",
+          width: 140,
+          x: 96,
+          y: 400,
+        },
+        {
+          type: "arrow",
+          x: 247,
+          y: 420,
+          width: 395,
+          height: 35,
+          strokeColor: "#1864ab",
+          start: {
+            type: "rectangle",
+            width: 300,
+            height: 300,
+          },
+          end: {
+            id: "ellipse-1",
+          },
+        },
+        {
+          type: "arrow",
+          x: 227,
+          y: 450,
+          width: 400,
+          strokeColor: "#e67700",
+          start: {
+            id: "diamond-1",
+          },
+          end: {
+            id: "ellipse-1",
+          },
+        },
+      ];
+
+      const excaldrawElements = convertToExcalidrawElements(
+        elements as ExcalidrawElementSkeleton[],
+      );
+
+      expect(excaldrawElements.length).toBe(5);
+
+      excaldrawElements.forEach((ele) => {
+        expect(ele).toMatchSnapshot({
+          seed: expect.any(Number),
+          versionNonce: expect.any(Number),
+          id: expect.any(String),
+        });
+      });
+    });
+
+    it("should bind arrows to existing text elements when start / end provided with ids", () => {
+      const elements = [
+        {
+          x: 100,
+          y: 239,
+          type: "text",
+          text: "HEYYYYY",
+          id: "text-1",
+          strokeColor: "#c2255c",
+        },
+        {
+          type: "text",
+          id: "text-2",
+          x: 560,
+          y: 239,
+          text: "Whats up ?",
+        },
+        {
+          type: "arrow",
+          x: 255,
+          y: 239,
+          label: {
+            text: "HELLO WORLD!!",
+          },
+          start: {
+            id: "text-1",
+          },
+          end: {
+            id: "text-2",
+          },
+        },
+      ];
+
+      const excaldrawElements = convertToExcalidrawElements(
+        elements as ExcalidrawElementSkeleton[],
+      );
+
+      expect(excaldrawElements.length).toBe(4);
+
+      excaldrawElements.forEach((ele) => {
+        expect(ele).toMatchSnapshot({
+          seed: expect.any(Number),
+          versionNonce: expect.any(Number),
+          id: expect.any(String),
+        });
+      });
+    });
+
+    it("should bind arrows to existing elements if ids are correct", () => {
+      const consoleErrorSpy = vi
+        .spyOn(console, "error")
+        .mockImplementationOnce(() => void 0);
+      const elements = [
+        {
+          x: 100,
+          y: 239,
+          type: "text",
+          text: "HEYYYYY",
+          id: "text-1",
+          strokeColor: "#c2255c",
+        },
+        {
+          type: "rectangle",
+          x: 560,
+          y: 139,
+          id: "rect-1",
+          width: 100,
+          height: 200,
+          backgroundColor: "#bac8ff",
+        },
+        {
+          type: "arrow",
+          x: 255,
+          y: 239,
+          label: {
+            text: "HELLO WORLD!!",
+          },
+          start: {
+            id: "text-13",
+          },
+          end: {
+            id: "rect-11",
+          },
+        },
+      ];
+
+      const excaldrawElements = convertToExcalidrawElements(
+        elements as ExcalidrawElementSkeleton[],
+      );
+
+      expect(excaldrawElements.length).toBe(4);
+      const [, , arrow] = excaldrawElements;
+      expect(arrow).toMatchObject({
+        type: "arrow",
+        x: 255,
+        y: 239,
+        boundElements: [
+          {
+            id: "id46",
+            type: "text",
+          },
+        ],
+        startBinding: null,
+        endBinding: null,
+      });
+      expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
+      expect(consoleErrorSpy).toHaveBeenNthCalledWith(
+        1,
+        "No element for start binding with id text-13 found",
+      );
+      expect(consoleErrorSpy).toHaveBeenNthCalledWith(
+        2,
+        "No element for end binding with id rect-11 found",
+      );
+    });
+
+    it("should bind when ids referenced before the element data", () => {
+      const elements = [
+        {
+          type: "arrow",
+          x: 255,
+          y: 239,
+          end: {
+            id: "rect-1",
+          },
+        },
+        {
+          type: "rectangle",
+          x: 560,
+          y: 139,
+          id: "rect-1",
+          width: 100,
+          height: 200,
+          backgroundColor: "#bac8ff",
+        },
+      ];
+      const excaldrawElements = convertToExcalidrawElements(
+        elements as ExcalidrawElementSkeleton[],
+      );
+      expect(excaldrawElements.length).toBe(2);
+      const [arrow, rect] = excaldrawElements;
+      expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
+        elementId: "rect-1",
+        focus: 0,
+        gap: 5,
+      });
+      expect(rect.boundElements).toStrictEqual([
+        {
+          id: "id47",
+          type: "arrow",
+        },
+      ]);
+    });
+  });
+
+  it("should not allow duplicate ids", () => {
+    const consoleErrorSpy = vi
+      .spyOn(console, "error")
+      .mockImplementationOnce(() => void 0);
+    const elements = [
+      {
+        type: "rectangle",
+        x: 300,
+        y: 100,
+        id: "rect-1",
+        width: 100,
+        height: 200,
+      },
+
+      {
+        type: "rectangle",
+        x: 100,
+        y: 200,
+        id: "rect-1",
+        width: 100,
+        height: 200,
+      },
+    ];
+    const excaldrawElements = convertToExcalidrawElements(
+      elements as ExcalidrawElementSkeleton[],
+    );
+
+    expect(excaldrawElements.length).toBe(1);
+    expect(excaldrawElements[0]).toMatchSnapshot({
+      seed: expect.any(Number),
+      versionNonce: expect.any(Number),
+    });
+    expect(consoleErrorSpy).toHaveBeenCalledWith(
+      "Duplicate id found for rect-1",
+    );
+  });
+});

+ 561 - 0
src/data/transform.ts

@@ -0,0 +1,561 @@
+import {
+  DEFAULT_FONT_FAMILY,
+  DEFAULT_FONT_SIZE,
+  TEXT_ALIGN,
+  VERTICAL_ALIGN,
+} from "../constants";
+import {
+  newElement,
+  newLinearElement,
+  redrawTextBoundingBox,
+} from "../element";
+import { bindLinearElement } from "../element/binding";
+import {
+  ElementConstructorOpts,
+  newImageElement,
+  newTextElement,
+} from "../element/newElement";
+import {
+  getDefaultLineHeight,
+  measureText,
+  normalizeText,
+} from "../element/textElement";
+import {
+  ExcalidrawArrowElement,
+  ExcalidrawBindableElement,
+  ExcalidrawElement,
+  ExcalidrawEmbeddableElement,
+  ExcalidrawFrameElement,
+  ExcalidrawFreeDrawElement,
+  ExcalidrawGenericElement,
+  ExcalidrawImageElement,
+  ExcalidrawLinearElement,
+  ExcalidrawSelectionElement,
+  ExcalidrawTextElement,
+  FileId,
+  FontFamilyValues,
+  TextAlign,
+  VerticalAlign,
+} from "../element/types";
+import { MarkOptional } from "../utility-types";
+import { assertNever, getFontString } from "../utils";
+
+export type ValidLinearElement = {
+  type: "arrow" | "line";
+  x: number;
+  y: number;
+  label?: {
+    text: string;
+    fontSize?: number;
+    fontFamily?: FontFamilyValues;
+    textAlign?: TextAlign;
+    verticalAlign?: VerticalAlign;
+  } & MarkOptional<ElementConstructorOpts, "x" | "y">;
+  end?:
+    | (
+        | (
+            | {
+                type: Exclude<
+                  ExcalidrawBindableElement["type"],
+                  "image" | "text" | "frame" | "embeddable"
+                >;
+                id?: ExcalidrawGenericElement["id"];
+              }
+            | {
+                id: ExcalidrawGenericElement["id"];
+                type?: Exclude<
+                  ExcalidrawBindableElement["type"],
+                  "image" | "text" | "frame" | "embeddable"
+                >;
+              }
+          )
+        | ((
+            | {
+                type: "text";
+                text: string;
+              }
+            | {
+                type?: "text";
+                id: ExcalidrawTextElement["id"];
+                text: string;
+              }
+          ) &
+            Partial<ExcalidrawTextElement>)
+      ) &
+        MarkOptional<ElementConstructorOpts, "x" | "y">;
+  start?:
+    | (
+        | (
+            | {
+                type: Exclude<
+                  ExcalidrawBindableElement["type"],
+                  "image" | "text" | "frame" | "embeddable"
+                >;
+                id?: ExcalidrawGenericElement["id"];
+              }
+            | {
+                id: ExcalidrawGenericElement["id"];
+                type?: Exclude<
+                  ExcalidrawBindableElement["type"],
+                  "image" | "text" | "frame" | "embeddable"
+                >;
+              }
+          )
+        | ((
+            | {
+                type: "text";
+                text: string;
+              }
+            | {
+                type?: "text";
+                id: ExcalidrawTextElement["id"];
+                text: string;
+              }
+          ) &
+            Partial<ExcalidrawTextElement>)
+      ) &
+        MarkOptional<ElementConstructorOpts, "x" | "y">;
+} & Partial<ExcalidrawLinearElement>;
+
+export type ValidContainer =
+  | {
+      type: Exclude<ExcalidrawGenericElement["type"], "selection">;
+      id?: ExcalidrawGenericElement["id"];
+      label?: {
+        text: string;
+        fontSize?: number;
+        fontFamily?: FontFamilyValues;
+        textAlign?: TextAlign;
+        verticalAlign?: VerticalAlign;
+      } & MarkOptional<ElementConstructorOpts, "x" | "y">;
+    } & ElementConstructorOpts;
+
+export type ExcalidrawElementSkeleton =
+  | Extract<
+      Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
+      | ExcalidrawEmbeddableElement
+      | ExcalidrawFreeDrawElement
+      | ExcalidrawFrameElement
+    >
+  | ({
+      type: Extract<ExcalidrawLinearElement["type"], "line">;
+      x: number;
+      y: number;
+    } & Partial<ExcalidrawLinearElement>)
+  | ValidContainer
+  | ValidLinearElement
+  | ({
+      type: "text";
+      text: string;
+      x: number;
+      y: number;
+      id?: ExcalidrawTextElement["id"];
+    } & Partial<ExcalidrawTextElement>)
+  | ({
+      type: Extract<ExcalidrawImageElement["type"], "image">;
+      x: number;
+      y: number;
+      fileId: FileId;
+    } & Partial<ExcalidrawImageElement>);
+
+const DEFAULT_LINEAR_ELEMENT_PROPS = {
+  width: 300,
+  height: 0,
+};
+
+const DEFAULT_DIMENSION = 100;
+
+const bindTextToContainer = (
+  container: ExcalidrawElement,
+  textProps: { text: string } & MarkOptional<ElementConstructorOpts, "x" | "y">,
+) => {
+  const textElement: ExcalidrawTextElement = newTextElement({
+    x: 0,
+    y: 0,
+    textAlign: TEXT_ALIGN.CENTER,
+    verticalAlign: VERTICAL_ALIGN.MIDDLE,
+    ...textProps,
+    containerId: container.id,
+    strokeColor: textProps.strokeColor || container.strokeColor,
+  });
+
+  Object.assign(container, {
+    boundElements: (container.boundElements || []).concat({
+      type: "text",
+      id: textElement.id,
+    }),
+  });
+
+  redrawTextBoundingBox(textElement, container);
+  return [container, textElement] as const;
+};
+
+const bindLinearElementToElement = (
+  linearElement: ExcalidrawArrowElement,
+  start: ValidLinearElement["start"],
+  end: ValidLinearElement["end"],
+  elementStore: ElementStore,
+): {
+  linearElement: ExcalidrawLinearElement;
+  startBoundElement?: ExcalidrawElement;
+  endBoundElement?: ExcalidrawElement;
+} => {
+  let startBoundElement;
+  let endBoundElement;
+
+  Object.assign(linearElement, {
+    startBinding: linearElement?.startBinding || null,
+    endBinding: linearElement.endBinding || null,
+  });
+
+  if (start) {
+    const width = start?.width ?? DEFAULT_DIMENSION;
+    const height = start?.height ?? DEFAULT_DIMENSION;
+
+    let existingElement;
+    if (start.id) {
+      existingElement = elementStore.getElement(start.id);
+      if (!existingElement) {
+        console.error(`No element for start binding with id ${start.id} found`);
+      }
+    }
+
+    const startX = start.x || linearElement.x - width;
+    const startY = start.y || linearElement.y - height / 2;
+    const startType = existingElement ? existingElement.type : start.type;
+
+    if (startType) {
+      if (startType === "text") {
+        let text = "";
+        if (existingElement && existingElement.type === "text") {
+          text = existingElement.text;
+        } else if (start.type === "text") {
+          text = start.text;
+        }
+        if (!text) {
+          console.error(
+            `No text found for start binding text element for ${linearElement.id}`,
+          );
+        }
+        startBoundElement = newTextElement({
+          x: startX,
+          y: startY,
+          type: "text",
+          ...existingElement,
+          ...start,
+          text,
+        });
+        // to position the text correctly when coordinates not provided
+        Object.assign(startBoundElement, {
+          x: start.x || linearElement.x - startBoundElement.width,
+          y: start.y || linearElement.y - startBoundElement.height / 2,
+        });
+      } else {
+        switch (startType) {
+          case "rectangle":
+          case "ellipse":
+          case "diamond": {
+            startBoundElement = newElement({
+              x: startX,
+              y: startY,
+              width,
+              height,
+              ...existingElement,
+              ...start,
+              type: startType,
+            });
+            break;
+          }
+          default: {
+            assertNever(
+              linearElement as never,
+              `Unhandled element start type "${start.type}"`,
+              true,
+            );
+          }
+        }
+      }
+
+      bindLinearElement(
+        linearElement,
+        startBoundElement as ExcalidrawBindableElement,
+        "start",
+      );
+    }
+  }
+  if (end) {
+    const height = end?.height ?? DEFAULT_DIMENSION;
+    const width = end?.width ?? DEFAULT_DIMENSION;
+
+    let existingElement;
+    if (end.id) {
+      existingElement = elementStore.getElement(end.id);
+      if (!existingElement) {
+        console.error(`No element for end binding with id ${end.id} found`);
+      }
+    }
+    const endX = end.x || linearElement.x + linearElement.width;
+    const endY = end.y || linearElement.y - height / 2;
+    const endType = existingElement ? existingElement.type : end.type;
+
+    if (endType) {
+      if (endType === "text") {
+        let text = "";
+        if (existingElement && existingElement.type === "text") {
+          text = existingElement.text;
+        } else if (end.type === "text") {
+          text = end.text;
+        }
+
+        if (!text) {
+          console.error(
+            `No text found for end binding text element for ${linearElement.id}`,
+          );
+        }
+        endBoundElement = newTextElement({
+          x: endX,
+          y: endY,
+          type: "text",
+          ...existingElement,
+          ...end,
+          text,
+        });
+        // to position the text correctly when coordinates not provided
+        Object.assign(endBoundElement, {
+          y: end.y || linearElement.y - endBoundElement.height / 2,
+        });
+      } else {
+        switch (endType) {
+          case "rectangle":
+          case "ellipse":
+          case "diamond": {
+            endBoundElement = newElement({
+              x: endX,
+              y: endY,
+              width,
+              height,
+              ...existingElement,
+              ...end,
+              type: endType,
+            });
+            break;
+          }
+          default: {
+            assertNever(
+              linearElement as never,
+              `Unhandled element end type "${endType}"`,
+              true,
+            );
+          }
+        }
+      }
+
+      bindLinearElement(
+        linearElement,
+        endBoundElement as ExcalidrawBindableElement,
+        "end",
+      );
+    }
+  }
+  return {
+    linearElement,
+    startBoundElement,
+    endBoundElement,
+  };
+};
+
+class ElementStore {
+  excalidrawElements = new Map<string, ExcalidrawElement>();
+
+  add = (ele?: ExcalidrawElement) => {
+    if (!ele) {
+      return;
+    }
+
+    this.excalidrawElements.set(ele.id, ele);
+  };
+  getElements = () => {
+    return Array.from(this.excalidrawElements.values());
+  };
+
+  getElement = (id: string) => {
+    return this.excalidrawElements.get(id);
+  };
+}
+
+export const convertToExcalidrawElements = (
+  elements: ExcalidrawElementSkeleton[] | null,
+) => {
+  if (!elements) {
+    return [];
+  }
+
+  const elementStore = new ElementStore();
+  const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
+
+  // Create individual elements
+  for (const element of elements) {
+    let excalidrawElement: ExcalidrawElement;
+    switch (element.type) {
+      case "rectangle":
+      case "ellipse":
+      case "diamond": {
+        const width =
+          element?.label?.text && element.width === undefined
+            ? 0
+            : element?.width || DEFAULT_DIMENSION;
+        const height =
+          element?.label?.text && element.height === undefined
+            ? 0
+            : element?.height || DEFAULT_DIMENSION;
+        excalidrawElement = newElement({
+          ...element,
+          width,
+          height,
+        });
+
+        break;
+      }
+      case "line": {
+        const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
+        const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
+        excalidrawElement = newLinearElement({
+          width,
+          height,
+          points: [
+            [0, 0],
+            [width, height],
+          ],
+          ...element,
+        });
+
+        break;
+      }
+      case "arrow": {
+        const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
+        const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
+        excalidrawElement = newLinearElement({
+          width,
+          height,
+          endArrowhead: "arrow",
+          points: [
+            [0, 0],
+            [width, height],
+          ],
+          ...element,
+        });
+        break;
+      }
+      case "text": {
+        const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
+        const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
+        const lineHeight =
+          element?.lineHeight || getDefaultLineHeight(fontFamily);
+        const text = element.text ?? "";
+        const normalizedText = normalizeText(text);
+        const metrics = measureText(
+          normalizedText,
+          getFontString({ fontFamily, fontSize }),
+          lineHeight,
+        );
+
+        excalidrawElement = newTextElement({
+          width: metrics.width,
+          height: metrics.height,
+          fontFamily,
+          fontSize,
+          ...element,
+        });
+        break;
+      }
+      case "image": {
+        excalidrawElement = newImageElement({
+          width: element?.width || DEFAULT_DIMENSION,
+          height: element?.height || DEFAULT_DIMENSION,
+          ...element,
+        });
+
+        break;
+      }
+      case "freedraw":
+      case "frame":
+      case "embeddable": {
+        excalidrawElement = element;
+        break;
+      }
+
+      default: {
+        excalidrawElement = element;
+        assertNever(
+          element,
+          `Unhandled element type "${(element as any).type}"`,
+          true,
+        );
+      }
+    }
+    const existingElement = elementStore.getElement(excalidrawElement.id);
+    if (existingElement) {
+      console.error(`Duplicate id found for ${excalidrawElement.id}`);
+    } else {
+      elementStore.add(excalidrawElement);
+      elementsWithIds.set(excalidrawElement.id, element);
+    }
+  }
+
+  // Add labels and arrow bindings
+  for (const [id, element] of elementsWithIds) {
+    const excalidrawElement = elementStore.getElement(id)!;
+
+    switch (element.type) {
+      case "rectangle":
+      case "ellipse":
+      case "diamond":
+      case "arrow": {
+        if (element.label?.text) {
+          let [container, text] = bindTextToContainer(
+            excalidrawElement,
+            element?.label,
+          );
+          elementStore.add(container);
+          elementStore.add(text);
+
+          if (container.type === "arrow") {
+            const originalStart =
+              element.type === "arrow" ? element?.start : undefined;
+            const originalEnd =
+              element.type === "arrow" ? element?.end : undefined;
+            const { linearElement, startBoundElement, endBoundElement } =
+              bindLinearElementToElement(
+                container as ExcalidrawArrowElement,
+                originalStart,
+                originalEnd,
+                elementStore,
+              );
+            container = linearElement;
+            elementStore.add(linearElement);
+            elementStore.add(startBoundElement);
+            elementStore.add(endBoundElement);
+          }
+        } else {
+          switch (element.type) {
+            case "arrow": {
+              const { linearElement, startBoundElement, endBoundElement } =
+                bindLinearElementToElement(
+                  excalidrawElement as ExcalidrawArrowElement,
+                  element.start,
+                  element.end,
+                  elementStore,
+                );
+              elementStore.add(linearElement);
+              elementStore.add(startBoundElement);
+              elementStore.add(endBoundElement);
+              break;
+            }
+          }
+        }
+        break;
+      }
+    }
+  }
+  return elementStore.getElements();
+};

+ 30 - 0
src/data/url.test.tsx

@@ -0,0 +1,30 @@
+import { normalizeLink } from "./url";
+
+describe("normalizeLink", () => {
+  // NOTE not an extensive XSS test suite, just to check if we're not
+  // regressing in sanitization
+  it("should sanitize links", () => {
+    expect(
+      // eslint-disable-next-line no-script-url
+      normalizeLink(`javascript://%0aalert(document.domain)`).startsWith(
+        // eslint-disable-next-line no-script-url
+        `javascript:`,
+      ),
+    ).toBe(false);
+    expect(normalizeLink("ola")).toBe("ola");
+    expect(normalizeLink(" ola")).toBe("ola");
+
+    expect(normalizeLink("https://www.excalidraw.com")).toBe(
+      "https://www.excalidraw.com",
+    );
+    expect(normalizeLink("www.excalidraw.com")).toBe("www.excalidraw.com");
+    expect(normalizeLink("/ola")).toBe("/ola");
+    expect(normalizeLink("http://test")).toBe("http://test");
+    expect(normalizeLink("ftp://test")).toBe("ftp://test");
+    expect(normalizeLink("file://")).toBe("file://");
+    expect(normalizeLink("file://")).toBe("file://");
+    expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
+    expect(normalizeLink("[[test]]")).toBe("[[test]]");
+    expect(normalizeLink("<test>")).toBe("<test>");
+  });
+});

+ 35 - 0
src/data/url.ts

@@ -0,0 +1,35 @@
+import { sanitizeUrl } from "@braintree/sanitize-url";
+
+export const normalizeLink = (link: string) => {
+  link = link.trim();
+  if (!link) {
+    return link;
+  }
+  return sanitizeUrl(link);
+};
+
+export const isLocalLink = (link: string | null) => {
+  return !!(link?.includes(location.origin) || link?.startsWith("/"));
+};
+
+/**
+ * Returns URL sanitized and safe for usage in places such as
+ * iframe's src attribute or <a> href attributes.
+ */
+export const toValidURL = (link: string) => {
+  link = normalizeLink(link);
+
+  // make relative links into fully-qualified urls
+  if (link.startsWith("/")) {
+    return `${location.origin}${link}`;
+  }
+
+  try {
+    new URL(link);
+  } catch {
+    // if link does not parse as URL, assume invalid and return blank page
+    return "about:blank";
+  }
+
+  return link;
+};

+ 0 - 4
src/element/Hyperlink.scss

@@ -55,10 +55,6 @@
     }
   }
 
-  .d-none {
-    display: none;
-  }
-
   &--remove .ToolIcon__icon svg {
     color: $oc-red-6;
   }

+ 144 - 46
src/element/Hyperlink.tsx

@@ -5,8 +5,12 @@ import {
   viewportCoordsToSceneCoords,
   wrapEvent,
 } from "../utils";
+import { getEmbedLink, embeddableURLValidator } from "./embeddable";
 import { mutateElement } from "./mutateElement";
-import { NonDeletedExcalidrawElement } from "./types";
+import {
+  ExcalidrawEmbeddableElement,
+  NonDeletedExcalidrawElement,
+} from "./types";
 
 import { register } from "../actions/register";
 import { ToolButton } from "../components/ToolButton";
@@ -29,10 +33,13 @@ import { getTooltipDiv, updateTooltipPosition } from "../components/Tooltip";
 import { getSelectedElements } from "../scene";
 import { isPointHittingElementBoundingBox } from "./collision";
 import { getElementAbsoluteCoords } from "./";
+import { isLocalLink, normalizeLink } from "../data/url";
 
 import "./Hyperlink.scss";
 import { trackEvent } from "../analytics";
-import { useExcalidrawAppState } from "../components/App";
+import { useAppProps, useExcalidrawAppState } from "../components/App";
+import { isEmbeddableElement } from "./typeChecks";
+import { ShapeCache } from "../scene/ShapeCache";
 
 const CONTAINER_WIDTH = 320;
 const SPACE_BOTTOM = 85;
@@ -47,37 +54,112 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
 
 let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
 
+const embeddableLinkCache = new Map<
+  ExcalidrawEmbeddableElement["id"],
+  string
+>();
+
 export const Hyperlink = ({
   element,
   setAppState,
   onLinkOpen,
+  setToast,
 }: {
   element: NonDeletedExcalidrawElement;
   setAppState: React.Component<any, AppState>["setState"];
   onLinkOpen: ExcalidrawProps["onLinkOpen"];
+  setToast: (
+    toast: { message: string; closable?: boolean; duration?: number } | null,
+  ) => void;
 }) => {
   const appState = useExcalidrawAppState();
+  const appProps = useAppProps();
 
   const linkVal = element.link || "";
 
   const [inputVal, setInputVal] = useState(linkVal);
   const inputRef = useRef<HTMLInputElement>(null);
-  const isEditing = appState.showHyperlinkPopup === "editor" || !linkVal;
+  const isEditing = appState.showHyperlinkPopup === "editor";
 
   const handleSubmit = useCallback(() => {
     if (!inputRef.current) {
       return;
     }
 
-    const link = normalizeLink(inputRef.current.value);
+    const link = normalizeLink(inputRef.current.value) || null;
 
     if (!element.link && link) {
       trackEvent("hyperlink", "create");
     }
 
-    mutateElement(element, { link });
-    setAppState({ showHyperlinkPopup: "info" });
-  }, [element, setAppState]);
+    if (isEmbeddableElement(element)) {
+      if (appState.activeEmbeddable?.element === element) {
+        setAppState({ activeEmbeddable: null });
+      }
+      if (!link) {
+        mutateElement(element, {
+          validated: false,
+          link: null,
+        });
+        return;
+      }
+
+      if (!embeddableURLValidator(link, appProps.validateEmbeddable)) {
+        if (link) {
+          setToast({ message: t("toast.unableToEmbed"), closable: true });
+        }
+        element.link && embeddableLinkCache.set(element.id, element.link);
+        mutateElement(element, {
+          validated: false,
+          link,
+        });
+        ShapeCache.delete(element);
+      } else {
+        const { width, height } = element;
+        const embedLink = getEmbedLink(link);
+        if (embedLink?.warning) {
+          setToast({ message: embedLink.warning, closable: true });
+        }
+        const ar = embedLink
+          ? embedLink.aspectRatio.w / embedLink.aspectRatio.h
+          : 1;
+        const hasLinkChanged =
+          embeddableLinkCache.get(element.id) !== element.link;
+        mutateElement(element, {
+          ...(hasLinkChanged
+            ? {
+                width:
+                  embedLink?.type === "video"
+                    ? width > height
+                      ? width
+                      : height * ar
+                    : width,
+                height:
+                  embedLink?.type === "video"
+                    ? width > height
+                      ? width / ar
+                      : height
+                    : height,
+              }
+            : {}),
+          validated: true,
+          link,
+        });
+        ShapeCache.delete(element);
+        if (embeddableLinkCache.has(element.id)) {
+          embeddableLinkCache.delete(element.id);
+        }
+      }
+    } else {
+      mutateElement(element, { link });
+    }
+  }, [
+    element,
+    setToast,
+    appProps.validateEmbeddable,
+    appState.activeEmbeddable,
+    setAppState,
+  ]);
 
   useLayoutEffect(() => {
     return () => {
@@ -131,10 +213,12 @@ export const Hyperlink = ({
     appState.draggingElement ||
     appState.resizingElement ||
     appState.isRotating ||
-    appState.openMenu
+    appState.openMenu ||
+    appState.viewModeEnabled
   ) {
     return null;
   }
+
   return (
     <div
       className="excalidraw-hyperlinkContainer"
@@ -144,6 +228,11 @@ export const Hyperlink = ({
         width: CONTAINER_WIDTH,
         padding: CONTAINER_PADDING,
       }}
+      onClick={() => {
+        if (!element.link && !isEditing) {
+          setAppState({ showHyperlinkPopup: "editor" });
+        }
+      }}
     >
       {isEditing ? (
         <input
@@ -161,15 +250,14 @@ export const Hyperlink = ({
             }
             if (event.key === KEYS.ENTER || event.key === KEYS.ESCAPE) {
               handleSubmit();
+              setAppState({ showHyperlinkPopup: "info" });
             }
           }}
         />
-      ) : (
+      ) : element.link ? (
         <a
-          href={element.link || ""}
-          className={clsx("excalidraw-hyperlinkContainer-link", {
-            "d-none": isEditing,
-          })}
+          href={normalizeLink(element.link || "")}
+          className="excalidraw-hyperlinkContainer-link"
           target={isLocalLink(element.link) ? "_self" : "_blank"}
           onClick={(event) => {
             if (element.link && onLinkOpen) {
@@ -177,7 +265,13 @@ export const Hyperlink = ({
                 EVENT.EXCALIDRAW_LINK,
                 event.nativeEvent,
               );
-              onLinkOpen(element, customEvent);
+              onLinkOpen(
+                {
+                  ...element,
+                  link: normalizeLink(element.link),
+                },
+                customEvent,
+              );
               if (customEvent.defaultPrevented) {
                 event.preventDefault();
               }
@@ -187,6 +281,10 @@ export const Hyperlink = ({
         >
           {element.link}
         </a>
+      ) : (
+        <div className="excalidraw-hyperlinkContainer-link">
+          {t("labels.link.empty")}
+        </div>
       )}
       <div className="excalidraw-hyperlinkContainer__buttons">
         {!isEditing && (
@@ -200,8 +298,7 @@ export const Hyperlink = ({
             icon={FreedrawIcon}
           />
         )}
-
-        {linkVal && (
+        {linkVal && !isEmbeddableElement(element) && (
           <ToolButton
             type="button"
             title={t("buttons.remove")}
@@ -231,21 +328,6 @@ const getCoordsForPopover = (
   return { x, y };
 };
 
-export const normalizeLink = (link: string) => {
-  link = link.trim();
-  if (link) {
-    // prefix with protocol if not fully-qualified
-    if (!link.includes("://") && !/^[[\\/]/.test(link)) {
-      link = `https://${link}`;
-    }
-  }
-  return link;
-};
-
-export const isLocalLink = (link: string | null) => {
-  return !!(link?.includes(location.origin) || link?.startsWith("/"));
-};
-
 export const actionLink = register({
   name: "hyperlink",
   perform: (elements, appState) => {
@@ -279,7 +361,11 @@ export const actionLink = register({
         type="button"
         icon={LinkIcon}
         aria-label={t(getContextMenuLabel(elements, appState))}
-        title={`${t("labels.link.label")} - ${getShortcutKey("CtrlOrCmd+K")}`}
+        title={`${
+          isEmbeddableElement(elements[0])
+            ? t("labels.link.labelEmbed")
+            : t("labels.link.label")
+        } - ${getShortcutKey("CtrlOrCmd+K")}`}
         onClick={() => updateData(null)}
         selected={selectedElements.length === 1 && !!selectedElements[0].link}
       />
@@ -293,7 +379,11 @@ export const getContextMenuLabel = (
 ) => {
   const selectedElements = getSelectedElements(elements, appState);
   const label = selectedElements[0]!.link
-    ? "labels.link.edit"
+    ? isEmbeddableElement(selectedElements[0])
+      ? "labels.link.editEmbed"
+      : "labels.link.edit"
+    : isEmbeddableElement(selectedElements[0])
+    ? "labels.link.createEmbed"
     : "labels.link.create";
   return label;
 };
@@ -301,7 +391,7 @@ export const getContextMenuLabel = (
 export const getLinkHandleFromCoords = (
   [x1, y1, x2, y2]: Bounds,
   angle: number,
-  appState: UIAppState,
+  appState: Pick<UIAppState, "zoom">,
 ): [x: number, y: number, width: number, height: number] => {
   const size = DEFAULT_LINK_SIZE;
   const linkWidth = size / appState.zoom.value;
@@ -335,21 +425,9 @@ export const isPointHittingLinkIcon = (
   element: NonDeletedExcalidrawElement,
   appState: AppState,
   [x, y]: Point,
-  isMobile: boolean,
 ) => {
-  if (!element.link || appState.selectedElementIds[element.id]) {
-    return false;
-  }
   const threshold = 4 / appState.zoom.value;
-  if (
-    !isMobile &&
-    appState.viewModeEnabled &&
-    isPointHittingElementBoundingBox(element, [x, y], threshold, null)
-  ) {
-    return true;
-  }
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-
   const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
     [x1, y1, x2, y2],
     element.angle,
@@ -363,6 +441,26 @@ export const isPointHittingLinkIcon = (
   return hitLink;
 };
 
+export const isPointHittingLink = (
+  element: NonDeletedExcalidrawElement,
+  appState: AppState,
+  [x, y]: Point,
+  isMobile: boolean,
+) => {
+  if (!element.link || appState.selectedElementIds[element.id]) {
+    return false;
+  }
+  const threshold = 4 / appState.zoom.value;
+  if (
+    !isMobile &&
+    appState.viewModeEnabled &&
+    isPointHittingElementBoundingBox(element, [x, y], threshold, null)
+  ) {
+    return true;
+  }
+  return isPointHittingLinkIcon(element, appState, [x, y]);
+};
+
 let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null;
 export const showHyperlinkTooltip = (
   element: NonDeletedExcalidrawElement,

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů