Browse Source

Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage

Daniel J. Geiger 10 months ago
parent
commit
c93e2fa9ce
100 changed files with 3971 additions and 1791 deletions
  1. 1 0
      .dockerignore
  2. 0 2
      .env.development
  3. 3 0
      .eslintignore
  4. 0 1
      .github/workflows/test.yml
  5. 1 1
      Dockerfile
  6. 1 1
      dev-docs/docs/@excalidraw/excalidraw/api/children-components/main-menu.mdx
  7. 3 3
      dev-docs/docs/@excalidraw/excalidraw/api/props/props.mdx
  8. 1 1
      dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx
  9. 1 1
      dev-docs/docs/@excalidraw/mermaid-to-excalidraw/api.mdx
  10. 1 1
      dev-docs/docs/codebase/json-schema.mdx
  11. 3 3
      dev-docs/package.json
  12. 45 26
      dev-docs/yarn.lock
  13. 0 0
      examples/excalidraw/components/ExampleApp.scss
  14. 2 2
      examples/excalidraw/components/ExampleApp.tsx
  15. 4 4
      examples/excalidraw/with-nextjs/package.json
  16. 1 1
      examples/excalidraw/with-nextjs/src/excalidrawWrapper.tsx
  17. 1 1
      examples/excalidraw/with-script-in-browser/index.tsx
  18. 45 58
      excalidraw-app/App.tsx
  19. 28 33
      excalidraw-app/CustomStats.tsx
  20. 1 0
      excalidraw-app/app_constants.ts
  21. 20 14
      excalidraw-app/collab/Portal.tsx
  22. 0 218
      excalidraw-app/collab/RoomDialog.tsx
  23. 159 0
      excalidraw-app/components/AI.tsx
  24. 23 19
      excalidraw-app/components/AppFooter.tsx
  25. 21 0
      excalidraw-app/components/AppMainMenu.tsx
  26. 311 0
      excalidraw-app/components/DebugCanvas.tsx
  27. 14 1
      excalidraw-app/data/LocalData.ts
  28. 16 72
      excalidraw-app/index.html
  29. 11 1
      excalidraw-app/package.json
  30. 2 2
      excalidraw-app/share/ShareDialog.scss
  31. 13 23
      excalidraw-app/share/ShareDialog.tsx
  32. 5 6
      excalidraw-app/tests/collab.test.tsx
  33. 18 5
      excalidraw-app/vite.config.mts
  34. 18 21
      package.json
  35. 4 0
      packages/excalidraw/CHANGELOG.md
  36. 69 42
      packages/excalidraw/actions/actionCanvas.tsx
  37. 2 10
      packages/excalidraw/actions/actionClipboard.tsx
  38. 36 5
      packages/excalidraw/actions/actionDeleteSelected.tsx
  39. 15 15
      packages/excalidraw/actions/actionDuplicateSelection.tsx
  40. 1 0
      packages/excalidraw/actions/actionElementLock.test.tsx
  41. 14 10
      packages/excalidraw/actions/actionFinalize.tsx
  42. 211 0
      packages/excalidraw/actions/actionFlip.test.tsx
  43. 71 2
      packages/excalidraw/actions/actionFlip.ts
  44. 12 8
      packages/excalidraw/actions/actionHistory.tsx
  45. 3 2
      packages/excalidraw/actions/actionLinearEditor.tsx
  46. 11 12
      packages/excalidraw/actions/actionProperties.test.tsx
  47. 255 27
      packages/excalidraw/actions/actionProperties.tsx
  48. 4 5
      packages/excalidraw/actions/actionToggleGridMode.tsx
  49. 1 1
      packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx
  50. 55 0
      packages/excalidraw/actions/actionToggleSearchMenu.ts
  51. 2 0
      packages/excalidraw/actions/index.ts
  52. 3 1
      packages/excalidraw/actions/shortcuts.ts
  53. 5 2
      packages/excalidraw/actions/types.ts
  54. 1 1
      packages/excalidraw/analytics.ts
  55. 20 5
      packages/excalidraw/appState.ts
  56. 105 0
      packages/excalidraw/binaryheap.ts
  57. 6 2
      packages/excalidraw/change.ts
  58. 7 27
      packages/excalidraw/charts.ts
  59. 16 21
      packages/excalidraw/components/Actions.tsx
  60. 401 229
      packages/excalidraw/components/App.tsx
  61. 1 1
      packages/excalidraw/components/ColorPicker/ColorPicker.tsx
  62. 14 1
      packages/excalidraw/components/CommandPalette/CommandPalette.tsx
  63. 1 1
      packages/excalidraw/components/DefaultSidebar.test.tsx
  64. 28 25
      packages/excalidraw/components/DefaultSidebar.tsx
  65. 17 0
      packages/excalidraw/components/DiagramToCodePlugin/DiagramToCodePlugin.tsx
  66. 69 2
      packages/excalidraw/components/FilledButton.scss
  67. 26 4
      packages/excalidraw/components/FilledButton.tsx
  68. 6 6
      packages/excalidraw/components/FontPicker/FontPickerList.tsx
  69. 14 0
      packages/excalidraw/components/HelpDialog.tsx
  70. 1 0
      packages/excalidraw/components/HintViewer.scss
  71. 53 12
      packages/excalidraw/components/HintViewer.tsx
  72. 28 5
      packages/excalidraw/components/ImageExportDialog.tsx
  73. 0 93
      packages/excalidraw/components/LayerUI.scss
  74. 5 36
      packages/excalidraw/components/LayerUI.tsx
  75. 0 18
      packages/excalidraw/components/MagicSettings.scss
  76. 0 160
      packages/excalidraw/components/MagicSettings.tsx
  77. 1 0
      packages/excalidraw/components/PublishLibrary.tsx
  78. 110 0
      packages/excalidraw/components/SearchMenu.scss
  79. 718 0
      packages/excalidraw/components/SearchMenu.tsx
  80. 2 2
      packages/excalidraw/components/ShareableLinkDialog.scss
  81. 14 24
      packages/excalidraw/components/ShareableLinkDialog.tsx
  82. 43 62
      packages/excalidraw/components/Sidebar/Sidebar.test.tsx
  83. 42 0
      packages/excalidraw/components/Sidebar/siderbar.test.helpers.tsx
  84. 3 1
      packages/excalidraw/components/Spinner.tsx
  85. 11 9
      packages/excalidraw/components/Stats/Angle.tsx
  86. 67 0
      packages/excalidraw/components/Stats/CanvasGrid.tsx
  87. 5 1
      packages/excalidraw/components/Stats/Collapsible.tsx
  88. 5 1
      packages/excalidraw/components/Stats/Dimension.tsx
  89. 7 6
      packages/excalidraw/components/Stats/DragInput.scss
  90. 87 47
      packages/excalidraw/components/Stats/DragInput.tsx
  91. 6 5
      packages/excalidraw/components/Stats/MultiAngle.tsx
  92. 19 6
      packages/excalidraw/components/Stats/MultiDimension.tsx
  93. 27 17
      packages/excalidraw/components/Stats/MultiPosition.tsx
  94. 13 12
      packages/excalidraw/components/Stats/Position.tsx
  95. 72 0
      packages/excalidraw/components/Stats/Stats.scss
  96. 227 136
      packages/excalidraw/components/Stats/index.tsx
  97. 90 122
      packages/excalidraw/components/Stats/stats.test.tsx
  98. 34 16
      packages/excalidraw/components/Stats/utils.ts
  99. 1 1
      packages/excalidraw/components/TTDDialog/TTDDialog.tsx
  100. 1 11
      packages/excalidraw/components/TTDDialog/TTDDialogTabs.tsx

+ 1 - 0
.dockerignore

@@ -8,6 +8,7 @@
 !package.json
 !public/
 !packages/
+!scripts/
 !tsconfig.json
 !yarn.lock
 

+ 0 - 2
.env.development

@@ -17,8 +17,6 @@ VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","a
 # put these in your .env.local, or make sure you don't commit!
 # must be lowercase `true` when turned on
 #
-# whether to enable Service Workers in development
-VITE_APP_DEV_ENABLE_SW=
 # whether to disable live reload / HMR. Usuaully what you want to do when
 # debugging Service Workers.
 VITE_APP_DEV_DISABLE_LIVE_RELOAD=

+ 3 - 0
.eslintignore

@@ -6,3 +6,6 @@ firebase/
 dist/
 public/workbox
 packages/excalidraw/types
+examples/**/public
+dev-dist
+coverage

+ 0 - 1
.github/workflows/test.yml

@@ -1,7 +1,6 @@
 name: Tests
 
 on:
-  pull_request:
   push:
     branches: master
 

+ 1 - 1
Dockerfile

@@ -12,7 +12,7 @@ ARG NODE_ENV=production
 
 RUN yarn build:app:docker
 
-FROM nginx:1.24-alpine
+FROM nginx:1.27-alpine
 
 COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
 

+ 1 - 1
dev-docs/docs/@excalidraw/excalidraw/api/children-components/main-menu.mdx

