Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5b406b055 | ||
|
|
7522285869 | ||
|
|
58567dd2b6 | ||
|
|
a76999e9a2 | ||
|
|
5af532d229 | ||
|
|
96fc3ac5bb | ||
|
|
906652bac2 | ||
|
|
53a0428705 | ||
|
|
927e36c7b4 | ||
|
|
e187faee77 | ||
|
|
d12d97bfcb | ||
|
|
5acb5c9d91 | ||
|
|
23ee054025 | ||
|
|
d91b234db1 | ||
|
|
b2b03af1ec | ||
|
|
f0876e3c03 | ||
|
|
6d0ee330b7 | ||
|
|
e48eed6b21 | ||
|
|
549786a504 | ||
|
|
73c53a3c7c | ||
|
|
72a98da527 | ||
|
|
45ff9d1053 | ||
|
|
6a1477a55c |
@@ -8,7 +8,6 @@
|
|||||||
!package.json
|
!package.json
|
||||||
!public/
|
!public/
|
||||||
!packages/
|
!packages/
|
||||||
!scripts/
|
|
||||||
!tsconfig.json
|
!tsconfig.json
|
||||||
!yarn.lock
|
!yarn.lock
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ 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_ENABLE_TRACKING=true
|
VITE_APP_DISABLE_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_ENABLE_TRACKING=false
|
VITE_APP_DISABLE_TRACKING=
|
||||||
|
|||||||
@@ -6,5 +6,3 @@ firebase/
|
|||||||
dist/
|
dist/
|
||||||
public/workbox
|
public/workbox
|
||||||
packages/excalidraw/types
|
packages/excalidraw/types
|
||||||
examples/**/public
|
|
||||||
dev-dist
|
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
name: Tests
|
name: Tests
|
||||||
|
|
||||||
on:
|
on: pull_request
|
||||||
push:
|
|
||||||
branches: master
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v2
|
||||||
- name: Setup Node.js 18.x
|
- name: Setup Node.js 18.x
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: 18.x
|
node-version: 18.x
|
||||||
- name: Install and test
|
- name: Install and test
|
||||||
run: |
|
run: |
|
||||||
yarn install
|
yarn install
|
||||||
yarn test:app
|
yarn test
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ ARG NODE_ENV=production
|
|||||||
|
|
||||||
RUN yarn build:app:docker
|
RUN yarn build:app:docker
|
||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.24-alpine
|
||||||
|
|
||||||
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
|||||||
@@ -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 events |
|
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down evenets |
|
||||||
| [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. |
|
| [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. |
|
||||||
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when something is pasted into the scene |
|
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when the something is pasted in to the scene |
|
||||||
| [`onLibraryChange`](#onlibrarychange) | `function` | _ | The callback if supplied is triggered when the library is updated and receives the library items. |
|
| [`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>` |
|
||||||
|
|||||||
@@ -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 excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
<Excalidraw ref={(api) => setExcalidrawAPI(api)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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.6",
|
"@excalidraw/excalidraw": "0.17.0",
|
||||||
"@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": "18.2.0",
|
"react": "^17.0.2",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "^17.0.2",
|
||||||
"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: 700;
|
font-weight: bold;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -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.6":
|
"@excalidraw/excalidraw@0.17.0":
|
||||||
version "0.17.6"
|
version "0.17.0"
|
||||||
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.6.tgz#5fd208ce69d33ca712d1804b50d7d06d5c46ac4d"
|
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.0.tgz#3c64aa8e36406ac171b008cfecbdce5bb0755725"
|
||||||
integrity sha512-fyCl+zG/Z5yhHDh5Fq2ZGmphcrALmuOdtITm8gN4d8w4ntnaopTXcTfnAAaU3VleDC6LhTkoLOTG6P5kgREiIg==
|
integrity sha512-NzP22v5xMqxYW27ZtTHhiGFe7kE8NeBk45aoeM/mDSkXiOXPDH+PcvwzHRN/Ei+Vj/0sTPHxejn8bZyRWKGjXg==
|
||||||
|
|
||||||
"@hapi/hoek@^9.0.0":
|
"@hapi/hoek@^9.0.0":
|
||||||
version "9.3.0"
|
version "9.3.0"
|
||||||
|
|||||||
@@ -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 Excalifont";
|
ctx.font = "30px Virgil";
|
||||||
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 Excalifont";
|
ctx.font = "30px Virgil";
|
||||||
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: 5 },
|
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 },
|
||||||
scrollToContent: true,
|
scrollToContent: true,
|
||||||
libraryItems: [
|
libraryItems: [
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -34,6 +34,3 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# copied assets
|
|
||||||
public/*.woff2
|
|
||||||
@@ -3,23 +3,22 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
"build:workspaces": "yarn workspace @excalidraw/utils run build:esm && yarn workspace @excalidraw/excalidraw run build:esm",
|
||||||
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
|
"dev": "yarn build:workspaces && next dev -p 3005",
|
||||||
"dev": "yarn build:workspace && next dev -p 3005",
|
"build": "yarn build:workspaces && next build",
|
||||||
"build": "yarn build:workspace && next build",
|
|
||||||
"start": "next start -p 3006",
|
"start": "next start -p 3006",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@excalidraw/excalidraw": "*",
|
"@excalidraw/excalidraw": "*",
|
||||||
"next": "14.1",
|
"next": "14.1",
|
||||||
"react": "18.2.0",
|
"react": "^18",
|
||||||
"react-dom": "18.2.0"
|
"react-dom": "^18"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "18.2.0",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "18.2.0",
|
"@types/react-dom": "^18",
|
||||||
"path2d-polyfill": "2.0.1",
|
"path2d-polyfill": "2.0.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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
|
||||||
@@ -16,9 +15,7 @@ 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: 500;
|
font-weight: 550;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# copied assets
|
|
||||||
public/*.woff2
|
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -12,10 +12,9 @@
|
|||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
"build:workspaces": "yarn workspace @excalidraw/utils run build:esm && yarn workspace @excalidraw/excalidraw run build:esm",
|
||||||
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
|
"start": "yarn build:workspaces && vite",
|
||||||
"start": "yarn build:workspace && vite",
|
"build": "yarn build:workspaces && vite build",
|
||||||
"build": "yarn build:workspace && vite build",
|
|
||||||
"build:preview": "yarn build && vite preview --port 5002"
|
"build:preview": "yarn build && vite preview --port 5002"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-53
@@ -1,4 +1,5 @@
|
|||||||
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";
|
||||||
@@ -21,6 +22,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,
|
TTDDialog,
|
||||||
TTDDialogTrigger,
|
TTDDialogTrigger,
|
||||||
@@ -91,7 +93,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 { Provider, useAtom, useAtomValue } from "jotai";
|
import { atom, 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";
|
||||||
|
|
||||||
@@ -119,45 +121,11 @@ 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";
|
|
||||||
|
|
||||||
polyfill();
|
polyfill();
|
||||||
|
|
||||||
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface BeforeInstallPromptEventChoiceResult {
|
|
||||||
outcome: "accepted" | "dismissed";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BeforeInstallPromptEvent extends Event {
|
|
||||||
prompt(): Promise<void>;
|
|
||||||
userChoice: Promise<BeforeInstallPromptEventChoiceResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WindowEventMap {
|
|
||||||
beforeinstallprompt: BeforeInstallPromptEvent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let pwaEvent: BeforeInstallPromptEvent | null = null;
|
|
||||||
|
|
||||||
// Adding a listener outside of the component as it may (?) need to be
|
|
||||||
// subscribed early to catch the event.
|
|
||||||
//
|
|
||||||
// Also note that it will fire only if certain heuristics are met (user has
|
|
||||||
// used the app for some time, etc.)
|
|
||||||
window.addEventListener(
|
|
||||||
"beforeinstallprompt",
|
|
||||||
(event: BeforeInstallPromptEvent) => {
|
|
||||||
// prevent Chrome <= 67 from automatically showing the prompt
|
|
||||||
event.preventDefault();
|
|
||||||
// cache for later use
|
|
||||||
pwaEvent = event;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let isSelfEmbedding = false;
|
let isSelfEmbedding = false;
|
||||||
|
|
||||||
if (window.self !== window.top) {
|
if (window.self !== window.top) {
|
||||||
@@ -172,6 +140,11 @@ 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: (
|
||||||
@@ -317,15 +290,19 @@ 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();
|
|
||||||
|
|
||||||
// initial state
|
// initial state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -481,7 +458,11 @@ 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();
|
||||||
setLangCode(getPreferredLanguage());
|
let langCode = languageDetector.detect() || defaultLang.code;
|
||||||
|
if (Array.isArray(langCode)) {
|
||||||
|
langCode = langCode[0];
|
||||||
|
}
|
||||||
|
setLangCode(langCode);
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...localDataState,
|
...localDataState,
|
||||||
storeAction: StoreAction.UPDATE,
|
storeAction: StoreAction.UPDATE,
|
||||||
@@ -582,6 +563,10 @@ const ExcalidrawWrapper = () => {
|
|||||||
};
|
};
|
||||||
}, [excalidrawAPI]);
|
}, [excalidrawAPI]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
languageDetector.cacheUserLanguage(langCode);
|
||||||
|
}, [langCode]);
|
||||||
|
|
||||||
const onChange = (
|
const onChange = (
|
||||||
elements: readonly OrderedExcalidrawElement[],
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
@@ -1115,21 +1100,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: t("labels.installPWA"),
|
|
||||||
category: DEFAULT_CATEGORIES.app,
|
|
||||||
predicate: () => !!pwaEvent,
|
|
||||||
perform: () => {
|
|
||||||
if (pwaEvent) {
|
|
||||||
pwaEvent.prompt();
|
|
||||||
pwaEvent.userChoice.then(() => {
|
|
||||||
// event cannot be reused, but we'll hopefully
|
|
||||||
// grab new one as the event should be fired again
|
|
||||||
pwaEvent = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Excalidraw>
|
</Excalidraw>
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
|
||||||
import { defaultLang, languages } from "../../packages/excalidraw";
|
|
||||||
|
|
||||||
export const languageDetector = new LanguageDetector();
|
|
||||||
|
|
||||||
languageDetector.init({
|
|
||||||
languageUtils: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getPreferredLanguage = () => {
|
|
||||||
const detectedLanguages = languageDetector.detect();
|
|
||||||
|
|
||||||
const detectedLanguage = Array.isArray(detectedLanguages)
|
|
||||||
? detectedLanguages[0]
|
|
||||||
: detectedLanguages;
|
|
||||||
|
|
||||||
const initialLanguage =
|
|
||||||
(detectedLanguage
|
|
||||||
? // region code may not be defined if user uses generic preferred language
|
|
||||||
// (e.g. chinese vs instead of chinese-simplified)
|
|
||||||
languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code
|
|
||||||
: null) || defaultLang.code;
|
|
||||||
|
|
||||||
return initialLanguage;
|
|
||||||
};
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { getPreferredLanguage, languageDetector } from "./language-detector";
|
|
||||||
|
|
||||||
export const appLangCodeAtom = atom(getPreferredLanguage());
|
|
||||||
|
|
||||||
export const useAppLangCode = () => {
|
|
||||||
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
languageDetector.cacheUserLanguage(langCode);
|
|
||||||
}, [langCode]);
|
|
||||||
|
|
||||||
return [langCode, setLangCode] as const;
|
|
||||||
};
|
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
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 "../app-language/LanguageList";
|
import { LanguageList } from "./LanguageList";
|
||||||
|
|
||||||
export const AppMainMenu: React.FC<{
|
export const AppMainMenu: React.FC<{
|
||||||
onCollabDialogOpen: () => any;
|
onCollabDialogOpen: () => any;
|
||||||
@@ -34,7 +34,7 @@ export const AppMainMenu: React.FC<{
|
|||||||
<MainMenu.ItemLink
|
<MainMenu.ItemLink
|
||||||
icon={ExcalLogo}
|
icon={ExcalLogo}
|
||||||
href={`${
|
href={`${
|
||||||
import.meta.env.VITE_APP_PLUS_LP
|
import.meta.env.VITE_APP_PLUS_APP
|
||||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
|
||||||
className=""
|
className=""
|
||||||
>
|
>
|
||||||
|
|||||||
+3
-2
@@ -1,7 +1,8 @@
|
|||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useI18n, languages } from "../../packages/excalidraw/i18n";
|
import { appLangCodeAtom } from "../App";
|
||||||
import { appLangCodeAtom } from "./language-state";
|
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||||
|
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();
|
||||||
+20
-55
@@ -20,7 +20,7 @@
|
|||||||
name="description"
|
name="description"
|
||||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||||
/>
|
/>
|
||||||
<meta name="image" content="https://excalidraw.com/og-image-3.png" />
|
<meta name="image" content="https://excalidraw.com/og-image-2.png" />
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:site_name" content="Excalidraw" />
|
<meta property="og:site_name" content="Excalidraw" />
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
property="og:description"
|
property="og:description"
|
||||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||||
/>
|
/>
|
||||||
<meta property="og:image" content="https://excalidraw.com/og-image-3.png" />
|
<meta property="og:image" content="https://excalidraw.com/og-image-2.png" />
|
||||||
|
|
||||||
<!-- Twitter -->
|
<!-- Twitter -->
|
||||||
<meta property="twitter:card" content="summary_large_image" />
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
property="twitter:image"
|
property="twitter:image"
|
||||||
content="https://excalidraw.com/og-image-3.png"
|
content="https://excalidraw.com/og-twitter-v2.png"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- General tags -->
|
<!-- General tags -->
|
||||||
@@ -95,11 +95,6 @@
|
|||||||
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>
|
||||||
@@ -120,55 +115,8 @@
|
|||||||
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>
|
|
||||||
|
|
||||||
<!-- in DEV we need to preload from the local server and without the hash -->
|
|
||||||
<link
|
|
||||||
rel="preload"
|
|
||||||
href="../packages/excalidraw/fonts/assets/Excalifont-Regular.woff2"
|
|
||||||
as="font"
|
|
||||||
type="font/woff2"
|
|
||||||
crossorigin="anonymous"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="preload"
|
|
||||||
href="../packages/excalidraw/fonts/assets/Virgil-Regular.woff2"
|
|
||||||
as="font"
|
|
||||||
type="font/woff2"
|
|
||||||
crossorigin="anonymous"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="preload"
|
|
||||||
href="../packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2"
|
|
||||||
as="font"
|
|
||||||
type="font/woff2"
|
|
||||||
crossorigin="anonymous"
|
|
||||||
/>
|
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<!-- For Nunito only preload the latin range, which should be 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" />
|
||||||
@@ -176,6 +124,22 @@
|
|||||||
<!-- 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>
|
||||||
@@ -194,6 +158,7 @@
|
|||||||
</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>
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
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,28 +26,17 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"firebase": "8.3.3",
|
"vite-plugin-html": "3.2.2"
|
||||||
"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 build",
|
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
|
||||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
|
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
|
||||||
"build:version": "node ../scripts/build-version.js",
|
"build: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": "yarn build && yarn serve",
|
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 excalifont welcome-screen-decor"
|
class="welcome-screen-center__logo virgil 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 excalifont"
|
class="welcome-screen-center__heading welcome-screen-decor virgil"
|
||||||
>
|
>
|
||||||
All your data is saved locally in your browser.
|
All your data is saved locally in your browser.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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";
|
||||||
@@ -87,12 +88,12 @@ describe("collaboration", () => {
|
|||||||
const rect1 = API.createElement({ ...rect1Props });
|
const rect1 = API.createElement({ ...rect1Props });
|
||||||
const rect2 = API.createElement({ ...rect2Props });
|
const rect2 = API.createElement({ ...rect2Props });
|
||||||
|
|
||||||
API.updateScene({
|
updateSceneData({
|
||||||
elements: syncInvalidIndices([rect1, rect2]),
|
elements: syncInvalidIndices([rect1, rect2]),
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
API.updateScene({
|
updateSceneData({
|
||||||
elements: syncInvalidIndices([
|
elements: syncInvalidIndices([
|
||||||
rect1,
|
rect1,
|
||||||
newElementWith(h.elements[1], { isDeleted: true }),
|
newElementWith(h.elements[1], { isDeleted: true }),
|
||||||
@@ -142,7 +143,7 @@ describe("collaboration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// simulate force deleting the element remotely
|
// simulate force deleting the element remotely
|
||||||
API.updateScene({
|
updateSceneData({
|
||||||
elements: syncInvalidIndices([rect1]),
|
elements: syncInvalidIndices([rect1]),
|
||||||
storeAction: StoreAction.UPDATE,
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
@@ -177,7 +178,7 @@ describe("collaboration", () => {
|
|||||||
act(() => h.app.actionManager.executeAction(undoAction));
|
act(() => h.app.actionManager.executeAction(undoAction));
|
||||||
|
|
||||||
// simulate local update
|
// simulate local update
|
||||||
API.updateScene({
|
updateSceneData({
|
||||||
elements: syncInvalidIndices([
|
elements: syncInvalidIndices([
|
||||||
h.elements[0],
|
h.elements[0],
|
||||||
newElementWith(h.elements[1], { x: 100 }),
|
newElementWith(h.elements[1], { x: 100 }),
|
||||||
@@ -215,7 +216,7 @@ describe("collaboration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// simulate force deleting the element remotely
|
// simulate force deleting the element remotely
|
||||||
API.updateScene({
|
updateSceneData({
|
||||||
elements: syncInvalidIndices([rect1]),
|
elements: syncInvalidIndices([rect1]),
|
||||||
storeAction: StoreAction.UPDATE,
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||||
|
import { STORAGE_KEYS } from "../app_constants";
|
||||||
|
|
||||||
|
export const initLocalStorage = (data: ImportedDataState) => {
|
||||||
|
if (data.elements) {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||||
|
JSON.stringify(data.elements),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.appState) {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||||
|
JSON.stringify(data.appState),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -5,7 +5,6 @@ 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("", `../`);
|
||||||
@@ -23,14 +22,6 @@ 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
|
||||||
@@ -50,7 +41,6 @@ export default defineConfig({
|
|||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
woff2BrowserPlugin(),
|
|
||||||
react(),
|
react(),
|
||||||
checker({
|
checker({
|
||||||
typescript: true,
|
typescript: true,
|
||||||
|
|||||||
+19
-14
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"name": "excalidraw-monorepo",
|
"name": "excalidraw-monorepo",
|
||||||
"packageManager": "yarn@1.22.22",
|
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"excalidraw-app",
|
"excalidraw-app",
|
||||||
"packages/excalidraw",
|
"packages/excalidraw",
|
||||||
@@ -9,8 +8,19 @@
|
|||||||
"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",
|
||||||
@@ -40,7 +50,7 @@
|
|||||||
"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": "2.4.0",
|
||||||
"vitest": "1.6.0",
|
"vitest": "1.5.3",
|
||||||
"vitest-canvas-mock": "0.3.2"
|
"vitest-canvas-mock": "0.3.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -53,7 +63,8 @@
|
|||||||
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
|
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
|
||||||
"build:app": "yarn --cwd ./excalidraw-app build:app",
|
"build:app": "yarn --cwd ./excalidraw-app build:app",
|
||||||
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
||||||
"build": "yarn --cwd ./excalidraw-app build",
|
"build": "yarn workspace @excalidraw/utils build:esm && yarn --cwd ./excalidraw-app build",
|
||||||
|
"clear:workspaces": "yarn workspace @excalidraw/utils run clear && yarn workspace @excalidraw/excalidraw run clear",
|
||||||
"fix:code": "yarn test:code --fix",
|
"fix:code": "yarn test:code --fix",
|
||||||
"fix:other": "yarn prettier --write",
|
"fix:other": "yarn prettier --write",
|
||||||
"fix": "yarn fix:other && yarn fix:code",
|
"fix": "yarn fix:other && yarn fix:code",
|
||||||
@@ -61,7 +72,7 @@
|
|||||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||||
"start": "yarn --cwd ./excalidraw-app start",
|
"start": "yarn clear:workspaces && yarn workspace @excalidraw/utils run build:esm && yarn --cwd ./excalidraw-app start & node scripts/watchUtils.js",
|
||||||
"start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
"start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
||||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
|
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
|
||||||
"test:app": "vitest",
|
"test:app": "vitest",
|
||||||
@@ -69,19 +80,13 @@
|
|||||||
"test:other": "yarn prettier --list-different",
|
"test:other": "yarn prettier --list-different",
|
||||||
"test:typecheck": "tsc",
|
"test:typecheck": "tsc",
|
||||||
"test:update": "yarn test:app --update --watch=false",
|
"test:update": "yarn test:app --update --watch=false",
|
||||||
"test": "yarn test:app",
|
"test": "yarn clear:workspaces && yarn workspace @excalidraw/utils build:esm && yarn test:app",
|
||||||
"test:coverage": "vitest --coverage",
|
"test:coverage": "yarn clear:workspaces && yarn workspace @excalidraw/utils build:esm && yarn test:app --coverage",
|
||||||
"test:coverage:watch": "vitest --coverage --watch",
|
"test:coverage:watch": "vitest --coverage --watch",
|
||||||
"test:ui": "yarn test --ui --coverage.enabled=true",
|
"test:ui": "yarn test --ui --coverage.enabled=true",
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,8 @@ 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)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
BOUND_TEXT_PADDING,
|
BOUND_TEXT_PADDING,
|
||||||
ROUNDNESS,
|
ROUNDNESS,
|
||||||
TEXT_ALIGN,
|
|
||||||
VERTICAL_ALIGN,
|
VERTICAL_ALIGN,
|
||||||
|
TEXT_ALIGN,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { isTextElement, newElement } from "../element";
|
import { isTextElement, newElement } from "../element";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
@@ -142,7 +142,6 @@ export const actionBindText = register({
|
|||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||||
textAlign: TEXT_ALIGN.CENTER,
|
textAlign: TEXT_ALIGN.CENTER,
|
||||||
autoResize: true,
|
|
||||||
});
|
});
|
||||||
mutateElement(container, {
|
mutateElement(container, {
|
||||||
boundElements: (container.boundElements || []).concat({
|
boundElements: (container.boundElements || []).concat({
|
||||||
@@ -297,7 +296,6 @@ export const actionWrapTextInContainer = register({
|
|||||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||||
boundElements: null,
|
boundElements: null,
|
||||||
textAlign: TEXT_ALIGN.CENTER,
|
textAlign: TEXT_ALIGN.CENTER,
|
||||||
autoResize: true,
|
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export const actionClearCanvas = register({
|
|||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
exportEmbedScene: appState.exportEmbedScene,
|
exportEmbedScene: appState.exportEmbedScene,
|
||||||
gridSize: appState.gridSize,
|
gridSize: appState.gridSize,
|
||||||
stats: appState.stats,
|
showStats: appState.showStats,
|
||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
activeTool:
|
activeTool:
|
||||||
appState.activeTool.type === "image"
|
appState.activeTool.type === "image"
|
||||||
|
|||||||
@@ -5,27 +5,20 @@ 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 { AppClassProperties, AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { 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 {
|
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||||
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)),
|
||||||
@@ -36,26 +29,6 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,11 +130,7 @@ export const actionDeleteSelected = register({
|
|||||||
: endBindingElement,
|
: endBindingElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
LinearElementEditor.deletePoints(
|
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
||||||
element,
|
|
||||||
selectedPointsIndices,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
@@ -180,7 +149,7 @@ export const actionDeleteSelected = register({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
let { elements: nextElements, appState: nextAppState } =
|
let { elements: nextElements, appState: nextAppState } =
|
||||||
deleteSelectedElements(elements, appState, app);
|
deleteSelectedElements(elements, appState);
|
||||||
fixBindingsAfterDeletion(
|
fixBindingsAfterDeletion(
|
||||||
nextElements,
|
nextElements,
|
||||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||||
|
|||||||
@@ -40,11 +40,12 @@ 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(
|
const ret = LinearElementEditor.duplicateSelectedPoints(
|
||||||
appState,
|
appState,
|
||||||
app.scene.getNonDeletedElementsMap(),
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!ret) {
|
if (!ret) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
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";
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export const actionFinalize = register({
|
|||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
scene,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -73,8 +72,8 @@ export const actionFinalize = register({
|
|||||||
|
|
||||||
const multiPointElement = appState.multiElement
|
const multiPointElement = appState.multiElement
|
||||||
? appState.multiElement
|
? appState.multiElement
|
||||||
: appState.newElement?.type === "freedraw"
|
: appState.editingElement?.type === "freedraw"
|
||||||
? appState.newElement
|
? appState.editingElement
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (multiPointElement) {
|
if (multiPointElement) {
|
||||||
@@ -132,13 +131,7 @@ export const actionFinalize = register({
|
|||||||
-1,
|
-1,
|
||||||
arrayToMap(elements),
|
arrayToMap(elements),
|
||||||
);
|
);
|
||||||
maybeBindLinearElement(
|
maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
|
||||||
multiPointElement,
|
|
||||||
appState,
|
|
||||||
{ x, y },
|
|
||||||
elementsMap,
|
|
||||||
elements,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,8 +169,7 @@ export const actionFinalize = register({
|
|||||||
? appState.activeTool
|
? appState.activeTool
|
||||||
: activeTool,
|
: activeTool,
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
newElement: null,
|
draggingElement: null,
|
||||||
selectionElement: null,
|
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
editingElement: null,
|
editingElement: null,
|
||||||
startBoundElement: null,
|
startBoundElement: null,
|
||||||
@@ -205,7 +197,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.newElement && appState.multiElement === null))) ||
|
(!appState.draggingElement && 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 }) => (
|
||||||
|
|||||||
@@ -124,9 +124,7 @@ const flipElements = (
|
|||||||
|
|
||||||
bindOrUnbindLinearElements(
|
bindOrUnbindLinearElements(
|
||||||
selectedElements.filter(isLinearElement),
|
selectedElements.filter(isLinearElement),
|
||||||
elementsMap,
|
app,
|
||||||
app.scene.getNonDeletedElements(),
|
|
||||||
app.scene,
|
|
||||||
isBindingEnabled(appState),
|
isBindingEnabled(appState),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ const writeData = (
|
|||||||
!appState.multiElement &&
|
!appState.multiElement &&
|
||||||
!appState.resizingElement &&
|
!appState.resizingElement &&
|
||||||
!appState.editingElement &&
|
!appState.editingElement &&
|
||||||
!appState.newElement &&
|
!appState.draggingElement
|
||||||
!appState.selectedElementsAreBeingDragged &&
|
|
||||||
!appState.selectionElement
|
|
||||||
) {
|
) {
|
||||||
const result = updater();
|
const result = updater();
|
||||||
|
|
||||||
@@ -52,7 +50,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||||||
icon: UndoIcon,
|
icon: UndoIcon,
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, value, app) =>
|
perform: (elements, appState) =>
|
||||||
writeData(appState, () =>
|
writeData(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`
|
||||||
@@ -67,10 +65,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||||||
PanelComponent: ({ updateData, data }) => {
|
PanelComponent: ({ updateData, data }) => {
|
||||||
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
||||||
history.onHistoryChangedEmitter,
|
history.onHistoryChangedEmitter,
|
||||||
new HistoryChangedEvent(
|
new HistoryChangedEvent(),
|
||||||
history.isUndoStackEmpty,
|
|
||||||
history.isRedoStackEmpty,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -81,7 +76,6 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
size={data?.size || "medium"}
|
size={data?.size || "medium"}
|
||||||
disabled={isUndoStackEmpty}
|
disabled={isUndoStackEmpty}
|
||||||
data-testid="button-undo"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -93,7 +87,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
|||||||
icon: RedoIcon,
|
icon: RedoIcon,
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, _, app) =>
|
perform: (elements, appState) =>
|
||||||
writeData(appState, () =>
|
writeData(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`
|
||||||
@@ -109,10 +103,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
|||||||
PanelComponent: ({ updateData, data }) => {
|
PanelComponent: ({ updateData, data }) => {
|
||||||
const { isRedoStackEmpty } = useEmitter(
|
const { isRedoStackEmpty } = useEmitter(
|
||||||
history.onHistoryChangedEmitter,
|
history.onHistoryChangedEmitter,
|
||||||
new HistoryChangedEvent(
|
new HistoryChangedEvent(),
|
||||||
history.isUndoStackEmpty,
|
|
||||||
history.isRedoStackEmpty,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -123,7 +114,6 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
|||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
size={data?.size || "medium"}
|
size={data?.size || "medium"}
|
||||||
disabled={isRedoStackEmpty}
|
disabled={isRedoStackEmpty}
|
||||||
data-testid="button-redo"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { isElbowArrow, isLinearElement } from "../element/typeChecks";
|
import { 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,8 +29,7 @@ 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,4 +1,3 @@
|
|||||||
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";
|
||||||
@@ -7,6 +6,8 @@ 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 />);
|
||||||
@@ -21,7 +22,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);
|
||||||
|
|
||||||
API.setAppState({
|
h.setState({
|
||||||
currentItemBackgroundColor: color,
|
currentItemBackgroundColor: color,
|
||||||
});
|
});
|
||||||
const activeColor = queryByTestId(
|
const activeColor = queryByTestId(
|
||||||
@@ -39,14 +40,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);
|
||||||
|
|
||||||
API.setAppState({
|
h.setState({
|
||||||
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");
|
||||||
API.setAppState({
|
h.setState({
|
||||||
currentItemFillStyle: "solid",
|
currentItemFillStyle: "solid",
|
||||||
});
|
});
|
||||||
const solidFillStyle = queryByTestId(document.body, `fill-solid`);
|
const solidFillStyle = queryByTestId(document.body, `fill-solid`);
|
||||||
@@ -56,7 +57,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");
|
||||||
|
|
||||||
API.setAppState({
|
h.setState({
|
||||||
currentItemBackgroundColor: COLOR_PALETTE.transparent,
|
currentItemBackgroundColor: COLOR_PALETTE.transparent,
|
||||||
currentItemFillStyle: "hachure",
|
currentItemFillStyle: "hachure",
|
||||||
});
|
});
|
||||||
@@ -68,7 +69,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");
|
||||||
|
|
||||||
API.setAppState({
|
h.setState({
|
||||||
currentItemTextAlign: "right",
|
currentItemTextAlign: "right",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ describe("element locking", () => {
|
|||||||
backgroundColor: "red",
|
backgroundColor: "red",
|
||||||
fillStyle: "cross-hatch",
|
fillStyle: "cross-hatch",
|
||||||
});
|
});
|
||||||
API.setElements([rect]);
|
h.elements = [rect];
|
||||||
API.setSelectedElements([rect]);
|
API.setSelectedElements([rect]);
|
||||||
|
|
||||||
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
|
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
|
||||||
@@ -97,7 +98,7 @@ describe("element locking", () => {
|
|||||||
backgroundColor: COLOR_PALETTE.transparent,
|
backgroundColor: COLOR_PALETTE.transparent,
|
||||||
fillStyle: "cross-hatch",
|
fillStyle: "cross-hatch",
|
||||||
});
|
});
|
||||||
API.setElements([rect]);
|
h.elements = [rect];
|
||||||
API.setSelectedElements([rect]);
|
API.setSelectedElements([rect]);
|
||||||
|
|
||||||
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
|
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
|
||||||
@@ -113,7 +114,7 @@ describe("element locking", () => {
|
|||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
strokeWidth: STROKE_WIDTH.thin,
|
strokeWidth: STROKE_WIDTH.thin,
|
||||||
});
|
});
|
||||||
API.setElements([rect1, rect2]);
|
h.elements = [rect1, rect2];
|
||||||
API.setSelectedElements([rect1, rect2]);
|
API.setSelectedElements([rect1, rect2]);
|
||||||
|
|
||||||
const thinStrokeWidthButton = queryByTestId(
|
const thinStrokeWidthButton = queryByTestId(
|
||||||
@@ -132,7 +133,7 @@ describe("element locking", () => {
|
|||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
strokeWidth: STROKE_WIDTH.bold,
|
strokeWidth: STROKE_WIDTH.bold,
|
||||||
});
|
});
|
||||||
API.setElements([rect1, rect2]);
|
h.elements = [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);
|
||||||
@@ -154,15 +155,13 @@ describe("element locking", () => {
|
|||||||
});
|
});
|
||||||
const text = API.createElement({
|
const text = API.createElement({
|
||||||
type: "text",
|
type: "text",
|
||||||
fontFamily: FONT_FAMILY["Comic Shanns"],
|
fontFamily: FONT_FAMILY.Cascadia,
|
||||||
});
|
});
|
||||||
API.setElements([rect, text]);
|
h.elements = [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`)).toHaveClass(
|
expect(queryByTestId(document.body, `font-family-code`)).toBeChecked();
|
||||||
"active",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||||
import type { AppClassProperties, AppState, Point, 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,
|
||||||
@@ -11,7 +9,6 @@ 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
|
||||||
@@ -41,6 +38,9 @@ import {
|
|||||||
FontSizeExtraLargeIcon,
|
FontSizeExtraLargeIcon,
|
||||||
EdgeSharpIcon,
|
EdgeSharpIcon,
|
||||||
EdgeRoundIcon,
|
EdgeRoundIcon,
|
||||||
|
FreedrawIcon,
|
||||||
|
FontFamilyNormalIcon,
|
||||||
|
FontFamilyCodeIcon,
|
||||||
TextAlignLeftIcon,
|
TextAlignLeftIcon,
|
||||||
TextAlignCenterIcon,
|
TextAlignCenterIcon,
|
||||||
TextAlignRightIcon,
|
TextAlignRightIcon,
|
||||||
@@ -50,12 +50,8 @@ 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,
|
||||||
@@ -69,17 +65,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 {
|
||||||
isArrowElement,
|
getBoundTextElement,
|
||||||
|
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,
|
||||||
@@ -98,23 +94,9 @@ import {
|
|||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { hasStrokeColor } from "../scene/comparisons";
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
import {
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
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";
|
|
||||||
|
|
||||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||||
|
|
||||||
@@ -185,7 +167,7 @@ const offsetElementAfterFontResize = (
|
|||||||
prevElement: ExcalidrawTextElement,
|
prevElement: ExcalidrawTextElement,
|
||||||
nextElement: ExcalidrawTextElement,
|
nextElement: ExcalidrawTextElement,
|
||||||
) => {
|
) => {
|
||||||
if (isBoundToContainer(nextElement) || !nextElement.autoResize) {
|
if (isBoundToContainer(nextElement)) {
|
||||||
return nextElement;
|
return nextElement;
|
||||||
}
|
}
|
||||||
return mutateElement(
|
return mutateElement(
|
||||||
@@ -747,388 +729,104 @@ export const actionIncreaseFontSize = register({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
type ChangeFontFamilyData = Partial<
|
|
||||||
Pick<
|
|
||||||
AppState,
|
|
||||||
"openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily"
|
|
||||||
>
|
|
||||||
> & {
|
|
||||||
/** cache of selected & editing elements populated on opened popup */
|
|
||||||
cachedElements?: Map<string, ExcalidrawElement>;
|
|
||||||
/** flag to reset all elements to their cached versions */
|
|
||||||
resetAll?: true;
|
|
||||||
/** flag to reset all containers to their cached versions */
|
|
||||||
resetContainers?: true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actionChangeFontFamily = register({
|
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) => {
|
||||||
const { cachedElements, resetAll, resetContainers, ...nextAppState } =
|
return {
|
||||||
value as ChangeFontFamilyData;
|
elements: changeProperty(
|
||||||
|
|
||||||
if (resetAll) {
|
|
||||||
const nextElements = changeProperty(
|
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
(element) => {
|
(oldElement) => {
|
||||||
const cachedElement = cachedElements?.get(element.id);
|
if (isTextElement(oldElement)) {
|
||||||
if (cachedElement) {
|
const newElement: ExcalidrawTextElement = newElementWith(
|
||||||
const newElement = newElementWith(element, {
|
oldElement,
|
||||||
...cachedElement,
|
{
|
||||||
});
|
fontFamily: value,
|
||||||
|
lineHeight: getDefaultLineHeight(value),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
redrawTextBoundingBox(
|
||||||
|
newElement,
|
||||||
|
app.scene.getContainerElement(oldElement),
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
return element;
|
return oldElement;
|
||||||
},
|
},
|
||||||
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,
|
||||||
...nextAppState,
|
currentItemFontFamily: value,
|
||||||
},
|
},
|
||||||
storeAction: nexStoreAction,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (nextFontFamily && !skipOnHoverRender) {
|
|
||||||
const elementContainerMapping = new Map<
|
|
||||||
ExcalidrawTextElement,
|
|
||||||
ExcalidrawElement | null
|
|
||||||
>();
|
|
||||||
let uniqueChars = new Set<string>();
|
|
||||||
let skipFontFaceCheck = false;
|
|
||||||
|
|
||||||
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
|
|
||||||
const fontFamily = Object.entries(FONT_FAMILY).find(
|
|
||||||
([_, value]) => value === nextFontFamily,
|
|
||||||
)?.[0];
|
|
||||||
|
|
||||||
// skip `document.font.check` check on hover, if at least one font family has loaded as it's super slow (could result in slightly different bbox, which is fine)
|
|
||||||
if (
|
|
||||||
currentHoveredFontFamily &&
|
|
||||||
fontFamily &&
|
|
||||||
fontsCache.some((sig) => sig.startsWith(fontFamily))
|
|
||||||
) {
|
|
||||||
skipFontFaceCheck = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// following causes re-render so make sure we changed the family
|
|
||||||
// otherwise it could cause unexpected issues, such as preventing opening the popover when in wysiwyg
|
|
||||||
Object.assign(result, {
|
|
||||||
elements: changeProperty(
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
(oldElement) => {
|
|
||||||
if (
|
|
||||||
isTextElement(oldElement) &&
|
|
||||||
(oldElement.fontFamily !== nextFontFamily ||
|
|
||||||
currentItemFontFamily) // force update on selection
|
|
||||||
) {
|
|
||||||
const newElement: ExcalidrawTextElement = newElementWith(
|
|
||||||
oldElement,
|
|
||||||
{
|
|
||||||
fontFamily: nextFontFamily,
|
|
||||||
lineHeight: getLineHeight(nextFontFamily!),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const cachedContainer =
|
|
||||||
cachedElements?.get(oldElement.containerId || "") || {};
|
|
||||||
|
|
||||||
const container = app.scene.getContainerElement(oldElement);
|
|
||||||
|
|
||||||
if (resetContainers && container && cachedContainer) {
|
|
||||||
// reset the container back to it's cached version
|
|
||||||
mutateElement(container, { ...cachedContainer }, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!skipFontFaceCheck) {
|
|
||||||
uniqueChars = new Set([
|
|
||||||
...uniqueChars,
|
|
||||||
...Array.from(newElement.originalText),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
elementContainerMapping.set(newElement, container);
|
|
||||||
|
|
||||||
return newElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
return oldElement;
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
// size is irrelevant, but necessary
|
|
||||||
const fontString = `10px ${getFontFamilyString({
|
|
||||||
fontFamily: nextFontFamily,
|
|
||||||
})}`;
|
|
||||||
const chars = Array.from(uniqueChars.values()).join();
|
|
||||||
|
|
||||||
if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
|
|
||||||
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
|
|
||||||
for (const [element, container] of elementContainerMapping) {
|
|
||||||
// trigger synchronous redraw
|
|
||||||
redrawTextBoundingBox(
|
|
||||||
element,
|
|
||||||
container,
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
|
|
||||||
window.document.fonts.load(fontString, chars).then((fontFaces) => {
|
|
||||||
for (const [element, container] of elementContainerMapping) {
|
|
||||||
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
|
|
||||||
const latestElement = app.scene.getElement(element.id);
|
|
||||||
const latestContainer = container
|
|
||||||
? app.scene.getElement(container.id)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (latestElement) {
|
|
||||||
// trigger async redraw
|
|
||||||
redrawTextBoundingBox(
|
|
||||||
latestElement as ExcalidrawTextElement,
|
|
||||||
latestContainer,
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// trigger update once we've mutated all the elements, which also updates our cache
|
|
||||||
app.fonts.onLoaded(fontFaces);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, app, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||||
const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map());
|
const options: {
|
||||||
const prevSelectedFontFamilyRef = useRef<number | null>(null);
|
value: FontFamilyValues;
|
||||||
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
|
text: string;
|
||||||
const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
|
icon: JSX.Element;
|
||||||
const isUnmounted = useRef(true);
|
testId: string;
|
||||||
|
}[] = [
|
||||||
const selectedFontFamily = useMemo(() => {
|
{
|
||||||
const getFontFamily = (
|
value: FONT_FAMILY.Virgil,
|
||||||
elementsArray: readonly ExcalidrawElement[],
|
text: t("labels.handDrawn"),
|
||||||
elementsMap: Map<string, ExcalidrawElement>,
|
icon: FreedrawIcon,
|
||||||
) =>
|
testId: "font-family-virgil",
|
||||||
getFormValue(
|
},
|
||||||
elementsArray,
|
{
|
||||||
appState,
|
value: FONT_FAMILY.Helvetica,
|
||||||
(element) => {
|
text: t("labels.normal"),
|
||||||
if (isTextElement(element)) {
|
icon: FontFamilyNormalIcon,
|
||||||
return element.fontFamily;
|
testId: "font-family-normal",
|
||||||
}
|
},
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
{
|
||||||
if (boundTextElement) {
|
value: FONT_FAMILY.Cascadia,
|
||||||
return boundTextElement.fontFamily;
|
text: t("labels.code"),
|
||||||
}
|
icon: FontFamilyCodeIcon,
|
||||||
return null;
|
testId: "font-family-code",
|
||||||
},
|
},
|
||||||
(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>
|
||||||
<FontPicker
|
<ButtonIconSelect<FontFamilyValues | false>
|
||||||
isOpened={appState.openPopup === "fontFamily"}
|
group="font-family"
|
||||||
selectedFontFamily={selectedFontFamily}
|
options={options}
|
||||||
hoveredFontFamily={appState.currentHoveredFontFamily}
|
value={getFormValue(
|
||||||
onSelect={(fontFamily) => {
|
elements,
|
||||||
setBatchedData({
|
appState,
|
||||||
openPopup: null,
|
(element) => {
|
||||||
currentHoveredFontFamily: null,
|
if (isTextElement(element)) {
|
||||||
currentItemFontFamily: fontFamily,
|
return element.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 { editingElement } = appState;
|
|
||||||
|
|
||||||
if (editingElement?.type === "text") {
|
|
||||||
// retrieve the latest version from the scene, as `editingElement` isn't mutated
|
|
||||||
const latestEditingElement = app.scene.getElement(
|
|
||||||
editingElement.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// inside the wysiwyg editor
|
|
||||||
cachedElementsRef.current.set(
|
|
||||||
editingElement.id,
|
|
||||||
newElementWith(
|
|
||||||
latestEditingElement || editingElement,
|
|
||||||
{},
|
|
||||||
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(
|
||||||
setBatchedData({
|
element,
|
||||||
openPopup: "fontFamily",
|
app.scene.getNonDeletedElementsMap(),
|
||||||
});
|
);
|
||||||
} else {
|
if (boundTextElement) {
|
||||||
// close, use the cache and clear it afterwards
|
return boundTextElement.fontFamily;
|
||||||
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>
|
||||||
);
|
);
|
||||||
@@ -1321,12 +1019,8 @@ 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) =>
|
||||||
if (isElbowArrow(el)) {
|
newElementWith(el, {
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
return newElementWith(el, {
|
|
||||||
roundness:
|
roundness:
|
||||||
value === "round"
|
value === "round"
|
||||||
? {
|
? {
|
||||||
@@ -1335,8 +1029,8 @@ export const actionChangeRoundness = register({
|
|||||||
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
});
|
}),
|
||||||
}),
|
),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
currentItemRoundness: value,
|
currentItemRoundness: value,
|
||||||
@@ -1376,8 +1070,7 @@ export const actionChangeRoundness = register({
|
|||||||
appState,
|
appState,
|
||||||
(element) =>
|
(element) =>
|
||||||
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
|
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
|
||||||
(element) =>
|
(element) => element.hasOwnProperty("roundness"),
|
||||||
!isArrowElement(element) && element.hasOwnProperty("roundness"),
|
|
||||||
(hasSelection) =>
|
(hasSelection) =>
|
||||||
hasSelection ? null : appState.currentItemRoundness,
|
hasSelection ? null : appState.currentItemRoundness,
|
||||||
)}
|
)}
|
||||||
@@ -1540,219 +1233,3 @@ export const actionChangeArrowhead = register({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeArrowType = register({
|
|
||||||
name: "changeArrowType",
|
|
||||||
label: "Change arrow types",
|
|
||||||
trackEvent: false,
|
|
||||||
perform: (elements, appState, value, app) => {
|
|
||||||
return {
|
|
||||||
elements: changeProperty(elements, appState, (el) => {
|
|
||||||
if (!isArrowElement(el)) {
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
const newElement = newElementWith(el, {
|
|
||||||
roundness:
|
|
||||||
value === ARROW_TYPE.round
|
|
||||||
? {
|
|
||||||
type: ROUNDNESS.PROPORTIONAL_RADIUS,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
elbowed: value === ARROW_TYPE.elbow,
|
|
||||||
points:
|
|
||||||
value === ARROW_TYPE.elbow || el.elbowed
|
|
||||||
? [el.points[0], el.points[el.points.length - 1]]
|
|
||||||
: el.points,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isElbowArrow(newElement)) {
|
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
|
||||||
|
|
||||||
app.dismissLinearEditor();
|
|
||||||
|
|
||||||
const startGlobalPoint =
|
|
||||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
|
||||||
newElement,
|
|
||||||
0,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
const endGlobalPoint =
|
|
||||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
|
||||||
newElement,
|
|
||||||
-1,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
const startHoveredElement =
|
|
||||||
!newElement.startBinding &&
|
|
||||||
getHoveredElementForBinding(
|
|
||||||
tupleToCoors(startGlobalPoint),
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const endHoveredElement =
|
|
||||||
!newElement.endBinding &&
|
|
||||||
getHoveredElementForBinding(
|
|
||||||
tupleToCoors(endGlobalPoint),
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const startElement = startHoveredElement
|
|
||||||
? startHoveredElement
|
|
||||||
: newElement.startBinding &&
|
|
||||||
(elementsMap.get(
|
|
||||||
newElement.startBinding.elementId,
|
|
||||||
) as ExcalidrawBindableElement);
|
|
||||||
const endElement = endHoveredElement
|
|
||||||
? endHoveredElement
|
|
||||||
: newElement.endBinding &&
|
|
||||||
(elementsMap.get(
|
|
||||||
newElement.endBinding.elementId,
|
|
||||||
) as ExcalidrawBindableElement);
|
|
||||||
|
|
||||||
const finalStartPoint = startHoveredElement
|
|
||||||
? bindPointToSnapToElementOutline(
|
|
||||||
startGlobalPoint,
|
|
||||||
endGlobalPoint,
|
|
||||||
startHoveredElement,
|
|
||||||
elementsMap,
|
|
||||||
)
|
|
||||||
: startGlobalPoint;
|
|
||||||
const finalEndPoint = endHoveredElement
|
|
||||||
? bindPointToSnapToElementOutline(
|
|
||||||
endGlobalPoint,
|
|
||||||
startGlobalPoint,
|
|
||||||
endHoveredElement,
|
|
||||||
elementsMap,
|
|
||||||
)
|
|
||||||
: endGlobalPoint;
|
|
||||||
|
|
||||||
startHoveredElement &&
|
|
||||||
bindLinearElement(
|
|
||||||
newElement,
|
|
||||||
startHoveredElement,
|
|
||||||
"start",
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
endHoveredElement &&
|
|
||||||
bindLinearElement(
|
|
||||||
newElement,
|
|
||||||
endHoveredElement,
|
|
||||||
"end",
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
mutateElbowArrow(
|
|
||||||
newElement,
|
|
||||||
elementsMap,
|
|
||||||
[finalStartPoint, finalEndPoint].map(
|
|
||||||
(point) =>
|
|
||||||
[point[0] - newElement.x, point[1] - newElement.y] as Point,
|
|
||||||
),
|
|
||||||
[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,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
mutateElement(
|
|
||||||
newElement,
|
|
||||||
{
|
|
||||||
startBinding: newElement.startBinding
|
|
||||||
? { ...newElement.startBinding, fixedPoint: null }
|
|
||||||
: null,
|
|
||||||
endBinding: newElement.endBinding
|
|
||||||
? { ...newElement.endBinding, fixedPoint: null }
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,7 +12,10 @@ import {
|
|||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { getBoundTextElement } from "../element/textElement";
|
import {
|
||||||
|
getBoundTextElement,
|
||||||
|
getDefaultLineHeight,
|
||||||
|
} from "../element/textElement";
|
||||||
import {
|
import {
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
canApplyRoundnessTypeToElement,
|
canApplyRoundnessTypeToElement,
|
||||||
@@ -24,7 +27,6 @@ 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 = "{}";
|
||||||
@@ -120,7 +122,7 @@ export const actionPasteStyles = register({
|
|||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
lineHeight:
|
lineHeight:
|
||||||
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
|
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
|
||||||
getLineHeight(fontFamily),
|
getDefaultLineHeight(fontFamily),
|
||||||
});
|
});
|
||||||
let container = null;
|
let container = null;
|
||||||
if (newElement.containerId) {
|
if (newElement.containerId) {
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { isTextElement } from "../element";
|
|
||||||
import { newElementWith } from "../element/mutateElement";
|
|
||||||
import { measureText } from "../element/textElement";
|
|
||||||
import { getSelectedElements } from "../scene";
|
|
||||||
import { StoreAction } from "../store";
|
|
||||||
import type { AppClassProperties } from "../types";
|
|
||||||
import { getFontString } from "../utils";
|
|
||||||
import { register } from "./register";
|
|
||||||
|
|
||||||
export const actionTextAutoResize = register({
|
|
||||||
name: "autoResize",
|
|
||||||
label: "labels.autoResize",
|
|
||||||
icon: null,
|
|
||||||
trackEvent: { category: "element" },
|
|
||||||
predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
|
||||||
return (
|
|
||||||
selectedElements.length === 1 &&
|
|
||||||
isTextElement(selectedElements[0]) &&
|
|
||||||
!selectedElements[0].autoResize
|
|
||||||
);
|
|
||||||
},
|
|
||||||
perform: (elements, appState, _, app) => {
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
|
||||||
|
|
||||||
return {
|
|
||||||
appState,
|
|
||||||
elements: elements.map((element) => {
|
|
||||||
if (element.id === selectedElements[0].id && isTextElement(element)) {
|
|
||||||
const metrics = measureText(
|
|
||||||
element.originalText,
|
|
||||||
getFontString(element),
|
|
||||||
element.lineHeight,
|
|
||||||
);
|
|
||||||
|
|
||||||
return newElementWith(element, {
|
|
||||||
autoResize: true,
|
|
||||||
width: metrics.width,
|
|
||||||
height: metrics.height,
|
|
||||||
text: element.originalText,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return element;
|
|
||||||
}),
|
|
||||||
storeAction: StoreAction.CAPTURE,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -5,22 +5,21 @@ import { StoreAction } from "../store";
|
|||||||
|
|
||||||
export const actionToggleStats = register({
|
export const actionToggleStats = register({
|
||||||
name: "stats",
|
name: "stats",
|
||||||
label: "stats.fullTitle",
|
label: "stats.title",
|
||||||
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,
|
||||||
stats: { ...appState.stats, open: !this.checked!(appState) },
|
showStats: !this.checked!(appState),
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState) => appState.stats.open,
|
checked: (appState) => appState.showStats,
|
||||||
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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ export type ActionName =
|
|||||||
| "changeSloppiness"
|
| "changeSloppiness"
|
||||||
| "changeStrokeStyle"
|
| "changeStrokeStyle"
|
||||||
| "changeArrowhead"
|
| "changeArrowhead"
|
||||||
| "changeArrowType"
|
|
||||||
| "changeOpacity"
|
| "changeOpacity"
|
||||||
| "changeFontSize"
|
| "changeFontSize"
|
||||||
| "toggleCanvasMenu"
|
| "toggleCanvasMenu"
|
||||||
@@ -135,9 +134,7 @@ export type ActionName =
|
|||||||
| "setEmbeddableAsActiveTool"
|
| "setEmbeddableAsActiveTool"
|
||||||
| "createContainerFromText"
|
| "createContainerFromText"
|
||||||
| "wrapTextInContainer"
|
| "wrapTextInContainer"
|
||||||
| "commandPalette"
|
| "commandPalette";
|
||||||
| "autoResize"
|
|
||||||
| "elementStats";
|
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
|
|||||||
@@ -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 = new Set(["command_palette"]);
|
const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
|
||||||
|
|
||||||
export const trackEvent = (
|
export const trackEvent = (
|
||||||
category: string,
|
category: string,
|
||||||
@@ -9,20 +9,17 @@ 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
|
||||||
import.meta.env.VITE_APP_ENABLE_TRACKING !== "true"
|
// comment out to debug locally
|
||||||
|
|| import.meta.env.PROD
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) {
|
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
// comment out to debug in dev
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
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,
|
||||||
EXPORT_SCALES,
|
EXPORT_SCALES,
|
||||||
STATS_PANELS,
|
|
||||||
THEME,
|
THEME,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import type { AppState, NormalizedZoomValue } from "./types";
|
import type { AppState, NormalizedZoomValue } from "./types";
|
||||||
@@ -34,14 +32,12 @@ 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,
|
||||||
newElement: null,
|
draggingElement: null,
|
||||||
editingElement: null,
|
editingElement: null,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
@@ -84,10 +80,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
selectedElementsAreBeingDragged: false,
|
selectedElementsAreBeingDragged: false,
|
||||||
selectionElement: null,
|
selectionElement: null,
|
||||||
shouldCacheIgnoreZoom: false,
|
shouldCacheIgnoreZoom: false,
|
||||||
stats: {
|
showStats: false,
|
||||||
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 },
|
||||||
@@ -145,11 +138,6 @@ 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 },
|
||||||
@@ -157,10 +145,9 @@ 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 },
|
||||||
newElement: { browser: false, export: false, server: false },
|
draggingElement: { browser: false, export: false, server: false },
|
||||||
editingElement: { browser: false, export: false, server: false },
|
editingElement: { browser: false, export: false, server: false },
|
||||||
editingGroupId: { browser: true, export: false, server: false },
|
editingGroupId: { browser: true, export: false, server: false },
|
||||||
editingLinearElement: { browser: false, export: false, server: false },
|
editingLinearElement: { browser: false, export: false, server: false },
|
||||||
@@ -209,7 +196,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 },
|
||||||
stats: { browser: true, export: false, server: false },
|
showStats: { 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 },
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
export default class BinaryHeap<T> {
|
|
||||||
private content: T[] = [];
|
|
||||||
|
|
||||||
constructor(private scoreFunction: (node: T) => number) {}
|
|
||||||
|
|
||||||
sinkDown(idx: number) {
|
|
||||||
const node = this.content[idx];
|
|
||||||
while (idx > 0) {
|
|
||||||
const parentN = ((idx + 1) >> 1) - 1;
|
|
||||||
const parent = this.content[parentN];
|
|
||||||
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
|
|
||||||
this.content[parentN] = node;
|
|
||||||
this.content[idx] = parent;
|
|
||||||
idx = parentN; // TODO: Optimize
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bubbleUp(idx: number) {
|
|
||||||
const length = this.content.length;
|
|
||||||
const node = this.content[idx];
|
|
||||||
const score = this.scoreFunction(node);
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const child2N = (idx + 1) << 1;
|
|
||||||
const child1N = child2N - 1;
|
|
||||||
let swap = null;
|
|
||||||
let child1Score = 0;
|
|
||||||
|
|
||||||
if (child1N < length) {
|
|
||||||
const child1 = this.content[child1N];
|
|
||||||
child1Score = this.scoreFunction(child1);
|
|
||||||
if (child1Score < score) {
|
|
||||||
swap = child1N;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (child2N < length) {
|
|
||||||
const child2 = this.content[child2N];
|
|
||||||
const child2Score = this.scoreFunction(child2);
|
|
||||||
if (child2Score < (swap === null ? score : child1Score)) {
|
|
||||||
swap = child2N;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (swap !== null) {
|
|
||||||
this.content[idx] = this.content[swap];
|
|
||||||
this.content[swap] = node;
|
|
||||||
idx = swap; // TODO: Optimize
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
push(node: T) {
|
|
||||||
this.content.push(node);
|
|
||||||
this.sinkDown(this.content.length - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
pop(): T | null {
|
|
||||||
if (this.content.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = this.content[0];
|
|
||||||
const end = this.content.pop()!;
|
|
||||||
|
|
||||||
if (this.content.length > 0) {
|
|
||||||
this.content[0] = end;
|
|
||||||
this.bubbleUp(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(node: T) {
|
|
||||||
if (this.content.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const i = this.content.indexOf(node);
|
|
||||||
const end = this.content.pop()!;
|
|
||||||
|
|
||||||
if (i < this.content.length) {
|
|
||||||
this.content[i] = end;
|
|
||||||
|
|
||||||
if (this.scoreFunction(end) < this.scoreFunction(node)) {
|
|
||||||
this.sinkDown(i);
|
|
||||||
} else {
|
|
||||||
this.bubbleUp(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
size(): number {
|
|
||||||
return this.content.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
rescoreElement(node: T) {
|
|
||||||
this.sinkDown(this.content.indexOf(node));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1100,6 +1100,7 @@ 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)
|
||||||
@@ -1108,9 +1109,6 @@ 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`,
|
||||||
@@ -1462,9 +1460,7 @@ 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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1481,28 +1477,19 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
return elements;
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
const unordered = Array.from(elements.values());
|
const previous = Array.from(elements.values());
|
||||||
const ordered = orderByFractionalIndex([...unordered]);
|
const reordered = orderByFractionalIndex([...previous]);
|
||||||
const moved = Delta.getRightDifferences(unordered, ordered, true).reduce(
|
|
||||||
(acc, arrayIndex) => {
|
|
||||||
const candidate = unordered[Number(arrayIndex)];
|
|
||||||
if (candidate && changed.has(candidate.id)) {
|
|
||||||
acc.set(candidate.id, candidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
if (
|
||||||
},
|
!flags.containsVisibleDifference &&
|
||||||
new Map(),
|
Delta.isRightDifferent(previous, reordered, true)
|
||||||
);
|
) {
|
||||||
|
|
||||||
if (!flags.containsVisibleDifference && moved.size) {
|
|
||||||
// we found a difference in order!
|
// we found a difference in order!
|
||||||
flags.containsVisibleDifference = true;
|
flags.containsVisibleDifference = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// synchronize all elements that were actually moved
|
// let's synchronize all invalid indices of moved elements
|
||||||
// could fallback to synchronizing all invalid indices
|
return arrayToMap(syncMovedIndices(reordered, changed)) as typeof elements;
|
||||||
return arrayToMap(syncMovedIndices(ordered, moved)) as typeof elements;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -257,6 +257,8 @@ const chartLines = (
|
|||||||
type: "line",
|
type: "line",
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
|
startArrowhead: null,
|
||||||
|
endArrowhead: null,
|
||||||
width: chartWidth,
|
width: chartWidth,
|
||||||
points: [
|
points: [
|
||||||
[0, 0],
|
[0, 0],
|
||||||
@@ -271,6 +273,8 @@ const chartLines = (
|
|||||||
type: "line",
|
type: "line",
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
|
startArrowhead: null,
|
||||||
|
endArrowhead: null,
|
||||||
height: chartHeight,
|
height: chartHeight,
|
||||||
points: [
|
points: [
|
||||||
[0, 0],
|
[0, 0],
|
||||||
@@ -285,6 +289,8 @@ 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,
|
||||||
@@ -412,6 +418,8 @@ 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,
|
||||||
@@ -445,6 +453,8 @@ 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,
|
||||||
|
|||||||
@@ -21,11 +21,10 @@ 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, toolIsArrow } from "../scene/comparisons";
|
import { hasStrokeColor } 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";
|
||||||
@@ -122,8 +121,7 @@ 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">
|
||||||
@@ -157,16 +155,13 @@ 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("changeFontFamily")}
|
|
||||||
{renderAction("changeFontSize")}
|
{renderAction("changeFontSize")}
|
||||||
|
|
||||||
|
{renderAction("changeFontFamily")}
|
||||||
|
|
||||||
{(appState.activeTool.type === "text" ||
|
{(appState.activeTool.type === "text" ||
|
||||||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
|
suppportsHorizontalAlign(targetElements, elementsMap)) &&
|
||||||
renderAction("changeTextAlign")}
|
renderAction("changeTextAlign")}
|
||||||
@@ -473,7 +468,6 @@ export const ExitZenModeAction = ({
|
|||||||
showExitZenModeBtn: boolean;
|
showExitZenModeBtn: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
className={clsx("disable-zen-mode", {
|
className={clsx("disable-zen-mode", {
|
||||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||||
})}
|
})}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
|||||||
@import "../css/theme";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
button.standalone {
|
|
||||||
@include outlineButtonIconStyles;
|
|
||||||
|
|
||||||
& > * {
|
|
||||||
// dissalow pointer events on children, so we always have event.target on the button itself
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { forwardRef } from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
import "./ButtonIcon.scss";
|
|
||||||
|
|
||||||
interface ButtonIconProps {
|
|
||||||
icon: JSX.Element;
|
|
||||||
title: string;
|
|
||||||
className?: string;
|
|
||||||
testId?: string;
|
|
||||||
/** if not supplied, defaults to value identity check */
|
|
||||||
active?: boolean;
|
|
||||||
/** include standalone style (could interfere with parent styles) */
|
|
||||||
standalone?: boolean;
|
|
||||||
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
|
|
||||||
(props, ref) => {
|
|
||||||
const { title, className, testId, active, standalone, icon, onClick } =
|
|
||||||
props;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
ref={ref}
|
|
||||||
key={title}
|
|
||||||
title={title}
|
|
||||||
data-testid={testId}
|
|
||||||
className={clsx(className, { standalone, active })}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
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>(
|
||||||
@@ -25,17 +24,20 @@ export const ButtonIconSelect = <T extends Object>(
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
) => (
|
) => (
|
||||||
<div className="buttonList">
|
<div className="buttonList buttonListIcon">
|
||||||
{props.options.map((option) =>
|
{props.options.map((option) =>
|
||||||
props.type === "button" ? (
|
props.type === "button" ? (
|
||||||
<ButtonIcon
|
<button
|
||||||
key={option.text}
|
key={option.text}
|
||||||
icon={option.icon}
|
|
||||||
title={option.text}
|
|
||||||
testId={option.testId}
|
|
||||||
active={option.active ?? props.value === option.value}
|
|
||||||
onClick={(event) => props.onClick(option.value, event)}
|
onClick={(event) => props.onClick(option.value, event)}
|
||||||
/>
|
className={clsx({
|
||||||
|
active: option.active ?? props.value === option.value,
|
||||||
|
})}
|
||||||
|
data-testid={option.testId}
|
||||||
|
title={option.text}
|
||||||
|
>
|
||||||
|
{option.icon}
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<label
|
<label
|
||||||
key={option.text}
|
key={option.text}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
export const ButtonSeparator = () => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 1,
|
|
||||||
height: "1rem",
|
|
||||||
backgroundColor: "var(--default-border-color)",
|
|
||||||
margin: "0 auto",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
@@ -22,12 +22,7 @@ export const CheckboxItem: React.FC<{
|
|||||||
).focus();
|
).focus();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
|
||||||
type="button"
|
|
||||||
className="Checkbox-box"
|
|
||||||
role="checkbox"
|
|
||||||
aria-checked={checked}
|
|
||||||
>
|
|
||||||
{checkIcon}
|
{checkIcon}
|
||||||
</button>
|
</button>
|
||||||
<div className="Checkbox-label">{children}</div>
|
<div className="Checkbox-label">{children}</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
@include isMobile {
|
@include isMobile {
|
||||||
max-width: 11rem;
|
max-width: 175px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
import { isTransparent } from "../../utils";
|
import { isInteractive, isTransparent, isWritableElement } 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 { useExcalidrawContainer } from "../App";
|
import { useDevice, 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";
|
||||||
|
|
||||||
@@ -73,7 +71,6 @@ 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(
|
||||||
@@ -81,6 +78,9 @@ 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,7 +94,6 @@ const ColorPickerPopupContent = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const focusPickerContent = () => {
|
const focusPickerContent = () => {
|
||||||
@@ -104,73 +103,120 @@ const ColorPickerPopupContent = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PropertiesPopover
|
<Popover.Portal container={container}>
|
||||||
container={container}
|
<Popover.Content
|
||||||
style={{ maxWidth: "208px" }}
|
ref={popoverRef}
|
||||||
onFocusOutside={(event) => {
|
className="focus-visible-none"
|
||||||
// refocus due to eye dropper
|
data-prevent-outside-click
|
||||||
focusPickerContent();
|
onFocusOutside={(event) => {
|
||||||
event.preventDefault();
|
focusPickerContent();
|
||||||
}}
|
|
||||||
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) => {
|
||||||
onClose={() => {
|
if (eyeDropperState) {
|
||||||
updateData({ openPopup: null });
|
// prevent from closing if we click outside the popover
|
||||||
setActiveColorPickerSection(null);
|
// while eyedropping (e.g. click when clicking the sidebar;
|
||||||
}}
|
// the eye-dropper-backdrop is prevented downstream)
|
||||||
>
|
event.preventDefault();
|
||||||
{palette ? (
|
}
|
||||||
<Picker
|
}}
|
||||||
palette={palette}
|
onCloseAutoFocus={(e) => {
|
||||||
color={color}
|
e.stopPropagation();
|
||||||
onChange={(changedColor) => {
|
// prevents focusing the trigger
|
||||||
onChange(changedColor);
|
e.preventDefault();
|
||||||
}}
|
|
||||||
onEyeDropperToggle={(force) => {
|
|
||||||
setEyeDropperState((state) => {
|
|
||||||
if (force) {
|
|
||||||
state = state || {
|
|
||||||
keepOpenOnAlt: true,
|
|
||||||
onSelect: onChange,
|
|
||||||
colorPickerType: type,
|
|
||||||
};
|
|
||||||
state.keepOpenOnAlt = true;
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
return force === false || state
|
// return focus to excalidraw container unless
|
||||||
? null
|
// user focuses an interactive element, such as a button, or
|
||||||
: {
|
// enters the text editor by clicking on canvas with the text tool
|
||||||
keepOpenOnAlt: false,
|
if (container && !isInteractive(document.activeElement)) {
|
||||||
|
container.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData({ openPopup: null });
|
||||||
|
setActiveColorPickerSection(null);
|
||||||
|
}}
|
||||||
|
side={
|
||||||
|
device.editor.isMobile && !device.viewport.isLandscape
|
||||||
|
? "bottom"
|
||||||
|
: "right"
|
||||||
|
}
|
||||||
|
align={
|
||||||
|
device.editor.isMobile && !device.viewport.isLandscape
|
||||||
|
? "center"
|
||||||
|
: "start"
|
||||||
|
}
|
||||||
|
alignOffset={-16}
|
||||||
|
sideOffset={20}
|
||||||
|
style={{
|
||||||
|
zIndex: "var(--zIndex-layerUI)",
|
||||||
|
backgroundColor: "var(--popup-bg-color)",
|
||||||
|
maxWidth: "208px",
|
||||||
|
maxHeight: window.innerHeight,
|
||||||
|
padding: "12px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
overflowY: "auto",
|
||||||
|
boxShadow:
|
||||||
|
"0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{palette ? (
|
||||||
|
<Picker
|
||||||
|
palette={palette}
|
||||||
|
color={color}
|
||||||
|
onChange={(changedColor) => {
|
||||||
|
onChange(changedColor);
|
||||||
|
}}
|
||||||
|
onEyeDropperToggle={(force) => {
|
||||||
|
setEyeDropperState((state) => {
|
||||||
|
if (force) {
|
||||||
|
state = state || {
|
||||||
|
keepOpenOnAlt: true,
|
||||||
onSelect: onChange,
|
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) => {
|
/>
|
||||||
if (eyeDropperState) {
|
</Popover.Content>
|
||||||
setEyeDropperState(null);
|
</Popover.Portal>
|
||||||
} else {
|
|
||||||
updateData({ openPopup: null });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
label={label}
|
|
||||||
type={type}
|
|
||||||
elements={elements}
|
|
||||||
updateData={updateData}
|
|
||||||
>
|
|
||||||
{colorInputJSX}
|
|
||||||
</Picker>
|
|
||||||
) : (
|
|
||||||
colorInputJSX
|
|
||||||
)}
|
|
||||||
</PropertiesPopover>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -186,7 +232,7 @@ const ColorPickerTrigger = ({
|
|||||||
return (
|
return (
|
||||||
<Popover.Trigger
|
<Popover.Trigger
|
||||||
type="button"
|
type="button"
|
||||||
className={clsx("color-picker__button active-color properties-trigger", {
|
className={clsx("color-picker__button active-color", {
|
||||||
"is-transparent": color === "transparent" || !color,
|
"is-transparent": color === "transparent" || !color,
|
||||||
})}
|
})}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
@@ -222,7 +268,14 @@ export const ColorPicker = ({
|
|||||||
type={type}
|
type={type}
|
||||||
topPicks={topPicks}
|
topPicks={topPicks}
|
||||||
/>
|
/>
|
||||||
<ButtonSeparator />
|
<div
|
||||||
|
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 properties-content"
|
className="color-picker-content"
|
||||||
// to allow focusing by clicking but not by tabbing
|
// to allow focusing by clicking but not by tabbing
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -540,7 +540,7 @@ function CommandPaletteInner({
|
|||||||
...command,
|
...command,
|
||||||
icon: command.icon || boltIcon,
|
icon: command.icon || boltIcon,
|
||||||
order: command.order ?? getCategoryOrder(command.category),
|
order: command.order ?? getCategoryOrder(command.category),
|
||||||
haystack: `${deburr(command.label.toLocaleLowerCase())} ${
|
haystack: `${deburr(command.label)} ${
|
||||||
command.keywords?.join(" ") || ""
|
command.keywords?.join(" ") || ""
|
||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
@@ -777,9 +777,7 @@ function CommandPaletteInner({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _query = deburr(
|
const _query = deburr(commandSearch.replace(/[<>-_| ]/g, ""));
|
||||||
commandSearch.toLocaleLowerCase().replace(/[<>_| -]/g, ""),
|
|
||||||
);
|
|
||||||
matchingCommands = fuzzy
|
matchingCommands = fuzzy
|
||||||
.filter(_query, matchingCommands, {
|
.filter(_query, matchingCommands, {
|
||||||
extract: (command) => command.haystack,
|
extract: (command) => command.haystack,
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ export const ContextMenu = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
className={clsx("context-menu-item", {
|
className={clsx("context-menu-item", {
|
||||||
dangerous: actionName === "deleteSelectedElements",
|
dangerous: actionName === "deleteSelectedElements",
|
||||||
checkmark: item.checked?.(appState),
|
checkmark: item.checked?.(appState),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
assertExcalidrawWithSidebar,
|
assertExcalidrawWithSidebar,
|
||||||
assertSidebarDockButton,
|
assertSidebarDockButton,
|
||||||
} from "./Sidebar/siderbar.test.helpers";
|
} from "./Sidebar/Sidebar.test";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title={t("buttons.close")}
|
title={t("buttons.close")}
|
||||||
aria-label={t("buttons.close")}
|
aria-label={t("buttons.close")}
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
{CloseIcon}
|
{CloseIcon}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -27,11 +27,7 @@ const FollowMode = ({
|
|||||||
{userToFollow.username}
|
{userToFollow.username}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={onDisconnect} className="follow-mode__disconnect-btn">
|
||||||
type="button"
|
|
||||||
onClick={onDisconnect}
|
|
||||||
className="follow-mode__disconnect-btn"
|
|
||||||
>
|
|
||||||
{CloseIcon}
|
{CloseIcon}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
@import "../../css/variables.module.scss";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.FontPicker__container {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: calc(1rem + 3 * var(--default-button-size)) 1rem 1fr; // calc ~ 2 gaps + 4 buttons
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
@include isMobile {
|
|
||||||
max-width: calc(
|
|
||||||
2rem + 4 * var(--default-button-size)
|
|
||||||
); // 4 gaps + 4 buttons
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import React, { useCallback, useMemo } from "react";
|
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
|
||||||
|
|
||||||
import { FontPickerList } from "./FontPickerList";
|
|
||||||
import { FontPickerTrigger } from "./FontPickerTrigger";
|
|
||||||
import { ButtonIconSelect } from "../ButtonIconSelect";
|
|
||||||
import {
|
|
||||||
FontFamilyCodeIcon,
|
|
||||||
FontFamilyNormalIcon,
|
|
||||||
FreedrawIcon,
|
|
||||||
} from "../icons";
|
|
||||||
import { ButtonSeparator } from "../ButtonSeparator";
|
|
||||||
import type { FontFamilyValues } from "../../element/types";
|
|
||||||
import { FONT_FAMILY } from "../../constants";
|
|
||||||
import { t } from "../../i18n";
|
|
||||||
|
|
||||||
import "./FontPicker.scss";
|
|
||||||
|
|
||||||
export const DEFAULT_FONTS = [
|
|
||||||
{
|
|
||||||
value: FONT_FAMILY.Excalifont,
|
|
||||||
icon: FreedrawIcon,
|
|
||||||
text: t("labels.handDrawn"),
|
|
||||||
testId: "font-family-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,
|
|
||||||
);
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
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,
|
|
||||||
);
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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: 700;
|
font-weight: bold;
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
&__island {
|
&__island {
|
||||||
h4 {
|
h4 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: bold;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-bottom: 0.625rem;
|
margin-bottom: 0.625rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
shortcuts={[getShortcutKey("Alt+Shift+D")]}
|
shortcuts={[getShortcutKey("Alt+Shift+D")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("stats.fullTitle")}
|
label={t("stats.title")}
|
||||||
shortcuts={[getShortcutKey("Alt+/")]}
|
shortcuts={[getShortcutKey("Alt+/")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
@@ -458,10 +458,6 @@ 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+<")]}
|
||||||
|
|||||||
@@ -30,13 +30,10 @@ 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.linearElementMulti");
|
return t("hints.linearElement");
|
||||||
}
|
}
|
||||||
if (activeTool.type === "arrow") {
|
return t("hints.linearElementMulti");
|
||||||
return t("hints.arrowTool", { arrowShortcut: getShortcutKey("A") });
|
|
||||||
}
|
|
||||||
return t("hints.linearElement");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTool.type === "freedraw") {
|
if (activeTool.type === "freedraw") {
|
||||||
@@ -85,7 +82,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
|||||||
|
|
||||||
if (activeTool.type === "selection") {
|
if (activeTool.type === "selection") {
|
||||||
if (
|
if (
|
||||||
appState.selectionElement &&
|
appState.draggingElement?.type === "selection" &&
|
||||||
!selectedElements.length &&
|
!selectedElements.length &&
|
||||||
!appState.editingElement &&
|
!appState.editingElement &&
|
||||||
!appState.editingLinearElement
|
!appState.editingLinearElement
|
||||||
@@ -93,7 +90,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
|||||||
return t("hints.deepBoxSelect");
|
return t("hints.deepBoxSelect");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appState.gridSize && appState.selectedElementsAreBeingDragged) {
|
if (appState.gridSize && appState.draggingElement) {
|
||||||
return t("hints.disableSnapping");
|
return t("hints.disableSnapping");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,8 +108,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
|||||||
return t("hints.lineEditor_info");
|
return t("hints.lineEditor_info");
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!appState.newElement &&
|
!appState.draggingElement &&
|
||||||
!appState.selectedElementsAreBeingDragged &&
|
|
||||||
isTextBindableContainer(selectedElements[0])
|
isTextBindableContainer(selectedElements[0])
|
||||||
) {
|
) {
|
||||||
return t("hints.bindTextToElement");
|
return t("hints.bindTextToElement");
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ function Picker<T>({
|
|||||||
<div className="picker-content" ref={rGallery}>
|
<div className="picker-content" ref={rGallery}>
|
||||||
{options.map((option, i) => (
|
{options.map((option, i) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
className={clsx("picker-option", {
|
className={clsx("picker-option", {
|
||||||
active: value === option.value,
|
active: value === option.value,
|
||||||
})}
|
})}
|
||||||
@@ -172,7 +171,6 @@ export function IconPicker<T>({
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
name={group}
|
name={group}
|
||||||
type="button"
|
|
||||||
className={isActive ? "active" : ""}
|
className={isActive ? "active" : ""}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onClick={() => setActive(!isActive)}
|
onClick={() => setActive(!isActive)}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { nativeFileSystemSupported } from "../data/filesystem";
|
|||||||
import type { NonDeletedExcalidrawElement } from "../element/types";
|
import type { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { exportToCanvas } from "../../utils/export";
|
import { exportToCanvas } from "@excalidraw/utils";
|
||||||
|
|
||||||
import { copyIcon, downloadIcon, helpIcon } from "./icons";
|
import { copyIcon, downloadIcon, helpIcon } from "./icons";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
|
|||||||
@@ -27,99 +27,6 @@
|
|||||||
& > * {
|
& > * {
|
||||||
pointer-events: var(--ui-pointerEvents);
|
pointer-events: var(--ui-pointerEvents);
|
||||||
}
|
}
|
||||||
|
|
||||||
& > .Stats {
|
|
||||||
width: 204px;
|
|
||||||
position: absolute;
|
|
||||||
top: 60px;
|
|
||||||
font-size: 12px;
|
|
||||||
z-index: var(--zIndex-layerUI);
|
|
||||||
pointer-events: var(--ui-pointerEvents);
|
|
||||||
|
|
||||||
.title {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionContent {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.elementType {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.elementsCount {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 12px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statsItem {
|
|
||||||
margin-top: 8px;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
|
|
||||||
.label {
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
white-space: nowrap;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close {
|
|
||||||
height: 16px;
|
|
||||||
width: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
th {
|
|
||||||
border-bottom: 1px solid var(--input-border-color);
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
tr {
|
|
||||||
td:nth-child(2) {
|
|
||||||
min-width: 24px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
width: 100%;
|
|
||||||
height: 1px;
|
|
||||||
background-color: var(--default-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: 12px;
|
|
||||||
right: initial;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__footer {
|
&__footer {
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import { JSONExportDialog } from "./JSONExportDialog";
|
|||||||
import { PenModeButton } from "./PenModeButton";
|
import { PenModeButton } from "./PenModeButton";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { useDevice } from "./App";
|
import { useDevice } from "./App";
|
||||||
|
import { Stats } from "./Stats";
|
||||||
|
import { actionToggleStats } from "../actions/actionToggleStats";
|
||||||
import Footer from "./footer/Footer";
|
import Footer from "./footer/Footer";
|
||||||
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||||
import { jotaiScope } from "../jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
@@ -62,8 +64,6 @@ import Scene from "../scene/Scene";
|
|||||||
import { LaserPointerButton } from "./LaserPointerButton";
|
import { LaserPointerButton } from "./LaserPointerButton";
|
||||||
import { MagicSettings } from "./MagicSettings";
|
import { MagicSettings } from "./MagicSettings";
|
||||||
import { TTDDialog } from "./TTDDialog/TTDDialog";
|
import { TTDDialog } from "./TTDDialog/TTDDialog";
|
||||||
import { Stats } from "./Stats";
|
|
||||||
import { actionToggleStats } from "../actions";
|
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
@@ -241,11 +241,6 @@ const LayerUI = ({
|
|||||||
elements,
|
elements,
|
||||||
);
|
);
|
||||||
|
|
||||||
const shouldShowStats =
|
|
||||||
appState.stats.open &&
|
|
||||||
!appState.zenModeEnabled &&
|
|
||||||
!appState.viewModeEnabled;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FixedSideContainer side="top">
|
<FixedSideContainer side="top">
|
||||||
<div className="App-menu App-menu_top">
|
<div className="App-menu App-menu_top">
|
||||||
@@ -358,15 +353,6 @@ const LayerUI = ({
|
|||||||
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
|
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
|
||||||
<tunnels.DefaultSidebarTriggerTunnel.Out />
|
<tunnels.DefaultSidebarTriggerTunnel.Out />
|
||||||
)}
|
)}
|
||||||
{shouldShowStats && (
|
|
||||||
<Stats
|
|
||||||
scene={app.scene}
|
|
||||||
onClose={() => {
|
|
||||||
actionManager.executeAction(actionToggleStats);
|
|
||||||
}}
|
|
||||||
renderCustomStats={renderCustomStats}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FixedSideContainer>
|
</FixedSideContainer>
|
||||||
@@ -458,7 +444,7 @@ const LayerUI = ({
|
|||||||
);
|
);
|
||||||
ShapeCache.delete(element);
|
ShapeCache.delete(element);
|
||||||
}
|
}
|
||||||
Scene.getScene(selectedElements[0])?.triggerUpdate();
|
Scene.getScene(selectedElements[0])?.informMutation();
|
||||||
} else if (colorPickerType === "elementBackground") {
|
} else if (colorPickerType === "elementBackground") {
|
||||||
setAppState({
|
setAppState({
|
||||||
currentItemBackgroundColor: color,
|
currentItemBackgroundColor: color,
|
||||||
@@ -556,9 +542,19 @@ const LayerUI = ({
|
|||||||
showExitZenModeBtn={showExitZenModeBtn}
|
showExitZenModeBtn={showExitZenModeBtn}
|
||||||
renderWelcomeScreen={renderWelcomeScreen}
|
renderWelcomeScreen={renderWelcomeScreen}
|
||||||
/>
|
/>
|
||||||
|
{appState.showStats && (
|
||||||
|
<Stats
|
||||||
|
appState={appState}
|
||||||
|
setAppState={setAppState}
|
||||||
|
elements={elements}
|
||||||
|
onClose={() => {
|
||||||
|
actionManager.executeAction(actionToggleStats);
|
||||||
|
}}
|
||||||
|
renderCustomStats={renderCustomStats}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{appState.scrolledOutside && (
|
{appState.scrolledOutside && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
className="scroll-back-to-content"
|
className="scroll-back-to-content"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAppState((appState) => ({
|
setAppState((appState) => ({
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
.library-actions-counter {
|
.library-actions-counter {
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
color: var(--color-primary-light);
|
color: var(--color-primary-light);
|
||||||
font-weight: 700;
|
font-weight: bold;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
font-weight: 700;
|
font-weight: bold;
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
&__header {
|
&__header {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 700;
|
font-weight: bold;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-right: 4rem; // due to dropdown button
|
padding-right: 4rem; // due to dropdown button
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { Section } from "./Section";
|
|||||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||||
import { LockButton } from "./LockButton";
|
import { LockButton } from "./LockButton";
|
||||||
import { PenModeButton } from "./PenModeButton";
|
import { PenModeButton } from "./PenModeButton";
|
||||||
|
import { Stats } from "./Stats";
|
||||||
|
import { actionToggleStats } from "../actions";
|
||||||
import { HandButton } from "./HandButton";
|
import { HandButton } from "./HandButton";
|
||||||
import { isHandToolActive } from "../appState";
|
import { isHandToolActive } from "../appState";
|
||||||
import { useTunnels } from "../context/tunnels";
|
import { useTunnels } from "../context/tunnels";
|
||||||
@@ -155,6 +157,17 @@ export const MobileMenu = ({
|
|||||||
<>
|
<>
|
||||||
{renderSidebars()}
|
{renderSidebars()}
|
||||||
{!appState.viewModeEnabled && renderToolbar()}
|
{!appState.viewModeEnabled && renderToolbar()}
|
||||||
|
{!appState.openMenu && appState.showStats && (
|
||||||
|
<Stats
|
||||||
|
appState={appState}
|
||||||
|
setAppState={setAppState}
|
||||||
|
elements={elements}
|
||||||
|
onClose={() => {
|
||||||
|
actionManager.executeAction(actionToggleStats);
|
||||||
|
}}
|
||||||
|
renderCustomStats={renderCustomStats}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="App-bottom-bar"
|
className="App-bottom-bar"
|
||||||
style={{
|
style={{
|
||||||
@@ -181,7 +194,6 @@ export const MobileMenu = ({
|
|||||||
!appState.openMenu &&
|
!appState.openMenu &&
|
||||||
!appState.openSidebar && (
|
!appState.openSidebar && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
className="scroll-back-to-content"
|
className="scroll-back-to-content"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAppState((appState) => ({
|
setAppState((appState) => ({
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ const ChartPreviewBtn = (props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
className="ChartPreview"
|
className="ChartPreview"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (chartElements) {
|
if (chartElements) {
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import React, { type ReactNode } from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
|
||||||
|
|
||||||
import { useDevice } from "./App";
|
|
||||||
import { Island } from "./Island";
|
|
||||||
import { isInteractive } from "../utils";
|
|
||||||
|
|
||||||
interface PropertiesPopoverProps {
|
|
||||||
className?: string;
|
|
||||||
container: HTMLDivElement | null;
|
|
||||||
children: ReactNode;
|
|
||||||
style?: object;
|
|
||||||
onClose: () => void;
|
|
||||||
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
|
|
||||||
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
|
|
||||||
onFocusOutside?: Popover.DismissableLayerProps["onFocusOutside"];
|
|
||||||
onPointerDownOutside?: Popover.DismissableLayerProps["onPointerDownOutside"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PropertiesPopover = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
PropertiesPopoverProps
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
className,
|
|
||||||
container,
|
|
||||||
children,
|
|
||||||
style,
|
|
||||||
onClose,
|
|
||||||
onKeyDown,
|
|
||||||
onFocusOutside,
|
|
||||||
onPointerLeave,
|
|
||||||
onPointerDownOutside,
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const device = useDevice();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover.Portal container={container}>
|
|
||||||
<Popover.Content
|
|
||||||
ref={ref}
|
|
||||||
className={clsx("focus-visible-none", className)}
|
|
||||||
data-prevent-outside-click
|
|
||||||
side={
|
|
||||||
device.editor.isMobile && !device.viewport.isLandscape
|
|
||||||
? "bottom"
|
|
||||||
: "right"
|
|
||||||
}
|
|
||||||
align={
|
|
||||||
device.editor.isMobile && !device.viewport.isLandscape
|
|
||||||
? "center"
|
|
||||||
: "start"
|
|
||||||
}
|
|
||||||
alignOffset={-16}
|
|
||||||
sideOffset={20}
|
|
||||||
style={{
|
|
||||||
zIndex: "var(--zIndex-popup)",
|
|
||||||
}}
|
|
||||||
onPointerLeave={onPointerLeave}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
onFocusOutside={onFocusOutside}
|
|
||||||
onPointerDownOutside={onPointerDownOutside}
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Island padding={3} style={style}>
|
|
||||||
{children}
|
|
||||||
</Island>
|
|
||||||
<Popover.Arrow
|
|
||||||
width={20}
|
|
||||||
height={10}
|
|
||||||
style={{
|
|
||||||
fill: "var(--popup-bg-color)",
|
|
||||||
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Portal>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
.required,
|
.required,
|
||||||
.error {
|
.error {
|
||||||
color: $oc-red-8;
|
color: $oc-red-8;
|
||||||
font-weight: 700;
|
font-weight: bold;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
margin: 0.2rem;
|
margin: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { t } from "../i18n";
|
|||||||
import Trans from "./Trans";
|
import Trans from "./Trans";
|
||||||
|
|
||||||
import type { LibraryItems, LibraryItem, UIAppState } from "../types";
|
import type { LibraryItems, LibraryItem, UIAppState } from "../types";
|
||||||
import { exportToCanvas, exportToSvg } from "../../utils/export";
|
import { exportToCanvas, exportToSvg } from "@excalidraw/utils";
|
||||||
import {
|
import {
|
||||||
EDITOR_LS_KEYS,
|
EDITOR_LS_KEYS,
|
||||||
EXPORT_DATA_TYPES,
|
EXPORT_DATA_TYPES,
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
.excalidraw {
|
|
||||||
--list-border-color: var(--color-gray-20);
|
|
||||||
|
|
||||||
.QuickSearch__wrapper {
|
|
||||||
position: relative;
|
|
||||||
height: 2.6rem; // added +0.1 due to Safari
|
|
||||||
border-bottom: 1px solid var(--list-border-color);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
position: absolute;
|
|
||||||
top: 47.5%; // 50% is not exactly in the center of the input
|
|
||||||
transform: translateY(-50%);
|
|
||||||
left: 0.75rem;
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
color: var(--color-gray-40);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.theme--dark {
|
|
||||||
--list-border-color: var(--color-gray-80);
|
|
||||||
|
|
||||||
.QuickSearch__wrapper {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.QuickSearch__input {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 0 !important;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
padding-left: 2.5rem !important;
|
|
||||||
padding-right: 0.75rem !important;
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: var(--color-gray-40);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import clsx from "clsx";
|
|
||||||
import React from "react";
|
|
||||||
import { searchIcon } from "./icons";
|
|
||||||
|
|
||||||
import "./QuickSearch.scss";
|
|
||||||
|
|
||||||
interface QuickSearchProps {
|
|
||||||
className?: string;
|
|
||||||
placeholder: string;
|
|
||||||
onChange: (term: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const QuickSearch = React.forwardRef<HTMLInputElement, QuickSearchProps>(
|
|
||||||
({ className, placeholder, onChange }, ref) => {
|
|
||||||
return (
|
|
||||||
<div className={clsx("QuickSearch__wrapper", className)}>
|
|
||||||
{searchIcon}
|
|
||||||
<input
|
|
||||||
ref={ref}
|
|
||||||
className="QuickSearch__input"
|
|
||||||
type="text"
|
|
||||||
placeholder={placeholder}
|
|
||||||
onChange={(e) => onChange(e.target.value.trim().toLowerCase())}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
.excalidraw {
|
|
||||||
.ScrollableList__wrapper {
|
|
||||||
position: static !important;
|
|
||||||
border: none;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
& > .empty,
|
|
||||||
& > .hint {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-gray-60);
|
|
||||||
overflow: hidden;
|
|
||||||
text-align: center;
|
|
||||||
line-height: 150%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import clsx from "clsx";
|
|
||||||
import { Children } from "react";
|
|
||||||
|
|
||||||
import "./ScrollableList.scss";
|
|
||||||
|
|
||||||
interface ScrollableListProps {
|
|
||||||
className?: string;
|
|
||||||
placeholder: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ScrollableList = ({
|
|
||||||
className,
|
|
||||||
placeholder,
|
|
||||||
children,
|
|
||||||
}: ScrollableListProps) => {
|
|
||||||
const isEmpty = !Children.count(children);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={clsx("ScrollableList__wrapper", className)} role="menu">
|
|
||||||
{isEmpty ? <div className="empty">{placeholder}</div> : children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,8 +2,8 @@ import React from "react";
|
|||||||
import { DEFAULT_SIDEBAR } from "../../constants";
|
import { DEFAULT_SIDEBAR } from "../../constants";
|
||||||
import { Excalidraw, Sidebar } from "../../index";
|
import { Excalidraw, Sidebar } from "../../index";
|
||||||
import {
|
import {
|
||||||
act,
|
|
||||||
fireEvent,
|
fireEvent,
|
||||||
|
GlobalTestState,
|
||||||
queryAllByTestId,
|
queryAllByTestId,
|
||||||
queryByTestId,
|
queryByTestId,
|
||||||
render,
|
render,
|
||||||
@@ -11,17 +11,39 @@ import {
|
|||||||
withExcalidrawDimensions,
|
withExcalidrawDimensions,
|
||||||
} from "../../tests/test-utils";
|
} from "../../tests/test-utils";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import {
|
|
||||||
assertExcalidrawWithSidebar,
|
|
||||||
assertSidebarDockButton,
|
|
||||||
} from "./siderbar.test.helpers";
|
|
||||||
|
|
||||||
const toggleSidebar = (
|
export const assertSidebarDockButton = async <T extends boolean>(
|
||||||
...args: Parameters<typeof window.h.app.toggleSidebar>
|
hasDockButton: T,
|
||||||
): Promise<boolean> => {
|
): Promise<
|
||||||
return act(() => {
|
T extends false
|
||||||
return window.h.app.toggleSidebar(...args);
|
? { dockButton: null; sidebar: HTMLElement }
|
||||||
});
|
: { dockButton: HTMLElement; sidebar: HTMLElement }
|
||||||
|
> => {
|
||||||
|
const sidebar =
|
||||||
|
GlobalTestState.renderResult.container.querySelector<HTMLElement>(
|
||||||
|
".sidebar",
|
||||||
|
);
|
||||||
|
expect(sidebar).not.toBe(null);
|
||||||
|
const dockButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||||
|
if (hasDockButton) {
|
||||||
|
expect(dockButton).not.toBe(null);
|
||||||
|
return { dockButton: dockButton!, sidebar: sidebar! } as any;
|
||||||
|
}
|
||||||
|
expect(dockButton).toBe(null);
|
||||||
|
return { dockButton: null, sidebar: sidebar! } as any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assertExcalidrawWithSidebar = async (
|
||||||
|
sidebar: React.ReactNode,
|
||||||
|
name: string,
|
||||||
|
test: () => void,
|
||||||
|
) => {
|
||||||
|
await render(
|
||||||
|
<Excalidraw initialData={{ appState: { openSidebar: { name } } }}>
|
||||||
|
{sidebar}
|
||||||
|
</Excalidraw>,
|
||||||
|
);
|
||||||
|
await withExcalidrawDimensions({ width: 1920, height: 1080 }, test);
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Sidebar", () => {
|
describe("Sidebar", () => {
|
||||||
@@ -81,7 +103,7 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
// toggle sidebar on
|
// toggle sidebar on
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
expect(await toggleSidebar({ name: "customSidebar" })).toBe(true);
|
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
@@ -90,7 +112,7 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
// toggle sidebar off
|
// toggle sidebar off
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
expect(await toggleSidebar({ name: "customSidebar" })).toBe(false);
|
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(false);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
@@ -99,9 +121,9 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
// force-toggle sidebar off (=> still hidden)
|
// force-toggle sidebar off (=> still hidden)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
expect(await toggleSidebar({ name: "customSidebar", force: false })).toBe(
|
expect(
|
||||||
false,
|
window.h.app.toggleSidebar({ name: "customSidebar", force: false }),
|
||||||
);
|
).toBe(false);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
@@ -110,12 +132,12 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
// force-toggle sidebar on
|
// force-toggle sidebar on
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
expect(await toggleSidebar({ name: "customSidebar", force: true })).toBe(
|
expect(
|
||||||
true,
|
window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
|
||||||
);
|
).toBe(true);
|
||||||
expect(await toggleSidebar({ name: "customSidebar", force: true })).toBe(
|
expect(
|
||||||
true,
|
window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
|
||||||
);
|
).toBe(true);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
@@ -124,7 +146,9 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
// toggle library (= hide custom sidebar)
|
// toggle library (= hide custom sidebar)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
expect(await toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(true);
|
expect(window.h.app.toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
@@ -137,13 +161,13 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
// closing sidebar using `{ name: null }`
|
// closing sidebar using `{ name: null }`
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
expect(await toggleSidebar({ name: "customSidebar" })).toBe(true);
|
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
expect(node).not.toBe(null);
|
expect(node).not.toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await toggleSidebar({ name: null })).toBe(false);
|
expect(window.h.app.toggleSidebar({ name: null })).toBe(false);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
expect(node).toBe(null);
|
expect(node).toBe(null);
|
||||||
@@ -297,9 +321,6 @@ describe("Sidebar", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shouldn't be user-dockable when only `onDock` supplied w/o `docked`", async () => {
|
it("shouldn't be user-dockable when only `onDock` supplied w/o `docked`", async () => {
|
||||||
// we expect warnings in this test and don't want to pollute stdout
|
|
||||||
const mock = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
|
|
||||||
await render(
|
await render(
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
|
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
|
||||||
@@ -320,8 +341,6 @@ describe("Sidebar", () => {
|
|||||||
await assertSidebarDockButton(false);
|
await assertSidebarDockButton(false);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
mock.mockRestore();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -348,9 +367,9 @@ describe("Sidebar", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
|
||||||
// open library sidebar
|
// open library sidebar
|
||||||
expect(await toggleSidebar({ name: "custom", tab: "library" })).toBe(
|
expect(
|
||||||
true,
|
window.h.app.toggleSidebar({ name: "custom", tab: "library" }),
|
||||||
);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
container.querySelector<HTMLElement>(
|
container.querySelector<HTMLElement>(
|
||||||
"[role=tabpanel][data-testid=library]",
|
"[role=tabpanel][data-testid=library]",
|
||||||
@@ -358,9 +377,9 @@ describe("Sidebar", () => {
|
|||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
|
|
||||||
// switch to comments tab
|
// switch to comments tab
|
||||||
expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe(
|
expect(
|
||||||
true,
|
window.h.app.toggleSidebar({ name: "custom", tab: "comments" }),
|
||||||
);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
container.querySelector<HTMLElement>(
|
container.querySelector<HTMLElement>(
|
||||||
"[role=tabpanel][data-testid=comments]",
|
"[role=tabpanel][data-testid=comments]",
|
||||||
@@ -368,9 +387,9 @@ describe("Sidebar", () => {
|
|||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
|
|
||||||
// toggle sidebar closed
|
// toggle sidebar closed
|
||||||
expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe(
|
expect(
|
||||||
false,
|
window.h.app.toggleSidebar({ name: "custom", tab: "comments" }),
|
||||||
);
|
).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
container.querySelector<HTMLElement>(
|
container.querySelector<HTMLElement>(
|
||||||
"[role=tabpanel][data-testid=comments]",
|
"[role=tabpanel][data-testid=comments]",
|
||||||
@@ -378,9 +397,9 @@ describe("Sidebar", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
|
||||||
// toggle sidebar open
|
// toggle sidebar open
|
||||||
expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe(
|
expect(
|
||||||
true,
|
window.h.app.toggleSidebar({ name: "custom", tab: "comments" }),
|
||||||
);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
container.querySelector<HTMLElement>(
|
container.querySelector<HTMLElement>(
|
||||||
"[role=tabpanel][data-testid=comments]",
|
"[role=tabpanel][data-testid=comments]",
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Excalidraw } from "../..";
|
|
||||||
import {
|
|
||||||
GlobalTestState,
|
|
||||||
queryByTestId,
|
|
||||||
render,
|
|
||||||
withExcalidrawDimensions,
|
|
||||||
} from "../../tests/test-utils";
|
|
||||||
|
|
||||||
export const assertSidebarDockButton = async <T extends boolean>(
|
|
||||||
hasDockButton: T,
|
|
||||||
): Promise<
|
|
||||||
T extends false
|
|
||||||
? { dockButton: null; sidebar: HTMLElement }
|
|
||||||
: { dockButton: HTMLElement; sidebar: HTMLElement }
|
|
||||||
> => {
|
|
||||||
const sidebar =
|
|
||||||
GlobalTestState.renderResult.container.querySelector<HTMLElement>(
|
|
||||||
".sidebar",
|
|
||||||
);
|
|
||||||
expect(sidebar).not.toBe(null);
|
|
||||||
const dockButton = queryByTestId(sidebar!, "sidebar-dock");
|
|
||||||
if (hasDockButton) {
|
|
||||||
expect(dockButton).not.toBe(null);
|
|
||||||
return { dockButton: dockButton!, sidebar: sidebar! } as any;
|
|
||||||
}
|
|
||||||
expect(dockButton).toBe(null);
|
|
||||||
return { dockButton: null, sidebar: sidebar! } as any;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const assertExcalidrawWithSidebar = async (
|
|
||||||
sidebar: React.ReactNode,
|
|
||||||
name: string,
|
|
||||||
test: () => void,
|
|
||||||
) => {
|
|
||||||
await render(
|
|
||||||
<Excalidraw initialData={{ appState: { openSidebar: { name } } }}>
|
|
||||||
{sidebar}
|
|
||||||
</Excalidraw>,
|
|
||||||
);
|
|
||||||
await withExcalidrawDimensions({ width: 1920, height: 1080 }, test);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
@import "../css/variables.module.scss";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.Stats {
|
||||||
|
position: absolute;
|
||||||
|
top: 64px;
|
||||||
|
right: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: var(--ui-pointerEvents);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 24px 8px 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
float: right;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
th {
|
||||||
|
border-bottom: 1px solid var(--input-border-color);
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td:nth-child(2) {
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: 12px;
|
||||||
|
right: initial;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 8px 24px;
|
||||||
|
}
|
||||||
|
.close {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { getCommonBounds } from "../element/bounds";
|
||||||
|
import type { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { getTargetElements } from "../scene";
|
||||||
|
import type { ExcalidrawProps, UIAppState } from "../types";
|
||||||
|
import { CloseIcon } from "./icons";
|
||||||
|
import { Island } from "./Island";
|
||||||
|
import "./Stats.scss";
|
||||||
|
|
||||||
|
export const Stats = (props: {
|
||||||
|
appState: UIAppState;
|
||||||
|
setAppState: React.Component<any, UIAppState>["setState"];
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
onClose: () => void;
|
||||||
|
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||||
|
}) => {
|
||||||
|
const boundingBox = getCommonBounds(props.elements);
|
||||||
|
const selectedElements = getTargetElements(props.elements, props.appState);
|
||||||
|
const selectedBoundingBox = getCommonBounds(selectedElements);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="Stats">
|
||||||
|
<Island padding={2}>
|
||||||
|
<div className="close" onClick={props.onClose}>
|
||||||
|
{CloseIcon}
|
||||||
|
</div>
|
||||||
|
<h3>{t("stats.title")}</h3>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th colSpan={2}>{t("stats.scene")}</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{t("stats.elements")}</td>
|
||||||
|
<td>{props.elements.length}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{t("stats.width")}</td>
|
||||||
|
<td>{Math.round(boundingBox[2]) - Math.round(boundingBox[0])}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{t("stats.height")}</td>
|
||||||
|
<td>{Math.round(boundingBox[3]) - Math.round(boundingBox[1])}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{selectedElements.length === 1 && (
|
||||||
|
<tr>
|
||||||
|
<th colSpan={2}>{t("stats.element")}</th>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedElements.length > 1 && (
|
||||||
|
<>
|
||||||
|
<tr>
|
||||||
|
<th colSpan={2}>{t("stats.selected")}</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{t("stats.elements")}</td>
|
||||||
|
<td>{selectedElements.length}</td>
|
||||||
|
</tr>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selectedElements.length > 0 && (
|
||||||
|
<>
|
||||||
|
<tr>
|
||||||
|
<td>{"x"}</td>
|
||||||
|
<td>{Math.round(selectedBoundingBox[0])}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{"y"}</td>
|
||||||
|
<td>{Math.round(selectedBoundingBox[1])}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{t("stats.width")}</td>
|
||||||
|
<td>
|
||||||
|
{Math.round(
|
||||||
|
selectedBoundingBox[2] - selectedBoundingBox[0],
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{t("stats.height")}</td>
|
||||||
|
<td>
|
||||||
|
{Math.round(
|
||||||
|
selectedBoundingBox[3] - selectedBoundingBox[1],
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selectedElements.length === 1 && (
|
||||||
|
<tr>
|
||||||
|
<td>{t("stats.angle")}</td>
|
||||||
|
<td>
|
||||||
|
{`${Math.round(
|
||||||
|
(selectedElements[0].angle * 180) / Math.PI,
|
||||||
|
)}°`}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{props.renderCustomStats?.(props.elements, props.appState)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Island>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { mutateElement } from "../../element/mutateElement";
|
|
||||||
import { getBoundTextElement } from "../../element/textElement";
|
|
||||||
import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
|
|
||||||
import type { ExcalidrawElement } from "../../element/types";
|
|
||||||
import { degreeToRadian, radianToDegree } from "../../math";
|
|
||||||
import { angleIcon } from "../icons";
|
|
||||||
import DragInput from "./DragInput";
|
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
|
||||||
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
|
|
||||||
import type Scene from "../../scene/Scene";
|
|
||||||
import type { AppState } from "../../types";
|
|
||||||
|
|
||||||
interface AngleProps {
|
|
||||||
element: ExcalidrawElement;
|
|
||||||
scene: Scene;
|
|
||||||
appState: AppState;
|
|
||||||
property: "angle";
|
|
||||||
}
|
|
||||||
|
|
||||||
const STEP_SIZE = 15;
|
|
||||||
|
|
||||||
const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
|
|
||||||
accumulatedChange,
|
|
||||||
originalElements,
|
|
||||||
shouldChangeByStepSize,
|
|
||||||
nextValue,
|
|
||||||
scene,
|
|
||||||
}) => {
|
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
|
||||||
const elements = scene.getNonDeletedElements();
|
|
||||||
const origElement = originalElements[0];
|
|
||||||
if (origElement && !isElbowArrow(origElement)) {
|
|
||||||
const latestElement = elementsMap.get(origElement.id);
|
|
||||||
if (!latestElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextValue !== undefined) {
|
|
||||||
const nextAngle = degreeToRadian(nextValue);
|
|
||||||
mutateElement(latestElement, {
|
|
||||||
angle: nextAngle,
|
|
||||||
});
|
|
||||||
updateBindings(latestElement, elementsMap, elements, scene);
|
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
|
||||||
if (boundTextElement && !isArrowElement(latestElement)) {
|
|
||||||
mutateElement(boundTextElement, { angle: nextAngle });
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalAngleInDegrees =
|
|
||||||
Math.round(radianToDegree(origElement.angle) * 100) / 100;
|
|
||||||
const changeInDegrees = Math.round(accumulatedChange);
|
|
||||||
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
|
||||||
if (shouldChangeByStepSize) {
|
|
||||||
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
|
|
||||||
}
|
|
||||||
|
|
||||||
nextAngleInDegrees =
|
|
||||||
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
|
||||||
|
|
||||||
const nextAngle = degreeToRadian(nextAngleInDegrees);
|
|
||||||
|
|
||||||
mutateElement(latestElement, {
|
|
||||||
angle: nextAngle,
|
|
||||||
});
|
|
||||||
updateBindings(latestElement, elementsMap, elements, scene);
|
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
|
||||||
if (boundTextElement && !isArrowElement(latestElement)) {
|
|
||||||
mutateElement(boundTextElement, { angle: nextAngle });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const Angle = ({ element, scene, appState, property }: AngleProps) => {
|
|
||||||
return (
|
|
||||||
<DragInput
|
|
||||||
label="A"
|
|
||||||
icon={angleIcon}
|
|
||||||
value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100}
|
|
||||||
elements={[element]}
|
|
||||||
dragInputCallback={handleDegreeChange}
|
|
||||||
editable={isPropertyEditable(element, "angle")}
|
|
||||||
scene={scene}
|
|
||||||
appState={appState}
|
|
||||||
property={property}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Angle;
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { InlineIcon } from "../InlineIcon";
|
|
||||||
import { collapseDownIcon, collapseUpIcon } from "../icons";
|
|
||||||
|
|
||||||
interface CollapsibleProps {
|
|
||||||
label: React.ReactNode;
|
|
||||||
// having it controlled so that the state is managed outside
|
|
||||||
// this is to keep the user's previous choice even when the
|
|
||||||
// Collapsible is unmounted
|
|
||||||
open: boolean;
|
|
||||||
openTrigger: () => void;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Collapsible = ({
|
|
||||||
label,
|
|
||||||
open,
|
|
||||||
openTrigger,
|
|
||||||
children,
|
|
||||||
}: CollapsibleProps) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
onClick={openTrigger}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
<InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
|
|
||||||
</div>
|
|
||||||
{open && <>{children}</>}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Collapsible;
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import type { ExcalidrawElement } from "../../element/types";
|
|
||||||
import DragInput from "./DragInput";
|
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
|
||||||
import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
|
|
||||||
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
|
||||||
import type Scene from "../../scene/Scene";
|
|
||||||
import type { AppState } from "../../types";
|
|
||||||
|
|
||||||
interface DimensionDragInputProps {
|
|
||||||
property: "width" | "height";
|
|
||||||
element: ExcalidrawElement;
|
|
||||||
scene: Scene;
|
|
||||||
appState: AppState;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STEP_SIZE = 10;
|
|
||||||
const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
|
|
||||||
return element.type === "image";
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDimensionChange: DragInputCallbackType<
|
|
||||||
DimensionDragInputProps["property"]
|
|
||||||
> = ({
|
|
||||||
accumulatedChange,
|
|
||||||
originalElements,
|
|
||||||
originalElementsMap,
|
|
||||||
shouldKeepAspectRatio,
|
|
||||||
shouldChangeByStepSize,
|
|
||||||
nextValue,
|
|
||||||
property,
|
|
||||||
scene,
|
|
||||||
}) => {
|
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
|
||||||
const elements = scene.getNonDeletedElements();
|
|
||||||
const origElement = originalElements[0];
|
|
||||||
if (origElement) {
|
|
||||||
const keepAspectRatio =
|
|
||||||
shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
|
|
||||||
const aspectRatio = origElement.width / origElement.height;
|
|
||||||
|
|
||||||
if (nextValue !== undefined) {
|
|
||||||
const nextWidth = Math.max(
|
|
||||||
property === "width"
|
|
||||||
? nextValue
|
|
||||||
: keepAspectRatio
|
|
||||||
? nextValue * aspectRatio
|
|
||||||
: origElement.width,
|
|
||||||
MIN_WIDTH_OR_HEIGHT,
|
|
||||||
);
|
|
||||||
const nextHeight = Math.max(
|
|
||||||
property === "height"
|
|
||||||
? nextValue
|
|
||||||
: keepAspectRatio
|
|
||||||
? nextValue / aspectRatio
|
|
||||||
: origElement.height,
|
|
||||||
MIN_WIDTH_OR_HEIGHT,
|
|
||||||
);
|
|
||||||
|
|
||||||
resizeElement(
|
|
||||||
nextWidth,
|
|
||||||
nextHeight,
|
|
||||||
keepAspectRatio,
|
|
||||||
origElement,
|
|
||||||
elementsMap,
|
|
||||||
elements,
|
|
||||||
scene,
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
|
||||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
|
||||||
|
|
||||||
let nextWidth = Math.max(0, origElement.width + changeInWidth);
|
|
||||||
if (property === "width") {
|
|
||||||
if (shouldChangeByStepSize) {
|
|
||||||
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
|
||||||
} else {
|
|
||||||
nextWidth = Math.round(nextWidth);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextHeight = Math.max(0, origElement.height + changeInHeight);
|
|
||||||
if (property === "height") {
|
|
||||||
if (shouldChangeByStepSize) {
|
|
||||||
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
|
||||||
} else {
|
|
||||||
nextHeight = Math.round(nextHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keepAspectRatio) {
|
|
||||||
if (property === "width") {
|
|
||||||
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
|
|
||||||
} else {
|
|
||||||
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
|
||||||
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
|
||||||
|
|
||||||
resizeElement(
|
|
||||||
nextWidth,
|
|
||||||
nextHeight,
|
|
||||||
keepAspectRatio,
|
|
||||||
origElement,
|
|
||||||
elementsMap,
|
|
||||||
elements,
|
|
||||||
scene,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const DimensionDragInput = ({
|
|
||||||
property,
|
|
||||||
element,
|
|
||||||
scene,
|
|
||||||
appState,
|
|
||||||
}: DimensionDragInputProps) => {
|
|
||||||
const value =
|
|
||||||
Math.round((property === "width" ? element.width : element.height) * 100) /
|
|
||||||
100;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DragInput
|
|
||||||
label={property === "width" ? "W" : "H"}
|
|
||||||
elements={[element]}
|
|
||||||
dragInputCallback={handleDimensionChange}
|
|
||||||
value={value}
|
|
||||||
editable={isPropertyEditable(element, property)}
|
|
||||||
scene={scene}
|
|
||||||
appState={appState}
|
|
||||||
property={property}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DimensionDragInput;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user