Compare commits

...
Author SHA1 Message Date
dwelle 93c33fef20 feat: support importing obsidian.md files 2024-07-19 12:39:35 +02:00
BlueGreenMagickandGitHub df8875a497 fix: freedraw jittering (#8238) 2024-07-14 08:44:47 +00:00
David LuzarandGitHub 6fbc44fd1f fix: messed up env variable (#8231) 2024-07-11 14:33:35 +02:00
Aakansha DoshiandGitHub d25a7d365b feat: upgrade mermaid-to-excalidraw to v1.1.0 (#8226)
* feat: upgrade mermaid-to-excalidraw to v1.1.0

* fixes

* upgrade and remove config as its redundant

* lint

* upgrade to v1.1.0
2024-07-10 20:57:43 +05:30
David LuzarandGitHub e52c2cd0b6 fix: log allowed events (#8224) 2024-07-09 12:16:14 +02:00
David LuzarandGitHub 96eeec5119 feat: bump max file size (#8220) 2024-07-08 18:35:13 +02:00
Hamir MahalandGitHub f5221d521b ci: upgrade gh actions checkout and setup-node to v4 (#8168)
fix: usage of `node12 which is deprecated`
2024-07-08 14:26:25 +05:30
Alexandre LemoineandGitHub db2c235cd4 Fix : exportToCanvas() doc example (#8127) 2024-07-08 08:52:05 +00:00
David LuzarandGitHub 148b895f46 feat: smarter preferred lang detection (#8205) 2024-07-04 17:55:35 +02:00
DDDDD12138andGitHub d9258a736b chore: Consolidate i18n import in LanguageList component (#8201) 2024-07-04 17:34:16 +02:00
zsviczianandGitHub 2e1f08c796 fix: memory leak - scene.destroy() and window.launchQueue (#8198) 2024-07-02 22:08:02 +02:00
David LuzarandGitHub 1d5b41dabb fix: stop updating text versions on init (#8191) 2024-07-01 14:04:58 +02:00
Márk TolmácsandGitHub 66a2f24296 fix: Add binding update to manual stat changes (#8183)
Manual stats changes now respect previous element bindings.
2024-07-01 09:45:31 +02:00
Márk TolmácsandGitHub 04668d8263 fix: Binding after duplicating is now applied for both the old and duplicate shapes (#8185)
Using ALT/OPT + drag to clone does not transfer the bindings (or leaves the duplicates in place of the old one , which are also not bound).

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2024-06-28 15:28:48 +02:00
David LuzarandGitHub abbeed3d5f feat: support Stats bound text fontSize editing (#8187) 2024-06-28 13:52:29 +02:00
Márk TolmácsandGitHub ba8c09d529 fix: Incorrect point offsetting in LinearElementEditor.movePoints() (#8145)
The LinearElementEditor.movePoints() function incorrectly calculates the offset for local linear element points when multiple targetPoints are provided, one of those target points is index === 0 AND the other points are moved in the negative direction, and ending up with negative local coordinates.

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2024-06-28 12:23:10 +02:00
David LuzarandGitHub 744b3e5d09 fix: stats state leaking & race conds (#8177) 2024-06-26 23:31:08 +02:00
43 changed files with 1581 additions and 1058 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ VITE_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when # whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers. # debugging Service Workers.
VITE_APP_DEV_DISABLE_LIVE_RELOAD= VITE_APP_DEV_DISABLE_LIVE_RELOAD=
VITE_APP_DISABLE_TRACKING=true VITE_APP_ENABLE_TRACKING=true
FAST_REFRESH=false FAST_REFRESH=false
+1 -1
View File
@@ -14,4 +14,4 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}' VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
VITE_APP_DISABLE_TRACKING= VITE_APP_ENABLE_TRACKING=false
+2 -2
View File
@@ -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
View File
@@ -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,
@@ -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;
};
+1 -1
View File
@@ -6,7 +6,7 @@ import {
import type { Theme } from "../../packages/excalidraw/element/types"; import type { Theme } from "../../packages/excalidraw/element/types";
import { MainMenu } from "../../packages/excalidraw/index"; import { MainMenu } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants"; import { isExcalidrawPlusSignedUser } from "../app_constants";
import { LanguageList } from "./LanguageList"; import { LanguageList } from "../app-language/LanguageList";
export const AppMainMenu: React.FC<{ export const AppMainMenu: React.FC<{
onCollabDialogOpen: () => any; onCollabDialogOpen: () => any;
+2 -2
View File
@@ -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",
+6 -1
View File
@@ -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,
);
} }
} }
+1 -1
View File
@@ -124,7 +124,7 @@ const flipElements = (
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement), selectedElements.filter(isLinearElement),
app, elementsMap,
isBindingEnabled(appState), isBindingEnabled(appState),
[], [],
); );
+10 -7
View File
@@ -1,6 +1,6 @@
// place here categories that you want to track. We want to track just a // place here categories that you want to track. We want to track just a
// small subset of categories at a given time. // small subset of categories at a given time.
const ALLOWED_CATEGORIES_TO_TRACK = ["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;
} }
+54 -96
View File
@@ -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>();
+59 -43
View File
@@ -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}
/> />
); );
}; };
+29 -33
View File
@@ -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;
+77 -14
View File
@@ -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;
+1 -1
View File
@@ -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";
+17 -1
View File
@@ -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(
+67 -48
View File
@@ -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)
+1 -3
View File
@@ -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;
} }
+2 -1
View File
@@ -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",
+2 -11
View File
@@ -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;
+9 -5
View File
@@ -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();
-5
View File
@@ -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"
+62
View File
@@ -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));
}
}
};
+46 -1
View File
@@ -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);
});
});
}); });
-1
View File
@@ -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"];
}; };
+1
View File
@@ -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;
+6 -6
View File
@@ -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: