Bläddra i källkod

Merge branch 'master' into arnost/sockets-reconnecting

# Conflicts:
#	package.json
#	src/locales/en.json
#	yarn.lock
dwelle 2 år sedan
förälder
incheckning
772a9999b8
100 ändrade filer med 1760 tillägg och 617 borttagningar
  1. 20 11
      .env.development
  2. 8 8
      .env.production
  3. 2 2
      .github/workflows/autorelease-excalidraw.yml
  4. 2 2
      .github/workflows/autorelease-preview.yml
  5. 2 2
      .github/workflows/lint.yml
  6. 2 2
      .github/workflows/locales-coverage.yml
  7. 1 1
      .github/workflows/semantic-pr-title.yml
  8. 2 2
      .github/workflows/sentry-production.yml
  9. 30 0
      .github/workflows/size-limit.yml
  10. 2 2
      .github/workflows/test.yml
  11. 2 0
      .gitignore
  12. 1 1
      Dockerfile
  13. 11 1
      dev-docs/docs/@excalidraw/excalidraw/api/props/props.mdx
  14. 13 0
      dev-docs/docs/@excalidraw/excalidraw/api/props/render-props.mdx
  15. 9 15
      index.html
  16. 24 41
      package.json
  17. 0 0
      public/workbox/workbox-background-sync.prod.js
  18. 0 2
      public/workbox/workbox-broadcast-update.prod.js
  19. 0 2
      public/workbox/workbox-cacheable-response.prod.js
  20. 0 0
      public/workbox/workbox-core.prod.js
  21. 0 0
      public/workbox/workbox-expiration.prod.js
  22. 0 2
      public/workbox/workbox-navigation-preload.prod.js
  23. 0 2
      public/workbox/workbox-offline-ga.prod.js
  24. 0 0
      public/workbox/workbox-precaching.prod.js
  25. 0 2
      public/workbox/workbox-range-requests.prod.js
  26. 0 0
      public/workbox/workbox-routing.prod.js
  27. 0 0
      public/workbox/workbox-strategies.prod.js
  28. 0 2
      public/workbox/workbox-streams.prod.js
  29. 0 2
      public/workbox/workbox-sw.js
  30. 0 0
      public/workbox/workbox-window.prod.es5.mjs
  31. 0 0
      public/workbox/workbox-window.prod.mjs
  32. 0 0
      public/workbox/workbox-window.prod.umd.js
  33. 17 18
      src/actions/actionAddToLibrary.ts
  34. 32 34
      src/actions/actionAlign.tsx
  35. 13 23
      src/actions/actionBoundText.tsx
  36. 7 11
      src/actions/actionCanvas.tsx
  37. 24 30
      src/actions/actionClipboard.tsx
  38. 1 0
      src/actions/actionDeleteSelected.tsx
  39. 15 22
      src/actions/actionDistribute.tsx
  40. 1 0
      src/actions/actionDuplicateSelection.tsx
  41. 11 9
      src/actions/actionElementLock.ts
  42. 5 5
      src/actions/actionExport.tsx
  43. 1 0
      src/actions/actionFinalize.tsx
  44. 4 2
      src/actions/actionFlip.ts
  45. 11 22
      src/actions/actionFrame.ts
  46. 17 20
      src/actions/actionGroup.tsx
  47. 11 19
      src/actions/actionLinearEditor.ts
  48. 1 0
      src/actions/actionSelectAll.ts
  49. 2 0
      src/actions/manager.tsx
  50. 4 0
      src/actions/types.ts
  51. 1 1
      src/analytics.ts
  52. 2 0
      src/appState.ts
  53. 2 3
      src/charts.ts
  54. 1 1
      src/colors.ts
  55. 82 32
      src/components/Actions.tsx
  56. 2 1
      src/components/App.test.tsx
  57. 513 77
      src/components/App.tsx
  58. 6 2
      src/components/ColorPicker/PickerColorList.tsx
  59. 9 3
      src/components/ContextMenu.tsx
  60. 1 1
      src/components/EyeDropper.tsx
  61. 10 13
      src/components/HintViewer.tsx
  62. 4 1
      src/components/LayerUI.tsx
  63. 8 5
      src/components/LibraryMenu.tsx
  64. 1 1
      src/components/LibraryMenuBrowseButton.tsx
  65. 5 0
      src/components/LibraryUnit.scss
  66. 10 2
      src/components/MobileMenu.tsx
  67. 1 1
      src/components/PublishLibrary.tsx
  68. 1 1
      src/components/Section.tsx
  69. 2 1
      src/components/Sidebar/Sidebar.test.tsx
  70. 1 1
      src/components/Sidebar/Sidebar.tsx
  71. 9 5
      src/components/Trans.test.tsx
  72. 2 2
      src/components/Trans.tsx
  73. 2 2
      src/components/__snapshots__/App.test.tsx.snap
  74. 8 0
      src/components/icons.tsx
  75. 10 2
      src/components/main-menu/DefaultItems.tsx
  76. 13 1
      src/constants.ts
  77. 43 0
      src/css/styles.scss
  78. 15 2
      src/data/restore.ts
  79. 26 0
      src/data/url.ts
  80. 0 4
      src/element/Hyperlink.scss
  81. 137 29
      src/element/Hyperlink.tsx
  82. 26 5
      src/element/collision.ts
  83. 330 0
      src/element/embeddable.ts
  84. 14 3
      src/element/newElement.ts
  85. 7 7
      src/element/showSelectedShapeActions.ts
  86. 17 1
      src/element/sizeHelpers.test.ts
  87. 12 12
      src/element/textWysiwyg.test.tsx
  88. 15 8
      src/element/typeChecks.ts
  89. 15 0
      src/element/types.ts
  90. 3 9
      src/excalidraw-app/collab/Collab.tsx
  91. 3 1
      src/excalidraw-app/components/AppWelcomeScreen.tsx
  92. 3 1
      src/excalidraw-app/components/ExcalidrawPlusAppLink.tsx
  93. 4 2
      src/excalidraw-app/data/firebase.ts
  94. 5 5
      src/excalidraw-app/data/index.ts
  95. 36 1
      src/excalidraw-app/index.tsx
  96. 0 31
      src/excalidraw-app/pwa.ts
  97. 2 2
      src/excalidraw-app/sentry.ts
  98. 7 2
      src/frame.ts
  99. 6 10
      src/global.d.ts
  100. 20 4
      src/groups.ts

+ 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)
 # 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
 # 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!
 # put these in your .env.local, or make sure you don't commit!
 # must be lowercase `true` when turned on
 # must be lowercase `true` when turned on
 #
 #
 # whether to enable Service Workers in development
 # 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
 # whether to disable live reload / HMR. Usuaully what you want to do when
 # debugging Service Workers.
 # 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
 FAST_REFRESH=false
 
 
+# The port the run the dev server
+VITE_APP_PORT=3000
+
 #Debug flags
 #Debug flags
 
 
 # To enable bounding box for text containers
 # 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

+ 8 - 8
.env.production

@@ -1,15 +1,15 @@
 REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
 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/
 REACT_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.
 # 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
       - uses: actions/checkout@v2
         with:
         with:
           fetch-depth: 2
           fetch-depth: 2
-      - name: Setup Node.js 14.x
+      - name: Setup Node.js 18.x
         uses: actions/setup-node@v2
         uses: actions/setup-node@v2
         with:
         with:
-          node-version: 14.x
+          node-version: 18.x
       - name: Set up publish access
       - name: Set up publish access
         run: |
         run: |
           npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
           npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}

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

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

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

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

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

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

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

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

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

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

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

@@ -7,10 +7,10 @@ jobs:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     steps:
     steps:
       - uses: actions/checkout@v2
       - uses: actions/checkout@v2
-      - name: Setup Node.js 14.x
+      - name: Setup Node.js 18.x
         uses: actions/setup-node@v2
         uses: actions/setup-node@v2
         with:
         with:
-          node-version: 14.x
+          node-version: 18.x
       - name: Install and test
       - name: Install and test
         run: |
         run: |
           yarn --frozen-lockfile
           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-assets-dev
 src/packages/excalidraw/example/public/excalidraw.development.js
 src/packages/excalidraw/example/public/excalidraw.development.js
 coverage
 coverage
+dev-dist
+html

+ 1 - 1
Dockerfile