@@ -133,7 +133,7 @@ function App() {
 }
 ```
 
-Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/mainMenu/DefaultItems.tsx) of the default items.
+Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/main-menu/DefaultItems.tsx) of the default items.
 
 ### MainMenu.Group
 

+ 3 - 3
dev-docs/docs/@excalidraw/excalidraw/api/props/props.mdx

@@ -9,9 +9,9 @@ All `props` are _optional_.
 | [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode |
 | [`onChange`](#onchange) | `function` | _ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. |
 | [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. |
-| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down evenets |
+| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down events |
 | [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. |
-| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when the something is pasted in to the scene |
+| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when something is pasted into the scene |
 | [`onLibraryChange`](#onlibrarychange) | `function` | _ | The callback if supplied is triggered when the library is updated and receives the library items. |
 | [`onLinkOpen`](#onlinkopen) | `function` | _ | The callback if supplied is triggered when any link is opened. |
 | [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw |
@@ -26,7 +26,7 @@ All `props` are _optional_.
 | [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) |
 | [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. |
 | [`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 |
 | [`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>` |

+ 1 - 1
dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx

@@ -20,7 +20,7 @@ exportToCanvas(&#123;<br/>&nbsp;
   getDimensions,<br/>&nbsp;
   files,<br/>&nbsp;
   exportPadding?: number;<br/>
-&#125;: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L21">ExportOpts</a>
+&#125;: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/utils/export.ts#L24">ExportOpts</a>
 </pre>
 
 | Name | Type | Default | Description |

+ 1 - 1
dev-docs/docs/@excalidraw/mermaid-to-excalidraw/api.mdx

@@ -14,7 +14,7 @@ This API receives the mermaid syntax as the input, and resolves to skeleton Exca
 import { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
 import { convertToExcalidrawElements}  from "@excalidraw/excalidraw"
 try {
-  const { elements, files } = await parseMermaid(mermaidSyntax: string, {
+  const { elements, files } = await parseMermaidToExcalidraw(mermaidSyntax: string, {
     fontSize: number,
   });
   const excalidrawElements = convertToExcalidrawElements(elements);

+ 1 - 1
dev-docs/docs/codebase/json-schema.mdx

@@ -43,7 +43,7 @@ When saving an Excalidraw scene locally to a file, the JSON file (`.excalidraw`)
 
   // editor state (canvas config, preferences, ...)
   "appState": {
-    "gridSize": null,
+    "gridSize": 20,
     "viewBackgroundColor": "#ffffff"
   },
 

+ 3 - 3
dev-docs/package.json

@@ -18,13 +18,13 @@
     "@docusaurus/core": "2.2.0",
     "@docusaurus/preset-classic": "2.2.0",
     "@docusaurus/theme-live-codeblock": "2.2.0",
-    "@excalidraw/excalidraw": "0.17.0",
+    "@excalidraw/excalidraw": "0.17.6",
     "@mdx-js/react": "^1.6.22",
     "clsx": "^1.2.1",
     "docusaurus-plugin-sass": "0.2.3",
     "prism-react-renderer": "^1.3.5",
-    "react": "^17.0.2",
-    "react-dom": "^17.0.2",
+    "react": "18.2.0",
+    "react-dom": "18.2.0",
     "sass": "1.57.1"
   },
   "devDependencies": {

+ 45 - 26
dev-docs/yarn.lock

@@ -1547,7 +1547,7 @@
     "@docusaurus/theme-search-algolia" "2.2.0"
     "@docusaurus/types" "2.2.0"
 
-"@docusaurus/[email protected]", "react-loadable@npm:@docusaurus/[email protected]":
+"@docusaurus/[email protected]":
   version "5.5.2"
   resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
   integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
@@ -1718,10 +1718,10 @@
     url-loader "^4.1.1"
     webpack "^5.73.0"
 
-"@excalidraw/[email protected].0":
-  version "0.17.0"
-  resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.0.tgz#3c64aa8e36406ac171b008cfecbdce5bb0755725"
-  integrity sha512-NzP22v5xMqxYW27ZtTHhiGFe7kE8NeBk45aoeM/mDSkXiOXPDH+PcvwzHRN/Ei+Vj/0sTPHxejn8bZyRWKGjXg==
+"@excalidraw/[email protected].6":
+  version "0.17.6"
+  resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.6.tgz#5fd208ce69d33ca712d1804b50d7d06d5c46ac4d"
+  integrity sha512-fyCl+zG/Z5yhHDh5Fq2ZGmphcrALmuOdtITm8gN4d8w4ntnaopTXcTfnAAaU3VleDC6LhTkoLOTG6P5kgREiIg==
 
 "@hapi/hoek@^9.0.0":
   version "9.3.0"
@@ -2789,7 +2789,14 @@ brace-expansion@^1.1.7:
     balanced-match "^1.0.0"
     concat-map "0.0.1"
 
-braces@^3.0.2, braces@~3.0.2:
+braces@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
+  integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
+  dependencies:
+    fill-range "^7.1.1"
+
+braces@~3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
   integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
@@ -4011,6 +4018,13 @@ fill-range@^7.0.1:
   dependencies:
     to-regex-range "^5.0.1"
 
+fill-range@^7.1.1:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
+  integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
+  dependencies:
+    to-regex-range "^5.0.1"
+
 [email protected]:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32"
@@ -5207,11 +5221,11 @@ methods@~1.1.2:
   integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
 
 micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
-  version "4.0.5"
-  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
-  integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
+  integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
   dependencies:
-    braces "^3.0.2"
+    braces "^3.0.3"
     picomatch "^2.3.1"
 
 [email protected], "mime-db@>= 1.43.0 < 2":
@@ -6190,14 +6204,13 @@ react-dev-utils@^12.0.1:
     strip-ansi "^6.0.1"
     text-table "^0.2.0"
 
-react-dom@^17.0.2:
-  version "17.0.2"
-  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
-  integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
+react-dom@18.2.0:
+  version "18.2.0"
+  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
+  integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
   dependencies:
     loose-envify "^1.1.0"
-    object-assign "^4.1.1"
-    scheduler "^0.20.2"
+    scheduler "^0.23.0"
 
 react-error-overlay@^6.0.11:
   version "6.0.11"
@@ -6260,6 +6273,14 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
   dependencies:
     "@babel/runtime" "^7.10.3"
 
+"react-loadable@npm:@docusaurus/[email protected]":
+  version "5.5.2"
+  resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
+  integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
+  dependencies:
+    "@types/react" "*"
+    prop-types "^15.6.2"
+
 react-router-config@^5.1.1:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988"
@@ -6310,13 +6331,12 @@ react-textarea-autosize@^8.3.2:
     use-composed-ref "^1.3.0"
     use-latest "^1.2.1"
 
-react@^17.0.2:
-  version "17.0.2"
-  resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
-  integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
+react@18.2.0:
+  version "18.2.0"
+  resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
+  integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
   dependencies:
     loose-envify "^1.1.0"
-    object-assign "^4.1.1"
 
 readable-stream@^2.0.1:
   version "2.3.7"
@@ -6664,13 +6684,12 @@ sax@^1.2.4:
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
   integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
 
-scheduler@^0.20.2:
-  version "0.20.2"
-  resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
-  integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==
+scheduler@^0.23.0:
+  version "0.23.2"
+  resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3"
+  integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
   dependencies:
     loose-envify "^1.1.0"
-    object-assign "^4.1.1"
 
 [email protected]:
   version "2.7.0"

+ 0 - 0
examples/excalidraw/components/App.scss → examples/excalidraw/components/ExampleApp.scss


+ 2 - 2
examples/excalidraw/components/App.tsx → examples/excalidraw/components/ExampleApp.tsx

@@ -40,7 +40,7 @@ import type {
 } from "@excalidraw/excalidraw/dist/excalidraw/element/types";
 import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types";
 
-import "./App.scss";
+import "./ExampleApp.scss";
 
 type Comment = {
   x: number;
@@ -73,7 +73,7 @@ export interface AppProps {
   excalidrawLib: typeof TExcalidraw;
 }
 
-export default function App({
+export default function ExampleApp({
   appTitle,
   useCustom,
   customArgs,

+ 4 - 4
examples/excalidraw/with-nextjs/package.json

@@ -13,13 +13,13 @@
   "dependencies": {
     "@excalidraw/excalidraw": "*",
     "next": "14.1",
-    "react": "^18",
-    "react-dom": "^18"
+    "react": "18.2.0",
+    "react-dom": "18.2.0"
   },
   "devDependencies": {
     "@types/node": "^20",
-    "@types/react": "^18",
-    "@types/react-dom": "^18",
+    "@types/react": "18.2.0",
+    "@types/react-dom": "18.2.0",
     "path2d-polyfill": "2.0.1",
     "typescript": "^5"
   }

+ 1 - 1
examples/excalidraw/with-nextjs/src/excalidrawWrapper.tsx

@@ -1,7 +1,7 @@
 "use client";
 import * as excalidrawLib from "@excalidraw/excalidraw";
 import { Excalidraw } from "@excalidraw/excalidraw";
-import App from "../../components/App";
+import App from "../../components/ExampleApp";
 
 import "@excalidraw/excalidraw/index.css";
 

+ 1 - 1
examples/excalidraw/with-script-in-browser/index.tsx

@@ -1,4 +1,4 @@
-import App from "../components/App";
+import App from "../components/ExampleApp";
 import React, { StrictMode } from "react";
 import { createRoot } from "react-dom/client";
 

+ 45 - 58
excalidraw-app/App.tsx

@@ -23,7 +23,6 @@ import { t } from "../packages/excalidraw/i18n";
 import {
   Excalidraw,
   LiveCollaborationTrigger,
-  TTDDialog,
   TTDDialogTrigger,
   StoreAction,
   reconcileElements,
@@ -122,6 +121,12 @@ import {
 import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
 import { getPreferredLanguage } from "./app-language/language-detector";
 import { useAppLangCode } from "./app-language/language-state";
+import DebugCanvas, {
+  debugRenderer,
+  isVisualDebuggerEnabled,
+  loadSavedDebugState,
+} from "./components/DebugCanvas";
+import { AIComponents } from "./components/AI";
 
 polyfill();
 
@@ -338,6 +343,8 @@ const ExcalidrawWrapper = () => {
       resolvablePromise<ExcalidrawInitialDataState | null>();
   }
 
+  const debugCanvasRef = useRef<HTMLCanvasElement>(null);
+
   useEffect(() => {
     trackEvent("load", "frame", getFrame());
     // Delayed so that the app has a time to load the latest SW
@@ -365,6 +372,23 @@ const ExcalidrawWrapper = () => {
     migrationAdapter: LibraryLocalStorageMigrationAdapter,
   });
 
+  const [, forceRefresh] = useState(false);
+
+  useEffect(() => {
+    if (import.meta.env.DEV) {
+      const debugState = loadSavedDebugState();
+
+      if (debugState.enabled && !window.visualDebug) {
+        window.visualDebug = {
+          data: [],
+        };
+      } else {
+        delete window.visualDebug;
+      }
+      forceRefresh((prev) => !prev);
+    }
+  }, [excalidrawAPI]);
+
   useEffect(() => {
     if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
       return;
@@ -625,6 +649,16 @@ const ExcalidrawWrapper = () => {
         }
       });
     }
+
+    // Render the debug scene if the debug canvas is available
+    if (debugCanvasRef.current && excalidrawAPI) {
+      debugRenderer(
+        debugCanvasRef.current,
+        appState,
+        window.devicePixelRatio,
+        () => forceRefresh((prev) => !prev),
+      );
+    }
   };
 
   const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
@@ -823,6 +857,7 @@ const ExcalidrawWrapper = () => {
           isCollabEnabled={!isCollabDisabled}
           theme={appTheme}
           setTheme={(theme) => setAppTheme(theme)}
+          refresh={() => forceRefresh((prev) => !prev)}
         />
         <AppWelcomeScreen
           onCollabDialogOpen={onCollabDialogOpen}
@@ -848,64 +883,9 @@ const ExcalidrawWrapper = () => {
             </OverwriteConfirmDialog.Action>
           )}
         </OverwriteConfirmDialog>
-        <AppFooter />
-        <TTDDialog
-          onTextSubmit={async (input) => {
-            try {
-              const response = await fetch(
-                `${
-                  import.meta.env.VITE_APP_AI_BACKEND
-                }/v1/ai/text-to-diagram/generate`,
-                {
-                  method: "POST",
-                  headers: {
-                    Accept: "application/json",
-                    "Content-Type": "application/json",
-                  },
-                  body: JSON.stringify({ prompt: input }),
-                },
-              );
-
-              const rateLimit = response.headers.has("X-Ratelimit-Limit")
-                ? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
-                : undefined;
-
-              const rateLimitRemaining = response.headers.has(
-                "X-Ratelimit-Remaining",
-              )
-                ? parseInt(
-                    response.headers.get("X-Ratelimit-Remaining") || "0",
-                    10,
-                  )
-                : undefined;
-
-              const json = await response.json();
-
-              if (!response.ok) {
-                if (response.status === 429) {
-                  return {
-                    rateLimit,
-                    rateLimitRemaining,
-                    error: new Error(
-                      "Too many requests today, please try again tomorrow!",
-                    ),
-                  };
-                }
-
-                throw new Error(json.message || "Generation failed...");
-              }
-
-              const generatedResponse = json.generatedResponse;
-              if (!generatedResponse) {
-                throw new Error("Generation failed...");
-              }
+        <AppFooter onChange={() => excalidrawAPI?.refresh()} />
+        {excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
 
-              return { generatedResponse, rateLimit, rateLimitRemaining };
-            } catch (err: any) {
-              throw new Error("Request failed");
-            }
-          }}
-        />
         <TTDDialogTrigger />
         {isCollaborating && isOffline && (
           <div className="collab-offline-warning">
@@ -1135,6 +1115,13 @@ const ExcalidrawWrapper = () => {
             },
           ]}
         />
+        {isVisualDebuggerEnabled() && excalidrawAPI && (
+          <DebugCanvas
+            appState={excalidrawAPI.getAppState()}
+            scale={window.devicePixelRatio}
+            ref={debugCanvasRef}
+          />
+        )}
       </Excalidraw>
     </div>
   );

+ 28 - 33
excalidraw-app/CustomStats.tsx

@@ -9,6 +9,7 @@ import { t } from "../packages/excalidraw/i18n";
 import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
 import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
 import type { UIAppState } from "../packages/excalidraw/types";
+import { Stats } from "../packages/excalidraw";
 
 type StorageSizes = { scene: number; total: number };
 
@@ -51,39 +52,33 @@ const CustomStats = (props: Props) => {
   }
 
   return (
-    <>
-      <tr>
-        <th colSpan={2}>{t("stats.storage")}</th>
-      </tr>
-      <tr>
-        <td>{t("stats.scene")}</td>
-        <td>{nFormatter(storageSizes.scene, 1)}</td>
-      </tr>
-      <tr>
-        <td>{t("stats.total")}</td>
-        <td>{nFormatter(storageSizes.total, 1)}</td>
-      </tr>
-      <tr>
-        <th colSpan={2}>{t("stats.version")}</th>
-      </tr>
-      <tr>
-        <td
-          colSpan={2}
-          style={{ textAlign: "center", cursor: "pointer" }}
-          onClick={async () => {
-            try {
-              await copyTextToSystemClipboard(getVersion());
-              props.setToast(t("toast.copyToClipboard"));
-            } catch {}
-          }}
-          title={t("stats.versionCopy")}
-        >
-          {timestamp}
-          <br />
-          {hash}
-        </td>
-      </tr>
-    </>
+    <Stats.StatsRows order={-1}>
+      <Stats.StatsRow heading>{t("stats.version")}</Stats.StatsRow>
+      <Stats.StatsRow
+        style={{ textAlign: "center", cursor: "pointer" }}
+        onClick={async () => {
+          try {
+            await copyTextToSystemClipboard(getVersion());
+            props.setToast(t("toast.copyToClipboard"));
+          } catch {}
+        }}
+        title={t("stats.versionCopy")}
+      >
+        {timestamp}
+        <br />
+        {hash}
+      </Stats.StatsRow>
+
+      <Stats.StatsRow heading>{t("stats.storage")}</Stats.StatsRow>
+      <Stats.StatsRow columns={2}>
+        <div>{t("stats.scene")}</div>
+        <div>{nFormatter(storageSizes.scene, 1)}</div>
+      </Stats.StatsRow>
+      <Stats.StatsRow columns={2}>
+        <div>{t("stats.total")}</div>
+        <div>{nFormatter(storageSizes.total, 1)}</div>
+      </Stats.StatsRow>
+    </Stats.StatsRows>
   );
 };
 

+ 1 - 0
excalidraw-app/app_constants.ts

@@ -40,6 +40,7 @@ export const STORAGE_KEYS = {
   LOCAL_STORAGE_APP_STATE: "excalidraw-state",
   LOCAL_STORAGE_COLLAB: "excalidraw-collab",
   LOCAL_STORAGE_THEME: "excalidraw-theme",
+  LOCAL_STORAGE_DEBUG: "excalidraw-debug",
   VERSION_DATA_STATE: "version-dataState",
   VERSION_FILES: "version-files",
 

+ 20 - 14
excalidraw-app/collab/Portal.tsx

@@ -116,20 +116,26 @@ class Portal {
       }
     }
 
-    this.collab.excalidrawAPI.updateScene({
-      elements: this.collab.excalidrawAPI
-        .getSceneElementsIncludingDeleted()
-        .map((element) => {
-          if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
-            // this will signal collaborators to pull image data from server
-            // (using mutation instead of newElementWith otherwise it'd break
-            // in-progress dragging)
-            return newElementWith(element, { status: "saved" });
-          }
-          return element;
-        }),
-      storeAction: StoreAction.UPDATE,
-    });
+    let isChanged = false;
+    const newElements = this.collab.excalidrawAPI
+      .getSceneElementsIncludingDeleted()
+      .map((element) => {
+        if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
+          isChanged = true;
+          // this will signal collaborators to pull image data from server
+          // (using mutation instead of newElementWith otherwise it'd break
+          // in-progress dragging)
+          return newElementWith(element, { status: "saved" });
+        }
+        return element;
+      });
+
+    if (isChanged) {
+      this.collab.excalidrawAPI.updateScene({
+        elements: newElements,
+        storeAction: StoreAction.UPDATE,
+      });
+    }
   }, FILE_UPLOAD_TIMEOUT);
 
   broadcastScene = async (

+ 0 - 218
excalidraw-app/collab/RoomDialog.tsx

@@ -1,218 +0,0 @@
-import { useRef, useState } from "react";
-import * as Popover from "@radix-ui/react-popover";
-
-import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
-import { trackEvent } from "../../packages/excalidraw/analytics";
-import { getFrame } from "../../packages/excalidraw/utils";
-import { useI18n } from "../../packages/excalidraw/i18n";
-import { KEYS } from "../../packages/excalidraw/keys";
-
-import { Dialog } from "../../packages/excalidraw/components/Dialog";
-import {
-  copyIcon,
-  playerPlayIcon,
-  playerStopFilledIcon,
-  share,
-  shareIOS,
-  shareWindows,
-  tablerCheckIcon,
-} from "../../packages/excalidraw/components/icons";
-import { TextField } from "../../packages/excalidraw/components/TextField";
-import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
-
-import { ReactComponent as CollabImage } from "../../packages/excalidraw/assets/lock.svg";
-import "./RoomDialog.scss";
-
-const getShareIcon = () => {
-  const navigator = window.navigator as any;
-  const isAppleBrowser = /Apple/.test(navigator.vendor);
-  const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
-
-  if (isAppleBrowser) {
-    return shareIOS;
-  } else if (isWindowsBrowser) {
-    return shareWindows;
-  }
-
-  return share;
-};
-
-export type RoomModalProps = {
-  handleClose: () => void;
-  activeRoomLink: string;
-  username: string;
-  onUsernameChange: (username: string) => void;
-  onRoomCreate: () => void;
-  onRoomDestroy: () => void;
-  setErrorMessage: (message: string) => void;
-};
-
-export const RoomModal = ({
-  activeRoomLink,
-  onRoomCreate,
-  onRoomDestroy,
-  setErrorMessage,
-  username,
-  onUsernameChange,
-  handleClose,
-}: RoomModalProps) => {
-  const { t } = useI18n();
-  const [justCopied, setJustCopied] = useState(false);
-  const timerRef = useRef<number>(0);
-  const ref = useRef<HTMLInputElement>(null);
-  const isShareSupported = "share" in navigator;
-
-  const copyRoomLink = async () => {
-    try {
-      await copyTextToSystemClipboard(activeRoomLink);
-    } catch (e) {
-      setErrorMessage(t("errors.copyToSystemClipboardFailed"));
-    }
-    setJustCopied(true);
-
-    if (timerRef.current) {
-      window.clearTimeout(timerRef.current);
-    }
-
-    timerRef.current = window.setTimeout(() => {
-      setJustCopied(false);
-    }, 3000);
-
-    ref.current?.select();
-  };
-
-  const shareRoomLink = async () => {
-    try {
-      await navigator.share({
-        title: t("roomDialog.shareTitle"),
-        text: t("roomDialog.shareTitle"),
-        url: activeRoomLink,
-      });
-    } catch (error: any) {
-      // Just ignore.
-    }
-  };
-
-  if (activeRoomLink) {
-    return (
-      <>
-        <h3 className="RoomDialog__active__header">
-          {t("labels.liveCollaboration")}
-        </h3>
-        <TextField
-          value={username}
-          placeholder="Your name"
-          label="Your name"
-          onChange={onUsernameChange}
-          onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
-        />
-        <div className="RoomDialog__active__linkRow">
-          <TextField
-            ref={ref}
-            label="Link"
-            readonly
-            fullWidth
-            value={activeRoomLink}
-          />
-          {isShareSupported && (
-            <FilledButton
-              size="large"
-              variant="icon"
-              label="Share"
-              icon={getShareIcon()}
-              className="RoomDialog__active__share"
-              onClick={shareRoomLink}
-            />
-          )}
-          <Popover.Root open={justCopied}>
-            <Popover.Trigger asChild>
-              <FilledButton
-                size="large"
-                label="Copy link"
-                icon={copyIcon}
-                onClick={copyRoomLink}
-              />
-            </Popover.Trigger>
-            <Popover.Content
-              onOpenAutoFocus={(event) => event.preventDefault()}
-              onCloseAutoFocus={(event) => event.preventDefault()}
-              className="RoomDialog__popover"
-              side="top"
-              align="end"
-              sideOffset={5.5}
-            >
-              {tablerCheckIcon} copied
-            </Popover.Content>
-          </Popover.Root>
-        </div>
-        <div className="RoomDialog__active__description">
-          <p>
-            <span
-              role="img"
-              aria-hidden="true"
-              className="RoomDialog__active__description__emoji"
-            >
-              🔒{" "}
-            </span>
-            {t("roomDialog.desc_privacy")}
-          </p>
-          <p>{t("roomDialog.desc_exitSession")}</p>
-        </div>
-
-        <div className="RoomDialog__active__actions">
-          <FilledButton
-            size="large"
-            variant="outlined"
-            color="danger"
-            label={t("roomDialog.button_stopSession")}
-            icon={playerStopFilledIcon}
-            onClick={() => {
-              trackEvent("share", "room closed");
-              onRoomDestroy();
-            }}
-          />
-        </div>
-      </>
-    );
-  }
-
-  return (
-    <>
-      <div className="RoomDialog__inactive__illustration">
-        <CollabImage />
-      </div>
-      <div className="RoomDialog__inactive__header">
-        {t("labels.liveCollaboration")}
-      </div>
-
-      <div className="RoomDialog__inactive__description">
-        <strong>{t("roomDialog.desc_intro")}</strong>
-        {t("roomDialog.desc_privacy")}
-      </div>
-
-      <div className="RoomDialog__inactive__start_session">
-        <FilledButton
-          size="large"
-          label={t("roomDialog.button_startSession")}
-          icon={playerPlayIcon}
-          onClick={() => {
-            trackEvent("share", "room creation", `ui (${getFrame()})`);
-            onRoomCreate();
-          }}
-        />
-      </div>
-    </>
-  );
-};
-
-const RoomDialog = (props: RoomModalProps) => {
-  return (
-    <Dialog size="small" onCloseRequest={props.handleClose} title={false}>
-      <div className="RoomDialog">
-        <RoomModal {...props} />
-      </div>
-    </Dialog>
-  );
-};
-
-export default RoomDialog;

+ 159 - 0
excalidraw-app/components/AI.tsx

@@ -0,0 +1,159 @@
+import type { ExcalidrawImperativeAPI } from "../../packages/excalidraw/types";
+import {
+  DiagramToCodePlugin,
+  exportToBlob,
+  getTextFromElements,
+  MIME_TYPES,
+  TTDDialog,
+} from "../../packages/excalidraw";
+import { getDataURL } from "../../packages/excalidraw/data/blob";
+import { safelyParseJSON } from "../../packages/excalidraw/utils";
+
+export const AIComponents = ({
+  excalidrawAPI,
+}: {
+  excalidrawAPI: ExcalidrawImperativeAPI;
+}) => {
+  return (
+    <>
+      <DiagramToCodePlugin
+        generate={async ({ frame, children }) => {
+          const appState = excalidrawAPI.getAppState();
+
+          const blob = await exportToBlob({
+            elements: children,
+            appState: {
+              ...appState,
+              exportBackground: true,
+              viewBackgroundColor: appState.viewBackgroundColor,
+            },
+            exportingFrame: frame,
+            files: excalidrawAPI.getFiles(),
+            mimeType: MIME_TYPES.jpg,
+          });
+
+          const dataURL = await getDataURL(blob);
+
+          const textFromFrameChildren = getTextFromElements(children);
+
+          const response = await fetch(
+            `${
+              import.meta.env.VITE_APP_AI_BACKEND
+            }/v1/ai/diagram-to-code/generate`,
+            {
+              method: "POST",
+              headers: {
+                Accept: "application/json",
+                "Content-Type": "application/json",
+              },
+              body: JSON.stringify({
+                texts: textFromFrameChildren,
+                image: dataURL,
+                theme: appState.theme,
+              }),
+            },
+          );
+
+          if (!response.ok) {
+            const text = await response.text();
+            const errorJSON = safelyParseJSON(text);
+
+            if (!errorJSON) {
+              throw new Error(text);
+            }
+
+            if (errorJSON.statusCode === 429) {
+              return {
+                html: `<html>
+                <body style="margin: 0; text-align: center">
+                <div style="display: flex; align-items: center; justify-content: center; flex-direction: column; height: 100vh; padding: 0 60px">
+                  <div style="color:red">Too many requests today,</br>please try again tomorrow!</div>
+                  </br>
+                  </br>
+                  <div>You can also try <a href="${
+                    import.meta.env.VITE_APP_PLUS_LP
+                  }/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div>
+                </div>
+                </body>
+                </html>`,
+              };
+            }
+
+            throw new Error(errorJSON.message || text);
+          }
+
+          try {
+            const { html } = await response.json();
+
+            if (!html) {
+              throw new Error("Generation failed (invalid response)");
+            }
+            return {
+              html,
+            };
+          } catch (error: any) {
+            throw new Error("Generation failed (invalid response)");
+          }
+        }}
+      />
+
+      <TTDDialog
+        onTextSubmit={async (input) => {
+          try {
+            const response = await fetch(
+              `${
+                import.meta.env.VITE_APP_AI_BACKEND
+              }/v1/ai/text-to-diagram/generate`,
+              {
+                method: "POST",
+                headers: {
+                  Accept: "application/json",
+                  "Content-Type": "application/json",
+                },
+                body: JSON.stringify({ prompt: input }),
+              },
+            );
+
+            const rateLimit = response.headers.has("X-Ratelimit-Limit")
+              ? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
+              : undefined;
+
+            const rateLimitRemaining = response.headers.has(
+              "X-Ratelimit-Remaining",
+            )
+              ? parseInt(
+                  response.headers.get("X-Ratelimit-Remaining") || "0",
+                  10,
+                )
+              : undefined;
+
+            const json = await response.json();
+
+            if (!response.ok) {
+              if (response.status === 429) {
+                return {
+                  rateLimit,
+                  rateLimitRemaining,
+                  error: new Error(
+                    "Too many requests today, please try again tomorrow!",
+                  ),
+                };
+              }
+
+              throw new Error(json.message || "Generation failed...");
+            }
+
+            const generatedResponse = json.generatedResponse;
+            if (!generatedResponse) {
+              throw new Error("Generation failed...");
+            }
+
+            return { generatedResponse, rateLimit, rateLimitRemaining };
+          } catch (err: any) {
+            throw new Error("Request failed");
+          }
+        }}
+      />
+    </>
+  );
+};

+ 23 - 19
excalidraw-app/components/AppFooter.tsx

@@ -3,23 +3,27 @@ import { Footer } from "../../packages/excalidraw/index";
 import { EncryptedIcon } from "./EncryptedIcon";
 import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
 import { isExcalidrawPlusSignedUser } from "../app_constants";
+import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
 
-export const AppFooter = React.memo(() => {
-  return (
-    <Footer>
-      <div
-        style={{
-          display: "flex",
-          gap: ".5rem",
-          alignItems: "center",
-        }}
-      >
-        {isExcalidrawPlusSignedUser ? (
-          <ExcalidrawPlusAppLink />
-        ) : (
-          <EncryptedIcon />
-        )}
-      </div>
-    </Footer>
-  );
-});
+export const AppFooter = React.memo(
+  ({ onChange }: { onChange: () => void }) => {
+    return (
+      <Footer>
+        <div
+          style={{
+            display: "flex",
+            gap: ".5rem",
+            alignItems: "center",
+          }}
+        >
+          {isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
+          {isExcalidrawPlusSignedUser ? (
+            <ExcalidrawPlusAppLink />
+          ) : (
+            <EncryptedIcon />
+          )}
+        </div>
+      </Footer>
+    );
+  },
+);

+ 21 - 0
excalidraw-app/components/AppMainMenu.tsx

@@ -2,11 +2,13 @@ import React from "react";
 import {
   loginIcon,
   ExcalLogo,
+  eyeIcon,
 } from "../../packages/excalidraw/components/icons";
 import type { Theme } from "../../packages/excalidraw/element/types";
 import { MainMenu } from "../../packages/excalidraw/index";
 import { isExcalidrawPlusSignedUser } from "../app_constants";
 import { LanguageList } from "../app-language/LanguageList";
+import { saveDebugState } from "./DebugCanvas";
 
 export const AppMainMenu: React.FC<{
   onCollabDialogOpen: () => any;
@@ -14,6 +16,7 @@ export const AppMainMenu: React.FC<{
   isCollabEnabled: boolean;
   theme: Theme | "system";
   setTheme: (theme: Theme | "system") => void;
+  refresh: () => void;
 }> = React.memo((props) => {
   return (
     <MainMenu>
@@ -28,6 +31,7 @@ export const AppMainMenu: React.FC<{
         />
       )}
       <MainMenu.DefaultItems.CommandPalette className="highlighted" />
+      <MainMenu.DefaultItems.SearchMenu />
       <MainMenu.DefaultItems.Help />
       <MainMenu.DefaultItems.ClearCanvas />
       <MainMenu.Separator />
@@ -50,6 +54,23 @@ export const AppMainMenu: React.FC<{
       >
         {isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
       </MainMenu.ItemLink>
+      {import.meta.env.DEV && (
+        <MainMenu.Item
+          icon={eyeIcon}
+          onClick={() => {
+            if (window.visualDebug) {
+              delete window.visualDebug;
+              saveDebugState({ enabled: false });
+            } else {
+              window.visualDebug = { data: [] };
+              saveDebugState({ enabled: true });
+            }
+            props?.refresh();
+          }}
+        >
+          Visual Debug
+        </MainMenu.Item>
+      )}
       <MainMenu.Separator />
       <MainMenu.DefaultItems.ToggleTheme
         allowSystemTheme

+ 311 - 0
excalidraw-app/components/DebugCanvas.tsx

@@ -0,0 +1,311 @@
+import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
+import { type AppState } from "../../packages/excalidraw/types";
+import { throttleRAF } from "../../packages/excalidraw/utils";
+import {
+  bootstrapCanvas,
+  getNormalizedCanvasDimensions,
+} from "../../packages/excalidraw/renderer/helpers";
+import type { DebugElement } from "../../packages/excalidraw/visualdebug";
+import {
+  ArrowheadArrowIcon,
+  CloseIcon,
+  TrashIcon,
+} from "../../packages/excalidraw/components/icons";
+import { STORAGE_KEYS } from "../app_constants";
+import {
+  isLineSegment,
+  type GlobalPoint,
+  type LineSegment,
+} from "../../packages/math";
+
+const renderLine = (
+  context: CanvasRenderingContext2D,
+  zoom: number,
+  segment: LineSegment<GlobalPoint>,
+  color: string,
+) => {
+  context.save();
+  context.strokeStyle = color;
+  context.beginPath();
+  context.moveTo(segment[0][0] * zoom, segment[0][1] * zoom);
+  context.lineTo(segment[1][0] * zoom, segment[1][1] * zoom);
+  context.stroke();
+  context.restore();
+};
+
+const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
+  context.strokeStyle = "#888";
+  context.save();
+  context.beginPath();
+  context.moveTo(-10 * zoom, -10 * zoom);
+  context.lineTo(10 * zoom, 10 * zoom);
+  context.moveTo(10 * zoom, -10 * zoom);
+  context.lineTo(-10 * zoom, 10 * zoom);
+  context.stroke();
+  context.save();
+};
+
+const render = (
+  frame: DebugElement[],
+  context: CanvasRenderingContext2D,
+  appState: AppState,
+) => {
+  frame.forEach((el: DebugElement) => {
+    switch (true) {
+      case isLineSegment(el.data):
+        renderLine(
+          context,
+          appState.zoom.value,
+          el.data as LineSegment<GlobalPoint>,
+          el.color,
+        );
+        break;
+    }
+  });
+};
+
+const _debugRenderer = (
+  canvas: HTMLCanvasElement,
+  appState: AppState,
+  scale: number,
+  refresh: () => void,
+) => {
+  const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
+    canvas,
+    scale,
+  );
+
+  if (appState.height !== canvas.height || appState.width !== canvas.width) {
+    refresh();
+  }
+
+  const context = bootstrapCanvas({
+    canvas,
+    scale,
+    normalizedWidth,
+    normalizedHeight,
+    viewBackgroundColor: "transparent",
+  });
+
+  // Apply zoom
+  context.save();
+  context.translate(
+    appState.scrollX * appState.zoom.value,
+    appState.scrollY * appState.zoom.value,
+  );
+
+  renderOrigin(context, appState.zoom.value);
+
+  if (
+    window.visualDebug?.currentFrame &&
+    window.visualDebug?.data &&
+    window.visualDebug.data.length > 0
+  ) {
+    // Render only one frame
+    const [idx] = debugFrameData();
+
+    render(window.visualDebug.data[idx], context, appState);
+  } else {
+    // Render all debug frames
+    window.visualDebug?.data.forEach((frame) => {
+      render(frame, context, appState);
+    });
+  }
+
+  if (window.visualDebug) {
+    window.visualDebug!.data =
+      window.visualDebug?.data.map((frame) =>
+        frame.filter((el) => el.permanent),
+      ) ?? [];
+  }
+};
+
+const debugFrameData = (): [number, number] => {
+  const currentFrame = window.visualDebug?.currentFrame ?? 0;
+  const frameCount = window.visualDebug?.data.length ?? 0;
+
+  if (frameCount > 0) {
+    return [currentFrame % frameCount, window.visualDebug?.currentFrame ?? 0];
+  }
+
+  return [0, 0];
+};
+
+export const saveDebugState = (debug: { enabled: boolean }) => {
+  try {
+    localStorage.setItem(
+      STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
+      JSON.stringify(debug),
+    );
+  } catch (error: any) {
+    console.error(error);
+  }
+};
+
+export const debugRenderer = throttleRAF(
+  (
+    canvas: HTMLCanvasElement,
+    appState: AppState,
+    scale: number,
+    refresh: () => void,
+  ) => {
+    _debugRenderer(canvas, appState, scale, refresh);
+  },
+  { trailing: true },
+);
+
+export const loadSavedDebugState = () => {
+  let debug;
+  try {
+    const savedDebugState = localStorage.getItem(
+      STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
+    );
+    if (savedDebugState) {
+      debug = JSON.parse(savedDebugState) as { enabled: boolean };
+    }
+  } catch (error: any) {
+    console.error(error);
+  }
+
+  return debug ?? { enabled: false };
+};
+
+export const isVisualDebuggerEnabled = () =>
+  Array.isArray(window.visualDebug?.data);
+
+export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
+  const moveForward = useCallback(() => {
+    if (
+      !window.visualDebug?.currentFrame ||
+      isNaN(window.visualDebug?.currentFrame ?? -1)
+    ) {
+      window.visualDebug!.currentFrame = 0;
+    }
+    window.visualDebug!.currentFrame += 1;
+    onChange();
+  }, [onChange]);
+  const moveBackward = useCallback(() => {
+    if (
+      !window.visualDebug?.currentFrame ||
+      isNaN(window.visualDebug?.currentFrame ?? -1) ||
+      window.visualDebug?.currentFrame < 1
+    ) {
+      window.visualDebug!.currentFrame = 1;
+    }
+    window.visualDebug!.currentFrame -= 1;
+    onChange();
+  }, [onChange]);
+  const reset = useCallback(() => {
+    window.visualDebug!.currentFrame = undefined;
+    onChange();
+  }, [onChange]);
+  const trashFrames = useCallback(() => {
+    if (window.visualDebug) {
+      window.visualDebug.currentFrame = undefined;
+      window.visualDebug.data = [];
+    }
+    onChange();
+  }, [onChange]);
+
+  return (
+    <>
+      <button
+        className="ToolIcon_type_button"
+        data-testid="debug-forward"
+        aria-label="Move forward"
+        type="button"
+        onClick={trashFrames}
+      >
+        <div
+          className="ToolIcon__icon"
+          aria-hidden="true"
+          aria-disabled="false"
+        >
+          {TrashIcon}
+        </div>
+      </button>
+      <button
+        className="ToolIcon_type_button"
+        data-testid="debug-forward"
+        aria-label="Move forward"
+        type="button"
+        onClick={moveBackward}
+      >
+        <div
+          className="ToolIcon__icon"
+          aria-hidden="true"
+          aria-disabled="false"
+        >
+          <ArrowheadArrowIcon flip />
+        </div>
+      </button>
+      <button
+        className="ToolIcon_type_button"
+        data-testid="debug-forward"
+        aria-label="Move forward"
+        type="button"
+        onClick={reset}
+      >
+        <div
+          className="ToolIcon__icon"
+          aria-hidden="true"
+          aria-disabled="false"
+        >
+          {CloseIcon}
+        </div>
+      </button>
+      <button
+        className="ToolIcon_type_button"
+        data-testid="debug-backward"
+        aria-label="Move backward"
+        type="button"
+        onClick={moveForward}
+      >
+        <div
+          className="ToolIcon__icon"
+          aria-hidden="true"
+          aria-disabled="false"
+        >
+          <ArrowheadArrowIcon />
+        </div>
+      </button>
+    </>
+  );
+};
+
+interface DebugCanvasProps {
+  appState: AppState;
+  scale: number;
+}
+
+const DebugCanvas = forwardRef<HTMLCanvasElement, DebugCanvasProps>(
+  ({ appState, scale }, ref) => {
+    const { width, height } = appState;
+
+    const canvasRef = useRef<HTMLCanvasElement>(null);
+    useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
+      ref,
+      () => canvasRef.current,
+      [canvasRef],
+    );
+
+    return (
+      <canvas
+        style={{
+          width,
+          height,
+          position: "absolute",
+          zIndex: 2,
+          pointerEvents: "none",
+        }}
+        width={width * scale}
+        height={height * scale}
+        ref={canvasRef}
+      >
+        Debug Canvas
+      </canvas>
+    );
+  },
+);
+
+export default DebugCanvas;

+ 14 - 1
excalidraw-app/data/LocalData.ts

@@ -20,6 +20,10 @@ import {
   get,
 } from "idb-keyval";
 import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
+import {
+  CANVAS_SEARCH_TAB,
+  DEFAULT_SIDEBAR,
+} from "../../packages/excalidraw/constants";
 import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
 import type { ImportedDataState } from "../../packages/excalidraw/data/types";
 import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
@@ -66,13 +70,22 @@ const saveDataStateToLocalStorage = (
   appState: AppState,
 ) => {
   try {
+    const _appState = clearAppStateForLocalStorage(appState);
+
+    if (
+      _appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
+      _appState.openSidebar.tab === CANVAS_SEARCH_TAB
+    ) {
+      _appState.openSidebar = null;
+    }
+
     localStorage.setItem(
       STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
       JSON.stringify(clearElementsForLocalStorage(elements)),
     );
     localStorage.setItem(
       STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
-      JSON.stringify(clearAppStateForLocalStorage(appState)),
+      JSON.stringify(_appState),
     );
     updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
   } catch (error: any) {

+ 16 - 72
excalidraw-app/index.html

@@ -95,6 +95,11 @@
         color: #fff;
       }
     </style>
+
+    <!-- Warmup the connection for Google fonts -->
+    <link rel="preconnect" href="https://fonts.googleapis.com" />
+    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+
     <!------------------------------------------------------------------------->
     <% if (typeof PROD != 'undefined' && PROD == true) { %>
     <script>
@@ -114,85 +119,17 @@
       ) {
         window.location.href = "https://app.excalidraw.com";
       }
-
-      // point into our CDN in prod
-      window.EXCALIDRAW_ASSET_PATH =
-        "https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/";
     </script>
+
+    <!-- Following placeholder is replaced during the build step -->
+    <!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
+
     <% } else { %>
     <script>
       window.EXCALIDRAW_ASSET_PATH = window.origin;
     </script>
     <% } %>
 
-    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
-    <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
-    <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
-
-    <!-- Excalidraw version -->
-    <meta name="version" content="{version}" />
-
-    <!-- Warmup the connection for Google fonts -->
-    <link rel="preconnect" href="https://fonts.googleapis.com" />
-    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
-
-    <!-- Preload all default fonts and Virgil for backwards compatibility to avoid swap on init -->
-    <% if (typeof PROD != 'undefined' && PROD == true) { %>
-    <link
-      rel="preload"
-      href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/Excalifont-Regular-C9eKQy_N.woff2"
-      as="font"
-      type="font/woff2"
-      crossorigin="anonymous"
-    />
-    <link
-      rel="preload"
-      href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/Virgil-Regular-hO16qHwV.woff2"
-      as="font"
-      type="font/woff2"
-      crossorigin="anonymous"
-    />
-    <link
-      rel="preload"
-      href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/ComicShanns-Regular-D0c8wzsC.woff2"
-      as="font"
-      type="font/woff2"
-      crossorigin="anonymous"
-    />
-    <% } else { %>
-    <!-- in DEV we need to preload from the local server and without the hash -->
-    <link
-      rel="preload"
-      href="../packages/excalidraw/fonts/assets/Excalifont-Regular.woff2"
-      as="font"
-      type="font/woff2"
-      crossorigin="anonymous"
-    />
-    <link
-      rel="preload"
-      href="../packages/excalidraw/fonts/assets/Virgil-Regular.woff2"
-      as="font"
-      type="font/woff2"
-      crossorigin="anonymous"
-    />
-    <link
-      rel="preload"
-      href="../packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2"
-      as="font"
-      type="font/woff2"
-      crossorigin="anonymous"
-    />
-    <% } %>
-
-    <!-- For Nunito only preload the latin range, which should be enough for now -->
-    <link
-      rel="preload"
-      href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
-      as="font"
-      type="font/woff2"
-      crossorigin="anonymous"
-    />
-
     <!-- Register Assistant as the UI font, before the scene inits -->
     <link
       rel="stylesheet"
@@ -200,6 +137,13 @@
       type="text/css"
     />
 
+    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
+    <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
+    <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
+
+    <!-- Excalidraw version -->
+    <meta name="version" content="{version}" />
+
     <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
     VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
     <script>

+ 11 - 1
excalidraw-app/package.json

@@ -26,7 +26,17 @@
     "node": ">=18.0.0"
   },
   "dependencies": {
-    "vite-plugin-html": "3.2.2"
+    "firebase": "8.3.3",
+    "idb-keyval": "6.0.3",
+    "jotai": "1.13.1",
+    "react": "18.2.0",
+    "react-dom": "18.2.0",
+    "vite-plugin-html": "3.2.2",
+    "@excalidraw/random-username": "1.0.0",
+    "@sentry/browser": "6.2.5",
+    "@sentry/integrations": "6.2.5",
+    "i18next-browser-languagedetector": "6.1.4",
+    "socket.io-client": "4.7.2"
   },
   "prettier": "@excalidraw/prettier-config",
   "scripts": {

+ 2 - 2
excalidraw-app/share/ShareDialog.scss

@@ -58,8 +58,8 @@
       font-size: 0.75rem;
       line-height: 110%;
 
-      background: var(--color-success-lighter);
-      color: var(--color-success);
+      background: var(--color-success);
+      color: var(--color-success-text);
 
       & > svg {
         width: 0.875rem;

+ 13 - 23
excalidraw-app/share/ShareDialog.tsx

@@ -1,5 +1,4 @@
 import { useEffect, useRef, useState } from "react";
-import * as Popover from "@radix-ui/react-popover";
 import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
 import { trackEvent } from "../../packages/excalidraw/analytics";
 import { getFrame } from "../../packages/excalidraw/utils";
@@ -14,7 +13,6 @@ import {
   share,
   shareIOS,
   shareWindows,
-  tablerCheckIcon,
 } from "../../packages/excalidraw/components/icons";
 import { TextField } from "../../packages/excalidraw/components/TextField";
 import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
@@ -24,6 +22,7 @@ import { atom, useAtom, useAtomValue } from "jotai";
 
 import "./ShareDialog.scss";
 import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
+import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
 
 type OnExportToBackend = () => void;
 type ShareDialogType = "share" | "collaborationOnly";
@@ -63,10 +62,11 @@ const ActiveRoomDialog = ({
   handleClose: () => void;
 }) => {
   const { t } = useI18n();
-  const [justCopied, setJustCopied] = useState(false);
+  const [, setJustCopied] = useState(false);
   const timerRef = useRef<number>(0);
   const ref = useRef<HTMLInputElement>(null);
   const isShareSupported = "share" in navigator;
+  const { onCopy, copyStatus } = useCopyStatus();
 
   const copyRoomLink = async () => {
     try {
@@ -130,26 +130,16 @@ const ActiveRoomDialog = ({
             onClick={shareRoomLink}
           />
         )}
-        <Popover.Root open={justCopied}>
-          <Popover.Trigger asChild>
-            <FilledButton
-              size="large"
-              label="Copy link"
-              icon={copyIcon}
-              onClick={copyRoomLink}
-            />
-          </Popover.Trigger>
-          <Popover.Content
-            onOpenAutoFocus={(event) => event.preventDefault()}
-            onCloseAutoFocus={(event) => event.preventDefault()}
-            className="ShareDialog__popover"
-            side="top"
-            align="end"
-            sideOffset={5.5}
-          >
-            {tablerCheckIcon} copied
-          </Popover.Content>
-        </Popover.Root>
+        <FilledButton
+          size="large"
+          label={t("buttons.copyLink")}
+          icon={copyIcon}
+          status={copyStatus}
+          onClick={() => {
+            copyRoomLink();
+            onCopy();
+          }}
+        />
       </div>
       <div className="ShareDialog__active__description">
         <p>

+ 5 - 6
excalidraw-app/tests/collab.test.tsx

@@ -2,7 +2,6 @@ import { vi } from "vitest";
 import {
   act,
   render,
-  updateSceneData,
   waitFor,
 } from "../../packages/excalidraw/tests/test-utils";
 import ExcalidrawApp from "../App";
@@ -88,12 +87,12 @@ describe("collaboration", () => {
     const rect1 = API.createElement({ ...rect1Props });
     const rect2 = API.createElement({ ...rect2Props });
 
-    updateSceneData({
+    API.updateScene({
       elements: syncInvalidIndices([rect1, rect2]),
       storeAction: StoreAction.CAPTURE,
     });
 
-    updateSceneData({
+    API.updateScene({
       elements: syncInvalidIndices([
         rect1,
         newElementWith(h.elements[1], { isDeleted: true }),
@@ -143,7 +142,7 @@ describe("collaboration", () => {
     });
 
     // simulate force deleting the element remotely
-    updateSceneData({
+    API.updateScene({
       elements: syncInvalidIndices([rect1]),
       storeAction: StoreAction.UPDATE,
     });
@@ -178,7 +177,7 @@ describe("collaboration", () => {
     act(() => h.app.actionManager.executeAction(undoAction));
 
     // simulate local update
-    updateSceneData({
+    API.updateScene({
       elements: syncInvalidIndices([
         h.elements[0],
         newElementWith(h.elements[1], { x: 100 }),
@@ -216,7 +215,7 @@ describe("collaboration", () => {
     });
 
     // simulate force deleting the element remotely
-    updateSceneData({
+    API.updateScene({
       elements: syncInvalidIndices([rect1]),
       storeAction: StoreAction.UPDATE,
     });

+ 18 - 5
excalidraw-app/vite.config.mts

@@ -26,10 +26,10 @@ export default defineConfig({
         assetFileNames(chunkInfo) {
           if (chunkInfo?.name?.endsWith(".woff2")) {
             // put on root so we are flexible about the CDN path
-            return '[name]-[hash][extname]';
+            return "[name]-[hash][extname]";
           }
 
-          return 'assets/[name]-[hash][extname]';
+          return "assets/[name]-[hash][extname]";
         },
         // Creating separate chunk for locales except for en and percentages.json so they
         // can be cached at runtime and not merged with
@@ -44,10 +44,12 @@ export default defineConfig({
             // Taking the substring after "locales/"
             return `locales/${id.substring(index + 8)}`;
           }
-        }
+        },
       },
     },
     sourcemap: true,
+    // don't auto-inline small assets (i.e. fonts hosted on CDN)
+    assetsInlineLimit: 0,
   },
   plugins: [
     woff2BrowserPlugin(),
@@ -73,8 +75,8 @@ export default defineConfig({
       },
 
       workbox: {
-        // Don't push fonts and locales to app precache
-        globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
+        // Don't push fonts, locales and wasm to app precache
+        globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"],
         runtimeCaching: [
           {
             urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
@@ -108,6 +110,17 @@ export default defineConfig({
               },
             },
           },
+          {
+            urlPattern: new RegExp(".wasm-.+.js"),
+            handler: "CacheFirst",
+            options: {
+              cacheName: "wasm",
+              expiration: {
+                maxEntries: 50,
+                maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
+              },
+            },
+          },
         ],
       },
       manifest: {

+ 18 - 21
package.json

@@ -6,24 +6,12 @@
     "excalidraw-app",
     "packages/excalidraw",
     "packages/utils",
+    "packages/math",
     "examples/excalidraw",
     "examples/excalidraw/*"
   ],
-  "dependencies": {
-    "@excalidraw/random-username": "1.0.0",
-    "@sentry/browser": "6.2.5",
-    "@sentry/integrations": "6.2.5",
-    "firebase": "8.3.3",
-    "i18next-browser-languagedetector": "6.1.4",
-    "idb-keyval": "6.0.3",
-    "jotai": "1.13.1",
-    "patch-package": "8.0.0",
-    "postinstall-postinstall": "2.1.0",
-    "react": "18.2.0",
-    "react-dom": "18.2.0",
-    "socket.io-client": "4.7.2"
-  },
   "devDependencies": {
+    "@babel/plugin-proposal-private-property-in-object": "7.21.11",
     "@excalidraw/eslint-config": "1.0.3",
     "@excalidraw/prettier-config": "1.0.2",
     "@types/chai": "4.3.0",
@@ -33,8 +21,8 @@
     "@types/react-dom": "18.2.0",
     "@types/socket.io-client": "3.0.0",
     "@vitejs/plugin-react": "3.1.0",
-    "@vitest/coverage-v8": "0.33.0",
-    "@vitest/ui": "0.32.2",
+    "@vitest/coverage-v8": "2.0.5",
+    "@vitest/ui": "2.0.5",
     "chai": "4.3.6",
     "dotenv": "16.0.1",
     "eslint-config-prettier": "8.5.0",
@@ -44,17 +32,19 @@
     "husky": "7.0.4",
     "jsdom": "22.1.0",
     "lint-staged": "12.3.7",
+    "patch-package": "8.0.0",
     "pepjs": "0.5.3",
+    "postinstall-postinstall": "2.1.0",
     "prettier": "2.6.2",
     "rewire": "6.0.0",
     "typescript": "4.9.4",
     "vite": "5.0.12",
-    "vite-plugin-checker": "0.6.1",
+    "vite-plugin-checker": "0.7.2",
     "vite-plugin-ejs": "1.7.0",
     "vite-plugin-pwa": "0.17.4",
-    "vite-plugin-svgr": "2.4.0",
-    "vitest": "1.5.3",
-    "vitest-canvas-mock": "0.3.2"
+    "vite-plugin-svgr": "4.2.0",
+    "vitest": "2.0.5",
+    "vitest-canvas-mock": "0.3.3"
   },
   "engines": {
     "node": "18.0.0 - 20.x.x"
@@ -90,6 +80,13 @@
     "autorelease": "node scripts/autorelease.js",
     "prerelease:excalidraw": "node scripts/prerelease.js",
     "build:preview": "yarn build && vite preview --port 5000",
-    "release:excalidraw": "node scripts/release.js"
+    "release:excalidraw": "node scripts/release.js",
+    "rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/*/{build,dist}",
+    "rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
+    "clean-install": "yarn rm:node_modules && yarn install"
+  },
+  "resolutions": {
+    "@types/react": "18.2.0",
+    "strip-ansi": "6.0.1"
   }
 }

+ 4 - 0
packages/excalidraw/CHANGELOG.md

@@ -15,6 +15,8 @@ Please add the latest change on the top under the correct section.
 
 ### Features
 
+- Prefer user defined coordinates and dimensions when creating a frame using [`convertToExcalidrawElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton#converttoexcalidrawelements) [#8517](https://github.com/excalidraw/excalidraw/pull/8517)
+
 - `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
 
 - Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
@@ -39,6 +41,8 @@ Please add the latest change on the top under the correct section.
 
 ### Breaking Changes
 
+- Stats container CSS changed, so if you're using `renderCustomStats`, you may need to adjust your styles to retain the same layout.
+
 - `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898)
 
 |  | Before `commitToHistory` | After `storeAction` | Notes |

+ 69 - 42
packages/excalidraw/actions/actionCanvas.tsx

@@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys";
 import { getNormalizedZoom } from "../scene";
 import { centerScrollOn } from "../scene/scroll";
 import { getStateForZoom } from "../scene/zoom";
-import type { AppState, NormalizedZoomValue } from "../types";
+import type { AppState, Offsets } from "../types";
 import { getShortcutKey, updateActiveTool } from "../utils";
 import { register } from "./register";
 import { Tooltip } from "../components/Tooltip";
@@ -38,6 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
 import type { SceneBounds } from "../element/bounds";
 import { setCursor } from "../cursor";
 import { StoreAction } from "../store";
+import { clamp, roundToStep } from "../../math";
 
 export const actionChangeViewBackgroundColor = register({
   name: "changeViewBackgroundColor",
@@ -104,6 +105,8 @@ export const actionClearCanvas = register({
         exportBackground: appState.exportBackground,
         exportEmbedScene: appState.exportEmbedScene,
         gridSize: appState.gridSize,
+        gridStep: appState.gridStep,
+        gridModeEnabled: appState.gridModeEnabled,
         stats: appState.stats,
         pasteDialog: appState.pasteDialog,
         activeTool:
@@ -244,6 +247,7 @@ export const actionResetZoom = register({
 const zoomValueToFitBoundsOnViewport = (
   bounds: SceneBounds,
   viewportDimensions: { width: number; height: number },
+  viewportZoomFactor: number = 1, // default to 1 if not provided
 ) => {
   const [x1, y1, x2, y2] = bounds;
   const commonBoundsWidth = x2 - x1;
@@ -251,78 +255,89 @@ const zoomValueToFitBoundsOnViewport = (
   const commonBoundsHeight = y2 - y1;
   const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
   const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
-  const zoomAdjustedToSteps =
-    Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
-  const clampedZoomValueToFitElements = Math.min(
-    Math.max(zoomAdjustedToSteps, MIN_ZOOM),
-    1,
-  );
-  return clampedZoomValueToFitElements as NormalizedZoomValue;
+
+  const adjustedZoomValue =
+    smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
+
+  return Math.min(adjustedZoomValue, 1);
 };
 
 export const zoomToFitBounds = ({
   bounds,
   appState,
+  canvasOffsets,
   fitToViewport = false,
-  viewportZoomFactor = 0.7,
+  viewportZoomFactor = 1,
+  minZoom = -Infinity,
+  maxZoom = Infinity,
 }: {
   bounds: SceneBounds;
+  canvasOffsets?: Offsets;
   appState: Readonly<AppState>;
   /** whether to fit content to viewport (beyond >100%) */
   fitToViewport: boolean;
   /** zoom content to cover X of the viewport, when fitToViewport=true */
   viewportZoomFactor?: number;
+  minZoom?: number;
+  maxZoom?: number;
 }) => {
+  viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
+
   const [x1, y1, x2, y2] = bounds;
   const centerX = (x1 + x2) / 2;
   const centerY = (y1 + y2) / 2;
 
-  let newZoomValue;
-  let scrollX;
-  let scrollY;
+  const canvasOffsetLeft = canvasOffsets?.left ?? 0;
+  const canvasOffsetTop = canvasOffsets?.top ?? 0;
+  const canvasOffsetRight = canvasOffsets?.right ?? 0;
+  const canvasOffsetBottom = canvasOffsets?.bottom ?? 0;
+
+  const effectiveCanvasWidth =
+    appState.width - canvasOffsetLeft - canvasOffsetRight;
+  const effectiveCanvasHeight =
+    appState.height - canvasOffsetTop - canvasOffsetBottom;
+
+  let adjustedZoomValue;
 
   if (fitToViewport) {
     const commonBoundsWidth = x2 - x1;
     const commonBoundsHeight = y2 - y1;
 
-    newZoomValue =
+    adjustedZoomValue =
       Math.min(
-        appState.width / commonBoundsWidth,
-        appState.height / commonBoundsHeight,
-      ) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
+        effectiveCanvasWidth / commonBoundsWidth,
+        effectiveCanvasHeight / commonBoundsHeight,
+      ) * viewportZoomFactor;
+  } else {
+    adjustedZoomValue = zoomValueToFitBoundsOnViewport(
+      bounds,
+      {
+        width: effectiveCanvasWidth,
+        height: effectiveCanvasHeight,
+      },
+      viewportZoomFactor,
+    );
+  }
 
-    // Apply clamping to newZoomValue to be between 10% and 3000%
-    newZoomValue = Math.min(
-      Math.max(newZoomValue, MIN_ZOOM),
-      MAX_ZOOM,
-    ) as NormalizedZoomValue;
+  const newZoomValue = getNormalizedZoom(
+    clamp(roundToStep(adjustedZoomValue, ZOOM_STEP, "floor"), minZoom, maxZoom),
+  );
 
-    scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
-    scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
-  } else {
-    newZoomValue = zoomValueToFitBoundsOnViewport(bounds, {
+  const centerScroll = centerScrollOn({
+    scenePoint: { x: centerX, y: centerY },
+    viewportDimensions: {
       width: appState.width,
       height: appState.height,
-    });
-
-    const centerScroll = centerScrollOn({
-      scenePoint: { x: centerX, y: centerY },
-      viewportDimensions: {
-        width: appState.width,
-        height: appState.height,
-      },
-      zoom: { value: newZoomValue },
-    });
-
-    scrollX = centerScroll.scrollX;
-    scrollY = centerScroll.scrollY;
-  }
+    },
+    offsets: canvasOffsets,
+    zoom: { value: newZoomValue },
+  });
 
   return {
     appState: {
       ...appState,
-      scrollX,
-      scrollY,
+      scrollX: centerScroll.scrollX,
+      scrollY: centerScroll.scrollY,
       zoom: { value: newZoomValue },
     },
     storeAction: StoreAction.NONE,
@@ -330,25 +345,34 @@ export const zoomToFitBounds = ({
 };
 
 export const zoomToFit = ({
+  canvasOffsets,
   targetElements,
   appState,
   fitToViewport,
   viewportZoomFactor,
+  minZoom,
+  maxZoom,
 }: {
+  canvasOffsets?: Offsets;
   targetElements: readonly ExcalidrawElement[];
   appState: Readonly<AppState>;
   /** whether to fit content to viewport (beyond >100%) */
   fitToViewport: boolean;
   /** zoom content to cover X of the viewport, when fitToViewport=true */
   viewportZoomFactor?: number;
+  minZoom?: number;
+  maxZoom?: number;
 }) => {
   const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
 
   return zoomToFitBounds({
+    canvasOffsets,
     bounds: commonBounds,
     appState,
     fitToViewport,
     viewportZoomFactor,
+    minZoom,
+    maxZoom,
   });
 };
 
@@ -369,6 +393,7 @@ export const actionZoomToFitSelectionInViewport = register({
         userToFollow: null,
       },
       fitToViewport: false,
+      canvasOffsets: app.getEditorUIOffsets(),
     });
   },
   // NOTE shift-2 should have been assigned actionZoomToFitSelection.
@@ -394,6 +419,7 @@ export const actionZoomToFitSelection = register({
         userToFollow: null,
       },
       fitToViewport: true,
+      canvasOffsets: app.getEditorUIOffsets(),
     });
   },
   // NOTE this action should use shift-2 per figma, alas
@@ -410,7 +436,7 @@ export const actionZoomToFit = register({
   icon: zoomAreaIcon,
   viewMode: true,
   trackEvent: { category: "canvas" },
-  perform: (elements, appState) =>
+  perform: (elements, appState, _, app) =>
     zoomToFit({
       targetElements: elements,
       appState: {
@@ -418,6 +444,7 @@ export const actionZoomToFit = register({
         userToFollow: null,
       },
       fitToViewport: false,
+      canvasOffsets: app.getEditorUIOffsets(),
     }),
   keyTest: (event) =>
     event.code === CODES.ONE &&

+ 2 - 10
packages/excalidraw/actions/actionClipboard.tsx

@@ -10,7 +10,7 @@ import {
 } from "../clipboard";
 import { actionDeleteSelected } from "./actionDeleteSelected";
 import { exportCanvas, prepareElementsForExport } from "../data/index";
-import { isTextElement } from "../element";
+import { getTextFromElements, isTextElement } from "../element";
 import { t } from "../i18n";
 import { isFirefox } from "../constants";
 import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
@@ -239,16 +239,8 @@ export const copyText = register({
       includeBoundTextElement: true,
     });
 
-    const text = selectedElements
-      .reduce((acc: string[], element) => {
-        if (isTextElement(element)) {
-          acc.push(element.text);
-        }
-        return acc;
-      }, [])
-      .join("\n\n");
     try {
-      copyTextToSystemClipboard(text);
+      copyTextToSystemClipboard(getTextFromElements(selectedElements));
     } catch (e) {
       throw new Error(t("errors.copyToSystemClipboardFailed"));
     }

+ 36 - 5
packages/excalidraw/actions/actionDeleteSelected.tsx

@@ -5,20 +5,27 @@ import { t } from "../i18n";
 import { register } from "./register";
 import { getNonDeletedElements } from "../element";
 import type { ExcalidrawElement } from "../element/types";
-import type { AppState } from "../types";
-import { newElementWith } from "../element/mutateElement";
+import type { AppClassProperties, AppState } from "../types";
+import { mutateElement, newElementWith } from "../element/mutateElement";
 import { getElementsInGroup } from "../groups";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { fixBindingsAfterDeletion } from "../element/binding";
-import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
+import {
+  isBoundToContainer,
+  isElbowArrow,
+  isFrameLikeElement,
+} from "../element/typeChecks";
 import { updateActiveTool } from "../utils";
 import { TrashIcon } from "../components/icons";
 import { StoreAction } from "../store";
+import { mutateElbowArrow } from "../element/routing";
 
 const deleteSelectedElements = (
   elements: readonly ExcalidrawElement[],
   appState: AppState,
+  app: AppClassProperties,
 ) => {
+  const elementsMap = app.scene.getNonDeletedElementsMap();
   const framesToBeDeleted = new Set(
     getSelectedElements(
       elements.filter((el) => isFrameLikeElement(el)),
@@ -29,6 +36,26 @@ const deleteSelectedElements = (
   return {
     elements: elements.map((el) => {
       if (appState.selectedElementIds[el.id]) {
+        if (el.boundElements) {
+          el.boundElements.forEach((candidate) => {
+            const bound = app.scene
+              .getNonDeletedElementsMap()
+              .get(candidate.id);
+            if (bound && isElbowArrow(bound)) {
+              mutateElement(bound, {
+                startBinding:
+                  el.id === bound.startBinding?.elementId
+                    ? null
+                    : bound.startBinding,
+                endBinding:
+                  el.id === bound.endBinding?.elementId
+                    ? null
+                    : bound.endBinding,
+              });
+              mutateElbowArrow(bound, elementsMap, bound.points);
+            }
+          });
+        }
         return newElementWith(el, { isDeleted: true });
       }
 
@@ -130,7 +157,11 @@ export const actionDeleteSelected = register({
           : endBindingElement,
       };
 
-      LinearElementEditor.deletePoints(element, selectedPointsIndices);
+      LinearElementEditor.deletePoints(
+        element,
+        selectedPointsIndices,
+        elementsMap,
+      );
 
       return {
         elements,
@@ -149,7 +180,7 @@ export const actionDeleteSelected = register({
       };
     }
     let { elements: nextElements, appState: nextAppState } =
-      deleteSelectedElements(elements, appState);
+      deleteSelectedElements(elements, appState, app);
     fixBindingsAfterDeletion(
       nextElements,
       elements.filter(({ id }) => appState.selectedElementIds[id]),

+ 15 - 15
packages/excalidraw/actions/actionDuplicateSelection.tsx

@@ -15,7 +15,7 @@ import {
 import type { AppState } from "../types";
 import { fixBindingsAfterDuplication } from "../element/binding";
 import type { ActionResult } from "./types";
-import { GRID_SIZE } from "../constants";
+import { DEFAULT_GRID_SIZE } from "../constants";
 import {
   bindTextToShapeAfterDuplication,
   getBoundTextElement,
@@ -40,23 +40,23 @@ export const actionDuplicateSelection = register({
   icon: DuplicateIcon,
   trackEvent: { category: "element" },
   perform: (elements, appState, formData, app) => {
-    const elementsMap = app.scene.getNonDeletedElementsMap();
     // duplicate selected point(s) if editing a line
     if (appState.editingLinearElement) {
-      const ret = LinearElementEditor.duplicateSelectedPoints(
-        appState,
-        elementsMap,
-      );
+      // TODO: Invariants should be checked here instead of duplicateSelectedPoints()
+      try {
+        const newAppState = LinearElementEditor.duplicateSelectedPoints(
+          appState,
+          app.scene.getNonDeletedElementsMap(),
+        );
 
-      if (!ret) {
+        return {
+          elements,
+          appState: newAppState,
+          storeAction: StoreAction.CAPTURE,
+        };
+      } catch {
         return false;
       }
-
-      return {
-        elements,
-        appState: ret.appState,
-        storeAction: StoreAction.CAPTURE,
-      };
     }
 
     return {
@@ -100,8 +100,8 @@ const duplicateElements = (
       groupIdMap,
       element,
       {
-        x: element.x + GRID_SIZE / 2,
-        y: element.y + GRID_SIZE / 2,
+        x: element.x + DEFAULT_GRID_SIZE / 2,
+        y: element.y + DEFAULT_GRID_SIZE / 2,
       },
     );
     duplicatedElementsMap.set(newElement.id, newElement);

+ 1 - 0
packages/excalidraw/actions/actionElementLock.test.tsx

@@ -1,3 +1,4 @@
+import React from "react";
 import { Excalidraw } from "../index";
 import { queryByTestId, fireEvent } from "@testing-library/react";
 import { render } from "../tests/test-utils";

+ 14 - 10
packages/excalidraw/actions/actionFinalize.tsx

@@ -6,7 +6,6 @@ import { done } from "../components/icons";
 import { t } from "../i18n";
 import { register } from "./register";
 import { mutateElement } from "../element/mutateElement";
-import { isPathALoop } from "../math";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import {
   maybeBindLinearElement,
@@ -16,6 +15,8 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks";
 import type { AppState } from "../types";
 import { resetCursor } from "../cursor";
 import { StoreAction } from "../store";
+import { pointFrom } from "../../math";
+import { isPathALoop } from "../shapes";
 
 export const actionFinalize = register({
   name: "finalize",
@@ -38,6 +39,7 @@ export const actionFinalize = register({
             startBindingElement,
             endBindingElement,
             elementsMap,
+            scene,
           );
         }
         return {
@@ -49,7 +51,6 @@ export const actionFinalize = register({
             ...appState,
             cursorButton: "up",
             editingLinearElement: null,
-            selectedLinearElement: null,
           },
           storeAction: StoreAction.CAPTURE,
         };
@@ -72,8 +73,8 @@ export const actionFinalize = register({
 
     const multiPointElement = appState.multiElement
       ? appState.multiElement
-      : appState.editingElement?.type === "freedraw"
-      ? appState.editingElement
+      : appState.newElement?.type === "freedraw"
+      ? appState.newElement
       : null;
 
     if (multiPointElement) {
@@ -112,10 +113,10 @@ export const actionFinalize = register({
           const linePoints = multiPointElement.points;
           const firstPoint = linePoints[0];
           mutateElement(multiPointElement, {
-            points: linePoints.map((point, index) =>
+            points: linePoints.map((p, index) =>
               index === linePoints.length - 1
-                ? ([firstPoint[0], firstPoint[1]] as const)
-                : point,
+                ? pointFrom(firstPoint[0], firstPoint[1])
+                : p,
             ),
           });
         }
@@ -136,6 +137,7 @@ export const actionFinalize = register({
           appState,
           { x, y },
           elementsMap,
+          elements,
         );
       }
     }
@@ -174,9 +176,10 @@ export const actionFinalize = register({
             ? appState.activeTool
             : activeTool,
         activeEmbeddable: null,
-        draggingElement: null,
+        newElement: null,
+        selectionElement: null,
         multiElement: null,
-        editingElement: null,
+        editingTextElement: null,
         startBoundElement: null,
         suggestedBindings: [],
         selectedElementIds:
@@ -202,7 +205,7 @@ export const actionFinalize = register({
   keyTest: (event, appState) =>
     (event.key === KEYS.ESCAPE &&
       (appState.editingLinearElement !== null ||
-        (!appState.draggingElement && appState.multiElement === null))) ||
+        (!appState.newElement && appState.multiElement === null))) ||
     ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
       appState.multiElement !== null),
   PanelComponent: ({ appState, updateData, data }) => (
@@ -214,6 +217,7 @@ export const actionFinalize = register({
       onClick={updateData}
       visible={appState.multiElement != null}
       size={data?.size || "medium"}
+      style={{ pointerEvents: "all" }}
     />
   ),
 });

+ 211 - 0
packages/excalidraw/actions/actionFlip.test.tsx

@@ -0,0 +1,211 @@
+import React from "react";
+import { Excalidraw } from "../index";
+import { render } from "../tests/test-utils";
+import { API } from "../tests/helpers/api";
+import { pointFrom } from "../../math";
+import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
+
+const { h } = window;
+
+describe("flipping re-centers selection", () => {
+  it("elbow arrow touches group selection side yet it remains in place after multiple moves", async () => {
+    const elements = [
+      API.createElement({
+        type: "rectangle",
+        id: "rec1",
+        x: 100,
+        y: 100,
+        width: 100,
+        height: 100,
+        boundElements: [{ id: "arr", type: "arrow" }],
+      }),
+      API.createElement({
+        type: "rectangle",
+        id: "rec2",
+        x: 220,
+        y: 250,
+        width: 100,
+        height: 100,
+        boundElements: [{ id: "arr", type: "arrow" }],
+      }),
+      API.createElement({
+        type: "arrow",
+        id: "arr",
+        x: 149.9,
+        y: 95,
+        width: 156,
+        height: 239.9,
+        startBinding: {
+          elementId: "rec1",
+          focus: 0,
+          gap: 5,
+          fixedPoint: [0.49, -0.05],
+        },
+        endBinding: {
+          elementId: "rec2",
+          focus: 0,
+          gap: 5,
+          fixedPoint: [-0.05, 0.49],
+        },
+        startArrowhead: null,
+        endArrowhead: "arrow",
+        points: [
+          pointFrom(0, 0),
+          pointFrom(0, -35),
+          pointFrom(-90.9, -35),
+          pointFrom(-90.9, 204.9),
+          pointFrom(65.1, 204.9),
+        ],
+        elbowed: true,
+      }),
+    ];
+    await render(<Excalidraw initialData={{ elements }} />);
+
+    API.setSelectedElements(elements);
+
+    expect(Object.keys(h.state.selectedElementIds).length).toBe(3);
+
+    API.executeAction(actionFlipHorizontal);
+    API.executeAction(actionFlipHorizontal);
+    API.executeAction(actionFlipHorizontal);
+    API.executeAction(actionFlipHorizontal);
+
+    const rec1 = h.elements.find((el) => el.id === "rec1");
+    expect(rec1?.x).toBeCloseTo(100);
+    expect(rec1?.y).toBeCloseTo(100);
+
+    const rec2 = h.elements.find((el) => el.id === "rec2");
+    expect(rec2?.x).toBeCloseTo(220);
+    expect(rec2?.y).toBeCloseTo(250);
+  });
+});
+
+describe("flipping arrowheads", () => {
+  beforeEach(async () => {
+    await render(<Excalidraw />);
+  });
+
+  it("flipping bound arrow should flip arrowheads only", () => {
+    const rect = API.createElement({
+      type: "rectangle",
+      boundElements: [{ type: "arrow", id: "arrow1" }],
+    });
+    const arrow = API.createElement({
+      type: "arrow",
+      id: "arrow1",
+      startArrowhead: "arrow",
+      endArrowhead: null,
+      endBinding: {
+        elementId: rect.id,
+        focus: 0.5,
+        gap: 5,
+      },
+    });
+
+    API.setElements([rect, arrow]);
+    API.setSelectedElements([arrow]);
+
+    expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+    expect(API.getElement(arrow).endArrowhead).toBe(null);
+
+    API.executeAction(actionFlipHorizontal);
+    expect(API.getElement(arrow).startArrowhead).toBe(null);
+    expect(API.getElement(arrow).endArrowhead).toBe("arrow");
+
+    API.executeAction(actionFlipHorizontal);
+    expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+    expect(API.getElement(arrow).endArrowhead).toBe(null);
+
+    API.executeAction(actionFlipVertical);
+    expect(API.getElement(arrow).startArrowhead).toBe(null);
+    expect(API.getElement(arrow).endArrowhead).toBe("arrow");
+  });
+
+  it("flipping bound arrow should flip arrowheads only 2", () => {
+    const rect = API.createElement({
+      type: "rectangle",
+      boundElements: [{ type: "arrow", id: "arrow1" }],
+    });
+    const rect2 = API.createElement({
+      type: "rectangle",
+      boundElements: [{ type: "arrow", id: "arrow1" }],
+    });
+    const arrow = API.createElement({
+      type: "arrow",
+      id: "arrow1",
+      startArrowhead: "arrow",
+      endArrowhead: "circle",
+      startBinding: {
+        elementId: rect.id,
+        focus: 0.5,
+        gap: 5,
+      },
+      endBinding: {
+        elementId: rect2.id,
+        focus: 0.5,
+        gap: 5,
+      },
+    });
+
+    API.setElements([rect, rect2, arrow]);
+    API.setSelectedElements([arrow]);
+
+    expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+    expect(API.getElement(arrow).endArrowhead).toBe("circle");
+
+    API.executeAction(actionFlipHorizontal);
+    expect(API.getElement(arrow).startArrowhead).toBe("circle");
+    expect(API.getElement(arrow).endArrowhead).toBe("arrow");
+
+    API.executeAction(actionFlipVertical);
+    expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+    expect(API.getElement(arrow).endArrowhead).toBe("circle");
+  });
+
+  it("flipping unbound arrow shouldn't flip arrowheads", () => {
+    const arrow = API.createElement({
+      type: "arrow",
+      id: "arrow1",
+      startArrowhead: "arrow",
+      endArrowhead: "circle",
+    });
+
+    API.setElements([arrow]);
+    API.setSelectedElements([arrow]);
+
+    expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+    expect(API.getElement(arrow).endArrowhead).toBe("circle");
+
+    API.executeAction(actionFlipHorizontal);
+    expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+    expect(API.getElement(arrow).endArrowhead).toBe("circle");
+  });
+
+  it("flipping bound arrow shouldn't flip arrowheads if selected alongside non-arrow eleemnt", () => {
+    const rect = API.createElement({
+      type: "rectangle",
+      boundElements: [{ type: "arrow", id: "arrow1" }],
+    });
+    const arrow = API.createElement({
+      type: "arrow",
+      id: "arrow1",
+      startArrowhead: "arrow",
+      endArrowhead: null,
+      endBinding: {
+        elementId: rect.id,
+        focus: 0.5,
+        gap: 5,
+      },
+    });
+
+    API.setElements([rect, arrow]);
+    API.setSelectedElements([rect, arrow]);
+
+    expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+    expect(API.getElement(arrow).endArrowhead).toBe(null);
+
+    API.executeAction(actionFlipHorizontal);
+    expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+    expect(API.getElement(arrow).endArrowhead).toBe(null);
+  });
+});

+ 71 - 2
packages/excalidraw/actions/actionFlip.ts

@@ -2,6 +2,8 @@ import { register } from "./register";
 import { getSelectedElements } from "../scene";
 import { getNonDeletedElements } from "../element";
 import type {
+  ExcalidrawArrowElement,
+  ExcalidrawElbowArrowElement,
   ExcalidrawElement,
   NonDeleted,
   NonDeletedSceneElementsMap,
@@ -18,7 +20,13 @@ import {
 import { updateFrameMembershipOfSelectedElements } from "../frame";
 import { flipHorizontal, flipVertical } from "../components/icons";
 import { StoreAction } from "../store";
-import { isLinearElement } from "../element/typeChecks";
+import {
+  isArrowElement,
+  isElbowArrow,
+  isLinearElement,
+} from "../element/typeChecks";
+import { mutateElbowArrow } from "../element/routing";
+import { mutateElement, newElementWith } from "../element/mutateElement";
 
 export const actionFlipHorizontal = register({
   name: "flipHorizontal",
@@ -109,7 +117,23 @@ const flipElements = (
   flipDirection: "horizontal" | "vertical",
   app: AppClassProperties,
 ): ExcalidrawElement[] => {
-  const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
+  if (
+    selectedElements.every(
+      (element) =>
+        isArrowElement(element) && (element.startBinding || element.endBinding),
+    )
+  ) {
+    return selectedElements.map((element) => {
+      const _element = element as ExcalidrawArrowElement;
+      return newElementWith(_element, {
+        startArrowhead: _element.endArrowhead,
+        endArrowhead: _element.startArrowhead,
+      });
+    });
+  }
+
+  const { minX, minY, maxX, maxY, midX, midY } =
+    getCommonBoundingBox(selectedElements);
 
   resizeMultipleElements(
     elementsMap,
@@ -125,9 +149,54 @@ const flipElements = (
   bindOrUnbindLinearElements(
     selectedElements.filter(isLinearElement),
     elementsMap,
+    app.scene.getNonDeletedElements(),
+    app.scene,
     isBindingEnabled(appState),
     [],
   );
 
+  // ---------------------------------------------------------------------------
+  // flipping arrow elements (and potentially other) makes the selection group
+  // "move" across the canvas because of how arrows can bump against the "wall"
+  // of the selection, so we need to center the group back to the original
+  // position so that repeated flips don't accumulate the offset
+
+  const { elbowArrows, otherElements } = selectedElements.reduce(
+    (
+      acc: {
+        elbowArrows: ExcalidrawElbowArrowElement[];
+        otherElements: ExcalidrawElement[];
+      },
+      element,
+    ) =>
+      isElbowArrow(element)
+        ? { ...acc, elbowArrows: acc.elbowArrows.concat(element) }
+        : { ...acc, otherElements: acc.otherElements.concat(element) },
+    { elbowArrows: [], otherElements: [] },
+  );
+
+  const { midX: newMidX, midY: newMidY } =
+    getCommonBoundingBox(selectedElements);
+  const [diffX, diffY] = [midX - newMidX, midY - newMidY];
+  otherElements.forEach((element) =>
+    mutateElement(element, {
+      x: element.x + diffX,
+      y: element.y + diffY,
+    }),
+  );
+  elbowArrows.forEach((element) =>
+    mutateElbowArrow(
+      element,
+      elementsMap,
+      element.points,
+      undefined,
+      undefined,
+      {
+        informMutation: false,
+      },
+    ),
+  );
+  // ---------------------------------------------------------------------------
+
   return selectedElements;
 };

+ 12 - 8
packages/excalidraw/actions/actionHistory.tsx

@@ -4,7 +4,7 @@ import { ToolButton } from "../components/ToolButton";
 import { t } from "../i18n";
 import type { History } from "../history";
 import { HistoryChangedEvent } from "../history";
-import type { AppState } from "../types";
+import type { AppClassProperties, AppState } from "../types";
 import { KEYS } from "../keys";
 import { arrayToMap } from "../utils";
 import { isWindows } from "../constants";
@@ -13,15 +13,19 @@ import type { Store } from "../store";
 import { StoreAction } from "../store";
 import { useEmitter } from "../hooks/useEmitter";
 
-const writeData = (
+const executeHistoryAction = (
+  app: AppClassProperties,
   appState: Readonly<AppState>,
   updater: () => [SceneElementsMap, AppState] | void,
 ): ActionResult => {
   if (
     !appState.multiElement &&
     !appState.resizingElement &&
-    !appState.editingElement &&
-    !appState.draggingElement
+    !appState.editingTextElement &&
+    !appState.newElement &&
+    !appState.selectedElementsAreBeingDragged &&
+    !appState.selectionElement &&
+    !app.flowChartCreator.isCreatingChart
   ) {
     const result = updater();
 
@@ -50,8 +54,8 @@ export const createUndoAction: ActionCreator = (history, store) => ({
   icon: UndoIcon,
   trackEvent: { category: "history" },
   viewMode: false,
-  perform: (elements, appState) =>
-    writeData(appState, () =>
+  perform: (elements, appState, value, app) =>
+    executeHistoryAction(app, appState, () =>
       history.undo(
         arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
         appState,
@@ -91,8 +95,8 @@ export const createRedoAction: ActionCreator = (history, store) => ({
   icon: RedoIcon,
   trackEvent: { category: "history" },
   viewMode: false,
-  perform: (elements, appState) =>
-    writeData(appState, () =>
+  perform: (elements, appState, _, app) =>
+    executeHistoryAction(app, appState, () =>
       history.redo(
         arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
         appState,

+ 3 - 2
packages/excalidraw/actions/actionLinearEditor.tsx

@@ -1,6 +1,6 @@
 import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
 import { LinearElementEditor } from "../element/linearElementEditor";
-import { isLinearElement } from "../element/typeChecks";
+import { isElbowArrow, isLinearElement } from "../element/typeChecks";
 import type { ExcalidrawLinearElement } from "../element/types";
 import { StoreAction } from "../store";
 import { register } from "./register";
@@ -29,7 +29,8 @@ export const actionToggleLinearEditor = register({
     if (
       !appState.editingLinearElement &&
       selectedElements.length === 1 &&
-      isLinearElement(selectedElements[0])
+      isLinearElement(selectedElements[0]) &&
+      !isElbowArrow(selectedElements[0])
     ) {
       return true;
     }

+ 11 - 12
packages/excalidraw/actions/actionProperties.test.tsx

@@ -1,3 +1,4 @@
+import React from "react";
 import { Excalidraw } from "../index";
 import { queryByTestId } from "@testing-library/react";
 import { render } from "../tests/test-utils";
@@ -6,8 +7,6 @@ import { API } from "../tests/helpers/api";
 import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors";
 import { FONT_FAMILY, STROKE_WIDTH } from "../constants";
 
-const { h } = window;
-
 describe("element locking", () => {
   beforeEach(async () => {
     await render(<Excalidraw />);
@@ -22,7 +21,7 @@ describe("element locking", () => {
       // just in case we change it in the future
       expect(color).not.toBe(COLOR_PALETTE.transparent);
 
-      h.setState({
+      API.setAppState({
         currentItemBackgroundColor: color,
       });
       const activeColor = queryByTestId(
@@ -40,14 +39,14 @@ describe("element locking", () => {
       // just in case we change it in the future
       expect(color).not.toBe(COLOR_PALETTE.transparent);
 
-      h.setState({
+      API.setAppState({
         currentItemBackgroundColor: color,
         currentItemFillStyle: "hachure",
       });
       const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
 
       expect(hachureFillButton).toHaveClass("active");
-      h.setState({
+      API.setAppState({
         currentItemFillStyle: "solid",
       });
       const solidFillStyle = queryByTestId(document.body, `fill-solid`);
@@ -57,7 +56,7 @@ describe("element locking", () => {
     it("should not show fill style when background transparent", () => {
       UI.clickTool("rectangle");
 
-      h.setState({
+      API.setAppState({
         currentItemBackgroundColor: COLOR_PALETTE.transparent,
         currentItemFillStyle: "hachure",
       });
@@ -69,7 +68,7 @@ describe("element locking", () => {
     it("should show horizontal text align for text tool", () => {
       UI.clickTool("text");
 
-      h.setState({
+      API.setAppState({
         currentItemTextAlign: "right",
       });
 
@@ -85,7 +84,7 @@ describe("element locking", () => {
         backgroundColor: "red",
         fillStyle: "cross-hatch",
       });
-      h.elements = [rect];
+      API.setElements([rect]);
       API.setSelectedElements([rect]);
 
       const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
@@ -98,7 +97,7 @@ describe("element locking", () => {
         backgroundColor: COLOR_PALETTE.transparent,
         fillStyle: "cross-hatch",
       });
-      h.elements = [rect];
+      API.setElements([rect]);
       API.setSelectedElements([rect]);
 
       const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
@@ -114,7 +113,7 @@ describe("element locking", () => {
         type: "rectangle",
         strokeWidth: STROKE_WIDTH.thin,
       });
-      h.elements = [rect1, rect2];
+      API.setElements([rect1, rect2]);
       API.setSelectedElements([rect1, rect2]);
 
       const thinStrokeWidthButton = queryByTestId(
@@ -133,7 +132,7 @@ describe("element locking", () => {
         type: "rectangle",
         strokeWidth: STROKE_WIDTH.bold,
       });
-      h.elements = [rect1, rect2];
+      API.setElements([rect1, rect2]);
       API.setSelectedElements([rect1, rect2]);
 
       expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null);
@@ -157,7 +156,7 @@ describe("element locking", () => {
         type: "text",
         fontFamily: FONT_FAMILY["Comic Shanns"],
       });
-      h.elements = [rect, text];
+      API.setElements([rect, text]);
       API.setSelectedElements([rect, text]);
 
       expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();

+ 255 - 27
packages/excalidraw/actions/actionProperties.tsx

@@ -50,8 +50,12 @@ import {
   ArrowheadDiamondIcon,
   ArrowheadDiamondOutlineIcon,
   fontSizeIcon,
+  sharpArrowIcon,
+  roundArrowIcon,
+  elbowArrowIcon,
 } from "../components/icons";
 import {
+  ARROW_TYPE,
   DEFAULT_FONT_FAMILY,
   DEFAULT_FONT_SIZE,
   FONT_FAMILY,
@@ -67,12 +71,15 @@ import {
 import { mutateElement, newElementWith } from "../element/mutateElement";
 import { getBoundTextElement } from "../element/textElement";
 import {
+  isArrowElement,
   isBoundToContainer,
+  isElbowArrow,
   isLinearElement,
   isUsingAdaptiveRadius,
 } from "../element/typeChecks";
 import type {
   Arrowhead,
+  ExcalidrawBindableElement,
   ExcalidrawElement,
   ExcalidrawLinearElement,
   ExcalidrawTextElement,
@@ -91,10 +98,25 @@ import {
   isSomeElementSelected,
 } from "../scene";
 import { hasStrokeColor } from "../scene/comparisons";
-import { arrayToMap, getFontFamilyString, getShortcutKey } from "../utils";
+import {
+  arrayToMap,
+  getFontFamilyString,
+  getShortcutKey,
+  tupleToCoors,
+} from "../utils";
 import { register } from "./register";
 import { StoreAction } from "../store";
 import { Fonts, getLineHeight } from "../fonts";
+import {
+  bindLinearElement,
+  bindPointToSnapToElementOutline,
+  calculateFixedPointForElbowArrowBinding,
+  getHoveredElementForBinding,
+} from "../element/binding";
+import { mutateElbowArrow } from "../element/routing";
+import { LinearElementEditor } from "../element/linearElementEditor";
+import type { LocalPoint } from "../../math";
+import { pointFrom, vector } from "../../math";
 
 const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
 
@@ -113,7 +135,7 @@ export const changeProperty = (
   return elements.map((element) => {
     if (
       selectedElementIds.get(element.id) ||
-      element.id === appState.editingElement?.id
+      element.id === appState.editingTextElement?.id
     ) {
       return callback(element);
     }
@@ -128,13 +150,13 @@ export const getFormValue = function <T extends Primitive>(
   isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
   defaultValue: T | ((isSomeElementSelected: boolean) => T),
 ): T {
-  const editingElement = appState.editingElement;
+  const editingTextElement = appState.editingTextElement;
   const nonDeletedElements = getNonDeletedElements(elements);
 
   let ret: T | null = null;
 
-  if (editingElement) {
-    ret = getAttribute(editingElement);
+  if (editingTextElement) {
+    ret = getAttribute(editingTextElement);
   }
 
   if (!ret) {
@@ -830,7 +852,7 @@ export const actionChangeFontFamily = register({
         ExcalidrawTextElement,
         ExcalidrawElement | null
       >();
-      let uniqueGlyphs = new Set<string>();
+      let uniqueChars = new Set<string>();
       let skipFontFaceCheck = false;
 
       const fontsCache = Array.from(Fonts.loadedFontsCache.values());
@@ -878,8 +900,8 @@ export const actionChangeFontFamily = register({
               }
 
               if (!skipFontFaceCheck) {
-                uniqueGlyphs = new Set([
-                  ...uniqueGlyphs,
+                uniqueChars = new Set([
+                  ...uniqueChars,
                   ...Array.from(newElement.originalText),
                 ]);
               }
@@ -899,12 +921,9 @@ export const actionChangeFontFamily = register({
       const fontString = `10px ${getFontFamilyString({
         fontFamily: nextFontFamily,
       })}`;
-      const glyphs = Array.from(uniqueGlyphs.values()).join();
+      const chars = Array.from(uniqueChars.values()).join();
 
-      if (
-        skipFontFaceCheck ||
-        window.document.fonts.check(fontString, glyphs)
-      ) {
+      if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
         // we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
         for (const [element, container] of elementContainerMapping) {
           // trigger synchronous redraw
@@ -916,8 +935,8 @@ export const actionChangeFontFamily = register({
           );
         }
       } else {
-        // otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded
-        window.document.fonts.load(fontString, glyphs).then((fontFaces) => {
+        // otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
+        window.document.fonts.load(fontString, chars).then((fontFaces) => {
           for (const [element, container] of elementContainerMapping) {
             // use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
             const latestElement = app.scene.getElement(element.id);
@@ -1056,19 +1075,20 @@ export const actionChangeFontFamily = register({
               // open, populate the cache from scratch
               cachedElementsRef.current.clear();
 
-              const { editingElement } = appState;
+              const { editingTextElement } = appState;
 
-              if (editingElement?.type === "text") {
-                // retrieve the latest version from the scene, as `editingElement` isn't mutated
-                const latestEditingElement = app.scene.getElement(
-                  editingElement.id,
+              // still check type to be safe
+              if (editingTextElement?.type === "text") {
+                // retrieve the latest version from the scene, as `editingTextElement` isn't mutated
+                const latesteditingTextElement = app.scene.getElement(
+                  editingTextElement.id,
                 );
 
                 // inside the wysiwyg editor
                 cachedElementsRef.current.set(
-                  editingElement.id,
+                  editingTextElement.id,
                   newElementWith(
-                    latestEditingElement || editingElement,
+                    latesteditingTextElement || editingTextElement,
                     {},
                     true,
                   ),
@@ -1304,8 +1324,12 @@ export const actionChangeRoundness = register({
   trackEvent: false,
   perform: (elements, appState, value) => {
     return {
-      elements: changeProperty(elements, appState, (el) =>
-        newElementWith(el, {
+      elements: changeProperty(elements, appState, (el) => {
+        if (isElbowArrow(el)) {
+          return el;
+        }
+
+        return newElementWith(el, {
           roundness:
             value === "round"
               ? {
@@ -1314,8 +1338,8 @@ export const actionChangeRoundness = register({
                     : ROUNDNESS.PROPORTIONAL_RADIUS,
                 }
               : null,
-        }),
-      ),
+        });
+      }),
       appState: {
         ...appState,
         currentItemRoundness: value,
@@ -1355,7 +1379,8 @@ export const actionChangeRoundness = register({
             appState,
             (element) =>
               hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
-            (element) => element.hasOwnProperty("roundness"),
+            (element) =>
+              !isArrowElement(element) && element.hasOwnProperty("roundness"),
             (hasSelection) =>
               hasSelection ? null : appState.currentItemRoundness,
           )}
@@ -1518,3 +1543,206 @@ export const actionChangeArrowhead = register({
     );
   },
 });
+
+export const actionChangeArrowType = register({
+  name: "changeArrowType",
+  label: "Change arrow types",
+  trackEvent: false,
+  perform: (elements, appState, value, app) => {
+    return {
+      elements: changeProperty(elements, appState, (el) => {
+        if (!isArrowElement(el)) {
+          return el;
+        }
+        const newElement = newElementWith(el, {
+          roundness:
+            value === ARROW_TYPE.round
+              ? {
+                  type: ROUNDNESS.PROPORTIONAL_RADIUS,
+                }
+              : null,
+          elbowed: value === ARROW_TYPE.elbow,
+          points:
+            value === ARROW_TYPE.elbow || el.elbowed
+              ? [el.points[0], el.points[el.points.length - 1]]
+              : el.points,
+        });
+
+        if (isElbowArrow(newElement)) {
+          const elementsMap = app.scene.getNonDeletedElementsMap();
+
+          app.dismissLinearEditor();
+
+          const startGlobalPoint =
+            LinearElementEditor.getPointAtIndexGlobalCoordinates(
+              newElement,
+              0,
+              elementsMap,
+            );
+          const endGlobalPoint =
+            LinearElementEditor.getPointAtIndexGlobalCoordinates(
+              newElement,
+              -1,
+              elementsMap,
+            );
+          const startHoveredElement =
+            !newElement.startBinding &&
+            getHoveredElementForBinding(
+              tupleToCoors(startGlobalPoint),
+              elements,
+              elementsMap,
+              true,
+            );
+          const endHoveredElement =
+            !newElement.endBinding &&
+            getHoveredElementForBinding(
+              tupleToCoors(endGlobalPoint),
+              elements,
+              elementsMap,
+              true,
+            );
+          const startElement = startHoveredElement
+            ? startHoveredElement
+            : newElement.startBinding &&
+              (elementsMap.get(
+                newElement.startBinding.elementId,
+              ) as ExcalidrawBindableElement);
+          const endElement = endHoveredElement
+            ? endHoveredElement
+            : newElement.endBinding &&
+              (elementsMap.get(
+                newElement.endBinding.elementId,
+              ) as ExcalidrawBindableElement);
+
+          const finalStartPoint = startHoveredElement
+            ? bindPointToSnapToElementOutline(
+                startGlobalPoint,
+                endGlobalPoint,
+                startHoveredElement,
+                elementsMap,
+              )
+            : startGlobalPoint;
+          const finalEndPoint = endHoveredElement
+            ? bindPointToSnapToElementOutline(
+                endGlobalPoint,
+                startGlobalPoint,
+                endHoveredElement,
+                elementsMap,
+              )
+            : endGlobalPoint;
+
+          startHoveredElement &&
+            bindLinearElement(
+              newElement,
+              startHoveredElement,
+              "start",
+              elementsMap,
+            );
+          endHoveredElement &&
+            bindLinearElement(
+              newElement,
+              endHoveredElement,
+              "end",
+              elementsMap,
+            );
+
+          mutateElbowArrow(
+            newElement,
+            elementsMap,
+            [finalStartPoint, finalEndPoint].map(
+              (p): LocalPoint =>
+                pointFrom(p[0] - newElement.x, p[1] - newElement.y),
+            ),
+            vector(0, 0),
+            {
+              ...(startElement && newElement.startBinding
+                ? {
+                    startBinding: {
+                      // @ts-ignore TS cannot discern check above
+                      ...newElement.startBinding!,
+                      ...calculateFixedPointForElbowArrowBinding(
+                        newElement,
+                        startElement,
+                        "start",
+                        elementsMap,
+                      ),
+                    },
+                  }
+                : {}),
+              ...(endElement && newElement.endBinding
+                ? {
+                    endBinding: {
+                      // @ts-ignore TS cannot discern check above
+                      ...newElement.endBinding,
+                      ...calculateFixedPointForElbowArrowBinding(
+                        newElement,
+                        endElement,
+                        "end",
+                        elementsMap,
+                      ),
+                    },
+                  }
+                : {}),
+            },
+          );
+        }
+
+        return newElement;
+      }),
+      appState: {
+        ...appState,
+        currentItemArrowType: value,
+      },
+      storeAction: StoreAction.CAPTURE,
+    };
+  },
+  PanelComponent: ({ elements, appState, updateData }) => {
+    return (
+      <fieldset>
+        <legend>{t("labels.arrowtypes")}</legend>
+        <ButtonIconSelect
+          group="arrowtypes"
+          options={[
+            {
+              value: ARROW_TYPE.sharp,
+              text: t("labels.arrowtype_sharp"),
+              icon: sharpArrowIcon,
+              testId: "sharp-arrow",
+            },
+            {
+              value: ARROW_TYPE.round,
+              text: t("labels.arrowtype_round"),
+              icon: roundArrowIcon,
+              testId: "round-arrow",
+            },
+            {
+              value: ARROW_TYPE.elbow,
+              text: t("labels.arrowtype_elbowed"),
+              icon: elbowArrowIcon,
+              testId: "elbow-arrow",
+            },
+          ]}
+          value={getFormValue(
+            elements,
+            appState,
+            (element) => {
+              if (isArrowElement(element)) {
+                return element.elbowed
+                  ? ARROW_TYPE.elbow
+                  : element.roundness
+                  ? ARROW_TYPE.round
+                  : ARROW_TYPE.sharp;
+              }
+
+              return null;
+            },
+            (element) => isArrowElement(element),
+            (hasSelection) =>
+              hasSelection ? null : appState.currentItemArrowType,
+          )}
+          onChange={(value) => updateData(value)}
+        />
+      </fieldset>
+    );
+  },
+});

+ 4 - 5
packages/excalidraw/actions/actionToggleGridMode.tsx

@@ -1,6 +1,5 @@
 import { CODES, KEYS } from "../keys";
 import { register } from "./register";
-import { GRID_SIZE } from "../constants";
 import type { AppState } from "../types";
 import { gridIcon } from "../components/icons";
 import { StoreAction } from "../store";
@@ -13,21 +12,21 @@ export const actionToggleGridMode = register({
   viewMode: true,
   trackEvent: {
     category: "canvas",
-    predicate: (appState) => !appState.gridSize,
+    predicate: (appState) => appState.gridModeEnabled,
   },
   perform(elements, appState) {
     return {
       appState: {
         ...appState,
-        gridSize: this.checked!(appState) ? null : GRID_SIZE,
+        gridModeEnabled: !this.checked!(appState),
         objectsSnapModeEnabled: false,
       },
       storeAction: StoreAction.NONE,
     };
   },
-  checked: (appState: AppState) => appState.gridSize !== null,
+  checked: (appState: AppState) => appState.gridModeEnabled,
   predicate: (element, appState, props) => {
-    return typeof props.gridModeEnabled === "undefined";
+    return props.gridModeEnabled === undefined;
   },
   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
 });

+ 1 - 1
packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx

@@ -17,7 +17,7 @@ export const actionToggleObjectsSnapMode = register({
       appState: {
         ...appState,
         objectsSnapModeEnabled: !this.checked!(appState),
-        gridSize: null,
+        gridModeEnabled: false,
       },
       storeAction: StoreAction.NONE,
     };

+ 55 - 0
packages/excalidraw/actions/actionToggleSearchMenu.ts

@@ -0,0 +1,55 @@
+import { KEYS } from "../keys";
+import { register } from "./register";
+import type { AppState } from "../types";
+import { searchIcon } from "../components/icons";
+import { StoreAction } from "../store";
+import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants";
+
+export const actionToggleSearchMenu = register({
+  name: "searchMenu",
+  icon: searchIcon,
+  keywords: ["search", "find"],
+  label: "search.title",
+  viewMode: true,
+  trackEvent: {
+    category: "search_menu",
+    action: "toggle",
+    predicate: (appState) => appState.gridModeEnabled,
+  },
+  perform(elements, appState, _, app) {
+    if (
+      appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
+      appState.openSidebar.tab === CANVAS_SEARCH_TAB
+    ) {
+      const searchInput =
+        app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
+          `.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
+        );
+
+      if (searchInput?.matches(":focus")) {
+        return {
+          appState: { ...appState, openSidebar: null },
+          storeAction: StoreAction.NONE,
+        };
+      }
+
+      searchInput?.focus();
+      searchInput?.select();
+      return false;
+    }
+
+    return {
+      appState: {
+        ...appState,
+        openSidebar: { name: DEFAULT_SIDEBAR.name, tab: CANVAS_SEARCH_TAB },
+        openDialog: null,
+      },
+      storeAction: StoreAction.NONE,
+    };
+  },
+  checked: (appState: AppState) => appState.gridModeEnabled,
+  predicate: (element, appState, props) => {
+    return props.gridModeEnabled === undefined;
+  },
+  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
+});

+ 2 - 0
packages/excalidraw/actions/index.ts

@@ -86,3 +86,5 @@ export { actionUnbindText, actionBindText } from "./actionBoundText";
 export { actionLink } from "./actionLink";
 export { actionToggleElementLock } from "./actionElementLock";
 export { actionToggleLinearEditor } from "./actionLinearEditor";
+
+export { actionToggleSearchMenu } from "./actionToggleSearchMenu";

+ 3 - 1
packages/excalidraw/actions/shortcuts.ts

@@ -52,7 +52,8 @@ export type ShortcutName =
     >
   | "saveScene"
   | "imageExport"
-  | "commandPalette";
+  | "commandPalette"
+  | "searchMenu";
 
 export const registerCustomShortcuts = (
   shortcuts: Record<CustomActionName, string[]>,
@@ -122,6 +123,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
   saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
   saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
   toggleShortcuts: [getShortcutKey("?")],
+  searchMenu: [getShortcutKey("CtrlOrCmd+F")],
 };
 
 export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {

+ 5 - 2
packages/excalidraw/actions/types.ts

@@ -84,6 +84,7 @@ export type ActionName =
   | "changeSloppiness"
   | "changeStrokeStyle"
   | "changeArrowhead"
+  | "changeArrowType"
   | "changeOpacity"
   | "changeFontSize"
   | "toggleCanvasMenu"
@@ -150,7 +151,8 @@ export type ActionName =
   | "wrapTextInContainer"
   | "commandPalette"
   | "autoResize"
-  | "elementStats";
+  | "elementStats"
+  | "searchMenu";
 
 export type PanelComponentProps = {
   elements: readonly ExcalidrawElement[];
@@ -205,7 +207,8 @@ export interface Action {
           | "history"
           | "menu"
           | "collab"
-          | "hyperlink";
+          | "hyperlink"
+          | "search_menu";
         action?: string;
         predicate?: (
           appState: Readonly<AppState>,

+ 1 - 1
packages/excalidraw/analytics.ts

@@ -1,6 +1,6 @@
 // place here categories that you want to track. We want to track just a
 // small subset of categories at a given time.
-const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
+const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette", "export"]);
 
 export const trackEvent = (
   category: string,

+ 20 - 5
packages/excalidraw/appState.ts

@@ -1,12 +1,15 @@
 import { COLOR_PALETTE } from "./colors";
 import {
+  ARROW_TYPE,
   DEFAULT_ELEMENT_PROPS,
   DEFAULT_FONT_FAMILY,
   DEFAULT_FONT_SIZE,
   DEFAULT_TEXT_ALIGN,
+  DEFAULT_GRID_SIZE,
   EXPORT_SCALES,
   STATS_PANELS,
   THEME,
+  DEFAULT_GRID_STEP,
 } from "./constants";
 import type { AppState, NormalizedZoomValue } from "./types";
 
@@ -33,14 +36,15 @@ export const getDefaultAppState = (): Omit<
     currentItemStartArrowhead: null,
     currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
     currentItemRoundness: "round",
+    currentItemArrowType: ARROW_TYPE.round,
     currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
     currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
     currentItemTextAlign: DEFAULT_TEXT_ALIGN,
     currentHoveredFontFamily: null,
     cursorButton: "up",
     activeEmbeddable: null,
-    draggingElement: null,
-    editingElement: null,
+    newElement: null,
+    editingTextElement: null,
     editingGroupId: null,
     editingLinearElement: null,
     activeTool: {
@@ -57,7 +61,9 @@ export const getDefaultAppState = (): Omit<
     exportEmbedScene: false,
     exportWithDarkMode: false,
     fileHandle: null,
-    gridSize: null,
+    gridSize: DEFAULT_GRID_SIZE,
+    gridStep: DEFAULT_GRID_STEP,
+    gridModeEnabled: false,
     isBindingEnabled: true,
     defaultSidebarDockedPreference: false,
     isLoading: false,
@@ -110,6 +116,7 @@ export const getDefaultAppState = (): Omit<
     objectsSnapModeEnabled: false,
     userToFollow: null,
     followedBy: new Set(),
+    searchMatches: [],
   };
 };
 
@@ -143,6 +150,11 @@ const APP_STATE_STORAGE_CONF = (<
     export: false,
     server: false,
   },
+  currentItemArrowType: {
+    browser: true,
+    export: false,
+    server: false,
+  },
   currentItemOpacity: { browser: true, export: false, server: false },
   currentItemRoughness: { browser: true, export: false, server: false },
   currentItemStartArrowhead: { browser: true, export: false, server: false },
@@ -153,8 +165,8 @@ const APP_STATE_STORAGE_CONF = (<
   currentHoveredFontFamily: { browser: false, export: false, server: false },
   cursorButton: { browser: true, export: false, server: false },
   activeEmbeddable: { browser: false, export: false, server: false },
-  draggingElement: { browser: false, export: false, server: false },
-  editingElement: { browser: false, export: false, server: false },
+  newElement: { browser: false, export: false, server: false },
+  editingTextElement: { browser: false, export: false, server: false },
   editingGroupId: { browser: true, export: false, server: false },
   editingLinearElement: { browser: false, export: false, server: false },
   activeTool: { browser: true, export: false, server: false },
@@ -169,6 +181,8 @@ const APP_STATE_STORAGE_CONF = (<
   exportWithDarkMode: { browser: true, export: false, server: false },
   fileHandle: { browser: false, export: false, server: false },
   gridSize: { browser: true, export: true, server: true },
+  gridStep: { browser: true, export: true, server: true },
+  gridModeEnabled: { browser: true, export: true, server: true },
   height: { browser: false, export: false, server: false },
   isBindingEnabled: { browser: false, export: false, server: false },
   defaultSidebarDockedPreference: {
@@ -225,6 +239,7 @@ const APP_STATE_STORAGE_CONF = (<
   objectsSnapModeEnabled: { browser: true, export: false, server: false },
   userToFollow: { browser: false, export: false, server: false },
   followedBy: { browser: false, export: false, server: false },
+  searchMatches: { browser: false, export: false, server: false },
 });
 
 const _clearAppStateForStorage = <

+ 105 - 0
packages/excalidraw/binaryheap.ts

@@ -0,0 +1,105 @@
+export default class BinaryHeap<T> {
+  private content: T[] = [];
+
+  constructor(private scoreFunction: (node: T) => number) {}
+
+  sinkDown(idx: number) {
+    const node = this.content[idx];
+    while (idx > 0) {
+      const parentN = ((idx + 1) >> 1) - 1;
+      const parent = this.content[parentN];
+      if (this.scoreFunction(node) < this.scoreFunction(parent)) {
+        this.content[parentN] = node;
+        this.content[idx] = parent;
+        idx = parentN; // TODO: Optimize
+      } else {
+        break;
+      }
+    }
+  }
+
+  bubbleUp(idx: number) {
+    const length = this.content.length;
+    const node = this.content[idx];
+    const score = this.scoreFunction(node);
+
+    while (true) {
+      const child2N = (idx + 1) << 1;
+      const child1N = child2N - 1;
+      let swap = null;
+      let child1Score = 0;
+
+      if (child1N < length) {
+        const child1 = this.content[child1N];
+        child1Score = this.scoreFunction(child1);
+        if (child1Score < score) {
+          swap = child1N;
+        }
+      }
+
+      if (child2N < length) {
+        const child2 = this.content[child2N];
+        const child2Score = this.scoreFunction(child2);
+        if (child2Score < (swap === null ? score : child1Score)) {
+          swap = child2N;
+        }
+      }
+
+      if (swap !== null) {
+        this.content[idx] = this.content[swap];
+        this.content[swap] = node;
+        idx = swap; // TODO: Optimize
+      } else {
+        break;
+      }
+    }
+  }
+
+  push(node: T) {
+    this.content.push(node);
+    this.sinkDown(this.content.length - 1);
+  }
+
+  pop(): T | null {
+    if (this.content.length === 0) {
+      return null;
+    }
+
+    const result = this.content[0];
+    const end = this.content.pop()!;
+
+    if (this.content.length > 0) {
+      this.content[0] = end;
+      this.bubbleUp(0);
+    }
+
+    return result;
+  }
+
+  remove(node: T) {
+    if (this.content.length === 0) {
+      return;
+    }
+
+    const i = this.content.indexOf(node);
+    const end = this.content.pop()!;
+
+    if (i < this.content.length) {
+      this.content[i] = end;
+
+      if (this.scoreFunction(end) < this.scoreFunction(node)) {
+        this.sinkDown(i);
+      } else {
+        this.bubbleUp(i);
+      }
+    }
+  }
+
+  size(): number {
+    return this.content.length;
+  }
+
+  rescoreElement(node: T) {
+    this.sinkDown(this.content.indexOf(node));
+  }
+}

+ 6 - 2
packages/excalidraw/change.ts

@@ -1100,7 +1100,6 @@ export class ElementsChange implements Change<SceneElementsMap> {
     try {
       // TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
       ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
-      ElementsChange.redrawBoundArrows(nextElements, changedElements);
 
       // the following reorder performs also mutations, but only on new instances of changed elements
       // (unless something goes really bad and it fallbacks to fixing all invalid indices)
@@ -1109,6 +1108,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
         changedElements,
         flags,
       );
+
+      // Need ordered nextElements to avoid z-index binding issues
+      ElementsChange.redrawBoundArrows(nextElements, changedElements);
     } catch (e) {
       console.error(
         `Couldn't mutate elements after applying elements change`,
@@ -1460,7 +1462,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
   ) {
     for (const element of changed.values()) {
       if (!element.isDeleted && isBindableElement(element)) {
-        updateBoundElements(element, elements);
+        updateBoundElements(element, elements, {
+          changedElements: changed,
+        });
       }
     }
   }

+ 7 - 27
packages/excalidraw/charts.ts

@@ -1,3 +1,5 @@
+import type { Radians } from "../math";
+import { pointFrom } from "../math";
 import {
   COLOR_PALETTE,
   DEFAULT_CHART_COLOR_INDEX,
@@ -211,7 +213,7 @@ const chartXLabels = (
         x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
         y: y + BAR_GAP / 2,
         width: BAR_WIDTH,
-        angle: 5.87,
+        angle: 5.87 as Radians,
         fontSize: 16,
         textAlign: "center",
         verticalAlign: "top",
@@ -268,13 +270,8 @@ const chartLines = (
     type: "line",
     x,
     y,
-    startArrowhead: null,
-    endArrowhead: null,
     width: chartWidth,
-    points: [
-      [0, 0],
-      [chartWidth, 0],
-    ],
+    points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
     ...selectSubtype(spreadsheet, "line"),
   });
 
@@ -285,13 +282,8 @@ const chartLines = (
     type: "line",
     x,
     y,
-    startArrowhead: null,
-    endArrowhead: null,
     height: chartHeight,
-    points: [
-      [0, 0],
-      [0, -chartHeight],
-    ],
+    points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
     ...selectSubtype(spreadsheet, "line"),
   });
 
@@ -302,15 +294,10 @@ const chartLines = (
     type: "line",
     x,
     y: y - BAR_HEIGHT - BAR_GAP,
-    startArrowhead: null,
-    endArrowhead: null,
     strokeStyle: "dotted",
     width: chartWidth,
     opacity: GRID_OPACITY,
-    points: [
-      [0, 0],
-      [chartWidth, 0],
-    ],
+    points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
     ...selectSubtype(spreadsheet, "line"),
   });
 
@@ -435,8 +422,6 @@ const chartTypeLine = (
     type: "line",
     x: x + BAR_GAP + BAR_WIDTH / 2,
     y: y - BAR_GAP,
-    startArrowhead: null,
-    endArrowhead: null,
     height: maxY - minY,
     width: maxX - minX,
     strokeWidth: 2,
@@ -472,15 +457,10 @@ const chartTypeLine = (
       type: "line",
       x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
       y: y - cy,
-      startArrowhead: null,
-      endArrowhead: null,
       height: cy,
       strokeStyle: "dotted",
       opacity: GRID_OPACITY,
-      points: [
-        [0, 0],
-        [0, cy],
-      ],
+      points: [pointFrom(0, 0), pointFrom(0, cy)],
       ...selectSubtype(spreadsheet, "line"),
     });
   });

+ 16 - 21
packages/excalidraw/components/Actions.tsx

@@ -22,10 +22,11 @@ import { capitalizeString, isTransparent } from "../utils";
 import Stack from "./Stack";
 import { ToolButton } from "./ToolButton";
 import { SubtypeShapeActions } from "./Subtypes";
-import { hasStrokeColor } from "../scene/comparisons";
+import { hasStrokeColor, toolIsArrow } from "../scene/comparisons";
 import { trackEvent } from "../analytics";
 import {
   hasBoundTextElement,
+  isElbowArrow,
   isLinearElement,
   isTextElement,
 } from "../element/typeChecks";
@@ -45,11 +46,11 @@ import {
   frameToolIcon,
   mermaidLogoIcon,
   laserPointerToolIcon,
-  OpenAIIcon,
   MagicIcon,
 } from "./icons";
 import { KEYS } from "../keys";
 import { useTunnels } from "../context/tunnels";
+import { CLASSES } from "../constants";
 
 export const canChangeStrokeColor = (
   appState: UIAppState,
@@ -104,7 +105,9 @@ export const SelectedShapeActions = ({
   ) {
     isSingleElementBoundContainer = true;
   }
-  const isEditing = Boolean(appState.editingElement);
+  const isEditingTextOrNewElement = Boolean(
+    appState.editingTextElement || appState.newElement,
+  );
   const device = useDevice();
   const isRTL = document.documentElement.getAttribute("dir") === "rtl";
 
@@ -122,7 +125,8 @@ export const SelectedShapeActions = ({
   const showLineEditorAction =
     !appState.editingLinearElement &&
     targetElements.length === 1 &&
-    isLinearElement(targetElements[0]);
+    isLinearElement(targetElements[0]) &&
+    !isElbowArrow(targetElements[0]);
 
   return (
     <div className="panelColumn">
@@ -157,6 +161,11 @@ export const SelectedShapeActions = ({
         <>{renderAction("changeRoundness")}</>
       )}
 
+      {(toolIsArrow(appState.activeTool.type) ||
+        targetElements.some((element) => toolIsArrow(element.type))) && (
+        <>{renderAction("changeArrowType")}</>
+      )}
+
       {(appState.activeTool.type === "text" ||
         targetElements.some(isTextElement)) && (
         <>
@@ -229,7 +238,7 @@ export const SelectedShapeActions = ({
           </div>
         </fieldset>
       )}
-      {!isEditing && targetElements.length > 0 && (
+      {!isEditingTextOrNewElement && targetElements.length > 0 && (
         <fieldset>
           <legend>{t("labels.actions")}</legend>
           <div className="buttonList">
@@ -395,7 +404,7 @@ export const ShapesSwitcher = ({
           >
             {t("toolBar.mermaidToExcalidraw")}
           </DropdownMenu.Item>
-          {app.props.aiEnabled !== false && (
+          {app.props.aiEnabled !== false && app.plugins.diagramToCode && (
             <>
               <DropdownMenu.Item
                 onSelect={() => app.onMagicframeToolSelect()}
@@ -405,20 +414,6 @@ export const ShapesSwitcher = ({
                 {t("toolBar.magicframe")}
                 <DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
               </DropdownMenu.Item>
-              <DropdownMenu.Item
-                onSelect={() => {
-                  trackEvent("ai", "open-settings", "d2c");
-                  app.setOpenDialog({
-                    name: "settings",
-                    source: "settings",
-                    tab: "diagram-to-code",
-                  });
-                }}
-                icon={OpenAIIcon}
-                data-testid="toolbar-magicSettings"
-              >
-                {t("toolBar.magicSettings")}
-              </DropdownMenu.Item>
             </>
           )}
         </DropdownMenu.Content>
@@ -434,7 +429,7 @@ export const ZoomActions = ({
   renderAction: ActionManager["renderAction"];
   zoom: Zoom;
 }) => (
-  <Stack.Col gap={1} className="zoom-actions">
+  <Stack.Col gap={1} className={CLASSES.ZOOM_ACTIONS}>
     <Stack.Row align="center">
       {renderAction("zoomOut")}
       {renderAction("resetZoom")}

File diff suppressed because it is too large
+ 401 - 229
packages/excalidraw/components/App.tsx


+ 1 - 1
packages/excalidraw/components/ColorPicker/ColorPicker.tsx

@@ -106,7 +106,7 @@ const ColorPickerPopupContent = ({
   return (
     <PropertiesPopover
       container={container}
-      style={{ maxWidth: "208px" }}
+      style={{ maxWidth: "13rem" }}
       onFocusOutside={(event) => {
         // refocus due to eye dropper
         focusPickerContent();

+ 14 - 1
packages/excalidraw/components/CommandPalette/CommandPalette.tsx

@@ -43,7 +43,11 @@ import { InlineIcon } from "../InlineIcon";
 import { SHAPES } from "../../shapes";
 import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
 import { useStableCallback } from "../../hooks/useStableCallback";
-import { actionClearCanvas, actionLink } from "../../actions";
+import {
+  actionClearCanvas,
+  actionLink,
+  actionToggleSearchMenu,
+} from "../../actions";
 import { jotaiStore } from "../../jotai";
 import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
 import type { CommandPaletteItem } from "./types";
@@ -382,6 +386,15 @@ function CommandPaletteInner({
             }
           },
         },
+        {
+          label: t("search.title"),
+          category: DEFAULT_CATEGORIES.app,
+          icon: searchIcon,
+          viewMode: true,
+          perform: () => {
+            actionManager.executeAction(actionToggleSearchMenu);
+          },
+        },
         {
           label: t("labels.changeStroke"),
           keywords: ["color", "outline"],

+ 1 - 1
packages/excalidraw/components/DefaultSidebar.test.tsx

@@ -9,7 +9,7 @@ import {
 import {
   assertExcalidrawWithSidebar,
   assertSidebarDockButton,
-} from "./Sidebar/Sidebar.test";
+} from "./Sidebar/siderbar.test.helpers";
 
 const { h } = window;
 

+ 28 - 25
packages/excalidraw/components/DefaultSidebar.tsx

@@ -1,8 +1,11 @@
 import clsx from "clsx";
-import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants";
+import {
+  CANVAS_SEARCH_TAB,
+  DEFAULT_SIDEBAR,
+  LIBRARY_SIDEBAR_TAB,
+} from "../constants";
 import { useTunnels } from "../context/tunnels";
 import { useUIAppState } from "../context/ui-appState";
-import { t } from "../i18n";
 import type { MarkOptional, Merge } from "../utility-types";
 import { composeEventHandlers } from "../utils";
 import { useExcalidrawSetAppState } from "./App";
@@ -10,6 +13,9 @@ import { withInternalFallback } from "./hoc/withInternalFallback";
 import { LibraryMenu } from "./LibraryMenu";
 import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
 import { Sidebar } from "./Sidebar/Sidebar";
+import "../components/dropdownMenu/DropdownMenu.scss";
+import { SearchMenu } from "./SearchMenu";
+import { LibraryIcon, searchIcon } from "./icons";
 
 const DefaultSidebarTrigger = withInternalFallback(
   "DefaultSidebarTrigger",
@@ -31,14 +37,11 @@ const DefaultSidebarTrigger = withInternalFallback(
 );
 DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
 
-const DefaultTabTriggers = ({
-  children,
-  ...rest
-}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
+const DefaultTabTriggers = ({ children }: { children: React.ReactNode }) => {
   const { DefaultSidebarTabTriggersTunnel } = useTunnels();
   return (
     <DefaultSidebarTabTriggersTunnel.In>
-      <Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers>
+      {children}
     </DefaultSidebarTabTriggersTunnel.In>
   );
 };
@@ -65,17 +68,21 @@ export const DefaultSidebar = Object.assign(
 
       const { DefaultSidebarTabTriggersTunnel } = useTunnels();
 
+      const isForceDocked = appState.openSidebar?.tab === CANVAS_SEARCH_TAB;
+
       return (
         <Sidebar
           {...rest}
           name="default"
           key="default"
           className={clsx("default-sidebar", className)}
-          docked={docked ?? appState.defaultSidebarDockedPreference}
+          docked={
+            isForceDocked || (docked ?? appState.defaultSidebarDockedPreference)
+          }
           onDock={
             // `onDock=false` disables docking.
             // if `docked` passed, but no onDock passed, disable manual docking.
-            onDock === false || (!onDock && docked != null)
+            isForceDocked || onDock === false || (!onDock && docked != null)
               ? undefined
               : // compose to allow the host app to listen on default behavior
                 composeEventHandlers(onDock, (docked) => {
@@ -85,26 +92,22 @@ export const DefaultSidebar = Object.assign(
         >
           <Sidebar.Tabs>
             <Sidebar.Header>
-              {rest.__fallback && (
-                <div
-                  style={{
-                    color: "var(--color-primary)",
-                    fontSize: "1.2em",
-                    fontWeight: "bold",
-                    textOverflow: "ellipsis",
-                    overflow: "hidden",
-                    whiteSpace: "nowrap",
-                    paddingRight: "1em",
-                  }}
-                >
-                  {t("toolBar.library")}
-                </div>
-              )}
-              <DefaultSidebarTabTriggersTunnel.Out />
+              <Sidebar.TabTriggers>
+                <Sidebar.TabTrigger tab={CANVAS_SEARCH_TAB}>
+                  {searchIcon}
+                </Sidebar.TabTrigger>
+                <Sidebar.TabTrigger tab={LIBRARY_SIDEBAR_TAB}>
+                  {LibraryIcon}
+                </Sidebar.TabTrigger>
+                <DefaultSidebarTabTriggersTunnel.Out />
+              </Sidebar.TabTriggers>
             </Sidebar.Header>
             <Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
               <LibraryMenu />
             </Sidebar.Tab>
+            <Sidebar.Tab tab={CANVAS_SEARCH_TAB}>
+              <SearchMenu />
+            </Sidebar.Tab>
             {children}
           </Sidebar.Tabs>
         </Sidebar>

+ 17 - 0
packages/excalidraw/components/DiagramToCodePlugin/DiagramToCodePlugin.tsx

@@ -0,0 +1,17 @@
+import { useLayoutEffect } from "react";
+import { useApp } from "../App";
+import type { GenerateDiagramToCode } from "../../types";
+
+export const DiagramToCodePlugin = (props: {
+  generate: GenerateDiagramToCode;
+}) => {
+  const app = useApp();
+
+  useLayoutEffect(() => {
+    app.setPlugins({
+      diagramToCode: { generate: props.generate },
+    });
+  }, [app, props.generate]);
+
+  return null;
+};

+ 69 - 2
packages/excalidraw/components/FilledButton.scss

@@ -1,5 +1,19 @@
 @import "../css/variables.module.scss";
 
+@keyframes successStatusAnimation {
+  0% {
+    transform: scale(0.35);
+  }
+
+  50% {
+    transform: scale(1.25);
+  }
+
+  100% {
+    transform: scale(1);
+  }
+}
+
 .excalidraw {
   .ExcButton {
     --text-color: transparent;
@@ -16,11 +30,20 @@
 
     .Spinner {
       --spinner-color: var(--color-surface-lowest);
-      position: absolute;
+    }
+
+    .ExcButton__statusIcon {
       visibility: visible;
+      position: absolute;
+
+      width: 1.2rem;
+      height: 1.2rem;
+
+      animation: successStatusAnimation 0.5s cubic-bezier(0.3, 1, 0.6, 1);
     }
 
-    &[disabled] {
+    &.ExcButton--status-loading,
+    &.ExcButton--status-success {
       pointer-events: none;
 
       .ExcButton__contents {
@@ -28,6 +51,10 @@
       }
     }
 
+    &[disabled] {
+      pointer-events: none;
+    }
+
     &,
     &__contents {
       display: flex;
@@ -119,6 +146,46 @@
       }
     }
 
+    &--color-success {
+      &.ExcButton--variant-filled {
+        --text-color: var(--color-success-text);
+        --back-color: var(--color-success);
+
+        .Spinner {
+          --spinner-color: var(--color-success);
+        }
+
+        &:hover {
+          --back-color: var(--color-success-darker);
+        }
+
+        &:active {
+          --back-color: var(--color-success-darkest);
+        }
+      }
+
+      &.ExcButton--variant-outlined,
+      &.ExcButton--variant-icon {
+        --text-color: var(--color-success-contrast);
+        --border-color: var(--color-success-contrast);
+        --back-color: transparent;
+
+        .Spinner {
+          --spinner-color: var(--color-success-contrast);
+        }
+
+        &:hover {
+          --text-color: var(--color-success-contrast-hover);
+          --border-color: var(--color-success-contrast-hover);
+        }
+
+        &:active {
+          --text-color: var(--color-success-contrast-active);
+          --border-color: var(--color-success-contrast-active);
+        }
+      }
+    }
+
     &--color-muted {
       &.ExcButton--variant-filled {
         --text-color: var(--island-bg-color);

+ 26 - 4
packages/excalidraw/components/FilledButton.tsx

@@ -5,9 +5,15 @@ import "./FilledButton.scss";
 import { AbortError } from "../errors";
 import Spinner from "./Spinner";
 import { isPromiseLike } from "../utils";
+import { tablerCheckIcon } from "./icons";
 
 export type ButtonVariant = "filled" | "outlined" | "icon";
-export type ButtonColor = "primary" | "danger" | "warning" | "muted";
+export type ButtonColor =
+  | "primary"
+  | "danger"
+  | "warning"
+  | "muted"
+  | "success";
 export type ButtonSize = "medium" | "large";
 
 export type FilledButtonProps = {
@@ -15,6 +21,7 @@ export type FilledButtonProps = {
 
   children?: React.ReactNode;
   onClick?: (event: React.MouseEvent) => void;
+  status?: null | "loading" | "success";
 
   variant?: ButtonVariant;
   color?: ButtonColor;
@@ -37,6 +44,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
       size = "medium",
       fullWidth,
       className,
+      status,
     },
     ref,
   ) => {
@@ -46,8 +54,11 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
       const ret = onClick?.(event);
 
       if (isPromiseLike(ret)) {
-        try {
+        // delay loading state to prevent flicker in case of quick response
+        const timer = window.setTimeout(() => {
           setIsLoading(true);
+        }, 50);
+        try {
           await ret;
         } catch (error: any) {
           if (!(error instanceof AbortError)) {
@@ -56,11 +67,15 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
             console.warn(error);
           }
         } finally {
+          clearTimeout(timer);
           setIsLoading(false);
         }
       }
     };
 
+    const _status = isLoading ? "loading" : status;
+    color = _status === "success" ? "success" : color;
+
     return (
       <button
         className={clsx(
@@ -68,6 +83,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
           `ExcButton--color-${color}`,
           `ExcButton--variant-${variant}`,
           `ExcButton--size-${size}`,
+          `ExcButton--status-${_status}`,
           { "ExcButton--fullWidth": fullWidth },
           className,
         )}
@@ -75,10 +91,16 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
         type="button"
         aria-label={label}
         ref={ref}
-        disabled={isLoading}
+        disabled={_status === "loading" || _status === "success"}
       >
         <div className="ExcButton__contents">
-          {isLoading && <Spinner />}
+          {_status === "loading" ? (
+            <Spinner className="ExcButton__statusIcon" />
+          ) : (
+            _status === "success" && (
+              <div className="ExcButton__statusIcon">{tablerCheckIcon}</div>
+            )
+          )}
           {icon && (
             <div className="ExcButton__icon" aria-hidden>
               {icon}

+ 6 - 6
packages/excalidraw/components/FontPicker/FontPickerList.tsx

@@ -63,15 +63,15 @@ export const FontPickerList = React.memo(
       () =>
         Array.from(Fonts.registered.entries())
           .filter(([_, { metadata }]) => !metadata.serverSide)
-          .map(([familyId, { metadata, fontFaces }]) => {
-            const font = {
+          .map(([familyId, { metadata, fonts }]) => {
+            const fontDescriptor = {
               value: familyId,
               icon: metadata.icon,
-              text: fontFaces[0].fontFace.family,
+              text: fonts[0].fontFace.family,
             };
 
             if (metadata.deprecated) {
-              Object.assign(font, {
+              Object.assign(fontDescriptor, {
                 deprecated: metadata.deprecated,
                 badge: {
                   type: DropDownMenuItemBadgeType.RED,
@@ -80,7 +80,7 @@ export const FontPickerList = React.memo(
               });
             }
 
-            return font as FontDescriptor;
+            return fontDescriptor as FontDescriptor;
           })
           .sort((a, b) =>
             a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1,
@@ -89,7 +89,7 @@ export const FontPickerList = React.memo(
     );
 
     const sceneFamilies = useMemo(
-      () => new Set(fonts.sceneFamilies),
+      () => new Set(fonts.getSceneFontFamilies()),
       // cache per selected font family, so hover re-render won't mess it up
       // eslint-disable-next-line react-hooks/exhaustive-deps
       [selectedFontFamily],

+ 14 - 0
packages/excalidraw/components/HelpDialog.tsx

@@ -288,6 +288,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
               label={t("stats.fullTitle")}
               shortcuts={[getShortcutKey("Alt+/")]}
             />
+            <Shortcut
+              label={t("search.title")}
+              shortcuts={[getShortcutFromShortcutName("searchMenu")]}
+            />
             <Shortcut
               label={t("commandPalette.title")}
               shortcuts={
@@ -304,6 +308,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
             className="HelpDialog__island--editor"
             caption={t("helpDialog.editor")}
           >
+            <Shortcut
+              label={t("helpDialog.createFlowchart")}
+              shortcuts={[getShortcutKey(`CtrlOrCmd+Arrow Key`)]}
+              isOr={true}
+            />
+            <Shortcut
+              label={t("helpDialog.navigateFlowchart")}
+              shortcuts={[getShortcutKey(`Alt+Arrow Key`)]}
+              isOr={true}
+            />
             <Shortcut
               label={t("labels.moveCanvas")}
               shortcuts={[

+ 1 - 0
packages/excalidraw/components/HintViewer.scss

@@ -9,6 +9,7 @@ $wide-viewport-width: 1000px;
     box-sizing: border-box;
     position: absolute;
     display: flex;
+    flex-direction: column;
     justify-content: center;
     left: 0;
     top: 100%;

+ 53 - 12
packages/excalidraw/components/HintViewer.tsx

@@ -1,6 +1,7 @@
 import { t } from "../i18n";
 import type { AppClassProperties, Device, UIAppState } from "../types";
 import {
+  isFlowchartNodeElement,
   isImageElement,
   isLinearElement,
   isTextBindableContainer,
@@ -10,6 +11,9 @@ import { getShortcutKey } from "../utils";
 import { isEraserActive } from "../appState";
 
 import "./HintViewer.scss";
+import { isNodeInFlowchart } from "../element/flowchart";
+import { isGridModeEnabled } from "../snapping";
+import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "../constants";
 
 interface HintViewerProps {
   appState: UIAppState;
@@ -18,10 +22,23 @@ interface HintViewerProps {
   app: AppClassProperties;
 }
 
-const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
+const getHints = ({
+  appState,
+  isMobile,
+  device,
+  app,
+}: HintViewerProps): null | string | string[] => {
   const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
   const multiMode = appState.multiElement !== null;
 
+  if (
+    appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
+    appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
+    appState.searchMatches?.length
+  ) {
+    return t("hints.dismissSearch");
+  }
+
   if (appState.openSidebar && !device.editor.canFitSidebar) {
     return null;
   }
@@ -30,10 +47,13 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
     return t("hints.eraserRevert");
   }
   if (activeTool.type === "arrow" || activeTool.type === "line") {
-    if (!multiMode) {
-      return t("hints.linearElement");
+    if (multiMode) {
+      return t("hints.linearElementMulti");
+    }
+    if (activeTool.type === "arrow") {
+      return t("hints.arrowTool", { arrowShortcut: getShortcutKey("A") });
     }
-    return t("hints.linearElementMulti");
+    return t("hints.linearElement");
   }
 
   if (activeTool.type === "freedraw") {
@@ -76,21 +96,21 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
     return t("hints.text_selected");
   }
 
-  if (appState.editingElement && isTextElement(appState.editingElement)) {
+  if (appState.editingTextElement) {
     return t("hints.text_editing");
   }
 
   if (activeTool.type === "selection") {
     if (
-      appState.draggingElement?.type === "selection" &&
+      appState.selectionElement &&
       !selectedElements.length &&
-      !appState.editingElement &&
+      !appState.editingTextElement &&
       !appState.editingLinearElement
     ) {
       return t("hints.deepBoxSelect");
     }
 
-    if (appState.gridSize && appState.draggingElement) {
+    if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
       return t("hints.disableSnapping");
     }
 
@@ -108,9 +128,23 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
         return t("hints.lineEditor_info");
       }
       if (
-        !appState.draggingElement &&
+        !appState.newElement &&
+        !appState.selectedElementsAreBeingDragged &&
         isTextBindableContainer(selectedElements[0])
       ) {
+        if (isFlowchartNodeElement(selectedElements[0])) {
+          if (
+            isNodeInFlowchart(
+              selectedElements[0],
+              app.scene.getNonDeletedElementsMap(),
+            )
+          ) {
+            return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
+          }
+
+          return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
+        }
+
         return t("hints.bindTextToElement");
       }
     }
@@ -125,17 +159,24 @@ export const HintViewer = ({
   device,
   app,
 }: HintViewerProps) => {
-  let hint = getHints({
+  const hints = getHints({
     appState,
     isMobile,
     device,
     app,
   });
-  if (!hint) {
+
+  if (!hints) {
     return null;
   }
 
-  hint = getShortcutKey(hint);
+  const hint = Array.isArray(hints)
+    ? hints
+        .map((hint) => {
+          return getShortcutKey(hint).replace(/\. ?$/, "");
+        })
+        .join(". ")
+    : getShortcutKey(hints);
 
   return (
     <div className="HintViewer">

+ 28 - 5
packages/excalidraw/components/ImageExportDialog.tsx

@@ -35,6 +35,7 @@ import "./ImageExportDialog.scss";
 import { FilledButton } from "./FilledButton";
 import { cloneJSON } from "../utils";
 import { prepareElementsForExport } from "../data";
+import { useCopyStatus } from "../hooks/useCopiedIndicator";
 
 const supportsContextFilters =
   "filter" in document.createElement("canvas").getContext("2d")!;
@@ -89,6 +90,21 @@ const ImageExportModal = ({
   const previewRef = useRef<HTMLDivElement>(null);
   const [renderError, setRenderError] = useState<Error | null>(null);
 
+  const { onCopy, copyStatus, resetCopyStatus } = useCopyStatus();
+
+  useEffect(() => {
+    // if user changes setting right after export to clipboard, reset the status
+    // so they don't have to wait for the timeout to click the button again
+    resetCopyStatus();
+  }, [
+    projectName,
+    exportWithBackground,
+    exportDarkMode,
+    exportScale,
+    embedScene,
+    resetCopyStatus,
+  ]);
+
   const { exportedElements, exportingFrame } = prepareElementsForExport(
     elementsSnapshot,
     appStateSnapshot,
@@ -105,6 +121,7 @@ const ImageExportModal = ({
     if (!maxWidth) {
       return;
     }
+
     exportToCanvas({
       elements: exportedElements,
       appState: {
@@ -294,11 +311,17 @@ const ImageExportModal = ({
             <FilledButton
               className="ImageExportModal__settings__buttons__button"
               label={t("imageExportDialog.title.copyPngToClipboard")}
-              onClick={() =>
-                onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, {
-                  exportingFrame,
-                })
-              }
+              status={copyStatus}
+              onClick={async () => {
+                await onExportImage(
+                  EXPORT_IMAGE_TYPES.clipboard,
+                  exportedElements,
+                  {
+                    exportingFrame,
+                  },
+                );
+                onCopy();
+              }}
               icon={copyIcon}
             >
               {t("imageExportDialog.button.copyPngToClipboard")}

+ 0 - 93
packages/excalidraw/components/LayerUI.scss

@@ -27,99 +27,6 @@
       & > * {
         pointer-events: var(--ui-pointerEvents);
       }
-
-      & > .Stats {
-        width: 204px;
-        position: absolute;
-        top: 60px;
-        font-size: 12px;
-        z-index: var(--zIndex-layerUI);
-        pointer-events: var(--ui-pointerEvents);
-
-        .title {
-          display: flex;
-          justify-content: space-between;
-          align-items: center;
-          margin-bottom: 12px;
-
-          h2 {
-            margin: 0;
-          }
-        }
-
-        .sectionContent {
-          display: flex;
-          flex-direction: column;
-          align-items: center;
-          justify-content: center;
-        }
-
-        .elementType {
-          font-size: 12px;
-          font-weight: 700;
-          margin-top: 8px;
-        }
-
-        .elementsCount {
-          width: 100%;
-          font-size: 12px;
-          display: flex;
-          justify-content: space-between;
-          margin-top: 8px;
-        }
-
-        .statsItem {
-          margin-top: 8px;
-          width: 100%;
-          margin-bottom: 4px;
-          display: grid;
-          gap: 4px;
-
-          .label {
-            margin-right: 4px;
-          }
-        }
-
-        h3 {
-          white-space: nowrap;
-          margin: 0;
-        }
-
-        .close {
-          height: 16px;
-          width: 16px;
-          cursor: pointer;
-          svg {
-            width: 100%;
-            height: 100%;
-          }
-        }
-
-        table {
-          width: 100%;
-          th {
-            border-bottom: 1px solid var(--input-border-color);
-            padding: 4px;
-          }
-          tr {
-            td:nth-child(2) {
-              min-width: 24px;
-              text-align: right;
-            }
-          }
-        }
-
-        .divider {
-          width: 100%;
-          height: 1px;
-          background-color: var(--default-border-color);
-        }
-
-        :root[dir="rtl"] & {
-          left: 12px;
-          right: initial;
-        }
-      }
     }
 
     &__footer {

+ 5 - 36
packages/excalidraw/components/LayerUI.tsx

@@ -53,19 +53,18 @@ import { LibraryIcon } from "./icons";
 import { UIAppStateContext } from "../context/ui-appState";
 import { DefaultSidebar } from "./DefaultSidebar";
 import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
-
-import "./LayerUI.scss";
-import "./Toolbar.scss";
 import { mutateElement } from "../element/mutateElement";
 import { ShapeCache } from "../scene/ShapeCache";
 import Scene from "../scene/Scene";
 import { SubtypeToggles } from "./Subtypes";
 import { LaserPointerButton } from "./LaserPointerButton";
-import { MagicSettings } from "./MagicSettings";
 import { TTDDialog } from "./TTDDialog/TTDDialog";
 import { Stats } from "./Stats";
 import { actionToggleStats } from "../actions";
 
+import "./LayerUI.scss";
+import "./Toolbar.scss";
+
 interface LayerUIProps {
   actionManager: ActionManager;
   appState: UIAppState;
@@ -86,14 +85,6 @@ interface LayerUIProps {
   children?: React.ReactNode;
   app: AppClassProperties;
   isCollaborating: boolean;
-  openAIKey: string | null;
-  isOpenAIKeyPersisted: boolean;
-  onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
-  onMagicSettingsConfirm: (
-    apiKey: string,
-    shouldPersist: boolean,
-    source: "tool" | "generation" | "settings",
-  ) => void;
 }
 
 const DefaultMainMenu: React.FC<{
@@ -109,6 +100,7 @@ const DefaultMainMenu: React.FC<{
       {UIOptions.canvasActions.saveAsImage && (
         <MainMenu.DefaultItems.SaveAsImage />
       )}
+      <MainMenu.DefaultItems.SearchMenu />
       <MainMenu.DefaultItems.Help />
       <MainMenu.DefaultItems.ClearCanvas />
       <MainMenu.Separator />
@@ -150,10 +142,6 @@ const LayerUI = ({
   children,
   app,
   isCollaborating,
-  openAIKey,
-  isOpenAIKeyPersisted,
-  onOpenAIAPIKeyChange,
-  onMagicSettingsConfirm,
 }: LayerUIProps) => {
   const device = useDevice();
   const tunnels = useInitializeTunnels();
@@ -362,7 +350,7 @@ const LayerUI = ({
               )}
             {shouldShowStats && (
               <Stats
-                scene={app.scene}
+                app={app}
                 onClose={() => {
                   actionManager.executeAction(actionToggleStats);
                 }}
@@ -484,25 +472,6 @@ const LayerUI = ({
           }}
         />
       )}
-      {appState.openDialog?.name === "settings" && (
-        <MagicSettings
-          openAIKey={openAIKey}
-          isPersisted={isOpenAIKeyPersisted}
-          onChange={onOpenAIAPIKeyChange}
-          onConfirm={(apiKey, shouldPersist) => {
-            const source =
-              appState.openDialog?.name === "settings"
-                ? appState.openDialog?.source
-                : "settings";
-            setAppState({ openDialog: null }, () => {
-              onMagicSettingsConfirm(apiKey, shouldPersist, source);
-            });
-          }}
-          onClose={() => {
-            setAppState({ openDialog: null });
-          }}
-        />
-      )}
       <ActiveConfirmDialog />
       <tunnels.OverwriteConfirmDialogTunnel.Out />
       {renderImageExportDialog()}

+ 0 - 18
packages/excalidraw/components/MagicSettings.scss

@@ -1,18 +0,0 @@
-.excalidraw {
-  .MagicSettings {
-    .Island {
-      height: 100%;
-      display: flex;
-      flex-direction: column;
-    }
-  }
-
-  .MagicSettings-confirm {
-    padding: 0.5rem 1rem;
-  }
-
-  .MagicSettings__confirm {
-    margin-top: 2rem;
-    margin-right: auto;
-  }
-}

+ 0 - 160
packages/excalidraw/components/MagicSettings.tsx

@@ -1,160 +0,0 @@
-import { useState } from "react";
-import { Dialog } from "./Dialog";
-import { TextField } from "./TextField";
-import { MagicIcon, OpenAIIcon } from "./icons";
-import { FilledButton } from "./FilledButton";
-import { CheckboxItem } from "./CheckboxItem";
-import { KEYS } from "../keys";
-import { useUIAppState } from "../context/ui-appState";
-import { InlineIcon } from "./InlineIcon";
-import { Paragraph } from "./Paragraph";
-
-import "./MagicSettings.scss";
-import TTDDialogTabs from "./TTDDialog/TTDDialogTabs";
-import { TTDDialogTab } from "./TTDDialog/TTDDialogTab";
-
-export const MagicSettings = (props: {
-  openAIKey: string | null;
-  isPersisted: boolean;
-  onChange: (key: string, shouldPersist: boolean) => void;
-  onConfirm: (key: string, shouldPersist: boolean) => void;
-  onClose: () => void;
-}) => {
-  const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
-  const [shouldPersist, setShouldPersist] = useState<boolean>(
-    props.isPersisted,
-  );
-
-  const appState = useUIAppState();
-
-  const onConfirm = () => {
-    props.onConfirm(keyInputValue.trim(), shouldPersist);
-  };
-
-  if (appState.openDialog?.name !== "settings") {
-    return null;
-  }
-
-  return (
-    <Dialog
-      onCloseRequest={() => {
-        props.onClose();
-        props.onConfirm(keyInputValue.trim(), shouldPersist);
-      }}
-      title={
-        <div style={{ display: "flex" }}>
-          Wireframe to Code (AI){" "}
-          <div
-            style={{
-              display: "flex",
-              alignItems: "center",
-              justifyContent: "center",
-              padding: "0.1rem 0.5rem",
-              marginLeft: "1rem",
-              fontSize: 14,
-              borderRadius: "12px",
-              background: "var(--color-promo)",
-              color: "var(--color-surface-lowest)",
-            }}
-          >
-            Experimental
-          </div>
-        </div>
-      }
-      className="MagicSettings"
-      autofocus={false}
-    >
-      {/*  <h2
-        style={{
-          margin: 0,
-          fontSize: "1.25rem",
-          paddingLeft: "2.5rem",
-        }}
-      >
-        AI Settings
-      </h2> */}
-      <TTDDialogTabs dialog="settings" tab={appState.openDialog.tab}>
-        {/* <TTDDialogTabTriggers>
-          <TTDDialogTabTrigger tab="text-to-diagram">
-            <InlineIcon icon={brainIcon} /> Text to diagram
-          </TTDDialogTabTrigger>
-          <TTDDialogTabTrigger tab="diagram-to-code">
-            <InlineIcon icon={MagicIcon} /> Wireframe to code
-          </TTDDialogTabTrigger>
-        </TTDDialogTabTriggers> */}
-        {/* <TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
-          TODO
-        </TTDDialogTab> */}
-        <TTDDialogTab
-          //  className="ttd-dialog-content"
-          tab="diagram-to-code"
-        >
-          <Paragraph>
-            For the diagram-to-code feature we use{" "}
-            <InlineIcon icon={OpenAIIcon} />
-            OpenAI.
-          </Paragraph>
-          <Paragraph>
-            While the OpenAI API is in beta, its use is strictly limited — as
-            such we require you use your own API key. You can create an{" "}
-            <a
-              href="https://platform.openai.com/login?launch"
-              rel="noopener noreferrer"
-              target="_blank"
-            >
-              OpenAI account
-            </a>
-            , add a small credit (5 USD minimum), and{" "}
-            <a
-              href="https://platform.openai.com/api-keys"
-              rel="noopener noreferrer"
-              target="_blank"
-            >
-              generate your own API key
-            </a>
-            .
-          </Paragraph>
-          <Paragraph>
-            Your OpenAI key does not leave the browser, and you can also set
-            your own limit in your OpenAI account dashboard if needed.
-          </Paragraph>
-          <TextField
-            isRedacted
-            value={keyInputValue}
-            placeholder="Paste your API key here"
-            label="OpenAI API key"
-            onChange={(value) => {
-              setKeyInputValue(value);
-              props.onChange(value.trim(), shouldPersist);
-            }}
-            selectOnRender
-            onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()}
-          />
-          <Paragraph>
-            By default, your API token is not persisted anywhere so you'll need
-            to insert it again after reload. But, you can persist locally in
-            your browser below.
-          </Paragraph>
-
-          <CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
-            Persist API key in browser storage
-          </CheckboxItem>
-
-          <Paragraph>
-            Once API key is set, you can use the <InlineIcon icon={MagicIcon} />{" "}
-            tool to wrap your elements in a frame that will then allow you to
-            turn it into code. This dialog can be accessed using the{" "}
-            <b>AI Settings</b> <InlineIcon icon={OpenAIIcon} />.
-          </Paragraph>
-
-          <FilledButton
-            className="MagicSettings__confirm"
-            size="large"
-            label="Confirm"
-            onClick={onConfirm}
-          />
-        </TTDDialogTab>
-      </TTDDialogTabs>
-    </Dialog>
-  );
-};

+ 1 - 0
packages/excalidraw/components/PublishLibrary.tsx

@@ -133,6 +133,7 @@ const SingleLibraryItem = ({
           exportBackground: true,
         },
         files: null,
+        skipInliningFonts: true,
       });
       node.innerHTML = svg.outerHTML;
     })();

+ 110 - 0
packages/excalidraw/components/SearchMenu.scss

@@ -0,0 +1,110 @@
+@import "open-color/open-color";
+
+.excalidraw {
+  .layer-ui__search {
+    flex: 1 0 auto;
+    display: flex;
+    flex-direction: column;
+    padding: 8px 0 0 0;
+  }
+
+  .layer-ui__search-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 0 0.75rem;
+    .ExcTextField {
+      flex: 1 0 auto;
+    }
+
+    .ExcTextField__input {
+      background-color: #f5f5f9;
+      @at-root .excalidraw.theme--dark#{&} {
+        background-color: #31303b;
+      }
+
+      border-radius: var(--border-radius-md);
+      border: 0;
+
+      input::placeholder {
+        font-size: 0.9rem;
+      }
+    }
+  }
+
+  .layer-ui__search-count {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 8px 8px 0 8px;
+    margin: 0 0.75rem 0.25rem 0.75rem;
+    font-size: 0.8em;
+
+    .result-nav {
+      display: flex;
+
+      .result-nav-btn {
+        width: 36px;
+        height: 36px;
+        --button-border: transparent;
+
+        &:active {
+          background-color: var(--color-surface-high);
+        }
+
+        &:first {
+          margin-right: 4px;
+        }
+      }
+    }
+  }
+
+  .layer-ui__search-result-container {
+    overflow-y: auto;
+    flex: 1 1 0;
+    display: flex;
+    flex-direction: column;
+
+    gap: 0.125rem;
+  }
+
+  .layer-ui__result-item {
+    display: flex;
+    align-items: center;
+    min-height: 2rem;
+    flex: 0 0 auto;
+    padding: 0.25rem 0.75rem;
+    cursor: pointer;
+    border: 1px solid transparent;
+    outline: none;
+
+    margin: 0 0.75rem;
+    border-radius: var(--border-radius-md);
+
+    .text-icon {
+      width: 1rem;
+      height: 1rem;
+      margin-right: 0.75rem;
+    }
+
+    .preview-text {
+      flex: 1;
+      max-height: 48px;
+      line-height: 24px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      word-break: break-all;
+    }
+
+    &:hover {
+      background-color: var(--color-surface-high);
+    }
+    &:active {
+      border-color: var(--color-primary);
+    }
+
+    &.active {
+      background-color: var(--color-surface-high);
+    }
+  }
+}

+ 718 - 0
packages/excalidraw/components/SearchMenu.tsx

@@ -0,0 +1,718 @@
+import { Fragment, memo, useEffect, useRef, useState } from "react";
+import { collapseDownIcon, upIcon, searchIcon } from "./icons";
+import { TextField } from "./TextField";
+import { Button } from "./Button";
+import { useApp, useExcalidrawSetAppState } from "./App";
+import { debounce } from "lodash";
+import type { AppClassProperties } from "../types";
+import { isTextElement, newTextElement } from "../element";
+import type { ExcalidrawTextElement } from "../element/types";
+import { measureText } from "../element/textElement";
+import { addEventListener, getFontString } from "../utils";
+import { KEYS } from "../keys";
+import clsx from "clsx";
+import { atom, useAtom } from "jotai";
+import { jotaiScope } from "../jotai";
+import { t } from "../i18n";
+import { isElementCompletelyInViewport } from "../element/sizeHelpers";
+import { randomInteger } from "../random";
+import { CLASSES, EVENT } from "../constants";
+import { useStable } from "../hooks/useStable";
+
+import "./SearchMenu.scss";
+import { round } from "../../math";
+
+const searchQueryAtom = atom<string>("");
+export const searchItemInFocusAtom = atom<number | null>(null);
+
+const SEARCH_DEBOUNCE = 350;
+
+type SearchMatchItem = {
+  textElement: ExcalidrawTextElement;
+  searchQuery: SearchQuery;
+  index: number;
+  preview: {
+    indexInSearchQuery: number;
+    previewText: string;
+    moreBefore: boolean;
+    moreAfter: boolean;
+  };
+  matchedLines: {
+    offsetX: number;
+    offsetY: number;
+    width: number;
+    height: number;
+  }[];
+};
+
+type SearchMatches = {
+  nonce: number | null;
+  items: SearchMatchItem[];
+};
+
+type SearchQuery = string & { _brand: "SearchQuery" };
+
+export const SearchMenu = () => {
+  const app = useApp();
+  const setAppState = useExcalidrawSetAppState();
+
+  const searchInputRef = useRef<HTMLInputElement>(null);
+
+  const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope);
+  const searchQuery = inputValue.trim() as SearchQuery;
+
+  const [isSearching, setIsSearching] = useState(false);
+
+  const [searchMatches, setSearchMatches] = useState<SearchMatches>({
+    nonce: null,
+    items: [],
+  });
+  const searchedQueryRef = useRef<SearchQuery | null>(null);
+  const lastSceneNonceRef = useRef<number | undefined>(undefined);
+
+  const [focusIndex, setFocusIndex] = useAtom(
+    searchItemInFocusAtom,
+    jotaiScope,
+  );
+  const elementsMap = app.scene.getNonDeletedElementsMap();
+
+  useEffect(() => {
+    if (isSearching) {
+      return;
+    }
+    if (
+      searchQuery !== searchedQueryRef.current ||
+      app.scene.getSceneNonce() !== lastSceneNonceRef.current
+    ) {
+      searchedQueryRef.current = null;
+      handleSearch(searchQuery, app, (matchItems, index) => {
+        setSearchMatches({
+          nonce: randomInteger(),
+          items: matchItems,
+        });
+        searchedQueryRef.current = searchQuery;
+        lastSceneNonceRef.current = app.scene.getSceneNonce();
+        setAppState({
+          searchMatches: matchItems.map((searchMatch) => ({
+            id: searchMatch.textElement.id,
+            focus: false,
+            matchedLines: searchMatch.matchedLines,
+          })),
+        });
+      });
+    }
+  }, [
+    isSearching,
+    searchQuery,
+    elementsMap,
+    app,
+    setAppState,
+    setFocusIndex,
+    lastSceneNonceRef,
+  ]);
+
+  const goToNextItem = () => {
+    if (searchMatches.items.length > 0) {
+      setFocusIndex((focusIndex) => {
+        if (focusIndex === null) {
+          return 0;
+        }
+
+        return (focusIndex + 1) % searchMatches.items.length;
+      });
+    }
+  };
+
+  const goToPreviousItem = () => {
+    if (searchMatches.items.length > 0) {
+      setFocusIndex((focusIndex) => {
+        if (focusIndex === null) {
+          return 0;
+        }
+
+        return focusIndex - 1 < 0
+          ? searchMatches.items.length - 1
+          : focusIndex - 1;
+      });
+    }
+  };
+
+  useEffect(() => {
+    setAppState((state) => {
+      return {
+        searchMatches: state.searchMatches.map((match, index) => {
+          if (index === focusIndex) {
+            return { ...match, focus: true };
+          }
+          return { ...match, focus: false };
+        }),
+      };
+    });
+  }, [focusIndex, setAppState]);
+
+  useEffect(() => {
+    if (searchMatches.items.length > 0 && focusIndex !== null) {
+      const match = searchMatches.items[focusIndex];
+
+      if (match) {
+        const zoomValue = app.state.zoom.value;
+
+        const matchAsElement = newTextElement({
+          text: match.searchQuery,
+          x: match.textElement.x + (match.matchedLines[0]?.offsetX ?? 0),
+          y: match.textElement.y + (match.matchedLines[0]?.offsetY ?? 0),
+          width: match.matchedLines[0]?.width,
+          height: match.matchedLines[0]?.height,
+          fontSize: match.textElement.fontSize,
+          fontFamily: match.textElement.fontFamily,
+        });
+
+        const FONT_SIZE_LEGIBILITY_THRESHOLD = 14;
+
+        const fontSize = match.textElement.fontSize;
+        const isTextTiny =
+          fontSize * zoomValue < FONT_SIZE_LEGIBILITY_THRESHOLD;
+
+        if (
+          !isElementCompletelyInViewport(
+            [matchAsElement],
+            app.canvas.width / window.devicePixelRatio,
+            app.canvas.height / window.devicePixelRatio,
+            {
+              offsetLeft: app.state.offsetLeft,
+              offsetTop: app.state.offsetTop,
+              scrollX: app.state.scrollX,
+              scrollY: app.state.scrollY,
+              zoom: app.state.zoom,
+            },
+            app.scene.getNonDeletedElementsMap(),
+            app.getEditorUIOffsets(),
+          ) ||
+          isTextTiny
+        ) {
+          let zoomOptions: Parameters<AppClassProperties["scrollToContent"]>[1];
+
+          if (isTextTiny) {
+            if (fontSize >= FONT_SIZE_LEGIBILITY_THRESHOLD) {
+              zoomOptions = { fitToContent: true };
+            } else {
+              zoomOptions = {
+                fitToViewport: true,
+                // calculate zoom level to make the fontSize ~equal to FONT_SIZE_THRESHOLD, rounded to nearest 10%
+                maxZoom: round(FONT_SIZE_LEGIBILITY_THRESHOLD / fontSize, 1),
+              };
+            }
+          } else {
+            zoomOptions = { fitToContent: true };
+          }
+
+          app.scrollToContent(matchAsElement, {
+            animate: true,
+            duration: 300,
+            ...zoomOptions,
+            canvasOffsets: app.getEditorUIOffsets(),
+          });
+        }
+      }
+    }
+  }, [focusIndex, searchMatches, app]);
+
+  useEffect(() => {
+    return () => {
+      setFocusIndex(null);
+      searchedQueryRef.current = null;
+      lastSceneNonceRef.current = undefined;
+      setAppState({
+        searchMatches: [],
+      });
+      setIsSearching(false);
+    };
+  }, [setAppState, setFocusIndex]);
+
+  const stableState = useStable({
+    goToNextItem,
+    goToPreviousItem,
+    searchMatches,
+  });
+
+  useEffect(() => {
+    const eventHandler = (event: KeyboardEvent) => {
+      if (
+        event.key === KEYS.ESCAPE &&
+        !app.state.openDialog &&
+        !app.state.openPopup
+      ) {
+        event.preventDefault();
+        event.stopPropagation();
+        setAppState({
+          openSidebar: null,
+        });
+        return;
+      }
+
+      if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F) {
+        event.preventDefault();
+        event.stopPropagation();
+
+        if (!searchInputRef.current?.matches(":focus")) {
+          if (app.state.openDialog) {
+            setAppState({
+              openDialog: null,
+            });
+          }
+          searchInputRef.current?.focus();
+          searchInputRef.current?.select();
+        } else {
+          setAppState({
+            openSidebar: null,
+          });
+        }
+      }
+
+      if (
+        event.target instanceof HTMLElement &&
+        event.target.closest(".layer-ui__search")
+      ) {
+        if (stableState.searchMatches.items.length) {
+          if (event.key === KEYS.ENTER) {
+            event.stopPropagation();
+            stableState.goToNextItem();
+          }
+
+          if (event.key === KEYS.ARROW_UP) {
+            event.stopPropagation();
+            stableState.goToPreviousItem();
+          } else if (event.key === KEYS.ARROW_DOWN) {
+            event.stopPropagation();
+            stableState.goToNextItem();
+          }
+        }
+      }
+    };
+
+    // `capture` needed to prevent firing on initial open from App.tsx,
+    // as well as to handle events before App ones
+    return addEventListener(window, EVENT.KEYDOWN, eventHandler, {
+      capture: true,
+    });
+  }, [setAppState, stableState, app]);
+
+  const matchCount = `${searchMatches.items.length} ${
+    searchMatches.items.length === 1
+      ? t("search.singleResult")
+      : t("search.multipleResults")
+  }`;
+
+  return (
+    <div className="layer-ui__search">
+      <div className="layer-ui__search-header">
+        <TextField
+          className={CLASSES.SEARCH_MENU_INPUT_WRAPPER}
+          value={inputValue}
+          ref={searchInputRef}
+          placeholder={t("search.placeholder")}
+          icon={searchIcon}
+          onChange={(value) => {
+            setInputValue(value);
+            setIsSearching(true);
+            const searchQuery = value.trim() as SearchQuery;
+            handleSearch(searchQuery, app, (matchItems, index) => {
+              setSearchMatches({
+                nonce: randomInteger(),
+                items: matchItems,
+              });
+              setFocusIndex(index);
+              searchedQueryRef.current = searchQuery;
+              lastSceneNonceRef.current = app.scene.getSceneNonce();
+              setAppState({
+                searchMatches: matchItems.map((searchMatch) => ({
+                  id: searchMatch.textElement.id,
+                  focus: false,
+                  matchedLines: searchMatch.matchedLines,
+                })),
+              });
+
+              setIsSearching(false);
+            });
+          }}
+          selectOnRender
+        />
+      </div>
+
+      <div className="layer-ui__search-count">
+        {searchMatches.items.length > 0 && (
+          <>
+            {focusIndex !== null && focusIndex > -1 ? (
+              <div>
+                {focusIndex + 1} / {matchCount}
+              </div>
+            ) : (
+              <div>{matchCount}</div>
+            )}
+            <div className="result-nav">
+              <Button
+                onSelect={() => {
+                  goToNextItem();
+                }}
+                className="result-nav-btn"
+              >
+                {collapseDownIcon}
+              </Button>
+              <Button
+                onSelect={() => {
+                  goToPreviousItem();
+                }}
+                className="result-nav-btn"
+              >
+                {upIcon}
+              </Button>
+            </div>
+          </>
+        )}
+
+        {searchMatches.items.length === 0 &&
+          searchQuery &&
+          searchedQueryRef.current && (
+            <div style={{ margin: "1rem auto" }}>{t("search.noMatch")}</div>
+          )}
+      </div>
+
+      <MatchList
+        matches={searchMatches}
+        onItemClick={setFocusIndex}
+        focusIndex={focusIndex}
+        searchQuery={searchQuery}
+      />
+    </div>
+  );
+};
+
+const ListItem = (props: {
+  preview: SearchMatchItem["preview"];
+  searchQuery: SearchQuery;
+  highlighted: boolean;
+  onClick?: () => void;
+}) => {
+  const preview = [
+    props.preview.moreBefore ? "..." : "",
+    props.preview.previewText.slice(0, props.preview.indexInSearchQuery),
+    props.preview.previewText.slice(
+      props.preview.indexInSearchQuery,
+      props.preview.indexInSearchQuery + props.searchQuery.length,
+    ),
+    props.preview.previewText.slice(
+      props.preview.indexInSearchQuery + props.searchQuery.length,
+    ),
+    props.preview.moreAfter ? "..." : "",
+  ];
+
+  return (
+    <div
+      tabIndex={-1}
+      className={clsx("layer-ui__result-item", {
+        active: props.highlighted,
+      })}
+      onClick={props.onClick}
+      ref={(ref) => {
+        if (props.highlighted) {
+          ref?.scrollIntoView({ behavior: "auto", block: "nearest" });
+        }
+      }}
+    >
+      <div className="preview-text">
+        {preview.flatMap((text, idx) => (
+          <Fragment key={idx}>{idx === 2 ? <b>{text}</b> : text}</Fragment>
+        ))}
+      </div>
+    </div>
+  );
+};
+
+interface MatchListProps {
+  matches: SearchMatches;
+  onItemClick: (index: number) => void;
+  focusIndex: number | null;
+  searchQuery: SearchQuery;
+}
+
+const MatchListBase = (props: MatchListProps) => {
+  return (
+    <div className="layer-ui__search-result-container">
+      {props.matches.items.map((searchMatch, index) => (
+        <ListItem
+          key={searchMatch.textElement.id + searchMatch.index}
+          searchQuery={props.searchQuery}
+          preview={searchMatch.preview}
+          highlighted={index === props.focusIndex}
+          onClick={() => props.onItemClick(index)}
+        />
+      ))}
+    </div>
+  );
+};
+
+const areEqual = (prevProps: MatchListProps, nextProps: MatchListProps) => {
+  return (
+    prevProps.matches.nonce === nextProps.matches.nonce &&
+    prevProps.focusIndex === nextProps.focusIndex
+  );
+};
+
+const MatchList = memo(MatchListBase, areEqual);
+
+const getMatchPreview = (
+  text: string,
+  index: number,
+  searchQuery: SearchQuery,
+) => {
+  const WORDS_BEFORE = 2;
+  const WORDS_AFTER = 5;
+
+  const substrBeforeQuery = text.slice(0, index);
+  const wordsBeforeQuery = substrBeforeQuery.split(/\s+/);
+  // text = "small", query = "mall", not complete before
+  // text = "small", query = "smal", complete before
+  const isQueryCompleteBefore = substrBeforeQuery.endsWith(" ");
+  const startWordIndex =
+    wordsBeforeQuery.length -
+    WORDS_BEFORE -
+    1 -
+    (isQueryCompleteBefore ? 0 : 1);
+  let wordsBeforeAsString =
+    wordsBeforeQuery.slice(startWordIndex <= 0 ? 0 : startWordIndex).join(" ") +
+    (isQueryCompleteBefore ? " " : "");
+
+  const MAX_ALLOWED_CHARS = 20;
+
+  wordsBeforeAsString =
+    wordsBeforeAsString.length > MAX_ALLOWED_CHARS
+      ? wordsBeforeAsString.slice(-MAX_ALLOWED_CHARS)
+      : wordsBeforeAsString;
+
+  const substrAfterQuery = text.slice(index + searchQuery.length);
+  const wordsAfter = substrAfterQuery.split(/\s+/);
+  // text = "small", query = "mall", complete after
+  // text = "small", query = "smal", not complete after
+  const isQueryCompleteAfter = !substrAfterQuery.startsWith(" ");
+  const numberOfWordsToTake = isQueryCompleteAfter
+    ? WORDS_AFTER + 1
+    : WORDS_AFTER;
+  const wordsAfterAsString =
+    (isQueryCompleteAfter ? "" : " ") +
+    wordsAfter.slice(0, numberOfWordsToTake).join(" ");
+
+  return {
+    indexInSearchQuery: wordsBeforeAsString.length,
+    previewText: wordsBeforeAsString + searchQuery + wordsAfterAsString,
+    moreBefore: startWordIndex > 0,
+    moreAfter: wordsAfter.length > numberOfWordsToTake,
+  };
+};
+
+const normalizeWrappedText = (
+  wrappedText: string,
+  originalText: string,
+): string => {
+  const wrappedLines = wrappedText.split("\n");
+  const normalizedLines: string[] = [];
+  let originalIndex = 0;
+
+  for (let i = 0; i < wrappedLines.length; i++) {
+    let currentLine = wrappedLines[i];
+    const nextLine = wrappedLines[i + 1];
+
+    if (nextLine) {
+      const nextLineIndexInOriginal = originalText.indexOf(
+        nextLine,
+        originalIndex,
+      );
+
+      if (nextLineIndexInOriginal > currentLine.length + originalIndex) {
+        let j = nextLineIndexInOriginal - (currentLine.length + originalIndex);
+
+        while (j > 0) {
+          currentLine += " ";
+          j--;
+        }
+      }
+    }
+
+    normalizedLines.push(currentLine);
+    originalIndex = originalIndex + currentLine.length;
+  }
+
+  return normalizedLines.join("\n");
+};
+
+const getMatchedLines = (
+  textElement: ExcalidrawTextElement,
+  searchQuery: SearchQuery,
+  index: number,
+) => {
+  const normalizedText = normalizeWrappedText(
+    textElement.text,
+    textElement.originalText,
+  );
+
+  const lines = normalizedText.split("\n");
+
+  const lineIndexRanges = [];
+  let currentIndex = 0;
+  let lineNumber = 0;
+
+  for (const line of lines) {
+    const startIndex = currentIndex;
+    const endIndex = startIndex + line.length - 1;
+
+    lineIndexRanges.push({
+      line,
+      startIndex,
+      endIndex,
+      lineNumber,
+    });
+
+    // Move to the next line's start index
+    currentIndex = endIndex + 1;
+    lineNumber++;
+  }
+
+  let startIndex = index;
+  let remainingQuery = textElement.originalText.slice(
+    index,
+    index + searchQuery.length,
+  );
+  const matchedLines: {
+    offsetX: number;
+    offsetY: number;
+    width: number;
+    height: number;
+  }[] = [];
+
+  for (const lineIndexRange of lineIndexRanges) {
+    if (remainingQuery === "") {
+      break;
+    }
+
+    if (
+      startIndex >= lineIndexRange.startIndex &&
+      startIndex <= lineIndexRange.endIndex
+    ) {
+      const matchCapacity = lineIndexRange.endIndex + 1 - startIndex;
+      const textToStart = lineIndexRange.line.slice(
+        0,
+        startIndex - lineIndexRange.startIndex,
+      );
+
+      const matchedWord = remainingQuery.slice(0, matchCapacity);
+      remainingQuery = remainingQuery.slice(matchCapacity);
+
+      const offset = measureText(
+        textToStart,
+        getFontString(textElement),
+        textElement.lineHeight,
+        true,
+      );
+
+      // measureText returns a non-zero width for the empty string
+      // which is not what we're after here, hence the check and the correction
+      if (textToStart === "") {
+        offset.width = 0;
+      }
+
+      if (textElement.textAlign !== "left" && lineIndexRange.line.length > 0) {
+        const lineLength = measureText(
+          lineIndexRange.line,
+          getFontString(textElement),
+          textElement.lineHeight,
+          true,
+        );
+
+        const spaceToStart =
+          textElement.textAlign === "center"
+            ? (textElement.width - lineLength.width) / 2
+            : textElement.width - lineLength.width;
+        offset.width += spaceToStart;
+      }
+
+      const { width, height } = measureText(
+        matchedWord,
+        getFontString(textElement),
+        textElement.lineHeight,
+      );
+
+      const offsetX = offset.width;
+      const offsetY = lineIndexRange.lineNumber * offset.height;
+
+      matchedLines.push({
+        offsetX,
+        offsetY,
+        width,
+        height,
+      });
+
+      startIndex += matchCapacity;
+    }
+  }
+
+  return matchedLines;
+};
+
+const escapeSpecialCharacters = (string: string) => {
+  return string.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
+};
+
+const handleSearch = debounce(
+  (
+    searchQuery: SearchQuery,
+    app: AppClassProperties,
+    cb: (matchItems: SearchMatchItem[], focusIndex: number | null) => void,
+  ) => {
+    if (!searchQuery || searchQuery === "") {
+      cb([], null);
+      return;
+    }
+
+    const elements = app.scene.getNonDeletedElements();
+    const texts = elements.filter((el) =>
+      isTextElement(el),
+    ) as ExcalidrawTextElement[];
+
+    texts.sort((a, b) => a.y - b.y);
+
+    const matchItems: SearchMatchItem[] = [];
+
+    const regex = new RegExp(escapeSpecialCharacters(searchQuery), "gi");
+
+    for (const textEl of texts) {
+      let match = null;
+      const text = textEl.originalText;
+
+      while ((match = regex.exec(text)) !== null) {
+        const preview = getMatchPreview(text, match.index, searchQuery);
+        const matchedLines = getMatchedLines(textEl, searchQuery, match.index);
+
+        if (matchedLines.length > 0) {
+          matchItems.push({
+            textElement: textEl,
+            searchQuery,
+            preview,
+            index: match.index,
+            matchedLines,
+          });
+        }
+      }
+    }
+
+    const visibleIds = new Set(
+      app.visibleElements.map((visibleElement) => visibleElement.id),
+    );
+
+    const focusIndex =
+      matchItems.findIndex((matchItem) =>
+        visibleIds.has(matchItem.textElement.id),
+      ) ?? null;
+
+    cb(matchItems, focusIndex);
+  },
+  SEARCH_DEBOUNCE,
+);

+ 2 - 2
packages/excalidraw/components/ShareableLinkDialog.scss

@@ -52,8 +52,8 @@
       font-size: 0.75rem;
       line-height: 110%;
 
-      background: var(--color-success-lighter);
-      color: var(--color-success);
+      background: var(--color-success);
+      color: var(--color-success-text);
 
       & > svg {
         width: 0.875rem;

+ 14 - 24
packages/excalidraw/components/ShareableLinkDialog.tsx

@@ -1,5 +1,4 @@
 import { useRef, useState } from "react";
-import * as Popover from "@radix-ui/react-popover";
 
 import { copyTextToSystemClipboard } from "../clipboard";
 import { useI18n } from "../i18n";
@@ -7,7 +6,8 @@ import { useI18n } from "../i18n";
 import { Dialog } from "./Dialog";
 import { TextField } from "./TextField";
 import { FilledButton } from "./FilledButton";
-import { copyIcon, tablerCheckIcon } from "./icons";
+import { useCopyStatus } from "../hooks/useCopiedIndicator";
+import { copyIcon } from "./icons";
 
 import "./ShareableLinkDialog.scss";
 
@@ -24,7 +24,7 @@ export const ShareableLinkDialog = ({
   setErrorMessage,
 }: ShareableLinkDialogProps) => {
   const { t } = useI18n();
-  const [justCopied, setJustCopied] = useState(false);
+  const [, setJustCopied] = useState(false);
   const timerRef = useRef<number>(0);
   const ref = useRef<HTMLInputElement>(null);
 
@@ -46,7 +46,7 @@ export const ShareableLinkDialog = ({
 
     ref.current?.select();
   };
-
+  const { onCopy, copyStatus } = useCopyStatus();
   return (
     <Dialog onCloseRequest={onCloseRequest} title={false} size="small">
       <div className="ShareableLinkDialog">
@@ -60,26 +60,16 @@ export const ShareableLinkDialog = ({
             value={link}
             selectOnRender
           />
-          <Popover.Root open={justCopied}>
-            <Popover.Trigger asChild>
-              <FilledButton
-                size="large"
-                label="Copy link"
-                icon={copyIcon}
-                onClick={copyRoomLink}
-              />
-            </Popover.Trigger>
-            <Popover.Content
-              onOpenAutoFocus={(event) => event.preventDefault()}
-              onCloseAutoFocus={(event) => event.preventDefault()}
-              className="ShareableLinkDialog__popover"
-              side="top"
-              align="end"
-              sideOffset={5.5}
-            >
-              {tablerCheckIcon} copied
-            </Popover.Content>
-          </Popover.Root>
+          <FilledButton
+            size="large"
+            label={t("buttons.copyLink")}
+            icon={copyIcon}
+            status={copyStatus}
+            onClick={() => {
+              onCopy();
+              copyRoomLink();
+            }}
+          />
         </div>
         <div className="ShareableLinkDialog__description">
           🔒 {t("alerts.uploadedSecurly")}

+ 43 - 62
packages/excalidraw/components/Sidebar/Sidebar.test.tsx

@@ -2,8 +2,8 @@ import React from "react";
 import { DEFAULT_SIDEBAR } from "../../constants";
 import { Excalidraw, Sidebar } from "../../index";
 import {
+  act,
   fireEvent,
-  GlobalTestState,
   queryAllByTestId,
   queryByTestId,
   render,
@@ -11,39 +11,17 @@ import {
   withExcalidrawDimensions,
 } from "../../tests/test-utils";
 import { vi } from "vitest";
-
-export const assertSidebarDockButton = async <T extends boolean>(
-  hasDockButton: T,
-): Promise<
-  T extends false
-    ? { dockButton: null; sidebar: HTMLElement }
-    : { dockButton: HTMLElement; sidebar: HTMLElement }
-> => {
-  const sidebar =
-    GlobalTestState.renderResult.container.querySelector<HTMLElement>(
-      ".sidebar",
-    );
-  expect(sidebar).not.toBe(null);
-  const dockButton = queryByTestId(sidebar!, "sidebar-dock");
-  if (hasDockButton) {
-    expect(dockButton).not.toBe(null);
-    return { dockButton: dockButton!, sidebar: sidebar! } as any;
-  }
-  expect(dockButton).toBe(null);
-  return { dockButton: null, sidebar: sidebar! } as any;
-};
-
-export const assertExcalidrawWithSidebar = async (
-  sidebar: React.ReactNode,
-  name: string,
-  test: () => void,
-) => {
-  await render(
-    <Excalidraw initialData={{ appState: { openSidebar: { name } } }}>
-      {sidebar}
-    </Excalidraw>,
-  );
-  await withExcalidrawDimensions({ width: 1920, height: 1080 }, test);
+import {
+  assertExcalidrawWithSidebar,
+  assertSidebarDockButton,
+} from "./siderbar.test.helpers";
+
+const toggleSidebar = (
+  ...args: Parameters<typeof window.h.app.toggleSidebar>
+): Promise<boolean> => {
+  return act(() => {
+    return window.h.app.toggleSidebar(...args);
+  });
 };
 
 describe("Sidebar", () => {
@@ -103,7 +81,7 @@ describe("Sidebar", () => {
 
       // toggle sidebar on
       // -------------------------------------------------------------------------
-      expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
+      expect(await toggleSidebar({ name: "customSidebar" })).toBe(true);
 
       await waitFor(() => {
         const node = container.querySelector("#test-sidebar-content");
@@ -112,7 +90,7 @@ describe("Sidebar", () => {
 
       // toggle sidebar off
       // -------------------------------------------------------------------------
-      expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(false);
+      expect(await toggleSidebar({ name: "customSidebar" })).toBe(false);
 
       await waitFor(() => {
         const node = container.querySelector("#test-sidebar-content");
@@ -121,9 +99,9 @@ describe("Sidebar", () => {
 
       // force-toggle sidebar off (=> still hidden)
       // -------------------------------------------------------------------------
-      expect(
-        window.h.app.toggleSidebar({ name: "customSidebar", force: false }),
-      ).toBe(false);
+      expect(await toggleSidebar({ name: "customSidebar", force: false })).toBe(
+        false,
+      );
 
       await waitFor(() => {
         const node = container.querySelector("#test-sidebar-content");
@@ -132,12 +110,12 @@ describe("Sidebar", () => {
 
       // force-toggle sidebar on
       // -------------------------------------------------------------------------
-      expect(
-        window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
-      ).toBe(true);
-      expect(
-        window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
-      ).toBe(true);
+      expect(await toggleSidebar({ name: "customSidebar", force: true })).toBe(
+        true,
+      );
+      expect(await toggleSidebar({ name: "customSidebar", force: true })).toBe(
+        true,
+      );
 
       await waitFor(() => {
         const node = container.querySelector("#test-sidebar-content");
@@ -146,9 +124,7 @@ describe("Sidebar", () => {
 
       // toggle library (= hide custom sidebar)
       // -------------------------------------------------------------------------
-      expect(window.h.app.toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(
-        true,
-      );
+      expect(await toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(true);
 
       await waitFor(() => {
         const node = container.querySelector("#test-sidebar-content");
@@ -161,13 +137,13 @@ describe("Sidebar", () => {
 
       // closing sidebar using `{ name: null }`
       // -------------------------------------------------------------------------
-      expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
+      expect(await toggleSidebar({ name: "customSidebar" })).toBe(true);
       await waitFor(() => {
         const node = container.querySelector("#test-sidebar-content");
         expect(node).not.toBe(null);
       });
 
-      expect(window.h.app.toggleSidebar({ name: null })).toBe(false);
+      expect(await toggleSidebar({ name: null })).toBe(false);
       await waitFor(() => {
         const node = container.querySelector("#test-sidebar-content");
         expect(node).toBe(null);
@@ -321,6 +297,9 @@ describe("Sidebar", () => {
     });
 
     it("shouldn't be user-dockable when only `onDock` supplied w/o `docked`", async () => {
+      // we expect warnings in this test and don't want to pollute stdout
+      const mock = jest.spyOn(console, "warn").mockImplementation(() => {});
+
       await render(
         <Excalidraw
           initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
@@ -341,6 +320,8 @@ describe("Sidebar", () => {
           await assertSidebarDockButton(false);
         },
       );
+
+      mock.mockRestore();
     });
   });
 
@@ -367,9 +348,9 @@ describe("Sidebar", () => {
           ).toBeNull();
 
           // open library sidebar
-          expect(
-            window.h.app.toggleSidebar({ name: "custom", tab: "library" }),
-          ).toBe(true);
+          expect(await toggleSidebar({ name: "custom", tab: "library" })).toBe(
+            true,
+          );
           expect(
             container.querySelector<HTMLElement>(
               "[role=tabpanel][data-testid=library]",
@@ -377,9 +358,9 @@ describe("Sidebar", () => {
           ).not.toBeNull();
 
           // switch to comments tab
-          expect(
-            window.h.app.toggleSidebar({ name: "custom", tab: "comments" }),
-          ).toBe(true);
+          expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe(
+            true,
+          );
           expect(
             container.querySelector<HTMLElement>(
               "[role=tabpanel][data-testid=comments]",
@@ -387,9 +368,9 @@ describe("Sidebar", () => {
           ).not.toBeNull();
 
           // toggle sidebar closed
-          expect(
-            window.h.app.toggleSidebar({ name: "custom", tab: "comments" }),
-          ).toBe(false);
+          expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe(
+            false,
+          );
           expect(
             container.querySelector<HTMLElement>(
               "[role=tabpanel][data-testid=comments]",
@@ -397,9 +378,9 @@ describe("Sidebar", () => {
           ).toBeNull();
 
           // toggle sidebar open
-          expect(
-            window.h.app.toggleSidebar({ name: "custom", tab: "comments" }),
-          ).toBe(true);
+          expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe(
+            true,
+          );
           expect(
             container.querySelector<HTMLElement>(
               "[role=tabpanel][data-testid=comments]",

+ 42 - 0
packages/excalidraw/components/Sidebar/siderbar.test.helpers.tsx

@@ -0,0 +1,42 @@
+import React from "react";
+import { Excalidraw } from "../..";
+import {
+  GlobalTestState,
+  queryByTestId,
+  render,
+  withExcalidrawDimensions,
+} from "../../tests/test-utils";
+
+export const assertSidebarDockButton = async <T extends boolean>(
+  hasDockButton: T,
+): Promise<
+  T extends false
+    ? { dockButton: null; sidebar: HTMLElement }
+    : { dockButton: HTMLElement; sidebar: HTMLElement }
+> => {
+  const sidebar =
+    GlobalTestState.renderResult.container.querySelector<HTMLElement>(
+      ".sidebar",
+    );
+  expect(sidebar).not.toBe(null);
+  const dockButton = queryByTestId(sidebar!, "sidebar-dock");
+  if (hasDockButton) {
+    expect(dockButton).not.toBe(null);
+    return { dockButton: dockButton!, sidebar: sidebar! } as any;
+  }
+  expect(dockButton).toBe(null);
+  return { dockButton: null, sidebar: sidebar! } as any;
+};
+
+export const assertExcalidrawWithSidebar = async (
+  sidebar: React.ReactNode,
+  name: string,
+  test: () => void,
+) => {
+  await render(
+    <Excalidraw initialData={{ appState: { openSidebar: { name } } }}>
+      {sidebar}
+    </Excalidraw>,
+  );
+  await withExcalidrawDimensions({ width: 1920, height: 1080 }, test);
+};

+ 3 - 1
packages/excalidraw/components/Spinner.tsx

@@ -6,16 +6,18 @@ const Spinner = ({
   size = "1em",
   circleWidth = 8,
   synchronized = false,
+  className = "",
 }: {
   size?: string | number;
   circleWidth?: number;
   synchronized?: boolean;
+  className?: string;
 }) => {
   const mountTime = React.useRef(Date.now());
   const mountDelay = -(mountTime.current % 1600);
 
   return (
-    <div className="Spinner">
+    <div className={`Spinner ${className}`}>
       <svg
         viewBox="0 0 100 100"
         style={{

+ 11 - 9
packages/excalidraw/components/Stats/Angle.tsx

@@ -1,14 +1,15 @@
 import { mutateElement } from "../../element/mutateElement";
 import { getBoundTextElement } from "../../element/textElement";
-import { isArrowElement } from "../../element/typeChecks";
+import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
 import type { ExcalidrawElement } from "../../element/types";
-import { degreeToRadian, radianToDegree } from "../../math";
 import { angleIcon } from "../icons";
 import DragInput from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
 import type Scene from "../../scene/Scene";
 import type { AppState } from "../../types";
+import type { Degrees } from "../../../math";
+import { degreesToRadians, radiansToDegrees } from "../../../math";
 
 interface AngleProps {
   element: ExcalidrawElement;
@@ -27,19 +28,20 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
   scene,
 }) => {
   const elementsMap = scene.getNonDeletedElementsMap();
+  const elements = scene.getNonDeletedElements();
   const origElement = originalElements[0];
-  if (origElement) {
+  if (origElement && !isElbowArrow(origElement)) {
     const latestElement = elementsMap.get(origElement.id);
     if (!latestElement) {
       return;
     }
 
     if (nextValue !== undefined) {
-      const nextAngle = degreeToRadian(nextValue);
+      const nextAngle = degreesToRadians(nextValue as Degrees);
       mutateElement(latestElement, {
         angle: nextAngle,
       });
-      updateBindings(latestElement, elementsMap);
+      updateBindings(latestElement, elementsMap, elements, scene);
 
       const boundTextElement = getBoundTextElement(latestElement, elementsMap);
       if (boundTextElement && !isArrowElement(latestElement)) {
@@ -50,7 +52,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
     }
 
     const originalAngleInDegrees =
-      Math.round(radianToDegree(origElement.angle) * 100) / 100;
+      Math.round(radiansToDegrees(origElement.angle) * 100) / 100;
     const changeInDegrees = Math.round(accumulatedChange);
     let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
     if (shouldChangeByStepSize) {
@@ -60,12 +62,12 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
     nextAngleInDegrees =
       nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
 
-    const nextAngle = degreeToRadian(nextAngleInDegrees);
+    const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
 
     mutateElement(latestElement, {
       angle: nextAngle,
     });
-    updateBindings(latestElement, elementsMap);
+    updateBindings(latestElement, elementsMap, elements, scene);
 
     const boundTextElement = getBoundTextElement(latestElement, elementsMap);
     if (boundTextElement && !isArrowElement(latestElement)) {
@@ -79,7 +81,7 @@ const Angle = ({ element, scene, appState, property }: AngleProps) => {
     <DragInput
       label="A"
       icon={angleIcon}
-      value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100}
+      value={Math.round((radiansToDegrees(element.angle) % 360) * 100) / 100}
       elements={[element]}
       dragInputCallback={handleDegreeChange}
       editable={isPropertyEditable(element, "angle")}

+ 67 - 0
packages/excalidraw/components/Stats/CanvasGrid.tsx

@@ -0,0 +1,67 @@
+import StatsDragInput from "./DragInput";
+import type Scene from "../../scene/Scene";
+import type { AppState } from "../../types";
+import { getStepSizedValue } from "./utils";
+import { getNormalizedGridStep } from "../../scene";
+
+interface PositionProps {
+  property: "gridStep";
+  scene: Scene;
+  appState: AppState;
+  setAppState: React.Component<any, AppState>["setState"];
+}
+
+const STEP_SIZE = 5;
+
+const CanvasGrid = ({
+  property,
+  scene,
+  appState,
+  setAppState,
+}: PositionProps) => {
+  return (
+    <StatsDragInput
+      label="Grid step"
+      sensitivity={8}
+      elements={[]}
+      dragInputCallback={({
+        nextValue,
+        instantChange,
+        shouldChangeByStepSize,
+        setInputValue,
+      }) => {
+        setAppState((state) => {
+          let nextGridStep;
+
+          if (nextValue) {
+            nextGridStep = nextValue;
+          } else if (instantChange) {
+            nextGridStep = shouldChangeByStepSize
+              ? getStepSizedValue(
+                  state.gridStep + STEP_SIZE * Math.sign(instantChange),
+                  STEP_SIZE,
+                )
+              : state.gridStep + instantChange;
+          }
+
+          if (!nextGridStep) {
+            setInputValue(state.gridStep);
+            return null;
+          }
+
+          nextGridStep = getNormalizedGridStep(nextGridStep);
+          setInputValue(nextGridStep);
+          return {
+            gridStep: nextGridStep,
+          };
+        });
+      }}
+      scene={scene}
+      value={appState.gridStep}
+      property={property}
+      appState={appState}
+    />
+  );
+};
+
+export default CanvasGrid;

+ 5 - 1
packages/excalidraw/components/Stats/Collapsible.tsx

@@ -31,7 +31,11 @@ const Collapsible = ({
         {label}
         <InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
       </div>
-      {open && <>{children}</>}
+      {open && (
+        <div style={{ display: "flex", flexDirection: "column" }}>
+          {children}
+        </div>
+      )}
     </>
   );
 };

+ 5 - 1
packages/excalidraw/components/Stats/Dimension.tsx

@@ -23,7 +23,6 @@ const handleDimensionChange: DragInputCallbackType<
 > = ({
   accumulatedChange,
   originalElements,
-  originalElementsMap,
   shouldKeepAspectRatio,
   shouldChangeByStepSize,
   nextValue,
@@ -31,6 +30,7 @@ const handleDimensionChange: DragInputCallbackType<
   scene,
 }) => {
   const elementsMap = scene.getNonDeletedElementsMap();
+  const elements = scene.getNonDeletedElements();
   const origElement = originalElements[0];
   if (origElement) {
     const keepAspectRatio =
@@ -61,6 +61,8 @@ const handleDimensionChange: DragInputCallbackType<
         keepAspectRatio,
         origElement,
         elementsMap,
+        elements,
+        scene,
       );
 
       return;
@@ -103,6 +105,8 @@ const handleDimensionChange: DragInputCallbackType<
       keepAspectRatio,
       origElement,
       elementsMap,
+      elements,
+      scene,
     );
   }
 };

+ 7 - 6
packages/excalidraw/components/Stats/DragInput.scss

@@ -5,7 +5,7 @@
 
     &:focus-within {
       box-shadow: 0 0 0 1px var(--color-primary-darkest);
-      border-radius: var(--border-radius-lg);
+      border-radius: var(--border-radius-md);
     }
   }
 
@@ -18,17 +18,18 @@
     flex-shrink: 0;
     border: 1px solid var(--default-border-color);
     border-right: 0;
-    width: 2rem;
+    padding: 0 0.5rem 0 0.75rem;
+    min-width: 1rem;
     height: 2rem;
     box-sizing: border-box;
     color: var(--popup-text-color);
 
     :root[dir="ltr"] & {
-      border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
+      border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
     }
 
     :root[dir="rtl"] & {
-      border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
+      border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
       border-right: 1px solid var(--default-border-color);
       border-left: 0;
     }
@@ -55,11 +56,11 @@
     letter-spacing: 0.4px;
 
     :root[dir="ltr"] & {
-      border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
+      border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
     }
 
     :root[dir="rtl"] & {
-      border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
+      border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
       border-left: 1px solid var(--default-border-color);
       border-right: 0;
     }

+ 87 - 47
packages/excalidraw/components/Stats/DragInput.tsx

@@ -25,10 +25,11 @@ export type DragInputCallbackType<
   originalElementsMap: ElementsMap;
   shouldKeepAspectRatio: boolean;
   shouldChangeByStepSize: boolean;
+  scene: Scene;
   nextValue?: number;
   property: P;
-  scene: Scene;
   originalAppState: AppState;
+  setInputValue: (value: number) => void;
 }) => void;
 
 interface StatsDragInputProps<
@@ -45,6 +46,8 @@ interface StatsDragInputProps<
   property: T;
   scene: Scene;
   appState: AppState;
+  /** how many px you need to drag to get 1 unit change */
+  sensitivity?: number;
 }
 
 const StatsDragInput = <
@@ -61,6 +64,7 @@ const StatsDragInput = <
   property,
   scene,
   appState,
+  sensitivity = 1,
 }: StatsDragInputProps<T, E>) => {
   const app = useApp();
   const inputRef = useRef<HTMLInputElement>(null);
@@ -122,31 +126,53 @@ const StatsDragInput = <
         originalElementsMap: app.scene.getNonDeletedElementsMap(),
         shouldKeepAspectRatio: shouldKeepAspectRatio!!,
         shouldChangeByStepSize: false,
+        scene,
         nextValue: rounded,
         property,
-        scene,
         originalAppState: appState,
+        setInputValue: (value) => setInputValue(String(value)),
       });
       app.syncActionResult({ storeAction: StoreAction.CAPTURE });
     }
   };
 
-  const handleInputValueRef = useRef(handleInputValue);
-  handleInputValueRef.current = handleInputValue;
+  const callbacksRef = useRef<
+    Partial<{
+      handleInputValue: typeof handleInputValue;
+      onPointerUp: (event: PointerEvent) => void;
+      onPointerMove: (event: PointerEvent) => void;
+    }>
+  >({});
+  callbacksRef.current.handleInputValue = handleInputValue;
 
   // make sure that clicking on canvas (which umounts the component)
   // updates current input value (blur isn't triggered)
   useEffect(() => {
     const input = inputRef.current;
+    const callbacks = callbacksRef.current;
     return () => {
       const nextValue = input?.value;
       if (nextValue) {
-        handleInputValueRef.current(
+        callbacks.handleInputValue?.(
           nextValue,
           stateRef.current.originalElements,
           stateRef.current.originalAppState,
         );
       }
+
+      // generally not needed, but in case `pointerup` doesn't fire and
+      // we don't remove the listeners that way, we should at least remove
+      // on unmount
+      window.removeEventListener(
+        EVENT.POINTER_MOVE,
+        callbacks.onPointerMove!,
+        false,
+      );
+      window.removeEventListener(
+        EVENT.POINTER_UP,
+        callbacks.onPointerUp!,
+        false,
+      );
     };
   }, [
     // we need to track change of `editable` state as mount/unmount
@@ -172,6 +198,8 @@ const StatsDragInput = <
         ref={labelRef}
         onPointerDown={(event) => {
           if (inputRef.current && editable) {
+            document.body.classList.add("excalidraw-cursor-resize");
+
             let startValue = Number(inputRef.current.value);
             if (isNaN(startValue)) {
               startValue = 0;
@@ -196,35 +224,43 @@ const StatsDragInput = <
 
             const originalAppState: AppState = cloneJSON(appState);
 
-            let accumulatedChange: number | null = null;
-
-            document.body.classList.add("excalidraw-cursor-resize");
+            let accumulatedChange = 0;
+            let stepChange = 0;
 
             const onPointerMove = (event: PointerEvent) => {
-              if (!accumulatedChange) {
-                accumulatedChange = 0;
-              }
-
               if (
                 lastPointer &&
                 originalElementsMap !== null &&
-                originalElements !== null &&
-                accumulatedChange !== null
+                originalElements !== null
               ) {
                 const instantChange = event.clientX - lastPointer.x;
-                accumulatedChange += instantChange;
-
-                dragInputCallback({
-                  accumulatedChange,
-                  instantChange,
-                  originalElements,
-                  originalElementsMap,
-                  shouldKeepAspectRatio: shouldKeepAspectRatio!!,
-                  shouldChangeByStepSize: event.shiftKey,
-                  property,
-                  scene,
-                  originalAppState,
-                });
+
+                if (instantChange !== 0) {
+                  stepChange += instantChange;
+
+                  if (Math.abs(stepChange) >= sensitivity) {
+                    stepChange =
+                      Math.sign(stepChange) *
+                      Math.floor(Math.abs(stepChange) / sensitivity);
+
+                    accumulatedChange += stepChange;
+
+                    dragInputCallback({
+                      accumulatedChange,
+                      instantChange: stepChange,
+                      originalElements,
+                      originalElementsMap,
+                      shouldKeepAspectRatio: shouldKeepAspectRatio!!,
+                      shouldChangeByStepSize: event.shiftKey,
+                      property,
+                      scene,
+                      originalAppState,
+                      setInputValue: (value) => setInputValue(String(value)),
+                    });
+
+                    stepChange = 0;
+                  }
+                }
               }
 
               lastPointer = {
@@ -233,27 +269,31 @@ const StatsDragInput = <
               };
             };
 
+            const onPointerUp = () => {
+              window.removeEventListener(
+                EVENT.POINTER_MOVE,
+                onPointerMove,
+                false,
+              );
+
+              app.syncActionResult({ storeAction: StoreAction.CAPTURE });
+
+              lastPointer = null;
+              accumulatedChange = 0;
+              stepChange = 0;
+              originalElements = null;
+              originalElementsMap = null;
+
+              document.body.classList.remove("excalidraw-cursor-resize");
+
+              window.removeEventListener(EVENT.POINTER_UP, onPointerUp, false);
+            };
+
+            callbacksRef.current.onPointerMove = onPointerMove;
+            callbacksRef.current.onPointerUp = onPointerUp;
+
             window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
-            window.addEventListener(
-              EVENT.POINTER_UP,
-              () => {
-                window.removeEventListener(
-                  EVENT.POINTER_MOVE,
-                  onPointerMove,
-                  false,
-                );
-
-                app.syncActionResult({ storeAction: StoreAction.CAPTURE });
-
-                lastPointer = null;
-                accumulatedChange = null;
-                originalElements = null;
-                originalElementsMap = null;
-
-                document.body.classList.remove("excalidraw-cursor-resize");
-              },
-              false,
-            );
+            window.addEventListener(EVENT.POINTER_UP, onPointerUp, false);
           }
         }}
         onPointerEnter={() => {

+ 6 - 5
packages/excalidraw/components/Stats/MultiAngle.tsx

@@ -3,13 +3,14 @@ import { getBoundTextElement } from "../../element/textElement";
 import { isArrowElement } from "../../element/typeChecks";
 import type { ExcalidrawElement } from "../../element/types";
 import { isInGroup } from "../../groups";
-import { degreeToRadian, radianToDegree } from "../../math";
 import type Scene from "../../scene/Scene";
 import { angleIcon } from "../icons";
 import DragInput from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import { getStepSizedValue, isPropertyEditable } from "./utils";
 import type { AppState } from "../../types";
+import type { Degrees } from "../../../math";
+import { degreesToRadians, radiansToDegrees } from "../../../math";
 
 interface MultiAngleProps {
   elements: readonly ExcalidrawElement[];
@@ -39,7 +40,7 @@ const handleDegreeChange: DragInputCallbackType<
   );
 
   if (nextValue !== undefined) {
-    const nextAngle = degreeToRadian(nextValue);
+    const nextAngle = degreesToRadians(nextValue as Degrees);
 
     for (const element of editableLatestIndividualElements) {
       if (!element) {
@@ -71,7 +72,7 @@ const handleDegreeChange: DragInputCallbackType<
     }
     const originalElement = editableOriginalIndividualElements[i];
     const originalAngleInDegrees =
-      Math.round(radianToDegree(originalElement.angle) * 100) / 100;
+      Math.round(radiansToDegrees(originalElement.angle) * 100) / 100;
     const changeInDegrees = Math.round(accumulatedChange);
     let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
     if (shouldChangeByStepSize) {
@@ -81,7 +82,7 @@ const handleDegreeChange: DragInputCallbackType<
     nextAngleInDegrees =
       nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
 
-    const nextAngle = degreeToRadian(nextAngleInDegrees);
+    const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
 
     mutateElement(
       latestElement,
@@ -109,7 +110,7 @@ const MultiAngle = ({
     (el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
   );
   const angles = editableLatestIndividualElements.map(
-    (el) => Math.round((radianToDegree(el.angle) % 360) * 100) / 100,
+    (el) => Math.round((radiansToDegrees(el.angle) % 360) * 100) / 100,
   );
   const value = new Set(angles).size === 1 ? angles[0] : "Mixed";
 

+ 19 - 6
packages/excalidraw/components/Stats/MultiDimension.tsx

@@ -13,13 +13,14 @@ import type {
   NonDeletedSceneElementsMap,
 } from "../../element/types";
 import type Scene from "../../scene/Scene";
-import type { AppState, Point } from "../../types";
+import type { AppState } from "../../types";
 import DragInput from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
 import { getElementsInAtomicUnit, resizeElement } from "./utils";
 import type { AtomicUnit } from "./utils";
 import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
+import { pointFrom, type GlobalPoint } from "../../../math";
 
 interface MultiDimensionProps {
   property: "width" | "height";
@@ -68,6 +69,7 @@ const resizeElementInGroup = (
   originalElementsMap: ElementsMap,
 ) => {
   const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
+  const { width: oldWidth, height: oldHeight } = latestElement;
 
   mutateElement(latestElement, updates, false);
   const boundTextElement = getBoundTextElement(
@@ -77,7 +79,7 @@ const resizeElementInGroup = (
   if (boundTextElement) {
     const newFontSize = boundTextElement.fontSize * scale;
     updateBoundElements(latestElement, elementsMap, {
-      newSize: { width: updates.width, height: updates.height },
+      oldSize: { width: oldWidth, height: oldHeight },
     });
     const latestBoundTextElement = elementsMap.get(boundTextElement.id);
     if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
@@ -103,7 +105,7 @@ const resizeGroup = (
   nextHeight: number,
   initialHeight: number,
   aspectRatio: number,
-  anchor: Point,
+  anchor: GlobalPoint,
   property: MultiDimensionProps["property"],
   latestElements: ExcalidrawElement[],
   originalElements: ExcalidrawElement[],
@@ -149,6 +151,7 @@ const handleDimensionChange: DragInputCallbackType<
   property,
 }) => {
   const elementsMap = scene.getNonDeletedElementsMap();
+  const elements = scene.getNonDeletedElements();
   const atomicUnits = getAtomicUnits(originalElements, originalAppState);
   if (nextValue !== undefined) {
     for (const atomicUnit of atomicUnits) {
@@ -179,7 +182,7 @@ const handleDimensionChange: DragInputCallbackType<
           nextHeight,
           initialHeight,
           aspectRatio,
-          [x1, y1],
+          pointFrom(x1, y1),
           property,
           latestElements,
           originalElements,
@@ -227,6 +230,8 @@ const handleDimensionChange: DragInputCallbackType<
             false,
             origElement,
             elementsMap,
+            elements,
+            scene,
             false,
           );
         }
@@ -282,7 +287,7 @@ const handleDimensionChange: DragInputCallbackType<
         nextHeight,
         initialHeight,
         aspectRatio,
-        [x1, y1],
+        pointFrom(x1, y1),
         property,
         latestElements,
         originalElements,
@@ -320,7 +325,15 @@ const handleDimensionChange: DragInputCallbackType<
         nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
         nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
 
-        resizeElement(nextWidth, nextHeight, false, origElement, elementsMap);
+        resizeElement(
+          nextWidth,
+          nextHeight,
+          false,
+          origElement,
+          elementsMap,
+          elements,
+          scene,
+        );
       }
     }
   }

+ 27 - 17
packages/excalidraw/components/Stats/MultiPosition.tsx

@@ -1,9 +1,9 @@
 import type {
   ElementsMap,
   ExcalidrawElement,
+  NonDeletedExcalidrawElement,
   NonDeletedSceneElementsMap,
 } from "../../element/types";
-import { rotate } from "../../math";
 import type Scene from "../../scene/Scene";
 import StatsDragInput from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
@@ -13,6 +13,7 @@ import { useMemo } from "react";
 import { getElementsInAtomicUnit, moveElement } from "./utils";
 import type { AtomicUnit } from "./utils";
 import type { AppState } from "../../types";
+import { pointFrom, pointRotateRads } from "../../../math";
 
 interface MultiPositionProps {
   property: "x" | "y";
@@ -33,6 +34,7 @@ const moveElements = (
   originalElements: readonly ExcalidrawElement[],
   elementsMap: NonDeletedSceneElementsMap,
   originalElementsMap: ElementsMap,
+  scene: Scene,
 ) => {
   for (let i = 0; i < elements.length; i++) {
     const origElement = originalElements[i];
@@ -41,11 +43,9 @@ const moveElements = (
       origElement.x + origElement.width / 2,
       origElement.y + origElement.height / 2,
     ];
-    const [topLeftX, topLeftY] = rotate(
-      origElement.x,
-      origElement.y,
-      cx,
-      cy,
+    const [topLeftX, topLeftY] = pointRotateRads(
+      pointFrom(origElement.x, origElement.y),
+      pointFrom(cx, cy),
       origElement.angle,
     );
 
@@ -60,6 +60,8 @@ const moveElements = (
       newTopLeftY,
       origElement,
       elementsMap,
+      elements,
+      scene,
       originalElementsMap,
       false,
     );
@@ -71,6 +73,7 @@ const moveGroupTo = (
   nextY: number,
   originalElements: ExcalidrawElement[],
   elementsMap: NonDeletedSceneElementsMap,
+  elements: readonly NonDeletedExcalidrawElement[],
   originalElementsMap: ElementsMap,
   scene: Scene,
 ) => {
@@ -93,11 +96,9 @@ const moveGroupTo = (
         latestElement.y + latestElement.height / 2,
       ];
 
-      const [topLeftX, topLeftY] = rotate(
-        latestElement.x,
-        latestElement.y,
-        cx,
-        cy,
+      const [topLeftX, topLeftY] = pointRotateRads(
+        pointFrom(latestElement.x, latestElement.y),
+        pointFrom(cx, cy),
         latestElement.angle,
       );
 
@@ -106,6 +107,8 @@ const moveGroupTo = (
         topLeftY + offsetY,
         origElement,
         elementsMap,
+        elements,
+        scene,
         originalElementsMap,
         false,
       );
@@ -126,6 +129,7 @@ const handlePositionChange: DragInputCallbackType<
   originalAppState,
 }) => {
   const elementsMap = scene.getNonDeletedElementsMap();
+  const elements = scene.getNonDeletedElements();
 
   if (nextValue !== undefined) {
     for (const atomicUnit of getAtomicUnits(
@@ -150,6 +154,7 @@ const handlePositionChange: DragInputCallbackType<
           newTopLeftY,
           elementsInUnit.map((el) => el.original),
           elementsMap,
+          elements,
           originalElementsMap,
           scene,
         );
@@ -165,11 +170,9 @@ const handlePositionChange: DragInputCallbackType<
             origElement.x + origElement.width / 2,
             origElement.y + origElement.height / 2,
           ];
-          const [topLeftX, topLeftY] = rotate(
-            origElement.x,
-            origElement.y,
-            cx,
-            cy,
+          const [topLeftX, topLeftY] = pointRotateRads(
+            pointFrom(origElement.x, origElement.y),
+            pointFrom(cx, cy),
             origElement.angle,
           );
 
@@ -180,6 +183,8 @@ const handlePositionChange: DragInputCallbackType<
             newTopLeftY,
             origElement,
             elementsMap,
+            elements,
+            scene,
             originalElementsMap,
             false,
           );
@@ -206,6 +211,7 @@ const handlePositionChange: DragInputCallbackType<
     originalElements,
     elementsMap,
     originalElementsMap,
+    scene,
   );
 
   scene.triggerUpdate();
@@ -234,7 +240,11 @@ const MultiPosition = ({
         const [el] = elementsInUnit;
         const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
 
-        const [topLeftX, topLeftY] = rotate(el.x, el.y, cx, cy, el.angle);
+        const [topLeftX, topLeftY] = pointRotateRads(
+          pointFrom(el.x, el.y),
+          pointFrom(cx, cy),
+          el.angle,
+        );
 
         return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
       }),

+ 13 - 12
packages/excalidraw/components/Stats/Position.tsx

@@ -1,10 +1,10 @@
 import type { ElementsMap, ExcalidrawElement } from "../../element/types";
-import { rotate } from "../../math";
 import StatsDragInput from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import { getStepSizedValue, moveElement } from "./utils";
 import type Scene from "../../scene/Scene";
 import type { AppState } from "../../types";
+import { pointFrom, pointRotateRads } from "../../../math";
 
 interface PositionProps {
   property: "x" | "y";
@@ -26,16 +26,15 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
   scene,
 }) => {
   const elementsMap = scene.getNonDeletedElementsMap();
+  const elements = scene.getNonDeletedElements();
   const origElement = originalElements[0];
   const [cx, cy] = [
     origElement.x + origElement.width / 2,
     origElement.y + origElement.height / 2,
   ];
-  const [topLeftX, topLeftY] = rotate(
-    origElement.x,
-    origElement.y,
-    cx,
-    cy,
+  const [topLeftX, topLeftY] = pointRotateRads(
+    pointFrom(origElement.x, origElement.y),
+    pointFrom(cx, cy),
     origElement.angle,
   );
 
@@ -47,6 +46,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
       newTopLeftY,
       origElement,
       elementsMap,
+      elements,
+      scene,
       originalElementsMap,
     );
     return;
@@ -78,6 +79,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
     newTopLeftY,
     origElement,
     elementsMap,
+    elements,
+    scene,
     originalElementsMap,
   );
 };
@@ -89,11 +92,9 @@ const Position = ({
   scene,
   appState,
 }: PositionProps) => {
-  const [topLeftX, topLeftY] = rotate(
-    element.x,
-    element.y,
-    element.x + element.width / 2,
-    element.y + element.height / 2,
+  const [topLeftX, topLeftY] = pointRotateRads(
+    pointFrom(element.x, element.y),
+    pointFrom(element.x + element.width / 2, element.y + element.height / 2),
     element.angle,
   );
   const value =
@@ -104,9 +105,9 @@ const Position = ({
       label={property === "x" ? "X" : "Y"}
       elements={[element]}
       dragInputCallback={handlePositionChange}
+      scene={scene}
       value={value}
       property={property}
-      scene={scene}
       appState={appState}
     />
   );

+ 72 - 0
packages/excalidraw/components/Stats/Stats.scss

@@ -0,0 +1,72 @@
+.exc-stats {
+  width: 204px;
+  position: absolute;
+  top: 60px;
+  font-size: 12px;
+  z-index: var(--zIndex-layerUI);
+  pointer-events: var(--ui-pointerEvents);
+
+  :root[dir="rtl"] & {
+    left: 12px;
+    right: initial;
+  }
+
+  h2 {
+    font-size: 1.5em;
+    margin-block-start: 0.83em;
+    margin-block-end: 0.83em;
+    font-weight: bold;
+  }
+  h3 {
+    white-space: nowrap;
+    font-size: 1.17em;
+    margin: 0;
+    font-weight: bold;
+  }
+
+  &__rows {
+    display: flex;
+    flex-direction: column;
+    gap: 0.3125rem;
+  }
+
+  &__row {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+
+    display: grid;
+    gap: 4px;
+
+    div + div {
+      text-align: right;
+    }
+  }
+
+  &__row--heading {
+    text-align: center;
+    font-weight: bold;
+    margin: 0.25rem 0;
+  }
+
+  .title {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 12px;
+
+    h2 {
+      margin: 0;
+    }
+  }
+
+  .close {
+    height: 16px;
+    width: 16px;
+    cursor: pointer;
+    svg {
+      width: 100%;
+      height: 100%;
+    }
+  }
+}

+ 227 - 136
packages/excalidraw/components/Stats/index.tsx

@@ -2,13 +2,16 @@ import { useEffect, useMemo, useState, memo } from "react";
 import { getCommonBounds } from "../../element/bounds";
 import type { NonDeletedExcalidrawElement } from "../../element/types";
 import { t } from "../../i18n";
-import type { AppState, ExcalidrawProps } from "../../types";
+import type {
+  AppClassProperties,
+  AppState,
+  ExcalidrawProps,
+} from "../../types";
 import { CloseIcon } from "../icons";
 import { Island } from "../Island";
 import { throttle } from "lodash";
 import Dimension from "./Dimension";
 import Angle from "./Angle";
-
 import FontSize from "./FontSize";
 import MultiDimension from "./MultiDimension";
 import { elementsAreInSameGroup } from "../../groups";
@@ -17,13 +20,18 @@ import MultiFontSize from "./MultiFontSize";
 import Position from "./Position";
 import MultiPosition from "./MultiPosition";
 import Collapsible from "./Collapsible";
-import type Scene from "../../scene/Scene";
 import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
 import { getAtomicUnits } from "./utils";
 import { STATS_PANELS } from "../../constants";
+import { isElbowArrow } from "../../element/typeChecks";
+import CanvasGrid from "./CanvasGrid";
+import clsx from "clsx";
+
+import "./Stats.scss";
+import { isGridModeEnabled } from "../../snapping";
 
 interface StatsProps {
-  scene: Scene;
+  app: AppClassProperties;
   onClose: () => void;
   renderCustomStats: ExcalidrawProps["renderCustomStats"];
 }
@@ -32,11 +40,12 @@ const STATS_TIMEOUT = 50;
 
 export const Stats = (props: StatsProps) => {
   const appState = useExcalidrawAppState();
-  const sceneNonce = props.scene.getSceneNonce() || 1;
-  const selectedElements = props.scene.getSelectedElements({
+  const sceneNonce = props.app.scene.getSceneNonce() || 1;
+  const selectedElements = props.app.scene.getSelectedElements({
     selectedElementIds: appState.selectedElementIds,
     includeBoundTextElement: false,
   });
+  const gridModeEnabled = isGridModeEnabled(props.app);
 
   return (
     <StatsInner
@@ -44,23 +53,71 @@ export const Stats = (props: StatsProps) => {
       appState={appState}
       sceneNonce={sceneNonce}
       selectedElements={selectedElements}
+      gridModeEnabled={gridModeEnabled}
     />
   );
 };
 
+const StatsRow = ({
+  children,
+  columns = 1,
+  heading,
+  style,
+  ...rest
+}: {
+  children: React.ReactNode;
+  columns?: number;
+  heading?: boolean;
+  style?: React.CSSProperties;
+} & React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={clsx("exc-stats__row", { "exc-stats__row--heading": heading })}
+    style={{
+      gridTemplateColumns: `repeat(${columns}, 1fr)`,
+      ...style,
+    }}
+    {...rest}
+  >
+    {children}
+  </div>
+);
+StatsRow.displayName = "StatsRow";
+
+const StatsRows = ({
+  children,
+  order,
+  style,
+  ...rest
+}: {
+  children: React.ReactNode;
+  order?: number;
+  style?: React.CSSProperties;
+} & React.HTMLAttributes<HTMLDivElement>) => (
+  <div className="exc-stats__rows" style={{ order, ...style }} {...rest}>
+    {children}
+  </div>
+);
+StatsRows.displayName = "StatsRows";
+
+Stats.StatsRow = StatsRow;
+Stats.StatsRows = StatsRows;
+
 export const StatsInner = memo(
   ({
-    scene,
+    app,
     onClose,
     renderCustomStats,
     selectedElements,
     appState,
     sceneNonce,
+    gridModeEnabled,
   }: StatsProps & {
     sceneNonce: number;
     selectedElements: readonly NonDeletedExcalidrawElement[];
     appState: AppState;
+    gridModeEnabled: boolean;
   }) => {
+    const scene = app.scene;
     const elements = scene.getNonDeletedElements();
     const elementsMap = scene.getNonDeletedElementsMap();
     const setAppState = useExcalidrawSetAppState();
@@ -105,7 +162,7 @@ export const StatsInner = memo(
     }, [selectedElements, appState]);
 
     return (
-      <div className="Stats">
+      <div className="exc-stats">
         <Island padding={3}>
           <div className="title">
             <h2>{t("stats.title")}</h2>
@@ -120,7 +177,6 @@ export const StatsInner = memo(
             openTrigger={() =>
               setAppState((state) => {
                 return {
-                  ...state,
                   stats: {
                     open: true,
                     panels: state.stats.panels ^ STATS_PANELS.generalStats,
@@ -129,26 +185,36 @@ export const StatsInner = memo(
               })
             }
           >
-            <table>
-              <tbody>
-                <tr>
-                  <th colSpan={2}>{t("stats.scene")}</th>
-                </tr>
-                <tr>
-                  <td>{t("stats.elements")}</td>
-                  <td>{elements.length}</td>
-                </tr>
-                <tr>
-                  <td>{t("stats.width")}</td>
-                  <td>{sceneDimension.width}</td>
-                </tr>
-                <tr>
-                  <td>{t("stats.height")}</td>
-                  <td>{sceneDimension.height}</td>
-                </tr>
-                {renderCustomStats?.(elements, appState)}
-              </tbody>
-            </table>
+            <StatsRows>
+              <StatsRow heading>{t("stats.scene")}</StatsRow>
+              <StatsRow columns={2}>
+                <div>{t("stats.shapes")}</div>
+                <div>{elements.length}</div>
+              </StatsRow>
+              <StatsRow columns={2}>
+                <div>{t("stats.width")}</div>
+                <div>{sceneDimension.width}</div>
+              </StatsRow>
+              <StatsRow columns={2}>
+                <div>{t("stats.height")}</div>
+                <div>{sceneDimension.height}</div>
+              </StatsRow>
+              {gridModeEnabled && (
+                <>
+                  <StatsRow heading>Canvas</StatsRow>
+                  <StatsRow>
+                    <CanvasGrid
+                      property="gridStep"
+                      scene={scene}
+                      appState={appState}
+                      setAppState={setAppState}
+                    />
+                  </StatsRow>
+                </>
+              )}
+            </StatsRows>
+
+            {renderCustomStats?.(elements, appState)}
           </Collapsible>
 
           {selectedElements.length > 0 && (
@@ -166,7 +232,6 @@ export const StatsInner = memo(
                 openTrigger={() =>
                   setAppState((state) => {
                     return {
-                      ...state,
                       stats: {
                         open: true,
                         panels:
@@ -176,115 +241,139 @@ export const StatsInner = memo(
                   })
                 }
               >
-                {singleElement && (
-                  <div className="sectionContent">
-                    <div className="elementType">
-                      {t(`element.${singleElement.type}`)}
-                    </div>
+                <StatsRows>
+                  {singleElement && (
+                    <>
+                      <StatsRow heading data-testid="stats-element-type">
+                        {t(`element.${singleElement.type}`)}
+                      </StatsRow>
 
-                    <div className="statsItem">
-                      <Position
-                        element={singleElement}
-                        property="x"
-                        elementsMap={elementsMap}
-                        scene={scene}
-                        appState={appState}
-                      />
-                      <Position
-                        element={singleElement}
-                        property="y"
-                        elementsMap={elementsMap}
-                        scene={scene}
-                        appState={appState}
-                      />
-                      <Dimension
-                        property="width"
-                        element={singleElement}
-                        scene={scene}
-                        appState={appState}
-                      />
-                      <Dimension
-                        property="height"
-                        element={singleElement}
-                        scene={scene}
-                        appState={appState}
-                      />
-                      <Angle
-                        property="angle"
-                        element={singleElement}
-                        scene={scene}
-                        appState={appState}
-                      />
-                      <FontSize
-                        property="fontSize"
-                        element={singleElement}
-                        scene={scene}
-                        appState={appState}
-                      />
-                    </div>
-                  </div>
-                )}
+                      <StatsRow>
+                        <Position
+                          element={singleElement}
+                          property="x"
+                          elementsMap={elementsMap}
+                          scene={scene}
+                          appState={appState}
+                        />
+                      </StatsRow>
+                      <StatsRow>
+                        <Position
+                          element={singleElement}
+                          property="y"
+                          elementsMap={elementsMap}
+                          scene={scene}
+                          appState={appState}
+                        />
+                      </StatsRow>
+                      <StatsRow>
+                        <Dimension
+                          property="width"
+                          element={singleElement}
+                          scene={scene}
+                          appState={appState}
+                        />
+                      </StatsRow>
+                      <StatsRow>
+                        <Dimension
+                          property="height"
+                          element={singleElement}
+                          scene={scene}
+                          appState={appState}
+                        />
+                      </StatsRow>
+                      {!isElbowArrow(singleElement) && (
+                        <StatsRow>
+                          <Angle
+                            property="angle"
+                            element={singleElement}
+                            scene={scene}
+                            appState={appState}
+                          />
+                        </StatsRow>
+                      )}
+                      <StatsRow>
+                        <FontSize
+                          property="fontSize"
+                          element={singleElement}
+                          scene={scene}
+                          appState={appState}
+                        />
+                      </StatsRow>
+                    </>
+                  )}
 
-                {multipleElements && (
-                  <div className="sectionContent">
-                    {elementsAreInSameGroup(multipleElements) && (
-                      <div className="elementType">{t("element.group")}</div>
-                    )}
+                  {multipleElements && (
+                    <>
+                      {elementsAreInSameGroup(multipleElements) && (
+                        <StatsRow heading>{t("element.group")}</StatsRow>
+                      )}
 
-                    <div className="elementsCount">
-                      <div>{t("stats.elements")}</div>
-                      <div>{selectedElements.length}</div>
-                    </div>
+                      <StatsRow columns={2} style={{ margin: "0.3125rem 0" }}>
+                        <div>{t("stats.shapes")}</div>
+                        <div>{selectedElements.length}</div>
+                      </StatsRow>
 
-                    <div className="statsItem">
-                      <MultiPosition
-                        property="x"
-                        elements={multipleElements}
-                        elementsMap={elementsMap}
-                        atomicUnits={atomicUnits}
-                        scene={scene}
-                        appState={appState}
-                      />
-                      <MultiPosition
-                        property="y"
-                        elements={multipleElements}
-                        elementsMap={elementsMap}
-                        atomicUnits={atomicUnits}
-                        scene={scene}
-                        appState={appState}
-                      />
-                      <MultiDimension
-                        property="width"
-                        elements={multipleElements}
-                        elementsMap={elementsMap}
-                        atomicUnits={atomicUnits}
-                        scene={scene}
-                        appState={appState}
-                      />
-                      <MultiDimension
-                        property="height"
-                        elements={multipleElements}
-                        elementsMap={elementsMap}
-                        atomicUnits={atomicUnits}
-                        scene={scene}
-                        appState={appState}
-                      />
-                      <MultiAngle
-                        property="angle"
-                        elements={multipleElements}
-                        scene={scene}
-                        appState={appState}
-                      />
-                      <MultiFontSize
-                        property="fontSize"
-                        elements={multipleElements}
-                        scene={scene}
-                        appState={appState}
-                        elementsMap={elementsMap}
-                      />
-                    </div>
-                  </div>
-                )}
+                      <StatsRow>
+                        <MultiPosition
+                          property="x"
+                          elements={multipleElements}
+                          elementsMap={elementsMap}
+                          atomicUnits={atomicUnits}
+                          scene={scene}
+                          appState={appState}
+                        />
+                      </StatsRow>
+                      <StatsRow>
+                        <MultiPosition
+                          property="y"
+                          elements={multipleElements}
+                          elementsMap={elementsMap}
+                          atomicUnits={atomicUnits}
+                          scene={scene}
+                          appState={appState}
+                        />
+                      </StatsRow>
+                      <StatsRow>
+                        <MultiDimension
+                          property="width"
+                          elements={multipleElements}
+                          elementsMap={elementsMap}
+                          atomicUnits={atomicUnits}
+                          scene={scene}
+                          appState={appState}
+                        />
+                      </StatsRow>
+                      <StatsRow>
+                        <MultiDimension
+                          property="height"
+                          elements={multipleElements}
+                          elementsMap={elementsMap}
+                          atomicUnits={atomicUnits}
+                          scene={scene}
+                          appState={appState}
+                        />
+                      </StatsRow>
+                      <StatsRow>
+                        <MultiAngle
+                          property="angle"
+                          elements={multipleElements}
+                          scene={scene}
+                          appState={appState}
+                        />
+                      </StatsRow>
+                      <StatsRow>
+                        <MultiFontSize
+                          property="fontSize"
+                          elements={multipleElements}
+                          scene={scene}
+                          appState={appState}
+                          elementsMap={elementsMap}
+                        />
+                      </StatsRow>
+                    </>
+                  )}
+                </StatsRows>
               </Collapsible>
             </div>
           )}
@@ -296,7 +385,9 @@ export const StatsInner = memo(
     return (
       prev.sceneNonce === next.sceneNonce &&
       prev.selectedElements === next.selectedElements &&
-      prev.appState.stats.panels === next.appState.stats.panels
+      prev.appState.stats.panels === next.appState.stats.panels &&
+      prev.gridModeEnabled === next.gridModeEnabled &&
+      prev.appState.gridStep === next.appState.gridStep
     );
   },
 );

+ 90 - 122
packages/excalidraw/components/Stats/stats.test.tsx

@@ -1,4 +1,5 @@
-import { fireEvent, queryByTestId } from "@testing-library/react";
+import React from "react";
+import { act, fireEvent, queryByTestId } from "@testing-library/react";
 import { Keyboard, Pointer, UI } from "../../tests/helpers/ui";
 import { getStepSizedValue } from "./utils";
 import {
@@ -18,13 +19,13 @@ import type {
   ExcalidrawLinearElement,
   ExcalidrawTextElement,
 } from "../../element/types";
-import { degreeToRadian, rotate } from "../../math";
 import { getTextEditor, updateTextEditor } from "../../tests/queries/dom";
 import { getCommonBounds, isTextElement } from "../../element";
 import { API } from "../../tests/helpers/api";
 import { actionGroup } from "../../actions";
 import { isInGroup } from "../../groups";
-import React from "react";
+import type { Degrees } from "../../../math";
+import { degreesToRadians, pointFrom, pointRotateRads } from "../../../math";
 
 const { h } = window;
 const mouse = new Pointer("mouse");
@@ -32,27 +33,6 @@ const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
 let stats: HTMLElement | null = null;
 let elementStats: HTMLElement | null | undefined = null;
 
-const editInput = (input: HTMLInputElement, value: string) => {
-  input.focus();
-  fireEvent.change(input, { target: { value } });
-  input.blur();
-};
-
-const getStatsProperty = (label: string) => {
-  const elementStats = UI.queryStats()?.querySelector("#elementStats");
-
-  if (elementStats) {
-    const properties = elementStats?.querySelector(".statsItem");
-    return (
-      properties?.querySelector?.(
-        `.drag-input-container[data-testid="${label}"]`,
-      ) || null
-    );
-  }
-
-  return null;
-};
-
 const testInputProperty = (
   element: ExcalidrawElement,
   property: "x" | "y" | "width" | "height" | "angle" | "fontSize",
@@ -60,14 +40,16 @@ const testInputProperty = (
   initialValue: number,
   nextValue: number,
 ) => {
-  const input = getStatsProperty(label)?.querySelector(
+  const input = UI.queryStatsProperty(label)?.querySelector(
     ".drag-input",
   ) as HTMLInputElement;
   expect(input).toBeDefined();
   expect(input.value).toBe(initialValue.toString());
-  editInput(input, String(nextValue));
+  UI.updateInput(input, String(nextValue));
   if (property === "angle") {
-    expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
+    expect(element[property]).toBe(
+      degreesToRadians(Number(nextValue) as Degrees),
+    );
   } else if (property === "fontSize" && isTextElement(element)) {
     expect(element[property]).toBe(Number(nextValue));
   } else if (property !== "fontSize") {
@@ -110,7 +92,7 @@ describe("binding with linear elements", () => {
 
     await render(<Excalidraw handleKeyboardGlobally={true} />);
 
-    h.elements = [];
+    API.setElements([]);
 
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
@@ -142,47 +124,47 @@ describe("binding with linear elements", () => {
 
   it("should remain bound to linear element on small position change", async () => {
     const linear = h.elements[1] as ExcalidrawLinearElement;
-    const inputX = getStatsProperty("X")?.querySelector(
+    const inputX = UI.queryStatsProperty("X")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
 
     expect(linear.startBinding).not.toBe(null);
     expect(inputX).not.toBeNull();
-    editInput(inputX, String("204"));
+    UI.updateInput(inputX, String("204"));
     expect(linear.startBinding).not.toBe(null);
   });
 
   it("should remain bound to linear element on small angle change", async () => {
     const linear = h.elements[1] as ExcalidrawLinearElement;
-    const inputAngle = getStatsProperty("A")?.querySelector(
+    const inputAngle = UI.queryStatsProperty("A")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
 
     expect(linear.startBinding).not.toBe(null);
-    editInput(inputAngle, String("1"));
+    UI.updateInput(inputAngle, String("1"));
     expect(linear.startBinding).not.toBe(null);
   });
 
   it("should unbind linear element on large position change", async () => {
     const linear = h.elements[1] as ExcalidrawLinearElement;
-    const inputX = getStatsProperty("X")?.querySelector(
+    const inputX = UI.queryStatsProperty("X")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
 
     expect(linear.startBinding).not.toBe(null);
     expect(inputX).not.toBeNull();
-    editInput(inputX, String("254"));
+    UI.updateInput(inputX, String("254"));
     expect(linear.startBinding).toBe(null);
   });
 
   it("should remain bound to linear element on small angle change", async () => {
     const linear = h.elements[1] as ExcalidrawLinearElement;
-    const inputAngle = getStatsProperty("A")?.querySelector(
+    const inputAngle = UI.queryStatsProperty("A")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
 
     expect(linear.startBinding).not.toBe(null);
-    editInput(inputAngle, String("45"));
+    UI.updateInput(inputAngle, String("45"));
     expect(linear.startBinding).toBe(null);
   });
 });
@@ -197,7 +179,7 @@ describe("stats for a generic element", () => {
 
     await render(<Excalidraw handleKeyboardGlobally={true} />);
 
-    h.elements = [];
+    API.setElements([]);
 
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
@@ -231,18 +213,14 @@ describe("stats for a generic element", () => {
     expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties"));
 
     // element type
-    const elementType = elementStats?.querySelector(".elementType");
+    const elementType = queryByTestId(elementStats!, "stats-element-type");
     expect(elementType).toBeDefined();
     expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
 
     // properties
-    const properties = elementStats?.querySelector(".statsItem");
-    expect(properties?.childNodes).toBeDefined();
     ["X", "Y", "W", "H", "A"].forEach((label) => () => {
       expect(
-        properties?.querySelector?.(
-          `.drag-input-container[data-testid="${label}"]`,
-        ),
+        stats!.querySelector?.(`.drag-input-container[data-testid="${label}"]`),
       ).toBeDefined();
     });
   });
@@ -263,18 +241,18 @@ describe("stats for a generic element", () => {
     const rectangle = h.elements[0];
     const rectangleId = rectangle.id;
 
-    const input = getStatsProperty("W")?.querySelector(
+    const input = UI.queryStatsProperty("W")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
     expect(input).toBeDefined();
     expect(input.value).toBe(rectangle.width.toString());
-    editInput(input, "123.123");
+    UI.updateInput(input, "123.123");
     expect(h.elements.length).toBe(1);
     expect(rectangle.id).toBe(rectangleId);
     expect(input.value).toBe("123.12");
     expect(rectangle.width).toBe(123.12);
 
-    editInput(input, "88.98766");
+    UI.updateInput(input, "88.98766");
     expect(input.value).toBe("88.99");
     expect(rectangle.width).toBe(88.99);
   });
@@ -285,19 +263,17 @@ describe("stats for a generic element", () => {
       rectangle.x + rectangle.width / 2,
       rectangle.y + rectangle.height / 2,
     ];
-    const [topLeftX, topLeftY] = rotate(
-      rectangle.x,
-      rectangle.y,
-      cx,
-      cy,
+    const [topLeftX, topLeftY] = pointRotateRads(
+      pointFrom(rectangle.x, rectangle.y),
+      pointFrom(cx, cy),
       rectangle.angle,
     );
 
-    const xInput = getStatsProperty("X")?.querySelector(
+    const xInput = UI.queryStatsProperty("X")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
 
-    const yInput = getStatsProperty("Y")?.querySelector(
+    const yInput = UI.queryStatsProperty("Y")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
 
@@ -306,11 +282,9 @@ describe("stats for a generic element", () => {
 
     testInputProperty(rectangle, "angle", "A", 0, 45);
 
-    let [newTopLeftX, newTopLeftY] = rotate(
-      rectangle.x,
-      rectangle.y,
-      cx,
-      cy,
+    let [newTopLeftX, newTopLeftY] = pointRotateRads(
+      pointFrom(rectangle.x, rectangle.y),
+      pointFrom(cx, cy),
       rectangle.angle,
     );
 
@@ -319,11 +293,9 @@ describe("stats for a generic element", () => {
 
     testInputProperty(rectangle, "angle", "A", 45, 66);
 
-    [newTopLeftX, newTopLeftY] = rotate(
-      rectangle.x,
-      rectangle.y,
-      cx,
-      cy,
+    [newTopLeftX, newTopLeftY] = pointRotateRads(
+      pointFrom(rectangle.x, rectangle.y),
+      pointFrom(cx, cy),
       rectangle.angle,
     );
     expect(newTopLeftX.toString()).not.toEqual(xInput.value);
@@ -338,11 +310,9 @@ describe("stats for a generic element", () => {
       rectangle.x + rectangle.width / 2,
       rectangle.y + rectangle.height / 2,
     ];
-    const [topLeftX, topLeftY] = rotate(
-      rectangle.x,
-      rectangle.y,
-      cx,
-      cy,
+    const [topLeftX, topLeftY] = pointRotateRads(
+      pointFrom(rectangle.x, rectangle.y),
+      pointFrom(cx, cy),
       rectangle.angle,
     );
     testInputProperty(rectangle, "width", "W", rectangle.width, 400);
@@ -350,11 +320,9 @@ describe("stats for a generic element", () => {
       rectangle.x + rectangle.width / 2,
       rectangle.y + rectangle.height / 2,
     ];
-    let [currentTopLeftX, currentTopLeftY] = rotate(
-      rectangle.x,
-      rectangle.y,
-      cx,
-      cy,
+    let [currentTopLeftX, currentTopLeftY] = pointRotateRads(
+      pointFrom(rectangle.x, rectangle.y),
+      pointFrom(cx, cy),
       rectangle.angle,
     );
     expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
@@ -365,11 +333,9 @@ describe("stats for a generic element", () => {
       rectangle.x + rectangle.width / 2,
       rectangle.y + rectangle.height / 2,
     ];
-    [currentTopLeftX, currentTopLeftY] = rotate(
-      rectangle.x,
-      rectangle.y,
-      cx,
-      cy,
+    [currentTopLeftX, currentTopLeftY] = pointRotateRads(
+      pointFrom(rectangle.x, rectangle.y),
+      pointFrom(cx, cy),
       rectangle.angle,
     );
 
@@ -387,7 +353,7 @@ describe("stats for a non-generic element", () => {
 
     await render(<Excalidraw handleKeyboardGlobally={true} />);
 
-    h.elements = [];
+    API.setElements([]);
 
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
@@ -412,9 +378,10 @@ describe("stats for a non-generic element", () => {
     mouse.clickAt(20, 30);
     const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
     const editor = await getTextEditor(textEditorSelector, true);
-    await new Promise((r) => setTimeout(r, 0));
     updateTextEditor(editor, "Hello!");
-    editor.blur();
+    act(() => {
+      editor.blur();
+    });
 
     const text = h.elements[0] as ExcalidrawTextElement;
     mouse.clickOn(text);
@@ -422,22 +389,22 @@ describe("stats for a non-generic element", () => {
     elementStats = stats?.querySelector("#elementStats");
 
     // can change font size
-    const input = getStatsProperty("F")?.querySelector(
+    const input = UI.queryStatsProperty("F")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
     expect(input).toBeDefined();
     expect(input.value).toBe(text.fontSize.toString());
-    editInput(input, "36");
+    UI.updateInput(input, "36");
     expect(text.fontSize).toBe(36);
 
     // cannot change width or height
-    const width = getStatsProperty("W")?.querySelector(".drag-input");
+    const width = UI.queryStatsProperty("W")?.querySelector(".drag-input");
     expect(width).toBeUndefined();
-    const height = getStatsProperty("H")?.querySelector(".drag-input");
+    const height = UI.queryStatsProperty("H")?.querySelector(".drag-input");
     expect(height).toBeUndefined();
 
     // min font size is 4
-    editInput(input, "0");
+    UI.updateInput(input, "0");
     expect(text.fontSize).not.toBe(0);
     expect(text.fontSize).toBe(4);
   });
@@ -449,8 +416,8 @@ describe("stats for a non-generic element", () => {
       x: 150,
       width: 150,
     });
-    h.elements = [frame];
-    h.setState({
+    API.setElements([frame]);
+    API.setAppState({
       selectedElementIds: {
         [frame.id]: true,
       },
@@ -461,7 +428,7 @@ describe("stats for a non-generic element", () => {
     expect(elementStats).toBeDefined();
 
     // cannot change angle
-    const angle = getStatsProperty("A")?.querySelector(".drag-input");
+    const angle = UI.queryStatsProperty("A")?.querySelector(".drag-input");
     expect(angle).toBeUndefined();
 
     // can change width or height
@@ -471,9 +438,9 @@ describe("stats for a non-generic element", () => {
 
   it("image element", () => {
     const image = API.createElement({ type: "image", width: 200, height: 100 });
-    h.elements = [image];
+    API.setElements([image]);
     mouse.clickOn(image);
-    h.setState({
+    API.setAppState({
       selectedElementIds: {
         [image.id]: true,
       },
@@ -508,15 +475,15 @@ describe("stats for a non-generic element", () => {
     mutateElement(container, {
       boundElements: [{ type: "text", id: text.id }],
     });
-    h.elements = [container, text];
+    API.setElements([container, text]);
 
     API.setSelectedElements([container]);
-    const fontSize = getStatsProperty("F")?.querySelector(
+    const fontSize = UI.queryStatsProperty("F")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
     expect(fontSize).toBeDefined();
 
-    editInput(fontSize, "40");
+    UI.updateInput(fontSize, "40");
 
     expect(text.fontSize).toBe(40);
   });
@@ -533,7 +500,7 @@ describe("stats for multiple elements", () => {
 
     await render(<Excalidraw handleKeyboardGlobally={true} />);
 
-    h.elements = [];
+    API.setElements([]);
 
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
@@ -566,7 +533,7 @@ describe("stats for multiple elements", () => {
     mouse.down(-100, -100);
     mouse.up(125, 145);
 
-    h.setState({
+    API.setAppState({
       selectedElementIds: h.elements.reduce((acc, el) => {
         acc[el.id] = true;
         return acc;
@@ -575,25 +542,25 @@ describe("stats for multiple elements", () => {
 
     elementStats = stats?.querySelector("#elementStats");
 
-    const width = getStatsProperty("W")?.querySelector(
+    const width = UI.queryStatsProperty("W")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
     expect(width?.value).toBe("Mixed");
-    const height = getStatsProperty("H")?.querySelector(
+    const height = UI.queryStatsProperty("H")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
     expect(height?.value).toBe("Mixed");
-    const angle = getStatsProperty("A")?.querySelector(
+    const angle = UI.queryStatsProperty("A")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
     expect(angle.value).toBe("0");
 
-    editInput(width, "250");
+    UI.updateInput(width, "250");
     h.elements.forEach((el) => {
       expect(el.width).toBe(250);
     });
 
-    editInput(height, "450");
+    UI.updateInput(height, "450");
     h.elements.forEach((el) => {
       expect(el.height).toBe(450);
     });
@@ -605,9 +572,10 @@ describe("stats for multiple elements", () => {
     mouse.clickAt(20, 30);
     const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
     const editor = await getTextEditor(textEditorSelector, true);
-    await new Promise((r) => setTimeout(r, 0));
     updateTextEditor(editor, "Hello!");
-    editor.blur();
+    act(() => {
+      editor.blur();
+    });
 
     UI.clickTool("rectangle");
     mouse.down();
@@ -619,12 +587,12 @@ describe("stats for multiple elements", () => {
       width: 150,
     });
 
-    h.elements = [...h.elements, frame];
+    API.setElements([...h.elements, frame]);
 
     const text = h.elements.find((el) => el.type === "text");
     const rectangle = h.elements.find((el) => el.type === "rectangle");
 
-    h.setState({
+    API.setAppState({
       selectedElementIds: h.elements.reduce((acc, el) => {
         acc[el.id] = true;
         return acc;
@@ -633,39 +601,39 @@ describe("stats for multiple elements", () => {
 
     elementStats = stats?.querySelector("#elementStats");
 
-    const width = getStatsProperty("W")?.querySelector(
+    const width = UI.queryStatsProperty("W")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
     expect(width).toBeDefined();
     expect(width.value).toBe("Mixed");
 
-    const height = getStatsProperty("H")?.querySelector(
+    const height = UI.queryStatsProperty("H")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
     expect(height).toBeDefined();
     expect(height.value).toBe("Mixed");
 
-    const angle = getStatsProperty("A")?.querySelector(
+    const angle = UI.queryStatsProperty("A")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
     expect(angle).toBeDefined();
     expect(angle.value).toBe("0");
 
-    const fontSize = getStatsProperty("F")?.querySelector(
+    const fontSize = UI.queryStatsProperty("F")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
     expect(fontSize).toBeDefined();
 
     // changing width does not affect text
-    editInput(width, "200");
+    UI.updateInput(width, "200");
 
     expect(rectangle?.width).toBe(200);
     expect(frame.width).toBe(200);
     expect(text?.width).not.toBe(200);
 
-    editInput(angle, "40");
+    UI.updateInput(angle, "40");
 
-    const angleInRadian = degreeToRadian(40);
+    const angleInRadian = degreesToRadians(40 as Degrees);
     expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
     expect(text?.angle).toBeCloseTo(angleInRadian, 4);
     expect(frame.angle).toBe(0);
@@ -686,7 +654,7 @@ describe("stats for multiple elements", () => {
         mouse.click();
       });
 
-      h.app.actionManager.executeAction(actionGroup);
+      API.executeAction(actionGroup);
     };
 
     createAndSelectGroup();
@@ -696,58 +664,58 @@ describe("stats for multiple elements", () => {
 
     elementStats = stats?.querySelector("#elementStats");
 
-    const x = getStatsProperty("X")?.querySelector(
+    const x = UI.queryStatsProperty("X")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
 
     expect(x).toBeDefined();
     expect(Number(x.value)).toBe(x1);
 
-    editInput(x, "300");
+    UI.updateInput(x, "300");
 
     expect(h.elements[0].x).toBe(300);
     expect(h.elements[1].x).toBe(400);
     expect(x.value).toBe("300");
 
-    const y = getStatsProperty("Y")?.querySelector(
+    const y = UI.queryStatsProperty("Y")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
 
     expect(y).toBeDefined();
     expect(Number(y.value)).toBe(y1);
 
-    editInput(y, "200");
+    UI.updateInput(y, "200");
 
     expect(h.elements[0].y).toBe(200);
     expect(h.elements[1].y).toBe(300);
     expect(y.value).toBe("200");
 
-    const width = getStatsProperty("W")?.querySelector(
+    const width = UI.queryStatsProperty("W")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
     expect(width).toBeDefined();
     expect(Number(width.value)).toBe(200);
 
-    const height = getStatsProperty("H")?.querySelector(
+    const height = UI.queryStatsProperty("H")?.querySelector(
       ".drag-input",
     ) as HTMLInputElement;
     expect(height).toBeDefined();
     expect(Number(height.value)).toBe(200);
 
-    editInput(width, "400");
+    UI.updateInput(width, "400");
 
     [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
     let newGroupWidth = x2 - x1;
 
     expect(newGroupWidth).toBeCloseTo(400, 4);
 
-    editInput(width, "300");
+    UI.updateInput(width, "300");
 
     [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
     newGroupWidth = x2 - x1;
     expect(newGroupWidth).toBeCloseTo(300, 4);
 
-    editInput(height, "500");
+    UI.updateInput(height, "500");
 
     [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
     const newGroupHeight = y2 - y1;

+ 34 - 16
packages/excalidraw/components/Stats/utils.ts

@@ -1,3 +1,5 @@
+import type { Radians } from "../../../math";
+import { pointFrom, pointRotateRads } from "../../../math";
 import {
   bindOrUnbindLinearElements,
   updateBoundElements,
@@ -30,7 +32,7 @@ import {
   getElementsInGroup,
   isInGroup,
 } from "../../groups";
-import { rotate } from "../../math";
+import type Scene from "../../scene/Scene";
 import type { AppState } from "../../types";
 import { getFontString } from "../../utils";
 
@@ -40,7 +42,8 @@ export type StatsInputProperty =
   | "width"
   | "height"
   | "angle"
-  | "fontSize";
+  | "fontSize"
+  | "gridStep";
 
 export const SMALLEST_DELTA = 0.01;
 
@@ -124,6 +127,8 @@ export const resizeElement = (
   keepAspectRatio: boolean,
   origElement: ExcalidrawElement,
   elementsMap: NonDeletedSceneElementsMap,
+  elements: readonly NonDeletedExcalidrawElement[],
+  scene: Scene,
   shouldInformMutation = true,
 ) => {
   const latestElement = elementsMap.get(origElement.id);
@@ -146,6 +151,8 @@ export const resizeElement = (
     nextHeight = Math.max(nextHeight, minHeight);
   }
 
+  const { width: oldWidth, height: oldHeight } = latestElement;
+
   mutateElement(
     latestElement,
     {
@@ -164,7 +171,7 @@ export const resizeElement = (
     },
     shouldInformMutation,
   );
-  updateBindings(latestElement, elementsMap, {
+  updateBindings(latestElement, elementsMap, elements, scene, {
     newSize: {
       width: nextWidth,
       height: nextHeight,
@@ -193,6 +200,10 @@ export const resizeElement = (
     }
   }
 
+  updateBoundElements(latestElement, elementsMap, {
+    oldSize: { width: oldWidth, height: oldHeight },
+  });
+
   if (boundTextElement && boundTextFont) {
     mutateElement(boundTextElement, {
       fontSize: boundTextFont.fontSize,
@@ -206,6 +217,8 @@ export const moveElement = (
   newTopLeftY: number,
   originalElement: ExcalidrawElement,
   elementsMap: NonDeletedSceneElementsMap,
+  elements: readonly NonDeletedExcalidrawElement[],
+  scene: Scene,
   originalElementsMap: ElementsMap,
   shouldInformMutation = true,
 ) => {
@@ -217,23 +230,19 @@ export const moveElement = (
     originalElement.x + originalElement.width / 2,
     originalElement.y + originalElement.height / 2,
   ];
-  const [topLeftX, topLeftY] = rotate(
-    originalElement.x,
-    originalElement.y,
-    cx,
-    cy,
+  const [topLeftX, topLeftY] = pointRotateRads(
+    pointFrom(originalElement.x, originalElement.y),
+    pointFrom(cx, cy),
     originalElement.angle,
   );
 
   const changeInX = newTopLeftX - topLeftX;
   const changeInY = newTopLeftY - topLeftY;
 
-  const [x, y] = rotate(
-    newTopLeftX,
-    newTopLeftY,
-    cx + changeInX,
-    cy + changeInY,
-    -originalElement.angle,
+  const [x, y] = pointRotateRads(
+    pointFrom(newTopLeftX, newTopLeftY),
+    pointFrom(cx + changeInX, cy + changeInY),
+    -originalElement.angle as Radians,
   );
 
   mutateElement(
@@ -244,7 +253,7 @@ export const moveElement = (
     },
     shouldInformMutation,
   );
-  updateBindings(latestElement, elementsMap);
+  updateBindings(latestElement, elementsMap, elements, scene);
 
   const boundTextElement = getBoundTextElement(
     originalElement,
@@ -288,13 +297,22 @@ export const getAtomicUnits = (
 export const updateBindings = (
   latestElement: ExcalidrawElement,
   elementsMap: NonDeletedSceneElementsMap,
+  elements: readonly NonDeletedExcalidrawElement[],
+  scene: Scene,
   options?: {
     simultaneouslyUpdated?: readonly ExcalidrawElement[];
     newSize?: { width: number; height: number };
   },
 ) => {
   if (isLinearElement(latestElement)) {
-    bindOrUnbindLinearElements([latestElement], elementsMap, true, []);
+    bindOrUnbindLinearElements(
+      [latestElement],
+      elementsMap,
+      elements,
+      scene,
+      true,
+      [],
+    );
   } else {
     updateBoundElements(latestElement, elementsMap, options);
   }

+ 1 - 1
packages/excalidraw/components/TTDDialog/TTDDialog.tsx

@@ -25,11 +25,11 @@ import type { BinaryFiles } from "../../types";
 import { ArrowRightIcon } from "../icons";
 
 import "./TTDDialog.scss";
-import { isFiniteNumber } from "../../utils";
 import { atom, useAtom } from "jotai";
 import { trackEvent } from "../../analytics";
 import { InlineIcon } from "../InlineIcon";
 import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
+import { isFiniteNumber } from "../../../math";
 
 const MIN_PROMPT_LENGTH = 3;
 const MAX_PROMPT_LENGTH = 1000;

+ 1 - 11
packages/excalidraw/components/TTDDialog/TTDDialogTabs.tsx

@@ -7,10 +7,7 @@ import { isMemberOf } from "../../utils";
 const TTDDialogTabs = (
   props: {
     children: ReactNode;
-  } & (
-    | { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" }
-    | { dialog: "settings"; tab: "text-to-diagram" | "diagram-to-code" }
-  ),
+  } & { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" },
 ) => {
   const setAppState = useExcalidrawSetAppState();
 
@@ -39,13 +36,6 @@ const TTDDialogTabs = (
           }
         }
         if (
-          props.dialog === "settings" &&
-          isMemberOf(["text-to-diagram", "diagram-to-code"], tab)
-        ) {
-          setAppState({
-            openDialog: { name: props.dialog, tab, source: "settings" },
-          });
-        } else if (
           props.dialog === "ttd" &&
           isMemberOf(["text-to-diagram", "mermaid"], tab)
         ) {

Some files were not shown because too many files changed in this diff