Compare commits

..

15 Commits

Author SHA1 Message Date
Ryan Di c68c2be44c handle bound texts 2024-06-04 23:06:27 +08:00
Ryan Di be65ac7f22 resize linear & freedraw 2024-06-04 19:34:17 +08:00
Ryan Di 09e249ae57 capture history 2024-06-04 16:27:53 +08:00
Ryan Di f0c1e9707a change dimension for multiple elements 2024-06-04 15:28:06 +08:00
Ryan Di 7f4659339b custom font size 2024-05-31 17:21:53 +08:00
Ryan Di 0987c5b770 refactor to include dimension and step size 2024-05-31 17:21:41 +08:00
Ryan Di 0a529bd2ed change a rotated element's width and height 2024-05-28 19:57:34 +08:00
Ryan Di 794b2b21a7 merge with master 2024-05-24 16:21:09 +08:00
Ryan Di 6e577d1308 wip: drag input 2023-04-18 16:26:01 +08:00
Ryan Di 80b9fd18b9 throttled stats 2023-04-10 18:10:46 +08:00
Ryan Di dbc48cfee2 move stats from layerui to app component 2023-04-06 16:05:36 +08:00
Ryan Di 3fc89b716a editing single element 2023-03-27 17:51:31 +08:00
Ryan Di 30743ec726 split stats into general and element stats 2023-03-22 18:32:21 +08:00
Ryan Di 86d49a273b rename 'stats for nerds' to 'general stats' 2023-03-21 14:49:32 +08:00
Ryan Di 92fe9b95d5 remove element stats from 'stats for nerds' 2023-03-21 14:47:46 +08:00
254 changed files with 7907 additions and 17586 deletions
-1
View File
@@ -8,7 +8,6 @@
!package.json !package.json
!public/ !public/
!packages/ !packages/
!scripts/
!tsconfig.json !tsconfig.json
!yarn.lock !yarn.lock
+1 -1
View File
@@ -22,7 +22,7 @@ VITE_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when # whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers. # debugging Service Workers.
VITE_APP_DEV_DISABLE_LIVE_RELOAD= VITE_APP_DEV_DISABLE_LIVE_RELOAD=
VITE_APP_ENABLE_TRACKING=true VITE_APP_DISABLE_TRACKING=true
FAST_REFRESH=false FAST_REFRESH=false
+1 -1
View File
@@ -14,4 +14,4 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}' VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
VITE_APP_ENABLE_TRACKING=false VITE_APP_DISABLE_TRACKING=
-2
View File
@@ -6,5 +6,3 @@ firebase/
dist/ dist/
public/workbox public/workbox
packages/excalidraw/types packages/excalidraw/types
examples/**/public
dev-dist
+3 -5
View File
@@ -1,16 +1,14 @@
name: Tests name: Tests
on: on: pull_request
push:
branches: master
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v2
- name: Setup Node.js 18.x - name: Setup Node.js 18.x
uses: actions/setup-node@v4 uses: actions/setup-node@v2
with: with:
node-version: 18.x node-version: 18.x
- name: Install and test - name: Install and test
+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.27-alpine FROM nginx:1.24-alpine
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
@@ -13,7 +13,7 @@ Once the callback is triggered, you will need to store the api in state to acces
```jsx showLineNumbers ```jsx showLineNumbers
export default function App() { export default function App() {
const [excalidrawAPI, setExcalidrawAPI] = useState(null); const [excalidrawAPI, setExcalidrawAPI] = useState(null);
return <Excalidraw excalidrawAPI={(api)=> setExcalidrawAPI(api)} />; return <Excalidraw excalidrawAPI={{(api)=> setExcalidrawAPI(api)}} />;
} }
``` ```
@@ -9,9 +9,9 @@ All `props` are _optional_.
| [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode | | [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode |
| [`onChange`](#onchange) | `function` | _ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. | | [`onChange`](#onchange) | `function` | _ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. |
| [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. | | [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. |
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down events | | [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down evenets |
| [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. | | [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. |
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when something is pasted into the scene | | [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when the something is pasted in to the scene |
| [`onLibraryChange`](#onlibrarychange) | `function` | _ | The callback if supplied is triggered when the library is updated and receives the library items. | | [`onLibraryChange`](#onlibrarychange) | `function` | _ | The callback if supplied is triggered when the library is updated and receives the library items. |
| [`onLinkOpen`](#onlinkopen) | `function` | _ | The callback if supplied is triggered when any link is opened. | | [`onLinkOpen`](#onlinkopen) | `function` | _ | The callback if supplied is triggered when any link is opened. |
| [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw | | [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw |
@@ -26,7 +26,7 @@ All `props` are _optional_.
| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) | | [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) |
| [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. | | [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. |
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. | | [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
| [`autoFocus`](#autofocus) | `boolean` | `false` | Indicates whether to focus the Excalidraw component on page load | | [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load |
| [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas | | [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation | | [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` | | [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
@@ -90,7 +90,7 @@ function App() {
<img src={canvasUrl} alt="" /> <img src={canvasUrl} alt="" />
</div> </div>
<div style={{ height: "400px" }}> <div style={{ height: "400px" }}>
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)} <Excalidraw ref={(api) => setExcalidrawAPI(api)}
/> />
</div> </div>
</> </>
+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.6", "@excalidraw/excalidraw": "0.17.0",
"@mdx-js/react": "^1.6.22", "@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"docusaurus-plugin-sass": "0.2.3", "docusaurus-plugin-sass": "0.2.3",
"prism-react-renderer": "^1.3.5", "prism-react-renderer": "^1.3.5",
"react": "18.2.0", "react": "^17.0.2",
"react-dom": "18.2.0", "react-dom": "^17.0.2",
"sass": "1.57.1" "sass": "1.57.1"
}, },
"devDependencies": { "devDependencies": {
+1 -1
View File
@@ -59,7 +59,7 @@ pre a {
padding: 5px; padding: 5px;
background: #70b1ec; background: #70b1ec;
color: white; color: white;
font-weight: 700; font-weight: bold;
border: none; border: none;
} }
+4 -4
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.6": "@excalidraw/excalidraw@0.17.0":
version "0.17.6" version "0.17.0"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.6.tgz#5fd208ce69d33ca712d1804b50d7d06d5c46ac4d" resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.0.tgz#3c64aa8e36406ac171b008cfecbdce5bb0755725"
integrity sha512-fyCl+zG/Z5yhHDh5Fq2ZGmphcrALmuOdtITm8gN4d8w4ntnaopTXcTfnAAaU3VleDC6LhTkoLOTG6P5kgREiIg== integrity sha512-NzP22v5xMqxYW27ZtTHhiGFe7kE8NeBk45aoeM/mDSkXiOXPDH+PcvwzHRN/Ei+Vj/0sTPHxejn8bZyRWKGjXg==
"@hapi/hoek@^9.0.0": "@hapi/hoek@^9.0.0":
version "9.3.0" version "9.3.0"
+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 Excalifont"; ctx.font = "30px Virgil";
ctx.strokeText("My custom text", 50, 60); ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL()); setCanvasUrl(canvas.toDataURL());
}} }}
@@ -893,7 +893,7 @@ export default function App({
files: excalidrawAPI.getFiles(), files: excalidrawAPI.getFiles(),
}); });
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.font = "30px Excalifont"; ctx.font = "30px Virgil";
ctx.strokeText("My custom text", 50, 60); ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL()); setCanvasUrl(canvas.toDataURL());
}} }}
+1 -1
View File
@@ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [
]; ];
export default { export default {
elements, elements,
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 }, appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 },
scrollToContent: true, scrollToContent: true,
libraryItems: [ libraryItems: [
[ [
@@ -34,6 +34,3 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# copied assets
public/*.woff2
+5 -6
View File
@@ -3,8 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets", "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm",
"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",
@@ -13,13 +12,13 @@
"dependencies": { "dependencies": {
"@excalidraw/excalidraw": "*", "@excalidraw/excalidraw": "*",
"next": "14.1", "next": "14.1",
"react": "18.2.0", "react": "^18",
"react-dom": "18.2.0" "react-dom": "^18"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
"@types/react": "18.2.0", "@types/react": "^18",
"@types/react-dom": "18.2.0", "@types/react-dom": "^18",
"path2d-polyfill": "2.0.1", "path2d-polyfill": "2.0.1",
"typescript": "^5" "typescript": "^5"
} }
@@ -1,5 +1,4 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Script from "next/script";
import "../common.scss"; import "../common.scss";
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically // Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
@@ -16,9 +15,7 @@ export default function Page() {
<> <>
<a href="/excalidraw-in-pages">Switch to Pages router</a> <a href="/excalidraw-in-pages">Switch to Pages router</a>
<h1 className="page-title">App Router</h1> <h1 className="page-title">App Router</h1>
<Script id="load-env-variables" strategy="beforeInteractive">
{`window["EXCALIDRAW_ASSET_PATH"] = window.origin;`}
</Script>
{/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */} {/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */}
<ExcalidrawWithClientOnly /> <ExcalidrawWithClientOnly />
</> </>
@@ -7,7 +7,7 @@ a {
color: #1c7ed6; color: #1c7ed6;
font-size: 20px; font-size: 20px;
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 550;
} }
.page-title { .page-title {
@@ -1,2 +0,0 @@
# copied assets
public/*.woff2
@@ -11,7 +11,6 @@
<title>React App</title> <title>React App</title>
<script> <script>
window.name = "codesandbox"; window.name = "codesandbox";
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script> </script>
<link rel="stylesheet" href="/dist/browser/dev/index.css" /> <link rel="stylesheet" href="/dist/browser/dev/index.css" />
</head> </head>
@@ -12,10 +12,8 @@
"typescript": "^5" "typescript": "^5"
}, },
"scripts": { "scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets", "start": "yarn workspace @excalidraw/excalidraw run build:esm && vite",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public", "build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build",
"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"
} }
} }
+23 -6
View File
@@ -1,4 +1,5 @@
import polyfill from "../packages/excalidraw/polyfill"; import polyfill from "../packages/excalidraw/polyfill";
import LanguageDetector from "i18next-browser-languagedetector";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { trackEvent } from "../packages/excalidraw/analytics"; import { trackEvent } from "../packages/excalidraw/analytics";
import { getDefaultAppState } from "../packages/excalidraw/appState"; import { getDefaultAppState } from "../packages/excalidraw/appState";
@@ -21,6 +22,7 @@ import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRef
import { t } from "../packages/excalidraw/i18n"; import { t } from "../packages/excalidraw/i18n";
import { import {
Excalidraw, Excalidraw,
defaultLang,
LiveCollaborationTrigger, LiveCollaborationTrigger,
TTDDialog, TTDDialog,
TTDDialogTrigger, TTDDialogTrigger,
@@ -91,7 +93,7 @@ import {
import { AppMainMenu } from "./components/AppMainMenu"; import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen"; import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter"; import { AppFooter } from "./components/AppFooter";
import { Provider, useAtom, useAtomValue } from "jotai"; import { atom, Provider, useAtom, useAtomValue } from "jotai";
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai"; import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
import { appJotaiStore } from "./app-jotai"; import { appJotaiStore } from "./app-jotai";
@@ -119,8 +121,6 @@ import {
youtubeIcon, youtubeIcon,
} from "../packages/excalidraw/components/icons"; } from "../packages/excalidraw/components/icons";
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme"; import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
polyfill(); polyfill();
@@ -172,6 +172,11 @@ if (window.self !== window.top) {
} }
} }
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
const shareableLinkConfirmDialog = { const shareableLinkConfirmDialog = {
title: t("overwriteConfirm.modal.shareableLink.title"), title: t("overwriteConfirm.modal.shareableLink.title"),
description: ( description: (
@@ -317,15 +322,19 @@ const initializeScene = async (opts: {
return { scene: null, isExternalScene: false }; return { scene: null, isExternalScene: false };
}; };
const detectedLangCode = languageDetector.detect() || defaultLang.code;
export const appLangCodeAtom = atom(
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
);
const ExcalidrawWrapper = () => { const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
const isCollabDisabled = isRunningInIframe(); const isCollabDisabled = isRunningInIframe();
const [appTheme, setAppTheme] = useAtom(appThemeAtom); const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const { editorTheme } = useHandleAppTheme(); const { editorTheme } = useHandleAppTheme();
const [langCode, setLangCode] = useAppLangCode();
// initial state // initial state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -481,7 +490,11 @@ const ExcalidrawWrapper = () => {
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) { if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
const localDataState = importFromLocalStorage(); const localDataState = importFromLocalStorage();
const username = importUsernameFromLocalStorage(); const username = importUsernameFromLocalStorage();
setLangCode(getPreferredLanguage()); let langCode = languageDetector.detect() || defaultLang.code;
if (Array.isArray(langCode)) {
langCode = langCode[0];
}
setLangCode(langCode);
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
...localDataState, ...localDataState,
storeAction: StoreAction.UPDATE, storeAction: StoreAction.UPDATE,
@@ -582,6 +595,10 @@ const ExcalidrawWrapper = () => {
}; };
}, [excalidrawAPI]); }, [excalidrawAPI]);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
const onChange = ( const onChange = (
elements: readonly OrderedExcalidrawElement[], elements: readonly OrderedExcalidrawElement[],
appState: AppState, appState: AppState,
@@ -1,25 +0,0 @@
import LanguageDetector from "i18next-browser-languagedetector";
import { defaultLang, languages } from "../../packages/excalidraw";
export const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
export const getPreferredLanguage = () => {
const detectedLanguages = languageDetector.detect();
const detectedLanguage = Array.isArray(detectedLanguages)
? detectedLanguages[0]
: detectedLanguages;
const initialLanguage =
(detectedLanguage
? // region code may not be defined if user uses generic preferred language
// (e.g. chinese vs instead of chinese-simplified)
languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code
: null) || defaultLang.code;
return initialLanguage;
};
@@ -1,15 +0,0 @@
import { atom, useAtom } from "jotai";
import { useEffect } from "react";
import { getPreferredLanguage, languageDetector } from "./language-detector";
export const appLangCodeAtom = atom(getPreferredLanguage());
export const useAppLangCode = () => {
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
return [langCode, setLangCode] as const;
};
+2 -2
View File
@@ -6,7 +6,7 @@ import {
import type { Theme } from "../../packages/excalidraw/element/types"; import type { Theme } from "../../packages/excalidraw/element/types";
import { MainMenu } from "../../packages/excalidraw/index"; import { MainMenu } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants"; import { isExcalidrawPlusSignedUser } from "../app_constants";
import { LanguageList } from "../app-language/LanguageList"; import { LanguageList } from "./LanguageList";
export const AppMainMenu: React.FC<{ export const AppMainMenu: React.FC<{
onCollabDialogOpen: () => any; onCollabDialogOpen: () => any;
@@ -34,7 +34,7 @@ export const AppMainMenu: React.FC<{
<MainMenu.ItemLink <MainMenu.ItemLink
icon={ExcalLogo} icon={ExcalLogo}
href={`${ href={`${
import.meta.env.VITE_APP_PLUS_LP import.meta.env.VITE_APP_PLUS_APP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`} }/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
className="" className=""
> >
@@ -1,7 +1,8 @@
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import React from "react"; import React from "react";
import { useI18n, languages } from "../../packages/excalidraw/i18n"; import { appLangCodeAtom } from "../App";
import { appLangCodeAtom } from "./language-state"; import { useI18n } from "../../packages/excalidraw/i18n";
import { languages } from "../../packages/excalidraw/i18n";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
const { t, langCode } = useI18n(); const { t, langCode } = useI18n();
+17 -52
View File
@@ -95,11 +95,6 @@
color: #fff; color: #fff;
} }
</style> </style>
<!-- Warmup the connection for Google fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-------------------------------------------------------------------------> <!------------------------------------------------------------------------->
<% if (typeof PROD != 'undefined' && PROD == true) { %> <% if (typeof PROD != 'undefined' && PROD == true) { %>
<script> <script>
@@ -120,55 +115,8 @@
window.location.href = "https://app.excalidraw.com"; window.location.href = "https://app.excalidraw.com";
} }
</script> </script>
<!-- Following placeholder is replaced during the build step -->
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
<% } else { %>
<script>
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script>
<!-- in DEV we need to preload from the local server and without the hash -->
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Excalifont-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Virgil-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<% } %> <% } %>
<!-- For Nunito only preload the latin range, which should be good enough for now -->
<link
rel="preload"
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<!-- Register Assistant as the UI font, before the scene inits -->
<link
rel="stylesheet"
href="../packages/excalidraw/fonts/assets/fonts.css"
type="text/css"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
@@ -176,6 +124,22 @@
<!-- Excalidraw version --> <!-- Excalidraw version -->
<meta name="version" content="{version}" /> <meta name="version" content="{version}" />
<link
rel="preload"
href="/Virgil.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="/Cascadia.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' && <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %> VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
<script> <script>
@@ -194,6 +158,7 @@
</script> </script>
<% } %> <% } %>
<script> <script>
window.EXCALIDRAW_ASSET_PATH = "/";
// setting this so that libraries installation reuses this window tab. // setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw"; window.name = "_excalidraw";
</script> </script>
-1
View File
@@ -25,7 +25,6 @@
margin-bottom: auto; margin-bottom: auto;
margin-inline-start: auto; margin-inline-start: auto;
margin-inline-end: 0.6em; margin-inline-end: 0.6em;
z-index: var(--zIndex-layerUI);
svg { svg {
width: 1.2rem; width: 1.2rem;
+4 -15
View File
@@ -26,28 +26,17 @@
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"dependencies": { "dependencies": {
"firebase": "8.3.3", "vite-plugin-html": "3.2.2"
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"vite-plugin-html": "3.2.2",
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"i18next-browser-languagedetector": "6.1.4",
"socket.io-client": "4.7.2"
}, },
"prettier": "@excalidraw/prettier-config", "prettier": "@excalidraw/prettier-config",
"scripts": { "scripts": {
"build-node": "node ./scripts/build-node.js", "build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build", "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build", "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
"build:version": "node ../scripts/build-version.js", "build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version", "build": "yarn build:app && yarn build:version",
"start": "yarn && vite", "start": "yarn && vite",
"start:production": "yarn build && yarn serve", "start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"serve": "npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000" "build:preview": "yarn build && vite preview --port 5000"
} }
} }
@@ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
class="welcome-screen-center" class="welcome-screen-center"
> >
<div <div
class="welcome-screen-center__logo excalifont welcome-screen-decor" class="welcome-screen-center__logo virgil welcome-screen-decor"
> >
<div <div
class="ExcalidrawLogo is-small" class="ExcalidrawLogo is-small"
@@ -48,7 +48,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
</div> </div>
</div> </div>
<div <div
class="welcome-screen-center__heading welcome-screen-decor excalifont" class="welcome-screen-center__heading welcome-screen-decor virgil"
> >
All your data is saved locally in your browser. All your data is saved locally in your browser.
</div> </div>
+6 -5
View File
@@ -2,6 +2,7 @@ import { vi } from "vitest";
import { import {
act, act,
render, render,
updateSceneData,
waitFor, waitFor,
} from "../../packages/excalidraw/tests/test-utils"; } from "../../packages/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App"; import ExcalidrawApp from "../App";
@@ -87,12 +88,12 @@ describe("collaboration", () => {
const rect1 = API.createElement({ ...rect1Props }); const rect1 = API.createElement({ ...rect1Props });
const rect2 = API.createElement({ ...rect2Props }); const rect2 = API.createElement({ ...rect2Props });
API.updateScene({ updateSceneData({
elements: syncInvalidIndices([rect1, rect2]), elements: syncInvalidIndices([rect1, rect2]),
storeAction: StoreAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}); });
API.updateScene({ updateSceneData({
elements: syncInvalidIndices([ elements: syncInvalidIndices([
rect1, rect1,
newElementWith(h.elements[1], { isDeleted: true }), newElementWith(h.elements[1], { isDeleted: true }),
@@ -142,7 +143,7 @@ describe("collaboration", () => {
}); });
// simulate force deleting the element remotely // simulate force deleting the element remotely
API.updateScene({ updateSceneData({
elements: syncInvalidIndices([rect1]), elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE, storeAction: StoreAction.UPDATE,
}); });
@@ -177,7 +178,7 @@ describe("collaboration", () => {
act(() => h.app.actionManager.executeAction(undoAction)); act(() => h.app.actionManager.executeAction(undoAction));
// simulate local update // simulate local update
API.updateScene({ updateSceneData({
elements: syncInvalidIndices([ elements: syncInvalidIndices([
h.elements[0], h.elements[0],
newElementWith(h.elements[1], { x: 100 }), newElementWith(h.elements[1], { x: 100 }),
@@ -215,7 +216,7 @@ describe("collaboration", () => {
}); });
// simulate force deleting the element remotely // simulate force deleting the element remotely
API.updateScene({ updateSceneData({
elements: syncInvalidIndices([rect1]), elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE, storeAction: StoreAction.UPDATE,
}); });
-10
View File
@@ -5,7 +5,6 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
import { VitePWA } from "vite-plugin-pwa"; import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker"; import checker from "vite-plugin-checker";
import { createHtmlPlugin } from "vite-plugin-html"; import { createHtmlPlugin } from "vite-plugin-html";
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
// To load .env.local variables // To load .env.local variables
const envVars = loadEnv("", `../`); const envVars = loadEnv("", `../`);
@@ -23,14 +22,6 @@ export default defineConfig({
outDir: "build", outDir: "build",
rollupOptions: { rollupOptions: {
output: { output: {
assetFileNames(chunkInfo) {
if (chunkInfo?.name?.endsWith(".woff2")) {
// put on root so we are flexible about the CDN path
return "[name]-[hash][extname]";
}
return "assets/[name]-[hash][extname]";
},
// Creating separate chunk for locales except for en and percentages.json so they // Creating separate chunk for locales except for en and percentages.json so they
// can be cached at runtime and not merged with // can be cached at runtime and not merged with
// app precache. en.json and percentages.json are needed for first load // app precache. en.json and percentages.json are needed for first load
@@ -50,7 +41,6 @@ export default defineConfig({
sourcemap: true, sourcemap: true,
}, },
plugins: [ plugins: [
woff2BrowserPlugin(),
react(), react(),
checker({ checker({
typescript: true, typescript: true,
+14 -9
View File
@@ -9,8 +9,19 @@
"examples/excalidraw", "examples/excalidraw",
"examples/excalidraw/*" "examples/excalidraw/*"
], ],
"dependencies": {
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"socket.io-client": "4.7.2"
},
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "7.21.11",
"@excalidraw/eslint-config": "1.0.3", "@excalidraw/eslint-config": "1.0.3",
"@excalidraw/prettier-config": "1.0.2", "@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.3.0", "@types/chai": "4.3.0",
@@ -40,7 +51,7 @@
"vite-plugin-ejs": "1.7.0", "vite-plugin-ejs": "1.7.0",
"vite-plugin-pwa": "0.17.4", "vite-plugin-pwa": "0.17.4",
"vite-plugin-svgr": "2.4.0", "vite-plugin-svgr": "2.4.0",
"vitest": "1.6.0", "vitest": "1.5.3",
"vitest-canvas-mock": "0.3.2" "vitest-canvas-mock": "0.3.2"
}, },
"engines": { "engines": {
@@ -76,12 +87,6 @@
"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"
} }
} }
-4
View File
@@ -15,12 +15,8 @@ Please add the latest change on the top under the correct section.
### Features ### Features
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348) - Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
- Added font picker component to have the ability to choose from a range of different fonts. Also, changed the default fonts to `Excalifont`, `Nunito` and `Comic Shanns` and deprecated `Virgil`, `Helvetica` and `Cascadia`.
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853) - `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655) - Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
+1 -1
View File
@@ -104,7 +104,7 @@ export const actionClearCanvas = register({
exportBackground: appState.exportBackground, exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene, exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize, gridSize: appState.gridSize,
stats: appState.stats, showStats: appState.showStats,
pasteDialog: appState.pasteDialog, pasteDialog: appState.pasteDialog,
activeTool: activeTool:
appState.activeTool.type === "image" appState.activeTool.type === "image"
@@ -5,27 +5,20 @@ import { t } from "../i18n";
import { register } from "./register"; import { register } from "./register";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import type { AppClassProperties, AppState } from "../types"; import type { AppState } from "../types";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups"; import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding"; import { fixBindingsAfterDeletion } from "../element/binding";
import { import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
isBoundToContainer,
isElbowArrow,
isFrameLikeElement,
} from "../element/typeChecks";
import { updateActiveTool } from "../utils"; import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons"; import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { mutateElbowArrow } from "../element/routing";
const deleteSelectedElements = ( const deleteSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
app: AppClassProperties,
) => { ) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
const framesToBeDeleted = new Set( const framesToBeDeleted = new Set(
getSelectedElements( getSelectedElements(
elements.filter((el) => isFrameLikeElement(el)), elements.filter((el) => isFrameLikeElement(el)),
@@ -36,26 +29,6 @@ const deleteSelectedElements = (
return { return {
elements: elements.map((el) => { elements: elements.map((el) => {
if (appState.selectedElementIds[el.id]) { if (appState.selectedElementIds[el.id]) {
if (el.boundElements) {
el.boundElements.forEach((candidate) => {
const bound = app.scene
.getNonDeletedElementsMap()
.get(candidate.id);
if (bound && isElbowArrow(bound)) {
mutateElement(bound, {
startBinding:
el.id === bound.startBinding?.elementId
? null
: bound.startBinding,
endBinding:
el.id === bound.endBinding?.elementId
? null
: bound.endBinding,
});
mutateElbowArrow(bound, elementsMap, bound.points);
}
});
}
return newElementWith(el, { isDeleted: true }); return newElementWith(el, { isDeleted: true });
} }
@@ -157,11 +130,7 @@ export const actionDeleteSelected = register({
: endBindingElement, : endBindingElement,
}; };
LinearElementEditor.deletePoints( LinearElementEditor.deletePoints(element, selectedPointsIndices);
element,
selectedPointsIndices,
elementsMap,
);
return { return {
elements, elements,
@@ -180,7 +149,7 @@ export const actionDeleteSelected = register({
}; };
} }
let { elements: nextElements, appState: nextAppState } = let { elements: nextElements, appState: nextAppState } =
deleteSelectedElements(elements, appState, app); deleteSelectedElements(elements, appState);
fixBindingsAfterDeletion( fixBindingsAfterDeletion(
nextElements, nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]), elements.filter(({ id }) => appState.selectedElementIds[id]),
@@ -40,11 +40,12 @@ export const actionDuplicateSelection = register({
icon: DuplicateIcon, icon: DuplicateIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, formData, app) => { perform: (elements, appState, formData, app) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
// duplicate selected point(s) if editing a line // duplicate selected point(s) if editing a line
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const ret = LinearElementEditor.duplicateSelectedPoints( const ret = LinearElementEditor.duplicateSelectedPoints(
appState, appState,
app.scene.getNonDeletedElementsMap(), elementsMap,
); );
if (!ret) { if (!ret) {
@@ -1,4 +1,3 @@
import React from "react";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { queryByTestId, fireEvent } from "@testing-library/react"; import { queryByTestId, fireEvent } from "@testing-library/react";
import { render } from "../tests/test-utils"; import { render } from "../tests/test-utils";
+5 -13
View File
@@ -38,7 +38,6 @@ export const actionFinalize = register({
startBindingElement, startBindingElement,
endBindingElement, endBindingElement,
elementsMap, elementsMap,
scene,
); );
} }
return { return {
@@ -73,8 +72,8 @@ export const actionFinalize = register({
const multiPointElement = appState.multiElement const multiPointElement = appState.multiElement
? appState.multiElement ? appState.multiElement
: appState.newElement?.type === "freedraw" : appState.editingElement?.type === "freedraw"
? appState.newElement ? appState.editingElement
: null; : null;
if (multiPointElement) { if (multiPointElement) {
@@ -132,13 +131,7 @@ export const actionFinalize = register({
-1, -1,
arrayToMap(elements), arrayToMap(elements),
); );
maybeBindLinearElement( maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
multiPointElement,
appState,
{ x, y },
elementsMap,
elements,
);
} }
} }
@@ -176,8 +169,7 @@ export const actionFinalize = register({
? appState.activeTool ? appState.activeTool
: activeTool, : activeTool,
activeEmbeddable: null, activeEmbeddable: null,
newElement: null, draggingElement: null,
selectionElement: null,
multiElement: null, multiElement: null,
editingElement: null, editingElement: null,
startBoundElement: null, startBoundElement: null,
@@ -205,7 +197,7 @@ export const actionFinalize = register({
keyTest: (event, appState) => keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE && (event.key === KEYS.ESCAPE &&
(appState.editingLinearElement !== null || (appState.editingLinearElement !== null ||
(!appState.newElement && appState.multiElement === null))) || (!appState.draggingElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null), appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => ( PanelComponent: ({ appState, updateData, data }) => (
+1 -3
View File
@@ -124,9 +124,7 @@ const flipElements = (
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement), selectedElements.filter(isLinearElement),
elementsMap, app,
app.scene.getNonDeletedElements(),
app.scene,
isBindingEnabled(appState), isBindingEnabled(appState),
[], [],
); );
@@ -21,9 +21,7 @@ const writeData = (
!appState.multiElement && !appState.multiElement &&
!appState.resizingElement && !appState.resizingElement &&
!appState.editingElement && !appState.editingElement &&
!appState.newElement && !appState.draggingElement
!appState.selectedElementsAreBeingDragged &&
!appState.selectionElement
) { ) {
const result = updater(); const result = updater();
@@ -52,7 +50,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
icon: UndoIcon, icon: UndoIcon,
trackEvent: { category: "history" }, trackEvent: { category: "history" },
viewMode: false, viewMode: false,
perform: (elements, appState, value, app) => perform: (elements, appState) =>
writeData(appState, () => writeData(appState, () =>
history.undo( history.undo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
@@ -93,7 +91,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
icon: RedoIcon, icon: RedoIcon,
trackEvent: { category: "history" }, trackEvent: { category: "history" },
viewMode: false, viewMode: false,
perform: (elements, appState, _, app) => perform: (elements, appState) =>
writeData(appState, () => writeData(appState, () =>
history.redo( history.redo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
@@ -1,6 +1,6 @@
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"; import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { isElbowArrow, isLinearElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
import type { ExcalidrawLinearElement } from "../element/types"; import type { ExcalidrawLinearElement } from "../element/types";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { register } from "./register"; import { register } from "./register";
@@ -29,8 +29,7 @@ export const actionToggleLinearEditor = register({
if ( if (
!appState.editingLinearElement && !appState.editingLinearElement &&
selectedElements.length === 1 && selectedElements.length === 1 &&
isLinearElement(selectedElements[0]) && isLinearElement(selectedElements[0])
!isElbowArrow(selectedElements[0])
) { ) {
return true; return true;
} }
@@ -1,4 +1,3 @@
import React from "react";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { queryByTestId } from "@testing-library/react"; import { queryByTestId } from "@testing-library/react";
import { render } from "../tests/test-utils"; import { render } from "../tests/test-utils";
@@ -7,6 +6,8 @@ import { API } from "../tests/helpers/api";
import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors"; import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors";
import { FONT_FAMILY, STROKE_WIDTH } from "../constants"; import { FONT_FAMILY, STROKE_WIDTH } from "../constants";
const { h } = window;
describe("element locking", () => { describe("element locking", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<Excalidraw />); await render(<Excalidraw />);
@@ -21,7 +22,7 @@ describe("element locking", () => {
// just in case we change it in the future // just in case we change it in the future
expect(color).not.toBe(COLOR_PALETTE.transparent); expect(color).not.toBe(COLOR_PALETTE.transparent);
API.setAppState({ h.setState({
currentItemBackgroundColor: color, currentItemBackgroundColor: color,
}); });
const activeColor = queryByTestId( const activeColor = queryByTestId(
@@ -39,14 +40,14 @@ describe("element locking", () => {
// just in case we change it in the future // just in case we change it in the future
expect(color).not.toBe(COLOR_PALETTE.transparent); expect(color).not.toBe(COLOR_PALETTE.transparent);
API.setAppState({ h.setState({
currentItemBackgroundColor: color, currentItemBackgroundColor: color,
currentItemFillStyle: "hachure", currentItemFillStyle: "hachure",
}); });
const hachureFillButton = queryByTestId(document.body, `fill-hachure`); const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
expect(hachureFillButton).toHaveClass("active"); expect(hachureFillButton).toHaveClass("active");
API.setAppState({ h.setState({
currentItemFillStyle: "solid", currentItemFillStyle: "solid",
}); });
const solidFillStyle = queryByTestId(document.body, `fill-solid`); const solidFillStyle = queryByTestId(document.body, `fill-solid`);
@@ -56,7 +57,7 @@ describe("element locking", () => {
it("should not show fill style when background transparent", () => { it("should not show fill style when background transparent", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
API.setAppState({ h.setState({
currentItemBackgroundColor: COLOR_PALETTE.transparent, currentItemBackgroundColor: COLOR_PALETTE.transparent,
currentItemFillStyle: "hachure", currentItemFillStyle: "hachure",
}); });
@@ -68,7 +69,7 @@ describe("element locking", () => {
it("should show horizontal text align for text tool", () => { it("should show horizontal text align for text tool", () => {
UI.clickTool("text"); UI.clickTool("text");
API.setAppState({ h.setState({
currentItemTextAlign: "right", currentItemTextAlign: "right",
}); });
@@ -84,7 +85,7 @@ describe("element locking", () => {
backgroundColor: "red", backgroundColor: "red",
fillStyle: "cross-hatch", fillStyle: "cross-hatch",
}); });
API.setElements([rect]); h.elements = [rect];
API.setSelectedElements([rect]); API.setSelectedElements([rect]);
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`); const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
@@ -97,7 +98,7 @@ describe("element locking", () => {
backgroundColor: COLOR_PALETTE.transparent, backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "cross-hatch", fillStyle: "cross-hatch",
}); });
API.setElements([rect]); h.elements = [rect];
API.setSelectedElements([rect]); API.setSelectedElements([rect]);
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`); const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
@@ -113,7 +114,7 @@ describe("element locking", () => {
type: "rectangle", type: "rectangle",
strokeWidth: STROKE_WIDTH.thin, strokeWidth: STROKE_WIDTH.thin,
}); });
API.setElements([rect1, rect2]); h.elements = [rect1, rect2];
API.setSelectedElements([rect1, rect2]); API.setSelectedElements([rect1, rect2]);
const thinStrokeWidthButton = queryByTestId( const thinStrokeWidthButton = queryByTestId(
@@ -132,7 +133,7 @@ describe("element locking", () => {
type: "rectangle", type: "rectangle",
strokeWidth: STROKE_WIDTH.bold, strokeWidth: STROKE_WIDTH.bold,
}); });
API.setElements([rect1, rect2]); h.elements = [rect1, rect2];
API.setSelectedElements([rect1, rect2]); API.setSelectedElements([rect1, rect2]);
expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null); expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null);
@@ -154,15 +155,13 @@ describe("element locking", () => {
}); });
const text = API.createElement({ const text = API.createElement({
type: "text", type: "text",
fontFamily: FONT_FAMILY["Comic Shanns"], fontFamily: FONT_FAMILY.Cascadia,
}); });
API.setElements([rect, text]); h.elements = [rect, text];
API.setSelectedElements([rect, text]); API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked(); expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass( expect(queryByTestId(document.body, `font-family-code`)).toBeChecked();
"active",
);
}); });
}); });
}); });
+89 -612
View File
@@ -1,6 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react"; import type { AppClassProperties, AppState, Primitive } from "../types";
import type { AppClassProperties, AppState, Point, Primitive } from "../types";
import type { StoreActionType } from "../store";
import { import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS, DEFAULT_ELEMENT_BACKGROUND_PICKS,
@@ -11,7 +9,6 @@ import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker/ColorPicker"; import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { IconPicker } from "../components/IconPicker"; import { IconPicker } from "../components/IconPicker";
import { FontPicker } from "../components/FontPicker/FontPicker";
// TODO barnabasmolnar/editor-redesign // TODO barnabasmolnar/editor-redesign
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon, // TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
// ArrowHead icons // ArrowHead icons
@@ -41,6 +38,9 @@ import {
FontSizeExtraLargeIcon, FontSizeExtraLargeIcon,
EdgeSharpIcon, EdgeSharpIcon,
EdgeRoundIcon, EdgeRoundIcon,
FreedrawIcon,
FontFamilyNormalIcon,
FontFamilyCodeIcon,
TextAlignLeftIcon, TextAlignLeftIcon,
TextAlignCenterIcon, TextAlignCenterIcon,
TextAlignRightIcon, TextAlignRightIcon,
@@ -50,12 +50,8 @@ import {
ArrowheadDiamondIcon, ArrowheadDiamondIcon,
ArrowheadDiamondOutlineIcon, ArrowheadDiamondOutlineIcon,
fontSizeIcon, fontSizeIcon,
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
} from "../components/icons"; } from "../components/icons";
import { import {
ARROW_TYPE,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_FAMILY, FONT_FAMILY,
@@ -69,17 +65,17 @@ import {
redrawTextBoundingBox, redrawTextBoundingBox,
} from "../element"; } from "../element";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { getBoundTextElement } from "../element/textElement";
import { import {
isArrowElement, getBoundTextElement,
getDefaultLineHeight,
} from "../element/textElement";
import {
isBoundToContainer, isBoundToContainer,
isElbowArrow,
isLinearElement, isLinearElement,
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
} from "../element/typeChecks"; } from "../element/typeChecks";
import type { import type {
Arrowhead, Arrowhead,
ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
@@ -98,23 +94,9 @@ import {
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { import { arrayToMap, getShortcutKey } from "../utils";
arrayToMap,
getFontFamilyString,
getShortcutKey,
tupleToCoors,
} from "../utils";
import { register } from "./register"; import { register } from "./register";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { Fonts, getLineHeight } from "../fonts";
import {
bindLinearElement,
bindPointToSnapToElementOutline,
calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding,
} from "../element/binding";
import { mutateElbowArrow } from "../element/routing";
import { LinearElementEditor } from "../element/linearElementEditor";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -747,388 +729,104 @@ export const actionIncreaseFontSize = register({
}, },
}); });
type ChangeFontFamilyData = Partial<
Pick<
AppState,
"openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily"
>
> & {
/** cache of selected & editing elements populated on opened popup */
cachedElements?: Map<string, ExcalidrawElement>;
/** flag to reset all elements to their cached versions */
resetAll?: true;
/** flag to reset all containers to their cached versions */
resetContainers?: true;
};
export const actionChangeFontFamily = register({ export const actionChangeFontFamily = register({
name: "changeFontFamily", name: "changeFontFamily",
label: "labels.fontFamily", label: "labels.fontFamily",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value, app) => { perform: (elements, appState, value, app) => {
const { cachedElements, resetAll, resetContainers, ...nextAppState } = return {
value as ChangeFontFamilyData; elements: changeProperty(
if (resetAll) {
const nextElements = changeProperty(
elements, elements,
appState, appState,
(element) => { (oldElement) => {
const cachedElement = cachedElements?.get(element.id); if (isTextElement(oldElement)) {
if (cachedElement) { const newElement: ExcalidrawTextElement = newElementWith(
const newElement = newElementWith(element, { oldElement,
...cachedElement, {
}); fontFamily: value,
lineHeight: getDefaultLineHeight(value),
},
);
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
return newElement; return newElement;
} }
return element; return oldElement;
}, },
true, true,
); ),
return {
elements: nextElements,
appState: {
...appState,
...nextAppState,
},
storeAction: StoreAction.UPDATE,
};
}
const { currentItemFontFamily, currentHoveredFontFamily } = value;
let nexStoreAction: StoreActionType = StoreAction.NONE;
let nextFontFamily: FontFamilyValues | undefined;
let skipOnHoverRender = false;
if (currentItemFontFamily) {
nextFontFamily = currentItemFontFamily;
nexStoreAction = StoreAction.CAPTURE;
} else if (currentHoveredFontFamily) {
nextFontFamily = currentHoveredFontFamily;
nexStoreAction = StoreAction.NONE;
const selectedTextElements = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
}).filter((element) => isTextElement(element));
// skip on hover re-render for more than 200 text elements or for text element with more than 5000 chars combined
if (selectedTextElements.length > 200) {
skipOnHoverRender = true;
} else {
let i = 0;
let textLengthAccumulator = 0;
while (
i < selectedTextElements.length &&
textLengthAccumulator < 5000
) {
const textElement = selectedTextElements[i] as ExcalidrawTextElement;
textLengthAccumulator += textElement?.originalText.length || 0;
i++;
}
if (textLengthAccumulator > 5000) {
skipOnHoverRender = true;
}
}
}
const result = {
appState: { appState: {
...appState, ...appState,
...nextAppState, currentItemFontFamily: value,
}, },
storeAction: nexStoreAction, storeAction: StoreAction.CAPTURE,
}; };
if (nextFontFamily && !skipOnHoverRender) {
const elementContainerMapping = new Map<
ExcalidrawTextElement,
ExcalidrawElement | null
>();
let uniqueChars = new Set<string>();
let skipFontFaceCheck = false;
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
const fontFamily = Object.entries(FONT_FAMILY).find(
([_, value]) => value === nextFontFamily,
)?.[0];
// skip `document.font.check` check on hover, if at least one font family has loaded as it's super slow (could result in slightly different bbox, which is fine)
if (
currentHoveredFontFamily &&
fontFamily &&
fontsCache.some((sig) => sig.startsWith(fontFamily))
) {
skipFontFaceCheck = true;
}
// following causes re-render so make sure we changed the family
// otherwise it could cause unexpected issues, such as preventing opening the popover when in wysiwyg
Object.assign(result, {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (
isTextElement(oldElement) &&
(oldElement.fontFamily !== nextFontFamily ||
currentItemFontFamily) // force update on selection
) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{
fontFamily: nextFontFamily,
lineHeight: getLineHeight(nextFontFamily!),
},
);
const cachedContainer =
cachedElements?.get(oldElement.containerId || "") || {};
const container = app.scene.getContainerElement(oldElement);
if (resetContainers && container && cachedContainer) {
// reset the container back to it's cached version
mutateElement(container, { ...cachedContainer }, false);
}
if (!skipFontFaceCheck) {
uniqueChars = new Set([
...uniqueChars,
...Array.from(newElement.originalText),
]);
}
elementContainerMapping.set(newElement, container);
return newElement;
}
return oldElement;
},
true,
),
});
// size is irrelevant, but necessary
const fontString = `10px ${getFontFamilyString({
fontFamily: nextFontFamily,
})}`;
const chars = Array.from(uniqueChars.values()).join();
if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
for (const [element, container] of elementContainerMapping) {
// trigger synchronous redraw
redrawTextBoundingBox(
element,
container,
app.scene.getNonDeletedElementsMap(),
false,
);
}
} else {
// otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
window.document.fonts.load(fontString, chars).then((fontFaces) => {
for (const [element, container] of elementContainerMapping) {
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
const latestElement = app.scene.getElement(element.id);
const latestContainer = container
? app.scene.getElement(container.id)
: null;
if (latestElement) {
// trigger async redraw
redrawTextBoundingBox(
latestElement as ExcalidrawTextElement,
latestContainer,
app.scene.getNonDeletedElementsMap(),
false,
);
}
}
// trigger update once we've mutated all the elements, which also updates our cache
app.fonts.onLoaded(fontFaces);
});
}
}
return result;
}, },
PanelComponent: ({ elements, appState, app, updateData }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map()); const options: {
const prevSelectedFontFamilyRef = useRef<number | null>(null); value: FontFamilyValues;
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them text: string;
const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({}); icon: JSX.Element;
const isUnmounted = useRef(true); testId: string;
}[] = [
const selectedFontFamily = useMemo(() => { {
const getFontFamily = ( value: FONT_FAMILY.Virgil,
elementsArray: readonly ExcalidrawElement[], text: t("labels.handDrawn"),
elementsMap: Map<string, ExcalidrawElement>, icon: FreedrawIcon,
) => testId: "font-family-virgil",
getFormValue( },
elementsArray, {
appState, value: FONT_FAMILY.Helvetica,
(element) => { text: t("labels.normal"),
if (isTextElement(element)) { icon: FontFamilyNormalIcon,
return element.fontFamily; testId: "font-family-normal",
} },
const boundTextElement = getBoundTextElement(element, elementsMap); {
if (boundTextElement) { value: FONT_FAMILY.Cascadia,
return boundTextElement.fontFamily; text: t("labels.code"),
} icon: FontFamilyCodeIcon,
return null; testId: "font-family-code",
}, },
(element) => ];
isTextElement(element) ||
getBoundTextElement(element, elementsMap) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
);
// popup opened, use cached elements
if (
batchedData.openPopup === "fontFamily" &&
appState.openPopup === "fontFamily"
) {
return getFontFamily(
Array.from(cachedElementsRef.current?.values() ?? []),
cachedElementsRef.current,
);
}
// popup closed, use all elements
if (!batchedData.openPopup && appState.openPopup !== "fontFamily") {
return getFontFamily(elements, app.scene.getNonDeletedElementsMap());
}
// popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had
return prevSelectedFontFamilyRef.current;
}, [batchedData.openPopup, appState, elements, app.scene]);
useEffect(() => {
prevSelectedFontFamilyRef.current = selectedFontFamily;
}, [selectedFontFamily]);
useEffect(() => {
if (Object.keys(batchedData).length) {
updateData(batchedData);
// reset the data after we've used the data
setBatchedData({});
}
// call update only on internal state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [batchedData]);
useEffect(() => {
isUnmounted.current = false;
return () => {
isUnmounted.current = true;
};
}, []);
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.fontFamily")}</legend> <legend>{t("labels.fontFamily")}</legend>
<FontPicker <ButtonIconSelect<FontFamilyValues | false>
isOpened={appState.openPopup === "fontFamily"} group="font-family"
selectedFontFamily={selectedFontFamily} options={options}
hoveredFontFamily={appState.currentHoveredFontFamily} value={getFormValue(
onSelect={(fontFamily) => { elements,
setBatchedData({ appState,
openPopup: null, (element) => {
currentHoveredFontFamily: null, if (isTextElement(element)) {
currentItemFontFamily: fontFamily, return element.fontFamily;
});
// defensive clear so immediate close won't abuse the cached elements
cachedElementsRef.current.clear();
}}
onHover={(fontFamily) => {
setBatchedData({
currentHoveredFontFamily: fontFamily,
cachedElements: new Map(cachedElementsRef.current),
resetContainers: true,
});
}}
onLeave={() => {
setBatchedData({
currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current),
resetAll: true,
});
}}
onPopupChange={(open) => {
if (open) {
// open, populate the cache from scratch
cachedElementsRef.current.clear();
const { editingElement } = appState;
if (editingElement?.type === "text") {
// retrieve the latest version from the scene, as `editingElement` isn't mutated
const latestEditingElement = app.scene.getElement(
editingElement.id,
);
// inside the wysiwyg editor
cachedElementsRef.current.set(
editingElement.id,
newElementWith(
latestEditingElement || editingElement,
{},
true,
),
);
} else {
const selectedElements = getSelectedElements(
elements,
appState,
{
includeBoundTextElement: true,
},
);
for (const element of selectedElements) {
cachedElementsRef.current.set(
element.id,
newElementWith(element, {}, true),
);
}
} }
const boundTextElement = getBoundTextElement(
setBatchedData({ element,
openPopup: "fontFamily", app.scene.getNonDeletedElementsMap(),
}); );
} else { if (boundTextElement) {
// close, use the cache and clear it afterwards return boundTextElement.fontFamily;
const data = {
openPopup: null,
currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current),
resetAll: true,
} as ChangeFontFamilyData;
if (isUnmounted.current) {
// in case the component was unmounted by the parent, trigger the update directly
updateData({ ...batchedData, ...data });
} else {
setBatchedData(data);
} }
return null;
cachedElementsRef.current.clear(); },
} (element) =>
}} isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
)}
onChange={(value) => updateData(value)}
/> />
</fieldset> </fieldset>
); );
@@ -1321,12 +1019,8 @@ export const actionChangeRoundness = register({
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => { elements: changeProperty(elements, appState, (el) =>
if (isElbowArrow(el)) { newElementWith(el, {
return el;
}
return newElementWith(el, {
roundness: roundness:
value === "round" value === "round"
? { ? {
@@ -1335,8 +1029,8 @@ export const actionChangeRoundness = register({
: ROUNDNESS.PROPORTIONAL_RADIUS, : ROUNDNESS.PROPORTIONAL_RADIUS,
} }
: null, : null,
}); }),
}), ),
appState: { appState: {
...appState, ...appState,
currentItemRoundness: value, currentItemRoundness: value,
@@ -1376,8 +1070,7 @@ export const actionChangeRoundness = register({
appState, appState,
(element) => (element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp", hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(element) => (element) => element.hasOwnProperty("roundness"),
!isArrowElement(element) && element.hasOwnProperty("roundness"),
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemRoundness, hasSelection ? null : appState.currentItemRoundness,
)} )}
@@ -1540,219 +1233,3 @@ export const actionChangeArrowhead = register({
); );
}, },
}); });
export const actionChangeArrowType = register({
name: "changeArrowType",
label: "Change arrow types",
trackEvent: false,
perform: (elements, appState, value, app) => {
return {
elements: changeProperty(elements, appState, (el) => {
if (!isArrowElement(el)) {
return el;
}
const newElement = newElementWith(el, {
roundness:
value === ARROW_TYPE.round
? {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
elbowed: value === ARROW_TYPE.elbow,
points:
value === ARROW_TYPE.elbow || el.elbowed
? [el.points[0], el.points[el.points.length - 1]]
: el.points,
});
if (isElbowArrow(newElement)) {
const elementsMap = app.scene.getNonDeletedElementsMap();
app.dismissLinearEditor();
const startGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
0,
elementsMap,
);
const endGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
-1,
elementsMap,
);
const startHoveredElement =
!newElement.startBinding &&
getHoveredElementForBinding(
tupleToCoors(startGlobalPoint),
elements,
elementsMap,
true,
);
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
true,
);
const startElement = startHoveredElement
? startHoveredElement
: newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement = endHoveredElement
? endHoveredElement
: newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const finalStartPoint = startHoveredElement
? bindPointToSnapToElementOutline(
startGlobalPoint,
endGlobalPoint,
startHoveredElement,
elementsMap,
)
: startGlobalPoint;
const finalEndPoint = endHoveredElement
? bindPointToSnapToElementOutline(
endGlobalPoint,
startGlobalPoint,
endHoveredElement,
elementsMap,
)
: endGlobalPoint;
startHoveredElement &&
bindLinearElement(
newElement,
startHoveredElement,
"start",
elementsMap,
);
endHoveredElement &&
bindLinearElement(
newElement,
endHoveredElement,
"end",
elementsMap,
);
mutateElbowArrow(
newElement,
elementsMap,
[finalStartPoint, finalEndPoint].map(
(point) =>
[point[0] - newElement.x, point[1] - newElement.y] as Point,
),
[0, 0],
{
...(startElement && newElement.startBinding
? {
startBinding: {
// @ts-ignore TS cannot discern check above
...newElement.startBinding!,
...calculateFixedPointForElbowArrowBinding(
newElement,
startElement,
"start",
elementsMap,
),
},
}
: {}),
...(endElement && newElement.endBinding
? {
endBinding: {
// @ts-ignore TS cannot discern check above
...newElement.endBinding,
...calculateFixedPointForElbowArrowBinding(
newElement,
endElement,
"end",
elementsMap,
),
},
}
: {}),
},
);
} else {
mutateElement(
newElement,
{
startBinding: newElement.startBinding
? { ...newElement.startBinding, fixedPoint: null }
: null,
endBinding: newElement.endBinding
? { ...newElement.endBinding, fixedPoint: null }
: null,
},
false,
);
}
return newElement;
}),
appState: {
...appState,
currentItemArrowType: value,
},
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
return (
<fieldset>
<legend>{t("labels.arrowtypes")}</legend>
<ButtonIconSelect
group="arrowtypes"
options={[
{
value: ARROW_TYPE.sharp,
text: t("labels.arrowtype_sharp"),
icon: sharpArrowIcon,
testId: "sharp-arrow",
},
{
value: ARROW_TYPE.round,
text: t("labels.arrowtype_round"),
icon: roundArrowIcon,
testId: "round-arrow",
},
{
value: ARROW_TYPE.elbow,
text: t("labels.arrowtype_elbowed"),
icon: elbowArrowIcon,
testId: "elbow-arrow",
},
]}
value={getFormValue(
elements,
appState,
(element) => {
if (isArrowElement(element)) {
return element.elbowed
? ARROW_TYPE.elbow
: element.roundness
? ARROW_TYPE.round
: ARROW_TYPE.sharp;
}
return null;
},
(element) => isArrowElement(element),
(hasSelection) =>
hasSelection ? null : appState.currentItemArrowType,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
},
});
+5 -3
View File
@@ -12,7 +12,10 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
} from "../constants"; } from "../constants";
import { getBoundTextElement } from "../element/textElement"; import {
getBoundTextElement,
getDefaultLineHeight,
} from "../element/textElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
canApplyRoundnessTypeToElement, canApplyRoundnessTypeToElement,
@@ -24,7 +27,6 @@ import { getSelectedElements } from "../scene";
import type { ExcalidrawTextElement } from "../element/types"; import type { ExcalidrawTextElement } from "../element/types";
import { paintIcon } from "../components/icons"; import { paintIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { getLineHeight } from "../fonts";
// `copiedStyles` is exported only for tests. // `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}"; export let copiedStyles: string = "{}";
@@ -120,7 +122,7 @@ export const actionPasteStyles = register({
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
lineHeight: lineHeight:
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight || (elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
getLineHeight(fontFamily), getDefaultLineHeight(fontFamily),
}); });
let container = null; let container = null;
if (newElement.containerId) { if (newElement.containerId) {
@@ -5,22 +5,21 @@ import { StoreAction } from "../store";
export const actionToggleStats = register({ export const actionToggleStats = register({
name: "stats", name: "stats",
label: "stats.fullTitle", label: "stats.title",
icon: abacusIcon, icon: abacusIcon,
paletteName: "Toggle stats", paletteName: "Toggle stats",
viewMode: true, viewMode: true,
trackEvent: { category: "menu" }, trackEvent: { category: "menu" },
keywords: ["edit", "attributes", "customize"],
perform(elements, appState) { perform(elements, appState) {
return { return {
appState: { appState: {
...appState, ...appState,
stats: { ...appState.stats, open: !this.checked!(appState) }, showStats: !this.checked!(appState),
}, },
storeAction: StoreAction.NONE, storeAction: StoreAction.NONE,
}; };
}, },
checked: (appState) => appState.stats.open, checked: (appState) => appState.showStats,
keyTest: (event) => keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH, !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
}); });
-1
View File
@@ -70,7 +70,6 @@ export type ActionName =
| "changeSloppiness" | "changeSloppiness"
| "changeStrokeStyle" | "changeStrokeStyle"
| "changeArrowhead" | "changeArrowhead"
| "changeArrowType"
| "changeOpacity" | "changeOpacity"
| "changeFontSize" | "changeFontSize"
| "toggleCanvasMenu" | "toggleCanvasMenu"
+7 -10
View File
@@ -1,6 +1,6 @@
// place here categories that you want to track. We want to track just a // place here categories that you want to track. We want to track just a
// small subset of categories at a given time. // small subset of categories at a given time.
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]); const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
export const trackEvent = ( export const trackEvent = (
category: string, category: string,
@@ -9,20 +9,17 @@ export const trackEvent = (
value?: number, value?: number,
) => { ) => {
try { try {
// prettier-ignore
if ( if (
typeof window === "undefined" || typeof window === "undefined"
import.meta.env.VITE_WORKER_ID || || import.meta.env.VITE_WORKER_ID
import.meta.env.VITE_APP_ENABLE_TRACKING !== "true" // comment out to debug locally
|| import.meta.env.PROD
) { ) {
return; return;
} }
if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) { if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
return;
}
if (import.meta.env.DEV) {
// comment out to debug in dev
return; return;
} }
+4 -17
View File
@@ -1,12 +1,10 @@
import { COLOR_PALETTE } from "./colors"; import { COLOR_PALETTE } from "./colors";
import { import {
ARROW_TYPE,
DEFAULT_ELEMENT_PROPS, DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
EXPORT_SCALES, EXPORT_SCALES,
STATS_PANELS,
THEME, THEME,
} from "./constants"; } from "./constants";
import type { AppState, NormalizedZoomValue } from "./types"; import type { AppState, NormalizedZoomValue } from "./types";
@@ -34,14 +32,12 @@ export const getDefaultAppState = (): Omit<
currentItemStartArrowhead: null, currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor, currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemRoundness: "round", currentItemRoundness: "round",
currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN, currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentHoveredFontFamily: null,
cursorButton: "up", cursorButton: "up",
activeEmbeddable: null, activeEmbeddable: null,
newElement: null, draggingElement: null,
editingElement: null, editingElement: null,
editingGroupId: null, editingGroupId: null,
editingLinearElement: null, editingLinearElement: null,
@@ -84,10 +80,7 @@ export const getDefaultAppState = (): Omit<
selectedElementsAreBeingDragged: false, selectedElementsAreBeingDragged: false,
selectionElement: null, selectionElement: null,
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
stats: { showStats: false,
open: false,
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
},
startBoundElement: null, startBoundElement: null,
suggestedBindings: [], suggestedBindings: [],
frameRendering: { enabled: true, clip: true, name: true, outline: true }, frameRendering: { enabled: true, clip: true, name: true, outline: true },
@@ -145,11 +138,6 @@ const APP_STATE_STORAGE_CONF = (<
export: false, export: false,
server: false, server: false,
}, },
currentItemArrowType: {
browser: true,
export: false,
server: false,
},
currentItemOpacity: { browser: true, export: false, server: false }, currentItemOpacity: { browser: true, export: false, server: false },
currentItemRoughness: { browser: true, export: false, server: false }, currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false }, currentItemStartArrowhead: { browser: true, export: false, server: false },
@@ -157,10 +145,9 @@ const APP_STATE_STORAGE_CONF = (<
currentItemStrokeStyle: { browser: true, export: false, server: false }, currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false }, currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false }, currentItemTextAlign: { browser: true, export: false, server: false },
currentHoveredFontFamily: { browser: false, export: false, server: false },
cursorButton: { browser: true, export: false, server: false }, cursorButton: { browser: true, export: false, server: false },
activeEmbeddable: { browser: false, export: false, server: false }, activeEmbeddable: { browser: false, export: false, server: false },
newElement: { browser: false, export: false, server: false }, draggingElement: { browser: false, export: false, server: false },
editingElement: { browser: false, export: false, server: false }, editingElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false }, editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false }, editingLinearElement: { browser: false, export: false, server: false },
@@ -209,7 +196,7 @@ const APP_STATE_STORAGE_CONF = (<
}, },
selectionElement: { browser: false, export: false, server: false }, selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
stats: { browser: true, export: false, server: false }, showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false }, startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false }, suggestedBindings: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false }, frameRendering: { browser: false, export: false, server: false },
-105
View File
@@ -1,105 +0,0 @@
export default class BinaryHeap<T> {
private content: T[] = [];
constructor(private scoreFunction: (node: T) => number) {}
sinkDown(idx: number) {
const node = this.content[idx];
while (idx > 0) {
const parentN = ((idx + 1) >> 1) - 1;
const parent = this.content[parentN];
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
this.content[parentN] = node;
this.content[idx] = parent;
idx = parentN; // TODO: Optimize
} else {
break;
}
}
}
bubbleUp(idx: number) {
const length = this.content.length;
const node = this.content[idx];
const score = this.scoreFunction(node);
while (true) {
const child2N = (idx + 1) << 1;
const child1N = child2N - 1;
let swap = null;
let child1Score = 0;
if (child1N < length) {
const child1 = this.content[child1N];
child1Score = this.scoreFunction(child1);
if (child1Score < score) {
swap = child1N;
}
}
if (child2N < length) {
const child2 = this.content[child2N];
const child2Score = this.scoreFunction(child2);
if (child2Score < (swap === null ? score : child1Score)) {
swap = child2N;
}
}
if (swap !== null) {
this.content[idx] = this.content[swap];
this.content[swap] = node;
idx = swap; // TODO: Optimize
} else {
break;
}
}
}
push(node: T) {
this.content.push(node);
this.sinkDown(this.content.length - 1);
}
pop(): T | null {
if (this.content.length === 0) {
return null;
}
const result = this.content[0];
const end = this.content.pop()!;
if (this.content.length > 0) {
this.content[0] = end;
this.bubbleUp(0);
}
return result;
}
remove(node: T) {
if (this.content.length === 0) {
return;
}
const i = this.content.indexOf(node);
const end = this.content.pop()!;
if (i < this.content.length) {
this.content[i] = end;
if (this.scoreFunction(end) < this.scoreFunction(node)) {
this.sinkDown(i);
} else {
this.bubbleUp(i);
}
}
}
size(): number {
return this.content.length;
}
rescoreElement(node: T) {
this.sinkDown(this.content.indexOf(node));
}
}
+2 -6
View File
@@ -1100,6 +1100,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
try { try {
// TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state // TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements); ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
ElementsChange.redrawBoundArrows(nextElements, changedElements);
// the following reorder performs also mutations, but only on new instances of changed elements // the following reorder performs also mutations, but only on new instances of changed elements
// (unless something goes really bad and it fallbacks to fixing all invalid indices) // (unless something goes really bad and it fallbacks to fixing all invalid indices)
@@ -1108,9 +1109,6 @@ export class ElementsChange implements Change<SceneElementsMap> {
changedElements, changedElements,
flags, flags,
); );
// Need ordered nextElements to avoid z-index binding issues
ElementsChange.redrawBoundArrows(nextElements, changedElements);
} catch (e) { } catch (e) {
console.error( console.error(
`Couldn't mutate elements after applying elements change`, `Couldn't mutate elements after applying elements change`,
@@ -1462,9 +1460,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
) { ) {
for (const element of changed.values()) { for (const element of changed.values()) {
if (!element.isDeleted && isBindableElement(element)) { if (!element.isDeleted && isBindableElement(element)) {
updateBoundElements(element, elements, { updateBoundElements(element, elements);
changedElements: changed,
});
} }
} }
} }
+10
View File
@@ -257,6 +257,8 @@ const chartLines = (
type: "line", type: "line",
x, x,
y, y,
startArrowhead: null,
endArrowhead: null,
width: chartWidth, width: chartWidth,
points: [ points: [
[0, 0], [0, 0],
@@ -271,6 +273,8 @@ const chartLines = (
type: "line", type: "line",
x, x,
y, y,
startArrowhead: null,
endArrowhead: null,
height: chartHeight, height: chartHeight,
points: [ points: [
[0, 0], [0, 0],
@@ -285,6 +289,8 @@ const chartLines = (
type: "line", type: "line",
x, x,
y: y - BAR_HEIGHT - BAR_GAP, y: y - BAR_HEIGHT - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
strokeStyle: "dotted", strokeStyle: "dotted",
width: chartWidth, width: chartWidth,
opacity: GRID_OPACITY, opacity: GRID_OPACITY,
@@ -412,6 +418,8 @@ const chartTypeLine = (
type: "line", type: "line",
x: x + BAR_GAP + BAR_WIDTH / 2, x: x + BAR_GAP + BAR_WIDTH / 2,
y: y - BAR_GAP, y: y - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
height: maxY - minY, height: maxY - minY,
width: maxX - minX, width: maxX - minX,
strokeWidth: 2, strokeWidth: 2,
@@ -445,6 +453,8 @@ const chartTypeLine = (
type: "line", type: "line",
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2, x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
y: y - cy, y: y - cy,
startArrowhead: null,
endArrowhead: null,
height: cy, height: cy,
strokeStyle: "dotted", strokeStyle: "dotted",
opacity: GRID_OPACITY, opacity: GRID_OPACITY,
+5 -10
View File
@@ -21,11 +21,10 @@ import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
import { capitalizeString, isTransparent } from "../utils"; import { capitalizeString, isTransparent } from "../utils";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { hasStrokeColor, toolIsArrow } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { import {
hasBoundTextElement, hasBoundTextElement,
isElbowArrow,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
@@ -122,8 +121,7 @@ export const SelectedShapeActions = ({
const showLineEditorAction = const showLineEditorAction =
!appState.editingLinearElement && !appState.editingLinearElement &&
targetElements.length === 1 && targetElements.length === 1 &&
isLinearElement(targetElements[0]) && isLinearElement(targetElements[0]);
!isElbowArrow(targetElements[0]);
return ( return (
<div className="panelColumn"> <div className="panelColumn">
@@ -157,16 +155,13 @@ export const SelectedShapeActions = ({
<>{renderAction("changeRoundness")}</> <>{renderAction("changeRoundness")}</>
)} )}
{(toolIsArrow(appState.activeTool.type) ||
targetElements.some((element) => toolIsArrow(element.type))) && (
<>{renderAction("changeArrowType")}</>
)}
{(appState.activeTool.type === "text" || {(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && ( targetElements.some(isTextElement)) && (
<> <>
{renderAction("changeFontFamily")}
{renderAction("changeFontSize")} {renderAction("changeFontSize")}
{renderAction("changeFontFamily")}
{(appState.activeTool.type === "text" || {(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements, elementsMap)) && suppportsHorizontalAlign(targetElements, elementsMap)) &&
renderAction("changeTextAlign")} renderAction("changeTextAlign")}
File diff suppressed because it is too large Load Diff
@@ -1,12 +0,0 @@
@import "../css/theme";
.excalidraw {
button.standalone {
@include outlineButtonIconStyles;
& > * {
// dissalow pointer events on children, so we always have event.target on the button itself
pointer-events: none;
}
}
}
@@ -1,36 +0,0 @@
import { forwardRef } from "react";
import clsx from "clsx";
import "./ButtonIcon.scss";
interface ButtonIconProps {
icon: JSX.Element;
title: string;
className?: string;
testId?: string;
/** if not supplied, defaults to value identity check */
active?: boolean;
/** include standalone style (could interfere with parent styles) */
standalone?: boolean;
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
(props, ref) => {
const { title, className, testId, active, standalone, icon, onClick } =
props;
return (
<button
type="button"
ref={ref}
key={title}
title={title}
data-testid={testId}
className={clsx(className, { standalone, active })}
onClick={onClick}
>
{icon}
</button>
);
},
);
@@ -1,5 +1,4 @@
import clsx from "clsx"; import clsx from "clsx";
import { ButtonIcon } from "./ButtonIcon";
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect /> // TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>( export const ButtonIconSelect = <T extends Object>(
@@ -25,17 +24,21 @@ export const ButtonIconSelect = <T extends Object>(
} }
), ),
) => ( ) => (
<div className="buttonList"> <div className="buttonList buttonListIcon">
{props.options.map((option) => {props.options.map((option) =>
props.type === "button" ? ( props.type === "button" ? (
<ButtonIcon <button
type="button"
key={option.text} key={option.text}
icon={option.icon}
title={option.text}
testId={option.testId}
active={option.active ?? props.value === option.value}
onClick={(event) => props.onClick(option.value, event)} onClick={(event) => props.onClick(option.value, event)}
/> className={clsx({
active: option.active ?? props.value === option.value,
})}
data-testid={option.testId}
title={option.text}
>
{option.icon}
</button>
) : ( ) : (
<label <label
key={option.text} key={option.text}
@@ -1,10 +0,0 @@
export const ButtonSeparator = () => (
<div
style={{
width: 1,
height: "1rem",
backgroundColor: "var(--default-border-color)",
margin: "0 auto",
}}
/>
);
@@ -20,7 +20,7 @@
align-items: center; align-items: center;
@include isMobile { @include isMobile {
max-width: 11rem; max-width: 175px;
} }
} }
@@ -1,24 +1,22 @@
import { isTransparent } from "../../utils"; import { isInteractive, isTransparent, isWritableElement } from "../../utils";
import type { ExcalidrawElement } from "../../element/types"; import type { ExcalidrawElement } from "../../element/types";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import { TopPicks } from "./TopPicks"; import { TopPicks } from "./TopPicks";
import { ButtonSeparator } from "../ButtonSeparator";
import { Picker } from "./Picker"; import { Picker } from "./Picker";
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type { ColorPickerType } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils";
import { activeColorPickerSectionAtom } from "./colorPickerUtils"; import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { useExcalidrawContainer } from "../App"; import { useDevice, useExcalidrawContainer } from "../App";
import type { ColorTuple, ColorPaletteCustom } from "../../colors"; import type { ColorTuple, ColorPaletteCustom } from "../../colors";
import { COLOR_PALETTE } from "../../colors"; import { COLOR_PALETTE } from "../../colors";
import PickerHeading from "./PickerHeading"; import PickerHeading from "./PickerHeading";
import { t } from "../../i18n"; import { t } from "../../i18n";
import clsx from "clsx"; import clsx from "clsx";
import { useRef } from "react";
import { jotaiScope } from "../../jotai"; import { jotaiScope } from "../../jotai";
import { ColorInput } from "./ColorInput"; import { ColorInput } from "./ColorInput";
import { useRef } from "react";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
import "./ColorPicker.scss"; import "./ColorPicker.scss";
@@ -73,7 +71,6 @@ const ColorPickerPopupContent = ({
| "palette" | "palette"
| "updateData" | "updateData"
>) => { >) => {
const { container } = useExcalidrawContainer();
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom); const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
const [eyeDropperState, setEyeDropperState] = useAtom( const [eyeDropperState, setEyeDropperState] = useAtom(
@@ -81,6 +78,9 @@ const ColorPickerPopupContent = ({
jotaiScope, jotaiScope,
); );
const { container } = useExcalidrawContainer();
const device = useDevice();
const colorInputJSX = ( const colorInputJSX = (
<div> <div>
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading> <PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
@@ -94,7 +94,6 @@ const ColorPickerPopupContent = ({
/> />
</div> </div>
); );
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const focusPickerContent = () => { const focusPickerContent = () => {
@@ -104,73 +103,120 @@ const ColorPickerPopupContent = ({
}; };
return ( return (
<PropertiesPopover <Popover.Portal container={container}>
container={container} <Popover.Content
style={{ maxWidth: "208px" }} ref={popoverRef}
onFocusOutside={(event) => { className="focus-visible-none"
// refocus due to eye dropper data-prevent-outside-click
focusPickerContent(); onFocusOutside={(event) => {
event.preventDefault(); focusPickerContent();
}}
onPointerDownOutside={(event) => {
if (eyeDropperState) {
// prevent from closing if we click outside the popover
// while eyedropping (e.g. click when clicking the sidebar;
// the eye-dropper-backdrop is prevented downstream)
event.preventDefault(); event.preventDefault();
} }}
}} onPointerDownOutside={(event) => {
onClose={() => { if (eyeDropperState) {
updateData({ openPopup: null }); // prevent from closing if we click outside the popover
setActiveColorPickerSection(null); // while eyedropping (e.g. click when clicking the sidebar;
}} // the eye-dropper-backdrop is prevented downstream)
> event.preventDefault();
{palette ? ( }
<Picker }}
palette={palette} onCloseAutoFocus={(e) => {
color={color} e.stopPropagation();
onChange={(changedColor) => { // prevents focusing the trigger
onChange(changedColor); e.preventDefault();
}}
onEyeDropperToggle={(force) => {
setEyeDropperState((state) => {
if (force) {
state = state || {
keepOpenOnAlt: true,
onSelect: onChange,
colorPickerType: type,
};
state.keepOpenOnAlt = true;
return state;
}
return force === false || state // return focus to excalidraw container unless
? null // user focuses an interactive element, such as a button, or
: { // enters the text editor by clicking on canvas with the text tool
keepOpenOnAlt: false, if (container && !isInteractive(document.activeElement)) {
container.focus();
}
updateData({ openPopup: null });
setActiveColorPickerSection(null);
}}
side={
device.editor.isMobile && !device.viewport.isLandscape
? "bottom"
: "right"
}
align={
device.editor.isMobile && !device.viewport.isLandscape
? "center"
: "start"
}
alignOffset={-16}
sideOffset={20}
style={{
zIndex: "var(--zIndex-layerUI)",
backgroundColor: "var(--popup-bg-color)",
maxWidth: "208px",
maxHeight: window.innerHeight,
padding: "12px",
borderRadius: "8px",
boxSizing: "border-box",
overflowY: "auto",
boxShadow:
"0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)",
}}
>
{palette ? (
<Picker
palette={palette}
color={color}
onChange={(changedColor) => {
onChange(changedColor);
}}
onEyeDropperToggle={(force) => {
setEyeDropperState((state) => {
if (force) {
state = state || {
keepOpenOnAlt: true,
onSelect: onChange, onSelect: onChange,
colorPickerType: type, colorPickerType: type,
}; };
}); state.keepOpenOnAlt = true;
return state;
}
return force === false || state
? null
: {
keepOpenOnAlt: false,
onSelect: onChange,
colorPickerType: type,
};
});
}}
onEscape={(event) => {
if (eyeDropperState) {
setEyeDropperState(null);
} else if (isWritableElement(event.target)) {
focusPickerContent();
} else {
updateData({ openPopup: null });
}
}}
label={label}
type={type}
elements={elements}
updateData={updateData}
>
{colorInputJSX}
</Picker>
) : (
colorInputJSX
)}
<Popover.Arrow
width={20}
height={10}
style={{
fill: "var(--popup-bg-color)",
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
}} }}
onEscape={(event) => { />
if (eyeDropperState) { </Popover.Content>
setEyeDropperState(null); </Popover.Portal>
} else {
updateData({ openPopup: null });
}
}}
label={label}
type={type}
elements={elements}
updateData={updateData}
>
{colorInputJSX}
</Picker>
) : (
colorInputJSX
)}
</PropertiesPopover>
); );
}; };
@@ -186,7 +232,7 @@ const ColorPickerTrigger = ({
return ( return (
<Popover.Trigger <Popover.Trigger
type="button" type="button"
className={clsx("color-picker__button active-color properties-trigger", { className={clsx("color-picker__button active-color", {
"is-transparent": color === "transparent" || !color, "is-transparent": color === "transparent" || !color,
})} })}
aria-label={label} aria-label={label}
@@ -222,7 +268,14 @@ export const ColorPicker = ({
type={type} type={type}
topPicks={topPicks} topPicks={topPicks}
/> />
<ButtonSeparator /> <div
style={{
width: 1,
height: "100%",
backgroundColor: "var(--default-border-color)",
margin: "0 auto",
}}
/>
<Popover.Root <Popover.Root
open={appState.openPopup === type} open={appState.openPopup === type}
onOpenChange={(open) => { onOpenChange={(open) => {
@@ -138,7 +138,7 @@ export const Picker = ({
event.stopPropagation(); event.stopPropagation();
} }
}} }}
className="color-picker-content properties-content" className="color-picker-content"
// to allow focusing by clicking but not by tabbing // to allow focusing by clicking but not by tabbing
tabIndex={-1} tabIndex={-1}
> >
@@ -9,7 +9,7 @@ import {
import { import {
assertExcalidrawWithSidebar, assertExcalidrawWithSidebar,
assertSidebarDockButton, assertSidebarDockButton,
} from "./Sidebar/siderbar.test.helpers"; } from "./Sidebar/Sidebar.test";
const { h } = window; const { h } = window;
@@ -1,15 +0,0 @@
@import "../../css/variables.module.scss";
.excalidraw {
.FontPicker__container {
display: grid;
grid-template-columns: calc(1rem + 3 * var(--default-button-size)) 1rem 1fr; // calc ~ 2 gaps + 4 buttons
align-items: center;
@include isMobile {
max-width: calc(
2rem + 4 * var(--default-button-size)
); // 4 gaps + 4 buttons
}
}
}
@@ -1,110 +0,0 @@
import React, { useCallback, useMemo } from "react";
import * as Popover from "@radix-ui/react-popover";
import { FontPickerList } from "./FontPickerList";
import { FontPickerTrigger } from "./FontPickerTrigger";
import { ButtonIconSelect } from "../ButtonIconSelect";
import {
FontFamilyCodeIcon,
FontFamilyNormalIcon,
FreedrawIcon,
} from "../icons";
import { ButtonSeparator } from "../ButtonSeparator";
import type { FontFamilyValues } from "../../element/types";
import { FONT_FAMILY } from "../../constants";
import { t } from "../../i18n";
import "./FontPicker.scss";
export const DEFAULT_FONTS = [
{
value: FONT_FAMILY.Excalifont,
icon: FreedrawIcon,
text: t("labels.handDrawn"),
testId: "font-family-handrawn",
},
{
value: FONT_FAMILY.Nunito,
icon: FontFamilyNormalIcon,
text: t("labels.normal"),
testId: "font-family-normal",
},
{
value: FONT_FAMILY["Comic Shanns"],
icon: FontFamilyCodeIcon,
text: t("labels.code"),
testId: "font-family-code",
},
];
const defaultFontFamilies = new Set(DEFAULT_FONTS.map((x) => x.value));
export const isDefaultFont = (fontFamily: number | null) => {
if (!fontFamily) {
return false;
}
return defaultFontFamilies.has(fontFamily);
};
interface FontPickerProps {
isOpened: boolean;
selectedFontFamily: FontFamilyValues | null;
hoveredFontFamily: FontFamilyValues | null;
onSelect: (fontFamily: FontFamilyValues) => void;
onHover: (fontFamily: FontFamilyValues) => void;
onLeave: () => void;
onPopupChange: (open: boolean) => void;
}
export const FontPicker = React.memo(
({
isOpened,
selectedFontFamily,
hoveredFontFamily,
onSelect,
onHover,
onLeave,
onPopupChange,
}: FontPickerProps) => {
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
const onSelectCallback = useCallback(
(value: number | false) => {
if (value) {
onSelect(value);
}
},
[onSelect],
);
return (
<div role="dialog" aria-modal="true" className="FontPicker__container">
<ButtonIconSelect<FontFamilyValues | false>
type="button"
options={defaultFonts}
value={selectedFontFamily}
onClick={onSelectCallback}
/>
<ButtonSeparator />
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
{isOpened && (
<FontPickerList
selectedFontFamily={selectedFontFamily}
hoveredFontFamily={hoveredFontFamily}
onSelect={onSelectCallback}
onHover={onHover}
onLeave={onLeave}
onOpen={() => onPopupChange(true)}
onClose={() => onPopupChange(false)}
/>
)}
</Popover.Root>
</div>
);
},
(prev, next) =>
prev.isOpened === next.isOpened &&
prev.selectedFontFamily === next.selectedFontFamily &&
prev.hoveredFontFamily === next.hoveredFontFamily,
);
@@ -1,268 +0,0 @@
import React, {
useMemo,
useState,
useRef,
useEffect,
useCallback,
type KeyboardEventHandler,
} from "react";
import { useApp, useAppProps, useExcalidrawContainer } from "../App";
import { PropertiesPopover } from "../PropertiesPopover";
import { QuickSearch } from "../QuickSearch";
import { ScrollableList } from "../ScrollableList";
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
import DropdownMenuItem, {
DropDownMenuItemBadgeType,
DropDownMenuItemBadge,
} from "../dropdownMenu/DropdownMenuItem";
import { type FontFamilyValues } from "../../element/types";
import { arrayToList, debounce, getFontFamilyString } from "../../utils";
import { t } from "../../i18n";
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import { Fonts } from "../../fonts";
import type { ValueOf } from "../../utility-types";
export interface FontDescriptor {
value: number;
icon: JSX.Element;
text: string;
deprecated?: true;
badge?: {
type: ValueOf<typeof DropDownMenuItemBadgeType>;
placeholder: string;
};
}
interface FontPickerListProps {
selectedFontFamily: FontFamilyValues | null;
hoveredFontFamily: FontFamilyValues | null;
onSelect: (value: number) => void;
onHover: (value: number) => void;
onLeave: () => void;
onOpen: () => void;
onClose: () => void;
}
export const FontPickerList = React.memo(
({
selectedFontFamily,
hoveredFontFamily,
onSelect,
onHover,
onLeave,
onOpen,
onClose,
}: FontPickerListProps) => {
const { container } = useExcalidrawContainer();
const { fonts } = useApp();
const { showDeprecatedFonts } = useAppProps();
const [searchTerm, setSearchTerm] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const allFonts = useMemo(
() =>
Array.from(Fonts.registered.entries())
.filter(([_, { metadata }]) => !metadata.serverSide)
.map(([familyId, { metadata, fonts }]) => {
const fontDescriptor = {
value: familyId,
icon: metadata.icon,
text: fonts[0].fontFace.family,
};
if (metadata.deprecated) {
Object.assign(fontDescriptor, {
deprecated: metadata.deprecated,
badge: {
type: DropDownMenuItemBadgeType.RED,
placeholder: t("fontList.badge.old"),
},
});
}
return fontDescriptor as FontDescriptor;
})
.sort((a, b) =>
a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1,
),
[],
);
const sceneFamilies = useMemo(
() => new Set(fonts.getSceneFontFamilies()),
// cache per selected font family, so hover re-render won't mess it up
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedFontFamily],
);
const sceneFonts = useMemo(
() => allFonts.filter((font) => sceneFamilies.has(font.value)), // always show all the fonts in the scene, even those that were deprecated
[allFonts, sceneFamilies],
);
const availableFonts = useMemo(
() =>
allFonts.filter(
(font) =>
!sceneFamilies.has(font.value) &&
(showDeprecatedFonts || !font.deprecated), // skip deprecated fonts
),
[allFonts, sceneFamilies, showDeprecatedFonts],
);
const filteredFonts = useMemo(
() =>
arrayToList(
[...sceneFonts, ...availableFonts].filter((font) =>
font.text?.toLowerCase().includes(searchTerm),
),
),
[sceneFonts, availableFonts, searchTerm],
);
const hoveredFont = useMemo(() => {
let font;
if (hoveredFontFamily) {
font = filteredFonts.find((font) => font.value === hoveredFontFamily);
} else if (selectedFontFamily) {
font = filteredFonts.find((font) => font.value === selectedFontFamily);
}
if (!font && searchTerm) {
if (filteredFonts[0]?.value) {
// hover first element on search
onHover(filteredFonts[0].value);
} else {
// re-render cache on no results
onLeave();
}
}
return font;
}, [
hoveredFontFamily,
selectedFontFamily,
searchTerm,
filteredFonts,
onHover,
onLeave,
]);
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
(event) => {
const handled = fontPickerKeyHandler({
event,
inputRef,
hoveredFont,
filteredFonts,
onSelect,
onHover,
onClose,
});
if (handled) {
event.preventDefault();
event.stopPropagation();
}
},
[hoveredFont, filteredFonts, onSelect, onHover, onClose],
);
useEffect(() => {
onOpen();
return () => {
onClose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const sceneFilteredFonts = useMemo(
() => filteredFonts.filter((font) => sceneFamilies.has(font.value)),
[filteredFonts, sceneFamilies],
);
const availableFilteredFonts = useMemo(
() => filteredFonts.filter((font) => !sceneFamilies.has(font.value)),
[filteredFonts, sceneFamilies],
);
const renderFont = (font: FontDescriptor, index: number) => (
<DropdownMenuItem
key={font.value}
icon={font.icon}
value={font.value}
order={index}
textStyle={{
fontFamily: getFontFamilyString({ fontFamily: font.value }),
}}
hovered={font.value === hoveredFont?.value}
selected={font.value === selectedFontFamily}
// allow to tab between search and selected font
tabIndex={font.value === selectedFontFamily ? 0 : -1}
onClick={(e) => {
onSelect(Number(e.currentTarget.value));
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
onHover(font.value);
}
}}
>
{font.text}
{font.badge && (
<DropDownMenuItemBadge type={font.badge.type}>
{font.badge.placeholder}
</DropDownMenuItemBadge>
)}
</DropdownMenuItem>
);
const groups = [];
if (sceneFilteredFonts.length) {
groups.push(
<DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1">
{sceneFilteredFonts.map(renderFont)}
</DropdownMenuGroup>,
);
}
if (availableFilteredFonts.length) {
groups.push(
<DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2">
{availableFilteredFonts.map((font, index) =>
renderFont(font, index + sceneFilteredFonts.length),
)}
</DropdownMenuGroup>,
);
}
return (
<PropertiesPopover
className="properties-content"
container={container}
style={{ width: "15rem" }}
onClose={onClose}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
>
<QuickSearch
ref={inputRef}
placeholder={t("quickSearch.placeholder")}
onChange={debounce(setSearchTerm, 20)}
/>
<ScrollableList
className="dropdown-menu fonts manual-hover"
placeholder={t("fontList.empty")}
>
{groups.length ? groups : null}
</ScrollableList>
</PropertiesPopover>
);
},
(prev, next) =>
prev.selectedFontFamily === next.selectedFontFamily &&
prev.hoveredFontFamily === next.hoveredFontFamily,
);
@@ -1,38 +0,0 @@
import * as Popover from "@radix-ui/react-popover";
import { useMemo } from "react";
import { ButtonIcon } from "../ButtonIcon";
import { TextIcon } from "../icons";
import type { FontFamilyValues } from "../../element/types";
import { t } from "../../i18n";
import { isDefaultFont } from "./FontPicker";
interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null;
}
export const FontPickerTrigger = ({
selectedFontFamily,
}: FontPickerTriggerProps) => {
const isTriggerActive = useMemo(
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
[selectedFontFamily],
);
return (
<Popover.Trigger asChild>
{/* Empty div as trigger so it's stretched 100% due to different button sizes */}
<div>
<ButtonIcon
standalone
icon={TextIcon}
title={t("labels.showFonts")}
className="properties-trigger"
testId={"font-family-show-fonts"}
active={isTriggerActive}
// no-op
onClick={() => {}}
/>
</div>
</Popover.Trigger>
);
};
@@ -1,66 +0,0 @@
import type { Node } from "../../utils";
import { KEYS } from "../../keys";
import { type FontDescriptor } from "./FontPickerList";
interface FontPickerKeyNavHandlerProps {
event: React.KeyboardEvent<HTMLDivElement>;
inputRef: React.RefObject<HTMLInputElement>;
hoveredFont: Node<FontDescriptor> | undefined;
filteredFonts: Node<FontDescriptor>[];
onClose: () => void;
onSelect: (value: number) => void;
onHover: (value: number) => void;
}
export const fontPickerKeyHandler = ({
event,
inputRef,
hoveredFont,
filteredFonts,
onClose,
onSelect,
onHover,
}: FontPickerKeyNavHandlerProps) => {
if (
!event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.F
) {
// refocus input on the popup trigger shortcut
inputRef.current?.focus();
return true;
}
if (event.key === KEYS.ESCAPE) {
onClose();
return true;
}
if (event.key === KEYS.ENTER) {
if (hoveredFont?.value) {
onSelect(hoveredFont.value);
}
return true;
}
if (event.key === KEYS.ARROW_DOWN) {
if (hoveredFont?.next) {
onHover(hoveredFont.next.value);
} else if (filteredFonts[0]?.value) {
onHover(filteredFonts[0].value);
}
return true;
}
if (event.key === KEYS.ARROW_UP) {
if (hoveredFont?.prev) {
onHover(hoveredFont.prev.value);
} else if (filteredFonts[filteredFonts.length - 1]?.value) {
onHover(filteredFonts[filteredFonts.length - 1].value);
}
return true;
}
};
@@ -8,7 +8,7 @@
h3 { h3 {
margin: 1.5rem 0; margin: 1.5rem 0;
font-weight: 700; font-weight: bold;
font-size: 1.125rem; font-size: 1.125rem;
} }
@@ -82,7 +82,7 @@
&__island { &__island {
h4 { h4 {
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: bold;
margin: 0; margin: 0;
margin-bottom: 0.625rem; margin-bottom: 0.625rem;
} }
@@ -285,7 +285,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[getShortcutKey("Alt+Shift+D")]} shortcuts={[getShortcutKey("Alt+Shift+D")]}
/> />
<Shortcut <Shortcut
label={t("stats.fullTitle")} label={t("stats.title")}
shortcuts={[getShortcutKey("Alt+/")]} shortcuts={[getShortcutKey("Alt+/")]}
/> />
<Shortcut <Shortcut
@@ -458,10 +458,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.showBackground")} label={t("labels.showBackground")}
shortcuts={[getShortcutKey("G")]} shortcuts={[getShortcutKey("G")]}
/> />
<Shortcut
label={t("labels.showFonts")}
shortcuts={[getShortcutKey("Shift+F")]}
/>
<Shortcut <Shortcut
label={t("labels.decreaseFontSize")} label={t("labels.decreaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]} shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
+6 -10
View File
@@ -30,13 +30,10 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
return t("hints.eraserRevert"); return t("hints.eraserRevert");
} }
if (activeTool.type === "arrow" || activeTool.type === "line") { if (activeTool.type === "arrow" || activeTool.type === "line") {
if (multiMode) { if (!multiMode) {
return t("hints.linearElementMulti"); return t("hints.linearElement");
} }
if (activeTool.type === "arrow") { return t("hints.linearElementMulti");
return t("hints.arrowTool", { arrowShortcut: getShortcutKey("A") });
}
return t("hints.linearElement");
} }
if (activeTool.type === "freedraw") { if (activeTool.type === "freedraw") {
@@ -85,7 +82,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
if (activeTool.type === "selection") { if (activeTool.type === "selection") {
if ( if (
appState.selectionElement && appState.draggingElement?.type === "selection" &&
!selectedElements.length && !selectedElements.length &&
!appState.editingElement && !appState.editingElement &&
!appState.editingLinearElement !appState.editingLinearElement
@@ -93,7 +90,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
return t("hints.deepBoxSelect"); return t("hints.deepBoxSelect");
} }
if (appState.gridSize && appState.selectedElementsAreBeingDragged) { if (appState.gridSize && appState.draggingElement) {
return t("hints.disableSnapping"); return t("hints.disableSnapping");
} }
@@ -111,8 +108,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
return t("hints.lineEditor_info"); return t("hints.lineEditor_info");
} }
if ( if (
!appState.newElement && !appState.draggingElement &&
!appState.selectedElementsAreBeingDragged &&
isTextBindableContainer(selectedElements[0]) isTextBindableContainer(selectedElements[0])
) { ) {
return t("hints.bindTextToElement"); return t("hints.bindTextToElement");
@@ -27,99 +27,6 @@
& > * { & > * {
pointer-events: var(--ui-pointerEvents); pointer-events: var(--ui-pointerEvents);
} }
& > .Stats {
width: 204px;
position: absolute;
top: 60px;
font-size: 12px;
z-index: var(--zIndex-layerUI);
pointer-events: var(--ui-pointerEvents);
.title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
h2 {
margin: 0;
}
}
.sectionContent {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.elementType {
font-size: 12px;
font-weight: 700;
margin-top: 8px;
}
.elementsCount {
width: 100%;
font-size: 12px;
display: flex;
justify-content: space-between;
margin-top: 8px;
}
.statsItem {
margin-top: 8px;
width: 100%;
margin-bottom: 4px;
display: grid;
gap: 4px;
.label {
margin-right: 4px;
}
}
h3 {
white-space: nowrap;
margin: 0;
}
.close {
height: 16px;
width: 16px;
cursor: pointer;
svg {
width: 100%;
height: 100%;
}
}
table {
width: 100%;
th {
border-bottom: 1px solid var(--input-border-color);
padding: 4px;
}
tr {
td:nth-child(2) {
min-width: 24px;
text-align: right;
}
}
}
.divider {
width: 100%;
height: 1px;
background-color: var(--default-border-color);
}
:root[dir="rtl"] & {
left: 12px;
right: initial;
}
}
} }
&__footer { &__footer {
@@ -62,8 +62,6 @@ import Scene from "../scene/Scene";
import { LaserPointerButton } from "./LaserPointerButton"; import { LaserPointerButton } from "./LaserPointerButton";
import { MagicSettings } from "./MagicSettings"; import { MagicSettings } from "./MagicSettings";
import { TTDDialog } from "./TTDDialog/TTDDialog"; import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@@ -241,11 +239,6 @@ const LayerUI = ({
elements, elements,
); );
const shouldShowStats =
appState.stats.open &&
!appState.zenModeEnabled &&
!appState.viewModeEnabled;
return ( return (
<FixedSideContainer side="top"> <FixedSideContainer side="top">
<div className="App-menu App-menu_top"> <div className="App-menu App-menu_top">
@@ -358,15 +351,6 @@ const LayerUI = ({
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && ( appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
<tunnels.DefaultSidebarTriggerTunnel.Out /> <tunnels.DefaultSidebarTriggerTunnel.Out />
)} )}
{shouldShowStats && (
<Stats
scene={app.scene}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
renderCustomStats={renderCustomStats}
/>
)}
</div> </div>
</div> </div>
</FixedSideContainer> </FixedSideContainer>
@@ -11,7 +11,7 @@
.library-actions-counter { .library-actions-counter {
background-color: var(--color-primary); background-color: var(--color-primary);
color: var(--color-primary-light); color: var(--color-primary-light);
font-weight: 700; font-weight: bold;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -13,7 +13,7 @@
&__label { &__label {
color: var(--color-primary); color: var(--color-primary);
font-weight: 700; font-weight: bold;
font-size: 1.125rem; font-size: 1.125rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
@@ -62,7 +62,7 @@
&__header { &__header {
color: var(--color-primary); color: var(--color-primary);
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 700; font-weight: bold;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
width: 100%; width: 100%;
padding-right: 4rem; // due to dropdown button padding-right: 4rem; // due to dropdown button
@@ -1,96 +0,0 @@
import React, { type ReactNode } from "react";
import clsx from "clsx";
import * as Popover from "@radix-ui/react-popover";
import { useDevice } from "./App";
import { Island } from "./Island";
import { isInteractive } from "../utils";
interface PropertiesPopoverProps {
className?: string;
container: HTMLDivElement | null;
children: ReactNode;
style?: object;
onClose: () => void;
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
onFocusOutside?: Popover.DismissableLayerProps["onFocusOutside"];
onPointerDownOutside?: Popover.DismissableLayerProps["onPointerDownOutside"];
}
export const PropertiesPopover = React.forwardRef<
HTMLDivElement,
PropertiesPopoverProps
>(
(
{
className,
container,
children,
style,
onClose,
onKeyDown,
onFocusOutside,
onPointerLeave,
onPointerDownOutside,
},
ref,
) => {
const device = useDevice();
return (
<Popover.Portal container={container}>
<Popover.Content
ref={ref}
className={clsx("focus-visible-none", className)}
data-prevent-outside-click
side={
device.editor.isMobile && !device.viewport.isLandscape
? "bottom"
: "right"
}
align={
device.editor.isMobile && !device.viewport.isLandscape
? "center"
: "start"
}
alignOffset={-16}
sideOffset={20}
style={{
zIndex: "var(--zIndex-popup)",
}}
onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown}
onFocusOutside={onFocusOutside}
onPointerDownOutside={onPointerDownOutside}
onCloseAutoFocus={(e) => {
e.stopPropagation();
// prevents focusing the trigger
e.preventDefault();
// return focus to excalidraw container unless
// user focuses an interactive element, such as a button, or
// enters the text editor by clicking on canvas with the text tool
if (container && !isInteractive(document.activeElement)) {
container.focus();
}
onClose();
}}
>
<Island padding={3} style={style}>
{children}
</Island>
<Popover.Arrow
width={20}
height={10}
style={{
fill: "var(--popup-bg-color)",
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
}}
/>
</Popover.Content>
</Popover.Portal>
);
},
);
@@ -133,7 +133,7 @@
.required, .required,
.error { .error {
color: $oc-red-8; color: $oc-red-8;
font-weight: 700; font-weight: bold;
font-size: 1rem; font-size: 1rem;
margin: 0.2rem; margin: 0.2rem;
} }
@@ -1,48 +0,0 @@
.excalidraw {
--list-border-color: var(--color-gray-20);
.QuickSearch__wrapper {
position: relative;
height: 2.6rem; // added +0.1 due to Safari
border-bottom: 1px solid var(--list-border-color);
svg {
position: absolute;
top: 47.5%; // 50% is not exactly in the center of the input
transform: translateY(-50%);
left: 0.75rem;
width: 1.25rem;
height: 1.25rem;
color: var(--color-gray-40);
z-index: 1;
}
}
&.theme--dark {
--list-border-color: var(--color-gray-80);
.QuickSearch__wrapper {
border-bottom: none;
}
}
.QuickSearch__input {
position: absolute;
top: 0;
left: 0;
width: 100%;
box-sizing: border-box;
border: 0 !important;
font-size: 0.875rem;
padding-left: 2.5rem !important;
padding-right: 0.75rem !important;
&::placeholder {
color: var(--color-gray-40);
}
&:focus {
box-shadow: none !important;
}
}
}
@@ -1,28 +0,0 @@
import clsx from "clsx";
import React from "react";
import { searchIcon } from "./icons";
import "./QuickSearch.scss";
interface QuickSearchProps {
className?: string;
placeholder: string;
onChange: (term: string) => void;
}
export const QuickSearch = React.forwardRef<HTMLInputElement, QuickSearchProps>(
({ className, placeholder, onChange }, ref) => {
return (
<div className={clsx("QuickSearch__wrapper", className)}>
{searchIcon}
<input
ref={ref}
className="QuickSearch__input"
type="text"
placeholder={placeholder}
onChange={(e) => onChange(e.target.value.trim().toLowerCase())}
/>
</div>
);
},
);
@@ -1,21 +0,0 @@
.excalidraw {
.ScrollableList__wrapper {
position: static !important;
border: none;
font-size: 0.875rem;
overflow-y: auto;
& > .empty,
& > .hint {
display: flex;
justify-content: center;
align-items: center;
padding: 0.5rem;
font-size: 0.75rem;
color: var(--color-gray-60);
overflow: hidden;
text-align: center;
line-height: 150%;
}
}
}
@@ -1,24 +0,0 @@
import clsx from "clsx";
import { Children } from "react";
import "./ScrollableList.scss";
interface ScrollableListProps {
className?: string;
placeholder: string;
children: React.ReactNode;
}
export const ScrollableList = ({
className,
placeholder,
children,
}: ScrollableListProps) => {
const isEmpty = !Children.count(children);
return (
<div className={clsx("ScrollableList__wrapper", className)} role="menu">
{isEmpty ? <div className="empty">{placeholder}</div> : children}
</div>
);
};
@@ -2,8 +2,8 @@ import React from "react";
import { DEFAULT_SIDEBAR } from "../../constants"; import { DEFAULT_SIDEBAR } from "../../constants";
import { Excalidraw, Sidebar } from "../../index"; import { Excalidraw, Sidebar } from "../../index";
import { import {
act,
fireEvent, fireEvent,
GlobalTestState,
queryAllByTestId, queryAllByTestId,
queryByTestId, queryByTestId,
render, render,
@@ -11,17 +11,39 @@ import {
withExcalidrawDimensions, withExcalidrawDimensions,
} from "../../tests/test-utils"; } from "../../tests/test-utils";
import { vi } from "vitest"; import { vi } from "vitest";
import {
assertExcalidrawWithSidebar,
assertSidebarDockButton,
} from "./siderbar.test.helpers";
const toggleSidebar = ( export const assertSidebarDockButton = async <T extends boolean>(
...args: Parameters<typeof window.h.app.toggleSidebar> hasDockButton: T,
): Promise<boolean> => { ): Promise<
return act(() => { T extends false
return window.h.app.toggleSidebar(...args); ? { dockButton: null; sidebar: HTMLElement }
}); : { dockButton: HTMLElement; sidebar: HTMLElement }
> => {
const sidebar =
GlobalTestState.renderResult.container.querySelector<HTMLElement>(
".sidebar",
);
expect(sidebar).not.toBe(null);
const dockButton = queryByTestId(sidebar!, "sidebar-dock");
if (hasDockButton) {
expect(dockButton).not.toBe(null);
return { dockButton: dockButton!, sidebar: sidebar! } as any;
}
expect(dockButton).toBe(null);
return { dockButton: null, sidebar: sidebar! } as any;
};
export const assertExcalidrawWithSidebar = async (
sidebar: React.ReactNode,
name: string,
test: () => void,
) => {
await render(
<Excalidraw initialData={{ appState: { openSidebar: { name } } }}>
{sidebar}
</Excalidraw>,
);
await withExcalidrawDimensions({ width: 1920, height: 1080 }, test);
}; };
describe("Sidebar", () => { describe("Sidebar", () => {
@@ -81,7 +103,7 @@ describe("Sidebar", () => {
// toggle sidebar on // toggle sidebar on
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
expect(await toggleSidebar({ name: "customSidebar" })).toBe(true); expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
await waitFor(() => { await waitFor(() => {
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
@@ -90,7 +112,7 @@ describe("Sidebar", () => {
// toggle sidebar off // toggle sidebar off
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
expect(await toggleSidebar({ name: "customSidebar" })).toBe(false); expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(false);
await waitFor(() => { await waitFor(() => {
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
@@ -99,9 +121,9 @@ describe("Sidebar", () => {
// force-toggle sidebar off (=> still hidden) // force-toggle sidebar off (=> still hidden)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
expect(await toggleSidebar({ name: "customSidebar", force: false })).toBe( expect(
false, window.h.app.toggleSidebar({ name: "customSidebar", force: false }),
); ).toBe(false);
await waitFor(() => { await waitFor(() => {
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
@@ -110,12 +132,12 @@ describe("Sidebar", () => {
// force-toggle sidebar on // force-toggle sidebar on
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
expect(await toggleSidebar({ name: "customSidebar", force: true })).toBe( expect(
true, window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
); ).toBe(true);
expect(await toggleSidebar({ name: "customSidebar", force: true })).toBe( expect(
true, window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
); ).toBe(true);
await waitFor(() => { await waitFor(() => {
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
@@ -124,7 +146,9 @@ describe("Sidebar", () => {
// toggle library (= hide custom sidebar) // toggle library (= hide custom sidebar)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
expect(await toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(true); expect(window.h.app.toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(
true,
);
await waitFor(() => { await waitFor(() => {
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
@@ -137,13 +161,13 @@ describe("Sidebar", () => {
// closing sidebar using `{ name: null }` // closing sidebar using `{ name: null }`
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
expect(await toggleSidebar({ name: "customSidebar" })).toBe(true); expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
await waitFor(() => { await waitFor(() => {
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null); expect(node).not.toBe(null);
}); });
expect(await toggleSidebar({ name: null })).toBe(false); expect(window.h.app.toggleSidebar({ name: null })).toBe(false);
await waitFor(() => { await waitFor(() => {
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null); expect(node).toBe(null);
@@ -297,9 +321,6 @@ describe("Sidebar", () => {
}); });
it("shouldn't be user-dockable when only `onDock` supplied w/o `docked`", async () => { it("shouldn't be user-dockable when only `onDock` supplied w/o `docked`", async () => {
// we expect warnings in this test and don't want to pollute stdout
const mock = jest.spyOn(console, "warn").mockImplementation(() => {});
await render( await render(
<Excalidraw <Excalidraw
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }} initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
@@ -320,8 +341,6 @@ describe("Sidebar", () => {
await assertSidebarDockButton(false); await assertSidebarDockButton(false);
}, },
); );
mock.mockRestore();
}); });
}); });
@@ -348,9 +367,9 @@ describe("Sidebar", () => {
).toBeNull(); ).toBeNull();
// open library sidebar // open library sidebar
expect(await toggleSidebar({ name: "custom", tab: "library" })).toBe( expect(
true, window.h.app.toggleSidebar({ name: "custom", tab: "library" }),
); ).toBe(true);
expect( expect(
container.querySelector<HTMLElement>( container.querySelector<HTMLElement>(
"[role=tabpanel][data-testid=library]", "[role=tabpanel][data-testid=library]",
@@ -358,9 +377,9 @@ describe("Sidebar", () => {
).not.toBeNull(); ).not.toBeNull();
// switch to comments tab // switch to comments tab
expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe( expect(
true, window.h.app.toggleSidebar({ name: "custom", tab: "comments" }),
); ).toBe(true);
expect( expect(
container.querySelector<HTMLElement>( container.querySelector<HTMLElement>(
"[role=tabpanel][data-testid=comments]", "[role=tabpanel][data-testid=comments]",
@@ -368,9 +387,9 @@ describe("Sidebar", () => {
).not.toBeNull(); ).not.toBeNull();
// toggle sidebar closed // toggle sidebar closed
expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe( expect(
false, window.h.app.toggleSidebar({ name: "custom", tab: "comments" }),
); ).toBe(false);
expect( expect(
container.querySelector<HTMLElement>( container.querySelector<HTMLElement>(
"[role=tabpanel][data-testid=comments]", "[role=tabpanel][data-testid=comments]",
@@ -378,9 +397,9 @@ describe("Sidebar", () => {
).toBeNull(); ).toBeNull();
// toggle sidebar open // toggle sidebar open
expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe( expect(
true, window.h.app.toggleSidebar({ name: "custom", tab: "comments" }),
); ).toBe(true);
expect( expect(
container.querySelector<HTMLElement>( container.querySelector<HTMLElement>(
"[role=tabpanel][data-testid=comments]", "[role=tabpanel][data-testid=comments]",
@@ -1,42 +0,0 @@
import React from "react";
import { Excalidraw } from "../..";
import {
GlobalTestState,
queryByTestId,
render,
withExcalidrawDimensions,
} from "../../tests/test-utils";
export const assertSidebarDockButton = async <T extends boolean>(
hasDockButton: T,
): Promise<
T extends false
? { dockButton: null; sidebar: HTMLElement }
: { dockButton: HTMLElement; sidebar: HTMLElement }
> => {
const sidebar =
GlobalTestState.renderResult.container.querySelector<HTMLElement>(
".sidebar",
);
expect(sidebar).not.toBe(null);
const dockButton = queryByTestId(sidebar!, "sidebar-dock");
if (hasDockButton) {
expect(dockButton).not.toBe(null);
return { dockButton: dockButton!, sidebar: sidebar! } as any;
}
expect(dockButton).toBe(null);
return { dockButton: null, sidebar: sidebar! } as any;
};
export const assertExcalidrawWithSidebar = async (
sidebar: React.ReactNode,
name: string,
test: () => void,
) => {
await render(
<Excalidraw initialData={{ appState: { openSidebar: { name } } }}>
{sidebar}
</Excalidraw>,
);
await withExcalidrawDimensions({ width: 1920, height: 1080 }, test);
};
+46 -65
View File
@@ -1,92 +1,73 @@
import { mutateElement } from "../../element/mutateElement"; import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement"; import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement, isElbowArrow } from "../../element/typeChecks"; import { isArrowElement } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types"; import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { degreeToRadian, radianToDegree } from "../../math"; import { degreeToRadian, radianToDegree } from "../../math";
import { angleIcon } from "../icons";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils"; import { getStepSizedValue, isPropertyEditable } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface AngleProps { interface AngleProps {
element: ExcalidrawElement; element: ExcalidrawElement;
scene: Scene; elementsMap: ElementsMap;
appState: AppState;
property: "angle";
} }
const STEP_SIZE = 15; const STEP_SIZE = 15;
const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({ const Angle = ({ element, elementsMap }: AngleProps) => {
accumulatedChange, const handleDegreeChange: DragInputCallbackType = ({
originalElements, accumulatedChange,
shouldChangeByStepSize, stateAtStart,
nextValue, shouldChangeByStepSize,
scene, nextValue,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const _stateAtStart = stateAtStart[0];
const elements = scene.getNonDeletedElements(); if (_stateAtStart) {
const origElement = originalElements[0]; if (nextValue !== undefined) {
if (origElement && !isElbowArrow(origElement)) { const nextAngle = degreeToRadian(nextValue);
const latestElement = elementsMap.get(origElement.id); mutateElement(element, {
if (!latestElement) { angle: nextAngle,
return; });
}
if (nextValue !== undefined) { const boundTextElement = getBoundTextElement(element, elementsMap);
const nextAngle = degreeToRadian(nextValue); if (boundTextElement && !isArrowElement(element)) {
mutateElement(latestElement, { mutateElement(boundTextElement, { angle: nextAngle });
angle: nextAngle, }
});
updateBindings(latestElement, elementsMap, elements, scene);
const boundTextElement = getBoundTextElement(latestElement, elementsMap); return;
if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle });
} }
return; const originalAngleInDegrees =
Math.round(radianToDegree(_stateAtStart.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
}
nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees);
mutateElement(element, {
angle: nextAngle,
});
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && !isArrowElement(element)) {
mutateElement(boundTextElement, { angle: nextAngle });
}
} }
};
const originalAngleInDegrees =
Math.round(radianToDegree(origElement.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
}
nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees);
mutateElement(latestElement, {
angle: nextAngle,
});
updateBindings(latestElement, elementsMap, elements, scene);
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle });
}
}
};
const Angle = ({ element, scene, appState, property }: AngleProps) => {
return ( return (
<DragInput <DragInput
label="A" label="A"
icon={angleIcon} value={Math.round(radianToDegree(element.angle) * 100) / 100}
value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100}
elements={[element]} elements={[element]}
dragInputCallback={handleDegreeChange} dragInputCallback={handleDegreeChange}
editable={isPropertyEditable(element, "angle")} editable={isPropertyEditable(element, "angle")}
scene={scene}
appState={appState}
property={property}
/> />
); );
}; };
@@ -1,39 +0,0 @@
import { InlineIcon } from "../InlineIcon";
import { collapseDownIcon, collapseUpIcon } from "../icons";
interface CollapsibleProps {
label: React.ReactNode;
// having it controlled so that the state is managed outside
// this is to keep the user's previous choice even when the
// Collapsible is unmounted
open: boolean;
openTrigger: () => void;
children: React.ReactNode;
}
const Collapsible = ({
label,
open,
openTrigger,
children,
}: CollapsibleProps) => {
return (
<>
<div
style={{
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
onClick={openTrigger}
>
{label}
<InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
</div>
{open && <>{children}</>}
</>
);
};
export default Collapsible;
+211 -103
View File
@@ -1,16 +1,26 @@
import type { ExcalidrawElement } from "../../element/types"; import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils"; import { getStepSizedValue, isPropertyEditable } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; import { mutateElement } from "../../element/mutateElement";
import type Scene from "../../scene/Scene"; import {
import type { AppState } from "../../types"; measureFontSizeFromWidth,
rescalePointsInElement,
} from "../../element/resizeElements";
import {
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextMaxWidth,
handleBindTextResize,
} from "../../element/textElement";
import { getFontString } from "../../utils";
import { updateBoundElements } from "../../element/binding";
interface DimensionDragInputProps { interface DimensionDragInputProps {
property: "width" | "height"; property: "width" | "height";
element: ExcalidrawElement; element: ExcalidrawElement;
scene: Scene; elementsMap: ElementsMap;
appState: AppState;
} }
const STEP_SIZE = 10; const STEP_SIZE = 10;
@@ -18,120 +28,218 @@ const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
return element.type === "image"; return element.type === "image";
}; };
const handleDimensionChange: DragInputCallbackType< export const newOrigin = (
DimensionDragInputProps["property"] x1: number,
> = ({ y1: number,
accumulatedChange, w1: number,
originalElements, h1: number,
originalElementsMap, w2: number,
shouldKeepAspectRatio, h2: number,
shouldChangeByStepSize, angle: number,
nextValue, ) => {
property, /**
scene, * The formula below is the result of solving
}) => { * rotate(x1, y1, cx1, cy1, angle) = rotate(x2, y2, cx2, cy2, angle)
const elementsMap = scene.getNonDeletedElementsMap(); * where rotate is the function defined in math.ts
const elements = scene.getNonDeletedElements(); *
const origElement = originalElements[0]; * This is so that the new origin (x2, y2),
if (origElement) { * when rotated against the new center (cx2, cy2),
const keepAspectRatio = * coincides with (x1, y1) rotated against (cx1, cy1)
shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement); *
const aspectRatio = origElement.width / origElement.height; * The reason for doing this computation is so the element's top left corner
* on the canvas remains fixed after any changes in its dimension.
*/
if (nextValue !== undefined) { return {
const nextWidth = Math.max( x:
property === "width" x1 +
? nextValue (w1 - w2) / 2 +
: keepAspectRatio ((w2 - w1) / 2) * Math.cos(angle) +
? nextValue * aspectRatio ((h1 - h2) / 2) * Math.sin(angle),
: origElement.width, y:
MIN_WIDTH_OR_HEIGHT, y1 +
); (h1 - h2) / 2 +
const nextHeight = Math.max( ((w2 - w1) / 2) * Math.sin(angle) +
property === "height" ((h2 - h1) / 2) * Math.cos(angle),
? nextValue };
: keepAspectRatio };
? nextValue / aspectRatio
: origElement.height,
MIN_WIDTH_OR_HEIGHT,
);
resizeElement( const resizeElement = (
nextWidth, nextWidth: number,
nextHeight, nextHeight: number,
keepAspectRatio, keepAspectRatio: boolean,
origElement, latestState: ExcalidrawElement,
elementsMap, stateAtStart: ExcalidrawElement,
elements, elementsMap: ElementsMap,
scene, originalElementsMap: Map<string, ExcalidrawElement>,
); ) => {
mutateElement(latestState, {
return; ...newOrigin(
} latestState.x,
const changeInWidth = property === "width" ? accumulatedChange : 0; latestState.y,
const changeInHeight = property === "height" ? accumulatedChange : 0; latestState.width,
latestState.height,
let nextWidth = Math.max(0, origElement.width + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, origElement.height + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
if (keepAspectRatio) {
if (property === "width") {
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
} else {
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
}
}
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
resizeElement(
nextWidth, nextWidth,
nextHeight, nextHeight,
keepAspectRatio, latestState.angle,
origElement, ),
elementsMap, width: nextWidth,
elements, height: nextHeight,
scene, ...rescalePointsInElement(stateAtStart, nextWidth, nextHeight, true),
); });
let boundTextFont: { fontSize?: number } = {};
const boundTextElement = getBoundTextElement(latestState, elementsMap);
if (boundTextElement) {
boundTextFont = {
fontSize: boundTextElement.fontSize,
};
if (keepAspectRatio) {
const updatedElement = {
...latestState,
width: nextWidth,
height: nextHeight,
};
const nextFont = measureFontSizeFromWidth(
boundTextElement,
elementsMap,
getBoundTextMaxWidth(updatedElement, boundTextElement),
);
boundTextFont = {
fontSize: nextFont?.size ?? boundTextElement.fontSize,
};
} else {
const minWidth = getApproxMinLineWidth(
getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const minHeight = getApproxMinLineHeight(
boundTextElement.fontSize,
boundTextElement.lineHeight,
);
nextWidth = Math.max(nextWidth, minWidth);
nextHeight = Math.max(nextHeight, minHeight);
}
} }
updateBoundElements(latestState, elementsMap, {
newSize: {
width: nextWidth,
height: nextHeight,
},
});
if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
});
}
handleBindTextResize(latestState, elementsMap, "e", keepAspectRatio);
}; };
const DimensionDragInput = ({ const DimensionDragInput = ({
property, property,
element, element,
scene, elementsMap,
appState,
}: DimensionDragInputProps) => { }: DimensionDragInputProps) => {
const value = const handleDimensionChange: DragInputCallbackType = ({
Math.round((property === "width" ? element.width : element.height) * 100) / accumulatedChange,
100; stateAtStart,
originalElementsMap,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
}) => {
const _stateAtStart = stateAtStart[0];
if (_stateAtStart) {
const keepAspectRatio =
shouldKeepAspectRatio || _shouldKeepAspectRatio(element);
const aspectRatio = _stateAtStart.width / _stateAtStart.height;
if (nextValue !== undefined) {
const nextWidth = Math.max(
property === "width"
? nextValue
: keepAspectRatio
? nextValue * aspectRatio
: _stateAtStart.width,
0,
);
const nextHeight = Math.max(
property === "height"
? nextValue
: keepAspectRatio
? nextValue / aspectRatio
: _stateAtStart.height,
0,
);
resizeElement(
nextWidth,
nextHeight,
keepAspectRatio,
element,
_stateAtStart,
elementsMap,
originalElementsMap,
);
return;
}
const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
let nextWidth = Math.max(0, _stateAtStart.width + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, _stateAtStart.height + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
if (keepAspectRatio) {
if (property === "width") {
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
} else {
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
}
}
resizeElement(
nextWidth,
nextHeight,
keepAspectRatio,
element,
_stateAtStart,
elementsMap,
originalElementsMap,
);
}
};
return ( return (
<DragInput <DragInput
label={property === "width" ? "W" : "H"} label={property === "width" ? "W" : "H"}
elements={[element]} elements={[element]}
dragInputCallback={handleDimensionChange} dragInputCallback={handleDimensionChange}
value={value} value={
Math.round(
(property === "width" ? element.width : element.height) * 100,
) / 100
}
editable={isPropertyEditable(element, property)} editable={isPropertyEditable(element, property)}
scene={scene}
appState={appState}
property={property}
/> />
); );
}; };
@@ -15,13 +15,12 @@
} }
.drag-input-label { .drag-input-label {
height: var(--default-button-size);
flex-shrink: 0; flex-shrink: 0;
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
border: 1px solid var(--default-border-color); border: 1px solid var(--default-border-color);
border-right: 0; border-right: 0;
width: 2rem;
height: 2rem;
box-sizing: border-box; box-sizing: border-box;
color: var(--popup-text-color);
:root[dir="ltr"] & { :root[dir="ltr"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg); border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
@@ -33,6 +32,7 @@
border-left: 0; border-left: 0;
} }
color: var(--input-label-color);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -49,7 +49,7 @@
color: var(--text-primary-color); color: var(--text-primary-color);
border: 0; border: 0;
outline: none; outline: none;
height: 2rem; height: var(--default-button-size);
border: 1px solid var(--default-border-color); border: 1px solid var(--default-border-color);
border-left: 0; border-left: 0;
letter-spacing: 0.4px; letter-spacing: 0.4px;
@@ -1,172 +1,65 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import throttle from "lodash.throttle";
import { EVENT } from "../../constants"; import { EVENT } from "../../constants";
import { KEYS } from "../../keys"; import { KEYS } from "../../keys";
import type { ElementsMap, ExcalidrawElement } from "../../element/types"; import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { deepCopyElement } from "../../element/newElement"; import { deepCopyElement } from "../../element/newElement";
import clsx from "clsx";
import { useApp } from "../App";
import { InlineIcon } from "../InlineIcon";
import type { StatsInputProperty } from "./utils";
import { SMALLEST_DELTA } from "./utils";
import { StoreAction } from "../../store";
import type Scene from "../../scene/Scene";
import "./DragInput.scss"; import "./DragInput.scss";
import type { AppState } from "../../types"; import clsx from "clsx";
import { cloneJSON } from "../../utils"; import { useApp } from "../App";
export type DragInputCallbackType< export type DragInputCallbackType = ({
P extends StatsInputProperty, accumulatedChange,
E = ExcalidrawElement, instantChange,
> = (props: { stateAtStart,
originalElementsMap,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
}: {
accumulatedChange: number; accumulatedChange: number;
instantChange: number; instantChange: number;
originalElements: readonly E[]; stateAtStart: ExcalidrawElement[];
originalElementsMap: ElementsMap; originalElementsMap: ElementsMap;
shouldKeepAspectRatio: boolean; shouldKeepAspectRatio: boolean;
shouldChangeByStepSize: boolean; shouldChangeByStepSize: boolean;
scene: Scene;
nextValue?: number; nextValue?: number;
property: P;
originalAppState: AppState;
}) => void; }) => void;
interface StatsDragInputProps< interface StatsDragInputProps {
T extends StatsInputProperty,
E = ExcalidrawElement,
> {
label: string | React.ReactNode; label: string | React.ReactNode;
icon?: React.ReactNode; value: number;
value: number | "Mixed"; elements: ExcalidrawElement[];
elements: readonly E[];
editable?: boolean; editable?: boolean;
shouldKeepAspectRatio?: boolean; shouldKeepAspectRatio?: boolean;
dragInputCallback: DragInputCallbackType<T, E>; dragInputCallback: DragInputCallbackType;
property: T;
scene: Scene;
appState: AppState;
} }
const StatsDragInput = < const StatsDragInput = ({
T extends StatsInputProperty,
E extends ExcalidrawElement = ExcalidrawElement,
>({
label, label,
icon,
dragInputCallback, dragInputCallback,
value, value,
elements, elements,
editable = true, editable = true,
shouldKeepAspectRatio, shouldKeepAspectRatio,
property, }: StatsDragInputProps) => {
scene,
appState,
}: StatsDragInputProps<T, E>) => {
const app = useApp(); const app = useApp();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLDivElement>(null); const labelRef = useRef<HTMLDivElement>(null);
const cbThrottled = useMemo(() => {
return throttle(dragInputCallback, 16);
}, [dragInputCallback]);
const [inputValue, setInputValue] = useState(value.toString()); const [inputValue, setInputValue] = useState(value.toString());
const stateRef = useRef<{
originalAppState: AppState;
originalElements: readonly E[];
lastUpdatedValue: string;
updatePending: boolean;
}>(null!);
if (!stateRef.current) {
stateRef.current = {
originalAppState: cloneJSON(appState),
originalElements: elements,
lastUpdatedValue: inputValue,
updatePending: false,
};
}
useEffect(() => { useEffect(() => {
const inputValue = value.toString(); setInputValue(value.toString());
setInputValue(inputValue);
stateRef.current.lastUpdatedValue = inputValue;
}, [value]); }, [value]);
const handleInputValue = (
updatedValue: string,
elements: readonly E[],
appState: AppState,
) => {
if (!stateRef.current.updatePending) {
return false;
}
stateRef.current.updatePending = false;
const parsed = Number(updatedValue);
if (isNaN(parsed)) {
setInputValue(value.toString());
return;
}
const rounded = Number(parsed.toFixed(2));
const original = Number(value);
// only update when
// 1. original was "Mixed" and we have a new value
// 2. original was not "Mixed" and the difference between a new value and previous value is greater
// than the smallest delta allowed, which is 0.01
// reason: idempotent to avoid unnecessary
if (isNaN(original) || Math.abs(rounded - original) >= SMALLEST_DELTA) {
stateRef.current.lastUpdatedValue = updatedValue;
dragInputCallback({
accumulatedChange: 0,
instantChange: 0,
originalElements: elements,
originalElementsMap: app.scene.getNonDeletedElementsMap(),
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: false,
scene,
nextValue: rounded,
property,
originalAppState: appState,
});
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
}
};
const handleInputValueRef = useRef(handleInputValue);
handleInputValueRef.current = handleInputValue;
// make sure that clicking on canvas (which umounts the component)
// updates current input value (blur isn't triggered)
useEffect(() => {
const input = inputRef.current;
return () => {
const nextValue = input?.value;
if (nextValue) {
handleInputValueRef.current(
nextValue,
stateRef.current.originalElements,
stateRef.current.originalAppState,
);
}
};
}, [
// we need to track change of `editable` state as mount/unmount
// because react doesn't trigger `blur` when a an input is blurred due
// to being disabled (https://github.com/facebook/react/issues/9142).
// As such, if we keep rendering disabled inputs, then change in selection
// to an element that has a given property as non-editable would not trigger
// blur/unmount and wouldn't update the value.
editable,
]);
if (!editable) {
return null;
}
return ( return (
<div <div className={clsx("drag-input-container", !editable && "disabled")}>
className={clsx("drag-input-container", !editable && "disabled")}
data-testid={label}
>
<div <div
className="drag-input-label" className="drag-input-label"
ref={labelRef} ref={labelRef}
@@ -182,48 +75,45 @@ const StatsDragInput = <
y: number; y: number;
} | null = null; } | null = null;
let stateAtStart: ExcalidrawElement[] | null = null;
let originalElementsMap: Map<string, ExcalidrawElement> | null = let originalElementsMap: Map<string, ExcalidrawElement> | null =
app.scene null;
.getNonDeletedElements()
.reduce((acc: ElementsMap, element) => {
acc.set(element.id, deepCopyElement(element));
return acc;
}, new Map());
let originalElements: readonly E[] | null = elements.map(
(element) => originalElementsMap!.get(element.id) as E,
);
const originalAppState: AppState = cloneJSON(appState);
let accumulatedChange: number | null = null; let accumulatedChange: number | null = null;
document.body.classList.add("excalidraw-cursor-resize"); document.body.classList.add("dragResize");
const onPointerMove = (event: PointerEvent) => { const onPointerMove = (event: PointerEvent) => {
if (!stateAtStart) {
stateAtStart = elements.map((element) =>
deepCopyElement(element),
);
}
if (!originalElementsMap) {
originalElementsMap = app.scene
.getNonDeletedElements()
.reduce((acc, element) => {
acc.set(element.id, deepCopyElement(element));
return acc;
}, new Map() as ElementsMap);
}
if (!accumulatedChange) { if (!accumulatedChange) {
accumulatedChange = 0; accumulatedChange = 0;
} }
if ( if (lastPointer && stateAtStart && accumulatedChange !== null) {
lastPointer &&
originalElementsMap !== null &&
originalElements !== null &&
accumulatedChange !== null
) {
const instantChange = event.clientX - lastPointer.x; const instantChange = event.clientX - lastPointer.x;
accumulatedChange += instantChange; accumulatedChange += instantChange;
dragInputCallback({ cbThrottled({
accumulatedChange, accumulatedChange,
instantChange, instantChange,
originalElements, stateAtStart,
originalElementsMap, originalElementsMap,
shouldKeepAspectRatio: shouldKeepAspectRatio!!, shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: event.shiftKey, shouldChangeByStepSize: event.shiftKey,
property,
scene,
originalAppState,
}); });
} }
@@ -243,14 +133,14 @@ const StatsDragInput = <
false, false,
); );
app.syncActionResult({ storeAction: StoreAction.CAPTURE }); app.store.shouldCaptureIncrement();
lastPointer = null; lastPointer = null;
accumulatedChange = null; accumulatedChange = null;
originalElements = null; stateAtStart = null;
originalElementsMap = null; originalElementsMap = null;
document.body.classList.remove("excalidraw-cursor-resize"); document.body.classList.remove("dragResize");
}, },
false, false,
); );
@@ -262,7 +152,7 @@ const StatsDragInput = <
} }
}} }}
> >
{icon ? <InlineIcon icon={icon} /> : label} {label}
</div> </div>
<input <input
className="drag-input" className="drag-input"
@@ -271,39 +161,46 @@ const StatsDragInput = <
onKeyDown={(event) => { onKeyDown={(event) => {
if (editable) { if (editable) {
const eventTarget = event.target; const eventTarget = event.target;
if ( if (
eventTarget instanceof HTMLInputElement && eventTarget instanceof HTMLInputElement &&
event.key === KEYS.ENTER event.key === KEYS.ENTER
) { ) {
handleInputValue(eventTarget.value, elements, appState); const v = Number(eventTarget.value);
app.focusContainer(); if (isNaN(v)) {
setInputValue(value.toString());
return;
}
dragInputCallback({
accumulatedChange: 0,
instantChange: 0,
stateAtStart: elements,
originalElementsMap: app.scene.getNonDeletedElementsMap(),
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: false,
nextValue: v,
});
app.store.shouldCaptureIncrement();
eventTarget.blur();
} }
} }
}} }}
ref={inputRef} ref={inputRef}
value={inputValue} value={inputValue}
onChange={(event) => { onChange={(event) => {
stateRef.current.updatePending = true; const eventTarget = event.target;
setInputValue(event.target.value); if (eventTarget instanceof HTMLInputElement) {
setInputValue(event.target.value);
}
}} }}
onFocus={(event) => { onBlur={() => {
event.target.select();
stateRef.current.originalElements = elements;
stateRef.current.originalAppState = cloneJSON(appState);
}}
onBlur={(event) => {
if (!inputValue) { if (!inputValue) {
setInputValue(value.toString()); setInputValue(value.toString());
} else if (editable) {
handleInputValue(
event.target.value,
stateRef.current.originalElements,
stateRef.current.originalAppState,
);
} }
}} }}
disabled={!editable} disabled={!editable}
/> ></input>
</div> </div>
); );
}; };
@@ -1,97 +1,71 @@
import type { import type { ElementsMap, ExcalidrawTextElement } from "../../element/types";
ExcalidrawElement, import { refreshTextDimensions } from "../../element/newElement";
ExcalidrawTextElement,
} from "../../element/types";
import StatsDragInput from "./DragInput"; import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { mutateElement } from "../../element/mutateElement"; import { mutateElement } from "../../element/mutateElement";
import { getStepSizedValue } from "./utils"; import { getStepSizedValue } from "./utils";
import { fontSizeIcon } from "../icons";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { isTextElement, redrawTextBoundingBox } from "../../element";
import { hasBoundTextElement } from "../../element/typeChecks";
import { getBoundTextElement } from "../../element/textElement";
interface FontSizeProps { interface FontSizeProps {
element: ExcalidrawElement; element: ExcalidrawTextElement;
scene: Scene; elementsMap: ElementsMap;
appState: AppState;
property: "fontSize";
} }
const MIN_FONT_SIZE = 4; const MIN_FONT_SIZE = 4;
const STEP_SIZE = 4; const STEP_SIZE = 4;
const handleFontSizeChange: DragInputCallbackType< const FontSize = ({ element, elementsMap }: FontSizeProps) => {
FontSizeProps["property"], const handleFontSizeChange: DragInputCallbackType = ({
ExcalidrawTextElement accumulatedChange,
> = ({ stateAtStart,
accumulatedChange, shouldChangeByStepSize,
originalElements, nextValue,
shouldChangeByStepSize, }) => {
nextValue, const _stateAtStart = stateAtStart[0];
scene, if (_stateAtStart) {
}) => { if (nextValue) {
const elementsMap = scene.getNonDeletedElementsMap(); const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
const origElement = originalElements[0]; const newElement = {
if (origElement) { ...element,
const latestElement = elementsMap.get(origElement.id); fontSize: nextFontSize,
if (!latestElement || !isTextElement(latestElement)) { };
return; const updates = refreshTextDimensions(newElement, null, elementsMap);
} mutateElement(element, {
...updates,
fontSize: nextFontSize,
});
return;
}
let nextFontSize; if (_stateAtStart.type === "text") {
const originalFontSize = Math.round(_stateAtStart.fontSize);
if (nextValue !== undefined) { const changeInFontSize = Math.round(accumulatedChange);
nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE); let nextFontSize = Math.max(
} else if (origElement.type === "text") { originalFontSize + changeInFontSize,
const originalFontSize = Math.round(origElement.fontSize); MIN_FONT_SIZE,
const changeInFontSize = Math.round(accumulatedChange); );
nextFontSize = Math.max( if (shouldChangeByStepSize) {
originalFontSize + changeInFontSize, nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
MIN_FONT_SIZE, }
); const newElement = {
if (shouldChangeByStepSize) { ...element,
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE); fontSize: nextFontSize,
};
const updates = refreshTextDimensions(newElement, null, elementsMap);
mutateElement(element, {
...updates,
fontSize: nextFontSize,
});
} }
} }
};
if (nextFontSize) {
mutateElement(latestElement, {
fontSize: nextFontSize,
});
redrawTextBoundingBox(
latestElement,
scene.getContainerElement(latestElement),
scene.getNonDeletedElementsMap(),
);
}
}
};
const FontSize = ({ element, scene, appState, property }: FontSizeProps) => {
const _element = isTextElement(element)
? element
: hasBoundTextElement(element)
? getBoundTextElement(element, scene.getNonDeletedElementsMap())
: null;
if (!_element) {
return null;
}
return ( return (
<StatsDragInput <StatsDragInput
label="F" label="F"
value={Math.round(_element.fontSize * 10) / 10} value={Math.round(element.fontSize * 10) / 10}
elements={[_element]} elements={[element]}
dragInputCallback={handleFontSizeChange} dragInputCallback={handleFontSizeChange}
icon={fontSizeIcon}
appState={appState}
scene={scene}
property={property}
/> />
); );
}; };
@@ -1,135 +0,0 @@
import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types";
import { isInGroup } from "../../groups";
import { degreeToRadian, radianToDegree } from "../../math";
import type Scene from "../../scene/Scene";
import { angleIcon } from "../icons";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import type { AppState } from "../../types";
interface MultiAngleProps {
elements: readonly ExcalidrawElement[];
scene: Scene;
appState: AppState;
property: "angle";
}
const STEP_SIZE = 15;
const handleDegreeChange: DragInputCallbackType<
MultiAngleProps["property"]
> = ({
accumulatedChange,
originalElements,
shouldChangeByStepSize,
nextValue,
property,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const editableLatestIndividualElements = originalElements
.map((el) => elementsMap.get(el.id))
.filter((el) => el && !isInGroup(el) && isPropertyEditable(el, property));
const editableOriginalIndividualElements = originalElements.filter(
(el) => !isInGroup(el) && isPropertyEditable(el, property),
);
if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue);
for (const element of editableLatestIndividualElements) {
if (!element) {
continue;
}
mutateElement(
element,
{
angle: nextAngle,
},
false,
);
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && !isArrowElement(element)) {
mutateElement(boundTextElement, { angle: nextAngle }, false);
}
}
scene.triggerUpdate();
return;
}
for (let i = 0; i < editableLatestIndividualElements.length; i++) {
const latestElement = editableLatestIndividualElements[i];
if (!latestElement) {
continue;
}
const originalElement = editableOriginalIndividualElements[i];
const originalAngleInDegrees =
Math.round(radianToDegree(originalElement.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
}
nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees);
mutateElement(
latestElement,
{
angle: nextAngle,
},
false,
);
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle }, false);
}
}
scene.triggerUpdate();
};
const MultiAngle = ({
elements,
scene,
appState,
property,
}: MultiAngleProps) => {
const editableLatestIndividualElements = elements.filter(
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
);
const angles = editableLatestIndividualElements.map(
(el) => Math.round((radianToDegree(el.angle) % 360) * 100) / 100,
);
const value = new Set(angles).size === 1 ? angles[0] : "Mixed";
const editable = editableLatestIndividualElements.some((el) =>
isPropertyEditable(el, "angle"),
);
return (
<DragInput
label="A"
icon={angleIcon}
value={value}
elements={elements}
dragInputCallback={handleDegreeChange}
editable={editable}
appState={appState}
scene={scene}
property={property}
/>
);
};
export default MultiAngle;
@@ -1,4 +1,3 @@
import { useMemo } from "react";
import { getCommonBounds, isTextElement } from "../../element"; import { getCommonBounds, isTextElement } from "../../element";
import { updateBoundElements } from "../../element/binding"; import { updateBoundElements } from "../../element/binding";
import { mutateElement } from "../../element/mutateElement"; import { mutateElement } from "../../element/mutateElement";
@@ -7,27 +6,15 @@ import {
getBoundTextElement, getBoundTextElement,
handleBindTextResize, handleBindTextResize,
} from "../../element/textElement"; } from "../../element/textElement";
import type { import type { ElementsMap, ExcalidrawElement } from "../../element/types";
ElementsMap,
ExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import type Scene from "../../scene/Scene";
import type { AppState, Point } from "../../types";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; import { getStepSizedValue } from "./utils";
import { getElementsInAtomicUnit, resizeElement } from "./utils";
import type { AtomicUnit } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
interface MultiDimensionProps { interface MultiDimensionProps {
property: "width" | "height"; property: "width" | "height";
elements: readonly ExcalidrawElement[]; elements: ExcalidrawElement[];
elementsMap: NonDeletedSceneElementsMap; elementsMap: ElementsMap;
atomicUnits: AtomicUnit[];
scene: Scene;
appState: AppState;
} }
const STEP_SIZE = 10; const STEP_SIZE = 10;
@@ -36,12 +23,12 @@ const getResizedUpdates = (
anchorX: number, anchorX: number,
anchorY: number, anchorY: number,
scale: number, scale: number,
origElement: ExcalidrawElement, stateAtStart: ExcalidrawElement,
) => { ) => {
const offsetX = origElement.x - anchorX; const offsetX = stateAtStart.x - anchorX;
const offsetY = origElement.y - anchorY; const offsetY = stateAtStart.y - anchorY;
const nextWidth = origElement.width * scale; const nextWidth = stateAtStart.width * scale;
const nextHeight = origElement.height * scale; const nextHeight = stateAtStart.height * scale;
const x = anchorX + offsetX * scale; const x = anchorX + offsetX * scale;
const y = anchorY + offsetY * scale; const y = anchorY + offsetY * scale;
@@ -50,27 +37,27 @@ const getResizedUpdates = (
height: nextHeight, height: nextHeight,
x, x,
y, y,
...rescalePointsInElement(origElement, nextWidth, nextHeight, false), ...rescalePointsInElement(stateAtStart, nextWidth, nextHeight, false),
...(isTextElement(origElement) ...(isTextElement(stateAtStart)
? { fontSize: origElement.fontSize * scale } ? { fontSize: stateAtStart.fontSize * scale }
: {}), : {}),
}; };
}; };
const resizeElementInGroup = ( const resizeElement = (
anchorX: number, anchorX: number,
anchorY: number, anchorY: number,
property: MultiDimensionProps["property"], property: MultiDimensionProps["property"],
scale: number, scale: number,
latestElement: ExcalidrawElement, latestElement: ExcalidrawElement,
origElement: ExcalidrawElement, origElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap, elementsMap: ElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
shouldInformMutation: boolean,
) => { ) => {
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, shouldInformMutation);
const boundTextElement = getBoundTextElement( const boundTextElement = getBoundTextElement(
origElement, origElement,
originalElementsMap, originalElementsMap,
@@ -78,7 +65,7 @@ const resizeElementInGroup = (
if (boundTextElement) { if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale; const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, elementsMap, { updateBoundElements(latestElement, elementsMap, {
oldSize: { width: oldWidth, height: oldHeight }, newSize: { width: updates.width, height: updates.height },
}); });
const latestBoundTextElement = elementsMap.get(boundTextElement.id); const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) { if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
@@ -87,7 +74,7 @@ const resizeElementInGroup = (
{ {
fontSize: newFontSize, fontSize: newFontSize,
}, },
false, shouldInformMutation,
); );
handleBindTextResize( handleBindTextResize(
latestElement, latestElement,
@@ -99,294 +86,123 @@ const resizeElementInGroup = (
} }
}; };
const resizeGroup = (
nextWidth: number,
nextHeight: number,
initialHeight: number,
aspectRatio: number,
anchor: Point,
property: MultiDimensionProps["property"],
latestElements: ExcalidrawElement[],
originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap,
) => {
// keep aspect ratio for groups
if (property === "width") {
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
} else {
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
}
const scale = nextHeight / initialHeight;
for (let i = 0; i < originalElements.length; i++) {
const origElement = originalElements[i];
const latestElement = latestElements[i];
resizeElementInGroup(
anchor[0],
anchor[1],
property,
scale,
latestElement,
origElement,
elementsMap,
originalElementsMap,
);
}
};
const handleDimensionChange: DragInputCallbackType<
MultiDimensionProps["property"]
> = ({
accumulatedChange,
originalElements,
originalElementsMap,
originalAppState,
shouldChangeByStepSize,
nextValue,
scene,
property,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
if (nextValue !== undefined) {
for (const atomicUnit of atomicUnits) {
const elementsInUnit = getElementsInAtomicUnit(
atomicUnit,
elementsMap,
originalElementsMap,
);
if (elementsInUnit.length > 1) {
const latestElements = elementsInUnit.map((el) => el.latest!);
const originalElements = elementsInUnit.map((el) => el.original!);
const [x1, y1, x2, y2] = getCommonBounds(originalElements);
const initialWidth = x2 - x1;
const initialHeight = y2 - y1;
const aspectRatio = initialWidth / initialHeight;
const nextWidth = Math.max(
MIN_WIDTH_OR_HEIGHT,
property === "width" ? Math.max(0, nextValue) : initialWidth,
);
const nextHeight = Math.max(
MIN_WIDTH_OR_HEIGHT,
property === "height" ? Math.max(0, nextValue) : initialHeight,
);
resizeGroup(
nextWidth,
nextHeight,
initialHeight,
aspectRatio,
[x1, y1],
property,
latestElements,
originalElements,
elementsMap,
originalElementsMap,
);
} else {
const [el] = elementsInUnit;
const latestElement = el?.latest;
const origElement = el?.original;
if (
latestElement &&
origElement &&
isPropertyEditable(latestElement, property)
) {
let nextWidth =
property === "width" ? Math.max(0, nextValue) : latestElement.width;
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight =
property === "height"
? Math.max(0, nextValue)
: latestElement.height;
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(
nextWidth,
nextHeight,
false,
origElement,
elementsMap,
elements,
scene,
false,
);
}
}
}
scene.triggerUpdate();
return;
}
const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
for (const atomicUnit of atomicUnits) {
const elementsInUnit = getElementsInAtomicUnit(
atomicUnit,
elementsMap,
originalElementsMap,
);
if (elementsInUnit.length > 1) {
const latestElements = elementsInUnit.map((el) => el.latest!);
const originalElements = elementsInUnit.map((el) => el.original!);
const [x1, y1, x2, y2] = getCommonBounds(originalElements);
const initialWidth = x2 - x1;
const initialHeight = y2 - y1;
const aspectRatio = initialWidth / initialHeight;
let nextWidth = Math.max(0, initialWidth + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, initialHeight + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeGroup(
nextWidth,
nextHeight,
initialHeight,
aspectRatio,
[x1, y1],
property,
latestElements,
originalElements,
elementsMap,
originalElementsMap,
);
} else {
const [el] = elementsInUnit;
const latestElement = el?.latest;
const origElement = el?.original;
if (
latestElement &&
origElement &&
isPropertyEditable(latestElement, property)
) {
let nextWidth = Math.max(0, origElement.width + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, origElement.height + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(
nextWidth,
nextHeight,
false,
origElement,
elementsMap,
elements,
scene,
);
}
}
}
scene.triggerUpdate();
};
const MultiDimension = ({ const MultiDimension = ({
property, property,
elements, elements,
elementsMap, elementsMap,
atomicUnits,
scene,
appState,
}: MultiDimensionProps) => { }: MultiDimensionProps) => {
const sizes = useMemo( const handleDimensionChange: DragInputCallbackType = ({
() => accumulatedChange,
atomicUnits.map((atomicUnit) => { stateAtStart,
const elementsInUnit = getElementsInAtomicUnit(atomicUnit, elementsMap); originalElementsMap,
shouldChangeByStepSize,
nextValue,
}) => {
const [x1, y1, x2, y2] = getCommonBounds(stateAtStart);
const initialWidth = x2 - x1;
const initialHeight = y2 - y1;
const keepAspectRatio = true;
const aspectRatio = initialWidth / initialHeight;
if (elementsInUnit.length > 1) { if (nextValue !== undefined) {
const [x1, y1, x2, y2] = getCommonBounds( const nextHeight =
elementsInUnit.map((el) => el.latest), property === "height" ? nextValue : nextValue / aspectRatio;
);
return ( const scale = nextHeight / initialHeight;
Math.round((property === "width" ? x2 - x1 : y2 - y1) * 100) / 100 const anchorX = property === "width" ? x1 : x1 + width / 2;
const anchorY = property === "height" ? y1 : y1 + height / 2;
let i = 0;
while (i < stateAtStart.length) {
const latestElement = elements[i];
const origElement = stateAtStart[i];
// it should never happen that element and origElement are different
// but check just in case
if (latestElement.id === origElement.id) {
resizeElement(
anchorX,
anchorY,
property,
scale,
latestElement,
origElement,
elementsMap,
originalElementsMap,
i === stateAtStart.length - 1,
); );
} }
const [el] = elementsInUnit; i++;
}
return ( return;
Math.round( }
(property === "width" ? el.latest.width : el.latest.height) * 100,
) / 100 const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
let nextWidth = Math.max(0, initialWidth + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, initialHeight + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
if (keepAspectRatio) {
if (property === "width") {
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
} else {
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
}
}
const scale = nextHeight / initialHeight;
const anchorX = property === "width" ? x1 : x1 + width / 2;
const anchorY = property === "height" ? y1 : y1 + height / 2;
let i = 0;
while (i < stateAtStart.length) {
const latestElement = elements[i];
const origElement = stateAtStart[i];
if (latestElement.id === origElement.id) {
resizeElement(
anchorX,
anchorY,
property,
scale,
latestElement,
origElement,
elementsMap,
originalElementsMap,
i === stateAtStart.length - 1,
); );
}), }
[elementsMap, atomicUnits, property], i++;
); }
};
const value = const [x1, y1, x2, y2] = getCommonBounds(elements);
new Set(sizes).size === 1 ? Math.round(sizes[0] * 100) / 100 : "Mixed"; const width = x2 - x1;
const height = y2 - y1;
const editable = sizes.length > 0;
return ( return (
<DragInput <DragInput
label={property === "width" ? "W" : "H"} label={property === "width" ? "W" : "H"}
elements={elements} elements={elements}
dragInputCallback={handleDimensionChange} dragInputCallback={handleDimensionChange}
value={value} value={Math.round((property === "width" ? width : height) * 100) / 100}
editable={editable}
appState={appState}
property={property}
scene={scene}
/> />
); );
}; };
@@ -1,164 +0,0 @@
import { isTextElement, redrawTextBoundingBox } from "../../element";
import { mutateElement } from "../../element/mutateElement";
import { hasBoundTextElement } from "../../element/typeChecks";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import { isInGroup } from "../../groups";
import type Scene from "../../scene/Scene";
import { fontSizeIcon } from "../icons";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue } from "./utils";
import type { AppState } from "../../types";
import { getBoundTextElement } from "../../element/textElement";
interface MultiFontSizeProps {
elements: readonly ExcalidrawElement[];
scene: Scene;
elementsMap: NonDeletedSceneElementsMap;
appState: AppState;
property: "fontSize";
}
const MIN_FONT_SIZE = 4;
const STEP_SIZE = 4;
const getApplicableTextElements = (
elements: readonly (ExcalidrawElement | undefined)[],
elementsMap: NonDeletedSceneElementsMap,
) =>
elements.reduce(
(acc: ExcalidrawTextElement[], el) => {
if (!el || isInGroup(el)) {
return acc;
}
if (isTextElement(el)) {
acc.push(el);
return acc;
}
if (hasBoundTextElement(el)) {
const boundTextElement = getBoundTextElement(el, elementsMap);
if (boundTextElement) {
acc.push(boundTextElement);
return acc;
}
}
return acc;
},
[],
);
const handleFontSizeChange: DragInputCallbackType<
MultiFontSizeProps["property"],
ExcalidrawTextElement
> = ({
accumulatedChange,
originalElements,
shouldChangeByStepSize,
nextValue,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const latestTextElements = originalElements.map((el) =>
elementsMap.get(el.id),
) as ExcalidrawTextElement[];
let nextFontSize;
if (nextValue) {
nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
for (const textElement of latestTextElements) {
mutateElement(
textElement,
{
fontSize: nextFontSize,
},
false,
);
redrawTextBoundingBox(
textElement,
scene.getContainerElement(textElement),
elementsMap,
false,
);
}
scene.triggerUpdate();
} else {
const originalTextElements = originalElements as ExcalidrawTextElement[];
for (let i = 0; i < latestTextElements.length; i++) {
const latestElement = latestTextElements[i];
const originalElement = originalTextElements[i];
const originalFontSize = Math.round(originalElement.fontSize);
const changeInFontSize = Math.round(accumulatedChange);
let nextFontSize = Math.max(
originalFontSize + changeInFontSize,
MIN_FONT_SIZE,
);
if (shouldChangeByStepSize) {
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
}
mutateElement(
latestElement,
{
fontSize: nextFontSize,
},
false,
);
redrawTextBoundingBox(
latestElement,
scene.getContainerElement(latestElement),
elementsMap,
false,
);
}
scene.triggerUpdate();
}
};
const MultiFontSize = ({
elements,
scene,
appState,
property,
elementsMap,
}: MultiFontSizeProps) => {
const latestTextElements = getApplicableTextElements(elements, elementsMap);
if (!latestTextElements.length) {
return null;
}
const fontSizes = latestTextElements.map(
(textEl) => Math.round(textEl.fontSize * 10) / 10,
);
const value = new Set(fontSizes).size === 1 ? fontSizes[0] : "Mixed";
const editable = fontSizes.length > 0;
return (
<StatsDragInput
label="F"
icon={fontSizeIcon}
elements={latestTextElements}
dragInputCallback={handleFontSizeChange}
value={value}
editable={editable}
scene={scene}
property={property}
appState={appState}
/>
);
};
export default MultiFontSize;
@@ -1,271 +0,0 @@
import type {
ElementsMap,
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import { rotate } from "../../math";
import type Scene from "../../scene/Scene";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getCommonBounds, isTextElement } from "../../element";
import { useMemo } from "react";
import { getElementsInAtomicUnit, moveElement } from "./utils";
import type { AtomicUnit } from "./utils";
import type { AppState } from "../../types";
interface MultiPositionProps {
property: "x" | "y";
elements: readonly ExcalidrawElement[];
elementsMap: ElementsMap;
atomicUnits: AtomicUnit[];
scene: Scene;
appState: AppState;
}
const STEP_SIZE = 10;
const moveElements = (
property: MultiPositionProps["property"],
changeInTopX: number,
changeInTopY: number,
elements: readonly ExcalidrawElement[],
originalElements: readonly ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap,
scene: Scene,
) => {
for (let i = 0; i < elements.length; i++) {
const origElement = originalElements[i];
const [cx, cy] = [
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
origElement.angle,
);
const newTopLeftX =
property === "x" ? Math.round(topLeftX + changeInTopX) : topLeftX;
const newTopLeftY =
property === "y" ? Math.round(topLeftY + changeInTopY) : topLeftY;
moveElement(
newTopLeftX,
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
);
}
};
const moveGroupTo = (
nextX: number,
nextY: number,
originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
originalElementsMap: ElementsMap,
scene: Scene,
) => {
const [x1, y1, ,] = getCommonBounds(originalElements);
const offsetX = nextX - x1;
const offsetY = nextY - y1;
for (let i = 0; i < originalElements.length; i++) {
const origElement = originalElements[i];
const latestElement = elementsMap.get(origElement.id);
if (!latestElement) {
continue;
}
// bound texts are moved with their containers
if (!isTextElement(latestElement) || !latestElement.containerId) {
const [cx, cy] = [
latestElement.x + latestElement.width / 2,
latestElement.y + latestElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
latestElement.x,
latestElement.y,
cx,
cy,
latestElement.angle,
);
moveElement(
topLeftX + offsetX,
topLeftY + offsetY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
);
}
}
};
const handlePositionChange: DragInputCallbackType<
MultiPositionProps["property"]
> = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldChangeByStepSize,
nextValue,
property,
scene,
originalAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
if (nextValue !== undefined) {
for (const atomicUnit of getAtomicUnits(
originalElements,
originalAppState,
)) {
const elementsInUnit = getElementsInAtomicUnit(
atomicUnit,
elementsMap,
originalElementsMap,
);
if (elementsInUnit.length > 1) {
const [x1, y1, ,] = getCommonBounds(
elementsInUnit.map((el) => el.latest!),
);
const newTopLeftX = property === "x" ? nextValue : x1;
const newTopLeftY = property === "y" ? nextValue : y1;
moveGroupTo(
newTopLeftX,
newTopLeftY,
elementsInUnit.map((el) => el.original),
elementsMap,
elements,
originalElementsMap,
scene,
);
} else {
const origElement = elementsInUnit[0]?.original;
const latestElement = elementsInUnit[0]?.latest;
if (
origElement &&
latestElement &&
isPropertyEditable(latestElement, property)
) {
const [cx, cy] = [
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
origElement.angle,
);
const newTopLeftX = property === "x" ? nextValue : topLeftX;
const newTopLeftY = property === "y" ? nextValue : topLeftY;
moveElement(
newTopLeftX,
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
);
}
}
}
scene.triggerUpdate();
return;
}
const change = shouldChangeByStepSize
? getStepSizedValue(accumulatedChange, STEP_SIZE)
: accumulatedChange;
const changeInTopX = property === "x" ? change : 0;
const changeInTopY = property === "y" ? change : 0;
moveElements(
property,
changeInTopX,
changeInTopY,
originalElements,
originalElements,
elementsMap,
originalElementsMap,
scene,
);
scene.triggerUpdate();
};
const MultiPosition = ({
property,
elements,
elementsMap,
atomicUnits,
scene,
appState,
}: MultiPositionProps) => {
const positions = useMemo(
() =>
atomicUnits.map((atomicUnit) => {
const elementsInUnit = Object.keys(atomicUnit)
.map((id) => elementsMap.get(id))
.filter((el) => el !== undefined) as ExcalidrawElement[];
// we're dealing with a group
if (elementsInUnit.length > 1) {
const [x1, y1] = getCommonBounds(elementsInUnit);
return Math.round((property === "x" ? x1 : y1) * 100) / 100;
}
const [el] = elementsInUnit;
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
const [topLeftX, topLeftY] = rotate(el.x, el.y, cx, cy, el.angle);
return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
}),
[atomicUnits, elementsMap, property],
);
const value = new Set(positions).size === 1 ? positions[0] : "Mixed";
return (
<StatsDragInput
label={property === "x" ? "X" : "Y"}
elements={elements}
dragInputCallback={handlePositionChange}
value={value}
property={property}
scene={scene}
appState={appState}
/>
);
};
export default MultiPosition;
@@ -1,120 +0,0 @@
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { rotate } from "../../math";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface PositionProps {
property: "x" | "y";
element: ExcalidrawElement;
elementsMap: ElementsMap;
scene: Scene;
appState: AppState;
}
const STEP_SIZE = 10;
const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldChangeByStepSize,
nextValue,
property,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0];
const [cx, cy] = [
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
origElement.angle,
);
if (nextValue !== undefined) {
const newTopLeftX = property === "x" ? nextValue : topLeftX;
const newTopLeftY = property === "y" ? nextValue : topLeftY;
moveElement(
newTopLeftX,
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
);
return;
}
const changeInTopX = property === "x" ? accumulatedChange : 0;
const changeInTopY = property === "y" ? accumulatedChange : 0;
const newTopLeftX =
property === "x"
? Math.round(
shouldChangeByStepSize
? getStepSizedValue(origElement.x + changeInTopX, STEP_SIZE)
: topLeftX + changeInTopX,
)
: topLeftX;
const newTopLeftY =
property === "y"
? Math.round(
shouldChangeByStepSize
? getStepSizedValue(origElement.y + changeInTopY, STEP_SIZE)
: topLeftY + changeInTopY,
)
: topLeftY;
moveElement(
newTopLeftX,
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
);
};
const Position = ({
property,
element,
elementsMap,
scene,
appState,
}: PositionProps) => {
const [topLeftX, topLeftY] = rotate(
element.x,
element.y,
element.x + element.width / 2,
element.y + element.height / 2,
element.angle,
);
const value =
Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
return (
<StatsDragInput
label={property === "x" ? "X" : "Y"}
elements={[element]}
dragInputCallback={handlePositionChange}
scene={scene}
value={value}
property={property}
appState={appState}
/>
);
};
export default Position;
@@ -0,0 +1,93 @@
@import "../../css/variables.module.scss";
.excalidraw {
.Stats {
width: 204px;
position: absolute;
top: 64px;
right: 12px;
font-size: 12px;
z-index: 10;
pointer-events: var(--ui-pointerEvents);
.sectionContent {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.elementType {
font-size: 12px;
font-weight: 700;
margin-bottom: 8px;
}
.elementsCount {
width: 100%;
font-size: 12px;
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}
.statsItem {
width: 100%;
margin-bottom: 4px;
display: grid;
gap: 4px;
.label {
margin-right: 4px;
}
}
h3 {
margin: 0 24px 8px 0;
white-space: nowrap;
}
.close {
float: right;
height: 16px;
width: 16px;
cursor: pointer;
svg {
width: 100%;
height: 100%;
}
}
table {
width: 100%;
th {
border-bottom: 1px solid var(--input-border-color);
padding: 4px;
}
tr {
td:nth-child(2) {
min-width: 24px;
text-align: right;
}
}
}
.divider {
width: 100%;
height: 1px;
background-color: var(--default-border-color);
}
:root[dir="rtl"] & {
left: 12px;
right: initial;
h3 {
margin: 0 0 8px 24px;
}
.close {
float: left;
}
}
}
}
+148 -278
View File
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState, memo } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { getCommonBounds } from "../../element/bounds"; import { getCommonBounds } from "../../element/bounds";
import type { NonDeletedExcalidrawElement } from "../../element/types"; import type { NonDeletedExcalidrawElement } from "../../element/types";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { getSelectedElements } from "../../scene";
import type Scene from "../../scene/Scene";
import type { AppState, ExcalidrawProps } from "../../types"; import type { AppState, ExcalidrawProps } from "../../types";
import { CloseIcon } from "../icons"; import { CloseIcon } from "../icons";
import { Island } from "../Island"; import { Island } from "../Island";
@@ -9,297 +11,165 @@ import { throttle } from "lodash";
import Dimension from "./Dimension"; import Dimension from "./Dimension";
import Angle from "./Angle"; import Angle from "./Angle";
import "./index.scss";
import FontSize from "./FontSize"; import FontSize from "./FontSize";
import MultiDimension from "./MultiDimension"; import MultiDimension from "./MultiDimension";
import { elementsAreInSameGroup } from "../../groups"; import { elementsAreInSameGroup } from "../../groups";
import MultiAngle from "./MultiAngle";
import MultiFontSize from "./MultiFontSize";
import Position from "./Position";
import MultiPosition from "./MultiPosition";
import Collapsible from "./Collapsible";
import type Scene from "../../scene/Scene";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { getAtomicUnits } from "./utils";
import { STATS_PANELS } from "../../constants";
import { isElbowArrow } from "../../element/typeChecks";
interface StatsProps { interface StatsProps {
appState: AppState;
scene: Scene; scene: Scene;
setAppState: React.Component<any, AppState>["setState"];
onClose: () => void; onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"]; renderCustomStats: ExcalidrawProps["renderCustomStats"];
} }
const STATS_TIMEOUT = 50; const STATS_TIMEOUT = 50;
export const Stats = (props: StatsProps) => { export const Stats = (props: StatsProps) => {
const appState = useExcalidrawAppState(); const elements = props.scene.getNonDeletedElements();
const sceneNonce = props.scene.getSceneNonce() || 1; const elementsMap = props.scene.getNonDeletedElementsMap();
const selectedElements = props.scene.getSelectedElements({ const sceneNonce = props.scene.getSceneNonce();
selectedElementIds: appState.selectedElementIds, // const selectedElements = getTargetElements(elements, props.appState);
includeBoundTextElement: false, const selectedElements = getSelectedElements(
props.scene.getNonDeletedElementsMap(),
props.appState,
{
includeBoundTextElement: false,
},
);
const singleElement =
selectedElements.length === 1 ? selectedElements[0] : null;
const multipleElements =
selectedElements.length > 1 ? selectedElements : null;
const [sceneDimension, setSceneDimension] = useState<{
width: number;
height: number;
}>({
width: 0,
height: 0,
}); });
const throttledSetSceneDimension = useMemo(
() =>
throttle((elements: readonly NonDeletedExcalidrawElement[]) => {
const boundingBox = getCommonBounds(elements);
setSceneDimension({
width: Math.round(boundingBox[2]) - Math.round(boundingBox[0]),
height: Math.round(boundingBox[3]) - Math.round(boundingBox[1]),
});
}, STATS_TIMEOUT),
[],
);
useEffect(() => {
throttledSetSceneDimension(elements);
}, [sceneNonce, elements, throttledSetSceneDimension]);
useEffect(
() => () => throttledSetSceneDimension.cancel(),
[throttledSetSceneDimension],
);
return ( return (
<StatsInner <div className="Stats">
{...props} <Island padding={3}>
appState={appState} <div className="section">
sceneNonce={sceneNonce} <div className="close" onClick={props.onClose}>
selectedElements={selectedElements} {CloseIcon}
/> </div>
<h3>{t("stats.generalStats")}</h3>
<table>
<tbody>
<tr>
<th colSpan={2}>{t("stats.scene")}</th>
</tr>
<tr>
<td>{t("stats.elements")}</td>
<td>{elements.length}</td>
</tr>
<tr>
<td>{t("stats.width")}</td>
<td>{sceneDimension.width}</td>
</tr>
<tr>
<td>{t("stats.height")}</td>
<td>{sceneDimension.height}</td>
</tr>
{props.renderCustomStats?.(elements, props.appState)}
</tbody>
</table>
</div>
{selectedElements.length > 0 && (
<div
className="section"
style={{
marginTop: 12,
}}
>
<h3>{t("stats.elementStats")}</h3>
{singleElement && (
<div className="sectionContent">
<div className="elementType">
{t(`element.${singleElement.type}`)}
</div>
<div className="statsItem">
<Dimension
property="width"
element={singleElement}
elementsMap={elementsMap}
/>
<Dimension
property="height"
element={singleElement}
elementsMap={elementsMap}
/>
<Angle element={singleElement} elementsMap={elementsMap} />
{singleElement.type === "text" && (
<FontSize
element={singleElement}
elementsMap={elementsMap}
/>
)}
</div>
{singleElement.type === "text" && <div></div>}
</div>
)}
{multipleElements && (
<div className="sectionContent">
{elementsAreInSameGroup(multipleElements) && (
<div className="elementType">{t("element.group")}</div>
)}
<div className="elementsCount">
<div>{t("stats.elements")}</div>
<div>{selectedElements.length}</div>
</div>
<div className="statsItem">
<MultiDimension
property="width"
elements={multipleElements}
elementsMap={elementsMap}
/>
<MultiDimension
property="height"
elements={multipleElements}
elementsMap={elementsMap}
/>
</div>
</div>
)}
</div>
)}
</Island>
</div>
); );
}; };
export const StatsInner = memo(
({
scene,
onClose,
renderCustomStats,
selectedElements,
appState,
sceneNonce,
}: StatsProps & {
sceneNonce: number;
selectedElements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
}) => {
const elements = scene.getNonDeletedElements();
const elementsMap = scene.getNonDeletedElementsMap();
const setAppState = useExcalidrawSetAppState();
const singleElement =
selectedElements.length === 1 ? selectedElements[0] : null;
const multipleElements =
selectedElements.length > 1 ? selectedElements : null;
const [sceneDimension, setSceneDimension] = useState<{
width: number;
height: number;
}>({
width: 0,
height: 0,
});
const throttledSetSceneDimension = useMemo(
() =>
throttle((elements: readonly NonDeletedExcalidrawElement[]) => {
const boundingBox = getCommonBounds(elements);
setSceneDimension({
width: Math.round(boundingBox[2]) - Math.round(boundingBox[0]),
height: Math.round(boundingBox[3]) - Math.round(boundingBox[1]),
});
}, STATS_TIMEOUT),
[],
);
useEffect(() => {
throttledSetSceneDimension(elements);
}, [sceneNonce, elements, throttledSetSceneDimension]);
useEffect(
() => () => throttledSetSceneDimension.cancel(),
[throttledSetSceneDimension],
);
const atomicUnits = useMemo(() => {
return getAtomicUnits(selectedElements, appState);
}, [selectedElements, appState]);
return (
<div className="Stats">
<Island padding={3}>
<div className="title">
<h2>{t("stats.title")}</h2>
<div className="close" onClick={onClose}>
{CloseIcon}
</div>
</div>
<Collapsible
label={<h3>{t("stats.generalStats")}</h3>}
open={!!(appState.stats.panels & STATS_PANELS.generalStats)}
openTrigger={() =>
setAppState((state) => {
return {
...state,
stats: {
open: true,
panels: state.stats.panels ^ STATS_PANELS.generalStats,
},
};
})
}
>
<table>
<tbody>
<tr>
<th colSpan={2}>{t("stats.scene")}</th>
</tr>
<tr>
<td>{t("stats.elements")}</td>
<td>{elements.length}</td>
</tr>
<tr>
<td>{t("stats.width")}</td>
<td>{sceneDimension.width}</td>
</tr>
<tr>
<td>{t("stats.height")}</td>
<td>{sceneDimension.height}</td>
</tr>
{renderCustomStats?.(elements, appState)}
</tbody>
</table>
</Collapsible>
{selectedElements.length > 0 && (
<div
id="elementStats"
style={{
marginTop: 12,
}}
>
<Collapsible
label={<h3>{t("stats.elementProperties")}</h3>}
open={
!!(appState.stats.panels & STATS_PANELS.elementProperties)
}
openTrigger={() =>
setAppState((state) => {
return {
...state,
stats: {
open: true,
panels:
state.stats.panels ^ STATS_PANELS.elementProperties,
},
};
})
}
>
{singleElement && (
<div className="sectionContent">
<div className="elementType">
{t(`element.${singleElement.type}`)}
</div>
<div className="statsItem">
<Position
element={singleElement}
property="x"
elementsMap={elementsMap}
scene={scene}
appState={appState}
/>
<Position
element={singleElement}
property="y"
elementsMap={elementsMap}
scene={scene}
appState={appState}
/>
<Dimension
property="width"
element={singleElement}
scene={scene}
appState={appState}
/>
<Dimension
property="height"
element={singleElement}
scene={scene}
appState={appState}
/>
{!isElbowArrow(singleElement) && (
<Angle
property="angle"
element={singleElement}
scene={scene}
appState={appState}
/>
)}
<FontSize
property="fontSize"
element={singleElement}
scene={scene}
appState={appState}
/>
</div>
</div>
)}
{multipleElements && (
<div className="sectionContent">
{elementsAreInSameGroup(multipleElements) && (
<div className="elementType">{t("element.group")}</div>
)}
<div className="elementsCount">
<div>{t("stats.elements")}</div>
<div>{selectedElements.length}</div>
</div>
<div className="statsItem">
<MultiPosition
property="x"
elements={multipleElements}
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
<MultiPosition
property="y"
elements={multipleElements}
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
<MultiDimension
property="width"
elements={multipleElements}
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
<MultiDimension
property="height"
elements={multipleElements}
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
<MultiAngle
property="angle"
elements={multipleElements}
scene={scene}
appState={appState}
/>
<MultiFontSize
property="fontSize"
elements={multipleElements}
scene={scene}
appState={appState}
elementsMap={elementsMap}
/>
</div>
</div>
)}
</Collapsible>
</div>
)}
</Island>
</div>
);
},
(prev, next) => {
return (
prev.sceneNonce === next.sceneNonce &&
prev.selectedElements === next.selectedElements &&
prev.appState.stats.panels === next.appState.stats.panels
);
},
);
@@ -1,752 +0,0 @@
import React from "react";
import { act, fireEvent, queryByTestId } from "@testing-library/react";
import { Keyboard, Pointer, UI } from "../../tests/helpers/ui";
import { getStepSizedValue } from "./utils";
import {
GlobalTestState,
mockBoundingClientRect,
render,
restoreOriginalGetBoundingClientRect,
} from "../../tests/test-utils";
import * as StaticScene from "../../renderer/staticScene";
import { vi } from "vitest";
import { reseed } from "../../random";
import { setDateTimeForTests } from "../../utils";
import { Excalidraw, mutateElement } from "../..";
import { t } from "../../i18n";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
} from "../../element/types";
import { degreeToRadian, rotate } from "../../math";
import { getTextEditor, updateTextEditor } from "../../tests/queries/dom";
import { getCommonBounds, isTextElement } from "../../element";
import { API } from "../../tests/helpers/api";
import { actionGroup } from "../../actions";
import { isInGroup } from "../../groups";
const { h } = window;
const mouse = new Pointer("mouse");
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
let stats: HTMLElement | null = null;
let elementStats: HTMLElement | null | undefined = null;
const getStatsProperty = (label: string) => {
const elementStats = UI.queryStats()?.querySelector("#elementStats");
if (elementStats) {
const properties = elementStats?.querySelector(".statsItem");
return (
properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
) || null
);
}
return null;
};
const testInputProperty = (
element: ExcalidrawElement,
property: "x" | "y" | "width" | "height" | "angle" | "fontSize",
label: string,
initialValue: number,
nextValue: number,
) => {
const input = getStatsProperty(label)?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(input).toBeDefined();
expect(input.value).toBe(initialValue.toString());
UI.updateInput(input, String(nextValue));
if (property === "angle") {
expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
} else if (property === "fontSize" && isTextElement(element)) {
expect(element[property]).toBe(Number(nextValue));
} else if (property !== "fontSize") {
expect(element[property]).toBe(Number(nextValue));
}
};
describe("step sized value", () => {
it("should return edge values correctly", () => {
const steps = [10, 15, 20, 25, 30];
const values = [10, 15, 20, 25, 30];
steps.forEach((step, idx) => {
expect(getStepSizedValue(values[idx], step)).toEqual(values[idx]);
});
});
it("step sized value lies in the middle", () => {
let stepSize = 15;
let values = [7.5, 9, 12, 14.99, 15, 22.49];
values.forEach((value) => {
expect(getStepSizedValue(value, stepSize)).toEqual(15);
});
stepSize = 10;
values = [-5, 4.99, 0, 1.23];
values.forEach((value) => {
expect(getStepSizedValue(value, stepSize)).toEqual(0);
});
});
});
describe("binding with linear elements", () => {
beforeEach(async () => {
localStorage.clear();
renderStaticScene.mockClear();
reseed(19);
setDateTimeForTests("201933152653");
await render(<Excalidraw handleKeyboardGlobally={true} />);
API.setElements([]);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
stats = UI.queryStats();
UI.clickTool("rectangle");
mouse.down();
mouse.up(200, 100);
UI.clickTool("arrow");
mouse.down(5, 0);
mouse.up(300, 50);
elementStats = stats?.querySelector("#elementStats");
});
beforeAll(() => {
mockBoundingClientRect();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
it("should remain bound to linear element on small position change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputX = getStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull();
UI.updateInput(inputX, String("204"));
expect(linear.startBinding).not.toBe(null);
});
it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = getStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
UI.updateInput(inputAngle, String("1"));
expect(linear.startBinding).not.toBe(null);
});
it("should unbind linear element on large position change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputX = getStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull();
UI.updateInput(inputX, String("254"));
expect(linear.startBinding).toBe(null);
});
it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = getStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
UI.updateInput(inputAngle, String("45"));
expect(linear.startBinding).toBe(null);
});
});
// single element
describe("stats for a generic element", () => {
beforeEach(async () => {
localStorage.clear();
renderStaticScene.mockClear();
reseed(7);
setDateTimeForTests("201933152653");
await render(<Excalidraw handleKeyboardGlobally={true} />);
API.setElements([]);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
stats = UI.queryStats();
UI.clickTool("rectangle");
mouse.down();
mouse.up(200, 100);
elementStats = stats?.querySelector("#elementStats");
});
beforeAll(() => {
mockBoundingClientRect();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
it("should open stats", () => {
expect(stats).toBeDefined();
expect(elementStats).toBeDefined();
// title
const title = elementStats?.querySelector("h3");
expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties"));
// element type
const elementType = elementStats?.querySelector(".elementType");
expect(elementType).toBeDefined();
expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
// properties
const properties = elementStats?.querySelector(".statsItem");
expect(properties?.childNodes).toBeDefined();
["X", "Y", "W", "H", "A"].forEach((label) => () => {
expect(
properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
),
).toBeDefined();
});
});
it("should be able to edit all properties for a general element", () => {
const rectangle = h.elements[0];
const initialX = rectangle.x;
const initialY = rectangle.y;
testInputProperty(rectangle, "width", "W", 200, 100);
testInputProperty(rectangle, "height", "H", 100, 200);
testInputProperty(rectangle, "x", "X", initialX, 230);
testInputProperty(rectangle, "y", "Y", initialY, 220);
testInputProperty(rectangle, "angle", "A", 0, 45);
});
it("should keep only two decimal places", () => {
const rectangle = h.elements[0];
const rectangleId = rectangle.id;
const input = getStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(input).toBeDefined();
expect(input.value).toBe(rectangle.width.toString());
UI.updateInput(input, "123.123");
expect(h.elements.length).toBe(1);
expect(rectangle.id).toBe(rectangleId);
expect(input.value).toBe("123.12");
expect(rectangle.width).toBe(123.12);
UI.updateInput(input, "88.98766");
expect(input.value).toBe("88.99");
expect(rectangle.width).toBe(88.99);
});
it("should update input x and y when angle is changed", () => {
const rectangle = h.elements[0];
const [cx, cy] = [
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
];
const [topLeftX, topLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
rectangle.angle,
);
const xInput = getStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
const yInput = getStatsProperty("Y")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(xInput.value).toBe(topLeftX.toString());
expect(yInput.value).toBe(topLeftY.toString());
testInputProperty(rectangle, "angle", "A", 0, 45);
let [newTopLeftX, newTopLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
rectangle.angle,
);
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
expect(newTopLeftY.toString()).not.toEqual(yInput.value);
testInputProperty(rectangle, "angle", "A", 45, 66);
[newTopLeftX, newTopLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
rectangle.angle,
);
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
expect(newTopLeftY.toString()).not.toEqual(yInput.value);
});
it("should fix top left corner when width or height is changed", () => {
const rectangle = h.elements[0];
testInputProperty(rectangle, "angle", "A", 0, 45);
let [cx, cy] = [
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
];
const [topLeftX, topLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
rectangle.angle,
);
testInputProperty(rectangle, "width", "W", rectangle.width, 400);
[cx, cy] = [
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
];
let [currentTopLeftX, currentTopLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
rectangle.angle,
);
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
expect(currentTopLeftY).toBeCloseTo(topLeftY, 4);
testInputProperty(rectangle, "height", "H", rectangle.height, 400);
[cx, cy] = [
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
];
[currentTopLeftX, currentTopLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
rectangle.angle,
);
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
expect(currentTopLeftY).toBeCloseTo(topLeftY, 4);
});
});
describe("stats for a non-generic element", () => {
beforeEach(async () => {
localStorage.clear();
renderStaticScene.mockClear();
reseed(7);
setDateTimeForTests("201933152653");
await render(<Excalidraw handleKeyboardGlobally={true} />);
API.setElements([]);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
stats = UI.queryStats();
});
beforeAll(() => {
mockBoundingClientRect();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
it("text element", async () => {
UI.clickTool("text");
mouse.clickAt(20, 30);
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello!");
act(() => {
editor.blur();
});
const text = h.elements[0] as ExcalidrawTextElement;
mouse.clickOn(text);
elementStats = stats?.querySelector("#elementStats");
// can change font size
const input = getStatsProperty("F")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(input).toBeDefined();
expect(input.value).toBe(text.fontSize.toString());
UI.updateInput(input, "36");
expect(text.fontSize).toBe(36);
// cannot change width or height
const width = getStatsProperty("W")?.querySelector(".drag-input");
expect(width).toBeUndefined();
const height = getStatsProperty("H")?.querySelector(".drag-input");
expect(height).toBeUndefined();
// min font size is 4
UI.updateInput(input, "0");
expect(text.fontSize).not.toBe(0);
expect(text.fontSize).toBe(4);
});
it("frame element", () => {
const frame = API.createElement({
id: "id0",
type: "frame",
x: 150,
width: 150,
});
API.setElements([frame]);
API.setAppState({
selectedElementIds: {
[frame.id]: true,
},
});
elementStats = stats?.querySelector("#elementStats");
expect(elementStats).toBeDefined();
// cannot change angle
const angle = getStatsProperty("A")?.querySelector(".drag-input");
expect(angle).toBeUndefined();
// can change width or height
testInputProperty(frame, "width", "W", frame.width, 250);
testInputProperty(frame, "height", "H", frame.height, 500);
});
it("image element", () => {
const image = API.createElement({ type: "image", width: 200, height: 100 });
API.setElements([image]);
mouse.clickOn(image);
API.setAppState({
selectedElementIds: {
[image.id]: true,
},
});
elementStats = stats?.querySelector("#elementStats");
expect(elementStats).toBeDefined();
const widthToHeight = image.width / image.height;
// when width or height is changed, the aspect ratio is preserved
testInputProperty(image, "width", "W", image.width, 400);
expect(image.width).toBe(400);
expect(image.width / image.height).toBe(widthToHeight);
testInputProperty(image, "height", "H", image.height, 80);
expect(image.height).toBe(80);
expect(image.width / image.height).toBe(widthToHeight);
});
it("should display fontSize for bound text", () => {
const container = API.createElement({
type: "rectangle",
width: 200,
height: 100,
});
const text = API.createElement({
type: "text",
width: 200,
height: 100,
containerId: container.id,
fontSize: 20,
});
mutateElement(container, {
boundElements: [{ type: "text", id: text.id }],
});
API.setElements([container, text]);
API.setSelectedElements([container]);
const fontSize = getStatsProperty("F")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(fontSize).toBeDefined();
UI.updateInput(fontSize, "40");
expect(text.fontSize).toBe(40);
});
});
// multiple elements
describe("stats for multiple elements", () => {
beforeEach(async () => {
mouse.reset();
localStorage.clear();
renderStaticScene.mockClear();
reseed(7);
setDateTimeForTests("201933152653");
await render(<Excalidraw handleKeyboardGlobally={true} />);
API.setElements([]);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
stats = UI.queryStats();
});
beforeAll(() => {
mockBoundingClientRect();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
it("should display MIXED for elements with different values", () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(200, 100);
UI.clickTool("ellipse");
mouse.down(50, 50);
mouse.up(100, 100);
UI.clickTool("diamond");
mouse.down(-100, -100);
mouse.up(125, 145);
API.setAppState({
selectedElementIds: h.elements.reduce((acc, el) => {
acc[el.id] = true;
return acc;
}, {} as Record<string, true>),
});
elementStats = stats?.querySelector("#elementStats");
const width = getStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(width?.value).toBe("Mixed");
const height = getStatsProperty("H")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(height?.value).toBe("Mixed");
const angle = getStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(angle.value).toBe("0");
UI.updateInput(width, "250");
h.elements.forEach((el) => {
expect(el.width).toBe(250);
});
UI.updateInput(height, "450");
h.elements.forEach((el) => {
expect(el.height).toBe(450);
});
});
it("should display a property when one of the elements is editable for that property", async () => {
// text, rectangle, frame
UI.clickTool("text");
mouse.clickAt(20, 30);
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello!");
act(() => {
editor.blur();
});
UI.clickTool("rectangle");
mouse.down();
mouse.up(200, 100);
const frame = API.createElement({
type: "frame",
x: 150,
width: 150,
});
API.setElements([...h.elements, frame]);
const text = h.elements.find((el) => el.type === "text");
const rectangle = h.elements.find((el) => el.type === "rectangle");
API.setAppState({
selectedElementIds: h.elements.reduce((acc, el) => {
acc[el.id] = true;
return acc;
}, {} as Record<string, true>),
});
elementStats = stats?.querySelector("#elementStats");
const width = getStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(width).toBeDefined();
expect(width.value).toBe("Mixed");
const height = getStatsProperty("H")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(height).toBeDefined();
expect(height.value).toBe("Mixed");
const angle = getStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(angle).toBeDefined();
expect(angle.value).toBe("0");
const fontSize = getStatsProperty("F")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(fontSize).toBeDefined();
// changing width does not affect text
UI.updateInput(width, "200");
expect(rectangle?.width).toBe(200);
expect(frame.width).toBe(200);
expect(text?.width).not.toBe(200);
UI.updateInput(angle, "40");
const angleInRadian = degreeToRadian(40);
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
expect(text?.angle).toBeCloseTo(angleInRadian, 4);
expect(frame.angle).toBe(0);
});
it("should treat groups as single units", () => {
const createAndSelectGroup = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
API.executeAction(actionGroup);
};
createAndSelectGroup();
const elementsInGroup = h.elements.filter((el) => isInGroup(el));
let [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
elementStats = stats?.querySelector("#elementStats");
const x = getStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(x).toBeDefined();
expect(Number(x.value)).toBe(x1);
UI.updateInput(x, "300");
expect(h.elements[0].x).toBe(300);
expect(h.elements[1].x).toBe(400);
expect(x.value).toBe("300");
const y = getStatsProperty("Y")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(y).toBeDefined();
expect(Number(y.value)).toBe(y1);
UI.updateInput(y, "200");
expect(h.elements[0].y).toBe(200);
expect(h.elements[1].y).toBe(300);
expect(y.value).toBe("200");
const width = getStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(width).toBeDefined();
expect(Number(width.value)).toBe(200);
const height = getStatsProperty("H")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(height).toBeDefined();
expect(Number(height.value)).toBe(200);
UI.updateInput(width, "400");
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
let newGroupWidth = x2 - x1;
expect(newGroupWidth).toBeCloseTo(400, 4);
UI.updateInput(width, "300");
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
newGroupWidth = x2 - x1;
expect(newGroupWidth).toBeCloseTo(300, 4);
UI.updateInput(height, "500");
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
const newGroupHeight = y2 - y1;
expect(newGroupHeight).toBeCloseTo(500, 4);
});
});
+2 -300
View File
@@ -1,49 +1,5 @@
import { import { isFrameLikeElement, isTextElement } from "../../element/typeChecks";
bindOrUnbindLinearElements, import type { ExcalidrawElement } from "../../element/types";
updateBoundElements,
} from "../../element/binding";
import { mutateElement } from "../../element/mutateElement";
import {
measureFontSizeFromWidth,
rescalePointsInElement,
} from "../../element/resizeElements";
import {
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextMaxWidth,
handleBindTextResize,
} from "../../element/textElement";
import {
isFrameLikeElement,
isLinearElement,
isTextElement,
} from "../../element/typeChecks";
import type {
ElementsMap,
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import {
getSelectedGroupIds,
getElementsInGroup,
isInGroup,
} from "../../groups";
import { rotate } from "../../math";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { getFontString } from "../../utils";
export type StatsInputProperty =
| "x"
| "y"
| "width"
| "height"
| "angle"
| "fontSize";
export const SMALLEST_DELTA = 0.01;
export const isPropertyEditable = ( export const isPropertyEditable = (
element: ExcalidrawElement, element: ExcalidrawElement,
@@ -65,257 +21,3 @@ export const getStepSizedValue = (value: number, stepSize: number) => {
const v = value + stepSize / 2; const v = value + stepSize / 2;
return v - (v % stepSize); return v - (v % stepSize);
}; };
export type AtomicUnit = Record<string, true>;
export const getElementsInAtomicUnit = (
atomicUnit: AtomicUnit,
elementsMap: ElementsMap,
originalElementsMap?: ElementsMap,
) => {
return Object.keys(atomicUnit)
.map((id) => ({
original: (originalElementsMap ?? elementsMap).get(id),
latest: elementsMap.get(id),
}))
.filter((el) => el.original !== undefined && el.latest !== undefined) as {
original: NonDeletedExcalidrawElement;
latest: NonDeletedExcalidrawElement;
}[];
};
export const newOrigin = (
x1: number,
y1: number,
w1: number,
h1: number,
w2: number,
h2: number,
angle: number,
) => {
/**
* The formula below is the result of solving
* rotate(x1, y1, cx1, cy1, angle) = rotate(x2, y2, cx2, cy2, angle)
* where rotate is the function defined in math.ts
*
* This is so that the new origin (x2, y2),
* when rotated against the new center (cx2, cy2),
* coincides with (x1, y1) rotated against (cx1, cy1)
*
* The reason for doing this computation is so the element's top left corner
* on the canvas remains fixed after any changes in its dimension.
*/
return {
x:
x1 +
(w1 - w2) / 2 +
((w2 - w1) / 2) * Math.cos(angle) +
((h1 - h2) / 2) * Math.sin(angle),
y:
y1 +
(h1 - h2) / 2 +
((w2 - w1) / 2) * Math.sin(angle) +
((h2 - h1) / 2) * Math.cos(angle),
};
};
export const resizeElement = (
nextWidth: number,
nextHeight: number,
keepAspectRatio: boolean,
origElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
shouldInformMutation = true,
) => {
const latestElement = elementsMap.get(origElement.id);
if (!latestElement) {
return;
}
let boundTextFont: { fontSize?: number } = {};
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement) {
const minWidth = getApproxMinLineWidth(
getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const minHeight = getApproxMinLineHeight(
boundTextElement.fontSize,
boundTextElement.lineHeight,
);
nextWidth = Math.max(nextWidth, minWidth);
nextHeight = Math.max(nextHeight, minHeight);
}
const { width: oldWidth, height: oldHeight } = latestElement;
mutateElement(
latestElement,
{
...newOrigin(
latestElement.x,
latestElement.y,
latestElement.width,
latestElement.height,
nextWidth,
nextHeight,
latestElement.angle,
),
width: nextWidth,
height: nextHeight,
...rescalePointsInElement(origElement, nextWidth, nextHeight, true),
},
shouldInformMutation,
);
updateBindings(latestElement, elementsMap, elements, scene, {
newSize: {
width: nextWidth,
height: nextHeight,
},
});
if (boundTextElement) {
boundTextFont = {
fontSize: boundTextElement.fontSize,
};
if (keepAspectRatio) {
const updatedElement = {
...latestElement,
width: nextWidth,
height: nextHeight,
};
const nextFont = measureFontSizeFromWidth(
boundTextElement,
elementsMap,
getBoundTextMaxWidth(updatedElement, boundTextElement),
);
boundTextFont = {
fontSize: nextFont?.size ?? boundTextElement.fontSize,
};
}
}
updateBoundElements(latestElement, elementsMap, {
oldSize: { width: oldWidth, height: oldHeight },
});
if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
});
}
handleBindTextResize(latestElement, elementsMap, "e", keepAspectRatio);
};
export const moveElement = (
newTopLeftX: number,
newTopLeftY: number,
originalElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
originalElementsMap: ElementsMap,
shouldInformMutation = true,
) => {
const latestElement = elementsMap.get(originalElement.id);
if (!latestElement) {
return;
}
const [cx, cy] = [
originalElement.x + originalElement.width / 2,
originalElement.y + originalElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
originalElement.x,
originalElement.y,
cx,
cy,
originalElement.angle,
);
const changeInX = newTopLeftX - topLeftX;
const changeInY = newTopLeftY - topLeftY;
const [x, y] = rotate(
newTopLeftX,
newTopLeftY,
cx + changeInX,
cy + changeInY,
-originalElement.angle,
);
mutateElement(
latestElement,
{
x,
y,
},
shouldInformMutation,
);
updateBindings(latestElement, elementsMap, elements, scene);
const boundTextElement = getBoundTextElement(
originalElement,
originalElementsMap,
);
if (boundTextElement) {
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
latestBoundTextElement &&
mutateElement(
latestBoundTextElement,
{
x: boundTextElement.x + changeInX,
y: boundTextElement.y + changeInY,
},
shouldInformMutation,
);
}
};
export const getAtomicUnits = (
targetElements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const selectedGroupIds = getSelectedGroupIds(appState);
const _atomicUnits = selectedGroupIds.map((gid) => {
return getElementsInGroup(targetElements, gid).reduce((acc, el) => {
acc[el.id] = true;
return acc;
}, {} as AtomicUnit);
});
targetElements
.filter((el) => !isInGroup(el))
.forEach((el) => {
_atomicUnits.push({
[el.id]: true,
});
});
return _atomicUnits;
};
export const updateBindings = (
latestElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
},
) => {
if (isLinearElement(latestElement)) {
bindOrUnbindLinearElements(
[latestElement],
elementsMap,
elements,
scene,
true,
[],
);
} else {
updateBoundElements(latestElement, elementsMap, options);
}
};
@@ -139,7 +139,7 @@ $verticalBreakpoint: 861px;
.ttd-dialog-output-error { .ttd-dialog-output-error {
color: red; color: red;
font-weight: 700; font-weight: 800;
font-size: 30px; font-size: 30px;
word-break: break-word; word-break: break-word;
overflow: auto; overflow: auto;
@@ -1,6 +1,10 @@
import type { MermaidConfig } from "@excalidraw/mermaid-to-excalidraw"; import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
import type { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces"; import type { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "../../constants"; import {
DEFAULT_EXPORT_PADDING,
DEFAULT_FONT_SIZE,
EDITOR_LS_KEYS,
} from "../../constants";
import { convertToExcalidrawElements, exportToCanvas } from "../../index"; import { convertToExcalidrawElements, exportToCanvas } from "../../index";
import type { NonDeletedExcalidrawElement } from "../../element/types"; import type { NonDeletedExcalidrawElement } from "../../element/types";
import type { AppClassProperties, BinaryFiles } from "../../types"; import type { AppClassProperties, BinaryFiles } from "../../types";
@@ -34,7 +38,7 @@ export interface MermaidToExcalidrawLibProps {
api: Promise<{ api: Promise<{
parseMermaidToExcalidraw: ( parseMermaidToExcalidraw: (
definition: string, definition: string,
config?: MermaidConfig, options: MermaidOptions,
) => Promise<MermaidToExcalidrawResult>; ) => Promise<MermaidToExcalidrawResult>;
}>; }>;
} }
@@ -74,10 +78,15 @@ export const convertMermaidToExcalidraw = async ({
let ret; let ret;
try { try {
ret = await api.parseMermaidToExcalidraw(mermaidDefinition); ret = await api.parseMermaidToExcalidraw(mermaidDefinition, {
fontSize: DEFAULT_FONT_SIZE,
});
} catch (err: any) { } catch (err: any) {
ret = await api.parseMermaidToExcalidraw( ret = await api.parseMermaidToExcalidraw(
mermaidDefinition.replace(/"/g, "'"), mermaidDefinition.replace(/"/g, "'"),
{
fontSize: DEFAULT_FONT_SIZE,
},
); );
} }
const { elements, files } = ret; const { elements, files } = ret;
+64 -2
View File
@@ -5,11 +5,10 @@
--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;
} }
@@ -22,6 +21,10 @@
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(
@@ -154,7 +157,66 @@
} }
.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;
}
} }
} }

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