Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93c33fef20 | |||
| df8875a497 | |||
| 6fbc44fd1f | |||
| d25a7d365b | |||
| e52c2cd0b6 | |||
| 96eeec5119 | |||
| f5221d521b | |||
| db2c235cd4 | |||
| 148b895f46 | |||
| d9258a736b | |||
| 2e1f08c796 | |||
| 1d5b41dabb | |||
| 66a2f24296 | |||
| 04668d8263 | |||
| abbeed3d5f | |||
| ba8c09d529 | |||
| 744b3e5d09 | |||
| 6ba9bd60e8 | |||
| a1ffa064df | |||
| 4dc4590f24 | |||
| d2f67e619f | |||
| 22b39277f5 | |||
| 63dee03ef0 | |||
| 08b13f971d | |||
| 69f4cc70cb | |||
| 860308eb27 | |||
| 4eb9463f26 | |||
| 6ed6131169 | |||
| 1ed98f9c93 | |||
| a71bb63d1f | |||
| 661d6a4a75 | |||
| defd34923a | |||
| c540bd68aa | |||
| eddbe55f50 | |||
| 2f9526da24 | |||
| 1b6e3fe05b | |||
| afe52c89a7 | |||
| be4e127f6c | |||
| ff0b4394b1 | |||
| 7d8b7fc14d | |||
| 971b4d4ae6 | |||
| cc4c51996c | |||
| 79257a1923 | |||
| dc66261c19 | |||
| 273ba803d9 | |||
| 301e83805d | |||
| ed5ce8d3de |
+1
-1
@@ -22,7 +22,7 @@ VITE_APP_DEV_ENABLE_SW=
|
|||||||
# whether to disable live reload / HMR. Usuaully what you want to do when
|
# whether to disable live reload / HMR. Usuaully what you want to do when
|
||||||
# debugging Service Workers.
|
# debugging Service Workers.
|
||||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
|
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||||
VITE_APP_DISABLE_TRACKING=true
|
VITE_APP_ENABLE_TRACKING=true
|
||||||
|
|
||||||
FAST_REFRESH=false
|
FAST_REFRESH=false
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -14,4 +14,4 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
|
|||||||
|
|
||||||
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||||
|
|
||||||
VITE_APP_DISABLE_TRACKING=
|
VITE_APP_ENABLE_TRACKING=false
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
name: Tests
|
name: Tests
|
||||||
|
|
||||||
on: pull_request
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- name: Setup Node.js 18.x
|
- name: Setup Node.js 18.x
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.x
|
node-version: 18.x
|
||||||
- name: Install and test
|
- name: Install and test
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Once the callback is triggered, you will need to store the api in state to acces
|
|||||||
```jsx showLineNumbers
|
```jsx showLineNumbers
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [excalidrawAPI, setExcalidrawAPI] = useState(null);
|
const [excalidrawAPI, setExcalidrawAPI] = useState(null);
|
||||||
return <Excalidraw excalidrawAPI={{(api)=> setExcalidrawAPI(api)}} />;
|
return <Excalidraw excalidrawAPI={(api)=> setExcalidrawAPI(api)} />;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ function App() {
|
|||||||
<img src={canvasUrl} alt="" />
|
<img src={canvasUrl} alt="" />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: "400px" }}>
|
<div style={{ height: "400px" }}>
|
||||||
<Excalidraw ref={(api) => setExcalidrawAPI(api)}
|
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+53
-23
@@ -1,5 +1,4 @@
|
|||||||
import polyfill from "../packages/excalidraw/polyfill";
|
import polyfill from "../packages/excalidraw/polyfill";
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { trackEvent } from "../packages/excalidraw/analytics";
|
import { trackEvent } from "../packages/excalidraw/analytics";
|
||||||
import { getDefaultAppState } from "../packages/excalidraw/appState";
|
import { getDefaultAppState } from "../packages/excalidraw/appState";
|
||||||
@@ -22,7 +21,6 @@ import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRef
|
|||||||
import { t } from "../packages/excalidraw/i18n";
|
import { t } from "../packages/excalidraw/i18n";
|
||||||
import {
|
import {
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
defaultLang,
|
|
||||||
LiveCollaborationTrigger,
|
LiveCollaborationTrigger,
|
||||||
TTDDialog,
|
TTDDialog,
|
||||||
TTDDialogTrigger,
|
TTDDialogTrigger,
|
||||||
@@ -93,7 +91,7 @@ import {
|
|||||||
import { AppMainMenu } from "./components/AppMainMenu";
|
import { AppMainMenu } from "./components/AppMainMenu";
|
||||||
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
||||||
import { AppFooter } from "./components/AppFooter";
|
import { AppFooter } from "./components/AppFooter";
|
||||||
import { atom, Provider, useAtom, useAtomValue } from "jotai";
|
import { Provider, useAtom, useAtomValue } from "jotai";
|
||||||
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
|
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
|
||||||
import { appJotaiStore } from "./app-jotai";
|
import { appJotaiStore } from "./app-jotai";
|
||||||
|
|
||||||
@@ -121,11 +119,45 @@ import {
|
|||||||
youtubeIcon,
|
youtubeIcon,
|
||||||
} from "../packages/excalidraw/components/icons";
|
} from "../packages/excalidraw/components/icons";
|
||||||
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
||||||
|
import { getPreferredLanguage } from "./app-language/language-detector";
|
||||||
|
import { useAppLangCode } from "./app-language/language-state";
|
||||||
|
|
||||||
polyfill();
|
polyfill();
|
||||||
|
|
||||||
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface BeforeInstallPromptEventChoiceResult {
|
||||||
|
outcome: "accepted" | "dismissed";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt(): Promise<void>;
|
||||||
|
userChoice: Promise<BeforeInstallPromptEventChoiceResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindowEventMap {
|
||||||
|
beforeinstallprompt: BeforeInstallPromptEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pwaEvent: BeforeInstallPromptEvent | null = null;
|
||||||
|
|
||||||
|
// Adding a listener outside of the component as it may (?) need to be
|
||||||
|
// subscribed early to catch the event.
|
||||||
|
//
|
||||||
|
// Also note that it will fire only if certain heuristics are met (user has
|
||||||
|
// used the app for some time, etc.)
|
||||||
|
window.addEventListener(
|
||||||
|
"beforeinstallprompt",
|
||||||
|
(event: BeforeInstallPromptEvent) => {
|
||||||
|
// prevent Chrome <= 67 from automatically showing the prompt
|
||||||
|
event.preventDefault();
|
||||||
|
// cache for later use
|
||||||
|
pwaEvent = event;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
let isSelfEmbedding = false;
|
let isSelfEmbedding = false;
|
||||||
|
|
||||||
if (window.self !== window.top) {
|
if (window.self !== window.top) {
|
||||||
@@ -140,11 +172,6 @@ if (window.self !== window.top) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const languageDetector = new LanguageDetector();
|
|
||||||
languageDetector.init({
|
|
||||||
languageUtils: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const shareableLinkConfirmDialog = {
|
const shareableLinkConfirmDialog = {
|
||||||
title: t("overwriteConfirm.modal.shareableLink.title"),
|
title: t("overwriteConfirm.modal.shareableLink.title"),
|
||||||
description: (
|
description: (
|
||||||
@@ -290,19 +317,15 @@ const initializeScene = async (opts: {
|
|||||||
return { scene: null, isExternalScene: false };
|
return { scene: null, isExternalScene: false };
|
||||||
};
|
};
|
||||||
|
|
||||||
const detectedLangCode = languageDetector.detect() || defaultLang.code;
|
|
||||||
export const appLangCodeAtom = atom(
|
|
||||||
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
|
|
||||||
);
|
|
||||||
|
|
||||||
const ExcalidrawWrapper = () => {
|
const ExcalidrawWrapper = () => {
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
|
||||||
const isCollabDisabled = isRunningInIframe();
|
const isCollabDisabled = isRunningInIframe();
|
||||||
|
|
||||||
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
||||||
const { editorTheme } = useHandleAppTheme();
|
const { editorTheme } = useHandleAppTheme();
|
||||||
|
|
||||||
|
const [langCode, setLangCode] = useAppLangCode();
|
||||||
|
|
||||||
// initial state
|
// initial state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -458,11 +481,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
||||||
const localDataState = importFromLocalStorage();
|
const localDataState = importFromLocalStorage();
|
||||||
const username = importUsernameFromLocalStorage();
|
const username = importUsernameFromLocalStorage();
|
||||||
let langCode = languageDetector.detect() || defaultLang.code;
|
setLangCode(getPreferredLanguage());
|
||||||
if (Array.isArray(langCode)) {
|
|
||||||
langCode = langCode[0];
|
|
||||||
}
|
|
||||||
setLangCode(langCode);
|
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...localDataState,
|
...localDataState,
|
||||||
storeAction: StoreAction.UPDATE,
|
storeAction: StoreAction.UPDATE,
|
||||||
@@ -563,10 +582,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
};
|
};
|
||||||
}, [excalidrawAPI]);
|
}, [excalidrawAPI]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
languageDetector.cacheUserLanguage(langCode);
|
|
||||||
}, [langCode]);
|
|
||||||
|
|
||||||
const onChange = (
|
const onChange = (
|
||||||
elements: readonly OrderedExcalidrawElement[],
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
@@ -1100,6 +1115,21 @@ const ExcalidrawWrapper = () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("labels.installPWA"),
|
||||||
|
category: DEFAULT_CATEGORIES.app,
|
||||||
|
predicate: () => !!pwaEvent,
|
||||||
|
perform: () => {
|
||||||
|
if (pwaEvent) {
|
||||||
|
pwaEvent.prompt();
|
||||||
|
pwaEvent.userChoice.then(() => {
|
||||||
|
// event cannot be reused, but we'll hopefully
|
||||||
|
// grab new one as the event should be fired again
|
||||||
|
pwaEvent = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Excalidraw>
|
</Excalidraw>
|
||||||
|
|||||||
+2
-3
@@ -1,8 +1,7 @@
|
|||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { appLangCodeAtom } from "../App";
|
import { useI18n, languages } from "../../packages/excalidraw/i18n";
|
||||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
import { appLangCodeAtom } from "./language-state";
|
||||||
import { languages } from "../../packages/excalidraw/i18n";
|
|
||||||
|
|
||||||
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
||||||
const { t, langCode } = useI18n();
|
const { t, langCode } = useI18n();
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
|
import { defaultLang, languages } from "../../packages/excalidraw";
|
||||||
|
|
||||||
|
export const languageDetector = new LanguageDetector();
|
||||||
|
|
||||||
|
languageDetector.init({
|
||||||
|
languageUtils: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getPreferredLanguage = () => {
|
||||||
|
const detectedLanguages = languageDetector.detect();
|
||||||
|
|
||||||
|
const detectedLanguage = Array.isArray(detectedLanguages)
|
||||||
|
? detectedLanguages[0]
|
||||||
|
: detectedLanguages;
|
||||||
|
|
||||||
|
const initialLanguage =
|
||||||
|
(detectedLanguage
|
||||||
|
? // region code may not be defined if user uses generic preferred language
|
||||||
|
// (e.g. chinese vs instead of chienese-simplified)
|
||||||
|
languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code
|
||||||
|
: null) || defaultLang.code;
|
||||||
|
|
||||||
|
return initialLanguage;
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { getPreferredLanguage, languageDetector } from "./language-detector";
|
||||||
|
|
||||||
|
export const appLangCodeAtom = atom(getPreferredLanguage());
|
||||||
|
|
||||||
|
export const useAppLangCode = () => {
|
||||||
|
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
languageDetector.cacheUserLanguage(langCode);
|
||||||
|
}, [langCode]);
|
||||||
|
|
||||||
|
return [langCode, setLangCode] as const;
|
||||||
|
};
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
import type { Theme } from "../../packages/excalidraw/element/types";
|
import type { Theme } from "../../packages/excalidraw/element/types";
|
||||||
import { MainMenu } from "../../packages/excalidraw/index";
|
import { MainMenu } from "../../packages/excalidraw/index";
|
||||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
import { LanguageList } from "./LanguageList";
|
import { LanguageList } from "../app-language/LanguageList";
|
||||||
|
|
||||||
export const AppMainMenu: React.FC<{
|
export const AppMainMenu: React.FC<{
|
||||||
onCollabDialogOpen: () => any;
|
onCollabDialogOpen: () => any;
|
||||||
@@ -34,7 +34,7 @@ export const AppMainMenu: React.FC<{
|
|||||||
<MainMenu.ItemLink
|
<MainMenu.ItemLink
|
||||||
icon={ExcalLogo}
|
icon={ExcalLogo}
|
||||||
href={`${
|
href={`${
|
||||||
import.meta.env.VITE_APP_PLUS_APP
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
|
||||||
className=""
|
className=""
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
name="description"
|
name="description"
|
||||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||||
/>
|
/>
|
||||||
<meta name="image" content="https://excalidraw.com/og-image-2.png" />
|
<meta name="image" content="https://excalidraw.com/og-image-3.png" />
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:site_name" content="Excalidraw" />
|
<meta property="og:site_name" content="Excalidraw" />
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
property="og:description"
|
property="og:description"
|
||||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||||
/>
|
/>
|
||||||
<meta property="og:image" content="https://excalidraw.com/og-image-2.png" />
|
<meta property="og:image" content="https://excalidraw.com/og-image-3.png" />
|
||||||
|
|
||||||
<!-- Twitter -->
|
<!-- Twitter -->
|
||||||
<meta property="twitter:card" content="summary_large_image" />
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
property="twitter:image"
|
property="twitter:image"
|
||||||
content="https://excalidraw.com/og-twitter-v2.png"
|
content="https://excalidraw.com/og-image-3.png"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- General tags -->
|
<!-- General tags -->
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
margin-bottom: auto;
|
margin-bottom: auto;
|
||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
margin-inline-end: 0.6em;
|
margin-inline-end: 0.6em;
|
||||||
|
z-index: var(--zIndex-layerUI);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 1.2rem;
|
width: 1.2rem;
|
||||||
|
|||||||
@@ -31,8 +31,8 @@
|
|||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
|
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
|
||||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
|
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
|
||||||
"build:version": "node ../scripts/build-version.js",
|
"build:version": "node ../scripts/build-version.js",
|
||||||
"build": "yarn build:app && yarn build:version",
|
"build": "yarn build:app && yarn build:version",
|
||||||
"start": "yarn && vite",
|
"start": "yarn && vite",
|
||||||
|
|||||||
@@ -64,7 +64,12 @@ export default defineConfig({
|
|||||||
|
|
||||||
workbox: {
|
workbox: {
|
||||||
// Don't push fonts and locales to app precache
|
// Don't push fonts and locales to app precache
|
||||||
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
|
globIgnores: [
|
||||||
|
"fonts.css",
|
||||||
|
"**/locales/**",
|
||||||
|
"service-worker.js",
|
||||||
|
"lz-string",
|
||||||
|
],
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
|
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"name": "excalidraw-monorepo",
|
"name": "excalidraw-monorepo",
|
||||||
|
"packageManager": "yarn@1.22.22",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"excalidraw-app",
|
"excalidraw-app",
|
||||||
"packages/excalidraw",
|
"packages/excalidraw",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
|
||||||
|
|
||||||
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
|
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
|
||||||
|
|
||||||
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
|
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
BOUND_TEXT_PADDING,
|
BOUND_TEXT_PADDING,
|
||||||
ROUNDNESS,
|
ROUNDNESS,
|
||||||
VERTICAL_ALIGN,
|
|
||||||
TEXT_ALIGN,
|
TEXT_ALIGN,
|
||||||
|
VERTICAL_ALIGN,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { isTextElement, newElement } from "../element";
|
import { isTextElement, newElement } from "../element";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
@@ -142,6 +142,7 @@ export const actionBindText = register({
|
|||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||||
textAlign: TEXT_ALIGN.CENTER,
|
textAlign: TEXT_ALIGN.CENTER,
|
||||||
|
autoResize: true,
|
||||||
});
|
});
|
||||||
mutateElement(container, {
|
mutateElement(container, {
|
||||||
boundElements: (container.boundElements || []).concat({
|
boundElements: (container.boundElements || []).concat({
|
||||||
@@ -296,6 +297,7 @@ export const actionWrapTextInContainer = register({
|
|||||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||||
boundElements: null,
|
boundElements: null,
|
||||||
textAlign: TEXT_ALIGN.CENTER,
|
textAlign: TEXT_ALIGN.CENTER,
|
||||||
|
autoResize: true,
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export const actionClearCanvas = register({
|
|||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
exportEmbedScene: appState.exportEmbedScene,
|
exportEmbedScene: appState.exportEmbedScene,
|
||||||
gridSize: appState.gridSize,
|
gridSize: appState.gridSize,
|
||||||
showStats: appState.showStats,
|
stats: appState.stats,
|
||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
activeTool:
|
activeTool:
|
||||||
appState.activeTool.type === "image"
|
appState.activeTool.type === "image"
|
||||||
|
|||||||
@@ -131,7 +131,12 @@ export const actionFinalize = register({
|
|||||||
-1,
|
-1,
|
||||||
arrayToMap(elements),
|
arrayToMap(elements),
|
||||||
);
|
);
|
||||||
maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
|
maybeBindLinearElement(
|
||||||
|
multiPointElement,
|
||||||
|
appState,
|
||||||
|
{ x, y },
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ const flipElements = (
|
|||||||
|
|
||||||
bindOrUnbindLinearElements(
|
bindOrUnbindLinearElements(
|
||||||
selectedElements.filter(isLinearElement),
|
selectedElements.filter(isLinearElement),
|
||||||
app,
|
elementsMap,
|
||||||
isBindingEnabled(appState),
|
isBindingEnabled(appState),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||||||
PanelComponent: ({ updateData, data }) => {
|
PanelComponent: ({ updateData, data }) => {
|
||||||
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
||||||
history.onHistoryChangedEmitter,
|
history.onHistoryChangedEmitter,
|
||||||
new HistoryChangedEvent(),
|
new HistoryChangedEvent(
|
||||||
|
history.isUndoStackEmpty,
|
||||||
|
history.isRedoStackEmpty,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,6 +79,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
size={data?.size || "medium"}
|
size={data?.size || "medium"}
|
||||||
disabled={isUndoStackEmpty}
|
disabled={isUndoStackEmpty}
|
||||||
|
data-testid="button-undo"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -103,7 +107,10 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
|||||||
PanelComponent: ({ updateData, data }) => {
|
PanelComponent: ({ updateData, data }) => {
|
||||||
const { isRedoStackEmpty } = useEmitter(
|
const { isRedoStackEmpty } = useEmitter(
|
||||||
history.onHistoryChangedEmitter,
|
history.onHistoryChangedEmitter,
|
||||||
new HistoryChangedEvent(),
|
new HistoryChangedEvent(
|
||||||
|
history.isUndoStackEmpty,
|
||||||
|
history.isRedoStackEmpty,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -114,6 +121,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
|||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
size={data?.size || "medium"}
|
size={data?.size || "medium"}
|
||||||
disabled={isRedoStackEmpty}
|
disabled={isRedoStackEmpty}
|
||||||
|
data-testid="button-redo"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ const offsetElementAfterFontResize = (
|
|||||||
prevElement: ExcalidrawTextElement,
|
prevElement: ExcalidrawTextElement,
|
||||||
nextElement: ExcalidrawTextElement,
|
nextElement: ExcalidrawTextElement,
|
||||||
) => {
|
) => {
|
||||||
if (isBoundToContainer(nextElement)) {
|
if (isBoundToContainer(nextElement) || !nextElement.autoResize) {
|
||||||
return nextElement;
|
return nextElement;
|
||||||
}
|
}
|
||||||
return mutateElement(
|
return mutateElement(
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { isTextElement } from "../element";
|
||||||
|
import { newElementWith } from "../element/mutateElement";
|
||||||
|
import { measureText } from "../element/textElement";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
import type { AppClassProperties } from "../types";
|
||||||
|
import { getFontString } from "../utils";
|
||||||
|
import { register } from "./register";
|
||||||
|
|
||||||
|
export const actionTextAutoResize = register({
|
||||||
|
name: "autoResize",
|
||||||
|
label: "labels.autoResize",
|
||||||
|
icon: null,
|
||||||
|
trackEvent: { category: "element" },
|
||||||
|
predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
return (
|
||||||
|
selectedElements.length === 1 &&
|
||||||
|
isTextElement(selectedElements[0]) &&
|
||||||
|
!selectedElements[0].autoResize
|
||||||
|
);
|
||||||
|
},
|
||||||
|
perform: (elements, appState, _, app) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState,
|
||||||
|
elements: elements.map((element) => {
|
||||||
|
if (element.id === selectedElements[0].id && isTextElement(element)) {
|
||||||
|
const metrics = measureText(
|
||||||
|
element.originalText,
|
||||||
|
getFontString(element),
|
||||||
|
element.lineHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
return newElementWith(element, {
|
||||||
|
autoResize: true,
|
||||||
|
width: metrics.width,
|
||||||
|
height: metrics.height,
|
||||||
|
text: element.originalText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}),
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -5,21 +5,22 @@ import { StoreAction } from "../store";
|
|||||||
|
|
||||||
export const actionToggleStats = register({
|
export const actionToggleStats = register({
|
||||||
name: "stats",
|
name: "stats",
|
||||||
label: "stats.title",
|
label: "stats.fullTitle",
|
||||||
icon: abacusIcon,
|
icon: abacusIcon,
|
||||||
paletteName: "Toggle stats",
|
paletteName: "Toggle stats",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "menu" },
|
trackEvent: { category: "menu" },
|
||||||
|
keywords: ["edit", "attributes", "customize"],
|
||||||
perform(elements, appState) {
|
perform(elements, appState) {
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
showStats: !this.checked!(appState),
|
stats: { ...appState.stats, open: !this.checked!(appState) },
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState) => appState.showStats,
|
checked: (appState) => appState.stats.open,
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -134,7 +134,9 @@ export type ActionName =
|
|||||||
| "setEmbeddableAsActiveTool"
|
| "setEmbeddableAsActiveTool"
|
||||||
| "createContainerFromText"
|
| "createContainerFromText"
|
||||||
| "wrapTextInContainer"
|
| "wrapTextInContainer"
|
||||||
| "commandPalette";
|
| "commandPalette"
|
||||||
|
| "autoResize"
|
||||||
|
| "elementStats";
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// place here categories that you want to track. We want to track just a
|
// place here categories that you want to track. We want to track just a
|
||||||
// small subset of categories at a given time.
|
// small subset of categories at a given time.
|
||||||
const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
|
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
|
||||||
|
|
||||||
export const trackEvent = (
|
export const trackEvent = (
|
||||||
category: string,
|
category: string,
|
||||||
@@ -9,17 +9,20 @@ export const trackEvent = (
|
|||||||
value?: number,
|
value?: number,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
// prettier-ignore
|
|
||||||
if (
|
if (
|
||||||
typeof window === "undefined"
|
typeof window === "undefined" ||
|
||||||
|| import.meta.env.VITE_WORKER_ID
|
import.meta.env.VITE_WORKER_ID ||
|
||||||
// comment out to debug locally
|
import.meta.env.VITE_APP_ENABLE_TRACKING !== "true"
|
||||||
|| import.meta.env.PROD
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
|
if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
// comment out to debug in dev
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
EXPORT_SCALES,
|
EXPORT_SCALES,
|
||||||
|
STATS_PANELS,
|
||||||
THEME,
|
THEME,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import type { AppState, NormalizedZoomValue } from "./types";
|
import type { AppState, NormalizedZoomValue } from "./types";
|
||||||
@@ -80,7 +81,10 @@ export const getDefaultAppState = (): Omit<
|
|||||||
selectedElementsAreBeingDragged: false,
|
selectedElementsAreBeingDragged: false,
|
||||||
selectionElement: null,
|
selectionElement: null,
|
||||||
shouldCacheIgnoreZoom: false,
|
shouldCacheIgnoreZoom: false,
|
||||||
showStats: false,
|
stats: {
|
||||||
|
open: false,
|
||||||
|
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
|
||||||
|
},
|
||||||
startBoundElement: null,
|
startBoundElement: null,
|
||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
||||||
@@ -196,7 +200,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
},
|
},
|
||||||
selectionElement: { browser: false, export: false, server: false },
|
selectionElement: { browser: false, export: false, server: false },
|
||||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||||
showStats: { browser: true, export: false, server: false },
|
stats: { browser: true, export: false, server: false },
|
||||||
startBoundElement: { browser: false, export: false, server: false },
|
startBoundElement: { browser: false, export: false, server: false },
|
||||||
suggestedBindings: { browser: false, export: false, server: false },
|
suggestedBindings: { browser: false, export: false, server: false },
|
||||||
frameRendering: { browser: false, export: false, server: false },
|
frameRendering: { browser: false, export: false, server: false },
|
||||||
|
|||||||
@@ -1477,19 +1477,28 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
return elements;
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previous = Array.from(elements.values());
|
const unordered = Array.from(elements.values());
|
||||||
const reordered = orderByFractionalIndex([...previous]);
|
const ordered = orderByFractionalIndex([...unordered]);
|
||||||
|
const moved = Delta.getRightDifferences(unordered, ordered, true).reduce(
|
||||||
|
(acc, arrayIndex) => {
|
||||||
|
const candidate = unordered[Number(arrayIndex)];
|
||||||
|
if (candidate && changed.has(candidate.id)) {
|
||||||
|
acc.set(candidate.id, candidate);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
return acc;
|
||||||
!flags.containsVisibleDifference &&
|
},
|
||||||
Delta.isRightDifferent(previous, reordered, true)
|
new Map(),
|
||||||
) {
|
);
|
||||||
|
|
||||||
|
if (!flags.containsVisibleDifference && moved.size) {
|
||||||
// we found a difference in order!
|
// we found a difference in order!
|
||||||
flags.containsVisibleDifference = true;
|
flags.containsVisibleDifference = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// let's synchronize all invalid indices of moved elements
|
// synchronize all elements that were actually moved
|
||||||
return arrayToMap(syncMovedIndices(reordered, changed)) as typeof elements;
|
// could fallback to synchronizing all invalid indices
|
||||||
|
return arrayToMap(syncMovedIndices(ordered, moved)) as typeof elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -468,6 +468,7 @@ export const ExitZenModeAction = ({
|
|||||||
showExitZenModeBtn: boolean;
|
showExitZenModeBtn: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={clsx("disable-zen-mode", {
|
className={clsx("disable-zen-mode", {
|
||||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ import {
|
|||||||
isIOS,
|
isIOS,
|
||||||
supportsResizeObserver,
|
supportsResizeObserver,
|
||||||
DEFAULT_COLLISION_THRESHOLD,
|
DEFAULT_COLLISION_THRESHOLD,
|
||||||
|
DEFAULT_TEXT_ALIGN,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import type { ExportedElements } from "../data";
|
import type { ExportedElements } from "../data";
|
||||||
import { exportCanvas, loadFromBlob } from "../data";
|
import { exportCanvas, loadFromBlob } from "../data";
|
||||||
@@ -114,7 +115,7 @@ import {
|
|||||||
newTextElement,
|
newTextElement,
|
||||||
newImageElement,
|
newImageElement,
|
||||||
transformElements,
|
transformElements,
|
||||||
updateTextElement,
|
refreshTextDimensions,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
@@ -223,16 +224,9 @@ import type {
|
|||||||
ScrollBars,
|
ScrollBars,
|
||||||
} from "../scene/types";
|
} from "../scene/types";
|
||||||
import { getStateForZoom } from "../scene/zoom";
|
import { getStateForZoom } from "../scene/zoom";
|
||||||
import { findShapeByKey } from "../shapes";
|
import { findShapeByKey, getElementShape } from "../shapes";
|
||||||
import type { GeometricShape } from "../../utils/geometry/shape";
|
import type { GeometricShape } from "../../utils/geometry/shape";
|
||||||
import {
|
import { getSelectionBoxShape } from "../../utils/geometry/shape";
|
||||||
getClosedCurveShape,
|
|
||||||
getCurveShape,
|
|
||||||
getEllipseShape,
|
|
||||||
getFreedrawShape,
|
|
||||||
getPolygonShape,
|
|
||||||
getSelectionBoxShape,
|
|
||||||
} from "../../utils/geometry/shape";
|
|
||||||
import { isPointInShape } from "../../utils/collision";
|
import { isPointInShape } from "../../utils/collision";
|
||||||
import type {
|
import type {
|
||||||
AppClassProperties,
|
AppClassProperties,
|
||||||
@@ -329,8 +323,11 @@ import {
|
|||||||
getContainerElement,
|
getContainerElement,
|
||||||
getDefaultLineHeight,
|
getDefaultLineHeight,
|
||||||
getLineHeightInPx,
|
getLineHeightInPx,
|
||||||
|
getMinTextElementWidth,
|
||||||
isMeasureTextSupported,
|
isMeasureTextSupported,
|
||||||
isValidTextContainer,
|
isValidTextContainer,
|
||||||
|
measureText,
|
||||||
|
wrapText,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import {
|
import {
|
||||||
showHyperlinkTooltip,
|
showHyperlinkTooltip,
|
||||||
@@ -419,7 +416,6 @@ import {
|
|||||||
hitElementBoundText,
|
hitElementBoundText,
|
||||||
hitElementBoundingBoxOnly,
|
hitElementBoundingBoxOnly,
|
||||||
hitElementItself,
|
hitElementItself,
|
||||||
shouldTestInside,
|
|
||||||
} from "../element/collision";
|
} from "../element/collision";
|
||||||
import { textWysiwyg } from "../element/textWysiwyg";
|
import { textWysiwyg } from "../element/textWysiwyg";
|
||||||
import { isOverScrollBars } from "../scene/scrollbars";
|
import { isOverScrollBars } from "../scene/scrollbars";
|
||||||
@@ -429,6 +425,9 @@ import {
|
|||||||
isPointHittingLinkIcon,
|
isPointHittingLinkIcon,
|
||||||
} from "./hyperlink/helpers";
|
} from "./hyperlink/helpers";
|
||||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||||
|
import { actionTextAutoResize } from "../actions/actionTextAutoResize";
|
||||||
|
import { getVisibleSceneBounds } from "../element/bounds";
|
||||||
|
import { isMaybeMermaidDefinition } from "../mermaid";
|
||||||
|
|
||||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||||
@@ -714,10 +713,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
id: this.id,
|
id: this.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.fonts = new Fonts({
|
this.fonts = new Fonts({ scene: this.scene });
|
||||||
scene: this.scene,
|
|
||||||
onSceneUpdated: this.onSceneUpdated,
|
|
||||||
});
|
|
||||||
this.history = new History();
|
this.history = new History();
|
||||||
|
|
||||||
this.actionManager.registerAll(actions);
|
this.actionManager.registerAll(actions);
|
||||||
@@ -940,7 +936,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
this.scene.informMutation();
|
this.scene.triggerUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
// GC
|
// GC
|
||||||
@@ -1452,10 +1448,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||||
const { renderTopRightUI, renderCustomStats } = this.props;
|
const { renderTopRightUI, renderCustomStats } = this.props;
|
||||||
|
|
||||||
const versionNonce = this.scene.getVersionNonce();
|
const sceneNonce = this.scene.getSceneNonce();
|
||||||
const { elementsMap, visibleElements } =
|
const { elementsMap, visibleElements } =
|
||||||
this.renderer.getRenderableElements({
|
this.renderer.getRenderableElements({
|
||||||
versionNonce,
|
sceneNonce,
|
||||||
zoom: this.state.zoom,
|
zoom: this.state.zoom,
|
||||||
offsetLeft: this.state.offsetLeft,
|
offsetLeft: this.state.offsetLeft,
|
||||||
offsetTop: this.state.offsetTop,
|
offsetTop: this.state.offsetTop,
|
||||||
@@ -1673,7 +1669,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
elementsMap={elementsMap}
|
elementsMap={elementsMap}
|
||||||
allElementsMap={allElementsMap}
|
allElementsMap={allElementsMap}
|
||||||
visibleElements={visibleElements}
|
visibleElements={visibleElements}
|
||||||
versionNonce={versionNonce}
|
sceneNonce={sceneNonce}
|
||||||
selectionNonce={
|
selectionNonce={
|
||||||
this.state.selectionElement?.versionNonce
|
this.state.selectionElement?.versionNonce
|
||||||
}
|
}
|
||||||
@@ -1694,8 +1690,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
canvas={this.interactiveCanvas}
|
canvas={this.interactiveCanvas}
|
||||||
elementsMap={elementsMap}
|
elementsMap={elementsMap}
|
||||||
visibleElements={visibleElements}
|
visibleElements={visibleElements}
|
||||||
|
allElementsMap={allElementsMap}
|
||||||
selectedElements={selectedElements}
|
selectedElements={selectedElements}
|
||||||
versionNonce={versionNonce}
|
sceneNonce={sceneNonce}
|
||||||
selectionNonce={
|
selectionNonce={
|
||||||
this.state.selectionElement?.versionNonce
|
this.state.selectionElement?.versionNonce
|
||||||
}
|
}
|
||||||
@@ -1819,7 +1816,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.magicGenerations.set(frameElement.id, data);
|
this.magicGenerations.set(frameElement.id, data);
|
||||||
this.onSceneUpdated();
|
this.triggerRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
private getTextFromElements(elements: readonly ExcalidrawElement[]) {
|
private getTextFromElements(elements: readonly ExcalidrawElement[]) {
|
||||||
@@ -2129,95 +2126,96 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private syncActionResult = withBatchedUpdates(
|
public syncActionResult = withBatchedUpdates((actionResult: ActionResult) => {
|
||||||
(actionResult: ActionResult) => {
|
if (this.unmounted || actionResult === false) {
|
||||||
if (this.unmounted || actionResult === false) {
|
return;
|
||||||
return;
|
}
|
||||||
|
|
||||||
|
if (actionResult.storeAction === StoreAction.UPDATE) {
|
||||||
|
this.store.shouldUpdateSnapshot();
|
||||||
|
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
|
||||||
|
this.store.shouldCaptureIncrement();
|
||||||
|
}
|
||||||
|
|
||||||
|
let didUpdate = false;
|
||||||
|
|
||||||
|
let editingElement: AppState["editingElement"] | null = null;
|
||||||
|
if (actionResult.elements) {
|
||||||
|
actionResult.elements.forEach((element) => {
|
||||||
|
if (
|
||||||
|
this.state.editingElement?.id === element.id &&
|
||||||
|
this.state.editingElement !== element &&
|
||||||
|
isNonDeletedElement(element)
|
||||||
|
) {
|
||||||
|
editingElement = element;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scene.replaceAllElements(actionResult.elements);
|
||||||
|
didUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionResult.files) {
|
||||||
|
this.files = actionResult.replaceFiles
|
||||||
|
? actionResult.files
|
||||||
|
: { ...this.files, ...actionResult.files };
|
||||||
|
this.addNewImagesToImageCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionResult.appState || editingElement || this.state.contextMenu) {
|
||||||
|
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
|
||||||
|
let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
|
||||||
|
let gridSize = actionResult?.appState?.gridSize || null;
|
||||||
|
const theme =
|
||||||
|
actionResult?.appState?.theme || this.props.theme || THEME.LIGHT;
|
||||||
|
const name = actionResult?.appState?.name ?? this.state.name;
|
||||||
|
const errorMessage =
|
||||||
|
actionResult?.appState?.errorMessage ?? this.state.errorMessage;
|
||||||
|
if (typeof this.props.viewModeEnabled !== "undefined") {
|
||||||
|
viewModeEnabled = this.props.viewModeEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
let editingElement: AppState["editingElement"] | null = null;
|
if (typeof this.props.zenModeEnabled !== "undefined") {
|
||||||
if (actionResult.elements) {
|
zenModeEnabled = this.props.zenModeEnabled;
|
||||||
actionResult.elements.forEach((element) => {
|
}
|
||||||
if (
|
|
||||||
this.state.editingElement?.id === element.id &&
|
if (typeof this.props.gridModeEnabled !== "undefined") {
|
||||||
this.state.editingElement !== element &&
|
gridSize = this.props.gridModeEnabled ? GRID_SIZE : null;
|
||||||
isNonDeletedElement(element)
|
}
|
||||||
) {
|
|
||||||
editingElement = element;
|
editingElement =
|
||||||
}
|
editingElement || actionResult.appState?.editingElement || null;
|
||||||
|
|
||||||
|
if (editingElement?.isDeleted) {
|
||||||
|
editingElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState((state) => {
|
||||||
|
// using Object.assign instead of spread to fool TS 4.2.2+ into
|
||||||
|
// regarding the resulting type as not containing undefined
|
||||||
|
// (which the following expression will never contain)
|
||||||
|
return Object.assign(actionResult.appState || {}, {
|
||||||
|
// NOTE this will prevent opening context menu using an action
|
||||||
|
// or programmatically from the host, so it will need to be
|
||||||
|
// rewritten later
|
||||||
|
contextMenu: null,
|
||||||
|
editingElement,
|
||||||
|
viewModeEnabled,
|
||||||
|
zenModeEnabled,
|
||||||
|
gridSize,
|
||||||
|
theme,
|
||||||
|
name,
|
||||||
|
errorMessage,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if (actionResult.storeAction === StoreAction.UPDATE) {
|
didUpdate = true;
|
||||||
this.store.shouldUpdateSnapshot();
|
}
|
||||||
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
|
|
||||||
this.store.shouldCaptureIncrement();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scene.replaceAllElements(actionResult.elements);
|
if (!didUpdate && actionResult.storeAction !== StoreAction.NONE) {
|
||||||
}
|
this.scene.triggerUpdate();
|
||||||
|
}
|
||||||
if (actionResult.files) {
|
});
|
||||||
this.files = actionResult.replaceFiles
|
|
||||||
? actionResult.files
|
|
||||||
: { ...this.files, ...actionResult.files };
|
|
||||||
this.addNewImagesToImageCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actionResult.appState || editingElement || this.state.contextMenu) {
|
|
||||||
if (actionResult.storeAction === StoreAction.UPDATE) {
|
|
||||||
this.store.shouldUpdateSnapshot();
|
|
||||||
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
|
|
||||||
this.store.shouldCaptureIncrement();
|
|
||||||
}
|
|
||||||
|
|
||||||
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
|
|
||||||
let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
|
|
||||||
let gridSize = actionResult?.appState?.gridSize || null;
|
|
||||||
const theme =
|
|
||||||
actionResult?.appState?.theme || this.props.theme || THEME.LIGHT;
|
|
||||||
const name = actionResult?.appState?.name ?? this.state.name;
|
|
||||||
const errorMessage =
|
|
||||||
actionResult?.appState?.errorMessage ?? this.state.errorMessage;
|
|
||||||
if (typeof this.props.viewModeEnabled !== "undefined") {
|
|
||||||
viewModeEnabled = this.props.viewModeEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof this.props.zenModeEnabled !== "undefined") {
|
|
||||||
zenModeEnabled = this.props.zenModeEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof this.props.gridModeEnabled !== "undefined") {
|
|
||||||
gridSize = this.props.gridModeEnabled ? GRID_SIZE : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
editingElement =
|
|
||||||
editingElement || actionResult.appState?.editingElement || null;
|
|
||||||
|
|
||||||
if (editingElement?.isDeleted) {
|
|
||||||
editingElement = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState((state) => {
|
|
||||||
// using Object.assign instead of spread to fool TS 4.2.2+ into
|
|
||||||
// regarding the resulting type as not containing undefined
|
|
||||||
// (which the following expression will never contain)
|
|
||||||
return Object.assign(actionResult.appState || {}, {
|
|
||||||
// NOTE this will prevent opening context menu using an action
|
|
||||||
// or programmatically from the host, so it will need to be
|
|
||||||
// rewritten later
|
|
||||||
contextMenu: null,
|
|
||||||
editingElement,
|
|
||||||
viewModeEnabled,
|
|
||||||
zenModeEnabled,
|
|
||||||
gridSize,
|
|
||||||
theme,
|
|
||||||
name,
|
|
||||||
errorMessage,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
|
|
||||||
@@ -2284,7 +2282,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
let initialData = null;
|
let initialData = null;
|
||||||
try {
|
try {
|
||||||
initialData = (await this.props.initialData) || null;
|
if (typeof this.props.initialData === "function") {
|
||||||
|
initialData = (await this.props.initialData()) || null;
|
||||||
|
} else {
|
||||||
|
initialData = (await this.props.initialData) || null;
|
||||||
|
}
|
||||||
if (initialData?.libraryItems) {
|
if (initialData?.libraryItems) {
|
||||||
this.library
|
this.library
|
||||||
.updateLibrary({
|
.updateLibrary({
|
||||||
@@ -2444,7 +2446,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.history.record(increment.elementsChange, increment.appStateChange);
|
this.history.record(increment.elementsChange, increment.appStateChange);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.scene.addCallback(this.onSceneUpdated);
|
this.scene.onUpdate(this.triggerRender);
|
||||||
this.addEventListeners();
|
this.addEventListeners();
|
||||||
|
|
||||||
if (this.props.autoFocus && this.excalidrawContainerRef.current) {
|
if (this.props.autoFocus && this.excalidrawContainerRef.current) {
|
||||||
@@ -2487,15 +2489,17 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public componentWillUnmount() {
|
public componentWillUnmount() {
|
||||||
|
(window as any).launchQueue?.setConsumer(() => {});
|
||||||
this.renderer.destroy();
|
this.renderer.destroy();
|
||||||
|
this.scene.destroy();
|
||||||
this.scene = new Scene();
|
this.scene = new Scene();
|
||||||
|
this.fonts = new Fonts({ scene: this.scene });
|
||||||
this.renderer = new Renderer(this.scene);
|
this.renderer = new Renderer(this.scene);
|
||||||
this.files = {};
|
this.files = {};
|
||||||
this.imageCache.clear();
|
this.imageCache.clear();
|
||||||
this.resizeObserver?.disconnect();
|
this.resizeObserver?.disconnect();
|
||||||
this.unmounted = true;
|
this.unmounted = true;
|
||||||
this.removeEventListeners();
|
this.removeEventListeners();
|
||||||
this.scene.destroy();
|
|
||||||
this.library.destroy();
|
this.library.destroy();
|
||||||
this.laserTrails.stop();
|
this.laserTrails.stop();
|
||||||
this.eraserTrail.stop();
|
this.eraserTrail.stop();
|
||||||
@@ -2566,7 +2570,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
addEventListener(document, EVENT.KEYUP, this.onKeyUp, { passive: true }),
|
addEventListener(document, EVENT.KEYUP, this.onKeyUp, { passive: true }),
|
||||||
addEventListener(
|
addEventListener(
|
||||||
document,
|
document,
|
||||||
EVENT.MOUSE_MOVE,
|
EVENT.POINTER_MOVE,
|
||||||
this.updateCurrentCursorPosition,
|
this.updateCurrentCursorPosition,
|
||||||
),
|
),
|
||||||
// rerender text elements on font load to fix #637 && #1553
|
// rerender text elements on font load to fix #637 && #1553
|
||||||
@@ -2595,6 +2599,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
),
|
),
|
||||||
addEventListener(window, EVENT.FOCUS, () => {
|
addEventListener(window, EVENT.FOCUS, () => {
|
||||||
this.maybeCleanupAfterMissingPointerUp(null);
|
this.maybeCleanupAfterMissingPointerUp(null);
|
||||||
|
// browsers (chrome?) tend to free up memory a lot, which results
|
||||||
|
// in canvas context being cleared. Thus re-render on focus.
|
||||||
|
this.triggerRender(true);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2804,7 +2811,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
nonDeletedElementsMap,
|
nonDeletedElementsMap,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
this,
|
this.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3042,6 +3049,31 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
retainSeed: isPlainPaste,
|
retainSeed: isPlainPaste,
|
||||||
});
|
});
|
||||||
} else if (data.text) {
|
} else if (data.text) {
|
||||||
|
if (data.text && isMaybeMermaidDefinition(data.text)) {
|
||||||
|
const api = await import("@excalidraw/mermaid-to-excalidraw");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { elements: skeletonElements, files } =
|
||||||
|
await api.parseMermaidToExcalidraw(data.text);
|
||||||
|
|
||||||
|
const elements = convertToExcalidrawElements(skeletonElements, {
|
||||||
|
regenerateIds: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addElementsFromPasteOrLibrary({
|
||||||
|
elements,
|
||||||
|
files,
|
||||||
|
position: "cursor",
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn(
|
||||||
|
`parsing pasted text as mermaid definition failed: ${err.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const nonEmptyLines = normalizeEOL(data.text)
|
const nonEmptyLines = normalizeEOL(data.text)
|
||||||
.split(/\n+/)
|
.split(/\n+/)
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
@@ -3339,32 +3371,53 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
text,
|
text,
|
||||||
fontSize: this.state.currentItemFontSize,
|
fontSize: this.state.currentItemFontSize,
|
||||||
fontFamily: this.state.currentItemFontFamily,
|
fontFamily: this.state.currentItemFontFamily,
|
||||||
textAlign: this.state.currentItemTextAlign,
|
textAlign: DEFAULT_TEXT_ALIGN,
|
||||||
verticalAlign: DEFAULT_VERTICAL_ALIGN,
|
verticalAlign: DEFAULT_VERTICAL_ALIGN,
|
||||||
locked: false,
|
locked: false,
|
||||||
};
|
};
|
||||||
|
const fontString = getFontString({
|
||||||
|
fontSize: textElementProps.fontSize,
|
||||||
|
fontFamily: 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);
|
||||||
const LINE_GAP = 10;
|
const LINE_GAP = 10;
|
||||||
let currentY = y;
|
let currentY = y;
|
||||||
|
|
||||||
const lines = isPlainPaste ? [text] : text.split("\n");
|
const lines = isPlainPaste ? [text] : text.split("\n");
|
||||||
const textElements = lines.reduce(
|
const textElements = lines.reduce(
|
||||||
(acc: ExcalidrawTextElement[], line, idx) => {
|
(acc: ExcalidrawTextElement[], line, idx) => {
|
||||||
const text = line.trim();
|
const originalText = line.trim();
|
||||||
|
if (originalText.length) {
|
||||||
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
|
|
||||||
if (text.length) {
|
|
||||||
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
|
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
|
||||||
x,
|
x,
|
||||||
y: currentY,
|
y: currentY,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let metrics = measureText(originalText, fontString, lineHeight);
|
||||||
|
const isTextWrapped = metrics.width > maxTextWidth;
|
||||||
|
|
||||||
|
const text = isTextWrapped
|
||||||
|
? wrapText(originalText, fontString, maxTextWidth)
|
||||||
|
: originalText;
|
||||||
|
|
||||||
|
metrics = isTextWrapped
|
||||||
|
? measureText(text, fontString, lineHeight)
|
||||||
|
: metrics;
|
||||||
|
|
||||||
|
const startX = x - metrics.width / 2;
|
||||||
|
const startY = currentY - metrics.height / 2;
|
||||||
|
|
||||||
const element = newTextElement({
|
const element = newTextElement({
|
||||||
...textElementProps,
|
...textElementProps,
|
||||||
x,
|
x: startX,
|
||||||
y: currentY,
|
y: startY,
|
||||||
text,
|
text,
|
||||||
|
originalText,
|
||||||
lineHeight,
|
lineHeight,
|
||||||
|
autoResize: !isTextWrapped,
|
||||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||||
});
|
});
|
||||||
acc.push(element);
|
acc.push(element);
|
||||||
@@ -3670,7 +3723,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
ShapeCache.delete(element);
|
ShapeCache.delete(element);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.scene.informMutation();
|
this.scene.triggerUpdate();
|
||||||
|
|
||||||
this.addNewImagesToImageCache();
|
this.addNewImagesToImageCache();
|
||||||
},
|
},
|
||||||
@@ -3681,7 +3734,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
elements?: SceneData["elements"];
|
elements?: SceneData["elements"];
|
||||||
appState?: Pick<AppState, K> | null;
|
appState?: Pick<AppState, K> | null;
|
||||||
collaborators?: SceneData["collaborators"];
|
collaborators?: SceneData["collaborators"];
|
||||||
/** @default StoreAction.CAPTURE */
|
/** @default StoreAction.NONE */
|
||||||
storeAction?: SceneData["storeAction"];
|
storeAction?: SceneData["storeAction"];
|
||||||
}) => {
|
}) => {
|
||||||
const nextElements = syncInvalidIndices(sceneData.elements ?? []);
|
const nextElements = syncInvalidIndices(sceneData.elements ?? []);
|
||||||
@@ -3730,8 +3783,15 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
private onSceneUpdated = () => {
|
private triggerRender = (
|
||||||
this.setState({});
|
/** force always re-renders canvas even if no change */
|
||||||
|
force?: boolean,
|
||||||
|
) => {
|
||||||
|
if (force === true) {
|
||||||
|
this.scene.triggerUpdate();
|
||||||
|
} else {
|
||||||
|
this.setState({});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3938,7 +3998,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState({
|
this.setState({
|
||||||
suggestedBindings: getSuggestedBindingsForArrows(
|
suggestedBindings: getSuggestedBindingsForArrows(
|
||||||
selectedElements,
|
selectedElements,
|
||||||
this,
|
this.scene.getNonDeletedElementsMap(),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4109,7 +4169,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (isArrowKey(event.key)) {
|
if (isArrowKey(event.key)) {
|
||||||
bindOrUnbindLinearElements(
|
bindOrUnbindLinearElements(
|
||||||
this.scene.getSelectedElements(this.state).filter(isLinearElement),
|
this.scene.getSelectedElements(this.state).filter(isLinearElement),
|
||||||
this,
|
this.scene.getNonDeletedElementsMap(),
|
||||||
isBindingEnabled(this.state),
|
isBindingEnabled(this.state),
|
||||||
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
|
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
|
||||||
);
|
);
|
||||||
@@ -4300,25 +4360,22 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
) {
|
) {
|
||||||
const elementsMap = this.scene.getElementsMapIncludingDeleted();
|
const elementsMap = this.scene.getElementsMapIncludingDeleted();
|
||||||
|
|
||||||
const updateElement = (
|
const updateElement = (nextOriginalText: string, isDeleted: boolean) => {
|
||||||
text: string,
|
|
||||||
originalText: string,
|
|
||||||
isDeleted: boolean,
|
|
||||||
) => {
|
|
||||||
this.scene.replaceAllElements([
|
this.scene.replaceAllElements([
|
||||||
// Not sure why we include deleted elements as well hence using deleted elements map
|
// Not sure why we include deleted elements as well hence using deleted elements map
|
||||||
...this.scene.getElementsIncludingDeleted().map((_element) => {
|
...this.scene.getElementsIncludingDeleted().map((_element) => {
|
||||||
if (_element.id === element.id && isTextElement(_element)) {
|
if (_element.id === element.id && isTextElement(_element)) {
|
||||||
return updateTextElement(
|
return newElementWith(_element, {
|
||||||
_element,
|
originalText: nextOriginalText,
|
||||||
getContainerElement(_element, elementsMap),
|
isDeleted: isDeleted ?? _element.isDeleted,
|
||||||
elementsMap,
|
// returns (wrapped) text and new dimensions
|
||||||
{
|
...refreshTextDimensions(
|
||||||
text,
|
_element,
|
||||||
isDeleted,
|
getContainerElement(_element, elementsMap),
|
||||||
originalText,
|
elementsMap,
|
||||||
},
|
nextOriginalText,
|
||||||
);
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return _element;
|
return _element;
|
||||||
}),
|
}),
|
||||||
@@ -4341,15 +4398,15 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
viewportY - this.state.offsetTop,
|
viewportY - this.state.offsetTop,
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
onChange: withBatchedUpdates((text) => {
|
onChange: withBatchedUpdates((nextOriginalText) => {
|
||||||
updateElement(text, text, false);
|
updateElement(nextOriginalText, false);
|
||||||
if (isNonDeletedElement(element)) {
|
if (isNonDeletedElement(element)) {
|
||||||
updateBoundElements(element, elementsMap);
|
updateBoundElements(element, elementsMap);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
onSubmit: withBatchedUpdates(({ text, viaKeyboard, originalText }) => {
|
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
|
||||||
const isDeleted = !text.trim();
|
const isDeleted = !nextOriginalText.trim();
|
||||||
updateElement(text, originalText, isDeleted);
|
updateElement(nextOriginalText, isDeleted);
|
||||||
// select the created text element only if submitting via keyboard
|
// select the created text element only if submitting via keyboard
|
||||||
// (when submitting via click it should act as signal to deselect)
|
// (when submitting via click it should act as signal to deselect)
|
||||||
if (!isDeleted && viaKeyboard) {
|
if (!isDeleted && viaKeyboard) {
|
||||||
@@ -4388,13 +4445,18 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
element,
|
element,
|
||||||
excalidrawContainer: this.excalidrawContainerRef.current,
|
excalidrawContainer: this.excalidrawContainerRef.current,
|
||||||
app: this,
|
app: this,
|
||||||
|
// when text is selected, it's hard (at least on iOS) to re-position the
|
||||||
|
// caret (i.e. deselect). There's not much use for always selecting
|
||||||
|
// the text on edit anyway (and users can select-all from contextmenu
|
||||||
|
// if needed)
|
||||||
|
autoSelect: !this.device.isTouchScreen,
|
||||||
});
|
});
|
||||||
// deselect all other elements when inserting text
|
// deselect all other elements when inserting text
|
||||||
this.deselectElements();
|
this.deselectElements();
|
||||||
|
|
||||||
// do an initial update to re-initialize element position since we were
|
// do an initial update to re-initialize element position since we were
|
||||||
// modifying element's x/y for sake of editor (case: syncing to remote)
|
// modifying element's x/y for sake of editor (case: syncing to remote)
|
||||||
updateElement(element.text, element.originalText, false);
|
updateElement(element.originalText, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private deselectElements() {
|
private deselectElements() {
|
||||||
@@ -4419,59 +4481,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return null;
|
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 {
|
private getBoundTextShape(element: ExcalidrawElement): GeometricShape | null {
|
||||||
const boundTextElement = getBoundTextElement(
|
const boundTextElement = getBoundTextElement(
|
||||||
element,
|
element,
|
||||||
@@ -4480,18 +4489,24 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
if (element.type === "arrow") {
|
if (element.type === "arrow") {
|
||||||
return this.getElementShape({
|
return getElementShape(
|
||||||
...boundTextElement,
|
{
|
||||||
// arrow's bound text accurate position is not stored in the element's property
|
...boundTextElement,
|
||||||
// but rather calculated and returned from the following static method
|
// arrow's bound text accurate position is not stored in the element's property
|
||||||
...LinearElementEditor.getBoundTextElementPosition(
|
// but rather calculated and returned from the following static method
|
||||||
element,
|
...LinearElementEditor.getBoundTextElementPosition(
|
||||||
boundTextElement,
|
element,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
boundTextElement,
|
||||||
),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
});
|
),
|
||||||
|
},
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return this.getElementShape(boundTextElement);
|
return getElementShape(
|
||||||
|
boundTextElement,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -4530,7 +4545,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
element: elementWithHighestZIndex,
|
element: elementWithHighestZIndex,
|
||||||
shape: this.getElementShape(elementWithHighestZIndex),
|
shape: getElementShape(
|
||||||
|
elementWithHighestZIndex,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
),
|
||||||
// when overlapping, we would like to be more precise
|
// when overlapping, we would like to be more precise
|
||||||
// this also avoids the need to update past tests
|
// this also avoids the need to update past tests
|
||||||
threshold: this.getElementHitThreshold() / 2,
|
threshold: this.getElementHitThreshold() / 2,
|
||||||
@@ -4635,7 +4653,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
element,
|
element,
|
||||||
shape: this.getElementShape(element),
|
shape: getElementShape(element, this.scene.getNonDeletedElementsMap()),
|
||||||
threshold: this.getElementHitThreshold(),
|
threshold: this.getElementHitThreshold(),
|
||||||
frameNameBound: isFrameLikeElement(element)
|
frameNameBound: isFrameLikeElement(element)
|
||||||
? this.frameNameBoundsCache.get(element)
|
? this.frameNameBoundsCache.get(element)
|
||||||
@@ -4667,7 +4685,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
element: elements[index],
|
element: elements[index],
|
||||||
shape: this.getElementShape(elements[index]),
|
shape: getElementShape(
|
||||||
|
elements[index],
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
),
|
||||||
threshold: this.getElementHitThreshold(),
|
threshold: this.getElementHitThreshold(),
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
@@ -4687,6 +4708,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
sceneY,
|
sceneY,
|
||||||
insertAtParentCenter = true,
|
insertAtParentCenter = true,
|
||||||
container,
|
container,
|
||||||
|
autoEdit = true,
|
||||||
}: {
|
}: {
|
||||||
/** X position to insert text at */
|
/** X position to insert text at */
|
||||||
sceneX: number;
|
sceneX: number;
|
||||||
@@ -4695,6 +4717,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
/** whether to attempt to insert at element center if applicable */
|
/** whether to attempt to insert at element center if applicable */
|
||||||
insertAtParentCenter?: boolean;
|
insertAtParentCenter?: boolean;
|
||||||
container?: ExcalidrawTextContainer | null;
|
container?: ExcalidrawTextContainer | null;
|
||||||
|
autoEdit?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
let shouldBindToContainer = false;
|
let shouldBindToContainer = false;
|
||||||
|
|
||||||
@@ -4827,13 +4850,16 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({
|
if (autoEdit || existingTextElement || container) {
|
||||||
editingElement: element,
|
this.handleTextWysiwyg(element, {
|
||||||
});
|
isExistingElement: !!existingTextElement,
|
||||||
|
});
|
||||||
this.handleTextWysiwyg(element, {
|
} else {
|
||||||
isExistingElement: !!existingTextElement,
|
this.setState({
|
||||||
});
|
draggingElement: element,
|
||||||
|
multiElement: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleCanvasDoubleClick = (
|
private handleCanvasDoubleClick = (
|
||||||
@@ -4920,7 +4946,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
x: sceneX,
|
x: sceneX,
|
||||||
y: sceneY,
|
y: sceneY,
|
||||||
element: container,
|
element: container,
|
||||||
shape: this.getElementShape(container),
|
shape: getElementShape(
|
||||||
|
container,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
),
|
||||||
threshold: this.getElementHitThreshold(),
|
threshold: this.getElementHitThreshold(),
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
@@ -5101,8 +5130,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
this.translateCanvas({
|
this.translateCanvas({
|
||||||
zoom: zoomState.zoom,
|
zoom: zoomState.zoom,
|
||||||
scrollX: zoomState.scrollX + deltaX / nextZoom,
|
// 2x multiplier is just a magic number that makes this work correctly
|
||||||
scrollY: zoomState.scrollY + deltaY / nextZoom,
|
// on touchscreen devices (note: if we get report that panning is slower/faster
|
||||||
|
// than actual movement, consider swapping with devicePixelRatio)
|
||||||
|
scrollX: zoomState.scrollX + 2 * (deltaX / nextZoom),
|
||||||
|
scrollY: zoomState.scrollY + 2 * (deltaY / nextZoom),
|
||||||
shouldCacheIgnoreZoom: true,
|
shouldCacheIgnoreZoom: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -5577,7 +5609,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.elementsPendingErasure = new Set(this.elementsPendingErasure);
|
this.elementsPendingErasure = new Set(this.elementsPendingErasure);
|
||||||
this.onSceneUpdated();
|
this.triggerRender();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -5609,7 +5641,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
x: scenePointerX,
|
x: scenePointerX,
|
||||||
y: scenePointerY,
|
y: scenePointerY,
|
||||||
element,
|
element,
|
||||||
shape: this.getElementShape(element),
|
shape: getElementShape(
|
||||||
|
element,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
||||||
@@ -5865,7 +5900,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
if (this.state.activeTool.type === "text") {
|
if (this.state.activeTool.type === "text") {
|
||||||
this.handleTextOnPointerDown(event, pointerDownState);
|
this.handleTextOnPointerDown(event, pointerDownState);
|
||||||
return;
|
|
||||||
} else if (
|
} else if (
|
||||||
this.state.activeTool.type === "arrow" ||
|
this.state.activeTool.type === "arrow" ||
|
||||||
this.state.activeTool.type === "line"
|
this.state.activeTool.type === "line"
|
||||||
@@ -5986,6 +6020,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
);
|
);
|
||||||
const clicklength =
|
const clicklength =
|
||||||
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
|
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
|
||||||
|
|
||||||
if (this.device.editor.isMobile && clicklength < 300) {
|
if (this.device.editor.isMobile && clicklength < 300) {
|
||||||
const hitElement = this.getElementAtPosition(
|
const hitElement = this.getElementAtPosition(
|
||||||
scenePointer.x,
|
scenePointer.x,
|
||||||
@@ -6659,6 +6694,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
sceneY,
|
sceneY,
|
||||||
insertAtParentCenter: !event.altKey,
|
insertAtParentCenter: !event.altKey,
|
||||||
container,
|
container,
|
||||||
|
autoEdit: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
resetCursor(this.interactiveCanvas);
|
resetCursor(this.interactiveCanvas);
|
||||||
@@ -6727,7 +6763,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
const boundElement = getHoveredElementForBinding(
|
const boundElement = getHoveredElementForBinding(
|
||||||
pointerDownState.origin,
|
pointerDownState.origin,
|
||||||
this,
|
this.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
this.scene.insertElement(element);
|
this.scene.insertElement(element);
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -6989,7 +7025,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
const boundElement = getHoveredElementForBinding(
|
const boundElement = getHoveredElementForBinding(
|
||||||
pointerDownState.origin,
|
pointerDownState.origin,
|
||||||
this,
|
this.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.scene.insertElement(element);
|
this.scene.insertElement(element);
|
||||||
@@ -7459,7 +7495,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState({
|
this.setState({
|
||||||
suggestedBindings: getSuggestedBindingsForArrows(
|
suggestedBindings: getSuggestedBindingsForArrows(
|
||||||
selectedElements,
|
selectedElements,
|
||||||
this,
|
this.scene.getNonDeletedElementsMap(),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -7980,7 +8016,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
draggingElement,
|
draggingElement,
|
||||||
this.state,
|
this.state,
|
||||||
pointerCoords,
|
pointerCoords,
|
||||||
this,
|
this.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.setState({ suggestedBindings: [], startBoundElement: null });
|
this.setState({ suggestedBindings: [], startBoundElement: null });
|
||||||
@@ -8009,6 +8045,28 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isTextElement(draggingElement)) {
|
||||||
|
const minWidth = getMinTextElementWidth(
|
||||||
|
getFontString({
|
||||||
|
fontSize: draggingElement.fontSize,
|
||||||
|
fontFamily: draggingElement.fontFamily,
|
||||||
|
}),
|
||||||
|
draggingElement.lineHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (draggingElement.width < minWidth) {
|
||||||
|
mutateElement(draggingElement, {
|
||||||
|
autoResize: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resetCursor();
|
||||||
|
|
||||||
|
this.handleTextWysiwyg(draggingElement, {
|
||||||
|
isExistingElement: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
activeTool.type !== "selection" &&
|
activeTool.type !== "selection" &&
|
||||||
draggingElement &&
|
draggingElement &&
|
||||||
@@ -8069,7 +8127,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.scene.informMutation();
|
this.scene.triggerUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8448,7 +8506,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
x: pointerDownState.origin.x,
|
x: pointerDownState.origin.x,
|
||||||
y: pointerDownState.origin.y,
|
y: pointerDownState.origin.y,
|
||||||
element: hitElement,
|
element: hitElement,
|
||||||
shape: this.getElementShape(hitElement),
|
shape: getElementShape(
|
||||||
|
hitElement,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
),
|
||||||
threshold: this.getElementHitThreshold(),
|
threshold: this.getElementHitThreshold(),
|
||||||
frameNameBound: isFrameLikeElement(hitElement)
|
frameNameBound: isFrameLikeElement(hitElement)
|
||||||
? this.frameNameBoundsCache.get(hitElement)
|
? this.frameNameBoundsCache.get(hitElement)
|
||||||
@@ -8516,7 +8577,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
bindOrUnbindLinearElements(
|
bindOrUnbindLinearElements(
|
||||||
linearElements,
|
linearElements,
|
||||||
this,
|
this.scene.getNonDeletedElementsMap(),
|
||||||
isBindingEnabled(this.state),
|
isBindingEnabled(this.state),
|
||||||
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
|
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
|
||||||
);
|
);
|
||||||
@@ -8564,7 +8625,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
private restoreReadyToEraseElements = () => {
|
private restoreReadyToEraseElements = () => {
|
||||||
this.elementsPendingErasure = new Set();
|
this.elementsPendingErasure = new Set();
|
||||||
this.onSceneUpdated();
|
this.triggerRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
private eraseElements = () => {
|
private eraseElements = () => {
|
||||||
@@ -8978,7 +9039,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
files,
|
files,
|
||||||
);
|
);
|
||||||
if (updatedFiles.size) {
|
if (updatedFiles.size) {
|
||||||
this.scene.informMutation();
|
this.scene.triggerUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -9004,7 +9065,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}): void => {
|
}): void => {
|
||||||
const hoveredBindableElement = getHoveredElementForBinding(
|
const hoveredBindableElement = getHoveredElementForBinding(
|
||||||
pointerCoords,
|
pointerCoords,
|
||||||
this,
|
this.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
this.setState({
|
this.setState({
|
||||||
suggestedBindings:
|
suggestedBindings:
|
||||||
@@ -9031,7 +9092,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
|
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
|
||||||
const hoveredBindableElement = getHoveredElementForBinding(
|
const hoveredBindableElement = getHoveredElementForBinding(
|
||||||
coords,
|
coords,
|
||||||
this,
|
this.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
hoveredBindableElement != null &&
|
hoveredBindableElement != null &&
|
||||||
@@ -9376,6 +9437,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
distance(pointerDownState.origin.y, pointerCoords.y),
|
distance(pointerDownState.origin.y, pointerCoords.y),
|
||||||
shouldMaintainAspectRatio(event),
|
shouldMaintainAspectRatio(event),
|
||||||
shouldResizeFromCenter(event),
|
shouldResizeFromCenter(event),
|
||||||
|
this.state.zoom.value,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
let [gridX, gridY] = getGridPoint(
|
let [gridX, gridY] = getGridPoint(
|
||||||
@@ -9433,6 +9495,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
? !shouldMaintainAspectRatio(event)
|
? !shouldMaintainAspectRatio(event)
|
||||||
: shouldMaintainAspectRatio(event),
|
: shouldMaintainAspectRatio(event),
|
||||||
shouldResizeFromCenter(event),
|
shouldResizeFromCenter(event),
|
||||||
|
this.state.zoom.value,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
this.state.originSnapOffset,
|
this.state.originSnapOffset,
|
||||||
);
|
);
|
||||||
@@ -9561,7 +9624,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
) {
|
) {
|
||||||
const suggestedBindings = getSuggestedBindingsForArrows(
|
const suggestedBindings = getSuggestedBindingsForArrows(
|
||||||
selectedElements,
|
selectedElements,
|
||||||
this,
|
this.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const elementsToHighlight = new Set<ExcalidrawElement>();
|
const elementsToHighlight = new Set<ExcalidrawElement>();
|
||||||
@@ -9633,6 +9696,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
CONTEXT_MENU_SEPARATOR,
|
||||||
actionCut,
|
actionCut,
|
||||||
actionCopy,
|
actionCopy,
|
||||||
actionPaste,
|
actionPaste,
|
||||||
@@ -9645,6 +9709,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
actionPasteStyles,
|
actionPasteStyles,
|
||||||
CONTEXT_MENU_SEPARATOR,
|
CONTEXT_MENU_SEPARATOR,
|
||||||
actionGroup,
|
actionGroup,
|
||||||
|
actionTextAutoResize,
|
||||||
actionUnbindText,
|
actionUnbindText,
|
||||||
actionBindText,
|
actionBindText,
|
||||||
actionWrapTextInContainer,
|
actionWrapTextInContainer,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const ButtonIconSelect = <T extends Object>(
|
|||||||
{props.options.map((option) =>
|
{props.options.map((option) =>
|
||||||
props.type === "button" ? (
|
props.type === "button" ? (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={option.text}
|
key={option.text}
|
||||||
onClick={(event) => props.onClick(option.value, event)}
|
onClick={(event) => props.onClick(option.value, event)}
|
||||||
className={clsx({
|
className={clsx({
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ export const CheckboxItem: React.FC<{
|
|||||||
).focus();
|
).focus();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="Checkbox-box"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
>
|
||||||
{checkIcon}
|
{checkIcon}
|
||||||
</button>
|
</button>
|
||||||
<div className="Checkbox-label">{children}</div>
|
<div className="Checkbox-label">{children}</div>
|
||||||
|
|||||||
@@ -540,7 +540,7 @@ function CommandPaletteInner({
|
|||||||
...command,
|
...command,
|
||||||
icon: command.icon || boltIcon,
|
icon: command.icon || boltIcon,
|
||||||
order: command.order ?? getCategoryOrder(command.category),
|
order: command.order ?? getCategoryOrder(command.category),
|
||||||
haystack: `${deburr(command.label)} ${
|
haystack: `${deburr(command.label.toLocaleLowerCase())} ${
|
||||||
command.keywords?.join(" ") || ""
|
command.keywords?.join(" ") || ""
|
||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
@@ -777,7 +777,9 @@ function CommandPaletteInner({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _query = deburr(commandSearch.replace(/[<>-_| ]/g, ""));
|
const _query = deburr(
|
||||||
|
commandSearch.toLocaleLowerCase().replace(/[<>_| -]/g, ""),
|
||||||
|
);
|
||||||
matchingCommands = fuzzy
|
matchingCommands = fuzzy
|
||||||
.filter(_query, matchingCommands, {
|
.filter(_query, matchingCommands, {
|
||||||
extract: (command) => command.haystack,
|
extract: (command) => command.haystack,
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export const ContextMenu = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={clsx("context-menu-item", {
|
className={clsx("context-menu-item", {
|
||||||
dangerous: actionName === "deleteSelectedElements",
|
dangerous: actionName === "deleteSelectedElements",
|
||||||
checkmark: item.checked?.(appState),
|
checkmark: item.checked?.(appState),
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title={t("buttons.close")}
|
title={t("buttons.close")}
|
||||||
aria-label={t("buttons.close")}
|
aria-label={t("buttons.close")}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
{CloseIcon}
|
{CloseIcon}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ const FollowMode = ({
|
|||||||
{userToFollow.username}
|
{userToFollow.username}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onDisconnect} className="follow-mode__disconnect-btn">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDisconnect}
|
||||||
|
className="follow-mode__disconnect-btn"
|
||||||
|
>
|
||||||
{CloseIcon}
|
{CloseIcon}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
shortcuts={[getShortcutKey("Alt+Shift+D")]}
|
shortcuts={[getShortcutKey("Alt+Shift+D")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("stats.title")}
|
label={t("stats.fullTitle")}
|
||||||
shortcuts={[getShortcutKey("Alt+/")]}
|
shortcuts={[getShortcutKey("Alt+/")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ function Picker<T>({
|
|||||||
<div className="picker-content" ref={rGallery}>
|
<div className="picker-content" ref={rGallery}>
|
||||||
{options.map((option, i) => (
|
{options.map((option, i) => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={clsx("picker-option", {
|
className={clsx("picker-option", {
|
||||||
active: value === option.value,
|
active: value === option.value,
|
||||||
})}
|
})}
|
||||||
@@ -171,6 +172,7 @@ export function IconPicker<T>({
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
name={group}
|
name={group}
|
||||||
|
type="button"
|
||||||
className={isActive ? "active" : ""}
|
className={isActive ? "active" : ""}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onClick={() => setActive(!isActive)}
|
onClick={() => setActive(!isActive)}
|
||||||
|
|||||||
@@ -27,6 +27,99 @@
|
|||||||
& > * {
|
& > * {
|
||||||
pointer-events: var(--ui-pointerEvents);
|
pointer-events: var(--ui-pointerEvents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& > .Stats {
|
||||||
|
width: 204px;
|
||||||
|
position: absolute;
|
||||||
|
top: 60px;
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: var(--zIndex-layerUI);
|
||||||
|
pointer-events: var(--ui-pointerEvents);
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.elementType {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.elementsCount {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsItem {
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
white-space: nowrap;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
th {
|
||||||
|
border-bottom: 1px solid var(--input-border-color);
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td:nth-child(2) {
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--default-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: 12px;
|
||||||
|
right: initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__footer {
|
&__footer {
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ import { JSONExportDialog } from "./JSONExportDialog";
|
|||||||
import { PenModeButton } from "./PenModeButton";
|
import { PenModeButton } from "./PenModeButton";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { useDevice } from "./App";
|
import { useDevice } from "./App";
|
||||||
import { Stats } from "./Stats";
|
|
||||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
|
||||||
import Footer from "./footer/Footer";
|
import Footer from "./footer/Footer";
|
||||||
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||||
import { jotaiScope } from "../jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
@@ -64,6 +62,8 @@ import Scene from "../scene/Scene";
|
|||||||
import { LaserPointerButton } from "./LaserPointerButton";
|
import { LaserPointerButton } from "./LaserPointerButton";
|
||||||
import { MagicSettings } from "./MagicSettings";
|
import { MagicSettings } from "./MagicSettings";
|
||||||
import { TTDDialog } from "./TTDDialog/TTDDialog";
|
import { TTDDialog } from "./TTDDialog/TTDDialog";
|
||||||
|
import { Stats } from "./Stats";
|
||||||
|
import { actionToggleStats } from "../actions";
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
@@ -241,6 +241,11 @@ const LayerUI = ({
|
|||||||
elements,
|
elements,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const shouldShowStats =
|
||||||
|
appState.stats.open &&
|
||||||
|
!appState.zenModeEnabled &&
|
||||||
|
!appState.viewModeEnabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FixedSideContainer side="top">
|
<FixedSideContainer side="top">
|
||||||
<div className="App-menu App-menu_top">
|
<div className="App-menu App-menu_top">
|
||||||
@@ -353,6 +358,15 @@ const LayerUI = ({
|
|||||||
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
|
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
|
||||||
<tunnels.DefaultSidebarTriggerTunnel.Out />
|
<tunnels.DefaultSidebarTriggerTunnel.Out />
|
||||||
)}
|
)}
|
||||||
|
{shouldShowStats && (
|
||||||
|
<Stats
|
||||||
|
scene={app.scene}
|
||||||
|
onClose={() => {
|
||||||
|
actionManager.executeAction(actionToggleStats);
|
||||||
|
}}
|
||||||
|
renderCustomStats={renderCustomStats}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FixedSideContainer>
|
</FixedSideContainer>
|
||||||
@@ -444,7 +458,7 @@ const LayerUI = ({
|
|||||||
);
|
);
|
||||||
ShapeCache.delete(element);
|
ShapeCache.delete(element);
|
||||||
}
|
}
|
||||||
Scene.getScene(selectedElements[0])?.informMutation();
|
Scene.getScene(selectedElements[0])?.triggerUpdate();
|
||||||
} else if (colorPickerType === "elementBackground") {
|
} else if (colorPickerType === "elementBackground") {
|
||||||
setAppState({
|
setAppState({
|
||||||
currentItemBackgroundColor: color,
|
currentItemBackgroundColor: color,
|
||||||
@@ -542,19 +556,9 @@ const LayerUI = ({
|
|||||||
showExitZenModeBtn={showExitZenModeBtn}
|
showExitZenModeBtn={showExitZenModeBtn}
|
||||||
renderWelcomeScreen={renderWelcomeScreen}
|
renderWelcomeScreen={renderWelcomeScreen}
|
||||||
/>
|
/>
|
||||||
{appState.showStats && (
|
|
||||||
<Stats
|
|
||||||
appState={appState}
|
|
||||||
setAppState={setAppState}
|
|
||||||
elements={elements}
|
|
||||||
onClose={() => {
|
|
||||||
actionManager.executeAction(actionToggleStats);
|
|
||||||
}}
|
|
||||||
renderCustomStats={renderCustomStats}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{appState.scrolledOutside && (
|
{appState.scrolledOutside && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="scroll-back-to-content"
|
className="scroll-back-to-content"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAppState((appState) => ({
|
setAppState((appState) => ({
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ import { Section } from "./Section";
|
|||||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||||
import { LockButton } from "./LockButton";
|
import { LockButton } from "./LockButton";
|
||||||
import { PenModeButton } from "./PenModeButton";
|
import { PenModeButton } from "./PenModeButton";
|
||||||
import { Stats } from "./Stats";
|
|
||||||
import { actionToggleStats } from "../actions";
|
|
||||||
import { HandButton } from "./HandButton";
|
import { HandButton } from "./HandButton";
|
||||||
import { isHandToolActive } from "../appState";
|
import { isHandToolActive } from "../appState";
|
||||||
import { useTunnels } from "../context/tunnels";
|
import { useTunnels } from "../context/tunnels";
|
||||||
@@ -157,17 +155,6 @@ export const MobileMenu = ({
|
|||||||
<>
|
<>
|
||||||
{renderSidebars()}
|
{renderSidebars()}
|
||||||
{!appState.viewModeEnabled && renderToolbar()}
|
{!appState.viewModeEnabled && renderToolbar()}
|
||||||
{!appState.openMenu && appState.showStats && (
|
|
||||||
<Stats
|
|
||||||
appState={appState}
|
|
||||||
setAppState={setAppState}
|
|
||||||
elements={elements}
|
|
||||||
onClose={() => {
|
|
||||||
actionManager.executeAction(actionToggleStats);
|
|
||||||
}}
|
|
||||||
renderCustomStats={renderCustomStats}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
className="App-bottom-bar"
|
className="App-bottom-bar"
|
||||||
style={{
|
style={{
|
||||||
@@ -194,6 +181,7 @@ export const MobileMenu = ({
|
|||||||
!appState.openMenu &&
|
!appState.openMenu &&
|
||||||
!appState.openSidebar && (
|
!appState.openSidebar && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="scroll-back-to-content"
|
className="scroll-back-to-content"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAppState((appState) => ({
|
setAppState((appState) => ({
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const ChartPreviewBtn = (props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="ChartPreview"
|
className="ChartPreview"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (chartElements) {
|
if (chartElements) {
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
@import "../css/variables.module.scss";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.Stats {
|
|
||||||
position: absolute;
|
|
||||||
top: 64px;
|
|
||||||
right: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
z-index: 10;
|
|
||||||
pointer-events: var(--ui-pointerEvents);
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0 24px 8px 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close {
|
|
||||||
float: right;
|
|
||||||
height: 16px;
|
|
||||||
width: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
th {
|
|
||||||
border-bottom: 1px solid var(--input-border-color);
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
tr {
|
|
||||||
td:nth-child(2) {
|
|
||||||
min-width: 24px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: 12px;
|
|
||||||
right: initial;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0 0 8px 24px;
|
|
||||||
}
|
|
||||||
.close {
|
|
||||||
float: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { getCommonBounds } from "../element/bounds";
|
|
||||||
import type { NonDeletedExcalidrawElement } from "../element/types";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { getTargetElements } from "../scene";
|
|
||||||
import type { ExcalidrawProps, UIAppState } from "../types";
|
|
||||||
import { CloseIcon } from "./icons";
|
|
||||||
import { Island } from "./Island";
|
|
||||||
import "./Stats.scss";
|
|
||||||
|
|
||||||
export const Stats = (props: {
|
|
||||||
appState: UIAppState;
|
|
||||||
setAppState: React.Component<any, UIAppState>["setState"];
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
|
||||||
onClose: () => void;
|
|
||||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
|
||||||
}) => {
|
|
||||||
const boundingBox = getCommonBounds(props.elements);
|
|
||||||
const selectedElements = getTargetElements(props.elements, props.appState);
|
|
||||||
const selectedBoundingBox = getCommonBounds(selectedElements);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="Stats">
|
|
||||||
<Island padding={2}>
|
|
||||||
<div className="close" onClick={props.onClose}>
|
|
||||||
{CloseIcon}
|
|
||||||
</div>
|
|
||||||
<h3>{t("stats.title")}</h3>
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<th colSpan={2}>{t("stats.scene")}</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{t("stats.elements")}</td>
|
|
||||||
<td>{props.elements.length}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{t("stats.width")}</td>
|
|
||||||
<td>{Math.round(boundingBox[2]) - Math.round(boundingBox[0])}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{t("stats.height")}</td>
|
|
||||||
<td>{Math.round(boundingBox[3]) - Math.round(boundingBox[1])}</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{selectedElements.length === 1 && (
|
|
||||||
<tr>
|
|
||||||
<th colSpan={2}>{t("stats.element")}</th>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedElements.length > 1 && (
|
|
||||||
<>
|
|
||||||
<tr>
|
|
||||||
<th colSpan={2}>{t("stats.selected")}</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{t("stats.elements")}</td>
|
|
||||||
<td>{selectedElements.length}</td>
|
|
||||||
</tr>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{selectedElements.length > 0 && (
|
|
||||||
<>
|
|
||||||
<tr>
|
|
||||||
<td>{"x"}</td>
|
|
||||||
<td>{Math.round(selectedBoundingBox[0])}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{"y"}</td>
|
|
||||||
<td>{Math.round(selectedBoundingBox[1])}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{t("stats.width")}</td>
|
|
||||||
<td>
|
|
||||||
{Math.round(
|
|
||||||
selectedBoundingBox[2] - selectedBoundingBox[0],
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{t("stats.height")}</td>
|
|
||||||
<td>
|
|
||||||
{Math.round(
|
|
||||||
selectedBoundingBox[3] - selectedBoundingBox[1],
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{selectedElements.length === 1 && (
|
|
||||||
<tr>
|
|
||||||
<td>{t("stats.angle")}</td>
|
|
||||||
<td>
|
|
||||||
{`${Math.round(
|
|
||||||
(selectedElements[0].angle * 180) / Math.PI,
|
|
||||||
)}°`}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{props.renderCustomStats?.(props.elements, props.appState)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</Island>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
|
import { getBoundTextElement } from "../../element/textElement";
|
||||||
|
import { isArrowElement } from "../../element/typeChecks";
|
||||||
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
|
import { degreeToRadian, radianToDegree } from "../../math";
|
||||||
|
import { angleIcon } from "../icons";
|
||||||
|
import DragInput from "./DragInput";
|
||||||
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
|
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
|
||||||
|
interface AngleProps {
|
||||||
|
element: ExcalidrawElement;
|
||||||
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
|
property: "angle";
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_SIZE = 15;
|
||||||
|
|
||||||
|
const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
|
||||||
|
accumulatedChange,
|
||||||
|
originalElements,
|
||||||
|
shouldChangeByStepSize,
|
||||||
|
nextValue,
|
||||||
|
scene,
|
||||||
|
}) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
const origElement = originalElements[0];
|
||||||
|
if (origElement) {
|
||||||
|
const latestElement = elementsMap.get(origElement.id);
|
||||||
|
if (!latestElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextValue !== undefined) {
|
||||||
|
const nextAngle = degreeToRadian(nextValue);
|
||||||
|
mutateElement(latestElement, {
|
||||||
|
angle: nextAngle,
|
||||||
|
});
|
||||||
|
updateBindings(latestElement, elementsMap);
|
||||||
|
|
||||||
|
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||||
|
if (boundTextElement && !isArrowElement(latestElement)) {
|
||||||
|
mutateElement(boundTextElement, { angle: nextAngle });
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalAngleInDegrees =
|
||||||
|
Math.round(radianToDegree(origElement.angle) * 100) / 100;
|
||||||
|
const changeInDegrees = Math.round(accumulatedChange);
|
||||||
|
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextAngleInDegrees =
|
||||||
|
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
||||||
|
|
||||||
|
const nextAngle = degreeToRadian(nextAngleInDegrees);
|
||||||
|
|
||||||
|
mutateElement(latestElement, {
|
||||||
|
angle: nextAngle,
|
||||||
|
});
|
||||||
|
updateBindings(latestElement, elementsMap);
|
||||||
|
|
||||||
|
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||||
|
if (boundTextElement && !isArrowElement(latestElement)) {
|
||||||
|
mutateElement(boundTextElement, { angle: nextAngle });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Angle = ({ element, scene, appState, property }: AngleProps) => {
|
||||||
|
return (
|
||||||
|
<DragInput
|
||||||
|
label="A"
|
||||||
|
icon={angleIcon}
|
||||||
|
value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100}
|
||||||
|
elements={[element]}
|
||||||
|
dragInputCallback={handleDegreeChange}
|
||||||
|
editable={isPropertyEditable(element, "angle")}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
property={property}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Angle;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { InlineIcon } from "../InlineIcon";
|
||||||
|
import { collapseDownIcon, collapseUpIcon } from "../icons";
|
||||||
|
|
||||||
|
interface CollapsibleProps {
|
||||||
|
label: React.ReactNode;
|
||||||
|
// having it controlled so that the state is managed outside
|
||||||
|
// this is to keep the user's previous choice even when the
|
||||||
|
// Collapsible is unmounted
|
||||||
|
open: boolean;
|
||||||
|
openTrigger: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Collapsible = ({
|
||||||
|
label,
|
||||||
|
open,
|
||||||
|
openTrigger,
|
||||||
|
children,
|
||||||
|
}: CollapsibleProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
onClick={openTrigger}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
|
||||||
|
</div>
|
||||||
|
{open && <>{children}</>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Collapsible;
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
|
import DragInput from "./DragInput";
|
||||||
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
|
import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
|
||||||
|
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
|
||||||
|
interface DimensionDragInputProps {
|
||||||
|
property: "width" | "height";
|
||||||
|
element: ExcalidrawElement;
|
||||||
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_SIZE = 10;
|
||||||
|
const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
|
||||||
|
return element.type === "image";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDimensionChange: DragInputCallbackType<
|
||||||
|
DimensionDragInputProps["property"]
|
||||||
|
> = ({
|
||||||
|
accumulatedChange,
|
||||||
|
originalElements,
|
||||||
|
originalElementsMap,
|
||||||
|
shouldKeepAspectRatio,
|
||||||
|
shouldChangeByStepSize,
|
||||||
|
nextValue,
|
||||||
|
property,
|
||||||
|
scene,
|
||||||
|
}) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
const origElement = originalElements[0];
|
||||||
|
if (origElement) {
|
||||||
|
const keepAspectRatio =
|
||||||
|
shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
|
||||||
|
const aspectRatio = origElement.width / origElement.height;
|
||||||
|
|
||||||
|
if (nextValue !== undefined) {
|
||||||
|
const nextWidth = Math.max(
|
||||||
|
property === "width"
|
||||||
|
? nextValue
|
||||||
|
: keepAspectRatio
|
||||||
|
? nextValue * aspectRatio
|
||||||
|
: origElement.width,
|
||||||
|
MIN_WIDTH_OR_HEIGHT,
|
||||||
|
);
|
||||||
|
const nextHeight = Math.max(
|
||||||
|
property === "height"
|
||||||
|
? nextValue
|
||||||
|
: keepAspectRatio
|
||||||
|
? nextValue / aspectRatio
|
||||||
|
: origElement.height,
|
||||||
|
MIN_WIDTH_OR_HEIGHT,
|
||||||
|
);
|
||||||
|
|
||||||
|
resizeElement(
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
keepAspectRatio,
|
||||||
|
origElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||||
|
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||||
|
|
||||||
|
let nextWidth = Math.max(0, origElement.width + changeInWidth);
|
||||||
|
if (property === "width") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextWidth = Math.round(nextWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextHeight = Math.max(0, origElement.height + changeInHeight);
|
||||||
|
if (property === "height") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextHeight = Math.round(nextHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keepAspectRatio) {
|
||||||
|
if (property === "width") {
|
||||||
|
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
|
||||||
|
} else {
|
||||||
|
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||||
|
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||||
|
|
||||||
|
resizeElement(
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
keepAspectRatio,
|
||||||
|
origElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const DimensionDragInput = ({
|
||||||
|
property,
|
||||||
|
element,
|
||||||
|
scene,
|
||||||
|
appState,
|
||||||
|
}: DimensionDragInputProps) => {
|
||||||
|
const value =
|
||||||
|
Math.round((property === "width" ? element.width : element.height) * 100) /
|
||||||
|
100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragInput
|
||||||
|
label={property === "width" ? "W" : "H"}
|
||||||
|
elements={[element]}
|
||||||
|
dragInputCallback={handleDimensionChange}
|
||||||
|
value={value}
|
||||||
|
editable={isPropertyEditable(element, property)}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
property={property}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DimensionDragInput;
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
.excalidraw {
|
||||||
|
.drag-input-container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
box-shadow: 0 0 0 1px var(--color-primary-darkest);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-input-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--default-border-color);
|
||||||
|
border-right: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: var(--popup-text-color);
|
||||||
|
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
|
||||||
|
border-right: 1px solid var(--default-border-color);
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--text-primary-color);
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
height: 2rem;
|
||||||
|
border: 1px solid var(--default-border-color);
|
||||||
|
border-left: 0;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
|
||||||
|
border-left: 1px solid var(--default-border-color);
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
padding: 0.5rem;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
appearance: none;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { EVENT } from "../../constants";
|
||||||
|
import { KEYS } from "../../keys";
|
||||||
|
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
||||||
|
import { deepCopyElement } from "../../element/newElement";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useApp } from "../App";
|
||||||
|
import { InlineIcon } from "../InlineIcon";
|
||||||
|
import type { StatsInputProperty } from "./utils";
|
||||||
|
import { SMALLEST_DELTA } from "./utils";
|
||||||
|
import { StoreAction } from "../../store";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
|
||||||
|
import "./DragInput.scss";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
import { cloneJSON } from "../../utils";
|
||||||
|
|
||||||
|
export type DragInputCallbackType<
|
||||||
|
P extends StatsInputProperty,
|
||||||
|
E = ExcalidrawElement,
|
||||||
|
> = (props: {
|
||||||
|
accumulatedChange: number;
|
||||||
|
instantChange: number;
|
||||||
|
originalElements: readonly E[];
|
||||||
|
originalElementsMap: ElementsMap;
|
||||||
|
shouldKeepAspectRatio: boolean;
|
||||||
|
shouldChangeByStepSize: boolean;
|
||||||
|
nextValue?: number;
|
||||||
|
property: P;
|
||||||
|
scene: Scene;
|
||||||
|
originalAppState: AppState;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
interface StatsDragInputProps<
|
||||||
|
T extends StatsInputProperty,
|
||||||
|
E = ExcalidrawElement,
|
||||||
|
> {
|
||||||
|
label: string | React.ReactNode;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
value: number | "Mixed";
|
||||||
|
elements: readonly E[];
|
||||||
|
editable?: boolean;
|
||||||
|
shouldKeepAspectRatio?: boolean;
|
||||||
|
dragInputCallback: DragInputCallbackType<T, E>;
|
||||||
|
property: T;
|
||||||
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatsDragInput = <
|
||||||
|
T extends StatsInputProperty,
|
||||||
|
E extends ExcalidrawElement = ExcalidrawElement,
|
||||||
|
>({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
dragInputCallback,
|
||||||
|
value,
|
||||||
|
elements,
|
||||||
|
editable = true,
|
||||||
|
shouldKeepAspectRatio,
|
||||||
|
property,
|
||||||
|
scene,
|
||||||
|
appState,
|
||||||
|
}: StatsDragInputProps<T, E>) => {
|
||||||
|
const app = useApp();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const labelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [inputValue, setInputValue] = useState(value.toString());
|
||||||
|
|
||||||
|
const stateRef = useRef<{
|
||||||
|
originalAppState: AppState;
|
||||||
|
originalElements: readonly E[];
|
||||||
|
lastUpdatedValue: string;
|
||||||
|
updatePending: boolean;
|
||||||
|
}>(null!);
|
||||||
|
if (!stateRef.current) {
|
||||||
|
stateRef.current = {
|
||||||
|
originalAppState: cloneJSON(appState),
|
||||||
|
originalElements: elements,
|
||||||
|
lastUpdatedValue: inputValue,
|
||||||
|
updatePending: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const inputValue = value.toString();
|
||||||
|
setInputValue(inputValue);
|
||||||
|
stateRef.current.lastUpdatedValue = inputValue;
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleInputValue = (
|
||||||
|
updatedValue: string,
|
||||||
|
elements: readonly E[],
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
if (!stateRef.current.updatePending) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
stateRef.current.updatePending = false;
|
||||||
|
|
||||||
|
const parsed = Number(updatedValue);
|
||||||
|
if (isNaN(parsed)) {
|
||||||
|
setInputValue(value.toString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rounded = Number(parsed.toFixed(2));
|
||||||
|
const original = Number(value);
|
||||||
|
|
||||||
|
// only update when
|
||||||
|
// 1. original was "Mixed" and we have a new value
|
||||||
|
// 2. original was not "Mixed" and the difference between a new value and previous value is greater
|
||||||
|
// than the smallest delta allowed, which is 0.01
|
||||||
|
// reason: idempotent to avoid unnecessary
|
||||||
|
if (isNaN(original) || Math.abs(rounded - original) >= SMALLEST_DELTA) {
|
||||||
|
stateRef.current.lastUpdatedValue = updatedValue;
|
||||||
|
dragInputCallback({
|
||||||
|
accumulatedChange: 0,
|
||||||
|
instantChange: 0,
|
||||||
|
originalElements: elements,
|
||||||
|
originalElementsMap: app.scene.getNonDeletedElementsMap(),
|
||||||
|
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
||||||
|
shouldChangeByStepSize: false,
|
||||||
|
nextValue: rounded,
|
||||||
|
property,
|
||||||
|
scene,
|
||||||
|
originalAppState: appState,
|
||||||
|
});
|
||||||
|
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputValueRef = useRef(handleInputValue);
|
||||||
|
handleInputValueRef.current = handleInputValue;
|
||||||
|
|
||||||
|
// make sure that clicking on canvas (which umounts the component)
|
||||||
|
// updates current input value (blur isn't triggered)
|
||||||
|
useEffect(() => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
return () => {
|
||||||
|
const nextValue = input?.value;
|
||||||
|
if (nextValue) {
|
||||||
|
handleInputValueRef.current(
|
||||||
|
nextValue,
|
||||||
|
stateRef.current.originalElements,
|
||||||
|
stateRef.current.originalAppState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
// we need to track change of `editable` state as mount/unmount
|
||||||
|
// because react doesn't trigger `blur` when a an input is blurred due
|
||||||
|
// to being disabled (https://github.com/facebook/react/issues/9142).
|
||||||
|
// As such, if we keep rendering disabled inputs, then change in selection
|
||||||
|
// to an element that has a given property as non-editable would not trigger
|
||||||
|
// blur/unmount and wouldn't update the value.
|
||||||
|
editable,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!editable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx("drag-input-container", !editable && "disabled")}
|
||||||
|
data-testid={label}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="drag-input-label"
|
||||||
|
ref={labelRef}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (inputRef.current && editable) {
|
||||||
|
let startValue = Number(inputRef.current.value);
|
||||||
|
if (isNaN(startValue)) {
|
||||||
|
startValue = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastPointer: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
let originalElementsMap: Map<string, ExcalidrawElement> | null =
|
||||||
|
app.scene
|
||||||
|
.getNonDeletedElements()
|
||||||
|
.reduce((acc: ElementsMap, element) => {
|
||||||
|
acc.set(element.id, deepCopyElement(element));
|
||||||
|
return acc;
|
||||||
|
}, new Map());
|
||||||
|
|
||||||
|
let originalElements: readonly E[] | null = elements.map(
|
||||||
|
(element) => originalElementsMap!.get(element.id) as E,
|
||||||
|
);
|
||||||
|
|
||||||
|
const originalAppState: AppState = cloneJSON(appState);
|
||||||
|
|
||||||
|
let accumulatedChange: number | null = null;
|
||||||
|
|
||||||
|
document.body.classList.add("excalidraw-cursor-resize");
|
||||||
|
|
||||||
|
const onPointerMove = (event: PointerEvent) => {
|
||||||
|
if (!accumulatedChange) {
|
||||||
|
accumulatedChange = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastPointer &&
|
||||||
|
originalElementsMap !== null &&
|
||||||
|
originalElements !== null &&
|
||||||
|
accumulatedChange !== null
|
||||||
|
) {
|
||||||
|
const instantChange = event.clientX - lastPointer.x;
|
||||||
|
accumulatedChange += instantChange;
|
||||||
|
|
||||||
|
dragInputCallback({
|
||||||
|
accumulatedChange,
|
||||||
|
instantChange,
|
||||||
|
originalElements,
|
||||||
|
originalElementsMap,
|
||||||
|
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
||||||
|
shouldChangeByStepSize: event.shiftKey,
|
||||||
|
property,
|
||||||
|
scene,
|
||||||
|
originalAppState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPointer = {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
|
||||||
|
window.addEventListener(
|
||||||
|
EVENT.POINTER_UP,
|
||||||
|
() => {
|
||||||
|
window.removeEventListener(
|
||||||
|
EVENT.POINTER_MOVE,
|
||||||
|
onPointerMove,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
||||||
|
|
||||||
|
lastPointer = null;
|
||||||
|
accumulatedChange = null;
|
||||||
|
originalElements = null;
|
||||||
|
originalElementsMap = null;
|
||||||
|
|
||||||
|
document.body.classList.remove("excalidraw-cursor-resize");
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerEnter={() => {
|
||||||
|
if (labelRef.current) {
|
||||||
|
labelRef.current.style.cursor = "ew-resize";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon ? <InlineIcon icon={icon} /> : label}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="drag-input"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck="false"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (editable) {
|
||||||
|
const eventTarget = event.target;
|
||||||
|
if (
|
||||||
|
eventTarget instanceof HTMLInputElement &&
|
||||||
|
event.key === KEYS.ENTER
|
||||||
|
) {
|
||||||
|
handleInputValue(eventTarget.value, elements, appState);
|
||||||
|
app.focusContainer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(event) => {
|
||||||
|
stateRef.current.updatePending = true;
|
||||||
|
setInputValue(event.target.value);
|
||||||
|
}}
|
||||||
|
onFocus={(event) => {
|
||||||
|
event.target.select();
|
||||||
|
stateRef.current.originalElements = elements;
|
||||||
|
stateRef.current.originalAppState = cloneJSON(appState);
|
||||||
|
}}
|
||||||
|
onBlur={(event) => {
|
||||||
|
if (!inputValue) {
|
||||||
|
setInputValue(value.toString());
|
||||||
|
} else if (editable) {
|
||||||
|
handleInputValue(
|
||||||
|
event.target.value,
|
||||||
|
stateRef.current.originalElements,
|
||||||
|
stateRef.current.originalAppState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!editable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatsDragInput;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
} from "../../element/types";
|
||||||
|
import StatsDragInput from "./DragInput";
|
||||||
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
|
import { getStepSizedValue } from "./utils";
|
||||||
|
import { fontSizeIcon } from "../icons";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
import { isTextElement, redrawTextBoundingBox } from "../../element";
|
||||||
|
import { hasBoundTextElement } from "../../element/typeChecks";
|
||||||
|
import { getBoundTextElement } from "../../element/textElement";
|
||||||
|
|
||||||
|
interface FontSizeProps {
|
||||||
|
element: ExcalidrawElement;
|
||||||
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
|
property: "fontSize";
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_FONT_SIZE = 4;
|
||||||
|
const STEP_SIZE = 4;
|
||||||
|
|
||||||
|
const handleFontSizeChange: DragInputCallbackType<
|
||||||
|
FontSizeProps["property"],
|
||||||
|
ExcalidrawTextElement
|
||||||
|
> = ({
|
||||||
|
accumulatedChange,
|
||||||
|
originalElements,
|
||||||
|
shouldChangeByStepSize,
|
||||||
|
nextValue,
|
||||||
|
scene,
|
||||||
|
}) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
|
const origElement = originalElements[0];
|
||||||
|
if (origElement) {
|
||||||
|
const latestElement = elementsMap.get(origElement.id);
|
||||||
|
if (!latestElement || !isTextElement(latestElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextFontSize;
|
||||||
|
|
||||||
|
if (nextValue !== undefined) {
|
||||||
|
nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
|
||||||
|
} else if (origElement.type === "text") {
|
||||||
|
const originalFontSize = Math.round(origElement.fontSize);
|
||||||
|
const changeInFontSize = Math.round(accumulatedChange);
|
||||||
|
nextFontSize = Math.max(
|
||||||
|
originalFontSize + changeInFontSize,
|
||||||
|
MIN_FONT_SIZE,
|
||||||
|
);
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextFontSize) {
|
||||||
|
mutateElement(latestElement, {
|
||||||
|
fontSize: nextFontSize,
|
||||||
|
});
|
||||||
|
redrawTextBoundingBox(
|
||||||
|
latestElement,
|
||||||
|
scene.getContainerElement(latestElement),
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const FontSize = ({ element, scene, appState, property }: FontSizeProps) => {
|
||||||
|
const _element = isTextElement(element)
|
||||||
|
? element
|
||||||
|
: hasBoundTextElement(element)
|
||||||
|
? getBoundTextElement(element, scene.getNonDeletedElementsMap())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!_element) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatsDragInput
|
||||||
|
label="F"
|
||||||
|
value={Math.round(_element.fontSize * 10) / 10}
|
||||||
|
elements={[_element]}
|
||||||
|
dragInputCallback={handleFontSizeChange}
|
||||||
|
icon={fontSizeIcon}
|
||||||
|
appState={appState}
|
||||||
|
scene={scene}
|
||||||
|
property={property}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FontSize;
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
|
import { getBoundTextElement } from "../../element/textElement";
|
||||||
|
import { isArrowElement } from "../../element/typeChecks";
|
||||||
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
|
import { isInGroup } from "../../groups";
|
||||||
|
import { degreeToRadian, radianToDegree } from "../../math";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
import { angleIcon } from "../icons";
|
||||||
|
import DragInput from "./DragInput";
|
||||||
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
|
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
|
||||||
|
interface MultiAngleProps {
|
||||||
|
elements: readonly ExcalidrawElement[];
|
||||||
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
|
property: "angle";
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_SIZE = 15;
|
||||||
|
|
||||||
|
const handleDegreeChange: DragInputCallbackType<
|
||||||
|
MultiAngleProps["property"]
|
||||||
|
> = ({
|
||||||
|
accumulatedChange,
|
||||||
|
originalElements,
|
||||||
|
shouldChangeByStepSize,
|
||||||
|
nextValue,
|
||||||
|
property,
|
||||||
|
scene,
|
||||||
|
}) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
const editableLatestIndividualElements = originalElements
|
||||||
|
.map((el) => elementsMap.get(el.id))
|
||||||
|
.filter((el) => el && !isInGroup(el) && isPropertyEditable(el, property));
|
||||||
|
const editableOriginalIndividualElements = originalElements.filter(
|
||||||
|
(el) => !isInGroup(el) && isPropertyEditable(el, property),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nextValue !== undefined) {
|
||||||
|
const nextAngle = degreeToRadian(nextValue);
|
||||||
|
|
||||||
|
for (const element of editableLatestIndividualElements) {
|
||||||
|
if (!element) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
mutateElement(
|
||||||
|
element,
|
||||||
|
{
|
||||||
|
angle: nextAngle,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
|
if (boundTextElement && !isArrowElement(element)) {
|
||||||
|
mutateElement(boundTextElement, { angle: nextAngle }, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.triggerUpdate();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < editableLatestIndividualElements.length; i++) {
|
||||||
|
const latestElement = editableLatestIndividualElements[i];
|
||||||
|
if (!latestElement) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const originalElement = editableOriginalIndividualElements[i];
|
||||||
|
const originalAngleInDegrees =
|
||||||
|
Math.round(radianToDegree(originalElement.angle) * 100) / 100;
|
||||||
|
const changeInDegrees = Math.round(accumulatedChange);
|
||||||
|
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextAngleInDegrees =
|
||||||
|
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
||||||
|
|
||||||
|
const nextAngle = degreeToRadian(nextAngleInDegrees);
|
||||||
|
|
||||||
|
mutateElement(
|
||||||
|
latestElement,
|
||||||
|
{
|
||||||
|
angle: nextAngle,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||||
|
if (boundTextElement && !isArrowElement(latestElement)) {
|
||||||
|
mutateElement(boundTextElement, { angle: nextAngle }, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scene.triggerUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const MultiAngle = ({
|
||||||
|
elements,
|
||||||
|
scene,
|
||||||
|
appState,
|
||||||
|
property,
|
||||||
|
}: MultiAngleProps) => {
|
||||||
|
const editableLatestIndividualElements = elements.filter(
|
||||||
|
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
|
||||||
|
);
|
||||||
|
const angles = editableLatestIndividualElements.map(
|
||||||
|
(el) => Math.round((radianToDegree(el.angle) % 360) * 100) / 100,
|
||||||
|
);
|
||||||
|
const value = new Set(angles).size === 1 ? angles[0] : "Mixed";
|
||||||
|
|
||||||
|
const editable = editableLatestIndividualElements.some((el) =>
|
||||||
|
isPropertyEditable(el, "angle"),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragInput
|
||||||
|
label="A"
|
||||||
|
icon={angleIcon}
|
||||||
|
value={value}
|
||||||
|
elements={elements}
|
||||||
|
dragInputCallback={handleDegreeChange}
|
||||||
|
editable={editable}
|
||||||
|
appState={appState}
|
||||||
|
scene={scene}
|
||||||
|
property={property}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MultiAngle;
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { getCommonBounds, isTextElement } from "../../element";
|
||||||
|
import { updateBoundElements } from "../../element/binding";
|
||||||
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
|
import { rescalePointsInElement } from "../../element/resizeElements";
|
||||||
|
import {
|
||||||
|
getBoundTextElement,
|
||||||
|
handleBindTextResize,
|
||||||
|
} from "../../element/textElement";
|
||||||
|
import type {
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawElement,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
|
} from "../../element/types";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
import type { AppState, Point } from "../../types";
|
||||||
|
import DragInput from "./DragInput";
|
||||||
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
|
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||||
|
import { getElementsInAtomicUnit, resizeElement } from "./utils";
|
||||||
|
import type { AtomicUnit } from "./utils";
|
||||||
|
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
||||||
|
|
||||||
|
interface MultiDimensionProps {
|
||||||
|
property: "width" | "height";
|
||||||
|
elements: readonly ExcalidrawElement[];
|
||||||
|
elementsMap: NonDeletedSceneElementsMap;
|
||||||
|
atomicUnits: AtomicUnit[];
|
||||||
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_SIZE = 10;
|
||||||
|
|
||||||
|
const getResizedUpdates = (
|
||||||
|
anchorX: number,
|
||||||
|
anchorY: number,
|
||||||
|
scale: number,
|
||||||
|
origElement: ExcalidrawElement,
|
||||||
|
) => {
|
||||||
|
const offsetX = origElement.x - anchorX;
|
||||||
|
const offsetY = origElement.y - anchorY;
|
||||||
|
const nextWidth = origElement.width * scale;
|
||||||
|
const nextHeight = origElement.height * scale;
|
||||||
|
const x = anchorX + offsetX * scale;
|
||||||
|
const y = anchorY + offsetY * scale;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: nextWidth,
|
||||||
|
height: nextHeight,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
...rescalePointsInElement(origElement, nextWidth, nextHeight, false),
|
||||||
|
...(isTextElement(origElement)
|
||||||
|
? { fontSize: origElement.fontSize * scale }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeElementInGroup = (
|
||||||
|
anchorX: number,
|
||||||
|
anchorY: number,
|
||||||
|
property: MultiDimensionProps["property"],
|
||||||
|
scale: number,
|
||||||
|
latestElement: ExcalidrawElement,
|
||||||
|
origElement: ExcalidrawElement,
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
originalElementsMap: ElementsMap,
|
||||||
|
) => {
|
||||||
|
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
|
||||||
|
|
||||||
|
mutateElement(latestElement, updates, false);
|
||||||
|
const boundTextElement = getBoundTextElement(
|
||||||
|
origElement,
|
||||||
|
originalElementsMap,
|
||||||
|
);
|
||||||
|
if (boundTextElement) {
|
||||||
|
const newFontSize = boundTextElement.fontSize * scale;
|
||||||
|
updateBoundElements(latestElement, elementsMap, {
|
||||||
|
newSize: { width: updates.width, height: updates.height },
|
||||||
|
});
|
||||||
|
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
|
||||||
|
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
|
||||||
|
mutateElement(
|
||||||
|
latestBoundTextElement,
|
||||||
|
{
|
||||||
|
fontSize: newFontSize,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
handleBindTextResize(
|
||||||
|
latestElement,
|
||||||
|
elementsMap,
|
||||||
|
property === "width" ? "e" : "s",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeGroup = (
|
||||||
|
nextWidth: number,
|
||||||
|
nextHeight: number,
|
||||||
|
initialHeight: number,
|
||||||
|
aspectRatio: number,
|
||||||
|
anchor: Point,
|
||||||
|
property: MultiDimensionProps["property"],
|
||||||
|
latestElements: ExcalidrawElement[],
|
||||||
|
originalElements: ExcalidrawElement[],
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
originalElementsMap: ElementsMap,
|
||||||
|
) => {
|
||||||
|
// keep aspect ratio for groups
|
||||||
|
if (property === "width") {
|
||||||
|
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
|
||||||
|
} else {
|
||||||
|
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = nextHeight / initialHeight;
|
||||||
|
|
||||||
|
for (let i = 0; i < originalElements.length; i++) {
|
||||||
|
const origElement = originalElements[i];
|
||||||
|
const latestElement = latestElements[i];
|
||||||
|
|
||||||
|
resizeElementInGroup(
|
||||||
|
anchor[0],
|
||||||
|
anchor[1],
|
||||||
|
property,
|
||||||
|
scale,
|
||||||
|
latestElement,
|
||||||
|
origElement,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDimensionChange: DragInputCallbackType<
|
||||||
|
MultiDimensionProps["property"]
|
||||||
|
> = ({
|
||||||
|
accumulatedChange,
|
||||||
|
originalElements,
|
||||||
|
originalElementsMap,
|
||||||
|
originalAppState,
|
||||||
|
shouldChangeByStepSize,
|
||||||
|
nextValue,
|
||||||
|
scene,
|
||||||
|
property,
|
||||||
|
}) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
|
||||||
|
if (nextValue !== undefined) {
|
||||||
|
for (const atomicUnit of atomicUnits) {
|
||||||
|
const elementsInUnit = getElementsInAtomicUnit(
|
||||||
|
atomicUnit,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (elementsInUnit.length > 1) {
|
||||||
|
const latestElements = elementsInUnit.map((el) => el.latest!);
|
||||||
|
const originalElements = elementsInUnit.map((el) => el.original!);
|
||||||
|
const [x1, y1, x2, y2] = getCommonBounds(originalElements);
|
||||||
|
const initialWidth = x2 - x1;
|
||||||
|
const initialHeight = y2 - y1;
|
||||||
|
const aspectRatio = initialWidth / initialHeight;
|
||||||
|
const nextWidth = Math.max(
|
||||||
|
MIN_WIDTH_OR_HEIGHT,
|
||||||
|
property === "width" ? Math.max(0, nextValue) : initialWidth,
|
||||||
|
);
|
||||||
|
const nextHeight = Math.max(
|
||||||
|
MIN_WIDTH_OR_HEIGHT,
|
||||||
|
property === "height" ? Math.max(0, nextValue) : initialHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
resizeGroup(
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
initialHeight,
|
||||||
|
aspectRatio,
|
||||||
|
[x1, y1],
|
||||||
|
property,
|
||||||
|
latestElements,
|
||||||
|
originalElements,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const [el] = elementsInUnit;
|
||||||
|
const latestElement = el?.latest;
|
||||||
|
const origElement = el?.original;
|
||||||
|
|
||||||
|
if (
|
||||||
|
latestElement &&
|
||||||
|
origElement &&
|
||||||
|
isPropertyEditable(latestElement, property)
|
||||||
|
) {
|
||||||
|
let nextWidth =
|
||||||
|
property === "width" ? Math.max(0, nextValue) : latestElement.width;
|
||||||
|
if (property === "width") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextWidth = Math.round(nextWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextHeight =
|
||||||
|
property === "height"
|
||||||
|
? Math.max(0, nextValue)
|
||||||
|
: latestElement.height;
|
||||||
|
if (property === "height") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextHeight = Math.round(nextHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||||
|
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||||
|
|
||||||
|
resizeElement(
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
false,
|
||||||
|
origElement,
|
||||||
|
elementsMap,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.triggerUpdate();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||||
|
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||||
|
|
||||||
|
for (const atomicUnit of atomicUnits) {
|
||||||
|
const elementsInUnit = getElementsInAtomicUnit(
|
||||||
|
atomicUnit,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (elementsInUnit.length > 1) {
|
||||||
|
const latestElements = elementsInUnit.map((el) => el.latest!);
|
||||||
|
const originalElements = elementsInUnit.map((el) => el.original!);
|
||||||
|
|
||||||
|
const [x1, y1, x2, y2] = getCommonBounds(originalElements);
|
||||||
|
const initialWidth = x2 - x1;
|
||||||
|
const initialHeight = y2 - y1;
|
||||||
|
const aspectRatio = initialWidth / initialHeight;
|
||||||
|
let nextWidth = Math.max(0, initialWidth + changeInWidth);
|
||||||
|
if (property === "width") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextWidth = Math.round(nextWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextHeight = Math.max(0, initialHeight + changeInHeight);
|
||||||
|
if (property === "height") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextHeight = Math.round(nextHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||||
|
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||||
|
|
||||||
|
resizeGroup(
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
initialHeight,
|
||||||
|
aspectRatio,
|
||||||
|
[x1, y1],
|
||||||
|
property,
|
||||||
|
latestElements,
|
||||||
|
originalElements,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const [el] = elementsInUnit;
|
||||||
|
const latestElement = el?.latest;
|
||||||
|
const origElement = el?.original;
|
||||||
|
|
||||||
|
if (
|
||||||
|
latestElement &&
|
||||||
|
origElement &&
|
||||||
|
isPropertyEditable(latestElement, property)
|
||||||
|
) {
|
||||||
|
let nextWidth = Math.max(0, origElement.width + changeInWidth);
|
||||||
|
if (property === "width") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextWidth = Math.round(nextWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextHeight = Math.max(0, origElement.height + changeInHeight);
|
||||||
|
if (property === "height") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextHeight = Math.round(nextHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||||
|
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||||
|
|
||||||
|
resizeElement(nextWidth, nextHeight, false, origElement, elementsMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.triggerUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const MultiDimension = ({
|
||||||
|
property,
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
atomicUnits,
|
||||||
|
scene,
|
||||||
|
appState,
|
||||||
|
}: MultiDimensionProps) => {
|
||||||
|
const sizes = useMemo(
|
||||||
|
() =>
|
||||||
|
atomicUnits.map((atomicUnit) => {
|
||||||
|
const elementsInUnit = getElementsInAtomicUnit(atomicUnit, elementsMap);
|
||||||
|
|
||||||
|
if (elementsInUnit.length > 1) {
|
||||||
|
const [x1, y1, x2, y2] = getCommonBounds(
|
||||||
|
elementsInUnit.map((el) => el.latest),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
Math.round((property === "width" ? x2 - x1 : y2 - y1) * 100) / 100
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [el] = elementsInUnit;
|
||||||
|
|
||||||
|
return (
|
||||||
|
Math.round(
|
||||||
|
(property === "width" ? el.latest.width : el.latest.height) * 100,
|
||||||
|
) / 100
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[elementsMap, atomicUnits, property],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value =
|
||||||
|
new Set(sizes).size === 1 ? Math.round(sizes[0] * 100) / 100 : "Mixed";
|
||||||
|
|
||||||
|
const editable = sizes.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragInput
|
||||||
|
label={property === "width" ? "W" : "H"}
|
||||||
|
elements={elements}
|
||||||
|
dragInputCallback={handleDimensionChange}
|
||||||
|
value={value}
|
||||||
|
editable={editable}
|
||||||
|
appState={appState}
|
||||||
|
property={property}
|
||||||
|
scene={scene}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MultiDimension;
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { isTextElement, redrawTextBoundingBox } from "../../element";
|
||||||
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
|
import { hasBoundTextElement } from "../../element/typeChecks";
|
||||||
|
import type {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
|
} from "../../element/types";
|
||||||
|
import { isInGroup } from "../../groups";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
import { fontSizeIcon } from "../icons";
|
||||||
|
import StatsDragInput from "./DragInput";
|
||||||
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
|
import { getStepSizedValue } from "./utils";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
import { getBoundTextElement } from "../../element/textElement";
|
||||||
|
|
||||||
|
interface MultiFontSizeProps {
|
||||||
|
elements: readonly ExcalidrawElement[];
|
||||||
|
scene: Scene;
|
||||||
|
elementsMap: NonDeletedSceneElementsMap;
|
||||||
|
appState: AppState;
|
||||||
|
property: "fontSize";
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_FONT_SIZE = 4;
|
||||||
|
const STEP_SIZE = 4;
|
||||||
|
|
||||||
|
const getApplicableTextElements = (
|
||||||
|
elements: readonly (ExcalidrawElement | undefined)[],
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
) =>
|
||||||
|
elements.reduce(
|
||||||
|
(acc: ExcalidrawTextElement[], el) => {
|
||||||
|
if (!el || isInGroup(el)) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
if (isTextElement(el)) {
|
||||||
|
acc.push(el);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
if (hasBoundTextElement(el)) {
|
||||||
|
const boundTextElement = getBoundTextElement(el, elementsMap);
|
||||||
|
if (boundTextElement) {
|
||||||
|
acc.push(boundTextElement);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFontSizeChange: DragInputCallbackType<
|
||||||
|
MultiFontSizeProps["property"],
|
||||||
|
ExcalidrawTextElement
|
||||||
|
> = ({
|
||||||
|
accumulatedChange,
|
||||||
|
originalElements,
|
||||||
|
shouldChangeByStepSize,
|
||||||
|
nextValue,
|
||||||
|
scene,
|
||||||
|
}) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
const latestTextElements = originalElements.map((el) =>
|
||||||
|
elementsMap.get(el.id),
|
||||||
|
) as ExcalidrawTextElement[];
|
||||||
|
|
||||||
|
let nextFontSize;
|
||||||
|
|
||||||
|
if (nextValue) {
|
||||||
|
nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
|
||||||
|
|
||||||
|
for (const textElement of latestTextElements) {
|
||||||
|
mutateElement(
|
||||||
|
textElement,
|
||||||
|
{
|
||||||
|
fontSize: nextFontSize,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
redrawTextBoundingBox(
|
||||||
|
textElement,
|
||||||
|
scene.getContainerElement(textElement),
|
||||||
|
elementsMap,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.triggerUpdate();
|
||||||
|
} else {
|
||||||
|
const originalTextElements = originalElements as ExcalidrawTextElement[];
|
||||||
|
|
||||||
|
for (let i = 0; i < latestTextElements.length; i++) {
|
||||||
|
const latestElement = latestTextElements[i];
|
||||||
|
const originalElement = originalTextElements[i];
|
||||||
|
|
||||||
|
const originalFontSize = Math.round(originalElement.fontSize);
|
||||||
|
const changeInFontSize = Math.round(accumulatedChange);
|
||||||
|
let nextFontSize = Math.max(
|
||||||
|
originalFontSize + changeInFontSize,
|
||||||
|
MIN_FONT_SIZE,
|
||||||
|
);
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
|
||||||
|
}
|
||||||
|
mutateElement(
|
||||||
|
latestElement,
|
||||||
|
{
|
||||||
|
fontSize: nextFontSize,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
redrawTextBoundingBox(
|
||||||
|
latestElement,
|
||||||
|
scene.getContainerElement(latestElement),
|
||||||
|
elementsMap,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.triggerUpdate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const MultiFontSize = ({
|
||||||
|
elements,
|
||||||
|
scene,
|
||||||
|
appState,
|
||||||
|
property,
|
||||||
|
elementsMap,
|
||||||
|
}: MultiFontSizeProps) => {
|
||||||
|
const latestTextElements = getApplicableTextElements(elements, elementsMap);
|
||||||
|
|
||||||
|
if (!latestTextElements.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontSizes = latestTextElements.map(
|
||||||
|
(textEl) => Math.round(textEl.fontSize * 10) / 10,
|
||||||
|
);
|
||||||
|
const value = new Set(fontSizes).size === 1 ? fontSizes[0] : "Mixed";
|
||||||
|
const editable = fontSizes.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatsDragInput
|
||||||
|
label="F"
|
||||||
|
icon={fontSizeIcon}
|
||||||
|
elements={latestTextElements}
|
||||||
|
dragInputCallback={handleFontSizeChange}
|
||||||
|
value={value}
|
||||||
|
editable={editable}
|
||||||
|
scene={scene}
|
||||||
|
property={property}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MultiFontSize;
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import type {
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawElement,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
|
} from "../../element/types";
|
||||||
|
import { rotate } from "../../math";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
import StatsDragInput from "./DragInput";
|
||||||
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
|
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||||
|
import { getCommonBounds, isTextElement } from "../../element";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { getElementsInAtomicUnit, moveElement } from "./utils";
|
||||||
|
import type { AtomicUnit } from "./utils";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
|
||||||
|
interface MultiPositionProps {
|
||||||
|
property: "x" | "y";
|
||||||
|
elements: readonly ExcalidrawElement[];
|
||||||
|
elementsMap: ElementsMap;
|
||||||
|
atomicUnits: AtomicUnit[];
|
||||||
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_SIZE = 10;
|
||||||
|
|
||||||
|
const moveElements = (
|
||||||
|
property: MultiPositionProps["property"],
|
||||||
|
changeInTopX: number,
|
||||||
|
changeInTopY: number,
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
originalElements: readonly ExcalidrawElement[],
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
originalElementsMap: ElementsMap,
|
||||||
|
) => {
|
||||||
|
for (let i = 0; i < elements.length; i++) {
|
||||||
|
const origElement = originalElements[i];
|
||||||
|
|
||||||
|
const [cx, cy] = [
|
||||||
|
origElement.x + origElement.width / 2,
|
||||||
|
origElement.y + origElement.height / 2,
|
||||||
|
];
|
||||||
|
const [topLeftX, topLeftY] = rotate(
|
||||||
|
origElement.x,
|
||||||
|
origElement.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
origElement.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
const newTopLeftX =
|
||||||
|
property === "x" ? Math.round(topLeftX + changeInTopX) : topLeftX;
|
||||||
|
|
||||||
|
const newTopLeftY =
|
||||||
|
property === "y" ? Math.round(topLeftY + changeInTopY) : topLeftY;
|
||||||
|
|
||||||
|
moveElement(
|
||||||
|
newTopLeftX,
|
||||||
|
newTopLeftY,
|
||||||
|
origElement,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveGroupTo = (
|
||||||
|
nextX: number,
|
||||||
|
nextY: number,
|
||||||
|
originalElements: ExcalidrawElement[],
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
originalElementsMap: ElementsMap,
|
||||||
|
scene: Scene,
|
||||||
|
) => {
|
||||||
|
const [x1, y1, ,] = getCommonBounds(originalElements);
|
||||||
|
const offsetX = nextX - x1;
|
||||||
|
const offsetY = nextY - y1;
|
||||||
|
|
||||||
|
for (let i = 0; i < originalElements.length; i++) {
|
||||||
|
const origElement = originalElements[i];
|
||||||
|
|
||||||
|
const latestElement = elementsMap.get(origElement.id);
|
||||||
|
if (!latestElement) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bound texts are moved with their containers
|
||||||
|
if (!isTextElement(latestElement) || !latestElement.containerId) {
|
||||||
|
const [cx, cy] = [
|
||||||
|
latestElement.x + latestElement.width / 2,
|
||||||
|
latestElement.y + latestElement.height / 2,
|
||||||
|
];
|
||||||
|
|
||||||
|
const [topLeftX, topLeftY] = rotate(
|
||||||
|
latestElement.x,
|
||||||
|
latestElement.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
latestElement.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
moveElement(
|
||||||
|
topLeftX + offsetX,
|
||||||
|
topLeftY + offsetY,
|
||||||
|
origElement,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePositionChange: DragInputCallbackType<
|
||||||
|
MultiPositionProps["property"]
|
||||||
|
> = ({
|
||||||
|
accumulatedChange,
|
||||||
|
originalElements,
|
||||||
|
originalElementsMap,
|
||||||
|
shouldChangeByStepSize,
|
||||||
|
nextValue,
|
||||||
|
property,
|
||||||
|
scene,
|
||||||
|
originalAppState,
|
||||||
|
}) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
|
if (nextValue !== undefined) {
|
||||||
|
for (const atomicUnit of getAtomicUnits(
|
||||||
|
originalElements,
|
||||||
|
originalAppState,
|
||||||
|
)) {
|
||||||
|
const elementsInUnit = getElementsInAtomicUnit(
|
||||||
|
atomicUnit,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (elementsInUnit.length > 1) {
|
||||||
|
const [x1, y1, ,] = getCommonBounds(
|
||||||
|
elementsInUnit.map((el) => el.latest!),
|
||||||
|
);
|
||||||
|
const newTopLeftX = property === "x" ? nextValue : x1;
|
||||||
|
const newTopLeftY = property === "y" ? nextValue : y1;
|
||||||
|
|
||||||
|
moveGroupTo(
|
||||||
|
newTopLeftX,
|
||||||
|
newTopLeftY,
|
||||||
|
elementsInUnit.map((el) => el.original),
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
scene,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const origElement = elementsInUnit[0]?.original;
|
||||||
|
const latestElement = elementsInUnit[0]?.latest;
|
||||||
|
if (
|
||||||
|
origElement &&
|
||||||
|
latestElement &&
|
||||||
|
isPropertyEditable(latestElement, property)
|
||||||
|
) {
|
||||||
|
const [cx, cy] = [
|
||||||
|
origElement.x + origElement.width / 2,
|
||||||
|
origElement.y + origElement.height / 2,
|
||||||
|
];
|
||||||
|
const [topLeftX, topLeftY] = rotate(
|
||||||
|
origElement.x,
|
||||||
|
origElement.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
origElement.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
const newTopLeftX = property === "x" ? nextValue : topLeftX;
|
||||||
|
const newTopLeftY = property === "y" ? nextValue : topLeftY;
|
||||||
|
moveElement(
|
||||||
|
newTopLeftX,
|
||||||
|
newTopLeftY,
|
||||||
|
origElement,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.triggerUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const change = shouldChangeByStepSize
|
||||||
|
? getStepSizedValue(accumulatedChange, STEP_SIZE)
|
||||||
|
: accumulatedChange;
|
||||||
|
|
||||||
|
const changeInTopX = property === "x" ? change : 0;
|
||||||
|
const changeInTopY = property === "y" ? change : 0;
|
||||||
|
|
||||||
|
moveElements(
|
||||||
|
property,
|
||||||
|
changeInTopX,
|
||||||
|
changeInTopY,
|
||||||
|
originalElements,
|
||||||
|
originalElements,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
scene.triggerUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const MultiPosition = ({
|
||||||
|
property,
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
atomicUnits,
|
||||||
|
scene,
|
||||||
|
appState,
|
||||||
|
}: MultiPositionProps) => {
|
||||||
|
const positions = useMemo(
|
||||||
|
() =>
|
||||||
|
atomicUnits.map((atomicUnit) => {
|
||||||
|
const elementsInUnit = Object.keys(atomicUnit)
|
||||||
|
.map((id) => elementsMap.get(id))
|
||||||
|
.filter((el) => el !== undefined) as ExcalidrawElement[];
|
||||||
|
|
||||||
|
// we're dealing with a group
|
||||||
|
if (elementsInUnit.length > 1) {
|
||||||
|
const [x1, y1] = getCommonBounds(elementsInUnit);
|
||||||
|
return Math.round((property === "x" ? x1 : y1) * 100) / 100;
|
||||||
|
}
|
||||||
|
const [el] = elementsInUnit;
|
||||||
|
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
|
||||||
|
|
||||||
|
const [topLeftX, topLeftY] = rotate(el.x, el.y, cx, cy, el.angle);
|
||||||
|
|
||||||
|
return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
|
||||||
|
}),
|
||||||
|
[atomicUnits, elementsMap, property],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = new Set(positions).size === 1 ? positions[0] : "Mixed";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatsDragInput
|
||||||
|
label={property === "x" ? "X" : "Y"}
|
||||||
|
elements={elements}
|
||||||
|
dragInputCallback={handlePositionChange}
|
||||||
|
value={value}
|
||||||
|
property={property}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MultiPosition;
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
||||||
|
import { rotate } from "../../math";
|
||||||
|
import StatsDragInput from "./DragInput";
|
||||||
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
|
import { getStepSizedValue, moveElement } from "./utils";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
|
||||||
|
interface PositionProps {
|
||||||
|
property: "x" | "y";
|
||||||
|
element: ExcalidrawElement;
|
||||||
|
elementsMap: ElementsMap;
|
||||||
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_SIZE = 10;
|
||||||
|
|
||||||
|
const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
|
||||||
|
accumulatedChange,
|
||||||
|
originalElements,
|
||||||
|
originalElementsMap,
|
||||||
|
shouldChangeByStepSize,
|
||||||
|
nextValue,
|
||||||
|
property,
|
||||||
|
scene,
|
||||||
|
}) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
const origElement = originalElements[0];
|
||||||
|
const [cx, cy] = [
|
||||||
|
origElement.x + origElement.width / 2,
|
||||||
|
origElement.y + origElement.height / 2,
|
||||||
|
];
|
||||||
|
const [topLeftX, topLeftY] = rotate(
|
||||||
|
origElement.x,
|
||||||
|
origElement.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
origElement.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nextValue !== undefined) {
|
||||||
|
const newTopLeftX = property === "x" ? nextValue : topLeftX;
|
||||||
|
const newTopLeftY = property === "y" ? nextValue : topLeftY;
|
||||||
|
moveElement(
|
||||||
|
newTopLeftX,
|
||||||
|
newTopLeftY,
|
||||||
|
origElement,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeInTopX = property === "x" ? accumulatedChange : 0;
|
||||||
|
const changeInTopY = property === "y" ? accumulatedChange : 0;
|
||||||
|
|
||||||
|
const newTopLeftX =
|
||||||
|
property === "x"
|
||||||
|
? Math.round(
|
||||||
|
shouldChangeByStepSize
|
||||||
|
? getStepSizedValue(origElement.x + changeInTopX, STEP_SIZE)
|
||||||
|
: topLeftX + changeInTopX,
|
||||||
|
)
|
||||||
|
: topLeftX;
|
||||||
|
|
||||||
|
const newTopLeftY =
|
||||||
|
property === "y"
|
||||||
|
? Math.round(
|
||||||
|
shouldChangeByStepSize
|
||||||
|
? getStepSizedValue(origElement.y + changeInTopY, STEP_SIZE)
|
||||||
|
: topLeftY + changeInTopY,
|
||||||
|
)
|
||||||
|
: topLeftY;
|
||||||
|
|
||||||
|
moveElement(
|
||||||
|
newTopLeftX,
|
||||||
|
newTopLeftY,
|
||||||
|
origElement,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Position = ({
|
||||||
|
property,
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
scene,
|
||||||
|
appState,
|
||||||
|
}: PositionProps) => {
|
||||||
|
const [topLeftX, topLeftY] = rotate(
|
||||||
|
element.x,
|
||||||
|
element.y,
|
||||||
|
element.x + element.width / 2,
|
||||||
|
element.y + element.height / 2,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
const value =
|
||||||
|
Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatsDragInput
|
||||||
|
label={property === "x" ? "X" : "Y"}
|
||||||
|
elements={[element]}
|
||||||
|
dragInputCallback={handlePositionChange}
|
||||||
|
value={value}
|
||||||
|
property={property}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Position;
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import { useEffect, useMemo, useState, memo } from "react";
|
||||||
|
import { getCommonBounds } from "../../element/bounds";
|
||||||
|
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import type { AppState, ExcalidrawProps } from "../../types";
|
||||||
|
import { CloseIcon } from "../icons";
|
||||||
|
import { Island } from "../Island";
|
||||||
|
import { throttle } from "lodash";
|
||||||
|
import Dimension from "./Dimension";
|
||||||
|
import Angle from "./Angle";
|
||||||
|
|
||||||
|
import FontSize from "./FontSize";
|
||||||
|
import MultiDimension from "./MultiDimension";
|
||||||
|
import { elementsAreInSameGroup } from "../../groups";
|
||||||
|
import MultiAngle from "./MultiAngle";
|
||||||
|
import MultiFontSize from "./MultiFontSize";
|
||||||
|
import Position from "./Position";
|
||||||
|
import MultiPosition from "./MultiPosition";
|
||||||
|
import Collapsible from "./Collapsible";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
|
||||||
|
import { getAtomicUnits } from "./utils";
|
||||||
|
import { STATS_PANELS } from "../../constants";
|
||||||
|
|
||||||
|
interface StatsProps {
|
||||||
|
scene: Scene;
|
||||||
|
onClose: () => void;
|
||||||
|
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATS_TIMEOUT = 50;
|
||||||
|
|
||||||
|
export const Stats = (props: StatsProps) => {
|
||||||
|
const appState = useExcalidrawAppState();
|
||||||
|
const sceneNonce = props.scene.getSceneNonce() || 1;
|
||||||
|
const selectedElements = props.scene.getSelectedElements({
|
||||||
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
includeBoundTextElement: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatsInner
|
||||||
|
{...props}
|
||||||
|
appState={appState}
|
||||||
|
sceneNonce={sceneNonce}
|
||||||
|
selectedElements={selectedElements}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StatsInner = memo(
|
||||||
|
({
|
||||||
|
scene,
|
||||||
|
onClose,
|
||||||
|
renderCustomStats,
|
||||||
|
selectedElements,
|
||||||
|
appState,
|
||||||
|
sceneNonce,
|
||||||
|
}: StatsProps & {
|
||||||
|
sceneNonce: number;
|
||||||
|
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
appState: AppState;
|
||||||
|
}) => {
|
||||||
|
const elements = scene.getNonDeletedElements();
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
|
||||||
|
const singleElement =
|
||||||
|
selectedElements.length === 1 ? selectedElements[0] : null;
|
||||||
|
|
||||||
|
const multipleElements =
|
||||||
|
selectedElements.length > 1 ? selectedElements : null;
|
||||||
|
|
||||||
|
const [sceneDimension, setSceneDimension] = useState<{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}>({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const throttledSetSceneDimension = useMemo(
|
||||||
|
() =>
|
||||||
|
throttle((elements: readonly NonDeletedExcalidrawElement[]) => {
|
||||||
|
const boundingBox = getCommonBounds(elements);
|
||||||
|
setSceneDimension({
|
||||||
|
width: Math.round(boundingBox[2]) - Math.round(boundingBox[0]),
|
||||||
|
height: Math.round(boundingBox[3]) - Math.round(boundingBox[1]),
|
||||||
|
});
|
||||||
|
}, STATS_TIMEOUT),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
throttledSetSceneDimension(elements);
|
||||||
|
}, [sceneNonce, elements, throttledSetSceneDimension]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => throttledSetSceneDimension.cancel(),
|
||||||
|
[throttledSetSceneDimension],
|
||||||
|
);
|
||||||
|
|
||||||
|
const atomicUnits = useMemo(() => {
|
||||||
|
return getAtomicUnits(selectedElements, appState);
|
||||||
|
}, [selectedElements, appState]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="Stats">
|
||||||
|
<Island padding={3}>
|
||||||
|
<div className="title">
|
||||||
|
<h2>{t("stats.title")}</h2>
|
||||||
|
<div className="close" onClick={onClose}>
|
||||||
|
{CloseIcon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Collapsible
|
||||||
|
label={<h3>{t("stats.generalStats")}</h3>}
|
||||||
|
open={!!(appState.stats.panels & STATS_PANELS.generalStats)}
|
||||||
|
openTrigger={() =>
|
||||||
|
setAppState((state) => {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
stats: {
|
||||||
|
open: true,
|
||||||
|
panels: state.stats.panels ^ STATS_PANELS.generalStats,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th colSpan={2}>{t("stats.scene")}</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{t("stats.elements")}</td>
|
||||||
|
<td>{elements.length}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{t("stats.width")}</td>
|
||||||
|
<td>{sceneDimension.width}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{t("stats.height")}</td>
|
||||||
|
<td>{sceneDimension.height}</td>
|
||||||
|
</tr>
|
||||||
|
{renderCustomStats?.(elements, appState)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{selectedElements.length > 0 && (
|
||||||
|
<div
|
||||||
|
id="elementStats"
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Collapsible
|
||||||
|
label={<h3>{t("stats.elementProperties")}</h3>}
|
||||||
|
open={
|
||||||
|
!!(appState.stats.panels & STATS_PANELS.elementProperties)
|
||||||
|
}
|
||||||
|
openTrigger={() =>
|
||||||
|
setAppState((state) => {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
stats: {
|
||||||
|
open: true,
|
||||||
|
panels:
|
||||||
|
state.stats.panels ^ STATS_PANELS.elementProperties,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{singleElement && (
|
||||||
|
<div className="sectionContent">
|
||||||
|
<div className="elementType">
|
||||||
|
{t(`element.${singleElement.type}`)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="statsItem">
|
||||||
|
<Position
|
||||||
|
element={singleElement}
|
||||||
|
property="x"
|
||||||
|
elementsMap={elementsMap}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
<Position
|
||||||
|
element={singleElement}
|
||||||
|
property="y"
|
||||||
|
elementsMap={elementsMap}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
<Dimension
|
||||||
|
property="width"
|
||||||
|
element={singleElement}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
<Dimension
|
||||||
|
property="height"
|
||||||
|
element={singleElement}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
<Angle
|
||||||
|
property="angle"
|
||||||
|
element={singleElement}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
<FontSize
|
||||||
|
property="fontSize"
|
||||||
|
element={singleElement}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{multipleElements && (
|
||||||
|
<div className="sectionContent">
|
||||||
|
{elementsAreInSameGroup(multipleElements) && (
|
||||||
|
<div className="elementType">{t("element.group")}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="elementsCount">
|
||||||
|
<div>{t("stats.elements")}</div>
|
||||||
|
<div>{selectedElements.length}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="statsItem">
|
||||||
|
<MultiPosition
|
||||||
|
property="x"
|
||||||
|
elements={multipleElements}
|
||||||
|
elementsMap={elementsMap}
|
||||||
|
atomicUnits={atomicUnits}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
<MultiPosition
|
||||||
|
property="y"
|
||||||
|
elements={multipleElements}
|
||||||
|
elementsMap={elementsMap}
|
||||||
|
atomicUnits={atomicUnits}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
<MultiDimension
|
||||||
|
property="width"
|
||||||
|
elements={multipleElements}
|
||||||
|
elementsMap={elementsMap}
|
||||||
|
atomicUnits={atomicUnits}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
<MultiDimension
|
||||||
|
property="height"
|
||||||
|
elements={multipleElements}
|
||||||
|
elementsMap={elementsMap}
|
||||||
|
atomicUnits={atomicUnits}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
<MultiAngle
|
||||||
|
property="angle"
|
||||||
|
elements={multipleElements}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
<MultiFontSize
|
||||||
|
property="fontSize"
|
||||||
|
elements={multipleElements}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
elementsMap={elementsMap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Island>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(prev, next) => {
|
||||||
|
return (
|
||||||
|
prev.sceneNonce === next.sceneNonce &&
|
||||||
|
prev.selectedElements === next.selectedElements &&
|
||||||
|
prev.appState.stats.panels === next.appState.stats.panels
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,756 @@
|
|||||||
|
import { fireEvent, queryByTestId } from "@testing-library/react";
|
||||||
|
import { Keyboard, Pointer, UI } from "../../tests/helpers/ui";
|
||||||
|
import { getStepSizedValue } from "./utils";
|
||||||
|
import {
|
||||||
|
GlobalTestState,
|
||||||
|
mockBoundingClientRect,
|
||||||
|
render,
|
||||||
|
restoreOriginalGetBoundingClientRect,
|
||||||
|
} from "../../tests/test-utils";
|
||||||
|
import * as StaticScene from "../../renderer/staticScene";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
import { reseed } from "../../random";
|
||||||
|
import { setDateTimeForTests } from "../../utils";
|
||||||
|
import { Excalidraw, mutateElement } from "../..";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import type {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
} from "../../element/types";
|
||||||
|
import { degreeToRadian, rotate } from "../../math";
|
||||||
|
import { getTextEditor, updateTextEditor } from "../../tests/queries/dom";
|
||||||
|
import { getCommonBounds, isTextElement } from "../../element";
|
||||||
|
import { API } from "../../tests/helpers/api";
|
||||||
|
import { actionGroup } from "../../actions";
|
||||||
|
import { isInGroup } from "../../groups";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const { h } = window;
|
||||||
|
const mouse = new Pointer("mouse");
|
||||||
|
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
||||||
|
let stats: HTMLElement | null = null;
|
||||||
|
let elementStats: HTMLElement | null | undefined = null;
|
||||||
|
|
||||||
|
const editInput = (input: HTMLInputElement, value: string) => {
|
||||||
|
input.focus();
|
||||||
|
fireEvent.change(input, { target: { value } });
|
||||||
|
input.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatsProperty = (label: string) => {
|
||||||
|
const elementStats = UI.queryStats()?.querySelector("#elementStats");
|
||||||
|
|
||||||
|
if (elementStats) {
|
||||||
|
const properties = elementStats?.querySelector(".statsItem");
|
||||||
|
return (
|
||||||
|
properties?.querySelector?.(
|
||||||
|
`.drag-input-container[data-testid="${label}"]`,
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const testInputProperty = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
property: "x" | "y" | "width" | "height" | "angle" | "fontSize",
|
||||||
|
label: string,
|
||||||
|
initialValue: number,
|
||||||
|
nextValue: number,
|
||||||
|
) => {
|
||||||
|
const input = getStatsProperty(label)?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(input).toBeDefined();
|
||||||
|
expect(input.value).toBe(initialValue.toString());
|
||||||
|
editInput(input, String(nextValue));
|
||||||
|
if (property === "angle") {
|
||||||
|
expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
|
||||||
|
} else if (property === "fontSize" && isTextElement(element)) {
|
||||||
|
expect(element[property]).toBe(Number(nextValue));
|
||||||
|
} else if (property !== "fontSize") {
|
||||||
|
expect(element[property]).toBe(Number(nextValue));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("step sized value", () => {
|
||||||
|
it("should return edge values correctly", () => {
|
||||||
|
const steps = [10, 15, 20, 25, 30];
|
||||||
|
const values = [10, 15, 20, 25, 30];
|
||||||
|
|
||||||
|
steps.forEach((step, idx) => {
|
||||||
|
expect(getStepSizedValue(values[idx], step)).toEqual(values[idx]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("step sized value lies in the middle", () => {
|
||||||
|
let stepSize = 15;
|
||||||
|
let values = [7.5, 9, 12, 14.99, 15, 22.49];
|
||||||
|
|
||||||
|
values.forEach((value) => {
|
||||||
|
expect(getStepSizedValue(value, stepSize)).toEqual(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
stepSize = 10;
|
||||||
|
values = [-5, 4.99, 0, 1.23];
|
||||||
|
values.forEach((value) => {
|
||||||
|
expect(getStepSizedValue(value, stepSize)).toEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("binding with linear elements", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
renderStaticScene.mockClear();
|
||||||
|
reseed(19);
|
||||||
|
setDateTimeForTests("201933152653");
|
||||||
|
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
|
||||||
|
h.elements = [];
|
||||||
|
|
||||||
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
|
button: 2,
|
||||||
|
clientX: 1,
|
||||||
|
clientY: 1,
|
||||||
|
});
|
||||||
|
const contextMenu = UI.queryContextMenu();
|
||||||
|
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||||
|
stats = UI.queryStats();
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down();
|
||||||
|
mouse.up(200, 100);
|
||||||
|
|
||||||
|
UI.clickTool("arrow");
|
||||||
|
mouse.down(5, 0);
|
||||||
|
mouse.up(300, 50);
|
||||||
|
|
||||||
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
mockBoundingClientRect();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
restoreOriginalGetBoundingClientRect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remain bound to linear element on small position change", async () => {
|
||||||
|
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||||
|
const inputX = getStatsProperty("X")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(linear.startBinding).not.toBe(null);
|
||||||
|
expect(inputX).not.toBeNull();
|
||||||
|
editInput(inputX, String("204"));
|
||||||
|
expect(linear.startBinding).not.toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remain bound to linear element on small angle change", async () => {
|
||||||
|
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||||
|
const inputAngle = getStatsProperty("A")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(linear.startBinding).not.toBe(null);
|
||||||
|
editInput(inputAngle, String("1"));
|
||||||
|
expect(linear.startBinding).not.toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should unbind linear element on large position change", async () => {
|
||||||
|
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||||
|
const inputX = getStatsProperty("X")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(linear.startBinding).not.toBe(null);
|
||||||
|
expect(inputX).not.toBeNull();
|
||||||
|
editInput(inputX, String("254"));
|
||||||
|
expect(linear.startBinding).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remain bound to linear element on small angle change", async () => {
|
||||||
|
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||||
|
const inputAngle = getStatsProperty("A")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(linear.startBinding).not.toBe(null);
|
||||||
|
editInput(inputAngle, String("45"));
|
||||||
|
expect(linear.startBinding).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// single element
|
||||||
|
describe("stats for a generic element", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
renderStaticScene.mockClear();
|
||||||
|
reseed(7);
|
||||||
|
setDateTimeForTests("201933152653");
|
||||||
|
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
|
||||||
|
h.elements = [];
|
||||||
|
|
||||||
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
|
button: 2,
|
||||||
|
clientX: 1,
|
||||||
|
clientY: 1,
|
||||||
|
});
|
||||||
|
const contextMenu = UI.queryContextMenu();
|
||||||
|
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||||
|
stats = UI.queryStats();
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down();
|
||||||
|
mouse.up(200, 100);
|
||||||
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
mockBoundingClientRect();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
restoreOriginalGetBoundingClientRect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should open stats", () => {
|
||||||
|
expect(stats).toBeDefined();
|
||||||
|
expect(elementStats).toBeDefined();
|
||||||
|
|
||||||
|
// title
|
||||||
|
const title = elementStats?.querySelector("h3");
|
||||||
|
expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties"));
|
||||||
|
|
||||||
|
// element type
|
||||||
|
const elementType = elementStats?.querySelector(".elementType");
|
||||||
|
expect(elementType).toBeDefined();
|
||||||
|
expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
|
||||||
|
|
||||||
|
// properties
|
||||||
|
const properties = elementStats?.querySelector(".statsItem");
|
||||||
|
expect(properties?.childNodes).toBeDefined();
|
||||||
|
["X", "Y", "W", "H", "A"].forEach((label) => () => {
|
||||||
|
expect(
|
||||||
|
properties?.querySelector?.(
|
||||||
|
`.drag-input-container[data-testid="${label}"]`,
|
||||||
|
),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be able to edit all properties for a general element", () => {
|
||||||
|
const rectangle = h.elements[0];
|
||||||
|
const initialX = rectangle.x;
|
||||||
|
const initialY = rectangle.y;
|
||||||
|
|
||||||
|
testInputProperty(rectangle, "width", "W", 200, 100);
|
||||||
|
testInputProperty(rectangle, "height", "H", 100, 200);
|
||||||
|
testInputProperty(rectangle, "x", "X", initialX, 230);
|
||||||
|
testInputProperty(rectangle, "y", "Y", initialY, 220);
|
||||||
|
testInputProperty(rectangle, "angle", "A", 0, 45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep only two decimal places", () => {
|
||||||
|
const rectangle = h.elements[0];
|
||||||
|
const rectangleId = rectangle.id;
|
||||||
|
|
||||||
|
const input = getStatsProperty("W")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(input).toBeDefined();
|
||||||
|
expect(input.value).toBe(rectangle.width.toString());
|
||||||
|
editInput(input, "123.123");
|
||||||
|
expect(h.elements.length).toBe(1);
|
||||||
|
expect(rectangle.id).toBe(rectangleId);
|
||||||
|
expect(input.value).toBe("123.12");
|
||||||
|
expect(rectangle.width).toBe(123.12);
|
||||||
|
|
||||||
|
editInput(input, "88.98766");
|
||||||
|
expect(input.value).toBe("88.99");
|
||||||
|
expect(rectangle.width).toBe(88.99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update input x and y when angle is changed", () => {
|
||||||
|
const rectangle = h.elements[0];
|
||||||
|
const [cx, cy] = [
|
||||||
|
rectangle.x + rectangle.width / 2,
|
||||||
|
rectangle.y + rectangle.height / 2,
|
||||||
|
];
|
||||||
|
const [topLeftX, topLeftY] = rotate(
|
||||||
|
rectangle.x,
|
||||||
|
rectangle.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
rectangle.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
const xInput = getStatsProperty("X")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
const yInput = getStatsProperty("Y")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(xInput.value).toBe(topLeftX.toString());
|
||||||
|
expect(yInput.value).toBe(topLeftY.toString());
|
||||||
|
|
||||||
|
testInputProperty(rectangle, "angle", "A", 0, 45);
|
||||||
|
|
||||||
|
let [newTopLeftX, newTopLeftY] = rotate(
|
||||||
|
rectangle.x,
|
||||||
|
rectangle.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
rectangle.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
|
||||||
|
expect(newTopLeftY.toString()).not.toEqual(yInput.value);
|
||||||
|
|
||||||
|
testInputProperty(rectangle, "angle", "A", 45, 66);
|
||||||
|
|
||||||
|
[newTopLeftX, newTopLeftY] = rotate(
|
||||||
|
rectangle.x,
|
||||||
|
rectangle.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
rectangle.angle,
|
||||||
|
);
|
||||||
|
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
|
||||||
|
expect(newTopLeftY.toString()).not.toEqual(yInput.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fix top left corner when width or height is changed", () => {
|
||||||
|
const rectangle = h.elements[0];
|
||||||
|
|
||||||
|
testInputProperty(rectangle, "angle", "A", 0, 45);
|
||||||
|
let [cx, cy] = [
|
||||||
|
rectangle.x + rectangle.width / 2,
|
||||||
|
rectangle.y + rectangle.height / 2,
|
||||||
|
];
|
||||||
|
const [topLeftX, topLeftY] = rotate(
|
||||||
|
rectangle.x,
|
||||||
|
rectangle.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
rectangle.angle,
|
||||||
|
);
|
||||||
|
testInputProperty(rectangle, "width", "W", rectangle.width, 400);
|
||||||
|
[cx, cy] = [
|
||||||
|
rectangle.x + rectangle.width / 2,
|
||||||
|
rectangle.y + rectangle.height / 2,
|
||||||
|
];
|
||||||
|
let [currentTopLeftX, currentTopLeftY] = rotate(
|
||||||
|
rectangle.x,
|
||||||
|
rectangle.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
rectangle.angle,
|
||||||
|
);
|
||||||
|
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
|
||||||
|
expect(currentTopLeftY).toBeCloseTo(topLeftY, 4);
|
||||||
|
|
||||||
|
testInputProperty(rectangle, "height", "H", rectangle.height, 400);
|
||||||
|
[cx, cy] = [
|
||||||
|
rectangle.x + rectangle.width / 2,
|
||||||
|
rectangle.y + rectangle.height / 2,
|
||||||
|
];
|
||||||
|
[currentTopLeftX, currentTopLeftY] = rotate(
|
||||||
|
rectangle.x,
|
||||||
|
rectangle.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
rectangle.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
|
||||||
|
expect(currentTopLeftY).toBeCloseTo(topLeftY, 4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stats for a non-generic element", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
renderStaticScene.mockClear();
|
||||||
|
reseed(7);
|
||||||
|
setDateTimeForTests("201933152653");
|
||||||
|
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
|
||||||
|
h.elements = [];
|
||||||
|
|
||||||
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
|
button: 2,
|
||||||
|
clientX: 1,
|
||||||
|
clientY: 1,
|
||||||
|
});
|
||||||
|
const contextMenu = UI.queryContextMenu();
|
||||||
|
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||||
|
stats = UI.queryStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
mockBoundingClientRect();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
restoreOriginalGetBoundingClientRect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("text element", async () => {
|
||||||
|
UI.clickTool("text");
|
||||||
|
mouse.clickAt(20, 30);
|
||||||
|
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
|
||||||
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
updateTextEditor(editor, "Hello!");
|
||||||
|
editor.blur();
|
||||||
|
|
||||||
|
const text = h.elements[0] as ExcalidrawTextElement;
|
||||||
|
mouse.clickOn(text);
|
||||||
|
|
||||||
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
|
// can change font size
|
||||||
|
const input = getStatsProperty("F")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(input).toBeDefined();
|
||||||
|
expect(input.value).toBe(text.fontSize.toString());
|
||||||
|
editInput(input, "36");
|
||||||
|
expect(text.fontSize).toBe(36);
|
||||||
|
|
||||||
|
// cannot change width or height
|
||||||
|
const width = getStatsProperty("W")?.querySelector(".drag-input");
|
||||||
|
expect(width).toBeUndefined();
|
||||||
|
const height = getStatsProperty("H")?.querySelector(".drag-input");
|
||||||
|
expect(height).toBeUndefined();
|
||||||
|
|
||||||
|
// min font size is 4
|
||||||
|
editInput(input, "0");
|
||||||
|
expect(text.fontSize).not.toBe(0);
|
||||||
|
expect(text.fontSize).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("frame element", () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
id: "id0",
|
||||||
|
type: "frame",
|
||||||
|
x: 150,
|
||||||
|
width: 150,
|
||||||
|
});
|
||||||
|
h.elements = [frame];
|
||||||
|
h.setState({
|
||||||
|
selectedElementIds: {
|
||||||
|
[frame.id]: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
|
expect(elementStats).toBeDefined();
|
||||||
|
|
||||||
|
// cannot change angle
|
||||||
|
const angle = getStatsProperty("A")?.querySelector(".drag-input");
|
||||||
|
expect(angle).toBeUndefined();
|
||||||
|
|
||||||
|
// can change width or height
|
||||||
|
testInputProperty(frame, "width", "W", frame.width, 250);
|
||||||
|
testInputProperty(frame, "height", "H", frame.height, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("image element", () => {
|
||||||
|
const image = API.createElement({ type: "image", width: 200, height: 100 });
|
||||||
|
h.elements = [image];
|
||||||
|
mouse.clickOn(image);
|
||||||
|
h.setState({
|
||||||
|
selectedElementIds: {
|
||||||
|
[image.id]: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
expect(elementStats).toBeDefined();
|
||||||
|
const widthToHeight = image.width / image.height;
|
||||||
|
|
||||||
|
// when width or height is changed, the aspect ratio is preserved
|
||||||
|
testInputProperty(image, "width", "W", image.width, 400);
|
||||||
|
expect(image.width).toBe(400);
|
||||||
|
expect(image.width / image.height).toBe(widthToHeight);
|
||||||
|
|
||||||
|
testInputProperty(image, "height", "H", image.height, 80);
|
||||||
|
expect(image.height).toBe(80);
|
||||||
|
expect(image.width / image.height).toBe(widthToHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display fontSize for bound text", () => {
|
||||||
|
const container = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
const text = API.createElement({
|
||||||
|
type: "text",
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
containerId: container.id,
|
||||||
|
fontSize: 20,
|
||||||
|
});
|
||||||
|
mutateElement(container, {
|
||||||
|
boundElements: [{ type: "text", id: text.id }],
|
||||||
|
});
|
||||||
|
h.elements = [container, text];
|
||||||
|
|
||||||
|
API.setSelectedElements([container]);
|
||||||
|
const fontSize = getStatsProperty("F")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(fontSize).toBeDefined();
|
||||||
|
|
||||||
|
editInput(fontSize, "40");
|
||||||
|
|
||||||
|
expect(text.fontSize).toBe(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// multiple elements
|
||||||
|
describe("stats for multiple elements", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
mouse.reset();
|
||||||
|
localStorage.clear();
|
||||||
|
renderStaticScene.mockClear();
|
||||||
|
reseed(7);
|
||||||
|
setDateTimeForTests("201933152653");
|
||||||
|
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
|
||||||
|
h.elements = [];
|
||||||
|
|
||||||
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
|
button: 2,
|
||||||
|
clientX: 1,
|
||||||
|
clientY: 1,
|
||||||
|
});
|
||||||
|
const contextMenu = UI.queryContextMenu();
|
||||||
|
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||||
|
stats = UI.queryStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
mockBoundingClientRect();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
restoreOriginalGetBoundingClientRect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display MIXED for elements with different values", () => {
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down();
|
||||||
|
mouse.up(200, 100);
|
||||||
|
|
||||||
|
UI.clickTool("ellipse");
|
||||||
|
mouse.down(50, 50);
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
UI.clickTool("diamond");
|
||||||
|
mouse.down(-100, -100);
|
||||||
|
mouse.up(125, 145);
|
||||||
|
|
||||||
|
h.setState({
|
||||||
|
selectedElementIds: h.elements.reduce((acc, el) => {
|
||||||
|
acc[el.id] = true;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, true>),
|
||||||
|
});
|
||||||
|
|
||||||
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
|
const width = getStatsProperty("W")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(width?.value).toBe("Mixed");
|
||||||
|
const height = getStatsProperty("H")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(height?.value).toBe("Mixed");
|
||||||
|
const angle = getStatsProperty("A")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(angle.value).toBe("0");
|
||||||
|
|
||||||
|
editInput(width, "250");
|
||||||
|
h.elements.forEach((el) => {
|
||||||
|
expect(el.width).toBe(250);
|
||||||
|
});
|
||||||
|
|
||||||
|
editInput(height, "450");
|
||||||
|
h.elements.forEach((el) => {
|
||||||
|
expect(el.height).toBe(450);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display a property when one of the elements is editable for that property", async () => {
|
||||||
|
// text, rectangle, frame
|
||||||
|
UI.clickTool("text");
|
||||||
|
mouse.clickAt(20, 30);
|
||||||
|
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
|
||||||
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
updateTextEditor(editor, "Hello!");
|
||||||
|
editor.blur();
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down();
|
||||||
|
mouse.up(200, 100);
|
||||||
|
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
x: 150,
|
||||||
|
width: 150,
|
||||||
|
});
|
||||||
|
|
||||||
|
h.elements = [...h.elements, frame];
|
||||||
|
|
||||||
|
const text = h.elements.find((el) => el.type === "text");
|
||||||
|
const rectangle = h.elements.find((el) => el.type === "rectangle");
|
||||||
|
|
||||||
|
h.setState({
|
||||||
|
selectedElementIds: h.elements.reduce((acc, el) => {
|
||||||
|
acc[el.id] = true;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, true>),
|
||||||
|
});
|
||||||
|
|
||||||
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
|
const width = getStatsProperty("W")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(width).toBeDefined();
|
||||||
|
expect(width.value).toBe("Mixed");
|
||||||
|
|
||||||
|
const height = getStatsProperty("H")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(height).toBeDefined();
|
||||||
|
expect(height.value).toBe("Mixed");
|
||||||
|
|
||||||
|
const angle = getStatsProperty("A")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(angle).toBeDefined();
|
||||||
|
expect(angle.value).toBe("0");
|
||||||
|
|
||||||
|
const fontSize = getStatsProperty("F")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(fontSize).toBeDefined();
|
||||||
|
|
||||||
|
// changing width does not affect text
|
||||||
|
editInput(width, "200");
|
||||||
|
|
||||||
|
expect(rectangle?.width).toBe(200);
|
||||||
|
expect(frame.width).toBe(200);
|
||||||
|
expect(text?.width).not.toBe(200);
|
||||||
|
|
||||||
|
editInput(angle, "40");
|
||||||
|
|
||||||
|
const angleInRadian = degreeToRadian(40);
|
||||||
|
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
|
||||||
|
expect(text?.angle).toBeCloseTo(angleInRadian, 4);
|
||||||
|
expect(frame.angle).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should treat groups as single units", () => {
|
||||||
|
const createAndSelectGroup = () => {
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down();
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(0, 0);
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
h.app.actionManager.executeAction(actionGroup);
|
||||||
|
};
|
||||||
|
|
||||||
|
createAndSelectGroup();
|
||||||
|
|
||||||
|
const elementsInGroup = h.elements.filter((el) => isInGroup(el));
|
||||||
|
let [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||||
|
|
||||||
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
|
const x = getStatsProperty("X")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(x).toBeDefined();
|
||||||
|
expect(Number(x.value)).toBe(x1);
|
||||||
|
|
||||||
|
editInput(x, "300");
|
||||||
|
|
||||||
|
expect(h.elements[0].x).toBe(300);
|
||||||
|
expect(h.elements[1].x).toBe(400);
|
||||||
|
expect(x.value).toBe("300");
|
||||||
|
|
||||||
|
const y = getStatsProperty("Y")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(y).toBeDefined();
|
||||||
|
expect(Number(y.value)).toBe(y1);
|
||||||
|
|
||||||
|
editInput(y, "200");
|
||||||
|
|
||||||
|
expect(h.elements[0].y).toBe(200);
|
||||||
|
expect(h.elements[1].y).toBe(300);
|
||||||
|
expect(y.value).toBe("200");
|
||||||
|
|
||||||
|
const width = getStatsProperty("W")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(width).toBeDefined();
|
||||||
|
expect(Number(width.value)).toBe(200);
|
||||||
|
|
||||||
|
const height = getStatsProperty("H")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(height).toBeDefined();
|
||||||
|
expect(Number(height.value)).toBe(200);
|
||||||
|
|
||||||
|
editInput(width, "400");
|
||||||
|
|
||||||
|
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||||
|
let newGroupWidth = x2 - x1;
|
||||||
|
|
||||||
|
expect(newGroupWidth).toBeCloseTo(400, 4);
|
||||||
|
|
||||||
|
editInput(width, "300");
|
||||||
|
|
||||||
|
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||||
|
newGroupWidth = x2 - x1;
|
||||||
|
expect(newGroupWidth).toBeCloseTo(300, 4);
|
||||||
|
|
||||||
|
editInput(height, "500");
|
||||||
|
|
||||||
|
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||||
|
const newGroupHeight = y2 - y1;
|
||||||
|
expect(newGroupHeight).toBeCloseTo(500, 4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import {
|
||||||
|
bindOrUnbindLinearElements,
|
||||||
|
updateBoundElements,
|
||||||
|
} from "../../element/binding";
|
||||||
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
|
import {
|
||||||
|
measureFontSizeFromWidth,
|
||||||
|
rescalePointsInElement,
|
||||||
|
} from "../../element/resizeElements";
|
||||||
|
import {
|
||||||
|
getApproxMinLineHeight,
|
||||||
|
getApproxMinLineWidth,
|
||||||
|
getBoundTextElement,
|
||||||
|
getBoundTextMaxWidth,
|
||||||
|
handleBindTextResize,
|
||||||
|
} from "../../element/textElement";
|
||||||
|
import {
|
||||||
|
isFrameLikeElement,
|
||||||
|
isLinearElement,
|
||||||
|
isTextElement,
|
||||||
|
} from "../../element/typeChecks";
|
||||||
|
import type {
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawElement,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
|
} from "../../element/types";
|
||||||
|
import {
|
||||||
|
getSelectedGroupIds,
|
||||||
|
getElementsInGroup,
|
||||||
|
isInGroup,
|
||||||
|
} from "../../groups";
|
||||||
|
import { rotate } from "../../math";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
import { getFontString } from "../../utils";
|
||||||
|
|
||||||
|
export type StatsInputProperty =
|
||||||
|
| "x"
|
||||||
|
| "y"
|
||||||
|
| "width"
|
||||||
|
| "height"
|
||||||
|
| "angle"
|
||||||
|
| "fontSize";
|
||||||
|
|
||||||
|
export const SMALLEST_DELTA = 0.01;
|
||||||
|
|
||||||
|
export const isPropertyEditable = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
property: keyof ExcalidrawElement,
|
||||||
|
) => {
|
||||||
|
if (property === "height" && isTextElement(element)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (property === "width" && isTextElement(element)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (property === "angle" && isFrameLikeElement(element)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStepSizedValue = (value: number, stepSize: number) => {
|
||||||
|
const v = value + stepSize / 2;
|
||||||
|
return v - (v % stepSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AtomicUnit = Record<string, true>;
|
||||||
|
export const getElementsInAtomicUnit = (
|
||||||
|
atomicUnit: AtomicUnit,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
originalElementsMap?: ElementsMap,
|
||||||
|
) => {
|
||||||
|
return Object.keys(atomicUnit)
|
||||||
|
.map((id) => ({
|
||||||
|
original: (originalElementsMap ?? elementsMap).get(id),
|
||||||
|
latest: elementsMap.get(id),
|
||||||
|
}))
|
||||||
|
.filter((el) => el.original !== undefined && el.latest !== undefined) as {
|
||||||
|
original: NonDeletedExcalidrawElement;
|
||||||
|
latest: NonDeletedExcalidrawElement;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const newOrigin = (
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
w1: number,
|
||||||
|
h1: number,
|
||||||
|
w2: number,
|
||||||
|
h2: number,
|
||||||
|
angle: number,
|
||||||
|
) => {
|
||||||
|
/**
|
||||||
|
* The formula below is the result of solving
|
||||||
|
* rotate(x1, y1, cx1, cy1, angle) = rotate(x2, y2, cx2, cy2, angle)
|
||||||
|
* where rotate is the function defined in math.ts
|
||||||
|
*
|
||||||
|
* This is so that the new origin (x2, y2),
|
||||||
|
* when rotated against the new center (cx2, cy2),
|
||||||
|
* coincides with (x1, y1) rotated against (cx1, cy1)
|
||||||
|
*
|
||||||
|
* The reason for doing this computation is so the element's top left corner
|
||||||
|
* on the canvas remains fixed after any changes in its dimension.
|
||||||
|
*/
|
||||||
|
|
||||||
|
return {
|
||||||
|
x:
|
||||||
|
x1 +
|
||||||
|
(w1 - w2) / 2 +
|
||||||
|
((w2 - w1) / 2) * Math.cos(angle) +
|
||||||
|
((h1 - h2) / 2) * Math.sin(angle),
|
||||||
|
y:
|
||||||
|
y1 +
|
||||||
|
(h1 - h2) / 2 +
|
||||||
|
((w2 - w1) / 2) * Math.sin(angle) +
|
||||||
|
((h2 - h1) / 2) * Math.cos(angle),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resizeElement = (
|
||||||
|
nextWidth: number,
|
||||||
|
nextHeight: number,
|
||||||
|
keepAspectRatio: boolean,
|
||||||
|
origElement: ExcalidrawElement,
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
shouldInformMutation = true,
|
||||||
|
) => {
|
||||||
|
const latestElement = elementsMap.get(origElement.id);
|
||||||
|
if (!latestElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let boundTextFont: { fontSize?: number } = {};
|
||||||
|
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||||
|
|
||||||
|
if (boundTextElement) {
|
||||||
|
const minWidth = getApproxMinLineWidth(
|
||||||
|
getFontString(boundTextElement),
|
||||||
|
boundTextElement.lineHeight,
|
||||||
|
);
|
||||||
|
const minHeight = getApproxMinLineHeight(
|
||||||
|
boundTextElement.fontSize,
|
||||||
|
boundTextElement.lineHeight,
|
||||||
|
);
|
||||||
|
nextWidth = Math.max(nextWidth, minWidth);
|
||||||
|
nextHeight = Math.max(nextHeight, minHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
mutateElement(
|
||||||
|
latestElement,
|
||||||
|
{
|
||||||
|
...newOrigin(
|
||||||
|
latestElement.x,
|
||||||
|
latestElement.y,
|
||||||
|
latestElement.width,
|
||||||
|
latestElement.height,
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
latestElement.angle,
|
||||||
|
),
|
||||||
|
width: nextWidth,
|
||||||
|
height: nextHeight,
|
||||||
|
...rescalePointsInElement(origElement, nextWidth, nextHeight, true),
|
||||||
|
},
|
||||||
|
shouldInformMutation,
|
||||||
|
);
|
||||||
|
updateBindings(latestElement, elementsMap, {
|
||||||
|
newSize: {
|
||||||
|
width: nextWidth,
|
||||||
|
height: nextHeight,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (boundTextElement) {
|
||||||
|
boundTextFont = {
|
||||||
|
fontSize: boundTextElement.fontSize,
|
||||||
|
};
|
||||||
|
if (keepAspectRatio) {
|
||||||
|
const updatedElement = {
|
||||||
|
...latestElement,
|
||||||
|
width: nextWidth,
|
||||||
|
height: nextHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextFont = measureFontSizeFromWidth(
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
getBoundTextMaxWidth(updatedElement, boundTextElement),
|
||||||
|
);
|
||||||
|
boundTextFont = {
|
||||||
|
fontSize: nextFont?.size ?? boundTextElement.fontSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boundTextElement && boundTextFont) {
|
||||||
|
mutateElement(boundTextElement, {
|
||||||
|
fontSize: boundTextFont.fontSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
handleBindTextResize(latestElement, elementsMap, "e", keepAspectRatio);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const moveElement = (
|
||||||
|
newTopLeftX: number,
|
||||||
|
newTopLeftY: number,
|
||||||
|
originalElement: ExcalidrawElement,
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
originalElementsMap: ElementsMap,
|
||||||
|
shouldInformMutation = true,
|
||||||
|
) => {
|
||||||
|
const latestElement = elementsMap.get(originalElement.id);
|
||||||
|
if (!latestElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [cx, cy] = [
|
||||||
|
originalElement.x + originalElement.width / 2,
|
||||||
|
originalElement.y + originalElement.height / 2,
|
||||||
|
];
|
||||||
|
const [topLeftX, topLeftY] = rotate(
|
||||||
|
originalElement.x,
|
||||||
|
originalElement.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
originalElement.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeInX = newTopLeftX - topLeftX;
|
||||||
|
const changeInY = newTopLeftY - topLeftY;
|
||||||
|
|
||||||
|
const [x, y] = rotate(
|
||||||
|
newTopLeftX,
|
||||||
|
newTopLeftY,
|
||||||
|
cx + changeInX,
|
||||||
|
cy + changeInY,
|
||||||
|
-originalElement.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
mutateElement(
|
||||||
|
latestElement,
|
||||||
|
{
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
},
|
||||||
|
shouldInformMutation,
|
||||||
|
);
|
||||||
|
updateBindings(latestElement, elementsMap);
|
||||||
|
|
||||||
|
const boundTextElement = getBoundTextElement(
|
||||||
|
originalElement,
|
||||||
|
originalElementsMap,
|
||||||
|
);
|
||||||
|
if (boundTextElement) {
|
||||||
|
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
|
||||||
|
latestBoundTextElement &&
|
||||||
|
mutateElement(
|
||||||
|
latestBoundTextElement,
|
||||||
|
{
|
||||||
|
x: boundTextElement.x + changeInX,
|
||||||
|
y: boundTextElement.y + changeInY,
|
||||||
|
},
|
||||||
|
shouldInformMutation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAtomicUnits = (
|
||||||
|
targetElements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||||
|
const _atomicUnits = selectedGroupIds.map((gid) => {
|
||||||
|
return getElementsInGroup(targetElements, gid).reduce((acc, el) => {
|
||||||
|
acc[el.id] = true;
|
||||||
|
return acc;
|
||||||
|
}, {} as AtomicUnit);
|
||||||
|
});
|
||||||
|
targetElements
|
||||||
|
.filter((el) => !isInGroup(el))
|
||||||
|
.forEach((el) => {
|
||||||
|
_atomicUnits.push({
|
||||||
|
[el.id]: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return _atomicUnits;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateBindings = (
|
||||||
|
latestElement: ExcalidrawElement,
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
options?: {
|
||||||
|
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
||||||
|
newSize?: { width: number; height: number };
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if (isLinearElement(latestElement)) {
|
||||||
|
bindOrUnbindLinearElements([latestElement], elementsMap, true, []);
|
||||||
|
} else {
|
||||||
|
updateBoundElements(latestElement, elementsMap, options);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
|
import type { MermaidConfig } from "@excalidraw/mermaid-to-excalidraw";
|
||||||
import type { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
|
import type { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
|
||||||
import {
|
import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "../../constants";
|
||||||
DEFAULT_EXPORT_PADDING,
|
|
||||||
DEFAULT_FONT_SIZE,
|
|
||||||
EDITOR_LS_KEYS,
|
|
||||||
} from "../../constants";
|
|
||||||
import { convertToExcalidrawElements, exportToCanvas } from "../../index";
|
import { convertToExcalidrawElements, exportToCanvas } from "../../index";
|
||||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||||
import type { AppClassProperties, BinaryFiles } from "../../types";
|
import type { AppClassProperties, BinaryFiles } from "../../types";
|
||||||
@@ -38,7 +34,7 @@ export interface MermaidToExcalidrawLibProps {
|
|||||||
api: Promise<{
|
api: Promise<{
|
||||||
parseMermaidToExcalidraw: (
|
parseMermaidToExcalidraw: (
|
||||||
definition: string,
|
definition: string,
|
||||||
options: MermaidOptions,
|
config?: MermaidConfig,
|
||||||
) => Promise<MermaidToExcalidrawResult>;
|
) => Promise<MermaidToExcalidrawResult>;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
@@ -78,15 +74,10 @@ export const convertMermaidToExcalidraw = async ({
|
|||||||
|
|
||||||
let ret;
|
let ret;
|
||||||
try {
|
try {
|
||||||
ret = await api.parseMermaidToExcalidraw(mermaidDefinition, {
|
ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
|
||||||
fontSize: DEFAULT_FONT_SIZE,
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ret = await api.parseMermaidToExcalidraw(
|
ret = await api.parseMermaidToExcalidraw(
|
||||||
mermaidDefinition.replace(/"/g, "'"),
|
mermaidDefinition.replace(/"/g, "'"),
|
||||||
{
|
|
||||||
fontSize: DEFAULT_FONT_SIZE,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { elements, files } = ret;
|
const { elements, files } = ret;
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import type {
|
|||||||
RenderableElementsMap,
|
RenderableElementsMap,
|
||||||
RenderInteractiveSceneCallback,
|
RenderInteractiveSceneCallback,
|
||||||
} from "../../scene/types";
|
} from "../../scene/types";
|
||||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
import type {
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
|
} from "../../element/types";
|
||||||
import { isRenderThrottlingEnabled } from "../../reactUtils";
|
import { isRenderThrottlingEnabled } from "../../reactUtils";
|
||||||
import { renderInteractiveScene } from "../../renderer/interactiveScene";
|
import { renderInteractiveScene } from "../../renderer/interactiveScene";
|
||||||
|
|
||||||
@@ -19,7 +22,8 @@ type InteractiveCanvasProps = {
|
|||||||
elementsMap: RenderableElementsMap;
|
elementsMap: RenderableElementsMap;
|
||||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||||
versionNonce: number | undefined;
|
allElementsMap: NonDeletedSceneElementsMap;
|
||||||
|
sceneNonce: number | undefined;
|
||||||
selectionNonce: number | undefined;
|
selectionNonce: number | undefined;
|
||||||
scale: number;
|
scale: number;
|
||||||
appState: InteractiveCanvasAppState;
|
appState: InteractiveCanvasAppState;
|
||||||
@@ -122,6 +126,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
|||||||
elementsMap: props.elementsMap,
|
elementsMap: props.elementsMap,
|
||||||
visibleElements: props.visibleElements,
|
visibleElements: props.visibleElements,
|
||||||
selectedElements: props.selectedElements,
|
selectedElements: props.selectedElements,
|
||||||
|
allElementsMap: props.allElementsMap,
|
||||||
scale: window.devicePixelRatio,
|
scale: window.devicePixelRatio,
|
||||||
appState: props.appState,
|
appState: props.appState,
|
||||||
renderConfig: {
|
renderConfig: {
|
||||||
@@ -197,6 +202,7 @@ const getRelevantAppStateProps = (
|
|||||||
activeEmbeddable: appState.activeEmbeddable,
|
activeEmbeddable: appState.activeEmbeddable,
|
||||||
snapLines: appState.snapLines,
|
snapLines: appState.snapLines,
|
||||||
zenModeEnabled: appState.zenModeEnabled,
|
zenModeEnabled: appState.zenModeEnabled,
|
||||||
|
editingElement: appState.editingElement,
|
||||||
});
|
});
|
||||||
|
|
||||||
const areEqual = (
|
const areEqual = (
|
||||||
@@ -206,10 +212,10 @@ const areEqual = (
|
|||||||
// This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation
|
// This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation
|
||||||
if (
|
if (
|
||||||
prevProps.selectionNonce !== nextProps.selectionNonce ||
|
prevProps.selectionNonce !== nextProps.selectionNonce ||
|
||||||
prevProps.versionNonce !== nextProps.versionNonce ||
|
prevProps.sceneNonce !== nextProps.sceneNonce ||
|
||||||
prevProps.scale !== nextProps.scale ||
|
prevProps.scale !== nextProps.scale ||
|
||||||
// we need to memoize on elementsMap because they may have renewed
|
// we need to memoize on elementsMap because they may have renewed
|
||||||
// even if versionNonce didn't change (e.g. we filter elements out based
|
// even if sceneNonce didn't change (e.g. we filter elements out based
|
||||||
// on appState)
|
// on appState)
|
||||||
prevProps.elementsMap !== nextProps.elementsMap ||
|
prevProps.elementsMap !== nextProps.elementsMap ||
|
||||||
prevProps.visibleElements !== nextProps.visibleElements ||
|
prevProps.visibleElements !== nextProps.visibleElements ||
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type StaticCanvasProps = {
|
|||||||
elementsMap: RenderableElementsMap;
|
elementsMap: RenderableElementsMap;
|
||||||
allElementsMap: NonDeletedSceneElementsMap;
|
allElementsMap: NonDeletedSceneElementsMap;
|
||||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||||
versionNonce: number | undefined;
|
sceneNonce: number | undefined;
|
||||||
selectionNonce: number | undefined;
|
selectionNonce: number | undefined;
|
||||||
scale: number;
|
scale: number;
|
||||||
appState: StaticCanvasAppState;
|
appState: StaticCanvasAppState;
|
||||||
@@ -112,10 +112,10 @@ const areEqual = (
|
|||||||
nextProps: StaticCanvasProps,
|
nextProps: StaticCanvasProps,
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
prevProps.versionNonce !== nextProps.versionNonce ||
|
prevProps.sceneNonce !== nextProps.sceneNonce ||
|
||||||
prevProps.scale !== nextProps.scale ||
|
prevProps.scale !== nextProps.scale ||
|
||||||
// we need to memoize on elementsMap because they may have renewed
|
// we need to memoize on elementsMap because they may have renewed
|
||||||
// even if versionNonce didn't change (e.g. we filter elements out based
|
// even if sceneNonce didn't change (e.g. we filter elements out based
|
||||||
// on appState)
|
// on appState)
|
||||||
prevProps.elementsMap !== nextProps.elementsMap ||
|
prevProps.elementsMap !== nextProps.elementsMap ||
|
||||||
prevProps.visibleElements !== nextProps.visibleElements
|
prevProps.visibleElements !== nextProps.visibleElements
|
||||||
|
|||||||
@@ -698,14 +698,18 @@ export const BringForwardIcon = createIcon(arrownNarrowUpJSX, tablerIconProps);
|
|||||||
|
|
||||||
export const SendBackwardIcon = createIcon(arrownNarrowUpJSX, {
|
export const SendBackwardIcon = createIcon(arrownNarrowUpJSX, {
|
||||||
...tablerIconProps,
|
...tablerIconProps,
|
||||||
transform: "rotate(180)",
|
style: {
|
||||||
|
transform: "rotate(180deg)",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const BringToFrontIcon = createIcon(arrowBarToTopJSX, tablerIconProps);
|
export const BringToFrontIcon = createIcon(arrowBarToTopJSX, tablerIconProps);
|
||||||
|
|
||||||
export const SendToBackIcon = createIcon(arrowBarToTopJSX, {
|
export const SendToBackIcon = createIcon(arrowBarToTopJSX, {
|
||||||
...tablerIconProps,
|
...tablerIconProps,
|
||||||
transform: "rotate(180)",
|
style: {
|
||||||
|
transform: "rotate(180deg)",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -1569,6 +1573,18 @@ export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) =>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const angleIcon = createIcon(
|
||||||
|
<g>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M21 19h-18l9 -15" />
|
||||||
|
<path d="M20.615 15.171h.015" />
|
||||||
|
<path d="M19.515 11.771h.015" />
|
||||||
|
<path d="M17.715 8.671h.015" />
|
||||||
|
<path d="M15.415 5.971h.015" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
export const publishIcon = createIcon(
|
export const publishIcon = createIcon(
|
||||||
<path
|
<path
|
||||||
d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"
|
d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"
|
||||||
@@ -2057,3 +2073,19 @@ export const lineEditorIcon = createIcon(
|
|||||||
</g>,
|
</g>,
|
||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const collapseDownIcon = createIcon(
|
||||||
|
<g>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M6 9l6 6l6 -6" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const collapseUpIcon = createIcon(
|
||||||
|
<g>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M6 15l6 -6l6 6" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ export const supportsResizeObserver =
|
|||||||
|
|
||||||
export const APP_NAME = "Excalidraw";
|
export const APP_NAME = "Excalidraw";
|
||||||
|
|
||||||
|
// distance when creating text before it's considered `autoResize: false`
|
||||||
|
// we're using higher threshold so that clicks that end up being drags
|
||||||
|
// don't unintentionally create text elements that are wrapped to a few chars
|
||||||
|
// (happens a lot with fast clicks with the text tool)
|
||||||
|
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
|
||||||
export const DRAGGING_THRESHOLD = 10; // px
|
export const DRAGGING_THRESHOLD = 10; // px
|
||||||
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
||||||
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||||
@@ -269,7 +274,7 @@ export const DEFAULT_EXPORT_PADDING = 10; // px
|
|||||||
|
|
||||||
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
||||||
|
|
||||||
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
|
||||||
|
|
||||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
@@ -400,3 +405,7 @@ export const EDITOR_LS_KEYS = {
|
|||||||
* where filename is optional and we can't retrieve name from app state
|
* where filename is optional and we can't retrieve name from app state
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_FILENAME = "Untitled";
|
export const DEFAULT_FILENAME = "Untitled";
|
||||||
|
|
||||||
|
export const STATS_PANELS = { generalStats: 1, elementProperties: 2 } as const;
|
||||||
|
|
||||||
|
export const MIN_WIDTH_OR_HEIGHT = 1;
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
--sat: env(safe-area-inset-top);
|
--sat: env(safe-area-inset-top);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.excalidraw-cursor-resize,
|
||||||
|
body.excalidraw-cursor-resize a:hover,
|
||||||
|
body.excalidraw-cursor-resize * {
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
||||||
Roboto, Helvetica, Arial, sans-serif;
|
Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||||||
exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 1`] = `
|
exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 1`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": [
|
"boundElements": [
|
||||||
{
|
{
|
||||||
@@ -273,6 +274,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
|||||||
exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 2`] = `
|
exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 2`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": [
|
"boundElements": [
|
||||||
{
|
{
|
||||||
@@ -378,6 +380,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
|||||||
exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 4`] = `
|
exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 4`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id48",
|
"containerId": "id48",
|
||||||
@@ -478,6 +481,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
|||||||
exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = `
|
exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id37",
|
"containerId": "id37",
|
||||||
@@ -652,6 +656,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
|||||||
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 2`] = `
|
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 2`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id41",
|
"containerId": "id41",
|
||||||
@@ -692,6 +697,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
|||||||
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 3`] = `
|
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 3`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": [
|
"boundElements": [
|
||||||
{
|
{
|
||||||
@@ -737,6 +743,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
|||||||
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 4`] = `
|
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 4`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": [
|
"boundElements": [
|
||||||
{
|
{
|
||||||
@@ -1194,6 +1201,7 @@ exports[`Test Transform > should transform regular shapes 6`] = `
|
|||||||
exports[`Test Transform > should transform text element 1`] = `
|
exports[`Test Transform > should transform text element 1`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": null,
|
"containerId": null,
|
||||||
@@ -1234,6 +1242,7 @@ exports[`Test Transform > should transform text element 1`] = `
|
|||||||
exports[`Test Transform > should transform text element 2`] = `
|
exports[`Test Transform > should transform text element 2`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": null,
|
"containerId": null,
|
||||||
@@ -1566,6 +1575,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||||||
exports[`Test Transform > should transform the elements correctly when linear elements have single point 7`] = `
|
exports[`Test Transform > should transform the elements correctly when linear elements have single point 7`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "B",
|
"containerId": "B",
|
||||||
@@ -1608,6 +1618,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||||||
exports[`Test Transform > should transform the elements correctly when linear elements have single point 8`] = `
|
exports[`Test Transform > should transform the elements correctly when linear elements have single point 8`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "A",
|
"containerId": "A",
|
||||||
@@ -1650,6 +1661,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||||||
exports[`Test Transform > should transform the elements correctly when linear elements have single point 9`] = `
|
exports[`Test Transform > should transform the elements correctly when linear elements have single point 9`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "Alice",
|
"containerId": "Alice",
|
||||||
@@ -1692,6 +1704,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||||||
exports[`Test Transform > should transform the elements correctly when linear elements have single point 10`] = `
|
exports[`Test Transform > should transform the elements correctly when linear elements have single point 10`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "Bob",
|
"containerId": "Bob",
|
||||||
@@ -1734,6 +1747,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||||||
exports[`Test Transform > should transform the elements correctly when linear elements have single point 11`] = `
|
exports[`Test Transform > should transform the elements correctly when linear elements have single point 11`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "Bob_Alice",
|
"containerId": "Bob_Alice",
|
||||||
@@ -1774,6 +1788,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||||||
exports[`Test Transform > should transform the elements correctly when linear elements have single point 12`] = `
|
exports[`Test Transform > should transform the elements correctly when linear elements have single point 12`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "Bob_B",
|
"containerId": "Bob_B",
|
||||||
@@ -2022,6 +2037,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
|||||||
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 5`] = `
|
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 5`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id25",
|
"containerId": "id25",
|
||||||
@@ -2062,6 +2078,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
|||||||
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 6`] = `
|
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 6`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id26",
|
"containerId": "id26",
|
||||||
@@ -2102,6 +2119,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
|||||||
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 7`] = `
|
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 7`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id27",
|
"containerId": "id27",
|
||||||
@@ -2143,6 +2161,7 @@ LABELLED ARROW",
|
|||||||
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 8`] = `
|
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 8`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id28",
|
"containerId": "id28",
|
||||||
@@ -2406,6 +2425,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
|||||||
exports[`Test Transform > should transform to text containers when label provided 7`] = `
|
exports[`Test Transform > should transform to text containers when label provided 7`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id13",
|
"containerId": "id13",
|
||||||
@@ -2446,6 +2466,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
|||||||
exports[`Test Transform > should transform to text containers when label provided 8`] = `
|
exports[`Test Transform > should transform to text containers when label provided 8`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id14",
|
"containerId": "id14",
|
||||||
@@ -2487,6 +2508,7 @@ CONTAINER",
|
|||||||
exports[`Test Transform > should transform to text containers when label provided 9`] = `
|
exports[`Test Transform > should transform to text containers when label provided 9`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id15",
|
"containerId": "id15",
|
||||||
@@ -2530,6 +2552,7 @@ CONTAINER",
|
|||||||
exports[`Test Transform > should transform to text containers when label provided 10`] = `
|
exports[`Test Transform > should transform to text containers when label provided 10`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id16",
|
"containerId": "id16",
|
||||||
@@ -2571,6 +2594,7 @@ TEXT CONTAINER",
|
|||||||
exports[`Test Transform > should transform to text containers when label provided 11`] = `
|
exports[`Test Transform > should transform to text containers when label provided 11`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id17",
|
"containerId": "id17",
|
||||||
@@ -2613,6 +2637,7 @@ CONTAINER",
|
|||||||
exports[`Test Transform > should transform to text containers when label provided 12`] = `
|
exports[`Test Transform > should transform to text containers when label provided 12`] = `
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id18",
|
"containerId": "id18",
|
||||||
|
|||||||
@@ -123,10 +123,26 @@ export const loadSceneOrLibraryFromBlob = async (
|
|||||||
fileHandle?: FileSystemHandle | null,
|
fileHandle?: FileSystemHandle | null,
|
||||||
) => {
|
) => {
|
||||||
const contents = await parseFileContents(blob);
|
const contents = await parseFileContents(blob);
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
|
|
||||||
|
// assume Obsidian excalidraw plugin file
|
||||||
|
if (blob.name?.endsWith(".excalidraw.md")) {
|
||||||
|
if (contents.indexOf("```compressed-json") > -1) {
|
||||||
|
let str = contents.slice(
|
||||||
|
contents.indexOf("```compressed-json") + '"```compressed-json'.length,
|
||||||
|
);
|
||||||
|
str = str.slice(0, str.indexOf("```"));
|
||||||
|
str = str.replace(/\n/g, "").replace(/\r/g, "");
|
||||||
|
const LZString = await import("lz-string");
|
||||||
|
|
||||||
|
data = JSON.parse(LZString.decompressFromBase64(str));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(contents);
|
data = data || JSON.parse(contents);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (isSupportedImageFile(blob)) {
|
if (isSupportedImageFile(blob)) {
|
||||||
throw new ImageSceneDataError(
|
throw new ImageSceneDataError(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawElementType,
|
ExcalidrawElementType,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawSelectionElement,
|
ExcalidrawSelectionElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
@@ -21,7 +22,12 @@ import {
|
|||||||
isInvisiblySmallElement,
|
isInvisiblySmallElement,
|
||||||
refreshTextDimensions,
|
refreshTextDimensions,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
import { isTextElement, isUsingAdaptiveRadius } from "../element/typeChecks";
|
import {
|
||||||
|
isArrowElement,
|
||||||
|
isLinearElement,
|
||||||
|
isTextElement,
|
||||||
|
isUsingAdaptiveRadius,
|
||||||
|
} from "../element/typeChecks";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import {
|
import {
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
@@ -45,6 +51,7 @@ import {
|
|||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import { normalizeLink } from "./url";
|
import { normalizeLink } from "./url";
|
||||||
import { syncInvalidIndices } from "../fractionalIndex";
|
import { syncInvalidIndices } from "../fractionalIndex";
|
||||||
|
import { getSizeFromPoints } from "../points";
|
||||||
|
|
||||||
type RestoredAppState = Omit<
|
type RestoredAppState = Omit<
|
||||||
AppState,
|
AppState,
|
||||||
@@ -208,7 +215,7 @@ const restoreElement = (
|
|||||||
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
|
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
|
||||||
containerId: element.containerId ?? null,
|
containerId: element.containerId ?? null,
|
||||||
originalText: element.originalText || text,
|
originalText: element.originalText || text,
|
||||||
|
autoResize: element.autoResize ?? true,
|
||||||
lineHeight,
|
lineHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -270,6 +277,7 @@ const restoreElement = (
|
|||||||
points,
|
points,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
|
...getSizeFromPoints(points),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,6 +466,23 @@ 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;
|
return restoredElements;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import type {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import { getElementAbsoluteCoords } from "./bounds";
|
import { getElementAbsoluteCoords } from "./bounds";
|
||||||
import type { AppClassProperties, AppState, Point } from "../types";
|
import type { AppState, Point } from "../types";
|
||||||
import { isPointOnShape } from "../../utils/collision";
|
import { isPointOnShape } from "../../utils/collision";
|
||||||
import { getElementAtPosition } from "../scene";
|
import { getElementAtPosition } from "../scene";
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +43,7 @@ import { LinearElementEditor } from "./linearElementEditor";
|
|||||||
import { arrayToMap, tupleToCoors } from "../utils";
|
import { arrayToMap, tupleToCoors } from "../utils";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||||
|
import { getElementShape } from "../shapes";
|
||||||
|
|
||||||
export type SuggestedBinding =
|
export type SuggestedBinding =
|
||||||
| NonDeleted<ExcalidrawBindableElement>
|
| NonDeleted<ExcalidrawBindableElement>
|
||||||
@@ -179,19 +180,19 @@ const bindOrUnbindLinearElementEdge = (
|
|||||||
const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
edge: "start" | "end",
|
edge: "start" | "end",
|
||||||
app: AppClassProperties,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
): NonDeleted<ExcalidrawElement> | null => {
|
): NonDeleted<ExcalidrawElement> | null => {
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
|
||||||
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
|
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
|
||||||
const elementId =
|
const elementId =
|
||||||
edge === "start"
|
edge === "start"
|
||||||
? linearElement.startBinding?.elementId
|
? linearElement.startBinding?.elementId
|
||||||
: linearElement.endBinding?.elementId;
|
: linearElement.endBinding?.elementId;
|
||||||
if (elementId) {
|
if (elementId) {
|
||||||
const element = elementsMap.get(
|
const element = elementsMap.get(elementId);
|
||||||
elementId,
|
if (
|
||||||
) as NonDeleted<ExcalidrawBindableElement>;
|
isBindableElement(element) &&
|
||||||
if (bindingBorderTest(element, coors, app)) {
|
bindingBorderTest(element, coors, elementsMap)
|
||||||
|
) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,13 +202,13 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
|||||||
|
|
||||||
const getOriginalBindingsIfStillCloseToArrowEnds = (
|
const getOriginalBindingsIfStillCloseToArrowEnds = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
app: AppClassProperties,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
): (NonDeleted<ExcalidrawElement> | null)[] =>
|
): (NonDeleted<ExcalidrawElement> | null)[] =>
|
||||||
["start", "end"].map((edge) =>
|
["start", "end"].map((edge) =>
|
||||||
getOriginalBindingIfStillCloseOfLinearElementEdge(
|
getOriginalBindingIfStillCloseOfLinearElementEdge(
|
||||||
linearElement,
|
linearElement,
|
||||||
edge as "start" | "end",
|
edge as "start" | "end",
|
||||||
app,
|
elementsMap,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -215,7 +216,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||||||
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
isBindingEnabled: boolean,
|
isBindingEnabled: boolean,
|
||||||
draggingPoints: readonly number[],
|
draggingPoints: readonly number[],
|
||||||
app: AppClassProperties,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
|
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
|
||||||
const startIdx = 0;
|
const startIdx = 0;
|
||||||
const endIdx = selectedElement.points.length - 1;
|
const endIdx = selectedElement.points.length - 1;
|
||||||
@@ -223,37 +224,57 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||||||
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
|
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
|
||||||
const start = startDragged
|
const start = startDragged
|
||||||
? isBindingEnabled
|
? isBindingEnabled
|
||||||
? getElligibleElementForBindingElement(selectedElement, "start", app)
|
? getElligibleElementForBindingElement(
|
||||||
|
selectedElement,
|
||||||
|
"start",
|
||||||
|
elementsMap,
|
||||||
|
)
|
||||||
: null // If binding is disabled and start is dragged, break all binds
|
: 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
|
: // We have to update the focus and gap of the binding, so let's rebind
|
||||||
getElligibleElementForBindingElement(selectedElement, "start", app);
|
getElligibleElementForBindingElement(
|
||||||
|
selectedElement,
|
||||||
|
"start",
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
const end = endDragged
|
const end = endDragged
|
||||||
? isBindingEnabled
|
? isBindingEnabled
|
||||||
? getElligibleElementForBindingElement(selectedElement, "end", app)
|
? getElligibleElementForBindingElement(
|
||||||
|
selectedElement,
|
||||||
|
"end",
|
||||||
|
elementsMap,
|
||||||
|
)
|
||||||
: null // If binding is disabled and end is dragged, break all binds
|
: 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
|
: // We have to update the focus and gap of the binding, so let's rebind
|
||||||
getElligibleElementForBindingElement(selectedElement, "end", app);
|
getElligibleElementForBindingElement(selectedElement, "end", elementsMap);
|
||||||
|
|
||||||
return [start, end];
|
return [start, end];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBindingStrategyForDraggingArrowOrJoints = (
|
const getBindingStrategyForDraggingArrowOrJoints = (
|
||||||
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
app: AppClassProperties,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
isBindingEnabled: boolean,
|
isBindingEnabled: boolean,
|
||||||
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
|
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
|
||||||
const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds(
|
const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds(
|
||||||
selectedElement,
|
selectedElement,
|
||||||
app,
|
elementsMap,
|
||||||
);
|
);
|
||||||
const start = startIsClose
|
const start = startIsClose
|
||||||
? isBindingEnabled
|
? isBindingEnabled
|
||||||
? getElligibleElementForBindingElement(selectedElement, "start", app)
|
? getElligibleElementForBindingElement(
|
||||||
|
selectedElement,
|
||||||
|
"start",
|
||||||
|
elementsMap,
|
||||||
|
)
|
||||||
: null
|
: null
|
||||||
: null;
|
: null;
|
||||||
const end = endIsClose
|
const end = endIsClose
|
||||||
? isBindingEnabled
|
? isBindingEnabled
|
||||||
? getElligibleElementForBindingElement(selectedElement, "end", app)
|
? getElligibleElementForBindingElement(
|
||||||
|
selectedElement,
|
||||||
|
"end",
|
||||||
|
elementsMap,
|
||||||
|
)
|
||||||
: null
|
: null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -262,7 +283,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
|||||||
|
|
||||||
export const bindOrUnbindLinearElements = (
|
export const bindOrUnbindLinearElements = (
|
||||||
selectedElements: NonDeleted<ExcalidrawLinearElement>[],
|
selectedElements: NonDeleted<ExcalidrawLinearElement>[],
|
||||||
app: AppClassProperties,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
isBindingEnabled: boolean,
|
isBindingEnabled: boolean,
|
||||||
draggingPoints: readonly number[] | null,
|
draggingPoints: readonly number[] | null,
|
||||||
): void => {
|
): void => {
|
||||||
@@ -273,27 +294,22 @@ export const bindOrUnbindLinearElements = (
|
|||||||
selectedElement,
|
selectedElement,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
draggingPoints ?? [],
|
draggingPoints ?? [],
|
||||||
app,
|
elementsMap,
|
||||||
)
|
)
|
||||||
: // The arrow itself (the shaft) or the inner joins are dragged
|
: // The arrow itself (the shaft) or the inner joins are dragged
|
||||||
getBindingStrategyForDraggingArrowOrJoints(
|
getBindingStrategyForDraggingArrowOrJoints(
|
||||||
selectedElement,
|
selectedElement,
|
||||||
app,
|
elementsMap,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
bindOrUnbindLinearElement(
|
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap);
|
||||||
selectedElement,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSuggestedBindingsForArrows = (
|
export const getSuggestedBindingsForArrows = (
|
||||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||||
app: AppClassProperties,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
): SuggestedBinding[] => {
|
): SuggestedBinding[] => {
|
||||||
// HOT PATH: Bail out if selected elements list is too large
|
// HOT PATH: Bail out if selected elements list is too large
|
||||||
if (selectedElements.length > 50) {
|
if (selectedElements.length > 50) {
|
||||||
@@ -304,7 +320,7 @@ export const getSuggestedBindingsForArrows = (
|
|||||||
selectedElements
|
selectedElements
|
||||||
.filter(isLinearElement)
|
.filter(isLinearElement)
|
||||||
.flatMap((element) =>
|
.flatMap((element) =>
|
||||||
getOriginalBindingsIfStillCloseToArrowEnds(element, app),
|
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap),
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
||||||
@@ -326,17 +342,20 @@ export const maybeBindLinearElement = (
|
|||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
pointerCoords: { x: number; y: number },
|
pointerCoords: { x: number; y: number },
|
||||||
app: AppClassProperties,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
): void => {
|
): void => {
|
||||||
if (appState.startBoundElement != null) {
|
if (appState.startBoundElement != null) {
|
||||||
bindLinearElement(
|
bindLinearElement(
|
||||||
linearElement,
|
linearElement,
|
||||||
appState.startBoundElement,
|
appState.startBoundElement,
|
||||||
"start",
|
"start",
|
||||||
app.scene.getNonDeletedElementsMap(),
|
elementsMap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const hoveredElement = getHoveredElementForBinding(pointerCoords, app);
|
const hoveredElement = getHoveredElementForBinding(
|
||||||
|
pointerCoords,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
hoveredElement != null &&
|
hoveredElement != null &&
|
||||||
!isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
|
!isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
|
||||||
@@ -345,12 +364,7 @@ export const maybeBindLinearElement = (
|
|||||||
"end",
|
"end",
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
bindLinearElement(
|
bindLinearElement(linearElement, hoveredElement, "end", elementsMap);
|
||||||
linearElement,
|
|
||||||
hoveredElement,
|
|
||||||
"end",
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -360,6 +374,9 @@ export const bindLinearElement = (
|
|||||||
startOrEnd: "start" | "end",
|
startOrEnd: "start" | "end",
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
): void => {
|
): void => {
|
||||||
|
if (!isArrowElement(linearElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
mutateElement(linearElement, {
|
mutateElement(linearElement, {
|
||||||
[startOrEnd === "start" ? "startBinding" : "endBinding"]: {
|
[startOrEnd === "start" ? "startBinding" : "endBinding"]: {
|
||||||
elementId: hoveredElement.id,
|
elementId: hoveredElement.id,
|
||||||
@@ -431,13 +448,13 @@ export const getHoveredElementForBinding = (
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
},
|
},
|
||||||
app: AppClassProperties,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||||
const hoveredElement = getElementAtPosition(
|
const hoveredElement = getElementAtPosition(
|
||||||
app.scene.getNonDeletedElements(),
|
[...elementsMap].map(([_, value]) => value),
|
||||||
(element) =>
|
(element) =>
|
||||||
isBindableElement(element, false) &&
|
isBindableElement(element, false) &&
|
||||||
bindingBorderTest(element, pointerCoords, app),
|
bindingBorderTest(element, pointerCoords, elementsMap),
|
||||||
);
|
);
|
||||||
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
|
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
|
||||||
};
|
};
|
||||||
@@ -661,15 +678,11 @@ const maybeCalculateNewGapWhenScaling = (
|
|||||||
const getElligibleElementForBindingElement = (
|
const getElligibleElementForBindingElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
startOrEnd: "start" | "end",
|
startOrEnd: "start" | "end",
|
||||||
app: AppClassProperties,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||||
return getHoveredElementForBinding(
|
return getHoveredElementForBinding(
|
||||||
getLinearElementEdgeCoors(
|
getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
|
||||||
linearElement,
|
elementsMap,
|
||||||
startOrEnd,
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
),
|
|
||||||
app,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -706,6 +719,9 @@ export const fixBindingsAfterDuplication = (
|
|||||||
const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||||
const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||||
const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
|
const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
|
||||||
|
const duplicateIdToOldId = new Map(
|
||||||
|
[...oldIdToDuplicatedId].map(([key, value]) => [value, key]),
|
||||||
|
);
|
||||||
oldElements.forEach((oldElement) => {
|
oldElements.forEach((oldElement) => {
|
||||||
const { boundElements } = oldElement;
|
const { boundElements } = oldElement;
|
||||||
if (boundElements != null && boundElements.length > 0) {
|
if (boundElements != null && boundElements.length > 0) {
|
||||||
@@ -755,7 +771,11 @@ export const fixBindingsAfterDuplication = (
|
|||||||
sceneElements
|
sceneElements
|
||||||
.filter(({ id }) => allBindableElementIds.has(id))
|
.filter(({ id }) => allBindableElementIds.has(id))
|
||||||
.forEach((bindableElement) => {
|
.forEach((bindableElement) => {
|
||||||
const { boundElements } = bindableElement;
|
const oldElementId = duplicateIdToOldId.get(bindableElement.id);
|
||||||
|
const { boundElements } = sceneElements.find(
|
||||||
|
({ id }) => id === oldElementId,
|
||||||
|
)!;
|
||||||
|
|
||||||
if (boundElements != null && boundElements.length > 0) {
|
if (boundElements != null && boundElements.length > 0) {
|
||||||
mutateElement(bindableElement, {
|
mutateElement(bindableElement, {
|
||||||
boundElements: boundElements.map((boundElement) =>
|
boundElements: boundElements.map((boundElement) =>
|
||||||
@@ -826,10 +846,10 @@ const newBoundElements = (
|
|||||||
const bindingBorderTest = (
|
const bindingBorderTest = (
|
||||||
element: NonDeleted<ExcalidrawBindableElement>,
|
element: NonDeleted<ExcalidrawBindableElement>,
|
||||||
{ x, y }: { x: number; y: number },
|
{ x, y }: { x: number; y: number },
|
||||||
app: AppClassProperties,
|
elementsMap: ElementsMap,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const threshold = maxBindingGap(element, element.width, element.height);
|
const threshold = maxBindingGap(element, element.width, element.height);
|
||||||
const shape = app.getElementShape(element);
|
const shape = getElementShape(element, elementsMap);
|
||||||
return isPointOnShape([x, y], shape, threshold);
|
return isPointOnShape([x, y], shape, threshold);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ import { getCommonBounds } from "./bounds";
|
|||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import { getPerfectElementSize } from "./sizeHelpers";
|
import { getPerfectElementSize } from "./sizeHelpers";
|
||||||
import type { NonDeletedExcalidrawElement } from "./types";
|
import type { NonDeletedExcalidrawElement } from "./types";
|
||||||
import type { AppState, PointerDownState } from "../types";
|
import type { AppState, NormalizedZoomValue, PointerDownState } from "../types";
|
||||||
import { getBoundTextElement } from "./textElement";
|
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
|
||||||
import { getGridPoint } from "../math";
|
import { getGridPoint } from "../math";
|
||||||
import type Scene from "../scene/Scene";
|
import type Scene from "../scene/Scene";
|
||||||
import { isArrowElement, isFrameLikeElement } from "./typeChecks";
|
import {
|
||||||
|
isArrowElement,
|
||||||
|
isFrameLikeElement,
|
||||||
|
isTextElement,
|
||||||
|
} from "./typeChecks";
|
||||||
|
import { getFontString } from "../utils";
|
||||||
|
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
|
||||||
|
|
||||||
export const dragSelectedElements = (
|
export const dragSelectedElements = (
|
||||||
pointerDownState: PointerDownState,
|
pointerDownState: PointerDownState,
|
||||||
@@ -140,6 +146,7 @@ export const dragNewElement = (
|
|||||||
height: number,
|
height: number,
|
||||||
shouldMaintainAspectRatio: boolean,
|
shouldMaintainAspectRatio: boolean,
|
||||||
shouldResizeFromCenter: boolean,
|
shouldResizeFromCenter: boolean,
|
||||||
|
zoom: NormalizedZoomValue,
|
||||||
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
|
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
|
||||||
true */
|
true */
|
||||||
widthAspectRatio?: number | null,
|
widthAspectRatio?: number | null,
|
||||||
@@ -185,12 +192,41 @@ export const dragNewElement = (
|
|||||||
newY = originY - height / 2;
|
newY = originY - height / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let textAutoResize = null;
|
||||||
|
|
||||||
|
// NOTE this should apply only to creating text elements, not existing
|
||||||
|
// (once we rewrite appState.draggingElement to actually mean dragging
|
||||||
|
// elements)
|
||||||
|
if (isTextElement(draggingElement)) {
|
||||||
|
height = draggingElement.height;
|
||||||
|
const minWidth = getMinTextElementWidth(
|
||||||
|
getFontString({
|
||||||
|
fontSize: draggingElement.fontSize,
|
||||||
|
fontFamily: draggingElement.fontFamily,
|
||||||
|
}),
|
||||||
|
draggingElement.lineHeight,
|
||||||
|
);
|
||||||
|
width = Math.max(width, minWidth);
|
||||||
|
|
||||||
|
if (Math.abs(x - originX) > TEXT_AUTOWRAP_THRESHOLD / zoom) {
|
||||||
|
textAutoResize = {
|
||||||
|
autoResize: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
newY = originY;
|
||||||
|
if (shouldResizeFromCenter) {
|
||||||
|
newX = originX - width / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (width !== 0 && height !== 0) {
|
if (width !== 0 && height !== 0) {
|
||||||
mutateElement(draggingElement, {
|
mutateElement(draggingElement, {
|
||||||
x: newX + (originOffset?.x ?? 0),
|
x: newX + (originOffset?.x ?? 0),
|
||||||
y: newY + (originOffset?.y ?? 0),
|
y: newY + (originOffset?.y ?? 0),
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
...textAutoResize,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { isLinearElementType } from "./typeChecks";
|
|||||||
export {
|
export {
|
||||||
newElement,
|
newElement,
|
||||||
newTextElement,
|
newTextElement,
|
||||||
updateTextElement,
|
|
||||||
refreshTextDimensions,
|
refreshTextDimensions,
|
||||||
newLinearElement,
|
newLinearElement,
|
||||||
newImageElement,
|
newImageElement,
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ export class LinearElementEditor {
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
app,
|
elementsMap,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -715,7 +715,10 @@ export class LinearElementEditor {
|
|||||||
},
|
},
|
||||||
selectedPointsIndices: [element.points.length - 1],
|
selectedPointsIndices: [element.points.length - 1],
|
||||||
lastUncommittedPoint: null,
|
lastUncommittedPoint: null,
|
||||||
endBindingElement: getHoveredElementForBinding(scenePointer, app),
|
endBindingElement: getHoveredElementForBinding(
|
||||||
|
scenePointer,
|
||||||
|
elementsMap,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
ret.didAddPoint = true;
|
ret.didAddPoint = true;
|
||||||
@@ -1165,7 +1168,7 @@ export class LinearElementEditor {
|
|||||||
const nextPoints = points.map((point, idx) => {
|
const nextPoints = points.map((point, idx) => {
|
||||||
const selectedPointData = targetPoints.find((p) => p.index === idx);
|
const selectedPointData = targetPoints.find((p) => p.index === idx);
|
||||||
if (selectedPointData) {
|
if (selectedPointData) {
|
||||||
if (selectedOriginPoint) {
|
if (selectedPointData.index === 0) {
|
||||||
return point;
|
return point;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1174,7 +1177,10 @@ export class LinearElementEditor {
|
|||||||
const deltaY =
|
const deltaY =
|
||||||
selectedPointData.point[1] - points[selectedPointData.index][1];
|
selectedPointData.point[1] - points[selectedPointData.index][1];
|
||||||
|
|
||||||
return [point[0] + deltaX, point[1] + deltaY] as const;
|
return [
|
||||||
|
point[0] + deltaX - offsetX,
|
||||||
|
point[1] + deltaY - offsetY,
|
||||||
|
] as const;
|
||||||
}
|
}
|
||||||
return offsetX || offsetY
|
return offsetX || offsetY
|
||||||
? ([point[0] - offsetX, point[1] - offsetY] as const)
|
? ([point[0] - offsetX, point[1] - offsetY] as const)
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
element.updated = getUpdatedTimestamp();
|
element.updated = getUpdatedTimestamp();
|
||||||
|
|
||||||
if (informMutation) {
|
if (informMutation) {
|
||||||
Scene.getScene(element)?.informMutation();
|
Scene.getScene(element)?.triggerUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
return element;
|
return element;
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ const getTextElementPositionOffsets = (
|
|||||||
export const newTextElement = (
|
export const newTextElement = (
|
||||||
opts: {
|
opts: {
|
||||||
text: string;
|
text: string;
|
||||||
|
originalText?: string;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
fontFamily?: FontFamilyValues;
|
fontFamily?: FontFamilyValues;
|
||||||
textAlign?: TextAlign;
|
textAlign?: TextAlign;
|
||||||
@@ -222,6 +223,7 @@ export const newTextElement = (
|
|||||||
containerId?: ExcalidrawTextContainer["id"] | null;
|
containerId?: ExcalidrawTextContainer["id"] | null;
|
||||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||||
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
|
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
|
||||||
|
autoResize?: ExcalidrawTextElement["autoResize"];
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): NonDeleted<ExcalidrawTextElement> => {
|
): NonDeleted<ExcalidrawTextElement> => {
|
||||||
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
||||||
@@ -240,24 +242,28 @@ export const newTextElement = (
|
|||||||
metrics,
|
metrics,
|
||||||
);
|
);
|
||||||
|
|
||||||
const textElement = newElementWith(
|
const textElementProps: ExcalidrawTextElement = {
|
||||||
{
|
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
||||||
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
text,
|
||||||
text,
|
fontSize,
|
||||||
fontSize,
|
fontFamily,
|
||||||
fontFamily,
|
textAlign,
|
||||||
textAlign,
|
verticalAlign,
|
||||||
verticalAlign,
|
x: opts.x - offsets.x,
|
||||||
x: opts.x - offsets.x,
|
y: opts.y - offsets.y,
|
||||||
y: opts.y - offsets.y,
|
width: metrics.width,
|
||||||
width: metrics.width,
|
height: metrics.height,
|
||||||
height: metrics.height,
|
containerId: opts.containerId || null,
|
||||||
containerId: opts.containerId || null,
|
originalText: opts.originalText ?? text,
|
||||||
originalText: text,
|
autoResize: opts.autoResize ?? true,
|
||||||
lineHeight,
|
lineHeight,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
const textElement: ExcalidrawTextElement = newElementWith(
|
||||||
|
textElementProps,
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
return textElement;
|
return textElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -271,18 +277,25 @@ const getAdjustedDimensions = (
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
} => {
|
} => {
|
||||||
const { width: nextWidth, height: nextHeight } = measureText(
|
let { width: nextWidth, height: nextHeight } = measureText(
|
||||||
nextText,
|
nextText,
|
||||||
getFontString(element),
|
getFontString(element),
|
||||||
element.lineHeight,
|
element.lineHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// wrapped text
|
||||||
|
if (!element.autoResize) {
|
||||||
|
nextWidth = element.width;
|
||||||
|
}
|
||||||
|
|
||||||
const { textAlign, verticalAlign } = element;
|
const { textAlign, verticalAlign } = element;
|
||||||
let x: number;
|
let x: number;
|
||||||
let y: number;
|
let y: number;
|
||||||
if (
|
if (
|
||||||
textAlign === "center" &&
|
textAlign === "center" &&
|
||||||
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
|
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
|
||||||
!element.containerId
|
!element.containerId &&
|
||||||
|
element.autoResize
|
||||||
) {
|
) {
|
||||||
const prevMetrics = measureText(
|
const prevMetrics = measureText(
|
||||||
element.text,
|
element.text,
|
||||||
@@ -343,38 +356,19 @@ export const refreshTextDimensions = (
|
|||||||
if (textElement.isDeleted) {
|
if (textElement.isDeleted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (container) {
|
if (container || !textElement.autoResize) {
|
||||||
text = wrapText(
|
text = wrapText(
|
||||||
text,
|
text,
|
||||||
getFontString(textElement),
|
getFontString(textElement),
|
||||||
getBoundTextMaxWidth(container, textElement),
|
container
|
||||||
|
? getBoundTextMaxWidth(container, textElement)
|
||||||
|
: textElement.width,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const dimensions = getAdjustedDimensions(textElement, elementsMap, text);
|
const dimensions = getAdjustedDimensions(textElement, elementsMap, text);
|
||||||
return { text, ...dimensions };
|
return { text, ...dimensions };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateTextElement = (
|
|
||||||
textElement: ExcalidrawTextElement,
|
|
||||||
container: ExcalidrawTextContainer | null,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
{
|
|
||||||
text,
|
|
||||||
isDeleted,
|
|
||||||
originalText,
|
|
||||||
}: {
|
|
||||||
text: string;
|
|
||||||
isDeleted?: boolean;
|
|
||||||
originalText: string;
|
|
||||||
},
|
|
||||||
): ExcalidrawTextElement => {
|
|
||||||
return newElementWith(textElement, {
|
|
||||||
originalText,
|
|
||||||
isDeleted: isDeleted ?? textElement.isDeleted,
|
|
||||||
...refreshTextDimensions(textElement, container, elementsMap, originalText),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const newFreeDrawElement = (
|
export const newFreeDrawElement = (
|
||||||
opts: {
|
opts: {
|
||||||
type: "freedraw";
|
type: "freedraw";
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ import {
|
|||||||
handleBindTextResize,
|
handleBindTextResize,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
getApproxMinLineHeight,
|
getApproxMinLineHeight,
|
||||||
|
wrapText,
|
||||||
|
measureText,
|
||||||
|
getMinTextElementWidth,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { isInGroup } from "../groups";
|
import { isInGroup } from "../groups";
|
||||||
@@ -84,14 +87,9 @@ export const transformElements = (
|
|||||||
shouldRotateWithDiscreteAngle,
|
shouldRotateWithDiscreteAngle,
|
||||||
);
|
);
|
||||||
updateBoundElements(element, elementsMap);
|
updateBoundElements(element, elementsMap);
|
||||||
} else if (
|
} else if (isTextElement(element) && transformHandleType) {
|
||||||
isTextElement(element) &&
|
|
||||||
(transformHandleType === "nw" ||
|
|
||||||
transformHandleType === "ne" ||
|
|
||||||
transformHandleType === "sw" ||
|
|
||||||
transformHandleType === "se")
|
|
||||||
) {
|
|
||||||
resizeSingleTextElement(
|
resizeSingleTextElement(
|
||||||
|
originalElements,
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
transformHandleType,
|
transformHandleType,
|
||||||
@@ -180,7 +178,7 @@ const rotateSingleElement = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const rescalePointsInElement = (
|
export const rescalePointsInElement = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
@@ -197,7 +195,7 @@ const rescalePointsInElement = (
|
|||||||
}
|
}
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
const measureFontSizeFromWidth = (
|
export const measureFontSizeFromWidth = (
|
||||||
element: NonDeleted<ExcalidrawTextElement>,
|
element: NonDeleted<ExcalidrawTextElement>,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
nextWidth: number,
|
nextWidth: number,
|
||||||
@@ -223,9 +221,10 @@ const measureFontSizeFromWidth = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resizeSingleTextElement = (
|
const resizeSingleTextElement = (
|
||||||
|
originalElements: PointerDownState["originalElements"],
|
||||||
element: NonDeleted<ExcalidrawTextElement>,
|
element: NonDeleted<ExcalidrawTextElement>,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
transformHandleType: "nw" | "ne" | "sw" | "se",
|
transformHandleType: TransformHandleDirection,
|
||||||
shouldResizeFromCenter: boolean,
|
shouldResizeFromCenter: boolean,
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
@@ -245,17 +244,19 @@ const resizeSingleTextElement = (
|
|||||||
let scaleX = 0;
|
let scaleX = 0;
|
||||||
let scaleY = 0;
|
let scaleY = 0;
|
||||||
|
|
||||||
if (transformHandleType.includes("e")) {
|
if (transformHandleType !== "e" && transformHandleType !== "w") {
|
||||||
scaleX = (rotatedX - x1) / (x2 - x1);
|
if (transformHandleType.includes("e")) {
|
||||||
}
|
scaleX = (rotatedX - x1) / (x2 - x1);
|
||||||
if (transformHandleType.includes("w")) {
|
}
|
||||||
scaleX = (x2 - rotatedX) / (x2 - x1);
|
if (transformHandleType.includes("w")) {
|
||||||
}
|
scaleX = (x2 - rotatedX) / (x2 - x1);
|
||||||
if (transformHandleType.includes("n")) {
|
}
|
||||||
scaleY = (y2 - rotatedY) / (y2 - y1);
|
if (transformHandleType.includes("n")) {
|
||||||
}
|
scaleY = (y2 - rotatedY) / (y2 - y1);
|
||||||
if (transformHandleType.includes("s")) {
|
}
|
||||||
scaleY = (rotatedY - y1) / (y2 - y1);
|
if (transformHandleType.includes("s")) {
|
||||||
|
scaleY = (rotatedY - y1) / (y2 - y1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const scale = Math.max(scaleX, scaleY);
|
const scale = Math.max(scaleX, scaleY);
|
||||||
@@ -318,6 +319,107 @@ const resizeSingleTextElement = (
|
|||||||
y: nextY,
|
y: nextY,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (transformHandleType === "e" || transformHandleType === "w") {
|
||||||
|
const stateAtResizeStart = originalElements.get(element.id)!;
|
||||||
|
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
|
||||||
|
stateAtResizeStart,
|
||||||
|
stateAtResizeStart.width,
|
||||||
|
stateAtResizeStart.height,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const startTopLeft: Point = [x1, y1];
|
||||||
|
const startBottomRight: Point = [x2, y2];
|
||||||
|
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
|
||||||
|
|
||||||
|
const rotatedPointer = rotatePoint(
|
||||||
|
[pointerX, pointerY],
|
||||||
|
startCenter,
|
||||||
|
-stateAtResizeStart.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
|
||||||
|
element,
|
||||||
|
element.width,
|
||||||
|
element.height,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const boundsCurrentWidth = esx2 - esx1;
|
||||||
|
|
||||||
|
const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
|
||||||
|
const minWidth = getMinTextElementWidth(
|
||||||
|
getFontString({
|
||||||
|
fontSize: element.fontSize,
|
||||||
|
fontFamily: element.fontFamily,
|
||||||
|
}),
|
||||||
|
element.lineHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
||||||
|
|
||||||
|
if (transformHandleType.includes("e")) {
|
||||||
|
scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
|
||||||
|
}
|
||||||
|
if (transformHandleType.includes("w")) {
|
||||||
|
scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newWidth =
|
||||||
|
element.width * scaleX < minWidth ? minWidth : element.width * scaleX;
|
||||||
|
|
||||||
|
const text = wrapText(
|
||||||
|
element.originalText,
|
||||||
|
getFontString(element),
|
||||||
|
Math.abs(newWidth),
|
||||||
|
);
|
||||||
|
const metrics = measureText(
|
||||||
|
text,
|
||||||
|
getFontString(element),
|
||||||
|
element.lineHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
const eleNewHeight = metrics.height;
|
||||||
|
|
||||||
|
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
||||||
|
getResizedElementAbsoluteCoords(
|
||||||
|
stateAtResizeStart,
|
||||||
|
newWidth,
|
||||||
|
eleNewHeight,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||||
|
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||||
|
|
||||||
|
let newTopLeft = [...startTopLeft] as [number, number];
|
||||||
|
if (["n", "w", "nw"].includes(transformHandleType)) {
|
||||||
|
newTopLeft = [
|
||||||
|
startBottomRight[0] - Math.abs(newBoundsWidth),
|
||||||
|
startTopLeft[1],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// adjust topLeft to new rotation point
|
||||||
|
const angle = stateAtResizeStart.angle;
|
||||||
|
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
|
||||||
|
const newCenter: Point = [
|
||||||
|
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||||
|
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||||
|
];
|
||||||
|
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
|
||||||
|
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
|
||||||
|
|
||||||
|
const resizedElement: Partial<ExcalidrawTextElement> = {
|
||||||
|
width: Math.abs(newWidth),
|
||||||
|
height: Math.abs(metrics.height),
|
||||||
|
x: newTopLeft[0],
|
||||||
|
y: newTopLeft[1],
|
||||||
|
text,
|
||||||
|
autoResize: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
mutateElement(element, resizedElement);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resizeSingleElement = (
|
export const resizeSingleElement = (
|
||||||
@@ -876,7 +978,7 @@ export const resizeMultipleElements = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scene.getScene(elementsAndUpdates[0].element)?.informMutation();
|
Scene.getScene(elementsAndUpdates[0].element)?.triggerUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
const rotateMultipleElements = (
|
const rotateMultipleElements = (
|
||||||
@@ -938,7 +1040,7 @@ const rotateMultipleElements = (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Scene.getScene(elements[0])?.informMutation();
|
Scene.getScene(elements[0])?.triggerUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getResizeOffsetXY = (
|
export const getResizeOffsetXY = (
|
||||||
|
|||||||
@@ -87,12 +87,8 @@ export const resizeTest = (
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Note that for a text element, when "resized" from the side
|
// do not resize from the sides for linear elements with only two points
|
||||||
// we should make it wrap/unwrap
|
if (!(isLinearElement(element) && element.points.length <= 2)) {
|
||||||
if (
|
|
||||||
element.type !== "text" &&
|
|
||||||
!(isLinearElement(element) && element.points.length <= 2)
|
|
||||||
) {
|
|
||||||
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||||
const sides = getSelectionBorders(
|
const sides = getSelectionBorders(
|
||||||
[x1 - SPACING, y1 - SPACING],
|
[x1 - SPACING, y1 - SPACING],
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const redrawTextBoundingBox = (
|
|||||||
textElement: ExcalidrawTextElement,
|
textElement: ExcalidrawTextElement,
|
||||||
container: ExcalidrawElement | null,
|
container: ExcalidrawElement | null,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
informMutation: boolean = true,
|
informMutation = true,
|
||||||
) => {
|
) => {
|
||||||
let maxWidth = undefined;
|
let maxWidth = undefined;
|
||||||
const boundTextUpdates = {
|
const boundTextUpdates = {
|
||||||
@@ -62,21 +62,27 @@ export const redrawTextBoundingBox = (
|
|||||||
|
|
||||||
boundTextUpdates.text = textElement.text;
|
boundTextUpdates.text = textElement.text;
|
||||||
|
|
||||||
if (container) {
|
if (container || !textElement.autoResize) {
|
||||||
maxWidth = getBoundTextMaxWidth(container, textElement);
|
maxWidth = container
|
||||||
|
? getBoundTextMaxWidth(container, textElement)
|
||||||
|
: textElement.width;
|
||||||
boundTextUpdates.text = wrapText(
|
boundTextUpdates.text = wrapText(
|
||||||
textElement.originalText,
|
textElement.originalText,
|
||||||
getFontString(textElement),
|
getFontString(textElement),
|
||||||
maxWidth,
|
maxWidth,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const metrics = measureText(
|
const metrics = measureText(
|
||||||
boundTextUpdates.text,
|
boundTextUpdates.text,
|
||||||
getFontString(textElement),
|
getFontString(textElement),
|
||||||
textElement.lineHeight,
|
textElement.lineHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
boundTextUpdates.width = metrics.width;
|
// Note: only update width for unwrapped text and bound texts (which always have autoResize set to true)
|
||||||
|
if (textElement.autoResize) {
|
||||||
|
boundTextUpdates.width = metrics.width;
|
||||||
|
}
|
||||||
boundTextUpdates.height = metrics.height;
|
boundTextUpdates.height = metrics.height;
|
||||||
|
|
||||||
if (container) {
|
if (container) {
|
||||||
@@ -932,3 +938,10 @@ export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
|
|||||||
}
|
}
|
||||||
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
|
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getMinTextElementWidth = (
|
||||||
|
font: FontString,
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
) => {
|
||||||
|
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
|
||||||
|
};
|
||||||
|
|||||||
@@ -236,6 +236,117 @@ describe("textWysiwyg", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Test text wrapping", () => {
|
||||||
|
const { h } = window;
|
||||||
|
const dimensions = { height: 400, width: 800 };
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
mockBoundingClientRect(dimensions);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
// @ts-ignore
|
||||||
|
h.app.refreshViewportBreakpoints();
|
||||||
|
// @ts-ignore
|
||||||
|
h.app.refreshEditorBreakpoints();
|
||||||
|
|
||||||
|
h.elements = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
restoreOriginalGetBoundingClientRect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep width when editing a wrapped text", async () => {
|
||||||
|
const text = API.createElement({
|
||||||
|
type: "text",
|
||||||
|
text: "Excalidraw\nEditor",
|
||||||
|
});
|
||||||
|
|
||||||
|
h.elements = [text];
|
||||||
|
|
||||||
|
const prevWidth = text.width;
|
||||||
|
const prevHeight = text.height;
|
||||||
|
const prevText = text.text;
|
||||||
|
|
||||||
|
// text is wrapped
|
||||||
|
UI.resize(text, "e", [-20, 0]);
|
||||||
|
expect(text.width).not.toEqual(prevWidth);
|
||||||
|
expect(text.height).not.toEqual(prevHeight);
|
||||||
|
expect(text.text).not.toEqual(prevText);
|
||||||
|
expect(text.autoResize).toBe(false);
|
||||||
|
|
||||||
|
const wrappedWidth = text.width;
|
||||||
|
const wrappedHeight = text.height;
|
||||||
|
const wrappedText = text.text;
|
||||||
|
|
||||||
|
// edit text
|
||||||
|
UI.clickTool("selection");
|
||||||
|
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
||||||
|
const editor = await getTextEditor(textEditorSelector);
|
||||||
|
expect(editor).not.toBe(null);
|
||||||
|
expect(h.state.editingElement?.id).toBe(text.id);
|
||||||
|
expect(h.elements.length).toBe(1);
|
||||||
|
|
||||||
|
const nextText = `${wrappedText} is great!`;
|
||||||
|
updateTextEditor(editor, nextText);
|
||||||
|
await new Promise((cb) => setTimeout(cb, 0));
|
||||||
|
editor.blur();
|
||||||
|
|
||||||
|
expect(h.elements[0].width).toEqual(wrappedWidth);
|
||||||
|
expect(h.elements[0].height).toBeGreaterThan(wrappedHeight);
|
||||||
|
|
||||||
|
// remove all texts and then add it back editing
|
||||||
|
updateTextEditor(editor, "");
|
||||||
|
await new Promise((cb) => setTimeout(cb, 0));
|
||||||
|
updateTextEditor(editor, nextText);
|
||||||
|
await new Promise((cb) => setTimeout(cb, 0));
|
||||||
|
editor.blur();
|
||||||
|
|
||||||
|
expect(h.elements[0].width).toEqual(wrappedWidth);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should restore original text after unwrapping a wrapped text", async () => {
|
||||||
|
const originalText = "Excalidraw\neditor\nis great!";
|
||||||
|
const text = API.createElement({
|
||||||
|
type: "text",
|
||||||
|
text: originalText,
|
||||||
|
});
|
||||||
|
h.elements = [text];
|
||||||
|
|
||||||
|
// wrap
|
||||||
|
UI.resize(text, "e", [-40, 0]);
|
||||||
|
// enter text editing mode
|
||||||
|
UI.clickTool("selection");
|
||||||
|
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
||||||
|
const editor = await getTextEditor(textEditorSelector);
|
||||||
|
editor.blur();
|
||||||
|
// restore after unwrapping
|
||||||
|
UI.resize(text, "e", [40, 0]);
|
||||||
|
expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText);
|
||||||
|
|
||||||
|
// wrap again and add a new line
|
||||||
|
UI.resize(text, "e", [-30, 0]);
|
||||||
|
const wrappedText = text.text;
|
||||||
|
UI.clickTool("selection");
|
||||||
|
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
||||||
|
updateTextEditor(editor, `${wrappedText}\nA new line!`);
|
||||||
|
await new Promise((cb) => setTimeout(cb, 0));
|
||||||
|
editor.blur();
|
||||||
|
// remove the newly added line
|
||||||
|
UI.clickTool("selection");
|
||||||
|
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
||||||
|
updateTextEditor(editor, wrappedText);
|
||||||
|
await new Promise((cb) => setTimeout(cb, 0));
|
||||||
|
editor.blur();
|
||||||
|
// unwrap
|
||||||
|
UI.resize(text, "e", [30, 0]);
|
||||||
|
// expect the text to be restored the same
|
||||||
|
expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Test container-unbound text", () => {
|
describe("Test container-unbound text", () => {
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
const dimensions = { height: 400, width: 800 };
|
const dimensions = { height: 400, width: 800 };
|
||||||
@@ -465,7 +576,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
it("text should never go beyond max width", async () => {
|
it("text should never go beyond max width", async () => {
|
||||||
UI.clickTool("text");
|
UI.clickTool("text");
|
||||||
mouse.clickAt(750, 300);
|
mouse.click(0, 0);
|
||||||
|
|
||||||
textarea = await getTextEditor(textEditorSelector, true);
|
textarea = await getTextEditor(textEditorSelector, true);
|
||||||
updateTextEditor(
|
updateTextEditor(
|
||||||
@@ -800,26 +911,15 @@ describe("textWysiwyg", () => {
|
|||||||
mouse.down();
|
mouse.down();
|
||||||
|
|
||||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
let editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
editor.blur();
|
editor.blur();
|
||||||
expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil);
|
expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil);
|
||||||
UI.clickTool("text");
|
|
||||||
|
|
||||||
mouse.clickAt(
|
|
||||||
rectangle.x + rectangle.width / 2,
|
|
||||||
rectangle.y + rectangle.height / 2,
|
|
||||||
);
|
|
||||||
mouse.down();
|
|
||||||
editor = await getTextEditor(textEditorSelector, true);
|
|
||||||
|
|
||||||
editor.select();
|
|
||||||
fireEvent.click(screen.getByTitle(/code/i));
|
fireEvent.click(screen.getByTitle(/code/i));
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
editor.blur();
|
|
||||||
expect(
|
expect(
|
||||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||||
).toEqual(FONT_FAMILY.Cascadia);
|
).toEqual(FONT_FAMILY.Cascadia);
|
||||||
@@ -964,7 +1064,7 @@ describe("textWysiwyg", () => {
|
|||||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||||
[
|
[
|
||||||
85,
|
85,
|
||||||
4.999999999999986,
|
"5.00000",
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -1009,8 +1109,8 @@ describe("textWysiwyg", () => {
|
|||||||
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||||
[
|
[
|
||||||
374.99999999999994,
|
"375.00000",
|
||||||
-535.0000000000001,
|
"-535.00000",
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,19 +77,23 @@ export const textWysiwyg = ({
|
|||||||
canvas,
|
canvas,
|
||||||
excalidrawContainer,
|
excalidrawContainer,
|
||||||
app,
|
app,
|
||||||
|
autoSelect = true,
|
||||||
}: {
|
}: {
|
||||||
id: ExcalidrawElement["id"];
|
id: ExcalidrawElement["id"];
|
||||||
onChange?: (text: string) => void;
|
/**
|
||||||
onSubmit: (data: {
|
* textWysiwyg only deals with `originalText`
|
||||||
text: string;
|
*
|
||||||
viaKeyboard: boolean;
|
* Note: `text`, which can be wrapped and therefore different from `originalText`,
|
||||||
originalText: string;
|
* is derived from `originalText`
|
||||||
}) => void;
|
*/
|
||||||
|
onChange?: (nextOriginalText: string) => void;
|
||||||
|
onSubmit: (data: { viaKeyboard: boolean; nextOriginalText: string }) => void;
|
||||||
getViewportCoords: (x: number, y: number) => [number, number];
|
getViewportCoords: (x: number, y: number) => [number, number];
|
||||||
element: ExcalidrawTextElement;
|
element: ExcalidrawTextElement;
|
||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
excalidrawContainer: HTMLDivElement | null;
|
excalidrawContainer: HTMLDivElement | null;
|
||||||
app: App;
|
app: App;
|
||||||
|
autoSelect?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const textPropertiesUpdated = (
|
const textPropertiesUpdated = (
|
||||||
updatedTextElement: ExcalidrawTextElement,
|
updatedTextElement: ExcalidrawTextElement,
|
||||||
@@ -129,11 +133,8 @@ export const textWysiwyg = ({
|
|||||||
app.scene.getNonDeletedElementsMap(),
|
app.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
let maxWidth = updatedTextElement.width;
|
let maxWidth = updatedTextElement.width;
|
||||||
|
|
||||||
let maxHeight = updatedTextElement.height;
|
let maxHeight = updatedTextElement.height;
|
||||||
let textElementWidth = updatedTextElement.width;
|
let textElementWidth = updatedTextElement.width;
|
||||||
// Set to element height by default since that's
|
|
||||||
// what is going to be used for unbounded text
|
|
||||||
const textElementHeight = updatedTextElement.height;
|
const textElementHeight = updatedTextElement.height;
|
||||||
|
|
||||||
if (container && updatedTextElement.containerId) {
|
if (container && updatedTextElement.containerId) {
|
||||||
@@ -226,6 +227,8 @@ export const textWysiwyg = ({
|
|||||||
if (!container) {
|
if (!container) {
|
||||||
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
||||||
textElementWidth = Math.min(textElementWidth, maxWidth);
|
textElementWidth = Math.min(textElementWidth, maxWidth);
|
||||||
|
} else {
|
||||||
|
textElementWidth += 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make sure text editor height doesn't go beyond viewport
|
// Make sure text editor height doesn't go beyond viewport
|
||||||
@@ -260,6 +263,7 @@ export const textWysiwyg = ({
|
|||||||
if (isTestEnv()) {
|
if (isTestEnv()) {
|
||||||
editable.style.fontFamily = getFontFamilyString(updatedTextElement);
|
editable.style.fontFamily = getFontFamilyString(updatedTextElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
mutateElement(updatedTextElement, { x: coordX, y: coordY });
|
mutateElement(updatedTextElement, { x: coordX, y: coordY });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -276,7 +280,7 @@ export const textWysiwyg = ({
|
|||||||
let whiteSpace = "pre";
|
let whiteSpace = "pre";
|
||||||
let wordBreak = "normal";
|
let wordBreak = "normal";
|
||||||
|
|
||||||
if (isBoundToContainer(element)) {
|
if (isBoundToContainer(element) || !element.autoResize) {
|
||||||
whiteSpace = "pre-wrap";
|
whiteSpace = "pre-wrap";
|
||||||
wordBreak = "break-word";
|
wordBreak = "break-word";
|
||||||
}
|
}
|
||||||
@@ -489,6 +493,11 @@ export const textWysiwyg = ({
|
|||||||
// so that we don't need to create separate a callback for event handlers
|
// so that we don't need to create separate a callback for event handlers
|
||||||
let submittedViaKeyboard = false;
|
let submittedViaKeyboard = false;
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
// prevent double submit
|
||||||
|
if (isDestroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isDestroyed = true;
|
||||||
// cleanup must be run before onSubmit otherwise when app blurs the wysiwyg
|
// cleanup must be run before onSubmit otherwise when app blurs the wysiwyg
|
||||||
// it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
|
// it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
|
||||||
// wysiwyg on update
|
// wysiwyg on update
|
||||||
@@ -499,14 +508,12 @@ export const textWysiwyg = ({
|
|||||||
if (!updateElement) {
|
if (!updateElement) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let text = editable.value;
|
|
||||||
const container = getContainerElement(
|
const container = getContainerElement(
|
||||||
updateElement,
|
updateElement,
|
||||||
app.scene.getNonDeletedElementsMap(),
|
app.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (container) {
|
if (container) {
|
||||||
text = updateElement.text;
|
|
||||||
if (editable.value.trim()) {
|
if (editable.value.trim()) {
|
||||||
const boundTextElementId = getBoundTextElementId(container);
|
const boundTextElementId = getBoundTextElementId(container);
|
||||||
if (!boundTextElementId || boundTextElementId !== element.id) {
|
if (!boundTextElementId || boundTextElementId !== element.id) {
|
||||||
@@ -538,17 +545,12 @@ export const textWysiwyg = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
onSubmit({
|
onSubmit({
|
||||||
text,
|
|
||||||
viaKeyboard: submittedViaKeyboard,
|
viaKeyboard: submittedViaKeyboard,
|
||||||
originalText: editable.value,
|
nextOriginalText: editable.value,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (isDestroyed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isDestroyed = true;
|
|
||||||
// remove events to ensure they don't late-fire
|
// remove events to ensure they don't late-fire
|
||||||
editable.onblur = null;
|
editable.onblur = null;
|
||||||
editable.oninput = null;
|
editable.oninput = null;
|
||||||
@@ -640,11 +642,27 @@ export const textWysiwyg = ({
|
|||||||
// handle edge-case where pointerup doesn't fire e.g. due to user
|
// handle edge-case where pointerup doesn't fire e.g. due to user
|
||||||
// alt-tabbing away
|
// alt-tabbing away
|
||||||
window.addEventListener("blur", handleSubmit);
|
window.addEventListener("blur", handleSubmit);
|
||||||
|
} else if (
|
||||||
|
event.target instanceof HTMLElement &&
|
||||||
|
!event.target.contains(editable) &&
|
||||||
|
// Vitest simply ignores stopPropagation, capture-mode, or rAF
|
||||||
|
// so without introducing crazier hacks, nothing we can do
|
||||||
|
!isTestEnv()
|
||||||
|
) {
|
||||||
|
// On mobile, blur event doesn't seem to always fire correctly,
|
||||||
|
// so we want to also submit on pointerdown outside the wysiwyg.
|
||||||
|
// Done in the next frame to prevent pointerdown from creating a new text
|
||||||
|
// immediately (if tools locked) so that users on mobile have chance
|
||||||
|
// to submit first (to hide virtual keyboard).
|
||||||
|
// Note: revisit if we want to differ this behavior on Desktop
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
handleSubmit();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// handle updates of textElement properties of editing element
|
// handle updates of textElement properties of editing element
|
||||||
const unbindUpdate = Scene.getScene(element)!.addCallback(() => {
|
const unbindUpdate = Scene.getScene(element)!.onUpdate(() => {
|
||||||
updateWysiwygStyle();
|
updateWysiwygStyle();
|
||||||
const isColorPickerActive = !!document.activeElement?.closest(
|
const isColorPickerActive = !!document.activeElement?.closest(
|
||||||
".color-picker-content",
|
".color-picker-content",
|
||||||
@@ -658,9 +676,11 @@ export const textWysiwyg = ({
|
|||||||
|
|
||||||
let isDestroyed = false;
|
let isDestroyed = false;
|
||||||
|
|
||||||
// select on init (focusing is done separately inside the bindBlurEvent()
|
if (autoSelect) {
|
||||||
// because we need it to happen *after* the blur event from `pointerdown`)
|
// select on init (focusing is done separately inside the bindBlurEvent()
|
||||||
editable.select();
|
// because we need it to happen *after* the blur event from `pointerdown`)
|
||||||
|
editable.select();
|
||||||
|
}
|
||||||
bindBlurEvent();
|
bindBlurEvent();
|
||||||
|
|
||||||
// reposition wysiwyg in case of canvas is resized. Using ResizeObserver
|
// reposition wysiwyg in case of canvas is resized. Using ResizeObserver
|
||||||
@@ -675,7 +695,13 @@ export const textWysiwyg = ({
|
|||||||
window.addEventListener("resize", updateWysiwygStyle);
|
window.addEventListener("resize", updateWysiwygStyle);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("pointerdown", onPointerDown);
|
editable.onpointerdown = (event) => event.stopPropagation();
|
||||||
|
|
||||||
|
// rAF (+ capture to by doubly sure) so we don't catch te pointerdown that
|
||||||
|
// triggered the wysiwyg
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.addEventListener("pointerdown", onPointerDown, { capture: true });
|
||||||
|
});
|
||||||
window.addEventListener("wheel", stopEvent, {
|
window.addEventListener("wheel", stopEvent, {
|
||||||
passive: false,
|
passive: false,
|
||||||
capture: true,
|
capture: true,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type { Bounds } from "./bounds";
|
|||||||
import { getElementAbsoluteCoords } from "./bounds";
|
import { getElementAbsoluteCoords } from "./bounds";
|
||||||
import { rotate } from "../math";
|
import { rotate } from "../math";
|
||||||
import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
|
import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
|
||||||
import { isTextElement } from ".";
|
|
||||||
import { isFrameLikeElement, isLinearElement } from "./typeChecks";
|
import { isFrameLikeElement, isLinearElement } from "./typeChecks";
|
||||||
import {
|
import {
|
||||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||||
@@ -65,13 +64,6 @@ export const OMIT_SIDES_FOR_FRAME = {
|
|||||||
rotation: true,
|
rotation: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const OMIT_SIDES_FOR_TEXT_ELEMENT = {
|
|
||||||
e: true,
|
|
||||||
s: true,
|
|
||||||
n: true,
|
|
||||||
w: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const OMIT_SIDES_FOR_LINE_SLASH = {
|
const OMIT_SIDES_FOR_LINE_SLASH = {
|
||||||
e: true,
|
e: true,
|
||||||
s: true,
|
s: true,
|
||||||
@@ -290,8 +282,6 @@ export const getTransformHandles = (
|
|||||||
omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
|
omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (isTextElement(element)) {
|
|
||||||
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
|
||||||
} else if (isFrameLikeElement(element)) {
|
} else if (isFrameLikeElement(element)) {
|
||||||
omitSides = {
|
omitSides = {
|
||||||
...omitSides,
|
...omitSides,
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export const isBindingElementType = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const isBindableElement = (
|
export const isBindableElement = (
|
||||||
element: ExcalidrawElement | null,
|
element: ExcalidrawElement | null | undefined,
|
||||||
includeLocked = true,
|
includeLocked = true,
|
||||||
): element is ExcalidrawBindableElement => {
|
): element is ExcalidrawBindableElement => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -193,6 +193,13 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
|||||||
verticalAlign: VerticalAlign;
|
verticalAlign: VerticalAlign;
|
||||||
containerId: ExcalidrawGenericElement["id"] | null;
|
containerId: ExcalidrawGenericElement["id"] | null;
|
||||||
originalText: string;
|
originalText: string;
|
||||||
|
/**
|
||||||
|
* If `true` the width will fit the text. If `false`, the text will
|
||||||
|
* wrap to fit the width.
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
autoResize: boolean;
|
||||||
/**
|
/**
|
||||||
* Unitless line height (aligned to W3C). To get line height in px, multiply
|
* Unitless line height (aligned to W3C). To get line height in px, multiply
|
||||||
* with font size (using `getLineHeightInPx` helper).
|
* with font size (using `getLineHeightInPx` helper).
|
||||||
|
|||||||
@@ -133,27 +133,11 @@ const getMovedIndicesGroups = (
|
|||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
||||||
while (i < elements.length) {
|
while (i < elements.length) {
|
||||||
if (
|
if (movedElements.has(elements[i].id)) {
|
||||||
movedElements.has(elements[i].id) &&
|
|
||||||
!isValidFractionalIndex(
|
|
||||||
elements[i]?.index,
|
|
||||||
elements[i - 1]?.index,
|
|
||||||
elements[i + 1]?.index,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
const indicesGroup = [i - 1, i]; // push the lower bound index as the first item
|
const indicesGroup = [i - 1, i]; // push the lower bound index as the first item
|
||||||
|
|
||||||
while (++i < elements.length) {
|
while (++i < elements.length) {
|
||||||
if (
|
if (!movedElements.has(elements[i].id)) {
|
||||||
!(
|
|
||||||
movedElements.has(elements[i].id) &&
|
|
||||||
!isValidFractionalIndex(
|
|
||||||
elements[i]?.index,
|
|
||||||
elements[i - 1]?.index,
|
|
||||||
elements[i + 1]?.index,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -373,7 +373,9 @@ export const getNonDeletedGroupIds = (elements: ElementsMap) => {
|
|||||||
return nonDeletedGroupIds;
|
return nonDeletedGroupIds;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
|
export const elementsAreInSameGroup = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
) => {
|
||||||
const allGroups = elements.flatMap((element) => element.groupIds);
|
const allGroups = elements.flatMap((element) => element.groupIds);
|
||||||
const groupCount = new Map<string, number>();
|
const groupCount = new Map<string, number>();
|
||||||
let maxGroup = 0;
|
let maxGroup = 0;
|
||||||
|
|||||||
@@ -148,7 +148,9 @@
|
|||||||
"discordChat": "Discord chat",
|
"discordChat": "Discord chat",
|
||||||
"zoomToFitViewport": "Zoom to fit in viewport",
|
"zoomToFitViewport": "Zoom to fit in viewport",
|
||||||
"zoomToFitSelection": "Zoom to fit selection",
|
"zoomToFitSelection": "Zoom to fit selection",
|
||||||
"zoomToFit": "Zoom to fit all elements"
|
"zoomToFit": "Zoom to fit all elements",
|
||||||
|
"installPWA": "Install Excalidraw locally (PWA)",
|
||||||
|
"autoResize": "Enable text auto-resizing"
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"noItems": "No items added yet...",
|
"noItems": "No items added yet...",
|
||||||
@@ -268,6 +270,22 @@
|
|||||||
"mermaidToExcalidraw": "Mermaid to Excalidraw",
|
"mermaidToExcalidraw": "Mermaid to Excalidraw",
|
||||||
"magicSettings": "AI settings"
|
"magicSettings": "AI settings"
|
||||||
},
|
},
|
||||||
|
"element": {
|
||||||
|
"rectangle": "Rectangle",
|
||||||
|
"diamond": "Diamond",
|
||||||
|
"ellipse": "Ellipse",
|
||||||
|
"arrow": "Arrow",
|
||||||
|
"line": "Line",
|
||||||
|
"freedraw": "Freedraw",
|
||||||
|
"text": "Text",
|
||||||
|
"image": "Image",
|
||||||
|
"group": "Group",
|
||||||
|
"frame": "Frame",
|
||||||
|
"magicframe": "Wireframe to code",
|
||||||
|
"embeddable": "Web Embed",
|
||||||
|
"selection": "Selection",
|
||||||
|
"iframe": "IFrame"
|
||||||
|
},
|
||||||
"headings": {
|
"headings": {
|
||||||
"canvasActions": "Canvas actions",
|
"canvasActions": "Canvas actions",
|
||||||
"selectedShapeActions": "Selected shape actions",
|
"selectedShapeActions": "Selected shape actions",
|
||||||
@@ -441,7 +459,10 @@
|
|||||||
"scene": "Scene",
|
"scene": "Scene",
|
||||||
"selected": "Selected",
|
"selected": "Selected",
|
||||||
"storage": "Storage",
|
"storage": "Storage",
|
||||||
"title": "Stats for nerds",
|
"fullTitle": "Stats & Element properties",
|
||||||
|
"title": "Stats",
|
||||||
|
"generalStats": "General stats",
|
||||||
|
"elementProperties": "Element properties",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"versionCopy": "Click to copy",
|
"versionCopy": "Click to copy",
|
||||||
|
|||||||
@@ -475,6 +475,14 @@ export const isRightAngle = (angle: number) => {
|
|||||||
return Math.round((angle / Math.PI) * 10000) % 5000 === 0;
|
return Math.round((angle / Math.PI) * 10000) % 5000 === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const radianToDegree = (r: number) => {
|
||||||
|
return (r * 180) / Math.PI;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const degreeToRadian = (d: number) => {
|
||||||
|
return (d / 180) * Math.PI;
|
||||||
|
};
|
||||||
|
|
||||||
// Given two ranges, return if the two ranges overlap with each other
|
// Given two ranges, return if the two ranges overlap with each other
|
||||||
// e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5]
|
// e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5]
|
||||||
export const rangesOverlap = (
|
export const rangesOverlap = (
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/** heuristically checks whether the text may be a mermaid diagram definition */
|
||||||
|
export const isMaybeMermaidDefinition = (text: string) => {
|
||||||
|
const chartTypes = [
|
||||||
|
"flowchart",
|
||||||
|
"sequenceDiagram",
|
||||||
|
"classDiagram",
|
||||||
|
"stateDiagram",
|
||||||
|
"stateDiagram-v2",
|
||||||
|
"erDiagram",
|
||||||
|
"journey",
|
||||||
|
"gantt",
|
||||||
|
"pie",
|
||||||
|
"quadrantChart",
|
||||||
|
"requirementDiagram",
|
||||||
|
"gitGraph",
|
||||||
|
"C4Context",
|
||||||
|
"mindmap",
|
||||||
|
"timeline",
|
||||||
|
"zenuml",
|
||||||
|
"sankey",
|
||||||
|
"xychart",
|
||||||
|
"block",
|
||||||
|
];
|
||||||
|
|
||||||
|
const re = new RegExp(
|
||||||
|
`^(?:%%{.*?}%%[\\s\\n]*)?\\b${chartTypes
|
||||||
|
.map((x) => `${x}(-beta)?`)
|
||||||
|
.join("|")}\\b`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return re.test(text.trim());
|
||||||
|
};
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@braintree/sanitize-url": "6.0.2",
|
"@braintree/sanitize-url": "6.0.2",
|
||||||
"@excalidraw/laser-pointer": "1.3.1",
|
"@excalidraw/laser-pointer": "1.3.1",
|
||||||
"@excalidraw/mermaid-to-excalidraw": "0.3.0",
|
"@excalidraw/mermaid-to-excalidraw": "1.1.0",
|
||||||
"@excalidraw/random-username": "1.1.0",
|
"@excalidraw/random-username": "1.1.0",
|
||||||
"@radix-ui/react-popover": "1.0.3",
|
"@radix-ui/react-popover": "1.0.3",
|
||||||
"@radix-ui/react-tabs": "1.0.2",
|
"@radix-ui/react-tabs": "1.0.2",
|
||||||
@@ -72,6 +72,7 @@
|
|||||||
"image-blob-reduce": "3.0.1",
|
"image-blob-reduce": "3.0.1",
|
||||||
"jotai": "1.13.1",
|
"jotai": "1.13.1",
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
|
"lz-string": "1.5.0",
|
||||||
"nanoid": "3.3.3",
|
"nanoid": "3.3.3",
|
||||||
"open-color": "1.9.1",
|
"open-color": "1.9.1",
|
||||||
"pako": "1.0.11",
|
"pako": "1.0.11",
|
||||||
|
|||||||
@@ -47,13 +47,18 @@ import {
|
|||||||
getNormalizedCanvasDimensions,
|
getNormalizedCanvasDimensions,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import { isFrameLikeElement, isLinearElement } from "../element/typeChecks";
|
import {
|
||||||
|
isFrameLikeElement,
|
||||||
|
isLinearElement,
|
||||||
|
isTextElement,
|
||||||
|
} from "../element/typeChecks";
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawFrameLikeElement,
|
ExcalidrawFrameLikeElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
GroupId,
|
GroupId,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
@@ -303,7 +308,6 @@ const renderSelectionBorder = (
|
|||||||
cy: number;
|
cy: number;
|
||||||
activeEmbeddable: boolean;
|
activeEmbeddable: boolean;
|
||||||
},
|
},
|
||||||
padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2,
|
|
||||||
) => {
|
) => {
|
||||||
const {
|
const {
|
||||||
angle,
|
angle,
|
||||||
@@ -320,6 +324,8 @@ const renderSelectionBorder = (
|
|||||||
const elementWidth = elementX2 - elementX1;
|
const elementWidth = elementX2 - elementX1;
|
||||||
const elementHeight = elementY2 - elementY1;
|
const elementHeight = elementY2 - elementY1;
|
||||||
|
|
||||||
|
const padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2;
|
||||||
|
|
||||||
const linePadding = padding / appState.zoom.value;
|
const linePadding = padding / appState.zoom.value;
|
||||||
const lineWidth = 8 / appState.zoom.value;
|
const lineWidth = 8 / appState.zoom.value;
|
||||||
const spaceWidth = 4 / appState.zoom.value;
|
const spaceWidth = 4 / appState.zoom.value;
|
||||||
@@ -570,11 +576,34 @@ const renderTransformHandles = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderTextBox = (
|
||||||
|
text: NonDeleted<ExcalidrawTextElement>,
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
appState: InteractiveCanvasAppState,
|
||||||
|
selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
|
||||||
|
) => {
|
||||||
|
context.save();
|
||||||
|
const padding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value;
|
||||||
|
const width = text.width + padding * 2;
|
||||||
|
const height = text.height + padding * 2;
|
||||||
|
const cx = text.x + width / 2;
|
||||||
|
const cy = text.y + height / 2;
|
||||||
|
const shiftX = -(width / 2 + padding);
|
||||||
|
const shiftY = -(height / 2 + padding);
|
||||||
|
context.translate(cx + appState.scrollX, cy + appState.scrollY);
|
||||||
|
context.rotate(text.angle);
|
||||||
|
context.lineWidth = 1 / appState.zoom.value;
|
||||||
|
context.strokeStyle = selectionColor;
|
||||||
|
context.strokeRect(shiftX, shiftY, width, height);
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
const _renderInteractiveScene = ({
|
const _renderInteractiveScene = ({
|
||||||
canvas,
|
canvas,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
visibleElements,
|
visibleElements,
|
||||||
selectedElements,
|
selectedElements,
|
||||||
|
allElementsMap,
|
||||||
scale,
|
scale,
|
||||||
appState,
|
appState,
|
||||||
renderConfig,
|
renderConfig,
|
||||||
@@ -626,12 +655,31 @@ const _renderInteractiveScene = ({
|
|||||||
// Paint selection element
|
// Paint selection element
|
||||||
if (appState.selectionElement) {
|
if (appState.selectionElement) {
|
||||||
try {
|
try {
|
||||||
renderSelectionElement(appState.selectionElement, context, appState);
|
renderSelectionElement(
|
||||||
|
appState.selectionElement,
|
||||||
|
context,
|
||||||
|
appState,
|
||||||
|
renderConfig.selectionColor,
|
||||||
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (appState.editingElement && isTextElement(appState.editingElement)) {
|
||||||
|
const textElement = allElementsMap.get(appState.editingElement.id) as
|
||||||
|
| ExcalidrawTextElement
|
||||||
|
| undefined;
|
||||||
|
if (textElement && !textElement.autoResize) {
|
||||||
|
renderTextBox(
|
||||||
|
textElement,
|
||||||
|
context,
|
||||||
|
appState,
|
||||||
|
renderConfig.selectionColor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (appState.isBindingEnabled) {
|
if (appState.isBindingEnabled) {
|
||||||
appState.suggestedBindings
|
appState.suggestedBindings
|
||||||
.filter((binding) => binding != null)
|
.filter((binding) => binding != null)
|
||||||
@@ -810,7 +858,12 @@ const _renderInteractiveScene = ({
|
|||||||
"mouse", // when we render we don't know which pointer type so use mouse,
|
"mouse", // when we render we don't know which pointer type so use mouse,
|
||||||
getOmitSidesForDevice(device),
|
getOmitSidesForDevice(device),
|
||||||
);
|
);
|
||||||
if (!appState.viewModeEnabled && showBoundingBox) {
|
if (
|
||||||
|
!appState.viewModeEnabled &&
|
||||||
|
showBoundingBox &&
|
||||||
|
// do not show transform handles when text is being edited
|
||||||
|
!isTextElement(appState.editingElement)
|
||||||
|
) {
|
||||||
renderTransformHandles(
|
renderTransformHandles(
|
||||||
context,
|
context,
|
||||||
renderConfig,
|
renderConfig,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type { RoughCanvas } from "roughjs/bin/canvas";
|
|||||||
import type {
|
import type {
|
||||||
StaticCanvasRenderConfig,
|
StaticCanvasRenderConfig,
|
||||||
RenderableElementsMap,
|
RenderableElementsMap,
|
||||||
|
InteractiveCanvasRenderConfig,
|
||||||
} from "../scene/types";
|
} from "../scene/types";
|
||||||
import { distance, getFontString, isRTL } from "../utils";
|
import { distance, getFontString, isRTL } from "../utils";
|
||||||
import { getCornerRadius, isRightAngle } from "../math";
|
import { getCornerRadius, isRightAngle } from "../math";
|
||||||
@@ -89,7 +90,7 @@ const shouldResetImageFilter = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getCanvasPadding = (element: ExcalidrawElement) =>
|
const getCanvasPadding = (element: ExcalidrawElement) =>
|
||||||
element.type === "freedraw" ? element.strokeWidth * 12 : 20;
|
element.type === "freedraw" ? element.strokeWidth * 12 : 200;
|
||||||
|
|
||||||
export const getRenderOpacity = (
|
export const getRenderOpacity = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
@@ -470,16 +471,7 @@ const drawElementFromCanvas = (
|
|||||||
const element = elementWithCanvas.element;
|
const element = elementWithCanvas.element;
|
||||||
const padding = getCanvasPadding(element);
|
const padding = getCanvasPadding(element);
|
||||||
const zoom = elementWithCanvas.scale;
|
const zoom = elementWithCanvas.scale;
|
||||||
let [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
|
||||||
|
|
||||||
// Free draw elements will otherwise "shuffle" as the min x and y change
|
|
||||||
if (isFreeDrawElement(element)) {
|
|
||||||
x1 = Math.floor(x1);
|
|
||||||
x2 = Math.ceil(x2);
|
|
||||||
y1 = Math.floor(y1);
|
|
||||||
y2 = Math.ceil(y2);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio;
|
const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio;
|
||||||
const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio;
|
const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio;
|
||||||
|
|
||||||
@@ -618,6 +610,7 @@ export const renderSelectionElement = (
|
|||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
appState: InteractiveCanvasAppState,
|
appState: InteractiveCanvasAppState,
|
||||||
|
selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
|
||||||
) => {
|
) => {
|
||||||
context.save();
|
context.save();
|
||||||
context.translate(element.x + appState.scrollX, element.y + appState.scrollY);
|
context.translate(element.x + appState.scrollX, element.y + appState.scrollY);
|
||||||
@@ -631,7 +624,7 @@ export const renderSelectionElement = (
|
|||||||
|
|
||||||
context.fillRect(offset, offset, element.width, element.height);
|
context.fillRect(offset, offset, element.width, element.height);
|
||||||
context.lineWidth = 1 / appState.zoom.value;
|
context.lineWidth = 1 / appState.zoom.value;
|
||||||
context.strokeStyle = " rgb(105, 101, 219)";
|
context.strokeStyle = selectionColor;
|
||||||
context.strokeRect(offset, offset, element.width, element.height);
|
context.strokeRect(offset, offset, element.width, element.height);
|
||||||
|
|
||||||
context.restore();
|
context.restore();
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { isTextElement, refreshTextDimensions } from "../element";
|
import { isTextElement } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
|
||||||
import { getContainerElement } from "../element/textElement";
|
import { getContainerElement } from "../element/textElement";
|
||||||
import { isBoundToContainer } from "../element/typeChecks";
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
@@ -12,17 +10,9 @@ import { ShapeCache } from "./ShapeCache";
|
|||||||
|
|
||||||
export class Fonts {
|
export class Fonts {
|
||||||
private scene: Scene;
|
private scene: Scene;
|
||||||
private onSceneUpdated: () => void;
|
|
||||||
|
|
||||||
constructor({
|
constructor({ scene }: { scene: Scene }) {
|
||||||
scene,
|
|
||||||
onSceneUpdated,
|
|
||||||
}: {
|
|
||||||
scene: Scene;
|
|
||||||
onSceneUpdated: () => void;
|
|
||||||
}) {
|
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
this.onSceneUpdated = onSceneUpdated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// it's ok to track fonts across multiple instances only once, so let's use
|
// it's ok to track fonts across multiple instances only once, so let's use
|
||||||
@@ -56,23 +46,21 @@ export class Fonts {
|
|||||||
|
|
||||||
let didUpdate = false;
|
let didUpdate = false;
|
||||||
|
|
||||||
this.scene.mapElements((element) => {
|
const elementsMap = this.scene.getNonDeletedElementsMap();
|
||||||
if (isTextElement(element) && !isBoundToContainer(element)) {
|
|
||||||
ShapeCache.delete(element);
|
for (const element of this.scene.getNonDeletedElements()) {
|
||||||
|
if (isTextElement(element)) {
|
||||||
didUpdate = true;
|
didUpdate = true;
|
||||||
return newElementWith(element, {
|
ShapeCache.delete(element);
|
||||||
...refreshTextDimensions(
|
const container = getContainerElement(element, elementsMap);
|
||||||
element,
|
if (container) {
|
||||||
getContainerElement(element, this.scene.getNonDeletedElementsMap()),
|
ShapeCache.delete(container);
|
||||||
this.scene.getNonDeletedElementsMap(),
|
}
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return element;
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (didUpdate) {
|
if (didUpdate) {
|
||||||
this.onSceneUpdated();
|
this.scene.triggerUpdate();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -107,9 +107,8 @@ export class Renderer {
|
|||||||
width,
|
width,
|
||||||
editingElement,
|
editingElement,
|
||||||
pendingImageElementId,
|
pendingImageElementId,
|
||||||
// unused but serves we cache on it to invalidate elements if they
|
// cache-invalidation nonce
|
||||||
// get mutated
|
sceneNonce: _sceneNonce,
|
||||||
versionNonce: _versionNonce,
|
|
||||||
}: {
|
}: {
|
||||||
zoom: AppState["zoom"];
|
zoom: AppState["zoom"];
|
||||||
offsetLeft: AppState["offsetLeft"];
|
offsetLeft: AppState["offsetLeft"];
|
||||||
@@ -120,7 +119,7 @@ export class Renderer {
|
|||||||
width: AppState["width"];
|
width: AppState["width"];
|
||||||
editingElement: AppState["editingElement"];
|
editingElement: AppState["editingElement"];
|
||||||
pendingImageElementId: AppState["pendingImageElementId"];
|
pendingImageElementId: AppState["pendingImageElementId"];
|
||||||
versionNonce: ReturnType<InstanceType<typeof Scene>["getVersionNonce"]>;
|
sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>;
|
||||||
}) => {
|
}) => {
|
||||||
const elements = this.scene.getNonDeletedElements();
|
const elements = this.scene.getNonDeletedElements();
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ class Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated pass down `app.scene` and use it directly
|
||||||
|
*/
|
||||||
static getScene(elementKey: ElementKey): Scene | null {
|
static getScene(elementKey: ElementKey): Scene | null {
|
||||||
if (isIdKey(elementKey)) {
|
if (isIdKey(elementKey)) {
|
||||||
return this.sceneMapById.get(elementKey) || null;
|
return this.sceneMapById.get(elementKey) || null;
|
||||||
@@ -138,7 +141,17 @@ class Scene {
|
|||||||
elements: null,
|
elements: null,
|
||||||
cache: new Map(),
|
cache: new Map(),
|
||||||
};
|
};
|
||||||
private versionNonce: number | undefined;
|
/**
|
||||||
|
* Random integer regenerated each scene update.
|
||||||
|
*
|
||||||
|
* Does not relate to elements versions, it's only a renderer
|
||||||
|
* cache-invalidation nonce at the moment.
|
||||||
|
*/
|
||||||
|
private sceneNonce: number | undefined;
|
||||||
|
|
||||||
|
getSceneNonce() {
|
||||||
|
return this.sceneNonce;
|
||||||
|
}
|
||||||
|
|
||||||
getNonDeletedElementsMap() {
|
getNonDeletedElementsMap() {
|
||||||
return this.nonDeletedElementsMap;
|
return this.nonDeletedElementsMap;
|
||||||
@@ -214,10 +227,6 @@ class Scene {
|
|||||||
return (this.elementsMap.get(id) as T | undefined) || null;
|
return (this.elementsMap.get(id) as T | undefined) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getVersionNonce() {
|
|
||||||
return this.versionNonce;
|
|
||||||
}
|
|
||||||
|
|
||||||
getNonDeletedElement(
|
getNonDeletedElement(
|
||||||
id: ExcalidrawElement["id"],
|
id: ExcalidrawElement["id"],
|
||||||
): NonDeleted<ExcalidrawElement> | null {
|
): NonDeleted<ExcalidrawElement> | null {
|
||||||
@@ -286,18 +295,18 @@ class Scene {
|
|||||||
this.frames = nextFrameLikes;
|
this.frames = nextFrameLikes;
|
||||||
this.nonDeletedFramesLikes = getNonDeletedElements(this.frames).elements;
|
this.nonDeletedFramesLikes = getNonDeletedElements(this.frames).elements;
|
||||||
|
|
||||||
this.informMutation();
|
this.triggerUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
informMutation() {
|
triggerUpdate() {
|
||||||
this.versionNonce = randomInteger();
|
this.sceneNonce = randomInteger();
|
||||||
|
|
||||||
for (const callback of Array.from(this.callbacks)) {
|
for (const callback of Array.from(this.callbacks)) {
|
||||||
callback();
|
callback();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addCallback(cb: SceneStateCallback): SceneStateCallbackRemover {
|
onUpdate(cb: SceneStateCallback): SceneStateCallbackRemover {
|
||||||
if (this.callbacks.has(cb)) {
|
if (this.callbacks.has(cb)) {
|
||||||
throw new Error();
|
throw new Error();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ export const exportToSvg = async (
|
|||||||
assetPath =
|
assetPath =
|
||||||
window.EXCALIDRAW_ASSET_PATH ||
|
window.EXCALIDRAW_ASSET_PATH ||
|
||||||
`https://unpkg.com/${import.meta.env.VITE_PKG_NAME}@${
|
`https://unpkg.com/${import.meta.env.VITE_PKG_NAME}@${
|
||||||
import.meta.env.PKG_VERSION
|
import.meta.env.VITE_PKG_VERSION
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
if (assetPath?.startsWith("/")) {
|
if (assetPath?.startsWith("/")) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { RoughCanvas } from "roughjs/bin/canvas";
|
|||||||
import type { Drawable } from "roughjs/bin/core";
|
import type { Drawable } from "roughjs/bin/core";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawTextElement,
|
|
||||||
NonDeletedElementsMap,
|
NonDeletedElementsMap,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
@@ -55,7 +54,7 @@ export type InteractiveCanvasRenderConfig = {
|
|||||||
remotePointerUserStates: Map<SocketId, UserIdleState>;
|
remotePointerUserStates: Map<SocketId, UserIdleState>;
|
||||||
remotePointerUsernames: Map<SocketId, string>;
|
remotePointerUsernames: Map<SocketId, string>;
|
||||||
remotePointerButton: Map<SocketId, string | undefined>;
|
remotePointerButton: Map<SocketId, string | undefined>;
|
||||||
selectionColor?: string;
|
selectionColor: string;
|
||||||
// extra options passed to the renderer
|
// extra options passed to the renderer
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
renderScrollbars?: boolean;
|
renderScrollbars?: boolean;
|
||||||
@@ -83,6 +82,7 @@ export type InteractiveSceneRenderConfig = {
|
|||||||
elementsMap: RenderableElementsMap;
|
elementsMap: RenderableElementsMap;
|
||||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
allElementsMap: NonDeletedSceneElementsMap;
|
||||||
scale: number;
|
scale: number;
|
||||||
appState: InteractiveCanvasAppState;
|
appState: InteractiveCanvasAppState;
|
||||||
renderConfig: InteractiveCanvasRenderConfig;
|
renderConfig: InteractiveCanvasRenderConfig;
|
||||||
@@ -95,10 +95,6 @@ export type SceneScroll = {
|
|||||||
scrollY: number;
|
scrollY: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Scene {
|
|
||||||
elements: ExcalidrawTextElement[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ExportType =
|
export type ExportType =
|
||||||
| "png"
|
| "png"
|
||||||
| "clipboard"
|
| "clipboard"
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
import {
|
||||||
|
getClosedCurveShape,
|
||||||
|
getCurveShape,
|
||||||
|
getEllipseShape,
|
||||||
|
getFreedrawShape,
|
||||||
|
getPolygonShape,
|
||||||
|
type GeometricShape,
|
||||||
|
} from "../utils/geometry/shape";
|
||||||
import {
|
import {
|
||||||
ArrowIcon,
|
ArrowIcon,
|
||||||
DiamondIcon,
|
DiamondIcon,
|
||||||
@@ -10,7 +18,11 @@ import {
|
|||||||
SelectionIcon,
|
SelectionIcon,
|
||||||
TextIcon,
|
TextIcon,
|
||||||
} from "./components/icons";
|
} from "./components/icons";
|
||||||
|
import { getElementAbsoluteCoords } from "./element";
|
||||||
|
import { shouldTestInside } from "./element/collision";
|
||||||
|
import type { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||||
import { KEYS } from "./keys";
|
import { KEYS } from "./keys";
|
||||||
|
import { ShapeCache } from "./scene/ShapeCache";
|
||||||
|
|
||||||
export const SHAPES = [
|
export const SHAPES = [
|
||||||
{
|
{
|
||||||
@@ -97,3 +109,53 @@ export const findShapeByKey = (key: string) => {
|
|||||||
});
|
});
|
||||||
return shape?.value || null;
|
return shape?.value || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get the pure geometric shape of an excalidraw element
|
||||||
|
* which is then used for hit detection
|
||||||
|
*/
|
||||||
|
export const getElementShape = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): 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, elementsMap);
|
||||||
|
|
||||||
|
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, elementsMap);
|
||||||
|
return getFreedrawShape(element, [cx, cy], shouldTestInside(element));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1375,6 +1375,7 @@ export const isActiveToolNonLinearSnappable = (
|
|||||||
activeToolType === TOOL_TYPE.diamond ||
|
activeToolType === TOOL_TYPE.diamond ||
|
||||||
activeToolType === TOOL_TYPE.frame ||
|
activeToolType === TOOL_TYPE.frame ||
|
||||||
activeToolType === TOOL_TYPE.magicframe ||
|
activeToolType === TOOL_TYPE.magicframe ||
|
||||||
activeToolType === TOOL_TYPE.image
|
activeToolType === TOOL_TYPE.image ||
|
||||||
|
activeToolType === TOOL_TYPE.text
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { deepCopyElement } from "./element/newElement";
|
|||||||
import type { OrderedExcalidrawElement } from "./element/types";
|
import type { OrderedExcalidrawElement } from "./element/types";
|
||||||
import { Emitter } from "./emitter";
|
import { Emitter } from "./emitter";
|
||||||
import type { AppState, ObservedAppState } from "./types";
|
import type { AppState, ObservedAppState } from "./types";
|
||||||
|
import type { ValueOf } from "./utility-types";
|
||||||
import { isShallowEqual } from "./utils";
|
import { isShallowEqual } from "./utils";
|
||||||
|
|
||||||
// hidden non-enumerable property for runtime checks
|
// hidden non-enumerable property for runtime checks
|
||||||
@@ -35,16 +36,41 @@ const isObservedAppState = (
|
|||||||
): appState is ObservedAppState =>
|
): appState is ObservedAppState =>
|
||||||
!!Reflect.get(appState, hiddenObservedAppStateProp);
|
!!Reflect.get(appState, hiddenObservedAppStateProp);
|
||||||
|
|
||||||
export type StoreActionType = "capture" | "update" | "none";
|
export const StoreAction = {
|
||||||
|
/**
|
||||||
export const StoreAction: {
|
* Immediately undoable.
|
||||||
[K in Uppercase<StoreActionType>]: StoreActionType;
|
*
|
||||||
} = {
|
* Use for updates which should be captured.
|
||||||
|
* Should be used for most of the local updates.
|
||||||
|
*
|
||||||
|
* These updates will _immediately_ make it to the local undo / redo stacks.
|
||||||
|
*/
|
||||||
CAPTURE: "capture",
|
CAPTURE: "capture",
|
||||||
|
/**
|
||||||
|
* Never undoable.
|
||||||
|
*
|
||||||
|
* Use for updates which should never be recorded, such as remote updates
|
||||||
|
* or scene initialization.
|
||||||
|
*
|
||||||
|
* These updates will _never_ make it to the local undo / redo stacks.
|
||||||
|
*/
|
||||||
UPDATE: "update",
|
UPDATE: "update",
|
||||||
|
/**
|
||||||
|
* Eventually undoable.
|
||||||
|
*
|
||||||
|
* Use for updates which should not be captured immediately - likely
|
||||||
|
* exceptions which are part of some async multi-step process. Otherwise, all
|
||||||
|
* such updates would end up being captured with the next
|
||||||
|
* `StoreAction.CAPTURE` - triggered either by the next `updateScene`
|
||||||
|
* or internally by the editor.
|
||||||
|
*
|
||||||
|
* These updates will _eventually_ make it to the local undo / redo stacks.
|
||||||
|
*/
|
||||||
NONE: "none",
|
NONE: "none",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type StoreActionType = ValueOf<typeof StoreAction>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represent an increment to the Store.
|
* Represent an increment to the Store.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,28 +1,12 @@
|
|||||||
import { act, render, waitFor } from "./test-utils";
|
import { act, render, waitFor } from "./test-utils";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import React from "react";
|
import { expect } from "vitest";
|
||||||
import { expect, vi } from "vitest";
|
|
||||||
import * as MermaidToExcalidraw from "@excalidraw/mermaid-to-excalidraw";
|
|
||||||
import { getTextEditor, updateTextEditor } from "./queries/dom";
|
import { getTextEditor, updateTextEditor } from "./queries/dom";
|
||||||
|
import { mockMermaidToExcalidraw } from "./helpers/mocks";
|
||||||
|
|
||||||
vi.mock("@excalidraw/mermaid-to-excalidraw", async (importActual) => {
|
mockMermaidToExcalidraw({
|
||||||
const module = (await importActual()) as any;
|
mockRef: true,
|
||||||
|
parseMermaidToExcalidraw: async (definition) => {
|
||||||
return {
|
|
||||||
__esModule: true,
|
|
||||||
...module,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const parseMermaidToExcalidrawSpy = vi.spyOn(
|
|
||||||
MermaidToExcalidraw,
|
|
||||||
"parseMermaidToExcalidraw",
|
|
||||||
);
|
|
||||||
|
|
||||||
parseMermaidToExcalidrawSpy.mockImplementation(
|
|
||||||
async (
|
|
||||||
definition: string,
|
|
||||||
options?: MermaidToExcalidraw.MermaidOptions | undefined,
|
|
||||||
) => {
|
|
||||||
const firstLine = definition.split("\n")[0];
|
const firstLine = definition.split("\n")[0];
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (firstLine === "flowchart TD") {
|
if (firstLine === "flowchart TD") {
|
||||||
@@ -88,12 +72,6 @@ parseMermaidToExcalidrawSpy.mockImplementation(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
|
||||||
|
|
||||||
vi.spyOn(React, "useRef").mockReturnValue({
|
|
||||||
current: {
|
|
||||||
parseMermaidToExcalidraw: parseMermaidToExcalidrawSpy,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Test <MermaidToExcalidraw/>", () => {
|
describe("Test <MermaidToExcalidraw/>", () => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
"collaborators": Map {},
|
"collaborators": Map {},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"items": [
|
"items": [
|
||||||
|
"separator",
|
||||||
{
|
{
|
||||||
"icon": <svg
|
"icon": <svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -26,7 +27,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -129,7 +130,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -181,7 +182,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -247,7 +248,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -287,7 +288,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -326,6 +327,16 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
"category": "element",
|
"category": "element",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"icon": null,
|
||||||
|
"label": "labels.autoResize",
|
||||||
|
"name": "autoResize",
|
||||||
|
"perform": [Function],
|
||||||
|
"predicate": [Function],
|
||||||
|
"trackEvent": {
|
||||||
|
"category": "element",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "labels.unbindText",
|
"label": "labels.unbindText",
|
||||||
"name": "unbindText",
|
"name": "unbindText",
|
||||||
@@ -387,11 +398,15 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
transform="rotate(180)"
|
style={
|
||||||
|
{
|
||||||
|
"transform": "rotate(180deg)",
|
||||||
|
}
|
||||||
|
}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -438,7 +453,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -482,11 +497,15 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
transform="rotate(180)"
|
style={
|
||||||
|
{
|
||||||
|
"transform": "rotate(180deg)",
|
||||||
|
}
|
||||||
|
}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -535,7 +554,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -584,7 +603,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -624,7 +643,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -855,10 +874,13 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
"selectionElement": null,
|
"selectionElement": null,
|
||||||
"shouldCacheIgnoreZoom": false,
|
"shouldCacheIgnoreZoom": false,
|
||||||
"showHyperlinkPopup": false,
|
"showHyperlinkPopup": false,
|
||||||
"showStats": false,
|
|
||||||
"showWelcomeScreen": true,
|
"showWelcomeScreen": true,
|
||||||
"snapLines": [],
|
"snapLines": [],
|
||||||
"startBoundElement": null,
|
"startBoundElement": null,
|
||||||
|
"stats": {
|
||||||
|
"open": false,
|
||||||
|
"panels": 3,
|
||||||
|
},
|
||||||
"suggestedBindings": [],
|
"suggestedBindings": [],
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@@ -1047,10 +1069,13 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
|||||||
"selectionElement": null,
|
"selectionElement": null,
|
||||||
"shouldCacheIgnoreZoom": false,
|
"shouldCacheIgnoreZoom": false,
|
||||||
"showHyperlinkPopup": false,
|
"showHyperlinkPopup": false,
|
||||||
"showStats": false,
|
|
||||||
"showWelcomeScreen": true,
|
"showWelcomeScreen": true,
|
||||||
"snapLines": [],
|
"snapLines": [],
|
||||||
"startBoundElement": null,
|
"startBoundElement": null,
|
||||||
|
"stats": {
|
||||||
|
"open": false,
|
||||||
|
"panels": 3,
|
||||||
|
},
|
||||||
"suggestedBindings": [],
|
"suggestedBindings": [],
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": {
|
"toast": {
|
||||||
@@ -1255,10 +1280,13 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
|||||||
"selectionElement": null,
|
"selectionElement": null,
|
||||||
"shouldCacheIgnoreZoom": false,
|
"shouldCacheIgnoreZoom": false,
|
||||||
"showHyperlinkPopup": false,
|
"showHyperlinkPopup": false,
|
||||||
"showStats": false,
|
|
||||||
"showWelcomeScreen": true,
|
"showWelcomeScreen": true,
|
||||||
"snapLines": [],
|
"snapLines": [],
|
||||||
"startBoundElement": null,
|
"startBoundElement": null,
|
||||||
|
"stats": {
|
||||||
|
"open": false,
|
||||||
|
"panels": 3,
|
||||||
|
},
|
||||||
"suggestedBindings": [],
|
"suggestedBindings": [],
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@@ -1578,10 +1606,13 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
|||||||
"selectionElement": null,
|
"selectionElement": null,
|
||||||
"shouldCacheIgnoreZoom": false,
|
"shouldCacheIgnoreZoom": false,
|
||||||
"showHyperlinkPopup": false,
|
"showHyperlinkPopup": false,
|
||||||
"showStats": false,
|
|
||||||
"showWelcomeScreen": true,
|
"showWelcomeScreen": true,
|
||||||
"snapLines": [],
|
"snapLines": [],
|
||||||
"startBoundElement": null,
|
"startBoundElement": null,
|
||||||
|
"stats": {
|
||||||
|
"open": false,
|
||||||
|
"panels": 3,
|
||||||
|
},
|
||||||
"suggestedBindings": [],
|
"suggestedBindings": [],
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@@ -1901,10 +1932,13 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
|||||||
"selectionElement": null,
|
"selectionElement": null,
|
||||||
"shouldCacheIgnoreZoom": false,
|
"shouldCacheIgnoreZoom": false,
|
||||||
"showHyperlinkPopup": false,
|
"showHyperlinkPopup": false,
|
||||||
"showStats": false,
|
|
||||||
"showWelcomeScreen": true,
|
"showWelcomeScreen": true,
|
||||||
"snapLines": [],
|
"snapLines": [],
|
||||||
"startBoundElement": null,
|
"startBoundElement": null,
|
||||||
|
"stats": {
|
||||||
|
"open": false,
|
||||||
|
"panels": 3,
|
||||||
|
},
|
||||||
"suggestedBindings": [],
|
"suggestedBindings": [],
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": {
|
"toast": {
|
||||||
@@ -2107,10 +2141,13 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
|||||||
"selectionElement": null,
|
"selectionElement": null,
|
||||||
"shouldCacheIgnoreZoom": false,
|
"shouldCacheIgnoreZoom": false,
|
||||||
"showHyperlinkPopup": false,
|
"showHyperlinkPopup": false,
|
||||||
"showStats": false,
|
|
||||||
"showWelcomeScreen": true,
|
"showWelcomeScreen": true,
|
||||||
"snapLines": [],
|
"snapLines": [],
|
||||||
"startBoundElement": null,
|
"startBoundElement": null,
|
||||||
|
"stats": {
|
||||||
|
"open": false,
|
||||||
|
"panels": 3,
|
||||||
|
},
|
||||||
"suggestedBindings": [],
|
"suggestedBindings": [],
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@@ -2341,10 +2378,13 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
|||||||
"selectionElement": null,
|
"selectionElement": null,
|
||||||
"shouldCacheIgnoreZoom": false,
|
"shouldCacheIgnoreZoom": false,
|
||||||
"showHyperlinkPopup": false,
|
"showHyperlinkPopup": false,
|
||||||
"showStats": false,
|
|
||||||
"showWelcomeScreen": true,
|
"showWelcomeScreen": true,
|
||||||
"snapLines": [],
|
"snapLines": [],
|
||||||
"startBoundElement": null,
|
"startBoundElement": null,
|
||||||
|
"stats": {
|
||||||
|
"open": false,
|
||||||
|
"panels": 3,
|
||||||
|
},
|
||||||
"suggestedBindings": [],
|
"suggestedBindings": [],
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@@ -2639,10 +2679,13 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
|||||||
"selectionElement": null,
|
"selectionElement": null,
|
||||||
"shouldCacheIgnoreZoom": false,
|
"shouldCacheIgnoreZoom": false,
|
||||||
"showHyperlinkPopup": false,
|
"showHyperlinkPopup": false,
|
||||||
"showStats": false,
|
|
||||||
"showWelcomeScreen": true,
|
"showWelcomeScreen": true,
|
||||||
"snapLines": [],
|
"snapLines": [],
|
||||||
"startBoundElement": null,
|
"startBoundElement": null,
|
||||||
|
"stats": {
|
||||||
|
"open": false,
|
||||||
|
"panels": 3,
|
||||||
|
},
|
||||||
"suggestedBindings": [],
|
"suggestedBindings": [],
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@@ -2995,10 +3038,13 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
|||||||
"selectionElement": null,
|
"selectionElement": null,
|
||||||
"shouldCacheIgnoreZoom": false,
|
"shouldCacheIgnoreZoom": false,
|
||||||
"showHyperlinkPopup": false,
|
"showHyperlinkPopup": false,
|
||||||
"showStats": false,
|
|
||||||
"showWelcomeScreen": true,
|
"showWelcomeScreen": true,
|
||||||
"snapLines": [],
|
"snapLines": [],
|
||||||
"startBoundElement": null,
|
"startBoundElement": null,
|
||||||
|
"stats": {
|
||||||
|
"open": false,
|
||||||
|
"panels": 3,
|
||||||
|
},
|
||||||
"suggestedBindings": [],
|
"suggestedBindings": [],
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": {
|
"toast": {
|
||||||
@@ -3462,10 +3508,13 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
|||||||
"selectionElement": null,
|
"selectionElement": null,
|
||||||
"shouldCacheIgnoreZoom": false,
|
"shouldCacheIgnoreZoom": false,
|
||||||
"showHyperlinkPopup": false,
|
"showHyperlinkPopup": false,
|
||||||
"showStats": false,
|
|
||||||
"showWelcomeScreen": true,
|
"showWelcomeScreen": true,
|
||||||
"snapLines": [],
|
"snapLines": [],
|
||||||
"startBoundElement": null,
|
"startBoundElement": null,
|
||||||
|
"stats": {
|
||||||
|
"open": false,
|
||||||
|
"panels": 3,
|
||||||
|
},
|
||||||
"suggestedBindings": [],
|
"suggestedBindings": [],
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@@ -3777,10 +3826,13 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
|||||||
"selectionElement": null,
|
"selectionElement": null,
|
||||||
"shouldCacheIgnoreZoom": false,
|
"shouldCacheIgnoreZoom": false,
|
||||||
"showHyperlinkPopup": false,
|
"showHyperlinkPopup": false,
|
||||||
"showStats": false,
|
|
||||||
"showWelcomeScreen": true,
|
"showWelcomeScreen": true,
|
||||||
"snapLines": [],
|
"snapLines": [],
|
||||||
"startBoundElement": null,
|
"startBoundElement": null,
|
||||||
|
"stats": {
|
||||||
|
"open": false,
|
||||||
|
"panels": 3,
|
||||||
|
},
|
||||||
"suggestedBindings": [],
|
"suggestedBindings": [],
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@@ -4095,10 +4147,13 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
|||||||
"selectionElement": null,
|
"selectionElement": null,
|
||||||
"shouldCacheIgnoreZoom": false,
|
"shouldCacheIgnoreZoom": false,
|
||||||
"showHyperlinkPopup": false,
|
"showHyperlinkPopup": false,
|
||||||
"showStats": false,
|
|
||||||
"showWelcomeScreen": true,
|
"showWelcomeScreen": true,
|
||||||
"snapLines": [],
|
"snapLines": [],
|
||||||
"startBoundElement": null,
|
"startBoundElement": null,
|
||||||
|
"stats": {
|
||||||
|
"open": false,
|
||||||
|
"panels": 3,
|
||||||
|
},
|
||||||
"suggestedBindings": [],
|
"suggestedBindings": [],
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@@ -4414,6 +4469,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
"collaborators": Map {},
|
"collaborators": Map {},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"items": [
|
"items": [
|
||||||
|
"separator",
|
||||||
{
|
{
|
||||||
"icon": <svg
|
"icon": <svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -4428,7 +4484,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -4531,7 +4587,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -4583,7 +4639,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -4649,7 +4705,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -4689,7 +4745,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -4728,6 +4784,16 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
"category": "element",
|
"category": "element",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"icon": null,
|
||||||
|
"label": "labels.autoResize",
|
||||||
|
"name": "autoResize",
|
||||||
|
"perform": [Function],
|
||||||
|
"predicate": [Function],
|
||||||
|
"trackEvent": {
|
||||||
|
"category": "element",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "labels.unbindText",
|
"label": "labels.unbindText",
|
||||||
"name": "unbindText",
|
"name": "unbindText",
|
||||||
@@ -4789,11 +4855,15 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
transform="rotate(180)"
|
style={
|
||||||
|
{
|
||||||
|
"transform": "rotate(180deg)",
|
||||||
|
}
|
||||||
|
}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -4840,7 +4910,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -4884,11 +4954,15 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
transform="rotate(180)"
|
style={
|
||||||
|
{
|
||||||
|
"transform": "rotate(180deg)",
|
||||||
|
}
|
||||||
|
}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -4937,7 +5011,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -4986,7 +5060,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -5026,7 +5100,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -5254,10 +5328,13 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
"selectionElement": null,
|
"selectionElement": null,
|
||||||
"shouldCacheIgnoreZoom": false,
|
"shouldCacheIgnoreZoom": false,
|
||||||
"showHyperlinkPopup": false,
|
"showHyperlinkPopup": false,
|
||||||
"showStats": false,
|
|
||||||
"showWelcomeScreen": true,
|
"showWelcomeScreen": true,
|
||||||
"snapLines": [],
|
"snapLines": [],
|
||||||
"startBoundElement": null,
|
"startBoundElement": null,
|
||||||
|
"stats": {
|
||||||
|
"open": false,
|
||||||
|
"panels": 3,
|
||||||
|
},
|
||||||
"suggestedBindings": [],
|
"suggestedBindings": [],
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@@ -5514,6 +5591,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
"collaborators": Map {},
|
"collaborators": Map {},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"items": [
|
"items": [
|
||||||
|
"separator",
|
||||||
{
|
{
|
||||||
"icon": <svg
|
"icon": <svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -5528,7 +5606,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -5631,7 +5709,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -5683,7 +5761,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -5749,7 +5827,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -5789,7 +5867,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -5828,6 +5906,16 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
"category": "element",
|
"category": "element",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"icon": null,
|
||||||
|
"label": "labels.autoResize",
|
||||||
|
"name": "autoResize",
|
||||||
|
"perform": [Function],
|
||||||
|
"predicate": [Function],
|
||||||
|
"trackEvent": {
|
||||||
|
"category": "element",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "labels.unbindText",
|
"label": "labels.unbindText",
|
||||||
"name": "unbindText",
|
"name": "unbindText",
|
||||||
@@ -5889,11 +5977,15 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
transform="rotate(180)"
|
style={
|
||||||
|
{
|
||||||
|
"transform": "rotate(180deg)",
|
||||||
|
}
|
||||||
|
}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -5940,7 +6032,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -5984,11 +6076,15 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
transform="rotate(180)"
|
style={
|
||||||
|
{
|
||||||
|
"transform": "rotate(180deg)",
|
||||||
|
}
|
||||||
|
}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -6037,7 +6133,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -6086,7 +6182,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -6126,7 +6222,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -6356,10 +6452,13 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
"selectionElement": null,
|
"selectionElement": null,
|
||||||
"shouldCacheIgnoreZoom": false,
|
"shouldCacheIgnoreZoom": false,
|
||||||
"showHyperlinkPopup": false,
|
"showHyperlinkPopup": false,
|
||||||
"showStats": false,
|
|
||||||
"showWelcomeScreen": true,
|
"showWelcomeScreen": true,
|
||||||
"snapLines": [],
|
"snapLines": [],
|
||||||
"startBoundElement": null,
|
"startBoundElement": null,
|
||||||
|
"stats": {
|
||||||
|
"open": false,
|
||||||
|
"panels": 3,
|
||||||
|
},
|
||||||
"suggestedBindings": [],
|
"suggestedBindings": [],
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@@ -6684,7 +6783,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -6736,7 +6835,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -6948,7 +7047,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -7004,7 +7103,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -7048,7 +7147,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -7101,7 +7200,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
|||||||
<g
|
<g
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -7143,7 +7242,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -7186,7 +7285,12 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
|||||||
</g>
|
</g>
|
||||||
</svg>,
|
</svg>,
|
||||||
"keyTest": [Function],
|
"keyTest": [Function],
|
||||||
"label": "stats.title",
|
"keywords": [
|
||||||
|
"edit",
|
||||||
|
"attributes",
|
||||||
|
"customize",
|
||||||
|
],
|
||||||
|
"label": "stats.fullTitle",
|
||||||
"name": "stats",
|
"name": "stats",
|
||||||
"paletteName": "Toggle stats",
|
"paletteName": "Toggle stats",
|
||||||
"perform": [Function],
|
"perform": [Function],
|
||||||
@@ -7274,10 +7378,13 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
|||||||
"selectionElement": null,
|
"selectionElement": null,
|
||||||
"shouldCacheIgnoreZoom": false,
|
"shouldCacheIgnoreZoom": false,
|
||||||
"showHyperlinkPopup": false,
|
"showHyperlinkPopup": false,
|
||||||
"showStats": false,
|
|
||||||
"showWelcomeScreen": true,
|
"showWelcomeScreen": true,
|
||||||
"snapLines": [],
|
"snapLines": [],
|
||||||
"startBoundElement": null,
|
"startBoundElement": null,
|
||||||
|
"stats": {
|
||||||
|
"open": false,
|
||||||
|
"panels": 3,
|
||||||
|
},
|
||||||
"suggestedBindings": [],
|
"suggestedBindings": [],
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@@ -7321,6 +7428,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
"collaborators": Map {},
|
"collaborators": Map {},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"items": [
|
"items": [
|
||||||
|
"separator",
|
||||||
{
|
{
|
||||||
"icon": <svg
|
"icon": <svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -7335,7 +7443,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -7438,7 +7546,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -7490,7 +7598,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -7556,7 +7664,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -7596,7 +7704,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -7635,6 +7743,16 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
"category": "element",
|
"category": "element",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"icon": null,
|
||||||
|
"label": "labels.autoResize",
|
||||||
|
"name": "autoResize",
|
||||||
|
"perform": [Function],
|
||||||
|
"predicate": [Function],
|
||||||
|
"trackEvent": {
|
||||||
|
"category": "element",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "labels.unbindText",
|
"label": "labels.unbindText",
|
||||||
"name": "unbindText",
|
"name": "unbindText",
|
||||||
@@ -7696,11 +7814,15 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
transform="rotate(180)"
|
style={
|
||||||
|
{
|
||||||
|
"transform": "rotate(180deg)",
|
||||||
|
}
|
||||||
|
}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -7747,7 +7869,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -7791,11 +7913,15 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
transform="rotate(180)"
|
style={
|
||||||
|
{
|
||||||
|
"transform": "rotate(180deg)",
|
||||||
|
}
|
||||||
|
}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -7844,7 +7970,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -7893,7 +8019,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -7933,7 +8059,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -8158,10 +8284,13 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
"selectionElement": null,
|
"selectionElement": null,
|
||||||
"shouldCacheIgnoreZoom": false,
|
"shouldCacheIgnoreZoom": false,
|
||||||
"showHyperlinkPopup": false,
|
"showHyperlinkPopup": false,
|
||||||
"showStats": false,
|
|
||||||
"showWelcomeScreen": true,
|
"showWelcomeScreen": true,
|
||||||
"snapLines": [],
|
"snapLines": [],
|
||||||
"startBoundElement": null,
|
"startBoundElement": null,
|
||||||
|
"stats": {
|
||||||
|
"open": false,
|
||||||
|
"panels": 3,
|
||||||
|
},
|
||||||
"suggestedBindings": [],
|
"suggestedBindings": [],
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@@ -8188,6 +8317,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
"collaborators": Map {},
|
"collaborators": Map {},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"items": [
|
"items": [
|
||||||
|
"separator",
|
||||||
{
|
{
|
||||||
"icon": <svg
|
"icon": <svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -8202,7 +8332,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -8305,7 +8435,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -8357,7 +8487,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -8423,7 +8553,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -8463,7 +8593,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -8502,6 +8632,16 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
"category": "element",
|
"category": "element",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"icon": null,
|
||||||
|
"label": "labels.autoResize",
|
||||||
|
"name": "autoResize",
|
||||||
|
"perform": [Function],
|
||||||
|
"predicate": [Function],
|
||||||
|
"trackEvent": {
|
||||||
|
"category": "element",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "labels.unbindText",
|
"label": "labels.unbindText",
|
||||||
"name": "unbindText",
|
"name": "unbindText",
|
||||||
@@ -8563,11 +8703,15 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
transform="rotate(180)"
|
style={
|
||||||
|
{
|
||||||
|
"transform": "rotate(180deg)",
|
||||||
|
}
|
||||||
|
}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -8614,7 +8758,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -8658,11 +8802,15 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
transform="rotate(180)"
|
style={
|
||||||
|
{
|
||||||
|
"transform": "rotate(180deg)",
|
||||||
|
}
|
||||||
|
}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -8711,7 +8859,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.5}
|
strokeWidth={"1.50000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -8760,7 +8908,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -8800,7 +8948,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
strokeWidth={1.25}
|
strokeWidth={"1.25000"}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
@@ -9028,10 +9176,13 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
"selectionElement": null,
|
"selectionElement": null,
|
||||||
"shouldCacheIgnoreZoom": false,
|
"shouldCacheIgnoreZoom": false,
|
||||||
"showHyperlinkPopup": false,
|
"showHyperlinkPopup": false,
|
||||||
"showStats": false,
|
|
||||||
"showWelcomeScreen": true,
|
"showWelcomeScreen": true,
|
||||||
"snapLines": [],
|
"snapLines": [],
|
||||||
"startBoundElement": null,
|
"startBoundElement": null,
|
||||||
|
"stats": {
|
||||||
|
"open": false,
|
||||||
|
"panels": 3,
|
||||||
|
},
|
||||||
"suggestedBindings": [],
|
"suggestedBindings": [],
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user