Compare commits

..

2 Commits

Author SHA1 Message Date
dwelle
37f5c04805 debug 2024-06-24 23:43:00 +02:00
dwelle
eac523c83f wip 2024-06-24 22:59:31 +02:00
636 changed files with 14202 additions and 49631 deletions

View File

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

View File

@@ -8,7 +8,7 @@ VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfu
VITE_APP_WS_SERVER_URL=http://localhost:3002
VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=http://localhost:3000
VITE_APP_PLUS_APP=https://app.excalidraw.com
VITE_APP_AI_BACKEND=http://localhost:3015
@@ -17,10 +17,12 @@ 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=
VITE_APP_ENABLE_TRACKING=true
VITE_APP_DISABLE_TRACKING=true
FAST_REFRESH=false
@@ -37,14 +39,3 @@ VITE_APP_COLLAPSE_OVERLAY=true
# Set this flag to false to disable eslint
VITE_APP_ENABLE_ESLINT=true
# Enable PWA in dev server
VITE_APP_ENABLE_PWA=false
VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm2g5T+Rub6Kbf1Mf57t0
7r2zeHuVg4dla3r5ryXMswtzz6x767octl6oLThn33mQsPSy3GKglFZoCTXJR4ij
ba8SxB04sL/N8eRrKja7TFWjCVtRwTTfyy771NYYNFVJclkxHyE5qw4m27crHF1y
UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD
s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot
kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS
HQIDAQAB'

View File

@@ -14,19 +14,4 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
VITE_APP_ENABLE_TRACKING=false
VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApQ0jM9Qz8TdFLzcuAZZX
/WvuKSOJxiw6AR/ZcE3eFQWM/mbFdhQgyK8eHGkKQifKzH1xUZjCxyXcxW6ZO02t
kPOPxhz+nxUrIoWCD/V4NGmUA1lxwHuO21HN1gzKrN3xHg5EGjyouR9vibT9VDGF
gq6+4Ic/kJX+AD2MM7Yre2+FsOdysrmuW2Fu3ahuC1uQE7pOe1j0k7auNP0y1q53
PrB8Ts2LUpepWC1l7zIXFm4ViDULuyWXTEpUcHSsEH8vpd1tckjypxCwkipfZsXx
iPszy0o0Dx2iArPfWMXlFAI9mvyFCyFC3+nSvfyAUb2C4uZgCwAuyFh/ydPF4DEE
PQIDAQAB'
# Set the below flags explicitly to false in production mode since vite loads and merges .env.local vars when running the build command
VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=false
VITE_APP_COLLAPSE_OVERLAY=false
# Enable eslint in dev server
VITE_APP_ENABLE_ESLINT=false
VITE_APP_DISABLE_TRACKING=

View File

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

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}

View File

@@ -11,6 +11,6 @@ jobs:
semantic:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
- uses: amannn/action-semantic-pull-request@v3.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,6 +1,7 @@
name: Tests
on:
pull_request:
push:
branches: master
@@ -8,9 +9,9 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Setup Node.js 18.x
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version: 18.x
- name: Install and test

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ The welcome screen consists of two main groups of subcomponents:
<img
src={require("@site/static/img/welcome-screen-overview.png").default}
alt="Excalidraw logo: Sketch hand-drawn like diagrams."
alt="Excalidraw logo: Sketch handrawn like diagrams."
/>
### Center

View File

@@ -12,7 +12,7 @@ import { FONT_FAMILY } from "@excalidraw/excalidraw";
| Font Family | Description |
| ----------- | ---------------------- |
| `Virgil` | The `Hand-drawn` font |
| `Virgil` | The `handwritten` font |
| `Helvetica` | The `Normal` Font |
| `Cascadia` | The `Code` Font |

View File