@@ -1,4 +1,4 @@
-FROM node:14-alpine AS build
+FROM node:18 AS build
 
 
 WORKDIR /opt/node_app
 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. |
 | [`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 |
 | [`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 |
 | [`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
 ### 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).
 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
 ### autoFocus
 
 
 This prop indicates whether to `focus` the Excalidraw component on page load. Defaults to false.
 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>
 (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.

+ 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. |

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

@@ -78,8 +78,7 @@
       }
       }
     </style>
     </style>
     <!------------------------------------------------------------------------->
     <!------------------------------------------------------------------------->
-
-    <% if (process.env.NODE_ENV === "production") { %>
+    <% if ("%PROD%" === "true") { %>
     <script>
     <script>
       // Redirect Excalidraw+ users which have auto-redirect enabled.
       // Redirect Excalidraw+ users which have auto-redirect enabled.
       //
       //
@@ -100,41 +99,35 @@
     </script>
     </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 -->
     <!-- Excalidraw version -->
     <meta name="version" content="{version}" />
     <meta name="version" content="{version}" />
 
 
     <link
     <link
       rel="preload"
       rel="preload"
-      href="Virgil.woff2"
+      href="/Virgil.woff2"
       as="font"
       as="font"
       type="font/woff2"
       type="font/woff2"
       crossorigin="anonymous"
       crossorigin="anonymous"
     />
     />
     <link
     <link
       rel="preload"
       rel="preload"
-      href="Cascadia.woff2"
+      href="/Cascadia.woff2"
       as="font"
       as="font"
       type="font/woff2"
       type="font/woff2"
       crossorigin="anonymous"
       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>
     <script>
       {
       {
         const _WebSocket = window.WebSocket;
         const _WebSocket = window.WebSocket;
         window.WebSocket = function (url) {
         window.WebSocket = function (url) {
           if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
           if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
             console.info(
             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 {
           } else {
             return new _WebSocket(url);
             return new _WebSocket(url);
@@ -200,7 +193,8 @@
       <h1 class="visually-hidden">Excalidraw</h1>
       <h1 class="visually-hidden">Excalidraw</h1>
     </header>
     </header>
     <div id="root"></div>
     <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 -->
     <!-- 100% privacy friendly analytics -->
     <script>
     <script>
       // need to load this script dynamically bcs. of iframe embed tracking
       // need to load this script dynamically bcs. of iframe embed tracking

+ 24 - 41
package.json

@@ -32,6 +32,7 @@
     "canvas-roundrect-polyfill": "0.0.1",
     "canvas-roundrect-polyfill": "0.0.1",
     "clsx": "1.1.1",
     "clsx": "1.1.1",
     "cross-env": "7.0.3",
     "cross-env": "7.0.3",
+    "eslint-plugin-react": "7.32.2",
     "fake-indexeddb": "3.1.7",
     "fake-indexeddb": "3.1.7",
     "firebase": "8.3.3",
     "firebase": "8.3.3",
     "i18next-browser-languagedetector": "6.1.4",
     "i18next-browser-languagedetector": "6.1.4",
@@ -51,26 +52,13 @@
     "pwacompat": "2.0.17",
     "pwacompat": "2.0.17",
     "react": "18.2.0",
     "react": "18.2.0",
     "react-dom": "18.2.0",
     "react-dom": "18.2.0",
-    "react-scripts": "5.0.1",
     "roughjs": "4.5.2",
     "roughjs": "4.5.2",
     "sass": "1.51.0",
     "sass": "1.51.0",
     "socket.io-client": "4.6.1",
     "socket.io-client": "4.6.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": {
   "devDependencies": {
-    "@excalidraw/eslint-config": "1.0.0",
+    "@excalidraw/eslint-config": "1.0.3",
     "@excalidraw/prettier-config": "1.0.2",
     "@excalidraw/prettier-config": "1.0.2",
     "@types/chai": "4.3.0",
     "@types/chai": "4.3.0",
     "@types/jest": "27.4.0",
     "@types/jest": "27.4.0",
@@ -81,48 +69,42 @@
     "@types/react-dom": "18.0.6",
     "@types/react-dom": "18.0.6",
     "@types/resize-observer-browser": "0.1.7",
     "@types/resize-observer-browser": "0.1.7",
     "@types/socket.io-client": "1.4.36",
     "@types/socket.io-client": "1.4.36",
+    "@vitejs/plugin-react": "3.1.0",
+    "@vitest/ui": "0.32.2",
     "chai": "4.3.6",
     "chai": "4.3.6",
     "dotenv": "16.0.1",
     "dotenv": "16.0.1",
     "eslint-config-prettier": "8.5.0",
     "eslint-config-prettier": "8.5.0",
+    "eslint-config-react-app": "7.0.1",
     "eslint-plugin-prettier": "3.3.1",
     "eslint-plugin-prettier": "3.3.1",
     "http-server": "14.1.1",
     "http-server": "14.1.1",
     "husky": "7.0.4",
     "husky": "7.0.4",
-    "jest-canvas-mock": "2.4.0",
+    "jsdom": "22.1.0",
     "lint-staged": "12.3.7",
     "lint-staged": "12.3.7",
     "pepjs": "0.5.3",
     "pepjs": "0.5.3",
     "prettier": "2.6.2",
     "prettier": "2.6.2",
     "rewire": "6.0.0",
     "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.32.2",
+    "vitest-canvas-mock": "0.3.2"
   },
   },
   "engines": {
   "engines": {
-    "node": ">=14.0.0"
+    "node": ">=18.0.0"
   },
   },
   "homepage": ".",
   "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",
   "name": "excalidraw",
   "prettier": "@excalidraw/prettier-config",
   "prettier": "@excalidraw/prettier-config",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {
     "build-node": "node ./scripts/build-node.js",
     "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 REACT_APP_DISABLE_SENTRY=true REACT_APP_DISABLE_TRACKING=true vite build",
+    "build:app": "cross-env REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
     "build:version": "node ./scripts/build-version.js",
     "build:version": "node ./scripts/build-version.js",
     "build": "yarn build:app && yarn build:version",
     "build": "yarn build:app && yarn build:version",
-    "eject": "react-scripts eject",
     "fix:code": "yarn test:code --fix",
     "fix:code": "yarn test:code --fix",
     "fix:other": "yarn prettier --write",
     "fix:other": "yarn prettier --write",
     "fix": "yarn fix:other && yarn fix:code",
     "fix": "yarn fix:other && yarn fix:code",
@@ -130,19 +112,20 @@
     "locales-coverage:description": "node scripts/locales-coverage-description.js",
     "locales-coverage:description": "node scripts/locales-coverage-description.js",
     "prepare": "husky install",
     "prepare": "husky install",
     "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
     "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",
     "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:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
-    "test:app": "react-scripts test --passWithNoTests",
+    "test:app": "vitest --config vitest.config.ts",
     "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
     "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:other": "yarn prettier --list-different",
     "test:typecheck": "tsc",
     "test:typecheck": "tsc",
-    "test:update": "yarn test:app --updateSnapshot --watchAll=false",
+    "test:update": "yarn test:app --update --watch=false",
     "test": "yarn test:app",
     "test": "yarn test:app",
-    "test:coverage": "react-scripts test --passWithNoTests --coverage --watchAll",
+    "test:coverage": "vitest --coverage --watchAll",
+    "test:ui": "yarn test --ui",
     "autorelease": "node scripts/autorelease.js",
     "autorelease": "node scripts/autorelease.js",
     "prerelease": "node scripts/prerelease.js",
     "prerelease": "node scripts/prerelease.js",
+    "build:preview": "yarn build && vite preview --port 5000",
     "release": "node scripts/release.js"
     "release": "node scripts/release.js"
   }
   }
 }
 }

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 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

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
public/workbox/workbox-core.prod.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 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

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 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

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
public/workbox/workbox-routing.prod.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 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

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
public/workbox/workbox-window.prod.es5.mjs


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
public/workbox/workbox-window.prod.mjs


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
public/workbox/workbox-window.prod.umd.js


+ 17 - 18
src/actions/actionAddToLibrary.ts

@@ -1,30 +1,29 @@
 import { register } from "./register";
 import { register } from "./register";
-import { getSelectedElements } from "../scene";
-import { getNonDeletedElements } from "../element";
 import { deepCopyElement } from "../element/newElement";
 import { deepCopyElement } from "../element/newElement";
 import { randomId } from "../random";
 import { randomId } from "../random";
 import { t } from "../i18n";
 import { t } from "../i18n";
+import { LIBRARY_DISABLED_TYPES } from "../constants";
 
 
 export const actionAddToLibrary = register({
 export const actionAddToLibrary = register({
   name: "addToLibrary",
   name: "addToLibrary",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
   perform: (elements, appState, _, app) => {
   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
     return app.library

+ 32 - 34
src/actions/actionAlign.tsx

@@ -13,19 +13,18 @@ import { ExcalidrawElement } from "../element/types";
 import { updateFrameMembershipOfSelectedElements } from "../frame";
 import { updateFrameMembershipOfSelectedElements } from "../frame";
 import { t } from "../i18n";
 import { t } from "../i18n";
 import { KEYS } from "../keys";
 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 { arrayToMap, getShortcutKey } from "../utils";
 import { register } from "./register";
 import { register } from "./register";
 
 
 const alignActionsPredicate = (
 const alignActionsPredicate = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
   appState: AppState,
   appState: AppState,
+  _: unknown,
+  app: AppClassProperties,
 ) => {
 ) => {
-  const selectedElements = getSelectedElements(
-    getNonDeletedElements(elements),
-    appState,
-  );
+  const selectedElements = app.scene.getSelectedElements(appState);
   return (
   return (
     selectedElements.length > 1 &&
     selectedElements.length > 1 &&
     // TODO enable aligning frames when implemented properly
     // TODO enable aligning frames when implemented properly
@@ -36,12 +35,10 @@ const alignActionsPredicate = (
 const alignSelectedElements = (
 const alignSelectedElements = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
   appState: Readonly<AppState>,
   appState: Readonly<AppState>,
+  app: AppClassProperties,
   alignment: Alignment,
   alignment: Alignment,
 ) => {
 ) => {
-  const selectedElements = getSelectedElements(
-    getNonDeletedElements(elements),
-    appState,
-  );
+  const selectedElements = app.scene.getSelectedElements(appState);
 
 
   const updatedElements = alignElements(selectedElements, alignment);
   const updatedElements = alignElements(selectedElements, alignment);
 
 
@@ -50,6 +47,7 @@ const alignSelectedElements = (
   return updateFrameMembershipOfSelectedElements(
   return updateFrameMembershipOfSelectedElements(
     elements.map((element) => updatedElementsMap.get(element.id) || element),
     elements.map((element) => updatedElementsMap.get(element.id) || element),
     appState,
     appState,
+    app,
   );
   );
 };
 };
 
 
@@ -57,10 +55,10 @@ export const actionAlignTop = register({
   name: "alignTop",
   name: "alignTop",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
   predicate: alignActionsPredicate,
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
     return {
       appState,
       appState,
-      elements: alignSelectedElements(elements, appState, {
+      elements: alignSelectedElements(elements, appState, app, {
         position: "start",
         position: "start",
         axis: "y",
         axis: "y",
       }),
       }),
@@ -69,9 +67,9 @@ export const actionAlignTop = register({
   },
   },
   keyTest: (event) =>
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
     <ToolButton
-      hidden={!alignActionsPredicate(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState, null, app)}
       type="button"
       type="button"
       icon={AlignTopIcon}
       icon={AlignTopIcon}
       onClick={() => updateData(null)}
       onClick={() => updateData(null)}
@@ -88,10 +86,10 @@ export const actionAlignBottom = register({
   name: "alignBottom",
   name: "alignBottom",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
   predicate: alignActionsPredicate,
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
     return {
       appState,
       appState,
-      elements: alignSelectedElements(elements, appState, {
+      elements: alignSelectedElements(elements, appState, app, {
         position: "end",
         position: "end",
         axis: "y",
         axis: "y",
       }),
       }),
@@ -100,9 +98,9 @@ export const actionAlignBottom = register({
   },
   },
   keyTest: (event) =>
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
     <ToolButton
-      hidden={!alignActionsPredicate(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState, null, app)}
       type="button"
       type="button"
       icon={AlignBottomIcon}
       icon={AlignBottomIcon}
       onClick={() => updateData(null)}
       onClick={() => updateData(null)}
@@ -119,10 +117,10 @@ export const actionAlignLeft = register({
   name: "alignLeft",
   name: "alignLeft",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
   predicate: alignActionsPredicate,
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
     return {
       appState,
       appState,
-      elements: alignSelectedElements(elements, appState, {
+      elements: alignSelectedElements(elements, appState, app, {
         position: "start",
         position: "start",
         axis: "x",
         axis: "x",
       }),
       }),
@@ -131,9 +129,9 @@ export const actionAlignLeft = register({
   },
   },
   keyTest: (event) =>
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
     <ToolButton
-      hidden={!alignActionsPredicate(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState, null, app)}
       type="button"
       type="button"
       icon={AlignLeftIcon}
       icon={AlignLeftIcon}
       onClick={() => updateData(null)}
       onClick={() => updateData(null)}
@@ -150,10 +148,10 @@ export const actionAlignRight = register({
   name: "alignRight",
   name: "alignRight",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
   predicate: alignActionsPredicate,
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
     return {
       appState,
       appState,
-      elements: alignSelectedElements(elements, appState, {
+      elements: alignSelectedElements(elements, appState, app, {
         position: "end",
         position: "end",
         axis: "x",
         axis: "x",
       }),
       }),
@@ -162,9 +160,9 @@ export const actionAlignRight = register({
   },
   },
   keyTest: (event) =>
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
     <ToolButton
-      hidden={!alignActionsPredicate(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState, null, app)}
       type="button"
       type="button"
       icon={AlignRightIcon}
       icon={AlignRightIcon}
       onClick={() => updateData(null)}
       onClick={() => updateData(null)}
@@ -181,19 +179,19 @@ export const actionAlignVerticallyCentered = register({
   name: "alignVerticallyCentered",
   name: "alignVerticallyCentered",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
   predicate: alignActionsPredicate,
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
     return {
       appState,
       appState,
-      elements: alignSelectedElements(elements, appState, {
+      elements: alignSelectedElements(elements, appState, app, {
         position: "center",
         position: "center",
         axis: "y",
         axis: "y",
       }),
       }),
       commitToHistory: true,
       commitToHistory: true,
     };
     };
   },
   },
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
     <ToolButton
-      hidden={!alignActionsPredicate(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState, null, app)}
       type="button"
       type="button"
       icon={CenterVerticallyIcon}
       icon={CenterVerticallyIcon}
       onClick={() => updateData(null)}
       onClick={() => updateData(null)}
@@ -208,19 +206,19 @@ export const actionAlignHorizontallyCentered = register({
   name: "alignHorizontallyCentered",
   name: "alignHorizontallyCentered",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
   predicate: alignActionsPredicate,
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
     return {
       appState,
       appState,
-      elements: alignSelectedElements(elements, appState, {
+      elements: alignSelectedElements(elements, appState, app, {
         position: "center",
         position: "center",
         axis: "x",
         axis: "x",
       }),
       }),
       commitToHistory: true,
       commitToHistory: true,
     };
     };
   },
   },
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
     <ToolButton
-      hidden={!alignActionsPredicate(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState, null, app)}
       type="button"
       type="button"
       icon={CenterHorizontallyIcon}
       icon={CenterHorizontallyIcon}
       onClick={() => updateData(null)}
       onClick={() => updateData(null)}

+ 13 - 23
src/actions/actionBoundText.tsx

@@ -4,7 +4,7 @@ import {
   VERTICAL_ALIGN,
   VERTICAL_ALIGN,
   TEXT_ALIGN,
   TEXT_ALIGN,
 } from "../constants";
 } from "../constants";
-import { getNonDeletedElements, isTextElement, newElement } from "../element";
+import { isTextElement, newElement } from "../element";
 import { mutateElement } from "../element/mutateElement";
 import { mutateElement } from "../element/mutateElement";
 import {
 import {
   computeBoundTextPosition,
   computeBoundTextPosition,
@@ -29,7 +29,6 @@ import {
   ExcalidrawTextContainer,
   ExcalidrawTextContainer,
   ExcalidrawTextElement,
   ExcalidrawTextElement,
 } from "../element/types";
 } from "../element/types";
-import { getSelectedElements } from "../scene";
 import { AppState } from "../types";
 import { AppState } from "../types";
 import { Mutable } from "../utility-types";
 import { Mutable } from "../utility-types";
 import { getFontString } from "../utils";
 import { getFontString } from "../utils";
@@ -39,16 +38,13 @@ export const actionUnbindText = register({
   name: "unbindText",
   name: "unbindText",
   contextItemLabel: "labels.unbindText",
   contextItemLabel: "labels.unbindText",
   trackEvent: { category: "element" },
   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));
     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) => {
     selectedElements.forEach((element) => {
       const boundTextElement = getBoundTextElement(element);
       const boundTextElement = getBoundTextElement(element);
       if (boundTextElement) {
       if (boundTextElement) {
@@ -93,8 +89,8 @@ export const actionBindText = register({
   name: "bindText",
   name: "bindText",
   contextItemLabel: "labels.bindText",
   contextItemLabel: "labels.bindText",
   trackEvent: { category: "element" },
   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) {
     if (selectedElements.length === 2) {
       const textElement =
       const textElement =
@@ -117,11 +113,8 @@ export const actionBindText = register({
     }
     }
     return false;
     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 textElement: ExcalidrawTextElement;
     let container: ExcalidrawTextContainer;
     let container: ExcalidrawTextContainer;
@@ -201,16 +194,13 @@ export const actionWrapTextInContainer = register({
   name: "wrapTextInContainer",
   name: "wrapTextInContainer",
   contextItemLabel: "labels.createContainerFromText",
   contextItemLabel: "labels.createContainerFromText",
   trackEvent: { category: "element" },
   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));
     const areTextElements = selectedElements.every((el) => isTextElement(el));
     return selectedElements.length > 0 && areTextElements;
     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();
     let updatedElements: readonly ExcalidrawElement[] = elements.slice();
     const containerIds: Mutable<AppState["selectedElementIds"]> = {};
     const containerIds: Mutable<AppState["selectedElementIds"]> = {};
 
 

+ 7 - 11
src/actions/actionCanvas.tsx

@@ -6,7 +6,7 @@ import { getCommonBounds, getNonDeletedElements } from "../element";
 import { ExcalidrawElement } from "../element/types";
 import { ExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
 import { t } from "../i18n";
 import { CODES, KEYS } from "../keys";
 import { CODES, KEYS } from "../keys";
-import { getNormalizedZoom, getSelectedElements } from "../scene";
+import { getNormalizedZoom } from "../scene";
 import { centerScrollOn } from "../scene/scroll";
 import { centerScrollOn } from "../scene/scroll";
 import { getStateForZoom } from "../scene/zoom";
 import { getStateForZoom } from "../scene/zoom";
 import { AppState, NormalizedZoomValue } from "../types";
 import { AppState, NormalizedZoomValue } from "../types";
@@ -302,11 +302,8 @@ export const zoomToFit = ({
 export const actionZoomToFitSelectionInViewport = register({
 export const actionZoomToFitSelectionInViewport = register({
   name: "zoomToFitSelectionInViewport",
   name: "zoomToFitSelectionInViewport",
   trackEvent: { category: "canvas" },
   trackEvent: { category: "canvas" },
-  perform: (elements, appState) => {
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-    );
+  perform: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
     return zoomToFit({
     return zoomToFit({
       targetElements: selectedElements.length ? selectedElements : elements,
       targetElements: selectedElements.length ? selectedElements : elements,
       appState,
       appState,
@@ -325,11 +322,8 @@ export const actionZoomToFitSelectionInViewport = register({
 export const actionZoomToFitSelection = register({
 export const actionZoomToFitSelection = register({
   name: "zoomToFitSelection",
   name: "zoomToFitSelection",
   trackEvent: { category: "canvas" },
   trackEvent: { category: "canvas" },
-  perform: (elements, appState) => {
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-    );
+  perform: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
     return zoomToFit({
     return zoomToFit({
       targetElements: selectedElements.length ? selectedElements : elements,
       targetElements: selectedElements.length ? selectedElements : elements,
       appState,
       appState,
@@ -402,6 +396,7 @@ export const actionToggleEraserTool = register({
         ...appState,
         ...appState,
         selectedElementIds: {},
         selectedElementIds: {},
         selectedGroupIds: {},
         selectedGroupIds: {},
+        activeEmbeddable: null,
         activeTool,
         activeTool,
       },
       },
       commitToHistory: true,
       commitToHistory: true,
@@ -436,6 +431,7 @@ export const actionToggleHandTool = register({
         ...appState,
         ...appState,
         selectedElementIds: {},
         selectedElementIds: {},
         selectedGroupIds: {},
         selectedGroupIds: {},
+        activeEmbeddable: null,
         activeTool,
         activeTool,
       },
       },
       commitToHistory: true,
       commitToHistory: true,

+ 24 - 30
src/actions/actionClipboard.tsx

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

+ 1 - 0
src/actions/actionDeleteSelected.tsx

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

+ 15 - 22
src/actions/actionDistribute.tsx

@@ -9,19 +9,13 @@ import { ExcalidrawElement } from "../element/types";
 import { updateFrameMembershipOfSelectedElements } from "../frame";
 import { updateFrameMembershipOfSelectedElements } from "../frame";
 import { t } from "../i18n";
 import { t } from "../i18n";
 import { CODES, KEYS } from "../keys";
 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 { arrayToMap, getShortcutKey } from "../utils";
 import { register } from "./register";
 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 (
   return (
     selectedElements.length > 1 &&
     selectedElements.length > 1 &&
     // TODO enable distributing frames when implemented properly
     // TODO enable distributing frames when implemented properly
@@ -32,12 +26,10 @@ const enableActionGroup = (
 const distributeSelectedElements = (
 const distributeSelectedElements = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
   appState: Readonly<AppState>,
   appState: Readonly<AppState>,
+  app: AppClassProperties,
   distribution: Distribution,
   distribution: Distribution,
 ) => {
 ) => {
-  const selectedElements = getSelectedElements(
-    getNonDeletedElements(elements),
-    appState,
-  );
+  const selectedElements = app.scene.getSelectedElements(appState);
 
 
   const updatedElements = distributeElements(selectedElements, distribution);
   const updatedElements = distributeElements(selectedElements, distribution);
 
 
@@ -46,16 +38,17 @@ const distributeSelectedElements = (
   return updateFrameMembershipOfSelectedElements(
   return updateFrameMembershipOfSelectedElements(
     elements.map((element) => updatedElementsMap.get(element.id) || element),
     elements.map((element) => updatedElementsMap.get(element.id) || element),
     appState,
     appState,
+    app,
   );
   );
 };
 };
 
 
 export const distributeHorizontally = register({
 export const distributeHorizontally = register({
   name: "distributeHorizontally",
   name: "distributeHorizontally",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
     return {
       appState,
       appState,
-      elements: distributeSelectedElements(elements, appState, {
+      elements: distributeSelectedElements(elements, appState, app, {
         space: "between",
         space: "between",
         axis: "x",
         axis: "x",
       }),
       }),
@@ -64,9 +57,9 @@ export const distributeHorizontally = register({
   },
   },
   keyTest: (event) =>
   keyTest: (event) =>
     !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
     !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
     <ToolButton
-      hidden={!enableActionGroup(elements, appState)}
+      hidden={!enableActionGroup(appState, app)}
       type="button"
       type="button"
       icon={DistributeHorizontallyIcon}
       icon={DistributeHorizontallyIcon}
       onClick={() => updateData(null)}
       onClick={() => updateData(null)}
@@ -82,10 +75,10 @@ export const distributeHorizontally = register({
 export const distributeVertically = register({
 export const distributeVertically = register({
   name: "distributeVertically",
   name: "distributeVertically",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
     return {
       appState,
       appState,
-      elements: distributeSelectedElements(elements, appState, {
+      elements: distributeSelectedElements(elements, appState, app, {
         space: "between",
         space: "between",
         axis: "y",
         axis: "y",
       }),
       }),
@@ -94,9 +87,9 @@ export const distributeVertically = register({
   },
   },
   keyTest: (event) =>
   keyTest: (event) =>
     !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
     !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
     <ToolButton
-      hidden={!enableActionGroup(elements, appState)}
+      hidden={!enableActionGroup(appState, app)}
       type="button"
       type="button"
       icon={DistributeVerticallyIcon}
       icon={DistributeVerticallyIcon}
       onClick={() => updateData(null)}
       onClick={() => updateData(null)}

+ 1 - 0
src/actions/actionDuplicateSelection.tsx

@@ -275,6 +275,7 @@ const duplicateElements = (
       },
       },
       getNonDeletedElements(finalElements),
       getNonDeletedElements(finalElements),
       appState,
       appState,
+      null,
     ),
     ),
   };
   };
 };
 };

+ 11 - 9
src/actions/actionElementLock.ts

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

+ 5 - 5
src/actions/actionExport.tsx

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

+ 1 - 0
src/actions/actionFinalize.tsx

@@ -160,6 +160,7 @@ export const actionFinalize = register({
           multiPointElement
           multiPointElement
             ? appState.activeTool
             ? appState.activeTool
             : activeTool,
             : activeTool,
+        activeEmbeddable: null,
         draggingElement: null,
         draggingElement: null,
         multiElement: null,
         multiElement: null,
         editingElement: null,
         editingElement: null,

+ 4 - 2
src/actions/actionFlip.ts

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

+ 11 - 22
src/actions/actionFrame.ts

@@ -3,19 +3,12 @@ import { ExcalidrawElement } from "../element/types";
 import { removeAllElementsFromFrame } from "../frame";
 import { removeAllElementsFromFrame } from "../frame";
 import { getFrameElements } from "../frame";
 import { getFrameElements } from "../frame";
 import { KEYS } from "../keys";
 import { KEYS } from "../keys";
-import { getSelectedElements } from "../scene";
-import { AppState } from "../types";
+import { AppClassProperties, AppState } from "../types";
 import { setCursorForShape, updateActiveTool } from "../utils";
 import { setCursorForShape, updateActiveTool } from "../utils";
 import { register } from "./register";
 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";
   return selectedElements.length === 1 && selectedElements[0].type === "frame";
 };
 };
@@ -23,11 +16,8 @@ const isSingleFrameSelected = (
 export const actionSelectAllElementsInFrame = register({
 export const actionSelectAllElementsInFrame = register({
   name: "selectAllElementsInFrame",
   name: "selectAllElementsInFrame",
   trackEvent: { category: "canvas" },
   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") {
     if (selectedFrame && selectedFrame.type === "frame") {
       const elementsInFrame = getFrameElements(
       const elementsInFrame = getFrameElements(
@@ -55,17 +45,15 @@ export const actionSelectAllElementsInFrame = register({
     };
     };
   },
   },
   contextItemLabel: "labels.selectAllElementsInFrame",
   contextItemLabel: "labels.selectAllElementsInFrame",
-  predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
+  predicate: (elements, appState, _, app) =>
+    isSingleFrameSelected(appState, app),
 });
 });
 
 
 export const actionRemoveAllElementsFromFrame = register({
 export const actionRemoveAllElementsFromFrame = register({
   name: "removeAllElementsFromFrame",
   name: "removeAllElementsFromFrame",
   trackEvent: { category: "history" },
   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") {
     if (selectedFrame && selectedFrame.type === "frame") {
       return {
       return {
@@ -87,7 +75,8 @@ export const actionRemoveAllElementsFromFrame = register({
     };
     };
   },
   },
   contextItemLabel: "labels.removeAllElementsFromFrame",
   contextItemLabel: "labels.removeAllElementsFromFrame",
-  predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
+  predicate: (elements, appState, _, app) =>
+    isSingleFrameSelected(appState, app),
 });
 });
 
 
 export const actionupdateFrameRendering = register({
 export const actionupdateFrameRendering = register({

+ 17 - 20
src/actions/actionGroup.tsx

@@ -4,7 +4,7 @@ import { arrayToMap, getShortcutKey } from "../utils";
 import { register } from "./register";
 import { register } from "./register";
 import { UngroupIcon, GroupIcon } from "../components/icons";
 import { UngroupIcon, GroupIcon } from "../components/icons";
 import { newElementWith } from "../element/mutateElement";
 import { newElementWith } from "../element/mutateElement";
-import { getSelectedElements, isSomeElementSelected } from "../scene";
+import { isSomeElementSelected } from "../scene";
 import {
 import {
   getSelectedGroupIds,
   getSelectedGroupIds,
   selectGroup,
   selectGroup,
@@ -22,7 +22,7 @@ import {
   ExcalidrawFrameElement,
   ExcalidrawFrameElement,
   ExcalidrawTextElement,
   ExcalidrawTextElement,
 } from "../element/types";
 } from "../element/types";
-import { AppState } from "../types";
+import { AppClassProperties, AppState } from "../types";
 import { isBoundToContainer } from "../element/typeChecks";
 import { isBoundToContainer } from "../element/typeChecks";
 import {
 import {
   getElementsInResizingFrame,
   getElementsInResizingFrame,
@@ -51,14 +51,12 @@ const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
 const enableActionGroup = (
 const enableActionGroup = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
   appState: AppState,
   appState: AppState,
+  app: AppClassProperties,
 ) => {
 ) => {
-  const selectedElements = getSelectedElements(
-    getNonDeletedElements(elements),
-    appState,
-    {
-      includeBoundTextElement: true,
-    },
-  );
+  const selectedElements = app.scene.getSelectedElements({
+    selectedElementIds: appState.selectedElementIds,
+    includeBoundTextElement: true,
+  });
   return (
   return (
     selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
     selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
   );
   );
@@ -68,13 +66,10 @@ export const actionGroup = register({
   name: "group",
   name: "group",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
   perform: (elements, appState, _, app) => {
   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) {
     if (selectedElements.length < 2) {
       // nothing to group
       // nothing to group
       return { appState, elements, commitToHistory: false };
       return { appState, elements, commitToHistory: false };
@@ -164,12 +159,13 @@ export const actionGroup = register({
     };
     };
   },
   },
   contextItemLabel: "labels.group",
   contextItemLabel: "labels.group",
-  predicate: (elements, appState) => enableActionGroup(elements, appState),
+  predicate: (elements, appState, _, app) =>
+    enableActionGroup(elements, appState, app),
   keyTest: (event) =>
   keyTest: (event) =>
     !event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
     !event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
     <ToolButton
-      hidden={!enableActionGroup(elements, appState)}
+      hidden={!enableActionGroup(elements, appState, app)}
       type="button"
       type="button"
       icon={<GroupIcon theme={appState.theme} />}
       icon={<GroupIcon theme={appState.theme} />}
       onClick={() => updateData(null)}
       onClick={() => updateData(null)}
@@ -191,7 +187,7 @@ export const actionUngroup = register({
 
 
     let nextElements = [...elements];
     let nextElements = [...elements];
 
 
-    const selectedElements = getSelectedElements(nextElements, appState);
+    const selectedElements = app.scene.getSelectedElements(appState);
     const frames = selectedElements
     const frames = selectedElements
       .filter((element) => element.frameId)
       .filter((element) => element.frameId)
       .map((element) =>
       .map((element) =>
@@ -219,6 +215,7 @@ export const actionUngroup = register({
       { ...appState, selectedGroupIds: {} },
       { ...appState, selectedGroupIds: {} },
       getNonDeletedElements(nextElements),
       getNonDeletedElements(nextElements),
       appState,
       appState,
+      null,
     );
     );
 
 
     frames.forEach((frame) => {
     frames.forEach((frame) => {

+ 11 - 19
src/actions/actionLinearEditor.ts

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

+ 1 - 0
src/actions/actionSelectAll.ts

@@ -42,6 +42,7 @@ export const actionSelectAll = register({
         },
         },
         getNonDeletedElements(elements),
         getNonDeletedElements(elements),
         appState,
         appState,
+        app,
       ),
       ),
       commitToHistory: true,
       commitToHistory: true,
     };
     };

+ 2 - 0
src/actions/manager.tsx

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

+ 4 - 0
src/actions/types.ts

@@ -121,6 +121,7 @@ export type ActionName =
   | "removeAllElementsFromFrame"
   | "removeAllElementsFromFrame"
   | "updateFrameRendering"
   | "updateFrameRendering"
   | "setFrameAsActiveTool"
   | "setFrameAsActiveTool"
+  | "setEmbeddableAsActiveTool"
   | "createContainerFromText"
   | "createContainerFromText"
   | "wrapTextInContainer";
   | "wrapTextInContainer";
 
 
@@ -130,6 +131,7 @@ export type PanelComponentProps = {
   updateData: (formData?: any) => void;
   updateData: (formData?: any) => void;
   appProps: ExcalidrawProps;
   appProps: ExcalidrawProps;
   data?: Record<string, any>;
   data?: Record<string, any>;
+  app: AppClassProperties;
 };
 };
 
 
 export interface Action {
 export interface Action {
@@ -141,12 +143,14 @@ export interface Action {
     event: React.KeyboardEvent | KeyboardEvent,
     event: React.KeyboardEvent | KeyboardEvent,
     appState: AppState,
     appState: AppState,
     elements: readonly ExcalidrawElement[],
     elements: readonly ExcalidrawElement[],
+    app: AppClassProperties,
   ) => boolean;
   ) => boolean;
   contextItemLabel?:
   contextItemLabel?:
     | string
     | string
     | ((
     | ((
         elements: readonly ExcalidrawElement[],
         elements: readonly ExcalidrawElement[],
         appState: Readonly<AppState>,
         appState: Readonly<AppState>,
+        app: AppClassProperties,
       ) => string);
       ) => string);
   predicate?: (
   predicate?: (
     elements: readonly ExcalidrawElement[],
     elements: readonly ExcalidrawElement[],

+ 1 - 1
src/analytics.ts

@@ -11,7 +11,7 @@ export const trackEvent = (
     // Uncomment the next line to track locally
     // Uncomment the next line to track locally
     // console.log("Track Event", { category, action, label, value });
     // 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;
       return;
     }
     }
 
 

+ 2 - 0
src/appState.ts

@@ -38,6 +38,7 @@ export const getDefaultAppState = (): Omit<
     currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
     currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
     currentItemTextAlign: DEFAULT_TEXT_ALIGN,
     currentItemTextAlign: DEFAULT_TEXT_ALIGN,
     cursorButton: "up",
     cursorButton: "up",
+    activeEmbeddable: null,
     draggingElement: null,
     draggingElement: null,
     editingElement: null,
     editingElement: null,
     editingGroupId: null,
     editingGroupId: null,
@@ -139,6 +140,7 @@ const APP_STATE_STORAGE_CONF = (<
   currentItemStrokeWidth: { browser: true, export: false, server: false },
   currentItemStrokeWidth: { browser: true, export: false, server: false },
   currentItemTextAlign: { browser: true, export: false, server: false },
   currentItemTextAlign: { browser: true, export: false, server: false },
   cursorButton: { 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 },
   draggingElement: { browser: false, export: false, server: false },
   editingElement: { browser: false, export: false, server: false },
   editingElement: { browser: false, export: false, server: false },
   editingGroupId: { browser: true, export: false, server: false },
   editingGroupId: { browser: true, export: false, server: false },

+ 2 - 3
src/charts.ts

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

+ 1 - 1
src/colors.ts

@@ -21,7 +21,7 @@ export type ColorPickerColor =
 export type ColorTuple = readonly [string, string, string, string, string];
 export type ColorTuple = readonly [string, string, string, string, string];
 export type ColorPalette = Merge<
 export type ColorPalette = Merge<
   Record<ColorPickerColor, ColorTuple>,
   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
 // used general type instead of specific type (ColorPalette) to support custom colors

+ 82 - 32
src/components/Actions.tsx

@@ -36,7 +36,7 @@ import {
 
 
 import "./Actions.scss";
 import "./Actions.scss";
 import DropdownMenu from "./dropdownMenu/DropdownMenu";
 import DropdownMenu from "./dropdownMenu/DropdownMenu";
-import { extraToolsIcon, frameToolIcon } from "./icons";
+import { EmbedIcon, extraToolsIcon, frameToolIcon } from "./icons";
 import { KEYS } from "../keys";
 import { KEYS } from "../keys";
 
 
 export const SelectedShapeActions = ({
 export const SelectedShapeActions = ({
@@ -266,6 +266,7 @@ export const ShapesSwitcher = ({
               });
               });
               setAppState({
               setAppState({
                 activeTool: nextActiveTool,
                 activeTool: nextActiveTool,
+                activeEmbeddable: null,
                 multiElement: null,
                 multiElement: null,
                 selectedElementIds: {},
                 selectedElementIds: {},
               });
               });
@@ -283,39 +284,72 @@ export const ShapesSwitcher = ({
       <div className="App-toolbar__divider" />
       <div className="App-toolbar__divider" />
       {/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
       {/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
       {device.isMobile ? (
       {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({
               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 open={isExtraToolsMenuOpen}>
           <DropdownMenu.Trigger
           <DropdownMenu.Trigger
@@ -347,6 +381,22 @@ export const ShapesSwitcher = ({
             >
             >
               {t("toolBar.frame")}
               {t("toolBar.frame")}
             </DropdownMenu.Item>
             </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.Content>
         </DropdownMenu>
         </DropdownMenu>
       )}
       )}

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

@@ -4,8 +4,9 @@ import { reseed } from "../random";
 import { render, queryByTestId } from "../tests/test-utils";
 import { render, queryByTestId } from "../tests/test-utils";
 
 
 import ExcalidrawApp from "../excalidraw-app";
 import ExcalidrawApp from "../excalidraw-app";
+import { vi } from "vitest";
 
 
-const renderScene = jest.spyOn(Renderer, "renderScene");
+const renderScene = vi.spyOn(Renderer, "renderScene");
 
 
 describe("Test <App/>", () => {
 describe("Test <App/>", () => {
   beforeEach(async () => {
   beforeEach(async () => {

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 513 - 77
src/components/App.tsx


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

@@ -8,7 +8,7 @@ import {
 } from "./colorPickerUtils";
 } from "./colorPickerUtils";
 import HotkeyLabel from "./HotkeyLabel";
 import HotkeyLabel from "./HotkeyLabel";
 import { ColorPaletteCustom } from "../../colors";
 import { ColorPaletteCustom } from "../../colors";
-import { t } from "../../i18n";
+import { TranslationKeys, t } from "../../i18n";
 
 
 interface PickerColorListProps {
 interface PickerColorListProps {
   palette: ColorPaletteCustom;
   palette: ColorPaletteCustom;
@@ -48,7 +48,11 @@ const PickerColorList = ({
           (Array.isArray(value) ? value[activeShade] : value) || "transparent";
           (Array.isArray(value) ? value[activeShade] : value) || "transparent";
 
 
         const keybinding = colorPickerHotkeyBindings[index];
         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 (
         return (
           <button
           <button

+ 9 - 3
src/components/ContextMenu.tsx

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

+ 1 - 1
src/components/EyeDropper.tsx

@@ -58,7 +58,7 @@ export const EyeDropper: React.FC<{
       return;
       return;
     }
     }
 
 
-    let currentColor = COLOR_PALETTE.black;
+    let currentColor: string = COLOR_PALETTE.black;
     let isHoldingPointerDown = false;
     let isHoldingPointerDown = false;
 
 
     const ctx = app.canvas.getContext("2d")!;
     const ctx = app.canvas.getContext("2d")!;

+ 10 - 13
src/components/HintViewer.tsx

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

+ 4 - 1
src/components/LayerUI.tsx

@@ -72,6 +72,7 @@ interface LayerUIProps {
   onExportImage: AppClassProperties["onExportImage"];
   onExportImage: AppClassProperties["onExportImage"];
   renderWelcomeScreen: boolean;
   renderWelcomeScreen: boolean;
   children?: React.ReactNode;
   children?: React.ReactNode;
+  app: AppClassProperties;
 }
 }
 
 
 const DefaultMainMenu: React.FC<{
 const DefaultMainMenu: React.FC<{
@@ -127,6 +128,7 @@ const LayerUI = ({
   onExportImage,
   onExportImage,
   renderWelcomeScreen,
   renderWelcomeScreen,
   children,
   children,
+  app,
 }: LayerUIProps) => {
 }: LayerUIProps) => {
   const device = useDevice();
   const device = useDevice();
   const tunnels = useInitializeTunnels();
   const tunnels = useInitializeTunnels();
@@ -240,9 +242,9 @@ const LayerUI = ({
                       >
                       >
                         <HintViewer
                         <HintViewer
                           appState={appState}
                           appState={appState}
-                          elements={elements}
                           isMobile={device.isMobile}
                           isMobile={device.isMobile}
                           device={device}
                           device={device}
+                          app={app}
                         />
                         />
                         {heading}
                         {heading}
                         <Stack.Row gap={1}>
                         <Stack.Row gap={1}>
@@ -401,6 +403,7 @@ const LayerUI = ({
       )}
       )}
       {device.isMobile && (
       {device.isMobile && (
         <MobileMenu
         <MobileMenu
+          app={app}
           appState={appState}
           appState={appState}
           elements={elements}
           elements={elements}
           actionManager={actionManager}
           actionManager={actionManager}

+ 8 - 5
src/components/LibraryMenu.tsx

@@ -29,6 +29,7 @@ import "./LibraryMenu.scss";
 import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
 import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
 import { isShallowEqual } from "../utils";
 import { isShallowEqual } from "../utils";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { NonDeletedExcalidrawElement } from "../element/types";
+import { LIBRARY_DISABLED_TYPES } from "../constants";
 
 
 export const isLibraryMenuOpenAtom = atom(false);
 export const isLibraryMenuOpenAtom = atom(false);
 
 
@@ -68,11 +69,12 @@ export const LibraryMenuContent = ({
         libraryItems: LibraryItems,
         libraryItems: LibraryItems,
       ) => {
       ) => {
         trackEvent("element", "addToLibrary", "ui");
         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 = [
         const nextItems: LibraryItems = [
           {
           {
@@ -197,6 +199,7 @@ export const LibraryMenu = () => {
     setAppState({
     setAppState({
       selectedElementIds: {},
       selectedElementIds: {},
       selectedGroupIds: {},
       selectedGroupIds: {},
+      activeEmbeddable: null,
     });
     });
   }, [setAppState]);
   }, [setAppState]);
 
 

+ 1 - 1
src/components/LibraryMenuBrowseButton.tsx

@@ -16,7 +16,7 @@ const LibraryMenuBrowseButton = ({
   return (
   return (
     <a
     <a
       className="library-menu-browse-button"
       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"
         window.name || "_blank"
       }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
       }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
         VERSIONS.excalidrawLibrary
         VERSIONS.excalidrawLibrary

+ 5 - 0
src/components/LibraryUnit.scss

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

+ 10 - 2
src/components/MobileMenu.tsx

@@ -1,5 +1,11 @@
 import React from "react";
 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 { ActionManager } from "../actions/manager";
 import { t } from "../i18n";
 import { t } from "../i18n";
 import Stack from "./Stack";
 import Stack from "./Stack";
@@ -41,6 +47,7 @@ type MobileMenuProps = {
   renderSidebars: () => JSX.Element | null;
   renderSidebars: () => JSX.Element | null;
   device: Device;
   device: Device;
   renderWelcomeScreen: boolean;
   renderWelcomeScreen: boolean;
+  app: AppClassProperties;
 };
 };
 
 
 export const MobileMenu = ({
 export const MobileMenu = ({
@@ -58,6 +65,7 @@ export const MobileMenu = ({
   renderSidebars,
   renderSidebars,
   device,
   device,
   renderWelcomeScreen,
   renderWelcomeScreen,
+  app,
 }: MobileMenuProps) => {
 }: MobileMenuProps) => {
   const {
   const {
     WelcomeScreenCenterTunnel,
     WelcomeScreenCenterTunnel,
@@ -119,9 +127,9 @@ export const MobileMenu = ({
         </Section>
         </Section>
         <HintViewer
         <HintViewer
           appState={appState}
           appState={appState}
-          elements={elements}
           isMobile={true}
           isMobile={true}
           device={device}
           device={device}
+          app={app}
         />
         />
       </FixedSideContainer>
       </FixedSideContainer>
     );
     );

+ 1 - 1
src/components/PublishLibrary.tsx

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

+ 1 - 1
src/components/Section.tsx

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

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

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

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

@@ -53,7 +53,7 @@ export const SidebarInner = forwardRef(
     }: SidebarProps & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">,
     }: SidebarProps & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">,
     ref: React.ForwardedRef<HTMLDivElement>,
     ref: React.ForwardedRef<HTMLDivElement>,
   ) => {
   ) => {
-    if (process.env.NODE_ENV === "development" && onDock && docked == null) {
+    if (import.meta.env.DEV && onDock && docked == null) {
       console.warn(
       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`",
         "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 fallbackLangData from "../locales/en.json";
 
 
 import Trans from "./Trans";
 import Trans from "./Trans";
+import { TranslationKeys } from "../i18n";
 
 
 describe("Test <Trans/>", () => {
 describe("Test <Trans/>", () => {
   it("should translate the the strings correctly", () => {
   it("should translate the the strings correctly", () => {
@@ -18,24 +19,27 @@ describe("Test <Trans/>", () => {
     const { getByTestId } = render(
     const { getByTestId } = render(
       <>
       <>
         <div data-testid="test1">
         <div data-testid="test1">
-          <Trans i18nKey="transTest.key1" audience="world" />
+          <Trans
+            i18nKey={"transTest.key1" as unknown as TranslationKeys}
+            audience="world"
+          />
         </div>
         </div>
         <div data-testid="test2">
         <div data-testid="test2">
           <Trans
           <Trans
-            i18nKey="transTest.key2"
+            i18nKey={"transTest.key2" as unknown as TranslationKeys}
             link={(el) => <a href="https://example.com">{el}</a>}
             link={(el) => <a href="https://example.com">{el}</a>}
           />
           />
         </div>
         </div>
         <div data-testid="test3">
         <div data-testid="test3">
           <Trans
           <Trans
-            i18nKey="transTest.key3"
+            i18nKey={"transTest.key3" as unknown as TranslationKeys}
             link={(el) => <a href="https://example.com">{el}</a>}
             link={(el) => <a href="https://example.com">{el}</a>}
             location="the button"
             location="the button"
           />
           />
         </div>
         </div>
         <div data-testid="test4">
         <div data-testid="test4">
           <Trans
           <Trans
-            i18nKey="transTest.key4"
+            i18nKey={"transTest.key4" as unknown as TranslationKeys}
             link={(el) => <a href="https://example.com">{el}</a>}
             link={(el) => <a href="https://example.com">{el}</a>}
             location="the button"
             location="the button"
             bold={(el) => <strong>{el}</strong>}
             bold={(el) => <strong>{el}</strong>}
@@ -43,7 +47,7 @@ describe("Test <Trans/>", () => {
         </div>
         </div>
         <div data-testid="test5">
         <div data-testid="test5">
           <Trans
           <Trans
-            i18nKey="transTest.key5"
+            i18nKey={"transTest.key5" as unknown as TranslationKeys}
             connect-link={(el) => <a href="https://example.com">{el}</a>}
             connect-link={(el) => <a href="https://example.com">{el}</a>}
           />
           />
         </div>
         </div>

+ 2 - 2
src/components/Trans.tsx

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

+ 8 - 0
src/components/icons.tsx

@@ -396,6 +396,14 @@ export const TrashIcon = createIcon(
   modifiedTablerIconProps,
   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(
 export const DuplicateIcon = createIcon(
   <g strokeWidth="1.25">
   <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" />
     <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,
   useExcalidrawSetAppState,
   useExcalidrawActionManager,
   useExcalidrawActionManager,
   useExcalidrawElements,
   useExcalidrawElements,
+  useAppProps,
 } from "../App";
 } from "../App";
 import {
 import {
   ExportIcon,
   ExportIcon,
@@ -198,13 +199,20 @@ export const ChangeCanvasBackground = () => {
   const { t } = useI18n();
   const { t } = useI18n();
   const appState = useUIAppState();
   const appState = useUIAppState();
   const actionManager = useExcalidrawActionManager();
   const actionManager = useExcalidrawActionManager();
+  const appProps = useAppProps();
 
 
-  if (appState.viewModeEnabled) {
+  if (
+    appState.viewModeEnabled ||
+    !appProps.UIOptions.canvasActions.changeViewBackgroundColor
+  ) {
     return null;
     return null;
   }
   }
   return (
   return (
     <div style={{ marginTop: "0.5rem" }}>
     <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")}
         {t("labels.canvasBackground")}
       </div>
       </div>
       <div style={{ padding: "0 0.625rem" }}>
       <div style={{ padding: "0 0.625rem" }}>

+ 13 - 1
src/constants.ts

@@ -71,8 +71,18 @@ export enum EVENT {
   // custom events
   // custom events
   EXCALIDRAW_LINK = "excalidraw-link",
   EXCALIDRAW_LINK = "excalidraw-link",
   MENU_ITEM_SELECT = "menu.itemSelect",
   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 = {
 export const ENV = {
   TEST: "test",
   TEST: "test",
   DEVELOPMENT: "development",
   DEVELOPMENT: "development",
@@ -92,7 +102,7 @@ export const FONT_FAMILY = {
 export const THEME = {
 export const THEME = {
   LIGHT: "light",
   LIGHT: "light",
   DARK: "dark",
   DARK: "dark",
-};
+} as const;
 
 
 export const FRAME_STYLE = {
 export const FRAME_STYLE = {
   strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
   strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
@@ -300,3 +310,5 @@ export const DEFAULT_SIDEBAR = {
   name: "default",
   name: "default",
   defaultTab: LIBRARY_SIDEBAR_TAB,
   defaultTab: LIBRARY_SIDEBAR_TAB,
 } as const;
 } as const;
+
+export const LIBRARY_DISABLED_TYPES = new Set(["embeddable", "image"] as const);

+ 43 - 0
src/css/styles.scss

@@ -77,6 +77,19 @@
     position: absolute;
     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 {
   &.theme--dark {
     // The percentage is inspired by
     // The percentage is inspired by
     // https://material.io/design/color/dark-theme.html#properties, which
     // https://material.io/design/color/dark-theme.html#properties, which
@@ -661,3 +674,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";
+  }
+}

+ 15 - 2
src/data/restore.ts

@@ -3,6 +3,7 @@ import {
   ExcalidrawSelectionElement,
   ExcalidrawSelectionElement,
   ExcalidrawTextElement,
   ExcalidrawTextElement,
   FontFamilyValues,
   FontFamilyValues,
+  PointBinding,
   StrokeRoundness,
   StrokeRoundness,
 } from "../element/types";
 } from "../element/types";
 import {
 import {
@@ -64,6 +65,7 @@ export const AllowedExcalidrawActiveTools: Record<
   eraser: false,
   eraser: false,
   custom: true,
   custom: true,
   frame: true,
   frame: true,
+  embeddable: true,
   hand: true,
   hand: true,
 };
 };
 
 
@@ -82,6 +84,13 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
   return DEFAULT_FONT_FAMILY;
   return DEFAULT_FONT_FAMILY;
 };
 };
 
 
+const repairBinding = (binding: PointBinding | null) => {
+  if (!binding) {
+    return null;
+  }
+  return { ...binding, focus: binding.focus || 0 };
+};
+
 const restoreElementWithProperties = <
 const restoreElementWithProperties = <
   T extends Required<Omit<ExcalidrawElement, "customData">> & {
   T extends Required<Omit<ExcalidrawElement, "customData">> & {
     customData?: ExcalidrawElement["customData"];
     customData?: ExcalidrawElement["customData"];
@@ -257,8 +266,8 @@ const restoreElement = (
           (element.type as ExcalidrawElement["type"] | "draw") === "draw"
           (element.type as ExcalidrawElement["type"] | "draw") === "draw"
             ? "line"
             ? "line"
             : element.type,
             : element.type,
-        startBinding: element.startBinding,
-        endBinding: element.endBinding,
+        startBinding: repairBinding(element.startBinding),
+        endBinding: repairBinding(element.endBinding),
         lastCommittedPoint: null,
         lastCommittedPoint: null,
         startArrowhead,
         startArrowhead,
         endArrowhead,
         endArrowhead,
@@ -275,6 +284,10 @@ const restoreElement = (
       return restoreElementWithProperties(element, {});
       return restoreElementWithProperties(element, {});
     case "diamond":
     case "diamond":
       return restoreElementWithProperties(element, {});
       return restoreElementWithProperties(element, {});
+    case "embeddable":
+      return restoreElementWithProperties(element, {
+        validated: undefined,
+      });
     case "frame":
     case "frame":
       return restoreElementWithProperties(element, {
       return restoreElementWithProperties(element, {
         name: element.name ?? null,
         name: element.name ?? null,

+ 26 - 0
src/data/url.ts

@@ -1,9 +1,35 @@
 import { sanitizeUrl } from "@braintree/sanitize-url";
 import { sanitizeUrl } from "@braintree/sanitize-url";
 
 
 export const normalizeLink = (link: string) => {
 export const normalizeLink = (link: string) => {
+  link = link.trim();
+  if (!link) {
+    return link;
+  }
   return sanitizeUrl(link);
   return sanitizeUrl(link);
 };
 };
 
 
 export const isLocalLink = (link: string | null) => {
 export const isLocalLink = (link: string | null) => {
   return !!(link?.includes(location.origin) || link?.startsWith("/"));
   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 {
   &--remove .ToolIcon__icon svg {
     color: $oc-red-6;
     color: $oc-red-6;
   }
   }

+ 137 - 29
src/element/Hyperlink.tsx

@@ -5,8 +5,12 @@ import {
   viewportCoordsToSceneCoords,
   viewportCoordsToSceneCoords,
   wrapEvent,
   wrapEvent,
 } from "../utils";
 } from "../utils";
+import { getEmbedLink, embeddableURLValidator } from "./embeddable";
 import { mutateElement } from "./mutateElement";
 import { mutateElement } from "./mutateElement";
-import { NonDeletedExcalidrawElement } from "./types";
+import {
+  ExcalidrawEmbeddableElement,
+  NonDeletedExcalidrawElement,
+} from "./types";
 
 
 import { register } from "../actions/register";
 import { register } from "../actions/register";
 import { ToolButton } from "../components/ToolButton";
 import { ToolButton } from "../components/ToolButton";
@@ -21,7 +25,10 @@ import {
 } from "react";
 } from "react";
 import clsx from "clsx";
 import clsx from "clsx";
 import { KEYS } from "../keys";
 import { KEYS } from "../keys";
-import { DEFAULT_LINK_SIZE } from "../renderer/renderElement";
+import {
+  DEFAULT_LINK_SIZE,
+  invalidateShapeForElement,
+} from "../renderer/renderElement";
 import { rotate } from "../math";
 import { rotate } from "../math";
 import { EVENT, HYPERLINK_TOOLTIP_DELAY, MIME_TYPES } from "../constants";
 import { EVENT, HYPERLINK_TOOLTIP_DELAY, MIME_TYPES } from "../constants";
 import { Bounds } from "./bounds";
 import { Bounds } from "./bounds";
@@ -33,7 +40,8 @@ import { isLocalLink, normalizeLink } from "../data/url";
 
 
 import "./Hyperlink.scss";
 import "./Hyperlink.scss";
 import { trackEvent } from "../analytics";
 import { trackEvent } from "../analytics";
-import { useExcalidrawAppState } from "../components/App";
+import { useAppProps, useExcalidrawAppState } from "../components/App";
+import { isEmbeddableElement } from "./typeChecks";
 
 
 const CONTAINER_WIDTH = 320;
 const CONTAINER_WIDTH = 320;
 const SPACE_BOTTOM = 85;
 const SPACE_BOTTOM = 85;
@@ -48,37 +56,112 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
 
 
 let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
 let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
 
 
+const embeddableLinkCache = new Map<
+  ExcalidrawEmbeddableElement["id"],
+  string
+>();
+
 export const Hyperlink = ({
 export const Hyperlink = ({
   element,
   element,
   setAppState,
   setAppState,
   onLinkOpen,
   onLinkOpen,
+  setToast,
 }: {
 }: {
   element: NonDeletedExcalidrawElement;
   element: NonDeletedExcalidrawElement;
   setAppState: React.Component<any, AppState>["setState"];
   setAppState: React.Component<any, AppState>["setState"];
   onLinkOpen: ExcalidrawProps["onLinkOpen"];
   onLinkOpen: ExcalidrawProps["onLinkOpen"];
+  setToast: (
+    toast: { message: string; closable?: boolean; duration?: number } | null,
+  ) => void;
 }) => {
 }) => {
   const appState = useExcalidrawAppState();
   const appState = useExcalidrawAppState();
+  const appProps = useAppProps();
 
 
   const linkVal = element.link || "";
   const linkVal = element.link || "";
 
 
   const [inputVal, setInputVal] = useState(linkVal);
   const [inputVal, setInputVal] = useState(linkVal);
   const inputRef = useRef<HTMLInputElement>(null);
   const inputRef = useRef<HTMLInputElement>(null);
-  const isEditing = appState.showHyperlinkPopup === "editor" || !linkVal;
+  const isEditing = appState.showHyperlinkPopup === "editor";
 
 
   const handleSubmit = useCallback(() => {
   const handleSubmit = useCallback(() => {
     if (!inputRef.current) {
     if (!inputRef.current) {
       return;
       return;
     }
     }
 
 
-    const link = normalizeLink(inputRef.current.value);
+    const link = normalizeLink(inputRef.current.value) || null;
 
 
     if (!element.link && link) {
     if (!element.link && link) {
       trackEvent("hyperlink", "create");
       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,
+        });
+        invalidateShapeForElement(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,
+        });
+        invalidateShapeForElement(element);
+        if (embeddableLinkCache.has(element.id)) {
+          embeddableLinkCache.delete(element.id);
+        }
+      }
+    } else {
+      mutateElement(element, { link });
+    }
+  }, [
+    element,
+    setToast,
+    appProps.validateEmbeddable,
+    appState.activeEmbeddable,
+    setAppState,
+  ]);
 
 
   useLayoutEffect(() => {
   useLayoutEffect(() => {
     return () => {
     return () => {
@@ -132,10 +215,12 @@ export const Hyperlink = ({
     appState.draggingElement ||
     appState.draggingElement ||
     appState.resizingElement ||
     appState.resizingElement ||
     appState.isRotating ||
     appState.isRotating ||
-    appState.openMenu
+    appState.openMenu ||
+    appState.viewModeEnabled
   ) {
   ) {
     return null;
     return null;
   }
   }
+
   return (
   return (
     <div
     <div
       className="excalidraw-hyperlinkContainer"
       className="excalidraw-hyperlinkContainer"
@@ -145,6 +230,11 @@ export const Hyperlink = ({
         width: CONTAINER_WIDTH,
         width: CONTAINER_WIDTH,
         padding: CONTAINER_PADDING,
         padding: CONTAINER_PADDING,
       }}
       }}
+      onClick={() => {
+        if (!element.link && !isEditing) {
+          setAppState({ showHyperlinkPopup: "editor" });
+        }
+      }}
     >
     >
       {isEditing ? (
       {isEditing ? (
         <input
         <input
@@ -162,15 +252,14 @@ export const Hyperlink = ({
             }
             }
             if (event.key === KEYS.ENTER || event.key === KEYS.ESCAPE) {
             if (event.key === KEYS.ENTER || event.key === KEYS.ESCAPE) {
               handleSubmit();
               handleSubmit();
+              setAppState({ showHyperlinkPopup: "info" });
             }
             }
           }}
           }}
         />
         />
-      ) : (
+      ) : element.link ? (
         <a
         <a
           href={normalizeLink(element.link || "")}
           href={normalizeLink(element.link || "")}
-          className={clsx("excalidraw-hyperlinkContainer-link", {
-            "d-none": isEditing,
-          })}
+          className="excalidraw-hyperlinkContainer-link"
           target={isLocalLink(element.link) ? "_self" : "_blank"}
           target={isLocalLink(element.link) ? "_self" : "_blank"}
           onClick={(event) => {
           onClick={(event) => {
             if (element.link && onLinkOpen) {
             if (element.link && onLinkOpen) {
@@ -194,6 +283,10 @@ export const Hyperlink = ({
         >
         >
           {element.link}
           {element.link}
         </a>
         </a>
+      ) : (
+        <div className="excalidraw-hyperlinkContainer-link">
+          {t("labels.link.empty")}
+        </div>
       )}
       )}
       <div className="excalidraw-hyperlinkContainer__buttons">
       <div className="excalidraw-hyperlinkContainer__buttons">
         {!isEditing && (
         {!isEditing && (
@@ -207,8 +300,7 @@ export const Hyperlink = ({
             icon={FreedrawIcon}
             icon={FreedrawIcon}
           />
           />
         )}
         )}
-
-        {linkVal && (
+        {linkVal && !isEmbeddableElement(element) && (
           <ToolButton
           <ToolButton
             type="button"
             type="button"
             title={t("buttons.remove")}
             title={t("buttons.remove")}
@@ -271,7 +363,11 @@ export const actionLink = register({
         type="button"
         type="button"
         icon={LinkIcon}
         icon={LinkIcon}
         aria-label={t(getContextMenuLabel(elements, appState))}
         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)}
         onClick={() => updateData(null)}
         selected={selectedElements.length === 1 && !!selectedElements[0].link}
         selected={selectedElements.length === 1 && !!selectedElements[0].link}
       />
       />
@@ -285,7 +381,11 @@ export const getContextMenuLabel = (
 ) => {
 ) => {
   const selectedElements = getSelectedElements(elements, appState);
   const selectedElements = getSelectedElements(elements, appState);
   const label = selectedElements[0]!.link
   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";
     : "labels.link.create";
   return label;
   return label;
 };
 };
@@ -327,21 +427,9 @@ export const isPointHittingLinkIcon = (
   element: NonDeletedExcalidrawElement,
   element: NonDeletedExcalidrawElement,
   appState: AppState,
   appState: AppState,
   [x, y]: Point,
   [x, y]: Point,
-  isMobile: boolean,
 ) => {
 ) => {
-  if (!element.link || appState.selectedElementIds[element.id]) {
-    return false;
-  }
   const threshold = 4 / appState.zoom.value;
   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 [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-
   const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
   const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
     [x1, y1, x2, y2],
     [x1, y1, x2, y2],
     element.angle,
     element.angle,
@@ -355,6 +443,26 @@ export const isPointHittingLinkIcon = (
   return hitLink;
   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;
 let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null;
 export const showHyperlinkTooltip = (
 export const showHyperlinkTooltip = (
   element: NonDeletedExcalidrawElement,
   element: NonDeletedExcalidrawElement,

+ 26 - 5
src/element/collision.ts

@@ -18,6 +18,7 @@ import {
   ExcalidrawBindableElement,
   ExcalidrawBindableElement,
   ExcalidrawElement,
   ExcalidrawElement,
   ExcalidrawRectangleElement,
   ExcalidrawRectangleElement,
+  ExcalidrawEmbeddableElement,
   ExcalidrawDiamondElement,
   ExcalidrawDiamondElement,
   ExcalidrawTextElement,
   ExcalidrawTextElement,
   ExcalidrawEllipseElement,
   ExcalidrawEllipseElement,
@@ -39,7 +40,11 @@ import { FrameNameBoundsCache, Point } from "../types";
 import { Drawable } from "roughjs/bin/core";
 import { Drawable } from "roughjs/bin/core";
 import { AppState } from "../types";
 import { AppState } from "../types";
 import { getShapeForElement } from "../renderer/renderElement";
 import { getShapeForElement } from "../renderer/renderElement";
-import { hasBoundTextElement, isImageElement } from "./typeChecks";
+import {
+  hasBoundTextElement,
+  isEmbeddableElement,
+  isImageElement,
+} from "./typeChecks";
 import { isTextElement } from ".";
 import { isTextElement } from ".";
 import { isTransparent } from "../utils";
 import { isTransparent } from "../utils";
 import { shouldShowBoundingBox } from "./transformHandles";
 import { shouldShowBoundingBox } from "./transformHandles";
@@ -57,7 +62,9 @@ const isElementDraggableFromInside = (
     return true;
     return true;
   }
   }
   const isDraggableFromInside =
   const isDraggableFromInside =
-    !isTransparent(element.backgroundColor) || hasBoundTextElement(element);
+    !isTransparent(element.backgroundColor) ||
+    hasBoundTextElement(element) ||
+    isEmbeddableElement(element);
   if (element.type === "line") {
   if (element.type === "line") {
     return isDraggableFromInside && isPathALoop(element.points);
     return isDraggableFromInside && isPathALoop(element.points);
   }
   }
@@ -248,6 +255,7 @@ type HitTestArgs = {
 const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
 const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
   switch (args.element.type) {
   switch (args.element.type) {
     case "rectangle":
     case "rectangle":
+    case "embeddable":
     case "image":
     case "image":
     case "text":
     case "text":
     case "diamond":
     case "diamond":
@@ -306,6 +314,7 @@ export const distanceToBindableElement = (
     case "rectangle":
     case "rectangle":
     case "image":
     case "image":
     case "text":
     case "text":
+    case "embeddable":
     case "frame":
     case "frame":
       return distanceToRectangle(element, point);
       return distanceToRectangle(element, point);
     case "diamond":
     case "diamond":
@@ -337,6 +346,7 @@ const distanceToRectangle = (
     | ExcalidrawTextElement
     | ExcalidrawTextElement
     | ExcalidrawFreeDrawElement
     | ExcalidrawFreeDrawElement
     | ExcalidrawImageElement
     | ExcalidrawImageElement
+    | ExcalidrawEmbeddableElement
     | ExcalidrawFrameElement,
     | ExcalidrawFrameElement,
   point: Point,
   point: Point,
 ): number => {
 ): number => {
@@ -645,17 +655,23 @@ export const determineFocusDistance = (
   const c = line[1];
   const c = line[1];
   const mabs = Math.abs(m);
   const mabs = Math.abs(m);
   const nabs = Math.abs(n);
   const nabs = Math.abs(n);
+  let ret;
   switch (element.type) {
   switch (element.type) {
     case "rectangle":
     case "rectangle":
     case "image":
     case "image":
     case "text":
     case "text":
+    case "embeddable":
     case "frame":
     case "frame":
-      return c / (hwidth * (nabs + q * mabs));
+      ret = c / (hwidth * (nabs + q * mabs));
+      break;
     case "diamond":
     case "diamond":
-      return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
+      ret = mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
+      break;
     case "ellipse":
     case "ellipse":
-      return c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2));
+      ret = c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2));
+      break;
   }
   }
+  return ret || 0;
 };
 };
 
 
 export const determineFocusPoint = (
 export const determineFocusPoint = (
@@ -682,6 +698,7 @@ export const determineFocusPoint = (
     case "image":
     case "image":
     case "text":
     case "text":
     case "diamond":
     case "diamond":
+    case "embeddable":
     case "frame":
     case "frame":
       point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
       point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
       break;
       break;
@@ -733,6 +750,7 @@ const getSortedElementLineIntersections = (
     case "image":
     case "image":
     case "text":
     case "text":
     case "diamond":
     case "diamond":
+    case "embeddable":
     case "frame":
     case "frame":
       const corners = getCorners(element);
       const corners = getCorners(element);
       intersections = corners
       intersections = corners
@@ -768,6 +786,7 @@ const getCorners = (
     | ExcalidrawImageElement
     | ExcalidrawImageElement
     | ExcalidrawDiamondElement
     | ExcalidrawDiamondElement
     | ExcalidrawTextElement
     | ExcalidrawTextElement
+    | ExcalidrawEmbeddableElement
     | ExcalidrawFrameElement,
     | ExcalidrawFrameElement,
   scale: number = 1,
   scale: number = 1,
 ): GA.Point[] => {
 ): GA.Point[] => {
@@ -777,6 +796,7 @@ const getCorners = (
     case "rectangle":
     case "rectangle":
     case "image":
     case "image":
     case "text":
     case "text":
+    case "embeddable":
     case "frame":
     case "frame":
       return [
       return [
         GA.point(hx, hy),
         GA.point(hx, hy),
@@ -926,6 +946,7 @@ export const findFocusPointForRectangulars = (
     | ExcalidrawImageElement
     | ExcalidrawImageElement
     | ExcalidrawDiamondElement
     | ExcalidrawDiamondElement
     | ExcalidrawTextElement
     | ExcalidrawTextElement
+    | ExcalidrawEmbeddableElement
     | ExcalidrawFrameElement,
     | ExcalidrawFrameElement,
   // Between -1 and 1 for how far away should the focus point be relative
   // Between -1 and 1 for how far away should the focus point be relative
   // to the size of the element. Sign determines orientation.
   // to the size of the element. Sign determines orientation.

+ 330 - 0
src/element/embeddable.ts

@@ -0,0 +1,330 @@
+import { register } from "../actions/register";
+import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
+import { t } from "../i18n";
+import { ExcalidrawProps } from "../types";
+import { getFontString, setCursorForShape, updateActiveTool } from "../utils";
+import { newTextElement } from "./newElement";
+import { getContainerElement, wrapText } from "./textElement";
+import { isEmbeddableElement } from "./typeChecks";
+import {
+  ExcalidrawElement,
+  ExcalidrawEmbeddableElement,
+  NonDeletedExcalidrawElement,
+  Theme,
+} from "./types";
+
+type EmbeddedLink =
+  | ({
+      aspectRatio: { w: number; h: number };
+      warning?: string;
+    } & (
+      | { type: "video" | "generic"; link: string }
+      | { type: "document"; srcdoc: (theme: Theme) => string }
+    ))
+  | null;
+
+const embeddedLinkCache = new Map<string, EmbeddedLink>();
+
+const RE_YOUTUBE =
+  /^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
+const RE_VIMEO =
+  /^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
+const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
+
+const RE_GH_GIST = /^https:\/\/gist\.github\.com/;
+const RE_GH_GIST_EMBED =
+  /^<script[\s\S]*?\ssrc=["'](https:\/\/gist.github.com\/.*?)\.js["']/i;
+
+// not anchored to start to allow <blockquote> twitter embeds
+const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?twitter.com/;
+const RE_TWITTER_EMBED =
+  /^<blockquote[\s\S]*?\shref=["'](https:\/\/twitter.com\/[^"']*)/i;
+
+const RE_GENERIC_EMBED =
+  /^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
+
+const ALLOWED_DOMAINS = new Set([
+  "youtube.com",
+  "youtu.be",
+  "vimeo.com",
+  "player.vimeo.com",
+  "figma.com",
+  "link.excalidraw.com",
+  "gist.github.com",
+  "twitter.com",
+  "stackblitz.com",
+]);
+
+const createSrcDoc = (body: string) => {
+  return `<html><body>${body}</body></html>`;
+};
+
+export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
+  if (!link) {
+    return null;
+  }
+
+  if (embeddedLinkCache.has(link)) {
+    return embeddedLinkCache.get(link)!;
+  }
+
+  const originalLink = link;
+
+  let type: "video" | "generic" = "generic";
+  let aspectRatio = { w: 560, h: 840 };
+  const ytLink = link.match(RE_YOUTUBE);
+  if (ytLink?.[2]) {
+    const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
+    const isPortrait = link.includes("shorts");
+    type = "video";
+    switch (ytLink[1]) {
+      case "embed/":
+      case "watch?v=":
+      case "shorts/":
+        link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`;
+        break;
+      case "playlist?list=":
+      case "embed/videoseries?list=":
+        link = `https://www.youtube.com/embed/videoseries?list=${ytLink[2]}&enablejsapi=1${time}`;
+        break;
+      default:
+        link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`;
+        break;
+    }
+    aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 };
+    embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
+    return { link, aspectRatio, type };
+  }
+
+  const vimeoLink = link.match(RE_VIMEO);
+  if (vimeoLink?.[1]) {
+    const target = vimeoLink?.[1];
+    const warning = !/^\d+$/.test(target)
+      ? t("toast.unrecognizedLinkFormat")
+      : undefined;
+    type = "video";
+    link = `https://player.vimeo.com/video/${target}?api=1`;
+    aspectRatio = { w: 560, h: 315 };
+    //warning deliberately ommited so it is displayed only once per link
+    //same link next time will be served from cache
+    embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
+    return { link, aspectRatio, type, warning };
+  }
+
+  const figmaLink = link.match(RE_FIGMA);
+  if (figmaLink) {
+    type = "generic";
+    link = `https://www.figma.com/embed?embed_host=share&url=${encodeURIComponent(
+      link,
+    )}`;
+    aspectRatio = { w: 550, h: 550 };
+    embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
+    return { link, aspectRatio, type };
+  }
+
+  if (RE_TWITTER.test(link)) {
+    let ret: EmbeddedLink;
+    // assume embed code
+    if (/<blockquote/.test(link)) {
+      const srcDoc = createSrcDoc(link);
+      ret = {
+        type: "document",
+        srcdoc: () => srcDoc,
+        aspectRatio: { w: 480, h: 480 },
+      };
+      // assume regular tweet url
+    } else {
+      ret = {
+        type: "document",
+        srcdoc: (theme: string) =>
+          createSrcDoc(
+            `<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
+          ),
+        aspectRatio: { w: 480, h: 480 },
+      };
+    }
+    embeddedLinkCache.set(originalLink, ret);
+    return ret;
+  }
+
+  if (RE_GH_GIST.test(link)) {
+    let ret: EmbeddedLink;
+    // assume embed code
+    if (/<script>/.test(link)) {
+      const srcDoc = createSrcDoc(link);
+      ret = {
+        type: "document",
+        srcdoc: () => srcDoc,
+        aspectRatio: { w: 550, h: 720 },
+      };
+      // assume regular url
+    } else {
+      ret = {
+        type: "document",
+        srcdoc: () =>
+          createSrcDoc(`
+          <script src="${link}.js"></script>
+          <style type="text/css">
+            * { margin: 0px; }
+            table, .gist { height: 100%; }
+            .gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
+          </style>
+        `),
+        aspectRatio: { w: 550, h: 720 },
+      };
+    }
+    embeddedLinkCache.set(link, ret);
+    return ret;
+  }
+
+  embeddedLinkCache.set(link, { link, aspectRatio, type });
+  return { link, aspectRatio, type };
+};
+
+export const isEmbeddableOrFrameLabel = (
+  element: NonDeletedExcalidrawElement,
+): Boolean => {
+  if (isEmbeddableElement(element)) {
+    return true;
+  }
+  if (element.type === "text") {
+    const container = getContainerElement(element);
+    if (container && isEmbeddableElement(container)) {
+      return true;
+    }
+  }
+  return false;
+};
+
+export const createPlaceholderEmbeddableLabel = (
+  element: ExcalidrawEmbeddableElement,
+): ExcalidrawElement => {
+  const text =
+    !element.link || element?.link === "" ? "Empty Web-Embed" : element.link;
+  const fontSize = Math.max(
+    Math.min(element.width / 2, element.width / text.length),
+    element.width / 30,
+  );
+  const fontFamily = FONT_FAMILY.Helvetica;
+
+  const fontString = getFontString({
+    fontSize,
+    fontFamily,
+  });
+
+  return newTextElement({
+    x: element.x + element.width / 2,
+    y: element.y + element.height / 2,
+    strokeColor:
+      element.strokeColor !== "transparent" ? element.strokeColor : "black",
+    backgroundColor: "transparent",
+    fontFamily,
+    fontSize,
+    text: wrapText(text, fontString, element.width - 20),
+    textAlign: "center",
+    verticalAlign: VERTICAL_ALIGN.MIDDLE,
+    angle: element.angle ?? 0,
+  });
+};
+
+export const actionSetEmbeddableAsActiveTool = register({
+  name: "setEmbeddableAsActiveTool",
+  trackEvent: { category: "toolbar" },
+  perform: (elements, appState, _, app) => {
+    const nextActiveTool = updateActiveTool(appState, {
+      type: "embeddable",
+    });
+
+    setCursorForShape(app.canvas, {
+      ...appState,
+      activeTool: nextActiveTool,
+    });
+
+    return {
+      elements,
+      appState: {
+        ...appState,
+        activeTool: updateActiveTool(appState, {
+          type: "embeddable",
+        }),
+      },
+      commitToHistory: false,
+    };
+  },
+});
+
+const validateHostname = (
+  url: string,
+  /** using a Set assumes it already contains normalized bare domains */
+  allowedHostnames: Set<string> | string,
+): boolean => {
+  try {
+    const { hostname } = new URL(url);
+
+    const bareDomain = hostname.replace(/^www\./, "");
+
+    if (allowedHostnames instanceof Set) {
+      return ALLOWED_DOMAINS.has(bareDomain);
+    }
+
+    if (bareDomain === allowedHostnames.replace(/^www\./, "")) {
+      return true;
+    }
+  } catch (error) {
+    // ignore
+  }
+  return false;
+};
+
+export const extractSrc = (htmlString: string): string => {
+  const twitterMatch = htmlString.match(RE_TWITTER_EMBED);
+  if (twitterMatch && twitterMatch.length === 2) {
+    return twitterMatch[1];
+  }
+
+  const gistMatch = htmlString.match(RE_GH_GIST_EMBED);
+  if (gistMatch && gistMatch.length === 2) {
+    return gistMatch[1];
+  }
+
+  const match = htmlString.match(RE_GENERIC_EMBED);
+  if (match && match.length === 2) {
+    return match[1];
+  }
+  return htmlString;
+};
+
+export const embeddableURLValidator = (
+  url: string | null | undefined,
+  validateEmbeddable: ExcalidrawProps["validateEmbeddable"],
+): boolean => {
+  if (!url) {
+    return false;
+  }
+  if (validateEmbeddable != null) {
+    if (typeof validateEmbeddable === "function") {
+      const ret = validateEmbeddable(url);
+      // if return value is undefined, leave validation to default
+      if (typeof ret === "boolean") {
+        return ret;
+      }
+    } else if (typeof validateEmbeddable === "boolean") {
+      return validateEmbeddable;
+    } else if (validateEmbeddable instanceof RegExp) {
+      return validateEmbeddable.test(url);
+    } else if (Array.isArray(validateEmbeddable)) {
+      for (const domain of validateEmbeddable) {
+        if (domain instanceof RegExp) {
+          if (url.match(domain)) {
+            return true;
+          }
+        } else if (validateHostname(url, domain)) {
+          return true;
+        }
+      }
+      return false;
+    }
+  }
+
+  return validateHostname(url, ALLOWED_DOMAINS);
+};

+ 14 - 3
src/element/newElement.ts

@@ -13,6 +13,7 @@ import {
   FontFamilyValues,
   FontFamilyValues,
   ExcalidrawTextContainer,
   ExcalidrawTextContainer,
   ExcalidrawFrameElement,
   ExcalidrawFrameElement,
+  ExcalidrawEmbeddableElement,
 } from "../element/types";
 } from "../element/types";
 import {
 import {
   arrayToMap,
   arrayToMap,
@@ -130,6 +131,18 @@ export const newElement = (
 ): NonDeleted<ExcalidrawGenericElement> =>
 ): NonDeleted<ExcalidrawGenericElement> =>
   _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
   _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
 
 
+export const newEmbeddableElement = (
+  opts: {
+    type: "embeddable";
+    validated: boolean | undefined;
+  } & ElementConstructorOpts,
+): NonDeleted<ExcalidrawEmbeddableElement> => {
+  return {
+    ..._newElementBase<ExcalidrawEmbeddableElement>("embeddable", opts),
+    validated: opts.validated,
+  };
+};
+
 export const newFrameElement = (
 export const newFrameElement = (
   opts: ElementConstructorOpts,
   opts: ElementConstructorOpts,
 ): NonDeleted<ExcalidrawFrameElement> => {
 ): NonDeleted<ExcalidrawFrameElement> => {
@@ -177,7 +190,6 @@ export const newTextElement = (
     containerId?: ExcalidrawTextContainer["id"];
     containerId?: ExcalidrawTextContainer["id"];
     lineHeight?: ExcalidrawTextElement["lineHeight"];
     lineHeight?: ExcalidrawTextElement["lineHeight"];
     strokeWidth?: ExcalidrawTextElement["strokeWidth"];
     strokeWidth?: ExcalidrawTextElement["strokeWidth"];
-    isFrameName?: boolean;
   } & ElementConstructorOpts,
   } & ElementConstructorOpts,
 ): NonDeleted<ExcalidrawTextElement> => {
 ): NonDeleted<ExcalidrawTextElement> => {
   const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
   const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
@@ -212,7 +224,6 @@ export const newTextElement = (
       containerId: opts.containerId || null,
       containerId: opts.containerId || null,
       originalText: text,
       originalText: text,
       lineHeight,
       lineHeight,
-      isFrameName: opts.isFrameName || false,
     },
     },
     {},
     {},
   );
   );
@@ -432,7 +443,7 @@ const _deepCopyElement = (val: any, depth: number = 0) => {
   // we're not cloning non-array & non-plain-object objects because we
   // we're not cloning non-array & non-plain-object objects because we
   // don't support them on excalidraw elements yet. If we do, we need to make
   // don't support them on excalidraw elements yet. If we do, we need to make
   // sure we start cloning them, so let's warn about it.
   // sure we start cloning them, so let's warn about it.
-  if (process.env.NODE_ENV === "development") {
+  if (import.meta.env.DEV) {
     if (
     if (
       objectType !== "[object Object]" &&
       objectType !== "[object Object]" &&
       objectType !== "[object Array]" &&
       objectType !== "[object Array]" &&

+ 7 - 7
src/element/showSelectedShapeActions.ts

@@ -7,11 +7,11 @@ export const showSelectedShapeActions = (
   elements: readonly NonDeletedExcalidrawElement[],
   elements: readonly NonDeletedExcalidrawElement[],
 ) =>
 ) =>
   Boolean(
   Boolean(
-    (!appState.viewModeEnabled &&
-      appState.activeTool.type !== "custom" &&
-      (appState.editingElement ||
-        (appState.activeTool.type !== "selection" &&
-          appState.activeTool.type !== "eraser" &&
-          appState.activeTool.type !== "hand"))) ||
-      getSelectedElements(elements, appState).length,
+    !appState.viewModeEnabled &&
+      ((appState.activeTool.type !== "custom" &&
+        (appState.editingElement ||
+          (appState.activeTool.type !== "selection" &&
+            appState.activeTool.type !== "eraser" &&
+            appState.activeTool.type !== "hand"))) ||
+        getSelectedElements(elements, appState).length),
   );
   );

+ 17 - 1
src/element/sizeHelpers.test.ts

@@ -1,19 +1,32 @@
+import { vi } from "vitest";
 import { getPerfectElementSize } from "./sizeHelpers";
 import { getPerfectElementSize } from "./sizeHelpers";
 import * as constants from "../constants";
 import * as constants from "../constants";
 
 
 const EPSILON_DIGITS = 3;
 const EPSILON_DIGITS = 3;
-
+// Needed so that we can mock the value of constants which is done in
+// below tests. In Jest this wasn't needed as global override was possible
+// but vite doesn't allow that hence we need to mock
+vi.mock(
+  "../constants.ts",
+  //@ts-ignore
+  async (importOriginal) => {
+    const module: any = await importOriginal();
+    return { ...module };
+  },
+);
 describe("getPerfectElementSize", () => {
 describe("getPerfectElementSize", () => {
   it("should return height:0 if `elementType` is line and locked angle is 0", () => {
   it("should return height:0 if `elementType` is line and locked angle is 0", () => {
     const { height, width } = getPerfectElementSize("line", 149, 10);
     const { height, width } = getPerfectElementSize("line", 149, 10);
     expect(width).toBeCloseTo(149, EPSILON_DIGITS);
     expect(width).toBeCloseTo(149, EPSILON_DIGITS);
     expect(height).toBeCloseTo(0, EPSILON_DIGITS);
     expect(height).toBeCloseTo(0, EPSILON_DIGITS);
   });
   });
+
   it("should return width:0 if `elementType` is line and locked angle is 90 deg (Math.PI/2)", () => {
   it("should return width:0 if `elementType` is line and locked angle is 90 deg (Math.PI/2)", () => {
     const { height, width } = getPerfectElementSize("line", 10, 140);
     const { height, width } = getPerfectElementSize("line", 10, 140);
     expect(width).toBeCloseTo(0, EPSILON_DIGITS);
     expect(width).toBeCloseTo(0, EPSILON_DIGITS);
     expect(height).toBeCloseTo(140, EPSILON_DIGITS);
     expect(height).toBeCloseTo(140, EPSILON_DIGITS);
   });
   });
+
   it("should return height:0 if `elementType` is arrow and locked angle is 0", () => {
   it("should return height:0 if `elementType` is arrow and locked angle is 0", () => {
     const { height, width } = getPerfectElementSize("arrow", 200, 20);
     const { height, width } = getPerfectElementSize("arrow", 200, 20);
     expect(width).toBeCloseTo(200, EPSILON_DIGITS);
     expect(width).toBeCloseTo(200, EPSILON_DIGITS);
@@ -24,16 +37,19 @@ describe("getPerfectElementSize", () => {
     expect(width).toBeCloseTo(0, EPSILON_DIGITS);
     expect(width).toBeCloseTo(0, EPSILON_DIGITS);
     expect(height).toBeCloseTo(100, EPSILON_DIGITS);
     expect(height).toBeCloseTo(100, EPSILON_DIGITS);
   });
   });
+
   it("should return adjust height to be width * tan(locked angle)", () => {
   it("should return adjust height to be width * tan(locked angle)", () => {
     const { height, width } = getPerfectElementSize("arrow", 120, 185);
     const { height, width } = getPerfectElementSize("arrow", 120, 185);
     expect(width).toBeCloseTo(120, EPSILON_DIGITS);
     expect(width).toBeCloseTo(120, EPSILON_DIGITS);
     expect(height).toBeCloseTo(207.846, EPSILON_DIGITS);
     expect(height).toBeCloseTo(207.846, EPSILON_DIGITS);
   });
   });
+
   it("should return height equals to width if locked angle is 45 deg", () => {
   it("should return height equals to width if locked angle is 45 deg", () => {
     const { height, width } = getPerfectElementSize("arrow", 135, 145);
     const { height, width } = getPerfectElementSize("arrow", 135, 145);
     expect(width).toBeCloseTo(135, EPSILON_DIGITS);
     expect(width).toBeCloseTo(135, EPSILON_DIGITS);
     expect(height).toBeCloseTo(135, EPSILON_DIGITS);
     expect(height).toBeCloseTo(135, EPSILON_DIGITS);
   });
   });
+
   it("should return height:0 and width:0 when width and height are 0", () => {
   it("should return height:0 and width:0 when width and height are 0", () => {
     const { height, width } = getPerfectElementSize("arrow", 0, 0);
     const { height, width } = getPerfectElementSize("arrow", 0, 0);
     expect(width).toBeCloseTo(0, EPSILON_DIGITS);
     expect(width).toBeCloseTo(0, EPSILON_DIGITS);

+ 12 - 12
src/element/textWysiwyg.test.tsx

@@ -955,7 +955,7 @@ describe("textWysiwyg", () => {
       // should center align horizontally and vertically by default
       // should center align horizontally and vertically by default
       resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
       resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
       expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
       expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
-        Array [
+        [
           85,
           85,
           4.5,
           4.5,
         ]
         ]
@@ -979,7 +979,7 @@ describe("textWysiwyg", () => {
       // should left align horizontally and bottom vertically after resize
       // should left align horizontally and bottom vertically after resize
       resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
       resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
       expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
       expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
-        Array [
+        [
           15,
           15,
           65,
           65,
         ]
         ]
@@ -1001,7 +1001,7 @@ describe("textWysiwyg", () => {
       // should right align horizontally and top vertically after resize
       // should right align horizontally and top vertically after resize
       resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
       resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
       expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
       expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
-        Array [
+        [
           375,
           375,
           -539,
           -539,
         ]
         ]
@@ -1279,7 +1279,7 @@ describe("textWysiwyg", () => {
         fireEvent.click(screen.getByTitle("Left"));
         fireEvent.click(screen.getByTitle("Left"));
         fireEvent.click(screen.getByTitle("Align top"));
         fireEvent.click(screen.getByTitle("Align top"));
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
-          Array [
+          [
             15,
             15,
             25,
             25,
           ]
           ]
@@ -1290,7 +1290,7 @@ describe("textWysiwyg", () => {
         fireEvent.click(screen.getByTitle("Center"));
         fireEvent.click(screen.getByTitle("Center"));
         fireEvent.click(screen.getByTitle("Align top"));
         fireEvent.click(screen.getByTitle("Align top"));
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
-          Array [
+          [
             30,
             30,
             25,
             25,
           ]
           ]
@@ -1302,7 +1302,7 @@ describe("textWysiwyg", () => {
         fireEvent.click(screen.getByTitle("Align top"));
         fireEvent.click(screen.getByTitle("Align top"));
 
 
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
-          Array [
+          [
             45,
             45,
             25,
             25,
           ]
           ]
@@ -1313,7 +1313,7 @@ describe("textWysiwyg", () => {
         fireEvent.click(screen.getByTitle("Center vertically"));
         fireEvent.click(screen.getByTitle("Center vertically"));
         fireEvent.click(screen.getByTitle("Left"));
         fireEvent.click(screen.getByTitle("Left"));
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
-          Array [
+          [
             15,
             15,
             45,
             45,
           ]
           ]
@@ -1325,7 +1325,7 @@ describe("textWysiwyg", () => {
         fireEvent.click(screen.getByTitle("Center vertically"));
         fireEvent.click(screen.getByTitle("Center vertically"));
 
 
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
-          Array [
+          [
             30,
             30,
             45,
             45,
           ]
           ]
@@ -1337,7 +1337,7 @@ describe("textWysiwyg", () => {
         fireEvent.click(screen.getByTitle("Center vertically"));
         fireEvent.click(screen.getByTitle("Center vertically"));
 
 
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
-          Array [
+          [
             45,
             45,
             45,
             45,
           ]
           ]
@@ -1349,7 +1349,7 @@ describe("textWysiwyg", () => {
         fireEvent.click(screen.getByTitle("Align bottom"));
         fireEvent.click(screen.getByTitle("Align bottom"));
 
 
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
-          Array [
+          [
             15,
             15,
             65,
             65,
           ]
           ]
@@ -1360,7 +1360,7 @@ describe("textWysiwyg", () => {
         fireEvent.click(screen.getByTitle("Center"));
         fireEvent.click(screen.getByTitle("Center"));
         fireEvent.click(screen.getByTitle("Align bottom"));
         fireEvent.click(screen.getByTitle("Align bottom"));
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
-          Array [
+          [
             30,
             30,
             65,
             65,
           ]
           ]
@@ -1371,7 +1371,7 @@ describe("textWysiwyg", () => {
         fireEvent.click(screen.getByTitle("Right"));
         fireEvent.click(screen.getByTitle("Right"));
         fireEvent.click(screen.getByTitle("Align bottom"));
         fireEvent.click(screen.getByTitle("Align bottom"));
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
-          Array [
+          [
             45,
             45,
             65,
             65,
           ]
           ]

+ 15 - 8
src/element/typeChecks.ts

@@ -4,6 +4,7 @@ import { MarkNonNullable } from "../utility-types";
 import {
 import {
   ExcalidrawElement,
   ExcalidrawElement,
   ExcalidrawTextElement,
   ExcalidrawTextElement,
+  ExcalidrawEmbeddableElement,
   ExcalidrawLinearElement,
   ExcalidrawLinearElement,
   ExcalidrawBindableElement,
   ExcalidrawBindableElement,
   ExcalidrawGenericElement,
   ExcalidrawGenericElement,
@@ -24,7 +25,8 @@ export const isGenericElement = (
     (element.type === "selection" ||
     (element.type === "selection" ||
       element.type === "rectangle" ||
       element.type === "rectangle" ||
       element.type === "diamond" ||
       element.type === "diamond" ||
-      element.type === "ellipse")
+      element.type === "ellipse" ||
+      element.type === "embeddable")
   );
   );
 };
 };
 
 
@@ -40,6 +42,12 @@ export const isImageElement = (
   return !!element && element.type === "image";
   return !!element && element.type === "image";
 };
 };
 
 
+export const isEmbeddableElement = (
+  element: ExcalidrawElement | null | undefined,
+): element is ExcalidrawEmbeddableElement => {
+  return !!element && element.type === "embeddable";
+};
+
 export const isTextElement = (
 export const isTextElement = (
   element: ExcalidrawElement | null,
   element: ExcalidrawElement | null,
 ): element is ExcalidrawTextElement => {
 ): element is ExcalidrawTextElement => {
@@ -112,6 +120,7 @@ export const isBindableElement = (
       element.type === "diamond" ||
       element.type === "diamond" ||
       element.type === "ellipse" ||
       element.type === "ellipse" ||
       element.type === "image" ||
       element.type === "image" ||
+      element.type === "embeddable" ||
       (element.type === "text" && !element.containerId))
       (element.type === "text" && !element.containerId))
   );
   );
 };
 };
@@ -135,6 +144,7 @@ export const isExcalidrawElement = (element: any): boolean => {
     element?.type === "text" ||
     element?.type === "text" ||
     element?.type === "diamond" ||
     element?.type === "diamond" ||
     element?.type === "rectangle" ||
     element?.type === "rectangle" ||
+    element?.type === "embeddable" ||
     element?.type === "ellipse" ||
     element?.type === "ellipse" ||
     element?.type === "arrow" ||
     element?.type === "arrow" ||
     element?.type === "freedraw" ||
     element?.type === "freedraw" ||
@@ -162,7 +172,8 @@ export const isBoundToContainer = (
   );
   );
 };
 };
 
 
-export const isUsingAdaptiveRadius = (type: string) => type === "rectangle";
+export const isUsingAdaptiveRadius = (type: string) =>
+  type === "rectangle" || type === "embeddable";
 
 
 export const isUsingProportionalRadius = (type: string) =>
 export const isUsingProportionalRadius = (type: string) =>
   type === "line" || type === "arrow" || type === "diamond";
   type === "line" || type === "arrow" || type === "diamond";
@@ -193,17 +204,13 @@ export const canApplyRoundnessTypeToElement = (
 export const getDefaultRoundnessTypeForElement = (
 export const getDefaultRoundnessTypeForElement = (
   element: ExcalidrawElement,
   element: ExcalidrawElement,
 ) => {
 ) => {
-  if (
-    element.type === "arrow" ||
-    element.type === "line" ||
-    element.type === "diamond"
-  ) {
+  if (isUsingProportionalRadius(element.type)) {
     return {
     return {
       type: ROUNDNESS.PROPORTIONAL_RADIUS,
       type: ROUNDNESS.PROPORTIONAL_RADIUS,
     };
     };
   }
   }
 
 
-  if (element.type === "rectangle") {
+  if (isUsingAdaptiveRadius(element.type)) {
     return {
     return {
       type: ROUNDNESS.ADAPTIVE_RADIUS,
       type: ROUNDNESS.ADAPTIVE_RADIUS,
     };
     };

+ 15 - 0
src/element/types.ts

@@ -84,6 +84,19 @@ export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
   type: "ellipse";
   type: "ellipse";
 };
 };
 
 
+export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
+  Readonly<{
+    /**
+     * indicates whether the embeddable src (url) has been validated for rendering.
+     * nullish value indicates that the validation is pending. We reset the
+     * value on each restore (or url change) so that we can guarantee
+     * the validation came from a trusted source (the editor). Also because we
+     * may not have access to host-app supplied url validator during restore.
+     */
+    validated?: boolean;
+    type: "embeddable";
+  }>;
+
 export type ExcalidrawImageElement = _ExcalidrawElementBase &
 export type ExcalidrawImageElement = _ExcalidrawElementBase &
   Readonly<{
   Readonly<{
     type: "image";
     type: "image";
@@ -110,6 +123,7 @@ export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
 export type ExcalidrawGenericElement =
 export type ExcalidrawGenericElement =
   | ExcalidrawSelectionElement
   | ExcalidrawSelectionElement
   | ExcalidrawRectangleElement
   | ExcalidrawRectangleElement
+  | ExcalidrawEmbeddableElement
   | ExcalidrawDiamondElement
   | ExcalidrawDiamondElement
   | ExcalidrawEllipseElement;
   | ExcalidrawEllipseElement;
 
 
@@ -156,6 +170,7 @@ export type ExcalidrawBindableElement =
   | ExcalidrawEllipseElement
   | ExcalidrawEllipseElement
   | ExcalidrawTextElement
   | ExcalidrawTextElement
   | ExcalidrawImageElement
   | ExcalidrawImageElement
+  | ExcalidrawEmbeddableElement
   | ExcalidrawFrameElement;
   | ExcalidrawFrameElement;
 
 
 export type ExcalidrawTextContainer =
 export type ExcalidrawTextContainer =

+ 3 - 9
src/excalidraw-app/collab/Collab.tsx

@@ -186,10 +186,7 @@ class Collab extends PureComponent<Props, CollabState> {
 
 
     appJotaiStore.set(collabAPIAtom, collabAPI);
     appJotaiStore.set(collabAPIAtom, collabAPI);
 
 
-    if (
-      process.env.NODE_ENV === ENV.TEST ||
-      process.env.NODE_ENV === ENV.DEVELOPMENT
-    ) {
+    if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
       window.collab = window.collab || ({} as Window["collab"]);
       window.collab = window.collab || ({} as Window["collab"]);
       Object.defineProperties(window, {
       Object.defineProperties(window, {
         collab: {
         collab: {
@@ -472,7 +469,7 @@ class Collab extends PureComponent<Props, CollabState> {
      * Indicates whether to fetch files that are errored or pending and older
      * Indicates whether to fetch files that are errored or pending and older
      * than 10 seconds.
      * than 10 seconds.
      *
      *
-     * Use this as a machanism to fetch files which may be ok but for some
+     * Use this as a mechanism to fetch files which may be ok but for some
      * reason their status was not updated correctly.
      * reason their status was not updated correctly.
      */
      */
     forceFetchFiles?: boolean;
     forceFetchFiles?: boolean;
@@ -1026,10 +1023,7 @@ declare global {
   }
   }
 }
 }
 
 
-if (
-  process.env.NODE_ENV === ENV.TEST ||
-  process.env.NODE_ENV === ENV.DEVELOPMENT
-) {
+if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
   window.collab = window.collab || ({} as Window["collab"]);
   window.collab = window.collab || ({} as Window["collab"]);
 }
 }
 
 

+ 3 - 1
src/excalidraw-app/components/AppWelcomeScreen.tsx

@@ -19,7 +19,9 @@ export const AppWelcomeScreen: React.FC<{
           return (
           return (
             <a
             <a
               style={{ pointerEvents: "all" }}
               style={{ pointerEvents: "all" }}
-              href={`${process.env.REACT_APP_PLUS_APP}?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
+              href={`${
+                import.meta.env.VITE_APP_PLUS_APP
+              }?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
               key={idx}
               key={idx}
             >
             >
               Excalidraw+
               Excalidraw+

+ 3 - 1
src/excalidraw-app/components/ExcalidrawPlusAppLink.tsx

@@ -6,7 +6,9 @@ export const ExcalidrawPlusAppLink = () => {
   }
   }
   return (
   return (
     <a
     <a
-      href={`${process.env.REACT_APP_PLUS_APP}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
+      href={`${
+        import.meta.env.VITE_APP_PLUS_APP
+      }?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
       target="_blank"
       target="_blank"
       rel="noreferrer"
       rel="noreferrer"
       className="plus-button"
       className="plus-button"

+ 4 - 2
src/excalidraw-app/data/firebase.ts

@@ -21,10 +21,12 @@ import { ResolutionType } from "../../utility-types";
 
 
 let FIREBASE_CONFIG: Record<string, any>;
 let FIREBASE_CONFIG: Record<string, any>;
 try {
 try {
-  FIREBASE_CONFIG = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
+  FIREBASE_CONFIG = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
 } catch (error: any) {
 } catch (error: any) {
   console.warn(
   console.warn(
-    `Error JSON parsing firebase config. Supplied value: ${process.env.REACT_APP_FIREBASE_CONFIG}`,
+    `Error JSON parsing firebase config. Supplied value: ${
+      import.meta.env.VITE_APP_FIREBASE_CONFIG
+    }`,
   );
   );
   FIREBASE_CONFIG = {};
   FIREBASE_CONFIG = {};
 }
 }

+ 5 - 5
src/excalidraw-app/data/index.ts

@@ -47,8 +47,8 @@ export const getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
     isSyncableElement(element),
     isSyncableElement(element),
   ) as SyncableExcalidrawElement[];
   ) as SyncableExcalidrawElement[];
 
 
-const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL;
-const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
+const BACKEND_V2_GET = import.meta.env.VITE_APP_BACKEND_V2_GET_URL;
+const BACKEND_V2_POST = import.meta.env.VITE_APP_BACKEND_V2_POST_URL;
 
 
 const generateRoomId = async () => {
 const generateRoomId = async () => {
   const buffer = new Uint8Array(ROOM_ID_BYTES);
   const buffer = new Uint8Array(ROOM_ID_BYTES);
@@ -67,16 +67,16 @@ export const getCollabServer = async (): Promise<{
   url: string;
   url: string;
   polling: boolean;
   polling: boolean;
 }> => {
 }> => {
-  if (process.env.REACT_APP_WS_SERVER_URL) {
+  if (import.meta.env.VITE_APP_WS_SERVER_URL) {
     return {
     return {
-      url: process.env.REACT_APP_WS_SERVER_URL,
+      url: import.meta.env.VITE_APP_WS_SERVER_URL,
       polling: true,
       polling: true,
     };
     };
   }
   }
 
 
   try {
   try {
     const resp = await fetch(
     const resp = await fetch(
-      `${process.env.REACT_APP_PORTAL_URL}/collab-server`,
+      `${import.meta.env.VITE_APP_PORTAL_URL}/collab-server`,
     );
     );
     return await resp.json();
     return await resp.json();
   } catch (error) {
   } catch (error) {

+ 36 - 1
src/excalidraw-app/index.tsx

@@ -100,6 +100,20 @@ polyfill();
 
 
 window.EXCALIDRAW_THROTTLE_RENDER = true;
 window.EXCALIDRAW_THROTTLE_RENDER = true;
 
 
+let isSelfEmbedding = false;
+
+if (window.self !== window.top) {
+  try {
+    const parentUrl = new URL(document.referrer);
+    const currentUrl = new URL(window.location.href);
+    if (parentUrl.origin === currentUrl.origin) {
+      isSelfEmbedding = true;
+    }
+  } catch (error) {
+    // ignore
+  }
+}
+
 const languageDetector = new LanguageDetector();
 const languageDetector = new LanguageDetector();
 languageDetector.init({
 languageDetector.init({
   languageUtils: {},
   languageUtils: {},
@@ -519,7 +533,9 @@ const ExcalidrawWrapper = () => {
 
 
   const [theme, setTheme] = useState<Theme>(
   const [theme, setTheme] = useState<Theme>(
     () =>
     () =>
-      localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) ||
+      (localStorage.getItem(
+        STORAGE_KEYS.LOCAL_STORAGE_THEME,
+      ) as Theme | null) ||
       // FIXME migration from old LS scheme. Can be removed later. #5660
       // FIXME migration from old LS scheme. Can be removed later. #5660
       importFromLocalStorage().appState?.theme ||
       importFromLocalStorage().appState?.theme ||
       THEME.LIGHT,
       THEME.LIGHT,
@@ -642,6 +658,25 @@ const ExcalidrawWrapper = () => {
 
 
   const isOffline = useAtomValue(isOfflineAtom);
   const isOffline = useAtomValue(isOfflineAtom);
 
 
+  // browsers generally prevent infinite self-embedding, there are
+  // cases where it still happens, and while we disallow self-embedding
+  // by not whitelisting our own origin, this serves as an additional guard
+  if (isSelfEmbedding) {
+    return (
+      <div
+        style={{
+          display: "flex",
+          alignItems: "center",
+          justifyContent: "center",
+          textAlign: "center",
+          height: "100%",
+        }}
+      >
+        <h1>I'm not a pretzel!</h1>
+      </div>
+    );
+  }
+
   return (
   return (
     <div
     <div
       style={{ height: "100%" }}
       style={{ height: "100%" }}

+ 0 - 31
src/excalidraw-app/pwa.ts

@@ -1,31 +0,0 @@
-import { register as registerServiceWorker } from "../serviceWorkerRegistration";
-import { EVENT } from "../constants";
-
-// On Apple mobile devices add the proprietary app icon and splashscreen markup.
-// No one should have to do this manually, and eventually this annoyance will
-// go away once https://bugs.webkit.org/show_bug.cgi?id=183937 is fixed.
-if (
-  /\b(iPad|iPhone|iPod|Safari)\b/.test(navigator.userAgent) &&
-  !matchMedia("(display-mode: standalone)").matches
-) {
-  import(/* webpackChunkName: "pwacompat" */ "pwacompat");
-}
-
-registerServiceWorker({
-  onUpdate: (registration) => {
-    const waitingServiceWorker = registration.waiting;
-    if (waitingServiceWorker) {
-      waitingServiceWorker.addEventListener(
-        EVENT.STATE_CHANGE,
-        (event: Event) => {
-          const target = event.target as ServiceWorker;
-          const state = target.state as ServiceWorkerState;
-          if (state === "activated") {
-            window.location.reload();
-          }
-        },
-      );
-      waitingServiceWorker.postMessage({ type: "SKIP_WAITING" });
-    }
-  },
-});

+ 2 - 2
src/excalidraw-app/sentry.ts

@@ -7,7 +7,7 @@ const SentryEnvHostnameMap: { [key: string]: string } = {
 };
 };
 
 
 const REACT_APP_DISABLE_SENTRY =
 const REACT_APP_DISABLE_SENTRY =
-  process.env.REACT_APP_DISABLE_SENTRY === "true";
+  import.meta.env.VITE_APP_DISABLE_SENTRY === "true";
 
 
 // Disable Sentry locally or inside the Docker to avoid noise/respect privacy
 // Disable Sentry locally or inside the Docker to avoid noise/respect privacy
 const onlineEnv =
 const onlineEnv =
@@ -21,7 +21,7 @@ Sentry.init({
     ? "https://[email protected]/5179260"
     ? "https://[email protected]/5179260"
     : undefined,
     : undefined,
   environment: onlineEnv ? SentryEnvHostnameMap[onlineEnv] : undefined,
   environment: onlineEnv ? SentryEnvHostnameMap[onlineEnv] : undefined,
-  release: process.env.REACT_APP_GIT_SHA,
+  release: import.meta.env.VITE_APP_GIT_SHA,
   ignoreErrors: [
   ignoreErrors: [
     "undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
     "undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
   ],
   ],

+ 7 - 2
src/frame.ts

@@ -16,7 +16,7 @@ import {
 } from "./element/textElement";
 } from "./element/textElement";
 import { arrayToMap, findIndex } from "./utils";
 import { arrayToMap, findIndex } from "./utils";
 import { mutateElement } from "./element/mutateElement";
 import { mutateElement } from "./element/mutateElement";
-import { AppState } from "./types";
+import { AppClassProperties, AppState } from "./types";
 import { getElementsWithinSelection, getSelectedElements } from "./scene";
 import { getElementsWithinSelection, getSelectedElements } from "./scene";
 import { isFrameElement } from "./element";
 import { isFrameElement } from "./element";
 import { moveOneRight } from "./zindex";
 import { moveOneRight } from "./zindex";
@@ -571,8 +571,13 @@ export const replaceAllElementsInFrame = (
 export const updateFrameMembershipOfSelectedElements = (
 export const updateFrameMembershipOfSelectedElements = (
   allElements: ExcalidrawElementsIncludingDeleted,
   allElements: ExcalidrawElementsIncludingDeleted,
   appState: AppState,
   appState: AppState,
+  app: AppClassProperties,
 ) => {
 ) => {
-  const selectedElements = getSelectedElements(allElements, appState);
+  const selectedElements = app.scene.getSelectedElements({
+    selectedElementIds: appState.selectedElementIds,
+    // supplying elements explicitly in case we're passed non-state elements
+    elements: allElements,
+  });
   const elementsToFilter = new Set<ExcalidrawElement>(selectedElements);
   const elementsToFilter = new Set<ExcalidrawElement>(selectedElements);
 
 
   if (appState.editingGroupId) {
   if (appState.editingGroupId) {

+ 6 - 10
src/global.d.ts

@@ -38,16 +38,6 @@ interface CanvasRenderingContext2D {
   ) => void;
   ) => void;
 }
 }
 
 
-// https://github.com/facebook/create-react-app/blob/ddcb7d5/packages/react-scripts/lib/react-app.d.ts
-declare namespace NodeJS {
-  interface ProcessEnv {
-    readonly REACT_APP_BACKEND_V2_GET_URL: string;
-    readonly REACT_APP_BACKEND_V2_POST_URL: string;
-    readonly REACT_APP_PORTAL_URL: string;
-    readonly REACT_APP_FIREBASE_CONFIG: string;
-  }
-}
-
 interface Clipboard extends EventTarget {
 interface Clipboard extends EventTarget {
   write(data: any[]): Promise<void>;
   write(data: any[]): Promise<void>;
 }
 }
@@ -120,3 +110,9 @@ declare module "image-blob-reduce" {
   const reduce: ImageBlobReduce.ImageBlobReduceStatic;
   const reduce: ImageBlobReduce.ImageBlobReduceStatic;
   export = reduce;
   export = reduce;
 }
 }
+
+declare namespace jest {
+  interface Expect {
+    toBeNonNaNNumber(): void;
+  }
+}

+ 20 - 4
src/groups.ts

@@ -1,5 +1,10 @@
-import { GroupId, ExcalidrawElement, NonDeleted } from "./element/types";
-import { AppState } from "./types";
+import {
+  GroupId,
+  ExcalidrawElement,
+  NonDeleted,
+  NonDeletedExcalidrawElement,
+} from "./element/types";
+import { AppClassProperties, AppState } from "./types";
 import { getSelectedElements } from "./scene";
 import { getSelectedElements } from "./scene";
 import { getBoundTextElement } from "./element/textElement";
 import { getBoundTextElement } from "./element/textElement";
 import { makeNextSelectedElementIds } from "./scene/selection";
 import { makeNextSelectedElementIds } from "./scene/selection";
@@ -67,12 +72,23 @@ export const getSelectedGroupIds = (appState: AppState): GroupId[] =>
  */
  */
 export const selectGroupsForSelectedElements = (
 export const selectGroupsForSelectedElements = (
   appState: AppState,
   appState: AppState,
-  elements: readonly NonDeleted<ExcalidrawElement>[],
+  elements: readonly NonDeletedExcalidrawElement[],
   prevAppState: AppState,
   prevAppState: AppState,
+  /**
+   * supply null in cases where you don't have access to App instance and
+   * you don't care about optimizing selectElements retrieval
+   */
+  app: AppClassProperties | null,
 ): AppState => {
 ): AppState => {
   let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
   let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
 
 
-  const selectedElements = getSelectedElements(elements, appState);
+  const selectedElements = app
+    ? app.scene.getSelectedElements({
+        selectedElementIds: appState.selectedElementIds,
+        // supplying elements explicitly in case we're passed non-state elements
+        elements,
+      })
+    : getSelectedElements(elements, appState);
 
 
   if (!selectedElements.length) {
   if (!selectedElements.length) {
     return {
     return {

Vissa filer visades inte eftersom för många filer har ändrats