Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93c33fef20 | ||
|
|
df8875a497 | ||
|
|
6fbc44fd1f | ||
|
|
d25a7d365b | ||
|
|
e52c2cd0b6 | ||
|
|
96eeec5119 | ||
|
|
f5221d521b | ||
|
|
db2c235cd4 | ||
|
|
148b895f46 | ||
|
|
d9258a736b | ||
|
|
2e1f08c796 | ||
|
|
1d5b41dabb | ||
|
|
66a2f24296 | ||
|
|
04668d8263 | ||
|
|
abbeed3d5f | ||
|
|
ba8c09d529 | ||
|
|
744b3e5d09 |
+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
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ 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
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+6
-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,6 +119,8 @@ import {
|
|||||||
youtubeIcon,
|
youtubeIcon,
|
||||||
} from "../packages/excalidraw/components/icons";
|
} from "../packages/excalidraw/components/icons";
|
||||||
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
||||||
|
import { getPreferredLanguage } from "./app-language/language-detector";
|
||||||
|
import { useAppLangCode } from "./app-language/language-state";
|
||||||
|
|
||||||
polyfill();
|
polyfill();
|
||||||
|
|
||||||
@@ -172,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: (
|
||||||
@@ -322,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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -490,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,
|
||||||
@@ -595,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,
|
||||||
|
|||||||
+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;
|
||||||
|
|||||||
@@ -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)"),
|
||||||
|
|||||||
@@ -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),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ import {
|
|||||||
import type { PastedMixedContent } from "../clipboard";
|
import type { PastedMixedContent } from "../clipboard";
|
||||||
import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
|
import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
|
||||||
import type { EXPORT_IMAGE_TYPES } from "../constants";
|
import type { EXPORT_IMAGE_TYPES } from "../constants";
|
||||||
import { DEFAULT_FONT_SIZE } from "../constants";
|
|
||||||
import {
|
import {
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
CURSOR_TYPE,
|
CURSOR_TYPE,
|
||||||
@@ -225,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,
|
||||||
@@ -424,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";
|
||||||
@@ -2498,7 +2489,9 @@ 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.fonts = new Fonts({ scene: this.scene });
|
||||||
this.renderer = new Renderer(this.scene);
|
this.renderer = new Renderer(this.scene);
|
||||||
@@ -2507,7 +2500,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
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();
|
||||||
@@ -2819,7 +2811,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
nonDeletedElementsMap,
|
nonDeletedElementsMap,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
this,
|
this.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3062,9 +3054,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { elements: skeletonElements, files } =
|
const { elements: skeletonElements, files } =
|
||||||
await api.parseMermaidToExcalidraw(data.text, {
|
await api.parseMermaidToExcalidraw(data.text);
|
||||||
fontSize: DEFAULT_FONT_SIZE,
|
|
||||||
});
|
|
||||||
|
|
||||||
const elements = convertToExcalidrawElements(skeletonElements, {
|
const elements = convertToExcalidrawElements(skeletonElements, {
|
||||||
regenerateIds: true,
|
regenerateIds: true,
|
||||||
@@ -4008,7 +3998,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState({
|
this.setState({
|
||||||
suggestedBindings: getSuggestedBindingsForArrows(
|
suggestedBindings: getSuggestedBindingsForArrows(
|
||||||
selectedElements,
|
selectedElements,
|
||||||
this,
|
this.scene.getNonDeletedElementsMap(),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4179,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 ?? [],
|
||||||
);
|
);
|
||||||
@@ -4491,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,
|
||||||
@@ -4552,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;
|
||||||
@@ -4602,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,
|
||||||
@@ -4707,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)
|
||||||
@@ -4739,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(),
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
@@ -4997,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(),
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
@@ -5689,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(
|
||||||
@@ -6808,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({
|
||||||
@@ -7070,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);
|
||||||
@@ -7540,7 +7495,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState({
|
this.setState({
|
||||||
suggestedBindings: getSuggestedBindingsForArrows(
|
suggestedBindings: getSuggestedBindingsForArrows(
|
||||||
selectedElements,
|
selectedElements,
|
||||||
this,
|
this.scene.getNonDeletedElementsMap(),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -8061,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 });
|
||||||
@@ -8551,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)
|
||||||
@@ -8619,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 ?? [],
|
||||||
);
|
);
|
||||||
@@ -9107,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:
|
||||||
@@ -9134,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 &&
|
||||||
@@ -9666,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>();
|
||||||
|
|||||||
@@ -1,67 +1,80 @@
|
|||||||
import { mutateElement } from "../../element/mutateElement";
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
import { getBoundTextElement } from "../../element/textElement";
|
import { getBoundTextElement } from "../../element/textElement";
|
||||||
import { isArrowElement } from "../../element/typeChecks";
|
import { isArrowElement } from "../../element/typeChecks";
|
||||||
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
import { degreeToRadian, radianToDegree } from "../../math";
|
import { degreeToRadian, radianToDegree } from "../../math";
|
||||||
import { angleIcon } from "../icons";
|
import { angleIcon } from "../icons";
|
||||||
import DragInput from "./DragInput";
|
import DragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
|
||||||
interface AngleProps {
|
interface AngleProps {
|
||||||
element: ExcalidrawElement;
|
element: ExcalidrawElement;
|
||||||
elementsMap: ElementsMap;
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
|
property: "angle";
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEP_SIZE = 15;
|
const STEP_SIZE = 15;
|
||||||
|
|
||||||
const Angle = ({ element, elementsMap }: AngleProps) => {
|
const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
|
||||||
const handleDegreeChange: DragInputCallbackType = ({
|
accumulatedChange,
|
||||||
accumulatedChange,
|
originalElements,
|
||||||
originalElements,
|
shouldChangeByStepSize,
|
||||||
shouldChangeByStepSize,
|
nextValue,
|
||||||
nextValue,
|
scene,
|
||||||
}) => {
|
}) => {
|
||||||
const origElement = originalElements[0];
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
if (origElement) {
|
const origElement = originalElements[0];
|
||||||
if (nextValue !== undefined) {
|
if (origElement) {
|
||||||
const nextAngle = degreeToRadian(nextValue);
|
const latestElement = elementsMap.get(origElement.id);
|
||||||
mutateElement(element, {
|
if (!latestElement) {
|
||||||
angle: nextAngle,
|
return;
|
||||||
});
|
}
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
if (nextValue !== undefined) {
|
||||||
if (boundTextElement && !isArrowElement(element)) {
|
const nextAngle = degreeToRadian(nextValue);
|
||||||
mutateElement(boundTextElement, { angle: nextAngle });
|
mutateElement(latestElement, {
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalAngleInDegrees =
|
|
||||||
Math.round(radianToDegree(origElement.angle) * 100) / 100;
|
|
||||||
const changeInDegrees = Math.round(accumulatedChange);
|
|
||||||
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
|
||||||
if (shouldChangeByStepSize) {
|
|
||||||
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
|
|
||||||
}
|
|
||||||
|
|
||||||
nextAngleInDegrees =
|
|
||||||
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
|
||||||
|
|
||||||
const nextAngle = degreeToRadian(nextAngleInDegrees);
|
|
||||||
|
|
||||||
mutateElement(element, {
|
|
||||||
angle: nextAngle,
|
angle: nextAngle,
|
||||||
});
|
});
|
||||||
|
updateBindings(latestElement, elementsMap);
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||||
if (boundTextElement && !isArrowElement(element)) {
|
if (boundTextElement && !isArrowElement(latestElement)) {
|
||||||
mutateElement(boundTextElement, { angle: nextAngle });
|
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 (
|
return (
|
||||||
<DragInput
|
<DragInput
|
||||||
label="A"
|
label="A"
|
||||||
@@ -70,6 +83,9 @@ const Angle = ({ element, elementsMap }: AngleProps) => {
|
|||||||
elements={[element]}
|
elements={[element]}
|
||||||
dragInputCallback={handleDegreeChange}
|
dragInputCallback={handleDegreeChange}
|
||||||
editable={isPropertyEditable(element, "angle")}
|
editable={isPropertyEditable(element, "angle")}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
property={property}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
import DragInput from "./DragInput";
|
import DragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
|
import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
|
||||||
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
|
||||||
interface DimensionDragInputProps {
|
interface DimensionDragInputProps {
|
||||||
property: "width" | "height";
|
property: "width" | "height";
|
||||||
element: ExcalidrawElement;
|
element: ExcalidrawElement;
|
||||||
elementsMap: ElementsMap;
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEP_SIZE = 10;
|
const STEP_SIZE = 10;
|
||||||
@@ -15,99 +18,101 @@ const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
|
|||||||
return element.type === "image";
|
return element.type === "image";
|
||||||
};
|
};
|
||||||
|
|
||||||
const DimensionDragInput = ({
|
const handleDimensionChange: DragInputCallbackType<
|
||||||
|
DimensionDragInputProps["property"]
|
||||||
|
> = ({
|
||||||
|
accumulatedChange,
|
||||||
|
originalElements,
|
||||||
|
originalElementsMap,
|
||||||
|
shouldKeepAspectRatio,
|
||||||
|
shouldChangeByStepSize,
|
||||||
|
nextValue,
|
||||||
property,
|
property,
|
||||||
element,
|
scene,
|
||||||
elementsMap,
|
}) => {
|
||||||
}: DimensionDragInputProps) => {
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const handleDimensionChange: DragInputCallbackType = ({
|
const origElement = originalElements[0];
|
||||||
accumulatedChange,
|
if (origElement) {
|
||||||
originalElements,
|
const keepAspectRatio =
|
||||||
originalElementsMap,
|
shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
|
||||||
shouldKeepAspectRatio,
|
const aspectRatio = origElement.width / origElement.height;
|
||||||
shouldChangeByStepSize,
|
|
||||||
nextValue,
|
|
||||||
}) => {
|
|
||||||
const origElement = originalElements[0];
|
|
||||||
if (origElement) {
|
|
||||||
const keepAspectRatio =
|
|
||||||
shouldKeepAspectRatio || _shouldKeepAspectRatio(element);
|
|
||||||
const aspectRatio = origElement.width / origElement.height;
|
|
||||||
|
|
||||||
if (nextValue !== undefined) {
|
if (nextValue !== undefined) {
|
||||||
const nextWidth = Math.max(
|
const nextWidth = Math.max(
|
||||||
property === "width"
|
property === "width"
|
||||||
? nextValue
|
? nextValue
|
||||||
: keepAspectRatio
|
: keepAspectRatio
|
||||||
? nextValue * aspectRatio
|
? nextValue * aspectRatio
|
||||||
: origElement.width,
|
: origElement.width,
|
||||||
MIN_WIDTH_OR_HEIGHT,
|
MIN_WIDTH_OR_HEIGHT,
|
||||||
);
|
);
|
||||||
const nextHeight = Math.max(
|
const nextHeight = Math.max(
|
||||||
property === "height"
|
property === "height"
|
||||||
? nextValue
|
? nextValue
|
||||||
: keepAspectRatio
|
: keepAspectRatio
|
||||||
? nextValue / aspectRatio
|
? nextValue / aspectRatio
|
||||||
: origElement.height,
|
: origElement.height,
|
||||||
MIN_WIDTH_OR_HEIGHT,
|
MIN_WIDTH_OR_HEIGHT,
|
||||||
);
|
);
|
||||||
|
|
||||||
resizeElement(
|
|
||||||
nextWidth,
|
|
||||||
nextHeight,
|
|
||||||
keepAspectRatio,
|
|
||||||
element,
|
|
||||||
origElement,
|
|
||||||
elementsMap,
|
|
||||||
originalElementsMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
|
||||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
|
||||||
|
|
||||||
let nextWidth = Math.max(0, origElement.width + changeInWidth);
|
|
||||||
if (property === "width") {
|
|
||||||
if (shouldChangeByStepSize) {
|
|
||||||
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
|
||||||
} else {
|
|
||||||
nextWidth = Math.round(nextWidth);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextHeight = Math.max(0, origElement.height + changeInHeight);
|
|
||||||
if (property === "height") {
|
|
||||||
if (shouldChangeByStepSize) {
|
|
||||||
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
|
||||||
} else {
|
|
||||||
nextHeight = Math.round(nextHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keepAspectRatio) {
|
|
||||||
if (property === "width") {
|
|
||||||
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
|
|
||||||
} else {
|
|
||||||
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
|
||||||
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
|
||||||
|
|
||||||
resizeElement(
|
resizeElement(
|
||||||
nextWidth,
|
nextWidth,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
keepAspectRatio,
|
keepAspectRatio,
|
||||||
element,
|
|
||||||
origElement,
|
origElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
originalElementsMap,
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||||
|
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||||
|
|
||||||
|
let nextWidth = Math.max(0, origElement.width + changeInWidth);
|
||||||
|
if (property === "width") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextWidth = Math.round(nextWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextHeight = Math.max(0, origElement.height + changeInHeight);
|
||||||
|
if (property === "height") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextHeight = Math.round(nextHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keepAspectRatio) {
|
||||||
|
if (property === "width") {
|
||||||
|
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
|
||||||
|
} else {
|
||||||
|
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||||
|
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||||
|
|
||||||
|
resizeElement(
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
keepAspectRatio,
|
||||||
|
origElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const DimensionDragInput = ({
|
||||||
|
property,
|
||||||
|
element,
|
||||||
|
scene,
|
||||||
|
appState,
|
||||||
|
}: DimensionDragInputProps) => {
|
||||||
const value =
|
const value =
|
||||||
Math.round((property === "width" ? element.width : element.height) * 100) /
|
Math.round((property === "width" ? element.width : element.height) * 100) /
|
||||||
100;
|
100;
|
||||||
@@ -119,6 +124,9 @@ const DimensionDragInput = ({
|
|||||||
dragInputCallback={handleDimensionChange}
|
dragInputCallback={handleDimensionChange}
|
||||||
value={value}
|
value={value}
|
||||||
editable={isPropertyEditable(element, property)}
|
editable={isPropertyEditable(element, property)}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
property={property}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,43 +3,54 @@ import { EVENT } from "../../constants";
|
|||||||
import { KEYS } from "../../keys";
|
import { KEYS } from "../../keys";
|
||||||
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
||||||
import { deepCopyElement } from "../../element/newElement";
|
import { deepCopyElement } from "../../element/newElement";
|
||||||
|
|
||||||
import "./DragInput.scss";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useApp } from "../App";
|
import { useApp } from "../App";
|
||||||
import { InlineIcon } from "../InlineIcon";
|
import { InlineIcon } from "../InlineIcon";
|
||||||
|
import type { StatsInputProperty } from "./utils";
|
||||||
import { SMALLEST_DELTA } from "./utils";
|
import { SMALLEST_DELTA } from "./utils";
|
||||||
import { StoreAction } from "../../store";
|
import { StoreAction } from "../../store";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
|
||||||
export type DragInputCallbackType = ({
|
import "./DragInput.scss";
|
||||||
accumulatedChange,
|
import type { AppState } from "../../types";
|
||||||
instantChange,
|
import { cloneJSON } from "../../utils";
|
||||||
originalElements,
|
|
||||||
originalElementsMap,
|
export type DragInputCallbackType<
|
||||||
shouldKeepAspectRatio,
|
P extends StatsInputProperty,
|
||||||
shouldChangeByStepSize,
|
E = ExcalidrawElement,
|
||||||
nextValue,
|
> = (props: {
|
||||||
}: {
|
|
||||||
accumulatedChange: number;
|
accumulatedChange: number;
|
||||||
instantChange: number;
|
instantChange: number;
|
||||||
originalElements: readonly ExcalidrawElement[];
|
originalElements: readonly E[];
|
||||||
originalElementsMap: ElementsMap;
|
originalElementsMap: ElementsMap;
|
||||||
shouldKeepAspectRatio: boolean;
|
shouldKeepAspectRatio: boolean;
|
||||||
shouldChangeByStepSize: boolean;
|
shouldChangeByStepSize: boolean;
|
||||||
nextValue?: number;
|
nextValue?: number;
|
||||||
|
property: P;
|
||||||
|
scene: Scene;
|
||||||
|
originalAppState: AppState;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
interface StatsDragInputProps {
|
interface StatsDragInputProps<
|
||||||
|
T extends StatsInputProperty,
|
||||||
|
E = ExcalidrawElement,
|
||||||
|
> {
|
||||||
label: string | React.ReactNode;
|
label: string | React.ReactNode;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
value: number | "Mixed";
|
value: number | "Mixed";
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly E[];
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
shouldKeepAspectRatio?: boolean;
|
shouldKeepAspectRatio?: boolean;
|
||||||
dragInputCallback: DragInputCallbackType;
|
dragInputCallback: DragInputCallbackType<T, E>;
|
||||||
|
property: T;
|
||||||
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatsDragInput = ({
|
const StatsDragInput = <
|
||||||
|
T extends StatsInputProperty,
|
||||||
|
E extends ExcalidrawElement = ExcalidrawElement,
|
||||||
|
>({
|
||||||
label,
|
label,
|
||||||
icon,
|
icon,
|
||||||
dragInputCallback,
|
dragInputCallback,
|
||||||
@@ -47,19 +58,48 @@ const StatsDragInput = ({
|
|||||||
elements,
|
elements,
|
||||||
editable = true,
|
editable = true,
|
||||||
shouldKeepAspectRatio,
|
shouldKeepAspectRatio,
|
||||||
}: StatsDragInputProps) => {
|
property,
|
||||||
|
scene,
|
||||||
|
appState,
|
||||||
|
}: StatsDragInputProps<T, E>) => {
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const labelRef = useRef<HTMLDivElement>(null);
|
const labelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [inputValue, setInputValue] = useState(value.toString());
|
const [inputValue, setInputValue] = useState(value.toString());
|
||||||
|
|
||||||
useEffect(() => {
|
const stateRef = useRef<{
|
||||||
setInputValue(value.toString());
|
originalAppState: AppState;
|
||||||
}, [value, elements]);
|
originalElements: readonly E[];
|
||||||
|
lastUpdatedValue: string;
|
||||||
|
updatePending: boolean;
|
||||||
|
}>(null!);
|
||||||
|
if (!stateRef.current) {
|
||||||
|
stateRef.current = {
|
||||||
|
originalAppState: cloneJSON(appState),
|
||||||
|
originalElements: elements,
|
||||||
|
lastUpdatedValue: inputValue,
|
||||||
|
updatePending: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const handleInputValue = (v: string) => {
|
useEffect(() => {
|
||||||
const parsed = Number(v);
|
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)) {
|
if (isNaN(parsed)) {
|
||||||
setInputValue(value.toString());
|
setInputValue(value.toString());
|
||||||
return;
|
return;
|
||||||
@@ -74,6 +114,7 @@ const StatsDragInput = ({
|
|||||||
// than the smallest delta allowed, which is 0.01
|
// than the smallest delta allowed, which is 0.01
|
||||||
// reason: idempotent to avoid unnecessary
|
// reason: idempotent to avoid unnecessary
|
||||||
if (isNaN(original) || Math.abs(rounded - original) >= SMALLEST_DELTA) {
|
if (isNaN(original) || Math.abs(rounded - original) >= SMALLEST_DELTA) {
|
||||||
|
stateRef.current.lastUpdatedValue = updatedValue;
|
||||||
dragInputCallback({
|
dragInputCallback({
|
||||||
accumulatedChange: 0,
|
accumulatedChange: 0,
|
||||||
instantChange: 0,
|
instantChange: 0,
|
||||||
@@ -82,6 +123,9 @@ const StatsDragInput = ({
|
|||||||
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
||||||
shouldChangeByStepSize: false,
|
shouldChangeByStepSize: false,
|
||||||
nextValue: rounded,
|
nextValue: rounded,
|
||||||
|
property,
|
||||||
|
scene,
|
||||||
|
originalAppState: appState,
|
||||||
});
|
});
|
||||||
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
||||||
}
|
}
|
||||||
@@ -97,12 +141,28 @@ const StatsDragInput = ({
|
|||||||
return () => {
|
return () => {
|
||||||
const nextValue = input?.value;
|
const nextValue = input?.value;
|
||||||
if (nextValue) {
|
if (nextValue) {
|
||||||
handleInputValueRef.current(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,
|
||||||
|
]);
|
||||||
|
|
||||||
return editable ? (
|
if (!editable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("drag-input-container", !editable && "disabled")}
|
className={clsx("drag-input-container", !editable && "disabled")}
|
||||||
data-testid={label}
|
data-testid={label}
|
||||||
@@ -122,30 +182,25 @@ const StatsDragInput = ({
|
|||||||
y: number;
|
y: number;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
let originalElements: ExcalidrawElement[] | null = null;
|
|
||||||
let originalElementsMap: Map<string, ExcalidrawElement> | null =
|
let originalElementsMap: Map<string, ExcalidrawElement> | null =
|
||||||
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;
|
let accumulatedChange: number | null = null;
|
||||||
|
|
||||||
document.body.classList.add("excalidraw-cursor-resize");
|
document.body.classList.add("excalidraw-cursor-resize");
|
||||||
|
|
||||||
const onPointerMove = (event: PointerEvent) => {
|
const onPointerMove = (event: PointerEvent) => {
|
||||||
if (!originalElementsMap) {
|
|
||||||
originalElementsMap = app.scene
|
|
||||||
.getNonDeletedElements()
|
|
||||||
.reduce((acc, element) => {
|
|
||||||
acc.set(element.id, deepCopyElement(element));
|
|
||||||
return acc;
|
|
||||||
}, new Map() as ElementsMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!originalElements) {
|
|
||||||
originalElements = elements.map(
|
|
||||||
(element) => originalElementsMap!.get(element.id)!,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accumulatedChange) {
|
if (!accumulatedChange) {
|
||||||
accumulatedChange = 0;
|
accumulatedChange = 0;
|
||||||
}
|
}
|
||||||
@@ -153,6 +208,7 @@ const StatsDragInput = ({
|
|||||||
if (
|
if (
|
||||||
lastPointer &&
|
lastPointer &&
|
||||||
originalElementsMap !== null &&
|
originalElementsMap !== null &&
|
||||||
|
originalElements !== null &&
|
||||||
accumulatedChange !== null
|
accumulatedChange !== null
|
||||||
) {
|
) {
|
||||||
const instantChange = event.clientX - lastPointer.x;
|
const instantChange = event.clientX - lastPointer.x;
|
||||||
@@ -165,6 +221,9 @@ const StatsDragInput = ({
|
|||||||
originalElementsMap,
|
originalElementsMap,
|
||||||
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
||||||
shouldChangeByStepSize: event.shiftKey,
|
shouldChangeByStepSize: event.shiftKey,
|
||||||
|
property,
|
||||||
|
scene,
|
||||||
|
originalAppState,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +275,7 @@ const StatsDragInput = ({
|
|||||||
eventTarget instanceof HTMLInputElement &&
|
eventTarget instanceof HTMLInputElement &&
|
||||||
event.key === KEYS.ENTER
|
event.key === KEYS.ENTER
|
||||||
) {
|
) {
|
||||||
handleInputValue(eventTarget.value);
|
handleInputValue(eventTarget.value, elements, appState);
|
||||||
app.focusContainer();
|
app.focusContainer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,23 +283,28 @@ const StatsDragInput = ({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
|
stateRef.current.updatePending = true;
|
||||||
setInputValue(event.target.value);
|
setInputValue(event.target.value);
|
||||||
}}
|
}}
|
||||||
onFocus={(event) => {
|
onFocus={(event) => {
|
||||||
event.target.select();
|
event.target.select();
|
||||||
|
stateRef.current.originalElements = elements;
|
||||||
|
stateRef.current.originalAppState = cloneJSON(appState);
|
||||||
}}
|
}}
|
||||||
onBlur={(event) => {
|
onBlur={(event) => {
|
||||||
if (!inputValue) {
|
if (!inputValue) {
|
||||||
setInputValue(value.toString());
|
setInputValue(value.toString());
|
||||||
} else if (editable) {
|
} else if (editable) {
|
||||||
handleInputValue(event.target.value);
|
handleInputValue(
|
||||||
|
event.target.value,
|
||||||
|
stateRef.current.originalElements,
|
||||||
|
stateRef.current.originalAppState,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!editable}
|
disabled={!editable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +1,97 @@
|
|||||||
import type { ElementsMap, ExcalidrawTextElement } from "../../element/types";
|
import type {
|
||||||
import { refreshTextDimensions } from "../../element/newElement";
|
ExcalidrawElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
} from "../../element/types";
|
||||||
import StatsDragInput from "./DragInput";
|
import StatsDragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import { mutateElement } from "../../element/mutateElement";
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
import { getStepSizedValue } from "./utils";
|
import { getStepSizedValue } from "./utils";
|
||||||
import { fontSizeIcon } from "../icons";
|
import { fontSizeIcon } from "../icons";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
import { isTextElement, redrawTextBoundingBox } from "../../element";
|
||||||
|
import { hasBoundTextElement } from "../../element/typeChecks";
|
||||||
|
import { getBoundTextElement } from "../../element/textElement";
|
||||||
|
|
||||||
interface FontSizeProps {
|
interface FontSizeProps {
|
||||||
element: ExcalidrawTextElement;
|
element: ExcalidrawElement;
|
||||||
elementsMap: ElementsMap;
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
|
property: "fontSize";
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIN_FONT_SIZE = 4;
|
const MIN_FONT_SIZE = 4;
|
||||||
const STEP_SIZE = 4;
|
const STEP_SIZE = 4;
|
||||||
|
|
||||||
const FontSize = ({ element, elementsMap }: FontSizeProps) => {
|
const handleFontSizeChange: DragInputCallbackType<
|
||||||
const handleFontSizeChange: DragInputCallbackType = ({
|
FontSizeProps["property"],
|
||||||
accumulatedChange,
|
ExcalidrawTextElement
|
||||||
originalElements,
|
> = ({
|
||||||
shouldChangeByStepSize,
|
accumulatedChange,
|
||||||
nextValue,
|
originalElements,
|
||||||
}) => {
|
shouldChangeByStepSize,
|
||||||
const origElement = originalElements[0];
|
nextValue,
|
||||||
if (origElement) {
|
scene,
|
||||||
if (nextValue !== undefined) {
|
}) => {
|
||||||
const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
const newElement = {
|
const origElement = originalElements[0];
|
||||||
...element,
|
if (origElement) {
|
||||||
fontSize: nextFontSize,
|
const latestElement = elementsMap.get(origElement.id);
|
||||||
};
|
if (!latestElement || !isTextElement(latestElement)) {
|
||||||
const updates = refreshTextDimensions(newElement, null, elementsMap);
|
return;
|
||||||
mutateElement(element, {
|
}
|
||||||
...updates,
|
|
||||||
fontSize: nextFontSize,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (origElement.type === "text") {
|
let nextFontSize;
|
||||||
const originalFontSize = Math.round(origElement.fontSize);
|
|
||||||
const changeInFontSize = Math.round(accumulatedChange);
|
if (nextValue !== undefined) {
|
||||||
let nextFontSize = Math.max(
|
nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
|
||||||
originalFontSize + changeInFontSize,
|
} else if (origElement.type === "text") {
|
||||||
MIN_FONT_SIZE,
|
const originalFontSize = Math.round(origElement.fontSize);
|
||||||
);
|
const changeInFontSize = Math.round(accumulatedChange);
|
||||||
if (shouldChangeByStepSize) {
|
nextFontSize = Math.max(
|
||||||
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
|
originalFontSize + changeInFontSize,
|
||||||
}
|
MIN_FONT_SIZE,
|
||||||
const newElement = {
|
);
|
||||||
...element,
|
if (shouldChangeByStepSize) {
|
||||||
fontSize: nextFontSize,
|
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
|
||||||
};
|
|
||||||
const updates = refreshTextDimensions(newElement, null, elementsMap);
|
|
||||||
mutateElement(element, {
|
|
||||||
...updates,
|
|
||||||
fontSize: nextFontSize,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
if (nextFontSize) {
|
||||||
|
mutateElement(latestElement, {
|
||||||
|
fontSize: nextFontSize,
|
||||||
|
});
|
||||||
|
redrawTextBoundingBox(
|
||||||
|
latestElement,
|
||||||
|
scene.getContainerElement(latestElement),
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const FontSize = ({ element, scene, appState, property }: FontSizeProps) => {
|
||||||
|
const _element = isTextElement(element)
|
||||||
|
? element
|
||||||
|
: hasBoundTextElement(element)
|
||||||
|
? getBoundTextElement(element, scene.getNonDeletedElementsMap())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!_element) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatsDragInput
|
<StatsDragInput
|
||||||
label="F"
|
label="F"
|
||||||
value={Math.round(element.fontSize * 10) / 10}
|
value={Math.round(_element.fontSize * 10) / 10}
|
||||||
elements={[element]}
|
elements={[_element]}
|
||||||
dragInputCallback={handleFontSizeChange}
|
dragInputCallback={handleFontSizeChange}
|
||||||
icon={fontSizeIcon}
|
icon={fontSizeIcon}
|
||||||
|
appState={appState}
|
||||||
|
scene={scene}
|
||||||
|
property={property}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { mutateElement } from "../../element/mutateElement";
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
import { getBoundTextElement } from "../../element/textElement";
|
import { getBoundTextElement } from "../../element/textElement";
|
||||||
import { isArrowElement } from "../../element/typeChecks";
|
import { isArrowElement } from "../../element/typeChecks";
|
||||||
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
import { isInGroup } from "../../groups";
|
import { isInGroup } from "../../groups";
|
||||||
import { degreeToRadian, radianToDegree } from "../../math";
|
import { degreeToRadian, radianToDegree } from "../../math";
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
@@ -9,84 +9,102 @@ import { angleIcon } from "../icons";
|
|||||||
import DragInput from "./DragInput";
|
import DragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
|
||||||
interface MultiAngleProps {
|
interface MultiAngleProps {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
elementsMap: ElementsMap;
|
|
||||||
scene: Scene;
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
|
property: "angle";
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEP_SIZE = 15;
|
const STEP_SIZE = 15;
|
||||||
|
|
||||||
const MultiAngle = ({ elements, elementsMap, scene }: MultiAngleProps) => {
|
const handleDegreeChange: DragInputCallbackType<
|
||||||
const handleDegreeChange: DragInputCallbackType = ({
|
MultiAngleProps["property"]
|
||||||
accumulatedChange,
|
> = ({
|
||||||
originalElements,
|
accumulatedChange,
|
||||||
shouldChangeByStepSize,
|
originalElements,
|
||||||
nextValue,
|
shouldChangeByStepSize,
|
||||||
}) => {
|
nextValue,
|
||||||
const editableLatestIndividualElements = elements.filter(
|
property,
|
||||||
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
|
scene,
|
||||||
);
|
}) => {
|
||||||
const editableOriginalIndividualElements = originalElements.filter(
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
|
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) {
|
if (nextValue !== undefined) {
|
||||||
const nextAngle = degreeToRadian(nextValue);
|
const nextAngle = degreeToRadian(nextValue);
|
||||||
|
|
||||||
for (const element of editableLatestIndividualElements) {
|
for (const element of editableLatestIndividualElements) {
|
||||||
mutateElement(
|
if (!element) {
|
||||||
element,
|
continue;
|
||||||
{
|
|
||||||
angle: nextAngle,
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
|
||||||
if (boundTextElement && !isArrowElement(element)) {
|
|
||||||
mutateElement(boundTextElement, { angle: nextAngle }, false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
scene.triggerUpdate();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < editableLatestIndividualElements.length; i++) {
|
|
||||||
const latestElement = editableLatestIndividualElements[i];
|
|
||||||
const originalElement = editableOriginalIndividualElements[i];
|
|
||||||
const originalAngleInDegrees =
|
|
||||||
Math.round(radianToDegree(originalElement.angle) * 100) / 100;
|
|
||||||
const changeInDegrees = Math.round(accumulatedChange);
|
|
||||||
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
|
||||||
if (shouldChangeByStepSize) {
|
|
||||||
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
|
|
||||||
}
|
|
||||||
|
|
||||||
nextAngleInDegrees =
|
|
||||||
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
|
||||||
|
|
||||||
const nextAngle = degreeToRadian(nextAngleInDegrees);
|
|
||||||
|
|
||||||
mutateElement(
|
mutateElement(
|
||||||
latestElement,
|
element,
|
||||||
{
|
{
|
||||||
angle: nextAngle,
|
angle: nextAngle,
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
if (boundTextElement && !isArrowElement(latestElement)) {
|
if (boundTextElement && !isArrowElement(element)) {
|
||||||
mutateElement(boundTextElement, { angle: nextAngle }, false);
|
mutateElement(boundTextElement, { angle: nextAngle }, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scene.triggerUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
|
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(
|
const editableLatestIndividualElements = elements.filter(
|
||||||
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
|
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
|
||||||
);
|
);
|
||||||
@@ -107,6 +125,9 @@ const MultiAngle = ({ elements, elementsMap, scene }: MultiAngleProps) => {
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
dragInputCallback={handleDegreeChange}
|
dragInputCallback={handleDegreeChange}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
|
appState={appState}
|
||||||
|
scene={scene}
|
||||||
|
property={property}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ import {
|
|||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
handleBindTextResize,
|
handleBindTextResize,
|
||||||
} from "../../element/textElement";
|
} from "../../element/textElement";
|
||||||
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
import type {
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawElement,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
|
} from "../../element/types";
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import type { Point } from "../../types";
|
import type { AppState, Point } from "../../types";
|
||||||
import DragInput from "./DragInput";
|
import DragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||||
import { getElementsInAtomicUnit, resizeElement } from "./utils";
|
import { getElementsInAtomicUnit, resizeElement } from "./utils";
|
||||||
import type { AtomicUnit } from "./utils";
|
import type { AtomicUnit } from "./utils";
|
||||||
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
||||||
@@ -20,9 +24,10 @@ import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
|||||||
interface MultiDimensionProps {
|
interface MultiDimensionProps {
|
||||||
property: "width" | "height";
|
property: "width" | "height";
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
elementsMap: ElementsMap;
|
elementsMap: NonDeletedSceneElementsMap;
|
||||||
atomicUnits: AtomicUnit[];
|
atomicUnits: AtomicUnit[];
|
||||||
scene: Scene;
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEP_SIZE = 10;
|
const STEP_SIZE = 10;
|
||||||
@@ -59,7 +64,7 @@ const resizeElementInGroup = (
|
|||||||
scale: number,
|
scale: number,
|
||||||
latestElement: ExcalidrawElement,
|
latestElement: ExcalidrawElement,
|
||||||
origElement: ExcalidrawElement,
|
origElement: ExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
originalElementsMap: ElementsMap,
|
originalElementsMap: ElementsMap,
|
||||||
) => {
|
) => {
|
||||||
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
|
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
|
||||||
@@ -102,7 +107,7 @@ const resizeGroup = (
|
|||||||
property: MultiDimensionProps["property"],
|
property: MultiDimensionProps["property"],
|
||||||
latestElements: ExcalidrawElement[],
|
latestElements: ExcalidrawElement[],
|
||||||
originalElements: ExcalidrawElement[],
|
originalElements: ExcalidrawElement[],
|
||||||
elementsMap: ElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
originalElementsMap: ElementsMap,
|
originalElementsMap: ElementsMap,
|
||||||
) => {
|
) => {
|
||||||
// keep aspect ratio for groups
|
// keep aspect ratio for groups
|
||||||
@@ -131,12 +136,205 @@ const resizeGroup = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDimensionChange: DragInputCallbackType<
|
||||||
|
MultiDimensionProps["property"]
|
||||||
|
> = ({
|
||||||
|
accumulatedChange,
|
||||||
|
originalElements,
|
||||||
|
originalElementsMap,
|
||||||
|
originalAppState,
|
||||||
|
shouldChangeByStepSize,
|
||||||
|
nextValue,
|
||||||
|
scene,
|
||||||
|
property,
|
||||||
|
}) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
|
||||||
|
if (nextValue !== undefined) {
|
||||||
|
for (const atomicUnit of atomicUnits) {
|
||||||
|
const elementsInUnit = getElementsInAtomicUnit(
|
||||||
|
atomicUnit,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (elementsInUnit.length > 1) {
|
||||||
|
const latestElements = elementsInUnit.map((el) => el.latest!);
|
||||||
|
const originalElements = elementsInUnit.map((el) => el.original!);
|
||||||
|
const [x1, y1, x2, y2] = getCommonBounds(originalElements);
|
||||||
|
const initialWidth = x2 - x1;
|
||||||
|
const initialHeight = y2 - y1;
|
||||||
|
const aspectRatio = initialWidth / initialHeight;
|
||||||
|
const nextWidth = Math.max(
|
||||||
|
MIN_WIDTH_OR_HEIGHT,
|
||||||
|
property === "width" ? Math.max(0, nextValue) : initialWidth,
|
||||||
|
);
|
||||||
|
const nextHeight = Math.max(
|
||||||
|
MIN_WIDTH_OR_HEIGHT,
|
||||||
|
property === "height" ? Math.max(0, nextValue) : initialHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
resizeGroup(
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
initialHeight,
|
||||||
|
aspectRatio,
|
||||||
|
[x1, y1],
|
||||||
|
property,
|
||||||
|
latestElements,
|
||||||
|
originalElements,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const [el] = elementsInUnit;
|
||||||
|
const latestElement = el?.latest;
|
||||||
|
const origElement = el?.original;
|
||||||
|
|
||||||
|
if (
|
||||||
|
latestElement &&
|
||||||
|
origElement &&
|
||||||
|
isPropertyEditable(latestElement, property)
|
||||||
|
) {
|
||||||
|
let nextWidth =
|
||||||
|
property === "width" ? Math.max(0, nextValue) : latestElement.width;
|
||||||
|
if (property === "width") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextWidth = Math.round(nextWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextHeight =
|
||||||
|
property === "height"
|
||||||
|
? Math.max(0, nextValue)
|
||||||
|
: latestElement.height;
|
||||||
|
if (property === "height") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextHeight = Math.round(nextHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||||
|
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||||
|
|
||||||
|
resizeElement(
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
false,
|
||||||
|
origElement,
|
||||||
|
elementsMap,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.triggerUpdate();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||||
|
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||||
|
|
||||||
|
for (const atomicUnit of atomicUnits) {
|
||||||
|
const elementsInUnit = getElementsInAtomicUnit(
|
||||||
|
atomicUnit,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (elementsInUnit.length > 1) {
|
||||||
|
const latestElements = elementsInUnit.map((el) => el.latest!);
|
||||||
|
const originalElements = elementsInUnit.map((el) => el.original!);
|
||||||
|
|
||||||
|
const [x1, y1, x2, y2] = getCommonBounds(originalElements);
|
||||||
|
const initialWidth = x2 - x1;
|
||||||
|
const initialHeight = y2 - y1;
|
||||||
|
const aspectRatio = initialWidth / initialHeight;
|
||||||
|
let nextWidth = Math.max(0, initialWidth + changeInWidth);
|
||||||
|
if (property === "width") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextWidth = Math.round(nextWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextHeight = Math.max(0, initialHeight + changeInHeight);
|
||||||
|
if (property === "height") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextHeight = Math.round(nextHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||||
|
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||||
|
|
||||||
|
resizeGroup(
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
initialHeight,
|
||||||
|
aspectRatio,
|
||||||
|
[x1, y1],
|
||||||
|
property,
|
||||||
|
latestElements,
|
||||||
|
originalElements,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const [el] = elementsInUnit;
|
||||||
|
const latestElement = el?.latest;
|
||||||
|
const origElement = el?.original;
|
||||||
|
|
||||||
|
if (
|
||||||
|
latestElement &&
|
||||||
|
origElement &&
|
||||||
|
isPropertyEditable(latestElement, property)
|
||||||
|
) {
|
||||||
|
let nextWidth = Math.max(0, origElement.width + changeInWidth);
|
||||||
|
if (property === "width") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextWidth = Math.round(nextWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextHeight = Math.max(0, origElement.height + changeInHeight);
|
||||||
|
if (property === "height") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextHeight = Math.round(nextHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||||
|
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||||
|
|
||||||
|
resizeElement(nextWidth, nextHeight, false, origElement, elementsMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.triggerUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
const MultiDimension = ({
|
const MultiDimension = ({
|
||||||
property,
|
property,
|
||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
atomicUnits,
|
atomicUnits,
|
||||||
scene,
|
scene,
|
||||||
|
appState,
|
||||||
}: MultiDimensionProps) => {
|
}: MultiDimensionProps) => {
|
||||||
const sizes = useMemo(
|
const sizes = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -167,202 +365,6 @@ const MultiDimension = ({
|
|||||||
|
|
||||||
const editable = sizes.length > 0;
|
const editable = sizes.length > 0;
|
||||||
|
|
||||||
const handleDimensionChange: DragInputCallbackType = ({
|
|
||||||
accumulatedChange,
|
|
||||||
originalElementsMap,
|
|
||||||
shouldChangeByStepSize,
|
|
||||||
nextValue,
|
|
||||||
}) => {
|
|
||||||
if (nextValue !== undefined) {
|
|
||||||
for (const atomicUnit of atomicUnits) {
|
|
||||||
const elementsInUnit = getElementsInAtomicUnit(
|
|
||||||
atomicUnit,
|
|
||||||
elementsMap,
|
|
||||||
originalElementsMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (elementsInUnit.length > 1) {
|
|
||||||
const latestElements = elementsInUnit.map((el) => el.latest!);
|
|
||||||
const originalElements = elementsInUnit.map((el) => el.original!);
|
|
||||||
const [x1, y1, x2, y2] = getCommonBounds(originalElements);
|
|
||||||
const initialWidth = x2 - x1;
|
|
||||||
const initialHeight = y2 - y1;
|
|
||||||
const aspectRatio = initialWidth / initialHeight;
|
|
||||||
const nextWidth = Math.max(
|
|
||||||
MIN_WIDTH_OR_HEIGHT,
|
|
||||||
property === "width" ? Math.max(0, nextValue) : initialWidth,
|
|
||||||
);
|
|
||||||
const nextHeight = Math.max(
|
|
||||||
MIN_WIDTH_OR_HEIGHT,
|
|
||||||
property === "height" ? Math.max(0, nextValue) : initialHeight,
|
|
||||||
);
|
|
||||||
|
|
||||||
resizeGroup(
|
|
||||||
nextWidth,
|
|
||||||
nextHeight,
|
|
||||||
initialHeight,
|
|
||||||
aspectRatio,
|
|
||||||
[x1, y1],
|
|
||||||
property,
|
|
||||||
latestElements,
|
|
||||||
originalElements,
|
|
||||||
elementsMap,
|
|
||||||
originalElementsMap,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const [el] = elementsInUnit;
|
|
||||||
const latestElement = el?.latest;
|
|
||||||
const origElement = el?.original;
|
|
||||||
|
|
||||||
if (
|
|
||||||
latestElement &&
|
|
||||||
origElement &&
|
|
||||||
isPropertyEditable(latestElement, property)
|
|
||||||
) {
|
|
||||||
let nextWidth =
|
|
||||||
property === "width"
|
|
||||||
? Math.max(0, nextValue)
|
|
||||||
: latestElement.width;
|
|
||||||
if (property === "width") {
|
|
||||||
if (shouldChangeByStepSize) {
|
|
||||||
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
|
||||||
} else {
|
|
||||||
nextWidth = Math.round(nextWidth);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextHeight =
|
|
||||||
property === "height"
|
|
||||||
? Math.max(0, nextValue)
|
|
||||||
: latestElement.height;
|
|
||||||
if (property === "height") {
|
|
||||||
if (shouldChangeByStepSize) {
|
|
||||||
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
|
||||||
} else {
|
|
||||||
nextHeight = Math.round(nextHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
|
||||||
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
|
||||||
|
|
||||||
resizeElement(
|
|
||||||
nextWidth,
|
|
||||||
nextHeight,
|
|
||||||
false,
|
|
||||||
latestElement,
|
|
||||||
origElement,
|
|
||||||
elementsMap,
|
|
||||||
originalElementsMap,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scene.triggerUpdate();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
|
||||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
|
||||||
|
|
||||||
for (const atomicUnit of atomicUnits) {
|
|
||||||
const elementsInUnit = getElementsInAtomicUnit(
|
|
||||||
atomicUnit,
|
|
||||||
elementsMap,
|
|
||||||
originalElementsMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (elementsInUnit.length > 1) {
|
|
||||||
const latestElements = elementsInUnit.map((el) => el.latest!);
|
|
||||||
const originalElements = elementsInUnit.map((el) => el.original!);
|
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = getCommonBounds(originalElements);
|
|
||||||
const initialWidth = x2 - x1;
|
|
||||||
const initialHeight = y2 - y1;
|
|
||||||
const aspectRatio = initialWidth / initialHeight;
|
|
||||||
let nextWidth = Math.max(0, initialWidth + changeInWidth);
|
|
||||||
if (property === "width") {
|
|
||||||
if (shouldChangeByStepSize) {
|
|
||||||
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
|
||||||
} else {
|
|
||||||
nextWidth = Math.round(nextWidth);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextHeight = Math.max(0, initialHeight + changeInHeight);
|
|
||||||
if (property === "height") {
|
|
||||||
if (shouldChangeByStepSize) {
|
|
||||||
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
|
||||||
} else {
|
|
||||||
nextHeight = Math.round(nextHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
|
||||||
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
|
||||||
|
|
||||||
resizeGroup(
|
|
||||||
nextWidth,
|
|
||||||
nextHeight,
|
|
||||||
initialHeight,
|
|
||||||
aspectRatio,
|
|
||||||
[x1, y1],
|
|
||||||
property,
|
|
||||||
latestElements,
|
|
||||||
originalElements,
|
|
||||||
elementsMap,
|
|
||||||
originalElementsMap,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const [el] = elementsInUnit;
|
|
||||||
const latestElement = el?.latest;
|
|
||||||
const origElement = el?.original;
|
|
||||||
|
|
||||||
if (
|
|
||||||
latestElement &&
|
|
||||||
origElement &&
|
|
||||||
isPropertyEditable(latestElement, property)
|
|
||||||
) {
|
|
||||||
let nextWidth = Math.max(0, origElement.width + changeInWidth);
|
|
||||||
if (property === "width") {
|
|
||||||
if (shouldChangeByStepSize) {
|
|
||||||
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
|
||||||
} else {
|
|
||||||
nextWidth = Math.round(nextWidth);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextHeight = Math.max(0, origElement.height + changeInHeight);
|
|
||||||
if (property === "height") {
|
|
||||||
if (shouldChangeByStepSize) {
|
|
||||||
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
|
||||||
} else {
|
|
||||||
nextHeight = Math.round(nextHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
|
||||||
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
|
||||||
|
|
||||||
resizeElement(
|
|
||||||
nextWidth,
|
|
||||||
nextHeight,
|
|
||||||
false,
|
|
||||||
latestElement,
|
|
||||||
origElement,
|
|
||||||
elementsMap,
|
|
||||||
originalElementsMap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scene.triggerUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragInput
|
<DragInput
|
||||||
label={property === "width" ? "W" : "H"}
|
label={property === "width" ? "W" : "H"}
|
||||||
@@ -370,6 +372,9 @@ const MultiDimension = ({
|
|||||||
dragInputCallback={handleDimensionChange}
|
dragInputCallback={handleDimensionChange}
|
||||||
value={value}
|
value={value}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
|
appState={appState}
|
||||||
|
property={property}
|
||||||
|
scene={scene}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { isTextElement, refreshTextDimensions } from "../../element";
|
import { isTextElement, redrawTextBoundingBox } from "../../element";
|
||||||
import { mutateElement } from "../../element/mutateElement";
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
import { isBoundToContainer } from "../../element/typeChecks";
|
import { hasBoundTextElement } from "../../element/typeChecks";
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
import { isInGroup } from "../../groups";
|
import { isInGroup } from "../../groups";
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
@@ -12,62 +12,87 @@ import { fontSizeIcon } from "../icons";
|
|||||||
import StatsDragInput from "./DragInput";
|
import StatsDragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import { getStepSizedValue } from "./utils";
|
import { getStepSizedValue } from "./utils";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
import { getBoundTextElement } from "../../element/textElement";
|
||||||
|
|
||||||
interface MultiFontSizeProps {
|
interface MultiFontSizeProps {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
elementsMap: ElementsMap;
|
|
||||||
scene: Scene;
|
scene: Scene;
|
||||||
|
elementsMap: NonDeletedSceneElementsMap;
|
||||||
|
appState: AppState;
|
||||||
|
property: "fontSize";
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIN_FONT_SIZE = 4;
|
const MIN_FONT_SIZE = 4;
|
||||||
const STEP_SIZE = 4;
|
const STEP_SIZE = 4;
|
||||||
|
|
||||||
const MultiFontSize = ({
|
const getApplicableTextElements = (
|
||||||
elements,
|
elements: readonly (ExcalidrawElement | undefined)[],
|
||||||
elementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
scene,
|
) =>
|
||||||
}: MultiFontSizeProps) => {
|
elements.reduce(
|
||||||
const latestTextElements = elements.filter(
|
(acc: ExcalidrawTextElement[], el) => {
|
||||||
(el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el),
|
if (!el || isInGroup(el)) {
|
||||||
) as ExcalidrawTextElement[];
|
return acc;
|
||||||
const fontSizes = latestTextElements.map(
|
}
|
||||||
(textEl) => Math.round(textEl.fontSize * 10) / 10,
|
if (isTextElement(el)) {
|
||||||
);
|
acc.push(el);
|
||||||
const value = new Set(fontSizes).size === 1 ? fontSizes[0] : "Mixed";
|
return acc;
|
||||||
const editable = fontSizes.length > 0;
|
}
|
||||||
|
if (hasBoundTextElement(el)) {
|
||||||
const handleFontSizeChange: DragInputCallbackType = ({
|
const boundTextElement = getBoundTextElement(el, elementsMap);
|
||||||
accumulatedChange,
|
if (boundTextElement) {
|
||||||
originalElements,
|
acc.push(boundTextElement);
|
||||||
shouldChangeByStepSize,
|
return acc;
|
||||||
nextValue,
|
}
|
||||||
}) => {
|
|
||||||
if (nextValue) {
|
|
||||||
const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
|
|
||||||
|
|
||||||
for (const textElement of latestTextElements) {
|
|
||||||
const newElement = {
|
|
||||||
...textElement,
|
|
||||||
fontSize: nextFontSize,
|
|
||||||
};
|
|
||||||
const updates = refreshTextDimensions(newElement, null, elementsMap);
|
|
||||||
mutateElement(
|
|
||||||
textElement,
|
|
||||||
{
|
|
||||||
...updates,
|
|
||||||
fontSize: nextFontSize,
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
scene.triggerUpdate();
|
return acc;
|
||||||
return;
|
},
|
||||||
|
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalTextElements = originalElements.filter(
|
scene.triggerUpdate();
|
||||||
(el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el),
|
} else {
|
||||||
) as ExcalidrawTextElement[];
|
const originalTextElements = originalElements as ExcalidrawTextElement[];
|
||||||
|
|
||||||
for (let i = 0; i < latestTextElements.length; i++) {
|
for (let i = 0; i < latestTextElements.length; i++) {
|
||||||
const latestElement = latestTextElements[i];
|
const latestElement = latestTextElements[i];
|
||||||
@@ -82,32 +107,56 @@ const MultiFontSize = ({
|
|||||||
if (shouldChangeByStepSize) {
|
if (shouldChangeByStepSize) {
|
||||||
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
|
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
|
||||||
}
|
}
|
||||||
const newElement = {
|
|
||||||
...latestElement,
|
|
||||||
fontSize: nextFontSize,
|
|
||||||
};
|
|
||||||
const updates = refreshTextDimensions(newElement, null, elementsMap);
|
|
||||||
mutateElement(
|
mutateElement(
|
||||||
latestElement,
|
latestElement,
|
||||||
{
|
{
|
||||||
...updates,
|
|
||||||
fontSize: nextFontSize,
|
fontSize: nextFontSize,
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
redrawTextBoundingBox(
|
||||||
|
latestElement,
|
||||||
|
scene.getContainerElement(latestElement),
|
||||||
|
elementsMap,
|
||||||
|
false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
scene.triggerUpdate();
|
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 (
|
return (
|
||||||
<StatsDragInput
|
<StatsDragInput
|
||||||
label="F"
|
label="F"
|
||||||
icon={fontSizeIcon}
|
icon={fontSizeIcon}
|
||||||
elements={elements}
|
elements={latestTextElements}
|
||||||
dragInputCallback={handleFontSizeChange}
|
dragInputCallback={handleFontSizeChange}
|
||||||
value={value}
|
value={value}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
|
scene={scene}
|
||||||
|
property={property}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
import type {
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawElement,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
|
} from "../../element/types";
|
||||||
import { rotate } from "../../math";
|
import { rotate } from "../../math";
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import StatsDragInput from "./DragInput";
|
import StatsDragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||||
import { getCommonBounds, isTextElement } from "../../element";
|
import { getCommonBounds, isTextElement } from "../../element";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { getElementsInAtomicUnit, moveElement } from "./utils";
|
import { getElementsInAtomicUnit, moveElement } from "./utils";
|
||||||
import type { AtomicUnit } from "./utils";
|
import type { AtomicUnit } from "./utils";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
|
||||||
interface MultiPositionProps {
|
interface MultiPositionProps {
|
||||||
property: "x" | "y";
|
property: "x" | "y";
|
||||||
@@ -15,6 +20,7 @@ interface MultiPositionProps {
|
|||||||
elementsMap: ElementsMap;
|
elementsMap: ElementsMap;
|
||||||
atomicUnits: AtomicUnit[];
|
atomicUnits: AtomicUnit[];
|
||||||
scene: Scene;
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEP_SIZE = 10;
|
const STEP_SIZE = 10;
|
||||||
@@ -25,12 +31,11 @@ const moveElements = (
|
|||||||
changeInTopY: number,
|
changeInTopY: number,
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
originalElements: readonly ExcalidrawElement[],
|
originalElements: readonly ExcalidrawElement[],
|
||||||
elementsMap: ElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
originalElementsMap: ElementsMap,
|
originalElementsMap: ElementsMap,
|
||||||
) => {
|
) => {
|
||||||
for (let i = 0; i < elements.length; i++) {
|
for (let i = 0; i < elements.length; i++) {
|
||||||
const origElement = originalElements[i];
|
const origElement = originalElements[i];
|
||||||
const latestElement = elements[i];
|
|
||||||
|
|
||||||
const [cx, cy] = [
|
const [cx, cy] = [
|
||||||
origElement.x + origElement.width / 2,
|
origElement.x + origElement.width / 2,
|
||||||
@@ -53,7 +58,6 @@ const moveElements = (
|
|||||||
moveElement(
|
moveElement(
|
||||||
newTopLeftX,
|
newTopLeftX,
|
||||||
newTopLeftY,
|
newTopLeftY,
|
||||||
latestElement,
|
|
||||||
origElement,
|
origElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
originalElementsMap,
|
originalElementsMap,
|
||||||
@@ -65,18 +69,22 @@ const moveElements = (
|
|||||||
const moveGroupTo = (
|
const moveGroupTo = (
|
||||||
nextX: number,
|
nextX: number,
|
||||||
nextY: number,
|
nextY: number,
|
||||||
latestElements: ExcalidrawElement[],
|
|
||||||
originalElements: ExcalidrawElement[],
|
originalElements: ExcalidrawElement[],
|
||||||
elementsMap: ElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
originalElementsMap: ElementsMap,
|
originalElementsMap: ElementsMap,
|
||||||
|
scene: Scene,
|
||||||
) => {
|
) => {
|
||||||
const [x1, y1, ,] = getCommonBounds(originalElements);
|
const [x1, y1, ,] = getCommonBounds(originalElements);
|
||||||
const offsetX = nextX - x1;
|
const offsetX = nextX - x1;
|
||||||
const offsetY = nextY - y1;
|
const offsetY = nextY - y1;
|
||||||
|
|
||||||
for (let i = 0; i < latestElements.length; i++) {
|
for (let i = 0; i < originalElements.length; i++) {
|
||||||
const origElement = originalElements[i];
|
const origElement = originalElements[i];
|
||||||
const latestElement = latestElements[i];
|
|
||||||
|
const latestElement = elementsMap.get(origElement.id);
|
||||||
|
if (!latestElement) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// bound texts are moved with their containers
|
// bound texts are moved with their containers
|
||||||
if (!isTextElement(latestElement) || !latestElement.containerId) {
|
if (!isTextElement(latestElement) || !latestElement.containerId) {
|
||||||
@@ -96,7 +104,6 @@ const moveGroupTo = (
|
|||||||
moveElement(
|
moveElement(
|
||||||
topLeftX + offsetX,
|
topLeftX + offsetX,
|
||||||
topLeftY + offsetY,
|
topLeftY + offsetY,
|
||||||
latestElement,
|
|
||||||
origElement,
|
origElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
originalElementsMap,
|
originalElementsMap,
|
||||||
@@ -106,12 +113,111 @@ const moveGroupTo = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePositionChange: DragInputCallbackType<
|
||||||
|
MultiPositionProps["property"]
|
||||||
|
> = ({
|
||||||
|
accumulatedChange,
|
||||||
|
originalElements,
|
||||||
|
originalElementsMap,
|
||||||
|
shouldChangeByStepSize,
|
||||||
|
nextValue,
|
||||||
|
property,
|
||||||
|
scene,
|
||||||
|
originalAppState,
|
||||||
|
}) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
|
if (nextValue !== undefined) {
|
||||||
|
for (const atomicUnit of getAtomicUnits(
|
||||||
|
originalElements,
|
||||||
|
originalAppState,
|
||||||
|
)) {
|
||||||
|
const elementsInUnit = getElementsInAtomicUnit(
|
||||||
|
atomicUnit,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (elementsInUnit.length > 1) {
|
||||||
|
const [x1, y1, ,] = getCommonBounds(
|
||||||
|
elementsInUnit.map((el) => el.latest!),
|
||||||
|
);
|
||||||
|
const newTopLeftX = property === "x" ? nextValue : x1;
|
||||||
|
const newTopLeftY = property === "y" ? nextValue : y1;
|
||||||
|
|
||||||
|
moveGroupTo(
|
||||||
|
newTopLeftX,
|
||||||
|
newTopLeftY,
|
||||||
|
elementsInUnit.map((el) => el.original),
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
scene,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const origElement = elementsInUnit[0]?.original;
|
||||||
|
const latestElement = elementsInUnit[0]?.latest;
|
||||||
|
if (
|
||||||
|
origElement &&
|
||||||
|
latestElement &&
|
||||||
|
isPropertyEditable(latestElement, property)
|
||||||
|
) {
|
||||||
|
const [cx, cy] = [
|
||||||
|
origElement.x + origElement.width / 2,
|
||||||
|
origElement.y + origElement.height / 2,
|
||||||
|
];
|
||||||
|
const [topLeftX, topLeftY] = rotate(
|
||||||
|
origElement.x,
|
||||||
|
origElement.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
origElement.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
const newTopLeftX = property === "x" ? nextValue : topLeftX;
|
||||||
|
const newTopLeftY = property === "y" ? nextValue : topLeftY;
|
||||||
|
moveElement(
|
||||||
|
newTopLeftX,
|
||||||
|
newTopLeftY,
|
||||||
|
origElement,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.triggerUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const change = shouldChangeByStepSize
|
||||||
|
? getStepSizedValue(accumulatedChange, STEP_SIZE)
|
||||||
|
: accumulatedChange;
|
||||||
|
|
||||||
|
const changeInTopX = property === "x" ? change : 0;
|
||||||
|
const changeInTopY = property === "y" ? change : 0;
|
||||||
|
|
||||||
|
moveElements(
|
||||||
|
property,
|
||||||
|
changeInTopX,
|
||||||
|
changeInTopY,
|
||||||
|
originalElements,
|
||||||
|
originalElements,
|
||||||
|
elementsMap,
|
||||||
|
originalElementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
scene.triggerUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
const MultiPosition = ({
|
const MultiPosition = ({
|
||||||
property,
|
property,
|
||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
atomicUnits,
|
atomicUnits,
|
||||||
scene,
|
scene,
|
||||||
|
appState,
|
||||||
}: MultiPositionProps) => {
|
}: MultiPositionProps) => {
|
||||||
const positions = useMemo(
|
const positions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -137,101 +243,15 @@ const MultiPosition = ({
|
|||||||
|
|
||||||
const value = new Set(positions).size === 1 ? positions[0] : "Mixed";
|
const value = new Set(positions).size === 1 ? positions[0] : "Mixed";
|
||||||
|
|
||||||
const handlePositionChange: DragInputCallbackType = ({
|
|
||||||
accumulatedChange,
|
|
||||||
originalElements,
|
|
||||||
originalElementsMap,
|
|
||||||
shouldChangeByStepSize,
|
|
||||||
nextValue,
|
|
||||||
}) => {
|
|
||||||
if (nextValue !== undefined) {
|
|
||||||
for (const atomicUnit of atomicUnits) {
|
|
||||||
const elementsInUnit = getElementsInAtomicUnit(
|
|
||||||
atomicUnit,
|
|
||||||
elementsMap,
|
|
||||||
originalElementsMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (elementsInUnit.length > 1) {
|
|
||||||
const [x1, y1, ,] = getCommonBounds(
|
|
||||||
elementsInUnit.map((el) => el.latest!),
|
|
||||||
);
|
|
||||||
const newTopLeftX = property === "x" ? nextValue : x1;
|
|
||||||
const newTopLeftY = property === "y" ? nextValue : y1;
|
|
||||||
|
|
||||||
moveGroupTo(
|
|
||||||
newTopLeftX,
|
|
||||||
newTopLeftY,
|
|
||||||
elementsInUnit.map((el) => el.latest),
|
|
||||||
elementsInUnit.map((el) => el.original),
|
|
||||||
elementsMap,
|
|
||||||
originalElementsMap,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const origElement = elementsInUnit[0]?.original;
|
|
||||||
const latestElement = elementsInUnit[0]?.latest;
|
|
||||||
if (
|
|
||||||
origElement &&
|
|
||||||
latestElement &&
|
|
||||||
isPropertyEditable(latestElement, property)
|
|
||||||
) {
|
|
||||||
const [cx, cy] = [
|
|
||||||
origElement.x + origElement.width / 2,
|
|
||||||
origElement.y + origElement.height / 2,
|
|
||||||
];
|
|
||||||
const [topLeftX, topLeftY] = rotate(
|
|
||||||
origElement.x,
|
|
||||||
origElement.y,
|
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
origElement.angle,
|
|
||||||
);
|
|
||||||
|
|
||||||
const newTopLeftX = property === "x" ? nextValue : topLeftX;
|
|
||||||
const newTopLeftY = property === "y" ? nextValue : topLeftY;
|
|
||||||
moveElement(
|
|
||||||
newTopLeftX,
|
|
||||||
newTopLeftY,
|
|
||||||
latestElement,
|
|
||||||
origElement,
|
|
||||||
elementsMap,
|
|
||||||
originalElementsMap,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scene.triggerUpdate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const change = shouldChangeByStepSize
|
|
||||||
? getStepSizedValue(accumulatedChange, STEP_SIZE)
|
|
||||||
: accumulatedChange;
|
|
||||||
|
|
||||||
const changeInTopX = property === "x" ? change : 0;
|
|
||||||
const changeInTopY = property === "y" ? change : 0;
|
|
||||||
|
|
||||||
moveElements(
|
|
||||||
property,
|
|
||||||
changeInTopX,
|
|
||||||
changeInTopY,
|
|
||||||
elements,
|
|
||||||
originalElements,
|
|
||||||
elementsMap,
|
|
||||||
originalElementsMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
scene.triggerUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatsDragInput
|
<StatsDragInput
|
||||||
label={property === "x" ? "X" : "Y"}
|
label={property === "x" ? "X" : "Y"}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
dragInputCallback={handlePositionChange}
|
dragInputCallback={handlePositionChange}
|
||||||
value={value}
|
value={value}
|
||||||
|
property={property}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,16 +3,92 @@ import { rotate } from "../../math";
|
|||||||
import StatsDragInput from "./DragInput";
|
import StatsDragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import { getStepSizedValue, moveElement } from "./utils";
|
import { getStepSizedValue, moveElement } from "./utils";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
|
||||||
interface PositionProps {
|
interface PositionProps {
|
||||||
property: "x" | "y";
|
property: "x" | "y";
|
||||||
element: ExcalidrawElement;
|
element: ExcalidrawElement;
|
||||||
elementsMap: ElementsMap;
|
elementsMap: ElementsMap;
|
||||||
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEP_SIZE = 10;
|
const STEP_SIZE = 10;
|
||||||
|
|
||||||
const Position = ({ property, element, elementsMap }: PositionProps) => {
|
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(
|
const [topLeftX, topLeftY] = rotate(
|
||||||
element.x,
|
element.x,
|
||||||
element.y,
|
element.y,
|
||||||
@@ -23,77 +99,15 @@ const Position = ({ property, element, elementsMap }: PositionProps) => {
|
|||||||
const value =
|
const value =
|
||||||
Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
|
Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
|
||||||
|
|
||||||
const handlePositionChange: DragInputCallbackType = ({
|
|
||||||
accumulatedChange,
|
|
||||||
originalElements,
|
|
||||||
originalElementsMap,
|
|
||||||
shouldChangeByStepSize,
|
|
||||||
nextValue,
|
|
||||||
}) => {
|
|
||||||
const origElement = originalElements[0];
|
|
||||||
const [cx, cy] = [
|
|
||||||
origElement.x + origElement.width / 2,
|
|
||||||
origElement.y + origElement.height / 2,
|
|
||||||
];
|
|
||||||
const [topLeftX, topLeftY] = rotate(
|
|
||||||
origElement.x,
|
|
||||||
origElement.y,
|
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
origElement.angle,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (nextValue !== undefined) {
|
|
||||||
const newTopLeftX = property === "x" ? nextValue : topLeftX;
|
|
||||||
const newTopLeftY = property === "y" ? nextValue : topLeftY;
|
|
||||||
moveElement(
|
|
||||||
newTopLeftX,
|
|
||||||
newTopLeftY,
|
|
||||||
element,
|
|
||||||
origElement,
|
|
||||||
elementsMap,
|
|
||||||
originalElementsMap,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeInTopX = property === "x" ? accumulatedChange : 0;
|
|
||||||
const changeInTopY = property === "y" ? accumulatedChange : 0;
|
|
||||||
|
|
||||||
const newTopLeftX =
|
|
||||||
property === "x"
|
|
||||||
? Math.round(
|
|
||||||
shouldChangeByStepSize
|
|
||||||
? getStepSizedValue(origElement.x + changeInTopX, STEP_SIZE)
|
|
||||||
: topLeftX + changeInTopX,
|
|
||||||
)
|
|
||||||
: topLeftX;
|
|
||||||
|
|
||||||
const newTopLeftY =
|
|
||||||
property === "y"
|
|
||||||
? Math.round(
|
|
||||||
shouldChangeByStepSize
|
|
||||||
? getStepSizedValue(origElement.y + changeInTopY, STEP_SIZE)
|
|
||||||
: topLeftY + changeInTopY,
|
|
||||||
)
|
|
||||||
: topLeftY;
|
|
||||||
|
|
||||||
moveElement(
|
|
||||||
newTopLeftX,
|
|
||||||
newTopLeftY,
|
|
||||||
element,
|
|
||||||
origElement,
|
|
||||||
elementsMap,
|
|
||||||
originalElementsMap,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatsDragInput
|
<StatsDragInput
|
||||||
label={property === "x" ? "X" : "Y"}
|
label={property === "x" ? "X" : "Y"}
|
||||||
elements={[element]}
|
elements={[element]}
|
||||||
dragInputCallback={handlePositionChange}
|
dragInputCallback={handlePositionChange}
|
||||||
value={value}
|
value={value}
|
||||||
|
property={property}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,12 +11,7 @@ import Angle from "./Angle";
|
|||||||
|
|
||||||
import FontSize from "./FontSize";
|
import FontSize from "./FontSize";
|
||||||
import MultiDimension from "./MultiDimension";
|
import MultiDimension from "./MultiDimension";
|
||||||
import {
|
import { elementsAreInSameGroup } from "../../groups";
|
||||||
elementsAreInSameGroup,
|
|
||||||
getElementsInGroup,
|
|
||||||
getSelectedGroupIds,
|
|
||||||
isInGroup,
|
|
||||||
} from "../../groups";
|
|
||||||
import MultiAngle from "./MultiAngle";
|
import MultiAngle from "./MultiAngle";
|
||||||
import MultiFontSize from "./MultiFontSize";
|
import MultiFontSize from "./MultiFontSize";
|
||||||
import Position from "./Position";
|
import Position from "./Position";
|
||||||
@@ -24,7 +19,7 @@ import MultiPosition from "./MultiPosition";
|
|||||||
import Collapsible from "./Collapsible";
|
import Collapsible from "./Collapsible";
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
|
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
|
||||||
import type { AtomicUnit } from "./utils";
|
import { getAtomicUnits } from "./utils";
|
||||||
import { STATS_PANELS } from "../../constants";
|
import { STATS_PANELS } from "../../constants";
|
||||||
|
|
||||||
interface StatsProps {
|
interface StatsProps {
|
||||||
@@ -106,21 +101,7 @@ export const StatsInner = memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const atomicUnits = useMemo(() => {
|
const atomicUnits = useMemo(() => {
|
||||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
return getAtomicUnits(selectedElements, appState);
|
||||||
const _atomicUnits = selectedGroupIds.map((gid) => {
|
|
||||||
return getElementsInGroup(selectedElements, gid).reduce((acc, el) => {
|
|
||||||
acc[el.id] = true;
|
|
||||||
return acc;
|
|
||||||
}, {} as AtomicUnit);
|
|
||||||
});
|
|
||||||
selectedElements
|
|
||||||
.filter((el) => !isInGroup(el))
|
|
||||||
.forEach((el) => {
|
|
||||||
_atomicUnits.push({
|
|
||||||
[el.id]: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return _atomicUnits;
|
|
||||||
}, [selectedElements, appState]);
|
}, [selectedElements, appState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -206,32 +187,40 @@ export const StatsInner = memo(
|
|||||||
element={singleElement}
|
element={singleElement}
|
||||||
property="x"
|
property="x"
|
||||||
elementsMap={elementsMap}
|
elementsMap={elementsMap}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
<Position
|
<Position
|
||||||
element={singleElement}
|
element={singleElement}
|
||||||
property="y"
|
property="y"
|
||||||
elementsMap={elementsMap}
|
elementsMap={elementsMap}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
<Dimension
|
<Dimension
|
||||||
property="width"
|
property="width"
|
||||||
element={singleElement}
|
element={singleElement}
|
||||||
elementsMap={elementsMap}
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
<Dimension
|
<Dimension
|
||||||
property="height"
|
property="height"
|
||||||
element={singleElement}
|
element={singleElement}
|
||||||
elementsMap={elementsMap}
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
<Angle
|
<Angle
|
||||||
|
property="angle"
|
||||||
element={singleElement}
|
element={singleElement}
|
||||||
elementsMap={elementsMap}
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
<FontSize
|
||||||
|
property="fontSize"
|
||||||
|
element={singleElement}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
{singleElement.type === "text" && (
|
|
||||||
<FontSize
|
|
||||||
element={singleElement}
|
|
||||||
elementsMap={elementsMap}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -254,6 +243,7 @@ export const StatsInner = memo(
|
|||||||
elementsMap={elementsMap}
|
elementsMap={elementsMap}
|
||||||
atomicUnits={atomicUnits}
|
atomicUnits={atomicUnits}
|
||||||
scene={scene}
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
<MultiPosition
|
<MultiPosition
|
||||||
property="y"
|
property="y"
|
||||||
@@ -261,6 +251,7 @@ export const StatsInner = memo(
|
|||||||
elementsMap={elementsMap}
|
elementsMap={elementsMap}
|
||||||
atomicUnits={atomicUnits}
|
atomicUnits={atomicUnits}
|
||||||
scene={scene}
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
<MultiDimension
|
<MultiDimension
|
||||||
property="width"
|
property="width"
|
||||||
@@ -268,6 +259,7 @@ export const StatsInner = memo(
|
|||||||
elementsMap={elementsMap}
|
elementsMap={elementsMap}
|
||||||
atomicUnits={atomicUnits}
|
atomicUnits={atomicUnits}
|
||||||
scene={scene}
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
<MultiDimension
|
<MultiDimension
|
||||||
property="height"
|
property="height"
|
||||||
@@ -275,16 +267,20 @@ export const StatsInner = memo(
|
|||||||
elementsMap={elementsMap}
|
elementsMap={elementsMap}
|
||||||
atomicUnits={atomicUnits}
|
atomicUnits={atomicUnits}
|
||||||
scene={scene}
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
<MultiAngle
|
<MultiAngle
|
||||||
|
property="angle"
|
||||||
elements={multipleElements}
|
elements={multipleElements}
|
||||||
elementsMap={elementsMap}
|
|
||||||
scene={scene}
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
<MultiFontSize
|
<MultiFontSize
|
||||||
|
property="fontSize"
|
||||||
elements={multipleElements}
|
elements={multipleElements}
|
||||||
elementsMap={elementsMap}
|
|
||||||
scene={scene}
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
elementsMap={elementsMap}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import * as StaticScene from "../../renderer/staticScene";
|
|||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import { reseed } from "../../random";
|
import { reseed } from "../../random";
|
||||||
import { setDateTimeForTests } from "../../utils";
|
import { setDateTimeForTests } from "../../utils";
|
||||||
import { Excalidraw } from "../..";
|
import { Excalidraw, mutateElement } from "../..";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
import { degreeToRadian, rotate } from "../../math";
|
import { degreeToRadian, rotate } from "../../math";
|
||||||
@@ -23,6 +24,7 @@ import { getCommonBounds, isTextElement } from "../../element";
|
|||||||
import { API } from "../../tests/helpers/api";
|
import { API } from "../../tests/helpers/api";
|
||||||
import { actionGroup } from "../../actions";
|
import { actionGroup } from "../../actions";
|
||||||
import { isInGroup } from "../../groups";
|
import { isInGroup } from "../../groups";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
const mouse = new Pointer("mouse");
|
const mouse = new Pointer("mouse");
|
||||||
@@ -30,11 +32,21 @@ const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
|||||||
let stats: HTMLElement | null = null;
|
let stats: HTMLElement | null = null;
|
||||||
let elementStats: HTMLElement | null | undefined = null;
|
let elementStats: HTMLElement | null | undefined = null;
|
||||||
|
|
||||||
|
const editInput = (input: HTMLInputElement, value: string) => {
|
||||||
|
input.focus();
|
||||||
|
fireEvent.change(input, { target: { value } });
|
||||||
|
input.blur();
|
||||||
|
};
|
||||||
|
|
||||||
const getStatsProperty = (label: string) => {
|
const getStatsProperty = (label: string) => {
|
||||||
|
const elementStats = UI.queryStats()?.querySelector("#elementStats");
|
||||||
|
|
||||||
if (elementStats) {
|
if (elementStats) {
|
||||||
const properties = elementStats?.querySelector(".statsItem");
|
const properties = elementStats?.querySelector(".statsItem");
|
||||||
return properties?.querySelector?.(
|
return (
|
||||||
`.drag-input-container[data-testid="${label}"]`,
|
properties?.querySelector?.(
|
||||||
|
`.drag-input-container[data-testid="${label}"]`,
|
||||||
|
) || null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,11 +63,9 @@ const testInputProperty = (
|
|||||||
const input = getStatsProperty(label)?.querySelector(
|
const input = getStatsProperty(label)?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(input).not.toBeNull();
|
expect(input).toBeDefined();
|
||||||
expect(input.value).toBe(initialValue.toString());
|
expect(input.value).toBe(initialValue.toString());
|
||||||
input?.focus();
|
editInput(input, String(nextValue));
|
||||||
input.value = nextValue.toString();
|
|
||||||
input?.blur();
|
|
||||||
if (property === "angle") {
|
if (property === "angle") {
|
||||||
expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
|
expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
|
||||||
} else if (property === "fontSize" && isTextElement(element)) {
|
} else if (property === "fontSize" && isTextElement(element)) {
|
||||||
@@ -91,6 +101,92 @@ describe("step sized value", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("binding with linear elements", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
renderStaticScene.mockClear();
|
||||||
|
reseed(19);
|
||||||
|
setDateTimeForTests("201933152653");
|
||||||
|
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
|
||||||
|
h.elements = [];
|
||||||
|
|
||||||
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
|
button: 2,
|
||||||
|
clientX: 1,
|
||||||
|
clientY: 1,
|
||||||
|
});
|
||||||
|
const contextMenu = UI.queryContextMenu();
|
||||||
|
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||||
|
stats = UI.queryStats();
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down();
|
||||||
|
mouse.up(200, 100);
|
||||||
|
|
||||||
|
UI.clickTool("arrow");
|
||||||
|
mouse.down(5, 0);
|
||||||
|
mouse.up(300, 50);
|
||||||
|
|
||||||
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
mockBoundingClientRect();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
restoreOriginalGetBoundingClientRect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remain bound to linear element on small position change", async () => {
|
||||||
|
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||||
|
const inputX = getStatsProperty("X")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(linear.startBinding).not.toBe(null);
|
||||||
|
expect(inputX).not.toBeNull();
|
||||||
|
editInput(inputX, String("204"));
|
||||||
|
expect(linear.startBinding).not.toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remain bound to linear element on small angle change", async () => {
|
||||||
|
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||||
|
const inputAngle = getStatsProperty("A")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(linear.startBinding).not.toBe(null);
|
||||||
|
editInput(inputAngle, String("1"));
|
||||||
|
expect(linear.startBinding).not.toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should unbind linear element on large position change", async () => {
|
||||||
|
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||||
|
const inputX = getStatsProperty("X")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(linear.startBinding).not.toBe(null);
|
||||||
|
expect(inputX).not.toBeNull();
|
||||||
|
editInput(inputX, String("254"));
|
||||||
|
expect(linear.startBinding).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remain bound to linear element on small angle change", async () => {
|
||||||
|
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||||
|
const inputAngle = getStatsProperty("A")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(linear.startBinding).not.toBe(null);
|
||||||
|
editInput(inputAngle, String("45"));
|
||||||
|
expect(linear.startBinding).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// single element
|
// single element
|
||||||
describe("stats for a generic element", () => {
|
describe("stats for a generic element", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -127,8 +223,8 @@ describe("stats for a generic element", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should open stats", () => {
|
it("should open stats", () => {
|
||||||
expect(stats).not.toBeNull();
|
expect(stats).toBeDefined();
|
||||||
expect(elementStats).not.toBeNull();
|
expect(elementStats).toBeDefined();
|
||||||
|
|
||||||
// title
|
// title
|
||||||
const title = elementStats?.querySelector("h3");
|
const title = elementStats?.querySelector("h3");
|
||||||
@@ -136,18 +232,18 @@ describe("stats for a generic element", () => {
|
|||||||
|
|
||||||
// element type
|
// element type
|
||||||
const elementType = elementStats?.querySelector(".elementType");
|
const elementType = elementStats?.querySelector(".elementType");
|
||||||
expect(elementType).not.toBeNull();
|
expect(elementType).toBeDefined();
|
||||||
expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
|
expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
|
||||||
|
|
||||||
// properties
|
// properties
|
||||||
const properties = elementStats?.querySelector(".statsItem");
|
const properties = elementStats?.querySelector(".statsItem");
|
||||||
expect(properties?.childNodes).not.toBeNull();
|
expect(properties?.childNodes).toBeDefined();
|
||||||
["X", "Y", "W", "H", "A"].forEach((label) => () => {
|
["X", "Y", "W", "H", "A"].forEach((label) => () => {
|
||||||
expect(
|
expect(
|
||||||
properties?.querySelector?.(
|
properties?.querySelector?.(
|
||||||
`.drag-input-container[data-testid="${label}"]`,
|
`.drag-input-container[data-testid="${label}"]`,
|
||||||
),
|
),
|
||||||
).not.toBeNull();
|
).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,19 +266,15 @@ describe("stats for a generic element", () => {
|
|||||||
const input = getStatsProperty("W")?.querySelector(
|
const input = getStatsProperty("W")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(input).not.toBeNull();
|
expect(input).toBeDefined();
|
||||||
expect(input.value).toBe(rectangle.width.toString());
|
expect(input.value).toBe(rectangle.width.toString());
|
||||||
input?.focus();
|
editInput(input, "123.123");
|
||||||
input.value = "123.123";
|
|
||||||
input?.blur();
|
|
||||||
expect(h.elements.length).toBe(1);
|
expect(h.elements.length).toBe(1);
|
||||||
expect(rectangle.id).toBe(rectangleId);
|
expect(rectangle.id).toBe(rectangleId);
|
||||||
expect(input.value).toBe("123.12");
|
expect(input.value).toBe("123.12");
|
||||||
expect(rectangle.width).toBe(123.12);
|
expect(rectangle.width).toBe(123.12);
|
||||||
|
|
||||||
input?.focus();
|
editInput(input, "88.98766");
|
||||||
input.value = "88.98766";
|
|
||||||
input?.blur();
|
|
||||||
expect(input.value).toBe("88.99");
|
expect(input.value).toBe("88.99");
|
||||||
expect(rectangle.width).toBe(88.99);
|
expect(rectangle.width).toBe(88.99);
|
||||||
});
|
});
|
||||||
@@ -333,11 +425,9 @@ describe("stats for a non-generic element", () => {
|
|||||||
const input = getStatsProperty("F")?.querySelector(
|
const input = getStatsProperty("F")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(input).not.toBeNull();
|
expect(input).toBeDefined();
|
||||||
expect(input.value).toBe(text.fontSize.toString());
|
expect(input.value).toBe(text.fontSize.toString());
|
||||||
input?.focus();
|
editInput(input, "36");
|
||||||
input.value = "36";
|
|
||||||
input?.blur();
|
|
||||||
expect(text.fontSize).toBe(36);
|
expect(text.fontSize).toBe(36);
|
||||||
|
|
||||||
// cannot change width or height
|
// cannot change width or height
|
||||||
@@ -347,9 +437,7 @@ describe("stats for a non-generic element", () => {
|
|||||||
expect(height).toBeUndefined();
|
expect(height).toBeUndefined();
|
||||||
|
|
||||||
// min font size is 4
|
// min font size is 4
|
||||||
input.focus();
|
editInput(input, "0");
|
||||||
input.value = "0";
|
|
||||||
input.blur();
|
|
||||||
expect(text.fontSize).not.toBe(0);
|
expect(text.fontSize).not.toBe(0);
|
||||||
expect(text.fontSize).toBe(4);
|
expect(text.fontSize).toBe(4);
|
||||||
});
|
});
|
||||||
@@ -370,7 +458,7 @@ describe("stats for a non-generic element", () => {
|
|||||||
|
|
||||||
elementStats = stats?.querySelector("#elementStats");
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
expect(elementStats).not.toBeNull();
|
expect(elementStats).toBeDefined();
|
||||||
|
|
||||||
// cannot change angle
|
// cannot change angle
|
||||||
const angle = getStatsProperty("A")?.querySelector(".drag-input");
|
const angle = getStatsProperty("A")?.querySelector(".drag-input");
|
||||||
@@ -391,7 +479,7 @@ describe("stats for a non-generic element", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
elementStats = stats?.querySelector("#elementStats");
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
expect(elementStats).not.toBeNull();
|
expect(elementStats).toBeDefined();
|
||||||
const widthToHeight = image.width / image.height;
|
const widthToHeight = image.width / image.height;
|
||||||
|
|
||||||
// when width or height is changed, the aspect ratio is preserved
|
// when width or height is changed, the aspect ratio is preserved
|
||||||
@@ -403,6 +491,35 @@ describe("stats for a non-generic element", () => {
|
|||||||
expect(image.height).toBe(80);
|
expect(image.height).toBe(80);
|
||||||
expect(image.width / image.height).toBe(widthToHeight);
|
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
|
// multiple elements
|
||||||
@@ -471,16 +588,12 @@ describe("stats for multiple elements", () => {
|
|||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(angle.value).toBe("0");
|
expect(angle.value).toBe("0");
|
||||||
|
|
||||||
width.focus();
|
editInput(width, "250");
|
||||||
width.value = "250";
|
|
||||||
width.blur();
|
|
||||||
h.elements.forEach((el) => {
|
h.elements.forEach((el) => {
|
||||||
expect(el.width).toBe(250);
|
expect(el.width).toBe(250);
|
||||||
});
|
});
|
||||||
|
|
||||||
height.focus();
|
editInput(height, "450");
|
||||||
height.value = "450";
|
|
||||||
height.blur();
|
|
||||||
h.elements.forEach((el) => {
|
h.elements.forEach((el) => {
|
||||||
expect(el.height).toBe(450);
|
expect(el.height).toBe(450);
|
||||||
});
|
});
|
||||||
@@ -501,7 +614,6 @@ describe("stats for multiple elements", () => {
|
|||||||
mouse.up(200, 100);
|
mouse.up(200, 100);
|
||||||
|
|
||||||
const frame = API.createElement({
|
const frame = API.createElement({
|
||||||
id: "id0",
|
|
||||||
type: "frame",
|
type: "frame",
|
||||||
x: 150,
|
x: 150,
|
||||||
width: 150,
|
width: 150,
|
||||||
@@ -524,38 +636,34 @@ describe("stats for multiple elements", () => {
|
|||||||
const width = getStatsProperty("W")?.querySelector(
|
const width = getStatsProperty("W")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(width).not.toBeNull();
|
expect(width).toBeDefined();
|
||||||
expect(width.value).toBe("Mixed");
|
expect(width.value).toBe("Mixed");
|
||||||
|
|
||||||
const height = getStatsProperty("H")?.querySelector(
|
const height = getStatsProperty("H")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(height).not.toBeNull();
|
expect(height).toBeDefined();
|
||||||
expect(height.value).toBe("Mixed");
|
expect(height.value).toBe("Mixed");
|
||||||
|
|
||||||
const angle = getStatsProperty("A")?.querySelector(
|
const angle = getStatsProperty("A")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(angle).not.toBeNull();
|
expect(angle).toBeDefined();
|
||||||
expect(angle.value).toBe("0");
|
expect(angle.value).toBe("0");
|
||||||
|
|
||||||
const fontSize = getStatsProperty("F")?.querySelector(
|
const fontSize = getStatsProperty("F")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(fontSize).not.toBeNull();
|
expect(fontSize).toBeDefined();
|
||||||
|
|
||||||
// changing width does not affect text
|
// changing width does not affect text
|
||||||
width.focus();
|
editInput(width, "200");
|
||||||
width.value = "200";
|
|
||||||
width.blur();
|
|
||||||
|
|
||||||
expect(rectangle?.width).toBe(200);
|
expect(rectangle?.width).toBe(200);
|
||||||
expect(frame.width).toBe(200);
|
expect(frame.width).toBe(200);
|
||||||
expect(text?.width).not.toBe(200);
|
expect(text?.width).not.toBe(200);
|
||||||
|
|
||||||
angle.focus();
|
editInput(angle, "40");
|
||||||
angle.value = "40";
|
|
||||||
angle.blur();
|
|
||||||
|
|
||||||
const angleInRadian = degreeToRadian(40);
|
const angleInRadian = degreeToRadian(40);
|
||||||
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
|
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
|
||||||
@@ -592,12 +700,10 @@ describe("stats for multiple elements", () => {
|
|||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
expect(x).not.toBeNull();
|
expect(x).toBeDefined();
|
||||||
expect(Number(x.value)).toBe(x1);
|
expect(Number(x.value)).toBe(x1);
|
||||||
|
|
||||||
x.focus();
|
editInput(x, "300");
|
||||||
x.value = "300";
|
|
||||||
x.blur();
|
|
||||||
|
|
||||||
expect(h.elements[0].x).toBe(300);
|
expect(h.elements[0].x).toBe(300);
|
||||||
expect(h.elements[1].x).toBe(400);
|
expect(h.elements[1].x).toBe(400);
|
||||||
@@ -607,12 +713,10 @@ describe("stats for multiple elements", () => {
|
|||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
expect(y).not.toBeNull();
|
expect(y).toBeDefined();
|
||||||
expect(Number(y.value)).toBe(y1);
|
expect(Number(y.value)).toBe(y1);
|
||||||
|
|
||||||
y.focus();
|
editInput(y, "200");
|
||||||
y.value = "200";
|
|
||||||
y.blur();
|
|
||||||
|
|
||||||
expect(h.elements[0].y).toBe(200);
|
expect(h.elements[0].y).toBe(200);
|
||||||
expect(h.elements[1].y).toBe(300);
|
expect(h.elements[1].y).toBe(300);
|
||||||
@@ -621,35 +725,29 @@ describe("stats for multiple elements", () => {
|
|||||||
const width = getStatsProperty("W")?.querySelector(
|
const width = getStatsProperty("W")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(width).not.toBeNull();
|
expect(width).toBeDefined();
|
||||||
expect(Number(width.value)).toBe(200);
|
expect(Number(width.value)).toBe(200);
|
||||||
|
|
||||||
const height = getStatsProperty("H")?.querySelector(
|
const height = getStatsProperty("H")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(height).not.toBeNull();
|
expect(height).toBeDefined();
|
||||||
expect(Number(height.value)).toBe(200);
|
expect(Number(height.value)).toBe(200);
|
||||||
|
|
||||||
width.focus();
|
editInput(width, "400");
|
||||||
width.value = "400";
|
|
||||||
width.blur();
|
|
||||||
|
|
||||||
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||||
let newGroupWidth = x2 - x1;
|
let newGroupWidth = x2 - x1;
|
||||||
|
|
||||||
expect(newGroupWidth).toBeCloseTo(400, 4);
|
expect(newGroupWidth).toBeCloseTo(400, 4);
|
||||||
|
|
||||||
width.focus();
|
editInput(width, "300");
|
||||||
width.value = "300";
|
|
||||||
width.blur();
|
|
||||||
|
|
||||||
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||||
newGroupWidth = x2 - x1;
|
newGroupWidth = x2 - x1;
|
||||||
expect(newGroupWidth).toBeCloseTo(300, 4);
|
expect(newGroupWidth).toBeCloseTo(300, 4);
|
||||||
|
|
||||||
height.focus();
|
editInput(height, "500");
|
||||||
height.value = "500";
|
|
||||||
height.blur();
|
|
||||||
|
|
||||||
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||||
const newGroupHeight = y2 - y1;
|
const newGroupHeight = y2 - y1;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { updateBoundElements } from "../../element/binding";
|
import {
|
||||||
|
bindOrUnbindLinearElements,
|
||||||
|
updateBoundElements,
|
||||||
|
} from "../../element/binding";
|
||||||
import { mutateElement } from "../../element/mutateElement";
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
import {
|
import {
|
||||||
measureFontSizeFromWidth,
|
measureFontSizeFromWidth,
|
||||||
@@ -11,15 +14,34 @@ import {
|
|||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
handleBindTextResize,
|
handleBindTextResize,
|
||||||
} from "../../element/textElement";
|
} from "../../element/textElement";
|
||||||
import { isFrameLikeElement, isTextElement } from "../../element/typeChecks";
|
import {
|
||||||
|
isFrameLikeElement,
|
||||||
|
isLinearElement,
|
||||||
|
isTextElement,
|
||||||
|
} from "../../element/typeChecks";
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
|
import {
|
||||||
|
getSelectedGroupIds,
|
||||||
|
getElementsInGroup,
|
||||||
|
isInGroup,
|
||||||
|
} from "../../groups";
|
||||||
import { rotate } from "../../math";
|
import { rotate } from "../../math";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
import { getFontString } from "../../utils";
|
import { getFontString } from "../../utils";
|
||||||
|
|
||||||
|
export type StatsInputProperty =
|
||||||
|
| "x"
|
||||||
|
| "y"
|
||||||
|
| "width"
|
||||||
|
| "height"
|
||||||
|
| "angle"
|
||||||
|
| "fontSize";
|
||||||
|
|
||||||
export const SMALLEST_DELTA = 0.01;
|
export const SMALLEST_DELTA = 0.01;
|
||||||
|
|
||||||
export const isPropertyEditable = (
|
export const isPropertyEditable = (
|
||||||
@@ -100,12 +122,14 @@ export const resizeElement = (
|
|||||||
nextWidth: number,
|
nextWidth: number,
|
||||||
nextHeight: number,
|
nextHeight: number,
|
||||||
keepAspectRatio: boolean,
|
keepAspectRatio: boolean,
|
||||||
latestElement: ExcalidrawElement,
|
|
||||||
origElement: ExcalidrawElement,
|
origElement: ExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
originalElementsMap: Map<string, ExcalidrawElement>,
|
|
||||||
shouldInformMutation = true,
|
shouldInformMutation = true,
|
||||||
) => {
|
) => {
|
||||||
|
const latestElement = elementsMap.get(origElement.id);
|
||||||
|
if (!latestElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let boundTextFont: { fontSize?: number } = {};
|
let boundTextFont: { fontSize?: number } = {};
|
||||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||||
|
|
||||||
@@ -140,6 +164,12 @@ export const resizeElement = (
|
|||||||
},
|
},
|
||||||
shouldInformMutation,
|
shouldInformMutation,
|
||||||
);
|
);
|
||||||
|
updateBindings(latestElement, elementsMap, {
|
||||||
|
newSize: {
|
||||||
|
width: nextWidth,
|
||||||
|
height: nextHeight,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
boundTextFont = {
|
boundTextFont = {
|
||||||
@@ -163,13 +193,6 @@ export const resizeElement = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBoundElements(latestElement, elementsMap, {
|
|
||||||
newSize: {
|
|
||||||
width: nextWidth,
|
|
||||||
height: nextHeight,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (boundTextElement && boundTextFont) {
|
if (boundTextElement && boundTextFont) {
|
||||||
mutateElement(boundTextElement, {
|
mutateElement(boundTextElement, {
|
||||||
fontSize: boundTextFont.fontSize,
|
fontSize: boundTextFont.fontSize,
|
||||||
@@ -181,12 +204,15 @@ export const resizeElement = (
|
|||||||
export const moveElement = (
|
export const moveElement = (
|
||||||
newTopLeftX: number,
|
newTopLeftX: number,
|
||||||
newTopLeftY: number,
|
newTopLeftY: number,
|
||||||
latestElement: ExcalidrawElement,
|
|
||||||
originalElement: ExcalidrawElement,
|
originalElement: ExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
originalElementsMap: ElementsMap,
|
originalElementsMap: ElementsMap,
|
||||||
shouldInformMutation = true,
|
shouldInformMutation = true,
|
||||||
) => {
|
) => {
|
||||||
|
const latestElement = elementsMap.get(originalElement.id);
|
||||||
|
if (!latestElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const [cx, cy] = [
|
const [cx, cy] = [
|
||||||
originalElement.x + originalElement.width / 2,
|
originalElement.x + originalElement.width / 2,
|
||||||
originalElement.y + originalElement.height / 2,
|
originalElement.y + originalElement.height / 2,
|
||||||
@@ -218,6 +244,7 @@ export const moveElement = (
|
|||||||
},
|
},
|
||||||
shouldInformMutation,
|
shouldInformMutation,
|
||||||
);
|
);
|
||||||
|
updateBindings(latestElement, elementsMap);
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(
|
const boundTextElement = getBoundTextElement(
|
||||||
originalElement,
|
originalElement,
|
||||||
@@ -236,3 +263,39 @@ export const moveElement = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getAtomicUnits = (
|
||||||
|
targetElements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||||
|
const _atomicUnits = selectedGroupIds.map((gid) => {
|
||||||
|
return getElementsInGroup(targetElements, gid).reduce((acc, el) => {
|
||||||
|
acc[el.id] = true;
|
||||||
|
return acc;
|
||||||
|
}, {} as AtomicUnit);
|
||||||
|
});
|
||||||
|
targetElements
|
||||||
|
.filter((el) => !isInGroup(el))
|
||||||
|
.forEach((el) => {
|
||||||
|
_atomicUnits.push({
|
||||||
|
[el.id]: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return _atomicUnits;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateBindings = (
|
||||||
|
latestElement: ExcalidrawElement,
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
options?: {
|
||||||
|
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
||||||
|
newSize?: { width: number; height: number };
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if (isLinearElement(latestElement)) {
|
||||||
|
bindOrUnbindLinearElements([latestElement], elementsMap, true, []);
|
||||||
|
} else {
|
||||||
|
updateBoundElements(latestElement, elementsMap, options);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -274,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";
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,9 +180,8 @@ 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"
|
||||||
@@ -189,7 +189,10 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
|||||||
: linearElement.endBinding?.elementId;
|
: linearElement.endBinding?.elementId;
|
||||||
if (elementId) {
|
if (elementId) {
|
||||||
const element = elementsMap.get(elementId);
|
const element = elementsMap.get(elementId);
|
||||||
if (isBindableElement(element) && bindingBorderTest(element, coors, app)) {
|
if (
|
||||||
|
isBindableElement(element) &&
|
||||||
|
bindingBorderTest(element, coors, elementsMap)
|
||||||
|
) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -213,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;
|
||||||
@@ -221,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;
|
||||||
|
|
||||||
@@ -260,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 => {
|
||||||
@@ -271,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) {
|
||||||
@@ -302,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> =>
|
||||||
@@ -324,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(
|
||||||
@@ -343,12 +364,7 @@ export const maybeBindLinearElement = (
|
|||||||
"end",
|
"end",
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
bindLinearElement(
|
bindLinearElement(linearElement, hoveredElement, "end", elementsMap);
|
||||||
linearElement,
|
|
||||||
hoveredElement,
|
|
||||||
"end",
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -432,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;
|
||||||
};
|
};
|
||||||
@@ -662,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,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -707,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) {
|
||||||
@@ -756,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) =>
|
||||||
@@ -827,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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -107,8 +107,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
export const newElementWith = <TElement extends ExcalidrawElement>(
|
export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||||
element: TElement,
|
element: TElement,
|
||||||
updates: ElementUpdate<TElement>,
|
updates: ElementUpdate<TElement>,
|
||||||
/** pass `true` to always regenerate */
|
|
||||||
force = false,
|
|
||||||
): TElement => {
|
): TElement => {
|
||||||
let didChange = false;
|
let didChange = false;
|
||||||
for (const key in updates) {
|
for (const key in updates) {
|
||||||
@@ -125,7 +123,7 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!didChange && !force) {
|
if (!didChange) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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": "1.0.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",
|
||||||
|
|||||||
@@ -90,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,
|
||||||
@@ -471,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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isTextElement } from "../element";
|
import { isTextElement } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { getContainerElement } from "../element/textElement";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
@@ -46,14 +46,18 @@ export class Fonts {
|
|||||||
|
|
||||||
let didUpdate = false;
|
let didUpdate = false;
|
||||||
|
|
||||||
this.scene.mapElements((element) => {
|
const elementsMap = this.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
|
for (const element of this.scene.getNonDeletedElements()) {
|
||||||
if (isTextElement(element)) {
|
if (isTextElement(element)) {
|
||||||
didUpdate = true;
|
didUpdate = true;
|
||||||
ShapeCache.delete(element);
|
ShapeCache.delete(element);
|
||||||
return newElementWith(element, {}, true);
|
const container = getContainerElement(element, elementsMap);
|
||||||
|
if (container) {
|
||||||
|
ShapeCache.delete(container);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return element;
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (didUpdate) {
|
if (didUpdate) {
|
||||||
this.scene.triggerUpdate();
|
this.scene.triggerUpdate();
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -96,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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fireEvent, render } from "./test-utils";
|
import { fireEvent, render } from "./test-utils";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw, isLinearElement } from "../index";
|
||||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||||
import { getTransformHandles } from "../element/transformHandles";
|
import { getTransformHandles } from "../element/transformHandles";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
@@ -433,4 +433,49 @@ describe("element binding", () => {
|
|||||||
expect(arrow.startBinding).not.toBe(null);
|
expect(arrow.startBinding).not.toBe(null);
|
||||||
expect(arrow.endBinding).toBe(null);
|
expect(arrow.endBinding).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should not unbind when duplicating via selection group", () => {
|
||||||
|
const rectLeft = UI.createElement("rectangle", {
|
||||||
|
x: 0,
|
||||||
|
width: 200,
|
||||||
|
height: 500,
|
||||||
|
});
|
||||||
|
const rectRight = UI.createElement("rectangle", {
|
||||||
|
x: 400,
|
||||||
|
y: 200,
|
||||||
|
width: 200,
|
||||||
|
height: 500,
|
||||||
|
});
|
||||||
|
const arrow = UI.createElement("arrow", {
|
||||||
|
x: 210,
|
||||||
|
y: 250,
|
||||||
|
width: 177,
|
||||||
|
height: 1,
|
||||||
|
});
|
||||||
|
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
|
||||||
|
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
|
||||||
|
|
||||||
|
mouse.downAt(-100, -100);
|
||||||
|
mouse.moveTo(650, 750);
|
||||||
|
mouse.up(0, 0);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements().length).toBe(3);
|
||||||
|
|
||||||
|
mouse.moveTo(5, 5);
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
mouse.downAt(5, 5);
|
||||||
|
mouse.moveTo(1000, 1000);
|
||||||
|
mouse.up(0, 0);
|
||||||
|
|
||||||
|
expect(window.h.elements.length).toBe(6);
|
||||||
|
window.h.elements.forEach((element) => {
|
||||||
|
if (isLinearElement(element)) {
|
||||||
|
expect(element.startBinding).not.toBe(null);
|
||||||
|
expect(element.endBinding).not.toBe(null);
|
||||||
|
} else {
|
||||||
|
expect(element.boundElements).not.toBe(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import * as textElementUtils from "../element/textElement";
|
|||||||
import { ROUNDNESS, VERTICAL_ALIGN } from "../constants";
|
import { ROUNDNESS, VERTICAL_ALIGN } from "../constants";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
const renderInteractiveScene = vi.spyOn(
|
const renderInteractiveScene = vi.spyOn(
|
||||||
InteractiveCanvas,
|
InteractiveCanvas,
|
||||||
@@ -972,10 +973,10 @@ describe("Test Linear Elements", () => {
|
|||||||
]);
|
]);
|
||||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
||||||
.toMatchInlineSnapshot(`
|
.toMatchInlineSnapshot(`
|
||||||
"Online whiteboard
|
"Online whiteboard
|
||||||
collaboration made
|
collaboration made
|
||||||
easy"
|
easy"
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should bind text to arrow when clicked on arrow and enter pressed", async () => {
|
it("should bind text to arrow when clicked on arrow and enter pressed", async () => {
|
||||||
@@ -1006,10 +1007,10 @@ describe("Test Linear Elements", () => {
|
|||||||
]);
|
]);
|
||||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
||||||
.toMatchInlineSnapshot(`
|
.toMatchInlineSnapshot(`
|
||||||
"Online whiteboard
|
"Online whiteboard
|
||||||
collaboration made
|
collaboration made
|
||||||
easy"
|
easy"
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not bind text to line when double clicked", async () => {
|
it("should not bind text to line when double clicked", async () => {
|
||||||
@@ -1349,4 +1350,27 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(label.y).toBe(0);
|
expect(label.y).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Test moving linear element points", () => {
|
||||||
|
it("should move the endpoint in the negative direction correctly when the start point is also moved in the positive direction", async () => {
|
||||||
|
const line = createThreePointerLinearElement("arrow");
|
||||||
|
const [origStartX, origStartY] = [line.x, line.y];
|
||||||
|
|
||||||
|
LinearElementEditor.movePoints(line, [
|
||||||
|
{ index: 0, point: [line.points[0][0] + 10, line.points[0][1] + 10] },
|
||||||
|
{
|
||||||
|
index: line.points.length - 1,
|
||||||
|
point: [
|
||||||
|
line.points[line.points.length - 1][0] - 10,
|
||||||
|
line.points[line.points.length - 1][1] - 10,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(line.x).toBe(origStartX + 10);
|
||||||
|
expect(line.y).toBe(origStartY + 10);
|
||||||
|
|
||||||
|
expect(line.points[line.points.length - 1][0]).toBe(20);
|
||||||
|
expect(line.points[line.points.length - 1][1]).toBe(-20);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -614,7 +614,6 @@ export type AppClassProperties = {
|
|||||||
setOpenDialog: App["setOpenDialog"];
|
setOpenDialog: App["setOpenDialog"];
|
||||||
insertEmbeddableElement: App["insertEmbeddableElement"];
|
insertEmbeddableElement: App["insertEmbeddableElement"];
|
||||||
onMagicframeToolSelect: App["onMagicframeToolSelect"];
|
onMagicframeToolSelect: App["onMagicframeToolSelect"];
|
||||||
getElementShape: App["getElementShape"];
|
|
||||||
getName: App["getName"];
|
getName: App["getName"];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
@@ -43,6 +43,7 @@ interface ImportMetaEnv {
|
|||||||
VITE_APP_COLLAPSE_OVERLAY: string;
|
VITE_APP_COLLAPSE_OVERLAY: string;
|
||||||
// Enable eslint in dev server
|
// Enable eslint in dev server
|
||||||
VITE_APP_ENABLE_ESLINT: string;
|
VITE_APP_ENABLE_ESLINT: string;
|
||||||
|
VITE_APP_ENABLE_TRACKING: string;
|
||||||
|
|
||||||
VITE_PKG_NAME: string;
|
VITE_PKG_NAME: string;
|
||||||
VITE_PKG_VERSION: string;
|
VITE_PKG_VERSION: string;
|
||||||
|
|||||||
@@ -1930,10 +1930,10 @@
|
|||||||
resolved "https://registry.npmjs.org/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
|
resolved "https://registry.npmjs.org/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
|
||||||
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
|
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
|
||||||
|
|
||||||
"@excalidraw/mermaid-to-excalidraw@1.0.0":
|
"@excalidraw/mermaid-to-excalidraw@1.1.0":
|
||||||
version "1.0.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.0.0.tgz#8c058d2a43230425cba96d01e4a669a2d7c586a2"
|
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.0.tgz#a24a7aa3ad2e4f671054fdb670a8508bab463814"
|
||||||
integrity sha512-RGSoJBY2gFag6mQOIwa3OakTrvAZYx0bwvnr5ojuCZInih8Fxhje4X1WZfsaQx+GATEH8Ioq3O3b1FPDg4nKjQ==
|
integrity sha512-YP2roqrImzek1SpUAeToSTNhH5Gfw9ogdI5KHp7c+I/mX7SEW8oNqqX7CP+oHcUgNF6RrYIkqSrnMRN9/3EGLg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@excalidraw/markdown-to-text" "0.1.2"
|
"@excalidraw/markdown-to-text" "0.1.2"
|
||||||
mermaid "10.9.0"
|
mermaid "10.9.0"
|
||||||
@@ -7753,9 +7753,9 @@ lru-cache@^6.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist "^4.0.0"
|
yallist "^4.0.0"
|
||||||
|
|
||||||
lz-string@^1.5.0:
|
lz-string@1.5.0, lz-string@^1.5.0:
|
||||||
version "1.5.0"
|
version "1.5.0"
|
||||||
resolved "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
|
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
|
||||||
integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==
|
integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==
|
||||||
|
|
||||||
magic-string@^0.25.0, magic-string@^0.25.7:
|
magic-string@^0.25.0, magic-string@^0.25.7:
|
||||||
|
|||||||
Reference in New Issue
Block a user