@@ -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 events |
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down evenets |
| [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. |
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when something is pasted into the scene |
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when the something is pasted in to 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>` |

View File

@@ -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/utils/export.ts#L24">ExportOpts</a>
&#125;: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L21">ExportOpts</a>
</pre>
| Name | Type | Default | Description |
@@ -90,7 +90,7 @@ function App() {
<img src={canvasUrl} alt="" />
</div>
<div style={{ height: "400px" }}>
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)}
<Excalidraw ref={(api) => setExcalidrawAPI(api)}
/>
</div>
</>

View File

@@ -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 parseMermaidToExcalidraw(mermaidSyntax: string, {
const { elements, files } = await parseMermaid(mermaidSyntax: string, {
fontSize: number,
});
const excalidrawElements = convertToExcalidrawElements(elements);

View File

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

View File

@@ -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.6",
"@excalidraw/excalidraw": "0.17.0",
"@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1",
"docusaurus-plugin-sass": "0.2.3",
"prism-react-renderer": "^1.3.5",
"react": "18.2.0",
"react-dom": "18.2.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"sass": "1.57.1"
},
"devDependencies": {

View File

@@ -59,7 +59,7 @@ pre a {
padding: 5px;
background: #70b1ec;
color: white;
font-weight: 700;
font-weight: bold;
border: none;
}

View File

@@ -1547,7 +1547,7 @@
"@docusaurus/theme-search-algolia" "2.2.0"
"@docusaurus/types" "2.2.0"
"@docusaurus/react-loadable@5.5.2":
"@docusaurus/react-loadable@5.5.2", "react-loadable@npm:@docusaurus/react-loadable@5.5.2":
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/excalidraw@0.17.6":
version "0.17.6"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.6.tgz#5fd208ce69d33ca712d1804b50d7d06d5c46ac4d"
integrity sha512-fyCl+zG/Z5yhHDh5Fq2ZGmphcrALmuOdtITm8gN4d8w4ntnaopTXcTfnAAaU3VleDC6LhTkoLOTG6P5kgREiIg==
"@excalidraw/excalidraw@0.17.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==
"@hapi/hoek@^9.0.0":
version "9.3.0"
@@ -2789,14 +2789,7 @@ brace-expansion@^1.1.7:
balanced-match "^1.0.0"
concat-map "0.0.1"
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:
braces@^3.0.2, 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==
@@ -3278,9 +3271,9 @@ cross-fetch@^3.1.5:
node-fetch "2.6.7"
cross-spawn@^7.0.3:
version "7.0.6"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
dependencies:
path-key "^3.1.0"
shebang-command "^2.0.0"
@@ -4018,13 +4011,6 @@ 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"
finalhandler@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32"
@@ -5221,11 +5207,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.8"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
version "4.0.5"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
dependencies:
braces "^3.0.3"
braces "^3.0.2"
picomatch "^2.3.1"
mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
@@ -6204,13 +6190,14 @@ react-dev-utils@^12.0.1:
strip-ansi "^6.0.1"
text-table "^0.2.0"
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==
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==
dependencies:
loose-envify "^1.1.0"
scheduler "^0.23.0"
object-assign "^4.1.1"
scheduler "^0.20.2"
react-error-overlay@^6.0.11:
version "6.0.11"
@@ -6273,14 +6260,6 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
dependencies:
"@babel/runtime" "^7.10.3"
"react-loadable@npm:@docusaurus/react-loadable@5.5.2":
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"
@@ -6331,12 +6310,13 @@ react-textarea-autosize@^8.3.2:
use-composed-ref "^1.3.0"
use-latest "^1.2.1"
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==
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==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
readable-stream@^2.0.1:
version "2.3.7"
@@ -6684,12 +6664,13 @@ sax@^1.2.4:
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
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==
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==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
schema-utils@2.7.0:
version "2.7.0"

View File

@@ -40,7 +40,7 @@ import type {
} from "@excalidraw/excalidraw/dist/excalidraw/element/types";
import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types";
import "./ExampleApp.scss";
import "./App.scss";
type Comment = {
x: number;
@@ -73,7 +73,7 @@ export interface AppProps {
excalidrawLib: typeof TExcalidraw;
}
export default function ExampleApp({
export default function App({
appTitle,
useCustom,
customArgs,
@@ -872,7 +872,7 @@ export default function ExampleApp({
files: excalidrawAPI.getFiles(),
});
const ctx = canvas.getContext("2d")!;
ctx.font = "30px Excalifont";
ctx.font = "30px Virgil";
ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL());
}}
@@ -893,7 +893,7 @@ export default function ExampleApp({
files: excalidrawAPI.getFiles(),
});
const ctx = canvas.getContext("2d")!;
ctx.font = "30px Excalifont";
ctx.font = "30px Virgil";
ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL());
}}

View File

@@ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [
];
export default {
elements,
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 },
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 },
scrollToContent: true,
libraryItems: [
[

View File

@@ -34,6 +34,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# copied assets
public/**/*.woff2

View File

@@ -3,8 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp -r ../../../packages/excalidraw/dist/prod/fonts ./public",
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm",
"dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build",
"start": "next start -p 3006",
@@ -13,13 +12,13 @@
"dependencies": {
"@excalidraw/excalidraw": "*",
"next": "14.1",
"react": "18.2.0",
"react-dom": "18.2.0"
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.0",
"@types/react": "^18",
"@types/react-dom": "^18",
"path2d-polyfill": "2.0.1",
"typescript": "^5"
}

View File

@@ -1,5 +1,4 @@
import dynamic from "next/dynamic";
import Script from "next/script";
import "../common.scss";
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
@@ -16,9 +15,7 @@ export default function Page() {
<>
<a href="/excalidraw-in-pages">Switch to Pages router</a>
<h1 className="page-title">App Router</h1>
<Script id="load-env-variables" strategy="beforeInteractive">
{`window["EXCALIDRAW_ASSET_PATH"] = window.origin;`}
</Script>
{/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */}
<ExcalidrawWithClientOnly />
</>

View File

@@ -7,7 +7,7 @@ a {
color: #1c7ed6;
font-size: 20px;
text-decoration: none;
font-weight: 500;
font-weight: 550;
}
.page-title {

View File

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

View File

@@ -1,2 +0,0 @@
# copied assets
public/**/*.woff2

View File

@@ -11,7 +11,6 @@
<title>React App</title>
<script>
window.name = "codesandbox";
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script>
<link rel="stylesheet" href="/dist/browser/dev/index.css" />
</head>

View File

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

View File

@@ -12,10 +12,8 @@
"typescript": "^5"
},
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp -r ../../../packages/excalidraw/dist/prod/fonts ./public",
"start": "yarn build:workspace && vite",
"build": "yarn build:workspace && vite build",
"start": "yarn workspace @excalidraw/excalidraw run build:esm && vite",
"build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build",
"build:preview": "yarn build && vite preview --port 5002"
}
}

View File

@@ -1,4 +1,5 @@
import polyfill from "../packages/excalidraw/polyfill";
import LanguageDetector from "i18next-browser-languagedetector";
import { useCallback, useEffect, useRef, useState } from "react";
import { trackEvent } from "../packages/excalidraw/analytics";
import { getDefaultAppState } from "../packages/excalidraw/appState";
@@ -21,7 +22,9 @@ import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRef
import { t } from "../packages/excalidraw/i18n";
import {
Excalidraw,
defaultLang,
LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger,
StoreAction,
reconcileElements,
@@ -90,7 +93,7 @@ import {
import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter";
import { Provider, useAtom, useAtomValue } from "jotai";
import { atom, Provider, useAtom, useAtomValue } from "jotai";
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
import { appJotaiStore } from "./app-jotai";
@@ -118,15 +121,6 @@ import {
youtubeIcon,
} from "../packages/excalidraw/components/icons";
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";
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
polyfill();
@@ -178,6 +172,11 @@ if (window.self !== window.top) {
}
}
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
const shareableLinkConfirmDialog = {
title: t("overwriteConfirm.modal.shareableLink.title"),
description: (
@@ -323,15 +322,19 @@ const initializeScene = async (opts: {
return { scene: null, isExternalScene: false };
};
const detectedLangCode = languageDetector.detect() || defaultLang.code;
export const appLangCodeAtom = atom(
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
);
const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState("");
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
const isCollabDisabled = isRunningInIframe();
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const { editorTheme } = useHandleAppTheme();
const [langCode, setLangCode] = useAppLangCode();
// initial state
// ---------------------------------------------------------------------------
@@ -343,8 +346,6 @@ 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
@@ -370,23 +371,6 @@ 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;
@@ -506,7 +490,11 @@ const ExcalidrawWrapper = () => {
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
const localDataState = importFromLocalStorage();
const username = importUsernameFromLocalStorage();
setLangCode(getPreferredLanguage());
let langCode = languageDetector.detect() || defaultLang.code;
if (Array.isArray(langCode)) {
langCode = langCode[0];
}
setLangCode(langCode);
excalidrawAPI.updateScene({
...localDataState,
storeAction: StoreAction.UPDATE,
@@ -607,6 +595,10 @@ const ExcalidrawWrapper = () => {
};
}, [excalidrawAPI]);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
const onChange = (
elements: readonly OrderedExcalidrawElement[],
appState: AppState,
@@ -647,16 +639,6 @@ 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>(
@@ -855,7 +837,6 @@ const ExcalidrawWrapper = () => {
isCollabEnabled={!isCollabDisabled}
theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
refresh={() => forceRefresh((prev) => !prev)}
/>
<AppWelcomeScreen
onCollabDialogOpen={onCollabDialogOpen}
@@ -881,9 +862,64 @@ const ExcalidrawWrapper = () => {
</OverwriteConfirmDialog.Action>
)}
</OverwriteConfirmDialog>
<AppFooter onChange={() => excalidrawAPI?.refresh()} />
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
<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...");
}
return { generatedResponse, rateLimit, rateLimitRemaining };
} catch (err: any) {
throw new Error("Request failed");
}
}}
/>
<TTDDialogTrigger />
{isCollaborating && isOffline && (
<div className="collab-offline-warning">
@@ -1113,25 +1149,12 @@ const ExcalidrawWrapper = () => {
},
]}
/>
{isVisualDebuggerEnabled() && excalidrawAPI && (
<DebugCanvas
appState={excalidrawAPI.getAppState()}
scale={window.devicePixelRatio}
ref={debugCanvasRef}
/>
)}
</Excalidraw>
</div>
);
};
const ExcalidrawApp = () => {
const isCloudExportWindow =
window.location.pathname === "/excalidraw-plus-export";
if (isCloudExportWindow) {
return <ExcalidrawPlusIframeExport />;
}
return (
<TopErrorBoundary>
<Provider unstable_createStore={() => appJotaiStore}>

View File

@@ -9,7 +9,6 @@ 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 };
@@ -52,33 +51,39 @@ const CustomStats = (props: Props) => {
}
return (
<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>
<>
<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>
</>
);
};

View File

@@ -1,222 +0,0 @@
import { useLayoutEffect, useRef } from "react";
import { STORAGE_KEYS } from "./app_constants";
import { LocalData } from "./data/LocalData";
import type {
FileId,
OrderedExcalidrawElement,
} from "../packages/excalidraw/element/types";
import type { AppState, BinaryFileData } from "../packages/excalidraw/types";
import { ExcalidrawError } from "../packages/excalidraw/errors";
import { base64urlToString } from "../packages/excalidraw/data/encode";
const EVENT_REQUEST_SCENE = "REQUEST_SCENE";
const EXCALIDRAW_PLUS_ORIGIN = import.meta.env.VITE_APP_PLUS_APP;
// -----------------------------------------------------------------------------
// outgoing message
// -----------------------------------------------------------------------------
type MESSAGE_REQUEST_SCENE = {
type: "REQUEST_SCENE";
jwt: string;
};
type MESSAGE_FROM_PLUS = MESSAGE_REQUEST_SCENE;
// incoming messages
// -----------------------------------------------------------------------------
type MESSAGE_READY = { type: "READY" };
type MESSAGE_ERROR = { type: "ERROR"; message: string };
type MESSAGE_SCENE_DATA = {
type: "SCENE_DATA";
elements: OrderedExcalidrawElement[];
appState: Pick<AppState, "viewBackgroundColor">;
files: { loadedFiles: BinaryFileData[]; erroredFiles: Map<FileId, true> };
};
type MESSAGE_FROM_EDITOR = MESSAGE_ERROR | MESSAGE_SCENE_DATA | MESSAGE_READY;
// -----------------------------------------------------------------------------
const parseSceneData = async ({
rawElementsString,
rawAppStateString,
}: {
rawElementsString: string | null;
rawAppStateString: string | null;
}): Promise<MESSAGE_SCENE_DATA> => {
if (!rawElementsString || !rawAppStateString) {
throw new ExcalidrawError("Elements or appstate is missing.");
}
try {
const elements = JSON.parse(
rawElementsString,
) as OrderedExcalidrawElement[];
if (!elements.length) {
throw new ExcalidrawError("Scene is empty, nothing to export.");
}
const appState = JSON.parse(rawAppStateString) as Pick<
AppState,
"viewBackgroundColor"
>;
const fileIds = elements.reduce((acc, el) => {
if ("fileId" in el && el.fileId) {
acc.push(el.fileId);
}
return acc;
}, [] as FileId[]);
const files = await LocalData.fileStorage.getFiles(fileIds);
return {
type: "SCENE_DATA",
elements,
appState,
files,
};
} catch (error: any) {
throw error instanceof ExcalidrawError
? error
: new ExcalidrawError("Failed to parse scene data.");
}
};
const verifyJWT = async ({
token,
publicKey,
}: {
token: string;
publicKey: string;
}) => {
try {
if (!publicKey) {
throw new ExcalidrawError("Public key is undefined");
}
const [header, payload, signature] = token.split(".");
if (!header || !payload || !signature) {
throw new ExcalidrawError("Invalid JWT format");
}
// JWT is using Base64URL encoding
const decodedPayload = base64urlToString(payload);
const decodedSignature = base64urlToString(signature);
const data = `${header}.${payload}`;
const signatureArrayBuffer = Uint8Array.from(decodedSignature, (c) =>
c.charCodeAt(0),
);
const keyData = publicKey.replace(/-----\w+ PUBLIC KEY-----/g, "");
const keyArrayBuffer = Uint8Array.from(atob(keyData), (c) =>
c.charCodeAt(0),
);
const key = await crypto.subtle.importKey(
"spki",
keyArrayBuffer,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
true,
["verify"],
);
const isValid = await crypto.subtle.verify(
"RSASSA-PKCS1-v1_5",
key,
signatureArrayBuffer,
new TextEncoder().encode(data),
);
if (!isValid) {
throw new Error("Invalid JWT");
}
const parsedPayload = JSON.parse(decodedPayload);
// Check for expiration
const currentTime = Math.floor(Date.now() / 1000);
if (parsedPayload.exp && parsedPayload.exp < currentTime) {
throw new Error("JWT has expired");
}
} catch (error) {
console.error("Failed to verify JWT:", error);
throw new Error(error instanceof Error ? error.message : "Invalid JWT");
}
};
export const ExcalidrawPlusIframeExport = () => {
const readyRef = useRef(false);
useLayoutEffect(() => {
const handleMessage = async (event: MessageEvent<MESSAGE_FROM_PLUS>) => {
if (event.origin !== EXCALIDRAW_PLUS_ORIGIN) {
throw new ExcalidrawError("Invalid origin");
}
if (event.data.type === EVENT_REQUEST_SCENE) {
if (!event.data.jwt) {
throw new ExcalidrawError("JWT is missing");
}
try {
try {
await verifyJWT({
token: event.data.jwt,
publicKey: import.meta.env.VITE_APP_PLUS_EXPORT_PUBLIC_KEY,
});
} catch (error: any) {
console.error(`Failed to verify JWT: ${error.message}`);
throw new ExcalidrawError("Failed to verify JWT");
}
const parsedSceneData: MESSAGE_SCENE_DATA = await parseSceneData({
rawAppStateString: localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
),
rawElementsString: localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
),
});
event.source!.postMessage(parsedSceneData, {
targetOrigin: EXCALIDRAW_PLUS_ORIGIN,
});
} catch (error) {
const responseData: MESSAGE_ERROR = {
type: "ERROR",
message:
error instanceof ExcalidrawError
? error.message
: "Failed to export scene data",
};
event.source!.postMessage(responseData, {
targetOrigin: EXCALIDRAW_PLUS_ORIGIN,
});
}
}
};
window.addEventListener("message", handleMessage);
// so we don't send twice in StrictMode
if (!readyRef.current) {
readyRef.current = true;
const message: MESSAGE_FROM_EDITOR = { type: "READY" };
window.parent.postMessage(message, EXCALIDRAW_PLUS_ORIGIN);
}
return () => {
window.removeEventListener("message", handleMessage);
};
}, []);
// Since this component is expected to run in a hidden iframe on Excaildraw+,
// it doesn't need to render anything. All the data we need is available in
// LocalStorage and IndexedDB. It only needs to handle the messaging between
// the parent window and the iframe with the relevant data.
return null;
};

View File

@@ -1,25 +0,0 @@
import LanguageDetector from "i18next-browser-languagedetector";
import { defaultLang, languages } from "../../packages/excalidraw";
export const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
export const getPreferredLanguage = () => {
const detectedLanguages = languageDetector.detect();
const detectedLanguage = Array.isArray(detectedLanguages)
? detectedLanguages[0]
: detectedLanguages;
const initialLanguage =
(detectedLanguage
? // region code may not be defined if user uses generic preferred language
// (e.g. chinese vs instead of chinese-simplified)
languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code
: null) || defaultLang.code;
return initialLanguage;
};

View File

@@ -1,15 +0,0 @@
import { atom, useAtom } from "jotai";
import { useEffect } from "react";
import { getPreferredLanguage, languageDetector } from "./language-detector";
export const appLangCodeAtom = atom(getPreferredLanguage());
export const useAppLangCode = () => {
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
return [langCode, setLangCode] as const;
};

View File

@@ -40,7 +40,6 @@ 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",

View File

@@ -1,7 +1,6 @@
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import type {
BinaryFileData,
ExcalidrawImperativeAPI,
SocketId,
} from "../../packages/excalidraw/types";
@@ -10,7 +9,6 @@ import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import type {
ExcalidrawElement,
FileId,
InitializedExcalidrawImageElement,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
@@ -159,7 +157,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
throw new AbortError();
}
const { savedFiles, erroredFiles } = await saveFilesToFirebase({
return saveFilesToFirebase({
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
files: await encodeFilesForUpload({
files: addedFiles,
@@ -167,29 +165,6 @@ class Collab extends PureComponent<CollabProps, CollabState> {
maxBytes: FILE_UPLOAD_MAX_BYTES,
}),
});
return {
savedFiles: savedFiles.reduce(
(acc: Map<FileId, BinaryFileData>, id) => {
const fileData = addedFiles.get(id);
if (fileData) {
acc.set(id, fileData);
}
return acc;
},
new Map(),
),
erroredFiles: erroredFiles.reduce(
(acc: Map<FileId, BinaryFileData>, id) => {
const fileData = addedFiles.get(id);
if (fileData) {
acc.set(id, fileData);
}
return acc;
},
new Map(),
),
};
},
});
this.excalidrawAPI = props.excalidrawAPI;
@@ -419,7 +394,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
.filter((element) => {
return (
isInitializedImageElement(element) &&
!this.fileManager.isFileTracked(element.fileId) &&
!this.fileManager.isFileHandled(element.fileId) &&
!element.isDeleted &&
(opts.forceFetchFiles
? element.status !== "pending" ||

View File

@@ -116,26 +116,20 @@ class Portal {
}
}
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,
});
}
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,
});
}, FILE_UPLOAD_TIMEOUT);
broadcastScene = async (

View File

@@ -0,0 +1,218 @@
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;

View File

@@ -1,159 +0,0 @@
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");
}
}}
/>
</>
);
};

View File

@@ -3,27 +3,23 @@ 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(
({ onChange }: { onChange: () => void }) => {
return (
<Footer>
<div
style={{
display: "flex",
gap: ".5rem",
alignItems: "center",
}}
>
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
{isExcalidrawPlusSignedUser ? (
<ExcalidrawPlusAppLink />
) : (
<EncryptedIcon />
)}
</div>
</Footer>
);
},
);
export const AppFooter = React.memo(() => {
return (
<Footer>
<div
style={{
display: "flex",
gap: ".5rem",
alignItems: "center",
}}
>
{isExcalidrawPlusSignedUser ? (
<ExcalidrawPlusAppLink />
) : (
<EncryptedIcon />
)}
</div>
</Footer>
);
});

View File

@@ -2,13 +2,11 @@ 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";
import { LanguageList } from "./LanguageList";
export const AppMainMenu: React.FC<{
onCollabDialogOpen: () => any;
@@ -16,7 +14,6 @@ export const AppMainMenu: React.FC<{
isCollabEnabled: boolean;
theme: Theme | "system";
setTheme: (theme: Theme | "system") => void;
refresh: () => void;
}> = React.memo((props) => {
return (
<MainMenu>
@@ -31,7 +28,6 @@ export const AppMainMenu: React.FC<{
/>
)}
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
@@ -54,23 +50,6 @@ 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

View File

@@ -1,311 +0,0 @@
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;

View File

@@ -1,7 +1,8 @@
import { useSetAtom } from "jotai";
import React from "react";
import { useI18n, languages } from "../../packages/excalidraw/i18n";
import { appLangCodeAtom } from "./language-state";
import { appLangCodeAtom } from "../App";
import { useI18n } from "../../packages/excalidraw/i18n";
import { languages } from "../../packages/excalidraw/i18n";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
const { t, langCode } = useI18n();

View File

@@ -16,26 +16,14 @@ import type {
BinaryFiles,
} from "../../packages/excalidraw/types";
type FileVersion = Required<BinaryFileData>["version"];
export class FileManager {
/** files being fetched */
private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private erroredFiles_fetch = new Map<
ExcalidrawImageElement["fileId"],
true
>();
/** files being saved */
private savingFiles = new Map<
ExcalidrawImageElement["fileId"],
FileVersion
>();
private savingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
/* files already saved to persistent storage */
private savedFiles = new Map<ExcalidrawImageElement["fileId"], FileVersion>();
private erroredFiles_save = new Map<
ExcalidrawImageElement["fileId"],
FileVersion
>();
private savedFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private erroredFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private _getFiles;
private _saveFiles;
@@ -49,8 +37,8 @@ export class FileManager {
erroredFiles: Map<FileId, true>;
}>;
saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{
savedFiles: Map<FileId, BinaryFileData>;
erroredFiles: Map<FileId, BinaryFileData>;
savedFiles: Map<FileId, true>;
erroredFiles: Map<FileId, true>;
}>;
}) {
this._getFiles = getFiles;
@@ -58,28 +46,19 @@ export class FileManager {
}
/**
* returns whether file is saved/errored, or being processed
* returns whether file is already saved or being processed
*/
isFileTracked = (id: FileId) => {
isFileHandled = (id: FileId) => {
return (
this.savedFiles.has(id) ||
this.savingFiles.has(id) ||
this.fetchingFiles.has(id) ||
this.erroredFiles_fetch.has(id) ||
this.erroredFiles_save.has(id)
this.savingFiles.has(id) ||
this.erroredFiles.has(id)
);
};
isFileSavedOrBeingSaved = (file: BinaryFileData) => {
const fileVersion = this.getFileVersion(file);
return (
this.savedFiles.get(file.id) === fileVersion ||
this.savingFiles.get(file.id) === fileVersion
);
};
getFileVersion = (file: BinaryFileData) => {
return file.version ?? 1;
isFileSaved = (id: FileId) => {
return this.savedFiles.has(id);
};
saveFiles = async ({
@@ -92,16 +71,13 @@ export class FileManager {
const addedFiles: Map<FileId, BinaryFileData> = new Map();
for (const element of elements) {
const fileData =
isInitializedImageElement(element) && files[element.fileId];
if (
fileData &&
// NOTE if errored during save, won't retry due to this check
!this.isFileSavedOrBeingSaved(fileData)
isInitializedImageElement(element) &&
files[element.fileId] &&
!this.isFileHandled(element.fileId)
) {
addedFiles.set(element.fileId, files[element.fileId]);
this.savingFiles.set(element.fileId, this.getFileVersion(fileData));
this.savingFiles.set(element.fileId, true);
}
}
@@ -110,12 +86,8 @@ export class FileManager {
addedFiles,
});
for (const [fileId, fileData] of savedFiles) {
this.savedFiles.set(fileId, this.getFileVersion(fileData));
}
for (const [fileId, fileData] of erroredFiles) {
this.erroredFiles_save.set(fileId, this.getFileVersion(fileData));
for (const [fileId] of savedFiles) {
this.savedFiles.set(fileId, true);
}
return {
@@ -149,10 +121,10 @@ export class FileManager {
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
for (const file of loadedFiles) {
this.savedFiles.set(file.id, this.getFileVersion(file));
this.savedFiles.set(file.id, true);
}
for (const [fileId] of erroredFiles) {
this.erroredFiles_fetch.set(fileId, true);
this.erroredFiles.set(fileId, true);
}
return { loadedFiles, erroredFiles };
@@ -188,7 +160,7 @@ export class FileManager {
): element is InitializedExcalidrawImageElement => {
return (
isInitializedImageElement(element) &&
this.savedFiles.has(element.fileId) &&
this.isFileSaved(element.fileId) &&
element.status === "pending"
);
};
@@ -197,8 +169,7 @@ export class FileManager {
this.fetchingFiles.clear();
this.savingFiles.clear();
this.savedFiles.clear();
this.erroredFiles_fetch.clear();
this.erroredFiles_save.clear();
this.erroredFiles.clear();
}
}

View File

@@ -20,10 +20,6 @@ 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";
@@ -70,22 +66,13 @@ 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(_appState),
JSON.stringify(clearAppStateForLocalStorage(appState)),
);
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
} catch (error: any) {
@@ -183,8 +170,8 @@ export class LocalData {
);
},
async saveFiles({ addedFiles }) {
const savedFiles = new Map<FileId, BinaryFileData>();
const erroredFiles = new Map<FileId, BinaryFileData>();
const savedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, true>();
// before we use `storage` event synchronization, let's update the flag
// optimistically. Hopefully nothing fails, and an IDB read executed
@@ -195,10 +182,10 @@ export class LocalData {
[...addedFiles].map(async ([id, fileData]) => {
try {
await set(id, fileData, filesStore);
savedFiles.set(id, fileData);
savedFiles.set(id, true);
} catch (error: any) {
console.error(error);
erroredFiles.set(id, fileData);
erroredFiles.set(id, true);
}
}),
);

View File

@@ -177,8 +177,8 @@ export const saveFilesToFirebase = async ({
}) => {
const firebase = await loadFirebaseStorage();
const erroredFiles: FileId[] = [];
const savedFiles: FileId[] = [];
const erroredFiles = new Map<FileId, true>();
const savedFiles = new Map<FileId, true>();
await Promise.all(
files.map(async ({ id, buffer }) => {
@@ -194,9 +194,9 @@ export const saveFilesToFirebase = async ({
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
},
);
savedFiles.push(id);
savedFiles.set(id, true);
} catch (error: any) {
erroredFiles.push(id);
erroredFiles.set(id, true);
}
}),
);

View File

@@ -54,6 +54,12 @@
content="https://excalidraw.com/og-image-3.png"
/>
<!-- General tags -->
<meta
name="description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<!------------------------------------------------------------------------->
<!-- to minimize white flash on load when user has dark mode enabled -->
<script>
@@ -89,11 +95,6 @@
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,21 +115,6 @@
window.location.href = "https://app.excalidraw.com";
}
</script>
<!-- Following placeholder is replaced during the build step -->
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
<!-- Register Assistant as the UI font, before the scene inits -->
<link
rel="stylesheet"
href="../packages/excalidraw/fonts/fonts.css"
type="text/css"
/>
<% } else { %>
<script>
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script>
<% } %>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
@@ -138,6 +124,22 @@
<!-- Excalidraw version -->
<meta name="version" content="{version}" />
<link
rel="preload"
href="/Virgil.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="/Cascadia.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
<script>
@@ -156,6 +158,7 @@
</script>
<% } %>
<script>
window.EXCALIDRAW_ASSET_PATH = "/";
// setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw";
</script>
@@ -212,7 +215,6 @@
</header>
<div id="root"></div>
<script type="module" src="index.tsx"></script>
<% if (typeof PROD != 'undefined' && PROD == true) { %>
<!-- 100% privacy friendly analytics -->
<script>
// need to load this script dynamically bcs. of iframe embed tracking
@@ -245,6 +247,5 @@
}
</script>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
</body>
</html>

View File

@@ -23,34 +23,20 @@
]
},
"engines": {
"node": "18.0.0 - 22.x.x"
"node": ">=18.0.0"
},
"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",
"react": "18.2.0",
"react-dom": "18.2.0",
"socket.io-client": "4.7.2",
"vite-plugin-html": "3.2.2"
},
"prettier": "@excalidraw/prettier-config",
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
"build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version",
"start": "yarn && vite",
"start:production": "yarn build && yarn serve",
"serve": "npx http-server build -a localhost -p 5001 -o",
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000"
},
"devDependencies": {
"vite-plugin-sitemap": "0.7.1"
}
}

View File

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

View File

@@ -1,4 +1,5 @@
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";
@@ -13,6 +14,7 @@ import {
share,
shareIOS,
shareWindows,
tablerCheckIcon,
} from "../../packages/excalidraw/components/icons";
import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
@@ -22,7 +24,6 @@ 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";
@@ -62,11 +63,10 @@ const ActiveRoomDialog = ({
handleClose: () => void;
}) => {
const { t } = useI18n();
const [, setJustCopied] = useState(false);
const [justCopied, 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,16 +130,26 @@ const ActiveRoomDialog = ({
onClick={shareRoomLink}
/>
)}
<FilledButton
size="large"
label={t("buttons.copyLink")}
icon={copyIcon}
status={copyStatus}
onClick={() => {
copyRoomLink();
onCopy();
}}
/>
<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>
</div>
<div className="ShareDialog__active__description">
<p>

View File

@@ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
class="welcome-screen-center"
>
<div
class="welcome-screen-center__logo excalifont welcome-screen-decor"
class="welcome-screen-center__logo virgil welcome-screen-decor"
>
<div
class="ExcalidrawLogo is-small"
@@ -48,7 +48,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
</div>
</div>
<div
class="welcome-screen-center__heading welcome-screen-decor excalifont"
class="welcome-screen-center__heading welcome-screen-decor virgil"
>
All your data is saved locally in your browser.
</div>

View File

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

View File

@@ -29,9 +29,6 @@ interface ImportMetaEnv {
// Enable eslint in dev server
VITE_APP_ENABLE_ESLINT: string;
// Enable PWA in dev server
VITE_APP_ENABLE_PWA: string;
VITE_APP_PLUS_LP: string;
VITE_APP_PLUS_APP: string;

View File

@@ -5,237 +5,194 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker";
import { createHtmlPlugin } from "vite-plugin-html";
import Sitemap from "vite-plugin-sitemap";
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
export default defineConfig(({ mode }) => {
// To load .env variables
const envVars = loadEnv(mode, `../`);
// https://vitejs.dev/config/
return {
server: {
port: Number(envVars.VITE_APP_PORT || 3000),
// open the browser
open: true,
},
// We need to specify the envDir since now there are no
//more located in parallel with the vite.config.ts file but in parent dir
envDir: "../",
build: {
outDir: "build",
rollupOptions: {
output: {
assetFileNames(chunkInfo) {
if (chunkInfo?.name?.endsWith(".woff2")) {
const family = chunkInfo.name.split("-")[0];
return `fonts/${family}/[name][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
// app precache. en.json and percentages.json are needed for first load
// or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too.
manualChunks(id) {
if (
id.includes("packages/excalidraw/locales") &&
id.match(/en.json|percentages.json/) === null
) {
const index = id.indexOf("locales/");
// Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`;
}
},
// To load .env.local variables
const envVars = loadEnv("", `../`);
// https://vitejs.dev/config/
export default defineConfig({
server: {
port: Number(envVars.VITE_APP_PORT || 3000),
// open the browser
open: true,
},
// We need to specify the envDir since now there are no
//more located in parallel with the vite.config.ts file but in parent dir
envDir: "../",
build: {
outDir: "build",
rollupOptions: {
output: {
// Creating separate chunk for locales except for en and percentages.json so they
// can be cached at runtime and not merged with
// app precache. en.json and percentages.json are needed for first load
// or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too.
manualChunks(id) {
if (
id.includes("packages/excalidraw/locales") &&
id.match(/en.json|percentages.json/) === null
) {
const index = id.indexOf("locales/");
// 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: [
Sitemap({
hostname: "https://excalidraw.com",
outDir: "build",
changefreq: "monthly",
// its static in public folder
generateRobotsTxt: false,
}),
woff2BrowserPlugin(),
react(),
checker({
typescript: true,
eslint:
envVars.VITE_APP_ENABLE_ESLINT === "false"
? undefined
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
overlay: {
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
},
}),
svgrPlugin(),
ViteEjsPlugin(),
VitePWA({
registerType: "autoUpdate",
devOptions: {
/* set this flag to true to enable in Development mode */
enabled: envVars.VITE_APP_ENABLE_PWA === "true",
},
sourcemap: true,
},
plugins: [
react(),
checker({
typescript: true,
eslint:
envVars.VITE_APP_ENABLE_ESLINT === "false"
? undefined
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
overlay: {
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
},
}),
svgrPlugin(),
ViteEjsPlugin(),
VitePWA({
registerType: "autoUpdate",
devOptions: {
/* set this flag to true to enable in Development mode */
enabled: false,
},
workbox: {
// don't precache fonts, locales and separate chunks
globIgnores: [
"fonts.css",
"**/locales/**",
"service-worker.js",
"**/*.chunk-*.js",
],
runtimeCaching: [
{
urlPattern: new RegExp(".+.woff2"),
handler: "CacheFirst",
options: {
cacheName: "fonts",
expiration: {
maxEntries: 1000,
maxAgeSeconds: 60 * 60 * 24 * 90, // 90 days
},
cacheableResponse: {
// 0 to cache "opaque" responses from cross-origin requests (i.e. CDN)
statuses: [0, 200],
},
workbox: {
// Don't push fonts and locales to app precache
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
runtimeCaching: [
{
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
handler: "CacheFirst",
options: {
cacheName: "fonts",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
},
},
{
urlPattern: new RegExp("fonts.css"),
handler: "StaleWhileRevalidate",
options: {
cacheName: "fonts",
expiration: {
maxEntries: 50,
},
},
},
{
urlPattern: new RegExp("locales/[^/]+.js"),
handler: "CacheFirst",
options: {
cacheName: "locales",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
},
},
},
{
urlPattern: new RegExp(".chunk-.+.js"),
handler: "CacheFirst",
options: {
cacheName: "chunk",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
},
},
},
],
},
manifest: {
short_name: "Excalidraw",
name: "Excalidraw",
description:
"Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.",
icons: [
{
src: "android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "apple-touch-icon.png",
type: "image/png",
sizes: "180x180",
},
{
src: "favicon-32x32.png",
sizes: "32x32",
type: "image/png",
},
{
src: "favicon-16x16.png",
sizes: "16x16",
type: "image/png",
},
],
start_url: "/",
id:"excalidraw",
display: "standalone",
theme_color: "#121212",
background_color: "#ffffff",
file_handlers: [
{
action: "/",
accept: {
"application/vnd.excalidraw+json": [".excalidraw"],
},
},
],
share_target: {
action: "/web-share-target",
method: "POST",
enctype: "multipart/form-data",
params: {
files: [
{
name: "file",
accept: [
"application/vnd.excalidraw+json",
"application/json",
".excalidraw",
],
},
],
},
},
screenshots: [
{
src: "/screenshots/virtual-whiteboard.png",
type: "image/png",
sizes: "462x945",
{
urlPattern: new RegExp("fonts.css"),
handler: "StaleWhileRevalidate",
options: {
cacheName: "fonts",
expiration: {
maxEntries: 50,
},
},
{
src: "/screenshots/wireframe.png",
type: "image/png",
sizes: "462x945",
},
{
urlPattern: new RegExp("locales/[^/]+.js"),
handler: "CacheFirst",
options: {
cacheName: "locales",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
},
},
{
src: "/screenshots/illustration.png",
type: "image/png",
sizes: "462x945",
},
],
},
manifest: {
short_name: "Excalidraw",
name: "Excalidraw",
description:
"Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.",
icons: [
{
src: "android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "apple-touch-icon.png",
type: "image/png",
sizes: "180x180",
},
{
src: "favicon-32x32.png",
sizes: "32x32",
type: "image/png",
},
{
src: "favicon-16x16.png",
sizes: "16x16",
type: "image/png",
},
],
start_url: "/",
display: "standalone",
theme_color: "#121212",
background_color: "#ffffff",
file_handlers: [
{
action: "/",
accept: {
"application/vnd.excalidraw+json": [".excalidraw"],
},
{
src: "/screenshots/shapes.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/collaboration.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/export.png",
type: "image/png",
sizes: "462x945",
},
],
},
],
share_target: {
action: "/web-share-target",
method: "POST",
enctype: "multipart/form-data",
params: {
files: [
{
name: "file",
accept: [
"application/vnd.excalidraw+json",
"application/json",
".excalidraw",
],
},
],
},
},
}),
createHtmlPlugin({
minify: true,
}),
],
publicDir: "../public",
};
screenshots: [
{
src: "/screenshots/virtual-whiteboard.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/wireframe.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/illustration.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/shapes.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/collaboration.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/export.png",
type: "image/png",
sizes: "462x945",
},
],
},
}),
createHtmlPlugin({
minify: true,
}),
],
publicDir: "../public",
});

View File

@@ -6,12 +6,23 @@
"excalidraw-app",
"packages/excalidraw",
"packages/utils",
"packages/math",
"examples/excalidraw",
"examples/excalidraw/*"
],
"dependencies": {
"@excalidraw/random-username": "1.0.0",
"@imgly/background-removal": "1.5.3",
"@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",
"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",
@@ -21,8 +32,8 @@
"@types/react-dom": "18.2.0",
"@types/socket.io-client": "3.0.0",
"@vitejs/plugin-react": "3.1.0",
"@vitest/coverage-v8": "2.0.5",
"@vitest/ui": "2.0.5",
"@vitest/coverage-v8": "0.33.0",
"@vitest/ui": "0.32.2",
"chai": "4.3.6",
"dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0",
@@ -37,15 +48,15 @@
"rewire": "6.0.0",
"typescript": "4.9.4",
"vite": "5.0.12",
"vite-plugin-checker": "0.7.2",
"vite-plugin-checker": "0.6.1",
"vite-plugin-ejs": "1.7.0",
"vite-plugin-pwa": "0.17.4",
"vite-plugin-svgr": "4.2.0",
"vitest": "2.0.5",
"vitest-canvas-mock": "0.3.3"
"vite-plugin-svgr": "2.4.0",
"vitest": "1.5.3",
"vitest-canvas-mock": "0.3.2"
},
"engines": {
"node": "18.0.0 - 22.x.x"
"node": "18.0.0 - 20.x.x"
},
"homepage": ".",
"prettier": "@excalidraw/prettier-config",
@@ -55,9 +66,15 @@
"build:app": "yarn --cwd ./excalidraw-app build:app",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
"fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write",
"fix": "yarn fix:other && yarn fix:code",
"locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "yarn --cwd ./excalidraw-app start",
"start:production": "yarn --cwd ./excalidraw-app start:production",
"start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
@@ -68,22 +85,9 @@
"test:coverage": "vitest --coverage",
"test:coverage:watch": "vitest --coverage --watch",
"test:ui": "yarn test --ui --coverage.enabled=true",
"fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write",
"fix": "yarn fix:other && yarn fix:code",
"locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.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"
"build:preview": "yarn build && vite preview --port 5000",
"release:excalidraw": "node scripts/release.js"
}
}

View File

@@ -15,16 +15,10 @@ Please add the latest change on the top under the correct section.
### Features
- Added hand-drawn font for Chinese, Japanese and Korean (CJK) as a fallback for Excalifont. Improved overal text wrapping algorithm, not only accounting for CJK, but covering various edge cases with white spaces and text-align center/right. Added support for multi-codepoint emojis wrapping. Offloaded SVG export to Web Workers, with an automatic fallback to the main thread if not supported or not desired.[#8530](https://github.com/excalidraw/excalidraw/pull/8530)
- 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)
- Added font picker component to have the ability to choose from a range of different fonts. Also, changed the default fonts to `Excalifont`, `Nunito` and `Comic Shanns` and deprecated `Virgil`, `Helvetica` and `Cascadia`.
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
@@ -43,8 +37,6 @@ 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 |

View File

@@ -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, Offsets } from "../types";
import type { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
@@ -38,7 +38,6 @@ 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",
@@ -105,8 +104,6 @@ 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:
@@ -247,7 +244,6 @@ 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;
@@ -255,89 +251,92 @@ const zoomValueToFitBoundsOnViewport = (
const commonBoundsHeight = y2 - y1;
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
const adjustedZoomValue =
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
return Math.min(adjustedZoomValue, 1);
const zoomAdjustedToSteps =
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
const clampedZoomValueToFitElements = Math.min(
Math.max(zoomAdjustedToSteps, MIN_ZOOM),
1,
);
return clampedZoomValueToFitElements as NormalizedZoomValue;
};
export const zoomToFitBounds = ({
bounds,
appState,
canvasOffsets,
fitToViewport = false,
viewportZoomFactor = 1,
minZoom = -Infinity,
maxZoom = Infinity,
viewportZoomFactor = 0.7,
}: {
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;
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;
let newZoomValue;
let scrollX;
let scrollY;
if (fitToViewport) {
const commonBoundsWidth = x2 - x1;
const commonBoundsHeight = y2 - y1;
adjustedZoomValue =
newZoomValue =
Math.min(
effectiveCanvasWidth / commonBoundsWidth,
effectiveCanvasHeight / commonBoundsHeight,
) * viewportZoomFactor;
appState.width / commonBoundsWidth,
appState.height / commonBoundsHeight,
) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
// Apply clamping to newZoomValue to be between 10% and 3000%
newZoomValue = Math.min(
Math.max(newZoomValue, MIN_ZOOM),
MAX_ZOOM,
) as NormalizedZoomValue;
let appStateWidth = appState.width;
if (appState.openSidebar) {
const sidebarDOMElem = document.querySelector(
".sidebar",
) as HTMLElement | null;
const sidebarWidth = sidebarDOMElem?.offsetWidth ?? 0;
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
appStateWidth = !isRTL
? appState.width - sidebarWidth
: appState.width + sidebarWidth;
}
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
} else {
adjustedZoomValue = zoomValueToFitBoundsOnViewport(
bounds,
{
width: effectiveCanvasWidth,
height: effectiveCanvasHeight,
},
viewportZoomFactor,
);
}
const newZoomValue = getNormalizedZoom(
clamp(roundToStep(adjustedZoomValue, ZOOM_STEP, "floor"), minZoom, maxZoom),
);
const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
newZoomValue = zoomValueToFitBoundsOnViewport(bounds, {
width: appState.width,
height: appState.height,
},
offsets: canvasOffsets,
zoom: { value: newZoomValue },
});
});
const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: { value: newZoomValue },
});
scrollX = centerScroll.scrollX;
scrollY = centerScroll.scrollY;
}
return {
appState: {
...appState,
scrollX: centerScroll.scrollX,
scrollY: centerScroll.scrollY,
scrollX,
scrollY,
zoom: { value: newZoomValue },
},
storeAction: StoreAction.NONE,
@@ -345,34 +344,25 @@ 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,
});
};
@@ -393,7 +383,6 @@ export const actionZoomToFitSelectionInViewport = register({
userToFollow: null,
},
fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(),
});
},
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
@@ -419,7 +408,6 @@ export const actionZoomToFitSelection = register({
userToFollow: null,
},
fitToViewport: true,
canvasOffsets: app.getEditorUIOffsets(),
});
},
// NOTE this action should use shift-2 per figma, alas
@@ -436,7 +424,7 @@ export const actionZoomToFit = register({
icon: zoomAreaIcon,
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) =>
perform: (elements, appState) =>
zoomToFit({
targetElements: elements,
appState: {
@@ -444,7 +432,6 @@ export const actionZoomToFit = register({
userToFollow: null,
},
fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(),
}),
keyTest: (event) =>
event.code === CODES.ONE &&

View File

@@ -10,7 +10,7 @@ import {
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { exportCanvas, prepareElementsForExport } from "../data/index";
import { getTextFromElements, isTextElement } from "../element";
import { isTextElement } from "../element";
import { t } from "../i18n";
import { isFirefox } from "../constants";
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
@@ -147,32 +147,14 @@ export const actionCopyAsSvg = register({
name: app.getName(),
},
);
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
return {
appState: {
toast: {
message: t("toast.copyToClipboardAsSvg", {
exportSelection: selectedElements.length
? t("toast.selection")
: t("toast.canvas"),
exportColorScheme: appState.exportWithDarkMode
? t("buttons.darkMode")
: t("buttons.lightMode"),
}),
},
},
storeAction: StoreAction.NONE,
};
} catch (error: any) {
console.error(error);
return {
appState: {
...appState,
errorMessage: error.message,
},
storeAction: StoreAction.NONE,
@@ -257,8 +239,16 @@ 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(getTextFromElements(selectedElements));
copyTextToSystemClipboard(text);
} catch (e) {
throw new Error(t("errors.copyToSystemClipboardFailed"));
}

View File

@@ -1,55 +0,0 @@
import { register } from "./register";
import { cropIcon } from "../components/icons";
import { StoreAction } from "../store";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { isImageElement } from "../element/typeChecks";
import type { ExcalidrawImageElement } from "../element/types";
export const actionToggleCropEditor = register({
name: "cropEditor",
label: "helpDialog.cropStart",
icon: cropIcon,
viewMode: true,
trackEvent: { category: "menu" },
keywords: ["image", "crop"],
perform(elements, appState, _, app) {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawImageElement;
return {
appState: {
...appState,
isCropping: false,
croppingElementId: selectedElement.id,
},
storeAction: StoreAction.CAPTURE,
};
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (
!appState.croppingElementId &&
selectedElements.length === 1 &&
isImageElement(selectedElements[0])
) {
return true;
}
return false;
},
PanelComponent: ({ appState, updateData, app }) => {
const label = t("helpDialog.cropStart");
return (
<ToolButton
type="button"
icon={cropIcon}
title={label}
aria-label={label}
onClick={() => updateData(null)}
/>
);
},
});

View File

@@ -5,27 +5,20 @@ import { t } from "../i18n";
import { register } from "./register";
import { getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types";
import type { AppClassProperties, AppState } from "../types";
import { mutateElement, newElementWith } from "../element/mutateElement";
import type { AppState } from "../types";
import { newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding";
import {
isBoundToContainer,
isElbowArrow,
isFrameLikeElement,
} from "../element/typeChecks";
import { isBoundToContainer, 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)),
@@ -36,26 +29,6 @@ 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 });
}
@@ -157,11 +130,7 @@ export const actionDeleteSelected = register({
: endBindingElement,
};
LinearElementEditor.deletePoints(
element,
selectedPointsIndices,
elementsMap,
);
LinearElementEditor.deletePoints(element, selectedPointsIndices);
return {
elements,
@@ -180,7 +149,7 @@ export const actionDeleteSelected = register({
};
}
let { elements: nextElements, appState: nextAppState } =
deleteSelectedElements(elements, appState, app);
deleteSelectedElements(elements, appState);
fixBindingsAfterDeletion(
nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]),

View File

@@ -15,7 +15,7 @@ import {
import type { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding";
import type { ActionResult } from "./types";
import { DEFAULT_GRID_SIZE } from "../constants";
import { 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) {
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
try {
const newAppState = LinearElementEditor.duplicateSelectedPoints(
appState,
app.scene.getNonDeletedElementsMap(),
);
const ret = LinearElementEditor.duplicateSelectedPoints(
appState,
elementsMap,
);
return {
elements,
appState: newAppState,
storeAction: StoreAction.CAPTURE,
};
} catch {
if (!ret) {
return false;
}
return {
elements,
appState: ret.appState,
storeAction: StoreAction.CAPTURE,
};
}
return {
@@ -100,8 +100,8 @@ const duplicateElements = (
groupIdMap,
element,
{
x: element.x + DEFAULT_GRID_SIZE / 2,
y: element.y + DEFAULT_GRID_SIZE / 2,
x: element.x + GRID_SIZE / 2,
y: element.y + GRID_SIZE / 2,
},
);
duplicatedElementsMap.set(newElement.id, newElement);

View File

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

View File

@@ -6,6 +6,7 @@ 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,
@@ -15,8 +16,6 @@ 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",
@@ -39,7 +38,6 @@ export const actionFinalize = register({
startBindingElement,
endBindingElement,
elementsMap,
scene,
);
}
return {
@@ -51,6 +49,7 @@ export const actionFinalize = register({
...appState,
cursorButton: "up",
editingLinearElement: null,
selectedLinearElement: null,
},
storeAction: StoreAction.CAPTURE,
};
@@ -73,8 +72,8 @@ export const actionFinalize = register({
const multiPointElement = appState.multiElement
? appState.multiElement
: appState.newElement?.type === "freedraw"
? appState.newElement
: appState.editingElement?.type === "freedraw"
? appState.editingElement
: null;
if (multiPointElement) {
@@ -113,10 +112,10 @@ export const actionFinalize = register({
const linePoints = multiPointElement.points;
const firstPoint = linePoints[0];
mutateElement(multiPointElement, {
points: linePoints.map((p, index) =>
points: linePoints.map((point, index) =>
index === linePoints.length - 1
? pointFrom(firstPoint[0], firstPoint[1])
: p,
? ([firstPoint[0], firstPoint[1]] as const)
: point,
),
});
}
@@ -132,13 +131,7 @@ export const actionFinalize = register({
-1,
arrayToMap(elements),
);
maybeBindLinearElement(
multiPointElement,
appState,
{ x, y },
elementsMap,
elements,
);
maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
}
}
@@ -176,10 +169,9 @@ export const actionFinalize = register({
? appState.activeTool
: activeTool,
activeEmbeddable: null,
newElement: null,
selectionElement: null,
draggingElement: null,
multiElement: null,
editingTextElement: null,
editingElement: null,
startBoundElement: null,
suggestedBindings: [],
selectedElementIds:
@@ -205,7 +197,7 @@ export const actionFinalize = register({
keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE &&
(appState.editingLinearElement !== null ||
(!appState.newElement && appState.multiElement === null))) ||
(!appState.draggingElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => (
@@ -217,7 +209,6 @@ export const actionFinalize = register({
onClick={updateData}
visible={appState.multiElement != null}
size={data?.size || "medium"}
style={{ pointerEvents: "all" }}
/>
),
});

View File

@@ -1,211 +0,0 @@
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);
});
});

View File

@@ -2,8 +2,6 @@ import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement,
NonDeleted,
NonDeletedSceneElementsMap,
@@ -20,13 +18,7 @@ import {
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { flipHorizontal, flipVertical } from "../components/icons";
import { StoreAction } from "../store";
import {
isArrowElement,
isElbowArrow,
isLinearElement,
} from "../element/typeChecks";
import { mutateElbowArrow } from "../element/routing";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { isLinearElement } from "../element/typeChecks";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
@@ -117,23 +109,7 @@ const flipElements = (
flipDirection: "horizontal" | "vertical",
app: AppClassProperties,
): ExcalidrawElement[] => {
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);
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
resizeMultipleElements(
elementsMap,
@@ -148,55 +124,10 @@ const flipElements = (
bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement),
elementsMap,
app.scene.getNonDeletedElements(),
app.scene,
app,
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;
};

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { AppClassProperties, AppState, Primitive } from "../types";
import type { StoreActionType } from "../store";
import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
@@ -11,7 +9,6 @@ import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { IconPicker } from "../components/IconPicker";
import { FontPicker } from "../components/FontPicker/FontPicker";
// TODO barnabasmolnar/editor-redesign
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
// ArrowHead icons
@@ -41,6 +38,9 @@ import {
FontSizeExtraLargeIcon,
EdgeSharpIcon,
EdgeRoundIcon,
FreedrawIcon,
FontFamilyNormalIcon,
FontFamilyCodeIcon,
TextAlignLeftIcon,
TextAlignCenterIcon,
TextAlignRightIcon,
@@ -50,12 +50,8 @@ import {
ArrowheadDiamondIcon,
ArrowheadDiamondOutlineIcon,
fontSizeIcon,
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
} from "../components/icons";
import {
ARROW_TYPE,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
FONT_FAMILY,
@@ -69,17 +65,17 @@ import {
redrawTextBoundingBox,
} from "../element";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { getBoundTextElement } from "../element/textElement";
import {
isArrowElement,
getBoundTextElement,
getDefaultLineHeight,
} from "../element/textElement";
import {
isBoundToContainer,
isElbowArrow,
isLinearElement,
isUsingAdaptiveRadius,
} from "../element/typeChecks";
import type {
Arrowhead,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
@@ -98,25 +94,9 @@ import {
isSomeElementSelected,
} from "../scene";
import { hasStrokeColor } from "../scene/comparisons";
import {
arrayToMap,
getFontFamilyString,
getShortcutKey,
tupleToCoors,
} from "../utils";
import { arrayToMap, getShortcutKey } 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;
@@ -135,7 +115,7 @@ export const changeProperty = (
return elements.map((element) => {
if (
selectedElementIds.get(element.id) ||
element.id === appState.editingTextElement?.id
element.id === appState.editingElement?.id
) {
return callback(element);
}
@@ -150,13 +130,13 @@ export const getFormValue = function <T extends Primitive>(
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T {
const editingTextElement = appState.editingTextElement;
const editingElement = appState.editingElement;
const nonDeletedElements = getNonDeletedElements(elements);
let ret: T | null = null;
if (editingTextElement) {
ret = getAttribute(editingTextElement);
if (editingElement) {
ret = getAttribute(editingElement);
}
if (!ret) {
@@ -749,389 +729,104 @@ export const actionIncreaseFontSize = register({
},
});
type ChangeFontFamilyData = Partial<
Pick<
AppState,
"openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily"
>
> & {
/** cache of selected & editing elements populated on opened popup */
cachedElements?: Map<string, ExcalidrawElement>;
/** flag to reset all elements to their cached versions */
resetAll?: true;
/** flag to reset all containers to their cached versions */
resetContainers?: true;
};
export const actionChangeFontFamily = register({
name: "changeFontFamily",
label: "labels.fontFamily",
trackEvent: false,
perform: (elements, appState, value, app) => {
const { cachedElements, resetAll, resetContainers, ...nextAppState } =
value as ChangeFontFamilyData;
if (resetAll) {
const nextElements = changeProperty(
return {
elements: changeProperty(
elements,
appState,
(element) => {
const cachedElement = cachedElements?.get(element.id);
if (cachedElement) {
const newElement = newElementWith(element, {
...cachedElement,
});
(oldElement) => {
if (isTextElement(oldElement)) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{
fontFamily: value,
lineHeight: getDefaultLineHeight(value),
},
);
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
return newElement;
}
return element;
return oldElement;
},
true,
);
return {
elements: nextElements,
appState: {
...appState,
...nextAppState,
},
storeAction: StoreAction.UPDATE,
};
}
const { currentItemFontFamily, currentHoveredFontFamily } = value;
let nexStoreAction: StoreActionType = StoreAction.NONE;
let nextFontFamily: FontFamilyValues | undefined;
let skipOnHoverRender = false;
if (currentItemFontFamily) {
nextFontFamily = currentItemFontFamily;
nexStoreAction = StoreAction.CAPTURE;
} else if (currentHoveredFontFamily) {
nextFontFamily = currentHoveredFontFamily;
nexStoreAction = StoreAction.NONE;
const selectedTextElements = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
}).filter((element) => isTextElement(element));
// skip on hover re-render for more than 200 text elements or for text element with more than 5000 chars combined
if (selectedTextElements.length > 200) {
skipOnHoverRender = true;
} else {
let i = 0;
let textLengthAccumulator = 0;
while (
i < selectedTextElements.length &&
textLengthAccumulator < 5000
) {
const textElement = selectedTextElements[i] as ExcalidrawTextElement;
textLengthAccumulator += textElement?.originalText.length || 0;
i++;
}
if (textLengthAccumulator > 5000) {
skipOnHoverRender = true;
}
}
}
const result = {
),
appState: {
...appState,
...nextAppState,
currentItemFontFamily: value,
},
storeAction: nexStoreAction,
storeAction: StoreAction.CAPTURE,
};
if (nextFontFamily && !skipOnHoverRender) {
const elementContainerMapping = new Map<
ExcalidrawTextElement,
ExcalidrawElement | null
>();
let uniqueChars = new Set<string>();
let skipFontFaceCheck = false;
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
const fontFamily = Object.entries(FONT_FAMILY).find(
([_, value]) => value === nextFontFamily,
)?.[0];
// skip `document.font.check` check on hover, if at least one font family has loaded as it's super slow (could result in slightly different bbox, which is fine)
if (
currentHoveredFontFamily &&
fontFamily &&
fontsCache.some((sig) => sig.startsWith(fontFamily))
) {
skipFontFaceCheck = true;
}
// following causes re-render so make sure we changed the family
// otherwise it could cause unexpected issues, such as preventing opening the popover when in wysiwyg
Object.assign(result, {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (
isTextElement(oldElement) &&
(oldElement.fontFamily !== nextFontFamily ||
currentItemFontFamily) // force update on selection
) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{
fontFamily: nextFontFamily,
lineHeight: getLineHeight(nextFontFamily!),
},
);
const cachedContainer =
cachedElements?.get(oldElement.containerId || "") || {};
const container = app.scene.getContainerElement(oldElement);
if (resetContainers && container && cachedContainer) {
// reset the container back to it's cached version
mutateElement(container, { ...cachedContainer }, false);
}
if (!skipFontFaceCheck) {
uniqueChars = new Set([
...uniqueChars,
...Array.from(newElement.originalText),
]);
}
elementContainerMapping.set(newElement, container);
return newElement;
}
return oldElement;
},
true,
),
});
// size is irrelevant, but necessary
const fontString = `10px ${getFontFamilyString({
fontFamily: nextFontFamily,
})}`;
const chars = Array.from(uniqueChars.values()).join();
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
redrawTextBoundingBox(
element,
container,
app.scene.getNonDeletedElementsMap(),
false,
);
}
} else {
// 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);
const latestContainer = container
? app.scene.getElement(container.id)
: null;
if (latestElement) {
// trigger async redraw
redrawTextBoundingBox(
latestElement as ExcalidrawTextElement,
latestContainer,
app.scene.getNonDeletedElementsMap(),
false,
);
}
}
// trigger update once we've mutated all the elements, which also updates our cache
app.fonts.onLoaded(fontFaces);
});
}
}
return result;
},
PanelComponent: ({ elements, appState, app, updateData }) => {
const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map());
const prevSelectedFontFamilyRef = useRef<number | null>(null);
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
const isUnmounted = useRef(true);
const selectedFontFamily = useMemo(() => {
const getFontFamily = (
elementsArray: readonly ExcalidrawElement[],
elementsMap: Map<string, ExcalidrawElement>,
) =>
getFormValue(
elementsArray,
appState,
(element) => {
if (isTextElement(element)) {
return element.fontFamily;
}
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
return boundTextElement.fontFamily;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(element, elementsMap) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
);
// popup opened, use cached elements
if (
batchedData.openPopup === "fontFamily" &&
appState.openPopup === "fontFamily"
) {
return getFontFamily(
Array.from(cachedElementsRef.current?.values() ?? []),
cachedElementsRef.current,
);
}
// popup closed, use all elements
if (!batchedData.openPopup && appState.openPopup !== "fontFamily") {
return getFontFamily(elements, app.scene.getNonDeletedElementsMap());
}
// popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had
return prevSelectedFontFamilyRef.current;
}, [batchedData.openPopup, appState, elements, app.scene]);
useEffect(() => {
prevSelectedFontFamilyRef.current = selectedFontFamily;
}, [selectedFontFamily]);
useEffect(() => {
if (Object.keys(batchedData).length) {
updateData(batchedData);
// reset the data after we've used the data
setBatchedData({});
}
// call update only on internal state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [batchedData]);
useEffect(() => {
isUnmounted.current = false;
return () => {
isUnmounted.current = true;
};
}, []);
PanelComponent: ({ elements, appState, updateData, app }) => {
const options: {
value: FontFamilyValues;
text: string;
icon: JSX.Element;
testId: string;
}[] = [
{
value: FONT_FAMILY.Virgil,
text: t("labels.handDrawn"),
icon: FreedrawIcon,
testId: "font-family-virgil",
},
{
value: FONT_FAMILY.Helvetica,
text: t("labels.normal"),
icon: FontFamilyNormalIcon,
testId: "font-family-normal",
},
{
value: FONT_FAMILY.Cascadia,
text: t("labels.code"),
icon: FontFamilyCodeIcon,
testId: "font-family-code",
},
];
return (
<fieldset>
<legend>{t("labels.fontFamily")}</legend>
<FontPicker
isOpened={appState.openPopup === "fontFamily"}
selectedFontFamily={selectedFontFamily}
hoveredFontFamily={appState.currentHoveredFontFamily}
onSelect={(fontFamily) => {
setBatchedData({
openPopup: null,
currentHoveredFontFamily: null,
currentItemFontFamily: fontFamily,
});
// defensive clear so immediate close won't abuse the cached elements
cachedElementsRef.current.clear();
}}
onHover={(fontFamily) => {
setBatchedData({
currentHoveredFontFamily: fontFamily,
cachedElements: new Map(cachedElementsRef.current),
resetContainers: true,
});
}}
onLeave={() => {
setBatchedData({
currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current),
resetAll: true,
});
}}
onPopupChange={(open) => {
if (open) {
// open, populate the cache from scratch
cachedElementsRef.current.clear();
const { editingTextElement } = appState;
// 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(
editingTextElement.id,
newElementWith(
latesteditingTextElement || editingTextElement,
{},
true,
),
);
} else {
const selectedElements = getSelectedElements(
elements,
appState,
{
includeBoundTextElement: true,
},
);
for (const element of selectedElements) {
cachedElementsRef.current.set(
element.id,
newElementWith(element, {}, true),
);
}
<ButtonIconSelect<FontFamilyValues | false>
group="font-family"
options={options}
value={getFormValue(
elements,
appState,
(element) => {
if (isTextElement(element)) {
return element.fontFamily;
}
setBatchedData({
openPopup: "fontFamily",
});
} else {
// close, use the cache and clear it afterwards
const data = {
openPopup: null,
currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current),
resetAll: true,
} as ChangeFontFamilyData;
if (isUnmounted.current) {
// in case the component was unmounted by the parent, trigger the update directly
updateData({ ...batchedData, ...data });
} else {
setBatchedData(data);
const boundTextElement = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
return boundTextElement.fontFamily;
}
cachedElementsRef.current.clear();
}
}}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
@@ -1324,12 +1019,8 @@ export const actionChangeRoundness = register({
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) => {
if (isElbowArrow(el)) {
return el;
}
return newElementWith(el, {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
roundness:
value === "round"
? {
@@ -1338,8 +1029,8 @@ export const actionChangeRoundness = register({
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
});
}),
}),
),
appState: {
...appState,
currentItemRoundness: value,
@@ -1379,8 +1070,7 @@ export const actionChangeRoundness = register({
appState,
(element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(element) =>
!isArrowElement(element) && element.hasOwnProperty("roundness"),
(element) => element.hasOwnProperty("roundness"),
(hasSelection) =>
hasSelection ? null : appState.currentItemRoundness,
)}
@@ -1543,206 +1233,3 @@ 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>
);
},
});

View File

@@ -0,0 +1,129 @@
import { generateIdFromFile, getDataURL } from "../data/blob";
import { mutateElement } from "../element/mutateElement";
import { isInitializedImageElement } from "../element/typeChecks";
import type { InitializedExcalidrawImageElement } from "../element/types";
import type { BinaryFileData } from "../types";
import { register } from "./register";
export const actionRemoveBackground = register({
name: "removeBackground",
label: "stats.fullTitle",
trackEvent: false,
viewMode: false,
async perform(elements, appState, type, app) {
const selectedElements = app.scene.getSelectedElements(appState);
if (
selectedElements.length > 0 &&
selectedElements.every(isInitializedImageElement)
) {
const filesToProcess = selectedElements.reduce(
(
acc: Map<
BinaryFileData["id"],
{
file: BinaryFileData;
elements: InitializedExcalidrawImageElement[];
}
>,
imageElement,
) => {
const file = app.files[imageElement.fileId];
if (file) {
const fileWithRemovedBackground = Object.values(app.files).find(
(_file) =>
_file.customData?.source === "backgroundRemoval" &&
_file.customData.parentFileId === file.id,
);
if (fileWithRemovedBackground) {
mutateElement(
imageElement,
{ fileId: fileWithRemovedBackground.id },
false,
);
} else if (acc.has(file.id)) {
acc.get(file.id)!.elements.push(imageElement);
} else {
acc.set(file.id, { file, elements: [imageElement] });
}
}
return acc;
},
new Map(),
);
if (filesToProcess.size) {
const backgroundRemoval = await await import(
"@imgly/background-removal"
);
console.time("removeBackground");
for (const [, { file, elements }] of filesToProcess) {
const res = await backgroundRemoval.removeBackground(file.dataURL, {
debug: true,
progress: (...args) => {
console.log("progress", args);
},
device: type === "auto" ? undefined : type,
proxyToWorker: true,
});
const fileId = await generateIdFromFile(res);
const dataURL = await getDataURL(res);
for (const imageElement of elements) {
mutateElement(imageElement, { fileId }, false);
}
app.addFiles([
{
...file,
id: fileId,
dataURL,
customData: {
source: "backgroundRemoval",
version: 1,
parentFileId: file.id,
},
},
]);
}
console.timeEnd("removeBackground");
}
app.scene.triggerUpdate();
}
return false as false;
},
PanelComponent: ({ updateData }) => {
return (
<>
<button
onClick={() => {
updateData("auto");
}}
>
Remove background (auto)
</button>
<button
onClick={() => {
updateData("gpu");
}}
>
Remove background (gpu)
</button>
<button
onClick={() => {
updateData("cpu");
}}
>
Remove background (cpu)
</button>
</>
);
},
});

View File

@@ -12,7 +12,10 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
} from "../constants";
import { getBoundTextElement } from "../element/textElement";
import {
getBoundTextElement,
getDefaultLineHeight,
} from "../element/textElement";
import {
hasBoundTextElement,
canApplyRoundnessTypeToElement,
@@ -24,7 +27,6 @@ import { getSelectedElements } from "../scene";
import type { ExcalidrawTextElement } from "../element/types";
import { paintIcon } from "../components/icons";
import { StoreAction } from "../store";
import { getLineHeight } from "../fonts";
// `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}";
@@ -120,7 +122,7 @@ export const actionPasteStyles = register({
DEFAULT_TEXT_ALIGN,
lineHeight:
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
getLineHeight(fontFamily),
getDefaultLineHeight(fontFamily),
});
let container = null;
if (newElement.containerId) {

View File

@@ -1,5 +1,6 @@
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";
@@ -12,21 +13,21 @@ export const actionToggleGridMode = register({
viewMode: true,
trackEvent: {
category: "canvas",
predicate: (appState) => appState.gridModeEnabled,
predicate: (appState) => !appState.gridSize,
},
perform(elements, appState) {
return {
appState: {
...appState,
gridModeEnabled: !this.checked!(appState),
gridSize: this.checked!(appState) ? null : GRID_SIZE,
objectsSnapModeEnabled: false,
},
storeAction: StoreAction.NONE,
};
},
checked: (appState: AppState) => appState.gridModeEnabled,
checked: (appState: AppState) => appState.gridSize !== null,
predicate: (element, appState, props) => {
return props.gridModeEnabled === undefined;
return typeof props.gridModeEnabled === "undefined";
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
});

View File

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

View File

@@ -1,55 +0,0 @@
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,
});

View File

@@ -86,7 +86,4 @@ export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "./actionLink";
export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor";
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
export { actionToggleCropEditor } from "./actionCropEditor";
export { actionRemoveBackground } from "./actionRemoveBackground";

View File

@@ -23,6 +23,7 @@ export type ShortcutName =
| "sendToBack"
| "bringToFront"
| "copyAsPng"
| "copyAsSvg"
| "group"
| "ungroup"
| "gridMode"
@@ -50,8 +51,7 @@ export type ShortcutName =
>
| "saveScene"
| "imageExport"
| "commandPalette"
| "searchMenu";
| "commandPalette";
const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")],
@@ -87,6 +87,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
: getShortcutKey("CtrlOrCmd+Shift+]"),
],
copyAsPng: [getShortcutKey("Shift+Alt+C")],
copyAsSvg: [],
group: [getShortcutKey("CtrlOrCmd+G")],
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")],
@@ -111,7 +112,6 @@ 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) => {

View File

@@ -10,6 +10,7 @@ import type {
BinaryFiles,
UIAppState,
} from "../types";
import type { MarkOptional } from "../utility-types";
import type { StoreActionType } from "../store";
export type ActionSource =
@@ -23,7 +24,10 @@ export type ActionSource =
export type ActionResult =
| {
elements?: readonly ExcalidrawElement[] | null;
appState?: Partial<AppState> | null;
appState?: MarkOptional<
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
files?: BinaryFiles | null;
storeAction: StoreActionType;
replaceFiles?: boolean;
@@ -66,7 +70,6 @@ export type ActionName =
| "changeSloppiness"
| "changeStrokeStyle"
| "changeArrowhead"
| "changeArrowType"
| "changeOpacity"
| "changeFontSize"
| "toggleCanvasMenu"
@@ -134,8 +137,7 @@ export type ActionName =
| "commandPalette"
| "autoResize"
| "elementStats"
| "searchMenu"
| "cropEditor";
| "removeBackground";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
@@ -189,8 +191,7 @@ export interface Action {
| "history"
| "menu"
| "collab"
| "hyperlink"
| "search_menu";
| "hyperlink";
action?: string;
predicate?: (
appState: Readonly<AppState>,

View File

@@ -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", "export"]);
const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
export const trackEvent = (
category: string,
@@ -9,20 +9,17 @@ export const trackEvent = (
value?: number,
) => {
try {
// prettier-ignore
if (
typeof window === "undefined" ||
import.meta.env.VITE_WORKER_ID ||
import.meta.env.VITE_APP_ENABLE_TRACKING !== "true"
typeof window === "undefined"
|| import.meta.env.VITE_WORKER_ID
// comment out to debug locally
|| import.meta.env.PROD
) {
return;
}
if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) {
return;
}
if (import.meta.env.DEV) {
// comment out to debug in dev
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
return;
}

View File

@@ -1,15 +1,12 @@
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";
@@ -36,15 +33,13 @@ 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,
newElement: null,
editingTextElement: null,
draggingElement: null,
editingElement: null,
editingGroupId: null,
editingLinearElement: null,
activeTool: {
@@ -61,9 +56,7 @@ export const getDefaultAppState = (): Omit<
exportEmbedScene: false,
exportWithDarkMode: false,
fileHandle: null,
gridSize: DEFAULT_GRID_SIZE,
gridStep: DEFAULT_GRID_STEP,
gridModeEnabled: false,
gridSize: null,
isBindingEnabled: true,
defaultSidebarDockedPreference: false,
isLoading: false,
@@ -116,9 +109,6 @@ export const getDefaultAppState = (): Omit<
objectsSnapModeEnabled: false,
userToFollow: null,
followedBy: new Set(),
isCropping: false,
croppingElementId: null,
searchMatches: [],
};
};
@@ -152,11 +142,6 @@ 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 },
@@ -164,11 +149,10 @@ const APP_STATE_STORAGE_CONF = (<
currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false },
currentHoveredFontFamily: { browser: false, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
activeEmbeddable: { browser: false, export: false, server: false },
newElement: { browser: false, export: false, server: false },
editingTextElement: { browser: false, export: false, server: false },
draggingElement: { browser: false, export: false, server: false },
editingElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false },
activeTool: { browser: true, export: false, server: false },
@@ -181,8 +165,6 @@ 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: {
@@ -239,9 +221,6 @@ 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 },
isCropping: { browser: false, export: false, server: false },
croppingElementId: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false },
});
const _clearAppStateForStorage = <

View File

@@ -1,105 +0,0 @@
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));
}
}

View File

@@ -17,16 +17,13 @@ import {
hasBoundTextElement,
isBindableElement,
isBoundToContainer,
isImageElement,
isTextElement,
} from "./element/typeChecks";
import type {
ExcalidrawElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
NonDeleted,
Ordered,
OrderedExcalidrawElement,
SceneElementsMap,
} from "./element/types";
@@ -629,18 +626,6 @@ export class AppStateChange implements Change<AppState> {
);
break;
case "croppingElementId": {
const croppingElementId = nextAppState[key];
const element =
croppingElementId && nextElements.get(croppingElementId);
if (element && !element.isDeleted) {
visibleDifferenceFlag.value = true;
} else {
nextAppState[key] = null;
}
break;
}
case "editingGroupId":
const editingGroupId = nextAppState[key];
@@ -771,7 +756,6 @@ export class AppStateChange implements Change<AppState> {
selectedElementIds,
editingLinearElementId,
selectedLinearElementId,
croppingElementId,
...standaloneProps
} = delta as ObservedAppState;
@@ -795,10 +779,7 @@ export class AppStateChange implements Change<AppState> {
}
}
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
ElementUpdate<Ordered<T>>,
"seed"
>;
type ElementPartial = Omit<ElementUpdate<OrderedExcalidrawElement>, "seed">;
/**
* Elements change is a low level primitive to capture a change between two sets of elements.
@@ -1119,6 +1100,7 @@ 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)
@@ -1127,9 +1109,6 @@ 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`,
@@ -1235,18 +1214,6 @@ export class ElementsChange implements Change<SceneElementsMap> {
});
}
if (isImageElement(element)) {
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
// we want to override `crop` only if modified so that we don't reset
// when undoing/redoing unrelated change
if (_delta.deleted.crop || _delta.inserted.crop) {
Object.assign(directlyApplicablePartial, {
// apply change verbatim
crop: _delta.inserted.crop ?? null,
});
}
}
if (!flags.containsVisibleDifference) {
// strip away fractional as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial;
@@ -1493,9 +1460,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
) {
for (const element of changed.values()) {
if (!element.isDeleted && isBindableElement(element)) {
updateBoundElements(element, elements, {
changedElements: changed,
});
updateBoundElements(element, elements);
}
}
}

View File

@@ -1,5 +1,3 @@
import type { Radians } from "../math";
import { pointFrom } from "../math";
import {
COLOR_PALETTE,
DEFAULT_CHART_COLOR_INDEX,
@@ -205,7 +203,7 @@ const chartXLabels = (
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
y: y + BAR_GAP / 2,
width: BAR_WIDTH,
angle: 5.87 as Radians,
angle: 5.87,
fontSize: 16,
textAlign: "center",
verticalAlign: "top",
@@ -259,8 +257,13 @@ const chartLines = (
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
width: chartWidth,
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
points: [
[0, 0],
[chartWidth, 0],
],
});
const yLine = newLinearElement({
@@ -270,8 +273,13 @@ const chartLines = (
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
height: chartHeight,
points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
points: [
[0, 0],
[0, -chartHeight],
],
});
const maxLine = newLinearElement({
@@ -281,10 +289,15 @@ const chartLines = (
type: "line",
x,
y: y - BAR_HEIGHT - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
strokeStyle: "dotted",
width: chartWidth,
opacity: GRID_OPACITY,
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
points: [
[0, 0],
[chartWidth, 0],
],
});
return [xLine, yLine, maxLine];
@@ -405,6 +418,8 @@ 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,
@@ -438,10 +453,15 @@ 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: [pointFrom(0, 0), pointFrom(0, cy)],
points: [
[0, 0],
[0, cy],
],
});
});

View File

@@ -21,12 +21,11 @@ import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
import { capitalizeString, isTransparent } from "../utils";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor, toolIsArrow } from "../scene/comparisons";
import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics";
import {
hasBoundTextElement,
isElbowArrow,
isImageElement,
isInitializedImageElement,
isLinearElement,
isTextElement,
} from "../element/typeChecks";
@@ -46,11 +45,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,
@@ -105,9 +104,7 @@ export const SelectedShapeActions = ({
) {
isSingleElementBoundContainer = true;
}
const isEditingTextOrNewElement = Boolean(
appState.editingTextElement || appState.newElement,
);
const isEditing = Boolean(appState.editingElement);
const device = useDevice();
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
@@ -125,16 +122,14 @@ export const SelectedShapeActions = ({
const showLineEditorAction =
!appState.editingLinearElement &&
targetElements.length === 1 &&
isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
const showCropEditorAction =
!appState.croppingElementId &&
targetElements.length === 1 &&
isImageElement(targetElements[0]);
isLinearElement(targetElements[0]);
return (
<div className="panelColumn">
{targetElements.length > 0 &&
targetElements.every(isInitializedImageElement) && (
<div>{renderAction("removeBackground")}</div>
)}
<div>
{canChangeStrokeColor(appState, targetElements) &&
renderAction("changeStrokeColor")}
@@ -165,16 +160,13 @@ export const SelectedShapeActions = ({
<>{renderAction("changeRoundness")}</>
)}
{(toolIsArrow(appState.activeTool.type) ||
targetElements.some((element) => toolIsArrow(element.type))) && (
<>{renderAction("changeArrowType")}</>
)}
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && (
<>
{renderAction("changeFontFamily")}
{renderAction("changeFontSize")}
{renderAction("changeFontFamily")}
{(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
renderAction("changeTextAlign")}
@@ -242,7 +234,7 @@ export const SelectedShapeActions = ({
</div>
</fieldset>
)}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
{!isEditing && targetElements.length > 0 && (
<fieldset>
<legend>{t("labels.actions")}</legend>
<div className="buttonList">
@@ -251,7 +243,6 @@ export const SelectedShapeActions = ({
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
{showCropEditorAction && renderAction("cropEditor")}
{showLineEditorAction && renderAction("toggleLinearEditor")}
</div>
</fieldset>
@@ -409,7 +400,7 @@ export const ShapesSwitcher = ({
>
{t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item>
{app.props.aiEnabled !== false && app.plugins.diagramToCode && (
{app.props.aiEnabled !== false && (
<>
<DropdownMenu.Item
onSelect={() => app.onMagicframeToolSelect()}
@@ -419,6 +410,20 @@ 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 +439,7 @@ export const ZoomActions = ({
renderAction: ActionManager["renderAction"];
zoom: Zoom;
}) => (
<Stack.Col gap={1} className={CLASSES.ZOOM_ACTIONS}>
<Stack.Col gap={1} className="zoom-actions">
<Stack.Row align="center">
{renderAction("zoomOut")}
{renderAction("resetZoom")}

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +0,0 @@
@import "../css/theme";
.excalidraw {
button.standalone {
@include outlineButtonIconStyles;
& > * {
// dissalow pointer events on children, so we always have event.target on the button itself
pointer-events: none;
}
}
}

View File

@@ -1,36 +0,0 @@
import { forwardRef } from "react";
import clsx from "clsx";
import "./ButtonIcon.scss";
interface ButtonIconProps {
icon: JSX.Element;
title: string;
className?: string;
testId?: string;
/** if not supplied, defaults to value identity check */
active?: boolean;
/** include standalone style (could interfere with parent styles) */
standalone?: boolean;
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
(props, ref) => {
const { title, className, testId, active, standalone, icon, onClick } =
props;
return (
<button
type="button"
ref={ref}
key={title}
title={title}
data-testid={testId}
className={clsx(className, { standalone, active })}
onClick={onClick}
>
{icon}
</button>
);
},
);

View File

@@ -1,5 +1,4 @@
import clsx from "clsx";
import { ButtonIcon } from "./ButtonIcon";
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>(
@@ -25,17 +24,21 @@ export const ButtonIconSelect = <T extends Object>(
}
),
) => (
<div className="buttonList">
<div className="buttonList buttonListIcon">
{props.options.map((option) =>
props.type === "button" ? (
<ButtonIcon
<button
type="button"
key={option.text}
icon={option.icon}
title={option.text}
testId={option.testId}
active={option.active ?? props.value === option.value}
onClick={(event) => props.onClick(option.value, event)}
/>
className={clsx({
active: option.active ?? props.value === option.value,
})}
data-testid={option.testId}
title={option.text}
>
{option.icon}
</button>
) : (
<label
key={option.text}

View File

@@ -1,10 +0,0 @@
export const ButtonSeparator = () => (
<div
style={{
width: 1,
height: "1rem",
backgroundColor: "var(--default-border-color)",
margin: "0 auto",
}}
/>
);

View File

@@ -20,7 +20,7 @@
align-items: center;
@include isMobile {
max-width: 11rem;
max-width: 175px;
}
}

View File

@@ -1,24 +1,22 @@
import { isTransparent } from "../../utils";
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
import type { ExcalidrawElement } from "../../element/types";
import type { AppState } from "../../types";
import { TopPicks } from "./TopPicks";
import { ButtonSeparator } from "../ButtonSeparator";
import { Picker } from "./Picker";
import * as Popover from "@radix-ui/react-popover";
import { useAtom } from "jotai";
import type { ColorPickerType } from "./colorPickerUtils";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { useExcalidrawContainer } from "../App";
import { useDevice, useExcalidrawContainer } from "../App";
import type { ColorTuple, ColorPaletteCustom } from "../../colors";
import { COLOR_PALETTE } from "../../colors";
import PickerHeading from "./PickerHeading";
import { t } from "../../i18n";
import clsx from "clsx";
import { useRef } from "react";
import { jotaiScope } from "../../jotai";
import { ColorInput } from "./ColorInput";
import { useRef } from "react";
import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
import "./ColorPicker.scss";
@@ -73,7 +71,6 @@ const ColorPickerPopupContent = ({
| "palette"
| "updateData"
>) => {
const { container } = useExcalidrawContainer();
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
const [eyeDropperState, setEyeDropperState] = useAtom(
@@ -81,6 +78,9 @@ const ColorPickerPopupContent = ({
jotaiScope,
);
const { container } = useExcalidrawContainer();
const device = useDevice();
const colorInputJSX = (
<div>
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
@@ -94,7 +94,6 @@ const ColorPickerPopupContent = ({
/>
</div>
);
const popoverRef = useRef<HTMLDivElement>(null);
const focusPickerContent = () => {
@@ -104,73 +103,120 @@ const ColorPickerPopupContent = ({
};
return (
<PropertiesPopover
container={container}
style={{ maxWidth: "13rem" }}
onFocusOutside={(event) => {
// refocus due to eye dropper
focusPickerContent();
event.preventDefault();
}}
onPointerDownOutside={(event) => {
if (eyeDropperState) {
// prevent from closing if we click outside the popover
// while eyedropping (e.g. click when clicking the sidebar;
// the eye-dropper-backdrop is prevented downstream)
<Popover.Portal container={container}>
<Popover.Content
ref={popoverRef}
className="focus-visible-none"
data-prevent-outside-click
onFocusOutside={(event) => {
focusPickerContent();
event.preventDefault();
}
}}
onClose={() => {
updateData({ openPopup: null });
setActiveColorPickerSection(null);
}}
>
{palette ? (
<Picker
palette={palette}
color={color}
onChange={(changedColor) => {
onChange(changedColor);
}}
onEyeDropperToggle={(force) => {
setEyeDropperState((state) => {
if (force) {
state = state || {
keepOpenOnAlt: true,
onSelect: onChange,
colorPickerType: type,
};
state.keepOpenOnAlt = true;
return state;
}
}}
onPointerDownOutside={(event) => {
if (eyeDropperState) {
// prevent from closing if we click outside the popover
// while eyedropping (e.g. click when clicking the sidebar;
// the eye-dropper-backdrop is prevented downstream)
event.preventDefault();
}
}}
onCloseAutoFocus={(e) => {
e.stopPropagation();
// prevents focusing the trigger
e.preventDefault();
return force === false || state
? null
: {
keepOpenOnAlt: false,
// return focus to excalidraw container unless
// user focuses an interactive element, such as a button, or
// enters the text editor by clicking on canvas with the text tool
if (container && !isInteractive(document.activeElement)) {
container.focus();
}
updateData({ openPopup: null });
setActiveColorPickerSection(null);
}}
side={
device.editor.isMobile && !device.viewport.isLandscape
? "bottom"
: "right"
}
align={
device.editor.isMobile && !device.viewport.isLandscape
? "center"
: "start"
}
alignOffset={-16}
sideOffset={20}
style={{
zIndex: "var(--zIndex-layerUI)",
backgroundColor: "var(--popup-bg-color)",
maxWidth: "208px",
maxHeight: window.innerHeight,
padding: "12px",
borderRadius: "8px",
boxSizing: "border-box",
overflowY: "auto",
boxShadow:
"0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)",
}}
>
{palette ? (
<Picker
palette={palette}
color={color}
onChange={(changedColor) => {
onChange(changedColor);
}}
onEyeDropperToggle={(force) => {
setEyeDropperState((state) => {
if (force) {
state = state || {
keepOpenOnAlt: true,
onSelect: onChange,
colorPickerType: type,
};
});
state.keepOpenOnAlt = true;
return state;
}
return force === false || state
? null
: {
keepOpenOnAlt: false,
onSelect: onChange,
colorPickerType: type,
};
});
}}
onEscape={(event) => {
if (eyeDropperState) {
setEyeDropperState(null);
} else if (isWritableElement(event.target)) {
focusPickerContent();
} else {
updateData({ openPopup: null });
}
}}
label={label}
type={type}
elements={elements}
updateData={updateData}
>
{colorInputJSX}
</Picker>
) : (
colorInputJSX
)}
<Popover.Arrow
width={20}
height={10}
style={{
fill: "var(--popup-bg-color)",
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
}}
onEscape={(event) => {
if (eyeDropperState) {
setEyeDropperState(null);
} else {
updateData({ openPopup: null });
}
}}
label={label}
type={type}
elements={elements}
updateData={updateData}
>
{colorInputJSX}
</Picker>
) : (
colorInputJSX
)}
</PropertiesPopover>
/>
</Popover.Content>
</Popover.Portal>
);
};
@@ -186,7 +232,7 @@ const ColorPickerTrigger = ({
return (
<Popover.Trigger
type="button"
className={clsx("color-picker__button active-color properties-trigger", {
className={clsx("color-picker__button active-color", {
"is-transparent": color === "transparent" || !color,
})}
aria-label={label}
@@ -222,7 +268,14 @@ export const ColorPicker = ({
type={type}
topPicks={topPicks}
/>
<ButtonSeparator />
<div
style={{
width: 1,
height: "100%",
backgroundColor: "var(--default-border-color)",
margin: "0 auto",
}}
/>
<Popover.Root
open={appState.openPopup === type}
onOpenChange={(open) => {

View File

@@ -138,7 +138,7 @@ export const Picker = ({
event.stopPropagation();
}
}}
className="color-picker-content properties-content"
className="color-picker-content"
// to allow focusing by clicking but not by tabbing
tabIndex={-1}
>

View File

@@ -43,11 +43,7 @@ import { InlineIcon } from "../InlineIcon";
import { SHAPES } from "../../shapes";
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
import { useStableCallback } from "../../hooks/useStableCallback";
import {
actionClearCanvas,
actionLink,
actionToggleSearchMenu,
} from "../../actions";
import { actionClearCanvas, actionLink } from "../../actions";
import { jotaiStore } from "../../jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import type { CommandPaletteItem } from "./types";
@@ -279,7 +275,6 @@ function CommandPaletteInner({
actionManager.actions.increaseFontSize,
actionManager.actions.decreaseFontSize,
actionManager.actions.toggleLinearEditor,
actionManager.actions.cropEditor,
actionLink,
].map((action: Action) =>
actionToCommand(
@@ -387,15 +382,6 @@ 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"],

View File

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

View File

@@ -1,11 +1,8 @@
import clsx from "clsx";
import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
LIBRARY_SIDEBAR_TAB,
} from "../constants";
import { 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";
@@ -13,9 +10,6 @@ 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",
@@ -37,11 +31,14 @@ const DefaultSidebarTrigger = withInternalFallback(
);
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
const DefaultTabTriggers = ({ children }: { children: React.ReactNode }) => {
const DefaultTabTriggers = ({
children,
...rest
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
return (
<DefaultSidebarTabTriggersTunnel.In>
{children}
<Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers>
</DefaultSidebarTabTriggersTunnel.In>
);
};
@@ -68,21 +65,17 @@ 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={
isForceDocked || (docked ?? appState.defaultSidebarDockedPreference)
}
docked={docked ?? appState.defaultSidebarDockedPreference}
onDock={
// `onDock=false` disables docking.
// if `docked` passed, but no onDock passed, disable manual docking.
isForceDocked || onDock === false || (!onDock && docked != null)
onDock === false || (!onDock && docked != null)
? undefined
: // compose to allow the host app to listen on default behavior
composeEventHandlers(onDock, (docked) => {
@@ -92,22 +85,26 @@ export const DefaultSidebar = Object.assign(
>
<Sidebar.Tabs>
<Sidebar.Header>
<Sidebar.TabTriggers>
<Sidebar.TabTrigger tab={CANVAS_SEARCH_TAB}>
{searchIcon}
</Sidebar.TabTrigger>
<Sidebar.TabTrigger tab={LIBRARY_SIDEBAR_TAB}>
{LibraryIcon}
</Sidebar.TabTrigger>
<DefaultSidebarTabTriggersTunnel.Out />
</Sidebar.TabTriggers>
{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.Header>
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
<LibraryMenu />
</Sidebar.Tab>
<Sidebar.Tab tab={CANVAS_SEARCH_TAB}>
<SearchMenu />
</Sidebar.Tab>
{children}
</Sidebar.Tabs>
</Sidebar>

View File

@@ -1,17 +0,0 @@
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;
};

View File

@@ -1,19 +1,5 @@
@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;
@@ -30,20 +16,11 @@
.Spinner {
--spinner-color: var(--color-surface-lowest);
}
.ExcButton__statusIcon {
visibility: visible;
position: absolute;
width: 1.2rem;
height: 1.2rem;
animation: successStatusAnimation 0.5s cubic-bezier(0.3, 1, 0.6, 1);
visibility: visible;
}
&.ExcButton--status-loading,
&.ExcButton--status-success {
&[disabled] {
pointer-events: none;
.ExcButton__contents {
@@ -51,10 +28,6 @@
}
}
&[disabled] {
pointer-events: none;
}
&,
&__contents {
display: flex;
@@ -146,46 +119,6 @@
}
}
&--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);

View File

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

View File

@@ -1,15 +0,0 @@
@import "../../css/variables.module.scss";
.excalidraw {
.FontPicker__container {
display: grid;
grid-template-columns: calc(1rem + 3 * var(--default-button-size)) 1rem 1fr; // calc ~ 2 gaps + 4 buttons
align-items: center;
@include isMobile {
max-width: calc(
2rem + 4 * var(--default-button-size)
); // 4 gaps + 4 buttons
}
}
}

View File

@@ -1,110 +0,0 @@
import React, { useCallback, useMemo } from "react";
import * as Popover from "@radix-ui/react-popover";
import { FontPickerList } from "./FontPickerList";
import { FontPickerTrigger } from "./FontPickerTrigger";
import { ButtonIconSelect } from "../ButtonIconSelect";
import {
FontFamilyCodeIcon,
FontFamilyNormalIcon,
FreedrawIcon,
} from "../icons";
import { ButtonSeparator } from "../ButtonSeparator";
import type { FontFamilyValues } from "../../element/types";
import { FONT_FAMILY } from "../../constants";
import { t } from "../../i18n";
import "./FontPicker.scss";
export const DEFAULT_FONTS = [
{
value: FONT_FAMILY.Excalifont,
icon: FreedrawIcon,
text: t("labels.handDrawn"),
testId: "font-family-hand-drawn",
},
{
value: FONT_FAMILY.Nunito,
icon: FontFamilyNormalIcon,
text: t("labels.normal"),
testId: "font-family-normal",
},
{
value: FONT_FAMILY["Comic Shanns"],
icon: FontFamilyCodeIcon,
text: t("labels.code"),
testId: "font-family-code",
},
];
const defaultFontFamilies = new Set(DEFAULT_FONTS.map((x) => x.value));
export const isDefaultFont = (fontFamily: number | null) => {
if (!fontFamily) {
return false;
}
return defaultFontFamilies.has(fontFamily);
};
interface FontPickerProps {
isOpened: boolean;
selectedFontFamily: FontFamilyValues | null;
hoveredFontFamily: FontFamilyValues | null;
onSelect: (fontFamily: FontFamilyValues) => void;
onHover: (fontFamily: FontFamilyValues) => void;
onLeave: () => void;
onPopupChange: (open: boolean) => void;
}
export const FontPicker = React.memo(
({
isOpened,
selectedFontFamily,
hoveredFontFamily,
onSelect,
onHover,
onLeave,
onPopupChange,
}: FontPickerProps) => {
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
const onSelectCallback = useCallback(
(value: number | false) => {
if (value) {
onSelect(value);
}
},
[onSelect],
);
return (
<div role="dialog" aria-modal="true" className="FontPicker__container">
<ButtonIconSelect<FontFamilyValues | false>
type="button"
options={defaultFonts}
value={selectedFontFamily}
onClick={onSelectCallback}
/>
<ButtonSeparator />
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
{isOpened && (
<FontPickerList
selectedFontFamily={selectedFontFamily}
hoveredFontFamily={hoveredFontFamily}
onSelect={onSelectCallback}
onHover={onHover}
onLeave={onLeave}
onOpen={() => onPopupChange(true)}
onClose={() => onPopupChange(false)}
/>
)}
</Popover.Root>
</div>
);
},
(prev, next) =>
prev.isOpened === next.isOpened &&
prev.selectedFontFamily === next.selectedFontFamily &&
prev.hoveredFontFamily === next.hoveredFontFamily,
);

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