Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 521896cccf | |||
| fe318126bd | |||
| 8ca4cf3260 | |||
| f3f0ab7c83 | |||
| 44a1c8d857 | |||
| e0a22edfbd | |||
| c07f5a0c80 | |||
| 508f16dc04 | |||
| c1b310c56b | |||
| d4900e8f19 | |||
| caf2db934c | |||
| 60e3801691 | |||
| 80f3b75d42 | |||
| dc812bee19 | |||
| 01e83cc9a5 | |||
| 813f9b702e | |||
| fd39712ba6 | |||
| b46ca0192b | |||
| 72b7c937b1 | |||
| d107215564 | |||
| 6959a363f0 | |||
| 5a11c70714 | |||
| 6ff56c36e3 | |||
| 51ea184938 | |||
| 21fff26d31 | |||
| f4dd23fc31 | |||
| e3d1dee9d0 | |||
| e3f31df747 | |||
| 60e75406e0 | |||
| b396e07b90 | |||
| 2d1d84a47b | |||
| ee30225062 | |||
| 16cae4fc07 | |||
| 576bc0dbe5 | |||
| 00af35c692 | |||
| ea7c702cfc | |||
| 26d2296578 | |||
| afb68a6467 | |||
| b459e5cfd2 | |||
| 5facc0d6da | |||
| 824ad603e1 | |||
| 5e1ff7cafe | |||
| b5d7f5b4ba | |||
| fb4bb29aa5 | |||
| 3cfcc7b489 | |||
| 4320a3cf41 | |||
| 8420e1aa13 | |||
| 5daf1a1b4e | |||
| 97981804d7 | |||
| f7b3befd0a | |||
| 7b2bee9746 | |||
| 88014ace4a | |||
| 87a9430809 | |||
| 99b91c46f7 | |||
| 1ea5b26f25 | |||
| d5f4ee7b3f | |||
| 261304c1a4 | |||
| 84398a7e5c | |||
| 54491d13d4 | |||
| dd1370381d | |||
| 72d6ee48fc | |||
| 232242d2e9 | |||
| f19ce30dfe | |||
| 3cf14c73a3 | |||
| 8d530cf102 | |||
| b87925d253 | |||
| a6684b09f2 | |||
| b427617f66 | |||
| 2907fbe34b | |||
| c67815f7b0 | |||
| c641860cb1 | |||
| 84d89b9a8a | |||
| e63dd025c9 | |||
| 15e019706d | |||
| a133a70e87 | |||
| 80ea7ca23f | |||
| e844580b14 | |||
| 5a0771ad9c | |||
| adcdbe2907 | |||
| 230d0edc44 | |||
| d0a380758e | |||
| 7b36de0476 | |||
| 2427e622b0 | |||
| 62228e0bbb | |||
| 4c5408263c | |||
| bd7b778f41 | |||
| 43b2476dfe | |||
| df8875a497 | |||
| 6fbc44fd1f | |||
| d25a7d365b | |||
| e52c2cd0b6 | |||
| 96eeec5119 | |||
| f5221d521b | |||
| db2c235cd4 | |||
| 148b895f46 | |||
| d9258a736b | |||
| 2e1f08c796 | |||
| 1d5b41dabb | |||
| 66a2f24296 | |||
| 04668d8263 | |||
| abbeed3d5f | |||
| ba8c09d529 | |||
| 744b3e5d09 | |||
| 6ba9bd60e8 | |||
| a1ffa064df | |||
| 4dc4590f24 | |||
| d2f67e619f | |||
| 22b39277f5 | |||
| 63dee03ef0 | |||
| 08b13f971d | |||
| 69f4cc70cb | |||
| 860308eb27 | |||
| 4eb9463f26 | |||
| 6ed6131169 | |||
| 1ed98f9c93 |
@@ -8,6 +8,7 @@
|
|||||||
!package.json
|
!package.json
|
||||||
!public/
|
!public/
|
||||||
!packages/
|
!packages/
|
||||||
|
!scripts/
|
||||||
!tsconfig.json
|
!tsconfig.json
|
||||||
!yarn.lock
|
!yarn.lock
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -17,12 +17,10 @@ VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","a
|
|||||||
# put these in your .env.local, or make sure you don't commit!
|
# put these in your .env.local, or make sure you don't commit!
|
||||||
# must be lowercase `true` when turned on
|
# must be lowercase `true` when turned on
|
||||||
#
|
#
|
||||||
# whether to enable Service Workers in development
|
|
||||||
VITE_APP_DEV_ENABLE_SW=
|
|
||||||
# whether to disable live reload / HMR. Usuaully what you want to do when
|
# whether to disable live reload / HMR. Usuaully what you want to do when
|
||||||
# debugging Service Workers.
|
# debugging Service Workers.
|
||||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
|
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||||
VITE_APP_DISABLE_TRACKING=true
|
VITE_APP_ENABLE_TRACKING=true
|
||||||
|
|
||||||
FAST_REFRESH=false
|
FAST_REFRESH=false
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -14,4 +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_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_DISABLE_TRACKING=
|
VITE_APP_ENABLE_TRACKING=false
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ firebase/
|
|||||||
dist/
|
dist/
|
||||||
public/workbox
|
public/workbox
|
||||||
packages/excalidraw/types
|
packages/excalidraw/types
|
||||||
|
examples/**/public
|
||||||
|
dev-dist
|
||||||
|
coverage
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
name: Tests
|
name: Tests
|
||||||
|
|
||||||
on: pull_request
|
on:
|
||||||
|
push:
|
||||||
|
branches: master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- name: Setup Node.js 18.x
|
- name: Setup Node.js 18.x
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.x
|
node-version: 18.x
|
||||||
- name: Install and test
|
- name: Install and test
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ ARG NODE_ENV=production
|
|||||||
|
|
||||||
RUN yarn build:app:docker
|
RUN yarn build:app:docker
|
||||||
|
|
||||||
FROM nginx:1.24-alpine
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/mainMenu/DefaultItems.tsx) of the default items.
|
Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/main-menu/DefaultItems.tsx) of the default items.
|
||||||
|
|
||||||
### MainMenu.Group
|
### MainMenu.Group
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Once the callback is triggered, you will need to store the api in state to acces
|
|||||||
```jsx showLineNumbers
|
```jsx showLineNumbers
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [excalidrawAPI, setExcalidrawAPI] = useState(null);
|
const [excalidrawAPI, setExcalidrawAPI] = useState(null);
|
||||||
return <Excalidraw excalidrawAPI={{(api)=> setExcalidrawAPI(api)}} />;
|
return <Excalidraw excalidrawAPI={(api)=> setExcalidrawAPI(api)} />;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ All `props` are _optional_.
|
|||||||
| [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode |
|
| [`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`. |
|
| [`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. |
|
| [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. |
|
||||||
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down evenets |
|
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down events |
|
||||||
| [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. |
|
| [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. |
|
||||||
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when the something is pasted in to the scene |
|
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when something is pasted into the scene |
|
||||||
| [`onLibraryChange`](#onlibrarychange) | `function` | _ | The callback if supplied is triggered when the library is updated and receives the library items. |
|
| [`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. |
|
| [`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 |
|
| [`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) |
|
| [`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. |
|
| [`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. |
|
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
|
||||||
| [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load |
|
| [`autoFocus`](#autofocus) | `boolean` | `false` | Indicates whether to focus the Excalidraw component on page load |
|
||||||
| [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
|
| [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
|
||||||
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
|
| [`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>` |
|
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ exportToCanvas({<br/>
|
|||||||
getDimensions,<br/>
|
getDimensions,<br/>
|
||||||
files,<br/>
|
files,<br/>
|
||||||
exportPadding?: number;<br/>
|
exportPadding?: number;<br/>
|
||||||
}: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L21">ExportOpts</a>
|
}: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/utils/export.ts#L24">ExportOpts</a>
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
| Name | Type | Default | Description |
|
| Name | Type | Default | Description |
|
||||||
@@ -90,7 +90,7 @@ function App() {
|
|||||||
<img src={canvasUrl} alt="" />
|
<img src={canvasUrl} alt="" />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: "400px" }}>
|
<div style={{ height: "400px" }}>
|
||||||
<Excalidraw ref={(api) => setExcalidrawAPI(api)}
|
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
|
||||||
import { convertToExcalidrawElements} from "@excalidraw/excalidraw"
|
import { convertToExcalidrawElements} from "@excalidraw/excalidraw"
|
||||||
try {
|
try {
|
||||||
const { elements, files } = await parseMermaid(mermaidSyntax: string, {
|
const { elements, files } = await parseMermaidToExcalidraw(mermaidSyntax: string, {
|
||||||
fontSize: number,
|
fontSize: number,
|
||||||
});
|
});
|
||||||
const excalidrawElements = convertToExcalidrawElements(elements);
|
const excalidrawElements = convertToExcalidrawElements(elements);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ When saving an Excalidraw scene locally to a file, the JSON file (`.excalidraw`)
|
|||||||
|
|
||||||
// editor state (canvas config, preferences, ...)
|
// editor state (canvas config, preferences, ...)
|
||||||
"appState": {
|
"appState": {
|
||||||
"gridSize": null,
|
"gridSize": 20,
|
||||||
"viewBackgroundColor": "#ffffff"
|
"viewBackgroundColor": "#ffffff"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -18,13 +18,13 @@
|
|||||||
"@docusaurus/core": "2.2.0",
|
"@docusaurus/core": "2.2.0",
|
||||||
"@docusaurus/preset-classic": "2.2.0",
|
"@docusaurus/preset-classic": "2.2.0",
|
||||||
"@docusaurus/theme-live-codeblock": "2.2.0",
|
"@docusaurus/theme-live-codeblock": "2.2.0",
|
||||||
"@excalidraw/excalidraw": "0.17.0",
|
"@excalidraw/excalidraw": "0.17.6",
|
||||||
"@mdx-js/react": "^1.6.22",
|
"@mdx-js/react": "^1.6.22",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"docusaurus-plugin-sass": "0.2.3",
|
"docusaurus-plugin-sass": "0.2.3",
|
||||||
"prism-react-renderer": "^1.3.5",
|
"prism-react-renderer": "^1.3.5",
|
||||||
"react": "^17.0.2",
|
"react": "18.2.0",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "18.2.0",
|
||||||
"sass": "1.57.1"
|
"sass": "1.57.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ pre a {
|
|||||||
padding: 5px;
|
padding: 5px;
|
||||||
background: #70b1ec;
|
background: #70b1ec;
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: bold;
|
font-weight: 700;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+45
-26
@@ -1547,7 +1547,7 @@
|
|||||||
"@docusaurus/theme-search-algolia" "2.2.0"
|
"@docusaurus/theme-search-algolia" "2.2.0"
|
||||||
"@docusaurus/types" "2.2.0"
|
"@docusaurus/types" "2.2.0"
|
||||||
|
|
||||||
"@docusaurus/react-loadable@5.5.2", "react-loadable@npm:@docusaurus/react-loadable@5.5.2":
|
"@docusaurus/react-loadable@5.5.2":
|
||||||
version "5.5.2"
|
version "5.5.2"
|
||||||
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
|
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
|
||||||
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
|
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
|
||||||
@@ -1718,10 +1718,10 @@
|
|||||||
url-loader "^4.1.1"
|
url-loader "^4.1.1"
|
||||||
webpack "^5.73.0"
|
webpack "^5.73.0"
|
||||||
|
|
||||||
"@excalidraw/excalidraw@0.17.0":
|
"@excalidraw/excalidraw@0.17.6":
|
||||||
version "0.17.0"
|
version "0.17.6"
|
||||||
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.0.tgz#3c64aa8e36406ac171b008cfecbdce5bb0755725"
|
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.6.tgz#5fd208ce69d33ca712d1804b50d7d06d5c46ac4d"
|
||||||
integrity sha512-NzP22v5xMqxYW27ZtTHhiGFe7kE8NeBk45aoeM/mDSkXiOXPDH+PcvwzHRN/Ei+Vj/0sTPHxejn8bZyRWKGjXg==
|
integrity sha512-fyCl+zG/Z5yhHDh5Fq2ZGmphcrALmuOdtITm8gN4d8w4ntnaopTXcTfnAAaU3VleDC6LhTkoLOTG6P5kgREiIg==
|
||||||
|
|
||||||
"@hapi/hoek@^9.0.0":
|
"@hapi/hoek@^9.0.0":
|
||||||
version "9.3.0"
|
version "9.3.0"
|
||||||
@@ -2789,7 +2789,14 @@ brace-expansion@^1.1.7:
|
|||||||
balanced-match "^1.0.0"
|
balanced-match "^1.0.0"
|
||||||
concat-map "0.0.1"
|
concat-map "0.0.1"
|
||||||
|
|
||||||
braces@^3.0.2, braces@~3.0.2:
|
braces@^3.0.3:
|
||||||
|
version "3.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
|
||||||
|
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
|
||||||
|
dependencies:
|
||||||
|
fill-range "^7.1.1"
|
||||||
|
|
||||||
|
braces@~3.0.2:
|
||||||
version "3.0.2"
|
version "3.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
|
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
|
||||||
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
|
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
|
||||||
@@ -4011,6 +4018,13 @@ fill-range@^7.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range "^5.0.1"
|
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:
|
finalhandler@1.2.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32"
|
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32"
|
||||||
@@ -5207,11 +5221,11 @@ methods@~1.1.2:
|
|||||||
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
|
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
|
||||||
|
|
||||||
micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
|
micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
|
||||||
version "4.0.5"
|
version "4.0.8"
|
||||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
|
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
|
||||||
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
|
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
|
||||||
dependencies:
|
dependencies:
|
||||||
braces "^3.0.2"
|
braces "^3.0.3"
|
||||||
picomatch "^2.3.1"
|
picomatch "^2.3.1"
|
||||||
|
|
||||||
mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
|
mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
|
||||||
@@ -6190,14 +6204,13 @@ react-dev-utils@^12.0.1:
|
|||||||
strip-ansi "^6.0.1"
|
strip-ansi "^6.0.1"
|
||||||
text-table "^0.2.0"
|
text-table "^0.2.0"
|
||||||
|
|
||||||
react-dom@^17.0.2:
|
react-dom@18.2.0:
|
||||||
version "17.0.2"
|
version "18.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
|
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
|
||||||
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
|
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify "^1.1.0"
|
loose-envify "^1.1.0"
|
||||||
object-assign "^4.1.1"
|
scheduler "^0.23.0"
|
||||||
scheduler "^0.20.2"
|
|
||||||
|
|
||||||
react-error-overlay@^6.0.11:
|
react-error-overlay@^6.0.11:
|
||||||
version "6.0.11"
|
version "6.0.11"
|
||||||
@@ -6260,6 +6273,14 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime" "^7.10.3"
|
"@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:
|
react-router-config@^5.1.1:
|
||||||
version "5.1.1"
|
version "5.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988"
|
resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988"
|
||||||
@@ -6310,13 +6331,12 @@ react-textarea-autosize@^8.3.2:
|
|||||||
use-composed-ref "^1.3.0"
|
use-composed-ref "^1.3.0"
|
||||||
use-latest "^1.2.1"
|
use-latest "^1.2.1"
|
||||||
|
|
||||||
react@^17.0.2:
|
react@18.2.0:
|
||||||
version "17.0.2"
|
version "18.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
|
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
||||||
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
|
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify "^1.1.0"
|
loose-envify "^1.1.0"
|
||||||
object-assign "^4.1.1"
|
|
||||||
|
|
||||||
readable-stream@^2.0.1:
|
readable-stream@^2.0.1:
|
||||||
version "2.3.7"
|
version "2.3.7"
|
||||||
@@ -6664,13 +6684,12 @@ sax@^1.2.4:
|
|||||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
|
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
|
||||||
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
|
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
|
||||||
|
|
||||||
scheduler@^0.20.2:
|
scheduler@^0.23.0:
|
||||||
version "0.20.2"
|
version "0.23.2"
|
||||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
|
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3"
|
||||||
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==
|
integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify "^1.1.0"
|
loose-envify "^1.1.0"
|
||||||
object-assign "^4.1.1"
|
|
||||||
|
|
||||||
schema-utils@2.7.0:
|
schema-utils@2.7.0:
|
||||||
version "2.7.0"
|
version "2.7.0"
|
||||||
|
|||||||
+4
-4
@@ -40,7 +40,7 @@ import type {
|
|||||||
} from "@excalidraw/excalidraw/dist/excalidraw/element/types";
|
} from "@excalidraw/excalidraw/dist/excalidraw/element/types";
|
||||||
import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types";
|
import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types";
|
||||||
|
|
||||||
import "./App.scss";
|
import "./ExampleApp.scss";
|
||||||
|
|
||||||
type Comment = {
|
type Comment = {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -73,7 +73,7 @@ export interface AppProps {
|
|||||||
excalidrawLib: typeof TExcalidraw;
|
excalidrawLib: typeof TExcalidraw;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App({
|
export default function ExampleApp({
|
||||||
appTitle,
|
appTitle,
|
||||||
useCustom,
|
useCustom,
|
||||||
customArgs,
|
customArgs,
|
||||||
@@ -872,7 +872,7 @@ export default function App({
|
|||||||
files: excalidrawAPI.getFiles(),
|
files: excalidrawAPI.getFiles(),
|
||||||
});
|
});
|
||||||
const ctx = canvas.getContext("2d")!;
|
const ctx = canvas.getContext("2d")!;
|
||||||
ctx.font = "30px Virgil";
|
ctx.font = "30px Excalifont";
|
||||||
ctx.strokeText("My custom text", 50, 60);
|
ctx.strokeText("My custom text", 50, 60);
|
||||||
setCanvasUrl(canvas.toDataURL());
|
setCanvasUrl(canvas.toDataURL());
|
||||||
}}
|
}}
|
||||||
@@ -893,7 +893,7 @@ export default function App({
|
|||||||
files: excalidrawAPI.getFiles(),
|
files: excalidrawAPI.getFiles(),
|
||||||
});
|
});
|
||||||
const ctx = canvas.getContext("2d")!;
|
const ctx = canvas.getContext("2d")!;
|
||||||
ctx.font = "30px Virgil";
|
ctx.font = "30px Excalifont";
|
||||||
ctx.strokeText("My custom text", 50, 60);
|
ctx.strokeText("My custom text", 50, 60);
|
||||||
setCanvasUrl(canvas.toDataURL());
|
setCanvasUrl(canvas.toDataURL());
|
||||||
}}
|
}}
|
||||||
@@ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [
|
|||||||
];
|
];
|
||||||
export default {
|
export default {
|
||||||
elements,
|
elements,
|
||||||
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 },
|
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 },
|
||||||
scrollToContent: true,
|
scrollToContent: true,
|
||||||
libraryItems: [
|
libraryItems: [
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -34,3 +34,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# copied assets
|
||||||
|
public/*.woff2
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm",
|
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
||||||
|
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
|
||||||
"dev": "yarn build:workspace && next dev -p 3005",
|
"dev": "yarn build:workspace && next dev -p 3005",
|
||||||
"build": "yarn build:workspace && next build",
|
"build": "yarn build:workspace && next build",
|
||||||
"start": "next start -p 3006",
|
"start": "next start -p 3006",
|
||||||
@@ -12,13 +13,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@excalidraw/excalidraw": "*",
|
"@excalidraw/excalidraw": "*",
|
||||||
"next": "14.1",
|
"next": "14.1",
|
||||||
"react": "^18",
|
"react": "18.2.0",
|
||||||
"react-dom": "^18"
|
"react-dom": "18.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "18.2.0",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "18.2.0",
|
||||||
"path2d-polyfill": "2.0.1",
|
"path2d-polyfill": "2.0.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import Script from "next/script";
|
||||||
import "../common.scss";
|
import "../common.scss";
|
||||||
|
|
||||||
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
|
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
|
||||||
@@ -15,7 +16,9 @@ export default function Page() {
|
|||||||
<>
|
<>
|
||||||
<a href="/excalidraw-in-pages">Switch to Pages router</a>
|
<a href="/excalidraw-in-pages">Switch to Pages router</a>
|
||||||
<h1 className="page-title">App Router</h1>
|
<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 */}
|
{/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */}
|
||||||
<ExcalidrawWithClientOnly />
|
<ExcalidrawWithClientOnly />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ a {
|
|||||||
color: #1c7ed6;
|
color: #1c7ed6;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 550;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import * as excalidrawLib from "@excalidraw/excalidraw";
|
import * as excalidrawLib from "@excalidraw/excalidraw";
|
||||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||||
import App from "../../components/App";
|
import App from "../../components/ExampleApp";
|
||||||
|
|
||||||
import "@excalidraw/excalidraw/index.css";
|
import "@excalidraw/excalidraw/index.css";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# copied assets
|
||||||
|
public/*.woff2
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
<title>React App</title>
|
<title>React App</title>
|
||||||
<script>
|
<script>
|
||||||
window.name = "codesandbox";
|
window.name = "codesandbox";
|
||||||
|
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="/dist/browser/dev/index.css" />
|
<link rel="stylesheet" href="/dist/browser/dev/index.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import App from "../components/App";
|
import App from "../components/ExampleApp";
|
||||||
import React, { StrictMode } from "react";
|
import React, { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,10 @@
|
|||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "yarn workspace @excalidraw/excalidraw run build:esm && vite",
|
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
||||||
"build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build",
|
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
|
||||||
|
"start": "yarn build:workspace && vite",
|
||||||
|
"build": "yarn build:workspace && vite build",
|
||||||
"build:preview": "yarn build && vite preview --port 5002"
|
"build:preview": "yarn build && vite preview --port 5002"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-81
@@ -1,5 +1,4 @@
|
|||||||
import polyfill from "../packages/excalidraw/polyfill";
|
import polyfill from "../packages/excalidraw/polyfill";
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { trackEvent } from "../packages/excalidraw/analytics";
|
import { trackEvent } from "../packages/excalidraw/analytics";
|
||||||
import { getDefaultAppState } from "../packages/excalidraw/appState";
|
import { getDefaultAppState } from "../packages/excalidraw/appState";
|
||||||
@@ -22,9 +21,7 @@ import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRef
|
|||||||
import { t } from "../packages/excalidraw/i18n";
|
import { t } from "../packages/excalidraw/i18n";
|
||||||
import {
|
import {
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
defaultLang,
|
|
||||||
LiveCollaborationTrigger,
|
LiveCollaborationTrigger,
|
||||||
TTDDialog,
|
|
||||||
TTDDialogTrigger,
|
TTDDialogTrigger,
|
||||||
StoreAction,
|
StoreAction,
|
||||||
reconcileElements,
|
reconcileElements,
|
||||||
@@ -93,7 +90,7 @@ import {
|
|||||||
import { AppMainMenu } from "./components/AppMainMenu";
|
import { AppMainMenu } from "./components/AppMainMenu";
|
||||||
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
||||||
import { AppFooter } from "./components/AppFooter";
|
import { AppFooter } from "./components/AppFooter";
|
||||||
import { atom, Provider, useAtom, useAtomValue } from "jotai";
|
import { Provider, useAtom, useAtomValue } from "jotai";
|
||||||
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
|
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
|
||||||
import { appJotaiStore } from "./app-jotai";
|
import { appJotaiStore } from "./app-jotai";
|
||||||
|
|
||||||
@@ -121,6 +118,16 @@ import {
|
|||||||
youtubeIcon,
|
youtubeIcon,
|
||||||
} from "../packages/excalidraw/components/icons";
|
} from "../packages/excalidraw/components/icons";
|
||||||
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
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 type { SaveWarningRef } from "./components/SaveWarning";
|
||||||
|
import { SaveWarning } from "./components/SaveWarning";
|
||||||
|
|
||||||
polyfill();
|
polyfill();
|
||||||
|
|
||||||
@@ -172,11 +179,6 @@ if (window.self !== window.top) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const languageDetector = new LanguageDetector();
|
|
||||||
languageDetector.init({
|
|
||||||
languageUtils: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const shareableLinkConfirmDialog = {
|
const shareableLinkConfirmDialog = {
|
||||||
title: t("overwriteConfirm.modal.shareableLink.title"),
|
title: t("overwriteConfirm.modal.shareableLink.title"),
|
||||||
description: (
|
description: (
|
||||||
@@ -322,19 +324,17 @@ const initializeScene = async (opts: {
|
|||||||
return { scene: null, isExternalScene: false };
|
return { scene: null, isExternalScene: false };
|
||||||
};
|
};
|
||||||
|
|
||||||
const detectedLangCode = languageDetector.detect() || defaultLang.code;
|
|
||||||
export const appLangCodeAtom = atom(
|
|
||||||
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
|
|
||||||
);
|
|
||||||
|
|
||||||
const ExcalidrawWrapper = () => {
|
const ExcalidrawWrapper = () => {
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
|
||||||
const isCollabDisabled = isRunningInIframe();
|
const isCollabDisabled = isRunningInIframe();
|
||||||
|
|
||||||
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
||||||
const { editorTheme } = useHandleAppTheme();
|
const { editorTheme } = useHandleAppTheme();
|
||||||
|
|
||||||
|
const [langCode, setLangCode] = useAppLangCode();
|
||||||
|
|
||||||
|
const activityRef = useRef<SaveWarningRef | null>(null);
|
||||||
|
|
||||||
// initial state
|
// initial state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -346,6 +346,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
resolvablePromise<ExcalidrawInitialDataState | null>();
|
resolvablePromise<ExcalidrawInitialDataState | null>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const debugCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
trackEvent("load", "frame", getFrame());
|
trackEvent("load", "frame", getFrame());
|
||||||
// Delayed so that the app has a time to load the latest SW
|
// Delayed so that the app has a time to load the latest SW
|
||||||
@@ -371,6 +373,23 @@ const ExcalidrawWrapper = () => {
|
|||||||
migrationAdapter: LibraryLocalStorageMigrationAdapter,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||||
return;
|
return;
|
||||||
@@ -490,11 +509,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
||||||
const localDataState = importFromLocalStorage();
|
const localDataState = importFromLocalStorage();
|
||||||
const username = importUsernameFromLocalStorage();
|
const username = importUsernameFromLocalStorage();
|
||||||
let langCode = languageDetector.detect() || defaultLang.code;
|
setLangCode(getPreferredLanguage());
|
||||||
if (Array.isArray(langCode)) {
|
|
||||||
langCode = langCode[0];
|
|
||||||
}
|
|
||||||
setLangCode(langCode);
|
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...localDataState,
|
...localDataState,
|
||||||
storeAction: StoreAction.UPDATE,
|
storeAction: StoreAction.UPDATE,
|
||||||
@@ -595,10 +610,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
};
|
};
|
||||||
}, [excalidrawAPI]);
|
}, [excalidrawAPI]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
languageDetector.cacheUserLanguage(langCode);
|
|
||||||
}, [langCode]);
|
|
||||||
|
|
||||||
const onChange = (
|
const onChange = (
|
||||||
elements: readonly OrderedExcalidrawElement[],
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
@@ -608,6 +619,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
collabAPI.syncElements(elements);
|
collabAPI.syncElements(elements);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activityRef.current?.activity();
|
||||||
|
|
||||||
// this check is redundant, but since this is a hot path, it's best
|
// this check is redundant, but since this is a hot path, it's best
|
||||||
// not to evaludate the nested expression every time
|
// not to evaludate the nested expression every time
|
||||||
if (!LocalData.isSavePaused()) {
|
if (!LocalData.isSavePaused()) {
|
||||||
@@ -639,6 +652,16 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render the debug scene if the debug canvas is available
|
||||||
|
if (debugCanvasRef.current && excalidrawAPI) {
|
||||||
|
debugRenderer(
|
||||||
|
debugCanvasRef.current,
|
||||||
|
appState,
|
||||||
|
window.devicePixelRatio,
|
||||||
|
() => forceRefresh((prev) => !prev),
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
|
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
|
||||||
@@ -837,7 +860,9 @@ const ExcalidrawWrapper = () => {
|
|||||||
isCollabEnabled={!isCollabDisabled}
|
isCollabEnabled={!isCollabDisabled}
|
||||||
theme={appTheme}
|
theme={appTheme}
|
||||||
setTheme={(theme) => setAppTheme(theme)}
|
setTheme={(theme) => setAppTheme(theme)}
|
||||||
|
refresh={() => forceRefresh((prev) => !prev)}
|
||||||
/>
|
/>
|
||||||
|
<SaveWarning ref={activityRef} />
|
||||||
<AppWelcomeScreen
|
<AppWelcomeScreen
|
||||||
onCollabDialogOpen={onCollabDialogOpen}
|
onCollabDialogOpen={onCollabDialogOpen}
|
||||||
isCollabEnabled={!isCollabDisabled}
|
isCollabEnabled={!isCollabDisabled}
|
||||||
@@ -862,64 +887,9 @@ const ExcalidrawWrapper = () => {
|
|||||||
</OverwriteConfirmDialog.Action>
|
</OverwriteConfirmDialog.Action>
|
||||||
)}
|
)}
|
||||||
</OverwriteConfirmDialog>
|
</OverwriteConfirmDialog>
|
||||||
<AppFooter />
|
<AppFooter onChange={() => excalidrawAPI?.refresh()} />
|
||||||
<TTDDialog
|
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
|
||||||
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 />
|
<TTDDialogTrigger />
|
||||||
{isCollaborating && isOffline && (
|
{isCollaborating && isOffline && (
|
||||||
<div className="collab-offline-warning">
|
<div className="collab-offline-warning">
|
||||||
@@ -1149,6 +1119,13 @@ const ExcalidrawWrapper = () => {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
{isVisualDebuggerEnabled() && excalidrawAPI && (
|
||||||
|
<DebugCanvas
|
||||||
|
appState={excalidrawAPI.getAppState()}
|
||||||
|
scale={window.devicePixelRatio}
|
||||||
|
ref={debugCanvasRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Excalidraw>
|
</Excalidraw>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { t } from "../packages/excalidraw/i18n";
|
|||||||
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
|
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
|
||||||
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
|
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
|
||||||
import type { UIAppState } from "../packages/excalidraw/types";
|
import type { UIAppState } from "../packages/excalidraw/types";
|
||||||
|
import { Stats } from "../packages/excalidraw";
|
||||||
|
|
||||||
type StorageSizes = { scene: number; total: number };
|
type StorageSizes = { scene: number; total: number };
|
||||||
|
|
||||||
@@ -51,39 +52,33 @@ const CustomStats = (props: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Stats.StatsRows order={-1}>
|
||||||
<tr>
|
<Stats.StatsRow heading>{t("stats.version")}</Stats.StatsRow>
|
||||||
<th colSpan={2}>{t("stats.storage")}</th>
|
<Stats.StatsRow
|
||||||
</tr>
|
style={{ textAlign: "center", cursor: "pointer" }}
|
||||||
<tr>
|
onClick={async () => {
|
||||||
<td>{t("stats.scene")}</td>
|
try {
|
||||||
<td>{nFormatter(storageSizes.scene, 1)}</td>
|
await copyTextToSystemClipboard(getVersion());
|
||||||
</tr>
|
props.setToast(t("toast.copyToClipboard"));
|
||||||
<tr>
|
} catch {}
|
||||||
<td>{t("stats.total")}</td>
|
}}
|
||||||
<td>{nFormatter(storageSizes.total, 1)}</td>
|
title={t("stats.versionCopy")}
|
||||||
</tr>
|
>
|
||||||
<tr>
|
{timestamp}
|
||||||
<th colSpan={2}>{t("stats.version")}</th>
|
<br />
|
||||||
</tr>
|
{hash}
|
||||||
<tr>
|
</Stats.StatsRow>
|
||||||
<td
|
|
||||||
colSpan={2}
|
<Stats.StatsRow heading>{t("stats.storage")}</Stats.StatsRow>
|
||||||
style={{ textAlign: "center", cursor: "pointer" }}
|
<Stats.StatsRow columns={2}>
|
||||||
onClick={async () => {
|
<div>{t("stats.scene")}</div>
|
||||||
try {
|
<div>{nFormatter(storageSizes.scene, 1)}</div>
|
||||||
await copyTextToSystemClipboard(getVersion());
|
</Stats.StatsRow>
|
||||||
props.setToast(t("toast.copyToClipboard"));
|
<Stats.StatsRow columns={2}>
|
||||||
} catch {}
|
<div>{t("stats.total")}</div>
|
||||||
}}
|
<div>{nFormatter(storageSizes.total, 1)}</div>
|
||||||
title={t("stats.versionCopy")}
|
</Stats.StatsRow>
|
||||||
>
|
</Stats.StatsRows>
|
||||||
{timestamp}
|
|
||||||
<br />
|
|
||||||
{hash}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -1,8 +1,7 @@
|
|||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { appLangCodeAtom } from "../App";
|
import { useI18n, languages } from "../../packages/excalidraw/i18n";
|
||||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
import { appLangCodeAtom } from "./language-state";
|
||||||
import { languages } from "../../packages/excalidraw/i18n";
|
|
||||||
|
|
||||||
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
||||||
const { t, langCode } = useI18n();
|
const { t, langCode } = useI18n();
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
@@ -40,6 +40,7 @@ export const STORAGE_KEYS = {
|
|||||||
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
||||||
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
||||||
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
||||||
|
LOCAL_STORAGE_DEBUG: "excalidraw-debug",
|
||||||
VERSION_DATA_STATE: "version-dataState",
|
VERSION_DATA_STATE: "version-dataState",
|
||||||
VERSION_FILES: "version-files",
|
VERSION_FILES: "version-files",
|
||||||
|
|
||||||
|
|||||||
@@ -116,20 +116,26 @@ class Portal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.collab.excalidrawAPI.updateScene({
|
let isChanged = false;
|
||||||
elements: this.collab.excalidrawAPI
|
const newElements = this.collab.excalidrawAPI
|
||||||
.getSceneElementsIncludingDeleted()
|
.getSceneElementsIncludingDeleted()
|
||||||
.map((element) => {
|
.map((element) => {
|
||||||
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
|
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
|
||||||
// this will signal collaborators to pull image data from server
|
isChanged = true;
|
||||||
// (using mutation instead of newElementWith otherwise it'd break
|
// this will signal collaborators to pull image data from server
|
||||||
// in-progress dragging)
|
// (using mutation instead of newElementWith otherwise it'd break
|
||||||
return newElementWith(element, { status: "saved" });
|
// in-progress dragging)
|
||||||
}
|
return newElementWith(element, { status: "saved" });
|
||||||
return element;
|
}
|
||||||
}),
|
return element;
|
||||||
storeAction: StoreAction.UPDATE,
|
});
|
||||||
});
|
|
||||||
|
if (isChanged) {
|
||||||
|
this.collab.excalidrawAPI.updateScene({
|
||||||
|
elements: newElements,
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
|
});
|
||||||
|
}
|
||||||
}, FILE_UPLOAD_TIMEOUT);
|
}, FILE_UPLOAD_TIMEOUT);
|
||||||
|
|
||||||
broadcastScene = async (
|
broadcastScene = async (
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
import { useRef, useState } from "react";
|
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
|
||||||
|
|
||||||
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
|
||||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
|
||||||
import { getFrame } from "../../packages/excalidraw/utils";
|
|
||||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
|
||||||
import { KEYS } from "../../packages/excalidraw/keys";
|
|
||||||
|
|
||||||
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
|
||||||
import {
|
|
||||||
copyIcon,
|
|
||||||
playerPlayIcon,
|
|
||||||
playerStopFilledIcon,
|
|
||||||
share,
|
|
||||||
shareIOS,
|
|
||||||
shareWindows,
|
|
||||||
tablerCheckIcon,
|
|
||||||
} from "../../packages/excalidraw/components/icons";
|
|
||||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
|
||||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
|
||||||
|
|
||||||
import { ReactComponent as CollabImage } from "../../packages/excalidraw/assets/lock.svg";
|
|
||||||
import "./RoomDialog.scss";
|
|
||||||
|
|
||||||
const getShareIcon = () => {
|
|
||||||
const navigator = window.navigator as any;
|
|
||||||
const isAppleBrowser = /Apple/.test(navigator.vendor);
|
|
||||||
const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
|
|
||||||
|
|
||||||
if (isAppleBrowser) {
|
|
||||||
return shareIOS;
|
|
||||||
} else if (isWindowsBrowser) {
|
|
||||||
return shareWindows;
|
|
||||||
}
|
|
||||||
|
|
||||||
return share;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RoomModalProps = {
|
|
||||||
handleClose: () => void;
|
|
||||||
activeRoomLink: string;
|
|
||||||
username: string;
|
|
||||||
onUsernameChange: (username: string) => void;
|
|
||||||
onRoomCreate: () => void;
|
|
||||||
onRoomDestroy: () => void;
|
|
||||||
setErrorMessage: (message: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RoomModal = ({
|
|
||||||
activeRoomLink,
|
|
||||||
onRoomCreate,
|
|
||||||
onRoomDestroy,
|
|
||||||
setErrorMessage,
|
|
||||||
username,
|
|
||||||
onUsernameChange,
|
|
||||||
handleClose,
|
|
||||||
}: RoomModalProps) => {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const [justCopied, setJustCopied] = useState(false);
|
|
||||||
const timerRef = useRef<number>(0);
|
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
|
||||||
const isShareSupported = "share" in navigator;
|
|
||||||
|
|
||||||
const copyRoomLink = async () => {
|
|
||||||
try {
|
|
||||||
await copyTextToSystemClipboard(activeRoomLink);
|
|
||||||
} catch (e) {
|
|
||||||
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
|
|
||||||
}
|
|
||||||
setJustCopied(true);
|
|
||||||
|
|
||||||
if (timerRef.current) {
|
|
||||||
window.clearTimeout(timerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
timerRef.current = window.setTimeout(() => {
|
|
||||||
setJustCopied(false);
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
ref.current?.select();
|
|
||||||
};
|
|
||||||
|
|
||||||
const shareRoomLink = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.share({
|
|
||||||
title: t("roomDialog.shareTitle"),
|
|
||||||
text: t("roomDialog.shareTitle"),
|
|
||||||
url: activeRoomLink,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
// Just ignore.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (activeRoomLink) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h3 className="RoomDialog__active__header">
|
|
||||||
{t("labels.liveCollaboration")}
|
|
||||||
</h3>
|
|
||||||
<TextField
|
|
||||||
value={username}
|
|
||||||
placeholder="Your name"
|
|
||||||
label="Your name"
|
|
||||||
onChange={onUsernameChange}
|
|
||||||
onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
|
|
||||||
/>
|
|
||||||
<div className="RoomDialog__active__linkRow">
|
|
||||||
<TextField
|
|
||||||
ref={ref}
|
|
||||||
label="Link"
|
|
||||||
readonly
|
|
||||||
fullWidth
|
|
||||||
value={activeRoomLink}
|
|
||||||
/>
|
|
||||||
{isShareSupported && (
|
|
||||||
<FilledButton
|
|
||||||
size="large"
|
|
||||||
variant="icon"
|
|
||||||
label="Share"
|
|
||||||
icon={getShareIcon()}
|
|
||||||
className="RoomDialog__active__share"
|
|
||||||
onClick={shareRoomLink}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Popover.Root open={justCopied}>
|
|
||||||
<Popover.Trigger asChild>
|
|
||||||
<FilledButton
|
|
||||||
size="large"
|
|
||||||
label="Copy link"
|
|
||||||
icon={copyIcon}
|
|
||||||
onClick={copyRoomLink}
|
|
||||||
/>
|
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Content
|
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
||||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
|
||||||
className="RoomDialog__popover"
|
|
||||||
side="top"
|
|
||||||
align="end"
|
|
||||||
sideOffset={5.5}
|
|
||||||
>
|
|
||||||
{tablerCheckIcon} copied
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
</div>
|
|
||||||
<div className="RoomDialog__active__description">
|
|
||||||
<p>
|
|
||||||
<span
|
|
||||||
role="img"
|
|
||||||
aria-hidden="true"
|
|
||||||
className="RoomDialog__active__description__emoji"
|
|
||||||
>
|
|
||||||
🔒{" "}
|
|
||||||
</span>
|
|
||||||
{t("roomDialog.desc_privacy")}
|
|
||||||
</p>
|
|
||||||
<p>{t("roomDialog.desc_exitSession")}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="RoomDialog__active__actions">
|
|
||||||
<FilledButton
|
|
||||||
size="large"
|
|
||||||
variant="outlined"
|
|
||||||
color="danger"
|
|
||||||
label={t("roomDialog.button_stopSession")}
|
|
||||||
icon={playerStopFilledIcon}
|
|
||||||
onClick={() => {
|
|
||||||
trackEvent("share", "room closed");
|
|
||||||
onRoomDestroy();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="RoomDialog__inactive__illustration">
|
|
||||||
<CollabImage />
|
|
||||||
</div>
|
|
||||||
<div className="RoomDialog__inactive__header">
|
|
||||||
{t("labels.liveCollaboration")}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="RoomDialog__inactive__description">
|
|
||||||
<strong>{t("roomDialog.desc_intro")}</strong>
|
|
||||||
{t("roomDialog.desc_privacy")}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="RoomDialog__inactive__start_session">
|
|
||||||
<FilledButton
|
|
||||||
size="large"
|
|
||||||
label={t("roomDialog.button_startSession")}
|
|
||||||
icon={playerPlayIcon}
|
|
||||||
onClick={() => {
|
|
||||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
|
||||||
onRoomCreate();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const RoomDialog = (props: RoomModalProps) => {
|
|
||||||
return (
|
|
||||||
<Dialog size="small" onCloseRequest={props.handleClose} title={false}>
|
|
||||||
<div className="RoomDialog">
|
|
||||||
<RoomModal {...props} />
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoomDialog;
|
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import type { ExcalidrawImperativeAPI } from "../../packages/excalidraw/types";
|
||||||
|
import {
|
||||||
|
DiagramToCodePlugin,
|
||||||
|
exportToBlob,
|
||||||
|
getTextFromElements,
|
||||||
|
MIME_TYPES,
|
||||||
|
TTDDialog,
|
||||||
|
} from "../../packages/excalidraw";
|
||||||
|
import { getDataURL } from "../../packages/excalidraw/data/blob";
|
||||||
|
import { safelyParseJSON } from "../../packages/excalidraw/utils";
|
||||||
|
|
||||||
|
export const AIComponents = ({
|
||||||
|
excalidrawAPI,
|
||||||
|
}: {
|
||||||
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DiagramToCodePlugin
|
||||||
|
generate={async ({ frame, children }) => {
|
||||||
|
const appState = excalidrawAPI.getAppState();
|
||||||
|
|
||||||
|
const blob = await exportToBlob({
|
||||||
|
elements: children,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
exportBackground: true,
|
||||||
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
|
},
|
||||||
|
exportingFrame: frame,
|
||||||
|
files: excalidrawAPI.getFiles(),
|
||||||
|
mimeType: MIME_TYPES.jpg,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataURL = await getDataURL(blob);
|
||||||
|
|
||||||
|
const textFromFrameChildren = getTextFromElements(children);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_AI_BACKEND
|
||||||
|
}/v1/ai/diagram-to-code/generate`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
texts: textFromFrameChildren,
|
||||||
|
image: dataURL,
|
||||||
|
theme: appState.theme,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
const errorJSON = safelyParseJSON(text);
|
||||||
|
|
||||||
|
if (!errorJSON) {
|
||||||
|
throw new Error(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorJSON.statusCode === 429) {
|
||||||
|
return {
|
||||||
|
html: `<html>
|
||||||
|
<body style="margin: 0; text-align: center">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; flex-direction: column; height: 100vh; padding: 0 60px">
|
||||||
|
<div style="color:red">Too many requests today,</br>please try again tomorrow!</div>
|
||||||
|
</br>
|
||||||
|
</br>
|
||||||
|
<div>You can also try <a href="${
|
||||||
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorJSON.message || text);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { html } = await response.json();
|
||||||
|
|
||||||
|
if (!html) {
|
||||||
|
throw new Error("Generation failed (invalid response)");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error("Generation failed (invalid response)");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TTDDialog
|
||||||
|
onTextSubmit={async (input) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_AI_BACKEND
|
||||||
|
}/v1/ai/text-to-diagram/generate`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ prompt: input }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const rateLimit = response.headers.has("X-Ratelimit-Limit")
|
||||||
|
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const rateLimitRemaining = response.headers.has(
|
||||||
|
"X-Ratelimit-Remaining",
|
||||||
|
)
|
||||||
|
? parseInt(
|
||||||
|
response.headers.get("X-Ratelimit-Remaining") || "0",
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 429) {
|
||||||
|
return {
|
||||||
|
rateLimit,
|
||||||
|
rateLimitRemaining,
|
||||||
|
error: new Error(
|
||||||
|
"Too many requests today, please try again tomorrow!",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(json.message || "Generation failed...");
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedResponse = json.generatedResponse;
|
||||||
|
if (!generatedResponse) {
|
||||||
|
throw new Error("Generation failed...");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { generatedResponse, rateLimit, rateLimitRemaining };
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new Error("Request failed");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,23 +3,27 @@ import { Footer } from "../../packages/excalidraw/index";
|
|||||||
import { EncryptedIcon } from "./EncryptedIcon";
|
import { EncryptedIcon } from "./EncryptedIcon";
|
||||||
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
|
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
|
||||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
|
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
|
||||||
|
|
||||||
export const AppFooter = React.memo(() => {
|
export const AppFooter = React.memo(
|
||||||
return (
|
({ onChange }: { onChange: () => void }) => {
|
||||||
<Footer>
|
return (
|
||||||
<div
|
<Footer>
|
||||||
style={{
|
<div
|
||||||
display: "flex",
|
style={{
|
||||||
gap: ".5rem",
|
display: "flex",
|
||||||
alignItems: "center",
|
gap: ".5rem",
|
||||||
}}
|
alignItems: "center",
|
||||||
>
|
}}
|
||||||
{isExcalidrawPlusSignedUser ? (
|
>
|
||||||
<ExcalidrawPlusAppLink />
|
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
|
||||||
) : (
|
{isExcalidrawPlusSignedUser ? (
|
||||||
<EncryptedIcon />
|
<ExcalidrawPlusAppLink />
|
||||||
)}
|
) : (
|
||||||
</div>
|
<EncryptedIcon />
|
||||||
</Footer>
|
)}
|
||||||
);
|
</div>
|
||||||
});
|
</Footer>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import React from "react";
|
|||||||
import {
|
import {
|
||||||
loginIcon,
|
loginIcon,
|
||||||
ExcalLogo,
|
ExcalLogo,
|
||||||
|
eyeIcon,
|
||||||
} from "../../packages/excalidraw/components/icons";
|
} from "../../packages/excalidraw/components/icons";
|
||||||
import type { Theme } from "../../packages/excalidraw/element/types";
|
import type { Theme } from "../../packages/excalidraw/element/types";
|
||||||
import { MainMenu } from "../../packages/excalidraw/index";
|
import { MainMenu } from "../../packages/excalidraw/index";
|
||||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
import { LanguageList } from "./LanguageList";
|
import { LanguageList } from "../app-language/LanguageList";
|
||||||
|
import { saveDebugState } from "./DebugCanvas";
|
||||||
|
|
||||||
export const AppMainMenu: React.FC<{
|
export const AppMainMenu: React.FC<{
|
||||||
onCollabDialogOpen: () => any;
|
onCollabDialogOpen: () => any;
|
||||||
@@ -14,6 +16,7 @@ export const AppMainMenu: React.FC<{
|
|||||||
isCollabEnabled: boolean;
|
isCollabEnabled: boolean;
|
||||||
theme: Theme | "system";
|
theme: Theme | "system";
|
||||||
setTheme: (theme: Theme | "system") => void;
|
setTheme: (theme: Theme | "system") => void;
|
||||||
|
refresh: () => void;
|
||||||
}> = React.memo((props) => {
|
}> = React.memo((props) => {
|
||||||
return (
|
return (
|
||||||
<MainMenu>
|
<MainMenu>
|
||||||
@@ -28,13 +31,14 @@ export const AppMainMenu: React.FC<{
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
|
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
|
||||||
|
<MainMenu.DefaultItems.SearchMenu />
|
||||||
<MainMenu.DefaultItems.Help />
|
<MainMenu.DefaultItems.Help />
|
||||||
<MainMenu.DefaultItems.ClearCanvas />
|
<MainMenu.DefaultItems.ClearCanvas />
|
||||||
<MainMenu.Separator />
|
<MainMenu.Separator />
|
||||||
<MainMenu.ItemLink
|
<MainMenu.ItemLink
|
||||||
icon={ExcalLogo}
|
icon={ExcalLogo}
|
||||||
href={`${
|
href={`${
|
||||||
import.meta.env.VITE_APP_PLUS_APP
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
|
||||||
className=""
|
className=""
|
||||||
>
|
>
|
||||||
@@ -50,6 +54,23 @@ export const AppMainMenu: React.FC<{
|
|||||||
>
|
>
|
||||||
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
|
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
|
||||||
</MainMenu.ItemLink>
|
</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.Separator />
|
||||||
<MainMenu.DefaultItems.ToggleTheme
|
<MainMenu.DefaultItems.ToggleTheme
|
||||||
allowSystemTheme
|
allowSystemTheme
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||||
|
import { type AppState } from "../../packages/excalidraw/types";
|
||||||
|
import { throttleRAF } from "../../packages/excalidraw/utils";
|
||||||
|
import {
|
||||||
|
bootstrapCanvas,
|
||||||
|
getNormalizedCanvasDimensions,
|
||||||
|
} from "../../packages/excalidraw/renderer/helpers";
|
||||||
|
import type { DebugElement } from "../../packages/excalidraw/visualdebug";
|
||||||
|
import {
|
||||||
|
ArrowheadArrowIcon,
|
||||||
|
CloseIcon,
|
||||||
|
TrashIcon,
|
||||||
|
} from "../../packages/excalidraw/components/icons";
|
||||||
|
import { STORAGE_KEYS } from "../app_constants";
|
||||||
|
import {
|
||||||
|
isLineSegment,
|
||||||
|
type GlobalPoint,
|
||||||
|
type LineSegment,
|
||||||
|
} from "../../packages/math";
|
||||||
|
|
||||||
|
const renderLine = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
zoom: number,
|
||||||
|
segment: LineSegment<GlobalPoint>,
|
||||||
|
color: string,
|
||||||
|
) => {
|
||||||
|
context.save();
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(segment[0][0] * zoom, segment[0][1] * zoom);
|
||||||
|
context.lineTo(segment[1][0] * zoom, segment[1][1] * zoom);
|
||||||
|
context.stroke();
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
|
||||||
|
context.strokeStyle = "#888";
|
||||||
|
context.save();
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(-10 * zoom, -10 * zoom);
|
||||||
|
context.lineTo(10 * zoom, 10 * zoom);
|
||||||
|
context.moveTo(10 * zoom, -10 * zoom);
|
||||||
|
context.lineTo(-10 * zoom, 10 * zoom);
|
||||||
|
context.stroke();
|
||||||
|
context.save();
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = (
|
||||||
|
frame: DebugElement[],
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
frame.forEach((el: DebugElement) => {
|
||||||
|
switch (true) {
|
||||||
|
case isLineSegment(el.data):
|
||||||
|
renderLine(
|
||||||
|
context,
|
||||||
|
appState.zoom.value,
|
||||||
|
el.data as LineSegment<GlobalPoint>,
|
||||||
|
el.color,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const _debugRenderer = (
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
appState: AppState,
|
||||||
|
scale: number,
|
||||||
|
refresh: () => void,
|
||||||
|
) => {
|
||||||
|
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
|
||||||
|
canvas,
|
||||||
|
scale,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (appState.height !== canvas.height || appState.width !== canvas.width) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = bootstrapCanvas({
|
||||||
|
canvas,
|
||||||
|
scale,
|
||||||
|
normalizedWidth,
|
||||||
|
normalizedHeight,
|
||||||
|
viewBackgroundColor: "transparent",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply zoom
|
||||||
|
context.save();
|
||||||
|
context.translate(
|
||||||
|
appState.scrollX * appState.zoom.value,
|
||||||
|
appState.scrollY * appState.zoom.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
renderOrigin(context, appState.zoom.value);
|
||||||
|
|
||||||
|
if (
|
||||||
|
window.visualDebug?.currentFrame &&
|
||||||
|
window.visualDebug?.data &&
|
||||||
|
window.visualDebug.data.length > 0
|
||||||
|
) {
|
||||||
|
// Render only one frame
|
||||||
|
const [idx] = debugFrameData();
|
||||||
|
|
||||||
|
render(window.visualDebug.data[idx], context, appState);
|
||||||
|
} else {
|
||||||
|
// Render all debug frames
|
||||||
|
window.visualDebug?.data.forEach((frame) => {
|
||||||
|
render(frame, context, appState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.visualDebug) {
|
||||||
|
window.visualDebug!.data =
|
||||||
|
window.visualDebug?.data.map((frame) =>
|
||||||
|
frame.filter((el) => el.permanent),
|
||||||
|
) ?? [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const debugFrameData = (): [number, number] => {
|
||||||
|
const currentFrame = window.visualDebug?.currentFrame ?? 0;
|
||||||
|
const frameCount = window.visualDebug?.data.length ?? 0;
|
||||||
|
|
||||||
|
if (frameCount > 0) {
|
||||||
|
return [currentFrame % frameCount, window.visualDebug?.currentFrame ?? 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [0, 0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveDebugState = (debug: { enabled: boolean }) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
|
||||||
|
JSON.stringify(debug),
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const debugRenderer = throttleRAF(
|
||||||
|
(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
appState: AppState,
|
||||||
|
scale: number,
|
||||||
|
refresh: () => void,
|
||||||
|
) => {
|
||||||
|
_debugRenderer(canvas, appState, scale, refresh);
|
||||||
|
},
|
||||||
|
{ trailing: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const loadSavedDebugState = () => {
|
||||||
|
let debug;
|
||||||
|
try {
|
||||||
|
const savedDebugState = localStorage.getItem(
|
||||||
|
STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
|
||||||
|
);
|
||||||
|
if (savedDebugState) {
|
||||||
|
debug = JSON.parse(savedDebugState) as { enabled: boolean };
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return debug ?? { enabled: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isVisualDebuggerEnabled = () =>
|
||||||
|
Array.isArray(window.visualDebug?.data);
|
||||||
|
|
||||||
|
export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
|
||||||
|
const moveForward = useCallback(() => {
|
||||||
|
if (
|
||||||
|
!window.visualDebug?.currentFrame ||
|
||||||
|
isNaN(window.visualDebug?.currentFrame ?? -1)
|
||||||
|
) {
|
||||||
|
window.visualDebug!.currentFrame = 0;
|
||||||
|
}
|
||||||
|
window.visualDebug!.currentFrame += 1;
|
||||||
|
onChange();
|
||||||
|
}, [onChange]);
|
||||||
|
const moveBackward = useCallback(() => {
|
||||||
|
if (
|
||||||
|
!window.visualDebug?.currentFrame ||
|
||||||
|
isNaN(window.visualDebug?.currentFrame ?? -1) ||
|
||||||
|
window.visualDebug?.currentFrame < 1
|
||||||
|
) {
|
||||||
|
window.visualDebug!.currentFrame = 1;
|
||||||
|
}
|
||||||
|
window.visualDebug!.currentFrame -= 1;
|
||||||
|
onChange();
|
||||||
|
}, [onChange]);
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
window.visualDebug!.currentFrame = undefined;
|
||||||
|
onChange();
|
||||||
|
}, [onChange]);
|
||||||
|
const trashFrames = useCallback(() => {
|
||||||
|
if (window.visualDebug) {
|
||||||
|
window.visualDebug.currentFrame = undefined;
|
||||||
|
window.visualDebug.data = [];
|
||||||
|
}
|
||||||
|
onChange();
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="ToolIcon_type_button"
|
||||||
|
data-testid="debug-forward"
|
||||||
|
aria-label="Move forward"
|
||||||
|
type="button"
|
||||||
|
onClick={trashFrames}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="ToolIcon__icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
aria-disabled="false"
|
||||||
|
>
|
||||||
|
{TrashIcon}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ToolIcon_type_button"
|
||||||
|
data-testid="debug-forward"
|
||||||
|
aria-label="Move forward"
|
||||||
|
type="button"
|
||||||
|
onClick={moveBackward}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="ToolIcon__icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
aria-disabled="false"
|
||||||
|
>
|
||||||
|
<ArrowheadArrowIcon flip />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ToolIcon_type_button"
|
||||||
|
data-testid="debug-forward"
|
||||||
|
aria-label="Move forward"
|
||||||
|
type="button"
|
||||||
|
onClick={reset}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="ToolIcon__icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
aria-disabled="false"
|
||||||
|
>
|
||||||
|
{CloseIcon}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ToolIcon_type_button"
|
||||||
|
data-testid="debug-backward"
|
||||||
|
aria-label="Move backward"
|
||||||
|
type="button"
|
||||||
|
onClick={moveForward}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="ToolIcon__icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
aria-disabled="false"
|
||||||
|
>
|
||||||
|
<ArrowheadArrowIcon />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DebugCanvasProps {
|
||||||
|
appState: AppState;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DebugCanvas = forwardRef<HTMLCanvasElement, DebugCanvasProps>(
|
||||||
|
({ appState, scale }, ref) => {
|
||||||
|
const { width, height } = appState;
|
||||||
|
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
|
||||||
|
ref,
|
||||||
|
() => canvasRef.current,
|
||||||
|
[canvasRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
position: "absolute",
|
||||||
|
zIndex: 2,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
width={width * scale}
|
||||||
|
height={height * scale}
|
||||||
|
ref={canvasRef}
|
||||||
|
>
|
||||||
|
Debug Canvas
|
||||||
|
</canvas>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DebugCanvas;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { forwardRef, useImperativeHandle, useRef } from "react";
|
||||||
|
import { t } from "../../packages/excalidraw/i18n";
|
||||||
|
import { getShortcutKey } from "../../packages/excalidraw/utils";
|
||||||
|
|
||||||
|
export type SaveWarningRef = {
|
||||||
|
activity: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SaveWarning = forwardRef<SaveWarningRef, {}>((props, ref) => {
|
||||||
|
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
/**
|
||||||
|
* Call this API method via the ref to hide warning message
|
||||||
|
* and start an idle timer again.
|
||||||
|
*/
|
||||||
|
activity: async () => {
|
||||||
|
if (timerRef.current != null) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
dialogRef.current?.classList.remove("animate");
|
||||||
|
}
|
||||||
|
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
timerRef.current = null;
|
||||||
|
dialogRef.current?.classList.add("animate");
|
||||||
|
}, 5000);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={dialogRef} className="alert-save">
|
||||||
|
<div className="dialog">
|
||||||
|
{t("alerts.saveYourContent", {
|
||||||
|
shortcut: getShortcutKey("CtrlOrCmd + S"),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -20,6 +20,10 @@ import {
|
|||||||
get,
|
get,
|
||||||
} from "idb-keyval";
|
} from "idb-keyval";
|
||||||
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
|
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 { LibraryPersistedData } from "../../packages/excalidraw/data/library";
|
||||||
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||||
@@ -66,13 +70,22 @@ const saveDataStateToLocalStorage = (
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
const _appState = clearAppStateForLocalStorage(appState);
|
||||||
|
|
||||||
|
if (
|
||||||
|
_appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
||||||
|
_appState.openSidebar.tab === CANVAS_SEARCH_TAB
|
||||||
|
) {
|
||||||
|
_appState.openSidebar = null;
|
||||||
|
}
|
||||||
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||||
JSON.stringify(clearElementsForLocalStorage(elements)),
|
JSON.stringify(clearElementsForLocalStorage(elements)),
|
||||||
);
|
);
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||||
JSON.stringify(clearAppStateForLocalStorage(appState)),
|
JSON.stringify(_appState),
|
||||||
);
|
);
|
||||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
+29
-17
@@ -95,6 +95,11 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
</style>
|
</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) { %>
|
<% if (typeof PROD != 'undefined' && PROD == true) { %>
|
||||||
<script>
|
<script>
|
||||||
@@ -115,8 +120,32 @@
|
|||||||
window.location.href = "https://app.excalidraw.com";
|
window.location.href = "https://app.excalidraw.com";
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Following placeholder is replaced during the build step -->
|
||||||
|
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
|
||||||
|
|
||||||
|
<% } else { %>
|
||||||
|
<script>
|
||||||
|
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
||||||
|
</script>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
<!-- For Nunito only preload the latin range, which should be good enough for now -->
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Register Assistant as the UI font, before the scene inits -->
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="../packages/excalidraw/fonts/assets/fonts.css"
|
||||||
|
type="text/css"
|
||||||
|
/>
|
||||||
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
@@ -124,22 +153,6 @@
|
|||||||
<!-- Excalidraw version -->
|
<!-- Excalidraw version -->
|
||||||
<meta name="version" content="{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' &&
|
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
|
||||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
|
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
|
||||||
<script>
|
<script>
|
||||||
@@ -158,7 +171,6 @@
|
|||||||
</script>
|
</script>
|
||||||
<% } %>
|
<% } %>
|
||||||
<script>
|
<script>
|
||||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
|
||||||
// setting this so that libraries installation reuses this window tab.
|
// setting this so that libraries installation reuses this window tab.
|
||||||
window.name = "_excalidraw";
|
window.name = "_excalidraw";
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -18,6 +18,43 @@
|
|||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alert-save {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 10vh;
|
||||||
|
margin-inline: auto;
|
||||||
|
width: fit-content;
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0s;
|
||||||
|
|
||||||
|
&.animate {
|
||||||
|
opacity: 1;
|
||||||
|
transition: all 0.2s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
margin-inline: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
padding-inline: 1.25rem;
|
||||||
|
|
||||||
|
resize: none;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
background-color: var(--color-warning);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
border: 1px solid var(--dialog-border-color);
|
||||||
|
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.encrypted-icon {
|
.encrypted-icon {
|
||||||
border-radius: var(--space-factor);
|
border-radius: var(--space-factor);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
@@ -25,6 +62,7 @@
|
|||||||
margin-bottom: auto;
|
margin-bottom: auto;
|
||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
margin-inline-end: 0.6em;
|
margin-inline-end: 0.6em;
|
||||||
|
z-index: var(--zIndex-layerUI);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 1.2rem;
|
width: 1.2rem;
|
||||||
|
|||||||
@@ -26,17 +26,28 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vite-plugin-html": "3.2.2"
|
"firebase": "8.3.3",
|
||||||
|
"idb-keyval": "6.0.3",
|
||||||
|
"jotai": "1.13.1",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"vite-plugin-html": "3.2.2",
|
||||||
|
"@excalidraw/random-username": "1.0.0",
|
||||||
|
"@sentry/browser": "6.2.5",
|
||||||
|
"@sentry/integrations": "6.2.5",
|
||||||
|
"i18next-browser-languagedetector": "6.1.4",
|
||||||
|
"socket.io-client": "4.7.2"
|
||||||
},
|
},
|
||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
|
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
|
||||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
|
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
|
||||||
"build:version": "node ../scripts/build-version.js",
|
"build:version": "node ../scripts/build-version.js",
|
||||||
"build": "yarn build:app && yarn build:version",
|
"build": "yarn build:app && yarn build:version",
|
||||||
"start": "yarn && vite",
|
"start": "yarn && vite",
|
||||||
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
"start:production": "yarn build && yarn serve",
|
||||||
|
"serve": "npx http-server build -a localhost -p 5001 -o",
|
||||||
"build:preview": "yarn build && vite preview --port 5000"
|
"build:preview": "yarn build && vite preview --port 5000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,8 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 110%;
|
line-height: 110%;
|
||||||
|
|
||||||
background: var(--color-success-lighter);
|
background: var(--color-success);
|
||||||
color: var(--color-success);
|
color: var(--color-success-text);
|
||||||
|
|
||||||
& > svg {
|
& > svg {
|
||||||
width: 0.875rem;
|
width: 0.875rem;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
|
||||||
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
||||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||||
import { getFrame } from "../../packages/excalidraw/utils";
|
import { getFrame } from "../../packages/excalidraw/utils";
|
||||||
@@ -14,7 +13,6 @@ import {
|
|||||||
share,
|
share,
|
||||||
shareIOS,
|
shareIOS,
|
||||||
shareWindows,
|
shareWindows,
|
||||||
tablerCheckIcon,
|
|
||||||
} from "../../packages/excalidraw/components/icons";
|
} from "../../packages/excalidraw/components/icons";
|
||||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||||
@@ -24,6 +22,7 @@ import { atom, useAtom, useAtomValue } from "jotai";
|
|||||||
|
|
||||||
import "./ShareDialog.scss";
|
import "./ShareDialog.scss";
|
||||||
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
||||||
|
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
|
||||||
|
|
||||||
type OnExportToBackend = () => void;
|
type OnExportToBackend = () => void;
|
||||||
type ShareDialogType = "share" | "collaborationOnly";
|
type ShareDialogType = "share" | "collaborationOnly";
|
||||||
@@ -63,10 +62,11 @@ const ActiveRoomDialog = ({
|
|||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [justCopied, setJustCopied] = useState(false);
|
const [, setJustCopied] = useState(false);
|
||||||
const timerRef = useRef<number>(0);
|
const timerRef = useRef<number>(0);
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
const isShareSupported = "share" in navigator;
|
const isShareSupported = "share" in navigator;
|
||||||
|
const { onCopy, copyStatus } = useCopyStatus();
|
||||||
|
|
||||||
const copyRoomLink = async () => {
|
const copyRoomLink = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -130,26 +130,16 @@ const ActiveRoomDialog = ({
|
|||||||
onClick={shareRoomLink}
|
onClick={shareRoomLink}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Popover.Root open={justCopied}>
|
<FilledButton
|
||||||
<Popover.Trigger asChild>
|
size="large"
|
||||||
<FilledButton
|
label={t("buttons.copyLink")}
|
||||||
size="large"
|
icon={copyIcon}
|
||||||
label="Copy link"
|
status={copyStatus}
|
||||||
icon={copyIcon}
|
onClick={() => {
|
||||||
onClick={copyRoomLink}
|
copyRoomLink();
|
||||||
/>
|
onCopy();
|
||||||
</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>
|
||||||
<div className="ShareDialog__active__description">
|
<div className="ShareDialog__active__description">
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
|||||||
class="welcome-screen-center"
|
class="welcome-screen-center"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="welcome-screen-center__logo virgil welcome-screen-decor"
|
class="welcome-screen-center__logo excalifont welcome-screen-decor"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="ExcalidrawLogo is-small"
|
class="ExcalidrawLogo is-small"
|
||||||
@@ -48,7 +48,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="welcome-screen-center__heading welcome-screen-decor virgil"
|
class="welcome-screen-center__heading welcome-screen-decor excalifont"
|
||||||
>
|
>
|
||||||
All your data is saved locally in your browser.
|
All your data is saved locally in your browser.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
act,
|
act,
|
||||||
render,
|
render,
|
||||||
updateSceneData,
|
|
||||||
waitFor,
|
waitFor,
|
||||||
} from "../../packages/excalidraw/tests/test-utils";
|
} from "../../packages/excalidraw/tests/test-utils";
|
||||||
import ExcalidrawApp from "../App";
|
import ExcalidrawApp from "../App";
|
||||||
@@ -88,12 +87,12 @@ describe("collaboration", () => {
|
|||||||
const rect1 = API.createElement({ ...rect1Props });
|
const rect1 = API.createElement({ ...rect1Props });
|
||||||
const rect2 = API.createElement({ ...rect2Props });
|
const rect2 = API.createElement({ ...rect2Props });
|
||||||
|
|
||||||
updateSceneData({
|
API.updateScene({
|
||||||
elements: syncInvalidIndices([rect1, rect2]),
|
elements: syncInvalidIndices([rect1, rect2]),
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
updateSceneData({
|
API.updateScene({
|
||||||
elements: syncInvalidIndices([
|
elements: syncInvalidIndices([
|
||||||
rect1,
|
rect1,
|
||||||
newElementWith(h.elements[1], { isDeleted: true }),
|
newElementWith(h.elements[1], { isDeleted: true }),
|
||||||
@@ -143,7 +142,7 @@ describe("collaboration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// simulate force deleting the element remotely
|
// simulate force deleting the element remotely
|
||||||
updateSceneData({
|
API.updateScene({
|
||||||
elements: syncInvalidIndices([rect1]),
|
elements: syncInvalidIndices([rect1]),
|
||||||
storeAction: StoreAction.UPDATE,
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
@@ -178,7 +177,7 @@ describe("collaboration", () => {
|
|||||||
act(() => h.app.actionManager.executeAction(undoAction));
|
act(() => h.app.actionManager.executeAction(undoAction));
|
||||||
|
|
||||||
// simulate local update
|
// simulate local update
|
||||||
updateSceneData({
|
API.updateScene({
|
||||||
elements: syncInvalidIndices([
|
elements: syncInvalidIndices([
|
||||||
h.elements[0],
|
h.elements[0],
|
||||||
newElementWith(h.elements[1], { x: 100 }),
|
newElementWith(h.elements[1], { x: 100 }),
|
||||||
@@ -216,7 +215,7 @@ describe("collaboration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// simulate force deleting the element remotely
|
// simulate force deleting the element remotely
|
||||||
updateSceneData({
|
API.updateScene({
|
||||||
elements: syncInvalidIndices([rect1]),
|
elements: syncInvalidIndices([rect1]),
|
||||||
storeAction: StoreAction.UPDATE,
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
|
|||||||
import { VitePWA } from "vite-plugin-pwa";
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
import checker from "vite-plugin-checker";
|
import checker from "vite-plugin-checker";
|
||||||
import { createHtmlPlugin } from "vite-plugin-html";
|
import { createHtmlPlugin } from "vite-plugin-html";
|
||||||
|
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
|
||||||
|
|
||||||
// To load .env.local variables
|
// To load .env.local variables
|
||||||
const envVars = loadEnv("", `../`);
|
const envVars = loadEnv("", `../`);
|
||||||
@@ -22,6 +23,14 @@ export default defineConfig({
|
|||||||
outDir: "build",
|
outDir: "build",
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
|
assetFileNames(chunkInfo) {
|
||||||
|
if (chunkInfo?.name?.endsWith(".woff2")) {
|
||||||
|
// put on root so we are flexible about the CDN path
|
||||||
|
return "[name]-[hash][extname]";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "assets/[name]-[hash][extname]";
|
||||||
|
},
|
||||||
// Creating separate chunk for locales except for en and percentages.json so they
|
// Creating separate chunk for locales except for en and percentages.json so they
|
||||||
// can be cached at runtime and not merged with
|
// can be cached at runtime and not merged with
|
||||||
// app precache. en.json and percentages.json are needed for first load
|
// app precache. en.json and percentages.json are needed for first load
|
||||||
@@ -41,6 +50,7 @@ export default defineConfig({
|
|||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
woff2BrowserPlugin(),
|
||||||
react(),
|
react(),
|
||||||
checker({
|
checker({
|
||||||
typescript: true,
|
typescript: true,
|
||||||
@@ -63,8 +73,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
workbox: {
|
workbox: {
|
||||||
// Don't push fonts and locales to app precache
|
// Don't push fonts, locales and wasm to app precache
|
||||||
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
|
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"],
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
|
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
|
||||||
@@ -98,6 +108,17 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
urlPattern: new RegExp(".wasm-.+.js"),
|
||||||
|
handler: "CacheFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "wasm",
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 50,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
|
|||||||
+16
-19
@@ -6,22 +6,12 @@
|
|||||||
"excalidraw-app",
|
"excalidraw-app",
|
||||||
"packages/excalidraw",
|
"packages/excalidraw",
|
||||||
"packages/utils",
|
"packages/utils",
|
||||||
|
"packages/math",
|
||||||
"examples/excalidraw",
|
"examples/excalidraw",
|
||||||
"examples/excalidraw/*"
|
"examples/excalidraw/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
|
||||||
"@excalidraw/random-username": "1.0.0",
|
|
||||||
"@sentry/browser": "6.2.5",
|
|
||||||
"@sentry/integrations": "6.2.5",
|
|
||||||
"firebase": "8.3.3",
|
|
||||||
"i18next-browser-languagedetector": "6.1.4",
|
|
||||||
"idb-keyval": "6.0.3",
|
|
||||||
"jotai": "1.13.1",
|
|
||||||
"react": "18.2.0",
|
|
||||||
"react-dom": "18.2.0",
|
|
||||||
"socket.io-client": "4.7.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/plugin-proposal-private-property-in-object": "7.21.11",
|
||||||
"@excalidraw/eslint-config": "1.0.3",
|
"@excalidraw/eslint-config": "1.0.3",
|
||||||
"@excalidraw/prettier-config": "1.0.2",
|
"@excalidraw/prettier-config": "1.0.2",
|
||||||
"@types/chai": "4.3.0",
|
"@types/chai": "4.3.0",
|
||||||
@@ -31,8 +21,8 @@
|
|||||||
"@types/react-dom": "18.2.0",
|
"@types/react-dom": "18.2.0",
|
||||||
"@types/socket.io-client": "3.0.0",
|
"@types/socket.io-client": "3.0.0",
|
||||||
"@vitejs/plugin-react": "3.1.0",
|
"@vitejs/plugin-react": "3.1.0",
|
||||||
"@vitest/coverage-v8": "0.33.0",
|
"@vitest/coverage-v8": "2.0.5",
|
||||||
"@vitest/ui": "0.32.2",
|
"@vitest/ui": "2.0.5",
|
||||||
"chai": "4.3.6",
|
"chai": "4.3.6",
|
||||||
"dotenv": "16.0.1",
|
"dotenv": "16.0.1",
|
||||||
"eslint-config-prettier": "8.5.0",
|
"eslint-config-prettier": "8.5.0",
|
||||||
@@ -47,12 +37,12 @@
|
|||||||
"rewire": "6.0.0",
|
"rewire": "6.0.0",
|
||||||
"typescript": "4.9.4",
|
"typescript": "4.9.4",
|
||||||
"vite": "5.0.12",
|
"vite": "5.0.12",
|
||||||
"vite-plugin-checker": "0.6.1",
|
"vite-plugin-checker": "0.7.2",
|
||||||
"vite-plugin-ejs": "1.7.0",
|
"vite-plugin-ejs": "1.7.0",
|
||||||
"vite-plugin-pwa": "0.17.4",
|
"vite-plugin-pwa": "0.17.4",
|
||||||
"vite-plugin-svgr": "2.4.0",
|
"vite-plugin-svgr": "4.2.0",
|
||||||
"vitest": "1.5.3",
|
"vitest": "2.0.5",
|
||||||
"vitest-canvas-mock": "0.3.2"
|
"vitest-canvas-mock": "0.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18.0.0 - 20.x.x"
|
"node": "18.0.0 - 20.x.x"
|
||||||
@@ -87,6 +77,13 @@
|
|||||||
"autorelease": "node scripts/autorelease.js",
|
"autorelease": "node scripts/autorelease.js",
|
||||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
"prerelease:excalidraw": "node scripts/prerelease.js",
|
||||||
"build:preview": "yarn build && vite preview --port 5000",
|
"build:preview": "yarn build && vite preview --port 5000",
|
||||||
"release:excalidraw": "node scripts/release.js"
|
"release:excalidraw": "node scripts/release.js",
|
||||||
|
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/*/{build,dist}",
|
||||||
|
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
|
||||||
|
"clean-install": "yarn rm:node_modules && yarn install"
|
||||||
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"@types/react": "18.2.0",
|
||||||
|
"strip-ansi": "6.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
- `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 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)
|
- `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)
|
- 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)
|
||||||
@@ -35,6 +39,8 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
### Breaking Changes
|
### 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)
|
- `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 |
|
| | Before `commitToHistory` | After `storeAction` | Notes |
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys";
|
|||||||
import { getNormalizedZoom } from "../scene";
|
import { getNormalizedZoom } from "../scene";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { getStateForZoom } from "../scene/zoom";
|
import { getStateForZoom } from "../scene/zoom";
|
||||||
import type { AppState, NormalizedZoomValue } from "../types";
|
import type { AppState, Offsets } from "../types";
|
||||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
@@ -38,6 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
|||||||
import type { SceneBounds } from "../element/bounds";
|
import type { SceneBounds } from "../element/bounds";
|
||||||
import { setCursor } from "../cursor";
|
import { setCursor } from "../cursor";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
import { clamp, roundToStep } from "../../math";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
@@ -104,7 +105,9 @@ export const actionClearCanvas = register({
|
|||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
exportEmbedScene: appState.exportEmbedScene,
|
exportEmbedScene: appState.exportEmbedScene,
|
||||||
gridSize: appState.gridSize,
|
gridSize: appState.gridSize,
|
||||||
showStats: appState.showStats,
|
gridStep: appState.gridStep,
|
||||||
|
gridModeEnabled: appState.gridModeEnabled,
|
||||||
|
stats: appState.stats,
|
||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
activeTool:
|
activeTool:
|
||||||
appState.activeTool.type === "image"
|
appState.activeTool.type === "image"
|
||||||
@@ -244,6 +247,7 @@ export const actionResetZoom = register({
|
|||||||
const zoomValueToFitBoundsOnViewport = (
|
const zoomValueToFitBoundsOnViewport = (
|
||||||
bounds: SceneBounds,
|
bounds: SceneBounds,
|
||||||
viewportDimensions: { width: number; height: number },
|
viewportDimensions: { width: number; height: number },
|
||||||
|
viewportZoomFactor: number = 1, // default to 1 if not provided
|
||||||
) => {
|
) => {
|
||||||
const [x1, y1, x2, y2] = bounds;
|
const [x1, y1, x2, y2] = bounds;
|
||||||
const commonBoundsWidth = x2 - x1;
|
const commonBoundsWidth = x2 - x1;
|
||||||
@@ -251,92 +255,89 @@ const zoomValueToFitBoundsOnViewport = (
|
|||||||
const commonBoundsHeight = y2 - y1;
|
const commonBoundsHeight = y2 - y1;
|
||||||
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
|
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
|
||||||
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
|
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
|
||||||
const zoomAdjustedToSteps =
|
|
||||||
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
|
const adjustedZoomValue =
|
||||||
const clampedZoomValueToFitElements = Math.min(
|
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
|
||||||
Math.max(zoomAdjustedToSteps, MIN_ZOOM),
|
|
||||||
1,
|
return Math.min(adjustedZoomValue, 1);
|
||||||
);
|
|
||||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const zoomToFitBounds = ({
|
export const zoomToFitBounds = ({
|
||||||
bounds,
|
bounds,
|
||||||
appState,
|
appState,
|
||||||
|
canvasOffsets,
|
||||||
fitToViewport = false,
|
fitToViewport = false,
|
||||||
viewportZoomFactor = 0.7,
|
viewportZoomFactor = 1,
|
||||||
|
minZoom = -Infinity,
|
||||||
|
maxZoom = Infinity,
|
||||||
}: {
|
}: {
|
||||||
bounds: SceneBounds;
|
bounds: SceneBounds;
|
||||||
|
canvasOffsets?: Offsets;
|
||||||
appState: Readonly<AppState>;
|
appState: Readonly<AppState>;
|
||||||
/** whether to fit content to viewport (beyond >100%) */
|
/** whether to fit content to viewport (beyond >100%) */
|
||||||
fitToViewport: boolean;
|
fitToViewport: boolean;
|
||||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||||
viewportZoomFactor?: number;
|
viewportZoomFactor?: number;
|
||||||
|
minZoom?: number;
|
||||||
|
maxZoom?: number;
|
||||||
}) => {
|
}) => {
|
||||||
|
viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = bounds;
|
const [x1, y1, x2, y2] = bounds;
|
||||||
const centerX = (x1 + x2) / 2;
|
const centerX = (x1 + x2) / 2;
|
||||||
const centerY = (y1 + y2) / 2;
|
const centerY = (y1 + y2) / 2;
|
||||||
|
|
||||||
let newZoomValue;
|
const canvasOffsetLeft = canvasOffsets?.left ?? 0;
|
||||||
let scrollX;
|
const canvasOffsetTop = canvasOffsets?.top ?? 0;
|
||||||
let scrollY;
|
const canvasOffsetRight = canvasOffsets?.right ?? 0;
|
||||||
|
const canvasOffsetBottom = canvasOffsets?.bottom ?? 0;
|
||||||
|
|
||||||
|
const effectiveCanvasWidth =
|
||||||
|
appState.width - canvasOffsetLeft - canvasOffsetRight;
|
||||||
|
const effectiveCanvasHeight =
|
||||||
|
appState.height - canvasOffsetTop - canvasOffsetBottom;
|
||||||
|
|
||||||
|
let adjustedZoomValue;
|
||||||
|
|
||||||
if (fitToViewport) {
|
if (fitToViewport) {
|
||||||
const commonBoundsWidth = x2 - x1;
|
const commonBoundsWidth = x2 - x1;
|
||||||
const commonBoundsHeight = y2 - y1;
|
const commonBoundsHeight = y2 - y1;
|
||||||
|
|
||||||
newZoomValue =
|
adjustedZoomValue =
|
||||||
Math.min(
|
Math.min(
|
||||||
appState.width / commonBoundsWidth,
|
effectiveCanvasWidth / commonBoundsWidth,
|
||||||
appState.height / commonBoundsHeight,
|
effectiveCanvasHeight / commonBoundsHeight,
|
||||||
) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
|
) * viewportZoomFactor;
|
||||||
|
|
||||||
// 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 {
|
} else {
|
||||||
newZoomValue = zoomValueToFitBoundsOnViewport(bounds, {
|
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: {
|
||||||
width: appState.width,
|
width: appState.width,
|
||||||
height: appState.height,
|
height: appState.height,
|
||||||
});
|
},
|
||||||
|
offsets: canvasOffsets,
|
||||||
const centerScroll = centerScrollOn({
|
zoom: { value: newZoomValue },
|
||||||
scenePoint: { x: centerX, y: centerY },
|
});
|
||||||
viewportDimensions: {
|
|
||||||
width: appState.width,
|
|
||||||
height: appState.height,
|
|
||||||
},
|
|
||||||
zoom: { value: newZoomValue },
|
|
||||||
});
|
|
||||||
|
|
||||||
scrollX = centerScroll.scrollX;
|
|
||||||
scrollY = centerScroll.scrollY;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
scrollX,
|
scrollX: centerScroll.scrollX,
|
||||||
scrollY,
|
scrollY: centerScroll.scrollY,
|
||||||
zoom: { value: newZoomValue },
|
zoom: { value: newZoomValue },
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: StoreAction.NONE,
|
||||||
@@ -344,25 +345,34 @@ export const zoomToFitBounds = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const zoomToFit = ({
|
export const zoomToFit = ({
|
||||||
|
canvasOffsets,
|
||||||
targetElements,
|
targetElements,
|
||||||
appState,
|
appState,
|
||||||
fitToViewport,
|
fitToViewport,
|
||||||
viewportZoomFactor,
|
viewportZoomFactor,
|
||||||
|
minZoom,
|
||||||
|
maxZoom,
|
||||||
}: {
|
}: {
|
||||||
|
canvasOffsets?: Offsets;
|
||||||
targetElements: readonly ExcalidrawElement[];
|
targetElements: readonly ExcalidrawElement[];
|
||||||
appState: Readonly<AppState>;
|
appState: Readonly<AppState>;
|
||||||
/** whether to fit content to viewport (beyond >100%) */
|
/** whether to fit content to viewport (beyond >100%) */
|
||||||
fitToViewport: boolean;
|
fitToViewport: boolean;
|
||||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||||
viewportZoomFactor?: number;
|
viewportZoomFactor?: number;
|
||||||
|
minZoom?: number;
|
||||||
|
maxZoom?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
|
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
|
||||||
|
|
||||||
return zoomToFitBounds({
|
return zoomToFitBounds({
|
||||||
|
canvasOffsets,
|
||||||
bounds: commonBounds,
|
bounds: commonBounds,
|
||||||
appState,
|
appState,
|
||||||
fitToViewport,
|
fitToViewport,
|
||||||
viewportZoomFactor,
|
viewportZoomFactor,
|
||||||
|
minZoom,
|
||||||
|
maxZoom,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -383,6 +393,7 @@ export const actionZoomToFitSelectionInViewport = register({
|
|||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
fitToViewport: false,
|
fitToViewport: false,
|
||||||
|
canvasOffsets: app.getEditorUIOffsets(),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
|
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
|
||||||
@@ -408,6 +419,7 @@ export const actionZoomToFitSelection = register({
|
|||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
fitToViewport: true,
|
fitToViewport: true,
|
||||||
|
canvasOffsets: app.getEditorUIOffsets(),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// NOTE this action should use shift-2 per figma, alas
|
// NOTE this action should use shift-2 per figma, alas
|
||||||
@@ -424,7 +436,7 @@ export const actionZoomToFit = register({
|
|||||||
icon: zoomAreaIcon,
|
icon: zoomAreaIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState, _, app) =>
|
||||||
zoomToFit({
|
zoomToFit({
|
||||||
targetElements: elements,
|
targetElements: elements,
|
||||||
appState: {
|
appState: {
|
||||||
@@ -432,6 +444,7 @@ export const actionZoomToFit = register({
|
|||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
fitToViewport: false,
|
fitToViewport: false,
|
||||||
|
canvasOffsets: app.getEditorUIOffsets(),
|
||||||
}),
|
}),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.code === CODES.ONE &&
|
event.code === CODES.ONE &&
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "../clipboard";
|
} from "../clipboard";
|
||||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||||
import { isTextElement } from "../element";
|
import { getTextFromElements, isTextElement } from "../element";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isFirefox } from "../constants";
|
import { isFirefox } from "../constants";
|
||||||
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||||
@@ -239,16 +239,8 @@ export const copyText = register({
|
|||||||
includeBoundTextElement: true,
|
includeBoundTextElement: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = selectedElements
|
|
||||||
.reduce((acc: string[], element) => {
|
|
||||||
if (isTextElement(element)) {
|
|
||||||
acc.push(element.text);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, [])
|
|
||||||
.join("\n\n");
|
|
||||||
try {
|
try {
|
||||||
copyTextToSystemClipboard(text);
|
copyTextToSystemClipboard(getTextFromElements(selectedElements));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,27 @@ import { t } from "../i18n";
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import type { AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
import { getElementsInGroup } from "../groups";
|
import { getElementsInGroup } from "../groups";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
import {
|
||||||
|
isBoundToContainer,
|
||||||
|
isElbowArrow,
|
||||||
|
isFrameLikeElement,
|
||||||
|
} from "../element/typeChecks";
|
||||||
import { updateActiveTool } from "../utils";
|
import { updateActiveTool } from "../utils";
|
||||||
import { TrashIcon } from "../components/icons";
|
import { TrashIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
import { mutateElbowArrow } from "../element/routing";
|
||||||
|
|
||||||
const deleteSelectedElements = (
|
const deleteSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
app: AppClassProperties,
|
||||||
) => {
|
) => {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
const framesToBeDeleted = new Set(
|
const framesToBeDeleted = new Set(
|
||||||
getSelectedElements(
|
getSelectedElements(
|
||||||
elements.filter((el) => isFrameLikeElement(el)),
|
elements.filter((el) => isFrameLikeElement(el)),
|
||||||
@@ -29,6 +36,26 @@ const deleteSelectedElements = (
|
|||||||
return {
|
return {
|
||||||
elements: elements.map((el) => {
|
elements: elements.map((el) => {
|
||||||
if (appState.selectedElementIds[el.id]) {
|
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 });
|
return newElementWith(el, { isDeleted: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +157,11 @@ export const actionDeleteSelected = register({
|
|||||||
: endBindingElement,
|
: endBindingElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
LinearElementEditor.deletePoints(
|
||||||
|
element,
|
||||||
|
selectedPointsIndices,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
@@ -149,7 +180,7 @@ export const actionDeleteSelected = register({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
let { elements: nextElements, appState: nextAppState } =
|
let { elements: nextElements, appState: nextAppState } =
|
||||||
deleteSelectedElements(elements, appState);
|
deleteSelectedElements(elements, appState, app);
|
||||||
fixBindingsAfterDeletion(
|
fixBindingsAfterDeletion(
|
||||||
nextElements,
|
nextElements,
|
||||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||||
import type { ActionResult } from "./types";
|
import type { ActionResult } from "./types";
|
||||||
import { GRID_SIZE } from "../constants";
|
import { DEFAULT_GRID_SIZE } from "../constants";
|
||||||
import {
|
import {
|
||||||
bindTextToShapeAfterDuplication,
|
bindTextToShapeAfterDuplication,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
@@ -40,23 +40,23 @@ export const actionDuplicateSelection = register({
|
|||||||
icon: DuplicateIcon,
|
icon: DuplicateIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, formData, app) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
|
||||||
// duplicate selected point(s) if editing a line
|
// duplicate selected point(s) if editing a line
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const ret = LinearElementEditor.duplicateSelectedPoints(
|
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
|
||||||
appState,
|
try {
|
||||||
elementsMap,
|
const newAppState = LinearElementEditor.duplicateSelectedPoints(
|
||||||
);
|
appState,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
if (!ret) {
|
return {
|
||||||
|
elements,
|
||||||
|
appState: newAppState,
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
elements,
|
|
||||||
appState: ret.appState,
|
|
||||||
storeAction: StoreAction.CAPTURE,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -100,8 +100,8 @@ const duplicateElements = (
|
|||||||
groupIdMap,
|
groupIdMap,
|
||||||
element,
|
element,
|
||||||
{
|
{
|
||||||
x: element.x + GRID_SIZE / 2,
|
x: element.x + DEFAULT_GRID_SIZE / 2,
|
||||||
y: element.y + GRID_SIZE / 2,
|
y: element.y + DEFAULT_GRID_SIZE / 2,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
duplicatedElementsMap.set(newElement.id, newElement);
|
duplicatedElementsMap.set(newElement.id, newElement);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { queryByTestId, fireEvent } from "@testing-library/react";
|
import { queryByTestId, fireEvent } from "@testing-library/react";
|
||||||
import { render } from "../tests/test-utils";
|
import { render } from "../tests/test-utils";
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { done } from "../components/icons";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
import { isPathALoop } from "../math";
|
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import {
|
import {
|
||||||
maybeBindLinearElement,
|
maybeBindLinearElement,
|
||||||
@@ -16,6 +15,8 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
|||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { resetCursor } from "../cursor";
|
import { resetCursor } from "../cursor";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
import { point } from "../../math";
|
||||||
|
import { isPathALoop } from "../shapes";
|
||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
@@ -38,6 +39,7 @@ export const actionFinalize = register({
|
|||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
scene,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -49,7 +51,6 @@ export const actionFinalize = register({
|
|||||||
...appState,
|
...appState,
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
selectedLinearElement: null,
|
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
@@ -72,8 +73,8 @@ export const actionFinalize = register({
|
|||||||
|
|
||||||
const multiPointElement = appState.multiElement
|
const multiPointElement = appState.multiElement
|
||||||
? appState.multiElement
|
? appState.multiElement
|
||||||
: appState.editingElement?.type === "freedraw"
|
: appState.newElement?.type === "freedraw"
|
||||||
? appState.editingElement
|
? appState.newElement
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (multiPointElement) {
|
if (multiPointElement) {
|
||||||
@@ -112,10 +113,10 @@ export const actionFinalize = register({
|
|||||||
const linePoints = multiPointElement.points;
|
const linePoints = multiPointElement.points;
|
||||||
const firstPoint = linePoints[0];
|
const firstPoint = linePoints[0];
|
||||||
mutateElement(multiPointElement, {
|
mutateElement(multiPointElement, {
|
||||||
points: linePoints.map((point, index) =>
|
points: linePoints.map((p, index) =>
|
||||||
index === linePoints.length - 1
|
index === linePoints.length - 1
|
||||||
? ([firstPoint[0], firstPoint[1]] as const)
|
? point(firstPoint[0], firstPoint[1])
|
||||||
: point,
|
: p,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -131,7 +132,13 @@ export const actionFinalize = register({
|
|||||||
-1,
|
-1,
|
||||||
arrayToMap(elements),
|
arrayToMap(elements),
|
||||||
);
|
);
|
||||||
maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
|
maybeBindLinearElement(
|
||||||
|
multiPointElement,
|
||||||
|
appState,
|
||||||
|
{ x, y },
|
||||||
|
elementsMap,
|
||||||
|
elements,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,9 +176,10 @@ export const actionFinalize = register({
|
|||||||
? appState.activeTool
|
? appState.activeTool
|
||||||
: activeTool,
|
: activeTool,
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
draggingElement: null,
|
newElement: null,
|
||||||
|
selectionElement: null,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
editingElement: null,
|
editingTextElement: null,
|
||||||
startBoundElement: null,
|
startBoundElement: null,
|
||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
selectedElementIds:
|
selectedElementIds:
|
||||||
@@ -197,7 +205,7 @@ export const actionFinalize = register({
|
|||||||
keyTest: (event, appState) =>
|
keyTest: (event, appState) =>
|
||||||
(event.key === KEYS.ESCAPE &&
|
(event.key === KEYS.ESCAPE &&
|
||||||
(appState.editingLinearElement !== null ||
|
(appState.editingLinearElement !== null ||
|
||||||
(!appState.draggingElement && appState.multiElement === null))) ||
|
(!appState.newElement && appState.multiElement === null))) ||
|
||||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||||
appState.multiElement !== null),
|
appState.multiElement !== null),
|
||||||
PanelComponent: ({ appState, updateData, data }) => (
|
PanelComponent: ({ appState, updateData, data }) => (
|
||||||
@@ -209,6 +217,7 @@ export const actionFinalize = register({
|
|||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
visible={appState.multiElement != null}
|
visible={appState.multiElement != null}
|
||||||
size={data?.size || "medium"}
|
size={data?.size || "medium"}
|
||||||
|
style={{ pointerEvents: "all" }}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Excalidraw } from "../index";
|
||||||
|
import { render } from "../tests/test-utils";
|
||||||
|
import { API } from "../tests/helpers/api";
|
||||||
|
import { point } 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: [
|
||||||
|
point(0, 0),
|
||||||
|
point(0, -35),
|
||||||
|
point(-90.9, -35),
|
||||||
|
point(-90.9, 204.9),
|
||||||
|
point(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,8 @@ import { register } from "./register";
|
|||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import type {
|
import type {
|
||||||
|
ExcalidrawArrowElement,
|
||||||
|
ExcalidrawElbowArrowElement,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
@@ -18,7 +20,13 @@ import {
|
|||||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import { isLinearElement } from "../element/typeChecks";
|
import {
|
||||||
|
isArrowElement,
|
||||||
|
isElbowArrow,
|
||||||
|
isLinearElement,
|
||||||
|
} from "../element/typeChecks";
|
||||||
|
import { mutateElbowArrow } from "../element/routing";
|
||||||
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
|
|
||||||
export const actionFlipHorizontal = register({
|
export const actionFlipHorizontal = register({
|
||||||
name: "flipHorizontal",
|
name: "flipHorizontal",
|
||||||
@@ -109,7 +117,23 @@ const flipElements = (
|
|||||||
flipDirection: "horizontal" | "vertical",
|
flipDirection: "horizontal" | "vertical",
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
|
if (
|
||||||
|
selectedElements.every(
|
||||||
|
(element) =>
|
||||||
|
isArrowElement(element) && (element.startBinding || element.endBinding),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return selectedElements.map((element) => {
|
||||||
|
const _element = element as ExcalidrawArrowElement;
|
||||||
|
return newElementWith(_element, {
|
||||||
|
startArrowhead: _element.endArrowhead,
|
||||||
|
endArrowhead: _element.startArrowhead,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { minX, minY, maxX, maxY, midX, midY } =
|
||||||
|
getCommonBoundingBox(selectedElements);
|
||||||
|
|
||||||
resizeMultipleElements(
|
resizeMultipleElements(
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@@ -124,10 +148,55 @@ const flipElements = (
|
|||||||
|
|
||||||
bindOrUnbindLinearElements(
|
bindOrUnbindLinearElements(
|
||||||
selectedElements.filter(isLinearElement),
|
selectedElements.filter(isLinearElement),
|
||||||
app,
|
elementsMap,
|
||||||
|
app.scene.getNonDeletedElements(),
|
||||||
|
app.scene,
|
||||||
isBindingEnabled(appState),
|
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;
|
return selectedElements;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ToolButton } from "../components/ToolButton";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import type { History } from "../history";
|
import type { History } from "../history";
|
||||||
import { HistoryChangedEvent } from "../history";
|
import { HistoryChangedEvent } from "../history";
|
||||||
import type { AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { isWindows } from "../constants";
|
import { isWindows } from "../constants";
|
||||||
@@ -13,15 +13,19 @@ import type { Store } from "../store";
|
|||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import { useEmitter } from "../hooks/useEmitter";
|
import { useEmitter } from "../hooks/useEmitter";
|
||||||
|
|
||||||
const writeData = (
|
const executeHistoryAction = (
|
||||||
|
app: AppClassProperties,
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
updater: () => [SceneElementsMap, AppState] | void,
|
updater: () => [SceneElementsMap, AppState] | void,
|
||||||
): ActionResult => {
|
): ActionResult => {
|
||||||
if (
|
if (
|
||||||
!appState.multiElement &&
|
!appState.multiElement &&
|
||||||
!appState.resizingElement &&
|
!appState.resizingElement &&
|
||||||
!appState.editingElement &&
|
!appState.editingTextElement &&
|
||||||
!appState.draggingElement
|
!appState.newElement &&
|
||||||
|
!appState.selectedElementsAreBeingDragged &&
|
||||||
|
!appState.selectionElement &&
|
||||||
|
!app.flowChartCreator.isCreatingChart
|
||||||
) {
|
) {
|
||||||
const result = updater();
|
const result = updater();
|
||||||
|
|
||||||
@@ -50,8 +54,8 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||||||
icon: UndoIcon,
|
icon: UndoIcon,
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState, value, app) =>
|
||||||
writeData(appState, () =>
|
executeHistoryAction(app, appState, () =>
|
||||||
history.undo(
|
history.undo(
|
||||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||||
appState,
|
appState,
|
||||||
@@ -91,8 +95,8 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
|||||||
icon: RedoIcon,
|
icon: RedoIcon,
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState, _, app) =>
|
||||||
writeData(appState, () =>
|
executeHistoryAction(app, appState, () =>
|
||||||
history.redo(
|
history.redo(
|
||||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||||
appState,
|
appState,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { isLinearElement } from "../element/typeChecks";
|
import { isElbowArrow, isLinearElement } from "../element/typeChecks";
|
||||||
import type { ExcalidrawLinearElement } from "../element/types";
|
import type { ExcalidrawLinearElement } from "../element/types";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
@@ -29,7 +29,8 @@ export const actionToggleLinearEditor = register({
|
|||||||
if (
|
if (
|
||||||
!appState.editingLinearElement &&
|
!appState.editingLinearElement &&
|
||||||
selectedElements.length === 1 &&
|
selectedElements.length === 1 &&
|
||||||
isLinearElement(selectedElements[0])
|
isLinearElement(selectedElements[0]) &&
|
||||||
|
!isElbowArrow(selectedElements[0])
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { queryByTestId } from "@testing-library/react";
|
import { queryByTestId } from "@testing-library/react";
|
||||||
import { render } from "../tests/test-utils";
|
import { render } from "../tests/test-utils";
|
||||||
@@ -6,8 +7,6 @@ import { API } from "../tests/helpers/api";
|
|||||||
import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors";
|
import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors";
|
||||||
import { FONT_FAMILY, STROKE_WIDTH } from "../constants";
|
import { FONT_FAMILY, STROKE_WIDTH } from "../constants";
|
||||||
|
|
||||||
const { h } = window;
|
|
||||||
|
|
||||||
describe("element locking", () => {
|
describe("element locking", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await render(<Excalidraw />);
|
await render(<Excalidraw />);
|
||||||
@@ -22,7 +21,7 @@ describe("element locking", () => {
|
|||||||
// just in case we change it in the future
|
// just in case we change it in the future
|
||||||
expect(color).not.toBe(COLOR_PALETTE.transparent);
|
expect(color).not.toBe(COLOR_PALETTE.transparent);
|
||||||
|
|
||||||
h.setState({
|
API.setAppState({
|
||||||
currentItemBackgroundColor: color,
|
currentItemBackgroundColor: color,
|
||||||
});
|
});
|
||||||
const activeColor = queryByTestId(
|
const activeColor = queryByTestId(
|
||||||
@@ -40,14 +39,14 @@ describe("element locking", () => {
|
|||||||
// just in case we change it in the future
|
// just in case we change it in the future
|
||||||
expect(color).not.toBe(COLOR_PALETTE.transparent);
|
expect(color).not.toBe(COLOR_PALETTE.transparent);
|
||||||
|
|
||||||
h.setState({
|
API.setAppState({
|
||||||
currentItemBackgroundColor: color,
|
currentItemBackgroundColor: color,
|
||||||
currentItemFillStyle: "hachure",
|
currentItemFillStyle: "hachure",
|
||||||
});
|
});
|
||||||
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
|
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
|
||||||
|
|
||||||
expect(hachureFillButton).toHaveClass("active");
|
expect(hachureFillButton).toHaveClass("active");
|
||||||
h.setState({
|
API.setAppState({
|
||||||
currentItemFillStyle: "solid",
|
currentItemFillStyle: "solid",
|
||||||
});
|
});
|
||||||
const solidFillStyle = queryByTestId(document.body, `fill-solid`);
|
const solidFillStyle = queryByTestId(document.body, `fill-solid`);
|
||||||
@@ -57,7 +56,7 @@ describe("element locking", () => {
|
|||||||
it("should not show fill style when background transparent", () => {
|
it("should not show fill style when background transparent", () => {
|
||||||
UI.clickTool("rectangle");
|
UI.clickTool("rectangle");
|
||||||
|
|
||||||
h.setState({
|
API.setAppState({
|
||||||
currentItemBackgroundColor: COLOR_PALETTE.transparent,
|
currentItemBackgroundColor: COLOR_PALETTE.transparent,
|
||||||
currentItemFillStyle: "hachure",
|
currentItemFillStyle: "hachure",
|
||||||
});
|
});
|
||||||
@@ -69,7 +68,7 @@ describe("element locking", () => {
|
|||||||
it("should show horizontal text align for text tool", () => {
|
it("should show horizontal text align for text tool", () => {
|
||||||
UI.clickTool("text");
|
UI.clickTool("text");
|
||||||
|
|
||||||
h.setState({
|
API.setAppState({
|
||||||
currentItemTextAlign: "right",
|
currentItemTextAlign: "right",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,7 +84,7 @@ describe("element locking", () => {
|
|||||||
backgroundColor: "red",
|
backgroundColor: "red",
|
||||||
fillStyle: "cross-hatch",
|
fillStyle: "cross-hatch",
|
||||||
});
|
});
|
||||||
h.elements = [rect];
|
API.setElements([rect]);
|
||||||
API.setSelectedElements([rect]);
|
API.setSelectedElements([rect]);
|
||||||
|
|
||||||
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
|
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
|
||||||
@@ -98,7 +97,7 @@ describe("element locking", () => {
|
|||||||
backgroundColor: COLOR_PALETTE.transparent,
|
backgroundColor: COLOR_PALETTE.transparent,
|
||||||
fillStyle: "cross-hatch",
|
fillStyle: "cross-hatch",
|
||||||
});
|
});
|
||||||
h.elements = [rect];
|
API.setElements([rect]);
|
||||||
API.setSelectedElements([rect]);
|
API.setSelectedElements([rect]);
|
||||||
|
|
||||||
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
|
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
|
||||||
@@ -114,7 +113,7 @@ describe("element locking", () => {
|
|||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
strokeWidth: STROKE_WIDTH.thin,
|
strokeWidth: STROKE_WIDTH.thin,
|
||||||
});
|
});
|
||||||
h.elements = [rect1, rect2];
|
API.setElements([rect1, rect2]);
|
||||||
API.setSelectedElements([rect1, rect2]);
|
API.setSelectedElements([rect1, rect2]);
|
||||||
|
|
||||||
const thinStrokeWidthButton = queryByTestId(
|
const thinStrokeWidthButton = queryByTestId(
|
||||||
@@ -133,7 +132,7 @@ describe("element locking", () => {
|
|||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
strokeWidth: STROKE_WIDTH.bold,
|
strokeWidth: STROKE_WIDTH.bold,
|
||||||
});
|
});
|
||||||
h.elements = [rect1, rect2];
|
API.setElements([rect1, rect2]);
|
||||||
API.setSelectedElements([rect1, rect2]);
|
API.setSelectedElements([rect1, rect2]);
|
||||||
|
|
||||||
expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null);
|
expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null);
|
||||||
@@ -155,13 +154,15 @@ describe("element locking", () => {
|
|||||||
});
|
});
|
||||||
const text = API.createElement({
|
const text = API.createElement({
|
||||||
type: "text",
|
type: "text",
|
||||||
fontFamily: FONT_FAMILY.Cascadia,
|
fontFamily: FONT_FAMILY["Comic Shanns"],
|
||||||
});
|
});
|
||||||
h.elements = [rect, text];
|
API.setElements([rect, text]);
|
||||||
API.setSelectedElements([rect, text]);
|
API.setSelectedElements([rect, text]);
|
||||||
|
|
||||||
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
|
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
|
||||||
expect(queryByTestId(document.body, `font-family-code`)).toBeChecked();
|
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
|
||||||
|
"active",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||||
|
import type { StoreActionType } from "../store";
|
||||||
import {
|
import {
|
||||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||||
@@ -9,6 +11,7 @@ import { trackEvent } from "../analytics";
|
|||||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||||
import { IconPicker } from "../components/IconPicker";
|
import { IconPicker } from "../components/IconPicker";
|
||||||
|
import { FontPicker } from "../components/FontPicker/FontPicker";
|
||||||
// TODO barnabasmolnar/editor-redesign
|
// TODO barnabasmolnar/editor-redesign
|
||||||
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
|
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
|
||||||
// ArrowHead icons
|
// ArrowHead icons
|
||||||
@@ -38,9 +41,6 @@ import {
|
|||||||
FontSizeExtraLargeIcon,
|
FontSizeExtraLargeIcon,
|
||||||
EdgeSharpIcon,
|
EdgeSharpIcon,
|
||||||
EdgeRoundIcon,
|
EdgeRoundIcon,
|
||||||
FreedrawIcon,
|
|
||||||
FontFamilyNormalIcon,
|
|
||||||
FontFamilyCodeIcon,
|
|
||||||
TextAlignLeftIcon,
|
TextAlignLeftIcon,
|
||||||
TextAlignCenterIcon,
|
TextAlignCenterIcon,
|
||||||
TextAlignRightIcon,
|
TextAlignRightIcon,
|
||||||
@@ -50,8 +50,12 @@ import {
|
|||||||
ArrowheadDiamondIcon,
|
ArrowheadDiamondIcon,
|
||||||
ArrowheadDiamondOutlineIcon,
|
ArrowheadDiamondOutlineIcon,
|
||||||
fontSizeIcon,
|
fontSizeIcon,
|
||||||
|
sharpArrowIcon,
|
||||||
|
roundArrowIcon,
|
||||||
|
elbowArrowIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import {
|
import {
|
||||||
|
ARROW_TYPE,
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
@@ -65,17 +69,17 @@ import {
|
|||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
|
import { getBoundTextElement } from "../element/textElement";
|
||||||
import {
|
import {
|
||||||
getBoundTextElement,
|
isArrowElement,
|
||||||
getDefaultLineHeight,
|
|
||||||
} from "../element/textElement";
|
|
||||||
import {
|
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
|
isElbowArrow,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isUsingAdaptiveRadius,
|
isUsingAdaptiveRadius,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import type {
|
import type {
|
||||||
Arrowhead,
|
Arrowhead,
|
||||||
|
ExcalidrawBindableElement,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
@@ -94,9 +98,25 @@ import {
|
|||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { hasStrokeColor } from "../scene/comparisons";
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import {
|
||||||
|
arrayToMap,
|
||||||
|
getFontFamilyString,
|
||||||
|
getShortcutKey,
|
||||||
|
tupleToCoors,
|
||||||
|
} from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { StoreAction } from "../store";
|
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 { point, vector } from "../../math";
|
||||||
|
|
||||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||||
|
|
||||||
@@ -115,7 +135,7 @@ export const changeProperty = (
|
|||||||
return elements.map((element) => {
|
return elements.map((element) => {
|
||||||
if (
|
if (
|
||||||
selectedElementIds.get(element.id) ||
|
selectedElementIds.get(element.id) ||
|
||||||
element.id === appState.editingElement?.id
|
element.id === appState.editingTextElement?.id
|
||||||
) {
|
) {
|
||||||
return callback(element);
|
return callback(element);
|
||||||
}
|
}
|
||||||
@@ -130,13 +150,13 @@ export const getFormValue = function <T extends Primitive>(
|
|||||||
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
|
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
|
||||||
defaultValue: T | ((isSomeElementSelected: boolean) => T),
|
defaultValue: T | ((isSomeElementSelected: boolean) => T),
|
||||||
): T {
|
): T {
|
||||||
const editingElement = appState.editingElement;
|
const editingTextElement = appState.editingTextElement;
|
||||||
const nonDeletedElements = getNonDeletedElements(elements);
|
const nonDeletedElements = getNonDeletedElements(elements);
|
||||||
|
|
||||||
let ret: T | null = null;
|
let ret: T | null = null;
|
||||||
|
|
||||||
if (editingElement) {
|
if (editingTextElement) {
|
||||||
ret = getAttribute(editingElement);
|
ret = getAttribute(editingTextElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ret) {
|
if (!ret) {
|
||||||
@@ -729,104 +749,389 @@ 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({
|
export const actionChangeFontFamily = register({
|
||||||
name: "changeFontFamily",
|
name: "changeFontFamily",
|
||||||
label: "labels.fontFamily",
|
label: "labels.fontFamily",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return {
|
const { cachedElements, resetAll, resetContainers, ...nextAppState } =
|
||||||
elements: changeProperty(
|
value as ChangeFontFamilyData;
|
||||||
|
|
||||||
|
if (resetAll) {
|
||||||
|
const nextElements = changeProperty(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
(oldElement) => {
|
(element) => {
|
||||||
if (isTextElement(oldElement)) {
|
const cachedElement = cachedElements?.get(element.id);
|
||||||
const newElement: ExcalidrawTextElement = newElementWith(
|
if (cachedElement) {
|
||||||
oldElement,
|
const newElement = newElementWith(element, {
|
||||||
{
|
...cachedElement,
|
||||||
fontFamily: value,
|
});
|
||||||
lineHeight: getDefaultLineHeight(value),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
redrawTextBoundingBox(
|
|
||||||
newElement,
|
|
||||||
app.scene.getContainerElement(oldElement),
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
return oldElement;
|
return element;
|
||||||
},
|
},
|
||||||
true,
|
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: {
|
||||||
...appState,
|
...appState,
|
||||||
currentItemFontFamily: value,
|
...nextAppState,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: nexStoreAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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, updateData, app }) => {
|
PanelComponent: ({ elements, appState, app, updateData }) => {
|
||||||
const options: {
|
const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map());
|
||||||
value: FontFamilyValues;
|
const prevSelectedFontFamilyRef = useRef<number | null>(null);
|
||||||
text: string;
|
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
|
||||||
icon: JSX.Element;
|
const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
|
||||||
testId: string;
|
const isUnmounted = useRef(true);
|
||||||
}[] = [
|
|
||||||
{
|
const selectedFontFamily = useMemo(() => {
|
||||||
value: FONT_FAMILY.Virgil,
|
const getFontFamily = (
|
||||||
text: t("labels.handDrawn"),
|
elementsArray: readonly ExcalidrawElement[],
|
||||||
icon: FreedrawIcon,
|
elementsMap: Map<string, ExcalidrawElement>,
|
||||||
testId: "font-family-virgil",
|
) =>
|
||||||
},
|
getFormValue(
|
||||||
{
|
elementsArray,
|
||||||
value: FONT_FAMILY.Helvetica,
|
appState,
|
||||||
text: t("labels.normal"),
|
(element) => {
|
||||||
icon: FontFamilyNormalIcon,
|
if (isTextElement(element)) {
|
||||||
testId: "font-family-normal",
|
return element.fontFamily;
|
||||||
},
|
}
|
||||||
{
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
value: FONT_FAMILY.Cascadia,
|
if (boundTextElement) {
|
||||||
text: t("labels.code"),
|
return boundTextElement.fontFamily;
|
||||||
icon: FontFamilyCodeIcon,
|
}
|
||||||
testId: "font-family-code",
|
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;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.fontFamily")}</legend>
|
<legend>{t("labels.fontFamily")}</legend>
|
||||||
<ButtonIconSelect<FontFamilyValues | false>
|
<FontPicker
|
||||||
group="font-family"
|
isOpened={appState.openPopup === "fontFamily"}
|
||||||
options={options}
|
selectedFontFamily={selectedFontFamily}
|
||||||
value={getFormValue(
|
hoveredFontFamily={appState.currentHoveredFontFamily}
|
||||||
elements,
|
onSelect={(fontFamily) => {
|
||||||
appState,
|
setBatchedData({
|
||||||
(element) => {
|
openPopup: null,
|
||||||
if (isTextElement(element)) {
|
currentHoveredFontFamily: null,
|
||||||
return element.fontFamily;
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const boundTextElement = getBoundTextElement(
|
|
||||||
element,
|
setBatchedData({
|
||||||
app.scene.getNonDeletedElementsMap(),
|
openPopup: "fontFamily",
|
||||||
);
|
});
|
||||||
if (boundTextElement) {
|
} else {
|
||||||
return boundTextElement.fontFamily;
|
// 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);
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
},
|
cachedElementsRef.current.clear();
|
||||||
(element) =>
|
}
|
||||||
isTextElement(element) ||
|
}}
|
||||||
getBoundTextElement(
|
|
||||||
element,
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
) !== null,
|
|
||||||
(hasSelection) =>
|
|
||||||
hasSelection
|
|
||||||
? null
|
|
||||||
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
|
||||||
)}
|
|
||||||
onChange={(value) => updateData(value)}
|
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
@@ -1019,8 +1324,12 @@ export const actionChangeRoundness = register({
|
|||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
newElementWith(el, {
|
if (isElbowArrow(el)) {
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newElementWith(el, {
|
||||||
roundness:
|
roundness:
|
||||||
value === "round"
|
value === "round"
|
||||||
? {
|
? {
|
||||||
@@ -1029,8 +1338,8 @@ export const actionChangeRoundness = register({
|
|||||||
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
}),
|
});
|
||||||
),
|
}),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
currentItemRoundness: value,
|
currentItemRoundness: value,
|
||||||
@@ -1070,7 +1379,8 @@ export const actionChangeRoundness = register({
|
|||||||
appState,
|
appState,
|
||||||
(element) =>
|
(element) =>
|
||||||
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
|
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
|
||||||
(element) => element.hasOwnProperty("roundness"),
|
(element) =>
|
||||||
|
!isArrowElement(element) && element.hasOwnProperty("roundness"),
|
||||||
(hasSelection) =>
|
(hasSelection) =>
|
||||||
hasSelection ? null : appState.currentItemRoundness,
|
hasSelection ? null : appState.currentItemRoundness,
|
||||||
)}
|
)}
|
||||||
@@ -1233,3 +1543,206 @@ export const actionChangeArrowhead = register({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionChangeArrowType = register({
|
||||||
|
name: "changeArrowType",
|
||||||
|
label: "Change arrow types",
|
||||||
|
trackEvent: false,
|
||||||
|
perform: (elements, appState, value, app) => {
|
||||||
|
return {
|
||||||
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
|
if (!isArrowElement(el)) {
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
const newElement = newElementWith(el, {
|
||||||
|
roundness:
|
||||||
|
value === ARROW_TYPE.round
|
||||||
|
? {
|
||||||
|
type: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
elbowed: value === ARROW_TYPE.elbow,
|
||||||
|
points:
|
||||||
|
value === ARROW_TYPE.elbow || el.elbowed
|
||||||
|
? [el.points[0], el.points[el.points.length - 1]]
|
||||||
|
: el.points,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isElbowArrow(newElement)) {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
|
app.dismissLinearEditor();
|
||||||
|
|
||||||
|
const startGlobalPoint =
|
||||||
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
|
newElement,
|
||||||
|
0,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
const endGlobalPoint =
|
||||||
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
|
newElement,
|
||||||
|
-1,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
const startHoveredElement =
|
||||||
|
!newElement.startBinding &&
|
||||||
|
getHoveredElementForBinding(
|
||||||
|
tupleToCoors(startGlobalPoint),
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const endHoveredElement =
|
||||||
|
!newElement.endBinding &&
|
||||||
|
getHoveredElementForBinding(
|
||||||
|
tupleToCoors(endGlobalPoint),
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const startElement = startHoveredElement
|
||||||
|
? startHoveredElement
|
||||||
|
: newElement.startBinding &&
|
||||||
|
(elementsMap.get(
|
||||||
|
newElement.startBinding.elementId,
|
||||||
|
) as ExcalidrawBindableElement);
|
||||||
|
const endElement = endHoveredElement
|
||||||
|
? endHoveredElement
|
||||||
|
: newElement.endBinding &&
|
||||||
|
(elementsMap.get(
|
||||||
|
newElement.endBinding.elementId,
|
||||||
|
) as ExcalidrawBindableElement);
|
||||||
|
|
||||||
|
const finalStartPoint = startHoveredElement
|
||||||
|
? bindPointToSnapToElementOutline(
|
||||||
|
startGlobalPoint,
|
||||||
|
endGlobalPoint,
|
||||||
|
startHoveredElement,
|
||||||
|
elementsMap,
|
||||||
|
)
|
||||||
|
: startGlobalPoint;
|
||||||
|
const finalEndPoint = endHoveredElement
|
||||||
|
? bindPointToSnapToElementOutline(
|
||||||
|
endGlobalPoint,
|
||||||
|
startGlobalPoint,
|
||||||
|
endHoveredElement,
|
||||||
|
elementsMap,
|
||||||
|
)
|
||||||
|
: endGlobalPoint;
|
||||||
|
|
||||||
|
startHoveredElement &&
|
||||||
|
bindLinearElement(
|
||||||
|
newElement,
|
||||||
|
startHoveredElement,
|
||||||
|
"start",
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
endHoveredElement &&
|
||||||
|
bindLinearElement(
|
||||||
|
newElement,
|
||||||
|
endHoveredElement,
|
||||||
|
"end",
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
mutateElbowArrow(
|
||||||
|
newElement,
|
||||||
|
elementsMap,
|
||||||
|
[finalStartPoint, finalEndPoint].map(
|
||||||
|
(p): LocalPoint =>
|
||||||
|
point(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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,10 +12,7 @@ import {
|
|||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import {
|
import { getBoundTextElement } from "../element/textElement";
|
||||||
getBoundTextElement,
|
|
||||||
getDefaultLineHeight,
|
|
||||||
} from "../element/textElement";
|
|
||||||
import {
|
import {
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
canApplyRoundnessTypeToElement,
|
canApplyRoundnessTypeToElement,
|
||||||
@@ -27,6 +24,7 @@ import { getSelectedElements } from "../scene";
|
|||||||
import type { ExcalidrawTextElement } from "../element/types";
|
import type { ExcalidrawTextElement } from "../element/types";
|
||||||
import { paintIcon } from "../components/icons";
|
import { paintIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
import { getLineHeight } from "../fonts";
|
||||||
|
|
||||||
// `copiedStyles` is exported only for tests.
|
// `copiedStyles` is exported only for tests.
|
||||||
export let copiedStyles: string = "{}";
|
export let copiedStyles: string = "{}";
|
||||||
@@ -122,7 +120,7 @@ export const actionPasteStyles = register({
|
|||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
lineHeight:
|
lineHeight:
|
||||||
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
|
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
|
||||||
getDefaultLineHeight(fontFamily),
|
getLineHeight(fontFamily),
|
||||||
});
|
});
|
||||||
let container = null;
|
let container = null;
|
||||||
if (newElement.containerId) {
|
if (newElement.containerId) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { GRID_SIZE } from "../constants";
|
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { gridIcon } from "../components/icons";
|
import { gridIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
@@ -13,21 +12,21 @@ export const actionToggleGridMode = register({
|
|||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: {
|
trackEvent: {
|
||||||
category: "canvas",
|
category: "canvas",
|
||||||
predicate: (appState) => !appState.gridSize,
|
predicate: (appState) => appState.gridModeEnabled,
|
||||||
},
|
},
|
||||||
perform(elements, appState) {
|
perform(elements, appState) {
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
gridSize: this.checked!(appState) ? null : GRID_SIZE,
|
gridModeEnabled: !this.checked!(appState),
|
||||||
objectsSnapModeEnabled: false,
|
objectsSnapModeEnabled: false,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState: AppState) => appState.gridSize !== null,
|
checked: (appState: AppState) => appState.gridModeEnabled,
|
||||||
predicate: (element, appState, props) => {
|
predicate: (element, appState, props) => {
|
||||||
return typeof props.gridModeEnabled === "undefined";
|
return props.gridModeEnabled === undefined;
|
||||||
},
|
},
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const actionToggleObjectsSnapMode = register({
|
|||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
objectsSnapModeEnabled: !this.checked!(appState),
|
objectsSnapModeEnabled: !this.checked!(appState),
|
||||||
gridSize: null,
|
gridModeEnabled: false,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { KEYS } from "../keys";
|
||||||
|
import { register } from "./register";
|
||||||
|
import type { AppState } from "../types";
|
||||||
|
import { searchIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants";
|
||||||
|
|
||||||
|
export const actionToggleSearchMenu = register({
|
||||||
|
name: "searchMenu",
|
||||||
|
icon: searchIcon,
|
||||||
|
keywords: ["search", "find"],
|
||||||
|
label: "search.title",
|
||||||
|
viewMode: true,
|
||||||
|
trackEvent: {
|
||||||
|
category: "search_menu",
|
||||||
|
action: "toggle",
|
||||||
|
predicate: (appState) => appState.gridModeEnabled,
|
||||||
|
},
|
||||||
|
perform(elements, appState, _, app) {
|
||||||
|
if (
|
||||||
|
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
||||||
|
appState.openSidebar.tab === CANVAS_SEARCH_TAB
|
||||||
|
) {
|
||||||
|
const searchInput =
|
||||||
|
app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
|
||||||
|
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (searchInput?.matches(":focus")) {
|
||||||
|
return {
|
||||||
|
appState: { ...appState, openSidebar: null },
|
||||||
|
storeAction: StoreAction.NONE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
searchInput?.focus();
|
||||||
|
searchInput?.select();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: CANVAS_SEARCH_TAB },
|
||||||
|
openDialog: null,
|
||||||
|
},
|
||||||
|
storeAction: StoreAction.NONE,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
checked: (appState: AppState) => appState.gridModeEnabled,
|
||||||
|
predicate: (element, appState, props) => {
|
||||||
|
return props.gridModeEnabled === undefined;
|
||||||
|
},
|
||||||
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
|
||||||
|
});
|
||||||
@@ -5,21 +5,22 @@ import { StoreAction } from "../store";
|
|||||||
|
|
||||||
export const actionToggleStats = register({
|
export const actionToggleStats = register({
|
||||||
name: "stats",
|
name: "stats",
|
||||||
label: "stats.title",
|
label: "stats.fullTitle",
|
||||||
icon: abacusIcon,
|
icon: abacusIcon,
|
||||||
paletteName: "Toggle stats",
|
paletteName: "Toggle stats",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "menu" },
|
trackEvent: { category: "menu" },
|
||||||
|
keywords: ["edit", "attributes", "customize"],
|
||||||
perform(elements, appState) {
|
perform(elements, appState) {
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
showStats: !this.checked!(appState),
|
stats: { ...appState.stats, open: !this.checked!(appState) },
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState) => appState.showStats,
|
checked: (appState) => appState.stats.open,
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -86,3 +86,5 @@ export { actionUnbindText, actionBindText } from "./actionBoundText";
|
|||||||
export { actionLink } from "./actionLink";
|
export { actionLink } from "./actionLink";
|
||||||
export { actionToggleElementLock } from "./actionElementLock";
|
export { actionToggleElementLock } from "./actionElementLock";
|
||||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
||||||
|
|
||||||
|
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ export type ShortcutName =
|
|||||||
>
|
>
|
||||||
| "saveScene"
|
| "saveScene"
|
||||||
| "imageExport"
|
| "imageExport"
|
||||||
| "commandPalette";
|
| "commandPalette"
|
||||||
|
| "searchMenu";
|
||||||
|
|
||||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||||
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
||||||
@@ -112,6 +113,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
|
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
|
||||||
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
||||||
toggleShortcuts: [getShortcutKey("?")],
|
toggleShortcuts: [getShortcutKey("?")],
|
||||||
|
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export type ActionName =
|
|||||||
| "changeSloppiness"
|
| "changeSloppiness"
|
||||||
| "changeStrokeStyle"
|
| "changeStrokeStyle"
|
||||||
| "changeArrowhead"
|
| "changeArrowhead"
|
||||||
|
| "changeArrowType"
|
||||||
| "changeOpacity"
|
| "changeOpacity"
|
||||||
| "changeFontSize"
|
| "changeFontSize"
|
||||||
| "toggleCanvasMenu"
|
| "toggleCanvasMenu"
|
||||||
@@ -135,7 +136,9 @@ export type ActionName =
|
|||||||
| "createContainerFromText"
|
| "createContainerFromText"
|
||||||
| "wrapTextInContainer"
|
| "wrapTextInContainer"
|
||||||
| "commandPalette"
|
| "commandPalette"
|
||||||
| "autoResize";
|
| "autoResize"
|
||||||
|
| "elementStats"
|
||||||
|
| "searchMenu";
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
@@ -189,7 +192,8 @@ export interface Action {
|
|||||||
| "history"
|
| "history"
|
||||||
| "menu"
|
| "menu"
|
||||||
| "collab"
|
| "collab"
|
||||||
| "hyperlink";
|
| "hyperlink"
|
||||||
|
| "search_menu";
|
||||||
action?: string;
|
action?: string;
|
||||||
predicate?: (
|
predicate?: (
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// place here categories that you want to track. We want to track just a
|
// place here categories that you want to track. We want to track just a
|
||||||
// small subset of categories at a given time.
|
// small subset of categories at a given time.
|
||||||
const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
|
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette", "export"]);
|
||||||
|
|
||||||
export const trackEvent = (
|
export const trackEvent = (
|
||||||
category: string,
|
category: string,
|
||||||
@@ -9,17 +9,20 @@ export const trackEvent = (
|
|||||||
value?: number,
|
value?: number,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
// prettier-ignore
|
|
||||||
if (
|
if (
|
||||||
typeof window === "undefined"
|
typeof window === "undefined" ||
|
||||||
|| import.meta.env.VITE_WORKER_ID
|
import.meta.env.VITE_WORKER_ID ||
|
||||||
// comment out to debug locally
|
import.meta.env.VITE_APP_ENABLE_TRACKING !== "true"
|
||||||
|| import.meta.env.PROD
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
|
if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
// comment out to debug in dev
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { COLOR_PALETTE } from "./colors";
|
import { COLOR_PALETTE } from "./colors";
|
||||||
import {
|
import {
|
||||||
|
ARROW_TYPE,
|
||||||
DEFAULT_ELEMENT_PROPS,
|
DEFAULT_ELEMENT_PROPS,
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
|
DEFAULT_GRID_SIZE,
|
||||||
EXPORT_SCALES,
|
EXPORT_SCALES,
|
||||||
|
STATS_PANELS,
|
||||||
THEME,
|
THEME,
|
||||||
|
DEFAULT_GRID_STEP,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import type { AppState, NormalizedZoomValue } from "./types";
|
import type { AppState, NormalizedZoomValue } from "./types";
|
||||||
|
|
||||||
@@ -32,13 +36,15 @@ export const getDefaultAppState = (): Omit<
|
|||||||
currentItemStartArrowhead: null,
|
currentItemStartArrowhead: null,
|
||||||
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
|
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||||
currentItemRoundness: "round",
|
currentItemRoundness: "round",
|
||||||
|
currentItemArrowType: ARROW_TYPE.round,
|
||||||
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||||
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||||
|
currentHoveredFontFamily: null,
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
draggingElement: null,
|
newElement: null,
|
||||||
editingElement: null,
|
editingTextElement: null,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
activeTool: {
|
activeTool: {
|
||||||
@@ -55,7 +61,9 @@ export const getDefaultAppState = (): Omit<
|
|||||||
exportEmbedScene: false,
|
exportEmbedScene: false,
|
||||||
exportWithDarkMode: false,
|
exportWithDarkMode: false,
|
||||||
fileHandle: null,
|
fileHandle: null,
|
||||||
gridSize: null,
|
gridSize: DEFAULT_GRID_SIZE,
|
||||||
|
gridStep: DEFAULT_GRID_STEP,
|
||||||
|
gridModeEnabled: false,
|
||||||
isBindingEnabled: true,
|
isBindingEnabled: true,
|
||||||
defaultSidebarDockedPreference: false,
|
defaultSidebarDockedPreference: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -80,7 +88,10 @@ export const getDefaultAppState = (): Omit<
|
|||||||
selectedElementsAreBeingDragged: false,
|
selectedElementsAreBeingDragged: false,
|
||||||
selectionElement: null,
|
selectionElement: null,
|
||||||
shouldCacheIgnoreZoom: false,
|
shouldCacheIgnoreZoom: false,
|
||||||
showStats: false,
|
stats: {
|
||||||
|
open: false,
|
||||||
|
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
|
||||||
|
},
|
||||||
startBoundElement: null,
|
startBoundElement: null,
|
||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
||||||
@@ -105,6 +116,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
objectsSnapModeEnabled: false,
|
objectsSnapModeEnabled: false,
|
||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
followedBy: new Set(),
|
followedBy: new Set(),
|
||||||
|
searchMatches: [],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,6 +150,11 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
export: false,
|
export: false,
|
||||||
server: false,
|
server: false,
|
||||||
},
|
},
|
||||||
|
currentItemArrowType: {
|
||||||
|
browser: true,
|
||||||
|
export: false,
|
||||||
|
server: false,
|
||||||
|
},
|
||||||
currentItemOpacity: { browser: true, export: false, server: false },
|
currentItemOpacity: { browser: true, export: false, server: false },
|
||||||
currentItemRoughness: { browser: true, export: false, server: false },
|
currentItemRoughness: { browser: true, export: false, server: false },
|
||||||
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
||||||
@@ -145,10 +162,11 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||||
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||||
currentItemTextAlign: { browser: true, export: false, server: false },
|
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||||
|
currentHoveredFontFamily: { browser: false, export: false, server: false },
|
||||||
cursorButton: { browser: true, export: false, server: false },
|
cursorButton: { browser: true, export: false, server: false },
|
||||||
activeEmbeddable: { browser: false, export: false, server: false },
|
activeEmbeddable: { browser: false, export: false, server: false },
|
||||||
draggingElement: { browser: false, export: false, server: false },
|
newElement: { browser: false, export: false, server: false },
|
||||||
editingElement: { browser: false, export: false, server: false },
|
editingTextElement: { browser: false, export: false, server: false },
|
||||||
editingGroupId: { browser: true, export: false, server: false },
|
editingGroupId: { browser: true, export: false, server: false },
|
||||||
editingLinearElement: { browser: false, export: false, server: false },
|
editingLinearElement: { browser: false, export: false, server: false },
|
||||||
activeTool: { browser: true, export: false, server: false },
|
activeTool: { browser: true, export: false, server: false },
|
||||||
@@ -161,6 +179,8 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
exportWithDarkMode: { browser: true, export: false, server: false },
|
exportWithDarkMode: { browser: true, export: false, server: false },
|
||||||
fileHandle: { browser: false, export: false, server: false },
|
fileHandle: { browser: false, export: false, server: false },
|
||||||
gridSize: { browser: true, export: true, server: true },
|
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 },
|
height: { browser: false, export: false, server: false },
|
||||||
isBindingEnabled: { browser: false, export: false, server: false },
|
isBindingEnabled: { browser: false, export: false, server: false },
|
||||||
defaultSidebarDockedPreference: {
|
defaultSidebarDockedPreference: {
|
||||||
@@ -196,7 +216,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
},
|
},
|
||||||
selectionElement: { browser: false, export: false, server: false },
|
selectionElement: { browser: false, export: false, server: false },
|
||||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||||
showStats: { browser: true, export: false, server: false },
|
stats: { browser: true, export: false, server: false },
|
||||||
startBoundElement: { browser: false, export: false, server: false },
|
startBoundElement: { browser: false, export: false, server: false },
|
||||||
suggestedBindings: { browser: false, export: false, server: false },
|
suggestedBindings: { browser: false, export: false, server: false },
|
||||||
frameRendering: { browser: false, export: false, server: false },
|
frameRendering: { browser: false, export: false, server: false },
|
||||||
@@ -217,6 +237,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
objectsSnapModeEnabled: { browser: true, export: false, server: false },
|
objectsSnapModeEnabled: { browser: true, export: false, server: false },
|
||||||
userToFollow: { browser: false, export: false, server: false },
|
userToFollow: { browser: false, export: false, server: false },
|
||||||
followedBy: { browser: false, export: false, server: false },
|
followedBy: { browser: false, export: false, server: false },
|
||||||
|
searchMatches: { browser: false, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
export default class BinaryHeap<T> {
|
||||||
|
private content: T[] = [];
|
||||||
|
|
||||||
|
constructor(private scoreFunction: (node: T) => number) {}
|
||||||
|
|
||||||
|
sinkDown(idx: number) {
|
||||||
|
const node = this.content[idx];
|
||||||
|
while (idx > 0) {
|
||||||
|
const parentN = ((idx + 1) >> 1) - 1;
|
||||||
|
const parent = this.content[parentN];
|
||||||
|
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
|
||||||
|
this.content[parentN] = node;
|
||||||
|
this.content[idx] = parent;
|
||||||
|
idx = parentN; // TODO: Optimize
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bubbleUp(idx: number) {
|
||||||
|
const length = this.content.length;
|
||||||
|
const node = this.content[idx];
|
||||||
|
const score = this.scoreFunction(node);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const child2N = (idx + 1) << 1;
|
||||||
|
const child1N = child2N - 1;
|
||||||
|
let swap = null;
|
||||||
|
let child1Score = 0;
|
||||||
|
|
||||||
|
if (child1N < length) {
|
||||||
|
const child1 = this.content[child1N];
|
||||||
|
child1Score = this.scoreFunction(child1);
|
||||||
|
if (child1Score < score) {
|
||||||
|
swap = child1N;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child2N < length) {
|
||||||
|
const child2 = this.content[child2N];
|
||||||
|
const child2Score = this.scoreFunction(child2);
|
||||||
|
if (child2Score < (swap === null ? score : child1Score)) {
|
||||||
|
swap = child2N;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (swap !== null) {
|
||||||
|
this.content[idx] = this.content[swap];
|
||||||
|
this.content[swap] = node;
|
||||||
|
idx = swap; // TODO: Optimize
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
push(node: T) {
|
||||||
|
this.content.push(node);
|
||||||
|
this.sinkDown(this.content.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pop(): T | null {
|
||||||
|
if (this.content.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.content[0];
|
||||||
|
const end = this.content.pop()!;
|
||||||
|
|
||||||
|
if (this.content.length > 0) {
|
||||||
|
this.content[0] = end;
|
||||||
|
this.bubbleUp(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(node: T) {
|
||||||
|
if (this.content.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const i = this.content.indexOf(node);
|
||||||
|
const end = this.content.pop()!;
|
||||||
|
|
||||||
|
if (i < this.content.length) {
|
||||||
|
this.content[i] = end;
|
||||||
|
|
||||||
|
if (this.scoreFunction(end) < this.scoreFunction(node)) {
|
||||||
|
this.sinkDown(i);
|
||||||
|
} else {
|
||||||
|
this.bubbleUp(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size(): number {
|
||||||
|
return this.content.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
rescoreElement(node: T) {
|
||||||
|
this.sinkDown(this.content.indexOf(node));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1100,7 +1100,6 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
try {
|
try {
|
||||||
// TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
|
// TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
|
||||||
ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
|
ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
|
||||||
ElementsChange.redrawBoundArrows(nextElements, changedElements);
|
|
||||||
|
|
||||||
// the following reorder performs also mutations, but only on new instances of changed elements
|
// 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)
|
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
||||||
@@ -1109,6 +1108,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
changedElements,
|
changedElements,
|
||||||
flags,
|
flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Need ordered nextElements to avoid z-index binding issues
|
||||||
|
ElementsChange.redrawBoundArrows(nextElements, changedElements);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
`Couldn't mutate elements after applying elements change`,
|
`Couldn't mutate elements after applying elements change`,
|
||||||
@@ -1460,7 +1462,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
) {
|
) {
|
||||||
for (const element of changed.values()) {
|
for (const element of changed.values()) {
|
||||||
if (!element.isDeleted && isBindableElement(element)) {
|
if (!element.isDeleted && isBindableElement(element)) {
|
||||||
updateBoundElements(element, elements);
|
updateBoundElements(element, elements, {
|
||||||
|
changedElements: changed,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Radians } from "../math";
|
||||||
|
import { point } from "../math";
|
||||||
import {
|
import {
|
||||||
COLOR_PALETTE,
|
COLOR_PALETTE,
|
||||||
DEFAULT_CHART_COLOR_INDEX,
|
DEFAULT_CHART_COLOR_INDEX,
|
||||||
@@ -203,7 +205,7 @@ const chartXLabels = (
|
|||||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
||||||
y: y + BAR_GAP / 2,
|
y: y + BAR_GAP / 2,
|
||||||
width: BAR_WIDTH,
|
width: BAR_WIDTH,
|
||||||
angle: 5.87,
|
angle: 5.87 as Radians,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
verticalAlign: "top",
|
verticalAlign: "top",
|
||||||
@@ -257,13 +259,8 @@ const chartLines = (
|
|||||||
type: "line",
|
type: "line",
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
width: chartWidth,
|
width: chartWidth,
|
||||||
points: [
|
points: [point(0, 0), point(chartWidth, 0)],
|
||||||
[0, 0],
|
|
||||||
[chartWidth, 0],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const yLine = newLinearElement({
|
const yLine = newLinearElement({
|
||||||
@@ -273,13 +270,8 @@ const chartLines = (
|
|||||||
type: "line",
|
type: "line",
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
height: chartHeight,
|
height: chartHeight,
|
||||||
points: [
|
points: [point(0, 0), point(0, -chartHeight)],
|
||||||
[0, 0],
|
|
||||||
[0, -chartHeight],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxLine = newLinearElement({
|
const maxLine = newLinearElement({
|
||||||
@@ -289,15 +281,10 @@ const chartLines = (
|
|||||||
type: "line",
|
type: "line",
|
||||||
x,
|
x,
|
||||||
y: y - BAR_HEIGHT - BAR_GAP,
|
y: y - BAR_HEIGHT - BAR_GAP,
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
strokeStyle: "dotted",
|
strokeStyle: "dotted",
|
||||||
width: chartWidth,
|
width: chartWidth,
|
||||||
opacity: GRID_OPACITY,
|
opacity: GRID_OPACITY,
|
||||||
points: [
|
points: [point(0, 0), point(chartWidth, 0)],
|
||||||
[0, 0],
|
|
||||||
[chartWidth, 0],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return [xLine, yLine, maxLine];
|
return [xLine, yLine, maxLine];
|
||||||
@@ -418,8 +405,6 @@ const chartTypeLine = (
|
|||||||
type: "line",
|
type: "line",
|
||||||
x: x + BAR_GAP + BAR_WIDTH / 2,
|
x: x + BAR_GAP + BAR_WIDTH / 2,
|
||||||
y: y - BAR_GAP,
|
y: y - BAR_GAP,
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
height: maxY - minY,
|
height: maxY - minY,
|
||||||
width: maxX - minX,
|
width: maxX - minX,
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
@@ -453,15 +438,10 @@ const chartTypeLine = (
|
|||||||
type: "line",
|
type: "line",
|
||||||
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
|
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
|
||||||
y: y - cy,
|
y: y - cy,
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
height: cy,
|
height: cy,
|
||||||
strokeStyle: "dotted",
|
strokeStyle: "dotted",
|
||||||
opacity: GRID_OPACITY,
|
opacity: GRID_OPACITY,
|
||||||
points: [
|
points: [point(0, 0), point(0, cy)],
|
||||||
[0, 0],
|
|
||||||
[0, cy],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,11 @@ import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
|
|||||||
import { capitalizeString, isTransparent } from "../utils";
|
import { capitalizeString, isTransparent } from "../utils";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { hasStrokeColor } from "../scene/comparisons";
|
import { hasStrokeColor, toolIsArrow } from "../scene/comparisons";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import {
|
import {
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
|
isElbowArrow,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
@@ -44,11 +45,11 @@ import {
|
|||||||
frameToolIcon,
|
frameToolIcon,
|
||||||
mermaidLogoIcon,
|
mermaidLogoIcon,
|
||||||
laserPointerToolIcon,
|
laserPointerToolIcon,
|
||||||
OpenAIIcon,
|
|
||||||
MagicIcon,
|
MagicIcon,
|
||||||
} from "./icons";
|
} from "./icons";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { useTunnels } from "../context/tunnels";
|
import { useTunnels } from "../context/tunnels";
|
||||||
|
import { CLASSES } from "../constants";
|
||||||
|
|
||||||
export const canChangeStrokeColor = (
|
export const canChangeStrokeColor = (
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
@@ -103,7 +104,9 @@ export const SelectedShapeActions = ({
|
|||||||
) {
|
) {
|
||||||
isSingleElementBoundContainer = true;
|
isSingleElementBoundContainer = true;
|
||||||
}
|
}
|
||||||
const isEditing = Boolean(appState.editingElement);
|
const isEditingTextOrNewElement = Boolean(
|
||||||
|
appState.editingTextElement || appState.newElement,
|
||||||
|
);
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||||
|
|
||||||
@@ -121,7 +124,8 @@ export const SelectedShapeActions = ({
|
|||||||
const showLineEditorAction =
|
const showLineEditorAction =
|
||||||
!appState.editingLinearElement &&
|
!appState.editingLinearElement &&
|
||||||
targetElements.length === 1 &&
|
targetElements.length === 1 &&
|
||||||
isLinearElement(targetElements[0]);
|
isLinearElement(targetElements[0]) &&
|
||||||
|
!isElbowArrow(targetElements[0]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panelColumn">
|
<div className="panelColumn">
|
||||||
@@ -155,13 +159,16 @@ export const SelectedShapeActions = ({
|
|||||||
<>{renderAction("changeRoundness")}</>
|
<>{renderAction("changeRoundness")}</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(toolIsArrow(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) => toolIsArrow(element.type))) && (
|
||||||
|
<>{renderAction("changeArrowType")}</>
|
||||||
|
)}
|
||||||
|
|
||||||
{(appState.activeTool.type === "text" ||
|
{(appState.activeTool.type === "text" ||
|
||||||
targetElements.some(isTextElement)) && (
|
targetElements.some(isTextElement)) && (
|
||||||
<>
|
<>
|
||||||
{renderAction("changeFontSize")}
|
|
||||||
|
|
||||||
{renderAction("changeFontFamily")}
|
{renderAction("changeFontFamily")}
|
||||||
|
{renderAction("changeFontSize")}
|
||||||
{(appState.activeTool.type === "text" ||
|
{(appState.activeTool.type === "text" ||
|
||||||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
|
suppportsHorizontalAlign(targetElements, elementsMap)) &&
|
||||||
renderAction("changeTextAlign")}
|
renderAction("changeTextAlign")}
|
||||||
@@ -229,7 +236,7 @@ export const SelectedShapeActions = ({
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
{!isEditing && targetElements.length > 0 && (
|
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.actions")}</legend>
|
<legend>{t("labels.actions")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@@ -395,7 +402,7 @@ export const ShapesSwitcher = ({
|
|||||||
>
|
>
|
||||||
{t("toolBar.mermaidToExcalidraw")}
|
{t("toolBar.mermaidToExcalidraw")}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
{app.props.aiEnabled !== false && (
|
{app.props.aiEnabled !== false && app.plugins.diagramToCode && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => app.onMagicframeToolSelect()}
|
onSelect={() => app.onMagicframeToolSelect()}
|
||||||
@@ -405,20 +412,6 @@ export const ShapesSwitcher = ({
|
|||||||
{t("toolBar.magicframe")}
|
{t("toolBar.magicframe")}
|
||||||
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
||||||
</DropdownMenu.Item>
|
</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>
|
</DropdownMenu.Content>
|
||||||
@@ -434,7 +427,7 @@ export const ZoomActions = ({
|
|||||||
renderAction: ActionManager["renderAction"];
|
renderAction: ActionManager["renderAction"];
|
||||||
zoom: Zoom;
|
zoom: Zoom;
|
||||||
}) => (
|
}) => (
|
||||||
<Stack.Col gap={1} className="zoom-actions">
|
<Stack.Col gap={1} className={CLASSES.ZOOM_ACTIONS}>
|
||||||
<Stack.Row align="center">
|
<Stack.Row align="center">
|
||||||
{renderAction("zoomOut")}
|
{renderAction("zoomOut")}
|
||||||
{renderAction("resetZoom")}
|
{renderAction("resetZoom")}
|
||||||
|
|||||||
+1422
-956
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { ButtonIcon } from "./ButtonIcon";
|
||||||
|
|
||||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
||||||
export const ButtonIconSelect = <T extends Object>(
|
export const ButtonIconSelect = <T extends Object>(
|
||||||
@@ -24,21 +25,17 @@ export const ButtonIconSelect = <T extends Object>(
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
) => (
|
) => (
|
||||||
<div className="buttonList buttonListIcon">
|
<div className="buttonList">
|
||||||
{props.options.map((option) =>
|
{props.options.map((option) =>
|
||||||
props.type === "button" ? (
|
props.type === "button" ? (
|
||||||
<button
|
<ButtonIcon
|
||||||
type="button"
|
|
||||||
key={option.text}
|
key={option.text}
|
||||||
onClick={(event) => props.onClick(option.value, event)}
|
icon={option.icon}
|
||||||
className={clsx({
|
|
||||||
active: option.active ?? props.value === option.value,
|
|
||||||
})}
|
|
||||||
data-testid={option.testId}
|
|
||||||
title={option.text}
|
title={option.text}
|
||||||
>
|
testId={option.testId}
|
||||||
{option.icon}
|
active={option.active ?? props.value === option.value}
|
||||||
</button>
|
onClick={(event) => props.onClick(option.value, event)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<label
|
<label
|
||||||
key={option.text}
|
key={option.text}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const ButtonSeparator = () => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 1,
|
||||||
|
height: "1rem",
|
||||||
|
backgroundColor: "var(--default-border-color)",
|
||||||
|
margin: "0 auto",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
@include isMobile {
|
@include isMobile {
|
||||||
max-width: 175px;
|
max-width: 11rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
|
import { isTransparent } from "../../utils";
|
||||||
import type { ExcalidrawElement } from "../../element/types";
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
import { TopPicks } from "./TopPicks";
|
import { TopPicks } from "./TopPicks";
|
||||||
|
import { ButtonSeparator } from "../ButtonSeparator";
|
||||||
import { Picker } from "./Picker";
|
import { Picker } from "./Picker";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type { ColorPickerType } from "./colorPickerUtils";
|
import type { ColorPickerType } from "./colorPickerUtils";
|
||||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||||
import { useDevice, useExcalidrawContainer } from "../App";
|
import { useExcalidrawContainer } from "../App";
|
||||||
import type { ColorTuple, ColorPaletteCustom } from "../../colors";
|
import type { ColorTuple, ColorPaletteCustom } from "../../colors";
|
||||||
import { COLOR_PALETTE } from "../../colors";
|
import { COLOR_PALETTE } from "../../colors";
|
||||||
import PickerHeading from "./PickerHeading";
|
import PickerHeading from "./PickerHeading";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { useRef } from "react";
|
||||||
import { jotaiScope } from "../../jotai";
|
import { jotaiScope } from "../../jotai";
|
||||||
import { ColorInput } from "./ColorInput";
|
import { ColorInput } from "./ColorInput";
|
||||||
import { useRef } from "react";
|
|
||||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||||
|
import { PropertiesPopover } from "../PropertiesPopover";
|
||||||
|
|
||||||
import "./ColorPicker.scss";
|
import "./ColorPicker.scss";
|
||||||
|
|
||||||
@@ -71,6 +73,7 @@ const ColorPickerPopupContent = ({
|
|||||||
| "palette"
|
| "palette"
|
||||||
| "updateData"
|
| "updateData"
|
||||||
>) => {
|
>) => {
|
||||||
|
const { container } = useExcalidrawContainer();
|
||||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||||
|
|
||||||
const [eyeDropperState, setEyeDropperState] = useAtom(
|
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||||
@@ -78,9 +81,6 @@ const ColorPickerPopupContent = ({
|
|||||||
jotaiScope,
|
jotaiScope,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { container } = useExcalidrawContainer();
|
|
||||||
const device = useDevice();
|
|
||||||
|
|
||||||
const colorInputJSX = (
|
const colorInputJSX = (
|
||||||
<div>
|
<div>
|
||||||
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
|
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
|
||||||
@@ -94,6 +94,7 @@ const ColorPickerPopupContent = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const focusPickerContent = () => {
|
const focusPickerContent = () => {
|
||||||
@@ -103,120 +104,73 @@ const ColorPickerPopupContent = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Portal container={container}>
|
<PropertiesPopover
|
||||||
<Popover.Content
|
container={container}
|
||||||
ref={popoverRef}
|
style={{ maxWidth: "13rem" }}
|
||||||
className="focus-visible-none"
|
onFocusOutside={(event) => {
|
||||||
data-prevent-outside-click
|
// refocus due to eye dropper
|
||||||
onFocusOutside={(event) => {
|
focusPickerContent();
|
||||||
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)
|
||||||
event.preventDefault();
|
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)
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCloseAutoFocus={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
// prevents focusing the trigger
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// 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
|
onClose={() => {
|
||||||
? "center"
|
updateData({ openPopup: null });
|
||||||
: "start"
|
setActiveColorPickerSection(null);
|
||||||
}
|
}}
|
||||||
alignOffset={-16}
|
>
|
||||||
sideOffset={20}
|
{palette ? (
|
||||||
style={{
|
<Picker
|
||||||
zIndex: "var(--zIndex-layerUI)",
|
palette={palette}
|
||||||
backgroundColor: "var(--popup-bg-color)",
|
color={color}
|
||||||
maxWidth: "208px",
|
onChange={(changedColor) => {
|
||||||
maxHeight: window.innerHeight,
|
onChange(changedColor);
|
||||||
padding: "12px",
|
}}
|
||||||
borderRadius: "8px",
|
onEyeDropperToggle={(force) => {
|
||||||
boxSizing: "border-box",
|
setEyeDropperState((state) => {
|
||||||
overflowY: "auto",
|
if (force) {
|
||||||
boxShadow:
|
state = state || {
|
||||||
"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)",
|
keepOpenOnAlt: true,
|
||||||
}}
|
onSelect: onChange,
|
||||||
>
|
colorPickerType: type,
|
||||||
{palette ? (
|
};
|
||||||
<Picker
|
state.keepOpenOnAlt = true;
|
||||||
palette={palette}
|
return state;
|
||||||
color={color}
|
}
|
||||||
onChange={(changedColor) => {
|
|
||||||
onChange(changedColor);
|
return force === false || state
|
||||||
}}
|
? null
|
||||||
onEyeDropperToggle={(force) => {
|
: {
|
||||||
setEyeDropperState((state) => {
|
keepOpenOnAlt: false,
|
||||||
if (force) {
|
|
||||||
state = state || {
|
|
||||||
keepOpenOnAlt: true,
|
|
||||||
onSelect: onChange,
|
onSelect: onChange,
|
||||||
colorPickerType: type,
|
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) => {
|
||||||
</Popover.Content>
|
if (eyeDropperState) {
|
||||||
</Popover.Portal>
|
setEyeDropperState(null);
|
||||||
|
} else {
|
||||||
|
updateData({ openPopup: null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
label={label}
|
||||||
|
type={type}
|
||||||
|
elements={elements}
|
||||||
|
updateData={updateData}
|
||||||
|
>
|
||||||
|
{colorInputJSX}
|
||||||
|
</Picker>
|
||||||
|
) : (
|
||||||
|
colorInputJSX
|
||||||
|
)}
|
||||||
|
</PropertiesPopover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -232,7 +186,7 @@ const ColorPickerTrigger = ({
|
|||||||
return (
|
return (
|
||||||
<Popover.Trigger
|
<Popover.Trigger
|
||||||
type="button"
|
type="button"
|
||||||
className={clsx("color-picker__button active-color", {
|
className={clsx("color-picker__button active-color properties-trigger", {
|
||||||
"is-transparent": color === "transparent" || !color,
|
"is-transparent": color === "transparent" || !color,
|
||||||
})}
|
})}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
@@ -268,14 +222,7 @@ export const ColorPicker = ({
|
|||||||
type={type}
|
type={type}
|
||||||
topPicks={topPicks}
|
topPicks={topPicks}
|
||||||
/>
|
/>
|
||||||
<div
|
<ButtonSeparator />
|
||||||
style={{
|
|
||||||
width: 1,
|
|
||||||
height: "100%",
|
|
||||||
backgroundColor: "var(--default-border-color)",
|
|
||||||
margin: "0 auto",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Popover.Root
|
<Popover.Root
|
||||||
open={appState.openPopup === type}
|
open={appState.openPopup === type}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export const Picker = ({
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="color-picker-content"
|
className="color-picker-content properties-content"
|
||||||
// to allow focusing by clicking but not by tabbing
|
// to allow focusing by clicking but not by tabbing
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ import { InlineIcon } from "../InlineIcon";
|
|||||||
import { SHAPES } from "../../shapes";
|
import { SHAPES } from "../../shapes";
|
||||||
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
|
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
|
||||||
import { useStableCallback } from "../../hooks/useStableCallback";
|
import { useStableCallback } from "../../hooks/useStableCallback";
|
||||||
import { actionClearCanvas, actionLink } from "../../actions";
|
import {
|
||||||
|
actionClearCanvas,
|
||||||
|
actionLink,
|
||||||
|
actionToggleSearchMenu,
|
||||||
|
} from "../../actions";
|
||||||
import { jotaiStore } from "../../jotai";
|
import { jotaiStore } from "../../jotai";
|
||||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||||
import type { CommandPaletteItem } from "./types";
|
import type { CommandPaletteItem } from "./types";
|
||||||
@@ -382,6 +386,15 @@ function CommandPaletteInner({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("search.title"),
|
||||||
|
category: DEFAULT_CATEGORIES.app,
|
||||||
|
icon: searchIcon,
|
||||||
|
viewMode: true,
|
||||||
|
perform: () => {
|
||||||
|
actionManager.executeAction(actionToggleSearchMenu);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t("labels.changeStroke"),
|
label: t("labels.changeStroke"),
|
||||||
keywords: ["color", "outline"],
|
keywords: ["color", "outline"],
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
assertExcalidrawWithSidebar,
|
assertExcalidrawWithSidebar,
|
||||||
assertSidebarDockButton,
|
assertSidebarDockButton,
|
||||||
} from "./Sidebar/Sidebar.test";
|
} from "./Sidebar/siderbar.test.helpers";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants";
|
import {
|
||||||
|
CANVAS_SEARCH_TAB,
|
||||||
|
DEFAULT_SIDEBAR,
|
||||||
|
LIBRARY_SIDEBAR_TAB,
|
||||||
|
} from "../constants";
|
||||||
import { useTunnels } from "../context/tunnels";
|
import { useTunnels } from "../context/tunnels";
|
||||||
import { useUIAppState } from "../context/ui-appState";
|
import { useUIAppState } from "../context/ui-appState";
|
||||||
import { t } from "../i18n";
|
|
||||||
import type { MarkOptional, Merge } from "../utility-types";
|
import type { MarkOptional, Merge } from "../utility-types";
|
||||||
import { composeEventHandlers } from "../utils";
|
import { composeEventHandlers } from "../utils";
|
||||||
import { useExcalidrawSetAppState } from "./App";
|
import { useExcalidrawSetAppState } from "./App";
|
||||||
@@ -10,6 +13,9 @@ import { withInternalFallback } from "./hoc/withInternalFallback";
|
|||||||
import { LibraryMenu } from "./LibraryMenu";
|
import { LibraryMenu } from "./LibraryMenu";
|
||||||
import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
|
import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
|
||||||
import { Sidebar } from "./Sidebar/Sidebar";
|
import { Sidebar } from "./Sidebar/Sidebar";
|
||||||
|
import "../components/dropdownMenu/DropdownMenu.scss";
|
||||||
|
import { SearchMenu } from "./SearchMenu";
|
||||||
|
import { LibraryIcon, searchIcon } from "./icons";
|
||||||
|
|
||||||
const DefaultSidebarTrigger = withInternalFallback(
|
const DefaultSidebarTrigger = withInternalFallback(
|
||||||
"DefaultSidebarTrigger",
|
"DefaultSidebarTrigger",
|
||||||
@@ -31,14 +37,11 @@ const DefaultSidebarTrigger = withInternalFallback(
|
|||||||
);
|
);
|
||||||
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
|
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
|
||||||
|
|
||||||
const DefaultTabTriggers = ({
|
const DefaultTabTriggers = ({ children }: { children: React.ReactNode }) => {
|
||||||
children,
|
|
||||||
...rest
|
|
||||||
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
|
|
||||||
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
|
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
|
||||||
return (
|
return (
|
||||||
<DefaultSidebarTabTriggersTunnel.In>
|
<DefaultSidebarTabTriggersTunnel.In>
|
||||||
<Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers>
|
{children}
|
||||||
</DefaultSidebarTabTriggersTunnel.In>
|
</DefaultSidebarTabTriggersTunnel.In>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -65,17 +68,21 @@ export const DefaultSidebar = Object.assign(
|
|||||||
|
|
||||||
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
|
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
|
||||||
|
|
||||||
|
const isForceDocked = appState.openSidebar?.tab === CANVAS_SEARCH_TAB;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar
|
<Sidebar
|
||||||
{...rest}
|
{...rest}
|
||||||
name="default"
|
name="default"
|
||||||
key="default"
|
key="default"
|
||||||
className={clsx("default-sidebar", className)}
|
className={clsx("default-sidebar", className)}
|
||||||
docked={docked ?? appState.defaultSidebarDockedPreference}
|
docked={
|
||||||
|
isForceDocked || (docked ?? appState.defaultSidebarDockedPreference)
|
||||||
|
}
|
||||||
onDock={
|
onDock={
|
||||||
// `onDock=false` disables docking.
|
// `onDock=false` disables docking.
|
||||||
// if `docked` passed, but no onDock passed, disable manual docking.
|
// if `docked` passed, but no onDock passed, disable manual docking.
|
||||||
onDock === false || (!onDock && docked != null)
|
isForceDocked || onDock === false || (!onDock && docked != null)
|
||||||
? undefined
|
? undefined
|
||||||
: // compose to allow the host app to listen on default behavior
|
: // compose to allow the host app to listen on default behavior
|
||||||
composeEventHandlers(onDock, (docked) => {
|
composeEventHandlers(onDock, (docked) => {
|
||||||
@@ -85,26 +92,22 @@ export const DefaultSidebar = Object.assign(
|
|||||||
>
|
>
|
||||||
<Sidebar.Tabs>
|
<Sidebar.Tabs>
|
||||||
<Sidebar.Header>
|
<Sidebar.Header>
|
||||||
{rest.__fallback && (
|
<Sidebar.TabTriggers>
|
||||||
<div
|
<Sidebar.TabTrigger tab={CANVAS_SEARCH_TAB}>
|
||||||
style={{
|
{searchIcon}
|
||||||
color: "var(--color-primary)",
|
</Sidebar.TabTrigger>
|
||||||
fontSize: "1.2em",
|
<Sidebar.TabTrigger tab={LIBRARY_SIDEBAR_TAB}>
|
||||||
fontWeight: "bold",
|
{LibraryIcon}
|
||||||
textOverflow: "ellipsis",
|
</Sidebar.TabTrigger>
|
||||||
overflow: "hidden",
|
<DefaultSidebarTabTriggersTunnel.Out />
|
||||||
whiteSpace: "nowrap",
|
</Sidebar.TabTriggers>
|
||||||
paddingRight: "1em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("toolBar.library")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<DefaultSidebarTabTriggersTunnel.Out />
|
|
||||||
</Sidebar.Header>
|
</Sidebar.Header>
|
||||||
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
|
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
|
||||||
<LibraryMenu />
|
<LibraryMenu />
|
||||||
</Sidebar.Tab>
|
</Sidebar.Tab>
|
||||||
|
<Sidebar.Tab tab={CANVAS_SEARCH_TAB}>
|
||||||
|
<SearchMenu />
|
||||||
|
</Sidebar.Tab>
|
||||||
{children}
|
{children}
|
||||||
</Sidebar.Tabs>
|
</Sidebar.Tabs>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useLayoutEffect } from "react";
|
||||||
|
import { useApp } from "../App";
|
||||||
|
import type { GenerateDiagramToCode } from "../../types";
|
||||||
|
|
||||||
|
export const DiagramToCodePlugin = (props: {
|
||||||
|
generate: GenerateDiagramToCode;
|
||||||
|
}) => {
|
||||||
|
const app = useApp();
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
app.setPlugins({
|
||||||
|
diagramToCode: { generate: props.generate },
|
||||||
|
});
|
||||||
|
}, [app, props.generate]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
@import "../css/variables.module.scss";
|
@import "../css/variables.module.scss";
|
||||||
|
|
||||||
|
@keyframes successStatusAnimation {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(1.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.ExcButton {
|
.ExcButton {
|
||||||
--text-color: transparent;
|
--text-color: transparent;
|
||||||
@@ -16,11 +30,20 @@
|
|||||||
|
|
||||||
.Spinner {
|
.Spinner {
|
||||||
--spinner-color: var(--color-surface-lowest);
|
--spinner-color: var(--color-surface-lowest);
|
||||||
position: absolute;
|
|
||||||
visibility: visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&[disabled] {
|
.ExcButton__statusIcon {
|
||||||
|
visibility: visible;
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
|
||||||
|
animation: successStatusAnimation 0.5s cubic-bezier(0.3, 1, 0.6, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ExcButton--status-loading,
|
||||||
|
&.ExcButton--status-success {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
.ExcButton__contents {
|
.ExcButton__contents {
|
||||||
@@ -28,6 +51,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
&,
|
&,
|
||||||
&__contents {
|
&__contents {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -119,6 +146,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--color-success {
|
||||||
|
&.ExcButton--variant-filled {
|
||||||
|
--text-color: var(--color-success-text);
|
||||||
|
--back-color: var(--color-success);
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
--spinner-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
--back-color: var(--color-success-darker);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
--back-color: var(--color-success-darkest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ExcButton--variant-outlined,
|
||||||
|
&.ExcButton--variant-icon {
|
||||||
|
--text-color: var(--color-success-contrast);
|
||||||
|
--border-color: var(--color-success-contrast);
|
||||||
|
--back-color: transparent;
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
--spinner-color: var(--color-success-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
--text-color: var(--color-success-contrast-hover);
|
||||||
|
--border-color: var(--color-success-contrast-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
--text-color: var(--color-success-contrast-active);
|
||||||
|
--border-color: var(--color-success-contrast-active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&--color-muted {
|
&--color-muted {
|
||||||
&.ExcButton--variant-filled {
|
&.ExcButton--variant-filled {
|
||||||
--text-color: var(--island-bg-color);
|
--text-color: var(--island-bg-color);
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ import "./FilledButton.scss";
|
|||||||
import { AbortError } from "../errors";
|
import { AbortError } from "../errors";
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
import { isPromiseLike } from "../utils";
|
import { isPromiseLike } from "../utils";
|
||||||
|
import { tablerCheckIcon } from "./icons";
|
||||||
|
|
||||||
export type ButtonVariant = "filled" | "outlined" | "icon";
|
export type ButtonVariant = "filled" | "outlined" | "icon";
|
||||||
export type ButtonColor = "primary" | "danger" | "warning" | "muted";
|
export type ButtonColor =
|
||||||
|
| "primary"
|
||||||
|
| "danger"
|
||||||
|
| "warning"
|
||||||
|
| "muted"
|
||||||
|
| "success";
|
||||||
export type ButtonSize = "medium" | "large";
|
export type ButtonSize = "medium" | "large";
|
||||||
|
|
||||||
export type FilledButtonProps = {
|
export type FilledButtonProps = {
|
||||||
@@ -15,6 +21,7 @@ export type FilledButtonProps = {
|
|||||||
|
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onClick?: (event: React.MouseEvent) => void;
|
onClick?: (event: React.MouseEvent) => void;
|
||||||
|
status?: null | "loading" | "success";
|
||||||
|
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
color?: ButtonColor;
|
color?: ButtonColor;
|
||||||
@@ -37,6 +44,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||||||
size = "medium",
|
size = "medium",
|
||||||
fullWidth,
|
fullWidth,
|
||||||
className,
|
className,
|
||||||
|
status,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
@@ -46,8 +54,11 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||||||
const ret = onClick?.(event);
|
const ret = onClick?.(event);
|
||||||
|
|
||||||
if (isPromiseLike(ret)) {
|
if (isPromiseLike(ret)) {
|
||||||
try {
|
// delay loading state to prevent flicker in case of quick response
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
}, 50);
|
||||||
|
try {
|
||||||
await ret;
|
await ret;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (!(error instanceof AbortError)) {
|
if (!(error instanceof AbortError)) {
|
||||||
@@ -56,11 +67,15 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||||||
console.warn(error);
|
console.warn(error);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _status = isLoading ? "loading" : status;
|
||||||
|
color = _status === "success" ? "success" : color;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -68,6 +83,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||||||
`ExcButton--color-${color}`,
|
`ExcButton--color-${color}`,
|
||||||
`ExcButton--variant-${variant}`,
|
`ExcButton--variant-${variant}`,
|
||||||
`ExcButton--size-${size}`,
|
`ExcButton--size-${size}`,
|
||||||
|
`ExcButton--status-${_status}`,
|
||||||
{ "ExcButton--fullWidth": fullWidth },
|
{ "ExcButton--fullWidth": fullWidth },
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -75,10 +91,16 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
disabled={isLoading}
|
disabled={_status === "loading" || _status === "success"}
|
||||||
>
|
>
|
||||||
<div className="ExcButton__contents">
|
<div className="ExcButton__contents">
|
||||||
{isLoading && <Spinner />}
|
{_status === "loading" ? (
|
||||||
|
<Spinner className="ExcButton__statusIcon" />
|
||||||
|
) : (
|
||||||
|
_status === "success" && (
|
||||||
|
<div className="ExcButton__statusIcon">{tablerCheckIcon}</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
{icon && (
|
{icon && (
|
||||||
<div className="ExcButton__icon" aria-hidden>
|
<div className="ExcButton__icon" aria-hidden>
|
||||||
{icon}
|
{icon}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
@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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
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-handrawn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
);
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import React, {
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
type KeyboardEventHandler,
|
||||||
|
} from "react";
|
||||||
|
import { useApp, useAppProps, useExcalidrawContainer } from "../App";
|
||||||
|
import { PropertiesPopover } from "../PropertiesPopover";
|
||||||
|
import { QuickSearch } from "../QuickSearch";
|
||||||
|
import { ScrollableList } from "../ScrollableList";
|
||||||
|
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
|
||||||
|
import DropdownMenuItem, {
|
||||||
|
DropDownMenuItemBadgeType,
|
||||||
|
DropDownMenuItemBadge,
|
||||||
|
} from "../dropdownMenu/DropdownMenuItem";
|
||||||
|
import { type FontFamilyValues } from "../../element/types";
|
||||||
|
import { arrayToList, debounce, getFontFamilyString } from "../../utils";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
|
||||||
|
import { Fonts } from "../../fonts";
|
||||||
|
import type { ValueOf } from "../../utility-types";
|
||||||
|
|
||||||
|
export interface FontDescriptor {
|
||||||
|
value: number;
|
||||||
|
icon: JSX.Element;
|
||||||
|
text: string;
|
||||||
|
deprecated?: true;
|
||||||
|
badge?: {
|
||||||
|
type: ValueOf<typeof DropDownMenuItemBadgeType>;
|
||||||
|
placeholder: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FontPickerListProps {
|
||||||
|
selectedFontFamily: FontFamilyValues | null;
|
||||||
|
hoveredFontFamily: FontFamilyValues | null;
|
||||||
|
onSelect: (value: number) => void;
|
||||||
|
onHover: (value: number) => void;
|
||||||
|
onLeave: () => void;
|
||||||
|
onOpen: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FontPickerList = React.memo(
|
||||||
|
({
|
||||||
|
selectedFontFamily,
|
||||||
|
hoveredFontFamily,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
onLeave,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
}: FontPickerListProps) => {
|
||||||
|
const { container } = useExcalidrawContainer();
|
||||||
|
const { fonts } = useApp();
|
||||||
|
const { showDeprecatedFonts } = useAppProps();
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const allFonts = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(Fonts.registered.entries())
|
||||||
|
.filter(([_, { metadata }]) => !metadata.serverSide)
|
||||||
|
.map(([familyId, { metadata, fonts }]) => {
|
||||||
|
const fontDescriptor = {
|
||||||
|
value: familyId,
|
||||||
|
icon: metadata.icon,
|
||||||
|
text: fonts[0].fontFace.family,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (metadata.deprecated) {
|
||||||
|
Object.assign(fontDescriptor, {
|
||||||
|
deprecated: metadata.deprecated,
|
||||||
|
badge: {
|
||||||
|
type: DropDownMenuItemBadgeType.RED,
|
||||||
|
placeholder: t("fontList.badge.old"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return fontDescriptor as FontDescriptor;
|
||||||
|
})
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1,
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sceneFamilies = useMemo(
|
||||||
|
() => new Set(fonts.getSceneFontFamilies()),
|
||||||
|
// cache per selected font family, so hover re-render won't mess it up
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[selectedFontFamily],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sceneFonts = useMemo(
|
||||||
|
() => allFonts.filter((font) => sceneFamilies.has(font.value)), // always show all the fonts in the scene, even those that were deprecated
|
||||||
|
[allFonts, sceneFamilies],
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableFonts = useMemo(
|
||||||
|
() =>
|
||||||
|
allFonts.filter(
|
||||||
|
(font) =>
|
||||||
|
!sceneFamilies.has(font.value) &&
|
||||||
|
(showDeprecatedFonts || !font.deprecated), // skip deprecated fonts
|
||||||
|
),
|
||||||
|
[allFonts, sceneFamilies, showDeprecatedFonts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredFonts = useMemo(
|
||||||
|
() =>
|
||||||
|
arrayToList(
|
||||||
|
[...sceneFonts, ...availableFonts].filter((font) =>
|
||||||
|
font.text?.toLowerCase().includes(searchTerm),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[sceneFonts, availableFonts, searchTerm],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hoveredFont = useMemo(() => {
|
||||||
|
let font;
|
||||||
|
|
||||||
|
if (hoveredFontFamily) {
|
||||||
|
font = filteredFonts.find((font) => font.value === hoveredFontFamily);
|
||||||
|
} else if (selectedFontFamily) {
|
||||||
|
font = filteredFonts.find((font) => font.value === selectedFontFamily);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!font && searchTerm) {
|
||||||
|
if (filteredFonts[0]?.value) {
|
||||||
|
// hover first element on search
|
||||||
|
onHover(filteredFonts[0].value);
|
||||||
|
} else {
|
||||||
|
// re-render cache on no results
|
||||||
|
onLeave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return font;
|
||||||
|
}, [
|
||||||
|
hoveredFontFamily,
|
||||||
|
selectedFontFamily,
|
||||||
|
searchTerm,
|
||||||
|
filteredFonts,
|
||||||
|
onHover,
|
||||||
|
onLeave,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
|
||||||
|
(event) => {
|
||||||
|
const handled = fontPickerKeyHandler({
|
||||||
|
event,
|
||||||
|
inputRef,
|
||||||
|
hoveredFont,
|
||||||
|
filteredFonts,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hoveredFont, filteredFonts, onSelect, onHover, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onOpen();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sceneFilteredFonts = useMemo(
|
||||||
|
() => filteredFonts.filter((font) => sceneFamilies.has(font.value)),
|
||||||
|
[filteredFonts, sceneFamilies],
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableFilteredFonts = useMemo(
|
||||||
|
() => filteredFonts.filter((font) => !sceneFamilies.has(font.value)),
|
||||||
|
[filteredFonts, sceneFamilies],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderFont = (font: FontDescriptor, index: number) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={font.value}
|
||||||
|
icon={font.icon}
|
||||||
|
value={font.value}
|
||||||
|
order={index}
|
||||||
|
textStyle={{
|
||||||
|
fontFamily: getFontFamilyString({ fontFamily: font.value }),
|
||||||
|
}}
|
||||||
|
hovered={font.value === hoveredFont?.value}
|
||||||
|
selected={font.value === selectedFontFamily}
|
||||||
|
// allow to tab between search and selected font
|
||||||
|
tabIndex={font.value === selectedFontFamily ? 0 : -1}
|
||||||
|
onClick={(e) => {
|
||||||
|
onSelect(Number(e.currentTarget.value));
|
||||||
|
}}
|
||||||
|
onMouseMove={() => {
|
||||||
|
if (hoveredFont?.value !== font.value) {
|
||||||
|
onHover(font.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{font.text}
|
||||||
|
{font.badge && (
|
||||||
|
<DropDownMenuItemBadge type={font.badge.type}>
|
||||||
|
{font.badge.placeholder}
|
||||||
|
</DropDownMenuItemBadge>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = [];
|
||||||
|
|
||||||
|
if (sceneFilteredFonts.length) {
|
||||||
|
groups.push(
|
||||||
|
<DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1">
|
||||||
|
{sceneFilteredFonts.map(renderFont)}
|
||||||
|
</DropdownMenuGroup>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableFilteredFonts.length) {
|
||||||
|
groups.push(
|
||||||
|
<DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2">
|
||||||
|
{availableFilteredFonts.map((font, index) =>
|
||||||
|
renderFont(font, index + sceneFilteredFonts.length),
|
||||||
|
)}
|
||||||
|
</DropdownMenuGroup>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PropertiesPopover
|
||||||
|
className="properties-content"
|
||||||
|
container={container}
|
||||||
|
style={{ width: "15rem" }}
|
||||||
|
onClose={onClose}
|
||||||
|
onPointerLeave={onLeave}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
<QuickSearch
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder={t("quickSearch.placeholder")}
|
||||||
|
onChange={debounce(setSearchTerm, 20)}
|
||||||
|
/>
|
||||||
|
<ScrollableList
|
||||||
|
className="dropdown-menu fonts manual-hover"
|
||||||
|
placeholder={t("fontList.empty")}
|
||||||
|
>
|
||||||
|
{groups.length ? groups : null}
|
||||||
|
</ScrollableList>
|
||||||
|
</PropertiesPopover>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(prev, next) =>
|
||||||
|
prev.selectedFontFamily === next.selectedFontFamily &&
|
||||||
|
prev.hoveredFontFamily === next.hoveredFontFamily,
|
||||||
|
);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { ButtonIcon } from "../ButtonIcon";
|
||||||
|
import { TextIcon } from "../icons";
|
||||||
|
import type { FontFamilyValues } from "../../element/types";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import { isDefaultFont } from "./FontPicker";
|
||||||
|
|
||||||
|
interface FontPickerTriggerProps {
|
||||||
|
selectedFontFamily: FontFamilyValues | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FontPickerTrigger = ({
|
||||||
|
selectedFontFamily,
|
||||||
|
}: FontPickerTriggerProps) => {
|
||||||
|
const isTriggerActive = useMemo(
|
||||||
|
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
|
||||||
|
[selectedFontFamily],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
{/* Empty div as trigger so it's stretched 100% due to different button sizes */}
|
||||||
|
<div>
|
||||||
|
<ButtonIcon
|
||||||
|
standalone
|
||||||
|
icon={TextIcon}
|
||||||
|
title={t("labels.showFonts")}
|
||||||
|
className="properties-trigger"
|
||||||
|
testId={"font-family-show-fonts"}
|
||||||
|
active={isTriggerActive}
|
||||||
|
// no-op
|
||||||
|
onClick={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Popover.Trigger>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Node } from "../../utils";
|
||||||
|
import { KEYS } from "../../keys";
|
||||||
|
import { type FontDescriptor } from "./FontPickerList";
|
||||||
|
|
||||||
|
interface FontPickerKeyNavHandlerProps {
|
||||||
|
event: React.KeyboardEvent<HTMLDivElement>;
|
||||||
|
inputRef: React.RefObject<HTMLInputElement>;
|
||||||
|
hoveredFont: Node<FontDescriptor> | undefined;
|
||||||
|
filteredFonts: Node<FontDescriptor>[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (value: number) => void;
|
||||||
|
onHover: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fontPickerKeyHandler = ({
|
||||||
|
event,
|
||||||
|
inputRef,
|
||||||
|
hoveredFont,
|
||||||
|
filteredFonts,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
}: FontPickerKeyNavHandlerProps) => {
|
||||||
|
if (
|
||||||
|
!event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
event.shiftKey &&
|
||||||
|
event.key.toLowerCase() === KEYS.F
|
||||||
|
) {
|
||||||
|
// refocus input on the popup trigger shortcut
|
||||||
|
inputRef.current?.focus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === KEYS.ESCAPE) {
|
||||||
|
onClose();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === KEYS.ENTER) {
|
||||||
|
if (hoveredFont?.value) {
|
||||||
|
onSelect(hoveredFont.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === KEYS.ARROW_DOWN) {
|
||||||
|
if (hoveredFont?.next) {
|
||||||
|
onHover(hoveredFont.next.value);
|
||||||
|
} else if (filteredFonts[0]?.value) {
|
||||||
|
onHover(filteredFonts[0].value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === KEYS.ARROW_UP) {
|
||||||
|
if (hoveredFont?.prev) {
|
||||||
|
onHover(hoveredFont.prev.value);
|
||||||
|
} else if (filteredFonts[filteredFonts.length - 1]?.value) {
|
||||||
|
onHover(filteredFonts[filteredFonts.length - 1].value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 1.5rem 0;
|
margin: 1.5rem 0;
|
||||||
font-weight: bold;
|
font-weight: 700;
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
&__island {
|
&__island {
|
||||||
h4 {
|
h4 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: bold;
|
font-weight: 700;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-bottom: 0.625rem;
|
margin-bottom: 0.625rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,9 +285,13 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
shortcuts={[getShortcutKey("Alt+Shift+D")]}
|
shortcuts={[getShortcutKey("Alt+Shift+D")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("stats.title")}
|
label={t("stats.fullTitle")}
|
||||||
shortcuts={[getShortcutKey("Alt+/")]}
|
shortcuts={[getShortcutKey("Alt+/")]}
|
||||||
/>
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("search.title")}
|
||||||
|
shortcuts={[getShortcutFromShortcutName("searchMenu")]}
|
||||||
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("commandPalette.title")}
|
label={t("commandPalette.title")}
|
||||||
shortcuts={
|
shortcuts={
|
||||||
@@ -304,6 +308,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
className="HelpDialog__island--editor"
|
className="HelpDialog__island--editor"
|
||||||
caption={t("helpDialog.editor")}
|
caption={t("helpDialog.editor")}
|
||||||
>
|
>
|
||||||
|
<Shortcut
|
||||||
|
label={t("helpDialog.createFlowchart")}
|
||||||
|
shortcuts={[getShortcutKey(`CtrlOrCmd+Arrow Key`)]}
|
||||||
|
isOr={true}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("helpDialog.navigateFlowchart")}
|
||||||
|
shortcuts={[getShortcutKey(`Alt+Arrow Key`)]}
|
||||||
|
isOr={true}
|
||||||
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("labels.moveCanvas")}
|
label={t("labels.moveCanvas")}
|
||||||
shortcuts={[
|
shortcuts={[
|
||||||
@@ -458,6 +472,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("labels.showBackground")}
|
label={t("labels.showBackground")}
|
||||||
shortcuts={[getShortcutKey("G")]}
|
shortcuts={[getShortcutKey("G")]}
|
||||||
/>
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.showFonts")}
|
||||||
|
shortcuts={[getShortcutKey("Shift+F")]}
|
||||||
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("labels.decreaseFontSize")}
|
label={t("labels.decreaseFontSize")}
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
|
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ $wide-viewport-width: 1000px;
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import type { AppClassProperties, Device, UIAppState } from "../types";
|
import type { AppClassProperties, Device, UIAppState } from "../types";
|
||||||
import {
|
import {
|
||||||
|
isFlowchartNodeElement,
|
||||||
isImageElement,
|
isImageElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isTextBindableContainer,
|
isTextBindableContainer,
|
||||||
@@ -10,6 +11,9 @@ import { getShortcutKey } from "../utils";
|
|||||||
import { isEraserActive } from "../appState";
|
import { isEraserActive } from "../appState";
|
||||||
|
|
||||||
import "./HintViewer.scss";
|
import "./HintViewer.scss";
|
||||||
|
import { isNodeInFlowchart } from "../element/flowchart";
|
||||||
|
import { isGridModeEnabled } from "../snapping";
|
||||||
|
import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "../constants";
|
||||||
|
|
||||||
interface HintViewerProps {
|
interface HintViewerProps {
|
||||||
appState: UIAppState;
|
appState: UIAppState;
|
||||||
@@ -18,10 +22,23 @@ interface HintViewerProps {
|
|||||||
app: AppClassProperties;
|
app: AppClassProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
const getHints = ({
|
||||||
|
appState,
|
||||||
|
isMobile,
|
||||||
|
device,
|
||||||
|
app,
|
||||||
|
}: HintViewerProps): null | string | string[] => {
|
||||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||||
const multiMode = appState.multiElement !== null;
|
const multiMode = appState.multiElement !== null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
||||||
|
appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
|
||||||
|
appState.searchMatches?.length
|
||||||
|
) {
|
||||||
|
return t("hints.dismissSearch");
|
||||||
|
}
|
||||||
|
|
||||||
if (appState.openSidebar && !device.editor.canFitSidebar) {
|
if (appState.openSidebar && !device.editor.canFitSidebar) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -30,10 +47,13 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
|||||||
return t("hints.eraserRevert");
|
return t("hints.eraserRevert");
|
||||||
}
|
}
|
||||||
if (activeTool.type === "arrow" || activeTool.type === "line") {
|
if (activeTool.type === "arrow" || activeTool.type === "line") {
|
||||||
if (!multiMode) {
|
if (multiMode) {
|
||||||
return t("hints.linearElement");
|
return t("hints.linearElementMulti");
|
||||||
}
|
}
|
||||||
return t("hints.linearElementMulti");
|
if (activeTool.type === "arrow") {
|
||||||
|
return t("hints.arrowTool", { arrowShortcut: getShortcutKey("A") });
|
||||||
|
}
|
||||||
|
return t("hints.linearElement");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTool.type === "freedraw") {
|
if (activeTool.type === "freedraw") {
|
||||||
@@ -76,21 +96,21 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
|||||||
return t("hints.text_selected");
|
return t("hints.text_selected");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appState.editingElement && isTextElement(appState.editingElement)) {
|
if (appState.editingTextElement) {
|
||||||
return t("hints.text_editing");
|
return t("hints.text_editing");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTool.type === "selection") {
|
if (activeTool.type === "selection") {
|
||||||
if (
|
if (
|
||||||
appState.draggingElement?.type === "selection" &&
|
appState.selectionElement &&
|
||||||
!selectedElements.length &&
|
!selectedElements.length &&
|
||||||
!appState.editingElement &&
|
!appState.editingTextElement &&
|
||||||
!appState.editingLinearElement
|
!appState.editingLinearElement
|
||||||
) {
|
) {
|
||||||
return t("hints.deepBoxSelect");
|
return t("hints.deepBoxSelect");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appState.gridSize && appState.draggingElement) {
|
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
|
||||||
return t("hints.disableSnapping");
|
return t("hints.disableSnapping");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,9 +128,23 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
|||||||
return t("hints.lineEditor_info");
|
return t("hints.lineEditor_info");
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!appState.draggingElement &&
|
!appState.newElement &&
|
||||||
|
!appState.selectedElementsAreBeingDragged &&
|
||||||
isTextBindableContainer(selectedElements[0])
|
isTextBindableContainer(selectedElements[0])
|
||||||
) {
|
) {
|
||||||
|
if (isFlowchartNodeElement(selectedElements[0])) {
|
||||||
|
if (
|
||||||
|
isNodeInFlowchart(
|
||||||
|
selectedElements[0],
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
|
||||||
|
}
|
||||||
|
|
||||||
return t("hints.bindTextToElement");
|
return t("hints.bindTextToElement");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,17 +159,24 @@ export const HintViewer = ({
|
|||||||
device,
|
device,
|
||||||
app,
|
app,
|
||||||
}: HintViewerProps) => {
|
}: HintViewerProps) => {
|
||||||
let hint = getHints({
|
const hints = getHints({
|
||||||
appState,
|
appState,
|
||||||
isMobile,
|
isMobile,
|
||||||
device,
|
device,
|
||||||
app,
|
app,
|
||||||
});
|
});
|
||||||
if (!hint) {
|
|
||||||
|
if (!hints) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
hint = getShortcutKey(hint);
|
const hint = Array.isArray(hints)
|
||||||
|
? hints
|
||||||
|
.map((hint) => {
|
||||||
|
return getShortcutKey(hint).replace(/\. ?$/, "");
|
||||||
|
})
|
||||||
|
.join(". ")
|
||||||
|
: getShortcutKey(hints);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="HintViewer">
|
<div className="HintViewer">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user