Compare commits

...

29 Commits

Author SHA1 Message Date
Marcel Mraz 9c91cf93dd Glyph subsetting for SVG export 2024-08-08 18:31:50 +02:00
Márk Tolmács dd1370381d chore: Refactor and remove scene from elbow arrow generation (#8342)
* Refactor and remove scene from elbow arrow generation
2024-08-08 14:06:26 +02:00
Márk Tolmács 72d6ee48fc fix: Do not allow resizing unbound elbow arrows either (#8333)
* Fix resizing of unbound elbow arrows
2024-08-06 17:33:34 +02:00
David Luzar 232242d2e9 test: skip test.yml in PRs (#8330) 2024-08-06 16:04:04 +02:00
David Luzar f19ce30dfe chore: bump @testing-library/react 12.1.5 -> 16.0.0 (#8322) 2024-08-06 15:17:42 +02:00
Ryan Di 3cf14c73a3 refactor: rename draggingElement -> newElement (#8294)
* add newElement to appState

* freedraw should not be an editing element

* do not set editing element for freedraw and generic

* remove ununsed `appState.draggingElement`

* remove setting dragged for new linear element

* decouple selection element from new element

* fix hint for text bindables

* update snapshot

* fixes

* fix frame regressions

* add comments to types

* document `editingElement`

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-06 19:26:06 +08:00
Márk Tolmács 8d530cf102 fix: Docker build in CI (#8312)
* Fix Docker build CI
* Bump nginx-alpine version to 1.27
2024-08-06 13:21:20 +02:00
David Luzar b87925d253 build: add example apps public and vite dev-dist to eslintignore (#8326) 2024-08-05 23:43:48 +02:00
b1sar a6684b09f2 docs: fix typos in props.mdx (#8325) 2024-08-05 21:41:19 +00:00
David Luzar b427617f66 build: add rm:build, rm:node_modules & clean-install scripts (#8323) 2024-08-05 23:37:33 +02:00
dependabot[bot] 2907fbe34b build(deps): bump @excalidraw/excalidraw from 0.17.0 to 0.17.6 in /dev-docs (#7908)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 09:49:57 +02:00
zsviczian c67815f7b0 fix: Duplicating arrow without bound elements throws error (#8316)
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2024-08-03 16:37:36 +00:00
Márk Tolmács c641860cb1 fix: CVE-2023-45133 (#7988)
* Upgrade @babel/* versions to 7.24 to ensure non-vulnerable Babel versions
* Pinning React version to 18.2.0 exactly, avoiding test-utils type version clashes
* Fix warning message on yarn start
* Moving react to peer dependencies
* Moving app dependencies from workspace into app
* Bump vitest to 1.6.0 to fix history.test.tsx breaking

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2024-08-02 14:44:38 +02:00
Marcel Mraz 84d89b9a8a fix: throttle fractional indices validation (#8306) 2024-08-02 11:55:15 +02:00
David Luzar e63dd025c9 fix: allow binding elbow arrows to frame children (#8309) 2024-08-01 19:40:54 +02:00
Márk Tolmács 15e019706d feat: Orthogonal (elbow) arrows for diagramming (#8299)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-01 18:39:03 +02:00
Aakansha Doshi a133a70e87 build: update release script to build esm (#8308) 2024-08-01 20:19:37 +05:30
Marcel Mraz 80ea7ca23f fix: skip registering font faces for local fonts (#8303) 2024-08-01 11:32:16 +02:00
David Luzar e844580b14 feat: remove automatic frame naming (#8302) 2024-07-31 13:56:11 +02:00
Marcel Mraz 5a0771ad9c fix: load fonts for exportToCanvas (#8298) 2024-07-30 17:23:35 +02:00
Marcel Mraz adcdbe2907 fix: re-add Cascadia Code with ligatures (#8291) 2024-07-30 11:15:11 +02:00
Marcel Mraz 230d0edc44 feat: multiple fonts fallbacks (#8286) 2024-07-30 10:34:40 +02:00
Marcel Mraz d0a380758e feat: ability to debug the state of fractional indices (#8235) 2024-07-30 10:03:27 +02:00
Ryan Di 7b36de0476 fix: linear elements not selected on pointer up from hitting its bound text (#8285)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-07-27 13:02:00 +00:00
David Luzar 2427e622b0 feat: improve mermaid detection on paste (#8287) 2024-07-27 12:36:54 +02:00
Marcel Mraz 62228e0bbb feat: introduce font picker (#8012)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-07-25 18:55:55 +02:00
DDDDD12138 4c5408263c chore: Correct Typos in Code Comments (#8268)
chore: correct typos

Co-authored-by: wuzhiqing <wuzhiqing@linklogis.com>
2024-07-23 14:26:55 +05:30
Aakansha Doshi bd7b778f41 perf: cache the temp canvas created for labeled arrows (#8267)
* perf: cache the temp canvas created for labeled arrows

* use allEleemntsMap so bound text element can be retrieved when editing

* remove logs

* fix rotation

* pass isRotating

* feat: cache `element.angle` instead of relying on `appState.isRotating`

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-07-23 11:17:32 +05:30
David Luzar 43b2476dfe fix: revert default element canvas padding change (#8266) 2024-07-22 11:47:16 +02:00
229 changed files with 13442 additions and 6774 deletions
+1
View File
@@ -8,6 +8,7 @@
!package.json !package.json
!public/ !public/
!packages/ !packages/
!scripts/
!tsconfig.json !tsconfig.json
!yarn.lock !yarn.lock
+2
View File
@@ -6,3 +6,5 @@ firebase/
dist/ dist/
public/workbox public/workbox
packages/excalidraw/types packages/excalidraw/types
examples/**/public
dev-dist
-1
View File
@@ -1,7 +1,6 @@
name: Tests name: Tests
on: on:
pull_request:
push: push:
branches: master branches: master
+1 -1
View File
@@ -12,7 +12,7 @@ ARG NODE_ENV=production
RUN yarn build:app:docker RUN yarn build:app:docker
FROM nginx:1.24-alpine FROM nginx:1.27-alpine
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
@@ -9,9 +9,9 @@ All `props` are _optional_.
| [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode | | [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode |
| [`onChange`](#onchange) | `function` | _ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. | | [`onChange`](#onchange) | `function` | _ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. |
| [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. | | [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. |
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down evenets | | [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down events |
| [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. | | [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. |
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when the something is pasted in to the scene | | [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when something is pasted into the scene |
| [`onLibraryChange`](#onlibrarychange) | `function` | _ | The callback if supplied is triggered when the library is updated and receives the library items. | | [`onLibraryChange`](#onlibrarychange) | `function` | _ | The callback if supplied is triggered when the library is updated and receives the library items. |
| [`onLinkOpen`](#onlinkopen) | `function` | _ | The callback if supplied is triggered when any link is opened. | | [`onLinkOpen`](#onlinkopen) | `function` | _ | The callback if supplied is triggered when any link is opened. |
| [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw | | [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw |
@@ -26,7 +26,7 @@ All `props` are _optional_.
| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) | | [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) |
| [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. | | [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. |
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. | | [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
| [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load | | [`autoFocus`](#autofocus) | `boolean` | `false` | Indicates whether to focus the Excalidraw component on page load |
| [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas | | [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation | | [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` | | [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
+3 -3
View File
@@ -18,13 +18,13 @@
"@docusaurus/core": "2.2.0", "@docusaurus/core": "2.2.0",
"@docusaurus/preset-classic": "2.2.0", "@docusaurus/preset-classic": "2.2.0",
"@docusaurus/theme-live-codeblock": "2.2.0", "@docusaurus/theme-live-codeblock": "2.2.0",
"@excalidraw/excalidraw": "0.17.0", "@excalidraw/excalidraw": "0.17.6",
"@mdx-js/react": "^1.6.22", "@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"docusaurus-plugin-sass": "0.2.3", "docusaurus-plugin-sass": "0.2.3",
"prism-react-renderer": "^1.3.5", "prism-react-renderer": "^1.3.5",
"react": "^17.0.2", "react": "18.2.0",
"react-dom": "^17.0.2", "react-dom": "18.2.0",
"sass": "1.57.1" "sass": "1.57.1"
}, },
"devDependencies": { "devDependencies": {
+1 -1
View File
@@ -59,7 +59,7 @@ pre a {
padding: 5px; padding: 5px;
background: #70b1ec; background: #70b1ec;
color: white; color: white;
font-weight: bold; font-weight: 700;
border: none; border: none;
} }
+4 -4
View File
@@ -1718,10 +1718,10 @@
url-loader "^4.1.1" url-loader "^4.1.1"
webpack "^5.73.0" webpack "^5.73.0"
"@excalidraw/excalidraw@0.17.0": "@excalidraw/excalidraw@0.17.6":
version "0.17.0" version "0.17.6"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.0.tgz#3c64aa8e36406ac171b008cfecbdce5bb0755725" resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.6.tgz#5fd208ce69d33ca712d1804b50d7d06d5c46ac4d"
integrity sha512-NzP22v5xMqxYW27ZtTHhiGFe7kE8NeBk45aoeM/mDSkXiOXPDH+PcvwzHRN/Ei+Vj/0sTPHxejn8bZyRWKGjXg== integrity sha512-fyCl+zG/Z5yhHDh5Fq2ZGmphcrALmuOdtITm8gN4d8w4ntnaopTXcTfnAAaU3VleDC6LhTkoLOTG6P5kgREiIg==
"@hapi/hoek@^9.0.0": "@hapi/hoek@^9.0.0":
version "9.3.0" version "9.3.0"
+2 -2
View File
@@ -872,7 +872,7 @@ export default function App({
files: excalidrawAPI.getFiles(), files: excalidrawAPI.getFiles(),
}); });
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.font = "30px Virgil"; ctx.font = "30px Excalifont";
ctx.strokeText("My custom text", 50, 60); ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL()); setCanvasUrl(canvas.toDataURL());
}} }}
@@ -893,7 +893,7 @@ export default function App({
files: excalidrawAPI.getFiles(), files: excalidrawAPI.getFiles(),
}); });
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.font = "30px Virgil"; ctx.font = "30px Excalifont";
ctx.strokeText("My custom text", 50, 60); ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL()); setCanvasUrl(canvas.toDataURL());
}} }}
+1 -1
View File
@@ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [
]; ];
export default { export default {
elements, elements,
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 }, appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 },
scrollToContent: true, scrollToContent: true,
libraryItems: [ libraryItems: [
[ [
@@ -34,3 +34,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# copied assets
public/*.woff2
+6 -5
View File
@@ -3,7 +3,8 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm", "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"dev": "yarn build:workspace && next dev -p 3005", "dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build", "build": "yarn build:workspace && next build",
"start": "next start -p 3006", "start": "next start -p 3006",
@@ -12,13 +13,13 @@
"dependencies": { "dependencies": {
"@excalidraw/excalidraw": "*", "@excalidraw/excalidraw": "*",
"next": "14.1", "next": "14.1",
"react": "^18", "react": "18.2.0",
"react-dom": "^18" "react-dom": "18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "18.2.0",
"@types/react-dom": "^18", "@types/react-dom": "18.2.0",
"path2d-polyfill": "2.0.1", "path2d-polyfill": "2.0.1",
"typescript": "^5" "typescript": "^5"
} }
@@ -1,4 +1,5 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Script from "next/script";
import "../common.scss"; import "../common.scss";
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically // Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
@@ -15,7 +16,9 @@ export default function Page() {
<> <>
<a href="/excalidraw-in-pages">Switch to Pages router</a> <a href="/excalidraw-in-pages">Switch to Pages router</a>
<h1 className="page-title">App Router</h1> <h1 className="page-title">App Router</h1>
<Script id="load-env-variables" strategy="beforeInteractive">
{`window["EXCALIDRAW_ASSET_PATH"] = window.origin;`}
</Script>
{/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */} {/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */}
<ExcalidrawWithClientOnly /> <ExcalidrawWithClientOnly />
</> </>
@@ -7,7 +7,7 @@ a {
color: #1c7ed6; color: #1c7ed6;
font-size: 20px; font-size: 20px;
text-decoration: none; text-decoration: none;
font-weight: 550; font-weight: 500;
} }
.page-title { .page-title {
@@ -0,0 +1,2 @@
# copied assets
public/*.woff2
@@ -11,6 +11,7 @@
<title>React App</title> <title>React App</title>
<script> <script>
window.name = "codesandbox"; window.name = "codesandbox";
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script> </script>
<link rel="stylesheet" href="/dist/browser/dev/index.css" /> <link rel="stylesheet" href="/dist/browser/dev/index.css" />
</head> </head>
@@ -12,8 +12,10 @@
"typescript": "^5" "typescript": "^5"
}, },
"scripts": { "scripts": {
"start": "yarn workspace @excalidraw/excalidraw run build:esm && vite", "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build", "copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"start": "yarn build:workspace && vite",
"build": "yarn build:workspace && vite build",
"build:preview": "yarn build && vite preview --port 5002" "build:preview": "yarn build && vite preview --port 5002"
} }
} }
@@ -17,7 +17,7 @@ export const getPreferredLanguage = () => {
const initialLanguage = const initialLanguage =
(detectedLanguage (detectedLanguage
? // region code may not be defined if user uses generic preferred language ? // region code may not be defined if user uses generic preferred language
// (e.g. chinese vs instead of chienese-simplified) // (e.g. chinese vs instead of chinese-simplified)
languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code
: null) || defaultLang.code; : null) || defaultLang.code;
+52 -17
View File
@@ -95,6 +95,11 @@
color: #fff; color: #fff;
} }
</style> </style>
<!-- Warmup the connection for Google fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-------------------------------------------------------------------------> <!------------------------------------------------------------------------->
<% if (typeof PROD != 'undefined' && PROD == true) { %> <% if (typeof PROD != 'undefined' && PROD == true) { %>
<script> <script>
@@ -115,8 +120,55 @@
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" />
@@ -124,22 +176,6 @@
<!-- Excalidraw version --> <!-- Excalidraw version -->
<meta name="version" content="{version}" /> <meta name="version" content="{version}" />
<link
rel="preload"
href="/Virgil.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="/Cascadia.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' && <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %> VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
<script> <script>
@@ -158,7 +194,6 @@
</script> </script>
<% } %> <% } %>
<script> <script>
window.EXCALIDRAW_ASSET_PATH = "/";
// setting this so that libraries installation reuses this window tab. // setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw"; window.name = "_excalidraw";
</script> </script>
+13 -2
View File
@@ -26,7 +26,17 @@
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"dependencies": { "dependencies": {
"vite-plugin-html": "3.2.2" "firebase": "8.3.3",
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"vite-plugin-html": "3.2.2",
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"i18next-browser-languagedetector": "6.1.4",
"socket.io-client": "4.7.2"
}, },
"prettier": "@excalidraw/prettier-config", "prettier": "@excalidraw/prettier-config",
"scripts": { "scripts": {
@@ -36,7 +46,8 @@
"build:version": "node ../scripts/build-version.js", "build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version", "build": "yarn build:app && yarn build:version",
"start": "yarn && vite", "start": "yarn && vite",
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o", "start:production": "yarn build && yarn serve",
"serve": "npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000" "build:preview": "yarn build && vite preview --port 5000"
} }
} }
@@ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
class="welcome-screen-center" class="welcome-screen-center"
> >
<div <div
class="welcome-screen-center__logo virgil welcome-screen-decor" class="welcome-screen-center__logo excalifont welcome-screen-decor"
> >
<div <div
class="ExcalidrawLogo is-small" class="ExcalidrawLogo is-small"
@@ -48,7 +48,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
</div> </div>
</div> </div>
<div <div
class="welcome-screen-center__heading welcome-screen-decor virgil" class="welcome-screen-center__heading welcome-screen-decor excalifont"
> >
All your data is saved locally in your browser. All your data is saved locally in your browser.
</div> </div>
+5 -6
View File
@@ -2,7 +2,6 @@ import { vi } from "vitest";
import { import {
act, act,
render, render,
updateSceneData,
waitFor, waitFor,
} from "../../packages/excalidraw/tests/test-utils"; } from "../../packages/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App"; import ExcalidrawApp from "../App";
@@ -88,12 +87,12 @@ describe("collaboration", () => {
const rect1 = API.createElement({ ...rect1Props }); const rect1 = API.createElement({ ...rect1Props });
const rect2 = API.createElement({ ...rect2Props }); const rect2 = API.createElement({ ...rect2Props });
updateSceneData({ API.updateScene({
elements: syncInvalidIndices([rect1, rect2]), elements: syncInvalidIndices([rect1, rect2]),
storeAction: StoreAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}); });
updateSceneData({ API.updateScene({
elements: syncInvalidIndices([ elements: syncInvalidIndices([
rect1, rect1,
newElementWith(h.elements[1], { isDeleted: true }), newElementWith(h.elements[1], { isDeleted: true }),
@@ -143,7 +142,7 @@ describe("collaboration", () => {
}); });
// simulate force deleting the element remotely // simulate force deleting the element remotely
updateSceneData({ API.updateScene({
elements: syncInvalidIndices([rect1]), elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE, storeAction: StoreAction.UPDATE,
}); });
@@ -178,7 +177,7 @@ describe("collaboration", () => {
act(() => h.app.actionManager.executeAction(undoAction)); act(() => h.app.actionManager.executeAction(undoAction));
// simulate local update // simulate local update
updateSceneData({ API.updateScene({
elements: syncInvalidIndices([ elements: syncInvalidIndices([
h.elements[0], h.elements[0],
newElementWith(h.elements[1], { x: 100 }), newElementWith(h.elements[1], { x: 100 }),
@@ -216,7 +215,7 @@ describe("collaboration", () => {
}); });
// simulate force deleting the element remotely // simulate force deleting the element remotely
updateSceneData({ API.updateScene({
elements: syncInvalidIndices([rect1]), elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE, storeAction: StoreAction.UPDATE,
}); });
+10
View File
@@ -5,6 +5,7 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
import { VitePWA } from "vite-plugin-pwa"; import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker"; import checker from "vite-plugin-checker";
import { createHtmlPlugin } from "vite-plugin-html"; import { createHtmlPlugin } from "vite-plugin-html";
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
// To load .env.local variables // To load .env.local variables
const envVars = loadEnv("", `../`); const envVars = loadEnv("", `../`);
@@ -22,6 +23,14 @@ export default defineConfig({
outDir: "build", outDir: "build",
rollupOptions: { rollupOptions: {
output: { output: {
assetFileNames(chunkInfo) {
if (chunkInfo?.name?.endsWith(".woff2")) {
// put on root so we are flexible about the CDN path
return "[name]-[hash][extname]";
}
return "assets/[name]-[hash][extname]";
},
// Creating separate chunk for locales except for en and percentages.json so they // Creating separate chunk for locales except for en and percentages.json so they
// can be cached at runtime and not merged with // can be cached at runtime and not merged with
// app precache. en.json and percentages.json are needed for first load // app precache. en.json and percentages.json are needed for first load
@@ -41,6 +50,7 @@ export default defineConfig({
sourcemap: true, sourcemap: true,
}, },
plugins: [ plugins: [
woff2BrowserPlugin(),
react(), react(),
checker({ checker({
typescript: true, typescript: true,
+9 -14
View File
@@ -9,19 +9,8 @@
"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",
@@ -51,7 +40,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.5.3", "vitest": "1.6.0",
"vitest-canvas-mock": "0.3.2" "vitest-canvas-mock": "0.3.2"
}, },
"engines": { "engines": {
@@ -87,6 +76,12 @@
"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"
} }
} }
+2
View File
@@ -19,6 +19,8 @@ Please add the latest change on the top under the correct section.
- 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)
@@ -5,20 +5,27 @@ import { t } from "../i18n";
import { register } from "./register"; import { register } from "./register";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import type { AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
import { newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups"; import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding"; import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks"; import {
isBoundToContainer,
isElbowArrow,
isFrameLikeElement,
} from "../element/typeChecks";
import { updateActiveTool } from "../utils"; import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons"; import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { mutateElbowArrow } from "../element/routing";
const deleteSelectedElements = ( const deleteSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
app: AppClassProperties,
) => { ) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
const framesToBeDeleted = new Set( const framesToBeDeleted = new Set(
getSelectedElements( getSelectedElements(
elements.filter((el) => isFrameLikeElement(el)), elements.filter((el) => isFrameLikeElement(el)),
@@ -29,6 +36,26 @@ const deleteSelectedElements = (
return { return {
elements: elements.map((el) => { elements: elements.map((el) => {
if (appState.selectedElementIds[el.id]) { if (appState.selectedElementIds[el.id]) {
if (el.boundElements) {
el.boundElements.forEach((candidate) => {
const bound = app.scene
.getNonDeletedElementsMap()
.get(candidate.id);
if (bound && isElbowArrow(bound)) {
mutateElement(bound, {
startBinding:
el.id === bound.startBinding?.elementId
? null
: bound.startBinding,
endBinding:
el.id === bound.endBinding?.elementId
? null
: bound.endBinding,
});
mutateElbowArrow(bound, elementsMap, bound.points);
}
});
}
return newElementWith(el, { isDeleted: true }); return newElementWith(el, { isDeleted: true });
} }
@@ -130,7 +157,11 @@ export const actionDeleteSelected = register({
: endBindingElement, : endBindingElement,
}; };
LinearElementEditor.deletePoints(element, selectedPointsIndices); LinearElementEditor.deletePoints(
element,
selectedPointsIndices,
elementsMap,
);
return { return {
elements, elements,
@@ -149,7 +180,7 @@ export const actionDeleteSelected = register({
}; };
} }
let { elements: nextElements, appState: nextAppState } = let { elements: nextElements, appState: nextAppState } =
deleteSelectedElements(elements, appState); deleteSelectedElements(elements, appState, app);
fixBindingsAfterDeletion( fixBindingsAfterDeletion(
nextElements, nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]), elements.filter(({ id }) => appState.selectedElementIds[id]),
@@ -40,12 +40,11 @@ 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,
elementsMap, app.scene.getNonDeletedElementsMap(),
); );
if (!ret) { if (!ret) {
@@ -1,3 +1,4 @@
import React from "react";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { queryByTestId, fireEvent } from "@testing-library/react"; import { queryByTestId, fireEvent } from "@testing-library/react";
import { render } from "../tests/test-utils"; import { render } from "../tests/test-utils";
@@ -38,6 +38,7 @@ export const actionFinalize = register({
startBindingElement, startBindingElement,
endBindingElement, endBindingElement,
elementsMap, elementsMap,
scene,
); );
} }
return { return {
@@ -72,8 +73,8 @@ export const actionFinalize = register({
const multiPointElement = appState.multiElement const multiPointElement = appState.multiElement
? appState.multiElement ? appState.multiElement
: appState.editingElement?.type === "freedraw" : appState.newElement?.type === "freedraw"
? appState.editingElement ? appState.newElement
: null; : null;
if (multiPointElement) { if (multiPointElement) {
@@ -136,6 +137,7 @@ export const actionFinalize = register({
appState, appState,
{ x, y }, { x, y },
elementsMap, elementsMap,
elements,
); );
} }
} }
@@ -174,7 +176,8 @@ export const actionFinalize = register({
? appState.activeTool ? appState.activeTool
: activeTool, : activeTool,
activeEmbeddable: null, activeEmbeddable: null,
draggingElement: null, newElement: null,
selectionElement: null,
multiElement: null, multiElement: null,
editingElement: null, editingElement: null,
startBoundElement: null, startBoundElement: null,
@@ -202,7 +205,7 @@ export const actionFinalize = register({
keyTest: (event, appState) => keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE && (event.key === KEYS.ESCAPE &&
(appState.editingLinearElement !== null || (appState.editingLinearElement !== null ||
(!appState.draggingElement && appState.multiElement === null))) || (!appState.newElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null), appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => ( PanelComponent: ({ appState, updateData, data }) => (
@@ -125,6 +125,8 @@ const flipElements = (
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement), selectedElements.filter(isLinearElement),
elementsMap, elementsMap,
app.scene.getNonDeletedElements(),
app.scene,
isBindingEnabled(appState), isBindingEnabled(appState),
[], [],
); );
@@ -21,7 +21,9 @@ const writeData = (
!appState.multiElement && !appState.multiElement &&
!appState.resizingElement && !appState.resizingElement &&
!appState.editingElement && !appState.editingElement &&
!appState.draggingElement !appState.newElement &&
!appState.selectedElementsAreBeingDragged &&
!appState.selectionElement
) { ) {
const result = updater(); const result = updater();
@@ -50,7 +52,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
icon: UndoIcon, icon: UndoIcon,
trackEvent: { category: "history" }, trackEvent: { category: "history" },
viewMode: false, viewMode: false,
perform: (elements, appState) => perform: (elements, appState, value, app) =>
writeData(appState, () => 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`
@@ -91,7 +93,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
icon: RedoIcon, icon: RedoIcon,
trackEvent: { category: "history" }, trackEvent: { category: "history" },
viewMode: false, viewMode: false,
perform: (elements, appState) => perform: (elements, appState, _, app) =>
writeData(appState, () => 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`
@@ -1,6 +1,6 @@
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"; import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { isLinearElement } from "../element/typeChecks"; import { isElbowArrow, isLinearElement } from "../element/typeChecks";
import type { ExcalidrawLinearElement } from "../element/types"; import type { ExcalidrawLinearElement } from "../element/types";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { register } from "./register"; import { register } from "./register";
@@ -29,7 +29,8 @@ export const actionToggleLinearEditor = register({
if ( if (
!appState.editingLinearElement && !appState.editingLinearElement &&
selectedElements.length === 1 && selectedElements.length === 1 &&
isLinearElement(selectedElements[0]) isLinearElement(selectedElements[0]) &&
!isElbowArrow(selectedElements[0])
) { ) {
return true; return true;
} }
@@ -1,3 +1,4 @@
import React from "react";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { queryByTestId } from "@testing-library/react"; import { queryByTestId } from "@testing-library/react";
import { render } from "../tests/test-utils"; import { render } from "../tests/test-utils";
@@ -6,8 +7,6 @@ import { API } from "../tests/helpers/api";
import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors"; import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors";
import { FONT_FAMILY, STROKE_WIDTH } from "../constants"; import { FONT_FAMILY, STROKE_WIDTH } from "../constants";
const { h } = window;
describe("element locking", () => { describe("element locking", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<Excalidraw />); await render(<Excalidraw />);
@@ -22,7 +21,7 @@ describe("element locking", () => {
// just in case we change it in the future // just in case we change it in the future
expect(color).not.toBe(COLOR_PALETTE.transparent); expect(color).not.toBe(COLOR_PALETTE.transparent);
h.setState({ API.setAppState({
currentItemBackgroundColor: color, currentItemBackgroundColor: color,
}); });
const activeColor = queryByTestId( const activeColor = queryByTestId(
@@ -40,14 +39,14 @@ describe("element locking", () => {
// just in case we change it in the future // just in case we change it in the future
expect(color).not.toBe(COLOR_PALETTE.transparent); expect(color).not.toBe(COLOR_PALETTE.transparent);
h.setState({ API.setAppState({
currentItemBackgroundColor: color, currentItemBackgroundColor: color,
currentItemFillStyle: "hachure", currentItemFillStyle: "hachure",
}); });
const hachureFillButton = queryByTestId(document.body, `fill-hachure`); const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
expect(hachureFillButton).toHaveClass("active"); expect(hachureFillButton).toHaveClass("active");
h.setState({ API.setAppState({
currentItemFillStyle: "solid", currentItemFillStyle: "solid",
}); });
const solidFillStyle = queryByTestId(document.body, `fill-solid`); const solidFillStyle = queryByTestId(document.body, `fill-solid`);
@@ -57,7 +56,7 @@ describe("element locking", () => {
it("should not show fill style when background transparent", () => { it("should not show fill style when background transparent", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
h.setState({ API.setAppState({
currentItemBackgroundColor: COLOR_PALETTE.transparent, currentItemBackgroundColor: COLOR_PALETTE.transparent,
currentItemFillStyle: "hachure", currentItemFillStyle: "hachure",
}); });
@@ -69,7 +68,7 @@ describe("element locking", () => {
it("should show horizontal text align for text tool", () => { it("should show horizontal text align for text tool", () => {
UI.clickTool("text"); UI.clickTool("text");
h.setState({ API.setAppState({
currentItemTextAlign: "right", currentItemTextAlign: "right",
}); });
@@ -85,7 +84,7 @@ describe("element locking", () => {
backgroundColor: "red", backgroundColor: "red",
fillStyle: "cross-hatch", fillStyle: "cross-hatch",
}); });
h.elements = [rect]; API.setElements([rect]);
API.setSelectedElements([rect]); API.setSelectedElements([rect]);
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`); const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
@@ -98,7 +97,7 @@ describe("element locking", () => {
backgroundColor: COLOR_PALETTE.transparent, backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "cross-hatch", fillStyle: "cross-hatch",
}); });
h.elements = [rect]; API.setElements([rect]);
API.setSelectedElements([rect]); API.setSelectedElements([rect]);
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`); const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
@@ -114,7 +113,7 @@ describe("element locking", () => {
type: "rectangle", type: "rectangle",
strokeWidth: STROKE_WIDTH.thin, strokeWidth: STROKE_WIDTH.thin,
}); });
h.elements = [rect1, rect2]; API.setElements([rect1, rect2]);
API.setSelectedElements([rect1, rect2]); API.setSelectedElements([rect1, rect2]);
const thinStrokeWidthButton = queryByTestId( const thinStrokeWidthButton = queryByTestId(
@@ -133,7 +132,7 @@ describe("element locking", () => {
type: "rectangle", type: "rectangle",
strokeWidth: STROKE_WIDTH.bold, strokeWidth: STROKE_WIDTH.bold,
}); });
h.elements = [rect1, rect2]; API.setElements([rect1, rect2]);
API.setSelectedElements([rect1, rect2]); API.setSelectedElements([rect1, rect2]);
expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null); expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null);
@@ -155,13 +154,15 @@ describe("element locking", () => {
}); });
const text = API.createElement({ const text = API.createElement({
type: "text", type: "text",
fontFamily: FONT_FAMILY.Cascadia, fontFamily: FONT_FAMILY["Comic Shanns"],
}); });
h.elements = [rect, text]; API.setElements([rect, text]);
API.setSelectedElements([rect, text]); API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked(); expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toBeChecked(); expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
"active",
);
}); });
}); });
}); });
+612 -89
View File
@@ -1,4 +1,6 @@
import type { AppClassProperties, AppState, Primitive } from "../types"; import { useEffect, useMemo, useRef, useState } from "react";
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,
@@ -9,6 +11,7 @@ import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker/ColorPicker"; import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { IconPicker } from "../components/IconPicker"; import { IconPicker } from "../components/IconPicker";
import { FontPicker } from "../components/FontPicker/FontPicker";
// TODO barnabasmolnar/editor-redesign // TODO barnabasmolnar/editor-redesign
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon, // TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
// ArrowHead icons // ArrowHead icons
@@ -38,9 +41,6 @@ import {
FontSizeExtraLargeIcon, FontSizeExtraLargeIcon,
EdgeSharpIcon, EdgeSharpIcon,
EdgeRoundIcon, EdgeRoundIcon,
FreedrawIcon,
FontFamilyNormalIcon,
FontFamilyCodeIcon,
TextAlignLeftIcon, TextAlignLeftIcon,
TextAlignCenterIcon, TextAlignCenterIcon,
TextAlignRightIcon, TextAlignRightIcon,
@@ -50,8 +50,12 @@ import {
ArrowheadDiamondIcon, ArrowheadDiamondIcon,
ArrowheadDiamondOutlineIcon, ArrowheadDiamondOutlineIcon,
fontSizeIcon, fontSizeIcon,
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
} from "../components/icons"; } from "../components/icons";
import { import {
ARROW_TYPE,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_FAMILY, FONT_FAMILY,
@@ -65,17 +69,17 @@ import {
redrawTextBoundingBox, redrawTextBoundingBox,
} from "../element"; } from "../element";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { getBoundTextElement } from "../element/textElement";
import { import {
getBoundTextElement, isArrowElement,
getDefaultLineHeight,
} from "../element/textElement";
import {
isBoundToContainer, isBoundToContainer,
isElbowArrow,
isLinearElement, isLinearElement,
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
} from "../element/typeChecks"; } from "../element/typeChecks";
import type { import type {
Arrowhead, Arrowhead,
ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
@@ -94,9 +98,23 @@ import {
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { arrayToMap, getShortcutKey } from "../utils"; import {
arrayToMap,
getFontFamilyString,
getShortcutKey,
tupleToCoors,
} from "../utils";
import { register } from "./register"; import { register } from "./register";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { Fonts, getLineHeight } from "../fonts";
import {
bindLinearElement,
bindPointToSnapToElementOutline,
calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding,
} from "../element/binding";
import { mutateElbowArrow } from "../element/routing";
import { LinearElementEditor } from "../element/linearElementEditor";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -729,104 +747,388 @@ export const actionIncreaseFontSize = register({
}, },
}); });
type ChangeFontFamilyData = Partial<
Pick<
AppState,
"openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily"
>
> & {
/** cache of selected & editing elements populated on opened popup */
cachedElements?: Map<string, ExcalidrawElement>;
/** flag to reset all elements to their cached versions */
resetAll?: true;
/** flag to reset all containers to their cached versions */
resetContainers?: true;
};
export const actionChangeFontFamily = register({ export const actionChangeFontFamily = register({
name: "changeFontFamily", name: "changeFontFamily",
label: "labels.fontFamily", label: "labels.fontFamily",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value, app) => { perform: (elements, appState, value, app) => {
return { const { cachedElements, resetAll, resetContainers, ...nextAppState } =
elements: changeProperty( value as ChangeFontFamilyData;
if (resetAll) {
const nextElements = changeProperty(
elements, elements,
appState, appState,
(oldElement) => { (element) => {
if (isTextElement(oldElement)) { const cachedElement = cachedElements?.get(element.id);
const newElement: ExcalidrawTextElement = newElementWith( if (cachedElement) {
oldElement, const newElement = newElementWith(element, {
{ ...cachedElement,
fontFamily: value, });
lineHeight: getDefaultLineHeight(value),
},
);
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
return newElement; return newElement;
} }
return oldElement; return element;
}, },
true, true,
), );
return {
elements: nextElements,
appState: {
...appState,
...nextAppState,
},
storeAction: StoreAction.UPDATE,
};
}
const { currentItemFontFamily, currentHoveredFontFamily } = value;
let nexStoreAction: StoreActionType = StoreAction.NONE;
let nextFontFamily: FontFamilyValues | undefined;
let skipOnHoverRender = false;
if (currentItemFontFamily) {
nextFontFamily = currentItemFontFamily;
nexStoreAction = StoreAction.CAPTURE;
} else if (currentHoveredFontFamily) {
nextFontFamily = currentHoveredFontFamily;
nexStoreAction = StoreAction.NONE;
const selectedTextElements = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
}).filter((element) => isTextElement(element));
// skip on hover re-render for more than 200 text elements or for text element with more than 5000 chars combined
if (selectedTextElements.length > 200) {
skipOnHoverRender = true;
} else {
let i = 0;
let textLengthAccumulator = 0;
while (
i < selectedTextElements.length &&
textLengthAccumulator < 5000
) {
const textElement = selectedTextElements[i] as ExcalidrawTextElement;
textLengthAccumulator += textElement?.originalText.length || 0;
i++;
}
if (textLengthAccumulator > 5000) {
skipOnHoverRender = true;
}
}
}
const result = {
appState: { appState: {
...appState, ...appState,
currentItemFontFamily: value, ...nextAppState,
}, },
storeAction: StoreAction.CAPTURE, storeAction: nexStoreAction,
}; };
if (nextFontFamily && !skipOnHoverRender) {
const elementContainerMapping = new Map<
ExcalidrawTextElement,
ExcalidrawElement | null
>();
let uniqueChars = new Set<string>();
let skipFontFaceCheck = false;
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
const fontFamily = Object.entries(FONT_FAMILY).find(
([_, value]) => value === nextFontFamily,
)?.[0];
// skip `document.font.check` check on hover, if at least one font family has loaded as it's super slow (could result in slightly different bbox, which is fine)
if (
currentHoveredFontFamily &&
fontFamily &&
fontsCache.some((sig) => sig.startsWith(fontFamily))
) {
skipFontFaceCheck = true;
}
// following causes re-render so make sure we changed the family
// otherwise it could cause unexpected issues, such as preventing opening the popover when in wysiwyg
Object.assign(result, {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (
isTextElement(oldElement) &&
(oldElement.fontFamily !== nextFontFamily ||
currentItemFontFamily) // force update on selection
) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{
fontFamily: nextFontFamily,
lineHeight: getLineHeight(nextFontFamily!),
},
);
const cachedContainer =
cachedElements?.get(oldElement.containerId || "") || {};
const container = app.scene.getContainerElement(oldElement);
if (resetContainers && container && cachedContainer) {
// reset the container back to it's cached version
mutateElement(container, { ...cachedContainer }, false);
}
if (!skipFontFaceCheck) {
uniqueChars = new Set([
...uniqueChars,
...Array.from(newElement.originalText),
]);
}
elementContainerMapping.set(newElement, container);
return newElement;
}
return oldElement;
},
true,
),
});
// size is irrelevant, but necessary
const fontString = `10px ${getFontFamilyString({
fontFamily: nextFontFamily,
})}`;
const chars = Array.from(uniqueChars.values()).join();
if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
for (const [element, container] of elementContainerMapping) {
// trigger synchronous redraw
redrawTextBoundingBox(
element,
container,
app.scene.getNonDeletedElementsMap(),
false,
);
}
} else {
// otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
window.document.fonts.load(fontString, chars).then((fontFaces) => {
for (const [element, container] of elementContainerMapping) {
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
const latestElement = app.scene.getElement(element.id);
const latestContainer = container
? app.scene.getElement(container.id)
: null;
if (latestElement) {
// trigger async redraw
redrawTextBoundingBox(
latestElement as ExcalidrawTextElement,
latestContainer,
app.scene.getNonDeletedElementsMap(),
false,
);
}
}
// trigger update once we've mutated all the elements, which also updates our cache
app.fonts.onLoaded(fontFaces);
});
}
}
return result;
}, },
PanelComponent: ({ elements, appState, updateData, app }) => { PanelComponent: ({ elements, appState, app, updateData }) => {
const options: { const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map());
value: FontFamilyValues; const prevSelectedFontFamilyRef = useRef<number | null>(null);
text: string; // relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
icon: JSX.Element; const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
testId: string; const isUnmounted = useRef(true);
}[] = [
{ const selectedFontFamily = useMemo(() => {
value: FONT_FAMILY.Virgil, const getFontFamily = (
text: t("labels.handDrawn"), elementsArray: readonly ExcalidrawElement[],
icon: FreedrawIcon, elementsMap: Map<string, ExcalidrawElement>,
testId: "font-family-virgil", ) =>
}, getFormValue(
{ elementsArray,
value: FONT_FAMILY.Helvetica, appState,
text: t("labels.normal"), (element) => {
icon: FontFamilyNormalIcon, if (isTextElement(element)) {
testId: "font-family-normal", return element.fontFamily;
}, }
{ const boundTextElement = getBoundTextElement(element, elementsMap);
value: FONT_FAMILY.Cascadia, if (boundTextElement) {
text: t("labels.code"), return boundTextElement.fontFamily;
icon: FontFamilyCodeIcon, }
testId: "font-family-code", return null;
}, },
]; (element) =>
isTextElement(element) ||
getBoundTextElement(element, elementsMap) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
);
// popup opened, use cached elements
if (
batchedData.openPopup === "fontFamily" &&
appState.openPopup === "fontFamily"
) {
return getFontFamily(
Array.from(cachedElementsRef.current?.values() ?? []),
cachedElementsRef.current,
);
}
// popup closed, use all elements
if (!batchedData.openPopup && appState.openPopup !== "fontFamily") {
return getFontFamily(elements, app.scene.getNonDeletedElementsMap());
}
// popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had
return prevSelectedFontFamilyRef.current;
}, [batchedData.openPopup, appState, elements, app.scene]);
useEffect(() => {
prevSelectedFontFamilyRef.current = selectedFontFamily;
}, [selectedFontFamily]);
useEffect(() => {
if (Object.keys(batchedData).length) {
updateData(batchedData);
// reset the data after we've used the data
setBatchedData({});
}
// call update only on internal state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [batchedData]);
useEffect(() => {
isUnmounted.current = false;
return () => {
isUnmounted.current = true;
};
}, []);
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.fontFamily")}</legend> <legend>{t("labels.fontFamily")}</legend>
<ButtonIconSelect<FontFamilyValues | false> <FontPicker
group="font-family" isOpened={appState.openPopup === "fontFamily"}
options={options} selectedFontFamily={selectedFontFamily}
value={getFormValue( hoveredFontFamily={appState.currentHoveredFontFamily}
elements, onSelect={(fontFamily) => {
appState, setBatchedData({
(element) => { openPopup: null,
if (isTextElement(element)) { currentHoveredFontFamily: null,
return element.fontFamily; currentItemFontFamily: fontFamily,
});
// defensive clear so immediate close won't abuse the cached elements
cachedElementsRef.current.clear();
}}
onHover={(fontFamily) => {
setBatchedData({
currentHoveredFontFamily: fontFamily,
cachedElements: new Map(cachedElementsRef.current),
resetContainers: true,
});
}}
onLeave={() => {
setBatchedData({
currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current),
resetAll: true,
});
}}
onPopupChange={(open) => {
if (open) {
// open, populate the cache from scratch
cachedElementsRef.current.clear();
const { 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(
element, setBatchedData({
app.scene.getNonDeletedElementsMap(), openPopup: "fontFamily",
); });
if (boundTextElement) { } else {
return boundTextElement.fontFamily; // close, use the cache and clear it afterwards
const data = {
openPopup: null,
currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current),
resetAll: true,
} as ChangeFontFamilyData;
if (isUnmounted.current) {
// in case the component was unmounted by the parent, trigger the update directly
updateData({ ...batchedData, ...data });
} else {
setBatchedData(data);
} }
return null;
}, cachedElementsRef.current.clear();
(element) => }
isTextElement(element) || }}
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
)}
onChange={(value) => updateData(value)}
/> />
</fieldset> </fieldset>
); );
@@ -1019,8 +1321,12 @@ export const actionChangeRoundness = register({
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) => {
newElementWith(el, { if (isElbowArrow(el)) {
return el;
}
return newElementWith(el, {
roundness: roundness:
value === "round" value === "round"
? { ? {
@@ -1029,8 +1335,8 @@ export const actionChangeRoundness = register({
: ROUNDNESS.PROPORTIONAL_RADIUS, : ROUNDNESS.PROPORTIONAL_RADIUS,
} }
: null, : null,
}), });
), }),
appState: { appState: {
...appState, ...appState,
currentItemRoundness: value, currentItemRoundness: value,
@@ -1070,7 +1376,8 @@ export const actionChangeRoundness = register({
appState, appState,
(element) => (element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp", hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(element) => element.hasOwnProperty("roundness"), (element) =>
!isArrowElement(element) && element.hasOwnProperty("roundness"),
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemRoundness, hasSelection ? null : appState.currentItemRoundness,
)} )}
@@ -1233,3 +1540,219 @@ 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>
);
},
});
+3 -5
View File
@@ -12,10 +12,7 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
} from "../constants"; } from "../constants";
import { import { getBoundTextElement } from "../element/textElement";
getBoundTextElement,
getDefaultLineHeight,
} from "../element/textElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
canApplyRoundnessTypeToElement, canApplyRoundnessTypeToElement,
@@ -27,6 +24,7 @@ import { getSelectedElements } from "../scene";
import type { ExcalidrawTextElement } from "../element/types"; import type { ExcalidrawTextElement } from "../element/types";
import { paintIcon } from "../components/icons"; import { paintIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { getLineHeight } from "../fonts";
// `copiedStyles` is exported only for tests. // `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}"; export let copiedStyles: string = "{}";
@@ -122,7 +120,7 @@ export const actionPasteStyles = register({
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
lineHeight: lineHeight:
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight || (elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
getDefaultLineHeight(fontFamily), getLineHeight(fontFamily),
}); });
let container = null; let container = null;
if (newElement.containerId) { if (newElement.containerId) {
+1
View File
@@ -70,6 +70,7 @@ export type ActionName =
| "changeSloppiness" | "changeSloppiness"
| "changeStrokeStyle" | "changeStrokeStyle"
| "changeArrowhead" | "changeArrowhead"
| "changeArrowType"
| "changeOpacity" | "changeOpacity"
| "changeFontSize" | "changeFontSize"
| "toggleCanvasMenu" | "toggleCanvasMenu"
+11 -2
View File
@@ -1,5 +1,6 @@
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,
@@ -33,12 +34,14 @@ export const getDefaultAppState = (): Omit<
currentItemStartArrowhead: null, currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor, currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemRoundness: "round", currentItemRoundness: "round",
currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN, currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentHoveredFontFamily: null,
cursorButton: "up", cursorButton: "up",
activeEmbeddable: null, activeEmbeddable: null,
draggingElement: null, newElement: null,
editingElement: null, editingElement: null,
editingGroupId: null, editingGroupId: null,
editingLinearElement: null, editingLinearElement: null,
@@ -142,6 +145,11 @@ const APP_STATE_STORAGE_CONF = (<
export: false, export: false,
server: false, server: false,
}, },
currentItemArrowType: {
browser: true,
export: false,
server: false,
},
currentItemOpacity: { browser: true, export: false, server: false }, currentItemOpacity: { browser: true, export: false, server: false },
currentItemRoughness: { browser: true, export: false, server: false }, currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false }, currentItemStartArrowhead: { browser: true, export: false, server: false },
@@ -149,9 +157,10 @@ const APP_STATE_STORAGE_CONF = (<
currentItemStrokeStyle: { browser: true, export: false, server: false }, currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false }, currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false }, currentItemTextAlign: { browser: true, export: false, server: false },
currentHoveredFontFamily: { browser: false, export: false, server: false },
cursorButton: { browser: true, export: false, server: false }, cursorButton: { browser: true, export: false, server: false },
activeEmbeddable: { browser: false, export: false, server: false }, activeEmbeddable: { browser: false, export: false, server: false },
draggingElement: { browser: false, export: false, server: false }, newElement: { browser: false, export: false, server: false },
editingElement: { browser: false, export: false, server: false }, 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 },
+105
View File
@@ -0,0 +1,105 @@
export default class BinaryHeap<T> {
private content: T[] = [];
constructor(private scoreFunction: (node: T) => number) {}
sinkDown(idx: number) {
const node = this.content[idx];
while (idx > 0) {
const parentN = ((idx + 1) >> 1) - 1;
const parent = this.content[parentN];
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
this.content[parentN] = node;
this.content[idx] = parent;
idx = parentN; // TODO: Optimize
} else {
break;
}
}
}
bubbleUp(idx: number) {
const length = this.content.length;
const node = this.content[idx];
const score = this.scoreFunction(node);
while (true) {
const child2N = (idx + 1) << 1;
const child1N = child2N - 1;
let swap = null;
let child1Score = 0;
if (child1N < length) {
const child1 = this.content[child1N];
child1Score = this.scoreFunction(child1);
if (child1Score < score) {
swap = child1N;
}
}
if (child2N < length) {
const child2 = this.content[child2N];
const child2Score = this.scoreFunction(child2);
if (child2Score < (swap === null ? score : child1Score)) {
swap = child2N;
}
}
if (swap !== null) {
this.content[idx] = this.content[swap];
this.content[swap] = node;
idx = swap; // TODO: Optimize
} else {
break;
}
}
}
push(node: T) {
this.content.push(node);
this.sinkDown(this.content.length - 1);
}
pop(): T | null {
if (this.content.length === 0) {
return null;
}
const result = this.content[0];
const end = this.content.pop()!;
if (this.content.length > 0) {
this.content[0] = end;
this.bubbleUp(0);
}
return result;
}
remove(node: T) {
if (this.content.length === 0) {
return;
}
const i = this.content.indexOf(node);
const end = this.content.pop()!;
if (i < this.content.length) {
this.content[i] = end;
if (this.scoreFunction(end) < this.scoreFunction(node)) {
this.sinkDown(i);
} else {
this.bubbleUp(i);
}
}
}
size(): number {
return this.content.length;
}
rescoreElement(node: T) {
this.sinkDown(this.content.indexOf(node));
}
}
+6 -2
View File
@@ -1100,7 +1100,6 @@ export class ElementsChange implements Change<SceneElementsMap> {
try { try {
// TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state // TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements); ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
ElementsChange.redrawBoundArrows(nextElements, changedElements);
// the following reorder performs also mutations, but only on new instances of changed elements // the following reorder performs also mutations, but only on new instances of changed elements
// (unless something goes really bad and it fallbacks to fixing all invalid indices) // (unless something goes really bad and it fallbacks to fixing all invalid indices)
@@ -1109,6 +1108,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
changedElements, changedElements,
flags, flags,
); );
// Need ordered nextElements to avoid z-index binding issues
ElementsChange.redrawBoundArrows(nextElements, changedElements);
} catch (e) { } catch (e) {
console.error( console.error(
`Couldn't mutate elements after applying elements change`, `Couldn't mutate elements after applying elements change`,
@@ -1460,7 +1462,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
) { ) {
for (const element of changed.values()) { for (const element of changed.values()) {
if (!element.isDeleted && isBindableElement(element)) { if (!element.isDeleted && isBindableElement(element)) {
updateBoundElements(element, elements); updateBoundElements(element, elements, {
changedElements: changed,
});
} }
} }
} }
-10
View File
@@ -257,8 +257,6 @@ const chartLines = (
type: "line", type: "line",
x, x,
y, y,
startArrowhead: null,
endArrowhead: null,
width: chartWidth, width: chartWidth,
points: [ points: [
[0, 0], [0, 0],
@@ -273,8 +271,6 @@ const chartLines = (
type: "line", type: "line",
x, x,
y, y,
startArrowhead: null,
endArrowhead: null,
height: chartHeight, height: chartHeight,
points: [ points: [
[0, 0], [0, 0],
@@ -289,8 +285,6 @@ 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,
@@ -418,8 +412,6 @@ const chartTypeLine = (
type: "line", type: "line",
x: x + BAR_GAP + BAR_WIDTH / 2, x: x + BAR_GAP + BAR_WIDTH / 2,
y: y - BAR_GAP, y: y - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
height: maxY - minY, height: maxY - minY,
width: maxX - minX, width: maxX - minX,
strokeWidth: 2, strokeWidth: 2,
@@ -453,8 +445,6 @@ 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,
+10 -5
View File
@@ -21,10 +21,11 @@ import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
import { capitalizeString, isTransparent } from "../utils"; import { capitalizeString, isTransparent } from "../utils";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor, toolIsArrow } from "../scene/comparisons";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { import {
hasBoundTextElement, hasBoundTextElement,
isElbowArrow,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
@@ -121,7 +122,8 @@ export const SelectedShapeActions = ({
const showLineEditorAction = const showLineEditorAction =
!appState.editingLinearElement && !appState.editingLinearElement &&
targetElements.length === 1 && targetElements.length === 1 &&
isLinearElement(targetElements[0]); isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
return ( return (
<div className="panelColumn"> <div className="panelColumn">
@@ -155,13 +157,16 @@ export const SelectedShapeActions = ({
<>{renderAction("changeRoundness")}</> <>{renderAction("changeRoundness")}</>
)} )}
{(toolIsArrow(appState.activeTool.type) ||
targetElements.some((element) => toolIsArrow(element.type))) && (
<>{renderAction("changeArrowType")}</>
)}
{(appState.activeTool.type === "text" || {(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && ( targetElements.some(isTextElement)) && (
<> <>
{renderAction("changeFontSize")}
{renderAction("changeFontFamily")} {renderAction("changeFontFamily")}
{renderAction("changeFontSize")}
{(appState.activeTool.type === "text" || {(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements, elementsMap)) && suppportsHorizontalAlign(targetElements, elementsMap)) &&
renderAction("changeTextAlign")} renderAction("changeTextAlign")}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,12 @@
@import "../css/theme";
.excalidraw {
button.standalone {
@include outlineButtonIconStyles;
& > * {
// dissalow pointer events on children, so we always have event.target on the button itself
pointer-events: none;
}
}
}
@@ -0,0 +1,36 @@
import { forwardRef } from "react";
import clsx from "clsx";
import "./ButtonIcon.scss";
interface ButtonIconProps {
icon: JSX.Element;
title: string;
className?: string;
testId?: string;
/** if not supplied, defaults to value identity check */
active?: boolean;
/** include standalone style (could interfere with parent styles) */
standalone?: boolean;
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
(props, ref) => {
const { title, className, testId, active, standalone, icon, onClick } =
props;
return (
<button
type="button"
ref={ref}
key={title}
title={title}
data-testid={testId}
className={clsx(className, { standalone, active })}
onClick={onClick}
>
{icon}
</button>
);
},
);
@@ -1,4 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { ButtonIcon } from "./ButtonIcon";
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect /> // TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>( export const ButtonIconSelect = <T extends Object>(
@@ -24,21 +25,17 @@ export const ButtonIconSelect = <T extends Object>(
} }
), ),
) => ( ) => (
<div className="buttonList buttonListIcon"> <div className="buttonList">
{props.options.map((option) => {props.options.map((option) =>
props.type === "button" ? ( props.type === "button" ? (
<button <ButtonIcon
type="button"
key={option.text} key={option.text}
onClick={(event) => props.onClick(option.value, event)} icon={option.icon}
className={clsx({
active: option.active ?? props.value === option.value,
})}
data-testid={option.testId}
title={option.text} title={option.text}
> testId={option.testId}
{option.icon} active={option.active ?? props.value === option.value}
</button> onClick={(event) => props.onClick(option.value, event)}
/>
) : ( ) : (
<label <label
key={option.text} key={option.text}
@@ -0,0 +1,10 @@
export const ButtonSeparator = () => (
<div
style={{
width: 1,
height: "1rem",
backgroundColor: "var(--default-border-color)",
margin: "0 auto",
}}
/>
);
@@ -20,7 +20,7 @@
align-items: center; align-items: center;
@include isMobile { @include isMobile {
max-width: 175px; max-width: 11rem;
} }
} }
@@ -1,22 +1,24 @@
import { isInteractive, isTransparent, isWritableElement } from "../../utils"; import { isTransparent } from "../../utils";
import type { ExcalidrawElement } from "../../element/types"; import type { ExcalidrawElement } from "../../element/types";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import { TopPicks } from "./TopPicks"; import { TopPicks } from "./TopPicks";
import { ButtonSeparator } from "../ButtonSeparator";
import { Picker } from "./Picker"; import { Picker } from "./Picker";
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type { ColorPickerType } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils";
import { activeColorPickerSectionAtom } from "./colorPickerUtils"; import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { useDevice, useExcalidrawContainer } from "../App"; import { useExcalidrawContainer } from "../App";
import type { ColorTuple, ColorPaletteCustom } from "../../colors"; import type { ColorTuple, ColorPaletteCustom } from "../../colors";
import { COLOR_PALETTE } from "../../colors"; import { COLOR_PALETTE } from "../../colors";
import PickerHeading from "./PickerHeading"; import PickerHeading from "./PickerHeading";
import { t } from "../../i18n"; import { t } from "../../i18n";
import clsx from "clsx"; import clsx from "clsx";
import { useRef } from "react";
import { jotaiScope } from "../../jotai"; import { jotaiScope } from "../../jotai";
import { ColorInput } from "./ColorInput"; import { ColorInput } from "./ColorInput";
import { useRef } from "react";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
import "./ColorPicker.scss"; import "./ColorPicker.scss";
@@ -71,6 +73,7 @@ const ColorPickerPopupContent = ({
| "palette" | "palette"
| "updateData" | "updateData"
>) => { >) => {
const { container } = useExcalidrawContainer();
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom); const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
const [eyeDropperState, setEyeDropperState] = useAtom( const [eyeDropperState, setEyeDropperState] = useAtom(
@@ -78,9 +81,6 @@ const ColorPickerPopupContent = ({
jotaiScope, jotaiScope,
); );
const { container } = useExcalidrawContainer();
const device = useDevice();
const colorInputJSX = ( const colorInputJSX = (
<div> <div>
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading> <PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
@@ -94,6 +94,7 @@ const ColorPickerPopupContent = ({
/> />
</div> </div>
); );
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const focusPickerContent = () => { const focusPickerContent = () => {
@@ -103,120 +104,73 @@ const ColorPickerPopupContent = ({
}; };
return ( return (
<Popover.Portal container={container}> <PropertiesPopover
<Popover.Content container={container}
ref={popoverRef} style={{ maxWidth: "208px" }}
className="focus-visible-none" onFocusOutside={(event) => {
data-prevent-outside-click // refocus due to eye dropper
onFocusOutside={(event) => { focusPickerContent();
focusPickerContent(); event.preventDefault();
}}
onPointerDownOutside={(event) => {
if (eyeDropperState) {
// prevent from closing if we click outside the popover
// while eyedropping (e.g. click when clicking the sidebar;
// the eye-dropper-backdrop is prevented downstream)
event.preventDefault(); event.preventDefault();
}}
onPointerDownOutside={(event) => {
if (eyeDropperState) {
// prevent from closing if we click outside the popover
// while eyedropping (e.g. click when clicking the sidebar;
// the eye-dropper-backdrop is prevented downstream)
event.preventDefault();
}
}}
onCloseAutoFocus={(e) => {
e.stopPropagation();
// prevents focusing the trigger
e.preventDefault();
// return focus to excalidraw container unless
// user focuses an interactive element, such as a button, or
// enters the text editor by clicking on canvas with the text tool
if (container && !isInteractive(document.activeElement)) {
container.focus();
}
updateData({ openPopup: null });
setActiveColorPickerSection(null);
}}
side={
device.editor.isMobile && !device.viewport.isLandscape
? "bottom"
: "right"
} }
align={ }}
device.editor.isMobile && !device.viewport.isLandscape onClose={() => {
? "center" updateData({ openPopup: null });
: "start" setActiveColorPickerSection(null);
} }}
alignOffset={-16} >
sideOffset={20} {palette ? (
style={{ <Picker
zIndex: "var(--zIndex-layerUI)", palette={palette}
backgroundColor: "var(--popup-bg-color)", color={color}
maxWidth: "208px", onChange={(changedColor) => {
maxHeight: window.innerHeight, onChange(changedColor);
padding: "12px", }}
borderRadius: "8px", onEyeDropperToggle={(force) => {
boxSizing: "border-box", setEyeDropperState((state) => {
overflowY: "auto", if (force) {
boxShadow: state = state || {
"0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)", keepOpenOnAlt: true,
}} onSelect: onChange,
> colorPickerType: type,
{palette ? ( };
<Picker state.keepOpenOnAlt = true;
palette={palette} return state;
color={color} }
onChange={(changedColor) => {
onChange(changedColor); return force === false || state
}} ? null
onEyeDropperToggle={(force) => { : {
setEyeDropperState((state) => { keepOpenOnAlt: false,
if (force) {
state = state || {
keepOpenOnAlt: true,
onSelect: onChange, onSelect: onChange,
colorPickerType: type, colorPickerType: type,
}; };
state.keepOpenOnAlt = true; });
return state;
}
return force === false || state
? null
: {
keepOpenOnAlt: false,
onSelect: onChange,
colorPickerType: type,
};
});
}}
onEscape={(event) => {
if (eyeDropperState) {
setEyeDropperState(null);
} else if (isWritableElement(event.target)) {
focusPickerContent();
} else {
updateData({ openPopup: null });
}
}}
label={label}
type={type}
elements={elements}
updateData={updateData}
>
{colorInputJSX}
</Picker>
) : (
colorInputJSX
)}
<Popover.Arrow
width={20}
height={10}
style={{
fill: "var(--popup-bg-color)",
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
}} }}
/> onEscape={(event) => {
</Popover.Content> if (eyeDropperState) {
</Popover.Portal> setEyeDropperState(null);
} else {
updateData({ openPopup: null });
}
}}
label={label}
type={type}
elements={elements}
updateData={updateData}
>
{colorInputJSX}
</Picker>
) : (
colorInputJSX
)}
</PropertiesPopover>
); );
}; };
@@ -232,7 +186,7 @@ const ColorPickerTrigger = ({
return ( return (
<Popover.Trigger <Popover.Trigger
type="button" type="button"
className={clsx("color-picker__button active-color", { className={clsx("color-picker__button active-color properties-trigger", {
"is-transparent": color === "transparent" || !color, "is-transparent": color === "transparent" || !color,
})} })}
aria-label={label} aria-label={label}
@@ -268,14 +222,7 @@ export const ColorPicker = ({
type={type} type={type}
topPicks={topPicks} topPicks={topPicks}
/> />
<div <ButtonSeparator />
style={{
width: 1,
height: "100%",
backgroundColor: "var(--default-border-color)",
margin: "0 auto",
}}
/>
<Popover.Root <Popover.Root
open={appState.openPopup === type} open={appState.openPopup === type}
onOpenChange={(open) => { onOpenChange={(open) => {
@@ -138,7 +138,7 @@ export const Picker = ({
event.stopPropagation(); event.stopPropagation();
} }
}} }}
className="color-picker-content" className="color-picker-content properties-content"
// to allow focusing by clicking but not by tabbing // to allow focusing by clicking but not by tabbing
tabIndex={-1} tabIndex={-1}
> >
@@ -9,7 +9,7 @@ import {
import { import {
assertExcalidrawWithSidebar, assertExcalidrawWithSidebar,
assertSidebarDockButton, assertSidebarDockButton,
} from "./Sidebar/Sidebar.test"; } from "./Sidebar/siderbar.test.helpers";
const { h } = window; const { h } = window;
@@ -0,0 +1,15 @@
@import "../../css/variables.module.scss";
.excalidraw {
.FontPicker__container {
display: grid;
grid-template-columns: calc(1rem + 3 * var(--default-button-size)) 1rem 1fr; // calc ~ 2 gaps + 4 buttons
align-items: center;
@include isMobile {
max-width: calc(
2rem + 4 * var(--default-button-size)
); // 4 gaps + 4 buttons
}
}
}
@@ -0,0 +1,110 @@
import React, { useCallback, useMemo } from "react";
import * as Popover from "@radix-ui/react-popover";
import { FontPickerList } from "./FontPickerList";
import { FontPickerTrigger } from "./FontPickerTrigger";
import { ButtonIconSelect } from "../ButtonIconSelect";
import {
FontFamilyCodeIcon,
FontFamilyNormalIcon,
FreedrawIcon,
} from "../icons";
import { ButtonSeparator } from "../ButtonSeparator";
import type { FontFamilyValues } from "../../element/types";
import { FONT_FAMILY } from "../../constants";
import { t } from "../../i18n";
import "./FontPicker.scss";
export const DEFAULT_FONTS = [
{
value: FONT_FAMILY.Excalifont,
icon: FreedrawIcon,
text: t("labels.handDrawn"),
testId: "font-family-handrawn",
},
{
value: FONT_FAMILY.Nunito,
icon: FontFamilyNormalIcon,
text: t("labels.normal"),
testId: "font-family-normal",
},
{
value: FONT_FAMILY["Comic Shanns"],
icon: FontFamilyCodeIcon,
text: t("labels.code"),
testId: "font-family-code",
},
];
const defaultFontFamilies = new Set(DEFAULT_FONTS.map((x) => x.value));
export const isDefaultFont = (fontFamily: number | null) => {
if (!fontFamily) {
return false;
}
return defaultFontFamilies.has(fontFamily);
};
interface FontPickerProps {
isOpened: boolean;
selectedFontFamily: FontFamilyValues | null;
hoveredFontFamily: FontFamilyValues | null;
onSelect: (fontFamily: FontFamilyValues) => void;
onHover: (fontFamily: FontFamilyValues) => void;
onLeave: () => void;
onPopupChange: (open: boolean) => void;
}
export const FontPicker = React.memo(
({
isOpened,
selectedFontFamily,
hoveredFontFamily,
onSelect,
onHover,
onLeave,
onPopupChange,
}: FontPickerProps) => {
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
const onSelectCallback = useCallback(
(value: number | false) => {
if (value) {
onSelect(value);
}
},
[onSelect],
);
return (
<div role="dialog" aria-modal="true" className="FontPicker__container">
<ButtonIconSelect<FontFamilyValues | false>
type="button"
options={defaultFonts}
value={selectedFontFamily}
onClick={onSelectCallback}
/>
<ButtonSeparator />
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
{isOpened && (
<FontPickerList
selectedFontFamily={selectedFontFamily}
hoveredFontFamily={hoveredFontFamily}
onSelect={onSelectCallback}
onHover={onHover}
onLeave={onLeave}
onOpen={() => onPopupChange(true)}
onClose={() => onPopupChange(false)}
/>
)}
</Popover.Root>
</div>
);
},
(prev, next) =>
prev.isOpened === next.isOpened &&
prev.selectedFontFamily === next.selectedFontFamily &&
prev.hoveredFontFamily === next.hoveredFontFamily,
);
@@ -0,0 +1,268 @@
import React, {
useMemo,
useState,
useRef,
useEffect,
useCallback,
type KeyboardEventHandler,
} from "react";
import { useApp, useAppProps, useExcalidrawContainer } from "../App";
import { PropertiesPopover } from "../PropertiesPopover";
import { QuickSearch } from "../QuickSearch";
import { ScrollableList } from "../ScrollableList";
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
import DropdownMenuItem, {
DropDownMenuItemBadgeType,
DropDownMenuItemBadge,
} from "../dropdownMenu/DropdownMenuItem";
import { type FontFamilyValues } from "../../element/types";
import { arrayToList, debounce, getFontFamilyString } from "../../utils";
import { t } from "../../i18n";
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import { Fonts } from "../../fonts";
import type { ValueOf } from "../../utility-types";
export interface FontDescriptor {
value: number;
icon: JSX.Element;
text: string;
deprecated?: true;
badge?: {
type: ValueOf<typeof DropDownMenuItemBadgeType>;
placeholder: string;
};
}
interface FontPickerListProps {
selectedFontFamily: FontFamilyValues | null;
hoveredFontFamily: FontFamilyValues | null;
onSelect: (value: number) => void;
onHover: (value: number) => void;
onLeave: () => void;
onOpen: () => void;
onClose: () => void;
}
export const FontPickerList = React.memo(
({
selectedFontFamily,
hoveredFontFamily,
onSelect,
onHover,
onLeave,
onOpen,
onClose,
}: FontPickerListProps) => {
const { container } = useExcalidrawContainer();
const { fonts } = useApp();
const { showDeprecatedFonts } = useAppProps();
const [searchTerm, setSearchTerm] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const allFonts = useMemo(
() =>
Array.from(Fonts.registered.entries())
.filter(([_, { metadata }]) => !metadata.serverSide)
.map(([familyId, { metadata, fonts }]) => {
const fontDescriptor = {
value: familyId,
icon: metadata.icon,
text: fonts[0].fontFace.family,
};
if (metadata.deprecated) {
Object.assign(fontDescriptor, {
deprecated: metadata.deprecated,
badge: {
type: DropDownMenuItemBadgeType.RED,
placeholder: t("fontList.badge.old"),
},
});
}
return fontDescriptor as FontDescriptor;
})
.sort((a, b) =>
a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1,
),
[],
);
const sceneFamilies = useMemo(
() => new Set(fonts.getSceneFontFamilies()),
// cache per selected font family, so hover re-render won't mess it up
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedFontFamily],
);
const sceneFonts = useMemo(
() => allFonts.filter((font) => sceneFamilies.has(font.value)), // always show all the fonts in the scene, even those that were deprecated
[allFonts, sceneFamilies],
);
const availableFonts = useMemo(
() =>
allFonts.filter(
(font) =>
!sceneFamilies.has(font.value) &&
(showDeprecatedFonts || !font.deprecated), // skip deprecated fonts
),
[allFonts, sceneFamilies, showDeprecatedFonts],
);
const filteredFonts = useMemo(
() =>
arrayToList(
[...sceneFonts, ...availableFonts].filter((font) =>
font.text?.toLowerCase().includes(searchTerm),
),
),
[sceneFonts, availableFonts, searchTerm],
);
const hoveredFont = useMemo(() => {
let font;
if (hoveredFontFamily) {
font = filteredFonts.find((font) => font.value === hoveredFontFamily);
} else if (selectedFontFamily) {
font = filteredFonts.find((font) => font.value === selectedFontFamily);
}
if (!font && searchTerm) {
if (filteredFonts[0]?.value) {
// hover first element on search
onHover(filteredFonts[0].value);
} else {
// re-render cache on no results
onLeave();
}
}
return font;
}, [
hoveredFontFamily,
selectedFontFamily,
searchTerm,
filteredFonts,
onHover,
onLeave,
]);
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
(event) => {
const handled = fontPickerKeyHandler({
event,
inputRef,
hoveredFont,
filteredFonts,
onSelect,
onHover,
onClose,
});
if (handled) {
event.preventDefault();
event.stopPropagation();
}
},
[hoveredFont, filteredFonts, onSelect, onHover, onClose],
);
useEffect(() => {
onOpen();
return () => {
onClose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const sceneFilteredFonts = useMemo(
() => filteredFonts.filter((font) => sceneFamilies.has(font.value)),
[filteredFonts, sceneFamilies],
);
const availableFilteredFonts = useMemo(
() => filteredFonts.filter((font) => !sceneFamilies.has(font.value)),
[filteredFonts, sceneFamilies],
);
const renderFont = (font: FontDescriptor, index: number) => (
<DropdownMenuItem
key={font.value}
icon={font.icon}
value={font.value}
order={index}
textStyle={{
fontFamily: getFontFamilyString({ fontFamily: font.value }),
}}
hovered={font.value === hoveredFont?.value}
selected={font.value === selectedFontFamily}
// allow to tab between search and selected font
tabIndex={font.value === selectedFontFamily ? 0 : -1}
onClick={(e) => {
onSelect(Number(e.currentTarget.value));
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
onHover(font.value);
}
}}
>
{font.text}
{font.badge && (
<DropDownMenuItemBadge type={font.badge.type}>
{font.badge.placeholder}
</DropDownMenuItemBadge>
)}
</DropdownMenuItem>
);
const groups = [];
if (sceneFilteredFonts.length) {
groups.push(
<DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1">
{sceneFilteredFonts.map(renderFont)}
</DropdownMenuGroup>,
);
}
if (availableFilteredFonts.length) {
groups.push(
<DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2">
{availableFilteredFonts.map((font, index) =>
renderFont(font, index + sceneFilteredFonts.length),
)}
</DropdownMenuGroup>,
);
}
return (
<PropertiesPopover
className="properties-content"
container={container}
style={{ width: "15rem" }}
onClose={onClose}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
>
<QuickSearch
ref={inputRef}
placeholder={t("quickSearch.placeholder")}
onChange={debounce(setSearchTerm, 20)}
/>
<ScrollableList
className="dropdown-menu fonts manual-hover"
placeholder={t("fontList.empty")}
>
{groups.length ? groups : null}
</ScrollableList>
</PropertiesPopover>
);
},
(prev, next) =>
prev.selectedFontFamily === next.selectedFontFamily &&
prev.hoveredFontFamily === next.hoveredFontFamily,
);
@@ -0,0 +1,38 @@
import * as Popover from "@radix-ui/react-popover";
import { useMemo } from "react";
import { ButtonIcon } from "../ButtonIcon";
import { TextIcon } from "../icons";
import type { FontFamilyValues } from "../../element/types";
import { t } from "../../i18n";
import { isDefaultFont } from "./FontPicker";
interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null;
}
export const FontPickerTrigger = ({
selectedFontFamily,
}: FontPickerTriggerProps) => {
const isTriggerActive = useMemo(
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
[selectedFontFamily],
);
return (
<Popover.Trigger asChild>
{/* Empty div as trigger so it's stretched 100% due to different button sizes */}
<div>
<ButtonIcon
standalone
icon={TextIcon}
title={t("labels.showFonts")}
className="properties-trigger"
testId={"font-family-show-fonts"}
active={isTriggerActive}
// no-op
onClick={() => {}}
/>
</div>
</Popover.Trigger>
);
};
@@ -0,0 +1,66 @@
import type { Node } from "../../utils";
import { KEYS } from "../../keys";
import { type FontDescriptor } from "./FontPickerList";
interface FontPickerKeyNavHandlerProps {
event: React.KeyboardEvent<HTMLDivElement>;
inputRef: React.RefObject<HTMLInputElement>;
hoveredFont: Node<FontDescriptor> | undefined;
filteredFonts: Node<FontDescriptor>[];
onClose: () => void;
onSelect: (value: number) => void;
onHover: (value: number) => void;
}
export const fontPickerKeyHandler = ({
event,
inputRef,
hoveredFont,
filteredFonts,
onClose,
onSelect,
onHover,
}: FontPickerKeyNavHandlerProps) => {
if (
!event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.F
) {
// refocus input on the popup trigger shortcut
inputRef.current?.focus();
return true;
}
if (event.key === KEYS.ESCAPE) {
onClose();
return true;
}
if (event.key === KEYS.ENTER) {
if (hoveredFont?.value) {
onSelect(hoveredFont.value);
}
return true;
}
if (event.key === KEYS.ARROW_DOWN) {
if (hoveredFont?.next) {
onHover(hoveredFont.next.value);
} else if (filteredFonts[0]?.value) {
onHover(filteredFonts[0].value);
}
return true;
}
if (event.key === KEYS.ARROW_UP) {
if (hoveredFont?.prev) {
onHover(hoveredFont.prev.value);
} else if (filteredFonts[filteredFonts.length - 1]?.value) {
onHover(filteredFonts[filteredFonts.length - 1].value);
}
return true;
}
};
@@ -8,7 +8,7 @@
h3 { h3 {
margin: 1.5rem 0; margin: 1.5rem 0;
font-weight: bold; font-weight: 700;
font-size: 1.125rem; font-size: 1.125rem;
} }
@@ -82,7 +82,7 @@
&__island { &__island {
h4 { h4 {
font-size: 1rem; font-size: 1rem;
font-weight: bold; font-weight: 700;
margin: 0; margin: 0;
margin-bottom: 0.625rem; margin-bottom: 0.625rem;
} }
@@ -458,6 +458,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.showBackground")} label={t("labels.showBackground")}
shortcuts={[getShortcutKey("G")]} shortcuts={[getShortcutKey("G")]}
/> />
<Shortcut
label={t("labels.showFonts")}
shortcuts={[getShortcutKey("Shift+F")]}
/>
<Shortcut <Shortcut
label={t("labels.decreaseFontSize")} label={t("labels.decreaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]} shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
+10 -6
View File
@@ -30,10 +30,13 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
return t("hints.eraserRevert"); return t("hints.eraserRevert");
} }
if (activeTool.type === "arrow" || activeTool.type === "line") { if (activeTool.type === "arrow" || activeTool.type === "line") {
if (!multiMode) { if (multiMode) {
return t("hints.linearElement"); return t("hints.linearElementMulti");
} }
return t("hints.linearElementMulti"); if (activeTool.type === "arrow") {
return t("hints.arrowTool", { arrowShortcut: getShortcutKey("A") });
}
return t("hints.linearElement");
} }
if (activeTool.type === "freedraw") { if (activeTool.type === "freedraw") {
@@ -82,7 +85,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
if (activeTool.type === "selection") { if (activeTool.type === "selection") {
if ( if (
appState.draggingElement?.type === "selection" && appState.selectionElement &&
!selectedElements.length && !selectedElements.length &&
!appState.editingElement && !appState.editingElement &&
!appState.editingLinearElement !appState.editingLinearElement
@@ -90,7 +93,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
return t("hints.deepBoxSelect"); return t("hints.deepBoxSelect");
} }
if (appState.gridSize && appState.draggingElement) { if (appState.gridSize && appState.selectedElementsAreBeingDragged) {
return t("hints.disableSnapping"); return t("hints.disableSnapping");
} }
@@ -108,7 +111,8 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
return t("hints.lineEditor_info"); return t("hints.lineEditor_info");
} }
if ( if (
!appState.draggingElement && !appState.newElement &&
!appState.selectedElementsAreBeingDragged &&
isTextBindableContainer(selectedElements[0]) isTextBindableContainer(selectedElements[0])
) { ) {
return t("hints.bindTextToElement"); return t("hints.bindTextToElement");
@@ -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: bold; font-weight: 700;
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: bold; font-weight: 700;
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: bold; font-weight: 700;
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
@@ -0,0 +1,96 @@
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: bold; font-weight: 700;
font-size: 1rem; font-size: 1rem;
margin: 0.2rem; margin: 0.2rem;
} }
@@ -0,0 +1,48 @@
.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;
}
}
}
@@ -0,0 +1,28 @@
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>
);
},
);
@@ -0,0 +1,21 @@
.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%;
}
}
}
@@ -0,0 +1,24 @@
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,39 +11,17 @@ 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";
export const assertSidebarDockButton = async <T extends boolean>( const toggleSidebar = (
hasDockButton: T, ...args: Parameters<typeof window.h.app.toggleSidebar>
): Promise< ): Promise<boolean> => {
T extends false return act(() => {
? { dockButton: null; sidebar: HTMLElement } return window.h.app.toggleSidebar(...args);
: { 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", () => {
@@ -103,7 +81,7 @@ describe("Sidebar", () => {
// toggle sidebar on // toggle sidebar on
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true); expect(await toggleSidebar({ name: "customSidebar" })).toBe(true);
await waitFor(() => { await waitFor(() => {
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
@@ -112,7 +90,7 @@ describe("Sidebar", () => {
// toggle sidebar off // toggle sidebar off
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(false); expect(await toggleSidebar({ name: "customSidebar" })).toBe(false);
await waitFor(() => { await waitFor(() => {
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
@@ -121,9 +99,9 @@ describe("Sidebar", () => {
// force-toggle sidebar off (=> still hidden) // force-toggle sidebar off (=> still hidden)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
expect( expect(await toggleSidebar({ name: "customSidebar", force: false })).toBe(
window.h.app.toggleSidebar({ name: "customSidebar", force: false }), false,
).toBe(false); );
await waitFor(() => { await waitFor(() => {
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
@@ -132,12 +110,12 @@ describe("Sidebar", () => {
// force-toggle sidebar on // force-toggle sidebar on
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
expect( expect(await toggleSidebar({ name: "customSidebar", force: true })).toBe(
window.h.app.toggleSidebar({ name: "customSidebar", force: true }), true,
).toBe(true); );
expect( expect(await toggleSidebar({ name: "customSidebar", force: true })).toBe(
window.h.app.toggleSidebar({ name: "customSidebar", force: true }), true,
).toBe(true); );
await waitFor(() => { await waitFor(() => {
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
@@ -146,9 +124,7 @@ describe("Sidebar", () => {
// toggle library (= hide custom sidebar) // toggle library (= hide custom sidebar)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
expect(window.h.app.toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe( expect(await toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(true);
true,
);
await waitFor(() => { await waitFor(() => {
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
@@ -161,13 +137,13 @@ describe("Sidebar", () => {
// closing sidebar using `{ name: null }` // closing sidebar using `{ name: null }`
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true); expect(await 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(window.h.app.toggleSidebar({ name: null })).toBe(false); expect(await 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);
@@ -321,6 +297,9 @@ 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" } } }}
@@ -341,6 +320,8 @@ describe("Sidebar", () => {
await assertSidebarDockButton(false); await assertSidebarDockButton(false);
}, },
); );
mock.mockRestore();
}); });
}); });
@@ -367,9 +348,9 @@ describe("Sidebar", () => {
).toBeNull(); ).toBeNull();
// open library sidebar // open library sidebar
expect( expect(await toggleSidebar({ name: "custom", tab: "library" })).toBe(
window.h.app.toggleSidebar({ name: "custom", tab: "library" }), true,
).toBe(true); );
expect( expect(
container.querySelector<HTMLElement>( container.querySelector<HTMLElement>(
"[role=tabpanel][data-testid=library]", "[role=tabpanel][data-testid=library]",
@@ -377,9 +358,9 @@ describe("Sidebar", () => {
).not.toBeNull(); ).not.toBeNull();
// switch to comments tab // switch to comments tab
expect( expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe(
window.h.app.toggleSidebar({ name: "custom", tab: "comments" }), true,
).toBe(true); );
expect( expect(
container.querySelector<HTMLElement>( container.querySelector<HTMLElement>(
"[role=tabpanel][data-testid=comments]", "[role=tabpanel][data-testid=comments]",
@@ -387,9 +368,9 @@ describe("Sidebar", () => {
).not.toBeNull(); ).not.toBeNull();
// toggle sidebar closed // toggle sidebar closed
expect( expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe(
window.h.app.toggleSidebar({ name: "custom", tab: "comments" }), false,
).toBe(false); );
expect( expect(
container.querySelector<HTMLElement>( container.querySelector<HTMLElement>(
"[role=tabpanel][data-testid=comments]", "[role=tabpanel][data-testid=comments]",
@@ -397,9 +378,9 @@ describe("Sidebar", () => {
).toBeNull(); ).toBeNull();
// toggle sidebar open // toggle sidebar open
expect( expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe(
window.h.app.toggleSidebar({ name: "custom", tab: "comments" }), true,
).toBe(true); );
expect( expect(
container.querySelector<HTMLElement>( container.querySelector<HTMLElement>(
"[role=tabpanel][data-testid=comments]", "[role=tabpanel][data-testid=comments]",
@@ -0,0 +1,42 @@
import React from "react";
import { Excalidraw } from "../..";
import {
GlobalTestState,
queryByTestId,
render,
withExcalidrawDimensions,
} from "../../tests/test-utils";
export const assertSidebarDockButton = async <T extends boolean>(
hasDockButton: T,
): Promise<
T extends false
? { dockButton: null; sidebar: HTMLElement }
: { dockButton: HTMLElement; sidebar: HTMLElement }
> => {
const sidebar =
GlobalTestState.renderResult.container.querySelector<HTMLElement>(
".sidebar",
);
expect(sidebar).not.toBe(null);
const dockButton = queryByTestId(sidebar!, "sidebar-dock");
if (hasDockButton) {
expect(dockButton).not.toBe(null);
return { dockButton: dockButton!, sidebar: sidebar! } as any;
}
expect(dockButton).toBe(null);
return { dockButton: null, sidebar: sidebar! } as any;
};
export const assertExcalidrawWithSidebar = async (
sidebar: React.ReactNode,
name: string,
test: () => void,
) => {
await render(
<Excalidraw initialData={{ appState: { openSidebar: { name } } }}>
{sidebar}
</Excalidraw>,
);
await withExcalidrawDimensions({ width: 1920, height: 1080 }, test);
};
@@ -1,6 +1,6 @@
import { mutateElement } from "../../element/mutateElement"; import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement"; import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement } from "../../element/typeChecks"; import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types"; import type { ExcalidrawElement } from "../../element/types";
import { degreeToRadian, radianToDegree } from "../../math"; import { degreeToRadian, radianToDegree } from "../../math";
import { angleIcon } from "../icons"; import { angleIcon } from "../icons";
@@ -27,8 +27,9 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
scene, scene,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0]; const origElement = originalElements[0];
if (origElement) { if (origElement && !isElbowArrow(origElement)) {
const latestElement = elementsMap.get(origElement.id); const latestElement = elementsMap.get(origElement.id);
if (!latestElement) { if (!latestElement) {
return; return;
@@ -39,7 +40,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
mutateElement(latestElement, { mutateElement(latestElement, {
angle: nextAngle, angle: nextAngle,
}); });
updateBindings(latestElement, elementsMap); updateBindings(latestElement, elementsMap, elements, scene);
const boundTextElement = getBoundTextElement(latestElement, elementsMap); const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) { if (boundTextElement && !isArrowElement(latestElement)) {
@@ -65,7 +66,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
mutateElement(latestElement, { mutateElement(latestElement, {
angle: nextAngle, angle: nextAngle,
}); });
updateBindings(latestElement, elementsMap); updateBindings(latestElement, elementsMap, elements, scene);
const boundTextElement = getBoundTextElement(latestElement, elementsMap); const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) { if (boundTextElement && !isArrowElement(latestElement)) {
@@ -31,6 +31,7 @@ const handleDimensionChange: DragInputCallbackType<
scene, scene,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0]; const origElement = originalElements[0];
if (origElement) { if (origElement) {
const keepAspectRatio = const keepAspectRatio =
@@ -61,6 +62,8 @@ const handleDimensionChange: DragInputCallbackType<
keepAspectRatio, keepAspectRatio,
origElement, origElement,
elementsMap, elementsMap,
elements,
scene,
); );
return; return;
@@ -103,6 +106,8 @@ const handleDimensionChange: DragInputCallbackType<
keepAspectRatio, keepAspectRatio,
origElement, origElement,
elementsMap, elementsMap,
elements,
scene,
); );
} }
}; };
@@ -25,9 +25,9 @@ export type DragInputCallbackType<
originalElementsMap: ElementsMap; originalElementsMap: ElementsMap;
shouldKeepAspectRatio: boolean; shouldKeepAspectRatio: boolean;
shouldChangeByStepSize: boolean; shouldChangeByStepSize: boolean;
scene: Scene;
nextValue?: number; nextValue?: number;
property: P; property: P;
scene: Scene;
originalAppState: AppState; originalAppState: AppState;
}) => void; }) => void;
@@ -122,9 +122,9 @@ const StatsDragInput = <
originalElementsMap: app.scene.getNonDeletedElementsMap(), originalElementsMap: app.scene.getNonDeletedElementsMap(),
shouldKeepAspectRatio: shouldKeepAspectRatio!!, shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: false, shouldChangeByStepSize: false,
scene,
nextValue: rounded, nextValue: rounded,
property, property,
scene,
originalAppState: appState, originalAppState: appState,
}); });
app.syncActionResult({ storeAction: StoreAction.CAPTURE }); app.syncActionResult({ storeAction: StoreAction.CAPTURE });
@@ -68,6 +68,7 @@ const resizeElementInGroup = (
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
) => { ) => {
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement); const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
const { width: oldWidth, height: oldHeight } = latestElement;
mutateElement(latestElement, updates, false); mutateElement(latestElement, updates, false);
const boundTextElement = getBoundTextElement( const boundTextElement = getBoundTextElement(
@@ -77,7 +78,7 @@ const resizeElementInGroup = (
if (boundTextElement) { if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale; const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, elementsMap, { updateBoundElements(latestElement, elementsMap, {
newSize: { width: updates.width, height: updates.height }, oldSize: { width: oldWidth, height: oldHeight },
}); });
const latestBoundTextElement = elementsMap.get(boundTextElement.id); const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) { if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
@@ -149,6 +150,7 @@ const handleDimensionChange: DragInputCallbackType<
property, property,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const atomicUnits = getAtomicUnits(originalElements, originalAppState); const atomicUnits = getAtomicUnits(originalElements, originalAppState);
if (nextValue !== undefined) { if (nextValue !== undefined) {
for (const atomicUnit of atomicUnits) { for (const atomicUnit of atomicUnits) {
@@ -227,6 +229,8 @@ const handleDimensionChange: DragInputCallbackType<
false, false,
origElement, origElement,
elementsMap, elementsMap,
elements,
scene,
false, false,
); );
} }
@@ -320,7 +324,15 @@ const handleDimensionChange: DragInputCallbackType<
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(nextWidth, nextHeight, false, origElement, elementsMap); resizeElement(
nextWidth,
nextHeight,
false,
origElement,
elementsMap,
elements,
scene,
);
} }
} }
} }
@@ -1,6 +1,7 @@
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
} from "../../element/types"; } from "../../element/types";
import { rotate } from "../../math"; import { rotate } from "../../math";
@@ -33,6 +34,7 @@ const moveElements = (
originalElements: readonly ExcalidrawElement[], originalElements: readonly ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
scene: Scene,
) => { ) => {
for (let i = 0; i < elements.length; i++) { for (let i = 0; i < elements.length; i++) {
const origElement = originalElements[i]; const origElement = originalElements[i];
@@ -60,6 +62,8 @@ const moveElements = (
newTopLeftY, newTopLeftY,
origElement, origElement,
elementsMap, elementsMap,
elements,
scene,
originalElementsMap, originalElementsMap,
false, false,
); );
@@ -71,6 +75,7 @@ const moveGroupTo = (
nextY: number, nextY: number,
originalElements: ExcalidrawElement[], originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
scene: Scene, scene: Scene,
) => { ) => {
@@ -106,6 +111,8 @@ const moveGroupTo = (
topLeftY + offsetY, topLeftY + offsetY,
origElement, origElement,
elementsMap, elementsMap,
elements,
scene,
originalElementsMap, originalElementsMap,
false, false,
); );
@@ -126,6 +133,7 @@ const handlePositionChange: DragInputCallbackType<
originalAppState, originalAppState,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
if (nextValue !== undefined) { if (nextValue !== undefined) {
for (const atomicUnit of getAtomicUnits( for (const atomicUnit of getAtomicUnits(
@@ -150,6 +158,7 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftY, newTopLeftY,
elementsInUnit.map((el) => el.original), elementsInUnit.map((el) => el.original),
elementsMap, elementsMap,
elements,
originalElementsMap, originalElementsMap,
scene, scene,
); );
@@ -180,6 +189,8 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftY, newTopLeftY,
origElement, origElement,
elementsMap, elementsMap,
elements,
scene,
originalElementsMap, originalElementsMap,
false, false,
); );
@@ -206,6 +217,7 @@ const handlePositionChange: DragInputCallbackType<
originalElements, originalElements,
elementsMap, elementsMap,
originalElementsMap, originalElementsMap,
scene,
); );
scene.triggerUpdate(); scene.triggerUpdate();
@@ -26,6 +26,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
scene, scene,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0]; const origElement = originalElements[0];
const [cx, cy] = [ const [cx, cy] = [
origElement.x + origElement.width / 2, origElement.x + origElement.width / 2,
@@ -47,6 +48,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftY, newTopLeftY,
origElement, origElement,
elementsMap, elementsMap,
elements,
scene,
originalElementsMap, originalElementsMap,
); );
return; return;
@@ -78,6 +81,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftY, newTopLeftY,
origElement, origElement,
elementsMap, elementsMap,
elements,
scene,
originalElementsMap, originalElementsMap,
); );
}; };
@@ -104,9 +109,9 @@ const Position = ({
label={property === "x" ? "X" : "Y"} label={property === "x" ? "X" : "Y"}
elements={[element]} elements={[element]}
dragInputCallback={handlePositionChange} dragInputCallback={handlePositionChange}
scene={scene}
value={value} value={value}
property={property} property={property}
scene={scene}
appState={appState} appState={appState}
/> />
); );
@@ -21,6 +21,7 @@ import type Scene from "../../scene/Scene";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App"; import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { getAtomicUnits } from "./utils"; import { getAtomicUnits } from "./utils";
import { STATS_PANELS } from "../../constants"; import { STATS_PANELS } from "../../constants";
import { isElbowArrow } from "../../element/typeChecks";
interface StatsProps { interface StatsProps {
scene: Scene; scene: Scene;
@@ -209,12 +210,14 @@ export const StatsInner = memo(
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
<Angle {!isElbowArrow(singleElement) && (
property="angle" <Angle
element={singleElement} property="angle"
scene={scene} element={singleElement}
appState={appState} scene={scene}
/> appState={appState}
/>
)}
<FontSize <FontSize
property="fontSize" property="fontSize"
element={singleElement} element={singleElement}
@@ -1,4 +1,5 @@
import { fireEvent, queryByTestId } from "@testing-library/react"; import React from "react";
import { act, fireEvent, queryByTestId } from "@testing-library/react";
import { Keyboard, Pointer, UI } from "../../tests/helpers/ui"; import { Keyboard, Pointer, UI } from "../../tests/helpers/ui";
import { getStepSizedValue } from "./utils"; import { getStepSizedValue } from "./utils";
import { import {
@@ -24,7 +25,6 @@ import { getCommonBounds, isTextElement } from "../../element";
import { API } from "../../tests/helpers/api"; import { API } from "../../tests/helpers/api";
import { actionGroup } from "../../actions"; import { actionGroup } from "../../actions";
import { isInGroup } from "../../groups"; import { isInGroup } from "../../groups";
import React from "react";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@@ -32,12 +32,6 @@ const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
let stats: HTMLElement | null = null; let stats: HTMLElement | null = null;
let elementStats: HTMLElement | null | undefined = null; let elementStats: HTMLElement | null | undefined = null;
const editInput = (input: HTMLInputElement, value: string) => {
input.focus();
fireEvent.change(input, { target: { value } });
input.blur();
};
const getStatsProperty = (label: string) => { const getStatsProperty = (label: string) => {
const elementStats = UI.queryStats()?.querySelector("#elementStats"); const elementStats = UI.queryStats()?.querySelector("#elementStats");
@@ -65,7 +59,7 @@ const testInputProperty = (
) as HTMLInputElement; ) as HTMLInputElement;
expect(input).toBeDefined(); expect(input).toBeDefined();
expect(input.value).toBe(initialValue.toString()); expect(input.value).toBe(initialValue.toString());
editInput(input, String(nextValue)); UI.updateInput(input, String(nextValue));
if (property === "angle") { if (property === "angle") {
expect(element[property]).toBe(degreeToRadian(Number(nextValue))); expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
} else if (property === "fontSize" && isTextElement(element)) { } else if (property === "fontSize" && isTextElement(element)) {
@@ -110,7 +104,7 @@ describe("binding with linear elements", () => {
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
h.elements = []; API.setElements([]);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@@ -148,7 +142,7 @@ describe("binding with linear elements", () => {
expect(linear.startBinding).not.toBe(null); expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull(); expect(inputX).not.toBeNull();
editInput(inputX, String("204")); UI.updateInput(inputX, String("204"));
expect(linear.startBinding).not.toBe(null); expect(linear.startBinding).not.toBe(null);
}); });
@@ -159,7 +153,7 @@ describe("binding with linear elements", () => {
) as HTMLInputElement; ) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null); expect(linear.startBinding).not.toBe(null);
editInput(inputAngle, String("1")); UI.updateInput(inputAngle, String("1"));
expect(linear.startBinding).not.toBe(null); expect(linear.startBinding).not.toBe(null);
}); });
@@ -171,7 +165,7 @@ describe("binding with linear elements", () => {
expect(linear.startBinding).not.toBe(null); expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull(); expect(inputX).not.toBeNull();
editInput(inputX, String("254")); UI.updateInput(inputX, String("254"));
expect(linear.startBinding).toBe(null); expect(linear.startBinding).toBe(null);
}); });
@@ -182,7 +176,7 @@ describe("binding with linear elements", () => {
) as HTMLInputElement; ) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null); expect(linear.startBinding).not.toBe(null);
editInput(inputAngle, String("45")); UI.updateInput(inputAngle, String("45"));
expect(linear.startBinding).toBe(null); expect(linear.startBinding).toBe(null);
}); });
}); });
@@ -197,7 +191,7 @@ describe("stats for a generic element", () => {
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
h.elements = []; API.setElements([]);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@@ -268,13 +262,13 @@ describe("stats for a generic element", () => {
) as HTMLInputElement; ) as HTMLInputElement;
expect(input).toBeDefined(); expect(input).toBeDefined();
expect(input.value).toBe(rectangle.width.toString()); expect(input.value).toBe(rectangle.width.toString());
editInput(input, "123.123"); UI.updateInput(input, "123.123");
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
expect(rectangle.id).toBe(rectangleId); expect(rectangle.id).toBe(rectangleId);
expect(input.value).toBe("123.12"); expect(input.value).toBe("123.12");
expect(rectangle.width).toBe(123.12); expect(rectangle.width).toBe(123.12);
editInput(input, "88.98766"); UI.updateInput(input, "88.98766");
expect(input.value).toBe("88.99"); expect(input.value).toBe("88.99");
expect(rectangle.width).toBe(88.99); expect(rectangle.width).toBe(88.99);
}); });
@@ -387,7 +381,7 @@ describe("stats for a non-generic element", () => {
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
h.elements = []; API.setElements([]);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@@ -412,9 +406,10 @@ describe("stats for a non-generic element", () => {
mouse.clickAt(20, 30); mouse.clickAt(20, 30);
const textEditorSelector = ".excalidraw-textEditorContainer > textarea"; const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const editor = await getTextEditor(textEditorSelector, true); const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello!"); updateTextEditor(editor, "Hello!");
editor.blur(); act(() => {
editor.blur();
});
const text = h.elements[0] as ExcalidrawTextElement; const text = h.elements[0] as ExcalidrawTextElement;
mouse.clickOn(text); mouse.clickOn(text);
@@ -427,7 +422,7 @@ describe("stats for a non-generic element", () => {
) as HTMLInputElement; ) as HTMLInputElement;
expect(input).toBeDefined(); expect(input).toBeDefined();
expect(input.value).toBe(text.fontSize.toString()); expect(input.value).toBe(text.fontSize.toString());
editInput(input, "36"); UI.updateInput(input, "36");
expect(text.fontSize).toBe(36); expect(text.fontSize).toBe(36);
// cannot change width or height // cannot change width or height
@@ -437,7 +432,7 @@ describe("stats for a non-generic element", () => {
expect(height).toBeUndefined(); expect(height).toBeUndefined();
// min font size is 4 // min font size is 4
editInput(input, "0"); UI.updateInput(input, "0");
expect(text.fontSize).not.toBe(0); expect(text.fontSize).not.toBe(0);
expect(text.fontSize).toBe(4); expect(text.fontSize).toBe(4);
}); });
@@ -449,8 +444,8 @@ describe("stats for a non-generic element", () => {
x: 150, x: 150,
width: 150, width: 150,
}); });
h.elements = [frame]; API.setElements([frame]);
h.setState({ API.setAppState({
selectedElementIds: { selectedElementIds: {
[frame.id]: true, [frame.id]: true,
}, },
@@ -471,9 +466,9 @@ describe("stats for a non-generic element", () => {
it("image element", () => { it("image element", () => {
const image = API.createElement({ type: "image", width: 200, height: 100 }); const image = API.createElement({ type: "image", width: 200, height: 100 });
h.elements = [image]; API.setElements([image]);
mouse.clickOn(image); mouse.clickOn(image);
h.setState({ API.setAppState({
selectedElementIds: { selectedElementIds: {
[image.id]: true, [image.id]: true,
}, },
@@ -508,7 +503,7 @@ describe("stats for a non-generic element", () => {
mutateElement(container, { mutateElement(container, {
boundElements: [{ type: "text", id: text.id }], boundElements: [{ type: "text", id: text.id }],
}); });
h.elements = [container, text]; API.setElements([container, text]);
API.setSelectedElements([container]); API.setSelectedElements([container]);
const fontSize = getStatsProperty("F")?.querySelector( const fontSize = getStatsProperty("F")?.querySelector(
@@ -516,7 +511,7 @@ describe("stats for a non-generic element", () => {
) as HTMLInputElement; ) as HTMLInputElement;
expect(fontSize).toBeDefined(); expect(fontSize).toBeDefined();
editInput(fontSize, "40"); UI.updateInput(fontSize, "40");
expect(text.fontSize).toBe(40); expect(text.fontSize).toBe(40);
}); });
@@ -533,7 +528,7 @@ describe("stats for multiple elements", () => {
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
h.elements = []; API.setElements([]);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@@ -566,7 +561,7 @@ describe("stats for multiple elements", () => {
mouse.down(-100, -100); mouse.down(-100, -100);
mouse.up(125, 145); mouse.up(125, 145);
h.setState({ API.setAppState({
selectedElementIds: h.elements.reduce((acc, el) => { selectedElementIds: h.elements.reduce((acc, el) => {
acc[el.id] = true; acc[el.id] = true;
return acc; return acc;
@@ -588,12 +583,12 @@ describe("stats for multiple elements", () => {
) as HTMLInputElement; ) as HTMLInputElement;
expect(angle.value).toBe("0"); expect(angle.value).toBe("0");
editInput(width, "250"); UI.updateInput(width, "250");
h.elements.forEach((el) => { h.elements.forEach((el) => {
expect(el.width).toBe(250); expect(el.width).toBe(250);
}); });
editInput(height, "450"); UI.updateInput(height, "450");
h.elements.forEach((el) => { h.elements.forEach((el) => {
expect(el.height).toBe(450); expect(el.height).toBe(450);
}); });
@@ -605,9 +600,10 @@ describe("stats for multiple elements", () => {
mouse.clickAt(20, 30); mouse.clickAt(20, 30);
const textEditorSelector = ".excalidraw-textEditorContainer > textarea"; const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const editor = await getTextEditor(textEditorSelector, true); const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello!"); updateTextEditor(editor, "Hello!");
editor.blur(); act(() => {
editor.blur();
});
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(); mouse.down();
@@ -619,12 +615,12 @@ describe("stats for multiple elements", () => {
width: 150, width: 150,
}); });
h.elements = [...h.elements, frame]; API.setElements([...h.elements, frame]);
const text = h.elements.find((el) => el.type === "text"); const text = h.elements.find((el) => el.type === "text");
const rectangle = h.elements.find((el) => el.type === "rectangle"); const rectangle = h.elements.find((el) => el.type === "rectangle");
h.setState({ API.setAppState({
selectedElementIds: h.elements.reduce((acc, el) => { selectedElementIds: h.elements.reduce((acc, el) => {
acc[el.id] = true; acc[el.id] = true;
return acc; return acc;
@@ -657,13 +653,13 @@ describe("stats for multiple elements", () => {
expect(fontSize).toBeDefined(); expect(fontSize).toBeDefined();
// changing width does not affect text // changing width does not affect text
editInput(width, "200"); UI.updateInput(width, "200");
expect(rectangle?.width).toBe(200); expect(rectangle?.width).toBe(200);
expect(frame.width).toBe(200); expect(frame.width).toBe(200);
expect(text?.width).not.toBe(200); expect(text?.width).not.toBe(200);
editInput(angle, "40"); UI.updateInput(angle, "40");
const angleInRadian = degreeToRadian(40); const angleInRadian = degreeToRadian(40);
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4); expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
@@ -686,7 +682,7 @@ describe("stats for multiple elements", () => {
mouse.click(); mouse.click();
}); });
h.app.actionManager.executeAction(actionGroup); API.executeAction(actionGroup);
}; };
createAndSelectGroup(); createAndSelectGroup();
@@ -703,7 +699,7 @@ describe("stats for multiple elements", () => {
expect(x).toBeDefined(); expect(x).toBeDefined();
expect(Number(x.value)).toBe(x1); expect(Number(x.value)).toBe(x1);
editInput(x, "300"); UI.updateInput(x, "300");
expect(h.elements[0].x).toBe(300); expect(h.elements[0].x).toBe(300);
expect(h.elements[1].x).toBe(400); expect(h.elements[1].x).toBe(400);
@@ -716,7 +712,7 @@ describe("stats for multiple elements", () => {
expect(y).toBeDefined(); expect(y).toBeDefined();
expect(Number(y.value)).toBe(y1); expect(Number(y.value)).toBe(y1);
editInput(y, "200"); UI.updateInput(y, "200");
expect(h.elements[0].y).toBe(200); expect(h.elements[0].y).toBe(200);
expect(h.elements[1].y).toBe(300); expect(h.elements[1].y).toBe(300);
@@ -734,20 +730,20 @@ describe("stats for multiple elements", () => {
expect(height).toBeDefined(); expect(height).toBeDefined();
expect(Number(height.value)).toBe(200); expect(Number(height.value)).toBe(200);
editInput(width, "400"); UI.updateInput(width, "400");
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup); [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
let newGroupWidth = x2 - x1; let newGroupWidth = x2 - x1;
expect(newGroupWidth).toBeCloseTo(400, 4); expect(newGroupWidth).toBeCloseTo(400, 4);
editInput(width, "300"); UI.updateInput(width, "300");
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup); [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
newGroupWidth = x2 - x1; newGroupWidth = x2 - x1;
expect(newGroupWidth).toBeCloseTo(300, 4); expect(newGroupWidth).toBeCloseTo(300, 4);
editInput(height, "500"); UI.updateInput(height, "500");
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup); [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
const newGroupHeight = y2 - y1; const newGroupHeight = y2 - y1;
+23 -3
View File
@@ -31,6 +31,7 @@ import {
isInGroup, isInGroup,
} from "../../groups"; } from "../../groups";
import { rotate } from "../../math"; import { rotate } from "../../math";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import { getFontString } from "../../utils"; import { getFontString } from "../../utils";
@@ -124,6 +125,8 @@ export const resizeElement = (
keepAspectRatio: boolean, keepAspectRatio: boolean,
origElement: ExcalidrawElement, origElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
shouldInformMutation = true, shouldInformMutation = true,
) => { ) => {
const latestElement = elementsMap.get(origElement.id); const latestElement = elementsMap.get(origElement.id);
@@ -146,6 +149,8 @@ export const resizeElement = (
nextHeight = Math.max(nextHeight, minHeight); nextHeight = Math.max(nextHeight, minHeight);
} }
const { width: oldWidth, height: oldHeight } = latestElement;
mutateElement( mutateElement(
latestElement, latestElement,
{ {
@@ -164,7 +169,7 @@ export const resizeElement = (
}, },
shouldInformMutation, shouldInformMutation,
); );
updateBindings(latestElement, elementsMap, { updateBindings(latestElement, elementsMap, elements, scene, {
newSize: { newSize: {
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
@@ -193,6 +198,10 @@ export const resizeElement = (
} }
} }
updateBoundElements(latestElement, elementsMap, {
oldSize: { width: oldWidth, height: oldHeight },
});
if (boundTextElement && boundTextFont) { if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, { mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize, fontSize: boundTextFont.fontSize,
@@ -206,6 +215,8 @@ export const moveElement = (
newTopLeftY: number, newTopLeftY: number,
originalElement: ExcalidrawElement, originalElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
shouldInformMutation = true, shouldInformMutation = true,
) => { ) => {
@@ -244,7 +255,7 @@ export const moveElement = (
}, },
shouldInformMutation, shouldInformMutation,
); );
updateBindings(latestElement, elementsMap); updateBindings(latestElement, elementsMap, elements, scene);
const boundTextElement = getBoundTextElement( const boundTextElement = getBoundTextElement(
originalElement, originalElement,
@@ -288,13 +299,22 @@ export const getAtomicUnits = (
export const updateBindings = ( export const updateBindings = (
latestElement: ExcalidrawElement, latestElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
options?: { options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[]; simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number }; newSize?: { width: number; height: number };
}, },
) => { ) => {
if (isLinearElement(latestElement)) { if (isLinearElement(latestElement)) {
bindOrUnbindLinearElements([latestElement], elementsMap, true, []); bindOrUnbindLinearElements(
[latestElement],
elementsMap,
elements,
scene,
true,
[],
);
} else { } else {
updateBoundElements(latestElement, elementsMap, options); updateBoundElements(latestElement, elementsMap, options);
} }
@@ -139,7 +139,7 @@ $verticalBreakpoint: 861px;
.ttd-dialog-output-error { .ttd-dialog-output-error {
color: red; color: red;
font-weight: 800; font-weight: 700;
font-size: 30px; font-size: 30px;
word-break: break-word; word-break: break-word;
overflow: auto; overflow: auto;
+2 -64
View File
@@ -5,10 +5,11 @@
--avatarList-gap: 0.625rem; --avatarList-gap: 0.625rem;
--userList-padding: var(--space-factor); --userList-padding: var(--space-factor);
.UserList-wrapper { .UserList__wrapper {
display: flex; display: flex;
width: 100%; width: 100%;
justify-content: flex-end; justify-content: flex-end;
align-items: center;
pointer-events: none !important; pointer-events: none !important;
} }
@@ -21,10 +22,6 @@
align-items: center; align-items: center;
gap: var(--avatarList-gap); gap: var(--avatarList-gap);
&:empty {
display: none;
}
box-sizing: border-box; box-sizing: border-box;
--max-size: calc( --max-size: calc(
@@ -157,66 +154,7 @@
} }
.UserList__collaborators { .UserList__collaborators {
position: static;
top: auto; top: auto;
margin-top: 0;
max-height: 50vh; max-height: 50vh;
overflow-y: auto;
padding: 0.25rem 0.5rem;
border-top: 1px solid var(--userlist-collaborators-border-color);
border-bottom: 1px solid var(--userlist-collaborators-border-color);
&__empty {
color: var(--color-gray-60);
font-size: 0.75rem;
line-height: 150%;
padding: 0.5rem 0;
}
}
.UserList__hint {
padding: 0.5rem 0.75rem;
overflow: hidden;
text-align: center;
color: var(--userlist-hint-text-color);
font-size: 0.75rem;
line-height: 150%;
}
.UserList__search-wrapper {
position: relative;
height: 2.5rem;
svg {
position: absolute;
top: 50%;
transform: translateY(-50%);
left: 0.75rem;
width: 1.25rem;
height: 1.25rem;
color: var(--color-gray-40);
z-index: 1;
}
}
.UserList__search {
position: absolute;
top: 0;
left: 0;
width: 100%;
box-sizing: border-box;
border: 0 !important;
border-radius: 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;
}
} }
} }
+43 -50
View File
@@ -9,11 +9,12 @@ import type { ActionManager } from "../actions/manager";
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { Island } from "./Island"; import { Island } from "./Island";
import { searchIcon } from "./icons"; import { QuickSearch } from "./QuickSearch";
import { t } from "../i18n"; import { t } from "../i18n";
import { isShallowEqual } from "../utils"; import { isShallowEqual } from "../utils";
import { supportsResizeObserver } from "../constants"; import { supportsResizeObserver } from "../constants";
import type { MarkRequired } from "../utility-types"; import type { MarkRequired } from "../utility-types";
import { ScrollableList } from "./ScrollableList";
export type GoToCollaboratorComponentProps = { export type GoToCollaboratorComponentProps = {
socketId: SocketId; socketId: SocketId;
@@ -40,7 +41,7 @@ const ConditionalTooltipWrapper = ({
shouldWrap ? ( shouldWrap ? (
<Tooltip label={username || "Unknown user"}>{children}</Tooltip> <Tooltip label={username || "Unknown user"}>{children}</Tooltip>
) : ( ) : (
<React.Fragment>{children}</React.Fragment> <>{children}</>
); );
const renderCollaborator = ({ const renderCollaborator = ({
@@ -128,6 +129,10 @@ export const UserList = React.memo(
).filter((collaborator) => collaborator.username?.trim()); ).filter((collaborator) => collaborator.username?.trim());
const [searchTerm, setSearchTerm] = React.useState(""); const [searchTerm, setSearchTerm] = React.useState("");
const filteredCollaborators = uniqueCollaboratorsArray.filter(
(collaborator) =>
collaborator.username?.toLowerCase().includes(searchTerm),
);
const userListWrapper = React.useRef<HTMLDivElement | null>(null); const userListWrapper = React.useRef<HTMLDivElement | null>(null);
@@ -161,14 +166,6 @@ export const UserList = React.memo(
const [maxAvatars, setMaxAvatars] = React.useState(DEFAULT_MAX_AVATARS); const [maxAvatars, setMaxAvatars] = React.useState(DEFAULT_MAX_AVATARS);
const searchTermNormalized = searchTerm.trim().toLowerCase();
const filteredCollaborators = searchTermNormalized
? uniqueCollaboratorsArray.filter((collaborator) =>
collaborator.username?.toLowerCase().includes(searchTerm),
)
: uniqueCollaboratorsArray;
const firstNCollaborators = uniqueCollaboratorsArray.slice( const firstNCollaborators = uniqueCollaboratorsArray.slice(
0, 0,
maxAvatars - 1, maxAvatars - 1,
@@ -197,7 +194,7 @@ export const UserList = React.memo(
)} )}
</div> </div>
) : ( ) : (
<div className="UserList-wrapper" ref={userListWrapper}> <div className="UserList__wrapper" ref={userListWrapper}>
<div <div
className={clsx("UserList", className)} className={clsx("UserList", className)}
style={{ [`--max-avatars` as any]: maxAvatars }} style={{ [`--max-avatars` as any]: maxAvatars }}
@@ -205,13 +202,7 @@ export const UserList = React.memo(
{firstNAvatarsJSX} {firstNAvatarsJSX}
{uniqueCollaboratorsArray.length > maxAvatars - 1 && ( {uniqueCollaboratorsArray.length > maxAvatars - 1 && (
<Popover.Root <Popover.Root>
onOpenChange={(isOpen) => {
if (!isOpen) {
setSearchTerm("");
}
}}
>
<Popover.Trigger className="UserList__more"> <Popover.Trigger className="UserList__more">
+{uniqueCollaboratorsArray.length - maxAvatars + 1} +{uniqueCollaboratorsArray.length - maxAvatars + 1}
</Popover.Trigger> </Popover.Trigger>
@@ -224,41 +215,43 @@ export const UserList = React.memo(
align="end" align="end"
sideOffset={10} sideOffset={10}
> >
<Island style={{ overflow: "hidden" }}> <Island padding={2}>
{uniqueCollaboratorsArray.length >= {uniqueCollaboratorsArray.length >=
SHOW_COLLABORATORS_FILTER_AT && ( SHOW_COLLABORATORS_FILTER_AT && (
<div className="UserList__search-wrapper"> <QuickSearch
{searchIcon} placeholder={t("quickSearch.placeholder")}
<input onChange={setSearchTerm}
className="UserList__search" />
type="text"
placeholder={t("userList.search.placeholder")}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
}}
/>
</div>
)} )}
<div className="dropdown-menu UserList__collaborators"> <ScrollableList
{filteredCollaborators.length === 0 && ( className={"dropdown-menu UserList__collaborators"}
<div className="UserList__collaborators__empty"> placeholder={t("userList.empty")}
{t("userList.search.empty")} >
</div> {/* The list checks for `Children.count()`, hence defensively returning empty list */}
)} {filteredCollaborators.length > 0
<div className="UserList__hint"> ? [
{t("userList.hint.text")} <div className="hint">{t("userList.hint.text")}</div>,
</div> filteredCollaborators.map((collaborator) =>
{filteredCollaborators.map((collaborator) => renderCollaborator({
renderCollaborator({ actionManager,
actionManager, collaborator,
collaborator, socketId: collaborator.socketId,
socketId: collaborator.socketId, withName: true,
withName: true, isBeingFollowed:
isBeingFollowed: collaborator.socketId === userToFollow, collaborator.socketId === userToFollow,
}), }),
)} ),
</div> ]
: []}
</ScrollableList>
<Popover.Arrow
width={20}
height={10}
style={{
fill: "var(--popup-bg-color)",
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
}}
/>
</Island> </Island>
</Popover.Content> </Popover.Content>
</Popover.Root> </Popover.Root>
@@ -105,6 +105,7 @@ const getRelevantAppStateProps = (
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
frameToHighlight: appState.frameToHighlight, frameToHighlight: appState.frameToHighlight,
editingGroupId: appState.editingGroupId, editingGroupId: appState.editingGroupId,
currentHoveredFontFamily: appState.currentHoveredFontFamily,
}); });
const areEqual = ( const areEqual = (
@@ -4,7 +4,7 @@
.dropdown-menu { .dropdown-menu {
position: absolute; position: absolute;
top: 100%; top: 100%;
margin-top: 0.25rem; margin-top: 0.5rem;
&--mobile { &--mobile {
left: 0; left: 0;
@@ -35,21 +35,69 @@
.dropdown-menu-item-base { .dropdown-menu-item-base {
display: flex; display: flex;
padding: 0 0.625rem;
column-gap: 0.625rem; column-gap: 0.625rem;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-on-surface); color: var(--color-on-surface);
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
font-weight: normal; font-weight: 400;
font-family: inherit; font-family: inherit;
} }
&.manual-hover {
// disable built-in hover due to keyboard navigation
.dropdown-menu-item {
&:hover {
background-color: transparent;
}
&--hovered {
background-color: var(--button-hover-bg) !important;
}
&--selected {
background-color: var(--color-primary-light) !important;
}
}
}
&.fonts {
margin-top: 1rem;
// display max 7 items per list, where each has 2rem (2.25) height and 1px margin top & bottom
// count in 2 groups, where each allocates 1.3*0.75rem font-size and 0.5rem margin bottom, plus one extra 1rem margin top
max-height: calc(7 * (2rem + 2px) + 2 * (0.5rem + 1.3 * 0.75rem) + 1rem);
@media screen and (min-width: 1921px) {
max-height: calc(
7 * (2.25rem + 2px) + 2 * (0.5rem + 1.3 * 0.75rem) + 1rem
);
}
.dropdown-menu-item-base {
display: inline-flex;
}
.dropdown-menu-group:not(:first-child) {
margin-top: 1rem;
}
.dropdown-menu-group-title {
font-size: 0.75rem;
text-align: left;
font-weight: 400;
margin: 0 0 0.5rem;
line-height: 1.3;
}
}
.dropdown-menu-item { .dropdown-menu-item {
height: 2rem;
margin: 1px;
padding: 0 0.5rem;
width: calc(100% - 2px);
background-color: transparent; background-color: transparent;
border: 1px solid transparent; border: 1px solid transparent;
align-items: center; align-items: center;
height: 2rem;
cursor: pointer; cursor: pointer;
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
@@ -57,11 +105,6 @@
height: 2.25rem; height: 2.25rem;
} }
&--selected {
background: var(--color-primary-light);
--icon-fill-color: var(--color-primary-darker);
}
&__text { &__text {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -83,6 +126,11 @@
} }
} }
&--selected {
background: var(--color-primary-light);
--icon-fill-color: var(--color-primary-darker);
}
&:hover { &:hover {
background-color: var(--button-hover-bg); background-color: var(--button-hover-bg);
text-decoration: none; text-decoration: none;
@@ -1,7 +1,13 @@
import React from "react";
import { Excalidraw } from "../../index"; import { Excalidraw } from "../../index";
import { KEYS } from "../../keys"; import { KEYS } from "../../keys";
import { Keyboard } from "../../tests/helpers/ui"; import { Keyboard } from "../../tests/helpers/ui";
import { render, waitFor, getByTestId } from "../../tests/test-utils"; import {
render,
waitFor,
getByTestId,
fireEvent,
} from "../../tests/test-utils";
describe("Test <DropdownMenu/>", () => { describe("Test <DropdownMenu/>", () => {
it("should", async () => { it("should", async () => {
@@ -9,7 +15,7 @@ describe("Test <DropdownMenu/>", () => {
expect(window.h.state.openMenu).toBe(null); expect(window.h.state.openMenu).toBe(null);
getByTestId(container, "main-menu-trigger").click(); fireEvent.click(getByTestId(container, "main-menu-trigger"));
expect(window.h.state.openMenu).toBe("canvas"); expect(window.h.state.openMenu).toBe("canvas");
await waitFor(() => { await waitFor(() => {
@@ -1,37 +1,62 @@
import React from "react"; import React, { useEffect, useRef } from "react";
import { import {
getDropdownMenuItemClassName, getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick, useHandleDropdownMenuItemClick,
} from "./common"; } from "./common";
import MenuItemContent from "./DropdownMenuItemContent"; import MenuItemContent from "./DropdownMenuItemContent";
import { useExcalidrawAppState } from "../App";
import { THEME } from "../../constants";
import type { ValueOf } from "../../utility-types";
const DropdownMenuItem = ({ const DropdownMenuItem = ({
icon, icon,
onSelect, value,
order,
children, children,
shortcut, shortcut,
className, className,
hovered,
selected, selected,
textStyle,
onSelect,
onClick,
...rest ...rest
}: { }: {
icon?: JSX.Element; icon?: JSX.Element;
onSelect: (event: Event) => void; value?: string | number | undefined;
order?: number;
onSelect?: (event: Event) => void;
children: React.ReactNode; children: React.ReactNode;
shortcut?: string; shortcut?: string;
hovered?: boolean;
selected?: boolean; selected?: boolean;
textStyle?: React.CSSProperties;
className?: string; className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => { } & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect); const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect);
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (hovered) {
if (order === 0) {
// scroll into the first item differently, so it's visible what is above (i.e. group title)
ref.current?.scrollIntoView({ block: "end" });
} else {
ref.current?.scrollIntoView({ block: "nearest" });
}
}
}, [hovered, order]);
return ( return (
<button <button
{...rest} {...rest}
ref={ref}
value={value}
onClick={handleClick} onClick={handleClick}
type="button" className={getDropdownMenuItemClassName(className, selected, hovered)}
className={getDropdownMenuItemClassName(className, selected)}
title={rest.title ?? rest["aria-label"]} title={rest.title ?? rest["aria-label"]}
> >
<MenuItemContent icon={icon} shortcut={shortcut}> <MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
{children} {children}
</MenuItemContent> </MenuItemContent>
</button> </button>
@@ -39,24 +64,53 @@ const DropdownMenuItem = ({
}; };
DropdownMenuItem.displayName = "DropdownMenuItem"; DropdownMenuItem.displayName = "DropdownMenuItem";
export const DropDownMenuItemBadgeType = {
GREEN: "green",
RED: "red",
BLUE: "blue",
} as const;
export const DropDownMenuItemBadge = ({ export const DropDownMenuItemBadge = ({
type = DropDownMenuItemBadgeType.BLUE,
children, children,
}: { }: {
type?: ValueOf<typeof DropDownMenuItemBadgeType>;
children: React.ReactNode; children: React.ReactNode;
}) => { }) => {
return ( const { theme } = useExcalidrawAppState();
<div const style = {
style={{ display: "inline-flex",
display: "inline-flex", marginLeft: "auto",
marginLeft: "auto", padding: "2px 4px",
padding: "2px 4px", borderRadius: 6,
fontSize: 9,
fontFamily: "Cascadia, monospace",
border: theme === THEME.LIGHT ? "1.5px solid white" : "none",
};
switch (type) {
case DropDownMenuItemBadgeType.GREEN:
Object.assign(style, {
backgroundColor: "var(--background-color-badge)",
color: "var(--color-badge)",
});
break;
case DropDownMenuItemBadgeType.RED:
Object.assign(style, {
backgroundColor: "pink",
color: "darkred",
});
break;
case DropDownMenuItemBadgeType.BLUE:
default:
Object.assign(style, {
background: "var(--color-promo)", background: "var(--color-promo)",
color: "var(--color-surface-lowest)", color: "var(--color-surface-lowest)",
borderRadius: 6, });
fontSize: 9, }
fontFamily: "Cascadia, monospace",
}} return (
> <div className="DropDownMenuItemBadge" style={style}>
{children} {children}
</div> </div>
); );
@@ -1,19 +1,23 @@
import { useDevice } from "../App"; import { useDevice } from "../App";
const MenuItemContent = ({ const MenuItemContent = ({
textStyle,
icon, icon,
shortcut, shortcut,
children, children,
}: { }: {
icon?: JSX.Element; icon?: JSX.Element;
shortcut?: string; shortcut?: string;
textStyle?: React.CSSProperties;
children: React.ReactNode; children: React.ReactNode;
}) => { }) => {
const device = useDevice(); const device = useDevice();
return ( return (
<> <>
<div className="dropdown-menu-item__icon">{icon}</div> {icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<div className="dropdown-menu-item__text">{children}</div> <div style={textStyle} className="dropdown-menu-item__text">
{children}
</div>
{shortcut && !device.editor.isMobile && ( {shortcut && !device.editor.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div> <div className="dropdown-menu-item__shortcut">{shortcut}</div>
)} )}
@@ -9,9 +9,11 @@ export const DropdownMenuContentPropsContext = React.createContext<{
export const getDropdownMenuItemClassName = ( export const getDropdownMenuItemClassName = (
className = "", className = "",
selected = false, selected = false,
hovered = false,
) => { ) => {
return `dropdown-menu-item dropdown-menu-item-base ${className} ${ return `dropdown-menu-item dropdown-menu-item-base ${className}
selected ? "dropdown-menu-item--selected" : "" ${selected ? "dropdown-menu-item--selected" : ""} ${
hovered ? "dropdown-menu-item--hovered" : ""
}`.trim(); }`.trim();
}; };
@@ -1,3 +1,4 @@
import React from "react";
import { render, queryAllByTestId } from "../../tests/test-utils"; import { render, queryAllByTestId } from "../../tests/test-utils";
import { Excalidraw, MainMenu } from "../../index"; import { Excalidraw, MainMenu } from "../../index";
@@ -211,7 +211,7 @@ export const Hyperlink = ({
const { x, y } = getCoordsForPopover(element, appState, elementsMap); const { x, y } = getCoordsForPopover(element, appState, elementsMap);
if ( if (
appState.contextMenu || appState.contextMenu ||
appState.draggingElement || appState.selectedElementsAreBeingDragged ||
appState.resizingElement || appState.resizingElement ||
appState.isRotating || appState.isRotating ||
appState.openMenu || appState.openMenu ||
+50
View File
@@ -1438,6 +1438,27 @@ export const fontSizeIcon = createIcon(
tablerIconProps, tablerIconProps,
); );
export const FontFamilyHeadingIcon = createIcon(
<>
<g
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 12h10" />
<path d="M7 5v14" />
<path d="M17 5v14" />
<path d="M15 19h4" />
<path d="M15 5h4" />
<path d="M5 19h4" />
<path d="M5 5h4" />
</g>
</>,
tablerIconProps,
);
export const FontFamilyNormalIcon = createIcon( export const FontFamilyNormalIcon = createIcon(
<> <>
<g <g
@@ -2074,6 +2095,35 @@ export const lineEditorIcon = createIcon(
tablerIconProps, tablerIconProps,
); );
// arrow-up-right (modified)
export const sharpArrowIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 18l12 -12" />
<path d="M18 10v-4h-4" />
</g>,
tablerIconProps,
);
// arrow-guide (modified)
export const elbowArrowIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4,19L10,19C11.097,19 12,18.097 12,17L12,9C12,7.903 12.903,7 14,7L21,7" />
<path d="M18 4l3 3l-3 3" />
</g>,
tablerIconProps,
);
// arrow-ramp-right-2 (heavily modified)
export const roundArrowIcon = createIcon(
<g>
<path d="M16,12L20,9L16,6" />
<path d="M6 20c0 -6.075 4.925 -11 11 -11h3" />
</g>,
tablerIconProps,
);
export const collapseDownIcon = createIcon( export const collapseDownIcon = createIcon(
<g> <g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
@@ -109,7 +109,7 @@ Center.displayName = "Center";
const Logo = ({ children }: { children?: React.ReactNode }) => { const Logo = ({ children }: { children?: React.ReactNode }) => {
return ( return (
<div className="welcome-screen-center__logo virgil welcome-screen-decor"> <div className="welcome-screen-center__logo excalifont welcome-screen-decor">
{children || <ExcalidrawLogo withText />} {children || <ExcalidrawLogo withText />}
</div> </div>
); );
@@ -118,7 +118,7 @@ Logo.displayName = "Logo";
const Heading = ({ children }: { children: React.ReactNode }) => { const Heading = ({ children }: { children: React.ReactNode }) => {
return ( return (
<div className="welcome-screen-center__heading welcome-screen-decor virgil"> <div className="welcome-screen-center__heading welcome-screen-decor excalifont">
{children} {children}
</div> </div>
); );
@@ -10,7 +10,7 @@ const MenuHint = ({ children }: { children?: React.ReactNode }) => {
const { WelcomeScreenMenuHintTunnel } = useTunnels(); const { WelcomeScreenMenuHintTunnel } = useTunnels();
return ( return (
<WelcomeScreenMenuHintTunnel.In> <WelcomeScreenMenuHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu"> <div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
{WelcomeScreenMenuArrow} {WelcomeScreenMenuArrow}
<div className="welcome-screen-decor-hint__label"> <div className="welcome-screen-decor-hint__label">
{children || t("welcomeScreen.defaults.menuHint")} {children || t("welcomeScreen.defaults.menuHint")}
@@ -25,7 +25,7 @@ const ToolbarHint = ({ children }: { children?: React.ReactNode }) => {
const { WelcomeScreenToolbarHintTunnel } = useTunnels(); const { WelcomeScreenToolbarHintTunnel } = useTunnels();
return ( return (
<WelcomeScreenToolbarHintTunnel.In> <WelcomeScreenToolbarHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar"> <div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
<div className="welcome-screen-decor-hint__label"> <div className="welcome-screen-decor-hint__label">
{children || t("welcomeScreen.defaults.toolbarHint")} {children || t("welcomeScreen.defaults.toolbarHint")}
</div> </div>
@@ -40,7 +40,7 @@ const HelpHint = ({ children }: { children?: React.ReactNode }) => {
const { WelcomeScreenHelpHintTunnel } = useTunnels(); const { WelcomeScreenHelpHintTunnel } = useTunnels();
return ( return (
<WelcomeScreenHelpHintTunnel.In> <WelcomeScreenHelpHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help"> <div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
<div>{children || t("welcomeScreen.defaults.helpHint")}</div> <div>{children || t("welcomeScreen.defaults.helpHint")}</div>
{WelcomeScreenHelpArrow} {WelcomeScreenHelpArrow}
</div> </div>
@@ -1,6 +1,6 @@
.excalidraw { .excalidraw {
.virgil { .excalifont {
font-family: "Virgil"; font-family: "Excalifont";
} }
// WelcomeSreen common // WelcomeSreen common
+22 -4
View File
@@ -1,5 +1,5 @@
import cssVariables from "./css/variables.module.scss"; import cssVariables from "./css/variables.module.scss";
import type { AppProps } from "./types"; import type { AppProps, AppState } from "./types";
import type { ExcalidrawElement, FontFamilyValues } from "./element/types"; import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
import { COLOR_PALETTE } from "./colors"; import { COLOR_PALETTE } from "./colors";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform); export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
@@ -114,12 +114,24 @@ export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left", SHAPE_ACTIONS_MENU: "App-menu__left",
}; };
// 1-based in case we ever do `if(element.fontFamily)` /**
* // TODO: shouldn't be really `const`, likely neither have integers as values, due to value for the custom fonts, which should likely be some hash.
*
* Let's think this through and consider:
* - https://developer.mozilla.org/en-US/docs/Web/CSS/generic-family
* - https://drafts.csswg.org/css-fonts-4/#font-family-prop
* - https://learn.microsoft.com/en-us/typography/opentype/spec/ibmfc
*/
export const FONT_FAMILY = { export const FONT_FAMILY = {
Virgil: 1, Virgil: 1,
Helvetica: 2, Helvetica: 2,
Cascadia: 3, Cascadia: 3,
Assistant: 4, // leave 4 unused as it was historically used for Assistant (which we don't use anymore) or custom font (Obsidian)
Excalifont: 5,
Nunito: 6,
"Lilita One": 7,
"Comic Shanns": 8,
"Liberation Sans": 9,
}; };
export const THEME = { export const THEME = {
@@ -147,7 +159,7 @@ export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const MIN_FONT_SIZE = 1; export const MIN_FONT_SIZE = 1;
export const DEFAULT_FONT_SIZE = 20; export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil; export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
export const DEFAULT_TEXT_ALIGN = "left"; export const DEFAULT_TEXT_ALIGN = "left";
export const DEFAULT_VERTICAL_ALIGN = "top"; export const DEFAULT_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}"; export const DEFAULT_VERSION = "{version}";
@@ -409,3 +421,9 @@ export const DEFAULT_FILENAME = "Untitled";
export const STATS_PANELS = { generalStats: 1, elementProperties: 2 } as const; export const STATS_PANELS = { generalStats: 1, elementProperties: 2 } as const;
export const MIN_WIDTH_OR_HEIGHT = 1; export const MIN_WIDTH_OR_HEIGHT = 1;
export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
sharp: "sharp",
round: "round",
elbow: "elbow",
};
+9 -12
View File
@@ -152,7 +152,7 @@ body.excalidraw-cursor-resize * {
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-primary-color); color: var(--text-primary-color);
font-weight: normal; font-weight: 400;
display: block; display: block;
} }
@@ -227,14 +227,7 @@ body.excalidraw-cursor-resize * {
label, label,
button, button,
.zIndexButton { .zIndexButton {
@include outlineButtonStyles; @include outlineButtonIconStyles;
padding: 0;
svg {
width: var(--default-icon-size);
height: var(--default-icon-size);
}
} }
} }
@@ -394,7 +387,7 @@ body.excalidraw-cursor-resize * {
.App-menu__left { .App-menu__left {
overflow-y: auto; overflow-y: auto;
padding: 0.75rem; padding: 0.75rem;
width: 202px; width: 200px;
box-sizing: border-box; box-sizing: border-box;
position: absolute; position: absolute;
} }
@@ -585,7 +578,7 @@ body.excalidraw-cursor-resize * {
// use custom, minimalistic scrollbar // use custom, minimalistic scrollbar
// (doesn't work in Firefox) // (doesn't work in Firefox)
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 3px; width: 4px;
height: 3px; height: 3px;
} }
@@ -664,6 +657,10 @@ body.excalidraw-cursor-resize * {
--button-hover-bg: #363541; --button-hover-bg: #363541;
--button-bg: var(--color-surface-high); --button-bg: var(--color-surface-high);
} }
.buttonList {
padding: 0.25rem 0;
}
} }
.excalidraw__paragraph { .excalidraw__paragraph {
@@ -757,7 +754,7 @@ body.excalidraw-cursor-resize * {
padding: 1rem 1.6rem; padding: 1rem 1.6rem;
border-radius: 12px; border-radius: 12px;
color: #fff; color: #fff;
font-weight: bold; font-weight: 700;
letter-spacing: 0.6px; letter-spacing: 0.6px;
font-family: "Assistant"; font-family: "Assistant";
} }
+3
View File
@@ -151,6 +151,9 @@
--color-border-outline-variant: #c5c5d0; --color-border-outline-variant: #c5c5d0;
--color-surface-primary-container: #e0dfff; --color-surface-primary-container: #e0dfff;
--color-badge: #0b6513;
--background-color-badge: #d3ffd2;
&.theme--dark { &.theme--dark {
&.theme--dark-background-none { &.theme--dark-background-none {
background: none; background: none;
+11 -1
View File
@@ -124,6 +124,16 @@
} }
} }
@mixin outlineButtonIconStyles {
@include outlineButtonStyles;
padding: 0;
svg {
width: var(--default-icon-size);
height: var(--default-icon-size);
}
}
@mixin avatarStyles { @mixin avatarStyles {
width: var(--avatar-size, 1.5rem); width: var(--avatar-size, 1.5rem);
height: var(--avatar-size, 1.5rem); height: var(--avatar-size, 1.5rem);
@@ -135,7 +145,7 @@
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 800; font-weight: 700;
line-height: 1; line-height: 1;
color: var(--color-gray-90); color: var(--color-gray-90);
flex: 0 0 auto; flex: 0 0 auto;
@@ -84,9 +84,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "ellipse-1", "elementId": "ellipse-1",
"fixedPoint": null,
"focus": -0.008153707962747813, "focus": -0.008153707962747813,
"gap": 1, "gap": 1,
}, },
@@ -117,6 +119,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id47", "elementId": "id47",
"fixedPoint": null,
"focus": -0.08139534883720931, "focus": -0.08139534883720931,
"gap": 1, "gap": 1,
}, },
@@ -139,9 +142,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "ellipse-1", "elementId": "ellipse-1",
"fixedPoint": null,
"focus": 0.10666666666666667, "focus": 0.10666666666666667,
"gap": 3.834326468444573, "gap": 3.834326468444573,
}, },
@@ -172,6 +177,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "diamond-1", "elementId": "diamond-1",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@@ -239,7 +245,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"containerId": null, "containerId": null,
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -285,7 +291,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"containerId": null, "containerId": null,
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -328,9 +334,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
}, },
], ],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "text-2", "elementId": "text-2",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 205, "gap": 205,
}, },
@@ -361,6 +369,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "text-1", "elementId": "text-1",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@@ -386,7 +395,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"containerId": "id48", "containerId": "id48",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -429,9 +438,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
}, },
], ],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id40", "elementId": "id40",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@@ -462,6 +473,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id39", "elementId": "id39",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@@ -487,7 +499,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"containerId": "id37", "containerId": "id37",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -604,9 +616,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
}, },
], ],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id44", "elementId": "id44",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@@ -637,6 +651,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id43", "elementId": "id43",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@@ -662,7 +677,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"containerId": "id41", "containerId": "id41",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -708,7 +723,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"containerId": null, "containerId": null,
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -754,7 +769,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"containerId": null, "containerId": null,
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -824,6 +839,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
@@ -871,6 +887,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "triangle", "endArrowhead": "triangle",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
@@ -1207,7 +1224,7 @@ exports[`Test Transform > should transform text element 1`] = `
"containerId": null, "containerId": null,
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -1248,7 +1265,7 @@ exports[`Test Transform > should transform text element 2`] = `
"containerId": null, "containerId": null,
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -1463,9 +1480,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
}, },
], ],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "Alice", "elementId": "Alice",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 5.299874999999986, "gap": 5.299874999999986,
}, },
@@ -1498,6 +1517,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "Bob", "elementId": "Bob",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@@ -1525,9 +1545,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
}, },
], ],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "B", "elementId": "B",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@@ -1556,6 +1578,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "Bob", "elementId": "Bob",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@@ -1581,7 +1604,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "B", "containerId": "B",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [ "groupIds": [
@@ -1624,7 +1647,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "A", "containerId": "A",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [ "groupIds": [
@@ -1667,7 +1690,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Alice", "containerId": "Alice",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [ "groupIds": [
@@ -1710,7 +1733,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Bob", "containerId": "Bob",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [ "groupIds": [
@@ -1753,7 +1776,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Bob_Alice", "containerId": "Bob_Alice",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -1794,7 +1817,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Bob_B", "containerId": "Bob_B",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -1837,6 +1860,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
}, },
], ],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
@@ -1889,6 +1913,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
}, },
], ],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
@@ -1941,6 +1966,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
}, },
], ],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
@@ -1993,6 +2019,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
}, },
], ],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
@@ -2043,7 +2070,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id25", "containerId": "id25",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2084,7 +2111,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id26", "containerId": "id26",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2125,7 +2152,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id27", "containerId": "id27",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2167,7 +2194,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id28", "containerId": "id28",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2431,7 +2458,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id13", "containerId": "id13",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2472,7 +2499,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id14", "containerId": "id14",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2514,7 +2541,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id15", "containerId": "id15",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2558,7 +2585,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id16", "containerId": "id16",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2600,7 +2627,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id17", "containerId": "id17",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2643,7 +2670,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id18", "containerId": "id18",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
+41 -2
View File
@@ -1,5 +1,11 @@
import throttle from "lodash.throttle";
import { ENV } from "../constants";
import type { OrderedExcalidrawElement } from "../element/types"; import type { OrderedExcalidrawElement } from "../element/types";
import { orderByFractionalIndex, syncInvalidIndices } from "../fractionalIndex"; import {
orderByFractionalIndex,
syncInvalidIndices,
validateFractionalIndices,
} from "../fractionalIndex";
import type { AppState } from "../types"; import type { AppState } from "../types";
import type { MakeBrand } from "../utility-types"; import type { MakeBrand } from "../utility-types";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
@@ -20,7 +26,7 @@ const shouldDiscardRemoteElement = (
// local element is being edited // local element is being edited
(local.id === localAppState.editingElement?.id || (local.id === localAppState.editingElement?.id ||
local.id === localAppState.resizingElement?.id || local.id === localAppState.resizingElement?.id ||
local.id === localAppState.draggingElement?.id || // TODO: Is this still valid? As draggingElement is selection element, which is never part of the elements array local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array
// local element is newer // local element is newer
local.version > remote.version || local.version > remote.version ||
// resolve conflicting edits deterministically by taking the one with // resolve conflicting edits deterministically by taking the one with
@@ -33,6 +39,37 @@ const shouldDiscardRemoteElement = (
return false; return false;
}; };
const validateIndicesThrottled = throttle(
(
orderedElements: readonly OrderedExcalidrawElement[],
localElements: readonly OrderedExcalidrawElement[],
remoteElements: readonly RemoteExcalidrawElement[],
) => {
if (
import.meta.env.DEV ||
import.meta.env.MODE === ENV.TEST ||
window?.DEBUG_FRACTIONAL_INDICES
) {
// create new instances due to the mutation
const elements = syncInvalidIndices(
orderedElements.map((x) => ({ ...x })),
);
validateFractionalIndices(elements, {
// throw in dev & test only, to remain functional on `DEBUG_FRACTIONAL_INDICES`
shouldThrow: import.meta.env.DEV || import.meta.env.MODE === ENV.TEST,
includeBoundTextValidation: true,
reconciliationContext: {
localElements,
remoteElements,
},
});
}
},
1000 * 60,
{ leading: true, trailing: false },
);
export const reconcileElements = ( export const reconcileElements = (
localElements: readonly OrderedExcalidrawElement[], localElements: readonly OrderedExcalidrawElement[],
remoteElements: readonly RemoteExcalidrawElement[], remoteElements: readonly RemoteExcalidrawElement[],
@@ -72,6 +109,8 @@ export const reconcileElements = (
const orderedElements = orderByFractionalIndex(reconciledElements); const orderedElements = orderByFractionalIndex(reconciledElements);
validateIndicesThrottled(orderedElements, localElements, remoteElements);
// de-duplicate indices // de-duplicate indices
syncInvalidIndices(orderedElements); syncInvalidIndices(orderedElements);
+50 -15
View File
@@ -1,4 +1,5 @@
import type { import type {
ExcalidrawArrowElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawElementType, ExcalidrawElementType,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@@ -24,6 +25,7 @@ import {
} from "../element"; } from "../element";
import { import {
isArrowElement, isArrowElement,
isElbowArrow,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
@@ -44,14 +46,11 @@ import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils"; import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import type { MarkOptional, Mutable } from "../utility-types"; import type { MarkOptional, Mutable } from "../utility-types";
import { import { detectLineHeight, getContainerElement } from "../element/textElement";
detectLineHeight,
getContainerElement,
getDefaultLineHeight,
} from "../element/textElement";
import { normalizeLink } from "./url"; import { normalizeLink } from "./url";
import { syncInvalidIndices } from "../fractionalIndex"; import { syncInvalidIndices } from "../fractionalIndex";
import { getSizeFromPoints } from "../points"; import { getSizeFromPoints } from "../points";
import { getLineHeight } from "../fonts";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@@ -95,11 +94,21 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
return DEFAULT_FONT_FAMILY; return DEFAULT_FONT_FAMILY;
}; };
const repairBinding = (binding: PointBinding | null) => { const repairBinding = (
element: ExcalidrawLinearElement,
binding: PointBinding | null,
): PointBinding | null => {
if (!binding) { if (!binding) {
return null; return null;
} }
return { ...binding, focus: binding.focus || 0 };
return {
...binding,
focus: binding.focus || 0,
fixedPoint: isElbowArrow(element)
? binding.fixedPoint ?? ([0, 0] as [number, number])
: null,
};
}; };
const restoreElementWithProperties = < const restoreElementWithProperties = <
@@ -206,7 +215,7 @@ const restoreElement = (
detectLineHeight(element) detectLineHeight(element)
: // no element height likely means programmatic use, so default : // no element height likely means programmatic use, so default
// to a fixed line height // to a fixed line height
getDefaultLineHeight(element.fontFamily)); getLineHeight(element.fontFamily));
element = restoreElementWithProperties(element, { element = restoreElementWithProperties(element, {
fontSize, fontSize,
fontFamily, fontFamily,
@@ -245,11 +254,7 @@ const restoreElement = (
// @ts-ignore LEGACY type // @ts-ignore LEGACY type
// eslint-disable-next-line no-fallthrough // eslint-disable-next-line no-fallthrough
case "draw": case "draw":
case "arrow": { const { startArrowhead = null, endArrowhead = null } = element;
const {
startArrowhead = null,
endArrowhead = element.type === "arrow" ? "arrow" : null,
} = element;
let x = element.x; let x = element.x;
let y = element.y; let y = element.y;
let points = // migrate old arrow model to new one let points = // migrate old arrow model to new one
@@ -269,8 +274,8 @@ const restoreElement = (
(element.type as ExcalidrawElementType | "draw") === "draw" (element.type as ExcalidrawElementType | "draw") === "draw"
? "line" ? "line"
: element.type, : element.type,
startBinding: repairBinding(element.startBinding), startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element.endBinding), endBinding: repairBinding(element, element.endBinding),
lastCommittedPoint: null, lastCommittedPoint: null,
startArrowhead, startArrowhead,
endArrowhead, endArrowhead,
@@ -279,6 +284,36 @@ const restoreElement = (
y, y,
...getSizeFromPoints(points), ...getSizeFromPoints(points),
}); });
case "arrow": {
const { startArrowhead = null, endArrowhead = "arrow" } = element;
let x = element.x;
let y = element.y;
let points = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2
? [
[0, 0],
[element.width, element.height],
]
: element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) {
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
}
// TODO: Separate arrow from linear element
return restoreElementWithProperties(element as ExcalidrawArrowElement, {
type: element.type,
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
lastCommittedPoint: null,
startArrowhead,
endArrowhead,
points,
x,
y,
elbowed: (element as ExcalidrawArrowElement).elbowed,
...getSizeFromPoints(points),
});
} }
// generic elements // generic elements
@@ -771,6 +771,7 @@ describe("Test Transform", () => {
const [arrow, rect] = excalidrawElements; const [arrow, rect] = excalidrawElements;
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({ expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1", elementId: "rect-1",
fixedPoint: null,
focus: 0, focus: 0,
gap: 205, gap: 205,
}); });

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