Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5da6b08b2 | |||
| 9ee0b8ffcb | |||
| 16b86d7d16 | |||
| f12b92ce9d | |||
| 77dc055d81 | |||
| 26f02bebea | |||
| e3060dfb8f | |||
| c329470b73 | |||
| c8f4a4cb41 | |||
| 9e49c9254b | |||
| b0c8c5f7a7 | |||
| 4f64372506 | |||
| 424e94a403 | |||
| 302664e500 | |||
| 86c67bd37f | |||
| 511433988c | |||
| 9b6edc767a | |||
| 6cdb683410 | |||
| 84bab403ff | |||
| 61e0bb83d0 | |||
| bd1590fc74 | |||
| d29c3db7f6 | |||
| a58822c1c1 | |||
| a3e1619635 | |||
| 52eaf64591 | |||
| 7028daa44a | |||
| 65f218b100 | |||
| 807b3c59f2 | |||
| b8da5065fd | |||
| 49f1276ef2 | |||
| 8f20b29b73 | |||
| f87c2cde09 | |||
| 0bf234fcc9 | |||
| dd1b45a25a | |||
| ec06fbc1fc | |||
| fa05ae1230 | |||
| 91ebf8b0ea |
@@ -25,6 +25,7 @@ import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||
import { getFrame } from "../../packages/excalidraw/utils";
|
||||
import { ExcalidrawLogo } from "../../packages/excalidraw/components/ExcalidrawLogo";
|
||||
import { uploadBytes, ref } from "firebase/storage";
|
||||
|
||||
export const exportToExcalidrawPlus = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
@@ -32,7 +33,7 @@ export const exportToExcalidrawPlus = async (
|
||||
files: BinaryFiles,
|
||||
name: string,
|
||||
) => {
|
||||
const firebase = await loadFirebaseStorage();
|
||||
const storage = await loadFirebaseStorage();
|
||||
|
||||
const id = `${nanoid(12)}`;
|
||||
|
||||
@@ -49,15 +50,13 @@ export const exportToExcalidrawPlus = async (
|
||||
},
|
||||
);
|
||||
|
||||
await firebase
|
||||
.storage()
|
||||
.ref(`/migrations/scenes/${id}`)
|
||||
.put(blob, {
|
||||
customMetadata: {
|
||||
data: JSON.stringify({ version: 2, name }),
|
||||
created: Date.now().toString(),
|
||||
},
|
||||
});
|
||||
const storageRef = ref(storage, `/migrations/scenes/${id}`);
|
||||
await uploadBytes(storageRef, blob, {
|
||||
customMetadata: {
|
||||
data: JSON.stringify({ version: 2, name }),
|
||||
created: Date.now().toString(),
|
||||
},
|
||||
});
|
||||
|
||||
const filesMap = new Map<FileId, BinaryFileData>();
|
||||
for (const element of elements) {
|
||||
|
||||
@@ -22,9 +22,17 @@ import {
|
||||
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
||||
import type { SyncableExcalidrawElement } from ".";
|
||||
import { getSyncableElements } from ".";
|
||||
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
|
||||
import type { Socket } from "socket.io-client";
|
||||
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
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -41,80 +49,42 @@ try {
|
||||
FIREBASE_CONFIG = {};
|
||||
}
|
||||
|
||||
let firebasePromise: Promise<typeof import("firebase/app").default> | null =
|
||||
null;
|
||||
let firestorePromise: Promise<any> | null | true = null;
|
||||
let firebaseStoragePromise: Promise<any> | null | true = null;
|
||||
let firebaseApp: ReturnType<typeof initializeApp> | null = null;
|
||||
let firestore: ReturnType<typeof getFirestore> | null = null;
|
||||
let firebaseStorage: ReturnType<typeof getStorage> | null = null;
|
||||
|
||||
let isFirebaseInitialized = false;
|
||||
|
||||
const _loadFirebase = async () => {
|
||||
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;
|
||||
const _initializeFirebase = () => {
|
||||
if (!firebaseApp) {
|
||||
firebaseApp = initializeApp(FIREBASE_CONFIG);
|
||||
}
|
||||
|
||||
return firebase;
|
||||
return firebaseApp;
|
||||
};
|
||||
|
||||
const _getFirebase = async (): Promise<
|
||||
typeof import("firebase/app").default
|
||||
> => {
|
||||
if (!firebasePromise) {
|
||||
firebasePromise = _loadFirebase();
|
||||
const _getFirestore = () => {
|
||||
if (!firestore) {
|
||||
firestore = getFirestore(_initializeFirebase());
|
||||
}
|
||||
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 () => {
|
||||
const firebase = await _getFirebase();
|
||||
if (!firebaseStoragePromise) {
|
||||
firebaseStoragePromise = import(
|
||||
/* webpackChunkName: "storage" */ "firebase/storage"
|
||||
);
|
||||
}
|
||||
if (firebaseStoragePromise !== true) {
|
||||
await firebaseStoragePromise;
|
||||
firebaseStoragePromise = true;
|
||||
}
|
||||
return firebase;
|
||||
return _getStorage();
|
||||
};
|
||||
|
||||
interface FirebaseStoredScene {
|
||||
type FirebaseStoredScene = {
|
||||
sceneVersion: number;
|
||||
iv: firebase.default.firestore.Blob;
|
||||
ciphertext: firebase.default.firestore.Blob;
|
||||
}
|
||||
iv: Bytes;
|
||||
ciphertext: Bytes;
|
||||
};
|
||||
|
||||
const encryptElements = async (
|
||||
key: string,
|
||||
@@ -175,7 +145,7 @@ export const saveFilesToFirebase = async ({
|
||||
prefix: string;
|
||||
files: { id: FileId; buffer: Uint8Array }[];
|
||||
}) => {
|
||||
const firebase = await loadFirebaseStorage();
|
||||
const storage = await loadFirebaseStorage();
|
||||
|
||||
const erroredFiles: FileId[] = [];
|
||||
const savedFiles: FileId[] = [];
|
||||
@@ -183,17 +153,10 @@ export const saveFilesToFirebase = async ({
|
||||
await Promise.all(
|
||||
files.map(async ({ id, buffer }) => {
|
||||
try {
|
||||
await firebase
|
||||
.storage()
|
||||
.ref(`${prefix}/${id}`)
|
||||
.put(
|
||||
new Blob([buffer], {
|
||||
type: MIME_TYPES.binary,
|
||||
}),
|
||||
{
|
||||
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
|
||||
},
|
||||
);
|
||||
const storageRef = ref(storage, `${prefix}/${id}`);
|
||||
await uploadBytes(storageRef, buffer, {
|
||||
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
|
||||
});
|
||||
savedFiles.push(id);
|
||||
} catch (error: any) {
|
||||
erroredFiles.push(id);
|
||||
@@ -205,7 +168,6 @@ export const saveFilesToFirebase = async ({
|
||||
};
|
||||
|
||||
const createFirebaseSceneDocument = async (
|
||||
firebase: ResolutionType<typeof loadFirestore>,
|
||||
elements: readonly SyncableExcalidrawElement[],
|
||||
roomKey: string,
|
||||
) => {
|
||||
@@ -213,10 +175,8 @@ const createFirebaseSceneDocument = async (
|
||||
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
||||
return {
|
||||
sceneVersion,
|
||||
ciphertext: firebase.firestore.Blob.fromUint8Array(
|
||||
new Uint8Array(ciphertext),
|
||||
),
|
||||
iv: firebase.firestore.Blob.fromUint8Array(iv),
|
||||
ciphertext: Bytes.fromUint8Array(new Uint8Array(ciphertext)),
|
||||
iv: Bytes.fromUint8Array(iv),
|
||||
} as FirebaseStoredScene;
|
||||
};
|
||||
|
||||
@@ -236,20 +196,14 @@ export const saveToFirebase = async (
|
||||
return null;
|
||||
}
|
||||
|
||||
const firebase = await loadFirestore();
|
||||
const firestore = firebase.firestore();
|
||||
const firestore = _getFirestore();
|
||||
const docRef = doc(firestore, "scenes", roomId);
|
||||
|
||||
const docRef = firestore.collection("scenes").doc(roomId);
|
||||
|
||||
const storedScene = await firestore.runTransaction(async (transaction) => {
|
||||
const storedScene = await runTransaction(firestore, async (transaction) => {
|
||||
const snapshot = await transaction.get(docRef);
|
||||
|
||||
if (!snapshot.exists) {
|
||||
const storedScene = await createFirebaseSceneDocument(
|
||||
firebase,
|
||||
elements,
|
||||
roomKey,
|
||||
);
|
||||
if (!snapshot.exists()) {
|
||||
const storedScene = await createFirebaseSceneDocument(elements, roomKey);
|
||||
|
||||
transaction.set(docRef, storedScene);
|
||||
|
||||
@@ -269,7 +223,6 @@ export const saveToFirebase = async (
|
||||
);
|
||||
|
||||
const storedScene = await createFirebaseSceneDocument(
|
||||
firebase,
|
||||
reconciledElements,
|
||||
roomKey,
|
||||
);
|
||||
@@ -294,15 +247,13 @@ export const loadFromFirebase = async (
|
||||
roomKey: string,
|
||||
socket: Socket | null,
|
||||
): Promise<readonly SyncableExcalidrawElement[] | null> => {
|
||||
const firebase = await loadFirestore();
|
||||
const db = firebase.firestore();
|
||||
|
||||
const docRef = db.collection("scenes").doc(roomId);
|
||||
const doc = await docRef.get();
|
||||
if (!doc.exists) {
|
||||
const firestore = _getFirestore();
|
||||
const docRef = doc(firestore, "scenes", roomId);
|
||||
const docSnap = await getDoc(docRef);
|
||||
if (!docSnap.exists()) {
|
||||
return null;
|
||||
}
|
||||
const storedScene = doc.data() as FirebaseStoredScene;
|
||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
||||
const elements = getSyncableElements(
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||
);
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"firebase": "8.3.3",
|
||||
"@sentry/browser": "9.0.1",
|
||||
"callsites": "4.2.0",
|
||||
"firebase": "11.3.1",
|
||||
"i18next-browser-languagedetector": "6.1.4",
|
||||
"idb-keyval": "6.0.3",
|
||||
"jotai": "2.11.0",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import * as SentryIntegrations from "@sentry/integrations";
|
||||
import callsites from "callsites";
|
||||
|
||||
const SentryEnvHostnameMap: { [key: string]: string } = {
|
||||
"excalidraw.com": "production",
|
||||
"staging.excalidraw.com": "staging",
|
||||
"vercel.app": "staging",
|
||||
};
|
||||
|
||||
@@ -23,9 +24,13 @@ Sentry.init({
|
||||
release: import.meta.env.VITE_APP_GIT_SHA,
|
||||
ignoreErrors: [
|
||||
"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: [
|
||||
new SentryIntegrations.CaptureConsole({
|
||||
Sentry.captureConsoleIntegration({
|
||||
levels: ["error"],
|
||||
}),
|
||||
],
|
||||
@@ -33,6 +38,44 @@ Sentry.init({
|
||||
if (event.request?.url) {
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,10 +21,8 @@ import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const alignActionsPredicate = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
export const alignActionsPredicate = (
|
||||
appState: UIAppState,
|
||||
_: unknown,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@@ -48,6 +46,7 @@ const alignSelectedElements = (
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
alignment,
|
||||
app.scene,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
@@ -64,7 +63,8 @@ export const actionAlignTop = register({
|
||||
label: "labels.alignTop",
|
||||
icon: AlignTopIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -79,7 +79,7 @@ export const actionAlignTop = register({
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={AlignTopIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@@ -97,7 +97,8 @@ export const actionAlignBottom = register({
|
||||
label: "labels.alignBottom",
|
||||
icon: AlignBottomIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -112,7 +113,7 @@ export const actionAlignBottom = register({
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={AlignBottomIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@@ -130,7 +131,8 @@ export const actionAlignLeft = register({
|
||||
label: "labels.alignLeft",
|
||||
icon: AlignLeftIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -145,7 +147,7 @@ export const actionAlignLeft = register({
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={AlignLeftIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@@ -163,7 +165,8 @@ export const actionAlignRight = register({
|
||||
label: "labels.alignRight",
|
||||
icon: AlignRightIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -178,7 +181,7 @@ export const actionAlignRight = register({
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={AlignRightIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@@ -196,7 +199,8 @@ export const actionAlignVerticallyCentered = register({
|
||||
label: "labels.centerVertically",
|
||||
icon: CenterVerticallyIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -209,7 +213,7 @@ export const actionAlignVerticallyCentered = register({
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={CenterVerticallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@@ -225,7 +229,8 @@ export const actionAlignHorizontallyCentered = register({
|
||||
label: "labels.centerHorizontally",
|
||||
icon: CenterHorizontallyIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -238,7 +243,7 @@ export const actionAlignHorizontallyCentered = register({
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={CenterHorizontallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
computeBoundTextPosition,
|
||||
computeContainerDimensionForBoundText,
|
||||
getBoundTextElement,
|
||||
measureText,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
@@ -35,6 +34,7 @@ import { arrayToMap, getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { syncMovedIndices } from "../fractionalIndex";
|
||||
import { StoreAction } from "../store";
|
||||
import { measureText } from "../element/textMeasurements";
|
||||
|
||||
export const actionUnbindText = register({
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -18,14 +18,14 @@ import {
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { TrashIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
import { getContainerElement } from "../element/textElement";
|
||||
import { getFrameChildren } from "../frame";
|
||||
|
||||
const deleteSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const framesToBeDeleted = new Set(
|
||||
getSelectedElements(
|
||||
elements.filter((el) => isFrameLikeElement(el)),
|
||||
@@ -35,10 +35,50 @@ const deleteSelectedElements = (
|
||||
|
||||
const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
|
||||
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
|
||||
const processedElements = new Set<ExcalidrawElement["id"]>();
|
||||
|
||||
for (const frameId of framesToBeDeleted) {
|
||||
const frameChildren = getFrameChildren(elements, frameId);
|
||||
for (const el of frameChildren) {
|
||||
if (processedElements.has(el.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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)) {
|
||||
shouldSelectEditingGroup = false;
|
||||
selectedElementIds[el.id] = true;
|
||||
return el;
|
||||
}
|
||||
|
||||
if (
|
||||
boundElement?.frameId &&
|
||||
framesToBeDeleted.has(boundElement?.frameId)
|
||||
) {
|
||||
return el;
|
||||
}
|
||||
|
||||
if (el.boundElements) {
|
||||
el.boundElements.forEach((candidate) => {
|
||||
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
|
||||
@@ -51,7 +91,7 @@ const deleteSelectedElements = (
|
||||
endBinding:
|
||||
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
|
||||
});
|
||||
mutateElbowArrow(bound, elementsMap, bound.points);
|
||||
mutateElement(bound, { points: bound.points });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -61,7 +101,9 @@ const deleteSelectedElements = (
|
||||
// if deleting a frame, remove the children from it and select them
|
||||
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
||||
shouldSelectEditingGroup = false;
|
||||
selectedElementIds[el.id] = true;
|
||||
if (!isBoundToContainer(el)) {
|
||||
selectedElementIds[el.id] = true;
|
||||
}
|
||||
return newElementWith(el, { frameId: null });
|
||||
}
|
||||
|
||||
@@ -208,12 +250,7 @@ export const actionDeleteSelected = register({
|
||||
: endBindingElement,
|
||||
};
|
||||
|
||||
LinearElementEditor.deletePoints(
|
||||
element,
|
||||
selectedPointsIndices,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
);
|
||||
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
||||
|
||||
return {
|
||||
elements,
|
||||
@@ -231,11 +268,13 @@ export const actionDeleteSelected = register({
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
|
||||
let { elements: nextElements, appState: nextAppState } =
|
||||
deleteSelectedElements(elements, appState, app);
|
||||
|
||||
fixBindingsAfterDeletion(
|
||||
nextElements,
|
||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||
nextElements.filter((el) => el.isDeleted),
|
||||
);
|
||||
|
||||
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 { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import {
|
||||
arrayToMap,
|
||||
castArray,
|
||||
findLastIndex,
|
||||
getShortcutKey,
|
||||
invariant,
|
||||
} from "../utils";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import {
|
||||
selectGroupsForSelectedElements,
|
||||
@@ -19,8 +25,13 @@ import { DEFAULT_GRID_SIZE } from "../constants";
|
||||
import {
|
||||
bindTextToShapeAfterDuplication,
|
||||
getBoundTextElement,
|
||||
getContainerElement,
|
||||
} from "../element/textElement";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isBoundToContainer,
|
||||
isFrameLikeElement,
|
||||
} from "../element/typeChecks";
|
||||
import { normalizeElementOrder } from "../element/sortElements";
|
||||
import { DuplicateIcon } from "../components/icons";
|
||||
import {
|
||||
@@ -31,7 +42,6 @@ import {
|
||||
excludeElementsInFramesFromSelection,
|
||||
getSelectedElements,
|
||||
} from "../scene/selection";
|
||||
import { syncMovedIndices } from "../fractionalIndex";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
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 {
|
||||
...duplicateElements(elements, appState),
|
||||
...nextState,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
@@ -82,37 +104,69 @@ export const actionDuplicateSelection = register({
|
||||
const duplicateElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
): Partial<ActionResult> => {
|
||||
): Partial<Exclude<ActionResult, false>> => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// step (1)
|
||||
|
||||
const sortedElements = normalizeElementOrder(elements);
|
||||
const groupIdMap = new Map();
|
||||
const newElements: ExcalidrawElement[] = [];
|
||||
const oldElements: ExcalidrawElement[] = [];
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
||||
|
||||
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
||||
const newElement = duplicateElement(
|
||||
appState.editingGroupId,
|
||||
groupIdMap,
|
||||
element,
|
||||
{
|
||||
x: element.x + DEFAULT_GRID_SIZE / 2,
|
||||
y: element.y + DEFAULT_GRID_SIZE / 2,
|
||||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
const duplicateAndOffsetElement = <
|
||||
T extends ExcalidrawElement | ExcalidrawElement[],
|
||||
>(
|
||||
element: T,
|
||||
): T extends ExcalidrawElement[]
|
||||
? 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);
|
||||
oldElements.push(element);
|
||||
newElements.push(newElement);
|
||||
return newElement;
|
||||
|
||||
return (
|
||||
Array.isArray(element) ? _newElements : _newElements[0] || null
|
||||
) as T extends ExcalidrawElement[]
|
||||
? ExcalidrawElement[]
|
||||
: ExcalidrawElement | null;
|
||||
};
|
||||
|
||||
elements = normalizeElementOrder(elements);
|
||||
|
||||
const idsOfElementsToDuplicate = arrayToMap(
|
||||
getSelectedElements(sortedElements, appState, {
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
}),
|
||||
@@ -130,122 +184,133 @@ const duplicateElements = (
|
||||
// loop over them.
|
||||
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
||||
|
||||
const markAsProcessed = (elements: ExcalidrawElement[]) => {
|
||||
for (const element of elements) {
|
||||
processedIds.set(element.id, true);
|
||||
const elementsWithClones: ExcalidrawElement[] = elements.slice();
|
||||
|
||||
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;
|
||||
|
||||
while (++index < sortedElements.length) {
|
||||
const element = sortedElements[index];
|
||||
|
||||
if (processedIds.get(element.id)) {
|
||||
for (const element of elements) {
|
||||
if (processedIds.has(element.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, arrayToMap(elements));
|
||||
const isElementAFrameLike = isFrameLikeElement(element);
|
||||
if (!idsOfElementsToDuplicate.has(element.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (idsOfElementsToDuplicate.get(element.id)) {
|
||||
// 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],
|
||||
);
|
||||
// groups
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([
|
||||
...groupElements,
|
||||
...groupElements.map((element) =>
|
||||
duplicateAndOffsetElement(element),
|
||||
),
|
||||
]),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (boundTextElement) {
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([
|
||||
element,
|
||||
boundTextElement,
|
||||
duplicateAndOffsetElement(element),
|
||||
duplicateAndOffsetElement(boundTextElement),
|
||||
]),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (isElementAFrameLike) {
|
||||
const elementsInFrame = getFrameChildren(sortedElements, element.id);
|
||||
const groupId = getSelectedGroupForElement(appState, element);
|
||||
if (groupId) {
|
||||
const groupElements = getElementsInGroup(elements, groupId).flatMap(
|
||||
(element) =>
|
||||
isFrameLikeElement(element)
|
||||
? [...getFrameChildren(elements, element.id), element]
|
||||
: [element],
|
||||
);
|
||||
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([
|
||||
...elementsInFrame,
|
||||
element,
|
||||
...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
|
||||
duplicateAndOffsetElement(element),
|
||||
]),
|
||||
);
|
||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||
return el.groupIds?.includes(groupId);
|
||||
});
|
||||
|
||||
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,
|
||||
// they will have been copied along with the frame atomically in the
|
||||
// above branch, so we must skip those elements here
|
||||
//
|
||||
// now, for elements do not belong any frames or elements whose frames
|
||||
// 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)) {
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
|
||||
insertAfterIndex(targetIndex, duplicateAndOffsetElement(groupElements));
|
||||
continue;
|
||||
}
|
||||
|
||||
// frame duplication
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFrameLikeElement(element)) {
|
||||
const frameId = element.id;
|
||||
|
||||
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
|
||||
// that the last elements are in the correct order (contiguous or otherwise).
|
||||
// Thus we need to reverse as the last step (3).
|
||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||
return el.id === element.id || el.id === container?.id;
|
||||
});
|
||||
|
||||
const finalElementsReversed: ExcalidrawElement[] = [];
|
||||
if (container) {
|
||||
insertAfterIndex(
|
||||
targetIndex,
|
||||
duplicateAndOffsetElement([container, element]),
|
||||
);
|
||||
} else {
|
||||
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
|
||||
}
|
||||
|
||||
const finalElementIds = new Map<ExcalidrawElement["id"], true>();
|
||||
index = elementsWithClones.length;
|
||||
|
||||
while (--index >= 0) {
|
||||
const element = elementsWithClones[index];
|
||||
if (!finalElementIds.get(element.id)) {
|
||||
finalElementIds.set(element.id, true);
|
||||
finalElementsReversed.push(element);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// step (3)
|
||||
const finalElements = syncMovedIndices(
|
||||
finalElementsReversed.reverse(),
|
||||
arrayToMap(newElements),
|
||||
);
|
||||
// default duplication (regular elements)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
insertAfterIndex(
|
||||
findLastIndex(elementsWithClones, (el) => el.id === element.id),
|
||||
duplicateAndOffsetElement(element),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -260,7 +325,7 @@ const duplicateElements = (
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
bindElementsToFramesAfterDuplication(
|
||||
finalElements,
|
||||
elementsWithClones,
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
@@ -269,7 +334,7 @@ const duplicateElements = (
|
||||
excludeElementsInFramesFromSelection(newElements);
|
||||
|
||||
return {
|
||||
elements: finalElements,
|
||||
elements: elementsWithClones,
|
||||
appState: {
|
||||
...appState,
|
||||
...selectGroupsForSelectedElements(
|
||||
@@ -285,7 +350,7 @@ const duplicateElements = (
|
||||
{},
|
||||
),
|
||||
},
|
||||
getNonDeletedElements(finalElements),
|
||||
getNonDeletedElements(elementsWithClones),
|
||||
appState,
|
||||
null,
|
||||
),
|
||||
|
||||
@@ -49,12 +49,13 @@ describe("flipping re-centers selection", () => {
|
||||
},
|
||||
startArrowhead: null,
|
||||
endArrowhead: "arrow",
|
||||
fixedSegments: null,
|
||||
points: [
|
||||
pointFrom(0, 0),
|
||||
pointFrom(0, -35),
|
||||
pointFrom(-90.9, -35),
|
||||
pointFrom(-90.9, 204.9),
|
||||
pointFrom(65.1, 204.9),
|
||||
pointFrom(-90, -35),
|
||||
pointFrom(-90, 204),
|
||||
pointFrom(66, 204),
|
||||
],
|
||||
elbowed: true,
|
||||
}),
|
||||
@@ -70,13 +71,13 @@ describe("flipping re-centers selection", () => {
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
|
||||
const rec1 = h.elements.find((el) => el.id === "rec1");
|
||||
expect(rec1?.x).toBeCloseTo(100);
|
||||
expect(rec1?.y).toBeCloseTo(100);
|
||||
const rec1 = h.elements.find((el) => el.id === "rec1")!;
|
||||
expect(rec1.x).toBeCloseTo(100, 0);
|
||||
expect(rec1.y).toBeCloseTo(100, 0);
|
||||
|
||||
const rec2 = h.elements.find((el) => el.id === "rec2");
|
||||
expect(rec2?.x).toBeCloseTo(220);
|
||||
expect(rec2?.y).toBeCloseTo(250);
|
||||
const rec2 = h.elements.find((el) => el.id === "rec2")!;
|
||||
expect(rec2.x).toBeCloseTo(220, 0);
|
||||
expect(rec2.y).toBeCloseTo(250, 0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
} from "../element/typeChecks";
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import { deepCopyElement } from "../element/newElement";
|
||||
import { getCommonBoundingBox } from "../element/bounds";
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
@@ -134,12 +134,24 @@ const flipElements = (
|
||||
|
||||
const { midX, midY } = getCommonBoundingBox(selectedElements);
|
||||
|
||||
resizeMultipleElements(selectedElements, elementsMap, "nw", app.scene, {
|
||||
flipByX: flipDirection === "horizontal",
|
||||
flipByY: flipDirection === "vertical",
|
||||
shouldResizeFromCenter: true,
|
||||
shouldMaintainAspectRatio: true,
|
||||
});
|
||||
resizeMultipleElements(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
"nw",
|
||||
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(
|
||||
selectedElements.filter(isLinearElement),
|
||||
@@ -181,16 +193,10 @@ const flipElements = (
|
||||
}),
|
||||
);
|
||||
elbowArrows.forEach((element) =>
|
||||
mutateElbowArrow(
|
||||
element,
|
||||
elementsMap,
|
||||
element.points,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
informMutation: false,
|
||||
},
|
||||
),
|
||||
mutateElement(element, {
|
||||
x: element.x + diffX,
|
||||
y: element.y + diffY,
|
||||
}),
|
||||
);
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { frameToolIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { newFrameElement } from "../element/newElement";
|
||||
import { getElementsInGroup } from "../groups";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
|
||||
const isSingleFrameSelected = (
|
||||
appState: UIAppState,
|
||||
@@ -174,10 +176,31 @@ export const actionWrapSelectionInFrame = register({
|
||||
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 {
|
||||
|
||||
@@ -25,8 +25,10 @@ import type {
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import {
|
||||
frameAndChildrenSelectedTogether,
|
||||
getElementsInResizingFrame,
|
||||
getFrameLikeElements,
|
||||
getRootElements,
|
||||
groupByFrameLikes,
|
||||
removeElementsFromFrame,
|
||||
replaceAllElementsInFrame,
|
||||
@@ -60,8 +62,11 @@ const enableActionGroup = (
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
|
||||
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} />,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
const selectedElements = getRootElements(
|
||||
app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
}),
|
||||
);
|
||||
if (selectedElements.length < 2) {
|
||||
// nothing to group
|
||||
return { appState, elements, storeAction: StoreAction.NONE };
|
||||
|
||||
@@ -89,6 +89,7 @@ import type {
|
||||
FontFamilyValues,
|
||||
TextAlign,
|
||||
VerticalAlign,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../element/types";
|
||||
import { getLanguage, t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
@@ -115,11 +116,12 @@ import {
|
||||
bindPointToSnapToElementOutline,
|
||||
calculateFixedPointForElbowArrowBinding,
|
||||
getHoveredElementForBinding,
|
||||
updateBoundElements,
|
||||
} from "../element/binding";
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
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;
|
||||
|
||||
@@ -219,33 +221,47 @@ const changeFontSize = (
|
||||
) => {
|
||||
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 {
|
||||
elements: 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,
|
||||
),
|
||||
elements: updatedElements,
|
||||
appState: {
|
||||
...appState,
|
||||
// update state only if we've set all select text elements to
|
||||
@@ -615,25 +631,12 @@ export const actionChangeOpacity = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<label className="control-label">
|
||||
{t("labels.opacity")}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="10"
|
||||
onChange={(event) => updateData(+event.target.value)}
|
||||
value={
|
||||
getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) => element.opacity,
|
||||
true,
|
||||
appState.currentItemOpacity,
|
||||
) ?? undefined
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Range
|
||||
updateData={updateData}
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
testId="opacity"
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1560,152 +1563,166 @@ export const actionChangeArrowType = register({
|
||||
label: "Change arrow types",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) => {
|
||||
if (!isArrowElement(el)) {
|
||||
return el;
|
||||
}
|
||||
const newElement = newElementWith(el, {
|
||||
roundness:
|
||||
value === ARROW_TYPE.round
|
||||
? {
|
||||
type: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||
}
|
||||
: null,
|
||||
elbowed: value === ARROW_TYPE.elbow,
|
||||
points:
|
||||
value === ARROW_TYPE.elbow || el.elbowed
|
||||
? [el.points[0], el.points[el.points.length - 1]]
|
||||
: el.points,
|
||||
const newElements = changeProperty(elements, appState, (el) => {
|
||||
if (!isArrowElement(el)) {
|
||||
return el;
|
||||
}
|
||||
const newElement = newElementWith(el, {
|
||||
roundness:
|
||||
value === ARROW_TYPE.round
|
||||
? {
|
||||
type: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||
}
|
||||
: null,
|
||||
elbowed: value === ARROW_TYPE.elbow,
|
||||
points:
|
||||
value === ARROW_TYPE.elbow || el.elbowed
|
||||
? [el.points[0], el.points[el.points.length - 1]]
|
||||
: 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)) {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
LinearElementEditor.updateEditorMidPointsCache(
|
||||
newElement,
|
||||
elementsMap,
|
||||
app.state,
|
||||
);
|
||||
}
|
||||
|
||||
app.dismissLinearEditor();
|
||||
return newElement;
|
||||
});
|
||||
|
||||
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,
|
||||
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 newState = {
|
||||
...appState,
|
||||
currentItemArrowType: value,
|
||||
};
|
||||
|
||||
const finalStartPoint = startHoveredElement
|
||||
? bindPointToSnapToElementOutline(
|
||||
startGlobalPoint,
|
||||
endGlobalPoint,
|
||||
startHoveredElement,
|
||||
elementsMap,
|
||||
)
|
||||
: startGlobalPoint;
|
||||
const finalEndPoint = endHoveredElement
|
||||
? bindPointToSnapToElementOutline(
|
||||
endGlobalPoint,
|
||||
startGlobalPoint,
|
||||
endHoveredElement,
|
||||
elementsMap,
|
||||
)
|
||||
: endGlobalPoint;
|
||||
// Change the arrow type and update any other state settings for
|
||||
// the arrow.
|
||||
const selectedId = appState.selectedLinearElement?.elementId;
|
||||
if (selectedId) {
|
||||
const selected = newElements.find((el) => el.id === selectedId);
|
||||
if (selected) {
|
||||
newState.selectedLinearElement = new LinearElementEditor(
|
||||
selected as ExcalidrawLinearElement,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
startHoveredElement &&
|
||||
bindLinearElement(
|
||||
newElement,
|
||||
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,
|
||||
},
|
||||
return {
|
||||
elements: newElements,
|
||||
appState: newState,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||
import { selectAllIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
@@ -20,17 +19,17 @@ export const actionSelectAll = register({
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectedElementIds = excludeElementsInFramesFromSelection(
|
||||
elements.filter(
|
||||
const selectedElementIds = elements
|
||||
.filter(
|
||||
(element) =>
|
||||
!element.isDeleted &&
|
||||
!(isTextElement(element) && element.containerId) &&
|
||||
!element.locked,
|
||||
),
|
||||
).reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
map[element.id] = true;
|
||||
return map;
|
||||
}, {});
|
||||
)
|
||||
.reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
map[element.id] = true;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
appState: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isTextElement } from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { measureText } from "../element/textElement";
|
||||
import { measureText } from "../element/textMeasurements";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { StoreAction } from "../store";
|
||||
import type { AppClassProperties } from "../types";
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import type { BoundingBox } from "./element/bounds";
|
||||
import { getCommonBoundingBox } from "./element/bounds";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
import { updateBoundElements } from "./element/binding";
|
||||
import type Scene from "./scene/Scene";
|
||||
|
||||
export interface Alignment {
|
||||
position: "start" | "center" | "end";
|
||||
@@ -13,6 +15,7 @@ export const alignElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
alignment: Alignment,
|
||||
scene: Scene,
|
||||
): ExcalidrawElement[] => {
|
||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
||||
selectedElements,
|
||||
@@ -26,12 +29,18 @@ export const alignElements = (
|
||||
selectionBoundingBox,
|
||||
alignment,
|
||||
);
|
||||
return group.map((element) =>
|
||||
newElementWith(element, {
|
||||
return group.map((element) => {
|
||||
// update element
|
||||
const updatedEle = mutateElement(element, {
|
||||
x: element.x + translation.x,
|
||||
y: element.y + translation.y,
|
||||
}),
|
||||
);
|
||||
});
|
||||
// update bound elements
|
||||
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
|
||||
simultaneouslyUpdated: group,
|
||||
});
|
||||
return updatedEle;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import { KEYS } from "../keys";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { CLASSES } from "../constants";
|
||||
import { alignActionsPredicate } from "../actions/actionAlign";
|
||||
|
||||
export const canChangeStrokeColor = (
|
||||
appState: UIAppState,
|
||||
@@ -90,10 +91,12 @@ export const SelectedShapeActions = ({
|
||||
appState,
|
||||
elementsMap,
|
||||
renderAction,
|
||||
app,
|
||||
}: {
|
||||
appState: UIAppState;
|
||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
|
||||
renderAction: ActionManager["renderAction"];
|
||||
app: AppClassProperties;
|
||||
}) => {
|
||||
const targetElements = getTargetElements(elementsMap, appState);
|
||||
|
||||
@@ -133,6 +136,9 @@ export const SelectedShapeActions = ({
|
||||
targetElements.length === 1 &&
|
||||
isImageElement(targetElements[0]);
|
||||
|
||||
const showAlignActions =
|
||||
!isSingleElementBoundContainer && alignActionsPredicate(appState, app);
|
||||
|
||||
return (
|
||||
<div className="panelColumn">
|
||||
<div>
|
||||
@@ -200,7 +206,7 @@ export const SelectedShapeActions = ({
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{targetElements.length > 1 && !isSingleElementBoundContainer && (
|
||||
{showAlignActions && !isSingleElementBoundContainer && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.align")}</legend>
|
||||
<div className="buttonList">
|
||||
|
||||
@@ -165,6 +165,7 @@ import {
|
||||
isTextBindableContainer,
|
||||
isElbowArrow,
|
||||
isFlowchartNodeElement,
|
||||
isBindableElement,
|
||||
} from "../element/typeChecks";
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
@@ -189,7 +190,6 @@ import type {
|
||||
MagicGenerationData,
|
||||
ExcalidrawNonSelectionElement,
|
||||
ExcalidrawArrowElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../element/types";
|
||||
import { getCenter, getDistance } from "../gesture";
|
||||
import {
|
||||
@@ -292,7 +292,6 @@ import {
|
||||
getDateTime,
|
||||
isShallowEqual,
|
||||
arrayToMap,
|
||||
toBrandedType,
|
||||
} from "../utils";
|
||||
import {
|
||||
createSrcDoc,
|
||||
@@ -332,17 +331,10 @@ import type { FileSystemHandle } from "../data/filesystem";
|
||||
import { fileOpen } from "../data/filesystem";
|
||||
import {
|
||||
bindTextToShapeAfterDuplication,
|
||||
getApproxMinLineHeight,
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getContainerCenter,
|
||||
getContainerElement,
|
||||
getLineHeightInPx,
|
||||
getMinTextElementWidth,
|
||||
isMeasureTextSupported,
|
||||
isValidTextContainer,
|
||||
measureText,
|
||||
normalizeText,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
showHyperlinkTooltip,
|
||||
@@ -443,7 +435,6 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
|
||||
import { getVisibleSceneBounds } from "../element/bounds";
|
||||
import { isMaybeMermaidDefinition } from "../mermaid";
|
||||
import NewElementCanvas from "./canvases/NewElementCanvas";
|
||||
import { mutateElbowArrow, updateElbowArrow } from "../element/routing";
|
||||
import {
|
||||
FlowChartCreator,
|
||||
FlowChartNavigator,
|
||||
@@ -467,6 +458,15 @@ import { cropElement } from "../element/cropElement";
|
||||
import { wrapText } from "../element/textWrapping";
|
||||
import { actionCopyElementLink } from "../actions/actionElementLink";
|
||||
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
|
||||
import {
|
||||
isMeasureTextSupported,
|
||||
normalizeText,
|
||||
measureText,
|
||||
getLineHeightInPx,
|
||||
getApproxMinLineWidth,
|
||||
getApproxMinLineHeight,
|
||||
getMinTextElementWidth,
|
||||
} from "../element/textMeasurements";
|
||||
|
||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||
@@ -1524,13 +1524,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const allElementsMap = this.scene.getNonDeletedElementsMap();
|
||||
|
||||
const shouldBlockPointerEvents =
|
||||
this.state.selectionElement ||
|
||||
this.state.newElement ||
|
||||
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");
|
||||
// default back to `--ui-pointerEvents` flow if setPointerCapture
|
||||
// not supported
|
||||
"setPointerCapture" in HTMLElement.prototype
|
||||
? false
|
||||
: this.state.selectionElement ||
|
||||
this.state.newElement ||
|
||||
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];
|
||||
|
||||
@@ -3184,49 +3188,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
retainSeed?: boolean;
|
||||
fitToContent?: boolean;
|
||||
}) => {
|
||||
let elements = opts.elements.map((el, _, elements) => {
|
||||
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 elements = restoreElements(opts.elements, null, undefined);
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
|
||||
const elementsCenterX = distance(minX, maxX) / 2;
|
||||
@@ -3268,7 +3230,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
);
|
||||
|
||||
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));
|
||||
|
||||
@@ -3279,7 +3248,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
newElements,
|
||||
topLayerFrame,
|
||||
);
|
||||
addElementsToFrame(nextElements, eligibleElements, topLayerFrame);
|
||||
addElementsToFrame(
|
||||
nextElements,
|
||||
eligibleElements,
|
||||
topLayerFrame,
|
||||
this.state,
|
||||
);
|
||||
}
|
||||
|
||||
this.scene.replaceAllElements(nextElements);
|
||||
@@ -4370,14 +4344,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
selectedElements.forEach((element) => {
|
||||
mutateElement(element, {
|
||||
x: element.x + offsetX,
|
||||
y: element.y + offsetY,
|
||||
});
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
x: element.x + offsetX,
|
||||
y: element.y + offsetY,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
|
||||
simultaneouslyUpdated: selectedElements,
|
||||
zoom: this.state.zoom,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4391,6 +4368,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
),
|
||||
});
|
||||
|
||||
this.scene.triggerUpdate();
|
||||
|
||||
event.preventDefault();
|
||||
} else if (event.key === KEYS.ENTER) {
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
@@ -5365,6 +5344,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
|
||||
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||
event,
|
||||
this.state,
|
||||
);
|
||||
|
||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||
if (
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
@@ -5378,6 +5362,64 @@ class App extends React.Component<AppProps, AppState> {
|
||||
editingLinearElement: new LinearElementEditor(selectedElements[0]),
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5388,11 +5430,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
resetCursor(this.interactiveCanvas);
|
||||
|
||||
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||
event,
|
||||
this.state,
|
||||
);
|
||||
|
||||
const selectedGroupIds = getSelectedGroupIds(this.state);
|
||||
|
||||
if (selectedGroupIds.length > 0) {
|
||||
@@ -5746,7 +5783,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
}
|
||||
if (editingLinearElement?.lastUncommittedPoint != null) {
|
||||
this.maybeSuggestBindingAtCursor(scenePointer);
|
||||
this.maybeSuggestBindingAtCursor(
|
||||
scenePointer,
|
||||
editingLinearElement.elbowed,
|
||||
);
|
||||
} else {
|
||||
// causes stack overflow if not sync
|
||||
flushSync(() => {
|
||||
@@ -5766,7 +5806,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.startBoundElement,
|
||||
);
|
||||
} else {
|
||||
this.maybeSuggestBindingAtCursor(scenePointer);
|
||||
this.maybeSuggestBindingAtCursor(scenePointer, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5849,41 +5889,23 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (isPathALoop(points, this.state.zoom.value)) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
}
|
||||
if (isElbowArrow(multiElement)) {
|
||||
mutateElbowArrow(
|
||||
multiElement,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
[
|
||||
// update last uncommitted point
|
||||
mutateElement(
|
||||
multiElement,
|
||||
{
|
||||
points: [
|
||||
...points.slice(0, -1),
|
||||
pointFrom<LocalPoint>(
|
||||
lastCommittedX + dxFromLastCommitted,
|
||||
lastCommittedY + dyFromLastCommitted,
|
||||
),
|
||||
],
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
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,
|
||||
);
|
||||
}
|
||||
},
|
||||
false,
|
||||
{
|
||||
isDragging: true,
|
||||
},
|
||||
);
|
||||
|
||||
// in this path, we're mutating multiElement to reflect
|
||||
// how it will be after adding pointer position as the next point
|
||||
@@ -6049,7 +6071,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState({
|
||||
activeEmbeddable: { element: hitElement, state: "hover" },
|
||||
});
|
||||
} else {
|
||||
} else if (!hitElement || !isElbowArrow(hitElement)) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||
if (this.state.activeEmbeddable?.state === "hover") {
|
||||
this.setState({ activeEmbeddable: null });
|
||||
@@ -6235,14 +6257,18 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) {
|
||||
const isHoveringAPointHandle = isElbowArrow(element)
|
||||
? hoverPointIndex === 0 ||
|
||||
hoverPointIndex === element.points.length - 1
|
||||
: hoverPointIndex >= 0;
|
||||
if (isHoveringAPointHandle || segmentMidPointHoveredCoords) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||
}
|
||||
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
||||
if (
|
||||
// Ebow arrows can only be moved when unconnected
|
||||
!isElbowArrow(element) ||
|
||||
!(element.startBinding || element.endBinding)
|
||||
) {
|
||||
@@ -6282,6 +6308,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
private handleCanvasPointerDown = (
|
||||
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.maybeUnfollowRemoteUser();
|
||||
|
||||
@@ -6972,6 +7005,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (
|
||||
selectedElements.length === 1 &&
|
||||
!this.state.editingLinearElement &&
|
||||
!isElbowArrow(selectedElements[0]) &&
|
||||
!(
|
||||
this.state.selectedLinearElement &&
|
||||
this.state.selectedLinearElement.hoverPointIndex !== -1
|
||||
@@ -7673,6 +7707,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
|
||||
fixedSegments:
|
||||
this.state.currentItemArrowType === ARROW_TYPE.elbow
|
||||
? []
|
||||
: null,
|
||||
})
|
||||
: newLinearElement({
|
||||
type: elementType,
|
||||
@@ -7713,6 +7751,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.state.zoom,
|
||||
isElbowArrow(element),
|
||||
isElbowArrow(element),
|
||||
);
|
||||
|
||||
this.scene.insertElement(element);
|
||||
@@ -7913,6 +7952,63 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
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 =
|
||||
this.lastPointerMoveCoords ?? pointerDownState.origin;
|
||||
this.lastPointerMoveCoords = pointerCoords;
|
||||
@@ -8259,13 +8355,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
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
|
||||
// able to select and interact with the text input
|
||||
!this.state.editingFrame &&
|
||||
if (!this.state.editingFrame) {
|
||||
dragSelectedElements(
|
||||
pointerDownState,
|
||||
selectedElements,
|
||||
@@ -8274,6 +8368,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
snapOffset,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
|
||||
);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
selectedElementsAreBeingDragged: true,
|
||||
@@ -8356,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));
|
||||
|
||||
@@ -8449,26 +8554,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
},
|
||||
false,
|
||||
);
|
||||
} else if (points.length > 1 && isElbowArrow(newElement)) {
|
||||
mutateElbowArrow(
|
||||
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) {
|
||||
} else if (
|
||||
points.length === 2 ||
|
||||
(points.length > 1 && isElbowArrow(newElement))
|
||||
) {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
|
||||
},
|
||||
false,
|
||||
{ isDragging: true },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8535,6 +8631,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
elements,
|
||||
this.state.selectionElement,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
false,
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -8663,6 +8760,24 @@ class App extends React.Component<AppProps, AppState> {
|
||||
selectedElementsAreBeingDragged: false,
|
||||
});
|
||||
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
|
||||
// and sets binding element
|
||||
if (this.state.editingLinearElement) {
|
||||
@@ -8687,6 +8802,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
} 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 (
|
||||
pointerDownState.hit?.element?.id !==
|
||||
this.state.selectedLinearElement.elementId
|
||||
@@ -8934,6 +9060,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.getElementsMapIncludingDeleted(),
|
||||
elementsInsideFrame,
|
||||
newElement,
|
||||
this.state,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -9051,6 +9178,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
nextElements,
|
||||
elementsToAdd,
|
||||
topLayerFrame,
|
||||
this.state,
|
||||
);
|
||||
} else if (!topLayerFrame) {
|
||||
if (this.state.editingGroupId) {
|
||||
@@ -9126,10 +9254,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.selectedLinearElement?.elementId !== hitElement?.id &&
|
||||
isLinearElement(hitElement)
|
||||
) {
|
||||
const selectedELements = this.scene.getSelectedElements(this.state);
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
// set selectedLinearElement when no other element selected except
|
||||
// the one we've hit
|
||||
if (selectedELements.length === 1) {
|
||||
if (selectedElements.length === 1) {
|
||||
this.setState({
|
||||
selectedLinearElement: new LinearElementEditor(hitElement),
|
||||
});
|
||||
@@ -9337,6 +9465,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (
|
||||
// not elbow midpoint dragged
|
||||
!(hitElement && isElbowArrow(hitElement)) &&
|
||||
// not dragged
|
||||
!pointerDownState.drag.hasOccurred &&
|
||||
// not resized
|
||||
@@ -9909,15 +10039,20 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
};
|
||||
|
||||
private maybeSuggestBindingAtCursor = (pointerCoords: {
|
||||
x: number;
|
||||
y: number;
|
||||
}): void => {
|
||||
private maybeSuggestBindingAtCursor = (
|
||||
pointerCoords: {
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
considerAll: boolean,
|
||||
): void => {
|
||||
const hoveredBindableElement = getHoveredElementForBinding(
|
||||
pointerCoords,
|
||||
this.scene.getNonDeletedElements(),
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.state.zoom,
|
||||
false,
|
||||
considerAll,
|
||||
);
|
||||
this.setState({
|
||||
suggestedBindings:
|
||||
@@ -9947,7 +10082,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.getNonDeletedElements(),
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.state.zoom,
|
||||
isArrowElement(linearElement) && isElbowArrow(linearElement),
|
||||
isElbowArrow(linearElement),
|
||||
isElbowArrow(linearElement),
|
||||
);
|
||||
if (
|
||||
hoveredBindableElement != null &&
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getLanguage, t } from "../i18n";
|
||||
import clsx from "clsx";
|
||||
import Collapsible from "./Stats/Collapsible";
|
||||
import { atom, useAtom } from "../editor-jotai";
|
||||
import { useDevice } from "..";
|
||||
import { useDevice } from "./App";
|
||||
|
||||
import "./IconPicker.scss";
|
||||
|
||||
|
||||
@@ -219,6 +219,7 @@ const LayerUI = ({
|
||||
appState={appState}
|
||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||
renderAction={actionManager.renderAction}
|
||||
app={app}
|
||||
/>
|
||||
</Island>
|
||||
</Section>
|
||||
|
||||
@@ -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 {
|
||||
distributeLibraryItemsOnSquareGrid,
|
||||
@@ -11,6 +18,7 @@ import type {
|
||||
LibraryItem,
|
||||
ExcalidrawProps,
|
||||
UIAppState,
|
||||
AppClassProperties,
|
||||
} from "../types";
|
||||
import LibraryMenuItems from "./LibraryMenuItems";
|
||||
import { trackEvent } from "../analytics";
|
||||
@@ -27,9 +35,12 @@ import { useUIAppState } from "../context/ui-appState";
|
||||
|
||||
import "./LibraryMenu.scss";
|
||||
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
||||
import { isShallowEqual } from "../utils";
|
||||
import type { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||
import { isShallowEqual } from "../utils";
|
||||
|
||||
export const isLibraryMenuOpenAtom = atom(false);
|
||||
|
||||
@@ -37,156 +48,215 @@ const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
return <div className="layer-ui__library">{children}</div>;
|
||||
};
|
||||
|
||||
export const LibraryMenuContent = ({
|
||||
onInsertLibraryItems,
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
setAppState,
|
||||
libraryReturnUrl,
|
||||
library,
|
||||
id,
|
||||
theme,
|
||||
selectedItems,
|
||||
onSelectItems,
|
||||
}: {
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||
onAddToLibrary: () => void;
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
library: Library;
|
||||
id: string;
|
||||
theme: UIAppState["theme"];
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||
}) => {
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
||||
const LibraryMenuContent = memo(
|
||||
({
|
||||
onInsertLibraryItems,
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
setAppState,
|
||||
libraryReturnUrl,
|
||||
library,
|
||||
id,
|
||||
theme,
|
||||
selectedItems,
|
||||
onSelectItems,
|
||||
}: {
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||
onAddToLibrary: () => void;
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
library: Library;
|
||||
id: string;
|
||||
theme: UIAppState["theme"];
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||
}) => {
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
||||
|
||||
const _onAddToLibrary = useCallback(
|
||||
(elements: LibraryItem["elements"]) => {
|
||||
const addToLibrary = async (
|
||||
processedElements: LibraryItem["elements"],
|
||||
libraryItems: LibraryItems,
|
||||
) => {
|
||||
trackEvent("element", "addToLibrary", "ui");
|
||||
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||
if (processedElements.some((element) => element.type === type)) {
|
||||
return setAppState({
|
||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||
});
|
||||
const _onAddToLibrary = useCallback(
|
||||
(elements: LibraryItem["elements"]) => {
|
||||
const addToLibrary = async (
|
||||
processedElements: LibraryItem["elements"],
|
||||
libraryItems: LibraryItems,
|
||||
) => {
|
||||
trackEvent("element", "addToLibrary", "ui");
|
||||
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||
if (processedElements.some((element) => element.type === type)) {
|
||||
return setAppState({
|
||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const nextItems: LibraryItems = [
|
||||
{
|
||||
status: "unpublished",
|
||||
elements: processedElements,
|
||||
id: randomId(),
|
||||
created: Date.now(),
|
||||
},
|
||||
...libraryItems,
|
||||
];
|
||||
onAddToLibrary();
|
||||
library.setLibrary(nextItems).catch(() => {
|
||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||
});
|
||||
};
|
||||
addToLibrary(elements, libraryItemsData.libraryItems);
|
||||
},
|
||||
[onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
|
||||
);
|
||||
const nextItems: LibraryItems = [
|
||||
{
|
||||
status: "unpublished",
|
||||
elements: processedElements,
|
||||
id: randomId(),
|
||||
created: Date.now(),
|
||||
},
|
||||
...libraryItems,
|
||||
];
|
||||
onAddToLibrary();
|
||||
library.setLibrary(nextItems).catch(() => {
|
||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||
});
|
||||
};
|
||||
addToLibrary(elements, libraryItemsData.libraryItems);
|
||||
},
|
||||
[onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
|
||||
);
|
||||
|
||||
const libraryItems = useMemo(
|
||||
() => libraryItemsData.libraryItems,
|
||||
[libraryItemsData],
|
||||
);
|
||||
const libraryItems = useMemo(
|
||||
() => libraryItemsData.libraryItems,
|
||||
[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 (
|
||||
<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;
|
||||
|
||||
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" }}
|
||||
<LibraryMenuItems
|
||||
isLoading={libraryItemsData.status === "loading"}
|
||||
libraryItems={libraryItems}
|
||||
onAddToLibrary={_onAddToLibrary}
|
||||
onInsertLibraryItems={onInsertLibraryItems}
|
||||
pendingElements={pendingElements}
|
||||
id={id}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
theme={theme}
|
||||
onSelectItems={onSelectItems}
|
||||
selectedItems={selectedItems}
|
||||
/>
|
||||
)}
|
||||
</LibraryMenuWrapper>
|
||||
);
|
||||
};
|
||||
{showBtn && (
|
||||
<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 = (
|
||||
appState: UIAppState,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const create = () =>
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
const val = useRef(create());
|
||||
const prevAppState = useRef<UIAppState>(appState);
|
||||
const prevElements = useRef(elements);
|
||||
const elements = useExcalidrawElements();
|
||||
const [state, setState] = useState(() =>
|
||||
getPendingElements(elements, appState.selectedElementIds),
|
||||
);
|
||||
|
||||
if (
|
||||
!isShallowEqual(
|
||||
appState.selectedElementIds,
|
||||
prevAppState.current.selectedElementIds,
|
||||
) ||
|
||||
!isShallowEqual(elements, prevElements.current)
|
||||
) {
|
||||
val.current = create();
|
||||
prevAppState.current = appState;
|
||||
prevElements.current = elements;
|
||||
}
|
||||
return val.current;
|
||||
const selectedElementVersions = useRef(
|
||||
new Map<ExcalidrawElement["id"], ExcalidrawElement["version"]>(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
for (const element of state.pending) {
|
||||
selectedElementVersions.current.set(element.id, element.version);
|
||||
}
|
||||
}, [state.pending]);
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
* <DefaultSidebar/> or host apps Sidebar components.
|
||||
*/
|
||||
export const LibraryMenu = () => {
|
||||
const { library, id, onInsertElements } = useApp();
|
||||
export const LibraryMenu = memo(() => {
|
||||
const app = useApp();
|
||||
const { onInsertElements } = app;
|
||||
const appProps = useAppProps();
|
||||
const appState = useUIAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const elements = useExcalidrawElements();
|
||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||
const memoizedLibrary = useMemo(() => library, [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, elements);
|
||||
const memoizedLibrary = useMemo(() => app.library, [app.library]);
|
||||
const pendingElements = usePendingElementsMemo(appState, app);
|
||||
|
||||
const onInsertLibraryItems = useCallback(
|
||||
(libraryItems: LibraryItems) => {
|
||||
@@ -211,10 +281,10 @@ export const LibraryMenu = () => {
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={appProps.libraryReturnUrl}
|
||||
library={memoizedLibrary}
|
||||
id={id}
|
||||
id={app.id}
|
||||
theme={appState.theme}
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={setSelectedItems}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -145,12 +145,14 @@ export const MobileMenu = ({
|
||||
<div className="App-toolbar-content">
|
||||
<MainMenuTunnel.Out />
|
||||
{actionManager.renderAction("toggleEditMenu")}
|
||||
{actionManager.renderAction("undo")}
|
||||
{actionManager.renderAction("redo")}
|
||||
{actionManager.renderAction(
|
||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||
)}
|
||||
{actionManager.renderAction("deleteSelectedElements")}
|
||||
<div>
|
||||
{actionManager.renderAction("undo")}
|
||||
{actionManager.renderAction("redo")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -179,6 +181,7 @@ export const MobileMenu = ({
|
||||
appState={appState}
|
||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||
renderAction={actionManager.renderAction}
|
||||
app={app}
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,6 @@ import { debounce } from "lodash";
|
||||
import type { AppClassProperties } from "../types";
|
||||
import { isTextElement, newTextElement } from "../element";
|
||||
import type { ExcalidrawTextElement } from "../element/types";
|
||||
import { measureText } from "../element/textElement";
|
||||
import { addEventListener, getFontString } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import clsx from "clsx";
|
||||
@@ -20,6 +19,7 @@ import { useStable } from "../hooks/useStable";
|
||||
|
||||
import "./SearchMenu.scss";
|
||||
import { round } from "../../math";
|
||||
import { measureText } from "../element/textMeasurements";
|
||||
|
||||
const searchQueryAtom = atom<string>("");
|
||||
export const searchItemInFocusAtom = atom<number | null>(null);
|
||||
@@ -607,7 +607,6 @@ const getMatchedLines = (
|
||||
textToStart,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
true,
|
||||
);
|
||||
|
||||
// measureText returns a non-zero width for the empty string
|
||||
@@ -621,7 +620,6 @@ const getMatchedLines = (
|
||||
lineIndexRange.line,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
true,
|
||||
);
|
||||
|
||||
const spaceToStart =
|
||||
|
||||
@@ -31,6 +31,7 @@ import "./Stats.scss";
|
||||
import { isGridModeEnabled } from "../../snapping";
|
||||
import { getUncroppedWidthAndHeight } from "../../element/cropElement";
|
||||
import { round } from "../../../math";
|
||||
import { frameAndChildrenSelectedTogether } from "../../frame";
|
||||
|
||||
interface StatsProps {
|
||||
app: AppClassProperties;
|
||||
@@ -170,6 +171,10 @@ export const StatsInner = memo(
|
||||
return getAtomicUnits(selectedElements, appState);
|
||||
}, [selectedElements, appState]);
|
||||
|
||||
const _frameAndChildrenSelectedTogether = useMemo(() => {
|
||||
return frameAndChildrenSelectedTogether(selectedElements);
|
||||
}, [selectedElements]);
|
||||
|
||||
return (
|
||||
<div className="exc-stats">
|
||||
<Island padding={3}>
|
||||
@@ -226,7 +231,7 @@ export const StatsInner = memo(
|
||||
{renderCustomStats?.(elements, appState)}
|
||||
</Collapsible>
|
||||
|
||||
{selectedElements.length > 0 && (
|
||||
{!_frameAndChildrenSelectedTogether && selectedElements.length > 0 && (
|
||||
<div
|
||||
id="elementStats"
|
||||
style={{
|
||||
|
||||
@@ -55,146 +55,152 @@ type ToolButtonProps =
|
||||
onPointerDown?(data: { pointerType: PointerType }): void;
|
||||
});
|
||||
|
||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
const { id: excalId } = useExcalidrawContainer();
|
||||
const innerRef = React.useRef(null);
|
||||
React.useImperativeHandle(ref, () => innerRef.current);
|
||||
const sizeCn = `ToolIcon_size_${props.size}`;
|
||||
export const ToolButton = React.forwardRef(
|
||||
(
|
||||
{
|
||||
size = "medium",
|
||||
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 ret = "onClick" in props && props.onClick?.(event);
|
||||
const onClick = async (event: React.MouseEvent) => {
|
||||
const ret = "onClick" in props && props.onClick?.(event);
|
||||
|
||||
if (isPromiseLike(ret)) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await ret;
|
||||
} catch (error: any) {
|
||||
if (!(error instanceof AbortError)) {
|
||||
throw error;
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
if (isPromiseLike(ret)) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await ret;
|
||||
} catch (error: any) {
|
||||
if (!(error instanceof AbortError)) {
|
||||
throw error;
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
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 (
|
||||
<button
|
||||
className={clsx(
|
||||
"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}
|
||||
<label
|
||||
className={clsx("ToolIcon", className)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 });
|
||||
onPointerDown={(event) => {
|
||||
lastPointerTypeRef.current = event.pointerType || null;
|
||||
props.onPointerDown?.({ pointerType: event.pointerType || null });
|
||||
}}
|
||||
checked={props.checked}
|
||||
ref={innerRef}
|
||||
/>
|
||||
<div className="ToolIcon__icon">
|
||||
{props.icon}
|
||||
{props.keyBindingLabel && (
|
||||
<span className="ToolIcon__keybinding">{props.keyBindingLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
});
|
||||
|
||||
ToolButton.defaultProps = {
|
||||
visible: true,
|
||||
className: "",
|
||||
size: "medium",
|
||||
};
|
||||
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}
|
||||
ref={innerRef}
|
||||
/>
|
||||
<div className="ToolIcon__icon">
|
||||
{props.icon}
|
||||
{props.keyBindingLabel && (
|
||||
<span className="ToolIcon__keybinding">
|
||||
{props.keyBindingLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ToolButton.displayName = "ToolButton";
|
||||
|
||||
@@ -171,15 +171,17 @@ export const Hyperlink = ({
|
||||
}, [handleSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
if (
|
||||
inputRef &&
|
||||
inputRef.current &&
|
||||
isEditing &&
|
||||
inputRef?.current &&
|
||||
!(device.viewport.isMobile || device.isTouchScreen)
|
||||
) {
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing, device.viewport.isMobile, device.isTouchScreen]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (isEditing) {
|
||||
@@ -207,15 +209,7 @@ export const Hyperlink = ({
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
appState,
|
||||
element,
|
||||
isEditing,
|
||||
setAppState,
|
||||
elementsMap,
|
||||
device.viewport.isMobile,
|
||||
device.isTouchScreen,
|
||||
]);
|
||||
}, [appState, element, isEditing, setAppState, elementsMap]);
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
trackEvent("hyperlink", "delete");
|
||||
|
||||
@@ -1216,11 +1216,12 @@ export const EdgeRoundIcon = createIcon(
|
||||
);
|
||||
|
||||
export const ArrowheadNoneIcon = createIcon(
|
||||
<path d="M6 10H34" stroke="currentColor" strokeWidth={2} fill="none" />,
|
||||
{
|
||||
width: 40,
|
||||
height: 20,
|
||||
},
|
||||
<g stroke="currentColor" opacity={0.3} strokeWidth={2}>
|
||||
<path d="M12 12l9 0" />
|
||||
<path d="M3 9l6 6" />
|
||||
<path d="M3 15l6 -6" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const ArrowheadArrowIcon = React.memo(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.excalidraw {
|
||||
.excalifont {
|
||||
font-family: "Excalifont";
|
||||
font-family: "Excalifont", "Xiaolai";
|
||||
}
|
||||
|
||||
// WelcomeSreen common
|
||||
|
||||
@@ -458,3 +458,6 @@ export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
|
||||
|
||||
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
|
||||
export const ELEMENT_LINK_KEY = "element";
|
||||
|
||||
/** used in tests */
|
||||
export const ORIG_ID = Symbol.for("__test__originalId__");
|
||||
|
||||
@@ -649,15 +649,21 @@ body.excalidraw-cursor-resize * {
|
||||
@include filledButtonOnCanvas;
|
||||
}
|
||||
|
||||
.App-mobile-menu,
|
||||
.App-menu__left {
|
||||
--button-border: transparent;
|
||||
--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-bg: var(--color-surface-high);
|
||||
}
|
||||
}
|
||||
|
||||
.App-menu__left {
|
||||
.buttonList {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
--scrollbar-thumb: var(--button-gray-2);
|
||||
--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),
|
||||
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
|
||||
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275),
|
||||
@@ -207,6 +210,8 @@
|
||||
--scrollbar-thumb: #{$oc-gray-8};
|
||||
--scrollbar-thumb-hover: #{$oc-gray-7};
|
||||
|
||||
--color-slider-track: hsl(244, 23%, 39%);
|
||||
|
||||
// will be inverted to a lighter color.
|
||||
--color-selection: #3530c4;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,18 @@ import { Queue } from "../queue";
|
||||
import { hashElementsVersion, hashString } from "../element";
|
||||
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 = {
|
||||
/** deleted library items since last onLibraryChange event */
|
||||
@@ -469,26 +480,37 @@ export const distributeLibraryItemsOnSquareGrid = (
|
||||
return resElements;
|
||||
};
|
||||
|
||||
const validateLibraryUrl = (
|
||||
export const validateLibraryUrl = (
|
||||
libraryUrl: string,
|
||||
/**
|
||||
* If supplied, takes precedence over the default whitelist.
|
||||
* Return `true` if the URL is valid.
|
||||
* @returns `true` if the URL is valid, throws otherwise.
|
||||
*/
|
||||
validator?: (libraryUrl: string) => boolean,
|
||||
): boolean => {
|
||||
validator:
|
||||
| ((libraryUrl: string) => boolean)
|
||||
| string[] = ALLOWED_LIBRARY_URLS,
|
||||
): true => {
|
||||
if (
|
||||
validator
|
||||
typeof validator === "function"
|
||||
? validator(libraryUrl)
|
||||
: ALLOWED_LIBRARY_HOSTNAMES.includes(
|
||||
new URL(libraryUrl).hostname.split(".").slice(-2).join("."),
|
||||
)
|
||||
: validator.some((allowedUrlDef) => {
|
||||
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;
|
||||
}
|
||||
|
||||
console.error(`Invalid or disallowed library URL: "${libraryUrl}"`);
|
||||
throw new Error("Invalid or disallowed library URL");
|
||||
throw new Error(`Invalid or disallowed library URL: "${libraryUrl}"`);
|
||||
};
|
||||
|
||||
export const parseLibraryTokensFromUrl = () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawElementType,
|
||||
ExcalidrawLinearElement,
|
||||
@@ -45,7 +46,7 @@ import { bumpVersion } from "../element/mutateElement";
|
||||
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||
import { arrayToMap } from "../utils";
|
||||
import type { MarkOptional, Mutable } from "../utility-types";
|
||||
import { detectLineHeight, getContainerElement } from "../element/textElement";
|
||||
import { getContainerElement } from "../element/textElement";
|
||||
import { normalizeLink } from "./url";
|
||||
import { syncInvalidIndices } from "../fractionalIndex";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
} from "../scene";
|
||||
import type { LocalPoint, Radians } from "../../math";
|
||||
import { isFiniteNumber, pointFrom } from "../../math";
|
||||
import { detectLineHeight } from "../element/textMeasurements";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
@@ -101,23 +103,38 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
||||
return DEFAULT_FONT_FAMILY;
|
||||
};
|
||||
|
||||
const repairBinding = (
|
||||
element: ExcalidrawLinearElement,
|
||||
const repairBinding = <T extends ExcalidrawLinearElement>(
|
||||
element: T,
|
||||
binding: PointBinding | FixedPointBinding | null,
|
||||
): PointBinding | FixedPointBinding | null => {
|
||||
): T extends ExcalidrawElbowArrowElement
|
||||
? FixedPointBinding | null
|
||||
: PointBinding | FixedPointBinding | null => {
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...binding,
|
||||
focus: binding.focus || 0,
|
||||
...(isElbowArrow(element) && isFixedPointBinding(binding)
|
||||
const focus = binding.focus || 0;
|
||||
|
||||
if (isElbowArrow(element)) {
|
||||
const fixedPointBinding:
|
||||
| ExcalidrawElbowArrowElement["startBinding"]
|
||||
| ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding)
|
||||
? {
|
||||
...binding,
|
||||
focus,
|
||||
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
: null;
|
||||
|
||||
return fixedPointBinding;
|
||||
}
|
||||
|
||||
return {
|
||||
...binding,
|
||||
focus,
|
||||
} as T extends ExcalidrawElbowArrowElement
|
||||
? FixedPointBinding | null
|
||||
: PointBinding | FixedPointBinding | null;
|
||||
};
|
||||
|
||||
const restoreElementWithProperties = <
|
||||
@@ -189,6 +206,24 @@ const restoreElementWithProperties = <
|
||||
"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 {
|
||||
// spread the original element properties to not lose unknown ones
|
||||
// for forward-compatibility
|
||||
@@ -203,6 +238,21 @@ const restoreElementWithProperties = <
|
||||
const restoreElement = (
|
||||
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
): 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) {
|
||||
case "text":
|
||||
let fontSize = element.fontSize;
|
||||
@@ -308,8 +358,7 @@ const restoreElement = (
|
||||
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
|
||||
}
|
||||
|
||||
// TODO: Separate arrow from linear element
|
||||
return restoreElementWithProperties(element as ExcalidrawArrowElement, {
|
||||
const base = {
|
||||
type: element.type,
|
||||
startBinding: repairBinding(element, element.startBinding),
|
||||
endBinding: repairBinding(element, element.endBinding),
|
||||
@@ -321,7 +370,20 @@ const restoreElement = (
|
||||
y,
|
||||
elbowed: (element as ExcalidrawArrowElement).elbowed,
|
||||
...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
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
newMagicFrameElement,
|
||||
newTextElement,
|
||||
} from "../element/newElement";
|
||||
import { measureText, normalizeText } from "../element/textElement";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
@@ -55,6 +54,7 @@ import { syncInvalidIndices } from "../fractionalIndex";
|
||||
import { getLineHeight } from "../fonts";
|
||||
import { isArrowElement } from "../element/typeChecks";
|
||||
import { pointFrom, type LocalPoint } from "../../math";
|
||||
import { measureText, normalizeText } from "../element/textMeasurements";
|
||||
|
||||
export type ValidLinearElement = {
|
||||
type: "arrow" | "line";
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("normalizeLink", () => {
|
||||
expect(normalizeLink("file://")).toBe("file://");
|
||||
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
|
||||
expect(normalizeLink("[[test]]")).toBe("[[test]]");
|
||||
expect(normalizeLink("<test>")).toBe("<test>");
|
||||
expect(normalizeLink("test&")).toBe("test&");
|
||||
expect(normalizeLink("<test>")).toBe("<test>");
|
||||
expect(normalizeLink("test&")).toBe("test&");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
import { sanitizeHTMLAttribute } from "../utils";
|
||||
import { escapeDoubleQuotes } from "../utils";
|
||||
|
||||
export const normalizeLink = (link: string) => {
|
||||
link = link.trim();
|
||||
if (!link) {
|
||||
return link;
|
||||
}
|
||||
return sanitizeUrl(sanitizeHTMLAttribute(link));
|
||||
return sanitizeUrl(escapeDoubleQuotes(link));
|
||||
};
|
||||
|
||||
export const isLocalLink = (link: string | null) => {
|
||||
|
||||
@@ -32,7 +32,6 @@ import type { Bounds } from "./bounds";
|
||||
import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds";
|
||||
import type { AppState } from "../types";
|
||||
import { isPointOnShape } from "../../utils/collision";
|
||||
import { getElementAtPosition } from "../scene";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBindableElement,
|
||||
@@ -49,7 +48,11 @@ import type { ElementUpdate } from "./mutateElement";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import type Scene from "../scene/Scene";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { arrayToMap, tupleToCoors } from "../utils";
|
||||
import {
|
||||
arrayToMap,
|
||||
isBindingFallthroughEnabled,
|
||||
tupleToCoors,
|
||||
} from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
|
||||
@@ -425,7 +428,8 @@ export const maybeBindLinearElement = (
|
||||
elements,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
isElbowArrow(linearElement) && isElbowArrow(linearElement),
|
||||
isElbowArrow(linearElement),
|
||||
isElbowArrow(linearElement),
|
||||
);
|
||||
|
||||
if (hoveredElement !== null) {
|
||||
@@ -558,8 +562,65 @@ export const getHoveredElementForBinding = (
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
zoom?: AppState["zoom"],
|
||||
fullShape?: boolean,
|
||||
considerAllElements?: boolean,
|
||||
): 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,
|
||||
(element) =>
|
||||
isBindableElement(element, false) &&
|
||||
@@ -570,13 +631,58 @@ export const getHoveredElementForBinding = (
|
||||
zoom,
|
||||
// disable fullshape snapping for frame elements so we
|
||||
// can bind to frame children
|
||||
fullShape && !isFrameLikeElement(element),
|
||||
(fullShape || !isBindingFallthroughEnabled(element)) &&
|
||||
!isFrameLikeElement(element),
|
||||
),
|
||||
);
|
||||
|
||||
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 = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
@@ -623,11 +729,9 @@ export const updateBoundElements = (
|
||||
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
||||
newSize?: { width: number; height: number };
|
||||
changedElements?: Map<string, OrderedExcalidrawElement>;
|
||||
zoom?: AppState["zoom"];
|
||||
},
|
||||
) => {
|
||||
const { newSize, simultaneouslyUpdated, changedElements, zoom } =
|
||||
options ?? {};
|
||||
const { newSize, simultaneouslyUpdated } = options ?? {};
|
||||
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
||||
simultaneouslyUpdated,
|
||||
);
|
||||
@@ -661,7 +765,7 @@ export const updateBoundElements = (
|
||||
|
||||
// `linearElement` is being moved/scaled already, just update the binding
|
||||
if (simultaneouslyUpdatedElementIds.has(element.id)) {
|
||||
mutateElement(element, bindings);
|
||||
mutateElement(element, bindings, true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -703,23 +807,14 @@ export const updateBoundElements = (
|
||||
}> => update !== null,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
updates,
|
||||
elementsMap,
|
||||
{
|
||||
...(changedElement.id === element.startBinding?.elementId
|
||||
? { startBinding: bindings.startBinding }
|
||||
: {}),
|
||||
...(changedElement.id === element.endBinding?.elementId
|
||||
? { endBinding: bindings.endBinding }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
changedElements,
|
||||
zoom,
|
||||
},
|
||||
);
|
||||
LinearElementEditor.movePoints(element, updates, {
|
||||
...(changedElement.id === element.startBinding?.elementId
|
||||
? { startBinding: bindings.startBinding }
|
||||
: {}),
|
||||
...(changedElement.id === element.endBinding?.elementId
|
||||
? { endBinding: bindings.endBinding }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const boundText = getBoundTextElement(element, elementsMap);
|
||||
if (boundText && !boundText.isDeleted) {
|
||||
@@ -778,9 +873,7 @@ export const getHeadingForElbowArrowSnap = (
|
||||
);
|
||||
}
|
||||
|
||||
const pointHeading = headingForPointFromElement(bindableElement, aabb, p);
|
||||
|
||||
return pointHeading;
|
||||
return headingForPointFromElement(bindableElement, aabb, p);
|
||||
};
|
||||
|
||||
const getDistanceForBinding = (
|
||||
@@ -1233,6 +1326,8 @@ const getElligibleElementForBindingElement = (
|
||||
elements,
|
||||
elementsMap,
|
||||
zoom,
|
||||
isElbowArrow(linearElement),
|
||||
isElbowArrow(linearElement),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2283,7 +2378,7 @@ export const getGlobalFixedPointForBindableElement = (
|
||||
);
|
||||
};
|
||||
|
||||
const getGlobalFixedPoints = (
|
||||
export const getGlobalFixedPoints = (
|
||||
arrow: ExcalidrawElbowArrowElement,
|
||||
elementsMap: ElementsMap,
|
||||
): [GlobalPoint, GlobalPoint] => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
NullableGridSize,
|
||||
PointerDownState,
|
||||
} from "../types";
|
||||
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import type Scene from "../scene/Scene";
|
||||
import {
|
||||
isArrowElement,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { getFontString } from "../utils";
|
||||
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
|
||||
import { getGridPoint } from "../snapping";
|
||||
import { getMinTextElementWidth } from "./textMeasurements";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
@@ -42,9 +43,20 @@ export const dragSelectedElements = (
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedElements = _selectedElements.filter(
|
||||
(el) => !(isElbowArrow(el) && el.startBinding && el.endBinding),
|
||||
);
|
||||
const selectedElements = _selectedElements.filter((element) => {
|
||||
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
|
||||
// but when it happens (due to some bug), we want to avoid updating element
|
||||
@@ -78,10 +90,8 @@ export const dragSelectedElements = (
|
||||
|
||||
elementsToUpdate.forEach((element) => {
|
||||
updateElementCoords(pointerDownState, element, adjustedOffset);
|
||||
if (
|
||||
if (!isArrowElement(element)) {
|
||||
// skip arrow labels since we calculate its position during render
|
||||
!isArrowElement(element)
|
||||
) {
|
||||
const textElement = getBoundTextElement(
|
||||
element,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
@@ -89,10 +99,10 @@ export const dragSelectedElements = (
|
||||
if (textElement) {
|
||||
updateElementCoords(pointerDownState, textElement, adjustedOffset);
|
||||
}
|
||||
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
|
||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||
});
|
||||
}
|
||||
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
|
||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+112
-9
@@ -9,20 +9,121 @@ import {
|
||||
render,
|
||||
} from "../tests/test-utils";
|
||||
import { bindLinearElement } from "./binding";
|
||||
import { Excalidraw } from "../index";
|
||||
import { mutateElbowArrow } from "./routing";
|
||||
import { Excalidraw, mutateElement } from "../index";
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
import { ARROW_TYPE } from "../constants";
|
||||
import type { LocalPoint } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
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", () => {
|
||||
it("can properly generate orthogonal arrow points", () => {
|
||||
const scene = new Scene();
|
||||
@@ -31,10 +132,12 @@ describe("elbow arrow routing", () => {
|
||||
elbowed: true,
|
||||
}) as ExcalidrawElbowArrowElement;
|
||||
scene.insertElement(arrow);
|
||||
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
|
||||
pointFrom(-45 - arrow.x, -100.1 - arrow.y),
|
||||
pointFrom(45 - arrow.x, 99.9 - arrow.y),
|
||||
]);
|
||||
mutateElement(arrow, {
|
||||
points: [
|
||||
pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
|
||||
pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
|
||||
],
|
||||
});
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
[0, 100],
|
||||
@@ -81,7 +184,9 @@ describe("elbow arrow routing", () => {
|
||||
expect(arrow.startBinding).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([
|
||||
[0, 0],
|
||||
@@ -182,8 +287,6 @@ describe("elbow arrow ui", () => {
|
||||
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
|
||||
[0, 0],
|
||||
[35, 0],
|
||||
[35, 90],
|
||||
[35, 90], // Note that coordinates are rounded above!
|
||||
[35, 165],
|
||||
[103, 165],
|
||||
]);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,7 @@
|
||||
import { register } from "../actions/register";
|
||||
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
|
||||
import type { ExcalidrawProps } from "../types";
|
||||
import {
|
||||
getFontString,
|
||||
sanitizeHTMLAttribute,
|
||||
updateActiveTool,
|
||||
} from "../utils";
|
||||
import { escapeDoubleQuotes, getFontString, updateActiveTool } from "../utils";
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { newTextElement } from "./newElement";
|
||||
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
|
||||
// non-latin1 characters, and the username in the url can be set to anything
|
||||
// without affecting the embed.
|
||||
const safeURL = sanitizeHTMLAttribute(
|
||||
const safeURL = escapeDoubleQuotes(
|
||||
`https://twitter.com/x/status/${postId}`,
|
||||
);
|
||||
|
||||
@@ -231,7 +227,7 @@ export const getEmbedLink = (
|
||||
|
||||
if (RE_REDDIT.test(link)) {
|
||||
const [, page, postId, title] = link.match(RE_REDDIT)!;
|
||||
const safeURL = sanitizeHTMLAttribute(
|
||||
const safeURL = escapeDoubleQuotes(
|
||||
`https://reddit.com/r/${page}/comments/${postId}/${title}`,
|
||||
);
|
||||
const ret: IframeDataWithSandbox = {
|
||||
@@ -249,7 +245,7 @@ export const getEmbedLink = (
|
||||
|
||||
if (RE_GH_GIST.test(link)) {
|
||||
const [, user, gistId] = link.match(RE_GH_GIST)!;
|
||||
const safeURL = sanitizeHTMLAttribute(
|
||||
const safeURL = escapeDoubleQuotes(
|
||||
`https://gist.github.com/${user}/${gistId}`,
|
||||
);
|
||||
const ret: IframeDataWithSandbox = {
|
||||
|
||||
@@ -10,13 +10,15 @@ import {
|
||||
import { bindLinearElement } from "./binding";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { newArrowElement, newElement } from "./newElement";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFlowchartNodeElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
OrderedExcalidrawElement,
|
||||
import type { SceneElementsMap } from "./types";
|
||||
import {
|
||||
type ElementsMap,
|
||||
type ExcalidrawBindableElement,
|
||||
type ExcalidrawElement,
|
||||
type ExcalidrawFlowchartNodeElement,
|
||||
type NonDeletedSceneElementsMap,
|
||||
type Ordered,
|
||||
type OrderedExcalidrawElement,
|
||||
} from "./types";
|
||||
import { KEYS } from "../keys";
|
||||
import type { AppState, PendingExcalidrawElements } from "../types";
|
||||
@@ -28,9 +30,10 @@ import {
|
||||
isFrameElement,
|
||||
isFlowchartNodeElement,
|
||||
} from "./typeChecks";
|
||||
import { invariant } from "../utils";
|
||||
import { invariant, toBrandedType } from "../utils";
|
||||
import { pointFrom, type LocalPoint } from "../../math";
|
||||
import { aabbForElement } from "../shapes";
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
type LinkDirection = "up" | "right" | "down" | "left";
|
||||
|
||||
@@ -254,6 +257,9 @@ const addNewNode = (
|
||||
backgroundColor: element.backgroundColor,
|
||||
strokeColor: element.strokeColor,
|
||||
strokeWidth: element.strokeWidth,
|
||||
opacity: element.opacity,
|
||||
fillStyle: element.fillStyle,
|
||||
strokeStyle: element.strokeStyle,
|
||||
});
|
||||
|
||||
invariant(
|
||||
@@ -329,6 +335,9 @@ export const addNewNodes = (
|
||||
backgroundColor: startNode.backgroundColor,
|
||||
strokeColor: startNode.strokeColor,
|
||||
strokeWidth: startNode.strokeWidth,
|
||||
opacity: startNode.opacity,
|
||||
fillStyle: startNode.fillStyle,
|
||||
strokeStyle: startNode.strokeStyle,
|
||||
});
|
||||
|
||||
invariant(
|
||||
@@ -416,11 +425,13 @@ const createBindingArrow = (
|
||||
type: "arrow",
|
||||
x: startX,
|
||||
y: startY,
|
||||
startArrowhead: appState.currentItemStartArrowhead,
|
||||
startArrowhead: null,
|
||||
endArrowhead: appState.currentItemEndArrowhead,
|
||||
strokeColor: appState.currentItemStrokeColor,
|
||||
strokeStyle: appState.currentItemStrokeStyle,
|
||||
strokeWidth: appState.currentItemStrokeWidth,
|
||||
strokeColor: startBindingElement.strokeColor,
|
||||
strokeStyle: startBindingElement.strokeStyle,
|
||||
strokeWidth: startBindingElement.strokeWidth,
|
||||
opacity: startBindingElement.opacity,
|
||||
roughness: startBindingElement.roughness,
|
||||
points: [pointFrom(0, 0), pointFrom(endX, endY)],
|
||||
elbowed: true,
|
||||
});
|
||||
@@ -452,22 +463,30 @@ const createBindingArrow = (
|
||||
bindingArrow as OrderedExcalidrawElement,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
bindingArrow,
|
||||
[
|
||||
{
|
||||
index: 1,
|
||||
point: bindingArrow.points[1],
|
||||
},
|
||||
],
|
||||
elementsMap as NonDeletedSceneElementsMap,
|
||||
undefined,
|
||||
LinearElementEditor.movePoints(bindingArrow, [
|
||||
{
|
||||
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 {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
pointScaleFromOrigin,
|
||||
radiansToDegrees,
|
||||
triangleIncludesPoint,
|
||||
vectorFromPoint,
|
||||
} from "../../math";
|
||||
import { getCenterForBounds, type Bounds } from "./bounds";
|
||||
import type { ExcalidrawBindableElement } from "./types";
|
||||
@@ -52,9 +53,24 @@ export const vectorToHeading = (vec: Vector): Heading => {
|
||||
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) =>
|
||||
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
|
||||
// close fitting bounding box, then creating 4 search cones around the center of
|
||||
// the external bbox.
|
||||
@@ -63,7 +79,7 @@ export const headingForPointFromElement = <
|
||||
>(
|
||||
element: Readonly<ExcalidrawBindableElement>,
|
||||
aabb: Readonly<Bounds>,
|
||||
p: Readonly<LocalPoint | GlobalPoint>,
|
||||
p: Readonly<Point>,
|
||||
): Heading => {
|
||||
const SEARCH_CONE_MULTIPLIER = 2;
|
||||
|
||||
@@ -117,14 +133,22 @@ export const headingForPointFromElement = <
|
||||
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);
|
||||
} else if (
|
||||
triangleIncludesPoint([right, bottom, midPoint] as Triangle<Point>, p)
|
||||
triangleIncludesPoint<Point>(
|
||||
[right, bottom, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
) {
|
||||
return headingForDiamond(right, bottom);
|
||||
} else if (
|
||||
triangleIncludesPoint([bottom, left, midPoint] as Triangle<Point>, p)
|
||||
triangleIncludesPoint<Point>(
|
||||
[bottom, left, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
) {
|
||||
return headingForDiamond(bottom, left);
|
||||
}
|
||||
@@ -153,17 +177,17 @@ export const headingForPointFromElement = <
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
) as Point;
|
||||
|
||||
return triangleIncludesPoint(
|
||||
return triangleIncludesPoint<Point>(
|
||||
[topLeft, topRight, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
? HEADING_UP
|
||||
: triangleIncludesPoint(
|
||||
: triangleIncludesPoint<Point>(
|
||||
[topRight, bottomRight, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
? HEADING_RIGHT
|
||||
: triangleIncludesPoint(
|
||||
: triangleIncludesPoint<Point>(
|
||||
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
|
||||
@@ -7,9 +7,10 @@ import type {
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ElementsMap,
|
||||
NonDeletedSceneElementsMap,
|
||||
OrderedExcalidrawElement,
|
||||
FixedPointBinding,
|
||||
SceneElementsMap,
|
||||
FixedSegment,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
||||
import type { Bounds } from "./bounds";
|
||||
@@ -24,6 +25,7 @@ import type {
|
||||
InteractiveCanvasAppState,
|
||||
AppClassProperties,
|
||||
NullableGridSize,
|
||||
Zoom,
|
||||
} from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
|
||||
@@ -32,7 +34,7 @@ import {
|
||||
getHoveredElementForBinding,
|
||||
isBindingEnabled,
|
||||
} from "./binding";
|
||||
import { invariant, toBrandedType, tupleToCoors } from "../utils";
|
||||
import { invariant, tupleToCoors } from "../utils";
|
||||
import {
|
||||
isBindingElement,
|
||||
isElbowArrow,
|
||||
@@ -44,7 +46,6 @@ import { DRAGGING_THRESHOLD } from "../constants";
|
||||
import type { Mutable } from "../utility-types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import type { Store } from "../store";
|
||||
import { mutateElbowArrow } from "./routing";
|
||||
import type Scene from "../scene/Scene";
|
||||
import type { Radians } from "../../math";
|
||||
import {
|
||||
@@ -56,6 +57,8 @@ import {
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
pointDistance,
|
||||
pointTranslate,
|
||||
vectorFromPoint,
|
||||
} from "../../math";
|
||||
import {
|
||||
getBezierCurveLength,
|
||||
@@ -65,6 +68,7 @@ import {
|
||||
mapIntervalToBezierT,
|
||||
} from "../shapes";
|
||||
import { getGridPoint } from "../snapping";
|
||||
import { headingIsHorizontal, vectorToHeading } from "./heading";
|
||||
|
||||
const editorMidPointsCache: {
|
||||
version: number | null;
|
||||
@@ -144,13 +148,13 @@ export class LinearElementEditor {
|
||||
* @param id the `elementId` from the instance of this class (so that we can
|
||||
* statically guarantee this method returns an ExcalidrawLinearElement)
|
||||
*/
|
||||
static getElement(
|
||||
static getElement<T extends ExcalidrawLinearElement>(
|
||||
id: InstanceType<typeof LinearElementEditor>["elementId"],
|
||||
elementsMap: ElementsMap,
|
||||
) {
|
||||
): T | null {
|
||||
const element = elementsMap.get(id);
|
||||
if (element) {
|
||||
return element as NonDeleted<ExcalidrawLinearElement>;
|
||||
return element as NonDeleted<T>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -291,20 +295,16 @@ export class LinearElementEditor {
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
[
|
||||
{
|
||||
index: selectedIndex,
|
||||
point: pointFrom(
|
||||
width + referencePoint[0],
|
||||
height + referencePoint[1],
|
||||
),
|
||||
isDragging: selectedIndex === lastClickedPoint,
|
||||
},
|
||||
],
|
||||
elementsMap,
|
||||
);
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{
|
||||
index: selectedIndex,
|
||||
point: pointFrom(
|
||||
width + referencePoint[0],
|
||||
height + referencePoint[1],
|
||||
),
|
||||
isDragging: selectedIndex === lastClickedPoint,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
@@ -339,7 +339,6 @@ export class LinearElementEditor {
|
||||
isDragging: pointIndex === lastClickedPoint,
|
||||
};
|
||||
}),
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -422,19 +421,15 @@ export class LinearElementEditor {
|
||||
selectedPoint === element.points.length - 1
|
||||
) {
|
||||
if (isPathALoop(element.points, appState.zoom.value)) {
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
[
|
||||
{
|
||||
index: selectedPoint,
|
||||
point:
|
||||
selectedPoint === 0
|
||||
? element.points[element.points.length - 1]
|
||||
: element.points[0],
|
||||
},
|
||||
],
|
||||
elementsMap,
|
||||
);
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{
|
||||
index: selectedPoint,
|
||||
point:
|
||||
selectedPoint === 0
|
||||
? element.points[element.points.length - 1]
|
||||
: element.points[0],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const bindingElement = isBindingEnabled(appState)
|
||||
@@ -449,6 +444,8 @@ export class LinearElementEditor {
|
||||
elements,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
isElbowArrow(element),
|
||||
isElbowArrow(element),
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -495,6 +492,7 @@ export class LinearElementEditor {
|
||||
|
||||
// Since its not needed outside editor unless 2 pointer lines or bound text
|
||||
if (
|
||||
!isElbowArrow(element) &&
|
||||
!appState.editingLinearElement &&
|
||||
element.points.length > 2 &&
|
||||
!boundText
|
||||
@@ -533,6 +531,7 @@ export class LinearElementEditor {
|
||||
element,
|
||||
element.points[index],
|
||||
element.points[index + 1],
|
||||
index,
|
||||
appState.zoom,
|
||||
)
|
||||
) {
|
||||
@@ -573,19 +572,23 @@ export class LinearElementEditor {
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
);
|
||||
if (clickedPointIndex >= 0) {
|
||||
if (!isElbowArrow(element) && clickedPointIndex >= 0) {
|
||||
return null;
|
||||
}
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
if (points.length >= 3 && !appState.editingLinearElement) {
|
||||
if (
|
||||
points.length >= 3 &&
|
||||
!appState.editingLinearElement &&
|
||||
!isElbowArrow(element)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const threshold =
|
||||
LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value;
|
||||
(LinearElementEditor.POINT_HANDLE_SIZE + 1) / appState.zoom.value;
|
||||
|
||||
const existingSegmentMidpointHitCoords =
|
||||
linearElementEditor.segmentMidPointHoveredCoords;
|
||||
@@ -604,10 +607,11 @@ export class LinearElementEditor {
|
||||
let index = 0;
|
||||
const midPoints: typeof editorMidPointsCache["points"] =
|
||||
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
|
||||
|
||||
while (index < midPoints.length) {
|
||||
if (midPoints[index] !== null) {
|
||||
const distance = pointDistance(
|
||||
pointFrom(midPoints[index]![0], midPoints[index]![1]),
|
||||
midPoints[index]!,
|
||||
pointFrom(scenePointer.x, scenePointer.y),
|
||||
);
|
||||
if (distance <= threshold) {
|
||||
@@ -620,16 +624,25 @@ export class LinearElementEditor {
|
||||
return null;
|
||||
};
|
||||
|
||||
static isSegmentTooShort(
|
||||
static isSegmentTooShort<P extends GlobalPoint | LocalPoint>(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
startPoint: GlobalPoint | LocalPoint,
|
||||
endPoint: GlobalPoint | LocalPoint,
|
||||
zoom: AppState["zoom"],
|
||||
startPoint: P,
|
||||
endPoint: P,
|
||||
index: number,
|
||||
zoom: Zoom,
|
||||
) {
|
||||
let distance = pointDistance(
|
||||
pointFrom(startPoint[0], startPoint[1]),
|
||||
pointFrom(endPoint[0], endPoint[1]),
|
||||
);
|
||||
if (isElbowArrow(element)) {
|
||||
if (index >= 0 && index < element.points.length) {
|
||||
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) {
|
||||
distance = getBezierCurveLength(element, endPoint);
|
||||
}
|
||||
@@ -748,12 +761,8 @@ export class LinearElementEditor {
|
||||
segmentMidpoint,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
if (event.altKey && appState.editingLinearElement) {
|
||||
if (
|
||||
linearElementEditor.lastUncommittedPoint == null &&
|
||||
!isElbowArrow(element)
|
||||
) {
|
||||
} else if (event.altKey && appState.editingLinearElement) {
|
||||
if (linearElementEditor.lastUncommittedPoint == null) {
|
||||
mutateElement(element, {
|
||||
points: [
|
||||
...element.points,
|
||||
@@ -789,6 +798,7 @@ export class LinearElementEditor {
|
||||
elements,
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
linearElementEditor.elbowed,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -909,12 +919,7 @@ export class LinearElementEditor {
|
||||
|
||||
if (!event.altKey) {
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.deletePoints(
|
||||
element,
|
||||
[points.length - 1],
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
);
|
||||
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
@@ -952,23 +957,14 @@ export class LinearElementEditor {
|
||||
}
|
||||
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
[
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: newPoint,
|
||||
},
|
||||
],
|
||||
elementsMap,
|
||||
);
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: newPoint,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
LinearElementEditor.addPoints(
|
||||
element,
|
||||
[{ point: newPoint }],
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
);
|
||||
LinearElementEditor.addPoints(element, [{ point: newPoint }]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
@@ -1197,16 +1193,12 @@ export class LinearElementEditor {
|
||||
// potentially expanding the bounding box
|
||||
if (pointAddedToEnd) {
|
||||
const lastPoint = element.points[element.points.length - 1];
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
[
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
|
||||
},
|
||||
],
|
||||
elementsMap,
|
||||
);
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1221,8 +1213,6 @@ export class LinearElementEditor {
|
||||
static deletePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
pointIndices: readonly number[],
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
) {
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
@@ -1252,47 +1242,27 @@ export class LinearElementEditor {
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
LinearElementEditor._updatePoints(
|
||||
element,
|
||||
nextPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
elementsMap,
|
||||
);
|
||||
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
|
||||
}
|
||||
|
||||
static addPoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
targetPoints: { point: LocalPoint }[],
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
) {
|
||||
const offsetX = 0;
|
||||
const offsetY = 0;
|
||||
|
||||
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
|
||||
LinearElementEditor._updatePoints(
|
||||
element,
|
||||
nextPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
elementsMap,
|
||||
);
|
||||
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
|
||||
}
|
||||
|
||||
static movePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
otherUpdates?: {
|
||||
startBinding?: PointBinding | null;
|
||||
endBinding?: PointBinding | null;
|
||||
},
|
||||
options?: {
|
||||
changedElements?: Map<string, OrderedExcalidrawElement>;
|
||||
isDragging?: boolean;
|
||||
zoom?: AppState["zoom"];
|
||||
},
|
||||
) {
|
||||
const { points } = element;
|
||||
|
||||
@@ -1335,7 +1305,6 @@ export class LinearElementEditor {
|
||||
nextPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
elementsMap,
|
||||
otherUpdates,
|
||||
{
|
||||
isDragging: targetPoints.reduce(
|
||||
@@ -1343,8 +1312,6 @@ export class LinearElementEditor {
|
||||
dragging || targetPoint.isDragging === true,
|
||||
false,
|
||||
),
|
||||
changedElements: options?.changedElements,
|
||||
zoom: options?.zoom,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1451,54 +1418,49 @@ export class LinearElementEditor {
|
||||
nextPoints: readonly LocalPoint[],
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
otherUpdates?: {
|
||||
startBinding?: PointBinding | null;
|
||||
endBinding?: PointBinding | null;
|
||||
},
|
||||
options?: {
|
||||
changedElements?: Map<string, OrderedExcalidrawElement>;
|
||||
isDragging?: boolean;
|
||||
zoom?: AppState["zoom"];
|
||||
},
|
||||
) {
|
||||
if (isElbowArrow(element)) {
|
||||
const bindings: {
|
||||
const updates: {
|
||||
startBinding?: FixedPointBinding | null;
|
||||
endBinding?: FixedPointBinding | null;
|
||||
points?: LocalPoint[];
|
||||
} = {};
|
||||
if (otherUpdates?.startBinding !== undefined) {
|
||||
bindings.startBinding =
|
||||
updates.startBinding =
|
||||
otherUpdates.startBinding !== null &&
|
||||
isFixedPointBinding(otherUpdates.startBinding)
|
||||
? otherUpdates.startBinding
|
||||
: null;
|
||||
}
|
||||
if (otherUpdates?.endBinding !== undefined) {
|
||||
bindings.endBinding =
|
||||
updates.endBinding =
|
||||
otherUpdates.endBinding !== null &&
|
||||
isFixedPointBinding(otherUpdates.endBinding)
|
||||
? otherUpdates.endBinding
|
||||
: null;
|
||||
}
|
||||
|
||||
const mergedElementsMap = options?.changedElements
|
||||
? toBrandedType<SceneElementsMap>(
|
||||
new Map([...elementsMap, ...options.changedElements]),
|
||||
)
|
||||
: elementsMap;
|
||||
|
||||
mutateElbowArrow(
|
||||
element,
|
||||
mergedElementsMap,
|
||||
nextPoints,
|
||||
updates.points = Array.from(nextPoints);
|
||||
updates.points[0] = pointTranslate(
|
||||
updates.points[0],
|
||||
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 {
|
||||
const nextCoords = getElementPointsCoords(element, nextPoints);
|
||||
const prevCoords = getElementPointsCoords(element, element.points);
|
||||
@@ -1773,6 +1735,99 @@ export class LinearElementEditor {
|
||||
|
||||
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 = (
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
import type { ExcalidrawElement, SceneElementsMap } from "./types";
|
||||
import Scene from "../scene/Scene";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
import { randomInteger } from "../random";
|
||||
import { getUpdatedTimestamp } from "../utils";
|
||||
import { getUpdatedTimestamp, toBrandedType } from "../utils";
|
||||
import type { Mutable } from "../utility-types";
|
||||
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<
|
||||
Partial<TElement>,
|
||||
@@ -19,14 +22,54 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
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 => {
|
||||
let didChange = false;
|
||||
|
||||
// casting to any because can't use `in` operator
|
||||
// (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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
ExcalidrawIframeElement,
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
FixedSegment,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
import {
|
||||
arrayToMap,
|
||||
@@ -31,11 +33,7 @@ import { getNewGroupIdsForDuplication } from "../groups";
|
||||
import type { AppState } from "../types";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
measureText,
|
||||
normalizeText,
|
||||
getBoundTextMaxWidth,
|
||||
} from "./textElement";
|
||||
import { getBoundTextMaxWidth } from "./textElement";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import {
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
@@ -43,11 +41,13 @@ import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
ORIG_ID,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import type { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
import { getLineHeight } from "../fonts";
|
||||
import type { Radians } from "../../math";
|
||||
import { normalizeText, measureText } from "./textMeasurements";
|
||||
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
@@ -99,6 +99,28 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
...rest
|
||||
}: 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
|
||||
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
|
||||
id: rest.id || randomId(),
|
||||
@@ -450,15 +472,34 @@ export const newLinearElement = (
|
||||
};
|
||||
};
|
||||
|
||||
export const newArrowElement = (
|
||||
export const newArrowElement = <T extends boolean>(
|
||||
opts: {
|
||||
type: ExcalidrawArrowElement["type"];
|
||||
startArrowhead?: Arrowhead | null;
|
||||
endArrowhead?: Arrowhead | null;
|
||||
points?: ExcalidrawArrowElement["points"];
|
||||
elbowed?: boolean;
|
||||
elbowed?: T;
|
||||
fixedSegments?: FixedSegment[] | null;
|
||||
} & 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 {
|
||||
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
@@ -467,8 +508,10 @@ export const newArrowElement = (
|
||||
endBinding: null,
|
||||
startArrowhead: opts.startArrowhead || null,
|
||||
endArrowhead: opts.endArrowhead || null,
|
||||
elbowed: opts.elbowed || false,
|
||||
};
|
||||
elbowed: false,
|
||||
} as T extends true
|
||||
? NonDeleted<ExcalidrawElbowArrowElement>
|
||||
: NonDeleted<ExcalidrawArrowElement>;
|
||||
};
|
||||
|
||||
export const newImageElement = (
|
||||
@@ -569,26 +612,18 @@ export const deepCopyElement = <T extends ExcalidrawElement>(
|
||||
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
|
||||
* for test assertions.
|
||||
* utility wrapper to generate new id.
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
const regenerateId = () => {
|
||||
return randomId();
|
||||
};
|
||||
|
||||
@@ -614,7 +649,11 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||
): Readonly<TElement> => {
|
||||
let copy = deepCopyElement(element);
|
||||
|
||||
copy.id = regenerateId(copy.id);
|
||||
if (isTestEnv()) {
|
||||
__test__defineOrigId(copy, element.id);
|
||||
}
|
||||
|
||||
copy.id = regenerateId();
|
||||
copy.boundElements = null;
|
||||
copy.updated = getUpdatedTimestamp();
|
||||
copy.seed = randomInteger();
|
||||
@@ -623,7 +662,7 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||
editingGroupId,
|
||||
(groupId) => {
|
||||
if (!groupIdMapForOperation.has(groupId)) {
|
||||
groupIdMapForOperation.set(groupId, regenerateId(groupId));
|
||||
groupIdMapForOperation.set(groupId, regenerateId());
|
||||
}
|
||||
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
|
||||
// id exists, generate a new id for it and return it
|
||||
if (origElementsMap.has(id)) {
|
||||
const newId = regenerateId(id);
|
||||
const newId = regenerateId();
|
||||
elementNewIdsMap.set(id, newId);
|
||||
return newId;
|
||||
}
|
||||
@@ -683,6 +722,9 @@ export const duplicateElements = (
|
||||
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
|
||||
|
||||
clonedElement.id = maybeGetNewId(element.id)!;
|
||||
if (isTestEnv()) {
|
||||
__test__defineOrigId(clonedElement, element.id);
|
||||
}
|
||||
|
||||
if (opts?.randomizeSeed) {
|
||||
clonedElement.seed = randomInteger();
|
||||
@@ -692,7 +734,7 @@ export const duplicateElements = (
|
||||
if (clonedElement.groupIds) {
|
||||
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
|
||||
if (!groupNewIdsMap.has(groupId)) {
|
||||
groupNewIdsMap.set(groupId, regenerateId(groupId));
|
||||
groupNewIdsMap.set(groupId, regenerateId());
|
||||
}
|
||||
return groupNewIdsMap.get(groupId)!;
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ExcalidrawImageElement,
|
||||
ElementsMap,
|
||||
SceneElementsMap,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
import type { Mutable } from "../utility-types";
|
||||
import {
|
||||
@@ -40,20 +41,15 @@ import type {
|
||||
import type { PointerDownState } from "../types";
|
||||
import type Scene from "../scene/Scene";
|
||||
import {
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getBoundTextElementId,
|
||||
getContainerElement,
|
||||
handleBindTextResize,
|
||||
getBoundTextMaxWidth,
|
||||
getApproxMinLineHeight,
|
||||
measureText,
|
||||
getMinTextElementWidth,
|
||||
} from "./textElement";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { isInGroup } from "../groups";
|
||||
import { mutateElbowArrow } from "./routing";
|
||||
import type { GlobalPoint } from "../../math";
|
||||
import {
|
||||
pointCenter,
|
||||
@@ -64,6 +60,12 @@ import {
|
||||
type Radians,
|
||||
type LocalPoint,
|
||||
} from "../../math";
|
||||
import {
|
||||
getMinTextElementWidth,
|
||||
measureText,
|
||||
getApproxMinLineWidth,
|
||||
getApproxMinLineHeight,
|
||||
} from "./textMeasurements";
|
||||
|
||||
// Returns true when transform (resizing/rotation) happened
|
||||
export const transformElements = (
|
||||
@@ -177,10 +179,10 @@ export const transformElements = (
|
||||
elementsMap,
|
||||
transformHandleType,
|
||||
scene,
|
||||
originalElements,
|
||||
{
|
||||
shouldResizeFromCenter,
|
||||
shouldMaintainAspectRatio,
|
||||
originalElementsMap: originalElements,
|
||||
flipByX,
|
||||
flipByY,
|
||||
nextWidth,
|
||||
@@ -531,8 +533,10 @@ const rotateMultipleElements = (
|
||||
);
|
||||
|
||||
if (isElbowArrow(element)) {
|
||||
const points = getArrowLocalFixedPoints(element, elementsMap);
|
||||
mutateElbowArrow(element, elementsMap, points);
|
||||
// Needed to re-route the arrow
|
||||
mutateElement(element, {
|
||||
points: getArrowLocalFixedPoints(element, elementsMap),
|
||||
});
|
||||
} else {
|
||||
mutateElement(
|
||||
element,
|
||||
@@ -765,6 +769,26 @@ const getResizedOrigin = (
|
||||
y: y - (newHeight - prevHeight) / 2,
|
||||
};
|
||||
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 {
|
||||
x: x + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1),
|
||||
y:
|
||||
@@ -1201,6 +1225,7 @@ export const resizeMultipleElements = (
|
||||
elementsMap: ElementsMap,
|
||||
handleDirection: TransformHandleDirection,
|
||||
scene: Scene,
|
||||
originalElementsMap: ElementsMap,
|
||||
{
|
||||
shouldMaintainAspectRatio = false,
|
||||
shouldResizeFromCenter = false,
|
||||
@@ -1208,7 +1233,6 @@ export const resizeMultipleElements = (
|
||||
flipByY = false,
|
||||
nextHeight,
|
||||
nextWidth,
|
||||
originalElementsMap,
|
||||
originalBoundingBox,
|
||||
}: {
|
||||
nextWidth?: number;
|
||||
@@ -1217,7 +1241,6 @@ export const resizeMultipleElements = (
|
||||
shouldResizeFromCenter?: boolean;
|
||||
flipByX?: boolean;
|
||||
flipByY?: boolean;
|
||||
originalElementsMap?: ElementsMap;
|
||||
// added to improve performance
|
||||
originalBoundingBox?: BoundingBox;
|
||||
} = {},
|
||||
@@ -1387,6 +1410,9 @@ export const resizeMultipleElements = (
|
||||
fontSize?: ExcalidrawTextElement["fontSize"];
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
|
||||
startBinding?: ExcalidrawElbowArrowElement["startBinding"];
|
||||
endBinding?: ExcalidrawElbowArrowElement["endBinding"];
|
||||
fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"];
|
||||
};
|
||||
}[] = [];
|
||||
|
||||
@@ -1427,6 +1453,44 @@ export const resizeMultipleElements = (
|
||||
...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)) {
|
||||
update.scale = [
|
||||
orig.scale[0] * flipFactorX,
|
||||
@@ -1472,7 +1536,10 @@ export const resizeMultipleElements = (
|
||||
} of elementsAndUpdates) {
|
||||
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, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -116,8 +116,5 @@ const normalizeBoundElementsOrder = (
|
||||
export const normalizeElementOrder = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
// console.time();
|
||||
const ret = normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
|
||||
// console.timeEnd();
|
||||
return ret;
|
||||
return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
|
||||
};
|
||||
|
||||
@@ -6,9 +6,8 @@ import {
|
||||
getContainerCoords,
|
||||
getBoundTextMaxWidth,
|
||||
getBoundTextMaxHeight,
|
||||
detectLineHeight,
|
||||
getLineHeightInPx,
|
||||
} from "./textElement";
|
||||
import { detectLineHeight, getLineHeightInPx } from "./textMeasurements";
|
||||
import type { ExcalidrawTextElementWithContainer } from "./types";
|
||||
|
||||
describe("Test measureText", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils";
|
||||
import { getFontString, arrayToMap } from "../utils";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FontString,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
|
||||
ARROW_LABEL_WIDTH_FRACTION,
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
@@ -30,18 +28,7 @@ import {
|
||||
updateOriginalContainerCache,
|
||||
} from "./containerCache";
|
||||
import type { ExtractSetType } from "../utility-types";
|
||||
|
||||
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");
|
||||
};
|
||||
import { measureText } from "./textMeasurements";
|
||||
|
||||
export const redrawTextBoundingBox = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
@@ -281,201 +268,6 @@ export const computeBoundTextPosition = (
|
||||
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) => {
|
||||
return container?.boundElements?.length
|
||||
? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
|
||||
@@ -712,24 +504,6 @@ export const getBoundTextMaxHeight = (
|
||||
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 */
|
||||
export const getTextFromElements = (
|
||||
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);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ENV } from "../constants";
|
||||
import { charWidth, getLineWidth } from "./textElement";
|
||||
import { charWidth, getLineWidth } from "./textMeasurements";
|
||||
import type { FontString } from "./types";
|
||||
|
||||
let cachedCjkRegex: RegExp | undefined;
|
||||
@@ -385,7 +385,7 @@ export const wrapText = (
|
||||
const originalLines = text.split("\n");
|
||||
|
||||
for (const originalLine of originalLines) {
|
||||
const currentLineWidth = getLineWidth(originalLine, font, true);
|
||||
const currentLineWidth = getLineWidth(originalLine, font);
|
||||
|
||||
if (currentLineWidth <= maxWidth) {
|
||||
lines.push(originalLine);
|
||||
@@ -423,7 +423,7 @@ const wrapLine = (
|
||||
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
|
||||
const testLineWidth = isSingleCharacter(token)
|
||||
? currentLineWidth + charWidth.calculate(token, font)
|
||||
: getLineWidth(testLine, font, true);
|
||||
: getLineWidth(testLine, font);
|
||||
|
||||
// build up the current line, skipping length check for possibly trailing whitespaces
|
||||
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
|
||||
currentLine = trailingLine;
|
||||
currentLineWidth = getLineWidth(trailingLine, font, true);
|
||||
currentLineWidth = getLineWidth(trailingLine, font);
|
||||
iterator = tokenIterator.next();
|
||||
} else {
|
||||
// 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`.
|
||||
*/
|
||||
const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
||||
const shouldTrimWhitespaces = getLineWidth(line, font, true) > maxWidth;
|
||||
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
|
||||
|
||||
if (!shouldTrimWhitespaces) {
|
||||
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)) {
|
||||
const _charWidth = charWidth.calculate(whitespace, font);
|
||||
|
||||
@@ -24,8 +24,6 @@ import {
|
||||
getBoundTextElementId,
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
getTextWidth,
|
||||
normalizeText,
|
||||
redrawTextBoundingBox,
|
||||
getBoundTextMaxHeight,
|
||||
getBoundTextMaxWidth,
|
||||
@@ -50,6 +48,8 @@ import {
|
||||
originalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "./containerCache";
|
||||
import { getTextWidth } from "./textMeasurements";
|
||||
import { normalizeText } from "./textMeasurements";
|
||||
|
||||
const getTransform = (
|
||||
width: number,
|
||||
@@ -350,7 +350,7 @@ export const textWysiwyg = ({
|
||||
font,
|
||||
getBoundTextMaxWidth(container, boundTextElement),
|
||||
);
|
||||
const width = getTextWidth(wrappedText, font, true);
|
||||
const width = getTextWidth(wrappedText, font);
|
||||
editable.style.width = `${width}px`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -319,6 +319,12 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
endArrowhead: Arrowhead | null;
|
||||
}>;
|
||||
|
||||
export type FixedSegment = {
|
||||
start: LocalPoint;
|
||||
end: LocalPoint;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
|
||||
Readonly<{
|
||||
type: "arrow";
|
||||
@@ -331,6 +337,23 @@ export type ExcalidrawElbowArrowElement = Merge<
|
||||
elbowed: true;
|
||||
startBinding: 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;
|
||||
}
|
||||
>;
|
||||
|
||||
|
||||
@@ -3,11 +3,10 @@ import {
|
||||
FONT_FAMILY_FALLBACKS,
|
||||
CJK_HAND_DRAWN_FALLBACK_FONT,
|
||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||
isSafari,
|
||||
getFontFamilyFallbacks,
|
||||
} from "../constants";
|
||||
import { isTextElement } from "../element";
|
||||
import { charWidth, getContainerElement } from "../element/textElement";
|
||||
import { getContainerElement } from "../element/textElement";
|
||||
import { containsCJK } from "../element/textWrapping";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { getFontString, PromisePool, promiseTry } from "../utils";
|
||||
@@ -32,6 +31,7 @@ import type {
|
||||
} from "../element/types";
|
||||
import type Scene from "../scene/Scene";
|
||||
import type { ValueOf } from "../utility-types";
|
||||
import { charWidth } from "../element/textMeasurements";
|
||||
|
||||
export class Fonts {
|
||||
// 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.
|
||||
*
|
||||
* 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[]> => {
|
||||
const sceneFamilies = this.getSceneFamilies();
|
||||
const charsPerFamily = isSafari
|
||||
? Fonts.getCharsPerFamily(this.scene.getNonDeletedElements())
|
||||
: undefined;
|
||||
const charsPerFamily = Fonts.getCharsPerFamily(
|
||||
this.scene.getNonDeletedElements(),
|
||||
);
|
||||
|
||||
return Fonts.loadFontFaces(sceneFamilies, charsPerFamily);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): Promise<FontFace[]> => {
|
||||
const fontFamilies = Fonts.getUniqueFamilies(elements);
|
||||
const charsPerFamily = isSafari
|
||||
? Fonts.getCharsPerFamily(elements)
|
||||
: undefined;
|
||||
const charsPerFamily = Fonts.getCharsPerFamily(elements);
|
||||
|
||||
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.
|
||||
*/
|
||||
@@ -223,7 +201,7 @@ export class Fonts {
|
||||
|
||||
private static async loadFontFaces(
|
||||
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)
|
||||
for (const { fontFaces, metadata } of Fonts.registered.values()) {
|
||||
@@ -248,7 +226,7 @@ export class Fonts {
|
||||
|
||||
private static *fontFacesLoader(
|
||||
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
|
||||
charsPerFamily?: Record<number, Set<string>>,
|
||||
charsPerFamily: Record<number, Set<string>>,
|
||||
): Generator<Promise<void | readonly [number, FontFace[]]>> {
|
||||
for (const [index, fontFamily] of fontFamilies.entries()) {
|
||||
const font = getFontString({
|
||||
@@ -256,12 +234,9 @@ export class Fonts {
|
||||
fontSize: 16,
|
||||
});
|
||||
|
||||
// WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
|
||||
// for Safari on init, we rather check with the "text" param, even though it's less efficient, as otherwise fonts might remain unloaded
|
||||
const text =
|
||||
isSafari && charsPerFamily
|
||||
? Fonts.getCharacters(charsPerFamily, fontFamily)
|
||||
: "";
|
||||
// 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!
|
||||
// instead, we are always checking chars used in the family, so that no required font faces remain unloaded
|
||||
const text = Fonts.getCharacters(charsPerFamily, fontFamily);
|
||||
|
||||
if (!window.document.fonts.check(font, text)) {
|
||||
yield promiseTry(async () => {
|
||||
|
||||
@@ -190,7 +190,6 @@ export const syncInvalidIndices = (
|
||||
): OrderedExcalidrawElement[] => {
|
||||
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||
|
||||
for (const [element, update] of elementsUpdates) {
|
||||
mutateElement(element, update, false);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React from "react";
|
||||
import type { ExcalidrawElement } from "./element/types";
|
||||
import { convertToExcalidrawElements, Excalidraw } from "./index";
|
||||
import { API } from "./tests/helpers/api";
|
||||
import { Keyboard, Pointer } from "./tests/helpers/ui";
|
||||
import { render } from "./tests/test-utils";
|
||||
import { getCloneByOrigId, render } from "./tests/test-utils";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
@@ -413,10 +412,10 @@ describe("adding elements to frames", () => {
|
||||
|
||||
dragElementIntoFrame(frame, rect2);
|
||||
|
||||
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
|
||||
|
||||
selectElementAndDuplicate(rect2);
|
||||
|
||||
const rect2_copy = getCloneByOrigId(rect2.id);
|
||||
|
||||
expect(rect2_copy.frameId).toBe(frame.id);
|
||||
expect(rect2.frameId).toBe(frame.id);
|
||||
expectEqualIds([rect2_copy, rect2, frame]);
|
||||
@@ -427,11 +426,11 @@ describe("adding elements to frames", () => {
|
||||
|
||||
dragElementIntoFrame(frame, rect2);
|
||||
|
||||
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
|
||||
|
||||
// move the rect2 outside the frame
|
||||
selectElementAndDuplicate(rect2, [-1000, -1000]);
|
||||
|
||||
const rect2_copy = getCloneByOrigId(rect2.id);
|
||||
|
||||
expect(rect2_copy.frameId).toBe(frame.id);
|
||||
expect(rect2.frameId).toBe(null);
|
||||
expectEqualIds([rect2_copy, frame, rect2]);
|
||||
|
||||
+214
-44
@@ -95,12 +95,11 @@ export const getElementsCompletelyInFrame = (
|
||||
);
|
||||
|
||||
export const isElementContainingFrame = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
element: ExcalidrawElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return getElementsWithinSelection(elements, element, elementsMap).some(
|
||||
return getElementsWithinSelection([frame], element, elementsMap).some(
|
||||
(e) => e.id === frame.id,
|
||||
);
|
||||
};
|
||||
@@ -144,7 +143,7 @@ export const elementOverlapsWithFrame = (
|
||||
return (
|
||||
elementsAreInFrameBounds([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([
|
||||
...getElementsCompletelyInFrame(allElements, frame, elementsMap),
|
||||
...prevElementsInFrame.filter((element) =>
|
||||
isElementContainingFrame(allElements, element, frame, elementsMap),
|
||||
isElementContainingFrame(element, frame, elementsMap),
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -370,12 +369,57 @@ export const getElementsInNewFrame = (
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return omitGroupsContainingFrameLikes(
|
||||
elements,
|
||||
getElementsCompletelyInFrame(elements, frame, elementsMap),
|
||||
return omitPartialGroups(
|
||||
omitGroupsContainingFrameLikes(
|
||||
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 = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
@@ -454,6 +498,7 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
||||
allElements: T,
|
||||
elementsToAdd: NonDeletedExcalidrawElement[],
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
appState: AppState,
|
||||
): T => {
|
||||
const elementsMap = arrayToMap(allElements);
|
||||
const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
|
||||
@@ -489,6 +534,17 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
||||
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)) {
|
||||
finalElementsToAdd.push(element);
|
||||
}
|
||||
@@ -577,6 +633,7 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
|
||||
removeAllElementsFromFrame(allElements, frame),
|
||||
nextElementsInFrame,
|
||||
frame,
|
||||
app.state,
|
||||
).slice();
|
||||
};
|
||||
|
||||
@@ -683,6 +740,16 @@ export const getTargetFrame = (
|
||||
? getContainerElement(element, elementsMap) || 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] &&
|
||||
appState.selectedElementsAreBeingDragged
|
||||
? appState.frameToHighlight
|
||||
@@ -695,61 +762,151 @@ export const isElementInFrame = (
|
||||
element: ExcalidrawElement,
|
||||
allElementsMap: ElementsMap,
|
||||
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)
|
||||
? getContainerElement(element, allElementsMap) || element
|
||||
: element;
|
||||
|
||||
if (frame) {
|
||||
// Perf improvement:
|
||||
// For an element that's already in a frame, if it's not being dragged
|
||||
// then there is no need to refer to geometry (which, yes, is slow) to check if it's in a frame.
|
||||
// It has to be in its containing frame.
|
||||
if (
|
||||
!appState.selectedElementIds[element.id] ||
|
||||
!appState.selectedElementsAreBeingDragged
|
||||
) {
|
||||
const setGroupsInFrame = (isInFrame: boolean) => {
|
||||
if (opts?.checkedGroups) {
|
||||
_element.groupIds.forEach((groupId) => {
|
||||
opts.checkedGroups?.set(groupId, isInFrame);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (_element.groupIds.length === 0) {
|
||||
return elementOverlapsWithFrame(_element, frame, allElementsMap);
|
||||
selectedElements.forEach((selectedElement) => {
|
||||
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(
|
||||
_element.groupIds.flatMap((gid) =>
|
||||
getElementsInGroup(allElementsMap, gid),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
|
||||
const selectedElements = new Set(
|
||||
getSelectedElements(allElementsMap, appState),
|
||||
);
|
||||
// if an element is outside the frame, but is part of a group that has some elements
|
||||
// "in" the frame, we should clip the element
|
||||
if (
|
||||
!shouldClipElementItself &&
|
||||
element.groupIds.length > 0 &&
|
||||
!elementsAreInFrameBounds([element], frame, elementsMap)
|
||||
) {
|
||||
let shouldClip = false;
|
||||
|
||||
const editingGroupOverlapsFrame = appState.frameToHighlight !== null;
|
||||
|
||||
if (editingGroupOverlapsFrame) {
|
||||
return true;
|
||||
// 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 (!appState.selectedElementsAreBeingDragged) {
|
||||
shouldClip = element.frameId === frame.id;
|
||||
for (const groupId of element.groupIds) {
|
||||
checkedGroups?.set(groupId, shouldClip);
|
||||
}
|
||||
|
||||
selectedElements.forEach((selectedElement) => {
|
||||
allElementsInGroup.delete(selectedElement);
|
||||
} else {
|
||||
shouldClip = isElementInFrame(element, elementsMap, appState, {
|
||||
targetFrame: frame,
|
||||
checkedGroups,
|
||||
});
|
||||
}
|
||||
|
||||
for (const elementInGroup of allElementsInGroup) {
|
||||
if (isFrameLikeElement(elementInGroup)) {
|
||||
return false;
|
||||
}
|
||||
for (const groupId of element.groupIds) {
|
||||
checkedGroups?.set(groupId, shouldClip);
|
||||
}
|
||||
|
||||
for (const elementInGroup of allElementsInGroup) {
|
||||
if (elementOverlapsWithFrame(elementInGroup, frame, allElementsMap)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return shouldClip;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -779,3 +936,16 @@ export const getElementsOverlappingFrame = (
|
||||
.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),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
onPointerDown,
|
||||
onPointerUp,
|
||||
onScrollChange,
|
||||
onDuplicate,
|
||||
children,
|
||||
validateEmbeddable,
|
||||
renderEmbeddable,
|
||||
@@ -136,6 +137,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerUp={onPointerUp}
|
||||
onScrollChange={onScrollChange}
|
||||
onDuplicate={onDuplicate}
|
||||
validateEmbeddable={validateEmbeddable}
|
||||
renderEmbeddable={renderEmbeddable}
|
||||
aiEnabled={aiEnabled !== false}
|
||||
@@ -293,3 +295,5 @@ export {
|
||||
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
|
||||
export { getDataURL } from "./data/blob";
|
||||
export { isElementLink } from "./element/elementLink";
|
||||
|
||||
export { setCustomTextMetricsProvider } from "./element/textMeasurements";
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
"@types/pako": "1.0.3",
|
||||
"@types/pica": "5.1.3",
|
||||
"@types/resize-observer-browser": "0.1.7",
|
||||
"ansicolor": "2.0.3",
|
||||
"autoprefixer": "10.4.7",
|
||||
"babel-loader": "8.2.5",
|
||||
"babel-plugin-transform-class-properties": "6.24.1",
|
||||
@@ -117,6 +118,7 @@
|
||||
"fonteditor-core": "2.4.1",
|
||||
"harfbuzzjs": "0.3.6",
|
||||
"import-meta-loader": "1.1.0",
|
||||
"jest-diff": "29.7.0",
|
||||
"mini-css-extract-plugin": "2.6.1",
|
||||
"postcss-loader": "7.0.1",
|
||||
"sass-loader": "13.0.2",
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
getOmitSidesForDevice,
|
||||
shouldShowBoundingBox,
|
||||
} from "../element/transformHandles";
|
||||
import { arrayToMap, throttleRAF } from "../utils";
|
||||
import { arrayToMap, invariant, throttleRAF } from "../utils";
|
||||
import {
|
||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||
FRAME_STYLE,
|
||||
@@ -78,9 +78,32 @@ import type {
|
||||
InteractiveSceneRenderConfig,
|
||||
RenderableElementsMap,
|
||||
} from "../scene/types";
|
||||
import type { GlobalPoint, LocalPoint, Radians } from "../../math";
|
||||
import {
|
||||
pointFrom,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
type Radians,
|
||||
} from "../../math";
|
||||
import { getCornerRadius } from "../shapes";
|
||||
|
||||
const renderElbowArrowMidPointHighlight = (
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
invariant(appState.selectedLinearElement, "selectedLinearElement is null");
|
||||
|
||||
const { segmentMidPointHoveredCoords } = appState.selectedLinearElement;
|
||||
|
||||
invariant(segmentMidPointHoveredCoords, "midPointCoords is null");
|
||||
|
||||
context.save();
|
||||
context.translate(appState.scrollX, appState.scrollY);
|
||||
|
||||
highlightPoint(segmentMidPointHoveredCoords, context, appState);
|
||||
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const renderLinearElementPointHighlight = (
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: InteractiveCanvasAppState,
|
||||
@@ -490,7 +513,7 @@ const renderLinearPointHandles = (
|
||||
context.save();
|
||||
context.translate(appState.scrollX, appState.scrollY);
|
||||
context.lineWidth = 1 / appState.zoom.value;
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
const points: GlobalPoint[] = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
@@ -510,55 +533,57 @@ const renderLinearPointHandles = (
|
||||
renderSingleLinearPoint(context, appState, point, radius, isSelected);
|
||||
});
|
||||
|
||||
//Rendering segment mid points
|
||||
const midPoints = LinearElementEditor.getEditorMidPoints(
|
||||
element,
|
||||
elementsMap,
|
||||
appState,
|
||||
).filter((midPoint): midPoint is GlobalPoint => midPoint !== null);
|
||||
|
||||
midPoints.forEach((segmentMidPoint) => {
|
||||
if (
|
||||
appState?.selectedLinearElement?.segmentMidPointHoveredCoords &&
|
||||
LinearElementEditor.arePointsEqual(
|
||||
segmentMidPoint,
|
||||
appState.selectedLinearElement.segmentMidPointHoveredCoords,
|
||||
)
|
||||
) {
|
||||
// The order of renderingSingleLinearPoint and highLight points is different
|
||||
// inside vs outside editor as hover states are different,
|
||||
// in editor when hovered the original point is not visible as hover state fully covers it whereas outside the
|
||||
// editor original point is visible and hover state is just an outer circle.
|
||||
if (appState.editingLinearElement) {
|
||||
// Rendering segment mid points
|
||||
if (isElbowArrow(element)) {
|
||||
const fixedSegments =
|
||||
element.fixedSegments?.map((segment) => segment.index) || [];
|
||||
points.slice(0, -1).forEach((p, idx) => {
|
||||
if (
|
||||
!LinearElementEditor.isSegmentTooShort(
|
||||
element,
|
||||
points[idx + 1],
|
||||
points[idx],
|
||||
idx,
|
||||
appState.zoom,
|
||||
)
|
||||
) {
|
||||
renderSingleLinearPoint(
|
||||
context,
|
||||
appState,
|
||||
segmentMidPoint,
|
||||
radius,
|
||||
false,
|
||||
);
|
||||
highlightPoint(segmentMidPoint, context, appState);
|
||||
} else {
|
||||
highlightPoint(segmentMidPoint, context, appState);
|
||||
renderSingleLinearPoint(
|
||||
context,
|
||||
appState,
|
||||
segmentMidPoint,
|
||||
radius,
|
||||
pointFrom<GlobalPoint>(
|
||||
(p[0] + points[idx + 1][0]) / 2,
|
||||
(p[1] + points[idx + 1][1]) / 2,
|
||||
),
|
||||
POINT_HANDLE_SIZE / 2,
|
||||
false,
|
||||
!fixedSegments.includes(idx + 1),
|
||||
);
|
||||
}
|
||||
} else if (appState.editingLinearElement || points.length === 2) {
|
||||
renderSingleLinearPoint(
|
||||
context,
|
||||
appState,
|
||||
segmentMidPoint,
|
||||
POINT_HANDLE_SIZE / 2,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
const midPoints = LinearElementEditor.getEditorMidPoints(
|
||||
element,
|
||||
elementsMap,
|
||||
appState,
|
||||
).filter(
|
||||
(midPoint, idx, midPoints): midPoint is GlobalPoint =>
|
||||
midPoint !== null &&
|
||||
!(isElbowArrow(element) && (idx === 0 || idx === midPoints.length - 1)),
|
||||
);
|
||||
|
||||
midPoints.forEach((segmentMidPoint) => {
|
||||
if (appState.editingLinearElement || points.length === 2) {
|
||||
renderSingleLinearPoint(
|
||||
context,
|
||||
appState,
|
||||
segmentMidPoint,
|
||||
POINT_HANDLE_SIZE / 2,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
context.restore();
|
||||
};
|
||||
@@ -864,6 +889,12 @@ const _renderInteractiveScene = ({
|
||||
}
|
||||
|
||||
if (
|
||||
isElbowArrow(selectedElements[0]) &&
|
||||
appState.selectedLinearElement &&
|
||||
appState.selectedLinearElement.segmentMidPointHoveredCoords
|
||||
) {
|
||||
renderElbowArrowMidPointHighlight(context, appState);
|
||||
} else if (
|
||||
appState.selectedLinearElement &&
|
||||
appState.selectedLinearElement.hoverPointIndex >= 0 &&
|
||||
!(
|
||||
@@ -875,6 +906,7 @@ const _renderInteractiveScene = ({
|
||||
) {
|
||||
renderLinearElementPointHighlight(context, appState, elementsMap);
|
||||
}
|
||||
|
||||
// Paint selected elements
|
||||
if (!appState.multiElement && !appState.editingLinearElement) {
|
||||
const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
getBoundTextElement,
|
||||
getContainerCoords,
|
||||
getContainerElement,
|
||||
getLineHeightInPx,
|
||||
getBoundTextMaxHeight,
|
||||
getBoundTextMaxWidth,
|
||||
} from "../element/textElement";
|
||||
@@ -64,6 +63,7 @@ import { getVerticalOffset } from "../fonts";
|
||||
import { isRightAngleRads } from "../../math";
|
||||
import { getCornerRadius } from "../shapes";
|
||||
import { getUncroppedImageElement } from "../element/cropElement";
|
||||
import { getLineHeightInPx } from "../element/textMeasurements";
|
||||
|
||||
// using a stronger invert (100% vs our regular 93%) and saturate
|
||||
// as a temp hack to make images in dark theme look closer to original
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getElementAbsoluteCoords } from "../element";
|
||||
import {
|
||||
elementOverlapsWithFrame,
|
||||
getTargetFrame,
|
||||
isElementInFrame,
|
||||
shouldApplyFrameClip,
|
||||
} from "../frame";
|
||||
import {
|
||||
isEmbeddableElement,
|
||||
@@ -273,6 +273,8 @@ const _renderStaticScene = ({
|
||||
}
|
||||
});
|
||||
|
||||
const inFrameGroupsMap = new Map<string, boolean>();
|
||||
|
||||
// Paint visible elements
|
||||
visibleElements
|
||||
.filter((el) => !isIframeLikeElement(el))
|
||||
@@ -297,9 +299,16 @@ const _renderStaticScene = ({
|
||||
appState.frameRendering.clip
|
||||
) {
|
||||
const frame = getTargetFrame(element, elementsMap, appState);
|
||||
|
||||
// TODO do we need to check isElementInFrame here?
|
||||
if (frame && isElementInFrame(element, elementsMap, appState)) {
|
||||
if (
|
||||
frame &&
|
||||
shouldApplyFrameClip(
|
||||
element,
|
||||
frame,
|
||||
appState,
|
||||
elementsMap,
|
||||
inFrameGroupsMap,
|
||||
)
|
||||
) {
|
||||
frameClip(frame, context, renderConfig, appState);
|
||||
}
|
||||
renderElement(
|
||||
@@ -400,7 +409,16 @@ const _renderStaticScene = ({
|
||||
|
||||
const frame = getTargetFrame(element, elementsMap, appState);
|
||||
|
||||
if (frame && isElementInFrame(element, elementsMap, appState)) {
|
||||
if (
|
||||
frame &&
|
||||
shouldApplyFrameClip(
|
||||
element,
|
||||
frame,
|
||||
appState,
|
||||
elementsMap,
|
||||
inFrameGroupsMap,
|
||||
)
|
||||
) {
|
||||
frameClip(frame, context, renderConfig, appState);
|
||||
}
|
||||
render();
|
||||
|
||||
@@ -16,7 +16,6 @@ import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getContainerElement,
|
||||
getLineHeightInPx,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
isArrowElement,
|
||||
@@ -38,6 +37,7 @@ import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
|
||||
import { getVerticalOffset } from "../fonts";
|
||||
import { getCornerRadius, isPathALoop } from "../shapes";
|
||||
import { getUncroppedWidthAndHeight } from "../element/cropElement";
|
||||
import { getLineHeightInPx } from "../element/textMeasurements";
|
||||
|
||||
const roughSVGDrawWithPrecision = (
|
||||
rsvg: RoughSVG,
|
||||
|
||||
@@ -23,13 +23,9 @@ import {
|
||||
} from "../element/typeChecks";
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
import type { EmbedsValidationStatus } from "../types";
|
||||
import {
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
} from "../../math";
|
||||
import { pointFrom, pointDistance, type LocalPoint } from "../../math";
|
||||
import { getCornerRadius, isPathALoop } from "../shapes";
|
||||
import { headingForPointIsHorizontal } from "../element/heading";
|
||||
|
||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||
|
||||
@@ -527,45 +523,53 @@ export const _generateElementShape = (
|
||||
}
|
||||
};
|
||||
|
||||
const generateElbowArrowShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
points: readonly Point[],
|
||||
const generateElbowArrowShape = (
|
||||
points: readonly LocalPoint[],
|
||||
radius: number,
|
||||
) => {
|
||||
const subpoints = [] as [number, number][];
|
||||
for (let i = 1; i < points.length - 1; i += 1) {
|
||||
const prev = points[i - 1];
|
||||
const next = points[i + 1];
|
||||
const point = points[i];
|
||||
const prevIsHorizontal = headingForPointIsHorizontal(point, prev);
|
||||
const nextIsHorizontal = headingForPointIsHorizontal(next, point);
|
||||
const corner = Math.min(
|
||||
radius,
|
||||
pointDistance(points[i], next) / 2,
|
||||
pointDistance(points[i], prev) / 2,
|
||||
);
|
||||
|
||||
if (prev[0] < points[i][0] && prev[1] === points[i][1]) {
|
||||
// LEFT
|
||||
subpoints.push([points[i][0] - corner, points[i][1]]);
|
||||
} else if (prev[0] === points[i][0] && prev[1] < points[i][1]) {
|
||||
if (prevIsHorizontal) {
|
||||
if (prev[0] < point[0]) {
|
||||
// LEFT
|
||||
subpoints.push([points[i][0] - corner, points[i][1]]);
|
||||
} else {
|
||||
// RIGHT
|
||||
subpoints.push([points[i][0] + corner, points[i][1]]);
|
||||
}
|
||||
} else if (prev[1] < point[1]) {
|
||||
// UP
|
||||
subpoints.push([points[i][0], points[i][1] - corner]);
|
||||
} else if (prev[0] > points[i][0] && prev[1] === points[i][1]) {
|
||||
// RIGHT
|
||||
subpoints.push([points[i][0] + corner, points[i][1]]);
|
||||
} else {
|
||||
subpoints.push([points[i][0], points[i][1] + corner]);
|
||||
}
|
||||
|
||||
subpoints.push(points[i] as [number, number]);
|
||||
|
||||
if (next[0] < points[i][0] && next[1] === points[i][1]) {
|
||||
// LEFT
|
||||
subpoints.push([points[i][0] - corner, points[i][1]]);
|
||||
} else if (next[0] === points[i][0] && next[1] < points[i][1]) {
|
||||
if (nextIsHorizontal) {
|
||||
if (next[0] < point[0]) {
|
||||
// LEFT
|
||||
subpoints.push([points[i][0] - corner, points[i][1]]);
|
||||
} else {
|
||||
// RIGHT
|
||||
subpoints.push([points[i][0] + corner, points[i][1]]);
|
||||
}
|
||||
} else if (next[1] < point[1]) {
|
||||
// UP
|
||||
subpoints.push([points[i][0], points[i][1] - corner]);
|
||||
} else if (next[0] > points[i][0] && next[1] === points[i][1]) {
|
||||
// RIGHT
|
||||
subpoints.push([points[i][0] + corner, points[i][1]]);
|
||||
} else {
|
||||
// DOWN
|
||||
subpoints.push([points[i][0], points[i][1] + corner]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import { isIframeElement } from "../element/typeChecks";
|
||||
import type {
|
||||
ExcalidrawIframeElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import type { ElementOrToolType } from "../types";
|
||||
|
||||
export const hasBackground = (type: ElementOrToolType) =>
|
||||
@@ -47,48 +42,3 @@ export const canChangeRoundness = (type: ElementOrToolType) =>
|
||||
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
|
||||
|
||||
export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
|
||||
|
||||
export const getElementAtPosition = (
|
||||
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;
|
||||
};
|
||||
|
||||
export const getElementsAtPosition = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
|
||||
) => {
|
||||
const iframeLikes: ExcalidrawIframeElement[] = [];
|
||||
// The parameter elements comes ordered from lower z-index to higher.
|
||||
// We want to preserve that order on the returned array.
|
||||
// Exception being embeddables which should be on top of everything else in
|
||||
// terms of hit testing.
|
||||
const elsAtPos = elements.filter((element) => {
|
||||
const hit = !element.isDeleted && isAtPositionFn(element);
|
||||
if (hit) {
|
||||
if (isIframeElement(element)) {
|
||||
iframeLikes.push(element);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return elsAtPos.concat(iframeLikes);
|
||||
};
|
||||
|
||||
@@ -12,8 +12,6 @@ export {
|
||||
hasStrokeStyle,
|
||||
canHaveArrowheads,
|
||||
canChangeRoundness,
|
||||
getElementAtPosition,
|
||||
getElementsAtPosition,
|
||||
} from "./comparisons";
|
||||
export {
|
||||
getNormalizedZoom,
|
||||
|
||||
@@ -183,10 +183,12 @@ export const getSelectedElements = (
|
||||
includeElementsInFrames?: boolean;
|
||||
},
|
||||
) => {
|
||||
const addedElements = new Set<ExcalidrawElement["id"]>();
|
||||
const selectedElements: ExcalidrawElement[] = [];
|
||||
for (const element of elements.values()) {
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
selectedElements.push(element);
|
||||
addedElements.add(element.id);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
@@ -195,6 +197,7 @@ export const getSelectedElements = (
|
||||
appState.selectedElementIds[element?.containerId]
|
||||
) {
|
||||
selectedElements.push(element);
|
||||
addedElements.add(element.id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -203,8 +206,8 @@ export const getSelectedElements = (
|
||||
const elementsToInclude: ExcalidrawElement[] = [];
|
||||
selectedElements.forEach((element) => {
|
||||
if (isFrameLikeElement(element)) {
|
||||
getFrameChildren(elements, element.id).forEach((e) =>
|
||||
elementsToInclude.push(e),
|
||||
getFrameChildren(elements, element.id).forEach(
|
||||
(e) => !addedElements.has(e.id) && elementsToInclude.push(e),
|
||||
);
|
||||
}
|
||||
elementsToInclude.push(element);
|
||||
|
||||
@@ -2517,7 +2517,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0_copy": true,
|
||||
"id1": true,
|
||||
},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -2590,7 +2590,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 20,
|
||||
"id": "id0_copy",
|
||||
"id": "id1",
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
@@ -2680,7 +2680,7 @@ History {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"selectedElementIds": {
|
||||
"id0_copy": true,
|
||||
"id1": true,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
@@ -2693,7 +2693,7 @@ History {
|
||||
"elementsChange": ElementsChange {
|
||||
"added": Map {},
|
||||
"removed": Map {
|
||||
"id0_copy" => Delta {
|
||||
"id1" => Delta {
|
||||
"deleted": {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0_copy",
|
||||
"id": "id2",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
@@ -233,7 +233,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 11,
|
||||
"versionNonce": 1996028265,
|
||||
"versionNonce": 1051383431,
|
||||
"width": 81,
|
||||
"x": 110,
|
||||
"y": 50,
|
||||
|
||||
@@ -2129,7 +2129,7 @@ History {
|
||||
"elementsChange": ElementsChange {
|
||||
"added": Map {},
|
||||
"removed": Map {
|
||||
"id0_copy" => Delta {
|
||||
"id2" => Delta {
|
||||
"deleted": {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
@@ -10619,7 +10619,7 @@ History {
|
||||
"elementsChange": ElementsChange {
|
||||
"added": Map {},
|
||||
"removed": Map {
|
||||
"id0_copy" => Delta {
|
||||
"id6" => Delta {
|
||||
"deleted": {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
@@ -10628,7 +10628,7 @@ History {
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
"id4_copy",
|
||||
"id7",
|
||||
],
|
||||
"height": 10,
|
||||
"index": "a0",
|
||||
@@ -10652,7 +10652,7 @@ History {
|
||||
"isDeleted": true,
|
||||
},
|
||||
},
|
||||
"id1_copy" => Delta {
|
||||
"id8" => Delta {
|
||||
"deleted": {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
@@ -10661,7 +10661,7 @@ History {
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
"id4_copy",
|
||||
"id7",
|
||||
],
|
||||
"height": 10,
|
||||
"index": "a1",
|
||||
@@ -10685,7 +10685,7 @@ History {
|
||||
"isDeleted": true,
|
||||
},
|
||||
},
|
||||
"id2_copy" => Delta {
|
||||
"id9" => Delta {
|
||||
"deleted": {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
@@ -10694,7 +10694,7 @@ History {
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
"id4_copy",
|
||||
"id7",
|
||||
],
|
||||
"height": 10,
|
||||
"index": "a2",
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("actionStyles", () => {
|
||||
// Roughness
|
||||
fireEvent.click(screen.getByTitle("Cartoonist"));
|
||||
// Opacity
|
||||
fireEvent.change(screen.getByLabelText("Opacity"), {
|
||||
fireEvent.change(screen.getByTestId("opacity"), {
|
||||
target: { value: "60" },
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { render, waitFor, GlobalTestState } from "./test-utils";
|
||||
import { Pointer, Keyboard } from "./helpers/ui";
|
||||
import { Excalidraw } from "../index";
|
||||
import { KEYS } from "../keys";
|
||||
import { getLineHeightInPx } from "../element/textElement";
|
||||
import { getLineHeightInPx } from "../element/textMeasurements";
|
||||
import { getElementBounds } from "../element";
|
||||
import type { NormalizedZoomValue } from "../types";
|
||||
import { API } from "./helpers/api";
|
||||
|
||||
@@ -338,7 +338,7 @@ describe("contextMenu element", () => {
|
||||
// Roughness
|
||||
fireEvent.click(screen.getByTitle("Cartoonist"));
|
||||
// Opacity
|
||||
fireEvent.change(screen.getByLabelText("Opacity"), {
|
||||
fireEvent.change(screen.getByTestId("opacity"), {
|
||||
target: { value: "60" },
|
||||
});
|
||||
|
||||
|
||||
@@ -171,8 +171,8 @@ describe("Crop an image", () => {
|
||||
// test corner handle aspect ratio preserving
|
||||
UI.crop(image, "se", naturalWidth, naturalHeight, [initialWidth, 0], true);
|
||||
expect(image.width / image.height).toBe(resizedWidth / resizedHeight);
|
||||
expect(image.width).toBeLessThanOrEqual(initialWidth);
|
||||
expect(image.height).toBeLessThanOrEqual(initialHeight);
|
||||
expect(image.width).toBeLessThanOrEqual(initialWidth + 0.0001);
|
||||
expect(image.height).toBeLessThanOrEqual(initialHeight + 0.0001);
|
||||
|
||||
// reset
|
||||
image = API.createElement({ type: "image", width: 200, height: 100 });
|
||||
@@ -194,7 +194,7 @@ describe("Crop an image", () => {
|
||||
expect(image.width).toBeCloseTo(image.height);
|
||||
// max height should be reached
|
||||
expect(image.height).toBeCloseTo(initialHeight);
|
||||
expect(image.width).toBe(initialHeight);
|
||||
expect(image.width).toBeCloseTo(initialHeight);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ExcalidrawMagicFrameElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawArrowElement,
|
||||
FixedSegment,
|
||||
} from "../../element/types";
|
||||
import { newElement, newTextElement, newLinearElement } from "../../element";
|
||||
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
|
||||
@@ -39,6 +40,7 @@ import { createTestHook } from "../../components/App";
|
||||
import type { Action } from "../../actions/types";
|
||||
import { mutateElement } from "../../element/mutateElement";
|
||||
import { pointFrom, type LocalPoint, type Radians } from "../../../math";
|
||||
import { selectGroupsForSelectedElements } from "../../groups";
|
||||
|
||||
const readFile = util.promisify(fs.readFile);
|
||||
// so that window.h is available when App.tsx is not imported as well.
|
||||
@@ -67,13 +69,21 @@ export class API {
|
||||
});
|
||||
};
|
||||
|
||||
static setSelectedElements = (elements: ExcalidrawElement[]) => {
|
||||
static setSelectedElements = (elements: ExcalidrawElement[], editingGroupId?: string | null) => {
|
||||
act(() => {
|
||||
h.setState({
|
||||
selectedElementIds: elements.reduce((acc, element) => {
|
||||
acc[element.id] = true;
|
||||
return acc;
|
||||
}, {} as Record<ExcalidrawElement["id"], true>),
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: editingGroupId ?? null,
|
||||
selectedElementIds: elements.reduce((acc, element) => {
|
||||
acc[element.id] = true;
|
||||
return acc;
|
||||
}, {} as Record<ExcalidrawElement["id"], true>),
|
||||
},
|
||||
elements,
|
||||
h.state,
|
||||
h.app,
|
||||
)
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -157,7 +167,7 @@ export class API {
|
||||
isDeleted?: boolean;
|
||||
frameId?: ExcalidrawElement["id"] | null;
|
||||
index?: ExcalidrawElement["index"];
|
||||
groupIds?: string[];
|
||||
groupIds?: ExcalidrawElement["groupIds"];
|
||||
// generic element props
|
||||
strokeColor?: ExcalidrawGenericElement["strokeColor"];
|
||||
backgroundColor?: ExcalidrawGenericElement["backgroundColor"];
|
||||
@@ -197,6 +207,7 @@ export class API {
|
||||
? ExcalidrawArrowElement["endArrowhead"] | ExcalidrawElbowArrowElement["endArrowhead"]
|
||||
: never;
|
||||
elbowed?: boolean;
|
||||
fixedSegments?: FixedSegment[] | null;
|
||||
}): T extends "arrow" | "line"
|
||||
? ExcalidrawLinearElement
|
||||
: T extends "freedraw"
|
||||
@@ -367,6 +378,84 @@ export class API {
|
||||
return element as any;
|
||||
};
|
||||
|
||||
static createTextContainer = (opts?: {
|
||||
frameId?: ExcalidrawElement["id"];
|
||||
groupIds?: ExcalidrawElement["groupIds"];
|
||||
label?: {
|
||||
text?: string;
|
||||
frameId?: ExcalidrawElement["id"] | null;
|
||||
groupIds?: ExcalidrawElement["groupIds"];
|
||||
};
|
||||
}) => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
frameId: opts?.frameId || null,
|
||||
groupIds: opts?.groupIds,
|
||||
});
|
||||
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: opts?.label?.text || "sample-text",
|
||||
width: 50,
|
||||
height: 20,
|
||||
fontSize: 16,
|
||||
containerId: rectangle.id,
|
||||
frameId:
|
||||
opts?.label?.frameId === undefined
|
||||
? opts?.frameId ?? null
|
||||
: opts?.label?.frameId ?? null,
|
||||
groupIds: opts?.label?.groupIds === undefined
|
||||
? opts?.groupIds
|
||||
: opts?.label?.groupIds ,
|
||||
|
||||
});
|
||||
|
||||
mutateElement(
|
||||
rectangle,
|
||||
{
|
||||
boundElements: [{ type: "text", id: text.id }],
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
return [rectangle, text];
|
||||
};
|
||||
|
||||
static createLabeledArrow = (opts?: {
|
||||
frameId?: ExcalidrawElement["id"];
|
||||
label?: {
|
||||
text?: string;
|
||||
frameId?: ExcalidrawElement["id"] | null;
|
||||
};
|
||||
}) => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
frameId: opts?.frameId || null,
|
||||
});
|
||||
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
id: "text2",
|
||||
width: 50,
|
||||
height: 20,
|
||||
containerId: arrow.id,
|
||||
frameId:
|
||||
opts?.label?.frameId === undefined
|
||||
? opts?.frameId ?? null
|
||||
: opts?.label?.frameId ?? null,
|
||||
});
|
||||
|
||||
mutateElement(
|
||||
arrow,
|
||||
{
|
||||
boundElements: [{ type: "text", id: text.id }],
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
return [arrow, text];
|
||||
};
|
||||
|
||||
static readFile = async <T extends "utf8" | null>(
|
||||
filepath: string,
|
||||
encoding?: T,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
assertSelectedElements,
|
||||
render,
|
||||
togglePopover,
|
||||
getCloneByOrigId,
|
||||
} from "./test-utils";
|
||||
import { Excalidraw } from "../index";
|
||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||
@@ -15,7 +16,7 @@ import { getDefaultAppState } from "../appState";
|
||||
import { fireEvent, queryByTestId, waitFor } from "@testing-library/react";
|
||||
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
||||
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES, ORIG_ID } from "../constants";
|
||||
import type { AppState } from "../types";
|
||||
import { arrayToMap } from "../utils";
|
||||
import {
|
||||
@@ -1138,8 +1139,8 @@ describe("history", () => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
||||
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
||||
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: true }),
|
||||
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: true }),
|
||||
expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: true }),
|
||||
expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: true }),
|
||||
]);
|
||||
expect(h.state.editingGroupId).toBeNull();
|
||||
expect(h.state.selectedGroupIds).toEqual({ A: true });
|
||||
@@ -1151,8 +1152,8 @@ describe("history", () => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
||||
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
||||
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: false }),
|
||||
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: false }),
|
||||
expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: false }),
|
||||
expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: false }),
|
||||
]);
|
||||
expect(h.state.editingGroupId).toBeNull();
|
||||
expect(h.state.selectedGroupIds).not.toEqual(
|
||||
@@ -1171,14 +1172,14 @@ describe("history", () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
||||
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
||||
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: true }),
|
||||
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: true }),
|
||||
expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: true }),
|
||||
expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: true }),
|
||||
expect.objectContaining({
|
||||
id: `${rect1.id}_copy_copy`,
|
||||
[ORIG_ID]: getCloneByOrigId(rect1.id)?.id,
|
||||
isDeleted: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: `${rect2.id}_copy_copy`,
|
||||
[ORIG_ID]: getCloneByOrigId(rect2.id)?.id,
|
||||
isDeleted: false,
|
||||
}),
|
||||
]),
|
||||
@@ -2077,15 +2078,15 @@ describe("history", () => {
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.redo();
|
||||
Keyboard.undo();
|
||||
|
||||
const modifiedArrow = h.elements.filter(
|
||||
(el) => el.type === "arrow",
|
||||
)[0] as ExcalidrawElbowArrowElement;
|
||||
expect(modifiedArrow.points).toEqual([
|
||||
expect(modifiedArrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[451.9000000000001, 0],
|
||||
[451.9000000000001, 448.10100010002003],
|
||||
[178.9, 0],
|
||||
[178.9, 236.1],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
import { fireEvent, render, waitFor } from "./test-utils";
|
||||
import { fireEvent, getCloneByOrigId, render, waitFor } from "./test-utils";
|
||||
import { act, queryByTestId } from "@testing-library/react";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { API } from "./helpers/api";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { MIME_TYPES, ORIG_ID } from "../constants";
|
||||
import type { LibraryItem, LibraryItems } from "../types";
|
||||
import { UI } from "./helpers/ui";
|
||||
import { serializeLibraryAsJSON } from "../data/json";
|
||||
@@ -76,7 +76,7 @@ describe("library", () => {
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A_copy" })]);
|
||||
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,23 +125,27 @@ describe("library", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "rectangle1_copy",
|
||||
boundElements: expect.arrayContaining([
|
||||
{ type: "text", id: "text1_copy" },
|
||||
{ type: "arrow", id: "arrow1_copy" },
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "text1_copy",
|
||||
containerId: "rectangle1_copy",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "arrow1_copy",
|
||||
endBinding: expect.objectContaining({ elementId: "rectangle1_copy" }),
|
||||
}),
|
||||
]);
|
||||
expect(h.elements).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
[ORIG_ID]: "rectangle1",
|
||||
boundElements: expect.arrayContaining([
|
||||
{ type: "text", id: getCloneByOrigId("text1").id },
|
||||
{ type: "arrow", id: getCloneByOrigId("arrow1").id },
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
[ORIG_ID]: "text1",
|
||||
containerId: getCloneByOrigId("rectangle1").id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
[ORIG_ID]: "arrow1",
|
||||
endBinding: expect.objectContaining({
|
||||
elementId: getCloneByOrigId("rectangle1").id,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -170,10 +174,11 @@ describe("library", () => {
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "elem1_copy",
|
||||
[ORIG_ID]: "elem1",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: expect.not.stringMatching(/^(elem1_copy|elem1)$/),
|
||||
id: expect.not.stringMatching(/^elem1$/),
|
||||
[ORIG_ID]: expect.not.stringMatching(/^\w+$/),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -189,7 +194,7 @@ describe("library", () => {
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A_copy" })]);
|
||||
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
|
||||
});
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FontString,
|
||||
SceneElementsMap,
|
||||
} from "../element/types";
|
||||
import { Excalidraw, mutateElement } from "../index";
|
||||
import { reseed } from "../random";
|
||||
@@ -1353,23 +1352,19 @@ describe("Test Linear Elements", () => {
|
||||
const [origStartX, origStartY] = [line.x, line.y];
|
||||
|
||||
act(() => {
|
||||
LinearElementEditor.movePoints(
|
||||
line,
|
||||
[
|
||||
{
|
||||
index: 0,
|
||||
point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10),
|
||||
},
|
||||
{
|
||||
index: line.points.length - 1,
|
||||
point: pointFrom(
|
||||
line.points[line.points.length - 1][0] - 10,
|
||||
line.points[line.points.length - 1][1] - 10,
|
||||
),
|
||||
},
|
||||
],
|
||||
new Map() as SceneElementsMap,
|
||||
);
|
||||
LinearElementEditor.movePoints(line, [
|
||||
{
|
||||
index: 0,
|
||||
point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10),
|
||||
},
|
||||
{
|
||||
index: line.points.length - 1,
|
||||
point: pointFrom(
|
||||
line.points[line.points.length - 1][0] - 10,
|
||||
line.points[line.points.length - 1][1] - 10,
|
||||
),
|
||||
},
|
||||
]);
|
||||
});
|
||||
expect(line.x).toBe(origStartX + 10);
|
||||
expect(line.y).toBe(origStartY + 10);
|
||||
|
||||
@@ -535,7 +535,7 @@ describe("arrow element", () => {
|
||||
|
||||
UI.resize([rectangle, arrow], "nw", [300, 350]);
|
||||
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144, 2);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,11 @@ import { STORAGE_KEYS } from "../../../excalidraw-app/app_constants";
|
||||
import { getSelectedElements } from "../scene/selection";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { UI } from "./helpers/ui";
|
||||
import { diffStringsUnified } from "jest-diff";
|
||||
import ansi from "ansicolor";
|
||||
import { ORIG_ID } from "../constants";
|
||||
import { arrayToMap } from "../utils";
|
||||
import type { AllPossibleKeys } from "../utility-types";
|
||||
|
||||
const customQueries = {
|
||||
...queries,
|
||||
@@ -246,6 +251,36 @@ expect.extend({
|
||||
pass: false,
|
||||
};
|
||||
},
|
||||
|
||||
toCloselyEqualPoints(received, expected, precision) {
|
||||
if (!Array.isArray(received) || !Array.isArray(expected)) {
|
||||
throw new Error("expected and received are not point arrays");
|
||||
}
|
||||
|
||||
const COMPARE = 1 / Math.pow(10, precision || 2);
|
||||
const pass = received.every(
|
||||
(point, idx) =>
|
||||
Math.abs(expected[idx]?.[0] - point[0]) < COMPARE &&
|
||||
Math.abs(expected[idx]?.[1] - point[1]) < COMPARE,
|
||||
);
|
||||
|
||||
if (!pass) {
|
||||
return {
|
||||
message: () => ` The provided array of points are not close enough.
|
||||
|
||||
${diffStringsUnified(
|
||||
JSON.stringify(expected, undefined, 2),
|
||||
JSON.stringify(received, undefined, 2),
|
||||
)}`,
|
||||
pass: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: () => `expected ${received} to not be close to ${expected}`,
|
||||
pass: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -264,3 +299,150 @@ expect.addSnapshotSerializer({
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const getCloneByOrigId = <T extends boolean = false>(
|
||||
origId: ExcalidrawElement["id"],
|
||||
returnNullIfNotExists: T = false as T,
|
||||
): T extends true ? ExcalidrawElement | null : ExcalidrawElement => {
|
||||
const clonedElement = window.h.elements?.find(
|
||||
(el) => (el as any)[ORIG_ID] === origId,
|
||||
);
|
||||
if (clonedElement) {
|
||||
return clonedElement;
|
||||
}
|
||||
if (returnNullIfNotExists !== true) {
|
||||
throw new Error(`cloned element not found for origId: ${origId}`);
|
||||
}
|
||||
return null as T extends true ? ExcalidrawElement | null : ExcalidrawElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assertion helper that strips the actual elements of extra attributes
|
||||
* so that diffs are easier to read in case of failure.
|
||||
*
|
||||
* Asserts element order as well, and selected element ids
|
||||
* (when `selected: true` set for given element).
|
||||
*
|
||||
* If testing cloned elements, you can use { `[ORIG_ID]: origElement.id }
|
||||
* If you need to refer to cloned element properties, you can use
|
||||
* `getCloneByOrigId()`, e.g.: `{ frameId: getCloneByOrigId(origFrame.id)?.id }`
|
||||
*/
|
||||
export const assertElements = <T extends AllPossibleKeys<ExcalidrawElement>>(
|
||||
actualElements: readonly ExcalidrawElement[],
|
||||
/** array order matters */
|
||||
expectedElements: (Partial<Record<T, any>> & {
|
||||
/** meta, will be stripped for element attribute checks */
|
||||
selected?: true;
|
||||
} & (
|
||||
| {
|
||||
id: ExcalidrawElement["id"];
|
||||
}
|
||||
| { [ORIG_ID]?: string }
|
||||
))[],
|
||||
) => {
|
||||
const h = window.h;
|
||||
|
||||
const expectedElementsWithIds: (typeof expectedElements[number] & {
|
||||
id: ExcalidrawElement["id"];
|
||||
})[] = expectedElements.map((el) => {
|
||||
if ("id" in el) {
|
||||
return el;
|
||||
}
|
||||
const actualElement = actualElements.find(
|
||||
(act) => (act as any)[ORIG_ID] === el[ORIG_ID],
|
||||
);
|
||||
if (actualElement) {
|
||||
return { ...el, id: actualElement.id };
|
||||
}
|
||||
return {
|
||||
...el,
|
||||
id: "UNKNOWN_ID",
|
||||
};
|
||||
});
|
||||
|
||||
const map_expectedElements = arrayToMap(expectedElementsWithIds);
|
||||
|
||||
const selectedElementIds = expectedElementsWithIds.reduce(
|
||||
(acc: Record<ExcalidrawElement["id"], true>, el) => {
|
||||
if (el.selected) {
|
||||
acc[el.id] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const mappedActualElements = actualElements.map((el) => {
|
||||
const expectedElement = map_expectedElements.get(el.id);
|
||||
if (expectedElement) {
|
||||
const pickedAttrs: Record<string, any> = {};
|
||||
|
||||
for (const key of Object.keys(expectedElement)) {
|
||||
if (key === "selected") {
|
||||
delete expectedElement.selected;
|
||||
continue;
|
||||
}
|
||||
pickedAttrs[key] = (el as any)[key];
|
||||
}
|
||||
|
||||
if (ORIG_ID in expectedElement) {
|
||||
// @ts-ignore
|
||||
pickedAttrs[ORIG_ID] = (el as any)[ORIG_ID];
|
||||
}
|
||||
|
||||
return pickedAttrs;
|
||||
}
|
||||
return el;
|
||||
});
|
||||
|
||||
try {
|
||||
// testing order separately for even easier diffs
|
||||
expect(actualElements.map((x) => x.id)).toEqual(
|
||||
expectedElementsWithIds.map((x) => x.id),
|
||||
);
|
||||
} catch (err: any) {
|
||||
let errStr = "\n\nmismatched element order\n\n";
|
||||
|
||||
errStr += `actual: ${ansi.lightGray(
|
||||
`[${err.actual
|
||||
.map((id: string, index: number) => {
|
||||
const act = actualElements[index];
|
||||
|
||||
return `${
|
||||
id === err.expected[index] ? ansi.green(id) : ansi.red(id)
|
||||
} (${act.type.slice(0, 4)}${
|
||||
ORIG_ID in act ? ` ↳ ${(act as any)[ORIG_ID]}` : ""
|
||||
})`;
|
||||
})
|
||||
.join(", ")}]`,
|
||||
)}\n${ansi.lightGray(
|
||||
`expected: [${err.expected
|
||||
.map((exp: string, index: number) => {
|
||||
const expEl = actualElements.find((el) => el.id === exp);
|
||||
const origEl =
|
||||
expEl &&
|
||||
actualElements.find((el) => el.id === (expEl as any)[ORIG_ID]);
|
||||
return expEl
|
||||
? `${
|
||||
exp === err.actual[index]
|
||||
? ansi.green(expEl.id)
|
||||
: ansi.red(expEl.id)
|
||||
} (${expEl.type.slice(0, 4)}${origEl ? ` ↳ ${origEl.id}` : ""})`
|
||||
: exp;
|
||||
})
|
||||
.join(", ")}]\n`,
|
||||
)}`;
|
||||
|
||||
const error = new Error(errStr);
|
||||
const stack = err.stack.split("\n");
|
||||
stack.splice(1, 1);
|
||||
error.stack = stack.join("\n");
|
||||
throw error;
|
||||
}
|
||||
|
||||
expect(mappedActualElements).toEqual(
|
||||
expect.arrayContaining(expectedElementsWithIds),
|
||||
);
|
||||
|
||||
expect(h.state.selectedElementIds).toEqual(selectedElementIds);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isTransparent, sanitizeHTMLAttribute } from "../utils";
|
||||
import { isTransparent } from "../utils";
|
||||
|
||||
describe("Test isTransparent", () => {
|
||||
it("should return true when color is rgb transparent", () => {
|
||||
@@ -11,9 +11,3 @@ describe("Test isTransparent", () => {
|
||||
expect(isTransparent("#ced4da")).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeHTMLAttribute()", () => {
|
||||
it("should escape HTML attribute special characters & not double escape", () => {
|
||||
expect(sanitizeHTMLAttribute(`&"'><`)).toBe("&"'><");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { act, render } from "./test-utils";
|
||||
import { act, getCloneByOrigId, render } from "./test-utils";
|
||||
import { Excalidraw } from "../index";
|
||||
import { reseed } from "../random";
|
||||
import {
|
||||
@@ -916,9 +916,9 @@ describe("z-index manipulation", () => {
|
||||
API.executeAction(actionDuplicateSelection);
|
||||
expect(h.elements).toMatchObject([
|
||||
{ id: "A" },
|
||||
{ id: "A_copy" },
|
||||
{ id: getCloneByOrigId("A").id },
|
||||
{ id: "B" },
|
||||
{ id: "B_copy" },
|
||||
{ id: getCloneByOrigId("B").id },
|
||||
]);
|
||||
|
||||
populateElements([
|
||||
@@ -930,12 +930,12 @@ describe("z-index manipulation", () => {
|
||||
{ id: "A" },
|
||||
{ id: "B" },
|
||||
{
|
||||
id: "A_copy",
|
||||
id: getCloneByOrigId("A").id,
|
||||
|
||||
groupIds: [expect.stringMatching(/.{3,}/)],
|
||||
},
|
||||
{
|
||||
id: "B_copy",
|
||||
id: getCloneByOrigId("B").id,
|
||||
|
||||
groupIds: [expect.stringMatching(/.{3,}/)],
|
||||
},
|
||||
@@ -951,12 +951,12 @@ describe("z-index manipulation", () => {
|
||||
{ id: "A" },
|
||||
{ id: "B" },
|
||||
{
|
||||
id: "A_copy",
|
||||
id: getCloneByOrigId("A").id,
|
||||
|
||||
groupIds: [expect.stringMatching(/.{3,}/)],
|
||||
},
|
||||
{
|
||||
id: "B_copy",
|
||||
id: getCloneByOrigId("B").id,
|
||||
|
||||
groupIds: [expect.stringMatching(/.{3,}/)],
|
||||
},
|
||||
@@ -972,10 +972,10 @@ describe("z-index manipulation", () => {
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
"A_copy",
|
||||
"B_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
getCloneByOrigId("B").id,
|
||||
"C",
|
||||
"C_copy",
|
||||
getCloneByOrigId("C").id,
|
||||
]);
|
||||
|
||||
populateElements([
|
||||
@@ -988,12 +988,12 @@ describe("z-index manipulation", () => {
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
"A_copy",
|
||||
"B_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
getCloneByOrigId("B").id,
|
||||
"C",
|
||||
"D",
|
||||
"C_copy",
|
||||
"D_copy",
|
||||
getCloneByOrigId("C").id,
|
||||
getCloneByOrigId("D").id,
|
||||
]);
|
||||
|
||||
populateElements(
|
||||
@@ -1010,10 +1010,10 @@ describe("z-index manipulation", () => {
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
"A_copy",
|
||||
"B_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
getCloneByOrigId("B").id,
|
||||
"C",
|
||||
"C_copy",
|
||||
getCloneByOrigId("C").id,
|
||||
]);
|
||||
|
||||
populateElements(
|
||||
@@ -1031,9 +1031,9 @@ describe("z-index manipulation", () => {
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"A_copy",
|
||||
"B_copy",
|
||||
"C_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
getCloneByOrigId("B").id,
|
||||
getCloneByOrigId("C").id,
|
||||
]);
|
||||
|
||||
populateElements(
|
||||
@@ -1054,15 +1054,15 @@ describe("z-index manipulation", () => {
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"A_copy",
|
||||
"B_copy",
|
||||
"C_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
getCloneByOrigId("B").id,
|
||||
getCloneByOrigId("C").id,
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"D_copy",
|
||||
"E_copy",
|
||||
"F_copy",
|
||||
getCloneByOrigId("D").id,
|
||||
getCloneByOrigId("E").id,
|
||||
getCloneByOrigId("F").id,
|
||||
]);
|
||||
|
||||
populateElements(
|
||||
@@ -1076,7 +1076,7 @@ describe("z-index manipulation", () => {
|
||||
API.executeAction(actionDuplicateSelection);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"A_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
"B",
|
||||
"C",
|
||||
]);
|
||||
@@ -1093,7 +1093,7 @@ describe("z-index manipulation", () => {
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
"B_copy",
|
||||
getCloneByOrigId("B").id,
|
||||
"C",
|
||||
]);
|
||||
|
||||
@@ -1108,9 +1108,9 @@ describe("z-index manipulation", () => {
|
||||
API.executeAction(actionDuplicateSelection);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"A_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
"B",
|
||||
"B_copy",
|
||||
getCloneByOrigId("B").id,
|
||||
"C",
|
||||
]);
|
||||
});
|
||||
@@ -1125,8 +1125,8 @@ describe("z-index manipulation", () => {
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"C",
|
||||
"A_copy",
|
||||
"C_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
getCloneByOrigId("C").id,
|
||||
"B",
|
||||
]);
|
||||
});
|
||||
@@ -1144,9 +1144,9 @@ describe("z-index manipulation", () => {
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"A_copy",
|
||||
"B_copy",
|
||||
"C_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
getCloneByOrigId("B").id,
|
||||
getCloneByOrigId("C").id,
|
||||
"D",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -512,6 +512,22 @@ export interface ExcalidrawProps {
|
||||
data: ClipboardData,
|
||||
event: ClipboardEvent | null,
|
||||
) => Promise<boolean> | boolean;
|
||||
/**
|
||||
* Called when element(s) are duplicated so you can listen or modify as
|
||||
* needed.
|
||||
*
|
||||
* Called when duplicating via mouse-drag, keyboard, paste, library insert
|
||||
* etc.
|
||||
*
|
||||
* Returned elements will be used in place of the next elements
|
||||
* (you should return all elements, including deleted, and not mutate
|
||||
* the element if changes are made)
|
||||
*/
|
||||
onDuplicate?: (
|
||||
nextElements: readonly ExcalidrawElement[],
|
||||
/** excludes the duplicated elements */
|
||||
prevElements: readonly ExcalidrawElement[],
|
||||
) => ExcalidrawElement[] | void;
|
||||
renderTopRightUI?: (
|
||||
isMobile: boolean,
|
||||
appState: UIAppState,
|
||||
@@ -681,6 +697,8 @@ export type AppClassProperties = {
|
||||
getEditorUIOffsets: App["getEditorUIOffsets"];
|
||||
visibleElements: App["visibleElements"];
|
||||
excalidrawContainerValue: App["excalidrawContainerValue"];
|
||||
|
||||
onPointerUpEmitter: App["onPointerUpEmitter"];
|
||||
};
|
||||
|
||||
export type PointerDownState = Readonly<{
|
||||
|
||||
@@ -65,3 +65,6 @@ export type MakeBrand<T extends string> = {
|
||||
|
||||
/** Maybe just promise or already fulfilled one! */
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
// get union of all keys from the union of types
|
||||
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
isDarwin,
|
||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||
} from "./constants";
|
||||
import type { FontFamilyValues, FontString } from "./element/types";
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
FontFamilyValues,
|
||||
FontString,
|
||||
} from "./element/types";
|
||||
import type {
|
||||
ActiveTool,
|
||||
AppState,
|
||||
@@ -543,6 +547,9 @@ export const isTransparent = (color: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) =>
|
||||
el.fillStyle !== "solid" || isTransparent(el.backgroundColor);
|
||||
|
||||
export type ResolvablePromise<T> = Promise<T> & {
|
||||
resolve: [T] extends [undefined]
|
||||
? (value?: MaybePromise<Awaited<T>>) => void
|
||||
@@ -1226,15 +1233,13 @@ export class PromisePool<T> {
|
||||
}
|
||||
}
|
||||
|
||||
export const sanitizeHTMLAttribute = (html: string) => {
|
||||
return (
|
||||
html
|
||||
// note, if we're not doing stupid things, escaping " is enough,
|
||||
// but we might end up doing stupid things
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/</g, "<")
|
||||
);
|
||||
/**
|
||||
* use when you need to render unsafe string as HTML attribute, but MAKE SURE
|
||||
* the attribute is double-quoted when constructing the HTML string
|
||||
*/
|
||||
export const escapeDoubleQuotes = (str: string) => {
|
||||
return str.replace(/"/g, """);
|
||||
};
|
||||
|
||||
export const castArray = <T>(value: T | T[]): T[] =>
|
||||
Array.isArray(value) ? value : [value];
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
lineSegment,
|
||||
pointFrom,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
} from "../math";
|
||||
import type { LineSegment } from "../utils";
|
||||
import type { BoundingBox, Bounds } from "./element/bounds";
|
||||
@@ -15,6 +16,8 @@ declare global {
|
||||
data: DebugElement[][];
|
||||
currentFrame?: number;
|
||||
};
|
||||
debugDrawPoint: typeof debugDrawPoint;
|
||||
debugDrawLine: typeof debugDrawLine;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +150,23 @@ export const debugDrawBounds = (
|
||||
);
|
||||
};
|
||||
|
||||
export const debugDrawPoints = (
|
||||
{
|
||||
x,
|
||||
y,
|
||||
points,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
points: LocalPoint[];
|
||||
},
|
||||
options?: any,
|
||||
) => {
|
||||
points.forEach((p) =>
|
||||
debugDrawPoint(pointFrom<GlobalPoint>(x + p[0], y + p[1]), options),
|
||||
);
|
||||
};
|
||||
|
||||
export const debugCloseFrame = () => {
|
||||
window.visualDebug?.data.push([]);
|
||||
};
|
||||
|
||||
+37
-3
@@ -1,4 +1,4 @@
|
||||
import { pointCenter, pointRotateRads } from "./point";
|
||||
import { pointCenter, pointFrom, pointRotateRads } from "./point";
|
||||
import type { GlobalPoint, Line, LocalPoint, Radians } from "./types";
|
||||
|
||||
/**
|
||||
@@ -38,8 +38,16 @@ export function lineFromPointArray<P extends GlobalPoint | LocalPoint>(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// return the coordinates resulting from rotating the given line about an origin by an angle in degrees
|
||||
// note that when the origin is not given, the midpoint of the given line is used as the origin
|
||||
/**
|
||||
* Return the coordinates resulting from rotating the given line about an
|
||||
* origin by an angle in degrees note that when the origin is not given,
|
||||
* the midpoint of the given line is used as the origin
|
||||
*
|
||||
* @param l
|
||||
* @param angle
|
||||
* @param origin
|
||||
* @returns
|
||||
*/
|
||||
export const lineRotate = <Point extends LocalPoint | GlobalPoint>(
|
||||
l: Line<Point>,
|
||||
angle: Radians,
|
||||
@@ -50,3 +58,29 @@ export const lineRotate = <Point extends LocalPoint | GlobalPoint>(
|
||||
pointRotateRads(l[1], origin || pointCenter(l[0], l[1]), angle),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines the intersection point (unless the lines are parallel) of two
|
||||
* lines
|
||||
*
|
||||
* @param a
|
||||
* @param b
|
||||
* @returns
|
||||
*/
|
||||
export const linesIntersectAt = <Point extends GlobalPoint | LocalPoint>(
|
||||
a: Line<Point>,
|
||||
b: Line<Point>,
|
||||
): Point | null => {
|
||||
const A1 = a[1][1] - a[0][1];
|
||||
const B1 = a[0][0] - a[1][0];
|
||||
const A2 = b[1][1] - b[0][1];
|
||||
const B2 = b[0][0] - b[1][0];
|
||||
const D = A1 * B2 - A2 * B1;
|
||||
if (D !== 0) {
|
||||
const C1 = A1 * a[0][0] + B1 * a[0][1];
|
||||
const C2 = A2 * b[0][0] + B2 * b[0][1];
|
||||
return pointFrom<Point>((C1 * B2 - C2 * B1) / D, (A1 * C2 - A2 * C1) / D);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
+20
-1
@@ -61,6 +61,22 @@ export function pointFromVector<P extends GlobalPoint | LocalPoint>(
|
||||
return v as unknown as P;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the coordiante object to a point.
|
||||
*
|
||||
* @param coords The coordinate object with x and y properties
|
||||
* @returns
|
||||
*/
|
||||
export function pointFromCoords<Point extends GlobalPoint | LocalPoint>({
|
||||
x,
|
||||
y,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
}) {
|
||||
return [x, y] as Point;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the provided value has the shape of a Point.
|
||||
*
|
||||
@@ -217,7 +233,10 @@ export function pointDistanceSq<P extends LocalPoint | GlobalPoint>(
|
||||
a: P,
|
||||
b: P,
|
||||
): number {
|
||||
return Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
const xDiff = b[0] - a[0];
|
||||
const yDiff = b[1] - a[1];
|
||||
|
||||
return xDiff * xDiff + yDiff * yDiff;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./export";
|
||||
export * from "./withinBounds";
|
||||
export * from "./bbox";
|
||||
export { elementsOverlappingBBox } from "./withinBounds";
|
||||
export { getCommonBounds } from "../excalidraw/element/bounds";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@excalidraw/utils",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3-test27",
|
||||
"main": "./dist/prod/index.js",
|
||||
"type": "module",
|
||||
"module": "./dist/prod/index.js",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user