Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage
This commit is contained in:
commit
629cd307fd
@ -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_DISABLE_TRACKING=true
|
VITE_APP_ENABLE_TRACKING=true
|
||||||
|
|
||||||
FAST_REFRESH=false
|
FAST_REFRESH=false
|
||||||
|
|
||||||
|
@ -14,4 +14,4 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
|
|||||||
|
|
||||||
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||||
|
|
||||||
VITE_APP_DISABLE_TRACKING=
|
VITE_APP_ENABLE_TRACKING=false
|
||||||
|
9
.github/workflows/test.yml
vendored
9
.github/workflows/test.yml
vendored
@ -1,14 +1,17 @@
|
|||||||
name: Tests
|
name: Tests
|
||||||
|
|
||||||
on: pull_request
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- name: Setup Node.js 18.x
|
- name: Setup Node.js 18.x
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.x
|
node-version: 18.x
|
||||||
- name: Install and test
|
- name: Install and test
|
||||||
|
@ -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)} />;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -90,7 +90,7 @@ function App() {
|
|||||||
<img src={canvasUrl} alt="" />
|
<img src={canvasUrl} alt="" />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: "400px" }}>
|
<div style={{ height: "400px" }}>
|
||||||
<Excalidraw ref={(api) => setExcalidrawAPI(api)}
|
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -59,7 +59,7 @@ pre a {
|
|||||||
padding: 5px;
|
padding: 5px;
|
||||||
background: #70b1ec;
|
background: #70b1ec;
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: bold;
|
font-weight: 700;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -872,7 +872,7 @@ export default function App({
|
|||||||
files: excalidrawAPI.getFiles(),
|
files: excalidrawAPI.getFiles(),
|
||||||
});
|
});
|
||||||
const ctx = canvas.getContext("2d")!;
|
const ctx = canvas.getContext("2d")!;
|
||||||
ctx.font = "30px Virgil";
|
ctx.font = "30px Excalifont";
|
||||||
ctx.strokeText("My custom text", 50, 60);
|
ctx.strokeText("My custom text", 50, 60);
|
||||||
setCanvasUrl(canvas.toDataURL());
|
setCanvasUrl(canvas.toDataURL());
|
||||||
}}
|
}}
|
||||||
@ -893,7 +893,7 @@ export default function App({
|
|||||||
files: excalidrawAPI.getFiles(),
|
files: excalidrawAPI.getFiles(),
|
||||||
});
|
});
|
||||||
const ctx = canvas.getContext("2d")!;
|
const ctx = canvas.getContext("2d")!;
|
||||||
ctx.font = "30px Virgil";
|
ctx.font = "30px Excalifont";
|
||||||
ctx.strokeText("My custom text", 50, 60);
|
ctx.strokeText("My custom text", 50, 60);
|
||||||
setCanvasUrl(canvas.toDataURL());
|
setCanvasUrl(canvas.toDataURL());
|
||||||
}}
|
}}
|
||||||
|
@ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [
|
|||||||
];
|
];
|
||||||
export default {
|
export default {
|
||||||
elements,
|
elements,
|
||||||
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 },
|
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 },
|
||||||
scrollToContent: true,
|
scrollToContent: true,
|
||||||
libraryItems: [
|
libraryItems: [
|
||||||
[
|
[
|
||||||
|
3
examples/excalidraw/with-nextjs/.gitignore
vendored
3
examples/excalidraw/with-nextjs/.gitignore
vendored
@ -34,3 +34,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# copied assets
|
||||||
|
public/*.woff2
|
@ -3,7 +3,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm",
|
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
||||||
|
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
|
||||||
"dev": "yarn build:workspace && next dev -p 3005",
|
"dev": "yarn build:workspace && next dev -p 3005",
|
||||||
"build": "yarn build:workspace && next build",
|
"build": "yarn build:workspace && next build",
|
||||||
"start": "next start -p 3006",
|
"start": "next start -p 3006",
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import Script from "next/script";
|
||||||
import "../common.scss";
|
import "../common.scss";
|
||||||
|
|
||||||
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
|
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
|
||||||
@ -15,7 +16,9 @@ export default function Page() {
|
|||||||
<>
|
<>
|
||||||
<a href="/excalidraw-in-pages">Switch to Pages router</a>
|
<a href="/excalidraw-in-pages">Switch to Pages router</a>
|
||||||
<h1 className="page-title">App Router</h1>
|
<h1 className="page-title">App Router</h1>
|
||||||
|
<Script id="load-env-variables" strategy="beforeInteractive">
|
||||||
|
{`window["EXCALIDRAW_ASSET_PATH"] = window.origin;`}
|
||||||
|
</Script>
|
||||||
{/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */}
|
{/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */}
|
||||||
<ExcalidrawWithClientOnly />
|
<ExcalidrawWithClientOnly />
|
||||||
</>
|
</>
|
||||||
|
@ -7,7 +7,7 @@ a {
|
|||||||
color: #1c7ed6;
|
color: #1c7ed6;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 550;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
|
2
examples/excalidraw/with-script-in-browser/.gitignore
vendored
Normal file
2
examples/excalidraw/with-script-in-browser/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# copied assets
|
||||||
|
public/*.woff2
|
@ -11,6 +11,7 @@
|
|||||||
<title>React App</title>
|
<title>React App</title>
|
||||||
<script>
|
<script>
|
||||||
window.name = "codesandbox";
|
window.name = "codesandbox";
|
||||||
|
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="/dist/browser/dev/index.css" />
|
<link rel="stylesheet" href="/dist/browser/dev/index.css" />
|
||||||
</head>
|
</head>
|
||||||
|
@ -12,8 +12,10 @@
|
|||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "yarn workspace @excalidraw/excalidraw run build:esm && vite",
|
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
||||||
"build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build",
|
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
|
||||||
|
"start": "yarn build:workspace && vite",
|
||||||
|
"build": "yarn build:workspace && vite build",
|
||||||
"build:preview": "yarn build && vite preview --port 5002"
|
"build:preview": "yarn build && vite preview --port 5002"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import polyfill from "../packages/excalidraw/polyfill";
|
import polyfill from "../packages/excalidraw/polyfill";
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { trackEvent } from "../packages/excalidraw/analytics";
|
import { trackEvent } from "../packages/excalidraw/analytics";
|
||||||
import { getDefaultAppState } from "../packages/excalidraw/appState";
|
import { getDefaultAppState } from "../packages/excalidraw/appState";
|
||||||
@ -23,7 +22,6 @@ 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,
|
||||||
@ -94,7 +92,7 @@ import {
|
|||||||
import { AppMainMenu } from "./components/AppMainMenu";
|
import { AppMainMenu } from "./components/AppMainMenu";
|
||||||
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
||||||
import { AppFooter } from "./components/AppFooter";
|
import { AppFooter } from "./components/AppFooter";
|
||||||
import { atom, Provider, useAtom, useAtomValue } from "jotai";
|
import { Provider, useAtom, useAtomValue } from "jotai";
|
||||||
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
|
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
|
||||||
import { appJotaiStore } from "./app-jotai";
|
import { appJotaiStore } from "./app-jotai";
|
||||||
|
|
||||||
@ -122,11 +120,45 @@ import {
|
|||||||
youtubeIcon,
|
youtubeIcon,
|
||||||
} from "../packages/excalidraw/components/icons";
|
} from "../packages/excalidraw/components/icons";
|
||||||
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
||||||
|
import { getPreferredLanguage } from "./app-language/language-detector";
|
||||||
|
import { useAppLangCode } from "./app-language/language-state";
|
||||||
|
|
||||||
polyfill();
|
polyfill();
|
||||||
|
|
||||||
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface BeforeInstallPromptEventChoiceResult {
|
||||||
|
outcome: "accepted" | "dismissed";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt(): Promise<void>;
|
||||||
|
userChoice: Promise<BeforeInstallPromptEventChoiceResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindowEventMap {
|
||||||
|
beforeinstallprompt: BeforeInstallPromptEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pwaEvent: BeforeInstallPromptEvent | null = null;
|
||||||
|
|
||||||
|
// Adding a listener outside of the component as it may (?) need to be
|
||||||
|
// subscribed early to catch the event.
|
||||||
|
//
|
||||||
|
// Also note that it will fire only if certain heuristics are met (user has
|
||||||
|
// used the app for some time, etc.)
|
||||||
|
window.addEventListener(
|
||||||
|
"beforeinstallprompt",
|
||||||
|
(event: BeforeInstallPromptEvent) => {
|
||||||
|
// prevent Chrome <= 67 from automatically showing the prompt
|
||||||
|
event.preventDefault();
|
||||||
|
// cache for later use
|
||||||
|
pwaEvent = event;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
let isSelfEmbedding = false;
|
let isSelfEmbedding = false;
|
||||||
|
|
||||||
if (window.self !== window.top) {
|
if (window.self !== window.top) {
|
||||||
@ -141,11 +173,6 @@ if (window.self !== window.top) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const languageDetector = new LanguageDetector();
|
|
||||||
languageDetector.init({
|
|
||||||
languageUtils: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const shareableLinkConfirmDialog = {
|
const shareableLinkConfirmDialog = {
|
||||||
title: t("overwriteConfirm.modal.shareableLink.title"),
|
title: t("overwriteConfirm.modal.shareableLink.title"),
|
||||||
description: (
|
description: (
|
||||||
@ -291,19 +318,15 @@ 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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@ -461,11 +484,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
||||||
const localDataState = importFromLocalStorage();
|
const localDataState = importFromLocalStorage();
|
||||||
const username = importUsernameFromLocalStorage();
|
const username = importUsernameFromLocalStorage();
|
||||||
let langCode = languageDetector.detect() || defaultLang.code;
|
setLangCode(getPreferredLanguage());
|
||||||
if (Array.isArray(langCode)) {
|
|
||||||
langCode = langCode[0];
|
|
||||||
}
|
|
||||||
setLangCode(langCode);
|
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...localDataState,
|
...localDataState,
|
||||||
storeAction: StoreAction.UPDATE,
|
storeAction: StoreAction.UPDATE,
|
||||||
@ -566,10 +585,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
};
|
};
|
||||||
}, [excalidrawAPI]);
|
}, [excalidrawAPI]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
languageDetector.cacheUserLanguage(langCode);
|
|
||||||
}, [langCode]);
|
|
||||||
|
|
||||||
const onChange = (
|
const onChange = (
|
||||||
elements: readonly OrderedExcalidrawElement[],
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
@ -1103,6 +1118,21 @@ const ExcalidrawWrapper = () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("labels.installPWA"),
|
||||||
|
category: DEFAULT_CATEGORIES.app,
|
||||||
|
predicate: () => !!pwaEvent,
|
||||||
|
perform: () => {
|
||||||
|
if (pwaEvent) {
|
||||||
|
pwaEvent.prompt();
|
||||||
|
pwaEvent.userChoice.then(() => {
|
||||||
|
// event cannot be reused, but we'll hopefully
|
||||||
|
// grab new one as the event should be fired again
|
||||||
|
pwaEvent = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Excalidraw>
|
</Excalidraw>
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { appLangCodeAtom } from "../App";
|
import { useI18n, languages } from "../../packages/excalidraw/i18n";
|
||||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
import { appLangCodeAtom } from "./language-state";
|
||||||
import { languages } from "../../packages/excalidraw/i18n";
|
|
||||||
|
|
||||||
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
||||||
const { t, langCode } = useI18n();
|
const { t, langCode } = useI18n();
|
25
excalidraw-app/app-language/language-detector.ts
Normal file
25
excalidraw-app/app-language/language-detector.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
|
import { defaultLang, languages } from "../../packages/excalidraw";
|
||||||
|
|
||||||
|
export const languageDetector = new LanguageDetector();
|
||||||
|
|
||||||
|
languageDetector.init({
|
||||||
|
languageUtils: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getPreferredLanguage = () => {
|
||||||
|
const detectedLanguages = languageDetector.detect();
|
||||||
|
|
||||||
|
const detectedLanguage = Array.isArray(detectedLanguages)
|
||||||
|
? detectedLanguages[0]
|
||||||
|
: detectedLanguages;
|
||||||
|
|
||||||
|
const initialLanguage =
|
||||||
|
(detectedLanguage
|
||||||
|
? // region code may not be defined if user uses generic preferred language
|
||||||
|
// (e.g. chinese vs instead of chinese-simplified)
|
||||||
|
languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code
|
||||||
|
: null) || defaultLang.code;
|
||||||
|
|
||||||
|
return initialLanguage;
|
||||||
|
};
|
15
excalidraw-app/app-language/language-state.ts
Normal file
15
excalidraw-app/app-language/language-state.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { getPreferredLanguage, languageDetector } from "./language-detector";
|
||||||
|
|
||||||
|
export const appLangCodeAtom = atom(getPreferredLanguage());
|
||||||
|
|
||||||
|
export const useAppLangCode = () => {
|
||||||
|
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
languageDetector.cacheUserLanguage(langCode);
|
||||||
|
}, [langCode]);
|
||||||
|
|
||||||
|
return [langCode, setLangCode] as const;
|
||||||
|
};
|
@ -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 "./LanguageList";
|
import { LanguageList } from "../app-language/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_APP
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
|
||||||
className=""
|
className=""
|
||||||
>
|
>
|
||||||
|
@ -20,7 +20,7 @@
|
|||||||
name="description"
|
name="description"
|
||||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||||
/>
|
/>
|
||||||
<meta name="image" content="https://excalidraw.com/og-image-2.png" />
|
<meta name="image" content="https://excalidraw.com/og-image-3.png" />
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:site_name" content="Excalidraw" />
|
<meta property="og:site_name" content="Excalidraw" />
|
||||||
@ -35,7 +35,7 @@
|
|||||||
property="og:description"
|
property="og:description"
|
||||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||||
/>
|
/>
|
||||||
<meta property="og:image" content="https://excalidraw.com/og-image-2.png" />
|
<meta property="og:image" content="https://excalidraw.com/og-image-3.png" />
|
||||||
|
|
||||||
<!-- Twitter -->
|
<!-- Twitter -->
|
||||||
<meta property="twitter:card" content="summary_large_image" />
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
@ -51,7 +51,7 @@
|
|||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
property="twitter:image"
|
property="twitter:image"
|
||||||
content="https://excalidraw.com/og-twitter-v2.png"
|
content="https://excalidraw.com/og-image-3.png"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- General tags -->
|
<!-- General tags -->
|
||||||
@ -114,6 +114,14 @@
|
|||||||
) {
|
) {
|
||||||
window.location.href = "https://app.excalidraw.com";
|
window.location.href = "https://app.excalidraw.com";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// point into our CDN in prod
|
||||||
|
window.EXCALIDRAW_ASSET_PATH =
|
||||||
|
"https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/";
|
||||||
|
</script>
|
||||||
|
<% } else { %>
|
||||||
|
<script>
|
||||||
|
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
||||||
</script>
|
</script>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
@ -124,22 +132,74 @@
|
|||||||
<!-- Excalidraw version -->
|
<!-- Excalidraw version -->
|
||||||
<meta name="version" content="{version}" />
|
<meta name="version" content="{version}" />
|
||||||
|
|
||||||
|
<!-- Warmup the connection for Google fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|
||||||
|
<!-- Preload all default fonts and Virgil for backwards compatibility to avoid swap on init -->
|
||||||
|
<% if (typeof PROD != 'undefined' && PROD == true) { %>
|
||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
href="/Virgil.woff2"
|
href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/Excalifont-Regular-C9eKQy_N.woff2"
|
||||||
as="font"
|
as="font"
|
||||||
type="font/woff2"
|
type="font/woff2"
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
/>
|
/>
|
||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
href="/Cascadia.woff2"
|
href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/Virgil-Regular-hO16qHwV.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/ComicShanns-Regular-D0c8wzsC.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
<% } else { %>
|
||||||
|
<!-- 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 enough for now -->
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
|
||||||
as="font"
|
as="font"
|
||||||
type="font/woff2"
|
type="font/woff2"
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
|
<!-- Register Assistant as the UI font, before the scene inits -->
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="../packages/excalidraw/fonts/assets/fonts.css"
|
||||||
|
type="text/css"
|
||||||
|
/>
|
||||||
|
|
||||||
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
|
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
|
||||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
|
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
|
||||||
<script>
|
<script>
|
||||||
@ -158,7 +218,6 @@
|
|||||||
</script>
|
</script>
|
||||||
<% } %>
|
<% } %>
|
||||||
<script>
|
<script>
|
||||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
|
||||||
// setting this so that libraries installation reuses this window tab.
|
// setting this so that libraries installation reuses this window tab.
|
||||||
window.name = "_excalidraw";
|
window.name = "_excalidraw";
|
||||||
</script>
|
</script>
|
||||||
|
@ -25,6 +25,7 @@
|
|||||||
margin-bottom: auto;
|
margin-bottom: auto;
|
||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
margin-inline-end: 0.6em;
|
margin-inline-end: 0.6em;
|
||||||
|
z-index: var(--zIndex-layerUI);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 1.2rem;
|
width: 1.2rem;
|
||||||
|
@ -31,12 +31,13 @@
|
|||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
|
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
|
||||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
|
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
|
||||||
"build:version": "node ../scripts/build-version.js",
|
"build:version": "node ../scripts/build-version.js",
|
||||||
"build": "yarn build:app && yarn build:version",
|
"build": "yarn build:app && yarn build:version",
|
||||||
"start": "yarn && vite",
|
"start": "yarn && vite",
|
||||||
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
"start:production": "yarn build && yarn serve",
|
||||||
|
"serve": "npx http-server build -a localhost -p 5001 -o",
|
||||||
"build:preview": "yarn build && vite preview --port 5000"
|
"build:preview": "yarn build && vite preview --port 5000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
|||||||
class="welcome-screen-center"
|
class="welcome-screen-center"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="welcome-screen-center__logo virgil welcome-screen-decor"
|
class="welcome-screen-center__logo excalifont welcome-screen-decor"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="ExcalidrawLogo is-small"
|
class="ExcalidrawLogo is-small"
|
||||||
@ -48,7 +48,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="welcome-screen-center__heading welcome-screen-decor virgil"
|
class="welcome-screen-center__heading welcome-screen-decor excalifont"
|
||||||
>
|
>
|
||||||
All your data is saved locally in your browser.
|
All your data is saved locally in your browser.
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,6 +5,7 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
|
|||||||
import { VitePWA } from "vite-plugin-pwa";
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
import checker from "vite-plugin-checker";
|
import checker from "vite-plugin-checker";
|
||||||
import { createHtmlPlugin } from "vite-plugin-html";
|
import { createHtmlPlugin } from "vite-plugin-html";
|
||||||
|
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
|
||||||
|
|
||||||
// To load .env.local variables
|
// To load .env.local variables
|
||||||
const envVars = loadEnv("", `../`);
|
const envVars = loadEnv("", `../`);
|
||||||
@ -22,6 +23,14 @@ export default defineConfig({
|
|||||||
outDir: "build",
|
outDir: "build",
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
|
assetFileNames(chunkInfo) {
|
||||||
|
if (chunkInfo?.name?.endsWith(".woff2")) {
|
||||||
|
// put on root so we are flexible about the CDN path
|
||||||
|
return '[name]-[hash][extname]';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'assets/[name]-[hash][extname]';
|
||||||
|
},
|
||||||
// Creating separate chunk for locales except for en and percentages.json so they
|
// Creating separate chunk for locales except for en and percentages.json so they
|
||||||
// can be cached at runtime and not merged with
|
// can be cached at runtime and not merged with
|
||||||
// app precache. en.json and percentages.json are needed for first load
|
// app precache. en.json and percentages.json are needed for first load
|
||||||
@ -35,12 +44,13 @@ export default defineConfig({
|
|||||||
// Taking the substring after "locales/"
|
// Taking the substring after "locales/"
|
||||||
return `locales/${id.substring(index + 8)}`;
|
return `locales/${id.substring(index + 8)}`;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
woff2BrowserPlugin(),
|
||||||
react(),
|
react(),
|
||||||
checker({
|
checker({
|
||||||
typescript: true,
|
typescript: true,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"name": "excalidraw-monorepo",
|
"name": "excalidraw-monorepo",
|
||||||
|
"packageManager": "yarn@1.22.22",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"excalidraw-app",
|
"excalidraw-app",
|
||||||
"packages/excalidraw",
|
"packages/excalidraw",
|
||||||
|
@ -15,8 +15,12 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
|
||||||
|
|
||||||
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
|
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
|
||||||
|
|
||||||
|
- Added font picker component to have the ability to choose from a range of different fonts. Also, changed the default fonts to `Excalifont`, `Nunito` and `Comic Shanns` and deprecated `Virgil`, `Helvetica` and `Cascadia`.
|
||||||
|
|
||||||
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
|
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
|
||||||
|
|
||||||
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
BOUND_TEXT_PADDING,
|
BOUND_TEXT_PADDING,
|
||||||
ROUNDNESS,
|
ROUNDNESS,
|
||||||
VERTICAL_ALIGN,
|
|
||||||
TEXT_ALIGN,
|
TEXT_ALIGN,
|
||||||
|
VERTICAL_ALIGN,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { isTextElement, newElement } from "../element";
|
import { isTextElement, newElement } from "../element";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
@ -140,6 +140,7 @@ export const actionBindText = register({
|
|||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||||
textAlign: TEXT_ALIGN.CENTER,
|
textAlign: TEXT_ALIGN.CENTER,
|
||||||
|
autoResize: true,
|
||||||
});
|
});
|
||||||
mutateElement(container, {
|
mutateElement(container, {
|
||||||
boundElements: (container.boundElements || []).concat({
|
boundElements: (container.boundElements || []).concat({
|
||||||
@ -294,6 +295,7 @@ export const actionWrapTextInContainer = register({
|
|||||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||||
boundElements: null,
|
boundElements: null,
|
||||||
textAlign: TEXT_ALIGN.CENTER,
|
textAlign: TEXT_ALIGN.CENTER,
|
||||||
|
autoResize: true,
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
@ -104,7 +104,7 @@ export const actionClearCanvas = register({
|
|||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
exportEmbedScene: appState.exportEmbedScene,
|
exportEmbedScene: appState.exportEmbedScene,
|
||||||
gridSize: appState.gridSize,
|
gridSize: appState.gridSize,
|
||||||
showStats: appState.showStats,
|
stats: appState.stats,
|
||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
activeTool:
|
activeTool:
|
||||||
appState.activeTool.type === "image"
|
appState.activeTool.type === "image"
|
||||||
|
@ -131,7 +131,12 @@ export const actionFinalize = register({
|
|||||||
-1,
|
-1,
|
||||||
arrayToMap(elements),
|
arrayToMap(elements),
|
||||||
);
|
);
|
||||||
maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
|
maybeBindLinearElement(
|
||||||
|
multiPointElement,
|
||||||
|
appState,
|
||||||
|
{ x, y },
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,7 +124,7 @@ const flipElements = (
|
|||||||
|
|
||||||
bindOrUnbindLinearElements(
|
bindOrUnbindLinearElements(
|
||||||
selectedElements.filter(isLinearElement),
|
selectedElements.filter(isLinearElement),
|
||||||
app,
|
elementsMap,
|
||||||
isBindingEnabled(appState),
|
isBindingEnabled(appState),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
@ -65,7 +65,10 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||||||
PanelComponent: ({ updateData, data }) => {
|
PanelComponent: ({ updateData, data }) => {
|
||||||
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
||||||
history.onHistoryChangedEmitter,
|
history.onHistoryChangedEmitter,
|
||||||
new HistoryChangedEvent(),
|
new HistoryChangedEvent(
|
||||||
|
history.isUndoStackEmpty,
|
||||||
|
history.isRedoStackEmpty,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -76,6 +79,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
size={data?.size || "medium"}
|
size={data?.size || "medium"}
|
||||||
disabled={isUndoStackEmpty}
|
disabled={isUndoStackEmpty}
|
||||||
|
data-testid="button-undo"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -103,7 +107,10 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
|||||||
PanelComponent: ({ updateData, data }) => {
|
PanelComponent: ({ updateData, data }) => {
|
||||||
const { isRedoStackEmpty } = useEmitter(
|
const { isRedoStackEmpty } = useEmitter(
|
||||||
history.onHistoryChangedEmitter,
|
history.onHistoryChangedEmitter,
|
||||||
new HistoryChangedEvent(),
|
new HistoryChangedEvent(
|
||||||
|
history.isUndoStackEmpty,
|
||||||
|
history.isRedoStackEmpty,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -114,6 +121,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
|||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
size={data?.size || "medium"}
|
size={data?.size || "medium"}
|
||||||
disabled={isRedoStackEmpty}
|
disabled={isRedoStackEmpty}
|
||||||
|
data-testid="button-redo"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -155,13 +155,15 @@ describe("element locking", () => {
|
|||||||
});
|
});
|
||||||
const text = API.createElement({
|
const text = API.createElement({
|
||||||
type: "text",
|
type: "text",
|
||||||
fontFamily: FONT_FAMILY.Cascadia,
|
fontFamily: FONT_FAMILY["Comic Shanns"],
|
||||||
});
|
});
|
||||||
h.elements = [rect, text];
|
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`)).toBeChecked();
|
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
|
||||||
|
"active",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||||
|
import type { StoreActionType } from "../store";
|
||||||
import {
|
import {
|
||||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||||
@ -9,6 +11,7 @@ import { trackEvent } from "../analytics";
|
|||||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||||
import { IconPicker } from "../components/IconPicker";
|
import { IconPicker } from "../components/IconPicker";
|
||||||
|
import { FontPicker } from "../components/FontPicker/FontPicker";
|
||||||
// TODO barnabasmolnar/editor-redesign
|
// TODO barnabasmolnar/editor-redesign
|
||||||
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
|
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
|
||||||
// ArrowHead icons
|
// ArrowHead icons
|
||||||
@ -38,9 +41,6 @@ import {
|
|||||||
FontSizeExtraLargeIcon,
|
FontSizeExtraLargeIcon,
|
||||||
EdgeSharpIcon,
|
EdgeSharpIcon,
|
||||||
EdgeRoundIcon,
|
EdgeRoundIcon,
|
||||||
FreedrawIcon,
|
|
||||||
FontFamilyNormalIcon,
|
|
||||||
FontFamilyCodeIcon,
|
|
||||||
TextAlignLeftIcon,
|
TextAlignLeftIcon,
|
||||||
TextAlignCenterIcon,
|
TextAlignCenterIcon,
|
||||||
TextAlignRightIcon,
|
TextAlignRightIcon,
|
||||||
@ -65,10 +65,7 @@ import {
|
|||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
import {
|
import { getBoundTextElement } from "../element/textElement";
|
||||||
getBoundTextElement,
|
|
||||||
getDefaultLineHeight,
|
|
||||||
} from "../element/textElement";
|
|
||||||
import {
|
import {
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
@ -94,9 +91,10 @@ import {
|
|||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { hasStrokeColor } from "../scene/comparisons";
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { arrayToMap, getFontFamilyString, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
import { Fonts, getLineHeight } from "../fonts";
|
||||||
|
|
||||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||||
|
|
||||||
@ -167,7 +165,7 @@ const offsetElementAfterFontResize = (
|
|||||||
prevElement: ExcalidrawTextElement,
|
prevElement: ExcalidrawTextElement,
|
||||||
nextElement: ExcalidrawTextElement,
|
nextElement: ExcalidrawTextElement,
|
||||||
) => {
|
) => {
|
||||||
if (isBoundToContainer(nextElement)) {
|
if (isBoundToContainer(nextElement) || !nextElement.autoResize) {
|
||||||
return nextElement;
|
return nextElement;
|
||||||
}
|
}
|
||||||
return mutateElement(
|
return mutateElement(
|
||||||
@ -729,104 +727,391 @@ export const actionIncreaseFontSize = register({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type ChangeFontFamilyData = Partial<
|
||||||
|
Pick<
|
||||||
|
AppState,
|
||||||
|
"openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily"
|
||||||
|
>
|
||||||
|
> & {
|
||||||
|
/** cache of selected & editing elements populated on opened popup */
|
||||||
|
cachedElements?: Map<string, ExcalidrawElement>;
|
||||||
|
/** flag to reset all elements to their cached versions */
|
||||||
|
resetAll?: true;
|
||||||
|
/** flag to reset all containers to their cached versions */
|
||||||
|
resetContainers?: true;
|
||||||
|
};
|
||||||
|
|
||||||
export const actionChangeFontFamily = register({
|
export const actionChangeFontFamily = register({
|
||||||
name: "changeFontFamily",
|
name: "changeFontFamily",
|
||||||
label: "labels.fontFamily",
|
label: "labels.fontFamily",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return {
|
const { cachedElements, resetAll, resetContainers, ...nextAppState } =
|
||||||
elements: changeProperty(
|
value as ChangeFontFamilyData;
|
||||||
|
|
||||||
|
if (resetAll) {
|
||||||
|
const nextElements = changeProperty(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
(oldElement) => {
|
(element) => {
|
||||||
if (isTextElement(oldElement)) {
|
const cachedElement = cachedElements?.get(element.id);
|
||||||
const newElement: ExcalidrawTextElement = newElementWith(
|
if (cachedElement) {
|
||||||
oldElement,
|
const newElement = newElementWith(element, {
|
||||||
{
|
...cachedElement,
|
||||||
fontFamily: value,
|
});
|
||||||
lineHeight: getDefaultLineHeight(value),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
redrawTextBoundingBox(
|
|
||||||
newElement,
|
|
||||||
app.scene.getContainerElement(oldElement),
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
return oldElement;
|
return element;
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
),
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements: nextElements,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
...nextAppState,
|
||||||
|
},
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentItemFontFamily, currentHoveredFontFamily } = value;
|
||||||
|
|
||||||
|
let nexStoreAction: StoreActionType = StoreAction.NONE;
|
||||||
|
let nextFontFamily: FontFamilyValues | undefined;
|
||||||
|
let skipOnHoverRender = false;
|
||||||
|
|
||||||
|
if (currentItemFontFamily) {
|
||||||
|
nextFontFamily = currentItemFontFamily;
|
||||||
|
nexStoreAction = StoreAction.CAPTURE;
|
||||||
|
} else if (currentHoveredFontFamily) {
|
||||||
|
nextFontFamily = currentHoveredFontFamily;
|
||||||
|
nexStoreAction = StoreAction.NONE;
|
||||||
|
|
||||||
|
const selectedTextElements = getSelectedElements(elements, appState, {
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
}).filter((element) => isTextElement(element));
|
||||||
|
|
||||||
|
// skip on hover re-render for more than 200 text elements or for text element with more than 5000 chars combined
|
||||||
|
if (selectedTextElements.length > 200) {
|
||||||
|
skipOnHoverRender = true;
|
||||||
|
} else {
|
||||||
|
let i = 0;
|
||||||
|
let textLengthAccumulator = 0;
|
||||||
|
|
||||||
|
while (
|
||||||
|
i < selectedTextElements.length &&
|
||||||
|
textLengthAccumulator < 5000
|
||||||
|
) {
|
||||||
|
const textElement = selectedTextElements[i] as ExcalidrawTextElement;
|
||||||
|
textLengthAccumulator += textElement?.originalText.length || 0;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textLengthAccumulator > 5000) {
|
||||||
|
skipOnHoverRender = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
currentItemFontFamily: value,
|
...nextAppState,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: nexStoreAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (nextFontFamily && !skipOnHoverRender) {
|
||||||
|
const elementContainerMapping = new Map<
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
ExcalidrawElement | null
|
||||||
|
>();
|
||||||
|
let uniqueGlyphs = 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) {
|
||||||
|
uniqueGlyphs = new Set([
|
||||||
|
...uniqueGlyphs,
|
||||||
|
...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 glyphs = Array.from(uniqueGlyphs.values()).join();
|
||||||
|
|
||||||
|
if (
|
||||||
|
skipFontFaceCheck ||
|
||||||
|
window.document.fonts.check(fontString, glyphs)
|
||||||
|
) {
|
||||||
|
// 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 glyphs and redraw elements once our font faces loaded
|
||||||
|
window.document.fonts.load(fontString, glyphs).then((fontFaces) => {
|
||||||
|
for (const [element, container] of elementContainerMapping) {
|
||||||
|
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
|
||||||
|
const latestElement = app.scene.getElement(element.id);
|
||||||
|
const latestContainer = container
|
||||||
|
? app.scene.getElement(container.id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (latestElement) {
|
||||||
|
// trigger async redraw
|
||||||
|
redrawTextBoundingBox(
|
||||||
|
latestElement as ExcalidrawTextElement,
|
||||||
|
latestContainer,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trigger update once we've mutated all the elements, which also updates our cache
|
||||||
|
app.fonts.onLoaded(fontFaces);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, app, updateData }) => {
|
||||||
const options: {
|
const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map());
|
||||||
value: FontFamilyValues;
|
const prevSelectedFontFamilyRef = useRef<number | null>(null);
|
||||||
text: string;
|
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
|
||||||
icon: JSX.Element;
|
const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
|
||||||
testId: string;
|
const isUnmounted = useRef(true);
|
||||||
}[] = [
|
|
||||||
{
|
const selectedFontFamily = useMemo(() => {
|
||||||
value: FONT_FAMILY.Virgil,
|
const getFontFamily = (
|
||||||
text: t("labels.handDrawn"),
|
elementsArray: readonly ExcalidrawElement[],
|
||||||
icon: FreedrawIcon,
|
elementsMap: Map<string, ExcalidrawElement>,
|
||||||
testId: "font-family-virgil",
|
) =>
|
||||||
},
|
getFormValue(
|
||||||
{
|
elementsArray,
|
||||||
value: FONT_FAMILY.Helvetica,
|
appState,
|
||||||
text: t("labels.normal"),
|
(element) => {
|
||||||
icon: FontFamilyNormalIcon,
|
if (isTextElement(element)) {
|
||||||
testId: "font-family-normal",
|
return element.fontFamily;
|
||||||
},
|
}
|
||||||
{
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
value: FONT_FAMILY.Cascadia,
|
if (boundTextElement) {
|
||||||
text: t("labels.code"),
|
return boundTextElement.fontFamily;
|
||||||
icon: FontFamilyCodeIcon,
|
}
|
||||||
testId: "font-family-code",
|
return null;
|
||||||
},
|
},
|
||||||
];
|
(element) =>
|
||||||
|
isTextElement(element) ||
|
||||||
|
getBoundTextElement(element, elementsMap) !== null,
|
||||||
|
(hasSelection) =>
|
||||||
|
hasSelection
|
||||||
|
? null
|
||||||
|
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
||||||
|
);
|
||||||
|
|
||||||
|
// popup opened, use cached elements
|
||||||
|
if (
|
||||||
|
batchedData.openPopup === "fontFamily" &&
|
||||||
|
appState.openPopup === "fontFamily"
|
||||||
|
) {
|
||||||
|
return getFontFamily(
|
||||||
|
Array.from(cachedElementsRef.current?.values() ?? []),
|
||||||
|
cachedElementsRef.current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// popup closed, use all elements
|
||||||
|
if (!batchedData.openPopup && appState.openPopup !== "fontFamily") {
|
||||||
|
return getFontFamily(elements, app.scene.getNonDeletedElementsMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had
|
||||||
|
return prevSelectedFontFamilyRef.current;
|
||||||
|
}, [batchedData.openPopup, appState, elements, app.scene]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
prevSelectedFontFamilyRef.current = selectedFontFamily;
|
||||||
|
}, [selectedFontFamily]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Object.keys(batchedData).length) {
|
||||||
|
updateData(batchedData);
|
||||||
|
// reset the data after we've used the data
|
||||||
|
setBatchedData({});
|
||||||
|
}
|
||||||
|
// call update only on internal state changes
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [batchedData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isUnmounted.current = false;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isUnmounted.current = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.fontFamily")}</legend>
|
<legend>{t("labels.fontFamily")}</legend>
|
||||||
<ButtonIconSelect<FontFamilyValues | false>
|
<FontPicker
|
||||||
group="font-family"
|
isOpened={appState.openPopup === "fontFamily"}
|
||||||
options={options}
|
selectedFontFamily={selectedFontFamily}
|
||||||
value={getFormValue(
|
hoveredFontFamily={appState.currentHoveredFontFamily}
|
||||||
elements,
|
onSelect={(fontFamily) => {
|
||||||
appState,
|
setBatchedData({
|
||||||
(element) => {
|
openPopup: null,
|
||||||
if (isTextElement(element)) {
|
currentHoveredFontFamily: null,
|
||||||
return element.fontFamily;
|
currentItemFontFamily: fontFamily,
|
||||||
|
});
|
||||||
|
|
||||||
|
// defensive clear so immediate close won't abuse the cached elements
|
||||||
|
cachedElementsRef.current.clear();
|
||||||
|
}}
|
||||||
|
onHover={(fontFamily) => {
|
||||||
|
setBatchedData({
|
||||||
|
currentHoveredFontFamily: fontFamily,
|
||||||
|
cachedElements: new Map(cachedElementsRef.current),
|
||||||
|
resetContainers: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onLeave={() => {
|
||||||
|
setBatchedData({
|
||||||
|
currentHoveredFontFamily: null,
|
||||||
|
cachedElements: new Map(cachedElementsRef.current),
|
||||||
|
resetAll: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onPopupChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
// open, populate the cache from scratch
|
||||||
|
cachedElementsRef.current.clear();
|
||||||
|
|
||||||
|
const { editingElement } = appState;
|
||||||
|
|
||||||
|
if (editingElement?.type === "text") {
|
||||||
|
// retrieve the latest version from the scene, as `editingElement` isn't mutated
|
||||||
|
const latestEditingElement = app.scene.getElement(
|
||||||
|
editingElement.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// inside the wysiwyg editor
|
||||||
|
cachedElementsRef.current.set(
|
||||||
|
editingElement.id,
|
||||||
|
newElementWith(
|
||||||
|
latestEditingElement || editingElement,
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
{
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const element of selectedElements) {
|
||||||
|
cachedElementsRef.current.set(
|
||||||
|
element.id,
|
||||||
|
newElementWith(element, {}, true),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const boundTextElement = getBoundTextElement(
|
|
||||||
element,
|
setBatchedData({
|
||||||
app.scene.getNonDeletedElementsMap(),
|
openPopup: "fontFamily",
|
||||||
);
|
});
|
||||||
if (boundTextElement) {
|
} else {
|
||||||
return boundTextElement.fontFamily;
|
// close, use the cache and clear it afterwards
|
||||||
|
const data = {
|
||||||
|
openPopup: null,
|
||||||
|
currentHoveredFontFamily: null,
|
||||||
|
cachedElements: new Map(cachedElementsRef.current),
|
||||||
|
resetAll: true,
|
||||||
|
} as ChangeFontFamilyData;
|
||||||
|
|
||||||
|
if (isUnmounted.current) {
|
||||||
|
// in case the component was unmounted by the parent, trigger the update directly
|
||||||
|
updateData({ ...batchedData, ...data });
|
||||||
|
} else {
|
||||||
|
setBatchedData(data);
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
},
|
cachedElementsRef.current.clear();
|
||||||
(element) =>
|
}
|
||||||
isTextElement(element) ||
|
}}
|
||||||
getBoundTextElement(
|
|
||||||
element,
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
) !== null,
|
|
||||||
(hasSelection) =>
|
|
||||||
hasSelection
|
|
||||||
? null
|
|
||||||
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
|
||||||
)}
|
|
||||||
onChange={(value) => updateData(value)}
|
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
|
@ -12,10 +12,7 @@ import {
|
|||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import {
|
import { getBoundTextElement } from "../element/textElement";
|
||||||
getBoundTextElement,
|
|
||||||
getDefaultLineHeight,
|
|
||||||
} from "../element/textElement";
|
|
||||||
import {
|
import {
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
canApplyRoundnessTypeToElement,
|
canApplyRoundnessTypeToElement,
|
||||||
@ -27,6 +24,7 @@ import { getSelectedElements } from "../scene";
|
|||||||
import type { ExcalidrawTextElement } from "../element/types";
|
import type { ExcalidrawTextElement } from "../element/types";
|
||||||
import { paintIcon } from "../components/icons";
|
import { paintIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
import { getLineHeight } from "../fonts";
|
||||||
|
|
||||||
// `copiedStyles` is exported only for tests.
|
// `copiedStyles` is exported only for tests.
|
||||||
export let copiedStyles: string = "{}";
|
export let copiedStyles: string = "{}";
|
||||||
@ -122,7 +120,7 @@ export const actionPasteStyles = register({
|
|||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
lineHeight:
|
lineHeight:
|
||||||
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
|
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
|
||||||
getDefaultLineHeight(fontFamily),
|
getLineHeight(fontFamily),
|
||||||
});
|
});
|
||||||
let container = null;
|
let container = null;
|
||||||
if (newElement.containerId) {
|
if (newElement.containerId) {
|
||||||
|
48
packages/excalidraw/actions/actionTextAutoResize.ts
Normal file
48
packages/excalidraw/actions/actionTextAutoResize.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { isTextElement } from "../element";
|
||||||
|
import { newElementWith } from "../element/mutateElement";
|
||||||
|
import { measureText } from "../element/textElement";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
import type { AppClassProperties } from "../types";
|
||||||
|
import { getFontString } from "../utils";
|
||||||
|
import { register } from "./register";
|
||||||
|
|
||||||
|
export const actionTextAutoResize = register({
|
||||||
|
name: "autoResize",
|
||||||
|
label: "labels.autoResize",
|
||||||
|
icon: null,
|
||||||
|
trackEvent: { category: "element" },
|
||||||
|
predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
return (
|
||||||
|
selectedElements.length === 1 &&
|
||||||
|
isTextElement(selectedElements[0]) &&
|
||||||
|
!selectedElements[0].autoResize
|
||||||
|
);
|
||||||
|
},
|
||||||
|
perform: (elements, appState, _, app) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState,
|
||||||
|
elements: elements.map((element) => {
|
||||||
|
if (element.id === selectedElements[0].id && isTextElement(element)) {
|
||||||
|
const metrics = measureText(
|
||||||
|
element.originalText,
|
||||||
|
getFontString(element),
|
||||||
|
element.lineHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
return newElementWith(element, {
|
||||||
|
autoResize: true,
|
||||||
|
width: metrics.width,
|
||||||
|
height: metrics.height,
|
||||||
|
text: element.originalText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}),
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
@ -5,21 +5,22 @@ import { StoreAction } from "../store";
|
|||||||
|
|
||||||
export const actionToggleStats = register({
|
export const actionToggleStats = register({
|
||||||
name: "stats",
|
name: "stats",
|
||||||
label: "stats.title",
|
label: "stats.fullTitle",
|
||||||
icon: abacusIcon,
|
icon: abacusIcon,
|
||||||
paletteName: "Toggle stats",
|
paletteName: "Toggle stats",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "menu" },
|
trackEvent: { category: "menu" },
|
||||||
|
keywords: ["edit", "attributes", "customize"],
|
||||||
perform(elements, appState) {
|
perform(elements, appState) {
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
showStats: !this.checked!(appState),
|
stats: { ...appState.stats, open: !this.checked!(appState) },
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState) => appState.showStats,
|
checked: (appState) => appState.stats.open,
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
||||||
});
|
});
|
||||||
|
@ -148,7 +148,9 @@ export type ActionName =
|
|||||||
| "setEmbeddableAsActiveTool"
|
| "setEmbeddableAsActiveTool"
|
||||||
| "createContainerFromText"
|
| "createContainerFromText"
|
||||||
| "wrapTextInContainer"
|
| "wrapTextInContainer"
|
||||||
| "commandPalette";
|
| "commandPalette"
|
||||||
|
| "autoResize"
|
||||||
|
| "elementStats";
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
// place here categories that you want to track. We want to track just a
|
// place here categories that you want to track. We want to track just a
|
||||||
// small subset of categories at a given time.
|
// small subset of categories at a given time.
|
||||||
const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
|
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
|
||||||
|
|
||||||
export const trackEvent = (
|
export const trackEvent = (
|
||||||
category: string,
|
category: string,
|
||||||
@ -9,17 +9,20 @@ export const trackEvent = (
|
|||||||
value?: number,
|
value?: number,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
// prettier-ignore
|
|
||||||
if (
|
if (
|
||||||
typeof window === "undefined"
|
typeof window === "undefined" ||
|
||||||
|| import.meta.env.VITE_WORKER_ID
|
import.meta.env.VITE_WORKER_ID ||
|
||||||
// comment out to debug locally
|
import.meta.env.VITE_APP_ENABLE_TRACKING !== "true"
|
||||||
|| import.meta.env.PROD
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
|
if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
// comment out to debug in dev
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ import {
|
|||||||
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";
|
||||||
@ -35,6 +36,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||||
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||||
|
currentHoveredFontFamily: null,
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
draggingElement: null,
|
draggingElement: null,
|
||||||
@ -80,7 +82,10 @@ export const getDefaultAppState = (): Omit<
|
|||||||
selectedElementsAreBeingDragged: false,
|
selectedElementsAreBeingDragged: false,
|
||||||
selectionElement: null,
|
selectionElement: null,
|
||||||
shouldCacheIgnoreZoom: false,
|
shouldCacheIgnoreZoom: false,
|
||||||
showStats: false,
|
stats: {
|
||||||
|
open: false,
|
||||||
|
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
|
||||||
|
},
|
||||||
startBoundElement: null,
|
startBoundElement: null,
|
||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
||||||
@ -145,6 +150,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||||
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||||
currentItemTextAlign: { browser: true, export: false, server: false },
|
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||||
|
currentHoveredFontFamily: { browser: false, export: false, server: false },
|
||||||
cursorButton: { browser: true, export: false, server: false },
|
cursorButton: { browser: true, export: false, server: false },
|
||||||
activeEmbeddable: { browser: false, export: false, server: false },
|
activeEmbeddable: { browser: false, export: false, server: false },
|
||||||
draggingElement: { browser: false, export: false, server: false },
|
draggingElement: { browser: false, export: false, server: false },
|
||||||
@ -198,7 +204,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
},
|
},
|
||||||
selectionElement: { browser: false, export: false, server: false },
|
selectionElement: { browser: false, export: false, server: false },
|
||||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||||
showStats: { browser: true, export: false, server: false },
|
stats: { browser: true, export: false, server: false },
|
||||||
startBoundElement: { browser: false, export: false, server: false },
|
startBoundElement: { browser: false, export: false, server: false },
|
||||||
suggestedBindings: { browser: false, export: false, server: false },
|
suggestedBindings: { browser: false, export: false, server: false },
|
||||||
frameRendering: { browser: false, export: false, server: false },
|
frameRendering: { browser: false, export: false, server: false },
|
||||||
|
@ -1477,19 +1477,28 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
return elements;
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previous = Array.from(elements.values());
|
const unordered = Array.from(elements.values());
|
||||||
const reordered = orderByFractionalIndex([...previous]);
|
const ordered = orderByFractionalIndex([...unordered]);
|
||||||
|
const moved = Delta.getRightDifferences(unordered, ordered, true).reduce(
|
||||||
|
(acc, arrayIndex) => {
|
||||||
|
const candidate = unordered[Number(arrayIndex)];
|
||||||
|
if (candidate && changed.has(candidate.id)) {
|
||||||
|
acc.set(candidate.id, candidate);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
return acc;
|
||||||
!flags.containsVisibleDifference &&
|
},
|
||||||
Delta.isRightDifferent(previous, reordered, true)
|
new Map(),
|
||||||
) {
|
);
|
||||||
|
|
||||||
|
if (!flags.containsVisibleDifference && moved.size) {
|
||||||
// we found a difference in order!
|
// we found a difference in order!
|
||||||
flags.containsVisibleDifference = true;
|
flags.containsVisibleDifference = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// let's synchronize all invalid indices of moved elements
|
// synchronize all elements that were actually moved
|
||||||
return arrayToMap(syncMovedIndices(reordered, changed)) as typeof elements;
|
// could fallback to synchronizing all invalid indices
|
||||||
|
return arrayToMap(syncMovedIndices(ordered, moved)) as typeof elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -160,10 +160,8 @@ export const SelectedShapeActions = ({
|
|||||||
{(appState.activeTool.type === "text" ||
|
{(appState.activeTool.type === "text" ||
|
||||||
targetElements.some(isTextElement)) && (
|
targetElements.some(isTextElement)) && (
|
||||||
<>
|
<>
|
||||||
{renderAction("changeFontSize")}
|
|
||||||
|
|
||||||
{renderAction("changeFontFamily")}
|
{renderAction("changeFontFamily")}
|
||||||
|
{renderAction("changeFontSize")}
|
||||||
{(appState.activeTool.type === "text" ||
|
{(appState.activeTool.type === "text" ||
|
||||||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
|
suppportsHorizontalAlign(targetElements, elementsMap)) &&
|
||||||
renderAction("changeTextAlign")}
|
renderAction("changeTextAlign")}
|
||||||
@ -470,6 +468,7 @@ export const ExitZenModeAction = ({
|
|||||||
showExitZenModeBtn: boolean;
|
showExitZenModeBtn: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={clsx("disable-zen-mode", {
|
className={clsx("disable-zen-mode", {
|
||||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||||
})}
|
})}
|
||||||
|
File diff suppressed because it is too large
Load Diff
12
packages/excalidraw/components/ButtonIcon.scss
Normal file
12
packages/excalidraw/components/ButtonIcon.scss
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
@import "../css/theme";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
button.standalone {
|
||||||
|
@include outlineButtonIconStyles;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
// dissalow pointer events on children, so we always have event.target on the button itself
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
36
packages/excalidraw/components/ButtonIcon.tsx
Normal file
36
packages/excalidraw/components/ButtonIcon.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { forwardRef } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import "./ButtonIcon.scss";
|
||||||
|
|
||||||
|
interface ButtonIconProps {
|
||||||
|
icon: JSX.Element;
|
||||||
|
title: string;
|
||||||
|
className?: string;
|
||||||
|
testId?: string;
|
||||||
|
/** if not supplied, defaults to value identity check */
|
||||||
|
active?: boolean;
|
||||||
|
/** include standalone style (could interfere with parent styles) */
|
||||||
|
standalone?: boolean;
|
||||||
|
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const { title, className, testId, active, standalone, icon, onClick } =
|
||||||
|
props;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={ref}
|
||||||
|
key={title}
|
||||||
|
title={title}
|
||||||
|
data-testid={testId}
|
||||||
|
className={clsx(className, { standalone, active })}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
@ -1,4 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { ButtonIcon } from "./ButtonIcon";
|
||||||
|
|
||||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
||||||
export const ButtonIconSelect = <T extends Object>(
|
export const ButtonIconSelect = <T extends Object>(
|
||||||
@ -24,20 +25,17 @@ export const ButtonIconSelect = <T extends Object>(
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
) => (
|
) => (
|
||||||
<div className="buttonList buttonListIcon">
|
<div className="buttonList">
|
||||||
{props.options.map((option) =>
|
{props.options.map((option) =>
|
||||||
props.type === "button" ? (
|
props.type === "button" ? (
|
||||||
<button
|
<ButtonIcon
|
||||||
key={option.text}
|
key={option.text}
|
||||||
onClick={(event) => props.onClick(option.value, event)}
|
icon={option.icon}
|
||||||
className={clsx({
|
|
||||||
active: option.active ?? props.value === option.value,
|
|
||||||
})}
|
|
||||||
data-testid={option.testId}
|
|
||||||
title={option.text}
|
title={option.text}
|
||||||
>
|
testId={option.testId}
|
||||||
{option.icon}
|
active={option.active ?? props.value === option.value}
|
||||||
</button>
|
onClick={(event) => props.onClick(option.value, event)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<label
|
<label
|
||||||
key={option.text}
|
key={option.text}
|
||||||
|
10
packages/excalidraw/components/ButtonSeparator.tsx
Normal file
10
packages/excalidraw/components/ButtonSeparator.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export const ButtonSeparator = () => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 1,
|
||||||
|
height: "1rem",
|
||||||
|
backgroundColor: "var(--default-border-color)",
|
||||||
|
margin: "0 auto",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
@ -22,7 +22,12 @@ export const CheckboxItem: React.FC<{
|
|||||||
).focus();
|
).focus();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="Checkbox-box"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
>
|
||||||
{checkIcon}
|
{checkIcon}
|
||||||
</button>
|
</button>
|
||||||
<div className="Checkbox-label">{children}</div>
|
<div className="Checkbox-label">{children}</div>
|
||||||
|
@ -20,7 +20,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
@include isMobile {
|
@include isMobile {
|
||||||
max-width: 175px;
|
max-width: 11rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,22 +1,24 @@
|
|||||||
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
|
import { isTransparent } from "../../utils";
|
||||||
import type { ExcalidrawElement } from "../../element/types";
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
import { TopPicks } from "./TopPicks";
|
import { TopPicks } from "./TopPicks";
|
||||||
|
import { ButtonSeparator } from "../ButtonSeparator";
|
||||||
import { Picker } from "./Picker";
|
import { Picker } from "./Picker";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type { ColorPickerType } from "./colorPickerUtils";
|
import type { ColorPickerType } from "./colorPickerUtils";
|
||||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||||
import { useDevice, useExcalidrawContainer } from "../App";
|
import { useExcalidrawContainer } from "../App";
|
||||||
import type { ColorTuple, ColorPaletteCustom } from "../../colors";
|
import type { ColorTuple, ColorPaletteCustom } from "../../colors";
|
||||||
import { COLOR_PALETTE } from "../../colors";
|
import { COLOR_PALETTE } from "../../colors";
|
||||||
import PickerHeading from "./PickerHeading";
|
import PickerHeading from "./PickerHeading";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { useRef } from "react";
|
||||||
import { jotaiScope } from "../../jotai";
|
import { jotaiScope } from "../../jotai";
|
||||||
import { ColorInput } from "./ColorInput";
|
import { ColorInput } from "./ColorInput";
|
||||||
import { useRef } from "react";
|
|
||||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||||
|
import { PropertiesPopover } from "../PropertiesPopover";
|
||||||
|
|
||||||
import "./ColorPicker.scss";
|
import "./ColorPicker.scss";
|
||||||
|
|
||||||
@ -71,6 +73,7 @@ const ColorPickerPopupContent = ({
|
|||||||
| "palette"
|
| "palette"
|
||||||
| "updateData"
|
| "updateData"
|
||||||
>) => {
|
>) => {
|
||||||
|
const { container } = useExcalidrawContainer();
|
||||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||||
|
|
||||||
const [eyeDropperState, setEyeDropperState] = useAtom(
|
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||||
@ -78,9 +81,6 @@ const ColorPickerPopupContent = ({
|
|||||||
jotaiScope,
|
jotaiScope,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { container } = useExcalidrawContainer();
|
|
||||||
const device = useDevice();
|
|
||||||
|
|
||||||
const colorInputJSX = (
|
const colorInputJSX = (
|
||||||
<div>
|
<div>
|
||||||
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
|
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
|
||||||
@ -94,6 +94,7 @@ const ColorPickerPopupContent = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const focusPickerContent = () => {
|
const focusPickerContent = () => {
|
||||||
@ -103,120 +104,73 @@ const ColorPickerPopupContent = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Portal container={container}>
|
<PropertiesPopover
|
||||||
<Popover.Content
|
container={container}
|
||||||
ref={popoverRef}
|
style={{ maxWidth: "208px" }}
|
||||||
className="focus-visible-none"
|
onFocusOutside={(event) => {
|
||||||
data-prevent-outside-click
|
// refocus due to eye dropper
|
||||||
onFocusOutside={(event) => {
|
focusPickerContent();
|
||||||
focusPickerContent();
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
onPointerDownOutside={(event) => {
|
||||||
|
if (eyeDropperState) {
|
||||||
|
// prevent from closing if we click outside the popover
|
||||||
|
// while eyedropping (e.g. click when clicking the sidebar;
|
||||||
|
// the eye-dropper-backdrop is prevented downstream)
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}}
|
|
||||||
onPointerDownOutside={(event) => {
|
|
||||||
if (eyeDropperState) {
|
|
||||||
// prevent from closing if we click outside the popover
|
|
||||||
// while eyedropping (e.g. click when clicking the sidebar;
|
|
||||||
// the eye-dropper-backdrop is prevented downstream)
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCloseAutoFocus={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
// prevents focusing the trigger
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// return focus to excalidraw container unless
|
|
||||||
// user focuses an interactive element, such as a button, or
|
|
||||||
// enters the text editor by clicking on canvas with the text tool
|
|
||||||
if (container && !isInteractive(document.activeElement)) {
|
|
||||||
container.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateData({ openPopup: null });
|
|
||||||
setActiveColorPickerSection(null);
|
|
||||||
}}
|
|
||||||
side={
|
|
||||||
device.editor.isMobile && !device.viewport.isLandscape
|
|
||||||
? "bottom"
|
|
||||||
: "right"
|
|
||||||
}
|
}
|
||||||
align={
|
}}
|
||||||
device.editor.isMobile && !device.viewport.isLandscape
|
onClose={() => {
|
||||||
? "center"
|
updateData({ openPopup: null });
|
||||||
: "start"
|
setActiveColorPickerSection(null);
|
||||||
}
|
}}
|
||||||
alignOffset={-16}
|
>
|
||||||
sideOffset={20}
|
{palette ? (
|
||||||
style={{
|
<Picker
|
||||||
zIndex: "var(--zIndex-layerUI)",
|
palette={palette}
|
||||||
backgroundColor: "var(--popup-bg-color)",
|
color={color}
|
||||||
maxWidth: "208px",
|
onChange={(changedColor) => {
|
||||||
maxHeight: window.innerHeight,
|
onChange(changedColor);
|
||||||
padding: "12px",
|
}}
|
||||||
borderRadius: "8px",
|
onEyeDropperToggle={(force) => {
|
||||||
boxSizing: "border-box",
|
setEyeDropperState((state) => {
|
||||||
overflowY: "auto",
|
if (force) {
|
||||||
boxShadow:
|
state = state || {
|
||||||
"0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)",
|
keepOpenOnAlt: true,
|
||||||
}}
|
onSelect: onChange,
|
||||||
>
|
colorPickerType: type,
|
||||||
{palette ? (
|
};
|
||||||
<Picker
|
state.keepOpenOnAlt = true;
|
||||||
palette={palette}
|
return state;
|
||||||
color={color}
|
}
|
||||||
onChange={(changedColor) => {
|
|
||||||
onChange(changedColor);
|
return force === false || state
|
||||||
}}
|
? null
|
||||||
onEyeDropperToggle={(force) => {
|
: {
|
||||||
setEyeDropperState((state) => {
|
keepOpenOnAlt: false,
|
||||||
if (force) {
|
|
||||||
state = state || {
|
|
||||||
keepOpenOnAlt: true,
|
|
||||||
onSelect: onChange,
|
onSelect: onChange,
|
||||||
colorPickerType: type,
|
colorPickerType: type,
|
||||||
};
|
};
|
||||||
state.keepOpenOnAlt = true;
|
});
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
return force === false || state
|
|
||||||
? null
|
|
||||||
: {
|
|
||||||
keepOpenOnAlt: false,
|
|
||||||
onSelect: onChange,
|
|
||||||
colorPickerType: type,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onEscape={(event) => {
|
|
||||||
if (eyeDropperState) {
|
|
||||||
setEyeDropperState(null);
|
|
||||||
} else if (isWritableElement(event.target)) {
|
|
||||||
focusPickerContent();
|
|
||||||
} else {
|
|
||||||
updateData({ openPopup: null });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
label={label}
|
|
||||||
type={type}
|
|
||||||
elements={elements}
|
|
||||||
updateData={updateData}
|
|
||||||
>
|
|
||||||
{colorInputJSX}
|
|
||||||
</Picker>
|
|
||||||
) : (
|
|
||||||
colorInputJSX
|
|
||||||
)}
|
|
||||||
<Popover.Arrow
|
|
||||||
width={20}
|
|
||||||
height={10}
|
|
||||||
style={{
|
|
||||||
fill: "var(--popup-bg-color)",
|
|
||||||
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
|
|
||||||
}}
|
}}
|
||||||
/>
|
onEscape={(event) => {
|
||||||
</Popover.Content>
|
if (eyeDropperState) {
|
||||||
</Popover.Portal>
|
setEyeDropperState(null);
|
||||||
|
} else {
|
||||||
|
updateData({ openPopup: null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
label={label}
|
||||||
|
type={type}
|
||||||
|
elements={elements}
|
||||||
|
updateData={updateData}
|
||||||
|
>
|
||||||
|
{colorInputJSX}
|
||||||
|
</Picker>
|
||||||
|
) : (
|
||||||
|
colorInputJSX
|
||||||
|
)}
|
||||||
|
</PropertiesPopover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -232,7 +186,7 @@ const ColorPickerTrigger = ({
|
|||||||
return (
|
return (
|
||||||
<Popover.Trigger
|
<Popover.Trigger
|
||||||
type="button"
|
type="button"
|
||||||
className={clsx("color-picker__button active-color", {
|
className={clsx("color-picker__button active-color properties-trigger", {
|
||||||
"is-transparent": color === "transparent" || !color,
|
"is-transparent": color === "transparent" || !color,
|
||||||
})}
|
})}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
@ -268,14 +222,7 @@ export const ColorPicker = ({
|
|||||||
type={type}
|
type={type}
|
||||||
topPicks={topPicks}
|
topPicks={topPicks}
|
||||||
/>
|
/>
|
||||||
<div
|
<ButtonSeparator />
|
||||||
style={{
|
|
||||||
width: 1,
|
|
||||||
height: "100%",
|
|
||||||
backgroundColor: "var(--default-border-color)",
|
|
||||||
margin: "0 auto",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Popover.Root
|
<Popover.Root
|
||||||
open={appState.openPopup === type}
|
open={appState.openPopup === type}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
@ -138,7 +138,7 @@ export const Picker = ({
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="color-picker-content"
|
className="color-picker-content properties-content"
|
||||||
// to allow focusing by clicking but not by tabbing
|
// to allow focusing by clicking but not by tabbing
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
|
@ -540,7 +540,7 @@ function CommandPaletteInner({
|
|||||||
...command,
|
...command,
|
||||||
icon: command.icon || boltIcon,
|
icon: command.icon || boltIcon,
|
||||||
order: command.order ?? getCategoryOrder(command.category),
|
order: command.order ?? getCategoryOrder(command.category),
|
||||||
haystack: `${deburr(command.label)} ${
|
haystack: `${deburr(command.label.toLocaleLowerCase())} ${
|
||||||
command.keywords?.join(" ") || ""
|
command.keywords?.join(" ") || ""
|
||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
@ -777,7 +777,9 @@ function CommandPaletteInner({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _query = deburr(commandSearch.replace(/[<>-_| ]/g, ""));
|
const _query = deburr(
|
||||||
|
commandSearch.toLocaleLowerCase().replace(/[<>_| -]/g, ""),
|
||||||
|
);
|
||||||
matchingCommands = fuzzy
|
matchingCommands = fuzzy
|
||||||
.filter(_query, matchingCommands, {
|
.filter(_query, matchingCommands, {
|
||||||
extract: (command) => command.haystack,
|
extract: (command) => command.haystack,
|
||||||
|
@ -105,6 +105,7 @@ export const ContextMenu = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={clsx("context-menu-item", {
|
className={clsx("context-menu-item", {
|
||||||
dangerous: actionName === "deleteSelectedElements",
|
dangerous: actionName === "deleteSelectedElements",
|
||||||
checkmark: item.checked?.(appState),
|
checkmark: item.checked?.(appState),
|
||||||
|
@ -123,6 +123,7 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title={t("buttons.close")}
|
title={t("buttons.close")}
|
||||||
aria-label={t("buttons.close")}
|
aria-label={t("buttons.close")}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
{CloseIcon}
|
{CloseIcon}
|
||||||
</button>
|
</button>
|
||||||
|
@ -27,7 +27,11 @@ const FollowMode = ({
|
|||||||
{userToFollow.username}
|
{userToFollow.username}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onDisconnect} className="follow-mode__disconnect-btn">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDisconnect}
|
||||||
|
className="follow-mode__disconnect-btn"
|
||||||
|
>
|
||||||
{CloseIcon}
|
{CloseIcon}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
15
packages/excalidraw/components/FontPicker/FontPicker.scss
Normal file
15
packages/excalidraw/components/FontPicker/FontPicker.scss
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
@import "../../css/variables.module.scss";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.FontPicker__container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: calc(1rem + 3 * var(--default-button-size)) 1rem 1fr; // calc ~ 2 gaps + 4 buttons
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
@include isMobile {
|
||||||
|
max-width: calc(
|
||||||
|
2rem + 4 * var(--default-button-size)
|
||||||
|
); // 4 gaps + 4 buttons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
110
packages/excalidraw/components/FontPicker/FontPicker.tsx
Normal file
110
packages/excalidraw/components/FontPicker/FontPicker.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
|
||||||
|
import { FontPickerList } from "./FontPickerList";
|
||||||
|
import { FontPickerTrigger } from "./FontPickerTrigger";
|
||||||
|
import { ButtonIconSelect } from "../ButtonIconSelect";
|
||||||
|
import {
|
||||||
|
FontFamilyCodeIcon,
|
||||||
|
FontFamilyNormalIcon,
|
||||||
|
FreedrawIcon,
|
||||||
|
} from "../icons";
|
||||||
|
import { ButtonSeparator } from "../ButtonSeparator";
|
||||||
|
import type { FontFamilyValues } from "../../element/types";
|
||||||
|
import { FONT_FAMILY } from "../../constants";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
|
||||||
|
import "./FontPicker.scss";
|
||||||
|
|
||||||
|
export const DEFAULT_FONTS = [
|
||||||
|
{
|
||||||
|
value: FONT_FAMILY.Excalifont,
|
||||||
|
icon: FreedrawIcon,
|
||||||
|
text: t("labels.handDrawn"),
|
||||||
|
testId: "font-family-handrawn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: FONT_FAMILY.Nunito,
|
||||||
|
icon: FontFamilyNormalIcon,
|
||||||
|
text: t("labels.normal"),
|
||||||
|
testId: "font-family-normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: FONT_FAMILY["Comic Shanns"],
|
||||||
|
icon: FontFamilyCodeIcon,
|
||||||
|
text: t("labels.code"),
|
||||||
|
testId: "font-family-code",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultFontFamilies = new Set(DEFAULT_FONTS.map((x) => x.value));
|
||||||
|
|
||||||
|
export const isDefaultFont = (fontFamily: number | null) => {
|
||||||
|
if (!fontFamily) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultFontFamilies.has(fontFamily);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FontPickerProps {
|
||||||
|
isOpened: boolean;
|
||||||
|
selectedFontFamily: FontFamilyValues | null;
|
||||||
|
hoveredFontFamily: FontFamilyValues | null;
|
||||||
|
onSelect: (fontFamily: FontFamilyValues) => void;
|
||||||
|
onHover: (fontFamily: FontFamilyValues) => void;
|
||||||
|
onLeave: () => void;
|
||||||
|
onPopupChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FontPicker = React.memo(
|
||||||
|
({
|
||||||
|
isOpened,
|
||||||
|
selectedFontFamily,
|
||||||
|
hoveredFontFamily,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
onLeave,
|
||||||
|
onPopupChange,
|
||||||
|
}: FontPickerProps) => {
|
||||||
|
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
|
||||||
|
const onSelectCallback = useCallback(
|
||||||
|
(value: number | false) => {
|
||||||
|
if (value) {
|
||||||
|
onSelect(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="dialog" aria-modal="true" className="FontPicker__container">
|
||||||
|
<ButtonIconSelect<FontFamilyValues | false>
|
||||||
|
type="button"
|
||||||
|
options={defaultFonts}
|
||||||
|
value={selectedFontFamily}
|
||||||
|
onClick={onSelectCallback}
|
||||||
|
/>
|
||||||
|
<ButtonSeparator />
|
||||||
|
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
|
||||||
|
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
|
||||||
|
{isOpened && (
|
||||||
|
<FontPickerList
|
||||||
|
selectedFontFamily={selectedFontFamily}
|
||||||
|
hoveredFontFamily={hoveredFontFamily}
|
||||||
|
onSelect={onSelectCallback}
|
||||||
|
onHover={onHover}
|
||||||
|
onLeave={onLeave}
|
||||||
|
onOpen={() => onPopupChange(true)}
|
||||||
|
onClose={() => onPopupChange(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(prev, next) =>
|
||||||
|
prev.isOpened === next.isOpened &&
|
||||||
|
prev.selectedFontFamily === next.selectedFontFamily &&
|
||||||
|
prev.hoveredFontFamily === next.hoveredFontFamily,
|
||||||
|
);
|
268
packages/excalidraw/components/FontPicker/FontPickerList.tsx
Normal file
268
packages/excalidraw/components/FontPicker/FontPickerList.tsx
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
import React, {
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
type KeyboardEventHandler,
|
||||||
|
} from "react";
|
||||||
|
import { useApp, useAppProps, useExcalidrawContainer } from "../App";
|
||||||
|
import { PropertiesPopover } from "../PropertiesPopover";
|
||||||
|
import { QuickSearch } from "../QuickSearch";
|
||||||
|
import { ScrollableList } from "../ScrollableList";
|
||||||
|
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
|
||||||
|
import DropdownMenuItem, {
|
||||||
|
DropDownMenuItemBadgeType,
|
||||||
|
DropDownMenuItemBadge,
|
||||||
|
} from "../dropdownMenu/DropdownMenuItem";
|
||||||
|
import { type FontFamilyValues } from "../../element/types";
|
||||||
|
import { arrayToList, debounce, getFontFamilyString } from "../../utils";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
|
||||||
|
import { Fonts } from "../../fonts";
|
||||||
|
import type { ValueOf } from "../../utility-types";
|
||||||
|
|
||||||
|
export interface FontDescriptor {
|
||||||
|
value: number;
|
||||||
|
icon: JSX.Element;
|
||||||
|
text: string;
|
||||||
|
deprecated?: true;
|
||||||
|
badge?: {
|
||||||
|
type: ValueOf<typeof DropDownMenuItemBadgeType>;
|
||||||
|
placeholder: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FontPickerListProps {
|
||||||
|
selectedFontFamily: FontFamilyValues | null;
|
||||||
|
hoveredFontFamily: FontFamilyValues | null;
|
||||||
|
onSelect: (value: number) => void;
|
||||||
|
onHover: (value: number) => void;
|
||||||
|
onLeave: () => void;
|
||||||
|
onOpen: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FontPickerList = React.memo(
|
||||||
|
({
|
||||||
|
selectedFontFamily,
|
||||||
|
hoveredFontFamily,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
onLeave,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
}: FontPickerListProps) => {
|
||||||
|
const { container } = useExcalidrawContainer();
|
||||||
|
const { fonts } = useApp();
|
||||||
|
const { showDeprecatedFonts } = useAppProps();
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const allFonts = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(Fonts.registered.entries())
|
||||||
|
.filter(([_, { metadata }]) => !metadata.serverSide)
|
||||||
|
.map(([familyId, { metadata, fontFaces }]) => {
|
||||||
|
const font = {
|
||||||
|
value: familyId,
|
||||||
|
icon: metadata.icon,
|
||||||
|
text: fontFaces[0].fontFace.family,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (metadata.deprecated) {
|
||||||
|
Object.assign(font, {
|
||||||
|
deprecated: metadata.deprecated,
|
||||||
|
badge: {
|
||||||
|
type: DropDownMenuItemBadgeType.RED,
|
||||||
|
placeholder: t("fontList.badge.old"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return font as FontDescriptor;
|
||||||
|
})
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1,
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sceneFamilies = useMemo(
|
||||||
|
() => new Set(fonts.sceneFamilies),
|
||||||
|
// cache per selected font family, so hover re-render won't mess it up
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[selectedFontFamily],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sceneFonts = useMemo(
|
||||||
|
() => allFonts.filter((font) => sceneFamilies.has(font.value)), // always show all the fonts in the scene, even those that were deprecated
|
||||||
|
[allFonts, sceneFamilies],
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableFonts = useMemo(
|
||||||
|
() =>
|
||||||
|
allFonts.filter(
|
||||||
|
(font) =>
|
||||||
|
!sceneFamilies.has(font.value) &&
|
||||||
|
(showDeprecatedFonts || !font.deprecated), // skip deprecated fonts
|
||||||
|
),
|
||||||
|
[allFonts, sceneFamilies, showDeprecatedFonts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredFonts = useMemo(
|
||||||
|
() =>
|
||||||
|
arrayToList(
|
||||||
|
[...sceneFonts, ...availableFonts].filter((font) =>
|
||||||
|
font.text?.toLowerCase().includes(searchTerm),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[sceneFonts, availableFonts, searchTerm],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hoveredFont = useMemo(() => {
|
||||||
|
let font;
|
||||||
|
|
||||||
|
if (hoveredFontFamily) {
|
||||||
|
font = filteredFonts.find((font) => font.value === hoveredFontFamily);
|
||||||
|
} else if (selectedFontFamily) {
|
||||||
|
font = filteredFonts.find((font) => font.value === selectedFontFamily);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!font && searchTerm) {
|
||||||
|
if (filteredFonts[0]?.value) {
|
||||||
|
// hover first element on search
|
||||||
|
onHover(filteredFonts[0].value);
|
||||||
|
} else {
|
||||||
|
// re-render cache on no results
|
||||||
|
onLeave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return font;
|
||||||
|
}, [
|
||||||
|
hoveredFontFamily,
|
||||||
|
selectedFontFamily,
|
||||||
|
searchTerm,
|
||||||
|
filteredFonts,
|
||||||
|
onHover,
|
||||||
|
onLeave,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
|
||||||
|
(event) => {
|
||||||
|
const handled = fontPickerKeyHandler({
|
||||||
|
event,
|
||||||
|
inputRef,
|
||||||
|
hoveredFont,
|
||||||
|
filteredFonts,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hoveredFont, filteredFonts, onSelect, onHover, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onOpen();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sceneFilteredFonts = useMemo(
|
||||||
|
() => filteredFonts.filter((font) => sceneFamilies.has(font.value)),
|
||||||
|
[filteredFonts, sceneFamilies],
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableFilteredFonts = useMemo(
|
||||||
|
() => filteredFonts.filter((font) => !sceneFamilies.has(font.value)),
|
||||||
|
[filteredFonts, sceneFamilies],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderFont = (font: FontDescriptor, index: number) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={font.value}
|
||||||
|
icon={font.icon}
|
||||||
|
value={font.value}
|
||||||
|
order={index}
|
||||||
|
textStyle={{
|
||||||
|
fontFamily: getFontFamilyString({ fontFamily: font.value }),
|
||||||
|
}}
|
||||||
|
hovered={font.value === hoveredFont?.value}
|
||||||
|
selected={font.value === selectedFontFamily}
|
||||||
|
// allow to tab between search and selected font
|
||||||
|
tabIndex={font.value === selectedFontFamily ? 0 : -1}
|
||||||
|
onClick={(e) => {
|
||||||
|
onSelect(Number(e.currentTarget.value));
|
||||||
|
}}
|
||||||
|
onMouseMove={() => {
|
||||||
|
if (hoveredFont?.value !== font.value) {
|
||||||
|
onHover(font.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{font.text}
|
||||||
|
{font.badge && (
|
||||||
|
<DropDownMenuItemBadge type={font.badge.type}>
|
||||||
|
{font.badge.placeholder}
|
||||||
|
</DropDownMenuItemBadge>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = [];
|
||||||
|
|
||||||
|
if (sceneFilteredFonts.length) {
|
||||||
|
groups.push(
|
||||||
|
<DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1">
|
||||||
|
{sceneFilteredFonts.map(renderFont)}
|
||||||
|
</DropdownMenuGroup>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableFilteredFonts.length) {
|
||||||
|
groups.push(
|
||||||
|
<DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2">
|
||||||
|
{availableFilteredFonts.map((font, index) =>
|
||||||
|
renderFont(font, index + sceneFilteredFonts.length),
|
||||||
|
)}
|
||||||
|
</DropdownMenuGroup>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PropertiesPopover
|
||||||
|
className="properties-content"
|
||||||
|
container={container}
|
||||||
|
style={{ width: "15rem" }}
|
||||||
|
onClose={onClose}
|
||||||
|
onPointerLeave={onLeave}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
<QuickSearch
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder={t("quickSearch.placeholder")}
|
||||||
|
onChange={debounce(setSearchTerm, 20)}
|
||||||
|
/>
|
||||||
|
<ScrollableList
|
||||||
|
className="dropdown-menu fonts manual-hover"
|
||||||
|
placeholder={t("fontList.empty")}
|
||||||
|
>
|
||||||
|
{groups.length ? groups : null}
|
||||||
|
</ScrollableList>
|
||||||
|
</PropertiesPopover>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(prev, next) =>
|
||||||
|
prev.selectedFontFamily === next.selectedFontFamily &&
|
||||||
|
prev.hoveredFontFamily === next.hoveredFontFamily,
|
||||||
|
);
|
@ -0,0 +1,38 @@
|
|||||||
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { ButtonIcon } from "../ButtonIcon";
|
||||||
|
import { TextIcon } from "../icons";
|
||||||
|
import type { FontFamilyValues } from "../../element/types";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import { isDefaultFont } from "./FontPicker";
|
||||||
|
|
||||||
|
interface FontPickerTriggerProps {
|
||||||
|
selectedFontFamily: FontFamilyValues | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FontPickerTrigger = ({
|
||||||
|
selectedFontFamily,
|
||||||
|
}: FontPickerTriggerProps) => {
|
||||||
|
const isTriggerActive = useMemo(
|
||||||
|
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
|
||||||
|
[selectedFontFamily],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
{/* Empty div as trigger so it's stretched 100% due to different button sizes */}
|
||||||
|
<div>
|
||||||
|
<ButtonIcon
|
||||||
|
standalone
|
||||||
|
icon={TextIcon}
|
||||||
|
title={t("labels.showFonts")}
|
||||||
|
className="properties-trigger"
|
||||||
|
testId={"font-family-show-fonts"}
|
||||||
|
active={isTriggerActive}
|
||||||
|
// no-op
|
||||||
|
onClick={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Popover.Trigger>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,66 @@
|
|||||||
|
import type { Node } from "../../utils";
|
||||||
|
import { KEYS } from "../../keys";
|
||||||
|
import { type FontDescriptor } from "./FontPickerList";
|
||||||
|
|
||||||
|
interface FontPickerKeyNavHandlerProps {
|
||||||
|
event: React.KeyboardEvent<HTMLDivElement>;
|
||||||
|
inputRef: React.RefObject<HTMLInputElement>;
|
||||||
|
hoveredFont: Node<FontDescriptor> | undefined;
|
||||||
|
filteredFonts: Node<FontDescriptor>[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (value: number) => void;
|
||||||
|
onHover: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fontPickerKeyHandler = ({
|
||||||
|
event,
|
||||||
|
inputRef,
|
||||||
|
hoveredFont,
|
||||||
|
filteredFonts,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
}: FontPickerKeyNavHandlerProps) => {
|
||||||
|
if (
|
||||||
|
!event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
event.shiftKey &&
|
||||||
|
event.key.toLowerCase() === KEYS.F
|
||||||
|
) {
|
||||||
|
// refocus input on the popup trigger shortcut
|
||||||
|
inputRef.current?.focus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === KEYS.ESCAPE) {
|
||||||
|
onClose();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === KEYS.ENTER) {
|
||||||
|
if (hoveredFont?.value) {
|
||||||
|
onSelect(hoveredFont.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === KEYS.ARROW_DOWN) {
|
||||||
|
if (hoveredFont?.next) {
|
||||||
|
onHover(hoveredFont.next.value);
|
||||||
|
} else if (filteredFonts[0]?.value) {
|
||||||
|
onHover(filteredFonts[0].value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === KEYS.ARROW_UP) {
|
||||||
|
if (hoveredFont?.prev) {
|
||||||
|
onHover(hoveredFont.prev.value);
|
||||||
|
} else if (filteredFonts[filteredFonts.length - 1]?.value) {
|
||||||
|
onHover(filteredFonts[filteredFonts.length - 1].value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 1.5rem 0;
|
margin: 1.5rem 0;
|
||||||
font-weight: bold;
|
font-weight: 700;
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,7 +82,7 @@
|
|||||||
&__island {
|
&__island {
|
||||||
h4 {
|
h4 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: bold;
|
font-weight: 700;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-bottom: 0.625rem;
|
margin-bottom: 0.625rem;
|
||||||
}
|
}
|
||||||
|
@ -285,7 +285,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
shortcuts={[getShortcutKey("Alt+Shift+D")]}
|
shortcuts={[getShortcutKey("Alt+Shift+D")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("stats.title")}
|
label={t("stats.fullTitle")}
|
||||||
shortcuts={[getShortcutKey("Alt+/")]}
|
shortcuts={[getShortcutKey("Alt+/")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
@ -458,6 +458,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("labels.showBackground")}
|
label={t("labels.showBackground")}
|
||||||
shortcuts={[getShortcutKey("G")]}
|
shortcuts={[getShortcutKey("G")]}
|
||||||
/>
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.showFonts")}
|
||||||
|
shortcuts={[getShortcutKey("Shift+F")]}
|
||||||
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("labels.decreaseFontSize")}
|
label={t("labels.decreaseFontSize")}
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
|
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
|
||||||
|
@ -108,6 +108,7 @@ function Picker<T>({
|
|||||||
<div className="picker-content" ref={rGallery}>
|
<div className="picker-content" ref={rGallery}>
|
||||||
{options.map((option, i) => (
|
{options.map((option, i) => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={clsx("picker-option", {
|
className={clsx("picker-option", {
|
||||||
active: value === option.value,
|
active: value === option.value,
|
||||||
})}
|
})}
|
||||||
@ -171,6 +172,7 @@ export function IconPicker<T>({
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
name={group}
|
name={group}
|
||||||
|
type="button"
|
||||||
className={isActive ? "active" : ""}
|
className={isActive ? "active" : ""}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onClick={() => setActive(!isActive)}
|
onClick={() => setActive(!isActive)}
|
||||||
|
@ -27,6 +27,99 @@
|
|||||||
& > * {
|
& > * {
|
||||||
pointer-events: var(--ui-pointerEvents);
|
pointer-events: var(--ui-pointerEvents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& > .Stats {
|
||||||
|
width: 204px;
|
||||||
|
position: absolute;
|
||||||
|
top: 60px;
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: var(--zIndex-layerUI);
|
||||||
|
pointer-events: var(--ui-pointerEvents);
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.elementType {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.elementsCount {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsItem {
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
white-space: nowrap;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
th {
|
||||||
|
border-bottom: 1px solid var(--input-border-color);
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td:nth-child(2) {
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--default-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: 12px;
|
||||||
|
right: initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__footer {
|
&__footer {
|
||||||
|
@ -39,8 +39,6 @@ import { JSONExportDialog } from "./JSONExportDialog";
|
|||||||
import { PenModeButton } from "./PenModeButton";
|
import { PenModeButton } from "./PenModeButton";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { useDevice } from "./App";
|
import { useDevice } from "./App";
|
||||||
import { Stats } from "./Stats";
|
|
||||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
|
||||||
import Footer from "./footer/Footer";
|
import Footer from "./footer/Footer";
|
||||||
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||||
import { jotaiScope } from "../jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
@ -65,6 +63,8 @@ import { SubtypeToggles } from "./Subtypes";
|
|||||||
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;
|
||||||
@ -242,6 +242,11 @@ 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">
|
||||||
@ -355,6 +360,15 @@ 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>
|
||||||
@ -446,7 +460,7 @@ const LayerUI = ({
|
|||||||
);
|
);
|
||||||
ShapeCache.delete(element);
|
ShapeCache.delete(element);
|
||||||
}
|
}
|
||||||
Scene.getScene(selectedElements[0])?.informMutation();
|
Scene.getScene(selectedElements[0])?.triggerUpdate();
|
||||||
} else if (colorPickerType === "elementBackground") {
|
} else if (colorPickerType === "elementBackground") {
|
||||||
setAppState({
|
setAppState({
|
||||||
currentItemBackgroundColor: color,
|
currentItemBackgroundColor: color,
|
||||||
@ -544,19 +558,9 @@ const LayerUI = ({
|
|||||||
showExitZenModeBtn={showExitZenModeBtn}
|
showExitZenModeBtn={showExitZenModeBtn}
|
||||||
renderWelcomeScreen={renderWelcomeScreen}
|
renderWelcomeScreen={renderWelcomeScreen}
|
||||||
/>
|
/>
|
||||||
{appState.showStats && (
|
|
||||||
<Stats
|
|
||||||
appState={appState}
|
|
||||||
setAppState={setAppState}
|
|
||||||
elements={elements}
|
|
||||||
onClose={() => {
|
|
||||||
actionManager.executeAction(actionToggleStats);
|
|
||||||
}}
|
|
||||||
renderCustomStats={renderCustomStats}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{appState.scrolledOutside && (
|
{appState.scrolledOutside && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="scroll-back-to-content"
|
className="scroll-back-to-content"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAppState((appState) => ({
|
setAppState((appState) => ({
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
.library-actions-counter {
|
.library-actions-counter {
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
color: var(--color-primary-light);
|
color: var(--color-primary-light);
|
||||||
font-weight: bold;
|
font-weight: 700;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
font-weight: bold;
|
font-weight: 700;
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
@ -62,7 +62,7 @@
|
|||||||
&__header {
|
&__header {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: bold;
|
font-weight: 700;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-right: 4rem; // due to dropdown button
|
padding-right: 4rem; // due to dropdown button
|
||||||
|
@ -21,8 +21,6 @@ import { Section } from "./Section";
|
|||||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||||
import { LockButton } from "./LockButton";
|
import { LockButton } from "./LockButton";
|
||||||
import { PenModeButton } from "./PenModeButton";
|
import { PenModeButton } from "./PenModeButton";
|
||||||
import { Stats } from "./Stats";
|
|
||||||
import { actionToggleStats } from "../actions";
|
|
||||||
import { HandButton } from "./HandButton";
|
import { HandButton } from "./HandButton";
|
||||||
import { isHandToolActive } from "../appState";
|
import { isHandToolActive } from "../appState";
|
||||||
import { useTunnels } from "../context/tunnels";
|
import { useTunnels } from "../context/tunnels";
|
||||||
@ -159,17 +157,6 @@ export const MobileMenu = ({
|
|||||||
<>
|
<>
|
||||||
{renderSidebars()}
|
{renderSidebars()}
|
||||||
{!appState.viewModeEnabled && renderToolbar()}
|
{!appState.viewModeEnabled && renderToolbar()}
|
||||||
{!appState.openMenu && appState.showStats && (
|
|
||||||
<Stats
|
|
||||||
appState={appState}
|
|
||||||
setAppState={setAppState}
|
|
||||||
elements={elements}
|
|
||||||
onClose={() => {
|
|
||||||
actionManager.executeAction(actionToggleStats);
|
|
||||||
}}
|
|
||||||
renderCustomStats={renderCustomStats}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
className="App-bottom-bar"
|
className="App-bottom-bar"
|
||||||
style={{
|
style={{
|
||||||
@ -196,6 +183,7 @@ export const MobileMenu = ({
|
|||||||
!appState.openMenu &&
|
!appState.openMenu &&
|
||||||
!appState.openSidebar && (
|
!appState.openSidebar && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="scroll-back-to-content"
|
className="scroll-back-to-content"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAppState((appState) => ({
|
setAppState((appState) => ({
|
||||||
|
@ -94,6 +94,7 @@ const ChartPreviewBtn = (props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="ChartPreview"
|
className="ChartPreview"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (chartElements) {
|
if (chartElements) {
|
||||||
|
96
packages/excalidraw/components/PropertiesPopover.tsx
Normal file
96
packages/excalidraw/components/PropertiesPopover.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import React, { type ReactNode } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
|
||||||
|
import { useDevice } from "./App";
|
||||||
|
import { Island } from "./Island";
|
||||||
|
import { isInteractive } from "../utils";
|
||||||
|
|
||||||
|
interface PropertiesPopoverProps {
|
||||||
|
className?: string;
|
||||||
|
container: HTMLDivElement | null;
|
||||||
|
children: ReactNode;
|
||||||
|
style?: object;
|
||||||
|
onClose: () => void;
|
||||||
|
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
|
||||||
|
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
|
||||||
|
onFocusOutside?: Popover.DismissableLayerProps["onFocusOutside"];
|
||||||
|
onPointerDownOutside?: Popover.DismissableLayerProps["onPointerDownOutside"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PropertiesPopover = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
PropertiesPopoverProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
container,
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
onClose,
|
||||||
|
onKeyDown,
|
||||||
|
onFocusOutside,
|
||||||
|
onPointerLeave,
|
||||||
|
onPointerDownOutside,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const device = useDevice();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Portal container={container}>
|
||||||
|
<Popover.Content
|
||||||
|
ref={ref}
|
||||||
|
className={clsx("focus-visible-none", className)}
|
||||||
|
data-prevent-outside-click
|
||||||
|
side={
|
||||||
|
device.editor.isMobile && !device.viewport.isLandscape
|
||||||
|
? "bottom"
|
||||||
|
: "right"
|
||||||
|
}
|
||||||
|
align={
|
||||||
|
device.editor.isMobile && !device.viewport.isLandscape
|
||||||
|
? "center"
|
||||||
|
: "start"
|
||||||
|
}
|
||||||
|
alignOffset={-16}
|
||||||
|
sideOffset={20}
|
||||||
|
style={{
|
||||||
|
zIndex: "var(--zIndex-popup)",
|
||||||
|
}}
|
||||||
|
onPointerLeave={onPointerLeave}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onFocusOutside={onFocusOutside}
|
||||||
|
onPointerDownOutside={onPointerDownOutside}
|
||||||
|
onCloseAutoFocus={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// prevents focusing the trigger
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// return focus to excalidraw container unless
|
||||||
|
// user focuses an interactive element, such as a button, or
|
||||||
|
// enters the text editor by clicking on canvas with the text tool
|
||||||
|
if (container && !isInteractive(document.activeElement)) {
|
||||||
|
container.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Island padding={3} style={style}>
|
||||||
|
{children}
|
||||||
|
</Island>
|
||||||
|
<Popover.Arrow
|
||||||
|
width={20}
|
||||||
|
height={10}
|
||||||
|
style={{
|
||||||
|
fill: "var(--popup-bg-color)",
|
||||||
|
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
@ -133,7 +133,7 @@
|
|||||||
.required,
|
.required,
|
||||||
.error {
|
.error {
|
||||||
color: $oc-red-8;
|
color: $oc-red-8;
|
||||||
font-weight: bold;
|
font-weight: 700;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
margin: 0.2rem;
|
margin: 0.2rem;
|
||||||
}
|
}
|
||||||
|
48
packages/excalidraw/components/QuickSearch.scss
Normal file
48
packages/excalidraw/components/QuickSearch.scss
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
.excalidraw {
|
||||||
|
--list-border-color: var(--color-gray-20);
|
||||||
|
|
||||||
|
.QuickSearch__wrapper {
|
||||||
|
position: relative;
|
||||||
|
height: 2.6rem; // added +0.1 due to Safari
|
||||||
|
border-bottom: 1px solid var(--list-border-color);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
position: absolute;
|
||||||
|
top: 47.5%; // 50% is not exactly in the center of the input
|
||||||
|
transform: translateY(-50%);
|
||||||
|
left: 0.75rem;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
color: var(--color-gray-40);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.theme--dark {
|
||||||
|
--list-border-color: var(--color-gray-80);
|
||||||
|
|
||||||
|
.QuickSearch__wrapper {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.QuickSearch__input {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 0 !important;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding-left: 2.5rem !important;
|
||||||
|
padding-right: 0.75rem !important;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--color-gray-40);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
28
packages/excalidraw/components/QuickSearch.tsx
Normal file
28
packages/excalidraw/components/QuickSearch.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import React from "react";
|
||||||
|
import { searchIcon } from "./icons";
|
||||||
|
|
||||||
|
import "./QuickSearch.scss";
|
||||||
|
|
||||||
|
interface QuickSearchProps {
|
||||||
|
className?: string;
|
||||||
|
placeholder: string;
|
||||||
|
onChange: (term: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuickSearch = React.forwardRef<HTMLInputElement, QuickSearchProps>(
|
||||||
|
({ className, placeholder, onChange }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className={clsx("QuickSearch__wrapper", className)}>
|
||||||
|
{searchIcon}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className="QuickSearch__input"
|
||||||
|
type="text"
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange(e.target.value.trim().toLowerCase())}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
21
packages/excalidraw/components/ScrollableList.scss
Normal file
21
packages/excalidraw/components/ScrollableList.scss
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
.excalidraw {
|
||||||
|
.ScrollableList__wrapper {
|
||||||
|
position: static !important;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
& > .empty,
|
||||||
|
& > .hint {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-gray-60);
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 150%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
24
packages/excalidraw/components/ScrollableList.tsx
Normal file
24
packages/excalidraw/components/ScrollableList.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import { Children } from "react";
|
||||||
|
|
||||||
|
import "./ScrollableList.scss";
|
||||||
|
|
||||||
|
interface ScrollableListProps {
|
||||||
|
className?: string;
|
||||||
|
placeholder: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScrollableList = ({
|
||||||
|
className,
|
||||||
|
placeholder,
|
||||||
|
children,
|
||||||
|
}: ScrollableListProps) => {
|
||||||
|
const isEmpty = !Children.count(children);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("ScrollableList__wrapper", className)} role="menu">
|
||||||
|
{isEmpty ? <div className="empty">{placeholder}</div> : children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -1,54 +0,0 @@
|
|||||||
@import "../css/variables.module.scss";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.Stats {
|
|
||||||
position: absolute;
|
|
||||||
top: 64px;
|
|
||||||
right: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
z-index: 10;
|
|
||||||
pointer-events: var(--ui-pointerEvents);
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0 24px 8px 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close {
|
|
||||||
float: right;
|
|
||||||
height: 16px;
|
|
||||||
width: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
th {
|
|
||||||
border-bottom: 1px solid var(--input-border-color);
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
tr {
|
|
||||||
td:nth-child(2) {
|
|
||||||
min-width: 24px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: 12px;
|
|
||||||
right: initial;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0 0 8px 24px;
|
|
||||||
}
|
|
||||||
.close {
|
|
||||||
float: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,108 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { getCommonBounds } from "../element/bounds";
|
|
||||||
import type { NonDeletedExcalidrawElement } from "../element/types";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { getTargetElements } from "../scene";
|
|
||||||
import type { ExcalidrawProps, UIAppState } from "../types";
|
|
||||||
import { CloseIcon } from "./icons";
|
|
||||||
import { Island } from "./Island";
|
|
||||||
import "./Stats.scss";
|
|
||||||
|
|
||||||
export const Stats = (props: {
|
|
||||||
appState: UIAppState;
|
|
||||||
setAppState: React.Component<any, UIAppState>["setState"];
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
|
||||||
onClose: () => void;
|
|
||||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
|
||||||
}) => {
|
|
||||||
const boundingBox = getCommonBounds(props.elements);
|
|
||||||
const selectedElements = getTargetElements(props.elements, props.appState);
|
|
||||||
const selectedBoundingBox = getCommonBounds(selectedElements);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="Stats">
|
|
||||||
<Island padding={2}>
|
|
||||||
<div className="close" onClick={props.onClose}>
|
|
||||||
{CloseIcon}
|
|
||||||
</div>
|
|
||||||
<h3>{t("stats.title")}</h3>
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<th colSpan={2}>{t("stats.scene")}</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{t("stats.elements")}</td>
|
|
||||||
<td>{props.elements.length}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{t("stats.width")}</td>
|
|
||||||
<td>{Math.round(boundingBox[2]) - Math.round(boundingBox[0])}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{t("stats.height")}</td>
|
|
||||||
<td>{Math.round(boundingBox[3]) - Math.round(boundingBox[1])}</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{selectedElements.length === 1 && (
|
|
||||||
<tr>
|
|
||||||
<th colSpan={2}>{t("stats.element")}</th>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedElements.length > 1 && (
|
|
||||||
<>
|
|
||||||
<tr>
|
|
||||||
<th colSpan={2}>{t("stats.selected")}</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{t("stats.elements")}</td>
|
|
||||||
<td>{selectedElements.length}</td>
|
|
||||||
</tr>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{selectedElements.length > 0 && (
|
|
||||||
<>
|
|
||||||
<tr>
|
|
||||||
<td>{"x"}</td>
|
|
||||||
<td>{Math.round(selectedBoundingBox[0])}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{"y"}</td>
|
|
||||||
<td>{Math.round(selectedBoundingBox[1])}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{t("stats.width")}</td>
|
|
||||||
<td>
|
|
||||||
{Math.round(
|
|
||||||
selectedBoundingBox[2] - selectedBoundingBox[0],
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{t("stats.height")}</td>
|
|
||||||
<td>
|
|
||||||
{Math.round(
|
|
||||||
selectedBoundingBox[3] - selectedBoundingBox[1],
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{selectedElements.length === 1 && (
|
|
||||||
<tr>
|
|
||||||
<td>{t("stats.angle")}</td>
|
|
||||||
<td>
|
|
||||||
{`${Math.round(
|
|
||||||
(selectedElements[0].angle * 180) / Math.PI,
|
|
||||||
)}°`}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{props.renderCustomStats?.(props.elements, props.appState)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</Island>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
93
packages/excalidraw/components/Stats/Angle.tsx
Normal file
93
packages/excalidraw/components/Stats/Angle.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
|
import { getBoundTextElement } from "../../element/textElement";
|
||||||
|
import { isArrowElement } from "../../element/typeChecks";
|
||||||
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
|
import { degreeToRadian, radianToDegree } from "../../math";
|
||||||
|
import { angleIcon } from "../icons";
|
||||||
|
import DragInput from "./DragInput";
|
||||||
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
|
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
|
||||||
|
interface AngleProps {
|
||||||
|
element: ExcalidrawElement;
|
||||||
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
|
property: "angle";
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_SIZE = 15;
|
||||||
|
|
||||||
|
const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
|
||||||
|
accumulatedChange,
|
||||||
|
originalElements,
|
||||||
|
shouldChangeByStepSize,
|
||||||
|
nextValue,
|
||||||
|
scene,
|
||||||
|
}) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
const origElement = originalElements[0];
|
||||||
|
if (origElement) {
|
||||||
|
const latestElement = elementsMap.get(origElement.id);
|
||||||
|
if (!latestElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextValue !== undefined) {
|
||||||
|
const nextAngle = degreeToRadian(nextValue);
|
||||||
|
mutateElement(latestElement, {
|
||||||
|
angle: nextAngle,
|
||||||
|
});
|
||||||
|
updateBindings(latestElement, elementsMap);
|
||||||
|
|
||||||
|
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||||
|
if (boundTextElement && !isArrowElement(latestElement)) {
|
||||||
|
mutateElement(boundTextElement, { angle: nextAngle });
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalAngleInDegrees =
|
||||||
|
Math.round(radianToDegree(origElement.angle) * 100) / 100;
|
||||||
|
const changeInDegrees = Math.round(accumulatedChange);
|
||||||
|
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextAngleInDegrees =
|
||||||
|
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
||||||
|
|
||||||
|
const nextAngle = degreeToRadian(nextAngleInDegrees);
|
||||||
|
|
||||||
|
mutateElement(latestElement, {
|
||||||
|
angle: nextAngle,
|
||||||
|
});
|
||||||
|
updateBindings(latestElement, elementsMap);
|
||||||
|
|
||||||
|
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||||
|
if (boundTextElement && !isArrowElement(latestElement)) {
|
||||||
|
mutateElement(boundTextElement, { angle: nextAngle });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Angle = ({ element, scene, appState, property }: AngleProps) => {
|
||||||
|
return (
|
||||||
|
<DragInput
|
||||||
|
label="A"
|
||||||
|
icon={angleIcon}
|
||||||
|
value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100}
|
||||||
|
elements={[element]}
|
||||||
|
dragInputCallback={handleDegreeChange}
|
||||||
|
editable={isPropertyEditable(element, "angle")}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
property={property}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Angle;
|
39
packages/excalidraw/components/Stats/Collapsible.tsx
Normal file
39
packages/excalidraw/components/Stats/Collapsible.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
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;
|
134
packages/excalidraw/components/Stats/Dimension.tsx
Normal file
134
packages/excalidraw/components/Stats/Dimension.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
|
import DragInput from "./DragInput";
|
||||||
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
|
import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
|
||||||
|
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
|
||||||
|
interface DimensionDragInputProps {
|
||||||
|
property: "width" | "height";
|
||||||
|
element: ExcalidrawElement;
|
||||||
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_SIZE = 10;
|
||||||
|
const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
|
||||||
|
return element.type === "image";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDimensionChange: DragInputCallbackType<
|
||||||
|
DimensionDragInputProps["property"]
|
||||||
|
> = ({
|
||||||
|
accumulatedChange,
|
||||||
|
originalElements,
|
||||||
|
originalElementsMap,
|
||||||
|
shouldKeepAspectRatio,
|
||||||
|
shouldChangeByStepSize,
|
||||||
|
nextValue,
|
||||||
|
property,
|
||||||
|
scene,
|
||||||
|
}) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
const origElement = originalElements[0];
|
||||||
|
if (origElement) {
|
||||||
|
const keepAspectRatio =
|
||||||
|
shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
|
||||||
|
const aspectRatio = origElement.width / origElement.height;
|
||||||
|
|
||||||
|
if (nextValue !== undefined) {
|
||||||
|
const nextWidth = Math.max(
|
||||||
|
property === "width"
|
||||||
|
? nextValue
|
||||||
|
: keepAspectRatio
|
||||||
|
? nextValue * aspectRatio
|
||||||
|
: origElement.width,
|
||||||
|
MIN_WIDTH_OR_HEIGHT,
|
||||||
|
);
|
||||||
|
const nextHeight = Math.max(
|
||||||
|
property === "height"
|
||||||
|
? nextValue
|
||||||
|
: keepAspectRatio
|
||||||
|
? nextValue / aspectRatio
|
||||||
|
: origElement.height,
|
||||||
|
MIN_WIDTH_OR_HEIGHT,
|
||||||
|
);
|
||||||
|
|
||||||
|
resizeElement(
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
keepAspectRatio,
|
||||||
|
origElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||||
|
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||||
|
|
||||||
|
let nextWidth = Math.max(0, origElement.width + changeInWidth);
|
||||||
|
if (property === "width") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextWidth = Math.round(nextWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextHeight = Math.max(0, origElement.height + changeInHeight);
|
||||||
|
if (property === "height") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextHeight = Math.round(nextHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keepAspectRatio) {
|
||||||
|
if (property === "width") {
|
||||||
|
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
|
||||||
|
} else {
|
||||||
|
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||||
|
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||||
|
|
||||||
|
resizeElement(
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
keepAspectRatio,
|
||||||
|
origElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const DimensionDragInput = ({
|
||||||
|
property,
|
||||||
|
element,
|
||||||
|
scene,
|
||||||
|
appState,
|
||||||
|
}: DimensionDragInputProps) => {
|
||||||
|
const value =
|
||||||
|
Math.round((property === "width" ? element.width : element.height) * 100) /
|
||||||
|
100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragInput
|
||||||
|
label={property === "width" ? "W" : "H"}
|
||||||
|
elements={[element]}
|
||||||
|
dragInputCallback={handleDimensionChange}
|
||||||
|
value={value}
|
||||||
|
editable={isPropertyEditable(element, property)}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
property={property}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DimensionDragInput;
|
75
packages/excalidraw/components/Stats/DragInput.scss
Normal file
75
packages/excalidraw/components/Stats/DragInput.scss
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
.excalidraw {
|
||||||
|
.drag-input-container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
box-shadow: 0 0 0 1px var(--color-primary-darkest);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-input-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--default-border-color);
|
||||||
|
border-right: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: var(--popup-text-color);
|
||||||
|
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
|
||||||
|
border-right: 1px solid var(--default-border-color);
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--text-primary-color);
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
height: 2rem;
|
||||||
|
border: 1px solid var(--default-border-color);
|
||||||
|
border-left: 0;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
|
||||||
|
border-left: 1px solid var(--default-border-color);
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
padding: 0.5rem;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
appearance: none;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
311
packages/excalidraw/components/Stats/DragInput.tsx
Normal file
311
packages/excalidraw/components/Stats/DragInput.tsx
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { EVENT } from "../../constants";
|
||||||
|
import { KEYS } from "../../keys";
|
||||||
|
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
||||||
|
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 type { AppState } from "../../types";
|
||||||
|
import { cloneJSON } from "../../utils";
|
||||||
|
|
||||||
|
export type DragInputCallbackType<
|
||||||
|
P extends StatsInputProperty,
|
||||||
|
E = ExcalidrawElement,
|
||||||
|
> = (props: {
|
||||||
|
accumulatedChange: number;
|
||||||
|
instantChange: number;
|
||||||
|
originalElements: readonly E[];
|
||||||
|
originalElementsMap: ElementsMap;
|
||||||
|
shouldKeepAspectRatio: boolean;
|
||||||
|
shouldChangeByStepSize: boolean;
|
||||||
|
nextValue?: number;
|
||||||
|
property: P;
|
||||||
|
scene: Scene;
|
||||||
|
originalAppState: AppState;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
interface StatsDragInputProps<
|
||||||
|
T extends StatsInputProperty,
|
||||||
|
E = ExcalidrawElement,
|
||||||
|
> {
|
||||||
|
label: string | React.ReactNode;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
value: number | "Mixed";
|
||||||
|
elements: readonly E[];
|
||||||
|
editable?: boolean;
|
||||||
|
shouldKeepAspectRatio?: boolean;
|
||||||
|
dragInputCallback: DragInputCallbackType<T, E>;
|
||||||
|
property: T;
|
||||||
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatsDragInput = <
|
||||||
|
T extends StatsInputProperty,
|
||||||
|
E extends ExcalidrawElement = ExcalidrawElement,
|
||||||
|
>({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
dragInputCallback,
|
||||||
|
value,
|
||||||
|
elements,
|
||||||
|
editable = true,
|
||||||
|
shouldKeepAspectRatio,
|
||||||
|
property,
|
||||||
|
scene,
|
||||||
|
appState,
|
||||||
|
}: StatsDragInputProps<T, E>) => {
|
||||||
|
const app = useApp();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const labelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
const inputValue = value.toString();
|
||||||
|
setInputValue(inputValue);
|
||||||
|
stateRef.current.lastUpdatedValue = inputValue;
|
||||||
|
}, [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,
|
||||||
|
nextValue: rounded,
|
||||||
|
property,
|
||||||
|
scene,
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={clsx("drag-input-container", !editable && "disabled")}
|
||||||
|
data-testid={label}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="drag-input-label"
|
||||||
|
ref={labelRef}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (inputRef.current && editable) {
|
||||||
|
let startValue = Number(inputRef.current.value);
|
||||||
|
if (isNaN(startValue)) {
|
||||||
|
startValue = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastPointer: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
let originalElementsMap: Map<string, ExcalidrawElement> | null =
|
||||||
|
app.scene
|
||||||
|
.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;
|
||||||
|
|
||||||
|
document.body.classList.add("excalidraw-cursor-resize");
|
||||||
|
|
||||||
|
const onPointerMove = (event: PointerEvent) => {
|
||||||
|
if (!accumulatedChange) {
|
||||||
|
accumulatedChange = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastPointer &&
|
||||||
|
originalElementsMap !== null &&
|
||||||
|
originalElements !== null &&
|
||||||
|
accumulatedChange !== null
|
||||||
|
) {
|
||||||
|
const instantChange = event.clientX - lastPointer.x;
|
||||||
|
accumulatedChange += instantChange;
|
||||||
|
|
||||||
|
dragInputCallback({
|
||||||
|
accumulatedChange,
|
||||||
|
instantChange,
|
||||||
|
originalElements,
|
||||||
|
originalElementsMap,
|
||||||
|
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
||||||
|
shouldChangeByStepSize: event.shiftKey,
|
||||||
|
property,
|
||||||
|
scene,
|
||||||
|
originalAppState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPointer = {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
|
||||||
|
window.addEventListener(
|
||||||
|
EVENT.POINTER_UP,
|
||||||
|
() => {
|
||||||
|
window.removeEventListener(
|
||||||
|
EVENT.POINTER_MOVE,
|
||||||
|
onPointerMove,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
||||||
|
|
||||||
|
lastPointer = null;
|
||||||
|
accumulatedChange = null;
|
||||||
|
originalElements = null;
|
||||||
|
originalElementsMap = null;
|
||||||
|
|
||||||
|
document.body.classList.remove("excalidraw-cursor-resize");
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerEnter={() => {
|
||||||
|
if (labelRef.current) {
|
||||||
|
labelRef.current.style.cursor = "ew-resize";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon ? <InlineIcon icon={icon} /> : label}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="drag-input"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck="false"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (editable) {
|
||||||
|
const eventTarget = event.target;
|
||||||
|
if (
|
||||||
|
eventTarget instanceof HTMLInputElement &&
|
||||||
|
event.key === KEYS.ENTER
|
||||||
|
) {
|
||||||
|
handleInputValue(eventTarget.value, elements, appState);
|
||||||
|
app.focusContainer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(event) => {
|
||||||
|
stateRef.current.updatePending = true;
|
||||||
|
setInputValue(event.target.value);
|
||||||
|
}}
|
||||||
|
onFocus={(event) => {
|
||||||
|
event.target.select();
|
||||||
|
stateRef.current.originalElements = elements;
|
||||||
|
stateRef.current.originalAppState = cloneJSON(appState);
|
||||||
|
}}
|
||||||
|
onBlur={(event) => {
|
||||||
|
if (!inputValue) {
|
||||||
|
setInputValue(value.toString());
|
||||||
|
} else if (editable) {
|
||||||
|
handleInputValue(
|
||||||
|
event.target.value,
|
||||||
|
stateRef.current.originalElements,
|
||||||
|
stateRef.current.originalAppState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!editable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatsDragInput;
|
99
packages/excalidraw/components/Stats/FontSize.tsx
Normal file
99
packages/excalidraw/components/Stats/FontSize.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import type {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
} from "../../element/types";
|
||||||
|
import StatsDragInput from "./DragInput";
|
||||||
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
|
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 {
|
||||||
|
element: ExcalidrawElement;
|
||||||
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
|
property: "fontSize";
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_FONT_SIZE = 4;
|
||||||
|
const STEP_SIZE = 4;
|
||||||
|
|
||||||
|
const handleFontSizeChange: DragInputCallbackType<
|
||||||
|
FontSizeProps["property"],
|
||||||
|
ExcalidrawTextElement
|
||||||
|
> = ({
|
||||||
|
accumulatedChange,
|
||||||
|
originalElements,
|
||||||
|
shouldChangeByStepSize,
|
||||||
|
nextValue,
|
||||||
|
scene,
|
||||||
|
}) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
|
const origElement = originalElements[0];
|
||||||
|
if (origElement) {
|
||||||
|
const latestElement = elementsMap.get(origElement.id);
|
||||||
|
if (!latestElement || !isTextElement(latestElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextFontSize;
|
||||||
|
|
||||||
|
if (nextValue !== undefined) {
|
||||||
|
nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
|
||||||
|
} else if (origElement.type === "text") {
|
||||||
|
const originalFontSize = Math.round(origElement.fontSize);
|
||||||
|
const changeInFontSize = Math.round(accumulatedChange);
|
||||||
|
nextFontSize = Math.max(
|
||||||
|
originalFontSize + changeInFontSize,
|
||||||
|
MIN_FONT_SIZE,
|
||||||
|
);
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<StatsDragInput
|
||||||
|
label="F"
|
||||||
|
value={Math.round(_element.fontSize * 10) / 10}
|
||||||
|
elements={[_element]}
|
||||||
|
dragInputCallback={handleFontSizeChange}
|
||||||
|
icon={fontSizeIcon}
|
||||||
|
appState={appState}
|
||||||
|
scene={scene}
|
||||||
|
property={property}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FontSize;
|
135
packages/excalidraw/components/Stats/MultiAngle.tsx
Normal file
135
packages/excalidraw/components/Stats/MultiAngle.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
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;
|
382
packages/excalidraw/components/Stats/MultiDimension.tsx
Normal file
382
packages/excalidraw/components/Stats/MultiDimension.tsx
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { getCommonBounds, isTextElement } from "../../element";
|
||||||
|
import { updateBoundElements } from "../../element/binding";
|
||||||
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
|
import { rescalePointsInElement } from "../../element/resizeElements";
|
||||||
|
import {
|
||||||
|
getBoundTextElement,
|
||||||
|
handleBindTextResize,
|
||||||
|
} from "../../element/textElement";
|
||||||
|
import type {
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawElement,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
|
} from "../../element/types";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
import type { AppState, Point } from "../../types";
|
||||||
|
import DragInput from "./DragInput";
|
||||||
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
|
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||||
|
import { getElementsInAtomicUnit, resizeElement } from "./utils";
|
||||||
|
import type { AtomicUnit } from "./utils";
|
||||||
|
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
||||||
|
|
||||||
|
interface MultiDimensionProps {
|
||||||
|
property: "width" | "height";
|
||||||
|
elements: readonly ExcalidrawElement[];
|
||||||
|
elementsMap: NonDeletedSceneElementsMap;
|
||||||
|
atomicUnits: AtomicUnit[];
|
||||||
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_SIZE = 10;
|
||||||
|
|
||||||
|
const getResizedUpdates = (
|
||||||
|
anchorX: number,
|
||||||
|
anchorY: number,
|
||||||
|
scale: number,
|
||||||
|
origElement: ExcalidrawElement,
|
||||||
|
) => {
|
||||||
|
const offsetX = origElement.x - anchorX;
|
||||||
|
const offsetY = origElement.y - anchorY;
|
||||||
|
const nextWidth = origElement.width * scale;
|
||||||
|
const nextHeight = origElement.height * scale;
|
||||||
|
const x = anchorX + offsetX * scale;
|
||||||
|
const y = anchorY + offsetY * scale;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: nextWidth,
|
||||||
|
height: nextHeight,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
...rescalePointsInElement(origElement, nextWidth, nextHeight, false),
|
||||||
|
...(isTextElement(origElement)
|
||||||
|
? { fontSize: origElement.fontSize * scale }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeElementInGroup = (
|
||||||
|
anchorX: number,
|
||||||
|
anchorY: number,
|
||||||
|
property: MultiDimensionProps["property"],
|
||||||
|
scale: number,
|
||||||
|
latestElement: ExcalidrawElement,
|
||||||
|
origElement: ExcalidrawElement,
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
originalElementsMap: ElementsMap,
|
||||||
|
) => {
|
||||||
|
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
|
||||||
|
|
||||||
|
mutateElement(latestElement, updates, false);
|
||||||
|
const boundTextElement = getBoundTextElement(
|
||||||
|
origElement,
|
||||||
|
originalElementsMap,
|
||||||
|
);
|
||||||
|
if (boundTextElement) {
|
||||||
|
const newFontSize = boundTextElement.fontSize * scale;
|
||||||
|
updateBoundElements(latestElement, elementsMap, {
|
||||||
|
newSize: { width: updates.width, height: updates.height },
|
||||||
|
});
|
||||||
|
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
|
||||||
|
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
|
||||||
|
mutateElement(
|
||||||
|
latestBoundTextElement,
|
||||||
|
{
|
||||||
|
fontSize: newFontSize,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
handleBindTextResize(
|
||||||
|
latestElement,
|
||||||
|
elementsMap,
|
||||||
|
property === "width" ? "e" : "s",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.triggerUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const MultiDimension = ({
|
||||||
|
property,
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
atomicUnits,
|
||||||
|
scene,
|
||||||
|
appState,
|
||||||
|
}: MultiDimensionProps) => {
|
||||||
|
const sizes = useMemo(
|
||||||
|
() =>
|
||||||
|
atomicUnits.map((atomicUnit) => {
|
||||||
|
const elementsInUnit = getElementsInAtomicUnit(atomicUnit, elementsMap);
|
||||||
|
|
||||||
|
if (elementsInUnit.length > 1) {
|
||||||
|
const [x1, y1, x2, y2] = getCommonBounds(
|
||||||
|
elementsInUnit.map((el) => el.latest),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
Math.round((property === "width" ? x2 - x1 : y2 - y1) * 100) / 100
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [el] = elementsInUnit;
|
||||||
|
|
||||||
|
return (
|
||||||
|
Math.round(
|
||||||
|
(property === "width" ? el.latest.width : el.latest.height) * 100,
|
||||||
|
) / 100
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[elementsMap, atomicUnits, property],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value =
|
||||||
|
new Set(sizes).size === 1 ? Math.round(sizes[0] * 100) / 100 : "Mixed";
|
||||||
|
|
||||||
|
const editable = sizes.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragInput
|
||||||
|
label={property === "width" ? "W" : "H"}
|
||||||
|
elements={elements}
|
||||||
|
dragInputCallback={handleDimensionChange}
|
||||||
|
value={value}
|
||||||
|
editable={editable}
|
||||||
|
appState={appState}
|
||||||
|
property={property}
|
||||||
|
scene={scene}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MultiDimension;
|
164
packages/excalidraw/components/Stats/MultiFontSize.tsx
Normal file
164
packages/excalidraw/components/Stats/MultiFontSize.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
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;
|
259
packages/excalidraw/components/Stats/MultiPosition.tsx
Normal file
259
packages/excalidraw/components/Stats/MultiPosition.tsx
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
import type {
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawElement,
|
||||||
|
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,
|
||||||
|
) => {
|
||||||
|
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,
|
||||||
|
originalElementsMap,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveGroupTo = (
|
||||||
|
nextX: number,
|
||||||
|
nextY: number,
|
||||||
|
originalElements: ExcalidrawElement[],
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
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,
|
||||||
|
originalElementsMap,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePositionChange: DragInputCallbackType<
|
||||||
|
MultiPositionProps["property"]
|
||||||
|
> = ({
|
||||||
|
accumulatedChange,
|
||||||
|
originalElements,
|
||||||
|
originalElementsMap,
|
||||||
|
shouldChangeByStepSize,
|
||||||
|
nextValue,
|
||||||
|
property,
|
||||||
|
scene,
|
||||||
|
originalAppState,
|
||||||
|
}) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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.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;
|
115
packages/excalidraw/components/Stats/Position.tsx
Normal file
115
packages/excalidraw/components/Stats/Position.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
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 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,
|
||||||
|
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,
|
||||||
|
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}
|
||||||
|
value={value}
|
||||||
|
property={property}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Position;
|
302
packages/excalidraw/components/Stats/index.tsx
Normal file
302
packages/excalidraw/components/Stats/index.tsx
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
import { useEffect, useMemo, useState, memo } from "react";
|
||||||
|
import { getCommonBounds } from "../../element/bounds";
|
||||||
|
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import type { AppState, ExcalidrawProps } from "../../types";
|
||||||
|
import { CloseIcon } from "../icons";
|
||||||
|
import { Island } from "../Island";
|
||||||
|
import { throttle } from "lodash";
|
||||||
|
import Dimension from "./Dimension";
|
||||||
|
import Angle from "./Angle";
|
||||||
|
|
||||||
|
import FontSize from "./FontSize";
|
||||||
|
import MultiDimension from "./MultiDimension";
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface StatsProps {
|
||||||
|
scene: Scene;
|
||||||
|
onClose: () => void;
|
||||||
|
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATS_TIMEOUT = 50;
|
||||||
|
|
||||||
|
export const Stats = (props: StatsProps) => {
|
||||||
|
const appState = useExcalidrawAppState();
|
||||||
|
const sceneNonce = props.scene.getSceneNonce() || 1;
|
||||||
|
const selectedElements = props.scene.getSelectedElements({
|
||||||
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
includeBoundTextElement: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatsInner
|
||||||
|
{...props}
|
||||||
|
appState={appState}
|
||||||
|
sceneNonce={sceneNonce}
|
||||||
|
selectedElements={selectedElements}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
<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
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
756
packages/excalidraw/components/Stats/stats.test.tsx
Normal file
756
packages/excalidraw/components/Stats/stats.test.tsx
Normal file
@ -0,0 +1,756 @@
|
|||||||
|
import { 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";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
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 editInput = (input: HTMLInputElement, value: string) => {
|
||||||
|
input.focus();
|
||||||
|
fireEvent.change(input, { target: { value } });
|
||||||
|
input.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
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());
|
||||||
|
editInput(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} />);
|
||||||
|
|
||||||
|
h.elements = [];
|
||||||
|
|
||||||
|
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();
|
||||||
|
editInput(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);
|
||||||
|
editInput(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();
|
||||||
|
editInput(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);
|
||||||
|
editInput(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} />);
|
||||||
|
|
||||||
|
h.elements = [];
|
||||||
|
|
||||||
|
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());
|
||||||
|
editInput(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);
|
||||||
|
|
||||||
|
editInput(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} />);
|
||||||
|
|
||||||
|
h.elements = [];
|
||||||
|
|
||||||
|
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);
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
updateTextEditor(editor, "Hello!");
|
||||||
|
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());
|
||||||
|
editInput(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
|
||||||
|
editInput(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,
|
||||||
|
});
|
||||||
|
h.elements = [frame];
|
||||||
|
h.setState({
|
||||||
|
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 });
|
||||||
|
h.elements = [image];
|
||||||
|
mouse.clickOn(image);
|
||||||
|
h.setState({
|
||||||
|
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 }],
|
||||||
|
});
|
||||||
|
h.elements = [container, text];
|
||||||
|
|
||||||
|
API.setSelectedElements([container]);
|
||||||
|
const fontSize = getStatsProperty("F")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(fontSize).toBeDefined();
|
||||||
|
|
||||||
|
editInput(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} />);
|
||||||
|
|
||||||
|
h.elements = [];
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
h.setState({
|
||||||
|
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");
|
||||||
|
|
||||||
|
editInput(width, "250");
|
||||||
|
h.elements.forEach((el) => {
|
||||||
|
expect(el.width).toBe(250);
|
||||||
|
});
|
||||||
|
|
||||||
|
editInput(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);
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
updateTextEditor(editor, "Hello!");
|
||||||
|
editor.blur();
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down();
|
||||||
|
mouse.up(200, 100);
|
||||||
|
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
x: 150,
|
||||||
|
width: 150,
|
||||||
|
});
|
||||||
|
|
||||||
|
h.elements = [...h.elements, frame];
|
||||||
|
|
||||||
|
const text = h.elements.find((el) => el.type === "text");
|
||||||
|
const rectangle = h.elements.find((el) => el.type === "rectangle");
|
||||||
|
|
||||||
|
h.setState({
|
||||||
|
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
|
||||||
|
editInput(width, "200");
|
||||||
|
|
||||||
|
expect(rectangle?.width).toBe(200);
|
||||||
|
expect(frame.width).toBe(200);
|
||||||
|
expect(text?.width).not.toBe(200);
|
||||||
|
|
||||||
|
editInput(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();
|
||||||
|
});
|
||||||
|
|
||||||
|
h.app.actionManager.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);
|
||||||
|
|
||||||
|
editInput(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);
|
||||||
|
|
||||||
|
editInput(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);
|
||||||
|
|
||||||
|
editInput(width, "400");
|
||||||
|
|
||||||
|
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||||
|
let newGroupWidth = x2 - x1;
|
||||||
|
|
||||||
|
expect(newGroupWidth).toBeCloseTo(400, 4);
|
||||||
|
|
||||||
|
editInput(width, "300");
|
||||||
|
|
||||||
|
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||||
|
newGroupWidth = x2 - x1;
|
||||||
|
expect(newGroupWidth).toBeCloseTo(300, 4);
|
||||||
|
|
||||||
|
editInput(height, "500");
|
||||||
|
|
||||||
|
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||||
|
const newGroupHeight = y2 - y1;
|
||||||
|
expect(newGroupHeight).toBeCloseTo(500, 4);
|
||||||
|
});
|
||||||
|
});
|
301
packages/excalidraw/components/Stats/utils.ts
Normal file
301
packages/excalidraw/components/Stats/utils.ts
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
import {
|
||||||
|
bindOrUnbindLinearElements,
|
||||||
|
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 { 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 = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
property: keyof ExcalidrawElement,
|
||||||
|
) => {
|
||||||
|
if (property === "height" && isTextElement(element)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (property === "width" && isTextElement(element)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (property === "angle" && isFrameLikeElement(element)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStepSizedValue = (value: number, stepSize: number) => {
|
||||||
|
const v = value + stepSize / 2;
|
||||||
|
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,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boundTextElement && boundTextFont) {
|
||||||
|
mutateElement(boundTextElement, {
|
||||||
|
fontSize: boundTextFont.fontSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
handleBindTextResize(latestElement, elementsMap, "e", keepAspectRatio);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const moveElement = (
|
||||||
|
newTopLeftX: number,
|
||||||
|
newTopLeftY: number,
|
||||||
|
originalElement: ExcalidrawElement,
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
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);
|
||||||
|
|
||||||
|
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,
|
||||||
|
options?: {
|
||||||
|
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
||||||
|
newSize?: { width: number; height: number };
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if (isLinearElement(latestElement)) {
|
||||||
|
bindOrUnbindLinearElements([latestElement], elementsMap, 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: 800;
|
font-weight: 700;
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
|
import type { MermaidConfig } 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 {
|
import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "../../constants";
|
||||||
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";
|
||||||
@ -38,7 +34,7 @@ export interface MermaidToExcalidrawLibProps {
|
|||||||
api: Promise<{
|
api: Promise<{
|
||||||
parseMermaidToExcalidraw: (
|
parseMermaidToExcalidraw: (
|
||||||
definition: string,
|
definition: string,
|
||||||
options: MermaidOptions,
|
config?: MermaidConfig,
|
||||||
) => Promise<MermaidToExcalidrawResult>;
|
) => Promise<MermaidToExcalidrawResult>;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
@ -78,15 +74,10 @@ 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;
|
||||||
|
@ -5,10 +5,11 @@
|
|||||||
--avatarList-gap: 0.625rem;
|
--avatarList-gap: 0.625rem;
|
||||||
--userList-padding: var(--space-factor);
|
--userList-padding: var(--space-factor);
|
||||||
|
|
||||||
.UserList-wrapper {
|
.UserList__wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -21,10 +22,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--avatarList-gap);
|
gap: var(--avatarList-gap);
|
||||||
|
|
||||||
&:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
--max-size: calc(
|
--max-size: calc(
|
||||||
@ -157,66 +154,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.UserList__collaborators {
|
.UserList__collaborators {
|
||||||
position: static;
|
|
||||||
top: auto;
|
top: auto;
|
||||||
margin-top: 0;
|
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-top: 1px solid var(--userlist-collaborators-border-color);
|
|
||||||
border-bottom: 1px solid var(--userlist-collaborators-border-color);
|
|
||||||
|
|
||||||
&__empty {
|
|
||||||
color: var(--color-gray-60);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
line-height: 150%;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.UserList__hint {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--userlist-hint-text-color);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
line-height: 150%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.UserList__search-wrapper {
|
|
||||||
position: relative;
|
|
||||||
height: 2.5rem;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
left: 0.75rem;
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
color: var(--color-gray-40);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.UserList__search {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 0 !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
padding-left: 2.5rem !important;
|
|
||||||
padding-right: 0.75rem !important;
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: var(--color-gray-40);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,11 +9,12 @@ import type { ActionManager } from "../actions/manager";
|
|||||||
|
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import { searchIcon } from "./icons";
|
import { QuickSearch } from "./QuickSearch";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isShallowEqual } from "../utils";
|
import { isShallowEqual } from "../utils";
|
||||||
import { supportsResizeObserver } from "../constants";
|
import { supportsResizeObserver } from "../constants";
|
||||||
import type { MarkRequired } from "../utility-types";
|
import type { MarkRequired } from "../utility-types";
|
||||||
|
import { ScrollableList } from "./ScrollableList";
|
||||||
|
|
||||||
export type GoToCollaboratorComponentProps = {
|
export type GoToCollaboratorComponentProps = {
|
||||||
socketId: SocketId;
|
socketId: SocketId;
|
||||||
@ -40,7 +41,7 @@ const ConditionalTooltipWrapper = ({
|
|||||||
shouldWrap ? (
|
shouldWrap ? (
|
||||||
<Tooltip label={username || "Unknown user"}>{children}</Tooltip>
|
<Tooltip label={username || "Unknown user"}>{children}</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<React.Fragment>{children}</React.Fragment>
|
<>{children}</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderCollaborator = ({
|
const renderCollaborator = ({
|
||||||
@ -128,6 +129,10 @@ export const UserList = React.memo(
|
|||||||
).filter((collaborator) => collaborator.username?.trim());
|
).filter((collaborator) => collaborator.username?.trim());
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = React.useState("");
|
const [searchTerm, setSearchTerm] = React.useState("");
|
||||||
|
const filteredCollaborators = uniqueCollaboratorsArray.filter(
|
||||||
|
(collaborator) =>
|
||||||
|
collaborator.username?.toLowerCase().includes(searchTerm),
|
||||||
|
);
|
||||||
|
|
||||||
const userListWrapper = React.useRef<HTMLDivElement | null>(null);
|
const userListWrapper = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@ -161,14 +166,6 @@ export const UserList = React.memo(
|
|||||||
|
|
||||||
const [maxAvatars, setMaxAvatars] = React.useState(DEFAULT_MAX_AVATARS);
|
const [maxAvatars, setMaxAvatars] = React.useState(DEFAULT_MAX_AVATARS);
|
||||||
|
|
||||||
const searchTermNormalized = searchTerm.trim().toLowerCase();
|
|
||||||
|
|
||||||
const filteredCollaborators = searchTermNormalized
|
|
||||||
? uniqueCollaboratorsArray.filter((collaborator) =>
|
|
||||||
collaborator.username?.toLowerCase().includes(searchTerm),
|
|
||||||
)
|
|
||||||
: uniqueCollaboratorsArray;
|
|
||||||
|
|
||||||
const firstNCollaborators = uniqueCollaboratorsArray.slice(
|
const firstNCollaborators = uniqueCollaboratorsArray.slice(
|
||||||
0,
|
0,
|
||||||
maxAvatars - 1,
|
maxAvatars - 1,
|
||||||
@ -197,7 +194,7 @@ export const UserList = React.memo(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="UserList-wrapper" ref={userListWrapper}>
|
<div className="UserList__wrapper" ref={userListWrapper}>
|
||||||
<div
|
<div
|
||||||
className={clsx("UserList", className)}
|
className={clsx("UserList", className)}
|
||||||
style={{ [`--max-avatars` as any]: maxAvatars }}
|
style={{ [`--max-avatars` as any]: maxAvatars }}
|
||||||
@ -205,13 +202,7 @@ export const UserList = React.memo(
|
|||||||
{firstNAvatarsJSX}
|
{firstNAvatarsJSX}
|
||||||
|
|
||||||
{uniqueCollaboratorsArray.length > maxAvatars - 1 && (
|
{uniqueCollaboratorsArray.length > maxAvatars - 1 && (
|
||||||
<Popover.Root
|
<Popover.Root>
|
||||||
onOpenChange={(isOpen) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
setSearchTerm("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Popover.Trigger className="UserList__more">
|
<Popover.Trigger className="UserList__more">
|
||||||
+{uniqueCollaboratorsArray.length - maxAvatars + 1}
|
+{uniqueCollaboratorsArray.length - maxAvatars + 1}
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
@ -224,41 +215,43 @@ export const UserList = React.memo(
|
|||||||
align="end"
|
align="end"
|
||||||
sideOffset={10}
|
sideOffset={10}
|
||||||
>
|
>
|
||||||
<Island style={{ overflow: "hidden" }}>
|
<Island padding={2}>
|
||||||
{uniqueCollaboratorsArray.length >=
|
{uniqueCollaboratorsArray.length >=
|
||||||
SHOW_COLLABORATORS_FILTER_AT && (
|
SHOW_COLLABORATORS_FILTER_AT && (
|
||||||
<div className="UserList__search-wrapper">
|
<QuickSearch
|
||||||
{searchIcon}
|
placeholder={t("quickSearch.placeholder")}
|
||||||
<input
|
onChange={setSearchTerm}
|
||||||
className="UserList__search"
|
/>
|
||||||
type="text"
|
|
||||||
placeholder={t("userList.search.placeholder")}
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearchTerm(e.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="dropdown-menu UserList__collaborators">
|
<ScrollableList
|
||||||
{filteredCollaborators.length === 0 && (
|
className={"dropdown-menu UserList__collaborators"}
|
||||||
<div className="UserList__collaborators__empty">
|
placeholder={t("userList.empty")}
|
||||||
{t("userList.search.empty")}
|
>
|
||||||
</div>
|
{/* The list checks for `Children.count()`, hence defensively returning empty list */}
|
||||||
)}
|
{filteredCollaborators.length > 0
|
||||||
<div className="UserList__hint">
|
? [
|
||||||
{t("userList.hint.text")}
|
<div className="hint">{t("userList.hint.text")}</div>,
|
||||||
</div>
|
filteredCollaborators.map((collaborator) =>
|
||||||
{filteredCollaborators.map((collaborator) =>
|
renderCollaborator({
|
||||||
renderCollaborator({
|
actionManager,
|
||||||
actionManager,
|
collaborator,
|
||||||
collaborator,
|
socketId: collaborator.socketId,
|
||||||
socketId: collaborator.socketId,
|
withName: true,
|
||||||
withName: true,
|
isBeingFollowed:
|
||||||
isBeingFollowed: collaborator.socketId === userToFollow,
|
collaborator.socketId === userToFollow,
|
||||||
}),
|
}),
|
||||||
)}
|
),
|
||||||
</div>
|
]
|
||||||
|
: []}
|
||||||
|
</ScrollableList>
|
||||||
|
<Popover.Arrow
|
||||||
|
width={20}
|
||||||
|
height={10}
|
||||||
|
style={{
|
||||||
|
fill: "var(--popup-bg-color)",
|
||||||
|
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Island>
|
</Island>
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover.Root>
|
</Popover.Root>
|
||||||
|
@ -9,7 +9,10 @@ import type {
|
|||||||
RenderableElementsMap,
|
RenderableElementsMap,
|
||||||
RenderInteractiveSceneCallback,
|
RenderInteractiveSceneCallback,
|
||||||
} from "../../scene/types";
|
} from "../../scene/types";
|
||||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
import type {
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
|
} from "../../element/types";
|
||||||
import { isRenderThrottlingEnabled } from "../../reactUtils";
|
import { isRenderThrottlingEnabled } from "../../reactUtils";
|
||||||
import { renderInteractiveScene } from "../../renderer/interactiveScene";
|
import { renderInteractiveScene } from "../../renderer/interactiveScene";
|
||||||
|
|
||||||
@ -19,7 +22,8 @@ type InteractiveCanvasProps = {
|
|||||||
elementsMap: RenderableElementsMap;
|
elementsMap: RenderableElementsMap;
|
||||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||||
versionNonce: number | undefined;
|
allElementsMap: NonDeletedSceneElementsMap;
|
||||||
|
sceneNonce: number | undefined;
|
||||||
selectionNonce: number | undefined;
|
selectionNonce: number | undefined;
|
||||||
scale: number;
|
scale: number;
|
||||||
appState: InteractiveCanvasAppState;
|
appState: InteractiveCanvasAppState;
|
||||||
@ -122,6 +126,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
|||||||
elementsMap: props.elementsMap,
|
elementsMap: props.elementsMap,
|
||||||
visibleElements: props.visibleElements,
|
visibleElements: props.visibleElements,
|
||||||
selectedElements: props.selectedElements,
|
selectedElements: props.selectedElements,
|
||||||
|
allElementsMap: props.allElementsMap,
|
||||||
scale: window.devicePixelRatio,
|
scale: window.devicePixelRatio,
|
||||||
appState: props.appState,
|
appState: props.appState,
|
||||||
renderConfig: {
|
renderConfig: {
|
||||||
@ -197,6 +202,7 @@ const getRelevantAppStateProps = (
|
|||||||
activeEmbeddable: appState.activeEmbeddable,
|
activeEmbeddable: appState.activeEmbeddable,
|
||||||
snapLines: appState.snapLines,
|
snapLines: appState.snapLines,
|
||||||
zenModeEnabled: appState.zenModeEnabled,
|
zenModeEnabled: appState.zenModeEnabled,
|
||||||
|
editingElement: appState.editingElement,
|
||||||
});
|
});
|
||||||
|
|
||||||
const areEqual = (
|
const areEqual = (
|
||||||
@ -206,10 +212,10 @@ const areEqual = (
|
|||||||
// This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation
|
// This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation
|
||||||
if (
|
if (
|
||||||
prevProps.selectionNonce !== nextProps.selectionNonce ||
|
prevProps.selectionNonce !== nextProps.selectionNonce ||
|
||||||
prevProps.versionNonce !== nextProps.versionNonce ||
|
prevProps.sceneNonce !== nextProps.sceneNonce ||
|
||||||
prevProps.scale !== nextProps.scale ||
|
prevProps.scale !== nextProps.scale ||
|
||||||
// we need to memoize on elementsMap because they may have renewed
|
// we need to memoize on elementsMap because they may have renewed
|
||||||
// even if versionNonce didn't change (e.g. we filter elements out based
|
// even if sceneNonce didn't change (e.g. we filter elements out based
|
||||||
// on appState)
|
// on appState)
|
||||||
prevProps.elementsMap !== nextProps.elementsMap ||
|
prevProps.elementsMap !== nextProps.elementsMap ||
|
||||||
prevProps.visibleElements !== nextProps.visibleElements ||
|
prevProps.visibleElements !== nextProps.visibleElements ||
|
||||||
|
@ -19,7 +19,7 @@ type StaticCanvasProps = {
|
|||||||
elementsMap: RenderableElementsMap;
|
elementsMap: RenderableElementsMap;
|
||||||
allElementsMap: NonDeletedSceneElementsMap;
|
allElementsMap: NonDeletedSceneElementsMap;
|
||||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||||
versionNonce: number | undefined;
|
sceneNonce: number | undefined;
|
||||||
selectionNonce: number | undefined;
|
selectionNonce: number | undefined;
|
||||||
scale: number;
|
scale: number;
|
||||||
appState: StaticCanvasAppState;
|
appState: StaticCanvasAppState;
|
||||||
@ -105,6 +105,7 @@ const getRelevantAppStateProps = (
|
|||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
frameToHighlight: appState.frameToHighlight,
|
frameToHighlight: appState.frameToHighlight,
|
||||||
editingGroupId: appState.editingGroupId,
|
editingGroupId: appState.editingGroupId,
|
||||||
|
currentHoveredFontFamily: appState.currentHoveredFontFamily,
|
||||||
});
|
});
|
||||||
|
|
||||||
const areEqual = (
|
const areEqual = (
|
||||||
@ -112,10 +113,10 @@ const areEqual = (
|
|||||||
nextProps: StaticCanvasProps,
|
nextProps: StaticCanvasProps,
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
prevProps.versionNonce !== nextProps.versionNonce ||
|
prevProps.sceneNonce !== nextProps.sceneNonce ||
|
||||||
prevProps.scale !== nextProps.scale ||
|
prevProps.scale !== nextProps.scale ||
|
||||||
// we need to memoize on elementsMap because they may have renewed
|
// we need to memoize on elementsMap because they may have renewed
|
||||||
// even if versionNonce didn't change (e.g. we filter elements out based
|
// even if sceneNonce didn't change (e.g. we filter elements out based
|
||||||
// on appState)
|
// on appState)
|
||||||
prevProps.elementsMap !== nextProps.elementsMap ||
|
prevProps.elementsMap !== nextProps.elementsMap ||
|
||||||
prevProps.visibleElements !== nextProps.visibleElements
|
prevProps.visibleElements !== nextProps.visibleElements
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.5rem;
|
||||||
|
|
||||||
&--mobile {
|
&--mobile {
|
||||||
left: 0;
|
left: 0;
|
||||||
@ -35,21 +35,69 @@
|
|||||||
|
|
||||||
.dropdown-menu-item-base {
|
.dropdown-menu-item-base {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 0 0.625rem;
|
|
||||||
column-gap: 0.625rem;
|
column-gap: 0.625rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-weight: normal;
|
font-weight: 400;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.manual-hover {
|
||||||
|
// disable built-in hover due to keyboard navigation
|
||||||
|
.dropdown-menu-item {
|
||||||
|
&:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--hovered {
|
||||||
|
background-color: var(--button-hover-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--selected {
|
||||||
|
background-color: var(--color-primary-light) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fonts {
|
||||||
|
margin-top: 1rem;
|
||||||
|
// display max 7 items per list, where each has 2rem (2.25) height and 1px margin top & bottom
|
||||||
|
// count in 2 groups, where each allocates 1.3*0.75rem font-size and 0.5rem margin bottom, plus one extra 1rem margin top
|
||||||
|
max-height: calc(7 * (2rem + 2px) + 2 * (0.5rem + 1.3 * 0.75rem) + 1rem);
|
||||||
|
|
||||||
|
@media screen and (min-width: 1921px) {
|
||||||
|
max-height: calc(
|
||||||
|
7 * (2.25rem + 2px) + 2 * (0.5rem + 1.3 * 0.75rem) + 1rem
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu-item-base {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu-group:not(:first-child) {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu-group-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-menu-item {
|
.dropdown-menu-item {
|
||||||
|
height: 2rem;
|
||||||
|
margin: 1px;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
width: calc(100% - 2px);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 2rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
|
|
||||||
@ -57,11 +105,6 @@
|
|||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--selected {
|
|
||||||
background: var(--color-primary-light);
|
|
||||||
--icon-fill-color: var(--color-primary-darker);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__text {
|
&__text {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -83,6 +126,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--selected {
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
--icon-fill-color: var(--color-primary-darker);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--button-hover-bg);
|
background-color: var(--button-hover-bg);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
@ -1,37 +1,62 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
getDropdownMenuItemClassName,
|
getDropdownMenuItemClassName,
|
||||||
useHandleDropdownMenuItemClick,
|
useHandleDropdownMenuItemClick,
|
||||||
} from "./common";
|
} from "./common";
|
||||||
import MenuItemContent from "./DropdownMenuItemContent";
|
import MenuItemContent from "./DropdownMenuItemContent";
|
||||||
|
import { useExcalidrawAppState } from "../App";
|
||||||
|
import { THEME } from "../../constants";
|
||||||
|
import type { ValueOf } from "../../utility-types";
|
||||||
|
|
||||||
const DropdownMenuItem = ({
|
const DropdownMenuItem = ({
|
||||||
icon,
|
icon,
|
||||||
onSelect,
|
value,
|
||||||
|
order,
|
||||||
children,
|
children,
|
||||||
shortcut,
|
shortcut,
|
||||||
className,
|
className,
|
||||||
|
hovered,
|
||||||
selected,
|
selected,
|
||||||
|
textStyle,
|
||||||
|
onSelect,
|
||||||
|
onClick,
|
||||||
...rest
|
...rest
|
||||||
}: {
|
}: {
|
||||||
icon?: JSX.Element;
|
icon?: JSX.Element;
|
||||||
onSelect: (event: Event) => void;
|
value?: string | number | undefined;
|
||||||
|
order?: number;
|
||||||
|
onSelect?: (event: Event) => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
shortcut?: string;
|
shortcut?: string;
|
||||||
|
hovered?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
|
textStyle?: React.CSSProperties;
|
||||||
className?: string;
|
className?: string;
|
||||||
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||||
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect);
|
||||||
|
const ref = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hovered) {
|
||||||
|
if (order === 0) {
|
||||||
|
// scroll into the first item differently, so it's visible what is above (i.e. group title)
|
||||||
|
ref.current?.scrollIntoView({ block: "end" });
|
||||||
|
} else {
|
||||||
|
ref.current?.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [hovered, order]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
{...rest}
|
{...rest}
|
||||||
|
ref={ref}
|
||||||
|
value={value}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
type="button"
|
className={getDropdownMenuItemClassName(className, selected, hovered)}
|
||||||
className={getDropdownMenuItemClassName(className, selected)}
|
|
||||||
title={rest.title ?? rest["aria-label"]}
|
title={rest.title ?? rest["aria-label"]}
|
||||||
>
|
>
|
||||||
<MenuItemContent icon={icon} shortcut={shortcut}>
|
<MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
|
||||||
{children}
|
{children}
|
||||||
</MenuItemContent>
|
</MenuItemContent>
|
||||||
</button>
|
</button>
|
||||||
@ -39,24 +64,53 @@ const DropdownMenuItem = ({
|
|||||||
};
|
};
|
||||||
DropdownMenuItem.displayName = "DropdownMenuItem";
|
DropdownMenuItem.displayName = "DropdownMenuItem";
|
||||||
|
|
||||||
|
export const DropDownMenuItemBadgeType = {
|
||||||
|
GREEN: "green",
|
||||||
|
RED: "red",
|
||||||
|
BLUE: "blue",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const DropDownMenuItemBadge = ({
|
export const DropDownMenuItemBadge = ({
|
||||||
|
type = DropDownMenuItemBadgeType.BLUE,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
type?: ValueOf<typeof DropDownMenuItemBadgeType>;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
const { theme } = useExcalidrawAppState();
|
||||||
<div
|
const style = {
|
||||||
style={{
|
display: "inline-flex",
|
||||||
display: "inline-flex",
|
marginLeft: "auto",
|
||||||
marginLeft: "auto",
|
padding: "2px 4px",
|
||||||
padding: "2px 4px",
|
borderRadius: 6,
|
||||||
|
fontSize: 9,
|
||||||
|
fontFamily: "Cascadia, monospace",
|
||||||
|
border: theme === THEME.LIGHT ? "1.5px solid white" : "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case DropDownMenuItemBadgeType.GREEN:
|
||||||
|
Object.assign(style, {
|
||||||
|
backgroundColor: "var(--background-color-badge)",
|
||||||
|
color: "var(--color-badge)",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case DropDownMenuItemBadgeType.RED:
|
||||||
|
Object.assign(style, {
|
||||||
|
backgroundColor: "pink",
|
||||||
|
color: "darkred",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case DropDownMenuItemBadgeType.BLUE:
|
||||||
|
default:
|
||||||
|
Object.assign(style, {
|
||||||
background: "var(--color-promo)",
|
background: "var(--color-promo)",
|
||||||
color: "var(--color-surface-lowest)",
|
color: "var(--color-surface-lowest)",
|
||||||
borderRadius: 6,
|
});
|
||||||
fontSize: 9,
|
}
|
||||||
fontFamily: "Cascadia, monospace",
|
|
||||||
}}
|
return (
|
||||||
>
|
<div className="DropDownMenuItemBadge" style={style}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,19 +1,23 @@
|
|||||||
import { useDevice } from "../App";
|
import { useDevice } from "../App";
|
||||||
|
|
||||||
const MenuItemContent = ({
|
const MenuItemContent = ({
|
||||||
|
textStyle,
|
||||||
icon,
|
icon,
|
||||||
shortcut,
|
shortcut,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
icon?: JSX.Element;
|
icon?: JSX.Element;
|
||||||
shortcut?: string;
|
shortcut?: string;
|
||||||
|
textStyle?: React.CSSProperties;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="dropdown-menu-item__icon">{icon}</div>
|
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
|
||||||
<div className="dropdown-menu-item__text">{children}</div>
|
<div style={textStyle} className="dropdown-menu-item__text">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
{shortcut && !device.editor.isMobile && (
|
{shortcut && !device.editor.isMobile && (
|
||||||
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
||||||
)}
|
)}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user