Compare commits

...

42 Commits

Author SHA1 Message Date
dwelle e5da6b08b2 refactor: explicitly export elementsOverlappingBBox from utils package 2025-02-21 13:06:48 +01:00
Abhinav Pant 9ee0b8ffcb Enhancement: grouped together Undo and Redo buttons on mobile (#9109)
* bugfix: put the redo and undo button under the same div so that they look grouped together

* fixed the position of the redo and undo buttons to the right
2025-02-13 13:07:44 +00:00
David Luzar 16b86d7d16 chore: update firebase@8 to @11 (#9136) 2025-02-13 13:57:14 +01:00
Márk Tolmács f12b92ce9d chore: Upgrade Sentry to latest and update debug messages (#9134)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-02-13 12:47:27 +01:00
Márk Tolmács 77dc055d81 chore: Revert aspect ratio fix with element size limits and chk (#9131) 2025-02-12 15:02:35 +01:00
David Luzar 26f02bebea fix: stop using structuredClone (#9128)
fix: stop using `structuredClone`
2025-02-12 13:02:53 +01:00
Marcel Mraz e3060dfb8f feat: custom text metrics provider (#9121) 2025-02-11 14:23:08 +01:00
Kyosuke Fujimoto c329470b73 fix: Fix inconsistency in resizing while maintaining aspect ratio (#9116) 2025-02-10 15:24:08 +01:00
David Luzar c8f4a4cb41 feat: add props.onDuplicate (#9117)
* feat: add `props.onDuplicate`

* docs

* clarify docs

* fix docs
2025-02-10 14:20:18 +00:00
Márk Tolmács 9e49c9254b fix: IFrame and elbow arrow interaction fix (#9101) 2025-02-06 14:45:49 +01:00
David Luzar b0c8c5f7a7 feat: change empty arrowhead icon (#9100) 2025-02-06 10:52:03 +01:00
tothatt81 4f64372506 perf: Improved pointer events related performance when the sidebar is docked with a large library open (#9086)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-02-04 22:05:56 +01:00
David Luzar 424e94a403 fix: duplicating/removing frame while children selected (#9079) 2025-02-04 19:23:47 +01:00
Márk Tolmács 302664e500 fix: Elbow arrow z-index binding (#9067) 2025-02-01 19:21:03 +01:00
David Luzar 86c67bd37f fix: library item checkbox style regression (#9080) 2025-02-01 12:27:41 +01:00
David Luzar 511433988c feat: tweak slider colors to be more muted (#9076) 2025-01-31 16:52:50 +01:00
Márk Tolmács 9b6edc767a fix: Elbow arrow orthogonality (#9073) 2025-01-31 14:19:07 +01:00
David Luzar 6cdb683410 fix: button bg CSS variable leaking into other styles (#9075) 2025-01-31 12:33:54 +01:00
Shalini 84bab403ff Fix: issue #8818 Xiaolai font has been set as a fallback for Excalifont (#9055)
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2025-01-30 13:41:41 +00:00
Are 61e0bb83d0 feat: improve library sidebar performance (#9060)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-30 14:41:08 +01:00
Saikat Das bd1590fc74 feat: implement custom Range component for opacity control (#9009)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-29 21:46:40 +00:00
Marcel Mraz d29c3db7f6 fix: fonts not loading on export (again) (#9064) 2025-01-29 22:24:26 +01:00
Marcel Mraz a58822c1c1 fix: merge server-side fonts with liberation sans (#9052) 2025-01-29 22:04:49 +01:00
David Luzar a3e1619635 fix: hyperlinks html entities (#9063) 2025-01-29 19:02:54 +01:00
Ryan Di 52eaf64591 feat: box select frame & children to allow resizing at the same time (#9031)
* box select frame & children

* avoid selecting children twice to avoid double their moving

* do not show ele stats if frame and children selected together

* do not update frame membership if selected together

* do not group frame and its children

* comment and refactor code

* hide align altogether

* include frame children when selecting all

* simplify

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-28 22:10:16 +01:00
David Luzar 7028daa44a fix: remove flushSync to fix flickering (#9057) 2025-01-28 19:23:35 +01:00
Ashwin Temkar 65f218b100 fix: excalidraw issue #9045 flowcharts: align attributes of new node (#9047)
* fix: excalidraw#9045 by modifying the stroke style, opacity, and fill style for the new node and next nodes.

* fix: added roughness and opacity to the arrowbindings
2025-01-25 17:05:50 +01:00
Alplune 807b3c59f2 fix: align arrows bound to elements excalidraw#8833 (#8998) 2025-01-25 17:00:39 +01:00
Alplune b8da5065fd fix: update elbow arrow on font size change #8798 (#9002) 2025-01-25 17:00:26 +01:00
Márk Tolmács 49f1276ef2 fix: Undo for elbow arrows create incorrect routing (#9046) 2025-01-24 20:18:08 +01:00
Ashwin Temkar 8f20b29b73 fix: #8575 , Flowchart clones the current arrowhead (#8581)
* fix: #8575, Flowchart clones the current arrowhead

* fix: #8575, changed stroke color, style and width to startBindingElement
2025-01-24 16:50:07 +01:00
David Luzar f87c2cde09 feat: allow installing libs from excal github (#9041) 2025-01-23 16:50:47 +01:00
Ryan Di 0bf234fcc9 fix: adding partial group to frame (#9014)
* prevent new frame from including partial groups

* separate wrapped partial group
2025-01-23 07:26:12 +08:00
Ryan Di dd1b45a25a perf: reduce unnecessary frame clippings (#8980)
* reduce unnecessary frame clippings

* further optim
2025-01-23 07:25:46 +08:00
David Luzar ec06fbc1fc fix: do not refocus element link input on unrelated updates (#9037) 2025-01-22 21:30:15 +01:00
David Luzar fa05ae1230 refactor: remove defaultProps (#9035) 2025-01-22 12:43:02 +01:00
Márk Tolmács 91ebf8b0ea feat: Elbow arrow segment fixing & positioning (#8952)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2025-01-17 18:07:03 +01:00
Arnost Pleskot 8551823da9 feat: update jotai (#9015)
* feat: update jotai in excalidraw package

* feat: update jotai in excalidraw-app

* fix: exports from excalidraw/jotai

* fix: use isolated react hooks

* test: use jotai provider in <Trans /> test

* remove unused package

* refactor & make safer

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-16 16:59:11 +01:00
David Luzar ae6bee3403 feat: do not delete frame children on frame delete (#9011) 2025-01-14 21:08:25 +01:00
David Luzar 46f42ef8d7 fix: arrow binding behaving unexpectedly on pointerup (#9010)
* fix: arrow binding behaving unexpectedly on pointerup

* update snaps
2025-01-14 19:36:47 +01:00
Ryan Di 00b5b0a0ca feat: add action to wrap selected items in a frame (#9005)
* feat: add action to wrap selected items in a frame

* fix type

* select frame on wrap & refactor

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-13 15:03:56 +00:00
YongJoon Kim c92f3bebf5 fix: change cursor by tool change immediately (#8212) 2025-01-09 14:26:12 +01:00
144 changed files with 9427 additions and 5561 deletions
+15 -1
View File
@@ -3,6 +3,20 @@
"rules": { "rules": {
"import/no-anonymous-default-export": "off", "import/no-anonymous-default-export": "off",
"no-restricted-globals": "off", "no-restricted-globals": "off",
"@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports", "disallowTypeAnnotations": false, "fixStyle": "separate-type-imports" }] "@typescript-eslint/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"disallowTypeAnnotations": false,
"fixStyle": "separate-type-imports"
}
],
"no-restricted-imports": [
"error",
{
"name": "jotai",
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
}
]
} }
} }
+10 -7
View File
@@ -90,9 +90,13 @@ 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 { Provider, useAtom, useAtomValue } from "jotai"; import {
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai"; Provider,
import { appJotaiStore } from "./app-jotai"; useAtom,
useAtomValue,
useAtomWithInitialValue,
appJotaiStore,
} from "./app-jotai";
import "./index.scss"; import "./index.scss";
import type { ResolutionType } from "../packages/excalidraw/utility-types"; import type { ResolutionType } from "../packages/excalidraw/utility-types";
@@ -117,7 +121,7 @@ import {
share, share,
youtubeIcon, youtubeIcon,
} from "../packages/excalidraw/components/icons"; } from "../packages/excalidraw/components/icons";
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme"; import { useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector"; import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state"; import { useAppLangCode } from "./app-language/language-state";
import DebugCanvas, { import DebugCanvas, {
@@ -328,8 +332,7 @@ const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const isCollabDisabled = isRunningInIframe(); const isCollabDisabled = isRunningInIframe();
const [appTheme, setAppTheme] = useAtom(appThemeAtom); const { editorTheme, appTheme, setAppTheme } = useHandleAppTheme();
const { editorTheme } = useHandleAppTheme();
const [langCode, setLangCode] = useAppLangCode(); const [langCode, setLangCode] = useAppLangCode();
@@ -1141,7 +1144,7 @@ const ExcalidrawApp = () => {
return ( return (
<TopErrorBoundary> <TopErrorBoundary>
<Provider unstable_createStore={() => appJotaiStore}> <Provider store={appJotaiStore}>
<ExcalidrawWrapper /> <ExcalidrawWrapper />
</Provider> </Provider>
</TopErrorBoundary> </TopErrorBoundary>
+36 -2
View File
@@ -1,3 +1,37 @@
import { unstable_createStore } from "jotai"; // eslint-disable-next-line no-restricted-imports
import {
atom,
Provider,
useAtom,
useAtomValue,
useSetAtom,
createStore,
type PrimitiveAtom,
} from "jotai";
import { useLayoutEffect } from "react";
export const appJotaiStore = unstable_createStore(); export const appJotaiStore = createStore();
export { atom, Provider, useAtom, useAtomValue, useSetAtom };
export const useAtomWithInitialValue = <
T extends unknown,
A extends PrimitiveAtom<T>,
>(
atom: A,
initialValue: T | (() => T),
) => {
const [value, setValue] = useAtom(atom);
useLayoutEffect(() => {
if (typeof initialValue === "function") {
// @ts-ignore
setValue(initialValue());
} else {
setValue(initialValue);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return [value, setValue] as const;
};
+1 -1
View File
@@ -1,6 +1,6 @@
import { useSetAtom } from "jotai";
import React from "react"; import React from "react";
import { useI18n, languages } from "../../packages/excalidraw/i18n"; import { useI18n, languages } from "../../packages/excalidraw/i18n";
import { useSetAtom } from "../app-jotai";
import { appLangCodeAtom } from "./language-state"; import { appLangCodeAtom } from "./language-state";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
@@ -1,5 +1,5 @@
import { atom, useAtom } from "jotai";
import { useEffect } from "react"; import { useEffect } from "react";
import { atom, useAtom } from "../app-jotai";
import { getPreferredLanguage, languageDetector } from "./language-detector"; import { getPreferredLanguage, languageDetector } from "./language-detector";
export const appLangCodeAtom = atom(getPreferredLanguage()); export const appLangCodeAtom = atom(getPreferredLanguage());
+1 -2
View File
@@ -79,8 +79,7 @@ import { newElementWith } from "../../packages/excalidraw/element/mutateElement"
import { decryptData } from "../../packages/excalidraw/data/encryption"; import { decryptData } from "../../packages/excalidraw/data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync"; import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData"; import { LocalData } from "../data/LocalData";
import { atom } from "jotai"; import { appJotaiStore, atom } from "../app-jotai";
import { appJotaiStore } from "../app-jotai";
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types"; import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds"; import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils"; import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
+1 -1
View File
@@ -2,9 +2,9 @@ import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
import { warning } from "../../packages/excalidraw/components/icons"; import { warning } from "../../packages/excalidraw/components/icons";
import clsx from "clsx"; import clsx from "clsx";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { atom } from "../app-jotai";
import "./CollabError.scss"; import "./CollabError.scss";
import { atom } from "jotai";
type ErrorIndicator = { type ErrorIndicator = {
message: string | null; message: string | null;
@@ -25,6 +25,7 @@ import { MIME_TYPES } from "../../packages/excalidraw/constants";
import { trackEvent } from "../../packages/excalidraw/analytics"; import { trackEvent } from "../../packages/excalidraw/analytics";
import { getFrame } from "../../packages/excalidraw/utils"; import { getFrame } from "../../packages/excalidraw/utils";
import { ExcalidrawLogo } from "../../packages/excalidraw/components/ExcalidrawLogo"; import { ExcalidrawLogo } from "../../packages/excalidraw/components/ExcalidrawLogo";
import { uploadBytes, ref } from "firebase/storage";
export const exportToExcalidrawPlus = async ( export const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
@@ -32,7 +33,7 @@ export const exportToExcalidrawPlus = async (
files: BinaryFiles, files: BinaryFiles,
name: string, name: string,
) => { ) => {
const firebase = await loadFirebaseStorage(); const storage = await loadFirebaseStorage();
const id = `${nanoid(12)}`; const id = `${nanoid(12)}`;
@@ -49,15 +50,13 @@ export const exportToExcalidrawPlus = async (
}, },
); );
await firebase const storageRef = ref(storage, `/migrations/scenes/${id}`);
.storage() await uploadBytes(storageRef, blob, {
.ref(`/migrations/scenes/${id}`) customMetadata: {
.put(blob, { data: JSON.stringify({ version: 2, name }),
customMetadata: { created: Date.now().toString(),
data: JSON.stringify({ version: 2, name }), },
created: Date.now().toString(), });
},
});
const filesMap = new Map<FileId, BinaryFileData>(); const filesMap = new Map<FileId, BinaryFileData>();
for (const element of elements) { for (const element of elements) {
+49 -98
View File
@@ -22,9 +22,17 @@ import {
import { MIME_TYPES } from "../../packages/excalidraw/constants"; import { MIME_TYPES } from "../../packages/excalidraw/constants";
import type { SyncableExcalidrawElement } from "."; import type { SyncableExcalidrawElement } from ".";
import { getSyncableElements } from "."; import { getSyncableElements } from ".";
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile"; import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
import { initializeApp } from "firebase/app";
import {
getFirestore,
doc,
getDoc,
runTransaction,
Bytes,
} from "firebase/firestore";
import { getStorage, ref, uploadBytes } from "firebase/storage";
// private // private
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -41,80 +49,42 @@ try {
FIREBASE_CONFIG = {}; FIREBASE_CONFIG = {};
} }
let firebasePromise: Promise<typeof import("firebase/app").default> | null = let firebaseApp: ReturnType<typeof initializeApp> | null = null;
null; let firestore: ReturnType<typeof getFirestore> | null = null;
let firestorePromise: Promise<any> | null | true = null; let firebaseStorage: ReturnType<typeof getStorage> | null = null;
let firebaseStoragePromise: Promise<any> | null | true = null;
let isFirebaseInitialized = false; const _initializeFirebase = () => {
if (!firebaseApp) {
const _loadFirebase = async () => { firebaseApp = initializeApp(FIREBASE_CONFIG);
const firebase = (
await import(/* webpackChunkName: "firebase" */ "firebase/app")
).default;
if (!isFirebaseInitialized) {
try {
firebase.initializeApp(FIREBASE_CONFIG);
} catch (error: any) {
// trying initialize again throws. Usually this is harmless, and happens
// mainly in dev (HMR)
if (error.code === "app/duplicate-app") {
console.warn(error.name, error.code);
} else {
throw error;
}
}
isFirebaseInitialized = true;
} }
return firebaseApp;
return firebase;
}; };
const _getFirebase = async (): Promise< const _getFirestore = () => {
typeof import("firebase/app").default if (!firestore) {
> => { firestore = getFirestore(_initializeFirebase());
if (!firebasePromise) {
firebasePromise = _loadFirebase();
} }
return firebasePromise; return firestore;
};
const _getStorage = () => {
if (!firebaseStorage) {
firebaseStorage = getStorage(_initializeFirebase());
}
return firebaseStorage;
}; };
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
const loadFirestore = async () => {
const firebase = await _getFirebase();
if (!firestorePromise) {
firestorePromise = import(
/* webpackChunkName: "firestore" */ "firebase/firestore"
);
}
if (firestorePromise !== true) {
await firestorePromise;
firestorePromise = true;
}
return firebase;
};
export const loadFirebaseStorage = async () => { export const loadFirebaseStorage = async () => {
const firebase = await _getFirebase(); return _getStorage();
if (!firebaseStoragePromise) {
firebaseStoragePromise = import(
/* webpackChunkName: "storage" */ "firebase/storage"
);
}
if (firebaseStoragePromise !== true) {
await firebaseStoragePromise;
firebaseStoragePromise = true;
}
return firebase;
}; };
interface FirebaseStoredScene { type FirebaseStoredScene = {
sceneVersion: number; sceneVersion: number;
iv: firebase.default.firestore.Blob; iv: Bytes;
ciphertext: firebase.default.firestore.Blob; ciphertext: Bytes;
} };
const encryptElements = async ( const encryptElements = async (
key: string, key: string,
@@ -175,7 +145,7 @@ export const saveFilesToFirebase = async ({
prefix: string; prefix: string;
files: { id: FileId; buffer: Uint8Array }[]; files: { id: FileId; buffer: Uint8Array }[];
}) => { }) => {
const firebase = await loadFirebaseStorage(); const storage = await loadFirebaseStorage();
const erroredFiles: FileId[] = []; const erroredFiles: FileId[] = [];
const savedFiles: FileId[] = []; const savedFiles: FileId[] = [];
@@ -183,17 +153,10 @@ export const saveFilesToFirebase = async ({
await Promise.all( await Promise.all(
files.map(async ({ id, buffer }) => { files.map(async ({ id, buffer }) => {
try { try {
await firebase const storageRef = ref(storage, `${prefix}/${id}`);
.storage() await uploadBytes(storageRef, buffer, {
.ref(`${prefix}/${id}`) cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
.put( });
new Blob([buffer], {
type: MIME_TYPES.binary,
}),
{
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
},
);
savedFiles.push(id); savedFiles.push(id);
} catch (error: any) { } catch (error: any) {
erroredFiles.push(id); erroredFiles.push(id);
@@ -205,7 +168,6 @@ export const saveFilesToFirebase = async ({
}; };
const createFirebaseSceneDocument = async ( const createFirebaseSceneDocument = async (
firebase: ResolutionType<typeof loadFirestore>,
elements: readonly SyncableExcalidrawElement[], elements: readonly SyncableExcalidrawElement[],
roomKey: string, roomKey: string,
) => { ) => {
@@ -213,10 +175,8 @@ const createFirebaseSceneDocument = async (
const { ciphertext, iv } = await encryptElements(roomKey, elements); const { ciphertext, iv } = await encryptElements(roomKey, elements);
return { return {
sceneVersion, sceneVersion,
ciphertext: firebase.firestore.Blob.fromUint8Array( ciphertext: Bytes.fromUint8Array(new Uint8Array(ciphertext)),
new Uint8Array(ciphertext), iv: Bytes.fromUint8Array(iv),
),
iv: firebase.firestore.Blob.fromUint8Array(iv),
} as FirebaseStoredScene; } as FirebaseStoredScene;
}; };
@@ -236,20 +196,14 @@ export const saveToFirebase = async (
return null; return null;
} }
const firebase = await loadFirestore(); const firestore = _getFirestore();
const firestore = firebase.firestore(); const docRef = doc(firestore, "scenes", roomId);
const docRef = firestore.collection("scenes").doc(roomId); const storedScene = await runTransaction(firestore, async (transaction) => {
const storedScene = await firestore.runTransaction(async (transaction) => {
const snapshot = await transaction.get(docRef); const snapshot = await transaction.get(docRef);
if (!snapshot.exists) { if (!snapshot.exists()) {
const storedScene = await createFirebaseSceneDocument( const storedScene = await createFirebaseSceneDocument(elements, roomKey);
firebase,
elements,
roomKey,
);
transaction.set(docRef, storedScene); transaction.set(docRef, storedScene);
@@ -269,7 +223,6 @@ export const saveToFirebase = async (
); );
const storedScene = await createFirebaseSceneDocument( const storedScene = await createFirebaseSceneDocument(
firebase,
reconciledElements, reconciledElements,
roomKey, roomKey,
); );
@@ -294,15 +247,13 @@ export const loadFromFirebase = async (
roomKey: string, roomKey: string,
socket: Socket | null, socket: Socket | null,
): Promise<readonly SyncableExcalidrawElement[] | null> => { ): Promise<readonly SyncableExcalidrawElement[] | null> => {
const firebase = await loadFirestore(); const firestore = _getFirestore();
const db = firebase.firestore(); const docRef = doc(firestore, "scenes", roomId);
const docSnap = await getDoc(docRef);
const docRef = db.collection("scenes").doc(roomId); if (!docSnap.exists()) {
const doc = await docRef.get();
if (!doc.exists) {
return null; return null;
} }
const storedScene = doc.data() as FirebaseStoredScene; const storedScene = docSnap.data() as FirebaseStoredScene;
const elements = getSyncableElements( const elements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null), restoreElements(await decryptElements(storedScene, roomKey), null),
); );
+4 -4
View File
@@ -27,12 +27,12 @@
}, },
"dependencies": { "dependencies": {
"@excalidraw/random-username": "1.0.0", "@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5", "@sentry/browser": "9.0.1",
"@sentry/integrations": "6.2.5", "callsites": "4.2.0",
"firebase": "8.3.3", "firebase": "11.3.1",
"i18next-browser-languagedetector": "6.1.4", "i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3", "idb-keyval": "6.0.3",
"jotai": "1.13.1", "jotai": "2.11.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"socket.io-client": "4.7.2", "socket.io-client": "4.7.2",
+45 -2
View File
@@ -1,8 +1,9 @@
import * as Sentry from "@sentry/browser"; import * as Sentry from "@sentry/browser";
import * as SentryIntegrations from "@sentry/integrations"; import callsites from "callsites";
const SentryEnvHostnameMap: { [key: string]: string } = { const SentryEnvHostnameMap: { [key: string]: string } = {
"excalidraw.com": "production", "excalidraw.com": "production",
"staging.excalidraw.com": "staging",
"vercel.app": "staging", "vercel.app": "staging",
}; };
@@ -23,9 +24,13 @@ Sentry.init({
release: import.meta.env.VITE_APP_GIT_SHA, release: import.meta.env.VITE_APP_GIT_SHA,
ignoreErrors: [ ignoreErrors: [
"undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything "undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
"InvalidStateError: Failed to execute 'transaction' on 'IDBDatabase': The database connection is closing.", // Not much we can do about the IndexedDB closing error
/(Failed to fetch|(fetch|loading) dynamically imported module)/i, // This is happening when a service worker tries to load an old asset
/QuotaExceededError: (The quota has been exceeded|.*setItem.*Storage)/i, // localStorage quota exceeded
"Internal error opening backing store for indexedDB.open", // Private mode and disabled indexedDB
], ],
integrations: [ integrations: [
new SentryIntegrations.CaptureConsole({ Sentry.captureConsoleIntegration({
levels: ["error"], levels: ["error"],
}), }),
], ],
@@ -33,6 +38,44 @@ Sentry.init({
if (event.request?.url) { if (event.request?.url) {
event.request.url = event.request.url.replace(/#.*$/, ""); event.request.url = event.request.url.replace(/#.*$/, "");
} }
if (!event.exception) {
event.exception = {
values: [
{
type: "ConsoleError",
value: event.message ?? "Unknown error",
stacktrace: {
frames: callsites()
.slice(1)
.filter(
(frame) =>
frame.getFileName() &&
!frame.getFileName()?.includes("@sentry_browser.js"),
)
.map((frame) => ({
filename: frame.getFileName() ?? undefined,
function: frame.getFunctionName() ?? undefined,
in_app: !(
frame.getFileName()?.includes("node_modules") ?? false
),
lineno: frame.getLineNumber() ?? undefined,
colno: frame.getColumnNumber() ?? undefined,
})),
},
mechanism: {
type: "instrument",
handled: true,
data: {
function: "console.error",
handler: "Sentry.beforeSend",
},
},
},
],
};
}
return event; return event;
}, },
}); });
+3 -3
View File
@@ -18,11 +18,11 @@ import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton"; import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
import type { CollabAPI } from "../collab/Collab"; import type { CollabAPI } from "../collab/Collab";
import { activeRoomLinkAtom } from "../collab/Collab"; import { activeRoomLinkAtom } from "../collab/Collab";
import { atom, useAtom, useAtomValue } from "jotai";
import "./ShareDialog.scss";
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState"; import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator"; import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
import { atom, useAtom, useAtomValue } from "../app-jotai";
import "./ShareDialog.scss";
type OnExportToBackend = () => void; type OnExportToBackend = () => void;
type ShareDialogType = "share" | "collaborationOnly"; type ShareDialogType = "share" | "collaborationOnly";
+9 -10
View File
@@ -1,4 +1,3 @@
import { atom, useAtom } from "jotai";
import { useEffect, useLayoutEffect, useState } from "react"; import { useEffect, useLayoutEffect, useState } from "react";
import { THEME } from "../packages/excalidraw"; import { THEME } from "../packages/excalidraw";
import { EVENT } from "../packages/excalidraw/constants"; import { EVENT } from "../packages/excalidraw/constants";
@@ -6,18 +5,18 @@ import type { Theme } from "../packages/excalidraw/element/types";
import { CODES, KEYS } from "../packages/excalidraw/keys"; import { CODES, KEYS } from "../packages/excalidraw/keys";
import { STORAGE_KEYS } from "./app_constants"; import { STORAGE_KEYS } from "./app_constants";
export const appThemeAtom = atom<Theme | "system">(
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
| Theme
| "system"
| null) || THEME.LIGHT,
);
const getDarkThemeMediaQuery = (): MediaQueryList | undefined => const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
window.matchMedia?.("(prefers-color-scheme: dark)"); window.matchMedia?.("(prefers-color-scheme: dark)");
export const useHandleAppTheme = () => { export const useHandleAppTheme = () => {
const [appTheme, setAppTheme] = useAtom(appThemeAtom); const [appTheme, setAppTheme] = useState<Theme | "system">(() => {
return (
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
| Theme
| "system"
| null) || THEME.LIGHT
);
});
const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT); const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT);
useEffect(() => { useEffect(() => {
@@ -66,5 +65,5 @@ export const useHandleAppTheme = () => {
} }
}, [appTheme]); }, [appTheme]);
return { editorTheme }; return { editorTheme, appTheme, setAppTheme };
}; };
+20 -15
View File
@@ -21,10 +21,8 @@ import type { AppClassProperties, AppState, UIAppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
const alignActionsPredicate = ( export const alignActionsPredicate = (
elements: readonly ExcalidrawElement[],
appState: UIAppState, appState: UIAppState,
_: unknown,
app: AppClassProperties, app: AppClassProperties,
) => { ) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
@@ -48,6 +46,7 @@ const alignSelectedElements = (
selectedElements, selectedElements,
elementsMap, elementsMap,
alignment, alignment,
app.scene,
); );
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
@@ -64,7 +63,8 @@ export const actionAlignTop = register({
label: "labels.alignTop", label: "labels.alignTop",
icon: AlignTopIcon, icon: AlignTopIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -79,7 +79,7 @@ export const actionAlignTop = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
icon={AlignTopIcon} icon={AlignTopIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -97,7 +97,8 @@ export const actionAlignBottom = register({
label: "labels.alignBottom", label: "labels.alignBottom",
icon: AlignBottomIcon, icon: AlignBottomIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -112,7 +113,7 @@ export const actionAlignBottom = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
icon={AlignBottomIcon} icon={AlignBottomIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -130,7 +131,8 @@ export const actionAlignLeft = register({
label: "labels.alignLeft", label: "labels.alignLeft",
icon: AlignLeftIcon, icon: AlignLeftIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -145,7 +147,7 @@ export const actionAlignLeft = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
icon={AlignLeftIcon} icon={AlignLeftIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -163,7 +165,8 @@ export const actionAlignRight = register({
label: "labels.alignRight", label: "labels.alignRight",
icon: AlignRightIcon, icon: AlignRightIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -178,7 +181,7 @@ export const actionAlignRight = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
icon={AlignRightIcon} icon={AlignRightIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -196,7 +199,8 @@ export const actionAlignVerticallyCentered = register({
label: "labels.centerVertically", label: "labels.centerVertically",
icon: CenterVerticallyIcon, icon: CenterVerticallyIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -209,7 +213,7 @@ export const actionAlignVerticallyCentered = register({
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
icon={CenterVerticallyIcon} icon={CenterVerticallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -225,7 +229,8 @@ export const actionAlignHorizontallyCentered = register({
label: "labels.centerHorizontally", label: "labels.centerHorizontally",
icon: CenterHorizontallyIcon, icon: CenterHorizontallyIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -238,7 +243,7 @@ export const actionAlignHorizontallyCentered = register({
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
icon={CenterHorizontallyIcon} icon={CenterHorizontallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -10,7 +10,6 @@ import {
computeBoundTextPosition, computeBoundTextPosition,
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
getBoundTextElement, getBoundTextElement,
measureText,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "../element/textElement"; } from "../element/textElement";
import { import {
@@ -35,6 +34,7 @@ import { arrayToMap, getFontString } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { syncMovedIndices } from "../fractionalIndex"; import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { measureText } from "../element/textMeasurements";
export const actionUnbindText = register({ export const actionUnbindText = register({
name: "unbindText", name: "unbindText",
@@ -0,0 +1,211 @@
import React from "react";
import { Excalidraw, mutateElement } from "../index";
import { act, assertElements, render } from "../tests/test-utils";
import { API } from "../tests/helpers/api";
import { actionDeleteSelected } from "./actionDeleteSelected";
const { h } = window;
describe("deleting selected elements when frame selected should keep children + select them", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("frame only", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
API.setElements([f1, r1]);
API.setSelectedElements([f1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
]);
});
it("frame + text container (text's frameId set)", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: r1.id,
frameId: f1.id,
});
mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, r1, t1]);
API.setSelectedElements([f1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + text container (text's frameId not set)", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: r1.id,
frameId: null,
});
mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, r1, t1]);
API.setSelectedElements([f1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + text container (text selected too)", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: r1.id,
frameId: null,
});
mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, r1, t1]);
API.setSelectedElements([f1, t1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + labeled arrow", async () => {
const f1 = API.createElement({
type: "frame",
});
const a1 = API.createElement({
type: "arrow",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: a1.id,
frameId: null,
});
mutateElement(a1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, a1, t1]);
API.setSelectedElements([f1, t1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: a1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + children selected", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
API.setElements([f1, r1]);
API.setSelectedElements([f1, r1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
]);
});
});
@@ -7,7 +7,7 @@ import { getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups"; import { getElementsInGroup, selectGroupsForSelectedElements } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding"; import { fixBindingsAfterDeletion } from "../element/binding";
import { import {
@@ -18,14 +18,14 @@ import {
import { updateActiveTool } from "../utils"; import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons"; import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { mutateElbowArrow } from "../element/routing"; import { getContainerElement } from "../element/textElement";
import { getFrameChildren } from "../frame";
const deleteSelectedElements = ( const deleteSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
app: AppClassProperties, app: AppClassProperties,
) => { ) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
const framesToBeDeleted = new Set( const framesToBeDeleted = new Set(
getSelectedElements( getSelectedElements(
elements.filter((el) => isFrameLikeElement(el)), elements.filter((el) => isFrameLikeElement(el)),
@@ -33,48 +33,141 @@ const deleteSelectedElements = (
).map((el) => el.id), ).map((el) => el.id),
); );
return { const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
elements: elements.map((el) => {
if (appState.selectedElementIds[el.id]) { const elementsMap = app.scene.getNonDeletedElementsMap();
if (el.boundElements) {
el.boundElements.forEach((candidate) => { const processedElements = new Set<ExcalidrawElement["id"]>();
const bound = app.scene
.getNonDeletedElementsMap() for (const frameId of framesToBeDeleted) {
.get(candidate.id); const frameChildren = getFrameChildren(elements, frameId);
if (bound && isElbowArrow(bound)) { for (const el of frameChildren) {
mutateElement(bound, { if (processedElements.has(el.id)) {
startBinding: continue;
el.id === bound.startBinding?.elementId
? null
: bound.startBinding,
endBinding:
el.id === bound.endBinding?.elementId
? null
: bound.endBinding,
});
mutateElbowArrow(bound, elementsMap, bound.points);
}
});
}
return newElementWith(el, { isDeleted: true });
} }
if (isBoundToContainer(el)) {
const containerElement = getContainerElement(el, elementsMap);
if (containerElement) {
selectedElementIds[containerElement.id] = true;
}
} else {
selectedElementIds[el.id] = true;
}
processedElements.add(el.id);
}
}
let shouldSelectEditingGroup = true;
const nextElements = elements.map((el) => {
if (appState.selectedElementIds[el.id]) {
const boundElement = isBoundToContainer(el)
? getContainerElement(el, elementsMap)
: null;
if (el.frameId && framesToBeDeleted.has(el.frameId)) { if (el.frameId && framesToBeDeleted.has(el.frameId)) {
return newElementWith(el, { isDeleted: true }); shouldSelectEditingGroup = false;
selectedElementIds[el.id] = true;
return el;
} }
if ( if (
isBoundToContainer(el) && boundElement?.frameId &&
appState.selectedElementIds[el.containerId] framesToBeDeleted.has(boundElement?.frameId)
) { ) {
return newElementWith(el, { isDeleted: true }); return el;
} }
return el;
}), if (el.boundElements) {
el.boundElements.forEach((candidate) => {
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
if (bound && isElbowArrow(bound)) {
mutateElement(bound, {
startBinding:
el.id === bound.startBinding?.elementId
? null
: bound.startBinding,
endBinding:
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
});
mutateElement(bound, { points: bound.points });
}
});
}
return newElementWith(el, { isDeleted: true });
}
// if deleting a frame, remove the children from it and select them
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
shouldSelectEditingGroup = false;
if (!isBoundToContainer(el)) {
selectedElementIds[el.id] = true;
}
return newElementWith(el, { frameId: null });
}
if (isBoundToContainer(el) && appState.selectedElementIds[el.containerId]) {
return newElementWith(el, { isDeleted: true });
}
return el;
});
let nextEditingGroupId = appState.editingGroupId;
// select next eligible element in currently editing group or supergroup
if (shouldSelectEditingGroup && appState.editingGroupId) {
const elems = getElementsInGroup(
nextElements,
appState.editingGroupId,
).filter((el) => !el.isDeleted);
if (elems.length > 1) {
if (elems[0]) {
selectedElementIds[elems[0].id] = true;
}
} else {
nextEditingGroupId = null;
if (elems[0]) {
selectedElementIds[elems[0].id] = true;
}
const lastElementInGroup = elems[0];
if (lastElementInGroup) {
const editingGroupIdx = lastElementInGroup.groupIds.findIndex(
(groupId) => {
return groupId === appState.editingGroupId;
},
);
const superGroupId = lastElementInGroup.groupIds[editingGroupIdx + 1];
if (superGroupId) {
const elems = getElementsInGroup(nextElements, superGroupId).filter(
(el) => !el.isDeleted,
);
if (elems.length > 1) {
nextEditingGroupId = superGroupId;
elems.forEach((el) => {
selectedElementIds[el.id] = true;
});
}
}
}
}
}
return {
elements: nextElements,
appState: { appState: {
...appState, ...appState,
selectedElementIds: {}, ...selectGroupsForSelectedElements(
selectedGroupIds: {}, {
selectedElementIds,
editingGroupId: nextEditingGroupId,
},
nextElements,
appState,
null,
),
}, },
}; };
}; };
@@ -157,12 +250,7 @@ export const actionDeleteSelected = register({
: endBindingElement, : endBindingElement,
}; };
LinearElementEditor.deletePoints( LinearElementEditor.deletePoints(element, selectedPointsIndices);
element,
selectedPointsIndices,
elementsMap,
appState.zoom,
);
return { return {
elements, elements,
@@ -180,11 +268,13 @@ export const actionDeleteSelected = register({
storeAction: StoreAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
} }
let { elements: nextElements, appState: nextAppState } = let { elements: nextElements, appState: nextAppState } =
deleteSelectedElements(elements, appState, app); deleteSelectedElements(elements, appState, app);
fixBindingsAfterDeletion( fixBindingsAfterDeletion(
nextElements, nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]), nextElements.filter((el) => el.isDeleted),
); );
nextAppState = handleGroupEditingState(nextAppState, nextElements); nextAppState = handleGroupEditingState(nextAppState, nextElements);
@@ -0,0 +1,530 @@
import { Excalidraw } from "../index";
import {
act,
assertElements,
getCloneByOrigId,
render,
} from "../tests/test-utils";
import { API } from "../tests/helpers/api";
import { actionDuplicateSelection } from "./actionDuplicateSelection";
import React from "react";
import { ORIG_ID } from "../constants";
const { h } = window;
describe("actionDuplicateSelection", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
describe("duplicating frames", () => {
it("frame selected only", async () => {
const frame = API.createElement({
type: "frame",
});
const rectangle = API.createElement({
type: "rectangle",
frameId: frame.id,
});
API.setElements([frame, rectangle]);
API.setSelectedElements([frame]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
{ [ORIG_ID]: frame.id, selected: true },
]);
});
it("frame selected only (with text container)", async () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([frame]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id)?.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{ [ORIG_ID]: frame.id, selected: true },
]);
});
it("frame + text container selected (order A)", async () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([frame, rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{
[ORIG_ID]: rectangle.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id)?.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{
[ORIG_ID]: frame.id,
selected: true,
},
]);
});
it("frame + text container selected (order B)", async () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([text, rectangle, frame]);
API.setSelectedElements([rectangle, frame]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ id: frame.id },
{
type: "rectangle",
[ORIG_ID]: `${rectangle.id}`,
},
{
[ORIG_ID]: `${text.id}`,
type: "text",
containerId: getCloneByOrigId(rectangle.id)?.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{ [ORIG_ID]: `${frame.id}`, type: "frame", selected: true },
]);
});
});
describe("duplicating frame children", () => {
it("frame child selected", () => {
const frame = API.createElement({
type: "frame",
});
const rectangle = API.createElement({
type: "rectangle",
frameId: frame.id,
});
API.setElements([frame, rectangle]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
]);
});
it("frame text container selected (rectangle selected)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id).id,
frameId: frame.id,
},
]);
});
it("frame bound text selected (container not selected)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([text]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id).id,
frameId: frame.id,
},
]);
});
it("frame text container selected (text not exists)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
]);
});
// shouldn't happen
it("frame bound text selected (container not exists)", () => {
const frame = API.createElement({
type: "frame",
});
const [, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, text]);
API.setSelectedElements([text]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: text.id, frameId: frame.id },
{ [ORIG_ID]: text.id, frameId: frame.id },
]);
});
it("frame bound container selected (text has no frameId)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({
frameId: frame.id,
label: { frameId: null },
});
API.setElements([frame, rectangle, text]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id).id,
},
]);
});
});
describe("duplicating multiple frames", () => {
it("multiple frames selected (no children)", () => {
const frame1 = API.createElement({
type: "frame",
});
const rect1 = API.createElement({
type: "rectangle",
frameId: frame1.id,
});
const frame2 = API.createElement({
type: "frame",
});
const rect2 = API.createElement({
type: "rectangle",
frameId: frame2.id,
});
const ellipse = API.createElement({
type: "ellipse",
});
API.setElements([rect1, frame1, ellipse, rect2, frame2]);
API.setSelectedElements([frame1, frame2]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rect1.id, frameId: frame1.id },
{ id: frame1.id },
{ [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
{ [ORIG_ID]: frame1.id, selected: true },
{ id: ellipse.id },
{ id: rect2.id, frameId: frame2.id },
{ id: frame2.id },
{ [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
{ [ORIG_ID]: frame2.id, selected: true },
]);
});
it("multiple frames selected (no children) + unrelated element", () => {
const frame1 = API.createElement({
type: "frame",
});
const rect1 = API.createElement({
type: "rectangle",
frameId: frame1.id,
});
const frame2 = API.createElement({
type: "frame",
});
const rect2 = API.createElement({
type: "rectangle",
frameId: frame2.id,
});
const ellipse = API.createElement({
type: "ellipse",
});
API.setElements([rect1, frame1, ellipse, rect2, frame2]);
API.setSelectedElements([frame1, ellipse, frame2]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rect1.id, frameId: frame1.id },
{ id: frame1.id },
{ [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
{ [ORIG_ID]: frame1.id, selected: true },
{ id: ellipse.id },
{ [ORIG_ID]: ellipse.id, selected: true },
{ id: rect2.id, frameId: frame2.id },
{ id: frame2.id },
{ [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
{ [ORIG_ID]: frame2.id, selected: true },
]);
});
});
describe("duplicating containers/bound elements", () => {
it("labeled arrow (arrow selected)", () => {
const [arrow, text] = API.createLabeledArrow();
API.setElements([arrow, text]);
API.setSelectedElements([arrow]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: arrow.id },
{ id: text.id, containerId: arrow.id },
{ [ORIG_ID]: arrow.id, selected: true },
{ [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
]);
});
// shouldn't happen
it("labeled arrow (text selected)", () => {
const [arrow, text] = API.createLabeledArrow();
API.setElements([arrow, text]);
API.setSelectedElements([text]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: arrow.id },
{ id: text.id, containerId: arrow.id },
{ [ORIG_ID]: arrow.id, selected: true },
{ [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
]);
});
});
describe("duplicating groups", () => {
it("duplicate group containing frame (children don't have groupIds set)", () => {
const frame = API.createElement({
type: "frame",
groupIds: ["A"],
});
const [rectangle, text] = API.createTextContainer({
frameId: frame.id,
});
const ellipse = API.createElement({
type: "ellipse",
groupIds: ["A"],
});
API.setElements([rectangle, text, frame, ellipse]);
API.setSelectedElements([frame, ellipse]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, frameId: frame.id },
{ id: frame.id },
{ id: ellipse.id },
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
{ [ORIG_ID]: text.id, frameId: getCloneByOrigId(frame.id)?.id },
{ [ORIG_ID]: frame.id, selected: true },
{ [ORIG_ID]: ellipse.id, selected: true },
]);
});
it("duplicate group containing frame (children have groupIds)", () => {
const frame = API.createElement({
type: "frame",
groupIds: ["A"],
});
const [rectangle, text] = API.createTextContainer({
frameId: frame.id,
groupIds: ["A"],
});
const ellipse = API.createElement({
type: "ellipse",
groupIds: ["A"],
});
API.setElements([rectangle, text, frame, ellipse]);
API.setSelectedElements([frame, ellipse]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, frameId: frame.id },
{ id: frame.id },
{ id: ellipse.id },
{
[ORIG_ID]: rectangle.id,
frameId: getCloneByOrigId(frame.id)?.id,
// FIXME shouldn't be selected (in selectGroupsForSelectedElements)
selected: true,
},
{
[ORIG_ID]: text.id,
frameId: getCloneByOrigId(frame.id)?.id,
// FIXME shouldn't be selected (in selectGroupsForSelectedElements)
selected: true,
},
{ [ORIG_ID]: frame.id, selected: true },
{ [ORIG_ID]: ellipse.id, selected: true },
]);
});
it("duplicating element nested in group", () => {
const ellipse = API.createElement({
type: "ellipse",
groupIds: ["B"],
});
const rect1 = API.createElement({
type: "rectangle",
groupIds: ["A", "B"],
});
const rect2 = API.createElement({
type: "rectangle",
groupIds: ["A", "B"],
});
API.setElements([ellipse, rect1, rect2]);
API.setSelectedElements([ellipse], "B");
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: ellipse.id },
{ [ORIG_ID]: ellipse.id, groupIds: ["B"], selected: true },
{ id: rect1.id, groupIds: ["A", "B"] },
{ id: rect2.id, groupIds: ["A", "B"] },
]);
});
});
});
@@ -5,7 +5,13 @@ import { duplicateElement, getNonDeletedElements } from "../element";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils"; import {
arrayToMap,
castArray,
findLastIndex,
getShortcutKey,
invariant,
} from "../utils";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { import {
selectGroupsForSelectedElements, selectGroupsForSelectedElements,
@@ -19,8 +25,13 @@ import { DEFAULT_GRID_SIZE } from "../constants";
import { import {
bindTextToShapeAfterDuplication, bindTextToShapeAfterDuplication,
getBoundTextElement, getBoundTextElement,
getContainerElement,
} from "../element/textElement"; } from "../element/textElement";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks"; import {
hasBoundTextElement,
isBoundToContainer,
isFrameLikeElement,
} from "../element/typeChecks";
import { normalizeElementOrder } from "../element/sortElements"; import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons"; import { DuplicateIcon } from "../components/icons";
import { import {
@@ -31,7 +42,6 @@ import {
excludeElementsInFramesFromSelection, excludeElementsInFramesFromSelection,
getSelectedElements, getSelectedElements,
} from "../scene/selection"; } from "../scene/selection";
import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
@@ -59,8 +69,20 @@ export const actionDuplicateSelection = register({
} }
} }
const nextState = duplicateElements(elements, appState);
if (app.props.onDuplicate && nextState.elements) {
const mappedElements = app.props.onDuplicate(
nextState.elements,
elements,
);
if (mappedElements) {
nextState.elements = mappedElements;
}
}
return { return {
...duplicateElements(elements, appState), ...nextState,
storeAction: StoreAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
}, },
@@ -82,37 +104,69 @@ export const actionDuplicateSelection = register({
const duplicateElements = ( const duplicateElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
): Partial<ActionResult> => { ): Partial<Exclude<ActionResult, false>> => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// step (1)
const sortedElements = normalizeElementOrder(elements);
const groupIdMap = new Map(); const groupIdMap = new Map();
const newElements: ExcalidrawElement[] = []; const newElements: ExcalidrawElement[] = [];
const oldElements: ExcalidrawElement[] = []; const oldElements: ExcalidrawElement[] = [];
const oldIdToDuplicatedId = new Map(); const oldIdToDuplicatedId = new Map();
const duplicatedElementsMap = new Map<string, ExcalidrawElement>(); const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
const duplicateAndOffsetElement = (element: ExcalidrawElement) => { const elementsMap = arrayToMap(elements);
const newElement = duplicateElement(
appState.editingGroupId, const duplicateAndOffsetElement = <
groupIdMap, T extends ExcalidrawElement | ExcalidrawElement[],
element, >(
{ element: T,
x: element.x + DEFAULT_GRID_SIZE / 2, ): T extends ExcalidrawElement[]
y: element.y + DEFAULT_GRID_SIZE / 2, ? ExcalidrawElement[]
: ExcalidrawElement | null => {
const elements = castArray(element);
const _newElements = elements.reduce(
(acc: ExcalidrawElement[], element) => {
if (processedIds.has(element.id)) {
return acc;
}
processedIds.set(element.id, true);
const newElement = duplicateElement(
appState.editingGroupId,
groupIdMap,
element,
{
x: element.x + DEFAULT_GRID_SIZE / 2,
y: element.y + DEFAULT_GRID_SIZE / 2,
},
);
processedIds.set(newElement.id, true);
duplicatedElementsMap.set(newElement.id, newElement);
oldIdToDuplicatedId.set(element.id, newElement.id);
oldElements.push(element);
newElements.push(newElement);
acc.push(newElement);
return acc;
}, },
[],
); );
duplicatedElementsMap.set(newElement.id, newElement);
oldIdToDuplicatedId.set(element.id, newElement.id); return (
oldElements.push(element); Array.isArray(element) ? _newElements : _newElements[0] || null
newElements.push(newElement); ) as T extends ExcalidrawElement[]
return newElement; ? ExcalidrawElement[]
: ExcalidrawElement | null;
}; };
elements = normalizeElementOrder(elements);
const idsOfElementsToDuplicate = arrayToMap( const idsOfElementsToDuplicate = arrayToMap(
getSelectedElements(sortedElements, appState, { getSelectedElements(elements, appState, {
includeBoundTextElement: true, includeBoundTextElement: true,
includeElementsInFrames: true, includeElementsInFrames: true,
}), }),
@@ -130,122 +184,133 @@ const duplicateElements = (
// loop over them. // loop over them.
const processedIds = new Map<ExcalidrawElement["id"], true>(); const processedIds = new Map<ExcalidrawElement["id"], true>();
const markAsProcessed = (elements: ExcalidrawElement[]) => { const elementsWithClones: ExcalidrawElement[] = elements.slice();
for (const element of elements) {
processedIds.set(element.id, true); const insertAfterIndex = (
index: number,
elements: ExcalidrawElement | null | ExcalidrawElement[],
) => {
invariant(index !== -1, "targetIndex === -1 ");
if (!Array.isArray(elements) && !elements) {
return;
} }
return elements;
elementsWithClones.splice(index + 1, 0, ...castArray(elements));
}; };
const elementsWithClones: ExcalidrawElement[] = []; const frameIdsToDuplicate = new Set(
elements
.filter(
(el) => idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el),
)
.map((el) => el.id),
);
let index = -1; for (const element of elements) {
if (processedIds.has(element.id)) {
while (++index < sortedElements.length) {
const element = sortedElements[index];
if (processedIds.get(element.id)) {
continue; continue;
} }
const boundTextElement = getBoundTextElement(element, arrayToMap(elements)); if (!idsOfElementsToDuplicate.has(element.id)) {
const isElementAFrameLike = isFrameLikeElement(element); continue;
}
if (idsOfElementsToDuplicate.get(element.id)) { // groups
// if a group or a container/bound-text or frame, duplicate atomically // -------------------------------------------------------------------------
if (element.groupIds.length || boundTextElement || isElementAFrameLike) {
const groupId = getSelectedGroupForElement(appState, element);
if (groupId) {
// TODO:
// remove `.flatMap...`
// if the elements in a frame are grouped when the frame is grouped
const groupElements = getElementsInGroup(
sortedElements,
groupId,
).flatMap((element) =>
isFrameLikeElement(element)
? [...getFrameChildren(elements, element.id), element]
: [element],
);
elementsWithClones.push( const groupId = getSelectedGroupForElement(appState, element);
...markAsProcessed([ if (groupId) {
...groupElements, const groupElements = getElementsInGroup(elements, groupId).flatMap(
...groupElements.map((element) => (element) =>
duplicateAndOffsetElement(element), isFrameLikeElement(element)
), ? [...getFrameChildren(elements, element.id), element]
]), : [element],
); );
continue;
}
if (boundTextElement) {
elementsWithClones.push(
...markAsProcessed([
element,
boundTextElement,
duplicateAndOffsetElement(element),
duplicateAndOffsetElement(boundTextElement),
]),
);
continue;
}
if (isElementAFrameLike) {
const elementsInFrame = getFrameChildren(sortedElements, element.id);
elementsWithClones.push( const targetIndex = findLastIndex(elementsWithClones, (el) => {
...markAsProcessed([ return el.groupIds?.includes(groupId);
...elementsInFrame, });
element,
...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
duplicateAndOffsetElement(element),
]),
);
continue; insertAfterIndex(targetIndex, duplicateAndOffsetElement(groupElements));
} continue;
} }
// since elements in frames have a lower z-index than the frame itself,
// they will be looped first and if their frames are selected as well, // frame duplication
// they will have been copied along with the frame atomically in the // -------------------------------------------------------------------------
// above branch, so we must skip those elements here
// if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
// now, for elements do not belong any frames or elements whose frames continue;
// are selected (or elements that are left out from the above }
// steps for whatever reason) we (should at least) duplicate them here
if (!element.frameId || !idsOfElementsToDuplicate.has(element.frameId)) { if (isFrameLikeElement(element)) {
elementsWithClones.push( const frameId = element.id;
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithClones, (el) => {
return el.frameId === frameId || el.id === frameId;
});
insertAfterIndex(
targetIndex,
duplicateAndOffsetElement([...frameChildren, element]),
);
continue;
}
// text container
// -------------------------------------------------------------------------
if (hasBoundTextElement(element)) {
const boundTextElement = getBoundTextElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithClones, (el) => {
return (
el.id === element.id ||
("containerId" in el && el.containerId === element.id)
); );
});
if (boundTextElement) {
insertAfterIndex(
targetIndex,
duplicateAndOffsetElement([element, boundTextElement]),
);
} else {
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
} }
} else {
elementsWithClones.push(...markAsProcessed([element])); continue;
} }
}
// step (2) if (isBoundToContainer(element)) {
const container = getContainerElement(element, elementsMap);
// second pass to remove duplicates. We loop from the end as it's likelier const targetIndex = findLastIndex(elementsWithClones, (el) => {
// that the last elements are in the correct order (contiguous or otherwise). return el.id === element.id || el.id === container?.id;
// Thus we need to reverse as the last step (3). });
const finalElementsReversed: ExcalidrawElement[] = []; if (container) {
insertAfterIndex(
targetIndex,
duplicateAndOffsetElement([container, element]),
);
} else {
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
}
const finalElementIds = new Map<ExcalidrawElement["id"], true>(); continue;
index = elementsWithClones.length;
while (--index >= 0) {
const element = elementsWithClones[index];
if (!finalElementIds.get(element.id)) {
finalElementIds.set(element.id, true);
finalElementsReversed.push(element);
} }
}
// step (3) // default duplication (regular elements)
const finalElements = syncMovedIndices( // -------------------------------------------------------------------------
finalElementsReversed.reverse(),
arrayToMap(newElements), insertAfterIndex(
); findLastIndex(elementsWithClones, (el) => el.id === element.id),
duplicateAndOffsetElement(element),
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -260,7 +325,7 @@ const duplicateElements = (
oldIdToDuplicatedId, oldIdToDuplicatedId,
); );
bindElementsToFramesAfterDuplication( bindElementsToFramesAfterDuplication(
finalElements, elementsWithClones,
oldElements, oldElements,
oldIdToDuplicatedId, oldIdToDuplicatedId,
); );
@@ -269,7 +334,7 @@ const duplicateElements = (
excludeElementsInFramesFromSelection(newElements); excludeElementsInFramesFromSelection(newElements);
return { return {
elements: finalElements, elements: elementsWithClones,
appState: { appState: {
...appState, ...appState,
...selectGroupsForSelectedElements( ...selectGroupsForSelectedElements(
@@ -285,7 +350,7 @@ const duplicateElements = (
{}, {},
), ),
}, },
getNonDeletedElements(finalElements), getNonDeletedElements(elementsWithClones),
appState, appState,
null, null,
), ),
@@ -49,12 +49,13 @@ describe("flipping re-centers selection", () => {
}, },
startArrowhead: null, startArrowhead: null,
endArrowhead: "arrow", endArrowhead: "arrow",
fixedSegments: null,
points: [ points: [
pointFrom(0, 0), pointFrom(0, 0),
pointFrom(0, -35), pointFrom(0, -35),
pointFrom(-90.9, -35), pointFrom(-90, -35),
pointFrom(-90.9, 204.9), pointFrom(-90, 204),
pointFrom(65.1, 204.9), pointFrom(66, 204),
], ],
elbowed: true, elbowed: true,
}), }),
@@ -70,13 +71,13 @@ describe("flipping re-centers selection", () => {
API.executeAction(actionFlipHorizontal); API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal); API.executeAction(actionFlipHorizontal);
const rec1 = h.elements.find((el) => el.id === "rec1"); const rec1 = h.elements.find((el) => el.id === "rec1")!;
expect(rec1?.x).toBeCloseTo(100); expect(rec1.x).toBeCloseTo(100, 0);
expect(rec1?.y).toBeCloseTo(100); expect(rec1.y).toBeCloseTo(100, 0);
const rec2 = h.elements.find((el) => el.id === "rec2"); const rec2 = h.elements.find((el) => el.id === "rec2")!;
expect(rec2?.x).toBeCloseTo(220); expect(rec2.x).toBeCloseTo(220, 0);
expect(rec2?.y).toBeCloseTo(250); expect(rec2.y).toBeCloseTo(250, 0);
}); });
}); });
+23 -17
View File
@@ -24,8 +24,8 @@ import {
isElbowArrow, isElbowArrow,
isLinearElement, isLinearElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { mutateElbowArrow } from "../element/routing";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { deepCopyElement } from "../element/newElement";
import { getCommonBoundingBox } from "../element/bounds"; import { getCommonBoundingBox } from "../element/bounds";
export const actionFlipHorizontal = register({ export const actionFlipHorizontal = register({
@@ -134,12 +134,24 @@ const flipElements = (
const { midX, midY } = getCommonBoundingBox(selectedElements); const { midX, midY } = getCommonBoundingBox(selectedElements);
resizeMultipleElements(selectedElements, elementsMap, "nw", app.scene, { resizeMultipleElements(
flipByX: flipDirection === "horizontal", selectedElements,
flipByY: flipDirection === "vertical", elementsMap,
shouldResizeFromCenter: true, "nw",
shouldMaintainAspectRatio: true, app.scene,
}); new Map(
Array.from(elementsMap.values()).map((element) => [
element.id,
deepCopyElement(element),
]),
),
{
flipByX: flipDirection === "horizontal",
flipByY: flipDirection === "vertical",
shouldResizeFromCenter: true,
shouldMaintainAspectRatio: true,
},
);
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement), selectedElements.filter(isLinearElement),
@@ -181,16 +193,10 @@ const flipElements = (
}), }),
); );
elbowArrows.forEach((element) => elbowArrows.forEach((element) =>
mutateElbowArrow( mutateElement(element, {
element, x: element.x + diffX,
elementsMap, y: element.y + diffY,
element.points, }),
undefined,
undefined,
{
informMutation: false,
},
),
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+70 -2
View File
@@ -1,6 +1,6 @@
import { getNonDeletedElements } from "../element"; import { getCommonBounds, getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame"; import { addElementsToFrame, removeAllElementsFromFrame } from "../frame";
import { getFrameChildren } from "../frame"; import { getFrameChildren } from "../frame";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import type { AppClassProperties, AppState, UIAppState } from "../types"; import type { AppClassProperties, AppState, UIAppState } from "../types";
@@ -10,6 +10,10 @@ import { register } from "./register";
import { isFrameLikeElement } from "../element/typeChecks"; import { isFrameLikeElement } from "../element/typeChecks";
import { frameToolIcon } from "../components/icons"; import { frameToolIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { getSelectedElements } from "../scene";
import { newFrameElement } from "../element/newElement";
import { getElementsInGroup } from "../groups";
import { mutateElement } from "../element/mutateElement";
const isSingleFrameSelected = ( const isSingleFrameSelected = (
appState: UIAppState, appState: UIAppState,
@@ -144,3 +148,67 @@ export const actionSetFrameAsActiveTool = register({
!event.altKey && !event.altKey &&
event.key.toLocaleLowerCase() === KEYS.F, event.key.toLocaleLowerCase() === KEYS.F,
}); });
export const actionWrapSelectionInFrame = register({
name: "wrapSelectionInFrame",
label: "labels.wrapSelectionInFrame",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length > 0 &&
!selectedElements.some((element) => isFrameLikeElement(element))
);
},
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
const [x1, y1, x2, y2] = getCommonBounds(
selectedElements,
app.scene.getNonDeletedElementsMap(),
);
const PADDING = 16;
const frame = newFrameElement({
x: x1 - PADDING,
y: y1 - PADDING,
width: x2 - x1 + PADDING * 2,
height: y2 - y1 + PADDING * 2,
});
// for a selected partial group, we want to remove it from the remainder of the group
if (appState.editingGroupId) {
const elementsInGroup = getElementsInGroup(
selectedElements,
appState.editingGroupId,
);
for (const elementInGroup of elementsInGroup) {
const index = elementInGroup.groupIds.indexOf(appState.editingGroupId);
mutateElement(
elementInGroup,
{
groupIds: elementInGroup.groupIds.slice(0, index),
},
false,
);
}
}
const nextElements = addElementsToFrame(
[...app.scene.getElementsIncludingDeleted(), frame],
selectedElements,
frame,
appState,
);
return {
elements: nextElements,
appState: {
selectedElementIds: { [frame.id]: true },
},
storeAction: StoreAction.CAPTURE,
};
},
});
+12 -5
View File
@@ -25,8 +25,10 @@ import type {
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import { import {
frameAndChildrenSelectedTogether,
getElementsInResizingFrame, getElementsInResizingFrame,
getFrameLikeElements, getFrameLikeElements,
getRootElements,
groupByFrameLikes, groupByFrameLikes,
removeElementsFromFrame, removeElementsFromFrame,
replaceAllElementsInFrame, replaceAllElementsInFrame,
@@ -60,8 +62,11 @@ const enableActionGroup = (
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true, includeBoundTextElement: true,
}); });
return ( return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements) selectedElements.length >= 2 &&
!allElementsInSameGroup(selectedElements) &&
!frameAndChildrenSelectedTogether(selectedElements)
); );
}; };
@@ -71,10 +76,12 @@ export const actionGroup = register({
icon: (appState) => <GroupIcon theme={appState.theme} />, icon: (appState) => <GroupIcon theme={appState.theme} />,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({ const selectedElements = getRootElements(
selectedElementIds: appState.selectedElementIds, app.scene.getSelectedElements({
includeBoundTextElement: true, selectedElementIds: appState.selectedElementIds,
}); includeBoundTextElement: true,
}),
);
if (selectedElements.length < 2) { if (selectedElements.length < 2) {
// nothing to group // nothing to group
return { appState, elements, storeAction: StoreAction.NONE }; return { appState, elements, storeAction: StoreAction.NONE };
+204 -187
View File
@@ -89,6 +89,7 @@ import type {
FontFamilyValues, FontFamilyValues,
TextAlign, TextAlign,
VerticalAlign, VerticalAlign,
NonDeletedSceneElementsMap,
} from "../element/types"; } from "../element/types";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
@@ -115,11 +116,12 @@ import {
bindPointToSnapToElementOutline, bindPointToSnapToElementOutline,
calculateFixedPointForElbowArrowBinding, calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding, getHoveredElementForBinding,
updateBoundElements,
} from "../element/binding"; } from "../element/binding";
import { mutateElbowArrow } from "../element/routing";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import type { LocalPoint } from "../../math"; import type { LocalPoint } from "../../math";
import { pointFrom, vector } from "../../math"; import { pointFrom } from "../../math";
import { Range } from "../components/Range";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -219,33 +221,47 @@ const changeFontSize = (
) => { ) => {
const newFontSizes = new Set<number>(); const newFontSizes = new Set<number>();
const updatedElements = changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newFontSize = getNewFontSize(oldElement);
newFontSizes.add(newFontSize);
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize,
});
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
newElement = offsetElementAfterFontResize(oldElement, newElement);
return newElement;
}
return oldElement;
},
true,
);
// Update arrow elements after text elements have been updated
const updatedElementsMap = arrayToMap(updatedElements);
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
}).forEach((element) => {
if (isTextElement(element)) {
updateBoundElements(
element,
updatedElementsMap as NonDeletedSceneElementsMap,
);
}
});
return { return {
elements: changeProperty( elements: updatedElements,
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newFontSize = getNewFontSize(oldElement);
newFontSizes.add(newFontSize);
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize,
});
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
newElement = offsetElementAfterFontResize(oldElement, newElement);
return newElement;
}
return oldElement;
},
true,
),
appState: { appState: {
...appState, ...appState,
// update state only if we've set all select text elements to // update state only if we've set all select text elements to
@@ -615,25 +631,12 @@ export const actionChangeOpacity = register({
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<label className="control-label"> <Range
{t("labels.opacity")} updateData={updateData}
<input elements={elements}
type="range" appState={appState}
min="0" testId="opacity"
max="100" />
step="10"
onChange={(event) => updateData(+event.target.value)}
value={
getFormValue(
elements,
appState,
(element) => element.opacity,
true,
appState.currentItemOpacity,
) ?? undefined
}
/>
</label>
), ),
}); });
@@ -1560,152 +1563,166 @@ export const actionChangeArrowType = register({
label: "Change arrow types", label: "Change arrow types",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value, app) => { perform: (elements, appState, value, app) => {
return { const newElements = changeProperty(elements, appState, (el) => {
elements: changeProperty(elements, appState, (el) => { if (!isArrowElement(el)) {
if (!isArrowElement(el)) { return el;
return el; }
} const newElement = newElementWith(el, {
const newElement = newElementWith(el, { roundness:
roundness: value === ARROW_TYPE.round
value === ARROW_TYPE.round ? {
? { type: ROUNDNESS.PROPORTIONAL_RADIUS,
type: ROUNDNESS.PROPORTIONAL_RADIUS, }
} : null,
: null, elbowed: value === ARROW_TYPE.elbow,
elbowed: value === ARROW_TYPE.elbow, points:
points: value === ARROW_TYPE.elbow || el.elbowed
value === ARROW_TYPE.elbow || el.elbowed ? [el.points[0], el.points[el.points.length - 1]]
? [el.points[0], el.points[el.points.length - 1]] : el.points,
: el.points, });
if (isElbowArrow(newElement)) {
const elementsMap = app.scene.getNonDeletedElementsMap();
app.dismissLinearEditor();
const startGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
0,
elementsMap,
);
const endGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
-1,
elementsMap,
);
const startHoveredElement =
!newElement.startBinding &&
getHoveredElementForBinding(
tupleToCoors(startGlobalPoint),
elements,
elementsMap,
appState.zoom,
false,
true,
);
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
appState.zoom,
false,
true,
);
const startElement = startHoveredElement
? startHoveredElement
: newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement = endHoveredElement
? endHoveredElement
: newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const finalStartPoint = startHoveredElement
? bindPointToSnapToElementOutline(
startGlobalPoint,
endGlobalPoint,
startHoveredElement,
elementsMap,
)
: startGlobalPoint;
const finalEndPoint = endHoveredElement
? bindPointToSnapToElementOutline(
endGlobalPoint,
startGlobalPoint,
endHoveredElement,
elementsMap,
)
: endGlobalPoint;
startHoveredElement &&
bindLinearElement(
newElement,
startHoveredElement,
"start",
elementsMap,
);
endHoveredElement &&
bindLinearElement(newElement, endHoveredElement, "end", elementsMap);
mutateElement(newElement, {
points: [finalStartPoint, finalEndPoint].map(
(p): LocalPoint =>
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
),
...(startElement && newElement.startBinding
? {
startBinding: {
// @ts-ignore TS cannot discern check above
...newElement.startBinding!,
...calculateFixedPointForElbowArrowBinding(
newElement,
startElement,
"start",
elementsMap,
),
},
}
: {}),
...(endElement && newElement.endBinding
? {
endBinding: {
// @ts-ignore TS cannot discern check above
...newElement.endBinding,
...calculateFixedPointForElbowArrowBinding(
newElement,
endElement,
"end",
elementsMap,
),
},
}
: {}),
}); });
if (isElbowArrow(newElement)) { LinearElementEditor.updateEditorMidPointsCache(
const elementsMap = app.scene.getNonDeletedElementsMap(); newElement,
elementsMap,
app.state,
);
}
app.dismissLinearEditor(); return newElement;
});
const startGlobalPoint = const newState = {
LinearElementEditor.getPointAtIndexGlobalCoordinates( ...appState,
newElement, currentItemArrowType: value,
0, };
elementsMap,
);
const endGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
-1,
elementsMap,
);
const startHoveredElement =
!newElement.startBinding &&
getHoveredElementForBinding(
tupleToCoors(startGlobalPoint),
elements,
elementsMap,
appState.zoom,
true,
);
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
appState.zoom,
true,
);
const startElement = startHoveredElement
? startHoveredElement
: newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement = endHoveredElement
? endHoveredElement
: newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const finalStartPoint = startHoveredElement // Change the arrow type and update any other state settings for
? bindPointToSnapToElementOutline( // the arrow.
startGlobalPoint, const selectedId = appState.selectedLinearElement?.elementId;
endGlobalPoint, if (selectedId) {
startHoveredElement, const selected = newElements.find((el) => el.id === selectedId);
elementsMap, if (selected) {
) newState.selectedLinearElement = new LinearElementEditor(
: startGlobalPoint; selected as ExcalidrawLinearElement,
const finalEndPoint = endHoveredElement );
? bindPointToSnapToElementOutline( }
endGlobalPoint, }
startGlobalPoint,
endHoveredElement,
elementsMap,
)
: endGlobalPoint;
startHoveredElement && return {
bindLinearElement( elements: newElements,
newElement, appState: newState,
startHoveredElement,
"start",
elementsMap,
);
endHoveredElement &&
bindLinearElement(
newElement,
endHoveredElement,
"end",
elementsMap,
);
mutateElbowArrow(
newElement,
elementsMap,
[finalStartPoint, finalEndPoint].map(
(p): LocalPoint =>
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
),
vector(0, 0),
{
...(startElement && newElement.startBinding
? {
startBinding: {
// @ts-ignore TS cannot discern check above
...newElement.startBinding!,
...calculateFixedPointForElbowArrowBinding(
newElement,
startElement,
"start",
elementsMap,
),
},
}
: {}),
...(endElement && newElement.endBinding
? {
endBinding: {
// @ts-ignore TS cannot discern check above
...newElement.endBinding,
...calculateFixedPointForElbowArrowBinding(
newElement,
endElement,
"end",
elementsMap,
),
},
}
: {}),
},
);
}
return newElement;
}),
appState: {
...appState,
currentItemArrowType: value,
},
storeAction: StoreAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
}, },
@@ -5,7 +5,6 @@ import { getNonDeletedElements, isTextElement } from "../element";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import { isLinearElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { selectAllIcon } from "../components/icons"; import { selectAllIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
@@ -20,17 +19,17 @@ export const actionSelectAll = register({
return false; return false;
} }
const selectedElementIds = excludeElementsInFramesFromSelection( const selectedElementIds = elements
elements.filter( .filter(
(element) => (element) =>
!element.isDeleted && !element.isDeleted &&
!(isTextElement(element) && element.containerId) && !(isTextElement(element) && element.containerId) &&
!element.locked, !element.locked,
), )
).reduce((map: Record<ExcalidrawElement["id"], true>, element) => { .reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
map[element.id] = true; map[element.id] = true;
return map; return map;
}, {}); }, {});
return { return {
appState: { appState: {
@@ -1,6 +1,6 @@
import { isTextElement } from "../element"; import { isTextElement } from "../element";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { measureText } from "../element/textElement"; import { measureText } from "../element/textMeasurements";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import type { AppClassProperties } from "../types"; import type { AppClassProperties } from "../types";
+2
View File
@@ -47,6 +47,7 @@ export type ShortcutName =
| "saveFileToDisk" | "saveFileToDisk"
| "saveToActiveFile" | "saveToActiveFile"
| "toggleShortcuts" | "toggleShortcuts"
| "wrapSelectionInFrame"
> >
| "saveScene" | "saveScene"
| "imageExport" | "imageExport"
@@ -112,6 +113,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")], saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
toggleShortcuts: [getShortcutKey("?")], toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")], searchMenu: [getShortcutKey("CtrlOrCmd+F")],
wrapSelectionInFrame: [],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
+2 -1
View File
@@ -137,7 +137,8 @@ export type ActionName =
| "searchMenu" | "searchMenu"
| "copyElementLink" | "copyElementLink"
| "linkToElement" | "linkToElement"
| "cropEditor"; | "cropEditor"
| "wrapSelectionInFrame";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
+14 -5
View File
@@ -1,8 +1,10 @@
import type { ElementsMap, ExcalidrawElement } from "./element/types"; import type { ElementsMap, ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement"; import { mutateElement } from "./element/mutateElement";
import type { BoundingBox } from "./element/bounds"; import type { BoundingBox } from "./element/bounds";
import { getCommonBoundingBox } from "./element/bounds"; import { getCommonBoundingBox } from "./element/bounds";
import { getMaximumGroups } from "./groups"; import { getMaximumGroups } from "./groups";
import { updateBoundElements } from "./element/binding";
import type Scene from "./scene/Scene";
export interface Alignment { export interface Alignment {
position: "start" | "center" | "end"; position: "start" | "center" | "end";
@@ -13,6 +15,7 @@ export const alignElements = (
selectedElements: ExcalidrawElement[], selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap, elementsMap: ElementsMap,
alignment: Alignment, alignment: Alignment,
scene: Scene,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const groups: ExcalidrawElement[][] = getMaximumGroups( const groups: ExcalidrawElement[][] = getMaximumGroups(
selectedElements, selectedElements,
@@ -26,12 +29,18 @@ export const alignElements = (
selectionBoundingBox, selectionBoundingBox,
alignment, alignment,
); );
return group.map((element) => return group.map((element) => {
newElementWith(element, { // update element
const updatedEle = mutateElement(element, {
x: element.x + translation.x, x: element.x + translation.x,
y: element.y + translation.y, y: element.y + translation.y,
}), });
); // update bound elements
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
simultaneouslyUpdated: group,
});
return updatedEle;
});
}); });
}; };
+7 -1
View File
@@ -51,6 +51,7 @@ import {
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../context/tunnels";
import { CLASSES } from "../constants"; import { CLASSES } from "../constants";
import { alignActionsPredicate } from "../actions/actionAlign";
export const canChangeStrokeColor = ( export const canChangeStrokeColor = (
appState: UIAppState, appState: UIAppState,
@@ -90,10 +91,12 @@ export const SelectedShapeActions = ({
appState, appState,
elementsMap, elementsMap,
renderAction, renderAction,
app,
}: { }: {
appState: UIAppState; appState: UIAppState;
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
app: AppClassProperties;
}) => { }) => {
const targetElements = getTargetElements(elementsMap, appState); const targetElements = getTargetElements(elementsMap, appState);
@@ -133,6 +136,9 @@ export const SelectedShapeActions = ({
targetElements.length === 1 && targetElements.length === 1 &&
isImageElement(targetElements[0]); isImageElement(targetElements[0]);
const showAlignActions =
!isSingleElementBoundContainer && alignActionsPredicate(appState, app);
return ( return (
<div className="panelColumn"> <div className="panelColumn">
<div> <div>
@@ -200,7 +206,7 @@ export const SelectedShapeActions = ({
</div> </div>
</fieldset> </fieldset>
{targetElements.length > 1 && !isSingleElementBoundContainer && ( {showAlignActions && !isSingleElementBoundContainer && (
<fieldset> <fieldset>
<legend>{t("labels.align")}</legend> <legend>{t("labels.align")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -1,7 +1,6 @@
import { atom, useAtom } from "jotai";
import { actionClearCanvas } from "../actions"; import { actionClearCanvas } from "../actions";
import { t } from "../i18n"; import { t } from "../i18n";
import { jotaiScope } from "../jotai"; import { atom, useAtom } from "../editor-jotai";
import { useExcalidrawActionManager } from "./App"; import { useExcalidrawActionManager } from "./App";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
@@ -10,7 +9,6 @@ export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
export const ActiveConfirmDialog = () => { export const ActiveConfirmDialog = () => {
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom( const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
activeConfirmDialogAtom, activeConfirmDialogAtom,
jotaiScope,
); );
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
+280 -138
View File
@@ -165,6 +165,7 @@ import {
isTextBindableContainer, isTextBindableContainer,
isElbowArrow, isElbowArrow,
isFlowchartNodeElement, isFlowchartNodeElement,
isBindableElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import type { import type {
ExcalidrawBindableElement, ExcalidrawBindableElement,
@@ -189,7 +190,6 @@ import type {
MagicGenerationData, MagicGenerationData,
ExcalidrawNonSelectionElement, ExcalidrawNonSelectionElement,
ExcalidrawArrowElement, ExcalidrawArrowElement,
NonDeletedSceneElementsMap,
} from "../element/types"; } from "../element/types";
import { getCenter, getDistance } from "../gesture"; import { getCenter, getDistance } from "../gesture";
import { import {
@@ -292,7 +292,6 @@ import {
getDateTime, getDateTime,
isShallowEqual, isShallowEqual,
arrayToMap, arrayToMap,
toBrandedType,
} from "../utils"; } from "../utils";
import { import {
createSrcDoc, createSrcDoc,
@@ -332,17 +331,10 @@ import type { FileSystemHandle } from "../data/filesystem";
import { fileOpen } from "../data/filesystem"; import { fileOpen } from "../data/filesystem";
import { import {
bindTextToShapeAfterDuplication, bindTextToShapeAfterDuplication,
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement, getBoundTextElement,
getContainerCenter, getContainerCenter,
getContainerElement, getContainerElement,
getLineHeightInPx,
getMinTextElementWidth,
isMeasureTextSupported,
isValidTextContainer, isValidTextContainer,
measureText,
normalizeText,
} from "../element/textElement"; } from "../element/textElement";
import { import {
showHyperlinkTooltip, showHyperlinkTooltip,
@@ -378,9 +370,10 @@ import { actionPaste } from "../actions/actionClipboard";
import { import {
actionRemoveAllElementsFromFrame, actionRemoveAllElementsFromFrame,
actionSelectAllElementsInFrame, actionSelectAllElementsInFrame,
actionWrapSelectionInFrame,
} from "../actions/actionFrame"; } from "../actions/actionFrame";
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas"; import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
import { jotaiStore } from "../jotai"; import { editorJotaiStore } from "../editor-jotai";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { ImageSceneDataError } from "../errors"; import { ImageSceneDataError } from "../errors";
import { import {
@@ -442,7 +435,6 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
import { getVisibleSceneBounds } from "../element/bounds"; import { getVisibleSceneBounds } from "../element/bounds";
import { isMaybeMermaidDefinition } from "../mermaid"; import { isMaybeMermaidDefinition } from "../mermaid";
import NewElementCanvas from "./canvases/NewElementCanvas"; import NewElementCanvas from "./canvases/NewElementCanvas";
import { mutateElbowArrow, updateElbowArrow } from "../element/routing";
import { import {
FlowChartCreator, FlowChartCreator,
FlowChartNavigator, FlowChartNavigator,
@@ -466,6 +458,15 @@ import { cropElement } from "../element/cropElement";
import { wrapText } from "../element/textWrapping"; import { wrapText } from "../element/textWrapping";
import { actionCopyElementLink } from "../actions/actionElementLink"; import { actionCopyElementLink } from "../actions/actionElementLink";
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink"; import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
import {
isMeasureTextSupported,
normalizeText,
measureText,
getLineHeightInPx,
getApproxMinLineWidth,
getApproxMinLineHeight,
getMinTextElementWidth,
} from "../element/textMeasurements";
const AppContext = React.createContext<AppClassProperties>(null!); const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!); const AppPropsContext = React.createContext<AppProps>(null!);
@@ -1523,13 +1524,17 @@ class App extends React.Component<AppProps, AppState> {
const allElementsMap = this.scene.getNonDeletedElementsMap(); const allElementsMap = this.scene.getNonDeletedElementsMap();
const shouldBlockPointerEvents = const shouldBlockPointerEvents =
this.state.selectionElement || // default back to `--ui-pointerEvents` flow if setPointerCapture
this.state.newElement || // not supported
this.state.selectedElementsAreBeingDragged || "setPointerCapture" in HTMLElement.prototype
this.state.resizingElement || ? false
(this.state.activeTool.type === "laser" && : this.state.selectionElement ||
// technically we can just test on this once we make it more safe this.state.newElement ||
this.state.cursorButton === "down"); this.state.selectedElementsAreBeingDragged ||
this.state.resizingElement ||
(this.state.activeTool.type === "laser" &&
// technically we can just test on this once we make it more safe
this.state.cursorButton === "down");
const firstSelectedElement = selectedElements[0]; const firstSelectedElement = selectedElements[0];
@@ -2076,7 +2081,7 @@ class App extends React.Component<AppProps, AppState> {
}; };
private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => { private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
jotaiStore.set(activeEyeDropperAtom, { editorJotaiStore.set(activeEyeDropperAtom, {
swapPreviewOnAlt: true, swapPreviewOnAlt: true,
colorPickerType: colorPickerType:
type === "stroke" ? "elementStroke" : "elementBackground", type === "stroke" ? "elementStroke" : "elementBackground",
@@ -3183,49 +3188,7 @@ class App extends React.Component<AppProps, AppState> {
retainSeed?: boolean; retainSeed?: boolean;
fitToContent?: boolean; fitToContent?: boolean;
}) => { }) => {
let elements = opts.elements.map((el, _, elements) => { const elements = restoreElements(opts.elements, null, undefined);
if (isElbowArrow(el)) {
const startEndElements = [
el.startBinding &&
elements.find((l) => l.id === el.startBinding?.elementId),
el.endBinding &&
elements.find((l) => l.id === el.endBinding?.elementId),
];
const startBinding = startEndElements[0] ? el.startBinding : null;
const endBinding = startEndElements[1] ? el.endBinding : null;
return {
...el,
...updateElbowArrow(
{
...el,
startBinding,
endBinding,
},
toBrandedType<NonDeletedSceneElementsMap>(
new Map(
startEndElements
.filter((x) => x != null)
.map(
(el) =>
[el!.id, el] as [
string,
Ordered<NonDeletedExcalidrawElement>,
],
),
),
),
[el.points[0], el.points[el.points.length - 1]],
undefined,
{
zoom: this.state.zoom,
},
),
};
}
return el;
});
elements = restoreElements(elements, null, undefined);
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const elementsCenterX = distance(minX, maxX) / 2; const elementsCenterX = distance(minX, maxX) / 2;
@@ -3267,7 +3230,14 @@ class App extends React.Component<AppProps, AppState> {
); );
const prevElements = this.scene.getElementsIncludingDeleted(); const prevElements = this.scene.getElementsIncludingDeleted();
const nextElements = [...prevElements, ...newElements]; let nextElements = [...prevElements, ...newElements];
const mappedNewSceneElements = this.props.onDuplicate?.(
nextElements,
prevElements,
);
nextElements = mappedNewSceneElements || nextElements;
syncMovedIndices(nextElements, arrayToMap(newElements)); syncMovedIndices(nextElements, arrayToMap(newElements));
@@ -3278,7 +3248,12 @@ class App extends React.Component<AppProps, AppState> {
newElements, newElements,
topLayerFrame, topLayerFrame,
); );
addElementsToFrame(nextElements, eligibleElements, topLayerFrame); addElementsToFrame(
nextElements,
eligibleElements,
topLayerFrame,
this.state,
);
} }
this.scene.replaceAllElements(nextElements); this.scene.replaceAllElements(nextElements);
@@ -3324,7 +3299,7 @@ class App extends React.Component<AppProps, AppState> {
openSidebar: openSidebar:
this.state.openSidebar && this.state.openSidebar &&
this.device.editor.canFitSidebar && this.device.editor.canFitSidebar &&
jotaiStore.get(isSidebarDockedAtom) editorJotaiStore.get(isSidebarDockedAtom)
? this.state.openSidebar ? this.state.openSidebar
: null, : null,
...selectGroupsForSelectedElements( ...selectGroupsForSelectedElements(
@@ -4369,14 +4344,17 @@ class App extends React.Component<AppProps, AppState> {
} }
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
mutateElement(element, { mutateElement(
x: element.x + offsetX, element,
y: element.y + offsetY, {
}); x: element.x + offsetX,
y: element.y + offsetY,
},
false,
);
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), { updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
simultaneouslyUpdated: selectedElements, simultaneouslyUpdated: selectedElements,
zoom: this.state.zoom,
}); });
}); });
@@ -4390,6 +4368,8 @@ class App extends React.Component<AppProps, AppState> {
), ),
}); });
this.scene.triggerUpdate();
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.ENTER) { } else if (event.key === KEYS.ENTER) {
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
@@ -4552,7 +4532,7 @@ class App extends React.Component<AppProps, AppState> {
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
) { ) {
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas"); editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
} }
// eye dropper // eye dropper
@@ -4695,7 +4675,10 @@ class App extends React.Component<AppProps, AppState> {
if (nextActiveTool.type === "hand") { if (nextActiveTool.type === "hand") {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (!isHoldingSpace) { } else if (!isHoldingSpace) {
setCursorForShape(this.interactiveCanvas, this.state); setCursorForShape(this.interactiveCanvas, {
...this.state,
activeTool: nextActiveTool,
});
} }
if (isToolIcon(document.activeElement)) { if (isToolIcon(document.activeElement)) {
this.focusContainer(); this.focusContainer();
@@ -5361,6 +5344,11 @@ class App extends React.Component<AppProps, AppState> {
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event,
this.state,
);
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) { if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
if ( if (
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
@@ -5374,6 +5362,64 @@ class App extends React.Component<AppProps, AppState> {
editingLinearElement: new LinearElementEditor(selectedElements[0]), editingLinearElement: new LinearElementEditor(selectedElements[0]),
}); });
return; return;
} else if (
this.state.selectedLinearElement &&
isElbowArrow(selectedElements[0])
) {
const hitCoords = LinearElementEditor.getSegmentMidpointHitCoords(
this.state.selectedLinearElement,
{ x: sceneX, y: sceneY },
this.state,
this.scene.getNonDeletedElementsMap(),
);
const midPoint = hitCoords
? LinearElementEditor.getSegmentMidPointIndex(
this.state.selectedLinearElement,
this.state,
hitCoords,
this.scene.getNonDeletedElementsMap(),
)
: -1;
if (midPoint && midPoint > -1) {
this.store.shouldCaptureIncrement();
LinearElementEditor.deleteFixedSegment(selectedElements[0], midPoint);
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
{
...this.state.selectedLinearElement,
segmentMidPointHoveredCoords: null,
},
{ x: sceneX, y: sceneY },
this.state,
this.scene.getNonDeletedElementsMap(),
);
const nextIndex = nextCoords
? LinearElementEditor.getSegmentMidPointIndex(
this.state.selectedLinearElement,
this.state,
nextCoords,
this.scene.getNonDeletedElementsMap(),
)
: null;
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
pointerDownState: {
...this.state.selectedLinearElement.pointerDownState,
segmentMidpoint: {
index: nextIndex,
value: hitCoords,
added: false,
},
},
segmentMidPointHoveredCoords: nextCoords,
},
});
return;
}
} }
} }
@@ -5384,11 +5430,6 @@ class App extends React.Component<AppProps, AppState> {
resetCursor(this.interactiveCanvas); resetCursor(this.interactiveCanvas);
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event,
this.state,
);
const selectedGroupIds = getSelectedGroupIds(this.state); const selectedGroupIds = getSelectedGroupIds(this.state);
if (selectedGroupIds.length > 0) { if (selectedGroupIds.length > 0) {
@@ -5742,7 +5783,10 @@ class App extends React.Component<AppProps, AppState> {
}); });
} }
if (editingLinearElement?.lastUncommittedPoint != null) { if (editingLinearElement?.lastUncommittedPoint != null) {
this.maybeSuggestBindingAtCursor(scenePointer); this.maybeSuggestBindingAtCursor(
scenePointer,
editingLinearElement.elbowed,
);
} else { } else {
// causes stack overflow if not sync // causes stack overflow if not sync
flushSync(() => { flushSync(() => {
@@ -5762,7 +5806,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.startBoundElement, this.state.startBoundElement,
); );
} else { } else {
this.maybeSuggestBindingAtCursor(scenePointer); this.maybeSuggestBindingAtCursor(scenePointer, false);
} }
} }
@@ -5845,41 +5889,23 @@ class App extends React.Component<AppProps, AppState> {
if (isPathALoop(points, this.state.zoom.value)) { if (isPathALoop(points, this.state.zoom.value)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
} }
if (isElbowArrow(multiElement)) { // update last uncommitted point
mutateElbowArrow( mutateElement(
multiElement, multiElement,
this.scene.getNonDeletedElementsMap(), {
[ points: [
...points.slice(0, -1), ...points.slice(0, -1),
pointFrom<LocalPoint>( pointFrom<LocalPoint>(
lastCommittedX + dxFromLastCommitted, lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted, lastCommittedY + dyFromLastCommitted,
), ),
], ],
undefined, },
undefined, false,
{ {
isDragging: true, isDragging: true,
informMutation: false, },
zoom: this.state.zoom, );
},
);
} else {
// update last uncommitted point
mutateElement(
multiElement,
{
points: [
...points.slice(0, -1),
pointFrom<LocalPoint>(
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
),
],
},
false,
);
}
// in this path, we're mutating multiElement to reflect // in this path, we're mutating multiElement to reflect
// how it will be after adding pointer position as the next point // how it will be after adding pointer position as the next point
@@ -6045,7 +6071,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ this.setState({
activeEmbeddable: { element: hitElement, state: "hover" }, activeEmbeddable: { element: hitElement, state: "hover" },
}); });
} else { } else if (!hitElement || !isElbowArrow(hitElement)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
if (this.state.activeEmbeddable?.state === "hover") { if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null }); this.setState({ activeEmbeddable: null });
@@ -6231,14 +6257,18 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
); );
const isHoveringAPointHandle = isElbowArrow(element)
if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) { ? hoverPointIndex === 0 ||
hoverPointIndex === element.points.length - 1
: hoverPointIndex >= 0;
if (isHoveringAPointHandle || segmentMidPointHoveredCoords) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
} else if (this.hitElement(scenePointerX, scenePointerY, element)) { } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} }
} else if (this.hitElement(scenePointerX, scenePointerY, element)) { } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
if ( if (
// Ebow arrows can only be moved when unconnected
!isElbowArrow(element) || !isElbowArrow(element) ||
!(element.startBinding || element.endBinding) !(element.startBinding || element.endBinding)
) { ) {
@@ -6278,6 +6308,13 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerDown = ( private handleCanvasPointerDown = (
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLElement>,
) => { ) => {
const target = event.target as HTMLElement;
// capture subsequent pointer events to the canvas
// this makes other elements non-interactive until pointer up
if (target.setPointerCapture) {
target.setPointerCapture(event.pointerId);
}
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent); this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
this.maybeUnfollowRemoteUser(); this.maybeUnfollowRemoteUser();
@@ -6288,7 +6325,7 @@ class App extends React.Component<AppProps, AppState> {
focus: false, focus: false,
})), })),
})); }));
jotaiStore.set(searchItemInFocusAtom, null); editorJotaiStore.set(searchItemInFocusAtom, null);
} }
// since contextMenu options are potentially evaluated on each render, // since contextMenu options are potentially evaluated on each render,
@@ -6968,6 +7005,7 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
selectedElements.length === 1 && selectedElements.length === 1 &&
!this.state.editingLinearElement && !this.state.editingLinearElement &&
!isElbowArrow(selectedElements[0]) &&
!( !(
this.state.selectedLinearElement && this.state.selectedLinearElement &&
this.state.selectedLinearElement.hoverPointIndex !== -1 this.state.selectedLinearElement.hoverPointIndex !== -1
@@ -7669,6 +7707,10 @@ class App extends React.Component<AppProps, AppState> {
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow, elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
fixedSegments:
this.state.currentItemArrowType === ARROW_TYPE.elbow
? []
: null,
}) })
: newLinearElement({ : newLinearElement({
type: elementType, type: elementType,
@@ -7709,6 +7751,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.state.zoom, this.state.zoom,
isElbowArrow(element), isElbowArrow(element),
isElbowArrow(element),
); );
this.scene.insertElement(element); this.scene.insertElement(element);
@@ -7909,6 +7952,63 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
const pointerCoords = viewportCoordsToSceneCoords(event, this.state); const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
if (
this.state.selectedLinearElement &&
this.state.selectedLinearElement.elbowed &&
this.state.selectedLinearElement.pointerDownState.segmentMidpoint.index
) {
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
let index =
this.state.selectedLinearElement.pointerDownState.segmentMidpoint
.index;
if (index < 0) {
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
{
...this.state.selectedLinearElement,
segmentMidPointHoveredCoords: null,
},
{ x: gridX, y: gridY },
this.state,
this.scene.getNonDeletedElementsMap(),
);
index = nextCoords
? LinearElementEditor.getSegmentMidPointIndex(
this.state.selectedLinearElement,
this.state,
nextCoords,
this.scene.getNonDeletedElementsMap(),
)
: -1;
}
const ret = LinearElementEditor.moveFixedSegment(
this.state.selectedLinearElement,
index,
gridX,
gridY,
this.scene.getNonDeletedElementsMap(),
);
flushSync(() => {
if (this.state.selectedLinearElement) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
segmentMidPointHoveredCoords: ret.segmentMidPointHoveredCoords,
pointerDownState: ret.pointerDownState,
},
});
}
});
return;
}
const lastPointerCoords = const lastPointerCoords =
this.lastPointerMoveCoords ?? pointerDownState.origin; this.lastPointerMoveCoords ?? pointerDownState.origin;
this.lastPointerMoveCoords = pointerCoords; this.lastPointerMoveCoords = pointerCoords;
@@ -8255,13 +8355,11 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
); );
flushSync(() => { this.setState({ snapLines });
this.setState({ snapLines });
});
// when we're editing the name of a frame, we want the user to be // when we're editing the name of a frame, we want the user to be
// able to select and interact with the text input // able to select and interact with the text input
!this.state.editingFrame && if (!this.state.editingFrame) {
dragSelectedElements( dragSelectedElements(
pointerDownState, pointerDownState,
selectedElements, selectedElements,
@@ -8270,6 +8368,7 @@ class App extends React.Component<AppProps, AppState> {
snapOffset, snapOffset,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
); );
}
this.setState({ this.setState({
selectedElementsAreBeingDragged: true, selectedElementsAreBeingDragged: true,
@@ -8352,7 +8451,17 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
const nextSceneElements = [...nextElements, ...elementsToAppend]; let nextSceneElements: ExcalidrawElement[] = [
...nextElements,
...elementsToAppend,
];
const mappedNewSceneElements = this.props.onDuplicate?.(
nextSceneElements,
elements,
);
nextSceneElements = mappedNewSceneElements || nextSceneElements;
syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend)); syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend));
@@ -8445,26 +8554,17 @@ class App extends React.Component<AppProps, AppState> {
}, },
false, false,
); );
} else if (points.length > 1 && isElbowArrow(newElement)) { } else if (
mutateElbowArrow( points.length === 2 ||
newElement, (points.length > 1 && isElbowArrow(newElement))
elementsMap, ) {
[...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
vector(0, 0),
undefined,
{
isDragging: true,
informMutation: false,
zoom: this.state.zoom,
},
);
} else if (points.length === 2) {
mutateElement( mutateElement(
newElement, newElement,
{ {
points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)], points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
}, },
false, false,
{ isDragging: true },
); );
} }
@@ -8531,6 +8631,7 @@ class App extends React.Component<AppProps, AppState> {
elements, elements,
this.state.selectionElement, this.state.selectionElement,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
false,
) )
: []; : [];
@@ -8659,6 +8760,24 @@ class App extends React.Component<AppProps, AppState> {
selectedElementsAreBeingDragged: false, selectedElementsAreBeingDragged: false,
}); });
const elementsMap = this.scene.getNonDeletedElementsMap(); const elementsMap = this.scene.getNonDeletedElementsMap();
if (
pointerDownState.drag.hasOccurred &&
pointerDownState.hit?.element?.id
) {
const element = elementsMap.get(pointerDownState.hit.element.id);
if (isBindableElement(element)) {
// Renormalize elbow arrows when they are changed via indirect move
element.boundElements
?.filter((e) => e.type === "arrow")
.map((e) => elementsMap.get(e.id))
.filter((e) => isElbowArrow(e))
.forEach((e) => {
!!e && mutateElement(e, {}, true);
});
}
}
// Handle end of dragging a point of a linear element, might close a loop // Handle end of dragging a point of a linear element, might close a loop
// and sets binding element // and sets binding element
if (this.state.editingLinearElement) { if (this.state.editingLinearElement) {
@@ -8683,6 +8802,17 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
} else if (this.state.selectedLinearElement) { } else if (this.state.selectedLinearElement) {
// Normalize elbow arrow points, remove close parallel segments
if (this.state.selectedLinearElement.elbowed) {
const element = LinearElementEditor.getElement(
this.state.selectedLinearElement.elementId,
this.scene.getNonDeletedElementsMap(),
);
if (element) {
mutateElement(element, {}, true);
}
}
if ( if (
pointerDownState.hit?.element?.id !== pointerDownState.hit?.element?.id !==
this.state.selectedLinearElement.elementId this.state.selectedLinearElement.elementId
@@ -8930,6 +9060,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getElementsMapIncludingDeleted(), this.scene.getElementsMapIncludingDeleted(),
elementsInsideFrame, elementsInsideFrame,
newElement, newElement,
this.state,
), ),
); );
} }
@@ -9047,6 +9178,7 @@ class App extends React.Component<AppProps, AppState> {
nextElements, nextElements,
elementsToAdd, elementsToAdd,
topLayerFrame, topLayerFrame,
this.state,
); );
} else if (!topLayerFrame) { } else if (!topLayerFrame) {
if (this.state.editingGroupId) { if (this.state.editingGroupId) {
@@ -9122,10 +9254,10 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectedLinearElement?.elementId !== hitElement?.id && this.state.selectedLinearElement?.elementId !== hitElement?.id &&
isLinearElement(hitElement) isLinearElement(hitElement)
) { ) {
const selectedELements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
// set selectedLinearElement when no other element selected except // set selectedLinearElement when no other element selected except
// the one we've hit // the one we've hit
if (selectedELements.length === 1) { if (selectedElements.length === 1) {
this.setState({ this.setState({
selectedLinearElement: new LinearElementEditor(hitElement), selectedLinearElement: new LinearElementEditor(hitElement),
}); });
@@ -9333,6 +9465,8 @@ class App extends React.Component<AppProps, AppState> {
} }
if ( if (
// not elbow midpoint dragged
!(hitElement && isElbowArrow(hitElement)) &&
// not dragged // not dragged
!pointerDownState.drag.hasOccurred && !pointerDownState.drag.hasOccurred &&
// not resized // not resized
@@ -9905,15 +10039,20 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private maybeSuggestBindingAtCursor = (pointerCoords: { private maybeSuggestBindingAtCursor = (
x: number; pointerCoords: {
y: number; x: number;
}): void => { y: number;
},
considerAll: boolean,
): void => {
const hoveredBindableElement = getHoveredElementForBinding( const hoveredBindableElement = getHoveredElementForBinding(
pointerCoords, pointerCoords,
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.state.zoom, this.state.zoom,
false,
considerAll,
); );
this.setState({ this.setState({
suggestedBindings: suggestedBindings:
@@ -9943,7 +10082,8 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.state.zoom, this.state.zoom,
isArrowElement(linearElement) && isElbowArrow(linearElement), isElbowArrow(linearElement),
isElbowArrow(linearElement),
); );
if ( if (
hoveredBindableElement != null && hoveredBindableElement != null &&
@@ -10661,8 +10801,10 @@ class App extends React.Component<AppProps, AppState> {
actionCut, actionCut,
actionCopy, actionCopy,
actionPaste, actionPaste,
CONTEXT_MENU_SEPARATOR,
actionSelectAllElementsInFrame, actionSelectAllElementsInFrame,
actionRemoveAllElementsFromFrame, actionRemoveAllElementsFromFrame,
actionWrapSelectionInFrame,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionToggleCropEditor, actionToggleCropEditor,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
@@ -1,10 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { getColor } from "./ColorPicker"; import { getColor } from "./ColorPicker";
import { useAtom } from "jotai";
import type { ColorPickerType } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils";
import { activeColorPickerSectionAtom } from "./colorPickerUtils"; import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { eyeDropperIcon } from "../icons"; import { eyeDropperIcon } from "../icons";
import { jotaiScope } from "../../jotai"; import { useAtom } from "../../editor-jotai";
import { KEYS } from "../../keys"; import { KEYS } from "../../keys";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import clsx from "clsx"; import clsx from "clsx";
@@ -57,10 +56,7 @@ export const ColorInput = ({
} }
}, [activeSection]); }, [activeSection]);
const [eyeDropperState, setEyeDropperState] = useAtom( const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
activeEyeDropperAtom,
jotaiScope,
);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -5,7 +5,6 @@ import { TopPicks } from "./TopPicks";
import { ButtonSeparator } from "../ButtonSeparator"; import { ButtonSeparator } from "../ButtonSeparator";
import { Picker } from "./Picker"; import { Picker } from "./Picker";
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { useAtom } from "jotai";
import type { ColorPickerType } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils";
import { activeColorPickerSectionAtom } from "./colorPickerUtils"; import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { useExcalidrawContainer } from "../App"; import { useExcalidrawContainer } from "../App";
@@ -15,7 +14,7 @@ import PickerHeading from "./PickerHeading";
import { t } from "../../i18n"; import { t } from "../../i18n";
import clsx from "clsx"; import clsx from "clsx";
import { useRef } from "react"; import { useRef } from "react";
import { jotaiScope } from "../../jotai"; import { useAtom } from "../../editor-jotai";
import { ColorInput } from "./ColorInput"; import { ColorInput } from "./ColorInput";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover"; import { PropertiesPopover } from "../PropertiesPopover";
@@ -76,10 +75,7 @@ const ColorPickerPopupContent = ({
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom); const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
const [eyeDropperState, setEyeDropperState] = useAtom( const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
activeEyeDropperAtom,
jotaiScope,
);
const colorInputJSX = ( const colorInputJSX = (
<div> <div>
@@ -1,5 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { useAtom } from "jotai"; import { useAtom } from "../../editor-jotai";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { activeColorPickerSectionAtom } from "./colorPickerUtils"; import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel"; import HotkeyLabel from "./HotkeyLabel";
@@ -5,7 +5,7 @@ import type { ExcalidrawElement } from "../../element/types";
import { ShadeList } from "./ShadeList"; import { ShadeList } from "./ShadeList";
import PickerColorList from "./PickerColorList"; import PickerColorList from "./PickerColorList";
import { useAtom } from "jotai"; import { useAtom } from "../../editor-jotai";
import { CustomColorList } from "./CustomColorList"; import { CustomColorList } from "./CustomColorList";
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers"; import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
import PickerHeading from "./PickerHeading"; import PickerHeading from "./PickerHeading";
@@ -1,5 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { useAtom } from "jotai"; import { useAtom } from "../../editor-jotai";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { import {
activeColorPickerSectionAtom, activeColorPickerSectionAtom,
@@ -1,5 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { useAtom } from "jotai"; import { useAtom } from "../../editor-jotai";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { import {
activeColorPickerSectionAtom, activeColorPickerSectionAtom,
@@ -1,7 +1,7 @@
import type { ExcalidrawElement } from "../../element/types"; import type { ExcalidrawElement } from "../../element/types";
import { atom } from "jotai";
import type { ColorPickerColor, ColorPaletteCustom } from "../../colors"; import type { ColorPickerColor, ColorPaletteCustom } from "../../colors";
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors"; import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors";
import { atom } from "../../editor-jotai";
export const getColorNameAndShadeFromColor = ({ export const getColorNameAndShadeFromColor = ({
palette, palette,
@@ -36,7 +36,7 @@ import {
getShortcutKey, getShortcutKey,
isWritableElement, isWritableElement,
} from "../../utils"; } from "../../utils";
import { atom, useAtom } from "jotai"; import { atom, useAtom, editorJotaiStore } from "../../editor-jotai";
import { deburr } from "../../deburr"; import { deburr } from "../../deburr";
import type { MarkRequired } from "../../utility-types"; import type { MarkRequired } from "../../utility-types";
import { InlineIcon } from "../InlineIcon"; import { InlineIcon } from "../InlineIcon";
@@ -48,7 +48,6 @@ import {
actionLink, actionLink,
actionToggleSearchMenu, actionToggleSearchMenu,
} from "../../actions"; } from "../../actions";
import { jotaiStore } from "../../jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import type { CommandPaletteItem } from "./types"; import type { CommandPaletteItem } from "./types";
import * as defaultItems from "./defaultCommandPaletteItems"; import * as defaultItems from "./defaultCommandPaletteItems";
@@ -263,6 +262,7 @@ function CommandPaletteInner({
actionManager.actions.cut, actionManager.actions.cut,
actionManager.actions.copy, actionManager.actions.copy,
actionManager.actions.deleteSelectedElements, actionManager.actions.deleteSelectedElements,
actionManager.actions.wrapSelectionInFrame,
actionManager.actions.copyStyles, actionManager.actions.copyStyles,
actionManager.actions.pasteStyles, actionManager.actions.pasteStyles,
actionManager.actions.bringToFront, actionManager.actions.bringToFront,
@@ -348,7 +348,7 @@ function CommandPaletteInner({
keywords: ["delete", "destroy"], keywords: ["delete", "destroy"],
viewMode: false, viewMode: false,
perform: () => { perform: () => {
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas"); editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
}, },
}, },
{ {
@@ -5,10 +5,9 @@ import { Dialog } from "./Dialog";
import "./ConfirmDialog.scss"; import "./ConfirmDialog.scss";
import DialogActionButton from "./DialogActionButton"; import DialogActionButton from "./DialogActionButton";
import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu"; import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App"; import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
import { jotaiScope } from "../jotai"; import { useSetAtom } from "../editor-jotai";
interface Props extends Omit<DialogProps, "onCloseRequest"> { interface Props extends Omit<DialogProps, "onCloseRequest"> {
onConfirm: () => void; onConfirm: () => void;
@@ -27,7 +26,7 @@ const ConfirmDialog = (props: Props) => {
...rest ...rest
} = props; } = props;
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope); const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
return ( return (
+2 -3
View File
@@ -11,9 +11,8 @@ import "./Dialog.scss";
import { Island } from "./Island"; import { Island } from "./Island";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import { queryFocusableElements } from "../utils"; import { queryFocusableElements } from "../utils";
import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu"; import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { jotaiScope } from "../jotai"; import { useSetAtom } from "../editor-jotai";
import { t } from "../i18n"; import { t } from "../i18n";
import { CloseIcon } from "./icons"; import { CloseIcon } from "./icons";
@@ -92,7 +91,7 @@ export const Dialog = (props: DialogProps) => {
}, [islandNode, props.autofocus]); }, [islandNode, props.autofocus]);
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope); const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
const onClose = () => { const onClose = () => {
setAppState({ openMenu: null }); setAppState({ openMenu: null });
@@ -1,4 +1,3 @@
import { atom } from "jotai";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { rgbToHex } from "../colors"; import { rgbToHex } from "../colors";
@@ -14,6 +13,7 @@ import { useStable } from "../hooks/useStable";
import "./EyeDropper.scss"; import "./EyeDropper.scss";
import type { ColorPickerType } from "./ColorPicker/colorPickerUtils"; import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import { atom } from "../editor-jotai";
export type EyeDropperProperties = { export type EyeDropperProperties = {
keepOpenOnAlt: boolean; keepOpenOnAlt: boolean;
@@ -1,14 +1,13 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import "./IconPicker.scss";
import { isArrowKey, KEYS } from "../keys"; import { isArrowKey, KEYS } from "../keys";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import clsx from "clsx"; import clsx from "clsx";
import Collapsible from "./Stats/Collapsible"; import Collapsible from "./Stats/Collapsible";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "../editor-jotai";
import { jotaiScope } from "../jotai"; import { useDevice } from "./App";
import { useDevice } from "..";
import "./IconPicker.scss";
const moreOptionsAtom = atom(false); const moreOptionsAtom = atom(false);
@@ -94,10 +93,7 @@ function Picker<T>({
event.stopPropagation(); event.stopPropagation();
}; };
const [showMoreOptions, setShowMoreOptions] = useAtom( const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
moreOptionsAtom,
jotaiScope,
);
const alwaysVisibleOptions = React.useMemo( const alwaysVisibleOptions = React.useMemo(
() => options.slice(0, numberOfOptionsToAlwaysShow), () => options.slice(0, numberOfOptionsToAlwaysShow),
+8 -9
View File
@@ -41,8 +41,7 @@ import { trackEvent } from "../analytics";
import { useDevice } from "./App"; import { useDevice } from "./App";
import Footer from "./footer/Footer"; import Footer from "./footer/Footer";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai"; import { useAtom, useAtomValue } from "../editor-jotai";
import { Provider, useAtom, useAtomValue } from "jotai";
import MainMenu from "./main-menu/MainMenu"; import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog"; import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm"; import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
@@ -148,10 +147,9 @@ const LayerUI = ({
const device = useDevice(); const device = useDevice();
const tunnels = useInitializeTunnels(); const tunnels = useInitializeTunnels();
const [eyeDropperState, setEyeDropperState] = useAtom( const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
activeEyeDropperAtom,
jotaiScope, const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
);
const renderJSONExportDialog = () => { const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) { if (!UIOptions.canvasActions.export) {
@@ -221,6 +219,7 @@ const LayerUI = ({
appState={appState} appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()} elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
app={app}
/> />
</Island> </Island>
</Section> </Section>
@@ -382,7 +381,7 @@ const LayerUI = ({
); );
}; };
const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope); const isSidebarDocked = useAtomValue(isSidebarDockedAtom);
const layerUIJSX = ( const layerUIJSX = (
<> <>
@@ -566,11 +565,11 @@ const LayerUI = ({
return ( return (
<UIAppStateContext.Provider value={appState}> <UIAppStateContext.Provider value={appState}>
<Provider scope={tunnels.jotaiScope}> <TunnelsJotaiProvider>
<TunnelsContext.Provider value={tunnels}> <TunnelsContext.Provider value={tunnels}>
{layerUIJSX} {layerUIJSX}
</TunnelsContext.Provider> </TunnelsContext.Provider>
</Provider> </TunnelsJotaiProvider>
</UIAppStateContext.Provider> </UIAppStateContext.Provider>
); );
}; };
+201 -132
View File
@@ -1,4 +1,11 @@
import React, { useState, useCallback, useMemo, useRef } from "react"; import React, {
useState,
useCallback,
useMemo,
useEffect,
memo,
useRef,
} from "react";
import type Library from "../data/library"; import type Library from "../data/library";
import { import {
distributeLibraryItemsOnSquareGrid, distributeLibraryItemsOnSquareGrid,
@@ -11,11 +18,11 @@ import type {
LibraryItem, LibraryItem,
ExcalidrawProps, ExcalidrawProps,
UIAppState, UIAppState,
AppClassProperties,
} from "../types"; } from "../types";
import LibraryMenuItems from "./LibraryMenuItems"; import LibraryMenuItems from "./LibraryMenuItems";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "../editor-jotai";
import { jotaiScope } from "../jotai";
import Spinner from "./Spinner"; import Spinner from "./Spinner";
import { import {
useApp, useApp,
@@ -28,9 +35,12 @@ import { useUIAppState } from "../context/ui-appState";
import "./LibraryMenu.scss"; import "./LibraryMenu.scss";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons"; import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import { isShallowEqual } from "../utils"; import type {
import type { NonDeletedExcalidrawElement } from "../element/types"; ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { LIBRARY_DISABLED_TYPES } from "../constants"; import { LIBRARY_DISABLED_TYPES } from "../constants";
import { isShallowEqual } from "../utils";
export const isLibraryMenuOpenAtom = atom(false); export const isLibraryMenuOpenAtom = atom(false);
@@ -38,156 +48,215 @@ const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => {
return <div className="layer-ui__library">{children}</div>; return <div className="layer-ui__library">{children}</div>;
}; };
export const LibraryMenuContent = ({ const LibraryMenuContent = memo(
onInsertLibraryItems, ({
pendingElements, onInsertLibraryItems,
onAddToLibrary, pendingElements,
setAppState, onAddToLibrary,
libraryReturnUrl, setAppState,
library, libraryReturnUrl,
id, library,
theme, id,
selectedItems, theme,
onSelectItems, selectedItems,
}: { onSelectItems,
pendingElements: LibraryItem["elements"]; }: {
onInsertLibraryItems: (libraryItems: LibraryItems) => void; pendingElements: LibraryItem["elements"];
onAddToLibrary: () => void; onInsertLibraryItems: (libraryItems: LibraryItems) => void;
setAppState: React.Component<any, UIAppState>["setState"]; onAddToLibrary: () => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; setAppState: React.Component<any, UIAppState>["setState"];
library: Library; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
id: string; library: Library;
theme: UIAppState["theme"]; id: string;
selectedItems: LibraryItem["id"][]; theme: UIAppState["theme"];
onSelectItems: (id: LibraryItem["id"][]) => void; selectedItems: LibraryItem["id"][];
}) => { onSelectItems: (id: LibraryItem["id"][]) => void;
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); }) => {
const [libraryItemsData] = useAtom(libraryItemsAtom);
const _onAddToLibrary = useCallback( const _onAddToLibrary = useCallback(
(elements: LibraryItem["elements"]) => { (elements: LibraryItem["elements"]) => {
const addToLibrary = async ( const addToLibrary = async (
processedElements: LibraryItem["elements"], processedElements: LibraryItem["elements"],
libraryItems: LibraryItems, libraryItems: LibraryItems,
) => { ) => {
trackEvent("element", "addToLibrary", "ui"); trackEvent("element", "addToLibrary", "ui");
for (const type of LIBRARY_DISABLED_TYPES) { for (const type of LIBRARY_DISABLED_TYPES) {
if (processedElements.some((element) => element.type === type)) { if (processedElements.some((element) => element.type === type)) {
return setAppState({ return setAppState({
errorMessage: t(`errors.libraryElementTypeError.${type}`), errorMessage: t(`errors.libraryElementTypeError.${type}`),
}); });
}
} }
} const nextItems: LibraryItems = [
const nextItems: LibraryItems = [ {
{ status: "unpublished",
status: "unpublished", elements: processedElements,
elements: processedElements, id: randomId(),
id: randomId(), created: Date.now(),
created: Date.now(), },
}, ...libraryItems,
...libraryItems, ];
]; onAddToLibrary();
onAddToLibrary(); library.setLibrary(nextItems).catch(() => {
library.setLibrary(nextItems).catch(() => { setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") }); });
}); };
}; addToLibrary(elements, libraryItemsData.libraryItems);
addToLibrary(elements, libraryItemsData.libraryItems); },
}, [onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
[onAddToLibrary, library, setAppState, libraryItemsData.libraryItems], );
);
const libraryItems = useMemo( const libraryItems = useMemo(
() => libraryItemsData.libraryItems, () => libraryItemsData.libraryItems,
[libraryItemsData], [libraryItemsData],
); );
if (
libraryItemsData.status === "loading" &&
!libraryItemsData.isInitialized
) {
return (
<LibraryMenuWrapper>
<div className="layer-ui__library-message">
<div>
<Spinner size="2em" />
<span>{t("labels.libraryLoadingMessage")}</span>
</div>
</div>
</LibraryMenuWrapper>
);
}
const showBtn =
libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0;
if (
libraryItemsData.status === "loading" &&
!libraryItemsData.isInitialized
) {
return ( return (
<LibraryMenuWrapper> <LibraryMenuWrapper>
<div className="layer-ui__library-message"> <LibraryMenuItems
<div> isLoading={libraryItemsData.status === "loading"}
<Spinner size="2em" /> libraryItems={libraryItems}
<span>{t("labels.libraryLoadingMessage")}</span> onAddToLibrary={_onAddToLibrary}
</div> onInsertLibraryItems={onInsertLibraryItems}
</div> pendingElements={pendingElements}
</LibraryMenuWrapper>
);
}
const showBtn =
libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0;
return (
<LibraryMenuWrapper>
<LibraryMenuItems
isLoading={libraryItemsData.status === "loading"}
libraryItems={libraryItems}
onAddToLibrary={_onAddToLibrary}
onInsertLibraryItems={onInsertLibraryItems}
pendingElements={pendingElements}
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
onSelectItems={onSelectItems}
selectedItems={selectedItems}
/>
{showBtn && (
<LibraryMenuControlButtons
className="library-menu-control-buttons--at-bottom"
style={{ padding: "16px 12px 0 12px" }}
id={id} id={id}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
theme={theme} theme={theme}
onSelectItems={onSelectItems}
selectedItems={selectedItems}
/> />
)} {showBtn && (
</LibraryMenuWrapper> <LibraryMenuControlButtons
); className="library-menu-control-buttons--at-bottom"
}; style={{ padding: "16px 12px 0 12px" }}
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
/>
)}
</LibraryMenuWrapper>
);
},
);
const getPendingElements = (
elements: readonly NonDeletedExcalidrawElement[],
selectedElementIds: UIAppState["selectedElementIds"],
) => ({
elements,
pending: getSelectedElements(
elements,
{ selectedElementIds },
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
),
selectedElementIds,
});
const usePendingElementsMemo = ( const usePendingElementsMemo = (
appState: UIAppState, appState: UIAppState,
elements: readonly NonDeletedExcalidrawElement[], app: AppClassProperties,
) => { ) => {
const create = () => const elements = useExcalidrawElements();
getSelectedElements(elements, appState, { const [state, setState] = useState(() =>
includeBoundTextElement: true, getPendingElements(elements, appState.selectedElementIds),
includeElementsInFrames: true, );
});
const val = useRef(create());
const prevAppState = useRef<UIAppState>(appState);
const prevElements = useRef(elements);
if ( const selectedElementVersions = useRef(
!isShallowEqual( new Map<ExcalidrawElement["id"], ExcalidrawElement["version"]>(),
appState.selectedElementIds, );
prevAppState.current.selectedElementIds,
) || useEffect(() => {
!isShallowEqual(elements, prevElements.current) for (const element of state.pending) {
) { selectedElementVersions.current.set(element.id, element.version);
val.current = create(); }
prevAppState.current = appState; }, [state.pending]);
prevElements.current = elements;
} useEffect(() => {
return val.current; if (
// Only update once pointer is released.
// Reading directly from app.state to make it clear it's not reactive
// (hence, there's potential for stale state)
app.state.cursorButton === "up" &&
app.state.activeTool.type === "selection"
) {
setState((prev) => {
// if selectedElementIds changed, we don't have to compare versions
// ---------------------------------------------------------------------
if (
!isShallowEqual(prev.selectedElementIds, appState.selectedElementIds)
) {
selectedElementVersions.current.clear();
return getPendingElements(elements, appState.selectedElementIds);
}
// otherwise we need to check whether selected elements changed
// ---------------------------------------------------------------------
const elementsMap = app.scene.getNonDeletedElementsMap();
for (const id of Object.keys(appState.selectedElementIds)) {
const currVersion = elementsMap.get(id)?.version;
if (
currVersion &&
currVersion !== selectedElementVersions.current.get(id)
) {
// we can't update the selectedElementVersions in here
// because of double render in StrictMode which would overwrite
// the state in the second pass with the old `prev` state.
// Thus, we update versions in a separate effect. May create
// a race condition since current effect is not fully reactive.
return getPendingElements(elements, appState.selectedElementIds);
}
}
// nothing changed
// ---------------------------------------------------------------------
return prev;
});
}
}, [
app,
app.state.cursorButton,
app.state.activeTool.type,
appState.selectedElementIds,
elements,
]);
return state.pending;
}; };
/** /**
* This component is meant to be rendered inside <Sidebar.Tab/> inside our * This component is meant to be rendered inside <Sidebar.Tab/> inside our
* <DefaultSidebar/> or host apps Sidebar components. * <DefaultSidebar/> or host apps Sidebar components.
*/ */
export const LibraryMenu = () => { export const LibraryMenu = memo(() => {
const { library, id, onInsertElements } = useApp(); const app = useApp();
const { onInsertElements } = app;
const appProps = useAppProps(); const appProps = useAppProps();
const appState = useUIAppState(); const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements();
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]); const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const memoizedLibrary = useMemo(() => library, [library]); const memoizedLibrary = useMemo(() => app.library, [app.library]);
// BUG: pendingElements are still causing some unnecessary rerenders because clicking into canvas returns some ids even when no element is selected. const pendingElements = usePendingElementsMemo(appState, app);
const pendingElements = usePendingElementsMemo(appState, elements);
const onInsertLibraryItems = useCallback( const onInsertLibraryItems = useCallback(
(libraryItems: LibraryItems) => { (libraryItems: LibraryItems) => {
@@ -212,10 +281,10 @@ export const LibraryMenu = () => {
setAppState={setAppState} setAppState={setAppState}
libraryReturnUrl={appProps.libraryReturnUrl} libraryReturnUrl={appProps.libraryReturnUrl}
library={memoizedLibrary} library={memoizedLibrary}
id={id} id={app.id}
theme={appState.theme} theme={appState.theme}
selectedItems={selectedItems} selectedItems={selectedItems}
onSelectItems={setSelectedItems} onSelectItems={setSelectedItems}
/> />
); );
}; });
@@ -1,7 +1,7 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import Trans from "./Trans"; import Trans from "./Trans";
import { jotaiScope } from "../jotai"; import { useAtom } from "../editor-jotai";
import type { LibraryItem, LibraryItems, UIAppState } from "../types"; import type { LibraryItem, LibraryItems, UIAppState } from "../types";
import { useApp, useExcalidrawSetAppState } from "./App"; import { useApp, useExcalidrawSetAppState } from "./App";
import { saveLibraryAsJSON } from "../data/json"; import { saveLibraryAsJSON } from "../data/json";
@@ -17,7 +17,6 @@ import {
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { fileOpen } from "../data/filesystem"; import { fileOpen } from "../data/filesystem";
import { muteFSAbortError } from "../utils"; import { muteFSAbortError } from "../utils";
import { useAtom } from "jotai";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
import PublishLibrary from "./PublishLibrary"; import PublishLibrary from "./PublishLibrary";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
@@ -51,10 +50,9 @@ export const LibraryDropdownMenuButton: React.FC<{
appState, appState,
className, className,
}) => { }) => {
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); const [libraryItemsData] = useAtom(libraryItemsAtom);
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom( const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
isLibraryMenuOpenAtom, isLibraryMenuOpenAtom,
jotaiScope,
); );
const renderRemoveLibAlert = () => { const renderRemoveLibAlert = () => {
@@ -286,7 +284,7 @@ export const LibraryDropdownMenu = ({
const appState = useUIAppState(); const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); const [libraryItemsData] = useAtom(libraryItemsAtom);
const removeFromLibrary = async (libraryItems: LibraryItems) => { const removeFromLibrary = async (libraryItems: LibraryItems) => {
const nextItems = libraryItems.filter( const nextItems = libraryItems.filter(
@@ -145,12 +145,14 @@ export const MobileMenu = ({
<div className="App-toolbar-content"> <div className="App-toolbar-content">
<MainMenuTunnel.Out /> <MainMenuTunnel.Out />
{actionManager.renderAction("toggleEditMenu")} {actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
{actionManager.renderAction( {actionManager.renderAction(
appState.multiElement ? "finalize" : "duplicateSelection", appState.multiElement ? "finalize" : "duplicateSelection",
)} )}
{actionManager.renderAction("deleteSelectedElements")} {actionManager.renderAction("deleteSelectedElements")}
<div>
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
</div>
</div> </div>
); );
}; };
@@ -179,6 +181,7 @@ export const MobileMenu = ({
appState={appState} appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()} elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
app={app}
/> />
</Section> </Section>
) : null} ) : null}
@@ -1,8 +1,7 @@
import React from "react"; import React from "react";
import { useAtom } from "jotai";
import { useTunnels } from "../../context/tunnels"; import { useTunnels } from "../../context/tunnels";
import { jotaiScope } from "../../jotai"; import { useAtom } from "../../editor-jotai";
import { Dialog } from "../Dialog"; import { Dialog } from "../Dialog";
import { withInternalFallback } from "../hoc/withInternalFallback"; import { withInternalFallback } from "../hoc/withInternalFallback";
import { overwriteConfirmStateAtom } from "./OverwriteConfirmState"; import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
@@ -23,7 +22,6 @@ const OverwriteConfirmDialog = Object.assign(
const { OverwriteConfirmDialogTunnel } = useTunnels(); const { OverwriteConfirmDialogTunnel } = useTunnels();
const [overwriteConfirmState, setState] = useAtom( const [overwriteConfirmState, setState] = useAtom(
overwriteConfirmStateAtom, overwriteConfirmStateAtom,
jotaiScope,
); );
if (!overwriteConfirmState.active) { if (!overwriteConfirmState.active) {
@@ -1,5 +1,4 @@
import { atom } from "jotai"; import { atom, editorJotaiStore } from "../../editor-jotai";
import { jotaiStore } from "../../jotai";
import type React from "react"; import type React from "react";
export type OverwriteConfirmState = export type OverwriteConfirmState =
@@ -32,7 +31,7 @@ export async function openConfirmModal({
color: "danger" | "warning"; color: "danger" | "warning";
}) { }) {
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
jotaiStore.set(overwriteConfirmStateAtom, { editorJotaiStore.set(overwriteConfirmStateAtom, {
active: true, active: true,
onConfirm: () => resolve(true), onConfirm: () => resolve(true),
onClose: () => resolve(false), onClose: () => resolve(false),
+56
View File
@@ -0,0 +1,56 @@
@import "../css/variables.module.scss";
.excalidraw {
--slider-thumb-size: 16px;
.range-wrapper {
position: relative;
padding-top: 10px;
padding-bottom: 30px;
}
.range-input {
width: 100%;
height: 4px;
-webkit-appearance: none;
background: var(--color-slider-track);
border-radius: 2px;
outline: none;
}
.range-input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: var(--slider-thumb-size);
height: var(--slider-thumb-size);
background: var(--color-slider-thumb);
border-radius: 50%;
cursor: pointer;
border: none;
}
.range-input::-moz-range-thumb {
width: var(--slider-thumb-size);
height: var(--slider-thumb-size);
background: var(--color-slider-thumb);
border-radius: 50%;
cursor: pointer;
border: none;
}
.value-bubble {
position: absolute;
bottom: 0;
transform: translateX(-50%);
font-size: 12px;
color: var(--text-primary-color);
}
.zero-label {
position: absolute;
bottom: 0;
left: 4px;
font-size: 12px;
color: var(--text-primary-color);
}
}
+65
View File
@@ -0,0 +1,65 @@
import React, { useEffect } from "react";
import { getFormValue } from "../actions/actionProperties";
import { t } from "../i18n";
import "./Range.scss";
export type RangeProps = {
updateData: (value: number) => void;
appState: any;
elements: any;
testId?: string;
};
export const Range = ({
updateData,
appState,
elements,
testId,
}: RangeProps) => {
const rangeRef = React.useRef<HTMLInputElement>(null);
const valueRef = React.useRef<HTMLDivElement>(null);
const value = getFormValue(
elements,
appState,
(element) => element.opacity,
true,
appState.currentItemOpacity,
);
useEffect(() => {
if (rangeRef.current && valueRef.current) {
const rangeElement = rangeRef.current;
const valueElement = valueRef.current;
const inputWidth = rangeElement.offsetWidth;
const thumbWidth = 15; // 15 is the width of the thumb
const position =
(value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
valueElement.style.left = `${position}px`;
rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`;
}
}, [value]);
return (
<label className="control-label">
{t("labels.opacity")}
<div className="range-wrapper">
<input
ref={rangeRef}
type="range"
min="0"
max="100"
step="10"
onChange={(event) => {
updateData(+event.target.value);
}}
value={value}
className="range-input"
data-testid={testId}
/>
<div className="value-bubble" ref={valueRef}>
{value !== 0 ? value : null}
</div>
<div className="zero-label">0</div>
</div>
</label>
);
};
+4 -10
View File
@@ -7,12 +7,10 @@ import { debounce } from "lodash";
import type { AppClassProperties } from "../types"; import type { AppClassProperties } from "../types";
import { isTextElement, newTextElement } from "../element"; import { isTextElement, newTextElement } from "../element";
import type { ExcalidrawTextElement } from "../element/types"; import type { ExcalidrawTextElement } from "../element/types";
import { measureText } from "../element/textElement";
import { addEventListener, getFontString } from "../utils"; import { addEventListener, getFontString } from "../utils";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import clsx from "clsx"; import clsx from "clsx";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "../editor-jotai";
import { jotaiScope } from "../jotai";
import { t } from "../i18n"; import { t } from "../i18n";
import { isElementCompletelyInViewport } from "../element/sizeHelpers"; import { isElementCompletelyInViewport } from "../element/sizeHelpers";
import { randomInteger } from "../random"; import { randomInteger } from "../random";
@@ -21,6 +19,7 @@ import { useStable } from "../hooks/useStable";
import "./SearchMenu.scss"; import "./SearchMenu.scss";
import { round } from "../../math"; import { round } from "../../math";
import { measureText } from "../element/textMeasurements";
const searchQueryAtom = atom<string>(""); const searchQueryAtom = atom<string>("");
export const searchItemInFocusAtom = atom<number | null>(null); export const searchItemInFocusAtom = atom<number | null>(null);
@@ -58,7 +57,7 @@ export const SearchMenu = () => {
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope); const [inputValue, setInputValue] = useAtom(searchQueryAtom);
const searchQuery = inputValue.trim() as SearchQuery; const searchQuery = inputValue.trim() as SearchQuery;
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
@@ -70,10 +69,7 @@ export const SearchMenu = () => {
const searchedQueryRef = useRef<SearchQuery | null>(null); const searchedQueryRef = useRef<SearchQuery | null>(null);
const lastSceneNonceRef = useRef<number | undefined>(undefined); const lastSceneNonceRef = useRef<number | undefined>(undefined);
const [focusIndex, setFocusIndex] = useAtom( const [focusIndex, setFocusIndex] = useAtom(searchItemInFocusAtom);
searchItemInFocusAtom,
jotaiScope,
);
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
useEffect(() => { useEffect(() => {
@@ -611,7 +607,6 @@ const getMatchedLines = (
textToStart, textToStart,
getFontString(textElement), getFontString(textElement),
textElement.lineHeight, textElement.lineHeight,
true,
); );
// measureText returns a non-zero width for the empty string // measureText returns a non-zero width for the empty string
@@ -625,7 +620,6 @@ const getMatchedLines = (
lineIndexRange.line, lineIndexRange.line,
getFontString(textElement), getFontString(textElement),
textElement.lineHeight, textElement.lineHeight,
true,
); );
const spaceToStart = const spaceToStart =
@@ -8,8 +8,7 @@ import React, {
useCallback, useCallback,
} from "react"; } from "react";
import { Island } from "../Island"; import { Island } from "../Island";
import { atom, useSetAtom } from "jotai"; import { atom, useSetAtom } from "../../editor-jotai";
import { jotaiScope } from "../../jotai";
import type { SidebarProps, SidebarPropsContextValue } from "./common"; import type { SidebarProps, SidebarPropsContextValue } from "./common";
import { SidebarPropsContext } from "./common"; import { SidebarPropsContext } from "./common";
import { SidebarHeader } from "./SidebarHeader"; import { SidebarHeader } from "./SidebarHeader";
@@ -58,7 +57,7 @@ export const SidebarInner = forwardRef(
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom, jotaiScope); const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom);
useLayoutEffect(() => { useLayoutEffect(() => {
setIsSidebarDockedAtom(!!docked); setIsSidebarDockedAtom(!!docked);
@@ -237,6 +237,7 @@ const MultiPosition = ({
const [x1, y1] = getCommonBounds(elementsInUnit); const [x1, y1] = getCommonBounds(elementsInUnit);
return Math.round((property === "x" ? x1 : y1) * 100) / 100; return Math.round((property === "x" ? x1 : y1) * 100) / 100;
} }
const [el] = elementsInUnit; const [el] = elementsInUnit;
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2]; const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
@@ -31,6 +31,7 @@ import "./Stats.scss";
import { isGridModeEnabled } from "../../snapping"; import { isGridModeEnabled } from "../../snapping";
import { getUncroppedWidthAndHeight } from "../../element/cropElement"; import { getUncroppedWidthAndHeight } from "../../element/cropElement";
import { round } from "../../../math"; import { round } from "../../../math";
import { frameAndChildrenSelectedTogether } from "../../frame";
interface StatsProps { interface StatsProps {
app: AppClassProperties; app: AppClassProperties;
@@ -170,6 +171,10 @@ export const StatsInner = memo(
return getAtomicUnits(selectedElements, appState); return getAtomicUnits(selectedElements, appState);
}, [selectedElements, appState]); }, [selectedElements, appState]);
const _frameAndChildrenSelectedTogether = useMemo(() => {
return frameAndChildrenSelectedTogether(selectedElements);
}, [selectedElements]);
return ( return (
<div className="exc-stats"> <div className="exc-stats">
<Island padding={3}> <Island padding={3}>
@@ -226,7 +231,7 @@ export const StatsInner = memo(
{renderCustomStats?.(elements, appState)} {renderCustomStats?.(elements, appState)}
</Collapsible> </Collapsible>
{selectedElements.length > 0 && ( {!_frameAndChildrenSelectedTogether && selectedElements.length > 0 && (
<div <div
id="elementStats" id="elementStats"
style={{ style={{
@@ -25,7 +25,7 @@ import type { BinaryFiles } from "../../types";
import { ArrowRightIcon } from "../icons"; import { ArrowRightIcon } from "../icons";
import "./TTDDialog.scss"; import "./TTDDialog.scss";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "../../editor-jotai";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
import { InlineIcon } from "../InlineIcon"; import { InlineIcon } from "../InlineIcon";
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut"; import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
+135 -129
View File
@@ -55,146 +55,152 @@ type ToolButtonProps =
onPointerDown?(data: { pointerType: PointerType }): void; onPointerDown?(data: { pointerType: PointerType }): void;
}); });
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { export const ToolButton = React.forwardRef(
const { id: excalId } = useExcalidrawContainer(); (
const innerRef = React.useRef(null); {
React.useImperativeHandle(ref, () => innerRef.current); size = "medium",
const sizeCn = `ToolIcon_size_${props.size}`; visible = true,
className = "",
...props
}: ToolButtonProps,
ref,
) => {
const { id: excalId } = useExcalidrawContainer();
const innerRef = React.useRef(null);
React.useImperativeHandle(ref, () => innerRef.current);
const sizeCn = `ToolIcon_size_${size}`;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const onClick = async (event: React.MouseEvent) => { const onClick = async (event: React.MouseEvent) => {
const ret = "onClick" in props && props.onClick?.(event); const ret = "onClick" in props && props.onClick?.(event);
if (isPromiseLike(ret)) { if (isPromiseLike(ret)) {
try { try {
setIsLoading(true); setIsLoading(true);
await ret; await ret;
} catch (error: any) { } catch (error: any) {
if (!(error instanceof AbortError)) { if (!(error instanceof AbortError)) {
throw error; throw error;
} else { } else {
console.warn(error); console.warn(error);
} }
} finally { } finally {
if (isMountedRef.current) { if (isMountedRef.current) {
setIsLoading(false); setIsLoading(false);
}
} }
} }
}
};
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
}; };
}, []);
const lastPointerTypeRef = useRef<PointerType | null>(null); useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const lastPointerTypeRef = useRef<PointerType | null>(null);
if (
props.type === "button" ||
props.type === "icon" ||
props.type === "submit"
) {
const type = (props.type === "icon" ? "button" : props.type) as
| "button"
| "submit";
return (
<button
className={clsx(
"ToolIcon_type_button",
sizeCn,
className,
visible && !props.hidden
? "ToolIcon_type_button--show"
: "ToolIcon_type_button--hide",
{
ToolIcon: !props.hidden,
"ToolIcon--selected": props.selected,
"ToolIcon--plain": props.type === "icon",
},
)}
style={props.style}
data-testid={props["data-testid"]}
hidden={props.hidden}
title={props.title}
aria-label={props["aria-label"]}
type={type}
onClick={onClick}
ref={innerRef}
disabled={isLoading || props.isLoading || !!props.disabled}
>
{(props.icon || props.label) && (
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled={!!props.disabled}
>
{props.icon || props.label}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">
{props.keyBindingLabel}
</span>
)}
{props.isLoading && <Spinner />}
</div>
)}
{props.showAriaLabel && (
<div className="ToolIcon__label">
{props["aria-label"]} {isLoading && <Spinner />}
</div>
)}
{props.children}
</button>
);
}
if (
props.type === "button" ||
props.type === "icon" ||
props.type === "submit"
) {
const type = (props.type === "icon" ? "button" : props.type) as
| "button"
| "submit";
return ( return (
<button <label
className={clsx( className={clsx("ToolIcon", className)}
"ToolIcon_type_button",
sizeCn,
props.className,
props.visible && !props.hidden
? "ToolIcon_type_button--show"
: "ToolIcon_type_button--hide",
{
ToolIcon: !props.hidden,
"ToolIcon--selected": props.selected,
"ToolIcon--plain": props.type === "icon",
},
)}
style={props.style}
data-testid={props["data-testid"]}
hidden={props.hidden}
title={props.title} title={props.title}
aria-label={props["aria-label"]} onPointerDown={(event) => {
type={type} lastPointerTypeRef.current = event.pointerType || null;
onClick={onClick} props.onPointerDown?.({ pointerType: event.pointerType || null });
ref={innerRef}
disabled={isLoading || props.isLoading || !!props.disabled}
>
{(props.icon || props.label) && (
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled={!!props.disabled}
>
{props.icon || props.label}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">
{props.keyBindingLabel}
</span>
)}
{props.isLoading && <Spinner />}
</div>
)}
{props.showAriaLabel && (
<div className="ToolIcon__label">
{props["aria-label"]} {isLoading && <Spinner />}
</div>
)}
{props.children}
</button>
);
}
return (
<label
className={clsx("ToolIcon", props.className)}
title={props.title}
onPointerDown={(event) => {
lastPointerTypeRef.current = event.pointerType || null;
props.onPointerDown?.({ pointerType: event.pointerType || null });
}}
onPointerUp={() => {
requestAnimationFrame(() => {
lastPointerTypeRef.current = null;
});
}}
>
<input
className={`ToolIcon_type_radio ${sizeCn}`}
type="radio"
name={props.name}
aria-label={props["aria-label"]}
aria-keyshortcuts={props["aria-keyshortcuts"]}
data-testid={props["data-testid"]}
id={`${excalId}-${props.id}`}
onChange={() => {
props.onChange?.({ pointerType: lastPointerTypeRef.current });
}} }}
checked={props.checked} onPointerUp={() => {
ref={innerRef} requestAnimationFrame(() => {
/> lastPointerTypeRef.current = null;
<div className="ToolIcon__icon"> });
{props.icon} }}
{props.keyBindingLabel && ( >
<span className="ToolIcon__keybinding">{props.keyBindingLabel}</span> <input
)} className={`ToolIcon_type_radio ${sizeCn}`}
</div> type="radio"
</label> name={props.name}
); aria-label={props["aria-label"]}
}); aria-keyshortcuts={props["aria-keyshortcuts"]}
data-testid={props["data-testid"]}
ToolButton.defaultProps = { id={`${excalId}-${props.id}`}
visible: true, onChange={() => {
className: "", props.onChange?.({ pointerType: lastPointerTypeRef.current });
size: "medium", }}
}; checked={props.checked}
ref={innerRef}
/>
<div className="ToolIcon__icon">
{props.icon}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">
{props.keyBindingLabel}
</span>
)}
</div>
</label>
);
},
);
ToolButton.displayName = "ToolButton"; ToolButton.displayName = "ToolButton";
@@ -4,6 +4,7 @@ import fallbackLangData from "../locales/en.json";
import Trans from "./Trans"; import Trans from "./Trans";
import type { TranslationKeys } from "../i18n"; import type { TranslationKeys } from "../i18n";
import { EditorJotaiProvider } from "../editor-jotai";
describe("Test <Trans/>", () => { describe("Test <Trans/>", () => {
it("should translate the the strings correctly", () => { it("should translate the the strings correctly", () => {
@@ -17,7 +18,7 @@ describe("Test <Trans/>", () => {
}; };
const { getByTestId } = render( const { getByTestId } = render(
<> <EditorJotaiProvider>
<div data-testid="test1"> <div data-testid="test1">
<Trans <Trans
i18nKey={"transTest.key1" as unknown as TranslationKeys} i18nKey={"transTest.key1" as unknown as TranslationKeys}
@@ -51,7 +52,7 @@ describe("Test <Trans/>", () => {
connect-link={(el) => <a href="https://example.com">{el}</a>} connect-link={(el) => <a href="https://example.com">{el}</a>}
/> />
</div> </div>
</>, </EditorJotaiProvider>,
); );
expect(getByTestId("test1").innerHTML).toEqual("Hello world"); expect(getByTestId("test1").innerHTML).toEqual("Hello world");
@@ -1,6 +1,6 @@
import { atom, useAtom } from "jotai";
import React, { useLayoutEffect, useRef } from "react"; import React, { useLayoutEffect, useRef } from "react";
import { useTunnels } from "../../context/tunnels"; import { useTunnels } from "../../context/tunnels";
import { atom } from "../../editor-jotai";
export const withInternalFallback = <P,>( export const withInternalFallback = <P,>(
componentName: string, componentName: string,
@@ -13,9 +13,11 @@ export const withInternalFallback = <P,>(
__fallback?: boolean; __fallback?: boolean;
} }
> = (props) => { > = (props) => {
const { jotaiScope } = useTunnels(); const {
tunnelsJotai: { useAtom },
} = useTunnels();
// for rerenders // for rerenders
const [, setCounter] = useAtom(renderAtom, jotaiScope); const [, setCounter] = useAtom(renderAtom);
// for initial & subsequent renders. Tracked as component state // for initial & subsequent renders. Tracked as component state
// due to excalidraw multi-instance scanerios. // due to excalidraw multi-instance scanerios.
const metaRef = useRef({ const metaRef = useRef({
@@ -171,15 +171,17 @@ export const Hyperlink = ({
}, [handleSubmit]); }, [handleSubmit]);
useEffect(() => { useEffect(() => {
let timeoutId: number | null = null;
if ( if (
inputRef && isEditing &&
inputRef.current && inputRef?.current &&
!(device.viewport.isMobile || device.isTouchScreen) !(device.viewport.isMobile || device.isTouchScreen)
) { ) {
inputRef.current.select(); inputRef.current.select();
} }
}, [isEditing, device.viewport.isMobile, device.isTouchScreen]);
useEffect(() => {
let timeoutId: number | null = null;
const handlePointerMove = (event: PointerEvent) => { const handlePointerMove = (event: PointerEvent) => {
if (isEditing) { if (isEditing) {
@@ -207,15 +209,7 @@ export const Hyperlink = ({
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
}; };
}, [ }, [appState, element, isEditing, setAppState, elementsMap]);
appState,
element,
isEditing,
setAppState,
elementsMap,
device.viewport.isMobile,
device.isTouchScreen,
]);
const handleRemove = useCallback(() => { const handleRemove = useCallback(() => {
trackEvent("hyperlink", "delete"); trackEvent("hyperlink", "delete");
+6 -5
View File
@@ -1216,11 +1216,12 @@ export const EdgeRoundIcon = createIcon(
); );
export const ArrowheadNoneIcon = createIcon( export const ArrowheadNoneIcon = createIcon(
<path d="M6 10H34" stroke="currentColor" strokeWidth={2} fill="none" />, <g stroke="currentColor" opacity={0.3} strokeWidth={2}>
{ <path d="M12 12l9 0" />
width: 40, <path d="M3 9l6 6" />
height: 20, <path d="M3 15l6 -6" />
}, </g>,
tablerIconProps,
); );
export const ArrowheadArrowIcon = React.memo( export const ArrowheadArrowIcon = React.memo(
@@ -32,9 +32,8 @@ import {
actionToggleTheme, actionToggleTheme,
} from "../../actions"; } from "../../actions";
import clsx from "clsx"; import clsx from "clsx";
import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { jotaiScope } from "../../jotai"; import { useSetAtom } from "../../editor-jotai";
import { useUIAppState } from "../../context/ui-appState"; import { useUIAppState } from "../../context/ui-appState";
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState"; import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
import Trans from "../Trans"; import Trans from "../Trans";
@@ -189,10 +188,7 @@ Help.displayName = "Help";
export const ClearCanvas = () => { export const ClearCanvas = () => {
const { t } = useI18n(); const { t } = useI18n();
const setActiveConfirmDialog = useSetAtom( const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
activeConfirmDialogAtom,
jotaiScope,
);
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionClearCanvas)) { if (!actionManager.isActionEnabled(actionClearCanvas)) {
@@ -1,6 +1,6 @@
.excalidraw { .excalidraw {
.excalifont { .excalifont {
font-family: "Excalifont"; font-family: "Excalifont", "Xiaolai";
} }
// WelcomeSreen common // WelcomeSreen common
+3
View File
@@ -458,3 +458,6 @@ export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3; export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
export const ELEMENT_LINK_KEY = "element"; export const ELEMENT_LINK_KEY = "element";
/** used in tests */
export const ORIG_ID = Symbol.for("__test__originalId__");
+7 -2
View File
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import tunnel from "tunnel-rat"; import tunnel from "tunnel-rat";
import { createIsolation } from "jotai-scope";
export type Tunnel = ReturnType<typeof tunnel>; export type Tunnel = ReturnType<typeof tunnel>;
@@ -14,13 +15,17 @@ type TunnelsContextValue = {
DefaultSidebarTabTriggersTunnel: Tunnel; DefaultSidebarTabTriggersTunnel: Tunnel;
OverwriteConfirmDialogTunnel: Tunnel; OverwriteConfirmDialogTunnel: Tunnel;
TTDDialogTriggerTunnel: Tunnel; TTDDialogTriggerTunnel: Tunnel;
jotaiScope: symbol; // this can be removed once we create jotai stores per each editor
// instance
tunnelsJotai: ReturnType<typeof createIsolation>;
}; };
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!); export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
export const useTunnels = () => React.useContext(TunnelsContext); export const useTunnels = () => React.useContext(TunnelsContext);
const tunnelsJotai = createIsolation();
export const useInitializeTunnels = () => { export const useInitializeTunnels = () => {
return React.useMemo((): TunnelsContextValue => { return React.useMemo((): TunnelsContextValue => {
return { return {
@@ -34,7 +39,7 @@ export const useInitializeTunnels = () => {
DefaultSidebarTabTriggersTunnel: tunnel(), DefaultSidebarTabTriggersTunnel: tunnel(),
OverwriteConfirmDialogTunnel: tunnel(), OverwriteConfirmDialogTunnel: tunnel(),
TTDDialogTriggerTunnel: tunnel(), TTDDialogTriggerTunnel: tunnel(),
jotaiScope: Symbol(), tunnelsJotai,
}; };
}, []); }, []);
}; };
+7 -1
View File
@@ -649,15 +649,21 @@ body.excalidraw-cursor-resize * {
@include filledButtonOnCanvas; @include filledButtonOnCanvas;
} }
.App-mobile-menu,
.App-menu__left { .App-menu__left {
--button-border: transparent; --button-border: transparent;
--button-bg: var(--color-surface-mid); --button-bg: var(--color-surface-mid);
}
@at-root .excalidraw.theme--dark#{&} { @at-root .excalidraw.theme--dark#{&} {
.App-mobile-menu,
.App-menu__left {
--button-hover-bg: #363541; --button-hover-bg: #363541;
--button-bg: var(--color-surface-high); --button-bg: var(--color-surface-high);
} }
}
.App-menu__left {
.buttonList { .buttonList {
padding: 0.25rem 0; padding: 0.25rem 0;
} }
+5
View File
@@ -53,6 +53,9 @@
--scrollbar-thumb: var(--button-gray-2); --scrollbar-thumb: var(--button-gray-2);
--scrollbar-thumb-hover: var(--button-gray-3); --scrollbar-thumb-hover: var(--button-gray-3);
--color-slider-track: hsl(240, 100%, 90%);
--color-slider-thumb: var(--color-gray-80);
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07), --modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198), 0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275), 0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275),
@@ -207,6 +210,8 @@
--scrollbar-thumb: #{$oc-gray-8}; --scrollbar-thumb: #{$oc-gray-8};
--scrollbar-thumb-hover: #{$oc-gray-7}; --scrollbar-thumb-hover: #{$oc-gray-7};
--color-slider-track: hsl(244, 23%, 39%);
// will be inverted to a lighter color. // will be inverted to a lighter color.
--color-selection: #3530c4; --color-selection: #3530c4;
@@ -95,7 +95,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 33.519031369643244, "height": 35,
"id": Any<String>, "id": Any<String>,
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@@ -109,8 +109,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
0.5, 0.5,
], ],
[ [
382.47606040672997, 394.5,
34.019031369643244, 34.5,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -128,9 +128,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 7, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 381.97606040672997, "width": 395,
"x": 247, "x": 247,
"y": 420, "y": 420,
} }
@@ -167,7 +167,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
0, 0,
], ],
[ [
389.5, 399.5,
0, 0,
], ],
], ],
@@ -186,10 +186,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 390, "width": 400,
"x": 237, "x": 227,
"y": 450, "y": 450,
} }
`; `;
@@ -319,7 +319,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"verticalAlign": "top", "verticalAlign": "top",
"width": 100, "width": 100,
"x": 560, "x": 560,
"y": 236.95454545454544, "y": 226.5,
} }
`; `;
@@ -339,13 +339,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": { "endBinding": {
"elementId": "text-2", "elementId": "text-2",
"fixedPoint": null, "fixedPoint": null,
"focus": 1.625925925925924, "focus": 0,
"gap": 14, "gap": 14,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 18.278619528619487, "height": 0,
"id": Any<String>, "id": Any<String>,
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@@ -356,11 +356,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"points": [ "points": [
[ [
0.5, 0.5,
-0.5, 0,
], ],
[ [
357.2037037037038, 99.5,
-17.778619528619487, 0,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -378,11 +378,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 357.7037037037038, "width": 100,
"x": 171, "x": 255,
"y": 249.45454545454544, "y": 239,
} }
`; `;
@@ -482,7 +482,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 255, "x": 255,
@@ -660,7 +660,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 255, "x": 255,
@@ -1505,7 +1505,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
0, 0,
], ],
[ [
270.98528125, 272.485,
0, 0,
], ],
], ],
@@ -1526,10 +1526,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 7, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 270.48528125, "width": 272.985,
"x": 112.76171875, "x": 111.262,
"y": 57, "y": 57,
} }
`; `;
@@ -1587,11 +1587,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 0, "width": 0,
"x": 83.015625, "x": 77.017,
"y": 81.5, "y": 79,
} }
`; `;
+105
View File
@@ -0,0 +1,105 @@
import { validateLibraryUrl } from "./library";
describe("validateLibraryUrl", () => {
it("should validate hostname & pathname", () => {
// valid hostnames
// -------------------------------------------------------------------------
expect(
validateLibraryUrl("https://www.excalidraw.com", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://library.excalidraw.com", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://library.excalidraw.com", [
"library.excalidraw.com",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/", ["excalidraw.com/"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com", ["excalidraw.com/"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/", ["excalidraw.com"]),
).toBe(true);
// valid pathnames
// -------------------------------------------------------------------------
expect(
validateLibraryUrl("https://excalidraw.com/path", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/path/", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path", [
"excalidraw.com/specific/path",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path/", [
"excalidraw.com/specific/path",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path", [
"excalidraw.com/specific/path/",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path/other", [
"excalidraw.com/specific/path",
]),
).toBe(true);
// invalid hostnames
// -------------------------------------------------------------------------
expect(() =>
validateLibraryUrl("https://xexcalidraw.com", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://x-excalidraw.com", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.comx", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.comx", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com.mx", ["excalidraw.com"]),
).toThrow();
// protocol must be https
expect(() =>
validateLibraryUrl("http://excalidraw.com.mx", ["excalidraw.com"]),
).toThrow();
// invalid pathnames
// -------------------------------------------------------------------------
expect(() =>
validateLibraryUrl("https://excalidraw.com/specific/other/path", [
"excalidraw.com/specific/path",
]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com/specific/paths", [
"excalidraw.com/specific/path",
]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com/specific/path-s", [
"excalidraw.com/specific/path",
]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com/some/specific/path", [
"excalidraw.com/specific/path",
]),
).toThrow();
});
});
+38 -17
View File
@@ -8,8 +8,7 @@ import type {
} from "../types"; } from "../types";
import { restoreLibraryItems } from "./restore"; import { restoreLibraryItems } from "./restore";
import type App from "../components/App"; import type App from "../components/App";
import { atom } from "jotai"; import { atom, editorJotaiStore } from "../editor-jotai";
import { jotaiStore } from "../jotai";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import { getCommonBoundingBox } from "../element/bounds"; import { getCommonBoundingBox } from "../element/bounds";
import { AbortError } from "../errors"; import { AbortError } from "../errors";
@@ -37,7 +36,18 @@ import { Queue } from "../queue";
import { hashElementsVersion, hashString } from "../element"; import { hashElementsVersion, hashString } from "../element";
import { toValidURL } from "./url"; import { toValidURL } from "./url";
const ALLOWED_LIBRARY_HOSTNAMES = ["excalidraw.com"]; /**
* format: hostname or hostname/pathname
*
* Both hostname and pathname are matched partially,
* hostname from the end, pathname from the start, with subdomain/path
* boundaries
**/
const ALLOWED_LIBRARY_URLS = [
"excalidraw.com",
// when installing from github PRs
"raw.githubusercontent.com/excalidraw/excalidraw-libraries",
];
type LibraryUpdate = { type LibraryUpdate = {
/** deleted library items since last onLibraryChange event */ /** deleted library items since last onLibraryChange event */
@@ -191,13 +201,13 @@ class Library {
private notifyListeners = () => { private notifyListeners = () => {
if (this.updateQueue.length > 0) { if (this.updateQueue.length > 0) {
jotaiStore.set(libraryItemsAtom, (s) => ({ editorJotaiStore.set(libraryItemsAtom, (s) => ({
status: "loading", status: "loading",
libraryItems: this.currLibraryItems, libraryItems: this.currLibraryItems,
isInitialized: s.isInitialized, isInitialized: s.isInitialized,
})); }));
} else { } else {
jotaiStore.set(libraryItemsAtom, { editorJotaiStore.set(libraryItemsAtom, {
status: "loaded", status: "loaded",
libraryItems: this.currLibraryItems, libraryItems: this.currLibraryItems,
isInitialized: true, isInitialized: true,
@@ -225,7 +235,7 @@ class Library {
destroy = () => { destroy = () => {
this.updateQueue = []; this.updateQueue = [];
this.currLibraryItems = []; this.currLibraryItems = [];
jotaiStore.set(libraryItemSvgsCache, new Map()); editorJotaiStore.set(libraryItemSvgsCache, new Map());
// TODO uncomment after/if we make jotai store scoped to each excal instance // TODO uncomment after/if we make jotai store scoped to each excal instance
// jotaiStore.set(libraryItemsAtom, { // jotaiStore.set(libraryItemsAtom, {
// status: "loading", // status: "loading",
@@ -470,26 +480,37 @@ export const distributeLibraryItemsOnSquareGrid = (
return resElements; return resElements;
}; };
const validateLibraryUrl = ( export const validateLibraryUrl = (
libraryUrl: string, libraryUrl: string,
/** /**
* If supplied, takes precedence over the default whitelist. * @returns `true` if the URL is valid, throws otherwise.
* Return `true` if the URL is valid.
*/ */
validator?: (libraryUrl: string) => boolean, validator:
): boolean => { | ((libraryUrl: string) => boolean)
| string[] = ALLOWED_LIBRARY_URLS,
): true => {
if ( if (
validator typeof validator === "function"
? validator(libraryUrl) ? validator(libraryUrl)
: ALLOWED_LIBRARY_HOSTNAMES.includes( : validator.some((allowedUrlDef) => {
new URL(libraryUrl).hostname.split(".").slice(-2).join("."), const allowedUrl = new URL(
) `https://${allowedUrlDef.replace(/^https?:\/\//, "")}`,
);
const { hostname, pathname } = new URL(libraryUrl);
return (
new RegExp(`(^|\\.)${allowedUrl.hostname}$`).test(hostname) &&
new RegExp(
`^${allowedUrl.pathname.replace(/\/+$/, "")}(/+|$)`,
).test(pathname)
);
})
) { ) {
return true; return true;
} }
console.error(`Invalid or disallowed library URL: "${libraryUrl}"`); throw new Error(`Invalid or disallowed library URL: "${libraryUrl}"`);
throw new Error("Invalid or disallowed library URL");
}; };
export const parseLibraryTokensFromUrl = () => { export const parseLibraryTokensFromUrl = () => {
+75 -13
View File
@@ -1,5 +1,6 @@
import type { import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawElementType, ExcalidrawElementType,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@@ -45,7 +46,7 @@ import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils"; import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import type { MarkOptional, Mutable } from "../utility-types"; import type { MarkOptional, Mutable } from "../utility-types";
import { detectLineHeight, getContainerElement } from "../element/textElement"; import { getContainerElement } from "../element/textElement";
import { normalizeLink } from "./url"; import { normalizeLink } from "./url";
import { syncInvalidIndices } from "../fractionalIndex"; import { syncInvalidIndices } from "../fractionalIndex";
import { getSizeFromPoints } from "../points"; import { getSizeFromPoints } from "../points";
@@ -58,6 +59,7 @@ import {
} from "../scene"; } from "../scene";
import type { LocalPoint, Radians } from "../../math"; import type { LocalPoint, Radians } from "../../math";
import { isFiniteNumber, pointFrom } from "../../math"; import { isFiniteNumber, pointFrom } from "../../math";
import { detectLineHeight } from "../element/textMeasurements";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@@ -101,23 +103,38 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
return DEFAULT_FONT_FAMILY; return DEFAULT_FONT_FAMILY;
}; };
const repairBinding = ( const repairBinding = <T extends ExcalidrawLinearElement>(
element: ExcalidrawLinearElement, element: T,
binding: PointBinding | FixedPointBinding | null, binding: PointBinding | FixedPointBinding | null,
): PointBinding | FixedPointBinding | null => { ): T extends ExcalidrawElbowArrowElement
? FixedPointBinding | null
: PointBinding | FixedPointBinding | null => {
if (!binding) { if (!binding) {
return null; return null;
} }
return { const focus = binding.focus || 0;
...binding,
focus: binding.focus || 0, if (isElbowArrow(element)) {
...(isElbowArrow(element) && isFixedPointBinding(binding) const fixedPointBinding:
| ExcalidrawElbowArrowElement["startBinding"]
| ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding)
? { ? {
...binding,
focus,
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]), fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
} }
: {}), : null;
};
return fixedPointBinding;
}
return {
...binding,
focus,
} as T extends ExcalidrawElbowArrowElement
? FixedPointBinding | null
: PointBinding | FixedPointBinding | null;
}; };
const restoreElementWithProperties = < const restoreElementWithProperties = <
@@ -189,6 +206,24 @@ const restoreElementWithProperties = <
"customData" in extra ? extra.customData : element.customData; "customData" in extra ? extra.customData : element.customData;
} }
// NOTE (mtolmacs): This is a temporary check to detect extremely large
// element position or sizing
if (
element.x < -1e6 ||
element.x > 1e6 ||
element.y < -1e6 ||
element.y > 1e6 ||
element.width < -1e6 ||
element.width > 1e6 ||
element.height < -1e6 ||
element.height > 1e6
) {
console.error(
"Restore element with properties size or position is too large",
{ element },
);
}
return { return {
// spread the original element properties to not lose unknown ones // spread the original element properties to not lose unknown ones
// for forward-compatibility // for forward-compatibility
@@ -203,6 +238,21 @@ const restoreElementWithProperties = <
const restoreElement = ( const restoreElement = (
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>, element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
): typeof element | null => { ): typeof element | null => {
// NOTE (mtolmacs): This is a temporary check to detect extremely large
// element position or sizing
if (
element.x < -1e6 ||
element.x > 1e6 ||
element.y < -1e6 ||
element.y > 1e6 ||
element.width < -1e6 ||
element.width > 1e6 ||
element.height < -1e6 ||
element.height > 1e6
) {
console.error("Restore element size or position is too large", { element });
}
switch (element.type) { switch (element.type) {
case "text": case "text":
let fontSize = element.fontSize; let fontSize = element.fontSize;
@@ -308,8 +358,7 @@ const restoreElement = (
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element)); ({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
} }
// TODO: Separate arrow from linear element const base = {
return restoreElementWithProperties(element as ExcalidrawArrowElement, {
type: element.type, type: element.type,
startBinding: repairBinding(element, element.startBinding), startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding), endBinding: repairBinding(element, element.endBinding),
@@ -321,7 +370,20 @@ const restoreElement = (
y, y,
elbowed: (element as ExcalidrawArrowElement).elbowed, elbowed: (element as ExcalidrawArrowElement).elbowed,
...getSizeFromPoints(points), ...getSizeFromPoints(points),
}); } as const;
// TODO: Separate arrow from linear element
return isElbowArrow(element)
? restoreElementWithProperties(element as ExcalidrawElbowArrowElement, {
...base,
elbowed: true,
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
fixedSegments: element.fixedSegments,
startIsSpecial: element.startIsSpecial,
endIsSpecial: element.endIsSpecial,
})
: restoreElementWithProperties(element as ExcalidrawArrowElement, base);
} }
// generic elements // generic elements
+1 -1
View File
@@ -19,7 +19,6 @@ import {
newMagicFrameElement, newMagicFrameElement,
newTextElement, newTextElement,
} from "../element/newElement"; } from "../element/newElement";
import { measureText, normalizeText } from "../element/textElement";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
@@ -55,6 +54,7 @@ import { syncInvalidIndices } from "../fractionalIndex";
import { getLineHeight } from "../fonts"; import { getLineHeight } from "../fonts";
import { isArrowElement } from "../element/typeChecks"; import { isArrowElement } from "../element/typeChecks";
import { pointFrom, type LocalPoint } from "../../math"; import { pointFrom, type LocalPoint } from "../../math";
import { measureText, normalizeText } from "../element/textMeasurements";
export type ValidLinearElement = { export type ValidLinearElement = {
type: "arrow" | "line"; type: "arrow" | "line";
+2 -2
View File
@@ -25,7 +25,7 @@ describe("normalizeLink", () => {
expect(normalizeLink("file://")).toBe("file://"); expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)"); expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
expect(normalizeLink("[[test]]")).toBe("[[test]]"); expect(normalizeLink("[[test]]")).toBe("[[test]]");
expect(normalizeLink("<test>")).toBe("&lt;test&gt;"); expect(normalizeLink("<test>")).toBe("<test>");
expect(normalizeLink("test&")).toBe("test&amp;"); expect(normalizeLink("test&")).toBe("test&");
}); });
}); });
+2 -2
View File
@@ -1,12 +1,12 @@
import { sanitizeUrl } from "@braintree/sanitize-url"; import { sanitizeUrl } from "@braintree/sanitize-url";
import { sanitizeHTMLAttribute } from "../utils"; import { escapeDoubleQuotes } from "../utils";
export const normalizeLink = (link: string) => { export const normalizeLink = (link: string) => {
link = link.trim(); link = link.trim();
if (!link) { if (!link) {
return link; return link;
} }
return sanitizeUrl(sanitizeHTMLAttribute(link)); return sanitizeUrl(escapeDoubleQuotes(link));
}; };
export const isLocalLink = (link: string | null) => { export const isLocalLink = (link: string | null) => {
+13
View File
@@ -0,0 +1,13 @@
// eslint-disable-next-line no-restricted-imports
import { atom, createStore, type PrimitiveAtom } from "jotai";
import { createIsolation } from "jotai-scope";
const jotai = createIsolation();
export { atom, PrimitiveAtom };
export const { useAtom, useSetAtom, useAtomValue, useStore } = jotai;
export const EditorJotaiProvider: ReturnType<
typeof createIsolation
>["Provider"] = jotai.Provider;
export const editorJotaiStore: ReturnType<typeof createStore> = createStore();
+125 -36
View File
@@ -32,7 +32,6 @@ import type { Bounds } from "./bounds";
import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds"; import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds";
import type { AppState } from "../types"; import type { AppState } from "../types";
import { isPointOnShape } from "../../utils/collision"; import { isPointOnShape } from "../../utils/collision";
import { getElementAtPosition } from "../scene";
import { import {
isArrowElement, isArrowElement,
isBindableElement, isBindableElement,
@@ -49,7 +48,11 @@ import type { ElementUpdate } from "./mutateElement";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import type Scene from "../scene/Scene"; import type Scene from "../scene/Scene";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils"; import {
arrayToMap,
isBindingFallthroughEnabled,
tupleToCoors,
} from "../utils";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes"; import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
@@ -425,7 +428,8 @@ export const maybeBindLinearElement = (
elements, elements,
elementsMap, elementsMap,
appState.zoom, appState.zoom,
isElbowArrow(linearElement) && isElbowArrow(linearElement), isElbowArrow(linearElement),
isElbowArrow(linearElement),
); );
if (hoveredElement !== null) { if (hoveredElement !== null) {
@@ -504,12 +508,6 @@ export const bindLinearElement = (
}), }),
}); });
} }
// update bound elements to make sure the binding tips are in sync with
// the normalized gap from above
if (!isElbowArrow(linearElement)) {
updateBoundElements(hoveredElement, elementsMap);
}
}; };
// Don't bind both ends of a simple segment // Don't bind both ends of a simple segment
@@ -564,8 +562,65 @@ export const getHoveredElementForBinding = (
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
fullShape?: boolean, fullShape?: boolean,
considerAllElements?: boolean,
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
const hoveredElement = getElementAtPosition( if (considerAllElements) {
let cullRest = false;
const candidateElements = getAllElementsAtPositionForBinding(
elements,
(element) =>
isBindableElement(element, false) &&
bindingBorderTest(
element,
pointerCoords,
elementsMap,
zoom,
(fullShape ||
!isBindingFallthroughEnabled(
element as ExcalidrawBindableElement,
)) &&
// disable fullshape snapping for frame elements so we
// can bind to frame children
!isFrameLikeElement(element),
),
).filter((element) => {
if (cullRest) {
return false;
}
if (!isBindingFallthroughEnabled(element as ExcalidrawBindableElement)) {
cullRest = true;
}
return true;
}) as NonDeleted<ExcalidrawBindableElement>[] | null;
// Return early if there are no candidates or just one candidate
if (!candidateElements || candidateElements.length === 0) {
return null;
}
if (candidateElements.length === 1) {
return candidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
}
// Prefer the shape with the border being tested (if any)
const borderTestElements = candidateElements.filter((element) =>
bindingBorderTest(element, pointerCoords, elementsMap, zoom, false),
);
if (borderTestElements.length === 1) {
return borderTestElements[0];
}
// Prefer smaller shapes
return candidateElements
.sort(
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
)
.pop() as NonDeleted<ExcalidrawBindableElement>;
}
const hoveredElement = getElementAtPositionForBinding(
elements, elements,
(element) => (element) =>
isBindableElement(element, false) && isBindableElement(element, false) &&
@@ -576,13 +631,58 @@ export const getHoveredElementForBinding = (
zoom, zoom,
// disable fullshape snapping for frame elements so we // disable fullshape snapping for frame elements so we
// can bind to frame children // can bind to frame children
fullShape && !isFrameLikeElement(element), (fullShape || !isBindingFallthroughEnabled(element)) &&
!isFrameLikeElement(element),
), ),
); );
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null; return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
}; };
const getElementAtPositionForBinding = (
elements: readonly NonDeletedExcalidrawElement[],
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
) => {
let hitElement = null;
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
if (element.isDeleted) {
continue;
}
if (isAtPositionFn(element)) {
hitElement = element;
break;
}
}
return hitElement;
};
const getAllElementsAtPositionForBinding = (
elements: readonly NonDeletedExcalidrawElement[],
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
) => {
const elementsAtPosition: NonDeletedExcalidrawElement[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
if (element.isDeleted) {
continue;
}
if (isAtPositionFn(element)) {
elementsAtPosition.push(element);
}
}
return elementsAtPosition;
};
const calculateFocusAndGap = ( const calculateFocusAndGap = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
@@ -629,11 +729,9 @@ export const updateBoundElements = (
simultaneouslyUpdated?: readonly ExcalidrawElement[]; simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number }; newSize?: { width: number; height: number };
changedElements?: Map<string, OrderedExcalidrawElement>; changedElements?: Map<string, OrderedExcalidrawElement>;
zoom?: AppState["zoom"];
}, },
) => { ) => {
const { newSize, simultaneouslyUpdated, changedElements, zoom } = const { newSize, simultaneouslyUpdated } = options ?? {};
options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated, simultaneouslyUpdated,
); );
@@ -667,7 +765,7 @@ export const updateBoundElements = (
// `linearElement` is being moved/scaled already, just update the binding // `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(element.id)) { if (simultaneouslyUpdatedElementIds.has(element.id)) {
mutateElement(element, bindings); mutateElement(element, bindings, true);
return; return;
} }
@@ -709,23 +807,14 @@ export const updateBoundElements = (
}> => update !== null, }> => update !== null,
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, updates, {
element, ...(changedElement.id === element.startBinding?.elementId
updates, ? { startBinding: bindings.startBinding }
elementsMap, : {}),
{ ...(changedElement.id === element.endBinding?.elementId
...(changedElement.id === element.startBinding?.elementId ? { endBinding: bindings.endBinding }
? { startBinding: bindings.startBinding } : {}),
: {}), });
...(changedElement.id === element.endBinding?.elementId
? { endBinding: bindings.endBinding }
: {}),
},
{
changedElements,
zoom,
},
);
const boundText = getBoundTextElement(element, elementsMap); const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !boundText.isDeleted) { if (boundText && !boundText.isDeleted) {
@@ -784,9 +873,7 @@ export const getHeadingForElbowArrowSnap = (
); );
} }
const pointHeading = headingForPointFromElement(bindableElement, aabb, p); return headingForPointFromElement(bindableElement, aabb, p);
return pointHeading;
}; };
const getDistanceForBinding = ( const getDistanceForBinding = (
@@ -1239,6 +1326,8 @@ const getElligibleElementForBindingElement = (
elements, elements,
elementsMap, elementsMap,
zoom, zoom,
isElbowArrow(linearElement),
isElbowArrow(linearElement),
); );
}; };
@@ -2289,7 +2378,7 @@ export const getGlobalFixedPointForBindableElement = (
); );
}; };
const getGlobalFixedPoints = ( export const getGlobalFixedPoints = (
arrow: ExcalidrawElbowArrowElement, arrow: ExcalidrawElbowArrowElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): [GlobalPoint, GlobalPoint] => { ): [GlobalPoint, GlobalPoint] => {
+20 -10
View File
@@ -10,7 +10,7 @@ import type {
NullableGridSize, NullableGridSize,
PointerDownState, PointerDownState,
} from "../types"; } from "../types";
import { getBoundTextElement, getMinTextElementWidth } from "./textElement"; import { getBoundTextElement } from "./textElement";
import type Scene from "../scene/Scene"; import type Scene from "../scene/Scene";
import { import {
isArrowElement, isArrowElement,
@@ -22,6 +22,7 @@ import {
import { getFontString } from "../utils"; import { getFontString } from "../utils";
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants"; import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
import { getGridPoint } from "../snapping"; import { getGridPoint } from "../snapping";
import { getMinTextElementWidth } from "./textMeasurements";
export const dragSelectedElements = ( export const dragSelectedElements = (
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
@@ -42,9 +43,20 @@ export const dragSelectedElements = (
return; return;
} }
const selectedElements = _selectedElements.filter( const selectedElements = _selectedElements.filter((element) => {
(el) => !(isElbowArrow(el) && el.startBinding && el.endBinding), if (isElbowArrow(element) && element.startBinding && element.endBinding) {
); const startElement = _selectedElements.find(
(el) => el.id === element.startBinding?.elementId,
);
const endElement = _selectedElements.find(
(el) => el.id === element.endBinding?.elementId,
);
return startElement && endElement;
}
return true;
});
// we do not want a frame and its elements to be selected at the same time // we do not want a frame and its elements to be selected at the same time
// but when it happens (due to some bug), we want to avoid updating element // but when it happens (due to some bug), we want to avoid updating element
@@ -78,10 +90,8 @@ export const dragSelectedElements = (
elementsToUpdate.forEach((element) => { elementsToUpdate.forEach((element) => {
updateElementCoords(pointerDownState, element, adjustedOffset); updateElementCoords(pointerDownState, element, adjustedOffset);
if ( if (!isArrowElement(element)) {
// skip arrow labels since we calculate its position during render // skip arrow labels since we calculate its position during render
!isArrowElement(element)
) {
const textElement = getBoundTextElement( const textElement = getBoundTextElement(
element, element,
scene.getNonDeletedElementsMap(), scene.getNonDeletedElementsMap(),
@@ -89,10 +99,10 @@ export const dragSelectedElements = (
if (textElement) { if (textElement) {
updateElementCoords(pointerDownState, textElement, adjustedOffset); updateElementCoords(pointerDownState, textElement, adjustedOffset);
} }
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
simultaneouslyUpdated: Array.from(elementsToUpdate),
});
} }
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
simultaneouslyUpdated: Array.from(elementsToUpdate),
});
}); });
}; };
@@ -9,20 +9,121 @@ import {
render, render,
} from "../tests/test-utils"; } from "../tests/test-utils";
import { bindLinearElement } from "./binding"; import { bindLinearElement } from "./binding";
import { Excalidraw } from "../index"; import { Excalidraw, mutateElement } from "../index";
import { mutateElbowArrow } from "./routing";
import type { import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
} from "./types"; } from "./types";
import { ARROW_TYPE } from "../constants"; import { ARROW_TYPE } from "../constants";
import type { LocalPoint } from "../../math";
import { pointFrom } from "../../math"; import { pointFrom } from "../../math";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
describe("elbow arrow segment move", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("can move the second segment of a fully connected elbow arrow", () => {
UI.createElement("rectangle", {
x: -100,
y: -50,
width: 100,
height: 100,
});
UI.createElement("rectangle", {
x: 200,
y: 150,
width: 100,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(0, 0);
mouse.click();
mouse.moveTo(200, 200);
mouse.click();
mouse.reset();
mouse.moveTo(100, 100);
mouse.down();
mouse.moveTo(115, 100);
mouse.up();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawElbowArrowElement;
expect(h.state.selectedElementIds).toEqual({ [arrow.id]: true });
expect(arrow.fixedSegments?.length).toBe(1);
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[110, 0],
[110, 200],
[190, 200],
]);
mouse.reset();
mouse.moveTo(105, 74.275);
mouse.doubleClick();
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[110, 0],
[110, 200],
[190, 200],
]);
});
it("can move the second segment of an unconnected elbow arrow", () => {
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(0, 0);
mouse.click();
mouse.moveTo(250, 200);
mouse.click();
mouse.reset();
mouse.moveTo(125, 100);
mouse.down();
mouse.moveTo(130, 100);
mouse.up();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[130, 0],
[130, 200],
[250, 200],
]);
mouse.reset();
mouse.moveTo(130, 100);
mouse.doubleClick();
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[125, 0],
[125, 200],
[250, 200],
]);
});
});
describe("elbow arrow routing", () => { describe("elbow arrow routing", () => {
it("can properly generate orthogonal arrow points", () => { it("can properly generate orthogonal arrow points", () => {
const scene = new Scene(); const scene = new Scene();
@@ -31,10 +132,12 @@ describe("elbow arrow routing", () => {
elbowed: true, elbowed: true,
}) as ExcalidrawElbowArrowElement; }) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow); scene.insertElement(arrow);
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [ mutateElement(arrow, {
pointFrom(-45 - arrow.x, -100.1 - arrow.y), points: [
pointFrom(45 - arrow.x, 99.9 - arrow.y), pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
]); pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
],
});
expect(arrow.points).toEqual([ expect(arrow.points).toEqual([
[0, 0], [0, 0],
[0, 100], [0, 100],
@@ -81,7 +184,9 @@ describe("elbow arrow routing", () => {
expect(arrow.startBinding).not.toBe(null); expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null);
mutateElbowArrow(arrow, elementsMap, [pointFrom(0, 0), pointFrom(90, 200)]); mutateElement(arrow, {
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
});
expect(arrow.points).toEqual([ expect(arrow.points).toEqual([
[0, 0], [0, 0],
@@ -182,8 +287,6 @@ describe("elbow arrow ui", () => {
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([ expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
[0, 0], [0, 0],
[35, 0], [35, 0],
[35, 90],
[35, 90], // Note that coordinates are rounded above!
[35, 165], [35, 165],
[103, 165], [103, 165],
]); ]);
File diff suppressed because it is too large Load Diff
+4 -8
View File
@@ -1,11 +1,7 @@
import { register } from "../actions/register"; import { register } from "../actions/register";
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants"; import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
import type { ExcalidrawProps } from "../types"; import type { ExcalidrawProps } from "../types";
import { import { escapeDoubleQuotes, getFontString, updateActiveTool } from "../utils";
getFontString,
sanitizeHTMLAttribute,
updateActiveTool,
} from "../utils";
import { setCursorForShape } from "../cursor"; import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement"; import { newTextElement } from "./newElement";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
@@ -212,7 +208,7 @@ export const getEmbedLink = (
// Note that we don't attempt to parse the username as it can consist of // Note that we don't attempt to parse the username as it can consist of
// non-latin1 characters, and the username in the url can be set to anything // non-latin1 characters, and the username in the url can be set to anything
// without affecting the embed. // without affecting the embed.
const safeURL = sanitizeHTMLAttribute( const safeURL = escapeDoubleQuotes(
`https://twitter.com/x/status/${postId}`, `https://twitter.com/x/status/${postId}`,
); );
@@ -231,7 +227,7 @@ export const getEmbedLink = (
if (RE_REDDIT.test(link)) { if (RE_REDDIT.test(link)) {
const [, page, postId, title] = link.match(RE_REDDIT)!; const [, page, postId, title] = link.match(RE_REDDIT)!;
const safeURL = sanitizeHTMLAttribute( const safeURL = escapeDoubleQuotes(
`https://reddit.com/r/${page}/comments/${postId}/${title}`, `https://reddit.com/r/${page}/comments/${postId}/${title}`,
); );
const ret: IframeDataWithSandbox = { const ret: IframeDataWithSandbox = {
@@ -249,7 +245,7 @@ export const getEmbedLink = (
if (RE_GH_GIST.test(link)) { if (RE_GH_GIST.test(link)) {
const [, user, gistId] = link.match(RE_GH_GIST)!; const [, user, gistId] = link.match(RE_GH_GIST)!;
const safeURL = sanitizeHTMLAttribute( const safeURL = escapeDoubleQuotes(
`https://gist.github.com/${user}/${gistId}`, `https://gist.github.com/${user}/${gistId}`,
); );
const ret: IframeDataWithSandbox = { const ret: IframeDataWithSandbox = {
+43 -24
View File
@@ -10,13 +10,15 @@ import {
import { bindLinearElement } from "./binding"; import { bindLinearElement } from "./binding";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { newArrowElement, newElement } from "./newElement"; import { newArrowElement, newElement } from "./newElement";
import type { import type { SceneElementsMap } from "./types";
ElementsMap, import {
ExcalidrawBindableElement, type ElementsMap,
ExcalidrawElement, type ExcalidrawBindableElement,
ExcalidrawFlowchartNodeElement, type ExcalidrawElement,
NonDeletedSceneElementsMap, type ExcalidrawFlowchartNodeElement,
OrderedExcalidrawElement, type NonDeletedSceneElementsMap,
type Ordered,
type OrderedExcalidrawElement,
} from "./types"; } from "./types";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import type { AppState, PendingExcalidrawElements } from "../types"; import type { AppState, PendingExcalidrawElements } from "../types";
@@ -28,9 +30,10 @@ import {
isFrameElement, isFrameElement,
isFlowchartNodeElement, isFlowchartNodeElement,
} from "./typeChecks"; } from "./typeChecks";
import { invariant } from "../utils"; import { invariant, toBrandedType } from "../utils";
import { pointFrom, type LocalPoint } from "../../math"; import { pointFrom, type LocalPoint } from "../../math";
import { aabbForElement } from "../shapes"; import { aabbForElement } from "../shapes";
import { updateElbowArrowPoints } from "./elbowArrow";
type LinkDirection = "up" | "right" | "down" | "left"; type LinkDirection = "up" | "right" | "down" | "left";
@@ -254,6 +257,9 @@ const addNewNode = (
backgroundColor: element.backgroundColor, backgroundColor: element.backgroundColor,
strokeColor: element.strokeColor, strokeColor: element.strokeColor,
strokeWidth: element.strokeWidth, strokeWidth: element.strokeWidth,
opacity: element.opacity,
fillStyle: element.fillStyle,
strokeStyle: element.strokeStyle,
}); });
invariant( invariant(
@@ -329,6 +335,9 @@ export const addNewNodes = (
backgroundColor: startNode.backgroundColor, backgroundColor: startNode.backgroundColor,
strokeColor: startNode.strokeColor, strokeColor: startNode.strokeColor,
strokeWidth: startNode.strokeWidth, strokeWidth: startNode.strokeWidth,
opacity: startNode.opacity,
fillStyle: startNode.fillStyle,
strokeStyle: startNode.strokeStyle,
}); });
invariant( invariant(
@@ -416,11 +425,13 @@ const createBindingArrow = (
type: "arrow", type: "arrow",
x: startX, x: startX,
y: startY, y: startY,
startArrowhead: appState.currentItemStartArrowhead, startArrowhead: null,
endArrowhead: appState.currentItemEndArrowhead, endArrowhead: appState.currentItemEndArrowhead,
strokeColor: appState.currentItemStrokeColor, strokeColor: startBindingElement.strokeColor,
strokeStyle: appState.currentItemStrokeStyle, strokeStyle: startBindingElement.strokeStyle,
strokeWidth: appState.currentItemStrokeWidth, strokeWidth: startBindingElement.strokeWidth,
opacity: startBindingElement.opacity,
roughness: startBindingElement.roughness,
points: [pointFrom(0, 0), pointFrom(endX, endY)], points: [pointFrom(0, 0), pointFrom(endX, endY)],
elbowed: true, elbowed: true,
}); });
@@ -452,22 +463,30 @@ const createBindingArrow = (
bindingArrow as OrderedExcalidrawElement, bindingArrow as OrderedExcalidrawElement,
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(bindingArrow, [
bindingArrow,
[
{
index: 1,
point: bindingArrow.points[1],
},
],
elementsMap as NonDeletedSceneElementsMap,
undefined,
{ {
changedElements, index: 1,
point: bindingArrow.points[1],
}, },
]);
const update = updateElbowArrowPoints(
bindingArrow,
toBrandedType<SceneElementsMap>(
new Map([
...elementsMap.entries(),
[startBindingElement.id, startBindingElement],
[endBindingElement.id, endBindingElement],
[bindingArrow.id, bindingArrow],
] as [string, Ordered<ExcalidrawElement>][]),
),
{ points: bindingArrow.points },
); );
return bindingArrow; return {
...bindingArrow,
...update,
};
}; };
export class FlowChartNavigator { export class FlowChartNavigator {
+31 -7
View File
@@ -11,6 +11,7 @@ import {
pointScaleFromOrigin, pointScaleFromOrigin,
radiansToDegrees, radiansToDegrees,
triangleIncludesPoint, triangleIncludesPoint,
vectorFromPoint,
} from "../../math"; } from "../../math";
import { getCenterForBounds, type Bounds } from "./bounds"; import { getCenterForBounds, type Bounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types"; import type { ExcalidrawBindableElement } from "./types";
@@ -52,9 +53,24 @@ export const vectorToHeading = (vec: Vector): Heading => {
return HEADING_UP; return HEADING_UP;
}; };
export const headingForPoint = <P extends GlobalPoint | LocalPoint>(
p: P,
o: P,
) => vectorToHeading(vectorFromPoint<P>(p, o));
export const headingForPointIsHorizontal = <P extends GlobalPoint | LocalPoint>(
p: P,
o: P,
) => headingIsHorizontal(headingForPoint<P>(p, o));
export const compareHeading = (a: Heading, b: Heading) => export const compareHeading = (a: Heading, b: Heading) =>
a[0] === b[0] && a[1] === b[1]; a[0] === b[0] && a[1] === b[1];
export const headingIsHorizontal = (a: Heading) =>
compareHeading(a, HEADING_RIGHT) || compareHeading(a, HEADING_LEFT);
export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
// Gets the heading for the point by creating a bounding box around the rotated // Gets the heading for the point by creating a bounding box around the rotated
// close fitting bounding box, then creating 4 search cones around the center of // close fitting bounding box, then creating 4 search cones around the center of
// the external bbox. // the external bbox.
@@ -63,7 +79,7 @@ export const headingForPointFromElement = <
>( >(
element: Readonly<ExcalidrawBindableElement>, element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>, aabb: Readonly<Bounds>,
p: Readonly<LocalPoint | GlobalPoint>, p: Readonly<Point>,
): Heading => { ): Heading => {
const SEARCH_CONE_MULTIPLIER = 2; const SEARCH_CONE_MULTIPLIER = 2;
@@ -117,14 +133,22 @@ export const headingForPointFromElement = <
element.angle, element.angle,
); );
if (triangleIncludesPoint([top, right, midPoint] as Triangle<Point>, p)) { if (
triangleIncludesPoint<Point>([top, right, midPoint] as Triangle<Point>, p)
) {
return headingForDiamond(top, right); return headingForDiamond(top, right);
} else if ( } else if (
triangleIncludesPoint([right, bottom, midPoint] as Triangle<Point>, p) triangleIncludesPoint<Point>(
[right, bottom, midPoint] as Triangle<Point>,
p,
)
) { ) {
return headingForDiamond(right, bottom); return headingForDiamond(right, bottom);
} else if ( } else if (
triangleIncludesPoint([bottom, left, midPoint] as Triangle<Point>, p) triangleIncludesPoint<Point>(
[bottom, left, midPoint] as Triangle<Point>,
p,
)
) { ) {
return headingForDiamond(bottom, left); return headingForDiamond(bottom, left);
} }
@@ -153,17 +177,17 @@ export const headingForPointFromElement = <
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
) as Point; ) as Point;
return triangleIncludesPoint( return triangleIncludesPoint<Point>(
[topLeft, topRight, midPoint] as Triangle<Point>, [topLeft, topRight, midPoint] as Triangle<Point>,
p, p,
) )
? HEADING_UP ? HEADING_UP
: triangleIncludesPoint( : triangleIncludesPoint<Point>(
[topRight, bottomRight, midPoint] as Triangle<Point>, [topRight, bottomRight, midPoint] as Triangle<Point>,
p, p,
) )
? HEADING_RIGHT ? HEADING_RIGHT
: triangleIncludesPoint( : triangleIncludesPoint<Point>(
[bottomRight, bottomLeft, midPoint] as Triangle<Point>, [bottomRight, bottomLeft, midPoint] as Triangle<Point>,
p, p,
) )
+186 -131
View File
@@ -7,9 +7,10 @@ import type {
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ElementsMap, ElementsMap,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
FixedPointBinding, FixedPointBinding,
SceneElementsMap, SceneElementsMap,
FixedSegment,
ExcalidrawElbowArrowElement,
} from "./types"; } from "./types";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from "."; import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
@@ -24,6 +25,7 @@ import type {
InteractiveCanvasAppState, InteractiveCanvasAppState,
AppClassProperties, AppClassProperties,
NullableGridSize, NullableGridSize,
Zoom,
} from "../types"; } from "../types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
@@ -32,7 +34,7 @@ import {
getHoveredElementForBinding, getHoveredElementForBinding,
isBindingEnabled, isBindingEnabled,
} from "./binding"; } from "./binding";
import { invariant, toBrandedType, tupleToCoors } from "../utils"; import { invariant, tupleToCoors } from "../utils";
import { import {
isBindingElement, isBindingElement,
isElbowArrow, isElbowArrow,
@@ -44,7 +46,6 @@ import { DRAGGING_THRESHOLD } from "../constants";
import type { Mutable } from "../utility-types"; import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import type { Store } from "../store"; import type { Store } from "../store";
import { mutateElbowArrow } from "./routing";
import type Scene from "../scene/Scene"; import type Scene from "../scene/Scene";
import type { Radians } from "../../math"; import type { Radians } from "../../math";
import { import {
@@ -56,6 +57,8 @@ import {
type GlobalPoint, type GlobalPoint,
type LocalPoint, type LocalPoint,
pointDistance, pointDistance,
pointTranslate,
vectorFromPoint,
} from "../../math"; } from "../../math";
import { import {
getBezierCurveLength, getBezierCurveLength,
@@ -65,6 +68,7 @@ import {
mapIntervalToBezierT, mapIntervalToBezierT,
} from "../shapes"; } from "../shapes";
import { getGridPoint } from "../snapping"; import { getGridPoint } from "../snapping";
import { headingIsHorizontal, vectorToHeading } from "./heading";
const editorMidPointsCache: { const editorMidPointsCache: {
version: number | null; version: number | null;
@@ -144,13 +148,13 @@ export class LinearElementEditor {
* @param id the `elementId` from the instance of this class (so that we can * @param id the `elementId` from the instance of this class (so that we can
* statically guarantee this method returns an ExcalidrawLinearElement) * statically guarantee this method returns an ExcalidrawLinearElement)
*/ */
static getElement( static getElement<T extends ExcalidrawLinearElement>(
id: InstanceType<typeof LinearElementEditor>["elementId"], id: InstanceType<typeof LinearElementEditor>["elementId"],
elementsMap: ElementsMap, elementsMap: ElementsMap,
) { ): T | null {
const element = elementsMap.get(id); const element = elementsMap.get(id);
if (element) { if (element) {
return element as NonDeleted<ExcalidrawLinearElement>; return element as NonDeleted<T>;
} }
return null; return null;
} }
@@ -291,20 +295,16 @@ export class LinearElementEditor {
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, [
element, {
[ index: selectedIndex,
{ point: pointFrom(
index: selectedIndex, width + referencePoint[0],
point: pointFrom( height + referencePoint[1],
width + referencePoint[0], ),
height + referencePoint[1], isDragging: selectedIndex === lastClickedPoint,
), },
isDragging: selectedIndex === lastClickedPoint, ]);
},
],
elementsMap,
);
} else { } else {
const newDraggingPointPosition = LinearElementEditor.createPointAt( const newDraggingPointPosition = LinearElementEditor.createPointAt(
element, element,
@@ -339,7 +339,6 @@ export class LinearElementEditor {
isDragging: pointIndex === lastClickedPoint, isDragging: pointIndex === lastClickedPoint,
}; };
}), }),
elementsMap,
); );
} }
@@ -422,19 +421,15 @@ export class LinearElementEditor {
selectedPoint === element.points.length - 1 selectedPoint === element.points.length - 1
) { ) {
if (isPathALoop(element.points, appState.zoom.value)) { if (isPathALoop(element.points, appState.zoom.value)) {
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, [
element, {
[ index: selectedPoint,
{ point:
index: selectedPoint, selectedPoint === 0
point: ? element.points[element.points.length - 1]
selectedPoint === 0 : element.points[0],
? element.points[element.points.length - 1] },
: element.points[0], ]);
},
],
elementsMap,
);
} }
const bindingElement = isBindingEnabled(appState) const bindingElement = isBindingEnabled(appState)
@@ -449,6 +444,8 @@ export class LinearElementEditor {
elements, elements,
elementsMap, elementsMap,
appState.zoom, appState.zoom,
isElbowArrow(element),
isElbowArrow(element),
) )
: null; : null;
@@ -495,6 +492,7 @@ export class LinearElementEditor {
// Since its not needed outside editor unless 2 pointer lines or bound text // Since its not needed outside editor unless 2 pointer lines or bound text
if ( if (
!isElbowArrow(element) &&
!appState.editingLinearElement && !appState.editingLinearElement &&
element.points.length > 2 && element.points.length > 2 &&
!boundText !boundText
@@ -533,6 +531,7 @@ export class LinearElementEditor {
element, element,
element.points[index], element.points[index],
element.points[index + 1], element.points[index + 1],
index,
appState.zoom, appState.zoom,
) )
) { ) {
@@ -573,19 +572,23 @@ export class LinearElementEditor {
scenePointer.x, scenePointer.x,
scenePointer.y, scenePointer.y,
); );
if (clickedPointIndex >= 0) { if (!isElbowArrow(element) && clickedPointIndex >= 0) {
return null; return null;
} }
const points = LinearElementEditor.getPointsGlobalCoordinates( const points = LinearElementEditor.getPointsGlobalCoordinates(
element, element,
elementsMap, elementsMap,
); );
if (points.length >= 3 && !appState.editingLinearElement) { if (
points.length >= 3 &&
!appState.editingLinearElement &&
!isElbowArrow(element)
) {
return null; return null;
} }
const threshold = const threshold =
LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value; (LinearElementEditor.POINT_HANDLE_SIZE + 1) / appState.zoom.value;
const existingSegmentMidpointHitCoords = const existingSegmentMidpointHitCoords =
linearElementEditor.segmentMidPointHoveredCoords; linearElementEditor.segmentMidPointHoveredCoords;
@@ -604,10 +607,11 @@ export class LinearElementEditor {
let index = 0; let index = 0;
const midPoints: typeof editorMidPointsCache["points"] = const midPoints: typeof editorMidPointsCache["points"] =
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState); LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
while (index < midPoints.length) { while (index < midPoints.length) {
if (midPoints[index] !== null) { if (midPoints[index] !== null) {
const distance = pointDistance( const distance = pointDistance(
pointFrom(midPoints[index]![0], midPoints[index]![1]), midPoints[index]!,
pointFrom(scenePointer.x, scenePointer.y), pointFrom(scenePointer.x, scenePointer.y),
); );
if (distance <= threshold) { if (distance <= threshold) {
@@ -620,16 +624,25 @@ export class LinearElementEditor {
return null; return null;
}; };
static isSegmentTooShort( static isSegmentTooShort<P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
startPoint: GlobalPoint | LocalPoint, startPoint: P,
endPoint: GlobalPoint | LocalPoint, endPoint: P,
zoom: AppState["zoom"], index: number,
zoom: Zoom,
) { ) {
let distance = pointDistance( if (isElbowArrow(element)) {
pointFrom(startPoint[0], startPoint[1]), if (index >= 0 && index < element.points.length) {
pointFrom(endPoint[0], endPoint[1]), return (
); pointDistance(startPoint, endPoint) * zoom.value <
LinearElementEditor.POINT_HANDLE_SIZE / 2
);
}
return false;
}
let distance = pointDistance(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) { if (element.points.length > 2 && element.roundness) {
distance = getBezierCurveLength(element, endPoint); distance = getBezierCurveLength(element, endPoint);
} }
@@ -748,12 +761,8 @@ export class LinearElementEditor {
segmentMidpoint, segmentMidpoint,
elementsMap, elementsMap,
); );
} } else if (event.altKey && appState.editingLinearElement) {
if (event.altKey && appState.editingLinearElement) { if (linearElementEditor.lastUncommittedPoint == null) {
if (
linearElementEditor.lastUncommittedPoint == null &&
!isElbowArrow(element)
) {
mutateElement(element, { mutateElement(element, {
points: [ points: [
...element.points, ...element.points,
@@ -789,6 +798,7 @@ export class LinearElementEditor {
elements, elements,
elementsMap, elementsMap,
app.state.zoom, app.state.zoom,
linearElementEditor.elbowed,
), ),
}; };
@@ -909,12 +919,7 @@ export class LinearElementEditor {
if (!event.altKey) { if (!event.altKey) {
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints( LinearElementEditor.deletePoints(element, [points.length - 1]);
element,
[points.length - 1],
elementsMap,
app.state.zoom,
);
} }
return { return {
...appState.editingLinearElement, ...appState.editingLinearElement,
@@ -952,23 +957,14 @@ export class LinearElementEditor {
} }
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, [
element, {
[ index: element.points.length - 1,
{ point: newPoint,
index: element.points.length - 1, },
point: newPoint, ]);
},
],
elementsMap,
);
} else { } else {
LinearElementEditor.addPoints( LinearElementEditor.addPoints(element, [{ point: newPoint }]);
element,
[{ point: newPoint }],
elementsMap,
app.state.zoom,
);
} }
return { return {
...appState.editingLinearElement, ...appState.editingLinearElement,
@@ -1197,16 +1193,12 @@ export class LinearElementEditor {
// potentially expanding the bounding box // potentially expanding the bounding box
if (pointAddedToEnd) { if (pointAddedToEnd) {
const lastPoint = element.points[element.points.length - 1]; const lastPoint = element.points[element.points.length - 1];
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, [
element, {
[ index: element.points.length - 1,
{ point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
index: element.points.length - 1, },
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30), ]);
},
],
elementsMap,
);
} }
return { return {
@@ -1221,8 +1213,6 @@ export class LinearElementEditor {
static deletePoints( static deletePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
pointIndices: readonly number[], pointIndices: readonly number[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom: AppState["zoom"],
) { ) {
let offsetX = 0; let offsetX = 0;
let offsetY = 0; let offsetY = 0;
@@ -1252,47 +1242,27 @@ export class LinearElementEditor {
return acc; return acc;
}, []); }, []);
LinearElementEditor._updatePoints( LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
element,
nextPoints,
offsetX,
offsetY,
elementsMap,
);
} }
static addPoints( static addPoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { point: LocalPoint }[], targetPoints: { point: LocalPoint }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom: AppState["zoom"],
) { ) {
const offsetX = 0; const offsetX = 0;
const offsetY = 0; const offsetY = 0;
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)]; const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
LinearElementEditor._updatePoints( LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
element,
nextPoints,
offsetX,
offsetY,
elementsMap,
);
} }
static movePoints( static movePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[], targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
otherUpdates?: { otherUpdates?: {
startBinding?: PointBinding | null; startBinding?: PointBinding | null;
endBinding?: PointBinding | null; endBinding?: PointBinding | null;
}, },
options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean;
zoom?: AppState["zoom"];
},
) { ) {
const { points } = element; const { points } = element;
@@ -1335,7 +1305,6 @@ export class LinearElementEditor {
nextPoints, nextPoints,
offsetX, offsetX,
offsetY, offsetY,
elementsMap,
otherUpdates, otherUpdates,
{ {
isDragging: targetPoints.reduce( isDragging: targetPoints.reduce(
@@ -1343,8 +1312,6 @@ export class LinearElementEditor {
dragging || targetPoint.isDragging === true, dragging || targetPoint.isDragging === true,
false, false,
), ),
changedElements: options?.changedElements,
zoom: options?.zoom,
}, },
); );
} }
@@ -1451,54 +1418,49 @@ export class LinearElementEditor {
nextPoints: readonly LocalPoint[], nextPoints: readonly LocalPoint[],
offsetX: number, offsetX: number,
offsetY: number, offsetY: number,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
otherUpdates?: { otherUpdates?: {
startBinding?: PointBinding | null; startBinding?: PointBinding | null;
endBinding?: PointBinding | null; endBinding?: PointBinding | null;
}, },
options?: { options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean; isDragging?: boolean;
zoom?: AppState["zoom"]; zoom?: AppState["zoom"];
}, },
) { ) {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
const bindings: { const updates: {
startBinding?: FixedPointBinding | null; startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null; endBinding?: FixedPointBinding | null;
points?: LocalPoint[];
} = {}; } = {};
if (otherUpdates?.startBinding !== undefined) { if (otherUpdates?.startBinding !== undefined) {
bindings.startBinding = updates.startBinding =
otherUpdates.startBinding !== null && otherUpdates.startBinding !== null &&
isFixedPointBinding(otherUpdates.startBinding) isFixedPointBinding(otherUpdates.startBinding)
? otherUpdates.startBinding ? otherUpdates.startBinding
: null; : null;
} }
if (otherUpdates?.endBinding !== undefined) { if (otherUpdates?.endBinding !== undefined) {
bindings.endBinding = updates.endBinding =
otherUpdates.endBinding !== null && otherUpdates.endBinding !== null &&
isFixedPointBinding(otherUpdates.endBinding) isFixedPointBinding(otherUpdates.endBinding)
? otherUpdates.endBinding ? otherUpdates.endBinding
: null; : null;
} }
const mergedElementsMap = options?.changedElements updates.points = Array.from(nextPoints);
? toBrandedType<SceneElementsMap>( updates.points[0] = pointTranslate(
new Map([...elementsMap, ...options.changedElements]), updates.points[0],
)
: elementsMap;
mutateElbowArrow(
element,
mergedElementsMap,
nextPoints,
vector(offsetX, offsetY), vector(offsetX, offsetY),
bindings,
{
isDragging: options?.isDragging,
zoom: options?.zoom,
},
); );
updates.points[updates.points.length - 1] = pointTranslate(
updates.points[updates.points.length - 1],
vector(offsetX, offsetY),
);
mutateElement(element, updates, true, {
isDragging: options?.isDragging,
});
} else { } else {
const nextCoords = getElementPointsCoords(element, nextPoints); const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, element.points); const prevCoords = getElementPointsCoords(element, element.points);
@@ -1773,6 +1735,99 @@ export class LinearElementEditor {
return coords; return coords;
}; };
static moveFixedSegment(
linearElement: LinearElementEditor,
index: number,
x: number,
y: number,
elementsMap: ElementsMap,
): LinearElementEditor {
const element = LinearElementEditor.getElement(
linearElement.elementId,
elementsMap,
);
if (!element || !isElbowArrow(element)) {
return linearElement;
}
if (index && index > 0 && index < element.points.length) {
const isHorizontal = headingIsHorizontal(
vectorToHeading(
vectorFromPoint(element.points[index], element.points[index - 1]),
),
);
const fixedSegments = (element.fixedSegments ?? []).reduce(
(segments, s) => {
segments[s.index] = s;
return segments;
},
{} as Record<number, FixedSegment>,
);
fixedSegments[index] = {
index,
start: pointFrom<LocalPoint>(
!isHorizontal ? x - element.x : element.points[index - 1][0],
isHorizontal ? y - element.y : element.points[index - 1][1],
),
end: pointFrom<LocalPoint>(
!isHorizontal ? x - element.x : element.points[index][0],
isHorizontal ? y - element.y : element.points[index][1],
),
};
const nextFixedSegments = Object.values(fixedSegments).sort(
(a, b) => a.index - b.index,
);
const offset = nextFixedSegments
.map((segment) => segment.index)
.reduce((count, idx) => (idx < index ? count + 1 : count), 0);
mutateElement(element, {
fixedSegments: nextFixedSegments,
});
const point = pointFrom<GlobalPoint>(
element.x +
(element.fixedSegments![offset].start[0] +
element.fixedSegments![offset].end[0]) /
2,
element.y +
(element.fixedSegments![offset].start[1] +
element.fixedSegments![offset].end[1]) /
2,
);
return {
...linearElement,
segmentMidPointHoveredCoords: point,
pointerDownState: {
...linearElement.pointerDownState,
segmentMidpoint: {
added: false,
index: element.fixedSegments![offset].index,
value: point,
},
},
};
}
return linearElement;
}
static deleteFixedSegment(
element: ExcalidrawElbowArrowElement,
index: number,
): void {
mutateElement(element, {
fixedSegments: element.fixedSegments?.filter(
(segment) => segment.index !== index,
),
});
mutateElement(element, {}, true);
}
} }
const normalizeSelectedPoints = ( const normalizeSelectedPoints = (
+47 -4
View File
@@ -1,10 +1,13 @@
import type { ExcalidrawElement } from "./types"; import type { ExcalidrawElement, SceneElementsMap } from "./types";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { getSizeFromPoints } from "../points"; import { getSizeFromPoints } from "../points";
import { randomInteger } from "../random"; import { randomInteger } from "../random";
import { getUpdatedTimestamp } from "../utils"; import { getUpdatedTimestamp, toBrandedType } from "../utils";
import type { Mutable } from "../utility-types"; import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import { isElbowArrow } from "./typeChecks";
import { updateElbowArrowPoints } from "./elbowArrow";
import type { Radians } from "../../math";
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit< export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>, Partial<TElement>,
@@ -19,14 +22,54 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement, element: TElement,
updates: ElementUpdate<TElement>, updates: ElementUpdate<TElement>,
informMutation = true, informMutation = true,
options?: {
// Currently only for elbow arrows.
// If true, the elbow arrow tries to bind to the nearest element. If false
// it tries to keep the same bound element, if any.
isDragging?: boolean;
},
): TElement => { ): TElement => {
let didChange = false; let didChange = false;
// casting to any because can't use `in` operator // casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732) // (see https://github.com/microsoft/TypeScript/issues/21732)
const { points, fileId } = updates as any; const { points, fixedSegments, fileId, startBinding, endBinding } =
updates as any;
if (typeof points !== "undefined") { if (
isElbowArrow(element) &&
(Object.keys(updates).length === 0 || // normalization case
typeof points !== "undefined" || // repositioning
typeof fixedSegments !== "undefined" || // segment fixing
typeof startBinding !== "undefined" ||
typeof endBinding !== "undefined") // manual binding to element
) {
const elementsMap = toBrandedType<SceneElementsMap>(
Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
);
updates = {
...updates,
angle: 0 as Radians,
...updateElbowArrowPoints(
{
...element,
x: updates.x || element.x,
y: updates.y || element.y,
},
elementsMap,
{
fixedSegments,
points,
startBinding,
endBinding,
},
{
isDragging: options?.isDragging,
},
),
};
} else if (typeof points !== "undefined") {
updates = { ...getSizeFromPoints(points), ...updates }; updates = { ...getSizeFromPoints(points), ...updates };
} }
+74 -32
View File
@@ -18,6 +18,8 @@ import type {
ExcalidrawIframeElement, ExcalidrawIframeElement,
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
FixedSegment,
ExcalidrawElbowArrowElement,
} from "./types"; } from "./types";
import { import {
arrayToMap, arrayToMap,
@@ -31,11 +33,7 @@ import { getNewGroupIdsForDuplication } from "../groups";
import type { AppState } from "../types"; import type { AppState } from "../types";
import { getElementAbsoluteCoords } from "."; import { getElementAbsoluteCoords } from ".";
import { getResizedElementAbsoluteCoords } from "./bounds"; import { getResizedElementAbsoluteCoords } from "./bounds";
import { import { getBoundTextMaxWidth } from "./textElement";
measureText,
normalizeText,
getBoundTextMaxWidth,
} from "./textElement";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
import { import {
DEFAULT_ELEMENT_PROPS, DEFAULT_ELEMENT_PROPS,
@@ -43,11 +41,13 @@ import {
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN, DEFAULT_VERTICAL_ALIGN,
ORIG_ID,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import type { MarkOptional, Merge, Mutable } from "../utility-types"; import type { MarkOptional, Merge, Mutable } from "../utility-types";
import { getLineHeight } from "../fonts"; import { getLineHeight } from "../fonts";
import type { Radians } from "../../math"; import type { Radians } from "../../math";
import { normalizeText, measureText } from "./textMeasurements";
export type ElementConstructorOpts = MarkOptional< export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">, Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -99,6 +99,28 @@ const _newElementBase = <T extends ExcalidrawElement>(
...rest ...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">, }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => { ) => {
// NOTE (mtolmacs): This is a temporary check to detect extremely large
// element position or sizing
if (
x < -1e6 ||
x > 1e6 ||
y < -1e6 ||
y > 1e6 ||
width < -1e6 ||
width > 1e6 ||
height < -1e6 ||
height > 1e6
) {
console.error("New element size or position is too large", {
x,
y,
width,
height,
// @ts-ignore
points: rest.points,
});
}
// assign type to guard against excess properties // assign type to guard against excess properties
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = { const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
id: rest.id || randomId(), id: rest.id || randomId(),
@@ -450,15 +472,34 @@ export const newLinearElement = (
}; };
}; };
export const newArrowElement = ( export const newArrowElement = <T extends boolean>(
opts: { opts: {
type: ExcalidrawArrowElement["type"]; type: ExcalidrawArrowElement["type"];
startArrowhead?: Arrowhead | null; startArrowhead?: Arrowhead | null;
endArrowhead?: Arrowhead | null; endArrowhead?: Arrowhead | null;
points?: ExcalidrawArrowElement["points"]; points?: ExcalidrawArrowElement["points"];
elbowed?: boolean; elbowed?: T;
fixedSegments?: FixedSegment[] | null;
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawArrowElement> => { ): T extends true
? NonDeleted<ExcalidrawElbowArrowElement>
: NonDeleted<ExcalidrawArrowElement> => {
if (opts.elbowed) {
return {
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
points: opts.points || [],
lastCommittedPoint: null,
startBinding: null,
endBinding: null,
startArrowhead: opts.startArrowhead || null,
endArrowhead: opts.endArrowhead || null,
elbowed: true,
fixedSegments: opts.fixedSegments || [],
startIsSpecial: false,
endIsSpecial: false,
} as NonDeleted<ExcalidrawElbowArrowElement>;
}
return { return {
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts), ..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
@@ -467,8 +508,10 @@ export const newArrowElement = (
endBinding: null, endBinding: null,
startArrowhead: opts.startArrowhead || null, startArrowhead: opts.startArrowhead || null,
endArrowhead: opts.endArrowhead || null, endArrowhead: opts.endArrowhead || null,
elbowed: opts.elbowed || false, elbowed: false,
}; } as T extends true
? NonDeleted<ExcalidrawElbowArrowElement>
: NonDeleted<ExcalidrawArrowElement>;
}; };
export const newImageElement = ( export const newImageElement = (
@@ -569,26 +612,18 @@ export const deepCopyElement = <T extends ExcalidrawElement>(
return _deepCopyElement(val); return _deepCopyElement(val);
}; };
const __test__defineOrigId = (clonedObj: object, origId: string) => {
Object.defineProperty(clonedObj, ORIG_ID, {
value: origId,
writable: false,
enumerable: false,
});
};
/** /**
* utility wrapper to generate new id. In test env it reuses the old + postfix * utility wrapper to generate new id.
* for test assertions.
*/ */
export const regenerateId = ( const regenerateId = () => {
/** supply null if no previous id exists */
previousId: string | null,
) => {
if (isTestEnv() && previousId) {
let nextId = `${previousId}_copy`;
// `window.h` may not be defined in some unit tests
if (
window.h?.app
?.getSceneElementsIncludingDeleted()
.find((el: ExcalidrawElement) => el.id === nextId)
) {
nextId += "_copy";
}
return nextId;
}
return randomId(); return randomId();
}; };
@@ -614,7 +649,11 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
): Readonly<TElement> => { ): Readonly<TElement> => {
let copy = deepCopyElement(element); let copy = deepCopyElement(element);
copy.id = regenerateId(copy.id); if (isTestEnv()) {
__test__defineOrigId(copy, element.id);
}
copy.id = regenerateId();
copy.boundElements = null; copy.boundElements = null;
copy.updated = getUpdatedTimestamp(); copy.updated = getUpdatedTimestamp();
copy.seed = randomInteger(); copy.seed = randomInteger();
@@ -623,7 +662,7 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
editingGroupId, editingGroupId,
(groupId) => { (groupId) => {
if (!groupIdMapForOperation.has(groupId)) { if (!groupIdMapForOperation.has(groupId)) {
groupIdMapForOperation.set(groupId, regenerateId(groupId)); groupIdMapForOperation.set(groupId, regenerateId());
} }
return groupIdMapForOperation.get(groupId)!; return groupIdMapForOperation.get(groupId)!;
}, },
@@ -669,7 +708,7 @@ export const duplicateElements = (
// if we haven't migrated the element id, but an old element with the same // if we haven't migrated the element id, but an old element with the same
// id exists, generate a new id for it and return it // id exists, generate a new id for it and return it
if (origElementsMap.has(id)) { if (origElementsMap.has(id)) {
const newId = regenerateId(id); const newId = regenerateId();
elementNewIdsMap.set(id, newId); elementNewIdsMap.set(id, newId);
return newId; return newId;
} }
@@ -683,6 +722,9 @@ export const duplicateElements = (
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element); const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
clonedElement.id = maybeGetNewId(element.id)!; clonedElement.id = maybeGetNewId(element.id)!;
if (isTestEnv()) {
__test__defineOrigId(clonedElement, element.id);
}
if (opts?.randomizeSeed) { if (opts?.randomizeSeed) {
clonedElement.seed = randomInteger(); clonedElement.seed = randomInteger();
@@ -692,7 +734,7 @@ export const duplicateElements = (
if (clonedElement.groupIds) { if (clonedElement.groupIds) {
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => { clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
if (!groupNewIdsMap.has(groupId)) { if (!groupNewIdsMap.has(groupId)) {
groupNewIdsMap.set(groupId, regenerateId(groupId)); groupNewIdsMap.set(groupId, regenerateId());
} }
return groupNewIdsMap.get(groupId)!; return groupNewIdsMap.get(groupId)!;
}); });
+78 -11
View File
@@ -10,6 +10,7 @@ import type {
ExcalidrawImageElement, ExcalidrawImageElement,
ElementsMap, ElementsMap,
SceneElementsMap, SceneElementsMap,
ExcalidrawElbowArrowElement,
} from "./types"; } from "./types";
import type { Mutable } from "../utility-types"; import type { Mutable } from "../utility-types";
import { import {
@@ -40,20 +41,15 @@ import type {
import type { PointerDownState } from "../types"; import type { PointerDownState } from "../types";
import type Scene from "../scene/Scene"; import type Scene from "../scene/Scene";
import { import {
getApproxMinLineWidth,
getBoundTextElement, getBoundTextElement,
getBoundTextElementId, getBoundTextElementId,
getContainerElement, getContainerElement,
handleBindTextResize, handleBindTextResize,
getBoundTextMaxWidth, getBoundTextMaxWidth,
getApproxMinLineHeight,
measureText,
getMinTextElementWidth,
} from "./textElement"; } from "./textElement";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups"; import { isInGroup } from "../groups";
import { mutateElbowArrow } from "./routing";
import type { GlobalPoint } from "../../math"; import type { GlobalPoint } from "../../math";
import { import {
pointCenter, pointCenter,
@@ -64,6 +60,12 @@ import {
type Radians, type Radians,
type LocalPoint, type LocalPoint,
} from "../../math"; } from "../../math";
import {
getMinTextElementWidth,
measureText,
getApproxMinLineWidth,
getApproxMinLineHeight,
} from "./textMeasurements";
// Returns true when transform (resizing/rotation) happened // Returns true when transform (resizing/rotation) happened
export const transformElements = ( export const transformElements = (
@@ -177,10 +179,10 @@ export const transformElements = (
elementsMap, elementsMap,
transformHandleType, transformHandleType,
scene, scene,
originalElements,
{ {
shouldResizeFromCenter, shouldResizeFromCenter,
shouldMaintainAspectRatio, shouldMaintainAspectRatio,
originalElementsMap: originalElements,
flipByX, flipByX,
flipByY, flipByY,
nextWidth, nextWidth,
@@ -531,8 +533,10 @@ const rotateMultipleElements = (
); );
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
const points = getArrowLocalFixedPoints(element, elementsMap); // Needed to re-route the arrow
mutateElbowArrow(element, elementsMap, points); mutateElement(element, {
points: getArrowLocalFixedPoints(element, elementsMap),
});
} else { } else {
mutateElement( mutateElement(
element, element,
@@ -765,6 +769,26 @@ const getResizedOrigin = (
y: y - (newHeight - prevHeight) / 2, y: y - (newHeight - prevHeight) / 2,
}; };
case "east-side": case "east-side":
// NOTE (mtolmacs): Reverting this for a short period to test if it is
// the cause of the megasized elbow arrows showing up.
if (
Math.abs(
y +
((prevWidth - newWidth) / 2) * Math.sin(angle) +
(prevHeight - newHeight) / 2,
) > 1e6
) {
console.error(
"getResizedOrigin() new calculation creates extremely large (> 1e6) y value where the old calculation resulted in",
{
result:
y +
(newHeight - prevHeight) / 2 +
((prevWidth - newWidth) / 2) * Math.sin(angle),
},
);
}
return { return {
x: x + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1), x: x + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1),
y: y:
@@ -1201,6 +1225,7 @@ export const resizeMultipleElements = (
elementsMap: ElementsMap, elementsMap: ElementsMap,
handleDirection: TransformHandleDirection, handleDirection: TransformHandleDirection,
scene: Scene, scene: Scene,
originalElementsMap: ElementsMap,
{ {
shouldMaintainAspectRatio = false, shouldMaintainAspectRatio = false,
shouldResizeFromCenter = false, shouldResizeFromCenter = false,
@@ -1208,7 +1233,6 @@ export const resizeMultipleElements = (
flipByY = false, flipByY = false,
nextHeight, nextHeight,
nextWidth, nextWidth,
originalElementsMap,
originalBoundingBox, originalBoundingBox,
}: { }: {
nextWidth?: number; nextWidth?: number;
@@ -1217,7 +1241,6 @@ export const resizeMultipleElements = (
shouldResizeFromCenter?: boolean; shouldResizeFromCenter?: boolean;
flipByX?: boolean; flipByX?: boolean;
flipByY?: boolean; flipByY?: boolean;
originalElementsMap?: ElementsMap;
// added to improve performance // added to improve performance
originalBoundingBox?: BoundingBox; originalBoundingBox?: BoundingBox;
} = {}, } = {},
@@ -1387,6 +1410,9 @@ export const resizeMultipleElements = (
fontSize?: ExcalidrawTextElement["fontSize"]; fontSize?: ExcalidrawTextElement["fontSize"];
scale?: ExcalidrawImageElement["scale"]; scale?: ExcalidrawImageElement["scale"];
boundTextFontSize?: ExcalidrawTextElement["fontSize"]; boundTextFontSize?: ExcalidrawTextElement["fontSize"];
startBinding?: ExcalidrawElbowArrowElement["startBinding"];
endBinding?: ExcalidrawElbowArrowElement["endBinding"];
fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"];
}; };
}[] = []; }[] = [];
@@ -1427,6 +1453,44 @@ export const resizeMultipleElements = (
...rescaledPoints, ...rescaledPoints,
}; };
if (isElbowArrow(orig)) {
// Mirror fixed point binding for elbow arrows
// when resize goes into the negative direction
if (orig.startBinding) {
update.startBinding = {
...orig.startBinding,
fixedPoint: [
flipByX
? -orig.startBinding.fixedPoint[0] + 1
: orig.startBinding.fixedPoint[0],
flipByY
? -orig.startBinding.fixedPoint[1] + 1
: orig.startBinding.fixedPoint[1],
],
};
}
if (orig.endBinding) {
update.endBinding = {
...orig.endBinding,
fixedPoint: [
flipByX
? -orig.endBinding.fixedPoint[0] + 1
: orig.endBinding.fixedPoint[0],
flipByY
? -orig.endBinding.fixedPoint[1] + 1
: orig.endBinding.fixedPoint[1],
],
};
}
if (orig.fixedSegments && rescaledPoints.points) {
update.fixedSegments = orig.fixedSegments.map((segment) => ({
...segment,
start: rescaledPoints.points[segment.index - 1],
end: rescaledPoints.points[segment.index],
}));
}
}
if (isImageElement(orig)) { if (isImageElement(orig)) {
update.scale = [ update.scale = [
orig.scale[0] * flipFactorX, orig.scale[0] * flipFactorX,
@@ -1472,7 +1536,10 @@ export const resizeMultipleElements = (
} of elementsAndUpdates) { } of elementsAndUpdates) {
const { width, height, angle } = update; const { width, height, angle } = update;
mutateElement(element, update, false); mutateElement(element, update, false, {
// needed for the fixed binding point udpate to take effect
isDragging: true,
});
updateBoundElements(element, elementsMap as SceneElementsMap, { updateBoundElements(element, elementsMap as SceneElementsMap, {
simultaneouslyUpdated: elementsToUpdate, simultaneouslyUpdated: elementsToUpdate,
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -116,8 +116,5 @@ const normalizeBoundElementsOrder = (
export const normalizeElementOrder = ( export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => { ) => {
// console.time(); return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
const ret = normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
// console.timeEnd();
return ret;
}; };
@@ -6,9 +6,8 @@ import {
getContainerCoords, getContainerCoords,
getBoundTextMaxWidth, getBoundTextMaxWidth,
getBoundTextMaxHeight, getBoundTextMaxHeight,
detectLineHeight,
getLineHeightInPx,
} from "./textElement"; } from "./textElement";
import { detectLineHeight, getLineHeightInPx } from "./textMeasurements";
import type { ExcalidrawTextElementWithContainer } from "./types"; import type { ExcalidrawTextElementWithContainer } from "./types";
describe("Test measureText", () => { describe("Test measureText", () => {
+2 -228
View File
@@ -1,4 +1,4 @@
import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils"; import { getFontString, arrayToMap } from "../utils";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawElement, ExcalidrawElement,
@@ -6,7 +6,6 @@ import type {
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
FontString,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
@@ -14,7 +13,6 @@ import {
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO, ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
ARROW_LABEL_WIDTH_FRACTION, ARROW_LABEL_WIDTH_FRACTION,
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
TEXT_ALIGN, TEXT_ALIGN,
VERTICAL_ALIGN, VERTICAL_ALIGN,
@@ -30,18 +28,7 @@ import {
updateOriginalContainerCache, updateOriginalContainerCache,
} from "./containerCache"; } from "./containerCache";
import type { ExtractSetType } from "../utility-types"; import type { ExtractSetType } from "../utility-types";
import { measureText } from "./textMeasurements";
export const normalizeText = (text: string) => {
return (
normalizeEOL(text)
// replace tabs with spaces so they render and measure correctly
.replace(/\t/g, " ")
);
};
const splitIntoLines = (text: string) => {
return normalizeText(text).split("\n");
};
export const redrawTextBoundingBox = ( export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
@@ -281,201 +268,6 @@ export const computeBoundTextPosition = (
return { x, y }; return { x, y };
}; };
export const measureText = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
forceAdvanceWidth?: true,
) => {
const _text = text
.split("\n")
// replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation
.map((x) => x || " ")
.join("\n");
const fontSize = parseFloat(font);
const height = getTextHeight(_text, fontSize, lineHeight);
const width = getTextWidth(_text, font, forceAdvanceWidth);
return { width, height };
};
/**
* To get unitless line-height (if unknown) we can calculate it by dividing
* height-per-line by fontSize.
*/
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
const lineCount = splitIntoLines(textElement.text).length;
return (textElement.height /
lineCount /
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
};
/**
* We calculate the line height from the font size and the unitless line height,
* aligning with the W3C spec.
*/
export const getLineHeightInPx = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return fontSize * lineHeight;
};
// FIXME rename to getApproxMinContainerHeight
export const getApproxMinLineHeight = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
};
let canvas: HTMLCanvasElement | undefined;
/**
* @param forceAdvanceWidth use to force retrieve the "advance width" ~ `metrics.width`, instead of the actual boundind box width.
*
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
*
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
* - text wrapping
* - wysiwyg editor (+padding)
*
* Everything else should be based on the actual bounding box width.
*
* `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies.
*/
export const getLineWidth = (
text: string,
font: FontString,
forceAdvanceWidth?: true,
) => {
if (!canvas) {
canvas = document.createElement("canvas");
}
const canvas2dContext = canvas.getContext("2d")!;
canvas2dContext.font = font;
const metrics = canvas2dContext.measureText(text);
const advanceWidth = metrics.width;
// retrieve the actual bounding box width if these metrics are available (as of now > 95% coverage)
if (
!forceAdvanceWidth &&
window.TextMetrics &&
"actualBoundingBoxLeft" in window.TextMetrics.prototype &&
"actualBoundingBoxRight" in window.TextMetrics.prototype
) {
// could be negative, therefore getting the absolute value
const actualWidth =
Math.abs(metrics.actualBoundingBoxLeft) +
Math.abs(metrics.actualBoundingBoxRight);
// fallback to advance width if the actual width is zero, i.e. on text editing start
// or when actual width does not respect whitespace chars, i.e. spaces
// otherwise actual width should always be bigger
return Math.max(actualWidth, advanceWidth);
}
// since in test env the canvas measureText algo
// doesn't measure text and instead just returns number of
// characters hence we assume that each letteris 10px
if (isTestEnv()) {
return advanceWidth * 10;
}
return advanceWidth;
};
export const getTextWidth = (
text: string,
font: FontString,
forceAdvanceWidth?: true,
) => {
const lines = splitIntoLines(text);
let width = 0;
lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth));
});
return width;
};
export const getTextHeight = (
text: string,
fontSize: number,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const lineCount = splitIntoLines(text).length;
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
};
export const charWidth = (() => {
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
const calculate = (char: string, font: FontString) => {
const unicode = char.charCodeAt(0);
if (!cachedCharWidth[font]) {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][unicode]) {
const width = getLineWidth(char, font, true);
cachedCharWidth[font][unicode] = width;
}
return cachedCharWidth[font][unicode];
};
const getCache = (font: FontString) => {
return cachedCharWidth[font];
};
const clearCache = (font: FontString) => {
cachedCharWidth[font] = [];
};
return {
calculate,
getCache,
clearCache,
};
})();
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
// FIXME rename to getApproxMinContainerWidth
export const getApproxMinLineWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) {
return (
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
BOUND_TEXT_PADDING * 2
);
}
return maxCharWidth + BOUND_TEXT_PADDING * 2;
};
export const getMinCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.min(...cacheWithOutEmpty);
};
export const getMaxCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.max(...cacheWithOutEmpty);
};
export const getBoundTextElementId = (container: ExcalidrawElement | null) => { export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
return container?.boundElements?.length return container?.boundElements?.length
? container?.boundElements?.find((ele) => ele.type === "text")?.id || null ? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
@@ -712,24 +504,6 @@ export const getBoundTextMaxHeight = (
return height - BOUND_TEXT_PADDING * 2; return height - BOUND_TEXT_PADDING * 2;
}; };
export const isMeasureTextSupported = () => {
const width = getTextWidth(
DUMMY_TEXT,
getFontString({
fontSize: DEFAULT_FONT_SIZE,
fontFamily: DEFAULT_FONT_FAMILY,
}),
);
return width > 0;
};
export const getMinTextElementWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
};
/** retrieves text from text elements and concatenates to a single string */ /** retrieves text from text elements and concatenates to a single string */
export const getTextFromElements = ( export const getTextFromElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -0,0 +1,224 @@
import {
BOUND_TEXT_PADDING,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_FAMILY,
} from "../constants";
import { getFontString, isTestEnv, normalizeEOL } from "../utils";
import type { FontString, ExcalidrawTextElement } from "./types";
export const measureText = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const _text = text
.split("\n")
// replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation
.map((x) => x || " ")
.join("\n");
const fontSize = parseFloat(font);
const height = getTextHeight(_text, fontSize, lineHeight);
const width = getTextWidth(_text, font);
return { width, height };
};
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
// FIXME rename to getApproxMinContainerWidth
export const getApproxMinLineWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) {
return (
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
BOUND_TEXT_PADDING * 2
);
}
return maxCharWidth + BOUND_TEXT_PADDING * 2;
};
export const getMinTextElementWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
};
export const isMeasureTextSupported = () => {
const width = getTextWidth(
DUMMY_TEXT,
getFontString({
fontSize: DEFAULT_FONT_SIZE,
fontFamily: DEFAULT_FONT_FAMILY,
}),
);
return width > 0;
};
export const normalizeText = (text: string) => {
return (
normalizeEOL(text)
// replace tabs with spaces so they render and measure correctly
.replace(/\t/g, " ")
);
};
const splitIntoLines = (text: string) => {
return normalizeText(text).split("\n");
};
/**
* To get unitless line-height (if unknown) we can calculate it by dividing
* height-per-line by fontSize.
*/
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
const lineCount = splitIntoLines(textElement.text).length;
return (textElement.height /
lineCount /
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
};
/**
* We calculate the line height from the font size and the unitless line height,
* aligning with the W3C spec.
*/
export const getLineHeightInPx = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return fontSize * lineHeight;
};
// FIXME rename to getApproxMinContainerHeight
export const getApproxMinLineHeight = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
};
let textMetricsProvider: TextMetricsProvider | undefined;
/**
* Set a custom text metrics provider.
*
* Useful for overriding the width calculation algorithm where canvas API is not available / desired.
*/
export const setCustomTextMetricsProvider = (provider: TextMetricsProvider) => {
textMetricsProvider = provider;
};
export interface TextMetricsProvider {
getLineWidth(text: string, fontString: FontString): number;
}
class CanvasTextMetricsProvider implements TextMetricsProvider {
private canvas: HTMLCanvasElement;
constructor() {
this.canvas = document.createElement("canvas");
}
/**
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
* - text wrapping
* - wysiwyg editor (+padding)
*
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
*/
public getLineWidth(text: string, fontString: FontString): number {
const context = this.canvas.getContext("2d")!;
context.font = fontString;
const metrics = context.measureText(text);
const advanceWidth = metrics.width;
// since in test env the canvas measureText algo
// doesn't measure text and instead just returns number of
// characters hence we assume that each letteris 10px
if (isTestEnv()) {
return advanceWidth * 10;
}
return advanceWidth;
}
}
export const getLineWidth = (text: string, font: FontString) => {
if (!textMetricsProvider) {
textMetricsProvider = new CanvasTextMetricsProvider();
}
return textMetricsProvider.getLineWidth(text, font);
};
export const getTextWidth = (text: string, font: FontString) => {
const lines = splitIntoLines(text);
let width = 0;
lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font));
});
return width;
};
export const getTextHeight = (
text: string,
fontSize: number,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const lineCount = splitIntoLines(text).length;
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
};
export const charWidth = (() => {
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
const calculate = (char: string, font: FontString) => {
const unicode = char.charCodeAt(0);
if (!cachedCharWidth[font]) {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][unicode]) {
const width = getLineWidth(char, font);
cachedCharWidth[font][unicode] = width;
}
return cachedCharWidth[font][unicode];
};
const getCache = (font: FontString) => {
return cachedCharWidth[font];
};
const clearCache = (font: FontString) => {
cachedCharWidth[font] = [];
};
return {
calculate,
getCache,
clearCache,
};
})();
export const getMinCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.min(...cacheWithOutEmpty);
};
export const getMaxCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.max(...cacheWithOutEmpty);
};
+6 -6
View File
@@ -1,5 +1,5 @@
import { ENV } from "../constants"; import { ENV } from "../constants";
import { charWidth, getLineWidth } from "./textElement"; import { charWidth, getLineWidth } from "./textMeasurements";
import type { FontString } from "./types"; import type { FontString } from "./types";
let cachedCjkRegex: RegExp | undefined; let cachedCjkRegex: RegExp | undefined;
@@ -385,7 +385,7 @@ export const wrapText = (
const originalLines = text.split("\n"); const originalLines = text.split("\n");
for (const originalLine of originalLines) { for (const originalLine of originalLines) {
const currentLineWidth = getLineWidth(originalLine, font, true); const currentLineWidth = getLineWidth(originalLine, font);
if (currentLineWidth <= maxWidth) { if (currentLineWidth <= maxWidth) {
lines.push(originalLine); lines.push(originalLine);
@@ -423,7 +423,7 @@ const wrapLine = (
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here // cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
const testLineWidth = isSingleCharacter(token) const testLineWidth = isSingleCharacter(token)
? currentLineWidth + charWidth.calculate(token, font) ? currentLineWidth + charWidth.calculate(token, font)
: getLineWidth(testLine, font, true); : getLineWidth(testLine, font);
// build up the current line, skipping length check for possibly trailing whitespaces // build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) { if (/\s/.test(token) || testLineWidth <= maxWidth) {
@@ -443,7 +443,7 @@ const wrapLine = (
// trailing line of the wrapped word might still be joined with next token/s // trailing line of the wrapped word might still be joined with next token/s
currentLine = trailingLine; currentLine = trailingLine;
currentLineWidth = getLineWidth(trailingLine, font, true); currentLineWidth = getLineWidth(trailingLine, font);
iterator = tokenIterator.next(); iterator = tokenIterator.next();
} else { } else {
// push & reset, but don't iterate on the next token, as we didn't use it yet! // push & reset, but don't iterate on the next token, as we didn't use it yet!
@@ -514,7 +514,7 @@ const wrapWord = (
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`. * Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
*/ */
const trimLine = (line: string, font: FontString, maxWidth: number) => { const trimLine = (line: string, font: FontString, maxWidth: number) => {
const shouldTrimWhitespaces = getLineWidth(line, font, true) > maxWidth; const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
if (!shouldTrimWhitespaces) { if (!shouldTrimWhitespaces) {
return line; return line;
@@ -527,7 +527,7 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
"", "",
]; ];
let trimmedLineWidth = getLineWidth(trimmedLine, font, true); let trimmedLineWidth = getLineWidth(trimmedLine, font);
for (const whitespace of Array.from(whitespaces)) { for (const whitespace of Array.from(whitespaces)) {
const _charWidth = charWidth.calculate(whitespace, font); const _charWidth = charWidth.calculate(whitespace, font);
+3 -3
View File
@@ -24,8 +24,6 @@ import {
getBoundTextElementId, getBoundTextElementId,
getContainerElement, getContainerElement,
getTextElementAngle, getTextElementAngle,
getTextWidth,
normalizeText,
redrawTextBoundingBox, redrawTextBoundingBox,
getBoundTextMaxHeight, getBoundTextMaxHeight,
getBoundTextMaxWidth, getBoundTextMaxWidth,
@@ -50,6 +48,8 @@ import {
originalContainerCache, originalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
} from "./containerCache"; } from "./containerCache";
import { getTextWidth } from "./textMeasurements";
import { normalizeText } from "./textMeasurements";
const getTransform = ( const getTransform = (
width: number, width: number,
@@ -350,7 +350,7 @@ export const textWysiwyg = ({
font, font,
getBoundTextMaxWidth(container, boundTextElement), getBoundTextMaxWidth(container, boundTextElement),
); );
const width = getTextWidth(wrappedText, font, true); const width = getTextWidth(wrappedText, font);
editable.style.width = `${width}px`; editable.style.width = `${width}px`;
} }
}; };
+23
View File
@@ -319,6 +319,12 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
endArrowhead: Arrowhead | null; endArrowhead: Arrowhead | null;
}>; }>;
export type FixedSegment = {
start: LocalPoint;
end: LocalPoint;
index: number;
};
export type ExcalidrawArrowElement = ExcalidrawLinearElement & export type ExcalidrawArrowElement = ExcalidrawLinearElement &
Readonly<{ Readonly<{
type: "arrow"; type: "arrow";
@@ -331,6 +337,23 @@ export type ExcalidrawElbowArrowElement = Merge<
elbowed: true; elbowed: true;
startBinding: FixedPointBinding | null; startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null; endBinding: FixedPointBinding | null;
fixedSegments: readonly FixedSegment[] | null;
/**
* Marks that the 3rd point should be used as the 2nd point of the arrow in
* order to temporarily hide the first segment of the arrow without losing
* the data from the points array. It allows creating the expected arrow
* path when the arrow with fixed segments is bound on a horizontal side and
* moved to a vertical and vica versa.
*/
startIsSpecial: boolean | null;
/**
* Marks that the 3rd point backwards from the end should be used as the 2nd
* point of the arrow in order to temporarily hide the last segment of the
* arrow without losing the data from the points array. It allows creating
* the expected arrow path when the arrow with fixed segments is bound on a
* horizontal side and moved to a vertical and vica versa.
*/
endIsSpecial: boolean | null;
} }
>; >;
+11 -36
View File
@@ -3,11 +3,10 @@ import {
FONT_FAMILY_FALLBACKS, FONT_FAMILY_FALLBACKS,
CJK_HAND_DRAWN_FALLBACK_FONT, CJK_HAND_DRAWN_FALLBACK_FONT,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
isSafari,
getFontFamilyFallbacks, getFontFamilyFallbacks,
} from "../constants"; } from "../constants";
import { isTextElement } from "../element"; import { isTextElement } from "../element";
import { charWidth, getContainerElement } from "../element/textElement"; import { getContainerElement } from "../element/textElement";
import { containsCJK } from "../element/textWrapping"; import { containsCJK } from "../element/textWrapping";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import { getFontString, PromisePool, promiseTry } from "../utils"; import { getFontString, PromisePool, promiseTry } from "../utils";
@@ -32,6 +31,7 @@ import type {
} from "../element/types"; } from "../element/types";
import type Scene from "../scene/Scene"; import type Scene from "../scene/Scene";
import type { ValueOf } from "../utility-types"; import type { ValueOf } from "../utility-types";
import { charWidth } from "../element/textMeasurements";
export class Fonts { export class Fonts {
// it's ok to track fonts across multiple instances only once, so let's use // it's ok to track fonts across multiple instances only once, so let's use
@@ -137,50 +137,28 @@ export class Fonts {
/** /**
* Load font faces for a given scene and trigger scene update. * Load font faces for a given scene and trigger scene update.
*
* FontFaceSet loadingdone event we listen on may not always
* fire (looking at you Safari), so on init we manually load all
* fonts and rerender scene text elements once done.
*
* For Safari we make sure to check against each loaded font face
* with the unique characters per family in the scene,
* otherwise fonts might remain unloaded.
*/ */
public loadSceneFonts = async (): Promise<FontFace[]> => { public loadSceneFonts = async (): Promise<FontFace[]> => {
const sceneFamilies = this.getSceneFamilies(); const sceneFamilies = this.getSceneFamilies();
const charsPerFamily = isSafari const charsPerFamily = Fonts.getCharsPerFamily(
? Fonts.getCharsPerFamily(this.scene.getNonDeletedElements()) this.scene.getNonDeletedElements(),
: undefined; );
return Fonts.loadFontFaces(sceneFamilies, charsPerFamily); return Fonts.loadFontFaces(sceneFamilies, charsPerFamily);
}; };
/** /**
* Load font faces for passed elements - use when the scene is unavailable (i.e. export). * Load font faces for passed elements - use when the scene is unavailable (i.e. export).
*
* For Safari we make sure to check against each loaded font face,
* with the unique characters per family in the elements
* otherwise fonts might remain unloaded.
*/ */
public static loadElementsFonts = async ( public static loadElementsFonts = async (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
): Promise<FontFace[]> => { ): Promise<FontFace[]> => {
const fontFamilies = Fonts.getUniqueFamilies(elements); const fontFamilies = Fonts.getUniqueFamilies(elements);
const charsPerFamily = isSafari const charsPerFamily = Fonts.getCharsPerFamily(elements);
? Fonts.getCharsPerFamily(elements)
: undefined;
return Fonts.loadFontFaces(fontFamilies, charsPerFamily); return Fonts.loadFontFaces(fontFamilies, charsPerFamily);
}; };
/**
* Load all registered font faces.
*/
public static loadAllFonts = async (): Promise<FontFace[]> => {
const allFamilies = Fonts.getAllFamilies();
return Fonts.loadFontFaces(allFamilies);
};
/** /**
* Generate CSS @font-face declarations for the given elements. * Generate CSS @font-face declarations for the given elements.
*/ */
@@ -223,7 +201,7 @@ export class Fonts {
private static async loadFontFaces( private static async loadFontFaces(
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>, fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
charsPerFamily?: Record<number, Set<string>>, charsPerFamily: Record<number, Set<string>>,
) { ) {
// add all registered font faces into the `document.fonts` (if not added already) // add all registered font faces into the `document.fonts` (if not added already)
for (const { fontFaces, metadata } of Fonts.registered.values()) { for (const { fontFaces, metadata } of Fonts.registered.values()) {
@@ -248,7 +226,7 @@ export class Fonts {
private static *fontFacesLoader( private static *fontFacesLoader(
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>, fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
charsPerFamily?: Record<number, Set<string>>, charsPerFamily: Record<number, Set<string>>,
): Generator<Promise<void | readonly [number, FontFace[]]>> { ): Generator<Promise<void | readonly [number, FontFace[]]>> {
for (const [index, fontFamily] of fontFamilies.entries()) { for (const [index, fontFamily] of fontFamilies.entries()) {
const font = getFontString({ const font = getFontString({
@@ -256,12 +234,9 @@ export class Fonts {
fontSize: 16, fontSize: 16,
}); });
// WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one! // WARN: without "text" param it does not have to mean that all font faces are loaded as it could be just one irrelevant font face!
// for Safari on init, we rather check with the "text" param, even though it's less efficient, as otherwise fonts might remain unloaded // instead, we are always checking chars used in the family, so that no required font faces remain unloaded
const text = const text = Fonts.getCharacters(charsPerFamily, fontFamily);
isSafari && charsPerFamily
? Fonts.getCharacters(charsPerFamily, fontFamily)
: "";
if (!window.document.fonts.check(font, text)) { if (!window.document.fonts.check(font, text)) {
yield promiseTry(async () => { yield promiseTry(async () => {
-1
View File
@@ -190,7 +190,6 @@ export const syncInvalidIndices = (
): OrderedExcalidrawElement[] => { ): OrderedExcalidrawElement[] => {
const indicesGroups = getInvalidIndicesGroups(elements); const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups); const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, update] of elementsUpdates) { for (const [element, update] of elementsUpdates) {
mutateElement(element, update, false); mutateElement(element, update, false);
} }
+5 -6
View File
@@ -1,9 +1,8 @@
import React from "react";
import type { ExcalidrawElement } from "./element/types"; import type { ExcalidrawElement } from "./element/types";
import { convertToExcalidrawElements, Excalidraw } from "./index"; import { convertToExcalidrawElements, Excalidraw } from "./index";
import { API } from "./tests/helpers/api"; import { API } from "./tests/helpers/api";
import { Keyboard, Pointer } from "./tests/helpers/ui"; import { Keyboard, Pointer } from "./tests/helpers/ui";
import { render } from "./tests/test-utils"; import { getCloneByOrigId, render } from "./tests/test-utils";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@@ -413,10 +412,10 @@ describe("adding elements to frames", () => {
dragElementIntoFrame(frame, rect2); dragElementIntoFrame(frame, rect2);
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
selectElementAndDuplicate(rect2); selectElementAndDuplicate(rect2);
const rect2_copy = getCloneByOrigId(rect2.id);
expect(rect2_copy.frameId).toBe(frame.id); expect(rect2_copy.frameId).toBe(frame.id);
expect(rect2.frameId).toBe(frame.id); expect(rect2.frameId).toBe(frame.id);
expectEqualIds([rect2_copy, rect2, frame]); expectEqualIds([rect2_copy, rect2, frame]);
@@ -427,11 +426,11 @@ describe("adding elements to frames", () => {
dragElementIntoFrame(frame, rect2); dragElementIntoFrame(frame, rect2);
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
// move the rect2 outside the frame // move the rect2 outside the frame
selectElementAndDuplicate(rect2, [-1000, -1000]); selectElementAndDuplicate(rect2, [-1000, -1000]);
const rect2_copy = getCloneByOrigId(rect2.id);
expect(rect2_copy.frameId).toBe(frame.id); expect(rect2_copy.frameId).toBe(frame.id);
expect(rect2.frameId).toBe(null); expect(rect2.frameId).toBe(null);
expectEqualIds([rect2_copy, frame, rect2]); expectEqualIds([rect2_copy, frame, rect2]);
+214 -44
View File
@@ -95,12 +95,11 @@ export const getElementsCompletelyInFrame = (
); );
export const isElementContainingFrame = ( export const isElementContainingFrame = (
elements: readonly ExcalidrawElement[],
element: ExcalidrawElement, element: ExcalidrawElement,
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
return getElementsWithinSelection(elements, element, elementsMap).some( return getElementsWithinSelection([frame], element, elementsMap).some(
(e) => e.id === frame.id, (e) => e.id === frame.id,
); );
}; };
@@ -144,7 +143,7 @@ export const elementOverlapsWithFrame = (
return ( return (
elementsAreInFrameBounds([element], frame, elementsMap) || elementsAreInFrameBounds([element], frame, elementsMap) ||
isElementIntersectingFrame(element, frame, elementsMap) || isElementIntersectingFrame(element, frame, elementsMap) ||
isElementContainingFrame([frame], element, frame, elementsMap) isElementContainingFrame(element, frame, elementsMap)
); );
}; };
@@ -283,7 +282,7 @@ export const getElementsInResizingFrame = (
const elementsCompletelyInFrame = new Set([ const elementsCompletelyInFrame = new Set([
...getElementsCompletelyInFrame(allElements, frame, elementsMap), ...getElementsCompletelyInFrame(allElements, frame, elementsMap),
...prevElementsInFrame.filter((element) => ...prevElementsInFrame.filter((element) =>
isElementContainingFrame(allElements, element, frame, elementsMap), isElementContainingFrame(element, frame, elementsMap),
), ),
]); ]);
@@ -370,12 +369,57 @@ export const getElementsInNewFrame = (
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
return omitGroupsContainingFrameLikes( return omitPartialGroups(
elements, omitGroupsContainingFrameLikes(
getElementsCompletelyInFrame(elements, frame, elementsMap), elements,
getElementsCompletelyInFrame(elements, frame, elementsMap),
),
frame,
elementsMap,
); );
}; };
export const omitPartialGroups = (
elements: ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
allElementsMap: ElementsMap,
) => {
const elementsToReturn = [];
const checkedGroups = new Map<string, boolean>();
for (const element of elements) {
let shouldOmit = false;
if (element.groupIds.length > 0) {
// if some partial group should be omitted, then all elements in that group should be omitted
if (element.groupIds.some((gid) => checkedGroups.get(gid))) {
shouldOmit = true;
} else {
const allElementsInGroup = new Set(
element.groupIds.flatMap((gid) =>
getElementsInGroup(allElementsMap, gid),
),
);
shouldOmit = !elementsAreInFrameBounds(
Array.from(allElementsInGroup),
frame,
allElementsMap,
);
}
element.groupIds.forEach((gid) => {
checkedGroups.set(gid, shouldOmit);
});
}
if (!shouldOmit) {
elementsToReturn.push(element);
}
}
return elementsToReturn;
};
export const getContainingFrame = ( export const getContainingFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
@@ -454,6 +498,7 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
allElements: T, allElements: T,
elementsToAdd: NonDeletedExcalidrawElement[], elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
appState: AppState,
): T => { ): T => {
const elementsMap = arrayToMap(allElements); const elementsMap = arrayToMap(allElements);
const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>(); const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
@@ -489,6 +534,17 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
continue; continue;
} }
// if the element is already in another frame (which is also in elementsToAdd),
// it means that frame and children are selected at the same time
// => keep original frame membership, do not add to the target frame
if (
element.frameId &&
appState.selectedElementIds[element.id] &&
appState.selectedElementIds[element.frameId]
) {
continue;
}
if (!currTargetFrameChildrenMap.has(element.id)) { if (!currTargetFrameChildrenMap.has(element.id)) {
finalElementsToAdd.push(element); finalElementsToAdd.push(element);
} }
@@ -577,6 +633,7 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
removeAllElementsFromFrame(allElements, frame), removeAllElementsFromFrame(allElements, frame),
nextElementsInFrame, nextElementsInFrame,
frame, frame,
app.state,
).slice(); ).slice();
}; };
@@ -683,6 +740,16 @@ export const getTargetFrame = (
? getContainerElement(element, elementsMap) || element ? getContainerElement(element, elementsMap) || element
: element; : element;
// if the element and its containing frame are both selected, then
// the containing frame is the target frame
if (
_element.frameId &&
appState.selectedElementIds[_element.id] &&
appState.selectedElementIds[_element.frameId]
) {
return getContainingFrame(_element, elementsMap);
}
return appState.selectedElementIds[_element.id] && return appState.selectedElementIds[_element.id] &&
appState.selectedElementsAreBeingDragged appState.selectedElementsAreBeingDragged
? appState.frameToHighlight ? appState.frameToHighlight
@@ -695,61 +762,151 @@ export const isElementInFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
allElementsMap: ElementsMap, allElementsMap: ElementsMap,
appState: StaticCanvasAppState, appState: StaticCanvasAppState,
opts?: {
targetFrame?: ExcalidrawFrameLikeElement;
checkedGroups?: Map<string, boolean>;
},
) => { ) => {
const frame = getTargetFrame(element, allElementsMap, appState); const frame =
opts?.targetFrame ?? getTargetFrame(element, allElementsMap, appState);
if (!frame) {
return false;
}
const _element = isTextElement(element) const _element = isTextElement(element)
? getContainerElement(element, allElementsMap) || element ? getContainerElement(element, allElementsMap) || element
: element; : element;
if (frame) { const setGroupsInFrame = (isInFrame: boolean) => {
// Perf improvement: if (opts?.checkedGroups) {
// For an element that's already in a frame, if it's not being dragged _element.groupIds.forEach((groupId) => {
// then there is no need to refer to geometry (which, yes, is slow) to check if it's in a frame. opts.checkedGroups?.set(groupId, isInFrame);
// It has to be in its containing frame. });
if ( }
!appState.selectedElementIds[element.id] || };
!appState.selectedElementsAreBeingDragged
) { if (
// if the element is not selected, or it is selected but not being dragged,
// frame membership won't update, so return true
!appState.selectedElementIds[_element.id] ||
!appState.selectedElementsAreBeingDragged ||
// if both frame and element are selected, won't update membership, so return true
(appState.selectedElementIds[_element.id] &&
appState.selectedElementIds[frame.id])
) {
return true;
}
if (_element.groupIds.length === 0) {
return elementOverlapsWithFrame(_element, frame, allElementsMap);
}
for (const gid of _element.groupIds) {
if (opts?.checkedGroups?.has(gid)) {
return opts.checkedGroups.get(gid)!!;
}
}
const allElementsInGroup = new Set(
_element.groupIds
.filter((gid) => {
if (opts?.checkedGroups) {
return !opts.checkedGroups.has(gid);
}
return true;
})
.flatMap((gid) => getElementsInGroup(allElementsMap, gid)),
);
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
const selectedElements = new Set(
getSelectedElements(allElementsMap, appState),
);
const editingGroupOverlapsFrame = appState.frameToHighlight !== null;
if (editingGroupOverlapsFrame) {
return true; return true;
} }
if (_element.groupIds.length === 0) { selectedElements.forEach((selectedElement) => {
return elementOverlapsWithFrame(_element, frame, allElementsMap); allElementsInGroup.delete(selectedElement);
});
}
for (const elementInGroup of allElementsInGroup) {
if (isFrameLikeElement(elementInGroup)) {
setGroupsInFrame(false);
return false;
}
}
for (const elementInGroup of allElementsInGroup) {
if (elementOverlapsWithFrame(elementInGroup, frame, allElementsMap)) {
setGroupsInFrame(true);
return true;
}
}
return false;
};
export const shouldApplyFrameClip = (
element: ExcalidrawElement,
frame: ExcalidrawFrameLikeElement,
appState: StaticCanvasAppState,
elementsMap: ElementsMap,
checkedGroups?: Map<string, boolean>,
) => {
if (!appState.frameRendering || !appState.frameRendering.clip) {
return false;
}
// for individual elements, only clip when the element is
// a. overlapping with the frame, or
// b. containing the frame, for example when an element is used as a background
// and is therefore bigger than the frame and completely contains the frame
const shouldClipElementItself =
isElementIntersectingFrame(element, frame, elementsMap) ||
isElementContainingFrame(element, frame, elementsMap);
if (shouldClipElementItself) {
for (const groupId of element.groupIds) {
checkedGroups?.set(groupId, true);
} }
const allElementsInGroup = new Set( return true;
_element.groupIds.flatMap((gid) => }
getElementsInGroup(allElementsMap, gid),
),
);
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) { // if an element is outside the frame, but is part of a group that has some elements
const selectedElements = new Set( // "in" the frame, we should clip the element
getSelectedElements(allElementsMap, appState), if (
); !shouldClipElementItself &&
element.groupIds.length > 0 &&
!elementsAreInFrameBounds([element], frame, elementsMap)
) {
let shouldClip = false;
const editingGroupOverlapsFrame = appState.frameToHighlight !== null; // if no elements are being dragged, we can skip the geometry check
// because we know if the element is in the given frame or not
if (editingGroupOverlapsFrame) { if (!appState.selectedElementsAreBeingDragged) {
return true; shouldClip = element.frameId === frame.id;
for (const groupId of element.groupIds) {
checkedGroups?.set(groupId, shouldClip);
} }
} else {
selectedElements.forEach((selectedElement) => { shouldClip = isElementInFrame(element, elementsMap, appState, {
allElementsInGroup.delete(selectedElement); targetFrame: frame,
checkedGroups,
}); });
} }
for (const elementInGroup of allElementsInGroup) { for (const groupId of element.groupIds) {
if (isFrameLikeElement(elementInGroup)) { checkedGroups?.set(groupId, shouldClip);
return false;
}
} }
for (const elementInGroup of allElementsInGroup) { return shouldClip;
if (elementOverlapsWithFrame(elementInGroup, frame, allElementsMap)) {
return true;
}
}
} }
return false; return false;
@@ -779,3 +936,16 @@ export const getElementsOverlappingFrame = (
.filter((el) => !el.frameId || el.frameId === frame.id) .filter((el) => !el.frameId || el.frameId === frame.id)
); );
}; };
export const frameAndChildrenSelectedTogether = (
selectedElements: readonly ExcalidrawElement[],
) => {
const selectedElementsMap = arrayToMap(selectedElements);
return (
selectedElements.length > 1 &&
selectedElements.some(
(element) => element.frameId && selectedElementsMap.has(element.frameId),
)
);
};
+4
View File
@@ -105,6 +105,10 @@ export const selectGroupsForSelectedElements = (function () {
const groupElementsIndex: Record<GroupId, string[]> = {}; const groupElementsIndex: Record<GroupId, string[]> = {};
const selectedElementIdsInGroups = elements.reduce( const selectedElementIdsInGroups = elements.reduce(
(acc: Record<string, true>, element) => { (acc: Record<string, true>, element) => {
if (element.isDeleted) {
return acc;
}
const groupId = element.groupIds.find((id) => selectedGroupIds[id]); const groupId = element.groupIds.find((id) => selectedGroupIds[id]);
if (groupId) { if (groupId) {

Some files were not shown because too many files have changed in this diff Show More