Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b5d62c8d6 | |||
| 4f74274d04 |
@@ -25,7 +25,6 @@ import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
|||||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||||
import { getFrame } from "../../packages/excalidraw/utils";
|
import { getFrame } from "../../packages/excalidraw/utils";
|
||||||
import { ExcalidrawLogo } from "../../packages/excalidraw/components/ExcalidrawLogo";
|
import { ExcalidrawLogo } from "../../packages/excalidraw/components/ExcalidrawLogo";
|
||||||
import { uploadBytes, ref } from "firebase/storage";
|
|
||||||
|
|
||||||
export const exportToExcalidrawPlus = async (
|
export const exportToExcalidrawPlus = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
@@ -33,7 +32,7 @@ export const exportToExcalidrawPlus = async (
|
|||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
name: string,
|
name: string,
|
||||||
) => {
|
) => {
|
||||||
const storage = await loadFirebaseStorage();
|
const firebase = await loadFirebaseStorage();
|
||||||
|
|
||||||
const id = `${nanoid(12)}`;
|
const id = `${nanoid(12)}`;
|
||||||
|
|
||||||
@@ -50,13 +49,15 @@ export const exportToExcalidrawPlus = async (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const storageRef = ref(storage, `/migrations/scenes/${id}`);
|
await firebase
|
||||||
await uploadBytes(storageRef, blob, {
|
.storage()
|
||||||
customMetadata: {
|
.ref(`/migrations/scenes/${id}`)
|
||||||
data: JSON.stringify({ version: 2, name }),
|
.put(blob, {
|
||||||
created: Date.now().toString(),
|
customMetadata: {
|
||||||
},
|
data: JSON.stringify({ version: 2, name }),
|
||||||
});
|
created: Date.now().toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const filesMap = new Map<FileId, BinaryFileData>();
|
const filesMap = new Map<FileId, BinaryFileData>();
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
|
|||||||
@@ -22,17 +22,9 @@ import {
|
|||||||
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
||||||
import type { SyncableExcalidrawElement } from ".";
|
import type { SyncableExcalidrawElement } from ".";
|
||||||
import { getSyncableElements } from ".";
|
import { getSyncableElements } from ".";
|
||||||
|
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
|
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
|
||||||
import { initializeApp } from "firebase/app";
|
|
||||||
import {
|
|
||||||
getFirestore,
|
|
||||||
doc,
|
|
||||||
getDoc,
|
|
||||||
runTransaction,
|
|
||||||
Bytes,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import { getStorage, ref, uploadBytes } from "firebase/storage";
|
|
||||||
|
|
||||||
// private
|
// private
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -49,43 +41,81 @@ try {
|
|||||||
FIREBASE_CONFIG = {};
|
FIREBASE_CONFIG = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
let firebaseApp: ReturnType<typeof initializeApp> | null = null;
|
let firebasePromise: Promise<typeof import("firebase/app").default> | null =
|
||||||
let firestore: ReturnType<typeof getFirestore> | null = null;
|
null;
|
||||||
let firebaseStorage: ReturnType<typeof getStorage> | null = null;
|
let firestorePromise: Promise<any> | null | true = null;
|
||||||
|
let firebaseStoragePromise: Promise<any> | null | true = null;
|
||||||
|
|
||||||
const _initializeFirebase = () => {
|
let isFirebaseInitialized = false;
|
||||||
if (!firebaseApp) {
|
|
||||||
firebaseApp = initializeApp(FIREBASE_CONFIG);
|
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;
|
||||||
}
|
}
|
||||||
return firebaseApp;
|
|
||||||
|
return firebase;
|
||||||
};
|
};
|
||||||
|
|
||||||
const _getFirestore = () => {
|
const _getFirebase = async (): Promise<
|
||||||
if (!firestore) {
|
typeof import("firebase/app").default
|
||||||
firestore = getFirestore(_initializeFirebase());
|
> => {
|
||||||
|
if (!firebasePromise) {
|
||||||
|
firebasePromise = _loadFirebase();
|
||||||
}
|
}
|
||||||
return firestore;
|
return firebasePromise;
|
||||||
};
|
|
||||||
|
|
||||||
const _getStorage = () => {
|
|
||||||
if (!firebaseStorage) {
|
|
||||||
firebaseStorage = getStorage(_initializeFirebase());
|
|
||||||
}
|
|
||||||
return firebaseStorage;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
export const loadFirebaseStorage = async () => {
|
const loadFirestore = async () => {
|
||||||
return _getStorage();
|
const firebase = await _getFirebase();
|
||||||
|
if (!firestorePromise) {
|
||||||
|
firestorePromise = import(
|
||||||
|
/* webpackChunkName: "firestore" */ "firebase/firestore"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (firestorePromise !== true) {
|
||||||
|
await firestorePromise;
|
||||||
|
firestorePromise = true;
|
||||||
|
}
|
||||||
|
return firebase;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FirebaseStoredScene = {
|
export const loadFirebaseStorage = async () => {
|
||||||
sceneVersion: number;
|
const firebase = await _getFirebase();
|
||||||
iv: Bytes;
|
if (!firebaseStoragePromise) {
|
||||||
ciphertext: Bytes;
|
firebaseStoragePromise = import(
|
||||||
|
/* webpackChunkName: "storage" */ "firebase/storage"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (firebaseStoragePromise !== true) {
|
||||||
|
await firebaseStoragePromise;
|
||||||
|
firebaseStoragePromise = true;
|
||||||
|
}
|
||||||
|
return firebase;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface FirebaseStoredScene {
|
||||||
|
sceneVersion: number;
|
||||||
|
iv: firebase.default.firestore.Blob;
|
||||||
|
ciphertext: firebase.default.firestore.Blob;
|
||||||
|
}
|
||||||
|
|
||||||
const encryptElements = async (
|
const encryptElements = async (
|
||||||
key: string,
|
key: string,
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -145,7 +175,7 @@ export const saveFilesToFirebase = async ({
|
|||||||
prefix: string;
|
prefix: string;
|
||||||
files: { id: FileId; buffer: Uint8Array }[];
|
files: { id: FileId; buffer: Uint8Array }[];
|
||||||
}) => {
|
}) => {
|
||||||
const storage = await loadFirebaseStorage();
|
const firebase = await loadFirebaseStorage();
|
||||||
|
|
||||||
const erroredFiles: FileId[] = [];
|
const erroredFiles: FileId[] = [];
|
||||||
const savedFiles: FileId[] = [];
|
const savedFiles: FileId[] = [];
|
||||||
@@ -153,10 +183,17 @@ export const saveFilesToFirebase = async ({
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
files.map(async ({ id, buffer }) => {
|
files.map(async ({ id, buffer }) => {
|
||||||
try {
|
try {
|
||||||
const storageRef = ref(storage, `${prefix}/${id}`);
|
await firebase
|
||||||
await uploadBytes(storageRef, buffer, {
|
.storage()
|
||||||
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
|
.ref(`${prefix}/${id}`)
|
||||||
});
|
.put(
|
||||||
|
new Blob([buffer], {
|
||||||
|
type: MIME_TYPES.binary,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
savedFiles.push(id);
|
savedFiles.push(id);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
erroredFiles.push(id);
|
erroredFiles.push(id);
|
||||||
@@ -168,6 +205,7 @@ export const saveFilesToFirebase = async ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createFirebaseSceneDocument = async (
|
const createFirebaseSceneDocument = async (
|
||||||
|
firebase: ResolutionType<typeof loadFirestore>,
|
||||||
elements: readonly SyncableExcalidrawElement[],
|
elements: readonly SyncableExcalidrawElement[],
|
||||||
roomKey: string,
|
roomKey: string,
|
||||||
) => {
|
) => {
|
||||||
@@ -175,8 +213,10 @@ const createFirebaseSceneDocument = async (
|
|||||||
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
||||||
return {
|
return {
|
||||||
sceneVersion,
|
sceneVersion,
|
||||||
ciphertext: Bytes.fromUint8Array(new Uint8Array(ciphertext)),
|
ciphertext: firebase.firestore.Blob.fromUint8Array(
|
||||||
iv: Bytes.fromUint8Array(iv),
|
new Uint8Array(ciphertext),
|
||||||
|
),
|
||||||
|
iv: firebase.firestore.Blob.fromUint8Array(iv),
|
||||||
} as FirebaseStoredScene;
|
} as FirebaseStoredScene;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -196,14 +236,20 @@ export const saveToFirebase = async (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firestore = _getFirestore();
|
const firebase = await loadFirestore();
|
||||||
const docRef = doc(firestore, "scenes", roomId);
|
const firestore = firebase.firestore();
|
||||||
|
|
||||||
const storedScene = await runTransaction(firestore, async (transaction) => {
|
const docRef = firestore.collection("scenes").doc(roomId);
|
||||||
|
|
||||||
|
const storedScene = await firestore.runTransaction(async (transaction) => {
|
||||||
const snapshot = await transaction.get(docRef);
|
const snapshot = await transaction.get(docRef);
|
||||||
|
|
||||||
if (!snapshot.exists()) {
|
if (!snapshot.exists) {
|
||||||
const storedScene = await createFirebaseSceneDocument(elements, roomKey);
|
const storedScene = await createFirebaseSceneDocument(
|
||||||
|
firebase,
|
||||||
|
elements,
|
||||||
|
roomKey,
|
||||||
|
);
|
||||||
|
|
||||||
transaction.set(docRef, storedScene);
|
transaction.set(docRef, storedScene);
|
||||||
|
|
||||||
@@ -223,6 +269,7 @@ export const saveToFirebase = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const storedScene = await createFirebaseSceneDocument(
|
const storedScene = await createFirebaseSceneDocument(
|
||||||
|
firebase,
|
||||||
reconciledElements,
|
reconciledElements,
|
||||||
roomKey,
|
roomKey,
|
||||||
);
|
);
|
||||||
@@ -247,13 +294,15 @@ export const loadFromFirebase = async (
|
|||||||
roomKey: string,
|
roomKey: string,
|
||||||
socket: Socket | null,
|
socket: Socket | null,
|
||||||
): Promise<readonly SyncableExcalidrawElement[] | null> => {
|
): Promise<readonly SyncableExcalidrawElement[] | null> => {
|
||||||
const firestore = _getFirestore();
|
const firebase = await loadFirestore();
|
||||||
const docRef = doc(firestore, "scenes", roomId);
|
const db = firebase.firestore();
|
||||||
const docSnap = await getDoc(docRef);
|
|
||||||
if (!docSnap.exists()) {
|
const docRef = db.collection("scenes").doc(roomId);
|
||||||
|
const doc = await docRef.get();
|
||||||
|
if (!doc.exists) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
const storedScene = doc.data() as FirebaseStoredScene;
|
||||||
const elements = getSyncableElements(
|
const elements = getSyncableElements(
|
||||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,9 +27,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@excalidraw/random-username": "1.0.0",
|
"@excalidraw/random-username": "1.0.0",
|
||||||
"@sentry/browser": "9.0.1",
|
"@sentry/browser": "6.2.5",
|
||||||
"callsites": "4.2.0",
|
"@sentry/integrations": "6.2.5",
|
||||||
"firebase": "11.3.1",
|
"firebase": "8.3.3",
|
||||||
"i18next-browser-languagedetector": "6.1.4",
|
"i18next-browser-languagedetector": "6.1.4",
|
||||||
"idb-keyval": "6.0.3",
|
"idb-keyval": "6.0.3",
|
||||||
"jotai": "2.11.0",
|
"jotai": "2.11.0",
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import * as Sentry from "@sentry/browser";
|
import * as Sentry from "@sentry/browser";
|
||||||
import callsites from "callsites";
|
import * as SentryIntegrations from "@sentry/integrations";
|
||||||
|
|
||||||
const SentryEnvHostnameMap: { [key: string]: string } = {
|
const SentryEnvHostnameMap: { [key: string]: string } = {
|
||||||
"excalidraw.com": "production",
|
"excalidraw.com": "production",
|
||||||
"staging.excalidraw.com": "staging",
|
|
||||||
"vercel.app": "staging",
|
"vercel.app": "staging",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,13 +23,9 @@ Sentry.init({
|
|||||||
release: import.meta.env.VITE_APP_GIT_SHA,
|
release: import.meta.env.VITE_APP_GIT_SHA,
|
||||||
ignoreErrors: [
|
ignoreErrors: [
|
||||||
"undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
|
"undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
|
||||||
"InvalidStateError: Failed to execute 'transaction' on 'IDBDatabase': The database connection is closing.", // Not much we can do about the IndexedDB closing error
|
|
||||||
/(Failed to fetch|(fetch|loading) dynamically imported module)/i, // This is happening when a service worker tries to load an old asset
|
|
||||||
/QuotaExceededError: (The quota has been exceeded|.*setItem.*Storage)/i, // localStorage quota exceeded
|
|
||||||
"Internal error opening backing store for indexedDB.open", // Private mode and disabled indexedDB
|
|
||||||
],
|
],
|
||||||
integrations: [
|
integrations: [
|
||||||
Sentry.captureConsoleIntegration({
|
new SentryIntegrations.CaptureConsole({
|
||||||
levels: ["error"],
|
levels: ["error"],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -38,44 +33,6 @@ Sentry.init({
|
|||||||
if (event.request?.url) {
|
if (event.request?.url) {
|
||||||
event.request.url = event.request.url.replace(/#.*$/, "");
|
event.request.url = event.request.url.replace(/#.*$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!event.exception) {
|
|
||||||
event.exception = {
|
|
||||||
values: [
|
|
||||||
{
|
|
||||||
type: "ConsoleError",
|
|
||||||
value: event.message ?? "Unknown error",
|
|
||||||
stacktrace: {
|
|
||||||
frames: callsites()
|
|
||||||
.slice(1)
|
|
||||||
.filter(
|
|
||||||
(frame) =>
|
|
||||||
frame.getFileName() &&
|
|
||||||
!frame.getFileName()?.includes("@sentry_browser.js"),
|
|
||||||
)
|
|
||||||
.map((frame) => ({
|
|
||||||
filename: frame.getFileName() ?? undefined,
|
|
||||||
function: frame.getFunctionName() ?? undefined,
|
|
||||||
in_app: !(
|
|
||||||
frame.getFileName()?.includes("node_modules") ?? false
|
|
||||||
),
|
|
||||||
lineno: frame.getLineNumber() ?? undefined,
|
|
||||||
colno: frame.getColumnNumber() ?? undefined,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
mechanism: {
|
|
||||||
type: "instrument",
|
|
||||||
handled: true,
|
|
||||||
data: {
|
|
||||||
function: "console.error",
|
|
||||||
handler: "Sentry.beforeSend",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return event;
|
return event;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
computeBoundTextPosition,
|
computeBoundTextPosition,
|
||||||
computeContainerDimensionForBoundText,
|
computeContainerDimensionForBoundText,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
|
measureText,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +35,6 @@ import { arrayToMap, getFontString } from "../utils";
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { syncMovedIndices } from "../fractionalIndex";
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import { measureText } from "../element/textMeasurements";
|
|
||||||
|
|
||||||
export const actionUnbindText = register({
|
export const actionUnbindText = register({
|
||||||
name: "unbindText",
|
name: "unbindText",
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
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,8 +18,6 @@ import {
|
|||||||
import { updateActiveTool } from "../utils";
|
import { updateActiveTool } from "../utils";
|
||||||
import { TrashIcon } from "../components/icons";
|
import { TrashIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import { getContainerElement } from "../element/textElement";
|
|
||||||
import { getFrameChildren } from "../frame";
|
|
||||||
|
|
||||||
const deleteSelectedElements = (
|
const deleteSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -35,50 +33,10 @@ const deleteSelectedElements = (
|
|||||||
|
|
||||||
const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
|
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;
|
let shouldSelectEditingGroup = true;
|
||||||
|
|
||||||
const nextElements = elements.map((el) => {
|
const nextElements = elements.map((el) => {
|
||||||
if (appState.selectedElementIds[el.id]) {
|
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) {
|
if (el.boundElements) {
|
||||||
el.boundElements.forEach((candidate) => {
|
el.boundElements.forEach((candidate) => {
|
||||||
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
|
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
|
||||||
@@ -101,9 +59,7 @@ const deleteSelectedElements = (
|
|||||||
// if deleting a frame, remove the children from it and select them
|
// if deleting a frame, remove the children from it and select them
|
||||||
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
||||||
shouldSelectEditingGroup = false;
|
shouldSelectEditingGroup = false;
|
||||||
if (!isBoundToContainer(el)) {
|
selectedElementIds[el.id] = true;
|
||||||
selectedElementIds[el.id] = true;
|
|
||||||
}
|
|
||||||
return newElementWith(el, { frameId: null });
|
return newElementWith(el, { frameId: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,13 +224,11 @@ export const actionDeleteSelected = register({
|
|||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let { elements: nextElements, appState: nextAppState } =
|
let { elements: nextElements, appState: nextAppState } =
|
||||||
deleteSelectedElements(elements, appState, app);
|
deleteSelectedElements(elements, appState, app);
|
||||||
|
|
||||||
fixBindingsAfterDeletion(
|
fixBindingsAfterDeletion(
|
||||||
nextElements,
|
nextElements,
|
||||||
nextElements.filter((el) => el.isDeleted),
|
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||||
);
|
);
|
||||||
|
|
||||||
nextAppState = handleGroupEditingState(nextAppState, nextElements);
|
nextAppState = handleGroupEditingState(nextAppState, nextElements);
|
||||||
|
|||||||
@@ -1,530 +0,0 @@
|
|||||||
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,13 +5,7 @@ import { duplicateElement, getNonDeletedElements } from "../element";
|
|||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import {
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
arrayToMap,
|
|
||||||
castArray,
|
|
||||||
findLastIndex,
|
|
||||||
getShortcutKey,
|
|
||||||
invariant,
|
|
||||||
} from "../utils";
|
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import {
|
import {
|
||||||
selectGroupsForSelectedElements,
|
selectGroupsForSelectedElements,
|
||||||
@@ -25,13 +19,8 @@ import { DEFAULT_GRID_SIZE } from "../constants";
|
|||||||
import {
|
import {
|
||||||
bindTextToShapeAfterDuplication,
|
bindTextToShapeAfterDuplication,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getContainerElement,
|
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import {
|
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||||
hasBoundTextElement,
|
|
||||||
isBoundToContainer,
|
|
||||||
isFrameLikeElement,
|
|
||||||
} from "../element/typeChecks";
|
|
||||||
import { normalizeElementOrder } from "../element/sortElements";
|
import { normalizeElementOrder } from "../element/sortElements";
|
||||||
import { DuplicateIcon } from "../components/icons";
|
import { DuplicateIcon } from "../components/icons";
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +31,7 @@ import {
|
|||||||
excludeElementsInFramesFromSelection,
|
excludeElementsInFramesFromSelection,
|
||||||
getSelectedElements,
|
getSelectedElements,
|
||||||
} from "../scene/selection";
|
} from "../scene/selection";
|
||||||
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
@@ -69,20 +59,8 @@ export const actionDuplicateSelection = register({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextState = duplicateElements(elements, appState);
|
|
||||||
|
|
||||||
if (app.props.onDuplicate && nextState.elements) {
|
|
||||||
const mappedElements = app.props.onDuplicate(
|
|
||||||
nextState.elements,
|
|
||||||
elements,
|
|
||||||
);
|
|
||||||
if (mappedElements) {
|
|
||||||
nextState.elements = mappedElements;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...nextState,
|
...duplicateElements(elements, appState),
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -104,69 +82,37 @@ export const actionDuplicateSelection = register({
|
|||||||
const duplicateElements = (
|
const duplicateElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
): Partial<Exclude<ActionResult, false>> => {
|
): Partial<ActionResult> => {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// step (1)
|
||||||
|
|
||||||
|
const sortedElements = normalizeElementOrder(elements);
|
||||||
const groupIdMap = new Map();
|
const groupIdMap = new Map();
|
||||||
const newElements: ExcalidrawElement[] = [];
|
const newElements: ExcalidrawElement[] = [];
|
||||||
const oldElements: ExcalidrawElement[] = [];
|
const oldElements: ExcalidrawElement[] = [];
|
||||||
const oldIdToDuplicatedId = new Map();
|
const oldIdToDuplicatedId = new Map();
|
||||||
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
||||||
|
|
||||||
const elementsMap = arrayToMap(elements);
|
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
||||||
|
const newElement = duplicateElement(
|
||||||
const duplicateAndOffsetElement = <
|
appState.editingGroupId,
|
||||||
T extends ExcalidrawElement | ExcalidrawElement[],
|
groupIdMap,
|
||||||
>(
|
element,
|
||||||
element: T,
|
{
|
||||||
): T extends ExcalidrawElement[]
|
x: element.x + DEFAULT_GRID_SIZE / 2,
|
||||||
? ExcalidrawElement[]
|
y: element.y + DEFAULT_GRID_SIZE / 2,
|
||||||
: 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);
|
||||||
return (
|
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||||
Array.isArray(element) ? _newElements : _newElements[0] || null
|
oldElements.push(element);
|
||||||
) as T extends ExcalidrawElement[]
|
newElements.push(newElement);
|
||||||
? ExcalidrawElement[]
|
return newElement;
|
||||||
: ExcalidrawElement | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
elements = normalizeElementOrder(elements);
|
|
||||||
|
|
||||||
const idsOfElementsToDuplicate = arrayToMap(
|
const idsOfElementsToDuplicate = arrayToMap(
|
||||||
getSelectedElements(elements, appState, {
|
getSelectedElements(sortedElements, appState, {
|
||||||
includeBoundTextElement: true,
|
includeBoundTextElement: true,
|
||||||
includeElementsInFrames: true,
|
includeElementsInFrames: true,
|
||||||
}),
|
}),
|
||||||
@@ -184,134 +130,123 @@ const duplicateElements = (
|
|||||||
// loop over them.
|
// loop over them.
|
||||||
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
||||||
|
|
||||||
const elementsWithClones: ExcalidrawElement[] = elements.slice();
|
const markAsProcessed = (elements: ExcalidrawElement[]) => {
|
||||||
|
for (const element of elements) {
|
||||||
const insertAfterIndex = (
|
processedIds.set(element.id, true);
|
||||||
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 frameIdsToDuplicate = new Set(
|
const elementsWithClones: ExcalidrawElement[] = [];
|
||||||
elements
|
|
||||||
.filter(
|
|
||||||
(el) => idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el),
|
|
||||||
)
|
|
||||||
.map((el) => el.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const element of elements) {
|
let index = -1;
|
||||||
if (processedIds.has(element.id)) {
|
|
||||||
|
while (++index < sortedElements.length) {
|
||||||
|
const element = sortedElements[index];
|
||||||
|
|
||||||
|
if (processedIds.get(element.id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!idsOfElementsToDuplicate.has(element.id)) {
|
const boundTextElement = getBoundTextElement(element, arrayToMap(elements));
|
||||||
continue;
|
const isElementAFrameLike = isFrameLikeElement(element);
|
||||||
}
|
|
||||||
|
|
||||||
// groups
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
const groupId = getSelectedGroupForElement(appState, element);
|
elementsWithClones.push(
|
||||||
if (groupId) {
|
...markAsProcessed([
|
||||||
const groupElements = getElementsInGroup(elements, groupId).flatMap(
|
...groupElements,
|
||||||
(element) =>
|
...groupElements.map((element) =>
|
||||||
isFrameLikeElement(element)
|
duplicateAndOffsetElement(element),
|
||||||
? [...getFrameChildren(elements, element.id), element]
|
),
|
||||||
: [element],
|
]),
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (boundTextElement) {
|
||||||
|
elementsWithClones.push(
|
||||||
|
...markAsProcessed([
|
||||||
|
element,
|
||||||
|
boundTextElement,
|
||||||
|
duplicateAndOffsetElement(element),
|
||||||
|
duplicateAndOffsetElement(boundTextElement),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isElementAFrameLike) {
|
||||||
|
const elementsInFrame = getFrameChildren(sortedElements, element.id);
|
||||||
|
|
||||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
elementsWithClones.push(
|
||||||
return el.groupIds?.includes(groupId);
|
...markAsProcessed([
|
||||||
});
|
...elementsInFrame,
|
||||||
|
element,
|
||||||
|
...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
|
||||||
|
duplicateAndOffsetElement(element),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
insertAfterIndex(targetIndex, duplicateAndOffsetElement(groupElements));
|
continue;
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
// since elements in frames have a lower z-index than the frame itself,
|
||||||
continue;
|
// 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
|
||||||
if (isBoundToContainer(element)) {
|
//
|
||||||
const container = getContainerElement(element, elementsMap);
|
// now, for elements do not belong any frames or elements whose frames
|
||||||
|
// are selected (or elements that are left out from the above
|
||||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
// steps for whatever reason) we (should at least) duplicate them here
|
||||||
return el.id === element.id || el.id === container?.id;
|
if (!element.frameId || !idsOfElementsToDuplicate.has(element.frameId)) {
|
||||||
});
|
elementsWithClones.push(
|
||||||
|
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
|
||||||
if (container) {
|
|
||||||
insertAfterIndex(
|
|
||||||
targetIndex,
|
|
||||||
duplicateAndOffsetElement([container, element]),
|
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
continue;
|
elementsWithClones.push(...markAsProcessed([element]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// default duplication (regular elements)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
insertAfterIndex(
|
|
||||||
findLastIndex(elementsWithClones, (el) => el.id === element.id),
|
|
||||||
duplicateAndOffsetElement(element),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// step (2)
|
||||||
|
|
||||||
|
// 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 finalElementsReversed: ExcalidrawElement[] = [];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// step (3)
|
||||||
|
const finalElements = syncMovedIndices(
|
||||||
|
finalElementsReversed.reverse(),
|
||||||
|
arrayToMap(newElements),
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
bindTextToShapeAfterDuplication(
|
bindTextToShapeAfterDuplication(
|
||||||
@@ -325,7 +260,7 @@ const duplicateElements = (
|
|||||||
oldIdToDuplicatedId,
|
oldIdToDuplicatedId,
|
||||||
);
|
);
|
||||||
bindElementsToFramesAfterDuplication(
|
bindElementsToFramesAfterDuplication(
|
||||||
elementsWithClones,
|
finalElements,
|
||||||
oldElements,
|
oldElements,
|
||||||
oldIdToDuplicatedId,
|
oldIdToDuplicatedId,
|
||||||
);
|
);
|
||||||
@@ -334,7 +269,7 @@ const duplicateElements = (
|
|||||||
excludeElementsInFramesFromSelection(newElements);
|
excludeElementsInFramesFromSelection(newElements);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: elementsWithClones,
|
elements: finalElements,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
...selectGroupsForSelectedElements(
|
...selectGroupsForSelectedElements(
|
||||||
@@ -350,7 +285,7 @@ const duplicateElements = (
|
|||||||
{},
|
{},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
getNonDeletedElements(elementsWithClones),
|
getNonDeletedElements(finalElements),
|
||||||
appState,
|
appState,
|
||||||
null,
|
null,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -121,7 +121,6 @@ import {
|
|||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import type { LocalPoint } from "../../math";
|
import type { LocalPoint } from "../../math";
|
||||||
import { pointFrom } from "../../math";
|
import { pointFrom } from "../../math";
|
||||||
import { Range } from "../components/Range";
|
|
||||||
|
|
||||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||||
|
|
||||||
@@ -631,12 +630,25 @@ export const actionChangeOpacity = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<Range
|
<label className="control-label">
|
||||||
updateData={updateData}
|
{t("labels.opacity")}
|
||||||
elements={elements}
|
<input
|
||||||
appState={appState}
|
type="range"
|
||||||
testId="opacity"
|
min="0"
|
||||||
/>
|
max="100"
|
||||||
|
step="10"
|
||||||
|
onChange={(event) => updateData(+event.target.value)}
|
||||||
|
value={
|
||||||
|
getFormValue(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
(element) => element.opacity,
|
||||||
|
true,
|
||||||
|
appState.currentItemOpacity,
|
||||||
|
) ?? undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1605,8 +1617,6 @@ export const actionChangeArrowType = register({
|
|||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
appState.zoom,
|
appState.zoom,
|
||||||
false,
|
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
const endHoveredElement =
|
const endHoveredElement =
|
||||||
!newElement.endBinding &&
|
!newElement.endBinding &&
|
||||||
@@ -1615,8 +1625,6 @@ export const actionChangeArrowType = register({
|
|||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
appState.zoom,
|
appState.zoom,
|
||||||
false,
|
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
const startElement = startHoveredElement
|
const startElement = startHoveredElement
|
||||||
? startHoveredElement
|
? startHoveredElement
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { isTextElement } from "../element";
|
import { isTextElement } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { measureText } from "../element/textMeasurements";
|
import { measureText } from "../element/textElement";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import type { AppClassProperties } from "../types";
|
import type { AppClassProperties } from "../types";
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface AnimatedTrailOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AnimatedTrail implements Trail {
|
export class AnimatedTrail implements Trail {
|
||||||
private currentTrail?: LaserPointer;
|
currentTrail?: LaserPointer;
|
||||||
private pastTrails: LaserPointer[] = [];
|
private pastTrails: LaserPointer[] = [];
|
||||||
|
|
||||||
private container?: SVGSVGElement;
|
private container?: SVGSVGElement;
|
||||||
@@ -28,7 +28,7 @@ export class AnimatedTrail implements Trail {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private animationFrameHandler: AnimationFrameHandler,
|
private animationFrameHandler: AnimationFrameHandler,
|
||||||
private app: App,
|
protected app: App,
|
||||||
private options: Partial<LaserPointerOptions> &
|
private options: Partial<LaserPointerOptions> &
|
||||||
Partial<AnimatedTrailOptions>,
|
Partial<AnimatedTrailOptions>,
|
||||||
) {
|
) {
|
||||||
@@ -98,6 +98,16 @@ export class AnimatedTrail implements Trail {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCurrentTrail() {
|
||||||
|
return this.currentTrail;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTrails() {
|
||||||
|
this.pastTrails = [];
|
||||||
|
this.currentTrail = undefined;
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
private update() {
|
private update() {
|
||||||
this.start();
|
this.start();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
isCropping: false,
|
isCropping: false,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
searchMatches: [],
|
searchMatches: [],
|
||||||
|
lassoSelectionEnabled: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -244,6 +245,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
isCropping: { browser: false, export: false, server: false },
|
isCropping: { browser: false, export: false, server: false },
|
||||||
croppingElementId: { browser: false, export: false, server: false },
|
croppingElementId: { browser: false, export: false, server: false },
|
||||||
searchMatches: { browser: false, export: false, server: false },
|
searchMatches: { browser: false, export: false, server: false },
|
||||||
|
lassoSelectionEnabled: { browser: true, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <
|
||||||
|
|||||||
@@ -331,10 +331,17 @@ import type { FileSystemHandle } from "../data/filesystem";
|
|||||||
import { fileOpen } from "../data/filesystem";
|
import { fileOpen } from "../data/filesystem";
|
||||||
import {
|
import {
|
||||||
bindTextToShapeAfterDuplication,
|
bindTextToShapeAfterDuplication,
|
||||||
|
getApproxMinLineHeight,
|
||||||
|
getApproxMinLineWidth,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getContainerCenter,
|
getContainerCenter,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
|
getLineHeightInPx,
|
||||||
|
getMinTextElementWidth,
|
||||||
|
isMeasureTextSupported,
|
||||||
isValidTextContainer,
|
isValidTextContainer,
|
||||||
|
measureText,
|
||||||
|
normalizeText,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import {
|
import {
|
||||||
showHyperlinkTooltip,
|
showHyperlinkTooltip,
|
||||||
@@ -458,15 +465,7 @@ import { cropElement } from "../element/cropElement";
|
|||||||
import { wrapText } from "../element/textWrapping";
|
import { wrapText } from "../element/textWrapping";
|
||||||
import { actionCopyElementLink } from "../actions/actionElementLink";
|
import { actionCopyElementLink } from "../actions/actionElementLink";
|
||||||
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
|
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
|
||||||
import {
|
import { LassoTrail } from "../lasso";
|
||||||
isMeasureTextSupported,
|
|
||||||
normalizeText,
|
|
||||||
measureText,
|
|
||||||
getLineHeightInPx,
|
|
||||||
getApproxMinLineWidth,
|
|
||||||
getApproxMinLineHeight,
|
|
||||||
getMinTextElementWidth,
|
|
||||||
} from "../element/textMeasurements";
|
|
||||||
|
|
||||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||||
@@ -603,7 +602,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
private elementsPendingErasure: ElementsPendingErasure = new Set();
|
private elementsPendingErasure: ElementsPendingErasure = new Set();
|
||||||
|
|
||||||
public flowChartCreator: FlowChartCreator = new FlowChartCreator();
|
public flowChartCreator: FlowChartCreator = new FlowChartCreator();
|
||||||
private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator(this);
|
private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator();
|
||||||
|
|
||||||
hitLinkElement?: NonDeletedExcalidrawElement;
|
hitLinkElement?: NonDeletedExcalidrawElement;
|
||||||
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
|
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
|
||||||
@@ -637,6 +636,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
: "rgba(255, 255, 255, 0.2)",
|
: "rgba(255, 255, 255, 0.2)",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
lassoTrail = new LassoTrail(this.animationFrameHandler, this);
|
||||||
|
|
||||||
onChangeEmitter = new Emitter<
|
onChangeEmitter = new Emitter<
|
||||||
[
|
[
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -1524,17 +1525,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
const allElementsMap = this.scene.getNonDeletedElementsMap();
|
const allElementsMap = this.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
const shouldBlockPointerEvents =
|
const shouldBlockPointerEvents =
|
||||||
// default back to `--ui-pointerEvents` flow if setPointerCapture
|
this.state.selectionElement ||
|
||||||
// not supported
|
this.state.newElement ||
|
||||||
"setPointerCapture" in HTMLElement.prototype
|
this.state.selectedElementsAreBeingDragged ||
|
||||||
? false
|
this.state.resizingElement ||
|
||||||
: this.state.selectionElement ||
|
(this.state.activeTool.type === "laser" &&
|
||||||
this.state.newElement ||
|
// technically we can just test on this once we make it more safe
|
||||||
this.state.selectedElementsAreBeingDragged ||
|
this.state.cursorButton === "down");
|
||||||
this.state.resizingElement ||
|
|
||||||
(this.state.activeTool.type === "laser" &&
|
|
||||||
// technically we can just test on this once we make it more safe
|
|
||||||
this.state.cursorButton === "down");
|
|
||||||
|
|
||||||
const firstSelectedElement = selectedElements[0];
|
const firstSelectedElement = selectedElements[0];
|
||||||
|
|
||||||
@@ -1613,7 +1610,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
<div className="excalidraw-contextMenuContainer" />
|
<div className="excalidraw-contextMenuContainer" />
|
||||||
<div className="excalidraw-eye-dropper-container" />
|
<div className="excalidraw-eye-dropper-container" />
|
||||||
<SVGLayer
|
<SVGLayer
|
||||||
trails={[this.laserTrails, this.eraserTrail]}
|
trails={[
|
||||||
|
this.laserTrails,
|
||||||
|
this.eraserTrail,
|
||||||
|
this.lassoTrail,
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
{selectedElements.length === 1 &&
|
{selectedElements.length === 1 &&
|
||||||
this.state.openDialog?.name !==
|
this.state.openDialog?.name !==
|
||||||
@@ -3230,14 +3231,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const prevElements = this.scene.getElementsIncludingDeleted();
|
const prevElements = this.scene.getElementsIncludingDeleted();
|
||||||
let nextElements = [...prevElements, ...newElements];
|
const nextElements = [...prevElements, ...newElements];
|
||||||
|
|
||||||
const mappedNewSceneElements = this.props.onDuplicate?.(
|
|
||||||
nextElements,
|
|
||||||
prevElements,
|
|
||||||
);
|
|
||||||
|
|
||||||
nextElements = mappedNewSceneElements || nextElements;
|
|
||||||
|
|
||||||
syncMovedIndices(nextElements, arrayToMap(newElements));
|
syncMovedIndices(nextElements, arrayToMap(newElements));
|
||||||
|
|
||||||
@@ -4143,10 +4137,51 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (selectedElements.length === 1 && arrowKeyPressed) {
|
if (selectedElements.length === 1 && arrowKeyPressed) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
return this.flowChartNavigator.exploreByDirection(
|
const nextId = this.flowChartNavigator.exploreByDirection(
|
||||||
selectedElements[0],
|
selectedElements[0],
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
getLinkDirectionFromKey(event.key),
|
getLinkDirectionFromKey(event.key),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (nextId) {
|
||||||
|
this.setState((prevState) => ({
|
||||||
|
selectedElementIds: makeNextSelectedElementIds(
|
||||||
|
{
|
||||||
|
[nextId]: true,
|
||||||
|
},
|
||||||
|
prevState,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const nextNode = this.scene
|
||||||
|
.getNonDeletedElementsMap()
|
||||||
|
.get(nextId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
nextNode &&
|
||||||
|
!isElementCompletelyInViewport(
|
||||||
|
[nextNode],
|
||||||
|
this.canvas.width / window.devicePixelRatio,
|
||||||
|
this.canvas.height / window.devicePixelRatio,
|
||||||
|
{
|
||||||
|
offsetLeft: this.state.offsetLeft,
|
||||||
|
offsetTop: this.state.offsetTop,
|
||||||
|
scrollX: this.state.scrollX,
|
||||||
|
scrollY: this.state.scrollY,
|
||||||
|
zoom: this.state.zoom,
|
||||||
|
},
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
this.getEditorUIOffsets(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.scrollToContent(nextNode, {
|
||||||
|
animate: true,
|
||||||
|
duration: 300,
|
||||||
|
canvasOffsets: this.getEditorUIOffsets(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4487,6 +4522,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.key === KEYS[1] && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
|
||||||
|
if (this.state.activeTool.type === "selection") {
|
||||||
|
this.setActiveTool({ type: "lassoSelection" });
|
||||||
|
} else {
|
||||||
|
this.setActiveTool({ type: "selection" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
|
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
|
||||||
@@ -5742,10 +5785,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (editingLinearElement?.lastUncommittedPoint != null) {
|
if (editingLinearElement?.lastUncommittedPoint != null) {
|
||||||
this.maybeSuggestBindingAtCursor(
|
this.maybeSuggestBindingAtCursor(scenePointer);
|
||||||
scenePointer,
|
|
||||||
editingLinearElement.elbowed,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// causes stack overflow if not sync
|
// causes stack overflow if not sync
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
@@ -5765,7 +5805,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state.startBoundElement,
|
this.state.startBoundElement,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.maybeSuggestBindingAtCursor(scenePointer, false);
|
this.maybeSuggestBindingAtCursor(scenePointer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6267,13 +6307,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
private handleCanvasPointerDown = (
|
private handleCanvasPointerDown = (
|
||||||
event: React.PointerEvent<HTMLElement>,
|
event: React.PointerEvent<HTMLElement>,
|
||||||
) => {
|
) => {
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
// capture subsequent pointer events to the canvas
|
|
||||||
// this makes other elements non-interactive until pointer up
|
|
||||||
if (target.setPointerCapture) {
|
|
||||||
target.setPointerCapture(event.pointerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
|
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
|
||||||
this.maybeUnfollowRemoteUser();
|
this.maybeUnfollowRemoteUser();
|
||||||
|
|
||||||
@@ -6527,6 +6560,15 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state.activeTool.type,
|
this.state.activeTool.type,
|
||||||
pointerDownState,
|
pointerDownState,
|
||||||
);
|
);
|
||||||
|
} else if (this.state.activeTool.type === "lassoSelection") {
|
||||||
|
// Begin a mark capture. This does not have to update state yet.
|
||||||
|
const [gridX, gridY] = getGridPoint(
|
||||||
|
pointerDownState.origin.x,
|
||||||
|
pointerDownState.origin.y,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.lassoTrail.startPath(gridX, gridY);
|
||||||
} else if (this.state.activeTool.type === "custom") {
|
} else if (this.state.activeTool.type === "custom") {
|
||||||
setCursorForShape(this.interactiveCanvas, this.state);
|
setCursorForShape(this.interactiveCanvas, this.state);
|
||||||
} else if (
|
} else if (
|
||||||
@@ -7710,7 +7752,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
this.state.zoom,
|
this.state.zoom,
|
||||||
isElbowArrow(element),
|
isElbowArrow(element),
|
||||||
isElbowArrow(element),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.scene.insertElement(element);
|
this.scene.insertElement(element);
|
||||||
@@ -8410,17 +8451,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextSceneElements: ExcalidrawElement[] = [
|
const nextSceneElements = [...nextElements, ...elementsToAppend];
|
||||||
...nextElements,
|
|
||||||
...elementsToAppend,
|
|
||||||
];
|
|
||||||
|
|
||||||
const mappedNewSceneElements = this.props.onDuplicate?.(
|
|
||||||
nextSceneElements,
|
|
||||||
elements,
|
|
||||||
);
|
|
||||||
|
|
||||||
nextSceneElements = mappedNewSceneElements || nextSceneElements;
|
|
||||||
|
|
||||||
syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend));
|
syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend));
|
||||||
|
|
||||||
@@ -8454,6 +8485,63 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
pointerDownState.lastCoords.x = pointerCoords.x;
|
pointerDownState.lastCoords.x = pointerCoords.x;
|
||||||
pointerDownState.lastCoords.y = pointerCoords.y;
|
pointerDownState.lastCoords.y = pointerCoords.y;
|
||||||
this.maybeDragNewGenericElement(pointerDownState, event);
|
this.maybeDragNewGenericElement(pointerDownState, event);
|
||||||
|
} else if (this.state.activeTool.type === "lassoSelection") {
|
||||||
|
const { intersectedElementIds, enclosedElementIds } =
|
||||||
|
this.lassoTrail.addPointToPath(pointerCoords.x, pointerCoords.y);
|
||||||
|
|
||||||
|
this.setState((prevState) => {
|
||||||
|
const elements = [...intersectedElementIds, ...enclosedElementIds];
|
||||||
|
|
||||||
|
const nextSelectedElementIds = elements.reduce((acc, id) => {
|
||||||
|
acc[id] = true;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<ExcalidrawElement["id"], true>);
|
||||||
|
|
||||||
|
const nextSelectedGroupIds = selectGroupsForSelectedElements(
|
||||||
|
{
|
||||||
|
selectedElementIds: nextSelectedElementIds,
|
||||||
|
editingGroupId: prevState.editingGroupId,
|
||||||
|
},
|
||||||
|
this.scene.getNonDeletedElements(),
|
||||||
|
prevState,
|
||||||
|
this,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: not entirely correct (need to select all elements in group instead)
|
||||||
|
for (const [id, selected] of Object.entries(nextSelectedElementIds)) {
|
||||||
|
if (selected) {
|
||||||
|
const element = this.scene.getNonDeletedElement(id);
|
||||||
|
if (element && element.groupIds.length > 0) {
|
||||||
|
delete nextSelectedElementIds[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: make elegant and decide if all children are selected, do we keep?
|
||||||
|
for (const [id, selected] of Object.entries(nextSelectedElementIds)) {
|
||||||
|
if (selected) {
|
||||||
|
const element = this.scene.getNonDeletedElement(id);
|
||||||
|
|
||||||
|
if (element && isFrameLikeElement(element)) {
|
||||||
|
const elementsInFrame = getFrameChildren(
|
||||||
|
elementsMap,
|
||||||
|
element.id,
|
||||||
|
);
|
||||||
|
for (const child of elementsInFrame) {
|
||||||
|
delete nextSelectedElementIds[child.id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedElementIds: makeNextSelectedElementIds(
|
||||||
|
nextSelectedElementIds,
|
||||||
|
prevState,
|
||||||
|
),
|
||||||
|
selectedGroupIds: nextSelectedGroupIds.selectedGroupIds,
|
||||||
|
};
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// It is very important to read this.state within each move event,
|
// It is very important to read this.state within each move event,
|
||||||
// otherwise we would read a stale one!
|
// otherwise we would read a stale one!
|
||||||
@@ -8708,6 +8796,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
originSnapOffset: null,
|
originSnapOffset: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
this.lassoTrail.endPath();
|
||||||
|
|
||||||
this.lastPointerMoveCoords = null;
|
this.lastPointerMoveCoords = null;
|
||||||
|
|
||||||
SnapCache.setReferenceSnapPoints(null);
|
SnapCache.setReferenceSnapPoints(null);
|
||||||
@@ -8874,6 +8964,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isImageElement(newElement)) {
|
if (isImageElement(newElement)) {
|
||||||
const imageElement = newElement;
|
const imageElement = newElement;
|
||||||
try {
|
try {
|
||||||
@@ -9998,20 +10089,15 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private maybeSuggestBindingAtCursor = (
|
private maybeSuggestBindingAtCursor = (pointerCoords: {
|
||||||
pointerCoords: {
|
x: number;
|
||||||
x: number;
|
y: number;
|
||||||
y: number;
|
}): void => {
|
||||||
},
|
|
||||||
considerAll: boolean,
|
|
||||||
): void => {
|
|
||||||
const hoveredBindableElement = getHoveredElementForBinding(
|
const hoveredBindableElement = getHoveredElementForBinding(
|
||||||
pointerCoords,
|
pointerCoords,
|
||||||
this.scene.getNonDeletedElements(),
|
this.scene.getNonDeletedElements(),
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
this.state.zoom,
|
this.state.zoom,
|
||||||
false,
|
|
||||||
considerAll,
|
|
||||||
);
|
);
|
||||||
this.setState({
|
this.setState({
|
||||||
suggestedBindings:
|
suggestedBindings:
|
||||||
@@ -10041,8 +10127,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.scene.getNonDeletedElements(),
|
this.scene.getNonDeletedElements(),
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
this.state.zoom,
|
this.state.zoom,
|
||||||
isElbowArrow(linearElement),
|
isArrowElement(linearElement) && isElbowArrow(linearElement),
|
||||||
isElbowArrow(linearElement),
|
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
hoveredBindableElement != null &&
|
hoveredBindableElement != null &&
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getLanguage, t } from "../i18n";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Collapsible from "./Stats/Collapsible";
|
import Collapsible from "./Stats/Collapsible";
|
||||||
import { atom, useAtom } from "../editor-jotai";
|
import { atom, useAtom } from "../editor-jotai";
|
||||||
import { useDevice } from "./App";
|
import { useDevice } from "..";
|
||||||
|
|
||||||
import "./IconPicker.scss";
|
import "./IconPicker.scss";
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import React, {
|
import React, { useState, useCallback, useMemo, useRef } from "react";
|
||||||
useState,
|
|
||||||
useCallback,
|
|
||||||
useMemo,
|
|
||||||
useEffect,
|
|
||||||
memo,
|
|
||||||
useRef,
|
|
||||||
} from "react";
|
|
||||||
import type Library from "../data/library";
|
import type Library from "../data/library";
|
||||||
import {
|
import {
|
||||||
distributeLibraryItemsOnSquareGrid,
|
distributeLibraryItemsOnSquareGrid,
|
||||||
@@ -18,7 +11,6 @@ import type {
|
|||||||
LibraryItem,
|
LibraryItem,
|
||||||
ExcalidrawProps,
|
ExcalidrawProps,
|
||||||
UIAppState,
|
UIAppState,
|
||||||
AppClassProperties,
|
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import LibraryMenuItems from "./LibraryMenuItems";
|
import LibraryMenuItems from "./LibraryMenuItems";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
@@ -35,12 +27,9 @@ import { useUIAppState } from "../context/ui-appState";
|
|||||||
|
|
||||||
import "./LibraryMenu.scss";
|
import "./LibraryMenu.scss";
|
||||||
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
||||||
import type {
|
|
||||||
ExcalidrawElement,
|
|
||||||
NonDeletedExcalidrawElement,
|
|
||||||
} from "../element/types";
|
|
||||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
|
||||||
import { isShallowEqual } from "../utils";
|
import { isShallowEqual } from "../utils";
|
||||||
|
import type { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
|
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||||
|
|
||||||
export const isLibraryMenuOpenAtom = atom(false);
|
export const isLibraryMenuOpenAtom = atom(false);
|
||||||
|
|
||||||
@@ -48,215 +37,156 @@ const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => {
|
|||||||
return <div className="layer-ui__library">{children}</div>;
|
return <div className="layer-ui__library">{children}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LibraryMenuContent = memo(
|
export const LibraryMenuContent = ({
|
||||||
({
|
onInsertLibraryItems,
|
||||||
onInsertLibraryItems,
|
pendingElements,
|
||||||
pendingElements,
|
onAddToLibrary,
|
||||||
onAddToLibrary,
|
setAppState,
|
||||||
setAppState,
|
libraryReturnUrl,
|
||||||
libraryReturnUrl,
|
library,
|
||||||
library,
|
id,
|
||||||
id,
|
theme,
|
||||||
theme,
|
selectedItems,
|
||||||
selectedItems,
|
onSelectItems,
|
||||||
onSelectItems,
|
}: {
|
||||||
}: {
|
pendingElements: LibraryItem["elements"];
|
||||||
pendingElements: LibraryItem["elements"];
|
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
onAddToLibrary: () => void;
|
||||||
onAddToLibrary: () => void;
|
setAppState: React.Component<any, UIAppState>["setState"];
|
||||||
setAppState: React.Component<any, UIAppState>["setState"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
library: Library;
|
||||||
library: Library;
|
id: string;
|
||||||
id: string;
|
theme: UIAppState["theme"];
|
||||||
theme: UIAppState["theme"];
|
selectedItems: LibraryItem["id"][];
|
||||||
selectedItems: LibraryItem["id"][];
|
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
}) => {
|
||||||
}) => {
|
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
||||||
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
|
||||||
|
|
||||||
const _onAddToLibrary = useCallback(
|
const _onAddToLibrary = useCallback(
|
||||||
(elements: LibraryItem["elements"]) => {
|
(elements: LibraryItem["elements"]) => {
|
||||||
const addToLibrary = async (
|
const addToLibrary = async (
|
||||||
processedElements: LibraryItem["elements"],
|
processedElements: LibraryItem["elements"],
|
||||||
libraryItems: LibraryItems,
|
libraryItems: LibraryItems,
|
||||||
) => {
|
) => {
|
||||||
trackEvent("element", "addToLibrary", "ui");
|
trackEvent("element", "addToLibrary", "ui");
|
||||||
for (const type of LIBRARY_DISABLED_TYPES) {
|
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||||
if (processedElements.some((element) => element.type === type)) {
|
if (processedElements.some((element) => element.type === type)) {
|
||||||
return setAppState({
|
return setAppState({
|
||||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const nextItems: LibraryItems = [
|
}
|
||||||
{
|
const nextItems: LibraryItems = [
|
||||||
status: "unpublished",
|
{
|
||||||
elements: processedElements,
|
status: "unpublished",
|
||||||
id: randomId(),
|
elements: processedElements,
|
||||||
created: Date.now(),
|
id: randomId(),
|
||||||
},
|
created: Date.now(),
|
||||||
...libraryItems,
|
},
|
||||||
];
|
...libraryItems,
|
||||||
onAddToLibrary();
|
];
|
||||||
library.setLibrary(nextItems).catch(() => {
|
onAddToLibrary();
|
||||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
library.setLibrary(nextItems).catch(() => {
|
||||||
});
|
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||||
};
|
});
|
||||||
addToLibrary(elements, libraryItemsData.libraryItems);
|
};
|
||||||
},
|
addToLibrary(elements, libraryItemsData.libraryItems);
|
||||||
[onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
|
},
|
||||||
);
|
[onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
|
||||||
|
);
|
||||||
|
|
||||||
const libraryItems = useMemo(
|
const libraryItems = useMemo(
|
||||||
() => libraryItemsData.libraryItems,
|
() => libraryItemsData.libraryItems,
|
||||||
[libraryItemsData],
|
[libraryItemsData],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
|
||||||
libraryItemsData.status === "loading" &&
|
|
||||||
!libraryItemsData.isInitialized
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<LibraryMenuWrapper>
|
|
||||||
<div className="layer-ui__library-message">
|
|
||||||
<div>
|
|
||||||
<Spinner size="2em" />
|
|
||||||
<span>{t("labels.libraryLoadingMessage")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</LibraryMenuWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const showBtn =
|
|
||||||
libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0;
|
|
||||||
|
|
||||||
|
if (
|
||||||
|
libraryItemsData.status === "loading" &&
|
||||||
|
!libraryItemsData.isInitialized
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<LibraryMenuWrapper>
|
<LibraryMenuWrapper>
|
||||||
<LibraryMenuItems
|
<div className="layer-ui__library-message">
|
||||||
isLoading={libraryItemsData.status === "loading"}
|
<div>
|
||||||
libraryItems={libraryItems}
|
<Spinner size="2em" />
|
||||||
onAddToLibrary={_onAddToLibrary}
|
<span>{t("labels.libraryLoadingMessage")}</span>
|
||||||
onInsertLibraryItems={onInsertLibraryItems}
|
</div>
|
||||||
pendingElements={pendingElements}
|
</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" }}
|
||||||
id={id}
|
id={id}
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onSelectItems={onSelectItems}
|
|
||||||
selectedItems={selectedItems}
|
|
||||||
/>
|
/>
|
||||||
{showBtn && (
|
)}
|
||||||
<LibraryMenuControlButtons
|
</LibraryMenuWrapper>
|
||||||
className="library-menu-control-buttons--at-bottom"
|
);
|
||||||
style={{ padding: "16px 12px 0 12px" }}
|
};
|
||||||
id={id}
|
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
|
||||||
theme={theme}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</LibraryMenuWrapper>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const getPendingElements = (
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
|
||||||
selectedElementIds: UIAppState["selectedElementIds"],
|
|
||||||
) => ({
|
|
||||||
elements,
|
|
||||||
pending: getSelectedElements(
|
|
||||||
elements,
|
|
||||||
{ selectedElementIds },
|
|
||||||
{
|
|
||||||
includeBoundTextElement: true,
|
|
||||||
includeElementsInFrames: true,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
selectedElementIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
const usePendingElementsMemo = (
|
const usePendingElementsMemo = (
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
app: AppClassProperties,
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
const elements = useExcalidrawElements();
|
const create = () =>
|
||||||
const [state, setState] = useState(() =>
|
getSelectedElements(elements, appState, {
|
||||||
getPendingElements(elements, appState.selectedElementIds),
|
includeBoundTextElement: true,
|
||||||
);
|
includeElementsInFrames: true,
|
||||||
|
});
|
||||||
|
const val = useRef(create());
|
||||||
|
const prevAppState = useRef<UIAppState>(appState);
|
||||||
|
const prevElements = useRef(elements);
|
||||||
|
|
||||||
const selectedElementVersions = useRef(
|
if (
|
||||||
new Map<ExcalidrawElement["id"], ExcalidrawElement["version"]>(),
|
!isShallowEqual(
|
||||||
);
|
appState.selectedElementIds,
|
||||||
|
prevAppState.current.selectedElementIds,
|
||||||
useEffect(() => {
|
) ||
|
||||||
for (const element of state.pending) {
|
!isShallowEqual(elements, prevElements.current)
|
||||||
selectedElementVersions.current.set(element.id, element.version);
|
) {
|
||||||
}
|
val.current = create();
|
||||||
}, [state.pending]);
|
prevAppState.current = appState;
|
||||||
|
prevElements.current = elements;
|
||||||
useEffect(() => {
|
}
|
||||||
if (
|
return val.current;
|
||||||
// Only update once pointer is released.
|
|
||||||
// Reading directly from app.state to make it clear it's not reactive
|
|
||||||
// (hence, there's potential for stale state)
|
|
||||||
app.state.cursorButton === "up" &&
|
|
||||||
app.state.activeTool.type === "selection"
|
|
||||||
) {
|
|
||||||
setState((prev) => {
|
|
||||||
// if selectedElementIds changed, we don't have to compare versions
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
if (
|
|
||||||
!isShallowEqual(prev.selectedElementIds, appState.selectedElementIds)
|
|
||||||
) {
|
|
||||||
selectedElementVersions.current.clear();
|
|
||||||
return getPendingElements(elements, appState.selectedElementIds);
|
|
||||||
}
|
|
||||||
// otherwise we need to check whether selected elements changed
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
|
||||||
for (const id of Object.keys(appState.selectedElementIds)) {
|
|
||||||
const currVersion = elementsMap.get(id)?.version;
|
|
||||||
if (
|
|
||||||
currVersion &&
|
|
||||||
currVersion !== selectedElementVersions.current.get(id)
|
|
||||||
) {
|
|
||||||
// we can't update the selectedElementVersions in here
|
|
||||||
// because of double render in StrictMode which would overwrite
|
|
||||||
// the state in the second pass with the old `prev` state.
|
|
||||||
// Thus, we update versions in a separate effect. May create
|
|
||||||
// a race condition since current effect is not fully reactive.
|
|
||||||
return getPendingElements(elements, appState.selectedElementIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// nothing changed
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
app,
|
|
||||||
app.state.cursorButton,
|
|
||||||
app.state.activeTool.type,
|
|
||||||
appState.selectedElementIds,
|
|
||||||
elements,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return state.pending;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component is meant to be rendered inside <Sidebar.Tab/> inside our
|
* This component is meant to be rendered inside <Sidebar.Tab/> inside our
|
||||||
* <DefaultSidebar/> or host apps Sidebar components.
|
* <DefaultSidebar/> or host apps Sidebar components.
|
||||||
*/
|
*/
|
||||||
export const LibraryMenu = memo(() => {
|
export const LibraryMenu = () => {
|
||||||
const app = useApp();
|
const { library, id, onInsertElements } = useApp();
|
||||||
const { onInsertElements } = app;
|
|
||||||
const appProps = useAppProps();
|
const appProps = useAppProps();
|
||||||
const appState = useUIAppState();
|
const appState = useUIAppState();
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
const elements = useExcalidrawElements();
|
||||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||||
const memoizedLibrary = useMemo(() => app.library, [app.library]);
|
const memoizedLibrary = useMemo(() => library, [library]);
|
||||||
const pendingElements = usePendingElementsMemo(appState, app);
|
// 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 onInsertLibraryItems = useCallback(
|
const onInsertLibraryItems = useCallback(
|
||||||
(libraryItems: LibraryItems) => {
|
(libraryItems: LibraryItems) => {
|
||||||
@@ -281,10 +211,10 @@ export const LibraryMenu = memo(() => {
|
|||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
libraryReturnUrl={appProps.libraryReturnUrl}
|
libraryReturnUrl={appProps.libraryReturnUrl}
|
||||||
library={memoizedLibrary}
|
library={memoizedLibrary}
|
||||||
id={app.id}
|
id={id}
|
||||||
theme={appState.theme}
|
theme={appState.theme}
|
||||||
selectedItems={selectedItems}
|
selectedItems={selectedItems}
|
||||||
onSelectItems={setSelectedItems}
|
onSelectItems={setSelectedItems}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|||||||
@@ -145,14 +145,12 @@ export const MobileMenu = ({
|
|||||||
<div className="App-toolbar-content">
|
<div className="App-toolbar-content">
|
||||||
<MainMenuTunnel.Out />
|
<MainMenuTunnel.Out />
|
||||||
{actionManager.renderAction("toggleEditMenu")}
|
{actionManager.renderAction("toggleEditMenu")}
|
||||||
|
{actionManager.renderAction("undo")}
|
||||||
|
{actionManager.renderAction("redo")}
|
||||||
{actionManager.renderAction(
|
{actionManager.renderAction(
|
||||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||||
)}
|
)}
|
||||||
{actionManager.renderAction("deleteSelectedElements")}
|
{actionManager.renderAction("deleteSelectedElements")}
|
||||||
<div>
|
|
||||||
{actionManager.renderAction("undo")}
|
|
||||||
{actionManager.renderAction("redo")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
@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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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,6 +7,7 @@ import { debounce } from "lodash";
|
|||||||
import type { AppClassProperties } from "../types";
|
import type { AppClassProperties } from "../types";
|
||||||
import { isTextElement, newTextElement } from "../element";
|
import { isTextElement, newTextElement } from "../element";
|
||||||
import type { ExcalidrawTextElement } from "../element/types";
|
import type { ExcalidrawTextElement } from "../element/types";
|
||||||
|
import { measureText } from "../element/textElement";
|
||||||
import { addEventListener, getFontString } from "../utils";
|
import { addEventListener, getFontString } from "../utils";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -19,7 +20,6 @@ import { useStable } from "../hooks/useStable";
|
|||||||
|
|
||||||
import "./SearchMenu.scss";
|
import "./SearchMenu.scss";
|
||||||
import { round } from "../../math";
|
import { round } from "../../math";
|
||||||
import { measureText } from "../element/textMeasurements";
|
|
||||||
|
|
||||||
const searchQueryAtom = atom<string>("");
|
const searchQueryAtom = atom<string>("");
|
||||||
export const searchItemInFocusAtom = atom<number | null>(null);
|
export const searchItemInFocusAtom = atom<number | null>(null);
|
||||||
@@ -607,6 +607,7 @@ const getMatchedLines = (
|
|||||||
textToStart,
|
textToStart,
|
||||||
getFontString(textElement),
|
getFontString(textElement),
|
||||||
textElement.lineHeight,
|
textElement.lineHeight,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// measureText returns a non-zero width for the empty string
|
// measureText returns a non-zero width for the empty string
|
||||||
@@ -620,6 +621,7 @@ const getMatchedLines = (
|
|||||||
lineIndexRange.line,
|
lineIndexRange.line,
|
||||||
getFontString(textElement),
|
getFontString(textElement),
|
||||||
textElement.lineHeight,
|
textElement.lineHeight,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
const spaceToStart =
|
const spaceToStart =
|
||||||
|
|||||||
@@ -1216,12 +1216,11 @@ export const EdgeRoundIcon = createIcon(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const ArrowheadNoneIcon = createIcon(
|
export const ArrowheadNoneIcon = createIcon(
|
||||||
<g stroke="currentColor" opacity={0.3} strokeWidth={2}>
|
<path d="M6 10H34" stroke="currentColor" strokeWidth={2} fill="none" />,
|
||||||
<path d="M12 12l9 0" />
|
{
|
||||||
<path d="M3 9l6 6" />
|
width: 40,
|
||||||
<path d="M3 15l6 -6" />
|
height: 20,
|
||||||
</g>,
|
},
|
||||||
tablerIconProps,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ArrowheadArrowIcon = React.memo(
|
export const ArrowheadArrowIcon = React.memo(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.excalidraw {
|
.excalidraw {
|
||||||
.excalifont {
|
.excalifont {
|
||||||
font-family: "Excalifont", "Xiaolai";
|
font-family: "Excalifont";
|
||||||
}
|
}
|
||||||
|
|
||||||
// WelcomeSreen common
|
// WelcomeSreen common
|
||||||
|
|||||||
@@ -417,6 +417,7 @@ export const LIBRARY_DISABLED_TYPES = new Set([
|
|||||||
// use these constants to easily identify reference sites
|
// use these constants to easily identify reference sites
|
||||||
export const TOOL_TYPE = {
|
export const TOOL_TYPE = {
|
||||||
selection: "selection",
|
selection: "selection",
|
||||||
|
lassoSelection: "lassoSelection",
|
||||||
rectangle: "rectangle",
|
rectangle: "rectangle",
|
||||||
diamond: "diamond",
|
diamond: "diamond",
|
||||||
ellipse: "ellipse",
|
ellipse: "ellipse",
|
||||||
@@ -458,6 +459,3 @@ export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
|
|||||||
|
|
||||||
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
|
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
|
||||||
export const ELEMENT_LINK_KEY = "element";
|
export const ELEMENT_LINK_KEY = "element";
|
||||||
|
|
||||||
/** used in tests */
|
|
||||||
export const ORIG_ID = Symbol.for("__test__originalId__");
|
|
||||||
|
|||||||
@@ -649,21 +649,15 @@ body.excalidraw-cursor-resize * {
|
|||||||
@include filledButtonOnCanvas;
|
@include filledButtonOnCanvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
.App-mobile-menu,
|
|
||||||
.App-menu__left {
|
.App-menu__left {
|
||||||
--button-border: transparent;
|
--button-border: transparent;
|
||||||
--button-bg: var(--color-surface-mid);
|
--button-bg: var(--color-surface-mid);
|
||||||
}
|
|
||||||
|
|
||||||
@at-root .excalidraw.theme--dark#{&} {
|
@at-root .excalidraw.theme--dark#{&} {
|
||||||
.App-mobile-menu,
|
|
||||||
.App-menu__left {
|
|
||||||
--button-hover-bg: #363541;
|
--button-hover-bg: #363541;
|
||||||
--button-bg: var(--color-surface-high);
|
--button-bg: var(--color-surface-high);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.App-menu__left {
|
|
||||||
.buttonList {
|
.buttonList {
|
||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,9 +53,6 @@
|
|||||||
--scrollbar-thumb: var(--button-gray-2);
|
--scrollbar-thumb: var(--button-gray-2);
|
||||||
--scrollbar-thumb-hover: var(--button-gray-3);
|
--scrollbar-thumb-hover: var(--button-gray-3);
|
||||||
|
|
||||||
--color-slider-track: hsl(240, 100%, 90%);
|
|
||||||
--color-slider-thumb: var(--color-gray-80);
|
|
||||||
|
|
||||||
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
|
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
|
||||||
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
|
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
|
||||||
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275),
|
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275),
|
||||||
@@ -210,8 +207,6 @@
|
|||||||
--scrollbar-thumb: #{$oc-gray-8};
|
--scrollbar-thumb: #{$oc-gray-8};
|
||||||
--scrollbar-thumb-hover: #{$oc-gray-7};
|
--scrollbar-thumb-hover: #{$oc-gray-7};
|
||||||
|
|
||||||
--color-slider-track: hsl(244, 23%, 39%);
|
|
||||||
|
|
||||||
// will be inverted to a lighter color.
|
// will be inverted to a lighter color.
|
||||||
--color-selection: #3530c4;
|
--color-selection: #3530c4;
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import { bumpVersion } from "../element/mutateElement";
|
|||||||
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import type { MarkOptional, Mutable } from "../utility-types";
|
import type { MarkOptional, Mutable } from "../utility-types";
|
||||||
import { getContainerElement } from "../element/textElement";
|
import { detectLineHeight, getContainerElement } from "../element/textElement";
|
||||||
import { normalizeLink } from "./url";
|
import { normalizeLink } from "./url";
|
||||||
import { syncInvalidIndices } from "../fractionalIndex";
|
import { syncInvalidIndices } from "../fractionalIndex";
|
||||||
import { getSizeFromPoints } from "../points";
|
import { getSizeFromPoints } from "../points";
|
||||||
@@ -59,7 +59,6 @@ import {
|
|||||||
} from "../scene";
|
} from "../scene";
|
||||||
import type { LocalPoint, Radians } from "../../math";
|
import type { LocalPoint, Radians } from "../../math";
|
||||||
import { isFiniteNumber, pointFrom } from "../../math";
|
import { isFiniteNumber, pointFrom } from "../../math";
|
||||||
import { detectLineHeight } from "../element/textMeasurements";
|
|
||||||
|
|
||||||
type RestoredAppState = Omit<
|
type RestoredAppState = Omit<
|
||||||
AppState,
|
AppState,
|
||||||
@@ -71,6 +70,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
|||||||
boolean
|
boolean
|
||||||
> = {
|
> = {
|
||||||
selection: true,
|
selection: true,
|
||||||
|
lassoSelection: true,
|
||||||
text: true,
|
text: true,
|
||||||
rectangle: true,
|
rectangle: true,
|
||||||
diamond: true,
|
diamond: true,
|
||||||
@@ -206,24 +206,6 @@ const restoreElementWithProperties = <
|
|||||||
"customData" in extra ? extra.customData : element.customData;
|
"customData" in extra ? extra.customData : element.customData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE (mtolmacs): This is a temporary check to detect extremely large
|
|
||||||
// element position or sizing
|
|
||||||
if (
|
|
||||||
element.x < -1e6 ||
|
|
||||||
element.x > 1e6 ||
|
|
||||||
element.y < -1e6 ||
|
|
||||||
element.y > 1e6 ||
|
|
||||||
element.width < -1e6 ||
|
|
||||||
element.width > 1e6 ||
|
|
||||||
element.height < -1e6 ||
|
|
||||||
element.height > 1e6
|
|
||||||
) {
|
|
||||||
console.error(
|
|
||||||
"Restore element with properties size or position is too large",
|
|
||||||
{ element },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// spread the original element properties to not lose unknown ones
|
// spread the original element properties to not lose unknown ones
|
||||||
// for forward-compatibility
|
// for forward-compatibility
|
||||||
@@ -238,21 +220,6 @@ const restoreElementWithProperties = <
|
|||||||
const restoreElement = (
|
const restoreElement = (
|
||||||
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
): typeof element | null => {
|
): typeof element | null => {
|
||||||
// NOTE (mtolmacs): This is a temporary check to detect extremely large
|
|
||||||
// element position or sizing
|
|
||||||
if (
|
|
||||||
element.x < -1e6 ||
|
|
||||||
element.x > 1e6 ||
|
|
||||||
element.y < -1e6 ||
|
|
||||||
element.y > 1e6 ||
|
|
||||||
element.width < -1e6 ||
|
|
||||||
element.width > 1e6 ||
|
|
||||||
element.height < -1e6 ||
|
|
||||||
element.height > 1e6
|
|
||||||
) {
|
|
||||||
console.error("Restore element size or position is too large", { element });
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "text":
|
case "text":
|
||||||
let fontSize = element.fontSize;
|
let fontSize = element.fontSize;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
newMagicFrameElement,
|
newMagicFrameElement,
|
||||||
newTextElement,
|
newTextElement,
|
||||||
} from "../element/newElement";
|
} from "../element/newElement";
|
||||||
|
import { measureText, normalizeText } from "../element/textElement";
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
@@ -54,7 +55,6 @@ import { syncInvalidIndices } from "../fractionalIndex";
|
|||||||
import { getLineHeight } from "../fonts";
|
import { getLineHeight } from "../fonts";
|
||||||
import { isArrowElement } from "../element/typeChecks";
|
import { isArrowElement } from "../element/typeChecks";
|
||||||
import { pointFrom, type LocalPoint } from "../../math";
|
import { pointFrom, type LocalPoint } from "../../math";
|
||||||
import { measureText, normalizeText } from "../element/textMeasurements";
|
|
||||||
|
|
||||||
export type ValidLinearElement = {
|
export type ValidLinearElement = {
|
||||||
type: "arrow" | "line";
|
type: "arrow" | "line";
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe("normalizeLink", () => {
|
|||||||
expect(normalizeLink("file://")).toBe("file://");
|
expect(normalizeLink("file://")).toBe("file://");
|
||||||
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
|
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
|
||||||
expect(normalizeLink("[[test]]")).toBe("[[test]]");
|
expect(normalizeLink("[[test]]")).toBe("[[test]]");
|
||||||
expect(normalizeLink("<test>")).toBe("<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 { sanitizeUrl } from "@braintree/sanitize-url";
|
||||||
import { escapeDoubleQuotes } from "../utils";
|
import { sanitizeHTMLAttribute } from "../utils";
|
||||||
|
|
||||||
export const normalizeLink = (link: string) => {
|
export const normalizeLink = (link: string) => {
|
||||||
link = link.trim();
|
link = link.trim();
|
||||||
if (!link) {
|
if (!link) {
|
||||||
return link;
|
return link;
|
||||||
}
|
}
|
||||||
return sanitizeUrl(escapeDoubleQuotes(link));
|
return sanitizeUrl(sanitizeHTMLAttribute(link));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isLocalLink = (link: string | null) => {
|
export const isLocalLink = (link: string | null) => {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import type { Bounds } from "./bounds";
|
|||||||
import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds";
|
import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds";
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { isPointOnShape } from "../../utils/collision";
|
import { isPointOnShape } from "../../utils/collision";
|
||||||
|
import { getElementAtPosition } from "../scene";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
isBindableElement,
|
isBindableElement,
|
||||||
@@ -48,11 +49,7 @@ import type { ElementUpdate } from "./mutateElement";
|
|||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import type Scene from "../scene/Scene";
|
import type Scene from "../scene/Scene";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import {
|
import { arrayToMap, tupleToCoors } from "../utils";
|
||||||
arrayToMap,
|
|
||||||
isBindingFallthroughEnabled,
|
|
||||||
tupleToCoors,
|
|
||||||
} from "../utils";
|
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||||
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
|
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
|
||||||
@@ -428,8 +425,7 @@ export const maybeBindLinearElement = (
|
|||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
appState.zoom,
|
appState.zoom,
|
||||||
isElbowArrow(linearElement),
|
isElbowArrow(linearElement) && isElbowArrow(linearElement),
|
||||||
isElbowArrow(linearElement),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hoveredElement !== null) {
|
if (hoveredElement !== null) {
|
||||||
@@ -562,65 +558,8 @@ export const getHoveredElementForBinding = (
|
|||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
zoom?: AppState["zoom"],
|
zoom?: AppState["zoom"],
|
||||||
fullShape?: boolean,
|
fullShape?: boolean,
|
||||||
considerAllElements?: boolean,
|
|
||||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||||
if (considerAllElements) {
|
const hoveredElement = getElementAtPosition(
|
||||||
let cullRest = false;
|
|
||||||
const candidateElements = getAllElementsAtPositionForBinding(
|
|
||||||
elements,
|
|
||||||
(element) =>
|
|
||||||
isBindableElement(element, false) &&
|
|
||||||
bindingBorderTest(
|
|
||||||
element,
|
|
||||||
pointerCoords,
|
|
||||||
elementsMap,
|
|
||||||
zoom,
|
|
||||||
(fullShape ||
|
|
||||||
!isBindingFallthroughEnabled(
|
|
||||||
element as ExcalidrawBindableElement,
|
|
||||||
)) &&
|
|
||||||
// disable fullshape snapping for frame elements so we
|
|
||||||
// can bind to frame children
|
|
||||||
!isFrameLikeElement(element),
|
|
||||||
),
|
|
||||||
).filter((element) => {
|
|
||||||
if (cullRest) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isBindingFallthroughEnabled(element as ExcalidrawBindableElement)) {
|
|
||||||
cullRest = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}) as NonDeleted<ExcalidrawBindableElement>[] | null;
|
|
||||||
|
|
||||||
// Return early if there are no candidates or just one candidate
|
|
||||||
if (!candidateElements || candidateElements.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidateElements.length === 1) {
|
|
||||||
return candidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer the shape with the border being tested (if any)
|
|
||||||
const borderTestElements = candidateElements.filter((element) =>
|
|
||||||
bindingBorderTest(element, pointerCoords, elementsMap, zoom, false),
|
|
||||||
);
|
|
||||||
if (borderTestElements.length === 1) {
|
|
||||||
return borderTestElements[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer smaller shapes
|
|
||||||
return candidateElements
|
|
||||||
.sort(
|
|
||||||
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
|
|
||||||
)
|
|
||||||
.pop() as NonDeleted<ExcalidrawBindableElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hoveredElement = getElementAtPositionForBinding(
|
|
||||||
elements,
|
elements,
|
||||||
(element) =>
|
(element) =>
|
||||||
isBindableElement(element, false) &&
|
isBindableElement(element, false) &&
|
||||||
@@ -631,58 +570,13 @@ export const getHoveredElementForBinding = (
|
|||||||
zoom,
|
zoom,
|
||||||
// disable fullshape snapping for frame elements so we
|
// disable fullshape snapping for frame elements so we
|
||||||
// can bind to frame children
|
// can bind to frame children
|
||||||
(fullShape || !isBindingFallthroughEnabled(element)) &&
|
fullShape && !isFrameLikeElement(element),
|
||||||
!isFrameLikeElement(element),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
|
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getElementAtPositionForBinding = (
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
|
||||||
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
|
|
||||||
) => {
|
|
||||||
let hitElement = null;
|
|
||||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
|
||||||
// because array is ordered from lower z-index to highest and we want element z-index
|
|
||||||
// with higher z-index
|
|
||||||
for (let index = elements.length - 1; index >= 0; --index) {
|
|
||||||
const element = elements[index];
|
|
||||||
if (element.isDeleted) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isAtPositionFn(element)) {
|
|
||||||
hitElement = element;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hitElement;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAllElementsAtPositionForBinding = (
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
|
||||||
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
|
|
||||||
) => {
|
|
||||||
const elementsAtPosition: NonDeletedExcalidrawElement[] = [];
|
|
||||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
|
||||||
// because array is ordered from lower z-index to highest and we want element z-index
|
|
||||||
// with higher z-index
|
|
||||||
for (let index = elements.length - 1; index >= 0; --index) {
|
|
||||||
const element = elements[index];
|
|
||||||
if (element.isDeleted) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAtPositionFn(element)) {
|
|
||||||
elementsAtPosition.push(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return elementsAtPosition;
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateFocusAndGap = (
|
const calculateFocusAndGap = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
hoveredElement: ExcalidrawBindableElement,
|
hoveredElement: ExcalidrawBindableElement,
|
||||||
@@ -1326,8 +1220,6 @@ const getElligibleElementForBindingElement = (
|
|||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
zoom,
|
zoom,
|
||||||
isElbowArrow(linearElement),
|
|
||||||
isElbowArrow(linearElement),
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { generateRoughOptions } from "../scene/Shape";
|
|||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
|
isFrameLikeElement,
|
||||||
isFreeDrawElement,
|
isFreeDrawElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
@@ -324,6 +325,15 @@ export const getElementLineSegments = (
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isFrameLikeElement(element)) {
|
||||||
|
return [
|
||||||
|
lineSegment(nw, ne),
|
||||||
|
lineSegment(ne, se),
|
||||||
|
lineSegment(se, sw),
|
||||||
|
lineSegment(sw, nw),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
lineSegment(nw, ne),
|
lineSegment(nw, ne),
|
||||||
lineSegment(sw, se),
|
lineSegment(sw, se),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
NullableGridSize,
|
NullableGridSize,
|
||||||
PointerDownState,
|
PointerDownState,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { getBoundTextElement } from "./textElement";
|
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
|
||||||
import type Scene from "../scene/Scene";
|
import type Scene from "../scene/Scene";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
import { getFontString } from "../utils";
|
import { getFontString } from "../utils";
|
||||||
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
|
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
|
||||||
import { getGridPoint } from "../snapping";
|
import { getGridPoint } from "../snapping";
|
||||||
import { getMinTextElementWidth } from "./textMeasurements";
|
|
||||||
|
|
||||||
export const dragSelectedElements = (
|
export const dragSelectedElements = (
|
||||||
pointerDownState: PointerDownState,
|
pointerDownState: PointerDownState,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
clamp,
|
|
||||||
pointDistance,
|
pointDistance,
|
||||||
pointFrom,
|
pointFrom,
|
||||||
pointScaleFromOrigin,
|
pointScaleFromOrigin,
|
||||||
@@ -21,11 +20,11 @@ import {
|
|||||||
bindPointToSnapToElementOutline,
|
bindPointToSnapToElementOutline,
|
||||||
distanceToBindableElement,
|
distanceToBindableElement,
|
||||||
avoidRectangularCorner,
|
avoidRectangularCorner,
|
||||||
|
getHoveredElementForBinding,
|
||||||
FIXED_BINDING_DISTANCE,
|
FIXED_BINDING_DISTANCE,
|
||||||
getHeadingForElbowArrowSnap,
|
getHeadingForElbowArrowSnap,
|
||||||
getGlobalFixedPointForBindableElement,
|
getGlobalFixedPointForBindableElement,
|
||||||
snapToMid,
|
snapToMid,
|
||||||
getHoveredElementForBinding,
|
|
||||||
} from "./binding";
|
} from "./binding";
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
import type { Heading } from "./heading";
|
import type { Heading } from "./heading";
|
||||||
@@ -105,7 +104,7 @@ const handleSegmentRenormalization = (
|
|||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
) => {
|
) => {
|
||||||
const nextFixedSegments: FixedSegment[] | null = arrow.fixedSegments
|
const nextFixedSegments: FixedSegment[] | null = arrow.fixedSegments
|
||||||
? arrow.fixedSegments.slice()
|
? structuredClone(arrow.fixedSegments)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (nextFixedSegments) {
|
if (nextFixedSegments) {
|
||||||
@@ -245,12 +244,6 @@ const handleSegmentRenormalization = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
import.meta.env.DEV &&
|
|
||||||
invariant(
|
|
||||||
validateElbowPoints(nextPoints),
|
|
||||||
"Invalid elbow points with fixed segments",
|
|
||||||
);
|
|
||||||
|
|
||||||
return normalizeArrowElementUpdate(
|
return normalizeArrowElementUpdate(
|
||||||
nextPoints,
|
nextPoints,
|
||||||
filteredNextFixedSegments,
|
filteredNextFixedSegments,
|
||||||
@@ -271,7 +264,7 @@ const handleSegmentRenormalization = (
|
|||||||
|
|
||||||
const handleSegmentRelease = (
|
const handleSegmentRelease = (
|
||||||
arrow: ExcalidrawElbowArrowElement,
|
arrow: ExcalidrawElbowArrowElement,
|
||||||
fixedSegments: readonly FixedSegment[],
|
fixedSegments: FixedSegment[],
|
||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
) => {
|
) => {
|
||||||
const newFixedSegmentIndices = fixedSegments.map((segment) => segment.index);
|
const newFixedSegmentIndices = fixedSegments.map((segment) => segment.index);
|
||||||
@@ -445,7 +438,7 @@ const handleSegmentRelease = (
|
|||||||
*/
|
*/
|
||||||
const handleSegmentMove = (
|
const handleSegmentMove = (
|
||||||
arrow: ExcalidrawElbowArrowElement,
|
arrow: ExcalidrawElbowArrowElement,
|
||||||
fixedSegments: readonly FixedSegment[],
|
fixedSegments: FixedSegment[],
|
||||||
startHeading: Heading,
|
startHeading: Heading,
|
||||||
endHeading: Heading,
|
endHeading: Heading,
|
||||||
hoveredStartElement: ExcalidrawBindableElement | null,
|
hoveredStartElement: ExcalidrawBindableElement | null,
|
||||||
@@ -687,7 +680,7 @@ const handleSegmentMove = (
|
|||||||
const handleEndpointDrag = (
|
const handleEndpointDrag = (
|
||||||
arrow: ExcalidrawElbowArrowElement,
|
arrow: ExcalidrawElbowArrowElement,
|
||||||
updatedPoints: readonly LocalPoint[],
|
updatedPoints: readonly LocalPoint[],
|
||||||
fixedSegments: readonly FixedSegment[],
|
fixedSegments: FixedSegment[],
|
||||||
startHeading: Heading,
|
startHeading: Heading,
|
||||||
endHeading: Heading,
|
endHeading: Heading,
|
||||||
startGlobalPoint: GlobalPoint,
|
startGlobalPoint: GlobalPoint,
|
||||||
@@ -864,8 +857,6 @@ const handleEndpointDrag = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_POS = 1e6;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
@@ -875,8 +866,6 @@ export const updateElbowArrowPoints = (
|
|||||||
updates: {
|
updates: {
|
||||||
points?: readonly LocalPoint[];
|
points?: readonly LocalPoint[];
|
||||||
fixedSegments?: FixedSegment[] | null;
|
fixedSegments?: FixedSegment[] | null;
|
||||||
startBinding?: FixedPointBinding | null;
|
|
||||||
endBinding?: FixedPointBinding | null;
|
|
||||||
},
|
},
|
||||||
options?: {
|
options?: {
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
@@ -886,50 +875,6 @@ export const updateElbowArrowPoints = (
|
|||||||
return { points: updates.points ?? arrow.points };
|
return { points: updates.points ?? arrow.points };
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow
|
|
||||||
// arrow size is valid. This check will be removed once the issue is identified
|
|
||||||
if (
|
|
||||||
arrow.x < -MAX_POS ||
|
|
||||||
arrow.x > MAX_POS ||
|
|
||||||
arrow.y < -MAX_POS ||
|
|
||||||
arrow.y > MAX_POS ||
|
|
||||||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) <
|
|
||||||
-MAX_POS ||
|
|
||||||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) >
|
|
||||||
MAX_POS ||
|
|
||||||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) <
|
|
||||||
-MAX_POS ||
|
|
||||||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) >
|
|
||||||
MAX_POS ||
|
|
||||||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) <
|
|
||||||
-MAX_POS ||
|
|
||||||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) >
|
|
||||||
MAX_POS ||
|
|
||||||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) <
|
|
||||||
-MAX_POS ||
|
|
||||||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS
|
|
||||||
) {
|
|
||||||
console.error(
|
|
||||||
"Elbow arrow (or update) is outside reasonable bounds (> 1e6)",
|
|
||||||
{
|
|
||||||
arrow,
|
|
||||||
updates,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// @ts-ignore See above note
|
|
||||||
arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS);
|
|
||||||
// @ts-ignore See above note
|
|
||||||
arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS);
|
|
||||||
if (updates.points) {
|
|
||||||
updates.points = updates.points.map(([x, y]) =>
|
|
||||||
pointFrom<LocalPoint>(
|
|
||||||
clamp(x, -MAX_POS, MAX_POS),
|
|
||||||
clamp(y, -MAX_POS, MAX_POS),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!import.meta.env.PROD) {
|
if (!import.meta.env.PROD) {
|
||||||
invariant(
|
invariant(
|
||||||
!updates.points || updates.points.length >= 2,
|
!updates.points || updates.points.length >= 2,
|
||||||
@@ -967,11 +912,7 @@ export const updateElbowArrowPoints = (
|
|||||||
// 0. During all element replacement in the scene, we just need to renormalize
|
// 0. During all element replacement in the scene, we just need to renormalize
|
||||||
// the arrow
|
// the arrow
|
||||||
// TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
|
// TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
|
||||||
if (
|
if (elementsMap.size === 0 && updates.points) {
|
||||||
elementsMap.size === 0 &&
|
|
||||||
updates.points &&
|
|
||||||
validateElbowPoints(updates.points)
|
|
||||||
) {
|
|
||||||
return normalizeArrowElementUpdate(
|
return normalizeArrowElementUpdate(
|
||||||
updates.points.map((p) =>
|
updates.points.map((p) =>
|
||||||
pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]),
|
pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]),
|
||||||
@@ -991,8 +932,8 @@ export const updateElbowArrowPoints = (
|
|||||||
? updates.points![1]
|
? updates.points![1]
|
||||||
: p,
|
: p,
|
||||||
)
|
)
|
||||||
: updates.points.slice()
|
: structuredClone(updates.points)
|
||||||
: arrow.points.slice();
|
: structuredClone(arrow.points);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
startHeading,
|
startHeading,
|
||||||
@@ -1002,48 +943,17 @@ export const updateElbowArrowPoints = (
|
|||||||
hoveredStartElement,
|
hoveredStartElement,
|
||||||
hoveredEndElement,
|
hoveredEndElement,
|
||||||
...rest
|
...rest
|
||||||
} = getElbowArrowData(
|
} = getElbowArrowData(arrow, elementsMap, updatedPoints, options);
|
||||||
{
|
|
||||||
x: arrow.x,
|
|
||||||
y: arrow.y,
|
|
||||||
startBinding:
|
|
||||||
typeof updates.startBinding !== "undefined"
|
|
||||||
? updates.startBinding
|
|
||||||
: arrow.startBinding,
|
|
||||||
endBinding:
|
|
||||||
typeof updates.endBinding !== "undefined"
|
|
||||||
? updates.endBinding
|
|
||||||
: arrow.endBinding,
|
|
||||||
startArrowhead: arrow.startArrowhead,
|
|
||||||
endArrowhead: arrow.endArrowhead,
|
|
||||||
},
|
|
||||||
elementsMap,
|
|
||||||
updatedPoints,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
|
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
|
||||||
|
|
||||||
////
|
////
|
||||||
// 1. Renormalize the arrow
|
// 1. Renormalize the arrow
|
||||||
////
|
////
|
||||||
if (
|
if (!updates.points && !updates.fixedSegments) {
|
||||||
!updates.points &&
|
|
||||||
!updates.fixedSegments &&
|
|
||||||
!updates.startBinding &&
|
|
||||||
!updates.endBinding
|
|
||||||
) {
|
|
||||||
return handleSegmentRenormalization(arrow, elementsMap);
|
return handleSegmentRenormalization(arrow, elementsMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Short circuit on no-op to avoid huge performance hit
|
|
||||||
if (
|
|
||||||
updates.startBinding === arrow.startBinding &&
|
|
||||||
updates.endBinding === arrow.endBinding
|
|
||||||
) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
////
|
////
|
||||||
// 2. Just normal elbow arrow things
|
// 2. Just normal elbow arrow things
|
||||||
////
|
////
|
||||||
@@ -1090,7 +1000,6 @@ export const updateElbowArrowPoints = (
|
|||||||
|
|
||||||
////
|
////
|
||||||
// 5. Handle resize
|
// 5. Handle resize
|
||||||
////
|
|
||||||
if (updates.points && updates.fixedSegments) {
|
if (updates.points && updates.fixedSegments) {
|
||||||
return updates;
|
return updates;
|
||||||
}
|
}
|
||||||
@@ -2012,7 +1921,7 @@ const getBindableElementForId = (
|
|||||||
|
|
||||||
const normalizeArrowElementUpdate = (
|
const normalizeArrowElementUpdate = (
|
||||||
global: GlobalPoint[],
|
global: GlobalPoint[],
|
||||||
nextFixedSegments: readonly FixedSegment[] | null,
|
nextFixedSegments: FixedSegment[] | null,
|
||||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||||
): {
|
): {
|
||||||
@@ -2021,51 +1930,24 @@ const normalizeArrowElementUpdate = (
|
|||||||
y: number;
|
y: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
fixedSegments: readonly FixedSegment[] | null;
|
fixedSegments: FixedSegment[] | null;
|
||||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
||||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
||||||
} => {
|
} => {
|
||||||
const offsetX = global[0][0];
|
const offsetX = global[0][0];
|
||||||
const offsetY = global[0][1];
|
const offsetY = global[0][1];
|
||||||
|
|
||||||
let points = global.map((p) =>
|
const points = global.map((p) =>
|
||||||
pointTranslate<GlobalPoint, LocalPoint>(
|
pointTranslate<GlobalPoint, LocalPoint>(
|
||||||
p,
|
p,
|
||||||
vectorScale(vectorFromPoint(global[0]), -1),
|
vectorScale(vectorFromPoint(global[0]), -1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// NOTE (mtolmacs): This is a temporary check to see if the normalization
|
|
||||||
// creates an overly large arrow. This should be removed once we have an answer.
|
|
||||||
if (
|
|
||||||
offsetX < -MAX_POS ||
|
|
||||||
offsetX > MAX_POS ||
|
|
||||||
offsetY < -MAX_POS ||
|
|
||||||
offsetY > MAX_POS ||
|
|
||||||
offsetX + points[points.length - 1][0] < -MAX_POS ||
|
|
||||||
offsetY + points[points.length - 1][0] > MAX_POS ||
|
|
||||||
offsetX + points[points.length - 1][1] < -MAX_POS ||
|
|
||||||
offsetY + points[points.length - 1][1] > MAX_POS
|
|
||||||
) {
|
|
||||||
console.error(
|
|
||||||
"Elbow arrow normalization is outside reasonable bounds (> 1e6)",
|
|
||||||
{
|
|
||||||
x: offsetX,
|
|
||||||
y: offsetY,
|
|
||||||
points,
|
|
||||||
...getSizeFromPoints(points),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
points = points.map(([x, y]) =>
|
|
||||||
pointFrom<LocalPoint>(clamp(x, -1e6, 1e6), clamp(y, -1e6, 1e6)),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
points,
|
points,
|
||||||
x: clamp(offsetX, -1e6, 1e6),
|
x: offsetX,
|
||||||
y: clamp(offsetY, -1e6, 1e6),
|
y: offsetY,
|
||||||
fixedSegments:
|
fixedSegments:
|
||||||
(nextFixedSegments?.length ?? 0) > 0 ? nextFixedSegments : null,
|
(nextFixedSegments?.length ?? 0) > 0 ? nextFixedSegments : null,
|
||||||
...getSizeFromPoints(points),
|
...getSizeFromPoints(points),
|
||||||
@@ -2228,7 +2110,6 @@ const getHoveredElements = (
|
|||||||
nonDeletedSceneElementsMap,
|
nonDeletedSceneElementsMap,
|
||||||
zoom,
|
zoom,
|
||||||
true,
|
true,
|
||||||
true,
|
|
||||||
),
|
),
|
||||||
getHoveredElementForBinding(
|
getHoveredElementForBinding(
|
||||||
tupleToCoors(origEndGlobalPoint),
|
tupleToCoors(origEndGlobalPoint),
|
||||||
@@ -2236,23 +2117,9 @@ const getHoveredElements = (
|
|||||||
nonDeletedSceneElementsMap,
|
nonDeletedSceneElementsMap,
|
||||||
zoom,
|
zoom,
|
||||||
true,
|
true,
|
||||||
true,
|
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
|
const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
|
||||||
a[0] === b[0] && a[1] === b[1];
|
a[0] === b[0] && a[1] === b[1];
|
||||||
|
|
||||||
const validateElbowPoints = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
points: readonly P[],
|
|
||||||
tolerance: number = DEDUP_TRESHOLD,
|
|
||||||
) =>
|
|
||||||
points
|
|
||||||
.slice(1)
|
|
||||||
.map(
|
|
||||||
(p, i) =>
|
|
||||||
Math.abs(p[0] - points[i][0]) < tolerance ||
|
|
||||||
Math.abs(p[1] - points[i][1]) < tolerance,
|
|
||||||
)
|
|
||||||
.every(Boolean);
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { register } from "../actions/register";
|
import { register } from "../actions/register";
|
||||||
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
|
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
|
||||||
import type { ExcalidrawProps } from "../types";
|
import type { ExcalidrawProps } from "../types";
|
||||||
import { escapeDoubleQuotes, getFontString, updateActiveTool } from "../utils";
|
import {
|
||||||
|
getFontString,
|
||||||
|
sanitizeHTMLAttribute,
|
||||||
|
updateActiveTool,
|
||||||
|
} from "../utils";
|
||||||
import { setCursorForShape } from "../cursor";
|
import { setCursorForShape } from "../cursor";
|
||||||
import { newTextElement } from "./newElement";
|
import { newTextElement } from "./newElement";
|
||||||
import { wrapText } from "./textWrapping";
|
import { wrapText } from "./textWrapping";
|
||||||
@@ -208,7 +212,7 @@ export const getEmbedLink = (
|
|||||||
// Note that we don't attempt to parse the username as it can consist of
|
// Note that we don't attempt to parse the username as it can consist of
|
||||||
// non-latin1 characters, and the username in the url can be set to anything
|
// non-latin1 characters, and the username in the url can be set to anything
|
||||||
// without affecting the embed.
|
// without affecting the embed.
|
||||||
const safeURL = escapeDoubleQuotes(
|
const safeURL = sanitizeHTMLAttribute(
|
||||||
`https://twitter.com/x/status/${postId}`,
|
`https://twitter.com/x/status/${postId}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -227,7 +231,7 @@ export const getEmbedLink = (
|
|||||||
|
|
||||||
if (RE_REDDIT.test(link)) {
|
if (RE_REDDIT.test(link)) {
|
||||||
const [, page, postId, title] = link.match(RE_REDDIT)!;
|
const [, page, postId, title] = link.match(RE_REDDIT)!;
|
||||||
const safeURL = escapeDoubleQuotes(
|
const safeURL = sanitizeHTMLAttribute(
|
||||||
`https://reddit.com/r/${page}/comments/${postId}/${title}`,
|
`https://reddit.com/r/${page}/comments/${postId}/${title}`,
|
||||||
);
|
);
|
||||||
const ret: IframeDataWithSandbox = {
|
const ret: IframeDataWithSandbox = {
|
||||||
@@ -245,7 +249,7 @@ export const getEmbedLink = (
|
|||||||
|
|
||||||
if (RE_GH_GIST.test(link)) {
|
if (RE_GH_GIST.test(link)) {
|
||||||
const [, user, gistId] = link.match(RE_GH_GIST)!;
|
const [, user, gistId] = link.match(RE_GH_GIST)!;
|
||||||
const safeURL = escapeDoubleQuotes(
|
const safeURL = sanitizeHTMLAttribute(
|
||||||
`https://gist.github.com/${user}/${gistId}`,
|
`https://gist.github.com/${user}/${gistId}`,
|
||||||
);
|
);
|
||||||
const ret: IframeDataWithSandbox = {
|
const ret: IframeDataWithSandbox = {
|
||||||
|
|||||||
@@ -310,4 +310,95 @@ describe("flow chart navigation", () => {
|
|||||||
Keyboard.keyUp(KEYS.ALT);
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("take the most obvious link when possible", () => {
|
||||||
|
/**
|
||||||
|
* ▨ → ▨ ▨ → ▨
|
||||||
|
* ↓ ↑
|
||||||
|
* ▨ → ▨
|
||||||
|
*/
|
||||||
|
|
||||||
|
API.clearSelection();
|
||||||
|
const rectangle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([rectangle]);
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
// last node should be the one that's selected
|
||||||
|
const rightMostNode = h.elements[h.elements.length - 2];
|
||||||
|
expect(rightMostNode.type).toBe("rectangle");
|
||||||
|
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
|
||||||
|
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||||
|
|
||||||
|
// going any direction takes us to the predecessor as well
|
||||||
|
const predecessorToRightMostNode = h.elements[h.elements.length - 4];
|
||||||
|
expect(predecessorToRightMostNode.type).toBe("rectangle");
|
||||||
|
|
||||||
|
API.setSelectedElements([rightMostNode]);
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||||
|
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
API.setSelectedElements([rightMostNode]);
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||||
|
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
API.setSelectedElements([rightMostNode]);
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||||
|
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,15 +10,13 @@ import {
|
|||||||
import { bindLinearElement } from "./binding";
|
import { bindLinearElement } from "./binding";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { newArrowElement, newElement } from "./newElement";
|
import { newArrowElement, newElement } from "./newElement";
|
||||||
import type { SceneElementsMap } from "./types";
|
import type {
|
||||||
import {
|
ElementsMap,
|
||||||
type ElementsMap,
|
ExcalidrawBindableElement,
|
||||||
type ExcalidrawBindableElement,
|
ExcalidrawElement,
|
||||||
type ExcalidrawElement,
|
ExcalidrawFlowchartNodeElement,
|
||||||
type ExcalidrawFlowchartNodeElement,
|
NonDeletedSceneElementsMap,
|
||||||
type NonDeletedSceneElementsMap,
|
OrderedExcalidrawElement,
|
||||||
type Ordered,
|
|
||||||
type OrderedExcalidrawElement,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import type { AppState, PendingExcalidrawElements } from "../types";
|
import type { AppState, PendingExcalidrawElements } from "../types";
|
||||||
@@ -30,13 +28,9 @@ import {
|
|||||||
isFrameElement,
|
isFrameElement,
|
||||||
isFlowchartNodeElement,
|
isFlowchartNodeElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { invariant, toBrandedType } from "../utils";
|
import { invariant } from "../utils";
|
||||||
import { pointFrom, type LocalPoint } from "../../math";
|
import { pointFrom, type LocalPoint } from "../../math";
|
||||||
import { aabbForElement } from "../shapes";
|
import { aabbForElement } from "../shapes";
|
||||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
|
||||||
import type App from "../components/App";
|
|
||||||
import { makeNextSelectedElementIds } from "../scene/selection";
|
|
||||||
import { isElementCompletelyInViewport } from "./sizeHelpers";
|
|
||||||
|
|
||||||
type LinkDirection = "up" | "right" | "down" | "left";
|
type LinkDirection = "up" | "right" | "down" | "left";
|
||||||
|
|
||||||
@@ -473,75 +467,67 @@ const createBindingArrow = (
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const update = updateElbowArrowPoints(
|
return bindingArrow;
|
||||||
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,
|
|
||||||
...update,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class FlowChartNavigator {
|
export class FlowChartNavigator {
|
||||||
isExploring: boolean = false;
|
isExploring: boolean = false;
|
||||||
|
// nodes that are ONE link away (successor and predecessor both included)
|
||||||
private app: App;
|
private sameLevelNodes: ExcalidrawElement[] = [];
|
||||||
private siblingNodes: ExcalidrawElement[] = [];
|
private sameLevelIndex: number = 0;
|
||||||
private siblingIndex: number = 0;
|
// set it to the opposite of the defalut creation direction
|
||||||
private direction: LinkDirection | null = null;
|
private direction: LinkDirection | null = null;
|
||||||
|
// for speedier navigation
|
||||||
constructor(app: App) {
|
private visitedNodes: Set<ExcalidrawElement["id"]> = new Set();
|
||||||
this.app = app;
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.isExploring = false;
|
this.isExploring = false;
|
||||||
this.siblingNodes = [];
|
this.sameLevelNodes = [];
|
||||||
this.siblingIndex = 0;
|
this.sameLevelIndex = 0;
|
||||||
this.direction = null;
|
this.direction = null;
|
||||||
|
this.visitedNodes.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
exploreByDirection(
|
||||||
* Explore the flowchart by the given direction.
|
element: ExcalidrawElement,
|
||||||
*
|
elementsMap: ElementsMap,
|
||||||
* The exploration follows a (near) breadth-first approach: when there're multiple
|
direction: LinkDirection,
|
||||||
* nodes at the same level, we allow the user to traverse through them.
|
): ExcalidrawElement["id"] | null {
|
||||||
*/
|
|
||||||
exploreByDirection(element: ExcalidrawElement, direction: LinkDirection) {
|
|
||||||
if (!isBindableElement(element)) {
|
if (!isBindableElement(element)) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const elementsMap = this.app.scene.getNonDeletedElementsMap();
|
|
||||||
|
|
||||||
// clear if going at a different direction
|
// clear if going at a different direction
|
||||||
if (direction !== this.direction) {
|
if (direction !== this.direction) {
|
||||||
this.clear();
|
this.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// add the current node to the visited
|
||||||
|
if (!this.visitedNodes.has(element.id)) {
|
||||||
|
this.visitedNodes.add(element.id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* if we're already exploring (holding the alt key)
|
* CASE:
|
||||||
* and the direction is the same as the previous one
|
* - already started exploring, AND
|
||||||
* and there're multiple nodes at the same level
|
* - there are multiple nodes at the same level, AND
|
||||||
* then we should traverse through them before moving to the next level
|
* - still going at the same direction, AND
|
||||||
|
*
|
||||||
|
* RESULT:
|
||||||
|
* - loop through nodes at the same level
|
||||||
|
*
|
||||||
|
* WHY:
|
||||||
|
* - provides user the capability to loop through nodes at the same level
|
||||||
*/
|
*/
|
||||||
if (
|
if (
|
||||||
this.isExploring &&
|
this.isExploring &&
|
||||||
direction === this.direction &&
|
direction === this.direction &&
|
||||||
this.siblingNodes.length > 1
|
this.sameLevelNodes.length > 1
|
||||||
) {
|
) {
|
||||||
this.siblingIndex = (this.siblingIndex + 1) % this.siblingNodes.length;
|
this.sameLevelIndex =
|
||||||
return this.goToNode(this.siblingNodes[this.siblingIndex].id);
|
(this.sameLevelIndex + 1) % this.sameLevelNodes.length;
|
||||||
|
|
||||||
|
return this.sameLevelNodes[this.sameLevelIndex].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes = [
|
const nodes = [
|
||||||
@@ -549,52 +535,70 @@ export class FlowChartNavigator {
|
|||||||
...getPredecessors(element, elementsMap, direction),
|
...getPredecessors(element, elementsMap, direction),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CASE:
|
||||||
|
* - just started exploring at the given direction
|
||||||
|
*
|
||||||
|
* RESULT:
|
||||||
|
* - go to the first node in the given direction
|
||||||
|
*/
|
||||||
if (nodes.length > 0) {
|
if (nodes.length > 0) {
|
||||||
this.siblingIndex = 0;
|
this.sameLevelIndex = 0;
|
||||||
this.isExploring = true;
|
this.isExploring = true;
|
||||||
this.siblingNodes = nodes;
|
this.sameLevelNodes = nodes;
|
||||||
this.direction = direction;
|
this.direction = direction;
|
||||||
|
this.visitedNodes.add(nodes[0].id);
|
||||||
|
|
||||||
this.goToNode(nodes[0].id);
|
return nodes[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CASE:
|
||||||
|
* - (just started exploring or still going at the same direction) OR
|
||||||
|
* - there're no nodes at the given direction
|
||||||
|
*
|
||||||
|
* RESULT:
|
||||||
|
* - go to some other unvisited linked node
|
||||||
|
*
|
||||||
|
* WHY:
|
||||||
|
* - provide a speedier navigation from a given node to some predecessor
|
||||||
|
* without the user having to change arrow key
|
||||||
|
*/
|
||||||
|
if (direction === this.direction || !this.isExploring) {
|
||||||
|
if (!this.isExploring) {
|
||||||
|
// just started and no other nodes at the given direction
|
||||||
|
// so the current node is technically the first visited node
|
||||||
|
// (this is needed so that we don't get stuck between looping through )
|
||||||
|
this.visitedNodes.add(element.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherDirections: LinkDirection[] = [
|
||||||
|
"up",
|
||||||
|
"right",
|
||||||
|
"down",
|
||||||
|
"left",
|
||||||
|
].filter((dir): dir is LinkDirection => dir !== direction);
|
||||||
|
|
||||||
|
const otherLinkedNodes = otherDirections
|
||||||
|
.map((dir) => [
|
||||||
|
...getSuccessors(element, elementsMap, dir),
|
||||||
|
...getPredecessors(element, elementsMap, dir),
|
||||||
|
])
|
||||||
|
.flat()
|
||||||
|
.filter((linkedNode) => !this.visitedNodes.has(linkedNode.id));
|
||||||
|
|
||||||
|
for (const linkedNode of otherLinkedNodes) {
|
||||||
|
if (!this.visitedNodes.has(linkedNode.id)) {
|
||||||
|
this.visitedNodes.add(linkedNode.id);
|
||||||
|
this.isExploring = true;
|
||||||
|
this.direction = direction;
|
||||||
|
return linkedNode.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private goToNode = (nodeId: ExcalidrawElement["id"]) => {
|
|
||||||
this.app.setState((prevState) => ({
|
|
||||||
selectedElementIds: makeNextSelectedElementIds(
|
|
||||||
{
|
|
||||||
[nodeId]: true,
|
|
||||||
},
|
|
||||||
prevState,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const nextNode = this.app.scene.getNonDeletedElementsMap().get(nodeId);
|
|
||||||
|
|
||||||
if (
|
|
||||||
nextNode &&
|
|
||||||
!isElementCompletelyInViewport(
|
|
||||||
[nextNode],
|
|
||||||
this.app.canvas.width / window.devicePixelRatio,
|
|
||||||
this.app.canvas.height / window.devicePixelRatio,
|
|
||||||
{
|
|
||||||
offsetLeft: this.app.state.offsetLeft,
|
|
||||||
offsetTop: this.app.state.offsetTop,
|
|
||||||
scrollX: this.app.state.scrollX,
|
|
||||||
scrollY: this.app.state.scrollY,
|
|
||||||
zoom: this.app.state.zoom,
|
|
||||||
},
|
|
||||||
this.app.scene.getNonDeletedElementsMap(),
|
|
||||||
this.app.getEditorUIOffsets(),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
this.app.scrollToContent(nextNode, {
|
|
||||||
animate: true,
|
|
||||||
duration: 300,
|
|
||||||
canvasOffsets: this.app.getEditorUIOffsets(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FlowChartCreator {
|
export class FlowChartCreator {
|
||||||
|
|||||||
@@ -444,8 +444,6 @@ export class LinearElementEditor {
|
|||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
appState.zoom,
|
appState.zoom,
|
||||||
isElbowArrow(element),
|
|
||||||
isElbowArrow(element),
|
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -798,7 +796,6 @@ export class LinearElementEditor {
|
|||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
app.state.zoom,
|
app.state.zoom,
|
||||||
linearElementEditor.elbowed,
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -33,16 +33,13 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
|
|
||||||
// casting to any because can't use `in` operator
|
// casting to any because can't use `in` operator
|
||||||
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
||||||
const { points, fixedSegments, fileId, startBinding, endBinding } =
|
const { points, fixedSegments, fileId } = updates as any;
|
||||||
updates as any;
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isElbowArrow(element) &&
|
isElbowArrow(element) &&
|
||||||
(Object.keys(updates).length === 0 || // normalization case
|
(Object.keys(updates).length === 0 || // normalization case
|
||||||
typeof points !== "undefined" || // repositioning
|
typeof points !== "undefined" || // repositioning
|
||||||
typeof fixedSegments !== "undefined" || // segment fixing
|
typeof fixedSegments !== "undefined") // segment fixing
|
||||||
typeof startBinding !== "undefined" ||
|
|
||||||
typeof endBinding !== "undefined") // manual binding to element
|
|
||||||
) {
|
) {
|
||||||
const elementsMap = toBrandedType<SceneElementsMap>(
|
const elementsMap = toBrandedType<SceneElementsMap>(
|
||||||
Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
|
Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
|
||||||
@@ -61,8 +58,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
{
|
{
|
||||||
fixedSegments,
|
fixedSegments,
|
||||||
points,
|
points,
|
||||||
startBinding,
|
|
||||||
endBinding,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isDragging: options?.isDragging,
|
isDragging: options?.isDragging,
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ import { getNewGroupIdsForDuplication } from "../groups";
|
|||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { getElementAbsoluteCoords } from ".";
|
import { getElementAbsoluteCoords } from ".";
|
||||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||||
import { getBoundTextMaxWidth } from "./textElement";
|
import {
|
||||||
|
measureText,
|
||||||
|
normalizeText,
|
||||||
|
getBoundTextMaxWidth,
|
||||||
|
} from "./textElement";
|
||||||
import { wrapText } from "./textWrapping";
|
import { wrapText } from "./textWrapping";
|
||||||
import {
|
import {
|
||||||
DEFAULT_ELEMENT_PROPS,
|
DEFAULT_ELEMENT_PROPS,
|
||||||
@@ -41,13 +45,11 @@ import {
|
|||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
DEFAULT_VERTICAL_ALIGN,
|
DEFAULT_VERTICAL_ALIGN,
|
||||||
ORIG_ID,
|
|
||||||
VERTICAL_ALIGN,
|
VERTICAL_ALIGN,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import type { MarkOptional, Merge, Mutable } from "../utility-types";
|
import type { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||||
import { getLineHeight } from "../fonts";
|
import { getLineHeight } from "../fonts";
|
||||||
import type { Radians } from "../../math";
|
import type { Radians } from "../../math";
|
||||||
import { normalizeText, measureText } from "./textMeasurements";
|
|
||||||
|
|
||||||
export type ElementConstructorOpts = MarkOptional<
|
export type ElementConstructorOpts = MarkOptional<
|
||||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||||
@@ -99,28 +101,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||||||
...rest
|
...rest
|
||||||
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
||||||
) => {
|
) => {
|
||||||
// NOTE (mtolmacs): This is a temporary check to detect extremely large
|
|
||||||
// element position or sizing
|
|
||||||
if (
|
|
||||||
x < -1e6 ||
|
|
||||||
x > 1e6 ||
|
|
||||||
y < -1e6 ||
|
|
||||||
y > 1e6 ||
|
|
||||||
width < -1e6 ||
|
|
||||||
width > 1e6 ||
|
|
||||||
height < -1e6 ||
|
|
||||||
height > 1e6
|
|
||||||
) {
|
|
||||||
console.error("New element size or position is too large", {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
// @ts-ignore
|
|
||||||
points: rest.points,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// assign type to guard against excess properties
|
// assign type to guard against excess properties
|
||||||
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
|
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
|
||||||
id: rest.id || randomId(),
|
id: rest.id || randomId(),
|
||||||
@@ -612,18 +592,26 @@ export const deepCopyElement = <T extends ExcalidrawElement>(
|
|||||||
return _deepCopyElement(val);
|
return _deepCopyElement(val);
|
||||||
};
|
};
|
||||||
|
|
||||||
const __test__defineOrigId = (clonedObj: object, origId: string) => {
|
|
||||||
Object.defineProperty(clonedObj, ORIG_ID, {
|
|
||||||
value: origId,
|
|
||||||
writable: false,
|
|
||||||
enumerable: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* utility wrapper to generate new id.
|
* utility wrapper to generate new id. In test env it reuses the old + postfix
|
||||||
|
* for test assertions.
|
||||||
*/
|
*/
|
||||||
const regenerateId = () => {
|
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;
|
||||||
|
}
|
||||||
return randomId();
|
return randomId();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -649,11 +637,7 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
|
|||||||
): Readonly<TElement> => {
|
): Readonly<TElement> => {
|
||||||
let copy = deepCopyElement(element);
|
let copy = deepCopyElement(element);
|
||||||
|
|
||||||
if (isTestEnv()) {
|
copy.id = regenerateId(copy.id);
|
||||||
__test__defineOrigId(copy, element.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
copy.id = regenerateId();
|
|
||||||
copy.boundElements = null;
|
copy.boundElements = null;
|
||||||
copy.updated = getUpdatedTimestamp();
|
copy.updated = getUpdatedTimestamp();
|
||||||
copy.seed = randomInteger();
|
copy.seed = randomInteger();
|
||||||
@@ -662,7 +646,7 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
|
|||||||
editingGroupId,
|
editingGroupId,
|
||||||
(groupId) => {
|
(groupId) => {
|
||||||
if (!groupIdMapForOperation.has(groupId)) {
|
if (!groupIdMapForOperation.has(groupId)) {
|
||||||
groupIdMapForOperation.set(groupId, regenerateId());
|
groupIdMapForOperation.set(groupId, regenerateId(groupId));
|
||||||
}
|
}
|
||||||
return groupIdMapForOperation.get(groupId)!;
|
return groupIdMapForOperation.get(groupId)!;
|
||||||
},
|
},
|
||||||
@@ -708,7 +692,7 @@ export const duplicateElements = (
|
|||||||
// if we haven't migrated the element id, but an old element with the same
|
// if we haven't migrated the element id, but an old element with the same
|
||||||
// id exists, generate a new id for it and return it
|
// id exists, generate a new id for it and return it
|
||||||
if (origElementsMap.has(id)) {
|
if (origElementsMap.has(id)) {
|
||||||
const newId = regenerateId();
|
const newId = regenerateId(id);
|
||||||
elementNewIdsMap.set(id, newId);
|
elementNewIdsMap.set(id, newId);
|
||||||
return newId;
|
return newId;
|
||||||
}
|
}
|
||||||
@@ -722,9 +706,6 @@ export const duplicateElements = (
|
|||||||
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
|
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
|
||||||
|
|
||||||
clonedElement.id = maybeGetNewId(element.id)!;
|
clonedElement.id = maybeGetNewId(element.id)!;
|
||||||
if (isTestEnv()) {
|
|
||||||
__test__defineOrigId(clonedElement, element.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts?.randomizeSeed) {
|
if (opts?.randomizeSeed) {
|
||||||
clonedElement.seed = randomInteger();
|
clonedElement.seed = randomInteger();
|
||||||
@@ -734,7 +715,7 @@ export const duplicateElements = (
|
|||||||
if (clonedElement.groupIds) {
|
if (clonedElement.groupIds) {
|
||||||
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
|
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
|
||||||
if (!groupNewIdsMap.has(groupId)) {
|
if (!groupNewIdsMap.has(groupId)) {
|
||||||
groupNewIdsMap.set(groupId, regenerateId());
|
groupNewIdsMap.set(groupId, regenerateId(groupId));
|
||||||
}
|
}
|
||||||
return groupNewIdsMap.get(groupId)!;
|
return groupNewIdsMap.get(groupId)!;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,11 +41,15 @@ import type {
|
|||||||
import type { PointerDownState } from "../types";
|
import type { PointerDownState } from "../types";
|
||||||
import type Scene from "../scene/Scene";
|
import type Scene from "../scene/Scene";
|
||||||
import {
|
import {
|
||||||
|
getApproxMinLineWidth,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getBoundTextElementId,
|
getBoundTextElementId,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
handleBindTextResize,
|
handleBindTextResize,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
|
getApproxMinLineHeight,
|
||||||
|
measureText,
|
||||||
|
getMinTextElementWidth,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import { wrapText } from "./textWrapping";
|
import { wrapText } from "./textWrapping";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
@@ -60,12 +64,6 @@ import {
|
|||||||
type Radians,
|
type Radians,
|
||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
} from "../../math";
|
} from "../../math";
|
||||||
import {
|
|
||||||
getMinTextElementWidth,
|
|
||||||
measureText,
|
|
||||||
getApproxMinLineWidth,
|
|
||||||
getApproxMinLineHeight,
|
|
||||||
} from "./textMeasurements";
|
|
||||||
|
|
||||||
// Returns true when transform (resizing/rotation) happened
|
// Returns true when transform (resizing/rotation) happened
|
||||||
export const transformElements = (
|
export const transformElements = (
|
||||||
@@ -769,26 +767,6 @@ const getResizedOrigin = (
|
|||||||
y: y - (newHeight - prevHeight) / 2,
|
y: y - (newHeight - prevHeight) / 2,
|
||||||
};
|
};
|
||||||
case "east-side":
|
case "east-side":
|
||||||
// NOTE (mtolmacs): Reverting this for a short period to test if it is
|
|
||||||
// the cause of the megasized elbow arrows showing up.
|
|
||||||
if (
|
|
||||||
Math.abs(
|
|
||||||
y +
|
|
||||||
((prevWidth - newWidth) / 2) * Math.sin(angle) +
|
|
||||||
(prevHeight - newHeight) / 2,
|
|
||||||
) > 1e6
|
|
||||||
) {
|
|
||||||
console.error(
|
|
||||||
"getResizedOrigin() new calculation creates extremely large (> 1e6) y value where the old calculation resulted in",
|
|
||||||
{
|
|
||||||
result:
|
|
||||||
y +
|
|
||||||
(newHeight - prevHeight) / 2 +
|
|
||||||
((prevWidth - newWidth) / 2) * Math.sin(angle),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: x + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1),
|
x: x + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1),
|
||||||
y:
|
y:
|
||||||
|
|||||||
@@ -116,5 +116,8 @@ const normalizeBoundElementsOrder = (
|
|||||||
export const normalizeElementOrder = (
|
export const normalizeElementOrder = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
|
// console.time();
|
||||||
|
const ret = normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
|
||||||
|
// console.timeEnd();
|
||||||
|
return ret;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import {
|
|||||||
getContainerCoords,
|
getContainerCoords,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
getBoundTextMaxHeight,
|
getBoundTextMaxHeight,
|
||||||
|
detectLineHeight,
|
||||||
|
getLineHeightInPx,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import { detectLineHeight, getLineHeightInPx } from "./textMeasurements";
|
|
||||||
import type { ExcalidrawTextElementWithContainer } from "./types";
|
import type { ExcalidrawTextElementWithContainer } from "./types";
|
||||||
|
|
||||||
describe("Test measureText", () => {
|
describe("Test measureText", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getFontString, arrayToMap } from "../utils";
|
import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils";
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
ExcalidrawTextContainer,
|
ExcalidrawTextContainer,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
|
FontString,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
|
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
|
||||||
ARROW_LABEL_WIDTH_FRACTION,
|
ARROW_LABEL_WIDTH_FRACTION,
|
||||||
BOUND_TEXT_PADDING,
|
BOUND_TEXT_PADDING,
|
||||||
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
TEXT_ALIGN,
|
TEXT_ALIGN,
|
||||||
VERTICAL_ALIGN,
|
VERTICAL_ALIGN,
|
||||||
@@ -28,7 +30,18 @@ import {
|
|||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
} from "./containerCache";
|
} from "./containerCache";
|
||||||
import type { ExtractSetType } from "../utility-types";
|
import type { ExtractSetType } from "../utility-types";
|
||||||
import { measureText } from "./textMeasurements";
|
|
||||||
|
export const normalizeText = (text: string) => {
|
||||||
|
return (
|
||||||
|
normalizeEOL(text)
|
||||||
|
// replace tabs with spaces so they render and measure correctly
|
||||||
|
.replace(/\t/g, " ")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitIntoLines = (text: string) => {
|
||||||
|
return normalizeText(text).split("\n");
|
||||||
|
};
|
||||||
|
|
||||||
export const redrawTextBoundingBox = (
|
export const redrawTextBoundingBox = (
|
||||||
textElement: ExcalidrawTextElement,
|
textElement: ExcalidrawTextElement,
|
||||||
@@ -268,6 +281,201 @@ export const computeBoundTextPosition = (
|
|||||||
return { x, y };
|
return { x, y };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const measureText = (
|
||||||
|
text: string,
|
||||||
|
font: FontString,
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
forceAdvanceWidth?: true,
|
||||||
|
) => {
|
||||||
|
const _text = text
|
||||||
|
.split("\n")
|
||||||
|
// replace empty lines with single space because leading/trailing empty
|
||||||
|
// lines would be stripped from computation
|
||||||
|
.map((x) => x || " ")
|
||||||
|
.join("\n");
|
||||||
|
const fontSize = parseFloat(font);
|
||||||
|
const height = getTextHeight(_text, fontSize, lineHeight);
|
||||||
|
const width = getTextWidth(_text, font, forceAdvanceWidth);
|
||||||
|
return { width, height };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To get unitless line-height (if unknown) we can calculate it by dividing
|
||||||
|
* height-per-line by fontSize.
|
||||||
|
*/
|
||||||
|
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
|
||||||
|
const lineCount = splitIntoLines(textElement.text).length;
|
||||||
|
return (textElement.height /
|
||||||
|
lineCount /
|
||||||
|
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We calculate the line height from the font size and the unitless line height,
|
||||||
|
* aligning with the W3C spec.
|
||||||
|
*/
|
||||||
|
export const getLineHeightInPx = (
|
||||||
|
fontSize: ExcalidrawTextElement["fontSize"],
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
) => {
|
||||||
|
return fontSize * lineHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
// FIXME rename to getApproxMinContainerHeight
|
||||||
|
export const getApproxMinLineHeight = (
|
||||||
|
fontSize: ExcalidrawTextElement["fontSize"],
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
) => {
|
||||||
|
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
let canvas: HTMLCanvasElement | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param forceAdvanceWidth use to force retrieve the "advance width" ~ `metrics.width`, instead of the actual boundind box width.
|
||||||
|
*
|
||||||
|
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
|
||||||
|
*
|
||||||
|
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
|
||||||
|
* - text wrapping
|
||||||
|
* - wysiwyg editor (+padding)
|
||||||
|
*
|
||||||
|
* Everything else should be based on the actual bounding box width.
|
||||||
|
*
|
||||||
|
* `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies.
|
||||||
|
*/
|
||||||
|
export const getLineWidth = (
|
||||||
|
text: string,
|
||||||
|
font: FontString,
|
||||||
|
forceAdvanceWidth?: true,
|
||||||
|
) => {
|
||||||
|
if (!canvas) {
|
||||||
|
canvas = document.createElement("canvas");
|
||||||
|
}
|
||||||
|
const canvas2dContext = canvas.getContext("2d")!;
|
||||||
|
canvas2dContext.font = font;
|
||||||
|
const metrics = canvas2dContext.measureText(text);
|
||||||
|
|
||||||
|
const advanceWidth = metrics.width;
|
||||||
|
|
||||||
|
// retrieve the actual bounding box width if these metrics are available (as of now > 95% coverage)
|
||||||
|
if (
|
||||||
|
!forceAdvanceWidth &&
|
||||||
|
window.TextMetrics &&
|
||||||
|
"actualBoundingBoxLeft" in window.TextMetrics.prototype &&
|
||||||
|
"actualBoundingBoxRight" in window.TextMetrics.prototype
|
||||||
|
) {
|
||||||
|
// could be negative, therefore getting the absolute value
|
||||||
|
const actualWidth =
|
||||||
|
Math.abs(metrics.actualBoundingBoxLeft) +
|
||||||
|
Math.abs(metrics.actualBoundingBoxRight);
|
||||||
|
|
||||||
|
// fallback to advance width if the actual width is zero, i.e. on text editing start
|
||||||
|
// or when actual width does not respect whitespace chars, i.e. spaces
|
||||||
|
// otherwise actual width should always be bigger
|
||||||
|
return Math.max(actualWidth, advanceWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// since in test env the canvas measureText algo
|
||||||
|
// doesn't measure text and instead just returns number of
|
||||||
|
// characters hence we assume that each letteris 10px
|
||||||
|
if (isTestEnv()) {
|
||||||
|
return advanceWidth * 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return advanceWidth;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTextWidth = (
|
||||||
|
text: string,
|
||||||
|
font: FontString,
|
||||||
|
forceAdvanceWidth?: true,
|
||||||
|
) => {
|
||||||
|
const lines = splitIntoLines(text);
|
||||||
|
let width = 0;
|
||||||
|
lines.forEach((line) => {
|
||||||
|
width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth));
|
||||||
|
});
|
||||||
|
|
||||||
|
return width;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTextHeight = (
|
||||||
|
text: string,
|
||||||
|
fontSize: number,
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
) => {
|
||||||
|
const lineCount = splitIntoLines(text).length;
|
||||||
|
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const charWidth = (() => {
|
||||||
|
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
|
||||||
|
|
||||||
|
const calculate = (char: string, font: FontString) => {
|
||||||
|
const unicode = char.charCodeAt(0);
|
||||||
|
if (!cachedCharWidth[font]) {
|
||||||
|
cachedCharWidth[font] = [];
|
||||||
|
}
|
||||||
|
if (!cachedCharWidth[font][unicode]) {
|
||||||
|
const width = getLineWidth(char, font, true);
|
||||||
|
cachedCharWidth[font][unicode] = width;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedCharWidth[font][unicode];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCache = (font: FontString) => {
|
||||||
|
return cachedCharWidth[font];
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCache = (font: FontString) => {
|
||||||
|
cachedCharWidth[font] = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
calculate,
|
||||||
|
getCache,
|
||||||
|
clearCache,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||||
|
|
||||||
|
// FIXME rename to getApproxMinContainerWidth
|
||||||
|
export const getApproxMinLineWidth = (
|
||||||
|
font: FontString,
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
) => {
|
||||||
|
const maxCharWidth = getMaxCharWidth(font);
|
||||||
|
if (maxCharWidth === 0) {
|
||||||
|
return (
|
||||||
|
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
|
||||||
|
BOUND_TEXT_PADDING * 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return maxCharWidth + BOUND_TEXT_PADDING * 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMinCharWidth = (font: FontString) => {
|
||||||
|
const cache = charWidth.getCache(font);
|
||||||
|
if (!cache) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||||
|
|
||||||
|
return Math.min(...cacheWithOutEmpty);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMaxCharWidth = (font: FontString) => {
|
||||||
|
const cache = charWidth.getCache(font);
|
||||||
|
if (!cache) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||||
|
return Math.max(...cacheWithOutEmpty);
|
||||||
|
};
|
||||||
|
|
||||||
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
|
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
|
||||||
return container?.boundElements?.length
|
return container?.boundElements?.length
|
||||||
? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
|
? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
|
||||||
@@ -504,6 +712,24 @@ export const getBoundTextMaxHeight = (
|
|||||||
return height - BOUND_TEXT_PADDING * 2;
|
return height - BOUND_TEXT_PADDING * 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isMeasureTextSupported = () => {
|
||||||
|
const width = getTextWidth(
|
||||||
|
DUMMY_TEXT,
|
||||||
|
getFontString({
|
||||||
|
fontSize: DEFAULT_FONT_SIZE,
|
||||||
|
fontFamily: DEFAULT_FONT_FAMILY,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return width > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMinTextElementWidth = (
|
||||||
|
font: FontString,
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
) => {
|
||||||
|
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
|
||||||
|
};
|
||||||
|
|
||||||
/** retrieves text from text elements and concatenates to a single string */
|
/** retrieves text from text elements and concatenates to a single string */
|
||||||
export const getTextFromElements = (
|
export const getTextFromElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
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 { ENV } from "../constants";
|
||||||
import { charWidth, getLineWidth } from "./textMeasurements";
|
import { charWidth, getLineWidth } from "./textElement";
|
||||||
import type { FontString } from "./types";
|
import type { FontString } from "./types";
|
||||||
|
|
||||||
let cachedCjkRegex: RegExp | undefined;
|
let cachedCjkRegex: RegExp | undefined;
|
||||||
@@ -385,7 +385,7 @@ export const wrapText = (
|
|||||||
const originalLines = text.split("\n");
|
const originalLines = text.split("\n");
|
||||||
|
|
||||||
for (const originalLine of originalLines) {
|
for (const originalLine of originalLines) {
|
||||||
const currentLineWidth = getLineWidth(originalLine, font);
|
const currentLineWidth = getLineWidth(originalLine, font, true);
|
||||||
|
|
||||||
if (currentLineWidth <= maxWidth) {
|
if (currentLineWidth <= maxWidth) {
|
||||||
lines.push(originalLine);
|
lines.push(originalLine);
|
||||||
@@ -423,7 +423,7 @@ const wrapLine = (
|
|||||||
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
|
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
|
||||||
const testLineWidth = isSingleCharacter(token)
|
const testLineWidth = isSingleCharacter(token)
|
||||||
? currentLineWidth + charWidth.calculate(token, font)
|
? currentLineWidth + charWidth.calculate(token, font)
|
||||||
: getLineWidth(testLine, font);
|
: getLineWidth(testLine, font, true);
|
||||||
|
|
||||||
// build up the current line, skipping length check for possibly trailing whitespaces
|
// build up the current line, skipping length check for possibly trailing whitespaces
|
||||||
if (/\s/.test(token) || testLineWidth <= maxWidth) {
|
if (/\s/.test(token) || testLineWidth <= maxWidth) {
|
||||||
@@ -443,7 +443,7 @@ const wrapLine = (
|
|||||||
|
|
||||||
// trailing line of the wrapped word might still be joined with next token/s
|
// trailing line of the wrapped word might still be joined with next token/s
|
||||||
currentLine = trailingLine;
|
currentLine = trailingLine;
|
||||||
currentLineWidth = getLineWidth(trailingLine, font);
|
currentLineWidth = getLineWidth(trailingLine, font, true);
|
||||||
iterator = tokenIterator.next();
|
iterator = tokenIterator.next();
|
||||||
} else {
|
} else {
|
||||||
// push & reset, but don't iterate on the next token, as we didn't use it yet!
|
// push & reset, but don't iterate on the next token, as we didn't use it yet!
|
||||||
@@ -514,7 +514,7 @@ const wrapWord = (
|
|||||||
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
|
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
|
||||||
*/
|
*/
|
||||||
const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
||||||
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
|
const shouldTrimWhitespaces = getLineWidth(line, font, true) > maxWidth;
|
||||||
|
|
||||||
if (!shouldTrimWhitespaces) {
|
if (!shouldTrimWhitespaces) {
|
||||||
return line;
|
return line;
|
||||||
@@ -527,7 +527,7 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
|||||||
"",
|
"",
|
||||||
];
|
];
|
||||||
|
|
||||||
let trimmedLineWidth = getLineWidth(trimmedLine, font);
|
let trimmedLineWidth = getLineWidth(trimmedLine, font, true);
|
||||||
|
|
||||||
for (const whitespace of Array.from(whitespaces)) {
|
for (const whitespace of Array.from(whitespaces)) {
|
||||||
const _charWidth = charWidth.calculate(whitespace, font);
|
const _charWidth = charWidth.calculate(whitespace, font);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
getBoundTextElementId,
|
getBoundTextElementId,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
getTextElementAngle,
|
getTextElementAngle,
|
||||||
|
getTextWidth,
|
||||||
|
normalizeText,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
getBoundTextMaxHeight,
|
getBoundTextMaxHeight,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
@@ -48,8 +50,6 @@ import {
|
|||||||
originalContainerCache,
|
originalContainerCache,
|
||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
} from "./containerCache";
|
} from "./containerCache";
|
||||||
import { getTextWidth } from "./textMeasurements";
|
|
||||||
import { normalizeText } from "./textMeasurements";
|
|
||||||
|
|
||||||
const getTransform = (
|
const getTransform = (
|
||||||
width: number,
|
width: number,
|
||||||
@@ -350,7 +350,7 @@ export const textWysiwyg = ({
|
|||||||
font,
|
font,
|
||||||
getBoundTextMaxWidth(container, boundTextElement),
|
getBoundTextMaxWidth(container, boundTextElement),
|
||||||
);
|
);
|
||||||
const width = getTextWidth(wrappedText, font);
|
const width = getTextWidth(wrappedText, font, true);
|
||||||
editable.style.width = `${width}px`;
|
editable.style.width = `${width}px`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ export type ExcalidrawElbowArrowElement = Merge<
|
|||||||
elbowed: true;
|
elbowed: true;
|
||||||
startBinding: FixedPointBinding | null;
|
startBinding: FixedPointBinding | null;
|
||||||
endBinding: FixedPointBinding | null;
|
endBinding: FixedPointBinding | null;
|
||||||
fixedSegments: readonly FixedSegment[] | null;
|
fixedSegments: FixedSegment[] | null;
|
||||||
/**
|
/**
|
||||||
* Marks that the 3rd point should be used as the 2nd point of the arrow in
|
* 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
|
* order to temporarily hide the first segment of the arrow without losing
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import {
|
|||||||
FONT_FAMILY_FALLBACKS,
|
FONT_FAMILY_FALLBACKS,
|
||||||
CJK_HAND_DRAWN_FALLBACK_FONT,
|
CJK_HAND_DRAWN_FALLBACK_FONT,
|
||||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||||
|
isSafari,
|
||||||
getFontFamilyFallbacks,
|
getFontFamilyFallbacks,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { isTextElement } from "../element";
|
import { isTextElement } from "../element";
|
||||||
import { getContainerElement } from "../element/textElement";
|
import { charWidth, getContainerElement } from "../element/textElement";
|
||||||
import { containsCJK } from "../element/textWrapping";
|
import { containsCJK } from "../element/textWrapping";
|
||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
import { getFontString, PromisePool, promiseTry } from "../utils";
|
import { getFontString, PromisePool, promiseTry } from "../utils";
|
||||||
@@ -31,7 +32,6 @@ import type {
|
|||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import type Scene from "../scene/Scene";
|
import type Scene from "../scene/Scene";
|
||||||
import type { ValueOf } from "../utility-types";
|
import type { ValueOf } from "../utility-types";
|
||||||
import { charWidth } from "../element/textMeasurements";
|
|
||||||
|
|
||||||
export class Fonts {
|
export class Fonts {
|
||||||
// it's ok to track fonts across multiple instances only once, so let's use
|
// it's ok to track fonts across multiple instances only once, so let's use
|
||||||
@@ -137,28 +137,50 @@ export class Fonts {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Load font faces for a given scene and trigger scene update.
|
* Load font faces for a given scene and trigger scene update.
|
||||||
|
*
|
||||||
|
* FontFaceSet loadingdone event we listen on may not always
|
||||||
|
* fire (looking at you Safari), so on init we manually load all
|
||||||
|
* fonts and rerender scene text elements once done.
|
||||||
|
*
|
||||||
|
* For Safari we make sure to check against each loaded font face
|
||||||
|
* with the unique characters per family in the scene,
|
||||||
|
* otherwise fonts might remain unloaded.
|
||||||
*/
|
*/
|
||||||
public loadSceneFonts = async (): Promise<FontFace[]> => {
|
public loadSceneFonts = async (): Promise<FontFace[]> => {
|
||||||
const sceneFamilies = this.getSceneFamilies();
|
const sceneFamilies = this.getSceneFamilies();
|
||||||
const charsPerFamily = Fonts.getCharsPerFamily(
|
const charsPerFamily = isSafari
|
||||||
this.scene.getNonDeletedElements(),
|
? Fonts.getCharsPerFamily(this.scene.getNonDeletedElements())
|
||||||
);
|
: undefined;
|
||||||
|
|
||||||
return Fonts.loadFontFaces(sceneFamilies, charsPerFamily);
|
return Fonts.loadFontFaces(sceneFamilies, charsPerFamily);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load font faces for passed elements - use when the scene is unavailable (i.e. export).
|
* Load font faces for passed elements - use when the scene is unavailable (i.e. export).
|
||||||
|
*
|
||||||
|
* For Safari we make sure to check against each loaded font face,
|
||||||
|
* with the unique characters per family in the elements
|
||||||
|
* otherwise fonts might remain unloaded.
|
||||||
*/
|
*/
|
||||||
public static loadElementsFonts = async (
|
public static loadElementsFonts = async (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
): Promise<FontFace[]> => {
|
): Promise<FontFace[]> => {
|
||||||
const fontFamilies = Fonts.getUniqueFamilies(elements);
|
const fontFamilies = Fonts.getUniqueFamilies(elements);
|
||||||
const charsPerFamily = Fonts.getCharsPerFamily(elements);
|
const charsPerFamily = isSafari
|
||||||
|
? Fonts.getCharsPerFamily(elements)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return Fonts.loadFontFaces(fontFamilies, charsPerFamily);
|
return Fonts.loadFontFaces(fontFamilies, charsPerFamily);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all registered font faces.
|
||||||
|
*/
|
||||||
|
public static loadAllFonts = async (): Promise<FontFace[]> => {
|
||||||
|
const allFamilies = Fonts.getAllFamilies();
|
||||||
|
return Fonts.loadFontFaces(allFamilies);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate CSS @font-face declarations for the given elements.
|
* Generate CSS @font-face declarations for the given elements.
|
||||||
*/
|
*/
|
||||||
@@ -201,7 +223,7 @@ export class Fonts {
|
|||||||
|
|
||||||
private static async loadFontFaces(
|
private static async loadFontFaces(
|
||||||
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
|
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
|
||||||
charsPerFamily: Record<number, Set<string>>,
|
charsPerFamily?: Record<number, Set<string>>,
|
||||||
) {
|
) {
|
||||||
// add all registered font faces into the `document.fonts` (if not added already)
|
// add all registered font faces into the `document.fonts` (if not added already)
|
||||||
for (const { fontFaces, metadata } of Fonts.registered.values()) {
|
for (const { fontFaces, metadata } of Fonts.registered.values()) {
|
||||||
@@ -226,7 +248,7 @@ export class Fonts {
|
|||||||
|
|
||||||
private static *fontFacesLoader(
|
private static *fontFacesLoader(
|
||||||
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
|
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
|
||||||
charsPerFamily: Record<number, Set<string>>,
|
charsPerFamily?: Record<number, Set<string>>,
|
||||||
): Generator<Promise<void | readonly [number, FontFace[]]>> {
|
): Generator<Promise<void | readonly [number, FontFace[]]>> {
|
||||||
for (const [index, fontFamily] of fontFamilies.entries()) {
|
for (const [index, fontFamily] of fontFamilies.entries()) {
|
||||||
const font = getFontString({
|
const font = getFontString({
|
||||||
@@ -234,9 +256,12 @@ export class Fonts {
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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!
|
// WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
|
||||||
// instead, we are always checking chars used in the family, so that no required font faces remain unloaded
|
// 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 = Fonts.getCharacters(charsPerFamily, fontFamily);
|
const text =
|
||||||
|
isSafari && charsPerFamily
|
||||||
|
? Fonts.getCharacters(charsPerFamily, fontFamily)
|
||||||
|
: "";
|
||||||
|
|
||||||
if (!window.document.fonts.check(font, text)) {
|
if (!window.document.fonts.check(font, text)) {
|
||||||
yield promiseTry(async () => {
|
yield promiseTry(async () => {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
import type { ExcalidrawElement } from "./element/types";
|
import type { ExcalidrawElement } from "./element/types";
|
||||||
import { convertToExcalidrawElements, Excalidraw } from "./index";
|
import { convertToExcalidrawElements, Excalidraw } from "./index";
|
||||||
import { API } from "./tests/helpers/api";
|
import { API } from "./tests/helpers/api";
|
||||||
import { Keyboard, Pointer } from "./tests/helpers/ui";
|
import { Keyboard, Pointer } from "./tests/helpers/ui";
|
||||||
import { getCloneByOrigId, render } from "./tests/test-utils";
|
import { render } from "./tests/test-utils";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
const mouse = new Pointer("mouse");
|
const mouse = new Pointer("mouse");
|
||||||
@@ -412,9 +413,9 @@ describe("adding elements to frames", () => {
|
|||||||
|
|
||||||
dragElementIntoFrame(frame, rect2);
|
dragElementIntoFrame(frame, rect2);
|
||||||
|
|
||||||
selectElementAndDuplicate(rect2);
|
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
|
||||||
|
|
||||||
const rect2_copy = getCloneByOrigId(rect2.id);
|
selectElementAndDuplicate(rect2);
|
||||||
|
|
||||||
expect(rect2_copy.frameId).toBe(frame.id);
|
expect(rect2_copy.frameId).toBe(frame.id);
|
||||||
expect(rect2.frameId).toBe(frame.id);
|
expect(rect2.frameId).toBe(frame.id);
|
||||||
@@ -426,11 +427,11 @@ describe("adding elements to frames", () => {
|
|||||||
|
|
||||||
dragElementIntoFrame(frame, rect2);
|
dragElementIntoFrame(frame, rect2);
|
||||||
|
|
||||||
|
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
|
||||||
|
|
||||||
// move the rect2 outside the frame
|
// move the rect2 outside the frame
|
||||||
selectElementAndDuplicate(rect2, [-1000, -1000]);
|
selectElementAndDuplicate(rect2, [-1000, -1000]);
|
||||||
|
|
||||||
const rect2_copy = getCloneByOrigId(rect2.id);
|
|
||||||
|
|
||||||
expect(rect2_copy.frameId).toBe(frame.id);
|
expect(rect2_copy.frameId).toBe(frame.id);
|
||||||
expect(rect2.frameId).toBe(null);
|
expect(rect2.frameId).toBe(null);
|
||||||
expectEqualIds([rect2_copy, frame, rect2]);
|
expectEqualIds([rect2_copy, frame, rect2]);
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
|||||||
onPointerDown,
|
onPointerDown,
|
||||||
onPointerUp,
|
onPointerUp,
|
||||||
onScrollChange,
|
onScrollChange,
|
||||||
onDuplicate,
|
|
||||||
children,
|
children,
|
||||||
validateEmbeddable,
|
validateEmbeddable,
|
||||||
renderEmbeddable,
|
renderEmbeddable,
|
||||||
@@ -137,7 +136,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
|||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
onScrollChange={onScrollChange}
|
onScrollChange={onScrollChange}
|
||||||
onDuplicate={onDuplicate}
|
|
||||||
validateEmbeddable={validateEmbeddable}
|
validateEmbeddable={validateEmbeddable}
|
||||||
renderEmbeddable={renderEmbeddable}
|
renderEmbeddable={renderEmbeddable}
|
||||||
aiEnabled={aiEnabled !== false}
|
aiEnabled={aiEnabled !== false}
|
||||||
@@ -295,5 +293,3 @@ export {
|
|||||||
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
|
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
|
||||||
export { getDataURL } from "./data/blob";
|
export { getDataURL } from "./data/blob";
|
||||||
export { isElementLink } from "./element/elementLink";
|
export { isElementLink } from "./element/elementLink";
|
||||||
|
|
||||||
export { setCustomTextMetricsProvider } from "./element/textMeasurements";
|
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* all things related to lasso selection
|
||||||
|
* - lasso selection
|
||||||
|
* - intersection and enclosure checks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type GlobalPoint,
|
||||||
|
type LineSegment,
|
||||||
|
type LocalPoint,
|
||||||
|
type Polygon,
|
||||||
|
pointFrom,
|
||||||
|
pointsEqual,
|
||||||
|
polygonFromPoints,
|
||||||
|
segmentsIntersectAt,
|
||||||
|
} from "../math";
|
||||||
|
import { isPointInShape } from "../utils/collision";
|
||||||
|
import {
|
||||||
|
type GeometricShape,
|
||||||
|
polylineFromPoints,
|
||||||
|
} from "../utils/geometry/shape";
|
||||||
|
import { AnimatedTrail } from "./animated-trail";
|
||||||
|
import { type AnimationFrameHandler } from "./animation-frame-handler";
|
||||||
|
import type App from "./components/App";
|
||||||
|
import { getElementLineSegments } from "./element/bounds";
|
||||||
|
import type { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||||
|
import type { InteractiveCanvasRenderConfig } from "./scene/types";
|
||||||
|
import type { InteractiveCanvasAppState } from "./types";
|
||||||
|
import { easeOut } from "./utils";
|
||||||
|
|
||||||
|
export type LassoPath = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
points: LocalPoint[];
|
||||||
|
intersectedElements: Set<ExcalidrawElement["id"]>;
|
||||||
|
enclosedElements: Set<ExcalidrawElement["id"]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderLassoSelection = (
|
||||||
|
lassoPath: LassoPath,
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
appState: InteractiveCanvasAppState,
|
||||||
|
selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
|
||||||
|
) => {
|
||||||
|
context.save();
|
||||||
|
context.translate(
|
||||||
|
lassoPath.x + appState.scrollX,
|
||||||
|
lassoPath.y + appState.scrollY,
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstPoint = lassoPath.points[0];
|
||||||
|
|
||||||
|
if (firstPoint) {
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(firstPoint[0], firstPoint[1]);
|
||||||
|
|
||||||
|
for (let i = 1; i < lassoPath.points.length; i++) {
|
||||||
|
context.lineTo(lassoPath.points[i][0], lassoPath.points[i][1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.strokeStyle = selectionColor;
|
||||||
|
context.lineWidth = 3 / appState.zoom.value;
|
||||||
|
|
||||||
|
if (
|
||||||
|
lassoPath.points.length >= 3 &&
|
||||||
|
pointsEqual(
|
||||||
|
lassoPath.points[0],
|
||||||
|
lassoPath.points[lassoPath.points.length - 1],
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
context.closePath();
|
||||||
|
}
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
// export class LassoSelection {
|
||||||
|
// static createLassoPath = (x: number, y: number): LassoPath => {
|
||||||
|
// return {
|
||||||
|
// x,
|
||||||
|
// y,
|
||||||
|
// points: [],
|
||||||
|
// intersectedElements: new Set(),
|
||||||
|
// enclosedElements: new Set(),
|
||||||
|
// };
|
||||||
|
// };
|
||||||
|
|
||||||
|
// static updateLassoPath = (
|
||||||
|
// lassoPath: LassoPath,
|
||||||
|
// pointerCoords: { x: number; y: number },
|
||||||
|
// elementsMap: ElementsMap,
|
||||||
|
// ): LassoPath => {
|
||||||
|
// const points = lassoPath.points;
|
||||||
|
// const dx = pointerCoords.x - lassoPath.x;
|
||||||
|
// const dy = pointerCoords.y - lassoPath.y;
|
||||||
|
|
||||||
|
// const lastPoint = points.length > 0 && points[points.length - 1];
|
||||||
|
// const discardPoint =
|
||||||
|
// lastPoint && lastPoint[0] === dx && lastPoint[1] === dy;
|
||||||
|
|
||||||
|
// if (!discardPoint) {
|
||||||
|
// const nextLassoPath = {
|
||||||
|
// ...lassoPath,
|
||||||
|
// points: [...points, pointFrom<LocalPoint>(dx, dy)],
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // nextLassoPath.enclosedElements.clear();
|
||||||
|
|
||||||
|
// // const enclosedLassoPath = LassoSelection.closeLassoPath(
|
||||||
|
// // nextLassoPath,
|
||||||
|
// // elementsMap,
|
||||||
|
// // );
|
||||||
|
|
||||||
|
// // for (const [id, element] of elementsMap) {
|
||||||
|
// // if (!lassoPath.intersectedElements.has(element.id)) {
|
||||||
|
// // const intersects = intersect(nextLassoPath, element, elementsMap);
|
||||||
|
// // if (intersects) {
|
||||||
|
// // lassoPath.intersectedElements.add(element.id);
|
||||||
|
// // } else {
|
||||||
|
// // // check if the lasso path encloses the element
|
||||||
|
// // const enclosed = enclose(enclosedLassoPath, element, elementsMap);
|
||||||
|
// // if (enclosed) {
|
||||||
|
// // lassoPath.enclosedElements.add(element.id);
|
||||||
|
// // }
|
||||||
|
// // }
|
||||||
|
// // }
|
||||||
|
// // }
|
||||||
|
|
||||||
|
// return nextLassoPath;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return lassoPath;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// private static closeLassoPath = (
|
||||||
|
// lassoPath: LassoPath,
|
||||||
|
// elementsMap: ElementsMap,
|
||||||
|
// ) => {
|
||||||
|
// const finalPoints = [...lassoPath.points, lassoPath.points[0]];
|
||||||
|
// // TODO: check if the lasso path encloses or intersects with any element
|
||||||
|
|
||||||
|
// const finalLassoPath = {
|
||||||
|
// ...lassoPath,
|
||||||
|
// points: finalPoints,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// return finalLassoPath;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// static finalizeLassoPath = (
|
||||||
|
// lassoPath: LassoPath,
|
||||||
|
// elementsMap: ElementsMap,
|
||||||
|
// ) => {
|
||||||
|
// const enclosedLassoPath = LassoSelection.closeLassoPath(
|
||||||
|
// lassoPath,
|
||||||
|
// elementsMap,
|
||||||
|
// );
|
||||||
|
|
||||||
|
// enclosedLassoPath.enclosedElements.clear();
|
||||||
|
// enclosedLassoPath.intersectedElements.clear();
|
||||||
|
|
||||||
|
// // for (const [id, element] of elementsMap) {
|
||||||
|
// // const intersects = intersect(enclosedLassoPath, element, elementsMap);
|
||||||
|
// // if (intersects) {
|
||||||
|
// // enclosedLassoPath.intersectedElements.add(element.id);
|
||||||
|
// // } else {
|
||||||
|
// // const enclosed = enclose(enclosedLassoPath, element, elementsMap);
|
||||||
|
// // if (enclosed) {
|
||||||
|
// // enclosedLassoPath.enclosedElements.add(element.id);
|
||||||
|
// // }
|
||||||
|
// // }
|
||||||
|
// // }
|
||||||
|
|
||||||
|
// return enclosedLassoPath;
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
const intersectionTest = (
|
||||||
|
lassoPath: GlobalPoint[],
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): boolean => {
|
||||||
|
const elementLineSegments = getElementLineSegments(element, elementsMap);
|
||||||
|
const lassoSegments = lassoPath.reduce((acc, point, index) => {
|
||||||
|
if (index === 0) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
const prevPoint = pointFrom<GlobalPoint>(
|
||||||
|
lassoPath[index - 1][0],
|
||||||
|
lassoPath[index - 1][1],
|
||||||
|
);
|
||||||
|
const currentPoint = pointFrom<GlobalPoint>(point[0], point[1]);
|
||||||
|
acc.push([prevPoint, currentPoint] as LineSegment<GlobalPoint>);
|
||||||
|
return acc;
|
||||||
|
}, [] as LineSegment<GlobalPoint>[]);
|
||||||
|
|
||||||
|
for (const lassoSegment of lassoSegments) {
|
||||||
|
for (const elementSegment of elementLineSegments) {
|
||||||
|
if (segmentsIntersectAt(lassoSegment, elementSegment)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const enclosureTest = (
|
||||||
|
lassoPath: GlobalPoint[],
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): boolean => {
|
||||||
|
const polyline = polylineFromPoints(lassoPath);
|
||||||
|
|
||||||
|
const closedPathShape: GeometricShape<GlobalPoint> = {
|
||||||
|
type: "polygon",
|
||||||
|
data: polygonFromPoints(polyline.flat()),
|
||||||
|
} as {
|
||||||
|
type: "polygon";
|
||||||
|
data: Polygon<GlobalPoint>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const elementSegments = getElementLineSegments(element, elementsMap);
|
||||||
|
|
||||||
|
for (const segment of elementSegments) {
|
||||||
|
if (segment.some((point) => isPointInShape(point, closedPathShape))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class LassoTrail extends AnimatedTrail {
|
||||||
|
private intersectedElements: Set<ExcalidrawElement["id"]> = new Set();
|
||||||
|
private enclosedElements: Set<ExcalidrawElement["id"]> = new Set();
|
||||||
|
|
||||||
|
constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
|
||||||
|
super(animationFrameHandler, app, {
|
||||||
|
simplify: 0,
|
||||||
|
streamline: 0.4,
|
||||||
|
sizeMapping: (c) => {
|
||||||
|
const DECAY_TIME = Infinity;
|
||||||
|
const DECAY_LENGTH = 5000;
|
||||||
|
const t = Math.max(
|
||||||
|
0,
|
||||||
|
1 - (performance.now() - c.pressure) / DECAY_TIME,
|
||||||
|
);
|
||||||
|
const l =
|
||||||
|
(DECAY_LENGTH -
|
||||||
|
Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) /
|
||||||
|
DECAY_LENGTH;
|
||||||
|
|
||||||
|
return Math.min(easeOut(l), easeOut(t));
|
||||||
|
},
|
||||||
|
fill: () => "rgb(0,118,255)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startPath(x: number, y: number) {
|
||||||
|
super.startPath(x, y);
|
||||||
|
this.intersectedElements.clear();
|
||||||
|
this.enclosedElements.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
addPointToPath(x: number, y: number) {
|
||||||
|
super.addPointToPath(x, y);
|
||||||
|
const lassoPath = super
|
||||||
|
.getCurrentTrail()
|
||||||
|
?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1]));
|
||||||
|
if (lassoPath) {
|
||||||
|
// TODO: further OPT: do not check elements that are "far away"
|
||||||
|
const elementsMap = this.app.scene.getNonDeletedElementsMap();
|
||||||
|
const closedPath = polygonFromPoints(lassoPath);
|
||||||
|
// need to clear the enclosed elements as path might change
|
||||||
|
this.enclosedElements.clear();
|
||||||
|
for (const [, element] of elementsMap) {
|
||||||
|
if (!this.intersectedElements.has(element.id)) {
|
||||||
|
const intersects = intersectionTest(lassoPath, element, elementsMap);
|
||||||
|
if (intersects) {
|
||||||
|
this.intersectedElements.add(element.id);
|
||||||
|
} else {
|
||||||
|
// TODO: check bounding box is at least in the lasso path area first
|
||||||
|
// BUT: need to compare bounding box check with enclosure check performance
|
||||||
|
const enclosed = enclosureTest(closedPath, element, elementsMap);
|
||||||
|
if (enclosed) {
|
||||||
|
this.enclosedElements.add(element.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
intersectedElementIds: this.intersectedElements,
|
||||||
|
enclosedElementIds: this.enclosedElements,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
endPath(): void {
|
||||||
|
super.endPath();
|
||||||
|
super.clearTrails();
|
||||||
|
this.intersectedElements.clear();
|
||||||
|
this.enclosedElements.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,7 +103,6 @@
|
|||||||
"@types/pako": "1.0.3",
|
"@types/pako": "1.0.3",
|
||||||
"@types/pica": "5.1.3",
|
"@types/pica": "5.1.3",
|
||||||
"@types/resize-observer-browser": "0.1.7",
|
"@types/resize-observer-browser": "0.1.7",
|
||||||
"ansicolor": "2.0.3",
|
|
||||||
"autoprefixer": "10.4.7",
|
"autoprefixer": "10.4.7",
|
||||||
"babel-loader": "8.2.5",
|
"babel-loader": "8.2.5",
|
||||||
"babel-plugin-transform-class-properties": "6.24.1",
|
"babel-plugin-transform-class-properties": "6.24.1",
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import {
|
|||||||
type Radians,
|
type Radians,
|
||||||
} from "../../math";
|
} from "../../math";
|
||||||
import { getCornerRadius } from "../shapes";
|
import { getCornerRadius } from "../shapes";
|
||||||
|
import { renderLassoSelection } from "../lasso";
|
||||||
|
|
||||||
const renderElbowArrowMidPointHighlight = (
|
const renderElbowArrowMidPointHighlight = (
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getContainerCoords,
|
getContainerCoords,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
|
getLineHeightInPx,
|
||||||
getBoundTextMaxHeight,
|
getBoundTextMaxHeight,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
@@ -63,7 +64,6 @@ import { getVerticalOffset } from "../fonts";
|
|||||||
import { isRightAngleRads } from "../../math";
|
import { isRightAngleRads } from "../../math";
|
||||||
import { getCornerRadius } from "../shapes";
|
import { getCornerRadius } from "../shapes";
|
||||||
import { getUncroppedImageElement } from "../element/cropElement";
|
import { getUncroppedImageElement } from "../element/cropElement";
|
||||||
import { getLineHeightInPx } from "../element/textMeasurements";
|
|
||||||
|
|
||||||
// using a stronger invert (100% vs our regular 93%) and saturate
|
// 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
|
// as a temp hack to make images in dark theme look closer to original
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { LinearElementEditor } from "../element/linearElementEditor";
|
|||||||
import {
|
import {
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
|
getLineHeightInPx,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
@@ -37,7 +38,6 @@ import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
|
|||||||
import { getVerticalOffset } from "../fonts";
|
import { getVerticalOffset } from "../fonts";
|
||||||
import { getCornerRadius, isPathALoop } from "../shapes";
|
import { getCornerRadius, isPathALoop } from "../shapes";
|
||||||
import { getUncroppedWidthAndHeight } from "../element/cropElement";
|
import { getUncroppedWidthAndHeight } from "../element/cropElement";
|
||||||
import { getLineHeightInPx } from "../element/textMeasurements";
|
|
||||||
|
|
||||||
const roughSVGDrawWithPrecision = (
|
const roughSVGDrawWithPrecision = (
|
||||||
rsvg: RoughSVG,
|
rsvg: RoughSVG,
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import { isIframeElement } from "../element/typeChecks";
|
||||||
|
import type {
|
||||||
|
ExcalidrawIframeElement,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
|
} from "../element/types";
|
||||||
import type { ElementOrToolType } from "../types";
|
import type { ElementOrToolType } from "../types";
|
||||||
|
|
||||||
export const hasBackground = (type: ElementOrToolType) =>
|
export const hasBackground = (type: ElementOrToolType) =>
|
||||||
@@ -42,3 +47,48 @@ export const canChangeRoundness = (type: ElementOrToolType) =>
|
|||||||
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
|
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
|
||||||
|
|
||||||
export const canHaveArrowheads = (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,6 +12,8 @@ export {
|
|||||||
hasStrokeStyle,
|
hasStrokeStyle,
|
||||||
canHaveArrowheads,
|
canHaveArrowheads,
|
||||||
canChangeRoundness,
|
canChangeRoundness,
|
||||||
|
getElementAtPosition,
|
||||||
|
getElementsAtPosition,
|
||||||
} from "./comparisons";
|
} from "./comparisons";
|
||||||
export {
|
export {
|
||||||
getNormalizedZoom,
|
getNormalizedZoom,
|
||||||
|
|||||||
@@ -2517,7 +2517,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
|||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
"searchMatches": [],
|
"searchMatches": [],
|
||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id1": true,
|
"id0_copy": true,
|
||||||
},
|
},
|
||||||
"selectedElementsAreBeingDragged": false,
|
"selectedElementsAreBeingDragged": false,
|
||||||
"selectedGroupIds": {},
|
"selectedGroupIds": {},
|
||||||
@@ -2590,7 +2590,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
|||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 20,
|
"height": 20,
|
||||||
"id": "id1",
|
"id": "id0_copy",
|
||||||
"index": "a1",
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
@@ -2680,7 +2680,7 @@ History {
|
|||||||
"delta": Delta {
|
"delta": Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id1": true,
|
"id0_copy": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
@@ -2693,7 +2693,7 @@ History {
|
|||||||
"elementsChange": ElementsChange {
|
"elementsChange": ElementsChange {
|
||||||
"added": Map {},
|
"added": Map {},
|
||||||
"removed": Map {
|
"removed": Map {
|
||||||
"id1" => Delta {
|
"id0_copy" => Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"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,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id2",
|
"id": "id0_copy",
|
||||||
"index": "a0",
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
|||||||
@@ -2129,7 +2129,7 @@ History {
|
|||||||
"elementsChange": ElementsChange {
|
"elementsChange": ElementsChange {
|
||||||
"added": Map {},
|
"added": Map {},
|
||||||
"removed": Map {
|
"removed": Map {
|
||||||
"id2" => Delta {
|
"id0_copy" => Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
@@ -10619,7 +10619,7 @@ History {
|
|||||||
"elementsChange": ElementsChange {
|
"elementsChange": ElementsChange {
|
||||||
"added": Map {},
|
"added": Map {},
|
||||||
"removed": Map {
|
"removed": Map {
|
||||||
"id6" => Delta {
|
"id0_copy" => Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
@@ -10628,7 +10628,7 @@ History {
|
|||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [
|
"groupIds": [
|
||||||
"id7",
|
"id4_copy",
|
||||||
],
|
],
|
||||||
"height": 10,
|
"height": 10,
|
||||||
"index": "a0",
|
"index": "a0",
|
||||||
@@ -10652,7 +10652,7 @@ History {
|
|||||||
"isDeleted": true,
|
"isDeleted": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"id8" => Delta {
|
"id1_copy" => Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
@@ -10661,7 +10661,7 @@ History {
|
|||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [
|
"groupIds": [
|
||||||
"id7",
|
"id4_copy",
|
||||||
],
|
],
|
||||||
"height": 10,
|
"height": 10,
|
||||||
"index": "a1",
|
"index": "a1",
|
||||||
@@ -10685,7 +10685,7 @@ History {
|
|||||||
"isDeleted": true,
|
"isDeleted": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"id9" => Delta {
|
"id2_copy" => Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
@@ -10694,7 +10694,7 @@ History {
|
|||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [
|
"groupIds": [
|
||||||
"id7",
|
"id4_copy",
|
||||||
],
|
],
|
||||||
"height": 10,
|
"height": 10,
|
||||||
"index": "a2",
|
"index": "a2",
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ describe("actionStyles", () => {
|
|||||||
// Roughness
|
// Roughness
|
||||||
fireEvent.click(screen.getByTitle("Cartoonist"));
|
fireEvent.click(screen.getByTitle("Cartoonist"));
|
||||||
// Opacity
|
// Opacity
|
||||||
fireEvent.change(screen.getByTestId("opacity"), {
|
fireEvent.change(screen.getByLabelText("Opacity"), {
|
||||||
target: { value: "60" },
|
target: { value: "60" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { render, waitFor, GlobalTestState } from "./test-utils";
|
|||||||
import { Pointer, Keyboard } from "./helpers/ui";
|
import { Pointer, Keyboard } from "./helpers/ui";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getLineHeightInPx } from "../element/textMeasurements";
|
import { getLineHeightInPx } from "../element/textElement";
|
||||||
import { getElementBounds } from "../element";
|
import { getElementBounds } from "../element";
|
||||||
import type { NormalizedZoomValue } from "../types";
|
import type { NormalizedZoomValue } from "../types";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ describe("contextMenu element", () => {
|
|||||||
// Roughness
|
// Roughness
|
||||||
fireEvent.click(screen.getByTitle("Cartoonist"));
|
fireEvent.click(screen.getByTitle("Cartoonist"));
|
||||||
// Opacity
|
// Opacity
|
||||||
fireEvent.change(screen.getByTestId("opacity"), {
|
fireEvent.change(screen.getByLabelText("Opacity"), {
|
||||||
target: { value: "60" },
|
target: { value: "60" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ import { createTestHook } from "../../components/App";
|
|||||||
import type { Action } from "../../actions/types";
|
import type { Action } from "../../actions/types";
|
||||||
import { mutateElement } from "../../element/mutateElement";
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
import { pointFrom, type LocalPoint, type Radians } from "../../../math";
|
import { pointFrom, type LocalPoint, type Radians } from "../../../math";
|
||||||
import { selectGroupsForSelectedElements } from "../../groups";
|
|
||||||
|
|
||||||
const readFile = util.promisify(fs.readFile);
|
const readFile = util.promisify(fs.readFile);
|
||||||
// so that window.h is available when App.tsx is not imported as well.
|
// so that window.h is available when App.tsx is not imported as well.
|
||||||
@@ -69,21 +68,13 @@ export class API {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
static setSelectedElements = (elements: ExcalidrawElement[], editingGroupId?: string | null) => {
|
static setSelectedElements = (elements: ExcalidrawElement[]) => {
|
||||||
act(() => {
|
act(() => {
|
||||||
h.setState({
|
h.setState({
|
||||||
...selectGroupsForSelectedElements(
|
selectedElementIds: elements.reduce((acc, element) => {
|
||||||
{
|
acc[element.id] = true;
|
||||||
editingGroupId: editingGroupId ?? null,
|
return acc;
|
||||||
selectedElementIds: elements.reduce((acc, element) => {
|
}, {} as Record<ExcalidrawElement["id"], true>),
|
||||||
acc[element.id] = true;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<ExcalidrawElement["id"], true>),
|
|
||||||
},
|
|
||||||
elements,
|
|
||||||
h.state,
|
|
||||||
h.app,
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -167,7 +158,7 @@ export class API {
|
|||||||
isDeleted?: boolean;
|
isDeleted?: boolean;
|
||||||
frameId?: ExcalidrawElement["id"] | null;
|
frameId?: ExcalidrawElement["id"] | null;
|
||||||
index?: ExcalidrawElement["index"];
|
index?: ExcalidrawElement["index"];
|
||||||
groupIds?: ExcalidrawElement["groupIds"];
|
groupIds?: string[];
|
||||||
// generic element props
|
// generic element props
|
||||||
strokeColor?: ExcalidrawGenericElement["strokeColor"];
|
strokeColor?: ExcalidrawGenericElement["strokeColor"];
|
||||||
backgroundColor?: ExcalidrawGenericElement["backgroundColor"];
|
backgroundColor?: ExcalidrawGenericElement["backgroundColor"];
|
||||||
@@ -378,84 +369,6 @@ export class API {
|
|||||||
return element as any;
|
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>(
|
static readFile = async <T extends "utf8" | null>(
|
||||||
filepath: string,
|
filepath: string,
|
||||||
encoding?: T,
|
encoding?: T,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
assertSelectedElements,
|
assertSelectedElements,
|
||||||
render,
|
render,
|
||||||
togglePopover,
|
togglePopover,
|
||||||
getCloneByOrigId,
|
|
||||||
} from "./test-utils";
|
} from "./test-utils";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||||
@@ -16,7 +15,7 @@ import { getDefaultAppState } from "../appState";
|
|||||||
import { fireEvent, queryByTestId, waitFor } from "@testing-library/react";
|
import { fireEvent, queryByTestId, waitFor } from "@testing-library/react";
|
||||||
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
||||||
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
||||||
import { EXPORT_DATA_TYPES, MIME_TYPES, ORIG_ID } from "../constants";
|
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import {
|
import {
|
||||||
@@ -1139,8 +1138,8 @@ describe("history", () => {
|
|||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
||||||
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
||||||
expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: true }),
|
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: true }),
|
||||||
expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: true }),
|
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: true }),
|
||||||
]);
|
]);
|
||||||
expect(h.state.editingGroupId).toBeNull();
|
expect(h.state.editingGroupId).toBeNull();
|
||||||
expect(h.state.selectedGroupIds).toEqual({ A: true });
|
expect(h.state.selectedGroupIds).toEqual({ A: true });
|
||||||
@@ -1152,8 +1151,8 @@ describe("history", () => {
|
|||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
||||||
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
||||||
expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: false }),
|
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: false }),
|
||||||
expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: false }),
|
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: false }),
|
||||||
]);
|
]);
|
||||||
expect(h.state.editingGroupId).toBeNull();
|
expect(h.state.editingGroupId).toBeNull();
|
||||||
expect(h.state.selectedGroupIds).not.toEqual(
|
expect(h.state.selectedGroupIds).not.toEqual(
|
||||||
@@ -1172,14 +1171,14 @@ describe("history", () => {
|
|||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
||||||
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
||||||
expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: true }),
|
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: true }),
|
||||||
expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: true }),
|
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: true }),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
[ORIG_ID]: getCloneByOrigId(rect1.id)?.id,
|
id: `${rect1.id}_copy_copy`,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
[ORIG_ID]: getCloneByOrigId(rect2.id)?.id,
|
id: `${rect2.id}_copy_copy`,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import { fireEvent, getCloneByOrigId, render, waitFor } from "./test-utils";
|
import { fireEvent, render, waitFor } from "./test-utils";
|
||||||
import { act, queryByTestId } from "@testing-library/react";
|
import { act, queryByTestId } from "@testing-library/react";
|
||||||
|
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { MIME_TYPES, ORIG_ID } from "../constants";
|
import { MIME_TYPES } from "../constants";
|
||||||
import type { LibraryItem, LibraryItems } from "../types";
|
import type { LibraryItem, LibraryItems } from "../types";
|
||||||
import { UI } from "./helpers/ui";
|
import { UI } from "./helpers/ui";
|
||||||
import { serializeLibraryAsJSON } from "../data/json";
|
import { serializeLibraryAsJSON } from "../data/json";
|
||||||
@@ -76,7 +76,7 @@ describe("library", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
|
expect(h.elements).toEqual([expect.objectContaining({ id: "A_copy" })]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,27 +125,23 @@ describe("library", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual(
|
expect(h.elements).toEqual([
|
||||||
expect.arrayContaining([
|
expect.objectContaining({
|
||||||
expect.objectContaining({
|
id: "rectangle1_copy",
|
||||||
[ORIG_ID]: "rectangle1",
|
boundElements: expect.arrayContaining([
|
||||||
boundElements: expect.arrayContaining([
|
{ type: "text", id: "text1_copy" },
|
||||||
{ type: "text", id: getCloneByOrigId("text1").id },
|
{ type: "arrow", id: "arrow1_copy" },
|
||||||
{ type: "arrow", id: getCloneByOrigId("arrow1").id },
|
]),
|
||||||
]),
|
}),
|
||||||
}),
|
expect.objectContaining({
|
||||||
expect.objectContaining({
|
id: "text1_copy",
|
||||||
[ORIG_ID]: "text1",
|
containerId: "rectangle1_copy",
|
||||||
containerId: getCloneByOrigId("rectangle1").id,
|
}),
|
||||||
}),
|
expect.objectContaining({
|
||||||
expect.objectContaining({
|
id: "arrow1_copy",
|
||||||
[ORIG_ID]: "arrow1",
|
endBinding: expect.objectContaining({ elementId: "rectangle1_copy" }),
|
||||||
endBinding: expect.objectContaining({
|
}),
|
||||||
elementId: getCloneByOrigId("rectangle1").id,
|
]);
|
||||||
}),
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,11 +170,10 @@ describe("library", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
[ORIG_ID]: "elem1",
|
id: "elem1_copy",
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: expect.not.stringMatching(/^elem1$/),
|
id: expect.not.stringMatching(/^(elem1_copy|elem1)$/),
|
||||||
[ORIG_ID]: expect.not.stringMatching(/^\w+$/),
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -194,7 +189,7 @@ describe("library", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
|
expect(h.elements).toEqual([expect.objectContaining({ id: "A_copy" })]);
|
||||||
});
|
});
|
||||||
expect(h.state.activeTool.type).toBe("selection");
|
expect(h.state.activeTool.type).toBe("selection");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ import { getSelectedElements } from "../scene/selection";
|
|||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { UI } from "./helpers/ui";
|
import { UI } from "./helpers/ui";
|
||||||
import { diffStringsUnified } from "jest-diff";
|
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 = {
|
const customQueries = {
|
||||||
...queries,
|
...queries,
|
||||||
@@ -299,150 +295,3 @@ 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 } from "../utils";
|
import { isTransparent, sanitizeHTMLAttribute } from "../utils";
|
||||||
|
|
||||||
describe("Test isTransparent", () => {
|
describe("Test isTransparent", () => {
|
||||||
it("should return true when color is rgb transparent", () => {
|
it("should return true when color is rgb transparent", () => {
|
||||||
@@ -11,3 +11,9 @@ describe("Test isTransparent", () => {
|
|||||||
expect(isTransparent("#ced4da")).toEqual(false);
|
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 React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { act, getCloneByOrigId, render } from "./test-utils";
|
import { act, render } from "./test-utils";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { reseed } from "../random";
|
import { reseed } from "../random";
|
||||||
import {
|
import {
|
||||||
@@ -916,9 +916,9 @@ describe("z-index manipulation", () => {
|
|||||||
API.executeAction(actionDuplicateSelection);
|
API.executeAction(actionDuplicateSelection);
|
||||||
expect(h.elements).toMatchObject([
|
expect(h.elements).toMatchObject([
|
||||||
{ id: "A" },
|
{ id: "A" },
|
||||||
{ id: getCloneByOrigId("A").id },
|
{ id: "A_copy" },
|
||||||
{ id: "B" },
|
{ id: "B" },
|
||||||
{ id: getCloneByOrigId("B").id },
|
{ id: "B_copy" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
populateElements([
|
populateElements([
|
||||||
@@ -930,12 +930,12 @@ describe("z-index manipulation", () => {
|
|||||||
{ id: "A" },
|
{ id: "A" },
|
||||||
{ id: "B" },
|
{ id: "B" },
|
||||||
{
|
{
|
||||||
id: getCloneByOrigId("A").id,
|
id: "A_copy",
|
||||||
|
|
||||||
groupIds: [expect.stringMatching(/.{3,}/)],
|
groupIds: [expect.stringMatching(/.{3,}/)],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: getCloneByOrigId("B").id,
|
id: "B_copy",
|
||||||
|
|
||||||
groupIds: [expect.stringMatching(/.{3,}/)],
|
groupIds: [expect.stringMatching(/.{3,}/)],
|
||||||
},
|
},
|
||||||
@@ -951,12 +951,12 @@ describe("z-index manipulation", () => {
|
|||||||
{ id: "A" },
|
{ id: "A" },
|
||||||
{ id: "B" },
|
{ id: "B" },
|
||||||
{
|
{
|
||||||
id: getCloneByOrigId("A").id,
|
id: "A_copy",
|
||||||
|
|
||||||
groupIds: [expect.stringMatching(/.{3,}/)],
|
groupIds: [expect.stringMatching(/.{3,}/)],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: getCloneByOrigId("B").id,
|
id: "B_copy",
|
||||||
|
|
||||||
groupIds: [expect.stringMatching(/.{3,}/)],
|
groupIds: [expect.stringMatching(/.{3,}/)],
|
||||||
},
|
},
|
||||||
@@ -972,10 +972,10 @@ describe("z-index manipulation", () => {
|
|||||||
expect(h.elements.map((element) => element.id)).toEqual([
|
expect(h.elements.map((element) => element.id)).toEqual([
|
||||||
"A",
|
"A",
|
||||||
"B",
|
"B",
|
||||||
getCloneByOrigId("A").id,
|
"A_copy",
|
||||||
getCloneByOrigId("B").id,
|
"B_copy",
|
||||||
"C",
|
"C",
|
||||||
getCloneByOrigId("C").id,
|
"C_copy",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
populateElements([
|
populateElements([
|
||||||
@@ -988,12 +988,12 @@ describe("z-index manipulation", () => {
|
|||||||
expect(h.elements.map((element) => element.id)).toEqual([
|
expect(h.elements.map((element) => element.id)).toEqual([
|
||||||
"A",
|
"A",
|
||||||
"B",
|
"B",
|
||||||
getCloneByOrigId("A").id,
|
"A_copy",
|
||||||
getCloneByOrigId("B").id,
|
"B_copy",
|
||||||
"C",
|
"C",
|
||||||
"D",
|
"D",
|
||||||
getCloneByOrigId("C").id,
|
"C_copy",
|
||||||
getCloneByOrigId("D").id,
|
"D_copy",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
populateElements(
|
populateElements(
|
||||||
@@ -1010,10 +1010,10 @@ describe("z-index manipulation", () => {
|
|||||||
expect(h.elements.map((element) => element.id)).toEqual([
|
expect(h.elements.map((element) => element.id)).toEqual([
|
||||||
"A",
|
"A",
|
||||||
"B",
|
"B",
|
||||||
getCloneByOrigId("A").id,
|
"A_copy",
|
||||||
getCloneByOrigId("B").id,
|
"B_copy",
|
||||||
"C",
|
"C",
|
||||||
getCloneByOrigId("C").id,
|
"C_copy",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
populateElements(
|
populateElements(
|
||||||
@@ -1031,9 +1031,9 @@ describe("z-index manipulation", () => {
|
|||||||
"A",
|
"A",
|
||||||
"B",
|
"B",
|
||||||
"C",
|
"C",
|
||||||
getCloneByOrigId("A").id,
|
"A_copy",
|
||||||
getCloneByOrigId("B").id,
|
"B_copy",
|
||||||
getCloneByOrigId("C").id,
|
"C_copy",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
populateElements(
|
populateElements(
|
||||||
@@ -1054,15 +1054,15 @@ describe("z-index manipulation", () => {
|
|||||||
"A",
|
"A",
|
||||||
"B",
|
"B",
|
||||||
"C",
|
"C",
|
||||||
getCloneByOrigId("A").id,
|
"A_copy",
|
||||||
getCloneByOrigId("B").id,
|
"B_copy",
|
||||||
getCloneByOrigId("C").id,
|
"C_copy",
|
||||||
"D",
|
"D",
|
||||||
"E",
|
"E",
|
||||||
"F",
|
"F",
|
||||||
getCloneByOrigId("D").id,
|
"D_copy",
|
||||||
getCloneByOrigId("E").id,
|
"E_copy",
|
||||||
getCloneByOrigId("F").id,
|
"F_copy",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
populateElements(
|
populateElements(
|
||||||
@@ -1076,7 +1076,7 @@ describe("z-index manipulation", () => {
|
|||||||
API.executeAction(actionDuplicateSelection);
|
API.executeAction(actionDuplicateSelection);
|
||||||
expect(h.elements.map((element) => element.id)).toEqual([
|
expect(h.elements.map((element) => element.id)).toEqual([
|
||||||
"A",
|
"A",
|
||||||
getCloneByOrigId("A").id,
|
"A_copy",
|
||||||
"B",
|
"B",
|
||||||
"C",
|
"C",
|
||||||
]);
|
]);
|
||||||
@@ -1093,7 +1093,7 @@ describe("z-index manipulation", () => {
|
|||||||
expect(h.elements.map((element) => element.id)).toEqual([
|
expect(h.elements.map((element) => element.id)).toEqual([
|
||||||
"A",
|
"A",
|
||||||
"B",
|
"B",
|
||||||
getCloneByOrigId("B").id,
|
"B_copy",
|
||||||
"C",
|
"C",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1108,9 +1108,9 @@ describe("z-index manipulation", () => {
|
|||||||
API.executeAction(actionDuplicateSelection);
|
API.executeAction(actionDuplicateSelection);
|
||||||
expect(h.elements.map((element) => element.id)).toEqual([
|
expect(h.elements.map((element) => element.id)).toEqual([
|
||||||
"A",
|
"A",
|
||||||
getCloneByOrigId("A").id,
|
"A_copy",
|
||||||
"B",
|
"B",
|
||||||
getCloneByOrigId("B").id,
|
"B_copy",
|
||||||
"C",
|
"C",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -1125,8 +1125,8 @@ describe("z-index manipulation", () => {
|
|||||||
expect(h.elements.map((element) => element.id)).toEqual([
|
expect(h.elements.map((element) => element.id)).toEqual([
|
||||||
"A",
|
"A",
|
||||||
"C",
|
"C",
|
||||||
getCloneByOrigId("A").id,
|
"A_copy",
|
||||||
getCloneByOrigId("C").id,
|
"C_copy",
|
||||||
"B",
|
"B",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -1144,9 +1144,9 @@ describe("z-index manipulation", () => {
|
|||||||
"A",
|
"A",
|
||||||
"B",
|
"B",
|
||||||
"C",
|
"C",
|
||||||
getCloneByOrigId("A").id,
|
"A_copy",
|
||||||
getCloneByOrigId("B").id,
|
"B_copy",
|
||||||
getCloneByOrigId("C").id,
|
"C_copy",
|
||||||
"D",
|
"D",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
|
|||||||
|
|
||||||
export type ToolType =
|
export type ToolType =
|
||||||
| "selection"
|
| "selection"
|
||||||
|
| "lassoSelection"
|
||||||
| "rectangle"
|
| "rectangle"
|
||||||
| "diamond"
|
| "diamond"
|
||||||
| "ellipse"
|
| "ellipse"
|
||||||
@@ -408,6 +409,8 @@ export interface AppState {
|
|||||||
croppingElementId: ExcalidrawElement["id"] | null;
|
croppingElementId: ExcalidrawElement["id"] | null;
|
||||||
|
|
||||||
searchMatches: readonly SearchMatch[];
|
searchMatches: readonly SearchMatch[];
|
||||||
|
|
||||||
|
lassoSelectionEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchMatch = {
|
type SearchMatch = {
|
||||||
@@ -512,22 +515,6 @@ export interface ExcalidrawProps {
|
|||||||
data: ClipboardData,
|
data: ClipboardData,
|
||||||
event: ClipboardEvent | null,
|
event: ClipboardEvent | null,
|
||||||
) => Promise<boolean> | boolean;
|
) => 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?: (
|
renderTopRightUI?: (
|
||||||
isMobile: boolean,
|
isMobile: boolean,
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
@@ -697,8 +684,6 @@ export type AppClassProperties = {
|
|||||||
getEditorUIOffsets: App["getEditorUIOffsets"];
|
getEditorUIOffsets: App["getEditorUIOffsets"];
|
||||||
visibleElements: App["visibleElements"];
|
visibleElements: App["visibleElements"];
|
||||||
excalidrawContainerValue: App["excalidrawContainerValue"];
|
excalidrawContainerValue: App["excalidrawContainerValue"];
|
||||||
|
|
||||||
onPointerUpEmitter: App["onPointerUpEmitter"];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PointerDownState = Readonly<{
|
export type PointerDownState = Readonly<{
|
||||||
|
|||||||
@@ -65,6 +65,3 @@ export type MakeBrand<T extends string> = {
|
|||||||
|
|
||||||
/** Maybe just promise or already fulfilled one! */
|
/** Maybe just promise or already fulfilled one! */
|
||||||
export type MaybePromise<T> = T | Promise<T>;
|
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,11 +9,7 @@ import {
|
|||||||
isDarwin,
|
isDarwin,
|
||||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import type {
|
import type { FontFamilyValues, FontString } from "./element/types";
|
||||||
ExcalidrawBindableElement,
|
|
||||||
FontFamilyValues,
|
|
||||||
FontString,
|
|
||||||
} from "./element/types";
|
|
||||||
import type {
|
import type {
|
||||||
ActiveTool,
|
ActiveTool,
|
||||||
AppState,
|
AppState,
|
||||||
@@ -547,9 +543,6 @@ export const isTransparent = (color: string) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) =>
|
|
||||||
el.fillStyle !== "solid" || isTransparent(el.backgroundColor);
|
|
||||||
|
|
||||||
export type ResolvablePromise<T> = Promise<T> & {
|
export type ResolvablePromise<T> = Promise<T> & {
|
||||||
resolve: [T] extends [undefined]
|
resolve: [T] extends [undefined]
|
||||||
? (value?: MaybePromise<Awaited<T>>) => void
|
? (value?: MaybePromise<Awaited<T>>) => void
|
||||||
@@ -1233,13 +1226,15 @@ export class PromisePool<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const sanitizeHTMLAttribute = (html: string) => {
|
||||||
* use when you need to render unsafe string as HTML attribute, but MAKE SURE
|
return (
|
||||||
* the attribute is double-quoted when constructing the HTML string
|
html
|
||||||
*/
|
// note, if we're not doing stupid things, escaping " is enough,
|
||||||
export const escapeDoubleQuotes = (str: string) => {
|
// but we might end up doing stupid things
|
||||||
return str.replace(/"/g, """);
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const castArray = <T>(value: T | T[]): T[] =>
|
|
||||||
Array.isArray(value) ? value : [value];
|
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ export const getCurveShape = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const polylineFromPoints = <Point extends GlobalPoint | LocalPoint>(
|
export const polylineFromPoints = <Point extends GlobalPoint | LocalPoint>(
|
||||||
points: Point[],
|
points: Point[],
|
||||||
): Polyline<Point> => {
|
): Polyline<Point> => {
|
||||||
let previousPoint: Point = points[0];
|
let previousPoint: Point = points[0];
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -123,17 +123,6 @@ module.exports.woff2ServerPlugin = (options = {}) => {
|
|||||||
"./assets/NotoEmoji-Regular-2048.ttf",
|
"./assets/NotoEmoji-Regular-2048.ttf",
|
||||||
);
|
);
|
||||||
|
|
||||||
const liberationPath = path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./assets/LiberationSans-Regular.ttf",
|
|
||||||
);
|
|
||||||
|
|
||||||
// need to use the same em size as built-in fonts, otherwise pyftmerge throws (modified manually with font forge)
|
|
||||||
const liberationPath_2048 = path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./assets/LiberationSans-Regular-2048.ttf",
|
|
||||||
);
|
|
||||||
|
|
||||||
const xiaolaiFont = Font.create(fs.readFileSync(xiaolaiPath), {
|
const xiaolaiFont = Font.create(fs.readFileSync(xiaolaiPath), {
|
||||||
type: "ttf",
|
type: "ttf",
|
||||||
});
|
});
|
||||||
@@ -141,10 +130,6 @@ module.exports.woff2ServerPlugin = (options = {}) => {
|
|||||||
type: "ttf",
|
type: "ttf",
|
||||||
});
|
});
|
||||||
|
|
||||||
const liberationFont = Font.create(fs.readFileSync(liberationPath), {
|
|
||||||
type: "ttf",
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedFonts = Array.from(fonts.entries()).sort(
|
const sortedFonts = Array.from(fonts.entries()).sort(
|
||||||
([family1], [family2]) => (family1 > family2 ? 1 : -1),
|
([family1], [family2]) => (family1 > family2 ? 1 : -1),
|
||||||
);
|
);
|
||||||
@@ -156,6 +141,13 @@ module.exports.woff2ServerPlugin = (options = {}) => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fallbackFontsPaths = [];
|
||||||
|
const shouldIncludeXiaolaiFallback = family.includes("Excalifont");
|
||||||
|
|
||||||
|
if (shouldIncludeXiaolaiFallback) {
|
||||||
|
fallbackFontsPaths.push(xiaolaiPath);
|
||||||
|
}
|
||||||
|
|
||||||
const baseFont = Regular[0];
|
const baseFont = Regular[0];
|
||||||
const tempPaths = Regular.map((_, index) =>
|
const tempPaths = Regular.map((_, index) =>
|
||||||
path.resolve(outputDir, `temp_${family}_${index}.ttf`),
|
path.resolve(outputDir, `temp_${family}_${index}.ttf`),
|
||||||
@@ -173,18 +165,10 @@ module.exports.woff2ServerPlugin = (options = {}) => {
|
|||||||
|
|
||||||
const mergedFontPath = path.resolve(outputDir, `${family}.ttf`);
|
const mergedFontPath = path.resolve(outputDir, `${family}.ttf`);
|
||||||
|
|
||||||
const fallbackFontsPaths = [];
|
|
||||||
const shouldIncludeXiaolaiFallback = family.includes("Excalifont");
|
|
||||||
|
|
||||||
if (shouldIncludeXiaolaiFallback) {
|
|
||||||
fallbackFontsPaths.push(xiaolaiPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// add liberation as fallback to all fonts, so that unknown characters are rendered similarly to how browser renders them (Helvetica, Arial, etc.)
|
|
||||||
if (baseFont.data.head.unitsPerEm === 2048) {
|
if (baseFont.data.head.unitsPerEm === 2048) {
|
||||||
fallbackFontsPaths.push(emojiPath_2048, liberationPath_2048);
|
fallbackFontsPaths.push(emojiPath_2048);
|
||||||
} else {
|
} else {
|
||||||
fallbackFontsPaths.push(emojiPath, liberationPath);
|
fallbackFontsPaths.push(emojiPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// drop Vertical related metrics, otherwise it does not allow us to merge the fonts
|
// drop Vertical related metrics, otherwise it does not allow us to merge the fonts
|
||||||
@@ -212,12 +196,10 @@ module.exports.woff2ServerPlugin = (options = {}) => {
|
|||||||
const base = baseFont.data.name[field];
|
const base = baseFont.data.name[field];
|
||||||
const xiaolai = xiaolaiFont.data.name[field];
|
const xiaolai = xiaolaiFont.data.name[field];
|
||||||
const emoji = emojiFont.data.name[field];
|
const emoji = emojiFont.data.name[field];
|
||||||
const liberation = liberationFont.data.name[field];
|
|
||||||
// liberation font
|
|
||||||
|
|
||||||
return shouldIncludeXiaolaiFallback
|
return shouldIncludeXiaolaiFallback
|
||||||
? `${base} & ${xiaolai} & ${emoji} & ${liberation}`
|
? `${base} & ${xiaolai} & ${emoji}`
|
||||||
: `${base} & ${emoji} & ${liberation}`;
|
: `${base} & ${emoji}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
mergedFont.set({
|
mergedFont.set({
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import polyfill from "./packages/excalidraw/polyfill";
|
|||||||
import { testPolyfills } from "./packages/excalidraw/tests/helpers/polyfills";
|
import { testPolyfills } from "./packages/excalidraw/tests/helpers/polyfills";
|
||||||
import { yellow } from "./packages/excalidraw/tests/helpers/colorize";
|
import { yellow } from "./packages/excalidraw/tests/helpers/colorize";
|
||||||
|
|
||||||
// mock for pep.js not working with setPointerCapture()
|
|
||||||
HTMLElement.prototype.setPointerCapture = vi.fn();
|
|
||||||
|
|
||||||
Object.assign(globalThis, testPolyfills);
|
Object.assign(globalThis, testPolyfills);
|
||||||
|
|
||||||
require("fake-indexeddb/auto");
|
require("fake-indexeddb/auto");
|
||||||
|
|||||||
Reference in New Issue
Block a user