Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11ee139d5c | |||
| 16c0417f5b |
+1
-1
@@ -22,7 +22,7 @@ VITE_APP_DEV_ENABLE_SW=
|
||||
# whether to disable live reload / HMR. Usuaully what you want to do when
|
||||
# debugging Service Workers.
|
||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||
VITE_APP_ENABLE_TRACKING=true
|
||||
VITE_APP_DISABLE_TRACKING=true
|
||||
|
||||
FAST_REFRESH=false
|
||||
|
||||
|
||||
+1
-1
@@ -14,4 +14,4 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
|
||||
|
||||
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||
|
||||
VITE_APP_ENABLE_TRACKING=false
|
||||
VITE_APP_DISABLE_TRACKING=
|
||||
|
||||
@@ -9,9 +9,9 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18.x
|
||||
- name: Install and test
|
||||
|
||||
@@ -90,7 +90,7 @@ function App() {
|
||||
<img src={canvasUrl} alt="" />
|
||||
</div>
|
||||
<div style={{ height: "400px" }}>
|
||||
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||
<Excalidraw ref={(api) => setExcalidrawAPI(api)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -59,7 +59,7 @@ pre a {
|
||||
padding: 5px;
|
||||
background: #70b1ec;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
||||
+9
-17
@@ -1547,7 +1547,7 @@
|
||||
"@docusaurus/theme-search-algolia" "2.2.0"
|
||||
"@docusaurus/types" "2.2.0"
|
||||
|
||||
"@docusaurus/react-loadable@5.5.2":
|
||||
"@docusaurus/react-loadable@5.5.2", "react-loadable@npm:@docusaurus/react-loadable@5.5.2":
|
||||
version "5.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
|
||||
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
|
||||
@@ -2790,11 +2790,11 @@ brace-expansion@^1.1.7:
|
||||
concat-map "0.0.1"
|
||||
|
||||
braces@^3.0.2, braces@~3.0.2:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
|
||||
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
|
||||
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
|
||||
dependencies:
|
||||
fill-range "^7.1.1"
|
||||
fill-range "^7.0.1"
|
||||
|
||||
browserslist@^4.0.0, browserslist@^4.14.5, browserslist@^4.16.6, browserslist@^4.18.1, browserslist@^4.20.2, browserslist@^4.20.3, browserslist@^4.21.2:
|
||||
version "4.21.2"
|
||||
@@ -4004,10 +4004,10 @@ filesize@^8.0.6:
|
||||
resolved "https://registry.yarnpkg.com/filesize/-/filesize-8.0.7.tgz#695e70d80f4e47012c132d57a059e80c6b580bd8"
|
||||
integrity sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==
|
||||
|
||||
fill-range@^7.1.1:
|
||||
version "7.1.1"
|
||||
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
|
||||
integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
|
||||
fill-range@^7.0.1:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
|
||||
integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
|
||||
dependencies:
|
||||
to-regex-range "^5.0.1"
|
||||
|
||||
@@ -6260,14 +6260,6 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.10.3"
|
||||
|
||||
"react-loadable@npm:@docusaurus/react-loadable@5.5.2":
|
||||
version "5.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
|
||||
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
prop-types "^15.6.2"
|
||||
|
||||
react-router-config@^5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988"
|
||||
|
||||
@@ -872,7 +872,7 @@ export default function App({
|
||||
files: excalidrawAPI.getFiles(),
|
||||
});
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.font = "30px Excalifont";
|
||||
ctx.font = "30px Virgil";
|
||||
ctx.strokeText("My custom text", 50, 60);
|
||||
setCanvasUrl(canvas.toDataURL());
|
||||
}}
|
||||
@@ -893,7 +893,7 @@ export default function App({
|
||||
files: excalidrawAPI.getFiles(),
|
||||
});
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.font = "30px Excalifont";
|
||||
ctx.font = "30px Virgil";
|
||||
ctx.strokeText("My custom text", 50, 60);
|
||||
setCanvasUrl(canvas.toDataURL());
|
||||
}}
|
||||
|
||||
@@ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [
|
||||
];
|
||||
export default {
|
||||
elements,
|
||||
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 },
|
||||
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 },
|
||||
scrollToContent: true,
|
||||
libraryItems: [
|
||||
[
|
||||
|
||||
@@ -34,6 +34,3 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# copied assets
|
||||
public/*.woff2
|
||||
@@ -3,8 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
||||
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
|
||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm",
|
||||
"dev": "yarn build:workspace && next dev -p 3005",
|
||||
"build": "yarn build:workspace && next build",
|
||||
"start": "next start -p 3006",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import Script from "next/script";
|
||||
import "../common.scss";
|
||||
|
||||
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
|
||||
@@ -16,9 +15,7 @@ export default function Page() {
|
||||
<>
|
||||
<a href="/excalidraw-in-pages">Switch to Pages router</a>
|
||||
<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 */}
|
||||
<ExcalidrawWithClientOnly />
|
||||
</>
|
||||
|
||||
@@ -7,7 +7,7 @@ a {
|
||||
color: #1c7ed6;
|
||||
font-size: 20px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# copied assets
|
||||
public/*.woff2
|
||||
@@ -11,7 +11,6 @@
|
||||
<title>React App</title>
|
||||
<script>
|
||||
window.name = "codesandbox";
|
||||
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
||||
</script>
|
||||
<link rel="stylesheet" href="/dist/browser/dev/index.css" />
|
||||
</head>
|
||||
|
||||
@@ -12,10 +12,8 @@
|
||||
"typescript": "^5"
|
||||
},
|
||||
"scripts": {
|
||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
||||
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
|
||||
"start": "yarn build:workspace && vite",
|
||||
"build": "yarn build:workspace && vite build",
|
||||
"start": "yarn workspace @excalidraw/excalidraw run build:esm && vite",
|
||||
"build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build",
|
||||
"build:preview": "yarn build && vite preview --port 5002"
|
||||
}
|
||||
}
|
||||
|
||||
+23
-6
@@ -1,4 +1,5 @@
|
||||
import polyfill from "../packages/excalidraw/polyfill";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { trackEvent } from "../packages/excalidraw/analytics";
|
||||
import { getDefaultAppState } from "../packages/excalidraw/appState";
|
||||
@@ -21,6 +22,7 @@ import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRef
|
||||
import { t } from "../packages/excalidraw/i18n";
|
||||
import {
|
||||
Excalidraw,
|
||||
defaultLang,
|
||||
LiveCollaborationTrigger,
|
||||
TTDDialog,
|
||||
TTDDialogTrigger,
|
||||
@@ -91,7 +93,7 @@ import {
|
||||
import { AppMainMenu } from "./components/AppMainMenu";
|
||||
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
||||
import { AppFooter } from "./components/AppFooter";
|
||||
import { Provider, useAtom, useAtomValue } from "jotai";
|
||||
import { atom, Provider, useAtom, useAtomValue } from "jotai";
|
||||
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
|
||||
import { appJotaiStore } from "./app-jotai";
|
||||
|
||||
@@ -119,8 +121,6 @@ import {
|
||||
youtubeIcon,
|
||||
} from "../packages/excalidraw/components/icons";
|
||||
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
||||
import { getPreferredLanguage } from "./app-language/language-detector";
|
||||
import { useAppLangCode } from "./app-language/language-state";
|
||||
|
||||
polyfill();
|
||||
|
||||
@@ -172,6 +172,11 @@ if (window.self !== window.top) {
|
||||
}
|
||||
}
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
languageDetector.init({
|
||||
languageUtils: {},
|
||||
});
|
||||
|
||||
const shareableLinkConfirmDialog = {
|
||||
title: t("overwriteConfirm.modal.shareableLink.title"),
|
||||
description: (
|
||||
@@ -317,15 +322,19 @@ const initializeScene = async (opts: {
|
||||
return { scene: null, isExternalScene: false };
|
||||
};
|
||||
|
||||
const detectedLangCode = languageDetector.detect() || defaultLang.code;
|
||||
export const appLangCodeAtom = atom(
|
||||
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
|
||||
);
|
||||
|
||||
const ExcalidrawWrapper = () => {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
||||
const isCollabDisabled = isRunningInIframe();
|
||||
|
||||
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
||||
const { editorTheme } = useHandleAppTheme();
|
||||
|
||||
const [langCode, setLangCode] = useAppLangCode();
|
||||
|
||||
// initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -481,7 +490,11 @@ const ExcalidrawWrapper = () => {
|
||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
||||
const localDataState = importFromLocalStorage();
|
||||
const username = importUsernameFromLocalStorage();
|
||||
setLangCode(getPreferredLanguage());
|
||||
let langCode = languageDetector.detect() || defaultLang.code;
|
||||
if (Array.isArray(langCode)) {
|
||||
langCode = langCode[0];
|
||||
}
|
||||
setLangCode(langCode);
|
||||
excalidrawAPI.updateScene({
|
||||
...localDataState,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
@@ -582,6 +595,10 @@ const ExcalidrawWrapper = () => {
|
||||
};
|
||||
}, [excalidrawAPI]);
|
||||
|
||||
useEffect(() => {
|
||||
languageDetector.cacheUserLanguage(langCode);
|
||||
}, [langCode]);
|
||||
|
||||
const onChange = (
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { defaultLang, languages } from "../../packages/excalidraw";
|
||||
|
||||
export const languageDetector = new LanguageDetector();
|
||||
|
||||
languageDetector.init({
|
||||
languageUtils: {},
|
||||
});
|
||||
|
||||
export const getPreferredLanguage = () => {
|
||||
const detectedLanguages = languageDetector.detect();
|
||||
|
||||
const detectedLanguage = Array.isArray(detectedLanguages)
|
||||
? detectedLanguages[0]
|
||||
: detectedLanguages;
|
||||
|
||||
const initialLanguage =
|
||||
(detectedLanguage
|
||||
? // region code may not be defined if user uses generic preferred language
|
||||
// (e.g. chinese vs instead of chinese-simplified)
|
||||
languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code
|
||||
: null) || defaultLang.code;
|
||||
|
||||
return initialLanguage;
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { getPreferredLanguage, languageDetector } from "./language-detector";
|
||||
|
||||
export const appLangCodeAtom = atom(getPreferredLanguage());
|
||||
|
||||
export const useAppLangCode = () => {
|
||||
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
||||
|
||||
useEffect(() => {
|
||||
languageDetector.cacheUserLanguage(langCode);
|
||||
}, [langCode]);
|
||||
|
||||
return [langCode, setLangCode] as const;
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import type { Theme } from "../../packages/excalidraw/element/types";
|
||||
import { MainMenu } from "../../packages/excalidraw/index";
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
import { LanguageList } from "../app-language/LanguageList";
|
||||
import { LanguageList } from "./LanguageList";
|
||||
|
||||
export const AppMainMenu: React.FC<{
|
||||
onCollabDialogOpen: () => any;
|
||||
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
import { useSetAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { useI18n, languages } from "../../packages/excalidraw/i18n";
|
||||
import { appLangCodeAtom } from "./language-state";
|
||||
import { appLangCodeAtom } from "../App";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { languages } from "../../packages/excalidraw/i18n";
|
||||
|
||||
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
||||
const { t, langCode } = useI18n();
|
||||
@@ -114,14 +114,6 @@
|
||||
) {
|
||||
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>
|
||||
<% } %>
|
||||
|
||||
@@ -132,74 +124,22 @@
|
||||
<!-- Excalidraw 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
|
||||
rel="preload"
|
||||
href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/Excalifont-Regular-C9eKQy_N.woff2"
|
||||
href="/Virgil.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
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"
|
||||
href="/Cascadia.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<!-- Register Assistant as the UI font, before the scene inits -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="../packages/excalidraw/fonts/assets/fonts.css"
|
||||
type="text/css"
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
|
||||
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
|
||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
|
||||
<script>
|
||||
@@ -218,6 +158,7 @@
|
||||
</script>
|
||||
<% } %>
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
// setting this so that libraries installation reuses this window tab.
|
||||
window.name = "_excalidraw";
|
||||
</script>
|
||||
|
||||
@@ -31,13 +31,12 @@
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
"scripts": {
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
|
||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
|
||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
|
||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
|
||||
"build:version": "node ../scripts/build-version.js",
|
||||
"build": "yarn build:app && yarn build:version",
|
||||
"start": "yarn && vite",
|
||||
"start:production": "yarn build && yarn serve",
|
||||
"serve": "npx http-server build -a localhost -p 5001 -o",
|
||||
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
||||
"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"
|
||||
>
|
||||
<div
|
||||
class="welcome-screen-center__logo excalifont welcome-screen-decor"
|
||||
class="welcome-screen-center__logo virgil welcome-screen-decor"
|
||||
>
|
||||
<div
|
||||
class="ExcalidrawLogo is-small"
|
||||
@@ -48,7 +48,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="welcome-screen-center__heading welcome-screen-decor excalifont"
|
||||
class="welcome-screen-center__heading welcome-screen-decor virgil"
|
||||
>
|
||||
All your data is saved locally in your browser.
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import checker from "vite-plugin-checker";
|
||||
import { createHtmlPlugin } from "vite-plugin-html";
|
||||
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
|
||||
|
||||
// To load .env.local variables
|
||||
const envVars = loadEnv("", `../`);
|
||||
@@ -23,14 +22,6 @@ export default defineConfig({
|
||||
outDir: "build",
|
||||
rollupOptions: {
|
||||
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
|
||||
// can be cached at runtime and not merged with
|
||||
// app precache. en.json and percentages.json are needed for first load
|
||||
@@ -44,13 +35,12 @@ export default defineConfig({
|
||||
// Taking the substring after "locales/"
|
||||
return `locales/${id.substring(index + 8)}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
},
|
||||
plugins: [
|
||||
woff2BrowserPlugin(),
|
||||
react(),
|
||||
checker({
|
||||
typescript: true,
|
||||
|
||||
@@ -15,12 +15,8 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
### 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 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)
|
||||
|
||||
- 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)
|
||||
|
||||
@@ -131,12 +131,7 @@ export const actionFinalize = register({
|
||||
-1,
|
||||
arrayToMap(elements),
|
||||
);
|
||||
maybeBindLinearElement(
|
||||
multiPointElement,
|
||||
appState,
|
||||
{ x, y },
|
||||
elementsMap,
|
||||
);
|
||||
maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ const flipElements = (
|
||||
|
||||
bindOrUnbindLinearElements(
|
||||
selectedElements.filter(isLinearElement),
|
||||
elementsMap,
|
||||
app,
|
||||
isBindingEnabled(appState),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -155,15 +155,13 @@ describe("element locking", () => {
|
||||
});
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
fontFamily: FONT_FAMILY["Comic Shanns"],
|
||||
fontFamily: FONT_FAMILY.Cascadia,
|
||||
});
|
||||
h.elements = [rect, text];
|
||||
API.setSelectedElements([rect, text]);
|
||||
|
||||
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
|
||||
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
|
||||
"active",
|
||||
);
|
||||
expect(queryByTestId(document.body, `font-family-code`)).toBeChecked();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||
import type { StoreActionType } from "../store";
|
||||
import {
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
@@ -11,7 +9,6 @@ import { trackEvent } from "../analytics";
|
||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||
import { IconPicker } from "../components/IconPicker";
|
||||
import { FontPicker } from "../components/FontPicker/FontPicker";
|
||||
// TODO barnabasmolnar/editor-redesign
|
||||
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
|
||||
// ArrowHead icons
|
||||
@@ -41,6 +38,9 @@ import {
|
||||
FontSizeExtraLargeIcon,
|
||||
EdgeSharpIcon,
|
||||
EdgeRoundIcon,
|
||||
FreedrawIcon,
|
||||
FontFamilyNormalIcon,
|
||||
FontFamilyCodeIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignCenterIcon,
|
||||
TextAlignRightIcon,
|
||||
@@ -65,7 +65,10 @@ import {
|
||||
redrawTextBoundingBox,
|
||||
} from "../element";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import { getBoundTextElement } from "../element/textElement";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isLinearElement,
|
||||
@@ -91,10 +94,9 @@ import {
|
||||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { arrayToMap, getFontFamilyString, getShortcutKey } from "../utils";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { StoreAction } from "../store";
|
||||
import { Fonts, getLineHeight } from "../fonts";
|
||||
|
||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||
|
||||
@@ -727,391 +729,104 @@ export const actionIncreaseFontSize = register({
|
||||
},
|
||||
});
|
||||
|
||||
type ChangeFontFamilyData = Partial<
|
||||
Pick<
|
||||
AppState,
|
||||
"openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily"
|
||||
>
|
||||
> & {
|
||||
/** cache of selected & editing elements populated on opened popup */
|
||||
cachedElements?: Map<string, ExcalidrawElement>;
|
||||
/** flag to reset all elements to their cached versions */
|
||||
resetAll?: true;
|
||||
/** flag to reset all containers to their cached versions */
|
||||
resetContainers?: true;
|
||||
};
|
||||
|
||||
export const actionChangeFontFamily = register({
|
||||
name: "changeFontFamily",
|
||||
label: "labels.fontFamily",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
const { cachedElements, resetAll, resetContainers, ...nextAppState } =
|
||||
value as ChangeFontFamilyData;
|
||||
|
||||
if (resetAll) {
|
||||
const nextElements = changeProperty(
|
||||
return {
|
||||
elements: changeProperty(
|
||||
elements,
|
||||
appState,
|
||||
(element) => {
|
||||
const cachedElement = cachedElements?.get(element.id);
|
||||
if (cachedElement) {
|
||||
const newElement = newElementWith(element, {
|
||||
...cachedElement,
|
||||
});
|
||||
|
||||
(oldElement) => {
|
||||
if (isTextElement(oldElement)) {
|
||||
const newElement: ExcalidrawTextElement = newElementWith(
|
||||
oldElement,
|
||||
{
|
||||
fontFamily: value,
|
||||
lineHeight: getDefaultLineHeight(value),
|
||||
},
|
||||
);
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
app.scene.getContainerElement(oldElement),
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
return newElement;
|
||||
}
|
||||
|
||||
return element;
|
||||
return oldElement;
|
||||
},
|
||||
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,
|
||||
...nextAppState,
|
||||
currentItemFontFamily: value,
|
||||
},
|
||||
storeAction: nexStoreAction,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
|
||||
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, app, updateData }) => {
|
||||
const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map());
|
||||
const prevSelectedFontFamilyRef = useRef<number | null>(null);
|
||||
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
|
||||
const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
|
||||
const isUnmounted = useRef(true);
|
||||
|
||||
const selectedFontFamily = useMemo(() => {
|
||||
const getFontFamily = (
|
||||
elementsArray: readonly ExcalidrawElement[],
|
||||
elementsMap: Map<string, ExcalidrawElement>,
|
||||
) =>
|
||||
getFormValue(
|
||||
elementsArray,
|
||||
appState,
|
||||
(element) => {
|
||||
if (isTextElement(element)) {
|
||||
return element.fontFamily;
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement) {
|
||||
return boundTextElement.fontFamily;
|
||||
}
|
||||
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;
|
||||
};
|
||||
}, []);
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const options: {
|
||||
value: FontFamilyValues;
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
testId: string;
|
||||
}[] = [
|
||||
{
|
||||
value: FONT_FAMILY.Virgil,
|
||||
text: t("labels.handDrawn"),
|
||||
icon: FreedrawIcon,
|
||||
testId: "font-family-virgil",
|
||||
},
|
||||
{
|
||||
value: FONT_FAMILY.Helvetica,
|
||||
text: t("labels.normal"),
|
||||
icon: FontFamilyNormalIcon,
|
||||
testId: "font-family-normal",
|
||||
},
|
||||
{
|
||||
value: FONT_FAMILY.Cascadia,
|
||||
text: t("labels.code"),
|
||||
icon: FontFamilyCodeIcon,
|
||||
testId: "font-family-code",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fontFamily")}</legend>
|
||||
<FontPicker
|
||||
isOpened={appState.openPopup === "fontFamily"}
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
hoveredFontFamily={appState.currentHoveredFontFamily}
|
||||
onSelect={(fontFamily) => {
|
||||
setBatchedData({
|
||||
openPopup: null,
|
||||
currentHoveredFontFamily: null,
|
||||
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),
|
||||
);
|
||||
}
|
||||
<ButtonIconSelect<FontFamilyValues | false>
|
||||
group="font-family"
|
||||
options={options}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) => {
|
||||
if (isTextElement(element)) {
|
||||
return element.fontFamily;
|
||||
}
|
||||
|
||||
setBatchedData({
|
||||
openPopup: "fontFamily",
|
||||
});
|
||||
} else {
|
||||
// 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);
|
||||
const boundTextElement = getBoundTextElement(
|
||||
element,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
if (boundTextElement) {
|
||||
return boundTextElement.fontFamily;
|
||||
}
|
||||
|
||||
cachedElementsRef.current.clear();
|
||||
}
|
||||
}}
|
||||
return null;
|
||||
},
|
||||
(element) =>
|
||||
isTextElement(element) ||
|
||||
getBoundTextElement(
|
||||
element,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
) !== null,
|
||||
(hasSelection) =>
|
||||
hasSelection
|
||||
? null
|
||||
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
import { getBoundTextElement } from "../element/textElement";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
canApplyRoundnessTypeToElement,
|
||||
@@ -24,7 +27,6 @@ import { getSelectedElements } from "../scene";
|
||||
import type { ExcalidrawTextElement } from "../element/types";
|
||||
import { paintIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { getLineHeight } from "../fonts";
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
@@ -120,7 +122,7 @@ export const actionPasteStyles = register({
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
lineHeight:
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
|
||||
getLineHeight(fontFamily),
|
||||
getDefaultLineHeight(fontFamily),
|
||||
});
|
||||
let container = null;
|
||||
if (newElement.containerId) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// place here categories that you want to track. We want to track just a
|
||||
// small subset of categories at a given time.
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
|
||||
|
||||
export const trackEvent = (
|
||||
category: string,
|
||||
@@ -9,20 +9,17 @@ export const trackEvent = (
|
||||
value?: number,
|
||||
) => {
|
||||
try {
|
||||
// prettier-ignore
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
import.meta.env.VITE_WORKER_ID ||
|
||||
import.meta.env.VITE_APP_ENABLE_TRACKING !== "true"
|
||||
typeof window === "undefined"
|
||||
|| import.meta.env.VITE_WORKER_ID
|
||||
// comment out to debug locally
|
||||
|| import.meta.env.PROD
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// comment out to debug in dev
|
||||
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ export const getDefaultAppState = (): Omit<
|
||||
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||
currentHoveredFontFamily: null,
|
||||
cursorButton: "up",
|
||||
activeEmbeddable: null,
|
||||
draggingElement: null,
|
||||
@@ -150,7 +149,6 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||
currentItemStrokeWidth: { 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 },
|
||||
activeEmbeddable: { browser: false, export: false, server: false },
|
||||
draggingElement: { browser: false, export: false, server: false },
|
||||
|
||||
@@ -158,8 +158,10 @@ export const SelectedShapeActions = ({
|
||||
{(appState.activeTool.type === "text" ||
|
||||
targetElements.some(isTextElement)) && (
|
||||
<>
|
||||
{renderAction("changeFontFamily")}
|
||||
{renderAction("changeFontSize")}
|
||||
|
||||
{renderAction("changeFontFamily")}
|
||||
|
||||
{(appState.activeTool.type === "text" ||
|
||||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
|
||||
renderAction("changeTextAlign")}
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
import type { PastedMixedContent } from "../clipboard";
|
||||
import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
|
||||
import type { EXPORT_IMAGE_TYPES } from "../constants";
|
||||
import { DEFAULT_FONT_SIZE } from "../constants";
|
||||
import {
|
||||
APP_NAME,
|
||||
CURSOR_TYPE,
|
||||
@@ -224,9 +225,16 @@ import type {
|
||||
ScrollBars,
|
||||
} from "../scene/types";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { findShapeByKey, getElementShape } from "../shapes";
|
||||
import { findShapeByKey } from "../shapes";
|
||||
import type { GeometricShape } from "../../utils/geometry/shape";
|
||||
import { getSelectionBoxShape } from "../../utils/geometry/shape";
|
||||
import {
|
||||
getClosedCurveShape,
|
||||
getCurveShape,
|
||||
getEllipseShape,
|
||||
getFreedrawShape,
|
||||
getPolygonShape,
|
||||
getSelectionBoxShape,
|
||||
} from "../../utils/geometry/shape";
|
||||
import { isPointInShape } from "../../utils/collision";
|
||||
import type {
|
||||
AppClassProperties,
|
||||
@@ -321,6 +329,7 @@ import {
|
||||
getBoundTextElement,
|
||||
getContainerCenter,
|
||||
getContainerElement,
|
||||
getDefaultLineHeight,
|
||||
getLineHeightInPx,
|
||||
getMinTextElementWidth,
|
||||
isMeasureTextSupported,
|
||||
@@ -336,7 +345,7 @@ import {
|
||||
import { isLocalLink, normalizeLink, toValidURL } from "../data/url";
|
||||
import { shouldShowBoundingBox } from "../element/transformHandles";
|
||||
import { actionUnlockAllElements } from "../actions/actionElementLock";
|
||||
import { Fonts, getLineHeight } from "../fonts";
|
||||
import { Fonts } from "../scene/Fonts";
|
||||
import {
|
||||
getFrameChildren,
|
||||
isCursorInFrame,
|
||||
@@ -415,6 +424,7 @@ import {
|
||||
hitElementBoundText,
|
||||
hitElementBoundingBoxOnly,
|
||||
hitElementItself,
|
||||
shouldTestInside,
|
||||
} from "../element/collision";
|
||||
import { textWysiwyg } from "../element/textWysiwyg";
|
||||
import { isOverScrollBars } from "../scene/scrollbars";
|
||||
@@ -531,8 +541,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
private excalidrawContainerRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
public scene: Scene;
|
||||
public fonts: Fonts;
|
||||
public renderer: Renderer;
|
||||
private fonts: Fonts;
|
||||
private resizeObserver: ResizeObserver | undefined;
|
||||
private nearestScrollableContainer: HTMLElement | Document | undefined;
|
||||
public library: AppClassProperties["library"];
|
||||
@@ -2281,11 +2291,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
let initialData = null;
|
||||
try {
|
||||
if (typeof this.props.initialData === "function") {
|
||||
initialData = (await this.props.initialData()) || null;
|
||||
} else {
|
||||
initialData = (await this.props.initialData) || null;
|
||||
}
|
||||
initialData = (await this.props.initialData) || null;
|
||||
if (initialData?.libraryItems) {
|
||||
this.library
|
||||
.updateLibrary({
|
||||
@@ -2334,6 +2340,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}),
|
||||
};
|
||||
}
|
||||
// FontFaceSet loadingdone event we listen on may not always fire
|
||||
// (looking at you Safari), so on init we manually load fonts for current
|
||||
// text elements on canvas, and rerender them once done. This also
|
||||
// seems faster even in browsers that do fire the loadingdone event.
|
||||
this.fonts.loadFontsForElements(scene.elements);
|
||||
|
||||
this.resetStore();
|
||||
this.resetHistory();
|
||||
@@ -2341,12 +2352,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
...scene,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
|
||||
// FontFaceSet loadingdone event we listen on may not always
|
||||
// fire (looking at you Safari), so on init we manually load all
|
||||
// fonts and rerender scene text elements once done. This also
|
||||
// seems faster even in browsers that do fire the loadingdone event.
|
||||
this.fonts.load();
|
||||
};
|
||||
|
||||
private isMobileBreakpoint = (width: number, height: number) => {
|
||||
@@ -2439,10 +2444,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
configurable: true,
|
||||
value: this.store,
|
||||
},
|
||||
fonts: {
|
||||
configurable: true,
|
||||
value: this.fonts,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2493,9 +2494,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
(window as any).launchQueue?.setConsumer(() => {});
|
||||
this.renderer.destroy();
|
||||
this.scene.destroy();
|
||||
this.scene = new Scene();
|
||||
this.fonts = new Fonts({ scene: this.scene });
|
||||
this.renderer = new Renderer(this.scene);
|
||||
@@ -2504,6 +2503,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.resizeObserver?.disconnect();
|
||||
this.unmounted = true;
|
||||
this.removeEventListeners();
|
||||
this.scene.destroy();
|
||||
this.library.destroy();
|
||||
this.laserTrails.stop();
|
||||
this.eraserTrail.stop();
|
||||
@@ -2580,7 +2580,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// rerender text elements on font load to fix #637 && #1553
|
||||
addEventListener(document.fonts, "loadingdone", (event) => {
|
||||
const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces;
|
||||
this.fonts.onLoaded(loadedFontFaces);
|
||||
this.fonts.onFontsLoaded(loadedFontFaces);
|
||||
}),
|
||||
// Safari-only desktop pinch zoom
|
||||
addEventListener(
|
||||
@@ -2815,7 +2815,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
nonDeletedElementsMap,
|
||||
),
|
||||
),
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3058,7 +3058,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
try {
|
||||
const { elements: skeletonElements, files } =
|
||||
await api.parseMermaidToExcalidraw(data.text);
|
||||
await api.parseMermaidToExcalidraw(data.text, {
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
});
|
||||
|
||||
const elements = convertToExcalidrawElements(skeletonElements, {
|
||||
regenerateIds: true,
|
||||
@@ -3383,7 +3385,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
fontSize: textElementProps.fontSize,
|
||||
fontFamily: textElementProps.fontFamily,
|
||||
});
|
||||
const lineHeight = getLineHeight(textElementProps.fontFamily);
|
||||
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
|
||||
const [x1, , x2] = getVisibleSceneBounds(this.state);
|
||||
// long texts should not go beyond 800 pixels in width nor should it go below 200 px
|
||||
const maxTextWidth = Math.max(Math.min((x2 - x1) * 0.5, 800), 200);
|
||||
@@ -3401,13 +3403,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
|
||||
let metrics = measureText(originalText, fontString, lineHeight);
|
||||
const isTextUnwrapped = metrics.width > maxTextWidth;
|
||||
const isTextWrapped = metrics.width > maxTextWidth;
|
||||
|
||||
const text = isTextUnwrapped
|
||||
const text = isTextWrapped
|
||||
? wrapText(originalText, fontString, maxTextWidth)
|
||||
: originalText;
|
||||
|
||||
metrics = isTextUnwrapped
|
||||
metrics = isTextWrapped
|
||||
? measureText(text, fontString, lineHeight)
|
||||
: metrics;
|
||||
|
||||
@@ -3421,7 +3423,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
text,
|
||||
originalText,
|
||||
lineHeight,
|
||||
autoResize: !isTextUnwrapped,
|
||||
autoResize: !isTextWrapped,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
});
|
||||
acc.push(element);
|
||||
@@ -4002,7 +4004,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState({
|
||||
suggestedBindings: getSuggestedBindingsForArrows(
|
||||
selectedElements,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -4111,36 +4113,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
event.key.toLowerCase() === KEYS.F
|
||||
) {
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
|
||||
if (
|
||||
this.state.activeTool.type === "selection" &&
|
||||
!selectedElements.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.state.activeTool.type === "text" ||
|
||||
selectedElements.find(
|
||||
(element) =>
|
||||
isTextElement(element) ||
|
||||
getBoundTextElement(
|
||||
element,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
)
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.setState({ openPopup: "fontFamily" });
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
|
||||
if (this.state.activeTool.type === "laser") {
|
||||
this.setActiveTool({ type: "selection" });
|
||||
@@ -4203,7 +4175,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (isArrowKey(event.key)) {
|
||||
bindOrUnbindLinearElements(
|
||||
this.scene.getSelectedElements(this.state).filter(isLinearElement),
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this,
|
||||
isBindingEnabled(this.state),
|
||||
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
|
||||
);
|
||||
@@ -4515,6 +4487,59 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* get the pure geometric shape of an excalidraw element
|
||||
* which is then used for hit detection
|
||||
*/
|
||||
public getElementShape(element: ExcalidrawElement): GeometricShape {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
case "embeddable":
|
||||
case "image":
|
||||
case "iframe":
|
||||
case "text":
|
||||
case "selection":
|
||||
return getPolygonShape(element);
|
||||
case "arrow":
|
||||
case "line": {
|
||||
const roughShape =
|
||||
ShapeCache.get(element)?.[0] ??
|
||||
ShapeCache.generateElementShape(element, null)[0];
|
||||
const [, , , , cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
return shouldTestInside(element)
|
||||
? getClosedCurveShape(
|
||||
element,
|
||||
roughShape,
|
||||
[element.x, element.y],
|
||||
element.angle,
|
||||
[cx, cy],
|
||||
)
|
||||
: getCurveShape(roughShape, [element.x, element.y], element.angle, [
|
||||
cx,
|
||||
cy,
|
||||
]);
|
||||
}
|
||||
|
||||
case "ellipse":
|
||||
return getEllipseShape(element);
|
||||
|
||||
case "freedraw": {
|
||||
const [, , , , cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
return getFreedrawShape(element, [cx, cy], shouldTestInside(element));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getBoundTextShape(element: ExcalidrawElement): GeometricShape | null {
|
||||
const boundTextElement = getBoundTextElement(
|
||||
element,
|
||||
@@ -4523,24 +4548,18 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
if (boundTextElement) {
|
||||
if (element.type === "arrow") {
|
||||
return getElementShape(
|
||||
{
|
||||
...boundTextElement,
|
||||
// arrow's bound text accurate position is not stored in the element's property
|
||||
// but rather calculated and returned from the following static method
|
||||
...LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
},
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
return this.getElementShape({
|
||||
...boundTextElement,
|
||||
// arrow's bound text accurate position is not stored in the element's property
|
||||
// but rather calculated and returned from the following static method
|
||||
...LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
});
|
||||
}
|
||||
return getElementShape(
|
||||
boundTextElement,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
return this.getElementShape(boundTextElement);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -4579,10 +4598,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
x,
|
||||
y,
|
||||
element: elementWithHighestZIndex,
|
||||
shape: getElementShape(
|
||||
elementWithHighestZIndex,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
shape: this.getElementShape(elementWithHighestZIndex),
|
||||
// when overlapping, we would like to be more precise
|
||||
// this also avoids the need to update past tests
|
||||
threshold: this.getElementHitThreshold() / 2,
|
||||
@@ -4687,7 +4703,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
x,
|
||||
y,
|
||||
element,
|
||||
shape: getElementShape(element, this.scene.getNonDeletedElementsMap()),
|
||||
shape: this.getElementShape(element),
|
||||
threshold: this.getElementHitThreshold(),
|
||||
frameNameBound: isFrameLikeElement(element)
|
||||
? this.frameNameBoundsCache.get(element)
|
||||
@@ -4719,10 +4735,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
x,
|
||||
y,
|
||||
element: elements[index],
|
||||
shape: getElementShape(
|
||||
elements[index],
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
shape: this.getElementShape(elements[index]),
|
||||
threshold: this.getElementHitThreshold(),
|
||||
})
|
||||
) {
|
||||
@@ -4795,7 +4808,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
existingTextElement?.fontFamily || this.state.currentItemFontFamily;
|
||||
|
||||
const lineHeight =
|
||||
existingTextElement?.lineHeight || getLineHeight(fontFamily);
|
||||
existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily);
|
||||
const fontSize = this.state.currentItemFontSize;
|
||||
|
||||
if (
|
||||
@@ -4980,10 +4993,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
x: sceneX,
|
||||
y: sceneY,
|
||||
element: container,
|
||||
shape: getElementShape(
|
||||
container,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
shape: this.getElementShape(container),
|
||||
threshold: this.getElementHitThreshold(),
|
||||
})
|
||||
) {
|
||||
@@ -5675,10 +5685,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
x: scenePointerX,
|
||||
y: scenePointerY,
|
||||
element,
|
||||
shape: getElementShape(
|
||||
element,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
shape: this.getElementShape(element),
|
||||
})
|
||||
) {
|
||||
hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
||||
@@ -6797,7 +6804,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
const boundElement = getHoveredElementForBinding(
|
||||
pointerDownState.origin,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this,
|
||||
);
|
||||
this.scene.insertElement(element);
|
||||
this.setState({
|
||||
@@ -7059,7 +7066,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
const boundElement = getHoveredElementForBinding(
|
||||
pointerDownState.origin,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this,
|
||||
);
|
||||
|
||||
this.scene.insertElement(element);
|
||||
@@ -7529,7 +7536,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState({
|
||||
suggestedBindings: getSuggestedBindingsForArrows(
|
||||
selectedElements,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -8050,7 +8057,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
draggingElement,
|
||||
this.state,
|
||||
pointerCoords,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this,
|
||||
);
|
||||
}
|
||||
this.setState({ suggestedBindings: [], startBoundElement: null });
|
||||
@@ -8540,10 +8547,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
x: pointerDownState.origin.x,
|
||||
y: pointerDownState.origin.y,
|
||||
element: hitElement,
|
||||
shape: getElementShape(
|
||||
hitElement,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
shape: this.getElementShape(hitElement),
|
||||
threshold: this.getElementHitThreshold(),
|
||||
frameNameBound: isFrameLikeElement(hitElement)
|
||||
? this.frameNameBoundsCache.get(hitElement)
|
||||
@@ -8611,7 +8615,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
bindOrUnbindLinearElements(
|
||||
linearElements,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this,
|
||||
isBindingEnabled(this.state),
|
||||
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
|
||||
);
|
||||
@@ -9099,7 +9103,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}): void => {
|
||||
const hoveredBindableElement = getHoveredElementForBinding(
|
||||
pointerCoords,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this,
|
||||
);
|
||||
this.setState({
|
||||
suggestedBindings:
|
||||
@@ -9126,7 +9130,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
|
||||
const hoveredBindableElement = getHoveredElementForBinding(
|
||||
coords,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this,
|
||||
);
|
||||
if (
|
||||
hoveredBindableElement != null &&
|
||||
@@ -9658,7 +9662,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
) {
|
||||
const suggestedBindings = getSuggestedBindingsForArrows(
|
||||
selectedElements,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this,
|
||||
);
|
||||
|
||||
const elementsToHighlight = new Set<ExcalidrawElement>();
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
@import "../css/theme";
|
||||
|
||||
.excalidraw {
|
||||
button.standalone {
|
||||
@include outlineButtonIconStyles;
|
||||
|
||||
& > * {
|
||||
// dissalow pointer events on children, so we always have event.target on the button itself
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { forwardRef } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import "./ButtonIcon.scss";
|
||||
|
||||
interface ButtonIconProps {
|
||||
icon: JSX.Element;
|
||||
title: string;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
/** if not supplied, defaults to value identity check */
|
||||
active?: boolean;
|
||||
/** include standalone style (could interfere with parent styles) */
|
||||
standalone?: boolean;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
}
|
||||
|
||||
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
|
||||
(props, ref) => {
|
||||
const { title, className, testId, active, standalone, icon, onClick } =
|
||||
props;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={ref}
|
||||
key={title}
|
||||
title={title}
|
||||
data-testid={testId}
|
||||
className={clsx(className, { standalone, active })}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,5 +1,4 @@
|
||||
import clsx from "clsx";
|
||||
import { ButtonIcon } from "./ButtonIcon";
|
||||
|
||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
||||
export const ButtonIconSelect = <T extends Object>(
|
||||
@@ -25,17 +24,21 @@ export const ButtonIconSelect = <T extends Object>(
|
||||
}
|
||||
),
|
||||
) => (
|
||||
<div className="buttonList">
|
||||
<div className="buttonList buttonListIcon">
|
||||
{props.options.map((option) =>
|
||||
props.type === "button" ? (
|
||||
<ButtonIcon
|
||||
<button
|
||||
type="button"
|
||||
key={option.text}
|
||||
icon={option.icon}
|
||||
title={option.text}
|
||||
testId={option.testId}
|
||||
active={option.active ?? props.value === option.value}
|
||||
onClick={(event) => props.onClick(option.value, event)}
|
||||
/>
|
||||
className={clsx({
|
||||
active: option.active ?? props.value === option.value,
|
||||
})}
|
||||
data-testid={option.testId}
|
||||
title={option.text}
|
||||
>
|
||||
{option.icon}
|
||||
</button>
|
||||
) : (
|
||||
<label
|
||||
key={option.text}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export const ButtonSeparator = () => (
|
||||
<div
|
||||
style={{
|
||||
width: 1,
|
||||
height: "1rem",
|
||||
backgroundColor: "var(--default-border-color)",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -20,7 +20,7 @@
|
||||
align-items: center;
|
||||
|
||||
@include isMobile {
|
||||
max-width: 11rem;
|
||||
max-width: 175px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import { isTransparent } from "../../utils";
|
||||
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
|
||||
import type { ExcalidrawElement } from "../../element/types";
|
||||
import type { AppState } from "../../types";
|
||||
import { TopPicks } from "./TopPicks";
|
||||
import { ButtonSeparator } from "../ButtonSeparator";
|
||||
import { Picker } from "./Picker";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { useAtom } from "jotai";
|
||||
import type { ColorPickerType } from "./colorPickerUtils";
|
||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||
import { useExcalidrawContainer } from "../App";
|
||||
import { useDevice, useExcalidrawContainer } from "../App";
|
||||
import type { ColorTuple, ColorPaletteCustom } from "../../colors";
|
||||
import { COLOR_PALETTE } from "../../colors";
|
||||
import PickerHeading from "./PickerHeading";
|
||||
import { t } from "../../i18n";
|
||||
import clsx from "clsx";
|
||||
import { useRef } from "react";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import { ColorInput } from "./ColorInput";
|
||||
import { useRef } from "react";
|
||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||
import { PropertiesPopover } from "../PropertiesPopover";
|
||||
|
||||
import "./ColorPicker.scss";
|
||||
|
||||
@@ -73,7 +71,6 @@ const ColorPickerPopupContent = ({
|
||||
| "palette"
|
||||
| "updateData"
|
||||
>) => {
|
||||
const { container } = useExcalidrawContainer();
|
||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||
|
||||
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||
@@ -81,6 +78,9 @@ const ColorPickerPopupContent = ({
|
||||
jotaiScope,
|
||||
);
|
||||
|
||||
const { container } = useExcalidrawContainer();
|
||||
const device = useDevice();
|
||||
|
||||
const colorInputJSX = (
|
||||
<div>
|
||||
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
|
||||
@@ -94,7 +94,6 @@ const ColorPickerPopupContent = ({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const focusPickerContent = () => {
|
||||
@@ -104,73 +103,120 @@ const ColorPickerPopupContent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<PropertiesPopover
|
||||
container={container}
|
||||
style={{ maxWidth: "208px" }}
|
||||
onFocusOutside={(event) => {
|
||||
// refocus due to eye dropper
|
||||
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)
|
||||
<Popover.Portal container={container}>
|
||||
<Popover.Content
|
||||
ref={popoverRef}
|
||||
className="focus-visible-none"
|
||||
data-prevent-outside-click
|
||||
onFocusOutside={(event) => {
|
||||
focusPickerContent();
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
updateData({ openPopup: null });
|
||||
setActiveColorPickerSection(null);
|
||||
}}
|
||||
>
|
||||
{palette ? (
|
||||
<Picker
|
||||
palette={palette}
|
||||
color={color}
|
||||
onChange={(changedColor) => {
|
||||
onChange(changedColor);
|
||||
}}
|
||||
onEyeDropperToggle={(force) => {
|
||||
setEyeDropperState((state) => {
|
||||
if (force) {
|
||||
state = state || {
|
||||
keepOpenOnAlt: true,
|
||||
onSelect: onChange,
|
||||
colorPickerType: type,
|
||||
};
|
||||
state.keepOpenOnAlt = true;
|
||||
return state;
|
||||
}
|
||||
}}
|
||||
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 force === false || state
|
||||
? null
|
||||
: {
|
||||
keepOpenOnAlt: false,
|
||||
// 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
|
||||
? "center"
|
||||
: "start"
|
||||
}
|
||||
alignOffset={-16}
|
||||
sideOffset={20}
|
||||
style={{
|
||||
zIndex: "var(--zIndex-layerUI)",
|
||||
backgroundColor: "var(--popup-bg-color)",
|
||||
maxWidth: "208px",
|
||||
maxHeight: window.innerHeight,
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
boxSizing: "border-box",
|
||||
overflowY: "auto",
|
||||
boxShadow:
|
||||
"0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)",
|
||||
}}
|
||||
>
|
||||
{palette ? (
|
||||
<Picker
|
||||
palette={palette}
|
||||
color={color}
|
||||
onChange={(changedColor) => {
|
||||
onChange(changedColor);
|
||||
}}
|
||||
onEyeDropperToggle={(force) => {
|
||||
setEyeDropperState((state) => {
|
||||
if (force) {
|
||||
state = state || {
|
||||
keepOpenOnAlt: true,
|
||||
onSelect: onChange,
|
||||
colorPickerType: type,
|
||||
};
|
||||
});
|
||||
state.keepOpenOnAlt = true;
|
||||
return state;
|
||||
}
|
||||
|
||||
return force === false || state
|
||||
? null
|
||||
: {
|
||||
keepOpenOnAlt: false,
|
||||
onSelect: onChange,
|
||||
colorPickerType: type,
|
||||
};
|
||||
});
|
||||
}}
|
||||
onEscape={(event) => {
|
||||
if (eyeDropperState) {
|
||||
setEyeDropperState(null);
|
||||
} else if (isWritableElement(event.target)) {
|
||||
focusPickerContent();
|
||||
} else {
|
||||
updateData({ openPopup: null });
|
||||
}
|
||||
}}
|
||||
label={label}
|
||||
type={type}
|
||||
elements={elements}
|
||||
updateData={updateData}
|
||||
>
|
||||
{colorInputJSX}
|
||||
</Picker>
|
||||
) : (
|
||||
colorInputJSX
|
||||
)}
|
||||
<Popover.Arrow
|
||||
width={20}
|
||||
height={10}
|
||||
style={{
|
||||
fill: "var(--popup-bg-color)",
|
||||
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
|
||||
}}
|
||||
onEscape={(event) => {
|
||||
if (eyeDropperState) {
|
||||
setEyeDropperState(null);
|
||||
} else {
|
||||
updateData({ openPopup: null });
|
||||
}
|
||||
}}
|
||||
label={label}
|
||||
type={type}
|
||||
elements={elements}
|
||||
updateData={updateData}
|
||||
>
|
||||
{colorInputJSX}
|
||||
</Picker>
|
||||
) : (
|
||||
colorInputJSX
|
||||
)}
|
||||
</PropertiesPopover>
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -186,7 +232,7 @@ const ColorPickerTrigger = ({
|
||||
return (
|
||||
<Popover.Trigger
|
||||
type="button"
|
||||
className={clsx("color-picker__button active-color properties-trigger", {
|
||||
className={clsx("color-picker__button active-color", {
|
||||
"is-transparent": color === "transparent" || !color,
|
||||
})}
|
||||
aria-label={label}
|
||||
@@ -222,7 +268,14 @@ export const ColorPicker = ({
|
||||
type={type}
|
||||
topPicks={topPicks}
|
||||
/>
|
||||
<ButtonSeparator />
|
||||
<div
|
||||
style={{
|
||||
width: 1,
|
||||
height: "100%",
|
||||
backgroundColor: "var(--default-border-color)",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
/>
|
||||
<Popover.Root
|
||||
open={appState.openPopup === type}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -138,7 +138,7 @@ export const Picker = ({
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className="color-picker-content properties-content"
|
||||
className="color-picker-content"
|
||||
// to allow focusing by clicking but not by tabbing
|
||||
tabIndex={-1}
|
||||
>
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
@import "../../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.FontPicker__container {
|
||||
display: grid;
|
||||
grid-template-columns: calc(1rem + 3 * var(--default-button-size)) 1rem 1fr; // calc ~ 2 gaps + 4 buttons
|
||||
align-items: center;
|
||||
|
||||
@include isMobile {
|
||||
max-width: calc(
|
||||
2rem + 4 * var(--default-button-size)
|
||||
); // 4 gaps + 4 buttons
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import { FontPickerList } from "./FontPickerList";
|
||||
import { FontPickerTrigger } from "./FontPickerTrigger";
|
||||
import { ButtonIconSelect } from "../ButtonIconSelect";
|
||||
import {
|
||||
FontFamilyCodeIcon,
|
||||
FontFamilyNormalIcon,
|
||||
FreedrawIcon,
|
||||
} from "../icons";
|
||||
import { ButtonSeparator } from "../ButtonSeparator";
|
||||
import type { FontFamilyValues } from "../../element/types";
|
||||
import { FONT_FAMILY } from "../../constants";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
import "./FontPicker.scss";
|
||||
|
||||
export const DEFAULT_FONTS = [
|
||||
{
|
||||
value: FONT_FAMILY.Excalifont,
|
||||
icon: FreedrawIcon,
|
||||
text: t("labels.handDrawn"),
|
||||
testId: "font-family-handrawn",
|
||||
},
|
||||
{
|
||||
value: FONT_FAMILY.Nunito,
|
||||
icon: FontFamilyNormalIcon,
|
||||
text: t("labels.normal"),
|
||||
testId: "font-family-normal",
|
||||
},
|
||||
{
|
||||
value: FONT_FAMILY["Comic Shanns"],
|
||||
icon: FontFamilyCodeIcon,
|
||||
text: t("labels.code"),
|
||||
testId: "font-family-code",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultFontFamilies = new Set(DEFAULT_FONTS.map((x) => x.value));
|
||||
|
||||
export const isDefaultFont = (fontFamily: number | null) => {
|
||||
if (!fontFamily) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return defaultFontFamilies.has(fontFamily);
|
||||
};
|
||||
|
||||
interface FontPickerProps {
|
||||
isOpened: boolean;
|
||||
selectedFontFamily: FontFamilyValues | null;
|
||||
hoveredFontFamily: FontFamilyValues | null;
|
||||
onSelect: (fontFamily: FontFamilyValues) => void;
|
||||
onHover: (fontFamily: FontFamilyValues) => void;
|
||||
onLeave: () => void;
|
||||
onPopupChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const FontPicker = React.memo(
|
||||
({
|
||||
isOpened,
|
||||
selectedFontFamily,
|
||||
hoveredFontFamily,
|
||||
onSelect,
|
||||
onHover,
|
||||
onLeave,
|
||||
onPopupChange,
|
||||
}: FontPickerProps) => {
|
||||
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
|
||||
const onSelectCallback = useCallback(
|
||||
(value: number | false) => {
|
||||
if (value) {
|
||||
onSelect(value);
|
||||
}
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" className="FontPicker__container">
|
||||
<ButtonIconSelect<FontFamilyValues | false>
|
||||
type="button"
|
||||
options={defaultFonts}
|
||||
value={selectedFontFamily}
|
||||
onClick={onSelectCallback}
|
||||
/>
|
||||
<ButtonSeparator />
|
||||
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
|
||||
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
|
||||
{isOpened && (
|
||||
<FontPickerList
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
hoveredFontFamily={hoveredFontFamily}
|
||||
onSelect={onSelectCallback}
|
||||
onHover={onHover}
|
||||
onLeave={onLeave}
|
||||
onOpen={() => onPopupChange(true)}
|
||||
onClose={() => onPopupChange(false)}
|
||||
/>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prev, next) =>
|
||||
prev.isOpened === next.isOpened &&
|
||||
prev.selectedFontFamily === next.selectedFontFamily &&
|
||||
prev.hoveredFontFamily === next.hoveredFontFamily,
|
||||
);
|
||||
@@ -1,268 +0,0 @@
|
||||
import React, {
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type KeyboardEventHandler,
|
||||
} from "react";
|
||||
import { useApp, useAppProps, useExcalidrawContainer } from "../App";
|
||||
import { PropertiesPopover } from "../PropertiesPopover";
|
||||
import { QuickSearch } from "../QuickSearch";
|
||||
import { ScrollableList } from "../ScrollableList";
|
||||
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
|
||||
import DropdownMenuItem, {
|
||||
DropDownMenuItemBadgeType,
|
||||
DropDownMenuItemBadge,
|
||||
} from "../dropdownMenu/DropdownMenuItem";
|
||||
import { type FontFamilyValues } from "../../element/types";
|
||||
import { arrayToList, debounce, getFontFamilyString } from "../../utils";
|
||||
import { t } from "../../i18n";
|
||||
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
|
||||
import { Fonts } from "../../fonts";
|
||||
import type { ValueOf } from "../../utility-types";
|
||||
|
||||
export interface FontDescriptor {
|
||||
value: number;
|
||||
icon: JSX.Element;
|
||||
text: string;
|
||||
deprecated?: true;
|
||||
badge?: {
|
||||
type: ValueOf<typeof DropDownMenuItemBadgeType>;
|
||||
placeholder: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface FontPickerListProps {
|
||||
selectedFontFamily: FontFamilyValues | null;
|
||||
hoveredFontFamily: FontFamilyValues | null;
|
||||
onSelect: (value: number) => void;
|
||||
onHover: (value: number) => void;
|
||||
onLeave: () => void;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const FontPickerList = React.memo(
|
||||
({
|
||||
selectedFontFamily,
|
||||
hoveredFontFamily,
|
||||
onSelect,
|
||||
onHover,
|
||||
onLeave,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: FontPickerListProps) => {
|
||||
const { container } = useExcalidrawContainer();
|
||||
const { fonts } = useApp();
|
||||
const { showDeprecatedFonts } = useAppProps();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const allFonts = useMemo(
|
||||
() =>
|
||||
Array.from(Fonts.registered.entries())
|
||||
.filter(([_, { metadata }]) => !metadata.serverSide)
|
||||
.map(([familyId, { metadata, 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,
|
||||
);
|
||||
@@ -1,38 +0,0 @@
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { useMemo } from "react";
|
||||
import { ButtonIcon } from "../ButtonIcon";
|
||||
import { TextIcon } from "../icons";
|
||||
import type { FontFamilyValues } from "../../element/types";
|
||||
import { t } from "../../i18n";
|
||||
import { isDefaultFont } from "./FontPicker";
|
||||
|
||||
interface FontPickerTriggerProps {
|
||||
selectedFontFamily: FontFamilyValues | null;
|
||||
}
|
||||
|
||||
export const FontPickerTrigger = ({
|
||||
selectedFontFamily,
|
||||
}: FontPickerTriggerProps) => {
|
||||
const isTriggerActive = useMemo(
|
||||
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
|
||||
[selectedFontFamily],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover.Trigger asChild>
|
||||
{/* Empty div as trigger so it's stretched 100% due to different button sizes */}
|
||||
<div>
|
||||
<ButtonIcon
|
||||
standalone
|
||||
icon={TextIcon}
|
||||
title={t("labels.showFonts")}
|
||||
className="properties-trigger"
|
||||
testId={"font-family-show-fonts"}
|
||||
active={isTriggerActive}
|
||||
// no-op
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Trigger>
|
||||
);
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { Node } from "../../utils";
|
||||
import { KEYS } from "../../keys";
|
||||
import { type FontDescriptor } from "./FontPickerList";
|
||||
|
||||
interface FontPickerKeyNavHandlerProps {
|
||||
event: React.KeyboardEvent<HTMLDivElement>;
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
hoveredFont: Node<FontDescriptor> | undefined;
|
||||
filteredFonts: Node<FontDescriptor>[];
|
||||
onClose: () => void;
|
||||
onSelect: (value: number) => void;
|
||||
onHover: (value: number) => void;
|
||||
}
|
||||
|
||||
export const fontPickerKeyHandler = ({
|
||||
event,
|
||||
inputRef,
|
||||
hoveredFont,
|
||||
filteredFonts,
|
||||
onClose,
|
||||
onSelect,
|
||||
onHover,
|
||||
}: FontPickerKeyNavHandlerProps) => {
|
||||
if (
|
||||
!event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
event.key.toLowerCase() === KEYS.F
|
||||
) {
|
||||
// refocus input on the popup trigger shortcut
|
||||
inputRef.current?.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ESCAPE) {
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ENTER) {
|
||||
if (hoveredFont?.value) {
|
||||
onSelect(hoveredFont.value);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ARROW_DOWN) {
|
||||
if (hoveredFont?.next) {
|
||||
onHover(hoveredFont.next.value);
|
||||
} else if (filteredFonts[0]?.value) {
|
||||
onHover(filteredFonts[0].value);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ARROW_UP) {
|
||||
if (hoveredFont?.prev) {
|
||||
onHover(hoveredFont.prev.value);
|
||||
} else if (filteredFonts[filteredFonts.length - 1]?.value) {
|
||||
onHover(filteredFonts[filteredFonts.length - 1].value);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
h3 {
|
||||
margin: 1.5rem 0;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
&__island {
|
||||
h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
@@ -458,10 +458,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("labels.showBackground")}
|
||||
shortcuts={[getShortcutKey("G")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.showFonts")}
|
||||
shortcuts={[getShortcutKey("Shift+F")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.decreaseFontSize")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
.library-actions-counter {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-light);
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
&__label {
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
@@ -62,7 +62,7 @@
|
||||
&__header {
|
||||
color: var(--color-primary);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.75rem;
|
||||
width: 100%;
|
||||
padding-right: 4rem; // due to dropdown button
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import React, { type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import { useDevice } from "./App";
|
||||
import { Island } from "./Island";
|
||||
import { isInteractive } from "../utils";
|
||||
|
||||
interface PropertiesPopoverProps {
|
||||
className?: string;
|
||||
container: HTMLDivElement | null;
|
||||
children: ReactNode;
|
||||
style?: object;
|
||||
onClose: () => void;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
|
||||
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
|
||||
onFocusOutside?: Popover.DismissableLayerProps["onFocusOutside"];
|
||||
onPointerDownOutside?: Popover.DismissableLayerProps["onPointerDownOutside"];
|
||||
}
|
||||
|
||||
export const PropertiesPopover = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
PropertiesPopoverProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
container,
|
||||
children,
|
||||
style,
|
||||
onClose,
|
||||
onKeyDown,
|
||||
onFocusOutside,
|
||||
onPointerLeave,
|
||||
onPointerDownOutside,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const device = useDevice();
|
||||
|
||||
return (
|
||||
<Popover.Portal container={container}>
|
||||
<Popover.Content
|
||||
ref={ref}
|
||||
className={clsx("focus-visible-none", className)}
|
||||
data-prevent-outside-click
|
||||
side={
|
||||
device.editor.isMobile && !device.viewport.isLandscape
|
||||
? "bottom"
|
||||
: "right"
|
||||
}
|
||||
align={
|
||||
device.editor.isMobile && !device.viewport.isLandscape
|
||||
? "center"
|
||||
: "start"
|
||||
}
|
||||
alignOffset={-16}
|
||||
sideOffset={20}
|
||||
style={{
|
||||
zIndex: "var(--zIndex-popup)",
|
||||
}}
|
||||
onPointerLeave={onPointerLeave}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocusOutside={onFocusOutside}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.stopPropagation();
|
||||
// prevents focusing the trigger
|
||||
e.preventDefault();
|
||||
|
||||
// return focus to excalidraw container unless
|
||||
// user focuses an interactive element, such as a button, or
|
||||
// enters the text editor by clicking on canvas with the text tool
|
||||
if (container && !isInteractive(document.activeElement)) {
|
||||
container.focus();
|
||||
}
|
||||
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Island padding={3} style={style}>
|
||||
{children}
|
||||
</Island>
|
||||
<Popover.Arrow
|
||||
width={20}
|
||||
height={10}
|
||||
style={{
|
||||
fill: "var(--popup-bg-color)",
|
||||
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
|
||||
}}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -133,7 +133,7 @@
|
||||
.required,
|
||||
.error {
|
||||
color: $oc-red-8;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
.excalidraw {
|
||||
--list-border-color: var(--color-gray-20);
|
||||
|
||||
.QuickSearch__wrapper {
|
||||
position: relative;
|
||||
height: 2.6rem; // added +0.1 due to Safari
|
||||
border-bottom: 1px solid var(--list-border-color);
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
top: 47.5%; // 50% is not exactly in the center of the input
|
||||
transform: translateY(-50%);
|
||||
left: 0.75rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: var(--color-gray-40);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
--list-border-color: var(--color-gray-80);
|
||||
|
||||
.QuickSearch__wrapper {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.QuickSearch__input {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 0 !important;
|
||||
font-size: 0.875rem;
|
||||
padding-left: 2.5rem !important;
|
||||
padding-right: 0.75rem !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-gray-40);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { searchIcon } from "./icons";
|
||||
|
||||
import "./QuickSearch.scss";
|
||||
|
||||
interface QuickSearchProps {
|
||||
className?: string;
|
||||
placeholder: string;
|
||||
onChange: (term: string) => void;
|
||||
}
|
||||
|
||||
export const QuickSearch = React.forwardRef<HTMLInputElement, QuickSearchProps>(
|
||||
({ className, placeholder, onChange }, ref) => {
|
||||
return (
|
||||
<div className={clsx("QuickSearch__wrapper", className)}>
|
||||
{searchIcon}
|
||||
<input
|
||||
ref={ref}
|
||||
className="QuickSearch__input"
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value.trim().toLowerCase())}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,21 +0,0 @@
|
||||
.excalidraw {
|
||||
.ScrollableList__wrapper {
|
||||
position: static !important;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
overflow-y: auto;
|
||||
|
||||
& > .empty,
|
||||
& > .hint {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-gray-60);
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
line-height: 150%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import { Children } from "react";
|
||||
|
||||
import "./ScrollableList.scss";
|
||||
|
||||
interface ScrollableListProps {
|
||||
className?: string;
|
||||
placeholder: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ScrollableList = ({
|
||||
className,
|
||||
placeholder,
|
||||
children,
|
||||
}: ScrollableListProps) => {
|
||||
const isEmpty = !Children.count(children);
|
||||
|
||||
return (
|
||||
<div className={clsx("ScrollableList__wrapper", className)} role="menu">
|
||||
{isEmpty ? <div className="empty">{placeholder}</div> : children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,80 +1,67 @@
|
||||
import { mutateElement } from "../../element/mutateElement";
|
||||
import { getBoundTextElement } from "../../element/textElement";
|
||||
import { isArrowElement } from "../../element/typeChecks";
|
||||
import type { ExcalidrawElement } from "../../element/types";
|
||||
import type { ElementsMap, 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";
|
||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
|
||||
interface AngleProps {
|
||||
element: ExcalidrawElement;
|
||||
scene: Scene;
|
||||
appState: AppState;
|
||||
property: "angle";
|
||||
elementsMap: ElementsMap;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const Angle = ({ element, elementsMap }: AngleProps) => {
|
||||
const handleDegreeChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
const origElement = originalElements[0];
|
||||
if (origElement) {
|
||||
if (nextValue !== undefined) {
|
||||
const nextAngle = degreeToRadian(nextValue);
|
||||
mutateElement(element, {
|
||||
angle: nextAngle,
|
||||
});
|
||||
|
||||
if (nextValue !== undefined) {
|
||||
const nextAngle = degreeToRadian(nextValue);
|
||||
mutateElement(latestElement, {
|
||||
angle: nextAngle,
|
||||
});
|
||||
updateBindings(latestElement, elementsMap);
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(element)) {
|
||||
mutateElement(boundTextElement, { angle: nextAngle });
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(latestElement)) {
|
||||
mutateElement(boundTextElement, { angle: nextAngle });
|
||||
return;
|
||||
}
|
||||
|
||||
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(element, {
|
||||
angle: nextAngle,
|
||||
});
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(element)) {
|
||||
mutateElement(boundTextElement, { angle: nextAngle });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const originalAngleInDegrees =
|
||||
Math.round(radianToDegree(origElement.angle) * 100) / 100;
|
||||
const changeInDegrees = Math.round(accumulatedChange);
|
||||
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
||||
if (shouldChangeByStepSize) {
|
||||
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
|
||||
}
|
||||
|
||||
nextAngleInDegrees =
|
||||
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
||||
|
||||
const nextAngle = degreeToRadian(nextAngleInDegrees);
|
||||
|
||||
mutateElement(latestElement, {
|
||||
angle: nextAngle,
|
||||
});
|
||||
updateBindings(latestElement, elementsMap);
|
||||
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(latestElement)) {
|
||||
mutateElement(boundTextElement, { angle: nextAngle });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const Angle = ({ element, scene, appState, property }: AngleProps) => {
|
||||
return (
|
||||
<DragInput
|
||||
label="A"
|
||||
@@ -83,9 +70,6 @@ const Angle = ({ element, scene, appState, property }: AngleProps) => {
|
||||
elements={[element]}
|
||||
dragInputCallback={handleDegreeChange}
|
||||
editable={isPropertyEditable(element, "angle")}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
property={property}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import type { ExcalidrawElement } from "../../element/types";
|
||||
import type { ElementsMap, 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;
|
||||
elementsMap: ElementsMap;
|
||||
}
|
||||
|
||||
const STEP_SIZE = 10;
|
||||
@@ -18,101 +15,99 @@ const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
|
||||
return element.type === "image";
|
||||
};
|
||||
|
||||
const handleDimensionChange: DragInputCallbackType<
|
||||
DimensionDragInputProps["property"]
|
||||
> = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
originalElementsMap,
|
||||
shouldKeepAspectRatio,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
const DimensionDragInput = ({
|
||||
property,
|
||||
scene,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const origElement = originalElements[0];
|
||||
if (origElement) {
|
||||
const keepAspectRatio =
|
||||
shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
|
||||
const aspectRatio = origElement.width / origElement.height;
|
||||
element,
|
||||
elementsMap,
|
||||
}: DimensionDragInputProps) => {
|
||||
const handleDimensionChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
originalElementsMap,
|
||||
shouldKeepAspectRatio,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
const origElement = originalElements[0];
|
||||
if (origElement) {
|
||||
const keepAspectRatio =
|
||||
shouldKeepAspectRatio || _shouldKeepAspectRatio(element);
|
||||
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,
|
||||
);
|
||||
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,
|
||||
element,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
|
||||
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,
|
||||
element,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
|
||||
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;
|
||||
@@ -124,9 +119,6 @@ const DimensionDragInput = ({
|
||||
dragInputCallback={handleDimensionChange}
|
||||
value={value}
|
||||
editable={isPropertyEditable(element, property)}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
property={property}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,54 +3,43 @@ import { EVENT } from "../../constants";
|
||||
import { KEYS } from "../../keys";
|
||||
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
||||
import { deepCopyElement } from "../../element/newElement";
|
||||
|
||||
import "./DragInput.scss";
|
||||
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: {
|
||||
export type DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
instantChange,
|
||||
originalElements,
|
||||
originalElementsMap,
|
||||
shouldKeepAspectRatio,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}: {
|
||||
accumulatedChange: number;
|
||||
instantChange: number;
|
||||
originalElements: readonly E[];
|
||||
originalElements: readonly ExcalidrawElement[];
|
||||
originalElementsMap: ElementsMap;
|
||||
shouldKeepAspectRatio: boolean;
|
||||
shouldChangeByStepSize: boolean;
|
||||
nextValue?: number;
|
||||
property: P;
|
||||
scene: Scene;
|
||||
originalAppState: AppState;
|
||||
}) => void;
|
||||
|
||||
interface StatsDragInputProps<
|
||||
T extends StatsInputProperty,
|
||||
E = ExcalidrawElement,
|
||||
> {
|
||||
interface StatsDragInputProps {
|
||||
label: string | React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
value: number | "Mixed";
|
||||
elements: readonly E[];
|
||||
elements: readonly ExcalidrawElement[];
|
||||
editable?: boolean;
|
||||
shouldKeepAspectRatio?: boolean;
|
||||
dragInputCallback: DragInputCallbackType<T, E>;
|
||||
property: T;
|
||||
scene: Scene;
|
||||
appState: AppState;
|
||||
dragInputCallback: DragInputCallbackType;
|
||||
}
|
||||
|
||||
const StatsDragInput = <
|
||||
T extends StatsInputProperty,
|
||||
E extends ExcalidrawElement = ExcalidrawElement,
|
||||
>({
|
||||
const StatsDragInput = ({
|
||||
label,
|
||||
icon,
|
||||
dragInputCallback,
|
||||
@@ -58,48 +47,19 @@ const StatsDragInput = <
|
||||
elements,
|
||||
editable = true,
|
||||
shouldKeepAspectRatio,
|
||||
property,
|
||||
scene,
|
||||
appState,
|
||||
}: StatsDragInputProps<T, E>) => {
|
||||
}: StatsDragInputProps) => {
|
||||
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]);
|
||||
setInputValue(value.toString());
|
||||
}, [value, elements]);
|
||||
|
||||
const handleInputValue = (
|
||||
updatedValue: string,
|
||||
elements: readonly E[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
if (!stateRef.current.updatePending) {
|
||||
return false;
|
||||
}
|
||||
stateRef.current.updatePending = false;
|
||||
|
||||
const parsed = Number(updatedValue);
|
||||
const handleInputValue = (v: string) => {
|
||||
const parsed = Number(v);
|
||||
if (isNaN(parsed)) {
|
||||
setInputValue(value.toString());
|
||||
return;
|
||||
@@ -114,7 +74,6 @@ const StatsDragInput = <
|
||||
// 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,
|
||||
@@ -123,9 +82,6 @@ const StatsDragInput = <
|
||||
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
||||
shouldChangeByStepSize: false,
|
||||
nextValue: rounded,
|
||||
property,
|
||||
scene,
|
||||
originalAppState: appState,
|
||||
});
|
||||
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
||||
}
|
||||
@@ -141,28 +97,12 @@ const StatsDragInput = <
|
||||
return () => {
|
||||
const nextValue = input?.value;
|
||||
if (nextValue) {
|
||||
handleInputValueRef.current(
|
||||
nextValue,
|
||||
stateRef.current.originalElements,
|
||||
stateRef.current.originalAppState,
|
||||
);
|
||||
handleInputValueRef.current(nextValue);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
// we need to track change of `editable` state as mount/unmount
|
||||
// because react doesn't trigger `blur` when a an input is blurred due
|
||||
// to being disabled (https://github.com/facebook/react/issues/9142).
|
||||
// As such, if we keep rendering disabled inputs, then change in selection
|
||||
// to an element that has a given property as non-editable would not trigger
|
||||
// blur/unmount and wouldn't update the value.
|
||||
editable,
|
||||
]);
|
||||
}, []);
|
||||
|
||||
if (!editable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
return editable ? (
|
||||
<div
|
||||
className={clsx("drag-input-container", !editable && "disabled")}
|
||||
data-testid={label}
|
||||
@@ -182,25 +122,30 @@ const StatsDragInput = <
|
||||
y: number;
|
||||
} | null = null;
|
||||
|
||||
let originalElements: ExcalidrawElement[] | 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);
|
||||
null;
|
||||
|
||||
let accumulatedChange: number | null = null;
|
||||
|
||||
document.body.classList.add("excalidraw-cursor-resize");
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (!originalElementsMap) {
|
||||
originalElementsMap = app.scene
|
||||
.getNonDeletedElements()
|
||||
.reduce((acc, element) => {
|
||||
acc.set(element.id, deepCopyElement(element));
|
||||
return acc;
|
||||
}, new Map() as ElementsMap);
|
||||
}
|
||||
|
||||
if (!originalElements) {
|
||||
originalElements = elements.map(
|
||||
(element) => originalElementsMap!.get(element.id)!,
|
||||
);
|
||||
}
|
||||
|
||||
if (!accumulatedChange) {
|
||||
accumulatedChange = 0;
|
||||
}
|
||||
@@ -208,7 +153,6 @@ const StatsDragInput = <
|
||||
if (
|
||||
lastPointer &&
|
||||
originalElementsMap !== null &&
|
||||
originalElements !== null &&
|
||||
accumulatedChange !== null
|
||||
) {
|
||||
const instantChange = event.clientX - lastPointer.x;
|
||||
@@ -221,9 +165,6 @@ const StatsDragInput = <
|
||||
originalElementsMap,
|
||||
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
||||
shouldChangeByStepSize: event.shiftKey,
|
||||
property,
|
||||
scene,
|
||||
originalAppState,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -275,7 +216,7 @@ const StatsDragInput = <
|
||||
eventTarget instanceof HTMLInputElement &&
|
||||
event.key === KEYS.ENTER
|
||||
) {
|
||||
handleInputValue(eventTarget.value, elements, appState);
|
||||
handleInputValue(eventTarget.value);
|
||||
app.focusContainer();
|
||||
}
|
||||
}
|
||||
@@ -283,28 +224,23 @@ const StatsDragInput = <
|
||||
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,
|
||||
);
|
||||
handleInputValue(event.target.value);
|
||||
}
|
||||
}}
|
||||
disabled={!editable}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,97 +1,73 @@
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "../../element/types";
|
||||
import type { ElementsMap, ExcalidrawTextElement } from "../../element/types";
|
||||
import { refreshTextDimensions } from "../../element/newElement";
|
||||
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";
|
||||
element: ExcalidrawTextElement;
|
||||
elementsMap: ElementsMap;
|
||||
}
|
||||
|
||||
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 FontSize = ({ element, elementsMap }: FontSizeProps) => {
|
||||
const handleFontSizeChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
const origElement = originalElements[0];
|
||||
if (origElement) {
|
||||
if (nextValue !== undefined) {
|
||||
const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
|
||||
|
||||
const origElement = originalElements[0];
|
||||
if (origElement) {
|
||||
const latestElement = elementsMap.get(origElement.id);
|
||||
if (!latestElement || !isTextElement(latestElement)) {
|
||||
return;
|
||||
}
|
||||
const newElement = {
|
||||
...element,
|
||||
fontSize: nextFontSize,
|
||||
};
|
||||
const updates = refreshTextDimensions(newElement, null, elementsMap);
|
||||
mutateElement(element, {
|
||||
...updates,
|
||||
fontSize: nextFontSize,
|
||||
});
|
||||
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 (origElement.type === "text") {
|
||||
const originalFontSize = Math.round(origElement.fontSize);
|
||||
const changeInFontSize = Math.round(accumulatedChange);
|
||||
let nextFontSize = Math.max(
|
||||
originalFontSize + changeInFontSize,
|
||||
MIN_FONT_SIZE,
|
||||
);
|
||||
if (shouldChangeByStepSize) {
|
||||
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
|
||||
}
|
||||
const newElement = {
|
||||
...element,
|
||||
fontSize: nextFontSize,
|
||||
};
|
||||
const updates = refreshTextDimensions(newElement, null, elementsMap);
|
||||
mutateElement(element, {
|
||||
...updates,
|
||||
fontSize: nextFontSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (nextFontSize) {
|
||||
mutateElement(latestElement, {
|
||||
fontSize: nextFontSize,
|
||||
});
|
||||
redrawTextBoundingBox(
|
||||
latestElement,
|
||||
scene.getContainerElement(latestElement),
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const FontSize = ({ element, scene, appState, property }: FontSizeProps) => {
|
||||
const _element = isTextElement(element)
|
||||
? element
|
||||
: hasBoundTextElement(element)
|
||||
? getBoundTextElement(element, scene.getNonDeletedElementsMap())
|
||||
: null;
|
||||
|
||||
if (!_element) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StatsDragInput
|
||||
label="F"
|
||||
value={Math.round(_element.fontSize * 10) / 10}
|
||||
elements={[_element]}
|
||||
value={Math.round(element.fontSize * 10) / 10}
|
||||
elements={[element]}
|
||||
dragInputCallback={handleFontSizeChange}
|
||||
icon={fontSizeIcon}
|
||||
appState={appState}
|
||||
scene={scene}
|
||||
property={property}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mutateElement } from "../../element/mutateElement";
|
||||
import { getBoundTextElement } from "../../element/textElement";
|
||||
import { isArrowElement } from "../../element/typeChecks";
|
||||
import type { ExcalidrawElement } from "../../element/types";
|
||||
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
||||
import { isInGroup } from "../../groups";
|
||||
import { degreeToRadian, radianToDegree } from "../../math";
|
||||
import type Scene from "../../scene/Scene";
|
||||
@@ -9,102 +9,84 @@ 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[];
|
||||
elementsMap: ElementsMap;
|
||||
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),
|
||||
);
|
||||
const MultiAngle = ({ elements, elementsMap, scene }: MultiAngleProps) => {
|
||||
const handleDegreeChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
const editableLatestIndividualElements = elements.filter(
|
||||
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
|
||||
);
|
||||
const editableOriginalIndividualElements = originalElements.filter(
|
||||
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
|
||||
);
|
||||
|
||||
if (nextValue !== undefined) {
|
||||
const nextAngle = degreeToRadian(nextValue);
|
||||
if (nextValue !== undefined) {
|
||||
const nextAngle = degreeToRadian(nextValue);
|
||||
|
||||
for (const element of editableLatestIndividualElements) {
|
||||
if (!element) {
|
||||
continue;
|
||||
for (const element of editableLatestIndividualElements) {
|
||||
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];
|
||||
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(
|
||||
element,
|
||||
latestElement,
|
||||
{
|
||||
angle: nextAngle,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(element)) {
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(latestElement)) {
|
||||
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"),
|
||||
);
|
||||
@@ -125,9 +107,6 @@ const MultiAngle = ({
|
||||
elements={elements}
|
||||
dragInputCallback={handleDegreeChange}
|
||||
editable={editable}
|
||||
appState={appState}
|
||||
scene={scene}
|
||||
property={property}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,16 +7,12 @@ import {
|
||||
getBoundTextElement,
|
||||
handleBindTextResize,
|
||||
} from "../../element/textElement";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../../element/types";
|
||||
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import type { AppState, Point } from "../../types";
|
||||
import type { Point } from "../../types";
|
||||
import DragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
import { getElementsInAtomicUnit, resizeElement } from "./utils";
|
||||
import type { AtomicUnit } from "./utils";
|
||||
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
||||
@@ -24,10 +20,9 @@ import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
||||
interface MultiDimensionProps {
|
||||
property: "width" | "height";
|
||||
elements: readonly ExcalidrawElement[];
|
||||
elementsMap: NonDeletedSceneElementsMap;
|
||||
elementsMap: ElementsMap;
|
||||
atomicUnits: AtomicUnit[];
|
||||
scene: Scene;
|
||||
appState: AppState;
|
||||
}
|
||||
|
||||
const STEP_SIZE = 10;
|
||||
@@ -64,7 +59,7 @@ const resizeElementInGroup = (
|
||||
scale: number,
|
||||
latestElement: ExcalidrawElement,
|
||||
origElement: ExcalidrawElement,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elementsMap: ElementsMap,
|
||||
originalElementsMap: ElementsMap,
|
||||
) => {
|
||||
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
|
||||
@@ -107,7 +102,7 @@ const resizeGroup = (
|
||||
property: MultiDimensionProps["property"],
|
||||
latestElements: ExcalidrawElement[],
|
||||
originalElements: ExcalidrawElement[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elementsMap: ElementsMap,
|
||||
originalElementsMap: ElementsMap,
|
||||
) => {
|
||||
// keep aspect ratio for groups
|
||||
@@ -136,205 +131,12 @@ const resizeGroup = (
|
||||
}
|
||||
};
|
||||
|
||||
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(
|
||||
() =>
|
||||
@@ -365,6 +167,202 @@ const MultiDimension = ({
|
||||
|
||||
const editable = sizes.length > 0;
|
||||
|
||||
const handleDimensionChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
originalElementsMap,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
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,
|
||||
latestElement,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
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,
|
||||
latestElement,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene.triggerUpdate();
|
||||
};
|
||||
|
||||
return (
|
||||
<DragInput
|
||||
label={property === "width" ? "W" : "H"}
|
||||
@@ -372,9 +370,6 @@ const MultiDimension = ({
|
||||
dragInputCallback={handleDimensionChange}
|
||||
value={value}
|
||||
editable={editable}
|
||||
appState={appState}
|
||||
property={property}
|
||||
scene={scene}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { isTextElement, redrawTextBoundingBox } from "../../element";
|
||||
import { isTextElement, refreshTextDimensions } from "../../element";
|
||||
import { mutateElement } from "../../element/mutateElement";
|
||||
import { hasBoundTextElement } from "../../element/typeChecks";
|
||||
import { isBoundToContainer } from "../../element/typeChecks";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../../element/types";
|
||||
import { isInGroup } from "../../groups";
|
||||
import type Scene from "../../scene/Scene";
|
||||
@@ -12,87 +12,62 @@ 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[];
|
||||
elementsMap: ElementsMap;
|
||||
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,
|
||||
const MultiFontSize = ({
|
||||
elements,
|
||||
elementsMap,
|
||||
scene,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const latestTextElements = originalElements.map((el) =>
|
||||
elementsMap.get(el.id),
|
||||
}: MultiFontSizeProps) => {
|
||||
const latestTextElements = elements.filter(
|
||||
(el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el),
|
||||
) as ExcalidrawTextElement[];
|
||||
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;
|
||||
|
||||
let nextFontSize;
|
||||
const handleFontSizeChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
if (nextValue) {
|
||||
const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
|
||||
|
||||
if (nextValue) {
|
||||
nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
|
||||
|
||||
for (const textElement of latestTextElements) {
|
||||
mutateElement(
|
||||
textElement,
|
||||
{
|
||||
for (const textElement of latestTextElements) {
|
||||
const newElement = {
|
||||
...textElement,
|
||||
fontSize: nextFontSize,
|
||||
},
|
||||
false,
|
||||
);
|
||||
};
|
||||
const updates = refreshTextDimensions(newElement, null, elementsMap);
|
||||
mutateElement(
|
||||
textElement,
|
||||
{
|
||||
...updates,
|
||||
fontSize: nextFontSize,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
redrawTextBoundingBox(
|
||||
textElement,
|
||||
scene.getContainerElement(textElement),
|
||||
elementsMap,
|
||||
false,
|
||||
);
|
||||
scene.triggerUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
scene.triggerUpdate();
|
||||
} else {
|
||||
const originalTextElements = originalElements as ExcalidrawTextElement[];
|
||||
const originalTextElements = originalElements.filter(
|
||||
(el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el),
|
||||
) as ExcalidrawTextElement[];
|
||||
|
||||
for (let i = 0; i < latestTextElements.length; i++) {
|
||||
const latestElement = latestTextElements[i];
|
||||
@@ -107,56 +82,32 @@ const handleFontSizeChange: DragInputCallbackType<
|
||||
if (shouldChangeByStepSize) {
|
||||
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
|
||||
}
|
||||
const newElement = {
|
||||
...latestElement,
|
||||
fontSize: nextFontSize,
|
||||
};
|
||||
const updates = refreshTextDimensions(newElement, null, elementsMap);
|
||||
mutateElement(
|
||||
latestElement,
|
||||
{
|
||||
...updates,
|
||||
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}
|
||||
elements={elements}
|
||||
dragInputCallback={handleFontSizeChange}
|
||||
value={value}
|
||||
editable={editable}
|
||||
scene={scene}
|
||||
property={property}
|
||||
appState={appState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../../element/types";
|
||||
import type { ElementsMap, ExcalidrawElement } 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 { 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";
|
||||
@@ -20,7 +15,6 @@ interface MultiPositionProps {
|
||||
elementsMap: ElementsMap;
|
||||
atomicUnits: AtomicUnit[];
|
||||
scene: Scene;
|
||||
appState: AppState;
|
||||
}
|
||||
|
||||
const STEP_SIZE = 10;
|
||||
@@ -31,11 +25,12 @@ const moveElements = (
|
||||
changeInTopY: number,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
originalElements: readonly ExcalidrawElement[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elementsMap: ElementsMap,
|
||||
originalElementsMap: ElementsMap,
|
||||
) => {
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const origElement = originalElements[i];
|
||||
const latestElement = elements[i];
|
||||
|
||||
const [cx, cy] = [
|
||||
origElement.x + origElement.width / 2,
|
||||
@@ -58,6 +53,7 @@ const moveElements = (
|
||||
moveElement(
|
||||
newTopLeftX,
|
||||
newTopLeftY,
|
||||
latestElement,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
@@ -69,22 +65,18 @@ const moveElements = (
|
||||
const moveGroupTo = (
|
||||
nextX: number,
|
||||
nextY: number,
|
||||
latestElements: ExcalidrawElement[],
|
||||
originalElements: ExcalidrawElement[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elementsMap: ElementsMap,
|
||||
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++) {
|
||||
for (let i = 0; i < latestElements.length; i++) {
|
||||
const origElement = originalElements[i];
|
||||
|
||||
const latestElement = elementsMap.get(origElement.id);
|
||||
if (!latestElement) {
|
||||
continue;
|
||||
}
|
||||
const latestElement = latestElements[i];
|
||||
|
||||
// bound texts are moved with their containers
|
||||
if (!isTextElement(latestElement) || !latestElement.containerId) {
|
||||
@@ -104,6 +96,7 @@ const moveGroupTo = (
|
||||
moveElement(
|
||||
topLeftX + offsetX,
|
||||
topLeftY + offsetY,
|
||||
latestElement,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
@@ -113,111 +106,12 @@ const moveGroupTo = (
|
||||
}
|
||||
};
|
||||
|
||||
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(
|
||||
() =>
|
||||
@@ -243,15 +137,101 @@ const MultiPosition = ({
|
||||
|
||||
const value = new Set(positions).size === 1 ? positions[0] : "Mixed";
|
||||
|
||||
const handlePositionChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
originalElementsMap,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
if (nextValue !== undefined) {
|
||||
for (const atomicUnit of atomicUnits) {
|
||||
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.latest),
|
||||
elementsInUnit.map((el) => el.original),
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
} 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,
|
||||
latestElement,
|
||||
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,
|
||||
elements,
|
||||
originalElements,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
|
||||
scene.triggerUpdate();
|
||||
};
|
||||
|
||||
return (
|
||||
<StatsDragInput
|
||||
label={property === "x" ? "X" : "Y"}
|
||||
elements={elements}
|
||||
dragInputCallback={handlePositionChange}
|
||||
value={value}
|
||||
property={property}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,92 +3,16 @@ 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 Position = ({ property, element, elementsMap }: PositionProps) => {
|
||||
const [topLeftX, topLeftY] = rotate(
|
||||
element.x,
|
||||
element.y,
|
||||
@@ -99,15 +23,77 @@ const Position = ({
|
||||
const value =
|
||||
Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
|
||||
|
||||
const handlePositionChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
originalElementsMap,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
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,
|
||||
element,
|
||||
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,
|
||||
element,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StatsDragInput
|
||||
label={property === "x" ? "X" : "Y"}
|
||||
elements={[element]}
|
||||
dragInputCallback={handlePositionChange}
|
||||
value={value}
|
||||
property={property}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,7 +11,12 @@ import Angle from "./Angle";
|
||||
|
||||
import FontSize from "./FontSize";
|
||||
import MultiDimension from "./MultiDimension";
|
||||
import { elementsAreInSameGroup } from "../../groups";
|
||||
import {
|
||||
elementsAreInSameGroup,
|
||||
getElementsInGroup,
|
||||
getSelectedGroupIds,
|
||||
isInGroup,
|
||||
} from "../../groups";
|
||||
import MultiAngle from "./MultiAngle";
|
||||
import MultiFontSize from "./MultiFontSize";
|
||||
import Position from "./Position";
|
||||
@@ -19,7 +24,7 @@ import MultiPosition from "./MultiPosition";
|
||||
import Collapsible from "./Collapsible";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
|
||||
import { getAtomicUnits } from "./utils";
|
||||
import type { AtomicUnit } from "./utils";
|
||||
import { STATS_PANELS } from "../../constants";
|
||||
|
||||
interface StatsProps {
|
||||
@@ -101,7 +106,21 @@ export const StatsInner = memo(
|
||||
);
|
||||
|
||||
const atomicUnits = useMemo(() => {
|
||||
return getAtomicUnits(selectedElements, appState);
|
||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||
const _atomicUnits = selectedGroupIds.map((gid) => {
|
||||
return getElementsInGroup(selectedElements, gid).reduce((acc, el) => {
|
||||
acc[el.id] = true;
|
||||
return acc;
|
||||
}, {} as AtomicUnit);
|
||||
});
|
||||
selectedElements
|
||||
.filter((el) => !isInGroup(el))
|
||||
.forEach((el) => {
|
||||
_atomicUnits.push({
|
||||
[el.id]: true,
|
||||
});
|
||||
});
|
||||
return _atomicUnits;
|
||||
}, [selectedElements, appState]);
|
||||
|
||||
return (
|
||||
@@ -187,40 +206,32 @@ export const StatsInner = memo(
|
||||
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}
|
||||
elementsMap={elementsMap}
|
||||
/>
|
||||
<Dimension
|
||||
property="height"
|
||||
element={singleElement}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
elementsMap={elementsMap}
|
||||
/>
|
||||
<Angle
|
||||
property="angle"
|
||||
element={singleElement}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
<FontSize
|
||||
property="fontSize"
|
||||
element={singleElement}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
elementsMap={elementsMap}
|
||||
/>
|
||||
{singleElement.type === "text" && (
|
||||
<FontSize
|
||||
element={singleElement}
|
||||
elementsMap={elementsMap}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -243,7 +254,6 @@ export const StatsInner = memo(
|
||||
elementsMap={elementsMap}
|
||||
atomicUnits={atomicUnits}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
<MultiPosition
|
||||
property="y"
|
||||
@@ -251,7 +261,6 @@ export const StatsInner = memo(
|
||||
elementsMap={elementsMap}
|
||||
atomicUnits={atomicUnits}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
<MultiDimension
|
||||
property="width"
|
||||
@@ -259,7 +268,6 @@ export const StatsInner = memo(
|
||||
elementsMap={elementsMap}
|
||||
atomicUnits={atomicUnits}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
<MultiDimension
|
||||
property="height"
|
||||
@@ -267,20 +275,16 @@ export const StatsInner = memo(
|
||||
elementsMap={elementsMap}
|
||||
atomicUnits={atomicUnits}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
<MultiAngle
|
||||
property="angle"
|
||||
elements={multipleElements}
|
||||
elementsMap={elementsMap}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
<MultiFontSize
|
||||
property="fontSize"
|
||||
elements={multipleElements}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
elementsMap={elementsMap}
|
||||
scene={scene}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,11 +11,10 @@ import * as StaticScene from "../../renderer/staticScene";
|
||||
import { vi } from "vitest";
|
||||
import { reseed } from "../../random";
|
||||
import { setDateTimeForTests } from "../../utils";
|
||||
import { Excalidraw, mutateElement } from "../..";
|
||||
import { Excalidraw } from "../..";
|
||||
import { t } from "../../i18n";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "../../element/types";
|
||||
import { degreeToRadian, rotate } from "../../math";
|
||||
@@ -24,7 +23,6 @@ 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");
|
||||
@@ -32,21 +30,11 @@ 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 properties?.querySelector?.(
|
||||
`.drag-input-container[data-testid="${label}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,9 +51,11 @@ const testInputProperty = (
|
||||
const input = getStatsProperty(label)?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(input).toBeDefined();
|
||||
expect(input).not.toBeNull();
|
||||
expect(input.value).toBe(initialValue.toString());
|
||||
editInput(input, String(nextValue));
|
||||
input?.focus();
|
||||
input.value = nextValue.toString();
|
||||
input?.blur();
|
||||
if (property === "angle") {
|
||||
expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
|
||||
} else if (property === "fontSize" && isTextElement(element)) {
|
||||
@@ -101,92 +91,6 @@ describe("step sized value", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -223,8 +127,8 @@ describe("stats for a generic element", () => {
|
||||
});
|
||||
|
||||
it("should open stats", () => {
|
||||
expect(stats).toBeDefined();
|
||||
expect(elementStats).toBeDefined();
|
||||
expect(stats).not.toBeNull();
|
||||
expect(elementStats).not.toBeNull();
|
||||
|
||||
// title
|
||||
const title = elementStats?.querySelector("h3");
|
||||
@@ -232,18 +136,18 @@ describe("stats for a generic element", () => {
|
||||
|
||||
// element type
|
||||
const elementType = elementStats?.querySelector(".elementType");
|
||||
expect(elementType).toBeDefined();
|
||||
expect(elementType).not.toBeNull();
|
||||
expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
|
||||
|
||||
// properties
|
||||
const properties = elementStats?.querySelector(".statsItem");
|
||||
expect(properties?.childNodes).toBeDefined();
|
||||
expect(properties?.childNodes).not.toBeNull();
|
||||
["X", "Y", "W", "H", "A"].forEach((label) => () => {
|
||||
expect(
|
||||
properties?.querySelector?.(
|
||||
`.drag-input-container[data-testid="${label}"]`,
|
||||
),
|
||||
).toBeDefined();
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,15 +170,19 @@ describe("stats for a generic element", () => {
|
||||
const input = getStatsProperty("W")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(input).toBeDefined();
|
||||
expect(input).not.toBeNull();
|
||||
expect(input.value).toBe(rectangle.width.toString());
|
||||
editInput(input, "123.123");
|
||||
input?.focus();
|
||||
input.value = "123.123";
|
||||
input?.blur();
|
||||
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");
|
||||
input?.focus();
|
||||
input.value = "88.98766";
|
||||
input?.blur();
|
||||
expect(input.value).toBe("88.99");
|
||||
expect(rectangle.width).toBe(88.99);
|
||||
});
|
||||
@@ -425,9 +333,11 @@ describe("stats for a non-generic element", () => {
|
||||
const input = getStatsProperty("F")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(input).toBeDefined();
|
||||
expect(input).not.toBeNull();
|
||||
expect(input.value).toBe(text.fontSize.toString());
|
||||
editInput(input, "36");
|
||||
input?.focus();
|
||||
input.value = "36";
|
||||
input?.blur();
|
||||
expect(text.fontSize).toBe(36);
|
||||
|
||||
// cannot change width or height
|
||||
@@ -437,7 +347,9 @@ describe("stats for a non-generic element", () => {
|
||||
expect(height).toBeUndefined();
|
||||
|
||||
// min font size is 4
|
||||
editInput(input, "0");
|
||||
input.focus();
|
||||
input.value = "0";
|
||||
input.blur();
|
||||
expect(text.fontSize).not.toBe(0);
|
||||
expect(text.fontSize).toBe(4);
|
||||
});
|
||||
@@ -458,7 +370,7 @@ describe("stats for a non-generic element", () => {
|
||||
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
|
||||
expect(elementStats).toBeDefined();
|
||||
expect(elementStats).not.toBeNull();
|
||||
|
||||
// cannot change angle
|
||||
const angle = getStatsProperty("A")?.querySelector(".drag-input");
|
||||
@@ -479,7 +391,7 @@ describe("stats for a non-generic element", () => {
|
||||
},
|
||||
});
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
expect(elementStats).toBeDefined();
|
||||
expect(elementStats).not.toBeNull();
|
||||
const widthToHeight = image.width / image.height;
|
||||
|
||||
// when width or height is changed, the aspect ratio is preserved
|
||||
@@ -491,35 +403,6 @@ describe("stats for a non-generic element", () => {
|
||||
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
|
||||
@@ -588,12 +471,16 @@ describe("stats for multiple elements", () => {
|
||||
) as HTMLInputElement;
|
||||
expect(angle.value).toBe("0");
|
||||
|
||||
editInput(width, "250");
|
||||
width.focus();
|
||||
width.value = "250";
|
||||
width.blur();
|
||||
h.elements.forEach((el) => {
|
||||
expect(el.width).toBe(250);
|
||||
});
|
||||
|
||||
editInput(height, "450");
|
||||
height.focus();
|
||||
height.value = "450";
|
||||
height.blur();
|
||||
h.elements.forEach((el) => {
|
||||
expect(el.height).toBe(450);
|
||||
});
|
||||
@@ -614,6 +501,7 @@ describe("stats for multiple elements", () => {
|
||||
mouse.up(200, 100);
|
||||
|
||||
const frame = API.createElement({
|
||||
id: "id0",
|
||||
type: "frame",
|
||||
x: 150,
|
||||
width: 150,
|
||||
@@ -636,34 +524,38 @@ describe("stats for multiple elements", () => {
|
||||
const width = getStatsProperty("W")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(width).toBeDefined();
|
||||
expect(width).not.toBeNull();
|
||||
expect(width.value).toBe("Mixed");
|
||||
|
||||
const height = getStatsProperty("H")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(height).toBeDefined();
|
||||
expect(height).not.toBeNull();
|
||||
expect(height.value).toBe("Mixed");
|
||||
|
||||
const angle = getStatsProperty("A")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(angle).toBeDefined();
|
||||
expect(angle).not.toBeNull();
|
||||
expect(angle.value).toBe("0");
|
||||
|
||||
const fontSize = getStatsProperty("F")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(fontSize).toBeDefined();
|
||||
expect(fontSize).not.toBeNull();
|
||||
|
||||
// changing width does not affect text
|
||||
editInput(width, "200");
|
||||
width.focus();
|
||||
width.value = "200";
|
||||
width.blur();
|
||||
|
||||
expect(rectangle?.width).toBe(200);
|
||||
expect(frame.width).toBe(200);
|
||||
expect(text?.width).not.toBe(200);
|
||||
|
||||
editInput(angle, "40");
|
||||
angle.focus();
|
||||
angle.value = "40";
|
||||
angle.blur();
|
||||
|
||||
const angleInRadian = degreeToRadian(40);
|
||||
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
|
||||
@@ -700,10 +592,12 @@ describe("stats for multiple elements", () => {
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
expect(x).toBeDefined();
|
||||
expect(x).not.toBeNull();
|
||||
expect(Number(x.value)).toBe(x1);
|
||||
|
||||
editInput(x, "300");
|
||||
x.focus();
|
||||
x.value = "300";
|
||||
x.blur();
|
||||
|
||||
expect(h.elements[0].x).toBe(300);
|
||||
expect(h.elements[1].x).toBe(400);
|
||||
@@ -713,10 +607,12 @@ describe("stats for multiple elements", () => {
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
expect(y).toBeDefined();
|
||||
expect(y).not.toBeNull();
|
||||
expect(Number(y.value)).toBe(y1);
|
||||
|
||||
editInput(y, "200");
|
||||
y.focus();
|
||||
y.value = "200";
|
||||
y.blur();
|
||||
|
||||
expect(h.elements[0].y).toBe(200);
|
||||
expect(h.elements[1].y).toBe(300);
|
||||
@@ -725,29 +621,35 @@ describe("stats for multiple elements", () => {
|
||||
const width = getStatsProperty("W")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(width).toBeDefined();
|
||||
expect(width).not.toBeNull();
|
||||
expect(Number(width.value)).toBe(200);
|
||||
|
||||
const height = getStatsProperty("H")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(height).toBeDefined();
|
||||
expect(height).not.toBeNull();
|
||||
expect(Number(height.value)).toBe(200);
|
||||
|
||||
editInput(width, "400");
|
||||
width.focus();
|
||||
width.value = "400";
|
||||
width.blur();
|
||||
|
||||
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||
let newGroupWidth = x2 - x1;
|
||||
|
||||
expect(newGroupWidth).toBeCloseTo(400, 4);
|
||||
|
||||
editInput(width, "300");
|
||||
width.focus();
|
||||
width.value = "300";
|
||||
width.blur();
|
||||
|
||||
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||
newGroupWidth = x2 - x1;
|
||||
expect(newGroupWidth).toBeCloseTo(300, 4);
|
||||
|
||||
editInput(height, "500");
|
||||
height.focus();
|
||||
height.value = "500";
|
||||
height.blur();
|
||||
|
||||
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||
const newGroupHeight = y2 - y1;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
bindOrUnbindLinearElements,
|
||||
updateBoundElements,
|
||||
} from "../../element/binding";
|
||||
import { updateBoundElements } from "../../element/binding";
|
||||
import { mutateElement } from "../../element/mutateElement";
|
||||
import {
|
||||
measureFontSizeFromWidth,
|
||||
@@ -14,34 +11,15 @@ import {
|
||||
getBoundTextMaxWidth,
|
||||
handleBindTextResize,
|
||||
} from "../../element/textElement";
|
||||
import {
|
||||
isFrameLikeElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "../../element/typeChecks";
|
||||
import { isFrameLikeElement, 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 = (
|
||||
@@ -122,14 +100,12 @@ export const resizeElement = (
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
keepAspectRatio: boolean,
|
||||
latestElement: ExcalidrawElement,
|
||||
origElement: ExcalidrawElement,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elementsMap: ElementsMap,
|
||||
originalElementsMap: Map<string, ExcalidrawElement>,
|
||||
shouldInformMutation = true,
|
||||
) => {
|
||||
const latestElement = elementsMap.get(origElement.id);
|
||||
if (!latestElement) {
|
||||
return;
|
||||
}
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
|
||||
@@ -164,12 +140,6 @@ export const resizeElement = (
|
||||
},
|
||||
shouldInformMutation,
|
||||
);
|
||||
updateBindings(latestElement, elementsMap, {
|
||||
newSize: {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
},
|
||||
});
|
||||
|
||||
if (boundTextElement) {
|
||||
boundTextFont = {
|
||||
@@ -193,6 +163,13 @@ export const resizeElement = (
|
||||
}
|
||||
}
|
||||
|
||||
updateBoundElements(latestElement, elementsMap, {
|
||||
newSize: {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
},
|
||||
});
|
||||
|
||||
if (boundTextElement && boundTextFont) {
|
||||
mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
@@ -204,15 +181,12 @@ export const resizeElement = (
|
||||
export const moveElement = (
|
||||
newTopLeftX: number,
|
||||
newTopLeftY: number,
|
||||
latestElement: ExcalidrawElement,
|
||||
originalElement: ExcalidrawElement,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elementsMap: ElementsMap,
|
||||
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,
|
||||
@@ -244,7 +218,6 @@ export const moveElement = (
|
||||
},
|
||||
shouldInformMutation,
|
||||
);
|
||||
updateBindings(latestElement, elementsMap);
|
||||
|
||||
const boundTextElement = getBoundTextElement(
|
||||
originalElement,
|
||||
@@ -263,39 +236,3 @@ export const moveElement = (
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
color: red;
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
font-size: 30px;
|
||||
word-break: break-word;
|
||||
overflow: auto;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { MermaidConfig } from "@excalidraw/mermaid-to-excalidraw";
|
||||
import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
|
||||
import type { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
|
||||
import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "../../constants";
|
||||
import {
|
||||
DEFAULT_EXPORT_PADDING,
|
||||
DEFAULT_FONT_SIZE,
|
||||
EDITOR_LS_KEYS,
|
||||
} from "../../constants";
|
||||
import { convertToExcalidrawElements, exportToCanvas } from "../../index";
|
||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
import type { AppClassProperties, BinaryFiles } from "../../types";
|
||||
@@ -34,7 +38,7 @@ export interface MermaidToExcalidrawLibProps {
|
||||
api: Promise<{
|
||||
parseMermaidToExcalidraw: (
|
||||
definition: string,
|
||||
config?: MermaidConfig,
|
||||
options: MermaidOptions,
|
||||
) => Promise<MermaidToExcalidrawResult>;
|
||||
}>;
|
||||
}
|
||||
@@ -74,10 +78,15 @@ export const convertMermaidToExcalidraw = async ({
|
||||
|
||||
let ret;
|
||||
try {
|
||||
ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
|
||||
ret = await api.parseMermaidToExcalidraw(mermaidDefinition, {
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
});
|
||||
} catch (err: any) {
|
||||
ret = await api.parseMermaidToExcalidraw(
|
||||
mermaidDefinition.replace(/"/g, "'"),
|
||||
{
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
},
|
||||
);
|
||||
}
|
||||
const { elements, files } = ret;
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
--avatarList-gap: 0.625rem;
|
||||
--userList-padding: var(--space-factor);
|
||||
|
||||
.UserList__wrapper {
|
||||
.UserList-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
@@ -22,6 +21,10 @@
|
||||
align-items: center;
|
||||
gap: var(--avatarList-gap);
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
--max-size: calc(
|
||||
@@ -154,7 +157,66 @@
|
||||
}
|
||||
|
||||
.UserList__collaborators {
|
||||
position: static;
|
||||
top: auto;
|
||||
margin-top: 0;
|
||||
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,12 +9,11 @@ import type { ActionManager } from "../actions/manager";
|
||||
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { Island } from "./Island";
|
||||
import { QuickSearch } from "./QuickSearch";
|
||||
import { searchIcon } from "./icons";
|
||||
import { t } from "../i18n";
|
||||
import { isShallowEqual } from "../utils";
|
||||
import { supportsResizeObserver } from "../constants";
|
||||
import type { MarkRequired } from "../utility-types";
|
||||
import { ScrollableList } from "./ScrollableList";
|
||||
|
||||
export type GoToCollaboratorComponentProps = {
|
||||
socketId: SocketId;
|
||||
@@ -41,7 +40,7 @@ const ConditionalTooltipWrapper = ({
|
||||
shouldWrap ? (
|
||||
<Tooltip label={username || "Unknown user"}>{children}</Tooltip>
|
||||
) : (
|
||||
<>{children}</>
|
||||
<React.Fragment>{children}</React.Fragment>
|
||||
);
|
||||
|
||||
const renderCollaborator = ({
|
||||
@@ -129,10 +128,6 @@ export const UserList = React.memo(
|
||||
).filter((collaborator) => collaborator.username?.trim());
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
const filteredCollaborators = uniqueCollaboratorsArray.filter(
|
||||
(collaborator) =>
|
||||
collaborator.username?.toLowerCase().includes(searchTerm),
|
||||
);
|
||||
|
||||
const userListWrapper = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -166,6 +161,14 @@ export const UserList = React.memo(
|
||||
|
||||
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(
|
||||
0,
|
||||
maxAvatars - 1,
|
||||
@@ -194,7 +197,7 @@ export const UserList = React.memo(
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="UserList__wrapper" ref={userListWrapper}>
|
||||
<div className="UserList-wrapper" ref={userListWrapper}>
|
||||
<div
|
||||
className={clsx("UserList", className)}
|
||||
style={{ [`--max-avatars` as any]: maxAvatars }}
|
||||
@@ -202,7 +205,13 @@ export const UserList = React.memo(
|
||||
{firstNAvatarsJSX}
|
||||
|
||||
{uniqueCollaboratorsArray.length > maxAvatars - 1 && (
|
||||
<Popover.Root>
|
||||
<Popover.Root
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setSearchTerm("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger className="UserList__more">
|
||||
+{uniqueCollaboratorsArray.length - maxAvatars + 1}
|
||||
</Popover.Trigger>
|
||||
@@ -215,43 +224,41 @@ export const UserList = React.memo(
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
>
|
||||
<Island padding={2}>
|
||||
<Island style={{ overflow: "hidden" }}>
|
||||
{uniqueCollaboratorsArray.length >=
|
||||
SHOW_COLLABORATORS_FILTER_AT && (
|
||||
<QuickSearch
|
||||
placeholder={t("quickSearch.placeholder")}
|
||||
onChange={setSearchTerm}
|
||||
/>
|
||||
<div className="UserList__search-wrapper">
|
||||
{searchIcon}
|
||||
<input
|
||||
className="UserList__search"
|
||||
type="text"
|
||||
placeholder={t("userList.search.placeholder")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ScrollableList
|
||||
className={"dropdown-menu UserList__collaborators"}
|
||||
placeholder={t("userList.empty")}
|
||||
>
|
||||
{/* The list checks for `Children.count()`, hence defensively returning empty list */}
|
||||
{filteredCollaborators.length > 0
|
||||
? [
|
||||
<div className="hint">{t("userList.hint.text")}</div>,
|
||||
filteredCollaborators.map((collaborator) =>
|
||||
renderCollaborator({
|
||||
actionManager,
|
||||
collaborator,
|
||||
socketId: collaborator.socketId,
|
||||
withName: true,
|
||||
isBeingFollowed:
|
||||
collaborator.socketId === userToFollow,
|
||||
}),
|
||||
),
|
||||
]
|
||||
: []}
|
||||
</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)",
|
||||
}}
|
||||
/>
|
||||
<div className="dropdown-menu UserList__collaborators">
|
||||
{filteredCollaborators.length === 0 && (
|
||||
<div className="UserList__collaborators__empty">
|
||||
{t("userList.search.empty")}
|
||||
</div>
|
||||
)}
|
||||
<div className="UserList__hint">
|
||||
{t("userList.hint.text")}
|
||||
</div>
|
||||
{filteredCollaborators.map((collaborator) =>
|
||||
renderCollaborator({
|
||||
actionManager,
|
||||
collaborator,
|
||||
socketId: collaborator.socketId,
|
||||
withName: true,
|
||||
isBeingFollowed: collaborator.socketId === userToFollow,
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</Island>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
@@ -105,7 +105,6 @@ const getRelevantAppStateProps = (
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
frameToHighlight: appState.frameToHighlight,
|
||||
editingGroupId: appState.editingGroupId,
|
||||
currentHoveredFontFamily: appState.currentHoveredFontFamily,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
margin-top: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
|
||||
&--mobile {
|
||||
left: 0;
|
||||
@@ -35,69 +35,21 @@
|
||||
|
||||
.dropdown-menu-item-base {
|
||||
display: flex;
|
||||
padding: 0 0.625rem;
|
||||
column-gap: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-on-surface);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-weight: 400;
|
||||
font-weight: normal;
|
||||
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 {
|
||||
height: 2rem;
|
||||
margin: 1px;
|
||||
padding: 0 0.5rem;
|
||||
width: calc(100% - 2px);
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
align-items: center;
|
||||
height: 2rem;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-md);
|
||||
|
||||
@@ -105,6 +57,11 @@
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: var(--color-primary-light);
|
||||
--icon-fill-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&__text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -126,11 +83,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: var(--color-primary-light);
|
||||
--icon-fill-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-hover-bg);
|
||||
text-decoration: none;
|
||||
|
||||
@@ -1,62 +1,37 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import React from "react";
|
||||
import {
|
||||
getDropdownMenuItemClassName,
|
||||
useHandleDropdownMenuItemClick,
|
||||
} from "./common";
|
||||
import MenuItemContent from "./DropdownMenuItemContent";
|
||||
import { useExcalidrawAppState } from "../App";
|
||||
import { THEME } from "../../constants";
|
||||
import type { ValueOf } from "../../utility-types";
|
||||
|
||||
const DropdownMenuItem = ({
|
||||
icon,
|
||||
value,
|
||||
order,
|
||||
onSelect,
|
||||
children,
|
||||
shortcut,
|
||||
className,
|
||||
hovered,
|
||||
selected,
|
||||
textStyle,
|
||||
onSelect,
|
||||
onClick,
|
||||
...rest
|
||||
}: {
|
||||
icon?: JSX.Element;
|
||||
value?: string | number | undefined;
|
||||
order?: number;
|
||||
onSelect?: (event: Event) => void;
|
||||
onSelect: (event: Event) => void;
|
||||
children: React.ReactNode;
|
||||
shortcut?: string;
|
||||
hovered?: boolean;
|
||||
selected?: boolean;
|
||||
textStyle?: React.CSSProperties;
|
||||
className?: string;
|
||||
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "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]);
|
||||
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
ref={ref}
|
||||
value={value}
|
||||
onClick={handleClick}
|
||||
className={getDropdownMenuItemClassName(className, selected, hovered)}
|
||||
type="button"
|
||||
className={getDropdownMenuItemClassName(className, selected)}
|
||||
title={rest.title ?? rest["aria-label"]}
|
||||
>
|
||||
<MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
|
||||
<MenuItemContent icon={icon} shortcut={shortcut}>
|
||||
{children}
|
||||
</MenuItemContent>
|
||||
</button>
|
||||
@@ -64,53 +39,24 @@ const DropdownMenuItem = ({
|
||||
};
|
||||
DropdownMenuItem.displayName = "DropdownMenuItem";
|
||||
|
||||
export const DropDownMenuItemBadgeType = {
|
||||
GREEN: "green",
|
||||
RED: "red",
|
||||
BLUE: "blue",
|
||||
} as const;
|
||||
|
||||
export const DropDownMenuItemBadge = ({
|
||||
type = DropDownMenuItemBadgeType.BLUE,
|
||||
children,
|
||||
}: {
|
||||
type?: ValueOf<typeof DropDownMenuItemBadgeType>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { theme } = useExcalidrawAppState();
|
||||
const style = {
|
||||
display: "inline-flex",
|
||||
marginLeft: "auto",
|
||||
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, {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
marginLeft: "auto",
|
||||
padding: "2px 4px",
|
||||
background: "var(--color-promo)",
|
||||
color: "var(--color-surface-lowest)",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="DropDownMenuItemBadge" style={style}>
|
||||
borderRadius: 6,
|
||||
fontSize: 9,
|
||||
fontFamily: "Cascadia, monospace",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { useDevice } from "../App";
|
||||
|
||||
const MenuItemContent = ({
|
||||
textStyle,
|
||||
icon,
|
||||
shortcut,
|
||||
children,
|
||||
}: {
|
||||
icon?: JSX.Element;
|
||||
shortcut?: string;
|
||||
textStyle?: React.CSSProperties;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
return (
|
||||
<>
|
||||
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
|
||||
<div style={textStyle} className="dropdown-menu-item__text">
|
||||
{children}
|
||||
</div>
|
||||
<div className="dropdown-menu-item__icon">{icon}</div>
|
||||
<div className="dropdown-menu-item__text">{children}</div>
|
||||
{shortcut && !device.editor.isMobile && (
|
||||
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
||||
)}
|
||||
|
||||
@@ -9,11 +9,9 @@ export const DropdownMenuContentPropsContext = React.createContext<{
|
||||
export const getDropdownMenuItemClassName = (
|
||||
className = "",
|
||||
selected = false,
|
||||
hovered = false,
|
||||
) => {
|
||||
return `dropdown-menu-item dropdown-menu-item-base ${className}
|
||||
${selected ? "dropdown-menu-item--selected" : ""} ${
|
||||
hovered ? "dropdown-menu-item--hovered" : ""
|
||||
return `dropdown-menu-item dropdown-menu-item-base ${className} ${
|
||||
selected ? "dropdown-menu-item--selected" : ""
|
||||
}`.trim();
|
||||
};
|
||||
|
||||
|
||||
@@ -1438,27 +1438,6 @@ export const fontSizeIcon = createIcon(
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const FontFamilyHeadingIcon = createIcon(
|
||||
<>
|
||||
<g
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M7 12h10" />
|
||||
<path d="M7 5v14" />
|
||||
<path d="M17 5v14" />
|
||||
<path d="M15 19h4" />
|
||||
<path d="M15 5h4" />
|
||||
<path d="M5 19h4" />
|
||||
<path d="M5 5h4" />
|
||||
</g>
|
||||
</>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const FontFamilyNormalIcon = createIcon(
|
||||
<>
|
||||
<g
|
||||
|
||||
@@ -109,7 +109,7 @@ Center.displayName = "Center";
|
||||
|
||||
const Logo = ({ children }: { children?: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="welcome-screen-center__logo excalifont welcome-screen-decor">
|
||||
<div className="welcome-screen-center__logo virgil welcome-screen-decor">
|
||||
{children || <ExcalidrawLogo withText />}
|
||||
</div>
|
||||
);
|
||||
@@ -118,7 +118,7 @@ Logo.displayName = "Logo";
|
||||
|
||||
const Heading = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="welcome-screen-center__heading welcome-screen-decor excalifont">
|
||||
<div className="welcome-screen-center__heading welcome-screen-decor virgil">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ const MenuHint = ({ children }: { children?: React.ReactNode }) => {
|
||||
const { WelcomeScreenMenuHintTunnel } = useTunnels();
|
||||
return (
|
||||
<WelcomeScreenMenuHintTunnel.In>
|
||||
<div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
|
||||
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
|
||||
{WelcomeScreenMenuArrow}
|
||||
<div className="welcome-screen-decor-hint__label">
|
||||
{children || t("welcomeScreen.defaults.menuHint")}
|
||||
@@ -25,7 +25,7 @@ const ToolbarHint = ({ children }: { children?: React.ReactNode }) => {
|
||||
const { WelcomeScreenToolbarHintTunnel } = useTunnels();
|
||||
return (
|
||||
<WelcomeScreenToolbarHintTunnel.In>
|
||||
<div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
|
||||
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
|
||||
<div className="welcome-screen-decor-hint__label">
|
||||
{children || t("welcomeScreen.defaults.toolbarHint")}
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@ const HelpHint = ({ children }: { children?: React.ReactNode }) => {
|
||||
const { WelcomeScreenHelpHintTunnel } = useTunnels();
|
||||
return (
|
||||
<WelcomeScreenHelpHintTunnel.In>
|
||||
<div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
|
||||
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
|
||||
<div>{children || t("welcomeScreen.defaults.helpHint")}</div>
|
||||
{WelcomeScreenHelpArrow}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.excalidraw {
|
||||
.excalifont {
|
||||
font-family: "Excalifont";
|
||||
.virgil {
|
||||
font-family: "Virgil";
|
||||
}
|
||||
|
||||
// WelcomeSreen common
|
||||
|
||||
@@ -114,24 +114,12 @@ export const CLASSES = {
|
||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||
};
|
||||
|
||||
/**
|
||||
* // TODO: shouldn't be really `const`, likely neither have integers as values, due to value for the custom fonts, which should likely be some hash.
|
||||
*
|
||||
* Let's think this through and consider:
|
||||
* - https://developer.mozilla.org/en-US/docs/Web/CSS/generic-family
|
||||
* - https://drafts.csswg.org/css-fonts-4/#font-family-prop
|
||||
* - https://learn.microsoft.com/en-us/typography/opentype/spec/ibmfc
|
||||
*/
|
||||
// 1-based in case we ever do `if(element.fontFamily)`
|
||||
export const FONT_FAMILY = {
|
||||
Virgil: 1,
|
||||
Helvetica: 2,
|
||||
Cascadia: 3,
|
||||
// leave 4 unused as it was historically used for Assistant (which we don't use anymore) or custom font (Obsidian)
|
||||
Excalifont: 5,
|
||||
Nunito: 6,
|
||||
"Lilita One": 7,
|
||||
"Comic Shanns": 8,
|
||||
"Liberation Sans": 9,
|
||||
Assistant: 4,
|
||||
};
|
||||
|
||||
export const THEME = {
|
||||
@@ -159,7 +147,7 @@ export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||
|
||||
export const MIN_FONT_SIZE = 1;
|
||||
export const DEFAULT_FONT_SIZE = 20;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
|
||||
export const DEFAULT_TEXT_ALIGN = "left";
|
||||
export const DEFAULT_VERTICAL_ALIGN = "top";
|
||||
export const DEFAULT_VERSION = "{version}";
|
||||
@@ -286,7 +274,7 @@ export const DEFAULT_EXPORT_PADDING = 10; // px
|
||||
|
||||
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
||||
|
||||
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
|
||||
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ body.excalidraw-cursor-resize * {
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary-color);
|
||||
font-weight: 400;
|
||||
font-weight: normal;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -227,7 +227,14 @@ body.excalidraw-cursor-resize * {
|
||||
label,
|
||||
button,
|
||||
.zIndexButton {
|
||||
@include outlineButtonIconStyles;
|
||||
@include outlineButtonStyles;
|
||||
|
||||
padding: 0;
|
||||
|
||||
svg {
|
||||
width: var(--default-icon-size);
|
||||
height: var(--default-icon-size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,7 +394,7 @@ body.excalidraw-cursor-resize * {
|
||||
.App-menu__left {
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
width: 200px;
|
||||
width: 202px;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
}
|
||||
@@ -578,7 +585,7 @@ body.excalidraw-cursor-resize * {
|
||||
// use custom, minimalistic scrollbar
|
||||
// (doesn't work in Firefox)
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
@@ -657,10 +664,6 @@ body.excalidraw-cursor-resize * {
|
||||
--button-hover-bg: #363541;
|
||||
--button-bg: var(--color-surface-high);
|
||||
}
|
||||
|
||||
.buttonList {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw__paragraph {
|
||||
@@ -754,7 +757,7 @@ body.excalidraw-cursor-resize * {
|
||||
padding: 1rem 1.6rem;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.6px;
|
||||
font-family: "Assistant";
|
||||
}
|
||||
|
||||
@@ -151,9 +151,6 @@
|
||||
--color-border-outline-variant: #c5c5d0;
|
||||
--color-surface-primary-container: #e0dfff;
|
||||
|
||||
--color-badge: #0b6513;
|
||||
--background-color-badge: #d3ffd2;
|
||||
|
||||
&.theme--dark {
|
||||
&.theme--dark-background-none {
|
||||
background: none;
|
||||
|
||||
@@ -124,16 +124,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@mixin outlineButtonIconStyles {
|
||||
@include outlineButtonStyles;
|
||||
padding: 0;
|
||||
|
||||
svg {
|
||||
width: var(--default-icon-size);
|
||||
height: var(--default-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin avatarStyles {
|
||||
width: var(--avatar-size, 1.5rem);
|
||||
height: var(--avatar-size, 1.5rem);
|
||||
@@ -145,7 +135,7 @@
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
color: var(--color-gray-90);
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -239,7 +239,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"containerId": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -285,7 +285,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"containerId": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -386,7 +386,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"containerId": "id48",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -487,7 +487,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"containerId": "id37",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -662,7 +662,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"containerId": "id41",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -708,7 +708,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"containerId": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -754,7 +754,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"containerId": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1207,7 +1207,7 @@ exports[`Test Transform > should transform text element 1`] = `
|
||||
"containerId": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1248,7 +1248,7 @@ exports[`Test Transform > should transform text element 2`] = `
|
||||
"containerId": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1581,7 +1581,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"containerId": "B",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
@@ -1624,7 +1624,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"containerId": "A",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
@@ -1667,7 +1667,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"containerId": "Alice",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
@@ -1710,7 +1710,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"containerId": "Bob",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
@@ -1753,7 +1753,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"containerId": "Bob_Alice",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1794,7 +1794,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"containerId": "Bob_B",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2043,7 +2043,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"containerId": "id25",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2084,7 +2084,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"containerId": "id26",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2125,7 +2125,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"containerId": "id27",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2167,7 +2167,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"containerId": "id28",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2431,7 +2431,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"containerId": "id13",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2472,7 +2472,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"containerId": "id14",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2514,7 +2514,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"containerId": "id15",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2558,7 +2558,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"containerId": "id16",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2600,7 +2600,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"containerId": "id17",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2643,7 +2643,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"containerId": "id18",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawElementType,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawTextElement,
|
||||
FontFamilyValues,
|
||||
@@ -22,12 +21,7 @@ import {
|
||||
isInvisiblySmallElement,
|
||||
refreshTextDimensions,
|
||||
} from "../element";
|
||||
import {
|
||||
isArrowElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "../element/typeChecks";
|
||||
import { isTextElement, isUsingAdaptiveRadius } from "../element/typeChecks";
|
||||
import { randomId } from "../random";
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
@@ -44,11 +38,14 @@ import { bumpVersion } from "../element/mutateElement";
|
||||
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||
import { arrayToMap } from "../utils";
|
||||
import type { MarkOptional, Mutable } from "../utility-types";
|
||||
import { detectLineHeight, getContainerElement } from "../element/textElement";
|
||||
import {
|
||||
detectLineHeight,
|
||||
getContainerElement,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textElement";
|
||||
import { normalizeLink } from "./url";
|
||||
import { syncInvalidIndices } from "../fractionalIndex";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
import { getLineHeight } from "../fonts";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
@@ -203,7 +200,7 @@ const restoreElement = (
|
||||
detectLineHeight(element)
|
||||
: // no element height likely means programmatic use, so default
|
||||
// to a fixed line height
|
||||
getLineHeight(element.fontFamily));
|
||||
getDefaultLineHeight(element.fontFamily));
|
||||
element = restoreElementWithProperties(element, {
|
||||
fontSize,
|
||||
fontFamily,
|
||||
@@ -463,23 +460,6 @@ export const restoreElements = (
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (isLinearElement(element)) {
|
||||
if (
|
||||
element.startBinding &&
|
||||
(!restoredElementsMap.has(element.startBinding.elementId) ||
|
||||
!isArrowElement(element))
|
||||
) {
|
||||
(element as Mutable<ExcalidrawLinearElement>).startBinding = null;
|
||||
}
|
||||
if (
|
||||
element.endBinding &&
|
||||
(!restoredElementsMap.has(element.endBinding.elementId) ||
|
||||
!isArrowElement(element))
|
||||
) {
|
||||
(element as Mutable<ExcalidrawLinearElement>).endBinding = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return restoredElements;
|
||||
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
newMagicFrameElement,
|
||||
newTextElement,
|
||||
} from "../element/newElement";
|
||||
import { measureText, normalizeText } from "../element/textElement";
|
||||
import {
|
||||
getDefaultLineHeight,
|
||||
measureText,
|
||||
normalizeText,
|
||||
} from "../element/textElement";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
@@ -50,7 +54,6 @@ import {
|
||||
import { getSizeFromPoints } from "../points";
|
||||
import { randomId } from "../random";
|
||||
import { syncInvalidIndices } from "../fractionalIndex";
|
||||
import { getLineHeight } from "../fonts";
|
||||
|
||||
export type ValidLinearElement = {
|
||||
type: "arrow" | "line";
|
||||
@@ -565,7 +568,8 @@ export const convertToExcalidrawElements = (
|
||||
case "text": {
|
||||
const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
|
||||
const lineHeight = element?.lineHeight || getLineHeight(fontFamily);
|
||||
const lineHeight =
|
||||
element?.lineHeight || getDefaultLineHeight(fontFamily);
|
||||
const text = element.text ?? "";
|
||||
const normalizedText = normalizeText(text);
|
||||
const metrics = measureText(
|
||||
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import type { AppState, Point } from "../types";
|
||||
import type { AppClassProperties, AppState, Point } from "../types";
|
||||
import { isPointOnShape } from "../../utils/collision";
|
||||
import { getElementAtPosition } from "../scene";
|
||||
import {
|
||||
@@ -43,7 +43,6 @@ import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { arrayToMap, tupleToCoors } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||
import { getElementShape } from "../shapes";
|
||||
|
||||
export type SuggestedBinding =
|
||||
| NonDeleted<ExcalidrawBindableElement>
|
||||
@@ -180,19 +179,19 @@ const bindOrUnbindLinearElementEdge = (
|
||||
const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
edge: "start" | "end",
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
app: AppClassProperties,
|
||||
): NonDeleted<ExcalidrawElement> | null => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
|
||||
const elementId =
|
||||
edge === "start"
|
||||
? linearElement.startBinding?.elementId
|
||||
: linearElement.endBinding?.elementId;
|
||||
if (elementId) {
|
||||
const element = elementsMap.get(elementId);
|
||||
if (
|
||||
isBindableElement(element) &&
|
||||
bindingBorderTest(element, coors, elementsMap)
|
||||
) {
|
||||
const element = elementsMap.get(
|
||||
elementId,
|
||||
) as NonDeleted<ExcalidrawBindableElement>;
|
||||
if (bindingBorderTest(element, coors, app)) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
@@ -202,13 +201,13 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
||||
|
||||
const getOriginalBindingsIfStillCloseToArrowEnds = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
app: AppClassProperties,
|
||||
): (NonDeleted<ExcalidrawElement> | null)[] =>
|
||||
["start", "end"].map((edge) =>
|
||||
getOriginalBindingIfStillCloseOfLinearElementEdge(
|
||||
linearElement,
|
||||
edge as "start" | "end",
|
||||
elementsMap,
|
||||
app,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -216,7 +215,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
||||
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
isBindingEnabled: boolean,
|
||||
draggingPoints: readonly number[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
app: AppClassProperties,
|
||||
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
|
||||
const startIdx = 0;
|
||||
const endIdx = selectedElement.points.length - 1;
|
||||
@@ -224,57 +223,37 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
||||
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
|
||||
const start = startDragged
|
||||
? isBindingEnabled
|
||||
? getElligibleElementForBindingElement(
|
||||
selectedElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
)
|
||||
? getElligibleElementForBindingElement(selectedElement, "start", app)
|
||||
: null // If binding is disabled and start is dragged, break all binds
|
||||
: // We have to update the focus and gap of the binding, so let's rebind
|
||||
getElligibleElementForBindingElement(
|
||||
selectedElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
);
|
||||
getElligibleElementForBindingElement(selectedElement, "start", app);
|
||||
const end = endDragged
|
||||
? isBindingEnabled
|
||||
? getElligibleElementForBindingElement(
|
||||
selectedElement,
|
||||
"end",
|
||||
elementsMap,
|
||||
)
|
||||
? getElligibleElementForBindingElement(selectedElement, "end", app)
|
||||
: null // If binding is disabled and end is dragged, break all binds
|
||||
: // We have to update the focus and gap of the binding, so let's rebind
|
||||
getElligibleElementForBindingElement(selectedElement, "end", elementsMap);
|
||||
getElligibleElementForBindingElement(selectedElement, "end", app);
|
||||
|
||||
return [start, end];
|
||||
};
|
||||
|
||||
const getBindingStrategyForDraggingArrowOrJoints = (
|
||||
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
app: AppClassProperties,
|
||||
isBindingEnabled: boolean,
|
||||
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
|
||||
const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds(
|
||||
selectedElement,
|
||||
elementsMap,
|
||||
app,
|
||||
);
|
||||
const start = startIsClose
|
||||
? isBindingEnabled
|
||||
? getElligibleElementForBindingElement(
|
||||
selectedElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
)
|
||||
? getElligibleElementForBindingElement(selectedElement, "start", app)
|
||||
: null
|
||||
: null;
|
||||
const end = endIsClose
|
||||
? isBindingEnabled
|
||||
? getElligibleElementForBindingElement(
|
||||
selectedElement,
|
||||
"end",
|
||||
elementsMap,
|
||||
)
|
||||
? getElligibleElementForBindingElement(selectedElement, "end", app)
|
||||
: null
|
||||
: null;
|
||||
|
||||
@@ -283,7 +262,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
||||
|
||||
export const bindOrUnbindLinearElements = (
|
||||
selectedElements: NonDeleted<ExcalidrawLinearElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
app: AppClassProperties,
|
||||
isBindingEnabled: boolean,
|
||||
draggingPoints: readonly number[] | null,
|
||||
): void => {
|
||||
@@ -294,22 +273,27 @@ export const bindOrUnbindLinearElements = (
|
||||
selectedElement,
|
||||
isBindingEnabled,
|
||||
draggingPoints ?? [],
|
||||
elementsMap,
|
||||
app,
|
||||
)
|
||||
: // The arrow itself (the shaft) or the inner joins are dragged
|
||||
getBindingStrategyForDraggingArrowOrJoints(
|
||||
selectedElement,
|
||||
elementsMap,
|
||||
app,
|
||||
isBindingEnabled,
|
||||
);
|
||||
|
||||
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap);
|
||||
bindOrUnbindLinearElement(
|
||||
selectedElement,
|
||||
start,
|
||||
end,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const getSuggestedBindingsForArrows = (
|
||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
app: AppClassProperties,
|
||||
): SuggestedBinding[] => {
|
||||
// HOT PATH: Bail out if selected elements list is too large
|
||||
if (selectedElements.length > 50) {
|
||||
@@ -320,7 +304,7 @@ export const getSuggestedBindingsForArrows = (
|
||||
selectedElements
|
||||
.filter(isLinearElement)
|
||||
.flatMap((element) =>
|
||||
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap),
|
||||
getOriginalBindingsIfStillCloseToArrowEnds(element, app),
|
||||
)
|
||||
.filter(
|
||||
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
||||
@@ -342,20 +326,17 @@ export const maybeBindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
appState: AppState,
|
||||
pointerCoords: { x: number; y: number },
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
app: AppClassProperties,
|
||||
): void => {
|
||||
if (appState.startBoundElement != null) {
|
||||
bindLinearElement(
|
||||
linearElement,
|
||||
appState.startBoundElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
}
|
||||
const hoveredElement = getHoveredElementForBinding(
|
||||
pointerCoords,
|
||||
elementsMap,
|
||||
);
|
||||
const hoveredElement = getHoveredElementForBinding(pointerCoords, app);
|
||||
if (
|
||||
hoveredElement != null &&
|
||||
!isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
|
||||
@@ -364,7 +345,12 @@ export const maybeBindLinearElement = (
|
||||
"end",
|
||||
)
|
||||
) {
|
||||
bindLinearElement(linearElement, hoveredElement, "end", elementsMap);
|
||||
bindLinearElement(
|
||||
linearElement,
|
||||
hoveredElement,
|
||||
"end",
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -374,9 +360,6 @@ export const bindLinearElement = (
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
): void => {
|
||||
if (!isArrowElement(linearElement)) {
|
||||
return;
|
||||
}
|
||||
mutateElement(linearElement, {
|
||||
[startOrEnd === "start" ? "startBinding" : "endBinding"]: {
|
||||
elementId: hoveredElement.id,
|
||||
@@ -448,13 +431,13 @@ export const getHoveredElementForBinding = (
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
app: AppClassProperties,
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
const hoveredElement = getElementAtPosition(
|
||||
[...elementsMap].map(([_, value]) => value),
|
||||
app.scene.getNonDeletedElements(),
|
||||
(element) =>
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, pointerCoords, elementsMap),
|
||||
bindingBorderTest(element, pointerCoords, app),
|
||||
);
|
||||
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
|
||||
};
|
||||
@@ -678,11 +661,15 @@ const maybeCalculateNewGapWhenScaling = (
|
||||
const getElligibleElementForBindingElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
app: AppClassProperties,
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
return getHoveredElementForBinding(
|
||||
getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
|
||||
elementsMap,
|
||||
getLinearElementEdgeCoors(
|
||||
linearElement,
|
||||
startOrEnd,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
app,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -719,9 +706,6 @@ export const fixBindingsAfterDuplication = (
|
||||
const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||
const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||
const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
|
||||
const duplicateIdToOldId = new Map(
|
||||
[...oldIdToDuplicatedId].map(([key, value]) => [value, key]),
|
||||
);
|
||||
oldElements.forEach((oldElement) => {
|
||||
const { boundElements } = oldElement;
|
||||
if (boundElements != null && boundElements.length > 0) {
|
||||
@@ -771,11 +755,7 @@ export const fixBindingsAfterDuplication = (
|
||||
sceneElements
|
||||
.filter(({ id }) => allBindableElementIds.has(id))
|
||||
.forEach((bindableElement) => {
|
||||
const oldElementId = duplicateIdToOldId.get(bindableElement.id);
|
||||
const { boundElements } = sceneElements.find(
|
||||
({ id }) => id === oldElementId,
|
||||
)!;
|
||||
|
||||
const { boundElements } = bindableElement;
|
||||
if (boundElements != null && boundElements.length > 0) {
|
||||
mutateElement(bindableElement, {
|
||||
boundElements: boundElements.map((boundElement) =>
|
||||
@@ -846,10 +826,13 @@ const newBoundElements = (
|
||||
const bindingBorderTest = (
|
||||
element: NonDeleted<ExcalidrawBindableElement>,
|
||||
{ x, y }: { x: number; y: number },
|
||||
elementsMap: ElementsMap,
|
||||
app: AppClassProperties,
|
||||
): boolean => {
|
||||
if (!element || !element.width || !element.height) {
|
||||
return false;
|
||||
}
|
||||
const threshold = maxBindingGap(element, element.width, element.height);
|
||||
const shape = getElementShape(element, elementsMap);
|
||||
const shape = app.getElementShape(element);
|
||||
return isPointOnShape([x, y], shape, threshold);
|
||||
};
|
||||
|
||||
|
||||
@@ -381,7 +381,7 @@ export class LinearElementEditor {
|
||||
elementsMap,
|
||||
),
|
||||
),
|
||||
elementsMap,
|
||||
app,
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -715,10 +715,7 @@ export class LinearElementEditor {
|
||||
},
|
||||
selectedPointsIndices: [element.points.length - 1],
|
||||
lastUncommittedPoint: null,
|
||||
endBindingElement: getHoveredElementForBinding(
|
||||
scenePointer,
|
||||
elementsMap,
|
||||
),
|
||||
endBindingElement: getHoveredElementForBinding(scenePointer, app),
|
||||
};
|
||||
|
||||
ret.didAddPoint = true;
|
||||
@@ -1168,7 +1165,7 @@ export class LinearElementEditor {
|
||||
const nextPoints = points.map((point, idx) => {
|
||||
const selectedPointData = targetPoints.find((p) => p.index === idx);
|
||||
if (selectedPointData) {
|
||||
if (selectedPointData.index === 0) {
|
||||
if (selectedOriginPoint) {
|
||||
return point;
|
||||
}
|
||||
|
||||
@@ -1177,10 +1174,7 @@ export class LinearElementEditor {
|
||||
const deltaY =
|
||||
selectedPointData.point[1] - points[selectedPointData.index][1];
|
||||
|
||||
return [
|
||||
point[0] + deltaX - offsetX,
|
||||
point[1] + deltaY - offsetY,
|
||||
] as const;
|
||||
return [point[0] + deltaX, point[1] + deltaY] as const;
|
||||
}
|
||||
return offsetX || offsetY
|
||||
? ([point[0] - offsetX, point[1] - offsetY] as const)
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
normalizeText,
|
||||
wrapText,
|
||||
getBoundTextMaxWidth,
|
||||
getDefaultLineHeight,
|
||||
} from "./textElement";
|
||||
import {
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
@@ -46,7 +47,6 @@ import {
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import type { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
import { getLineHeight } from "../fonts";
|
||||
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
@@ -228,7 +228,7 @@ export const newTextElement = (
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
|
||||
const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
|
||||
const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily);
|
||||
const text = normalizeText(opts.text);
|
||||
const metrics = measureText(
|
||||
text,
|
||||
@@ -514,7 +514,7 @@ export const regenerateId = (
|
||||
if (
|
||||
window.h?.app
|
||||
?.getSceneElementsIncludingDeleted()
|
||||
.find((el: ExcalidrawElement) => el.id === nextId)
|
||||
.find((el) => el.id === nextId)
|
||||
) {
|
||||
nextId += "_copy";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
|
||||
import { getLineHeight } from "../fonts";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import {
|
||||
computeContainerDimensionForBoundText,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
wrapText,
|
||||
detectLineHeight,
|
||||
getLineHeightInPx,
|
||||
getDefaultLineHeight,
|
||||
parseTokens,
|
||||
} from "./textElement";
|
||||
import type { ExcalidrawTextElementWithContainer, FontString } from "./types";
|
||||
@@ -418,15 +418,15 @@ describe("Test getLineHeightInPx", () => {
|
||||
describe("Test getDefaultLineHeight", () => {
|
||||
it("should return line height using default font family when not passed", () => {
|
||||
//@ts-ignore
|
||||
expect(getLineHeight()).toBe(1.25);
|
||||
expect(getDefaultLineHeight()).toBe(1.25);
|
||||
});
|
||||
|
||||
it("should return line height using default font family for unknown font", () => {
|
||||
const UNKNOWN_FONT = 5;
|
||||
expect(getLineHeight(UNKNOWN_FONT)).toBe(1.25);
|
||||
expect(getDefaultLineHeight(UNKNOWN_FONT)).toBe(1.25);
|
||||
});
|
||||
|
||||
it("should return correct line height", () => {
|
||||
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||
expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FontFamilyValues,
|
||||
FontString,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
FONT_FAMILY,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
@@ -28,7 +30,7 @@ import {
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "./containerCache";
|
||||
import type { ExtractSetType } from "../utility-types";
|
||||
import type { ExtractSetType, MakeBrand } from "../utility-types";
|
||||
|
||||
export const normalizeText = (text: string) => {
|
||||
return (
|
||||
@@ -319,6 +321,24 @@ export const getLineHeightInPx = (
|
||||
return fontSize * lineHeight;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates vertical offset for a text with alphabetic baseline.
|
||||
*/
|
||||
export const getVerticalOffset = (
|
||||
fontFamily: ExcalidrawTextElement["fontFamily"],
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeightPx: number,
|
||||
) => {
|
||||
const { unitsPerEm, ascender, descender } =
|
||||
FONT_METRICS[fontFamily] || FONT_METRICS[FONT_FAMILY.Helvetica];
|
||||
|
||||
const fontSizeEm = fontSize / unitsPerEm;
|
||||
const lineGap = lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender;
|
||||
|
||||
const verticalOffset = fontSizeEm * ascender + lineGap;
|
||||
return verticalOffset;
|
||||
};
|
||||
|
||||
// FIXME rename to getApproxMinContainerHeight
|
||||
export const getApproxMinLineHeight = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
@@ -329,72 +349,29 @@ export const getApproxMinLineHeight = (
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
|
||||
/**
|
||||
* @param forceAdvanceWidth use to force retrieve the "advance width" ~ `metrics.width`, instead of the actual boundind box width.
|
||||
*
|
||||
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
|
||||
*
|
||||
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
|
||||
* - text wrapping
|
||||
* - wysiwyg editor (+padding)
|
||||
*
|
||||
* Everything else should be based on the actual bounding box width.
|
||||
*
|
||||
* `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies.
|
||||
*/
|
||||
const getLineWidth = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
forceAdvanceWidth?: true,
|
||||
) => {
|
||||
const getLineWidth = (text: string, font: FontString) => {
|
||||
if (!canvas) {
|
||||
canvas = document.createElement("canvas");
|
||||
}
|
||||
const canvas2dContext = canvas.getContext("2d")!;
|
||||
canvas2dContext.font = font;
|
||||
const metrics = canvas2dContext.measureText(text);
|
||||
|
||||
const advanceWidth = metrics.width;
|
||||
|
||||
// retrieve the actual bounding box width if these metrics are available (as of now > 95% coverage)
|
||||
if (
|
||||
!forceAdvanceWidth &&
|
||||
window.TextMetrics &&
|
||||
"actualBoundingBoxLeft" in window.TextMetrics.prototype &&
|
||||
"actualBoundingBoxRight" in window.TextMetrics.prototype
|
||||
) {
|
||||
// could be negative, therefore getting the absolute value
|
||||
const actualWidth =
|
||||
Math.abs(metrics.actualBoundingBoxLeft) +
|
||||
Math.abs(metrics.actualBoundingBoxRight);
|
||||
|
||||
// fallback to advance width if the actual width is zero, i.e. on text editing start
|
||||
// or when actual width does not respect whitespace chars, i.e. spaces
|
||||
// otherwise actual width should always be bigger
|
||||
return Math.max(actualWidth, advanceWidth);
|
||||
}
|
||||
const width = canvas2dContext.measureText(text).width;
|
||||
|
||||
// since in test env the canvas measureText algo
|
||||
// doesn't measure text and instead just returns number of
|
||||
// characters hence we assume that each letteris 10px
|
||||
if (isTestEnv()) {
|
||||
return advanceWidth * 10;
|
||||
return width * 10;
|
||||
}
|
||||
|
||||
return advanceWidth;
|
||||
return width;
|
||||
};
|
||||
|
||||
export const getTextWidth = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
forceAdvanceWidth?: true,
|
||||
) => {
|
||||
export const getTextWidth = (text: string, font: FontString) => {
|
||||
const lines = splitIntoLines(text);
|
||||
let width = 0;
|
||||
lines.forEach((line) => {
|
||||
width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth));
|
||||
width = Math.max(width, getLineWidth(line, font));
|
||||
});
|
||||
|
||||
return width;
|
||||
};
|
||||
|
||||
@@ -425,11 +402,7 @@ export const parseTokens = (text: string) => {
|
||||
return words.join(" ").split(" ");
|
||||
};
|
||||
|
||||
export const wrapText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): string => {
|
||||
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
// if maxWidth is not finite or NaN which can happen in case of bugs in
|
||||
// computation, we need to make sure we don't continue as we'll end up
|
||||
// in an infinite loop
|
||||
@@ -439,7 +412,7 @@ export const wrapText = (
|
||||
|
||||
const lines: Array<string> = [];
|
||||
const originalLines = text.split("\n");
|
||||
const spaceAdvanceWidth = getLineWidth(" ", font, true);
|
||||
const spaceWidth = getLineWidth(" ", font);
|
||||
|
||||
let currentLine = "";
|
||||
let currentLineWidthTillNow = 0;
|
||||
@@ -454,14 +427,13 @@ export const wrapText = (
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
};
|
||||
|
||||
for (const originalLine of originalLines) {
|
||||
const currentLineWidth = getLineWidth(originalLine, font, true);
|
||||
originalLines.forEach((originalLine) => {
|
||||
const currentLineWidth = getTextWidth(originalLine, font);
|
||||
|
||||
// Push the line if its <= maxWidth
|
||||
if (currentLineWidth <= maxWidth) {
|
||||
lines.push(originalLine);
|
||||
continue;
|
||||
return; // continue
|
||||
}
|
||||
|
||||
const words = parseTokens(originalLine);
|
||||
@@ -470,7 +442,7 @@ export const wrapText = (
|
||||
let index = 0;
|
||||
|
||||
while (index < words.length) {
|
||||
const currentWordWidth = getLineWidth(words[index], font, true);
|
||||
const currentWordWidth = getLineWidth(words[index], font);
|
||||
|
||||
// This will only happen when single word takes entire width
|
||||
if (currentWordWidth === maxWidth) {
|
||||
@@ -482,6 +454,7 @@ export const wrapText = (
|
||||
else if (currentWordWidth > maxWidth) {
|
||||
// push current line since the current word exceeds the max width
|
||||
// so will be appended in next line
|
||||
|
||||
push(currentLine);
|
||||
|
||||
resetParams();
|
||||
@@ -490,26 +463,20 @@ export const wrapText = (
|
||||
const currentChar = String.fromCodePoint(
|
||||
words[index].codePointAt(0)!,
|
||||
);
|
||||
|
||||
const line = currentLine + currentChar;
|
||||
// use advance width instead of the actual width as it's closest to the browser wapping algo
|
||||
// use width of the whole line instead of calculating individual chars to accomodate for kerning
|
||||
const lineAdvanceWidth = getLineWidth(line, font, true);
|
||||
const charAdvanceWidth = charWidth.calculate(currentChar, font);
|
||||
|
||||
currentLineWidthTillNow = lineAdvanceWidth;
|
||||
const width = charWidth.calculate(currentChar, font);
|
||||
currentLineWidthTillNow += width;
|
||||
words[index] = words[index].slice(currentChar.length);
|
||||
|
||||
if (currentLineWidthTillNow >= maxWidth) {
|
||||
push(currentLine);
|
||||
currentLine = currentChar;
|
||||
currentLineWidthTillNow = charAdvanceWidth;
|
||||
currentLineWidthTillNow = width;
|
||||
} else {
|
||||
currentLine = line;
|
||||
currentLine += currentChar;
|
||||
}
|
||||
}
|
||||
// push current line if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
push(currentLine);
|
||||
resetParams();
|
||||
// space needs to be appended before next word
|
||||
@@ -518,18 +485,14 @@ export const wrapText = (
|
||||
// with css word-wrap
|
||||
} else if (!currentLine.endsWith("-")) {
|
||||
currentLine += " ";
|
||||
currentLineWidthTillNow += spaceAdvanceWidth;
|
||||
currentLineWidthTillNow += spaceWidth;
|
||||
}
|
||||
index++;
|
||||
} else {
|
||||
// Start appending words in a line till max width reached
|
||||
while (currentLineWidthTillNow < maxWidth && index < words.length) {
|
||||
const word = words[index];
|
||||
currentLineWidthTillNow = getLineWidth(
|
||||
currentLine + word,
|
||||
font,
|
||||
true,
|
||||
);
|
||||
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
|
||||
|
||||
if (currentLineWidthTillNow > maxWidth) {
|
||||
push(currentLine);
|
||||
@@ -549,7 +512,7 @@ export const wrapText = (
|
||||
}
|
||||
|
||||
// Push the word if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
if (shouldAppendSpace) {
|
||||
lines.push(currentLine.slice(0, -1));
|
||||
} else {
|
||||
@@ -561,14 +524,12 @@ export const wrapText = (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine.slice(-1) === " ") {
|
||||
// only remove last trailing space which we have added when joining words
|
||||
currentLine = currentLine.slice(0, -1);
|
||||
push(currentLine);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
@@ -581,7 +542,7 @@ export const charWidth = (() => {
|
||||
cachedCharWidth[font] = [];
|
||||
}
|
||||
if (!cachedCharWidth[font][ascii]) {
|
||||
const width = getLineWidth(char, font, true);
|
||||
const width = getLineWidth(char, font);
|
||||
cachedCharWidth[font][ascii] = width;
|
||||
}
|
||||
|
||||
@@ -633,6 +594,30 @@ export const getMaxCharWidth = (font: FontString) => {
|
||||
return Math.max(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
|
||||
// Generally lower case is used so converting to lower case
|
||||
const dummyText = DUMMY_TEXT.toLocaleLowerCase();
|
||||
const batchLength = 6;
|
||||
let index = 0;
|
||||
let widthTillNow = 0;
|
||||
let str = "";
|
||||
while (widthTillNow <= width) {
|
||||
const batch = dummyText.substr(index, index + batchLength);
|
||||
str += batch;
|
||||
widthTillNow += getLineWidth(str, font);
|
||||
if (index === dummyText.length - 1) {
|
||||
index = 0;
|
||||
}
|
||||
index = index + batchLength;
|
||||
}
|
||||
|
||||
while (widthTillNow > width) {
|
||||
str = str.substr(0, str.length - 1);
|
||||
widthTillNow = getLineWidth(str, font);
|
||||
}
|
||||
return str.length;
|
||||
};
|
||||
|
||||
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
|
||||
return container?.boundElements?.length
|
||||
? container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id ||
|
||||
@@ -881,6 +866,79 @@ export const isMeasureTextSupported = () => {
|
||||
return width > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unitless line height
|
||||
*
|
||||
* In previous versions we used `normal` line height, which browsers interpret
|
||||
* differently, and based on font-family and font-size.
|
||||
*
|
||||
* To make line heights consistent across browsers we hardcode the values for
|
||||
* each of our fonts based on most common average line-heights.
|
||||
* See https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971
|
||||
* where the values come from.
|
||||
*/
|
||||
const DEFAULT_LINE_HEIGHT = {
|
||||
// ~1.25 is the average for Virgil in WebKit and Blink.
|
||||
// Gecko (FF) uses ~1.28.
|
||||
[FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"],
|
||||
// ~1.15 is the average for Helvetica in WebKit and Blink.
|
||||
[FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"],
|
||||
// ~1.2 is the average for Cascadia in WebKit and Blink, and kinda Gecko too
|
||||
[FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"],
|
||||
};
|
||||
|
||||
/** OS/2 sTypoAscender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypoascender */
|
||||
type sTypoAscender = number & MakeBrand<"sTypoAscender">;
|
||||
|
||||
/** OS/2 sTypoDescender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypodescender */
|
||||
type sTypoDescender = number & MakeBrand<"sTypoDescender">;
|
||||
|
||||
/** head.unitsPerEm, usually either 1000 or 2048 */
|
||||
type unitsPerEm = number & MakeBrand<"unitsPerEm">;
|
||||
|
||||
/**
|
||||
* Hardcoded metrics for default fonts, read by https://opentype.js.org/font-inspector.html.
|
||||
* For custom fonts, read these metrics from OS/2 table and extend this object.
|
||||
*
|
||||
* WARN: opentype does NOT open WOFF2 correctly, make sure to convert WOFF2 to TTF first.
|
||||
*/
|
||||
export const FONT_METRICS: Record<
|
||||
number,
|
||||
{
|
||||
unitsPerEm: number;
|
||||
ascender: sTypoAscender;
|
||||
descender: sTypoDescender;
|
||||
}
|
||||
> = {
|
||||
[FONT_FAMILY.Virgil]: {
|
||||
unitsPerEm: 1000 as unitsPerEm,
|
||||
ascender: 886 as sTypoAscender,
|
||||
descender: -374 as sTypoDescender,
|
||||
},
|
||||
[FONT_FAMILY.Helvetica]: {
|
||||
unitsPerEm: 2048 as unitsPerEm,
|
||||
ascender: 1577 as sTypoAscender,
|
||||
descender: -471 as sTypoDescender,
|
||||
},
|
||||
[FONT_FAMILY.Cascadia]: {
|
||||
unitsPerEm: 2048 as unitsPerEm,
|
||||
ascender: 1977 as sTypoAscender,
|
||||
descender: -480 as sTypoDescender,
|
||||
},
|
||||
[FONT_FAMILY.Assistant]: {
|
||||
unitsPerEm: 1000 as unitsPerEm,
|
||||
ascender: 1021 as sTypoAscender,
|
||||
descender: -287 as sTypoDescender,
|
||||
},
|
||||
};
|
||||
|
||||
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
|
||||
if (fontFamily in DEFAULT_LINE_HEIGHT) {
|
||||
return DEFAULT_LINE_HEIGHT[fontFamily];
|
||||
}
|
||||
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
|
||||
};
|
||||
|
||||
export const getMinTextElementWidth = (
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
|
||||
@@ -916,13 +916,13 @@ describe("textWysiwyg", () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont);
|
||||
expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil);
|
||||
|
||||
fireEvent.click(screen.getByTitle(/code/i));
|
||||
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||
).toEqual(FONT_FAMILY["Comic Shanns"]);
|
||||
).toEqual(FONT_FAMILY.Cascadia);
|
||||
|
||||
//undo
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
@@ -930,7 +930,7 @@ describe("textWysiwyg", () => {
|
||||
});
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||
).toEqual(FONT_FAMILY.Excalifont);
|
||||
).toEqual(FONT_FAMILY.Virgil);
|
||||
|
||||
//redo
|
||||
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
||||
@@ -938,7 +938,7 @@ describe("textWysiwyg", () => {
|
||||
});
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||
).toEqual(FONT_FAMILY["Comic Shanns"]);
|
||||
).toEqual(FONT_FAMILY.Cascadia);
|
||||
});
|
||||
|
||||
it("should wrap text and vertcially center align once text submitted", async () => {
|
||||
@@ -1330,14 +1330,14 @@ describe("textWysiwyg", () => {
|
||||
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||
).toEqual(FONT_FAMILY["Comic Shanns"]);
|
||||
).toEqual(FONT_FAMILY.Cascadia);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
fireEvent.click(screen.getByTitle(/Very large/i));
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
|
||||
).toEqual(36);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(100);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(97);
|
||||
});
|
||||
|
||||
it("should update line height when font family updated", async () => {
|
||||
@@ -1357,18 +1357,18 @@ describe("textWysiwyg", () => {
|
||||
fireEvent.click(screen.getByTitle(/code/i));
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||
).toEqual(FONT_FAMILY["Comic Shanns"]);
|
||||
).toEqual(FONT_FAMILY.Cascadia);
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||
).toEqual(1.25);
|
||||
).toEqual(1.2);
|
||||
|
||||
fireEvent.click(screen.getByTitle(/normal/i));
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||
).toEqual(FONT_FAMILY.Nunito);
|
||||
).toEqual(FONT_FAMILY.Helvetica);
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||
).toEqual(1.35);
|
||||
).toEqual(1.15);
|
||||
});
|
||||
|
||||
describe("should align correctly", () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { CLASSES, isSafari } from "../constants";
|
||||
import { CLASSES } from "../constants";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
@@ -132,15 +132,10 @@ export const textWysiwyg = ({
|
||||
updatedTextElement,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
let width = updatedTextElement.width;
|
||||
|
||||
// set to element height by default since that's
|
||||
// what is going to be used for unbounded text
|
||||
let height = updatedTextElement.height;
|
||||
|
||||
let maxWidth = updatedTextElement.width;
|
||||
let maxHeight = updatedTextElement.height;
|
||||
let textElementWidth = updatedTextElement.width;
|
||||
const textElementHeight = updatedTextElement.height;
|
||||
|
||||
if (container && updatedTextElement.containerId) {
|
||||
if (isArrowElement(container)) {
|
||||
@@ -182,9 +177,9 @@ export const textWysiwyg = ({
|
||||
);
|
||||
|
||||
// autogrow container height if text exceeds
|
||||
if (!isArrowElement(container) && height > maxHeight) {
|
||||
if (!isArrowElement(container) && textElementHeight > maxHeight) {
|
||||
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||||
height,
|
||||
textElementHeight,
|
||||
container.type,
|
||||
);
|
||||
|
||||
@@ -195,10 +190,10 @@ export const textWysiwyg = ({
|
||||
// is reached when text is removed
|
||||
!isArrowElement(container) &&
|
||||
container.height > originalContainerData.height &&
|
||||
height < maxHeight
|
||||
textElementHeight < maxHeight
|
||||
) {
|
||||
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||||
height,
|
||||
textElementHeight,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: targetContainerHeight });
|
||||
@@ -231,41 +226,30 @@ export const textWysiwyg = ({
|
||||
|
||||
if (!container) {
|
||||
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
||||
width = Math.min(width, maxWidth);
|
||||
textElementWidth = Math.min(textElementWidth, maxWidth);
|
||||
} else {
|
||||
width += 0.5;
|
||||
textElementWidth += 0.5;
|
||||
}
|
||||
|
||||
// add 5% buffer otherwise it causes wysiwyg to jump
|
||||
height *= 1.05;
|
||||
|
||||
const font = getFontString(updatedTextElement);
|
||||
|
||||
// adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari)
|
||||
const padding = !isSafari
|
||||
? Math.ceil(updatedTextElement.fontSize / 2)
|
||||
: 0;
|
||||
|
||||
// Make sure text editor height doesn't go beyond viewport
|
||||
const editorMaxHeight =
|
||||
(appState.height - viewportY) / appState.zoom.value;
|
||||
Object.assign(editable.style, {
|
||||
font,
|
||||
font: getFontString(updatedTextElement),
|
||||
// must be defined *after* font ¯\_(ツ)_/¯
|
||||
lineHeight: updatedTextElement.lineHeight,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
left: `${viewportX - padding}px`,
|
||||
width: `${textElementWidth}px`,
|
||||
height: `${textElementHeight}px`,
|
||||
left: `${viewportX}px`,
|
||||
top: `${viewportY}px`,
|
||||
transform: getTransform(
|
||||
width,
|
||||
height,
|
||||
textElementWidth,
|
||||
textElementHeight,
|
||||
getTextElementAngle(updatedTextElement, container),
|
||||
appState,
|
||||
maxWidth,
|
||||
editorMaxHeight,
|
||||
),
|
||||
padding: `0 ${padding}px`,
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
color: updatedTextElement.strokeColor,
|
||||
@@ -306,6 +290,7 @@ export const textWysiwyg = ({
|
||||
minHeight: "1em",
|
||||
backfaceVisibility: "hidden",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
border: 0,
|
||||
outline: 0,
|
||||
resize: "none",
|
||||
@@ -351,7 +336,7 @@ export const textWysiwyg = ({
|
||||
font,
|
||||
getBoundTextMaxWidth(container, boundTextElement),
|
||||
);
|
||||
const width = getTextWidth(wrappedText, font, true);
|
||||
const width = getTextWidth(wrappedText, font);
|
||||
editable.style.width = `${width}px`;
|
||||
}
|
||||
};
|
||||
@@ -500,10 +485,8 @@ export const textWysiwyg = ({
|
||||
};
|
||||
|
||||
const stopEvent = (event: Event) => {
|
||||
if (event.target instanceof HTMLCanvasElement) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
// using a state variable instead of passing it to the handleSubmit callback
|
||||
@@ -596,15 +579,46 @@ export const textWysiwyg = ({
|
||||
// in that same tick.
|
||||
const target = event?.target;
|
||||
|
||||
const isPropertiesTrigger =
|
||||
const isTargetPickerTrigger =
|
||||
target instanceof HTMLElement &&
|
||||
target.classList.contains("properties-trigger");
|
||||
target.classList.contains("active-color");
|
||||
|
||||
setTimeout(() => {
|
||||
editable.onblur = handleSubmit;
|
||||
|
||||
if (isTargetPickerTrigger) {
|
||||
const callback = (
|
||||
mutationList: MutationRecord[],
|
||||
observer: MutationObserver,
|
||||
) => {
|
||||
const radixIsRemoved = mutationList.find(
|
||||
(mutation) =>
|
||||
mutation.removedNodes.length > 0 &&
|
||||
(mutation.removedNodes[0] as HTMLElement).dataset
|
||||
?.radixPopperContentWrapper !== undefined,
|
||||
);
|
||||
|
||||
if (radixIsRemoved) {
|
||||
// should work without this in theory
|
||||
// and i think it does actually but radix probably somewhere,
|
||||
// somehow sets the focus elsewhere
|
||||
setTimeout(() => {
|
||||
editable.focus();
|
||||
});
|
||||
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(callback);
|
||||
|
||||
observer.observe(document.querySelector(".excalidraw-container")!, {
|
||||
childList: true,
|
||||
});
|
||||
}
|
||||
|
||||
// case: clicking on the same property → no change → no update → no focus
|
||||
if (!isPropertiesTrigger) {
|
||||
if (!isTargetPickerTrigger) {
|
||||
editable.focus();
|
||||
}
|
||||
});
|
||||
@@ -612,18 +626,16 @@ export const textWysiwyg = ({
|
||||
|
||||
// prevent blur when changing properties from the menu
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
const target = event?.target;
|
||||
|
||||
const isPropertiesTrigger =
|
||||
target instanceof HTMLElement &&
|
||||
target.classList.contains("properties-trigger");
|
||||
const isTargetPickerTrigger =
|
||||
event.target instanceof HTMLElement &&
|
||||
event.target.classList.contains("active-color");
|
||||
|
||||
if (
|
||||
((event.target instanceof HTMLElement ||
|
||||
event.target instanceof SVGElement) &&
|
||||
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
|
||||
!isWritableElement(event.target)) ||
|
||||
isPropertiesTrigger
|
||||
isTargetPickerTrigger
|
||||
) {
|
||||
editable.onblur = null;
|
||||
window.addEventListener("pointerup", bindBlurEvent);
|
||||
@@ -632,7 +644,7 @@ export const textWysiwyg = ({
|
||||
window.addEventListener("blur", handleSubmit);
|
||||
} else if (
|
||||
event.target instanceof HTMLElement &&
|
||||
event.target instanceof HTMLCanvasElement &&
|
||||
!event.target.contains(editable) &&
|
||||
// Vitest simply ignores stopPropagation, capture-mode, or rAF
|
||||
// so without introducing crazier hacks, nothing we can do
|
||||
!isTestEnv()
|
||||
@@ -652,10 +664,10 @@ export const textWysiwyg = ({
|
||||
// handle updates of textElement properties of editing element
|
||||
const unbindUpdate = Scene.getScene(element)!.onUpdate(() => {
|
||||
updateWysiwygStyle();
|
||||
const isPopupOpened = !!document.activeElement?.closest(
|
||||
".properties-content",
|
||||
const isColorPickerActive = !!document.activeElement?.closest(
|
||||
".color-picker-content",
|
||||
);
|
||||
if (!isPopupOpened) {
|
||||
if (!isColorPickerActive) {
|
||||
editable.focus();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -132,7 +132,7 @@ export const isBindingElementType = (
|
||||
};
|
||||
|
||||
export const isBindableElement = (
|
||||
element: ExcalidrawElement | null | undefined,
|
||||
element: ExcalidrawElement | null,
|
||||
includeLocked = true,
|
||||
): element is ExcalidrawBindableElement => {
|
||||
return (
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { stringToBase64, toByteString } from "../data/encode";
|
||||
|
||||
export interface Font {
|
||||
url: URL;
|
||||
fontFace: FontFace;
|
||||
getContent(): Promise<string>;
|
||||
}
|
||||
export const UNPKG_PROD_URL = `https://unpkg.com/${
|
||||
import.meta.env.VITE_PKG_NAME
|
||||
}@${import.meta.env.PKG_VERSION}/dist/prod/`;
|
||||
|
||||
export class ExcalidrawFont implements Font {
|
||||
public readonly url: URL;
|
||||
public readonly fontFace: FontFace;
|
||||
|
||||
constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) {
|
||||
// absolute assets paths, which are found in tests and excalidraw-app build, won't work with base url, so we are stripping initial slash away
|
||||
const assetUrl: string = uri.replace(/^\/+/, "");
|
||||
let baseUrl: string | undefined = undefined;
|
||||
|
||||
// fallback to unpkg to form a valid URL in case of a passed relative assetUrl
|
||||
let baseUrlBuilder = window.EXCALIDRAW_ASSET_PATH || UNPKG_PROD_URL;
|
||||
|
||||
// in case user passed a root-relative url (~absolute path),
|
||||
// like "/" or "/some/path", or relative (starts with "./"),
|
||||
// prepend it with `location.origin`
|
||||
if (/^\.?\//.test(baseUrlBuilder)) {
|
||||
baseUrlBuilder = new URL(
|
||||
baseUrlBuilder.replace(/^\.?\/+/, ""),
|
||||
window?.location?.origin,
|
||||
).toString();
|
||||
}
|
||||
|
||||
// ensure there is a trailing slash, otherwise url won't be correctly concatenated
|
||||
baseUrl = `${baseUrlBuilder.replace(/\/+$/, "")}/`;
|
||||
|
||||
this.url = new URL(assetUrl, baseUrl);
|
||||
this.fontFace = new FontFace(family, `url(${this.url})`, {
|
||||
display: "swap",
|
||||
style: "normal",
|
||||
weight: "400",
|
||||
...descriptors,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches woff2 content based on the registered url (browser).
|
||||
*
|
||||
* Use dataurl outside the browser environment.
|
||||
*/
|
||||
public async getContent(): Promise<string> {
|
||||
if (this.url.protocol === "data:") {
|
||||
// it's dataurl, the font is inlined as base64, no need to fetch
|
||||
return this.url.toString();
|
||||
}
|
||||
|
||||
const response = await fetch(this.url, {
|
||||
headers: {
|
||||
Accept: "font/woff2",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`Couldn't fetch font-family "${this.fontFace.family}" from url "${this.url}"`,
|
||||
response,
|
||||
);
|
||||
}
|
||||
|
||||
const mimeType = await response.headers.get("Content-Type");
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
return `data:${mimeType};base64,${await stringToBase64(
|
||||
await toByteString(buffer),
|
||||
true,
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user