Compare commits

...

25 Commits

Author SHA1 Message Date
Ryan Di 880afd12c9 leave it up to user to terminate looping 2025-02-24 12:51:39 +11:00
Ryan Di 247d6e2a2e revert to primitive navigation 2025-02-24 12:20:01 +11:00
Abhinav Pant 9ee0b8ffcb Enhancement: grouped together Undo and Redo buttons on mobile (#9109)
* bugfix: put the redo and undo button under the same div so that they look grouped together

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

* docs

* clarify docs

* fix docs
2025-02-10 14:20:18 +00:00
Márk Tolmács 9e49c9254b fix: IFrame and elbow arrow interaction fix (#9101) 2025-02-06 14:45:49 +01:00
David Luzar b0c8c5f7a7 feat: change empty arrowhead icon (#9100) 2025-02-06 10:52:03 +01:00
tothatt81 4f64372506 perf: Improved pointer events related performance when the sidebar is docked with a large library open (#9086)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-02-04 22:05:56 +01:00
David Luzar 424e94a403 fix: duplicating/removing frame while children selected (#9079) 2025-02-04 19:23:47 +01:00
Márk Tolmács 302664e500 fix: Elbow arrow z-index binding (#9067) 2025-02-01 19:21:03 +01:00
David Luzar 86c67bd37f fix: library item checkbox style regression (#9080) 2025-02-01 12:27:41 +01:00
David Luzar 511433988c feat: tweak slider colors to be more muted (#9076) 2025-01-31 16:52:50 +01:00
Márk Tolmács 9b6edc767a fix: Elbow arrow orthogonality (#9073) 2025-01-31 14:19:07 +01:00
David Luzar 6cdb683410 fix: button bg CSS variable leaking into other styles (#9075) 2025-01-31 12:33:54 +01:00
Shalini 84bab403ff Fix: issue #8818 Xiaolai font has been set as a fallback for Excalifont (#9055)
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2025-01-30 13:41:41 +00:00
Are 61e0bb83d0 feat: improve library sidebar performance (#9060)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-30 14:41:08 +01:00
Saikat Das bd1590fc74 feat: implement custom Range component for opacity control (#9009)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-29 21:46:40 +00:00
Marcel Mraz d29c3db7f6 fix: fonts not loading on export (again) (#9064) 2025-01-29 22:24:26 +01:00
Marcel Mraz a58822c1c1 fix: merge server-side fonts with liberation sans (#9052) 2025-01-29 22:04:49 +01:00
David Luzar a3e1619635 fix: hyperlinks html entities (#9063) 2025-01-29 19:02:54 +01:00
73 changed files with 4967 additions and 3397 deletions
@@ -25,6 +25,7 @@ import { MIME_TYPES } from "../../packages/excalidraw/constants";
import { trackEvent } from "../../packages/excalidraw/analytics"; import { trackEvent } from "../../packages/excalidraw/analytics";
import { getFrame } from "../../packages/excalidraw/utils"; import { getFrame } from "../../packages/excalidraw/utils";
import { ExcalidrawLogo } from "../../packages/excalidraw/components/ExcalidrawLogo"; import { ExcalidrawLogo } from "../../packages/excalidraw/components/ExcalidrawLogo";
import { uploadBytes, ref } from "firebase/storage";
export const exportToExcalidrawPlus = async ( export const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
@@ -32,7 +33,7 @@ export const exportToExcalidrawPlus = async (
files: BinaryFiles, files: BinaryFiles,
name: string, name: string,
) => { ) => {
const firebase = await loadFirebaseStorage(); const storage = await loadFirebaseStorage();
const id = `${nanoid(12)}`; const id = `${nanoid(12)}`;
@@ -49,15 +50,13 @@ export const exportToExcalidrawPlus = async (
}, },
); );
await firebase const storageRef = ref(storage, `/migrations/scenes/${id}`);
.storage() await uploadBytes(storageRef, blob, {
.ref(`/migrations/scenes/${id}`) customMetadata: {
.put(blob, { data: JSON.stringify({ version: 2, name }),
customMetadata: { created: Date.now().toString(),
data: JSON.stringify({ version: 2, name }), },
created: Date.now().toString(), });
},
});
const filesMap = new Map<FileId, BinaryFileData>(); const filesMap = new Map<FileId, BinaryFileData>();
for (const element of elements) { for (const element of elements) {
+49 -98
View File
@@ -22,9 +22,17 @@ import {
import { MIME_TYPES } from "../../packages/excalidraw/constants"; import { MIME_TYPES } from "../../packages/excalidraw/constants";
import type { SyncableExcalidrawElement } from "."; import type { SyncableExcalidrawElement } from ".";
import { getSyncableElements } from "."; import { getSyncableElements } from ".";
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile"; import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
import { initializeApp } from "firebase/app";
import {
getFirestore,
doc,
getDoc,
runTransaction,
Bytes,
} from "firebase/firestore";
import { getStorage, ref, uploadBytes } from "firebase/storage";
// private // private
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -41,80 +49,42 @@ try {
FIREBASE_CONFIG = {}; FIREBASE_CONFIG = {};
} }
let firebasePromise: Promise<typeof import("firebase/app").default> | null = let firebaseApp: ReturnType<typeof initializeApp> | null = null;
null; let firestore: ReturnType<typeof getFirestore> | null = null;
let firestorePromise: Promise<any> | null | true = null; let firebaseStorage: ReturnType<typeof getStorage> | null = null;
let firebaseStoragePromise: Promise<any> | null | true = null;
let isFirebaseInitialized = false; const _initializeFirebase = () => {
if (!firebaseApp) {
const _loadFirebase = async () => { firebaseApp = initializeApp(FIREBASE_CONFIG);
const firebase = (
await import(/* webpackChunkName: "firebase" */ "firebase/app")
).default;
if (!isFirebaseInitialized) {
try {
firebase.initializeApp(FIREBASE_CONFIG);
} catch (error: any) {
// trying initialize again throws. Usually this is harmless, and happens
// mainly in dev (HMR)
if (error.code === "app/duplicate-app") {
console.warn(error.name, error.code);
} else {
throw error;
}
}
isFirebaseInitialized = true;
} }
return firebaseApp;
return firebase;
}; };
const _getFirebase = async (): Promise< const _getFirestore = () => {
typeof import("firebase/app").default if (!firestore) {
> => { firestore = getFirestore(_initializeFirebase());
if (!firebasePromise) {
firebasePromise = _loadFirebase();
} }
return firebasePromise; return firestore;
};
const _getStorage = () => {
if (!firebaseStorage) {
firebaseStorage = getStorage(_initializeFirebase());
}
return firebaseStorage;
}; };
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
const loadFirestore = async () => {
const firebase = await _getFirebase();
if (!firestorePromise) {
firestorePromise = import(
/* webpackChunkName: "firestore" */ "firebase/firestore"
);
}
if (firestorePromise !== true) {
await firestorePromise;
firestorePromise = true;
}
return firebase;
};
export const loadFirebaseStorage = async () => { export const loadFirebaseStorage = async () => {
const firebase = await _getFirebase(); return _getStorage();
if (!firebaseStoragePromise) {
firebaseStoragePromise = import(
/* webpackChunkName: "storage" */ "firebase/storage"
);
}
if (firebaseStoragePromise !== true) {
await firebaseStoragePromise;
firebaseStoragePromise = true;
}
return firebase;
}; };
interface FirebaseStoredScene { type FirebaseStoredScene = {
sceneVersion: number; sceneVersion: number;
iv: firebase.default.firestore.Blob; iv: Bytes;
ciphertext: firebase.default.firestore.Blob; ciphertext: Bytes;
} };
const encryptElements = async ( const encryptElements = async (
key: string, key: string,
@@ -175,7 +145,7 @@ export const saveFilesToFirebase = async ({
prefix: string; prefix: string;
files: { id: FileId; buffer: Uint8Array }[]; files: { id: FileId; buffer: Uint8Array }[];
}) => { }) => {
const firebase = await loadFirebaseStorage(); const storage = await loadFirebaseStorage();
const erroredFiles: FileId[] = []; const erroredFiles: FileId[] = [];
const savedFiles: FileId[] = []; const savedFiles: FileId[] = [];
@@ -183,17 +153,10 @@ export const saveFilesToFirebase = async ({
await Promise.all( await Promise.all(
files.map(async ({ id, buffer }) => { files.map(async ({ id, buffer }) => {
try { try {
await firebase const storageRef = ref(storage, `${prefix}/${id}`);
.storage() await uploadBytes(storageRef, buffer, {
.ref(`${prefix}/${id}`) cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
.put( });
new Blob([buffer], {
type: MIME_TYPES.binary,
}),
{
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
},
);
savedFiles.push(id); savedFiles.push(id);
} catch (error: any) { } catch (error: any) {
erroredFiles.push(id); erroredFiles.push(id);
@@ -205,7 +168,6 @@ export const saveFilesToFirebase = async ({
}; };
const createFirebaseSceneDocument = async ( const createFirebaseSceneDocument = async (
firebase: ResolutionType<typeof loadFirestore>,
elements: readonly SyncableExcalidrawElement[], elements: readonly SyncableExcalidrawElement[],
roomKey: string, roomKey: string,
) => { ) => {
@@ -213,10 +175,8 @@ const createFirebaseSceneDocument = async (
const { ciphertext, iv } = await encryptElements(roomKey, elements); const { ciphertext, iv } = await encryptElements(roomKey, elements);
return { return {
sceneVersion, sceneVersion,
ciphertext: firebase.firestore.Blob.fromUint8Array( ciphertext: Bytes.fromUint8Array(new Uint8Array(ciphertext)),
new Uint8Array(ciphertext), iv: Bytes.fromUint8Array(iv),
),
iv: firebase.firestore.Blob.fromUint8Array(iv),
} as FirebaseStoredScene; } as FirebaseStoredScene;
}; };
@@ -236,20 +196,14 @@ export const saveToFirebase = async (
return null; return null;
} }
const firebase = await loadFirestore(); const firestore = _getFirestore();
const firestore = firebase.firestore(); const docRef = doc(firestore, "scenes", roomId);
const docRef = firestore.collection("scenes").doc(roomId); const storedScene = await runTransaction(firestore, async (transaction) => {
const storedScene = await firestore.runTransaction(async (transaction) => {
const snapshot = await transaction.get(docRef); const snapshot = await transaction.get(docRef);
if (!snapshot.exists) { if (!snapshot.exists()) {
const storedScene = await createFirebaseSceneDocument( const storedScene = await createFirebaseSceneDocument(elements, roomKey);
firebase,
elements,
roomKey,
);
transaction.set(docRef, storedScene); transaction.set(docRef, storedScene);
@@ -269,7 +223,6 @@ export const saveToFirebase = async (
); );
const storedScene = await createFirebaseSceneDocument( const storedScene = await createFirebaseSceneDocument(
firebase,
reconciledElements, reconciledElements,
roomKey, roomKey,
); );
@@ -294,15 +247,13 @@ export const loadFromFirebase = async (
roomKey: string, roomKey: string,
socket: Socket | null, socket: Socket | null,
): Promise<readonly SyncableExcalidrawElement[] | null> => { ): Promise<readonly SyncableExcalidrawElement[] | null> => {
const firebase = await loadFirestore(); const firestore = _getFirestore();
const db = firebase.firestore(); const docRef = doc(firestore, "scenes", roomId);
const docSnap = await getDoc(docRef);
const docRef = db.collection("scenes").doc(roomId); if (!docSnap.exists()) {
const doc = await docRef.get();
if (!doc.exists) {
return null; return null;
} }
const storedScene = doc.data() as FirebaseStoredScene; const storedScene = docSnap.data() as FirebaseStoredScene;
const elements = getSyncableElements( const elements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null), restoreElements(await decryptElements(storedScene, roomKey), null),
); );
+3 -3
View File
@@ -27,9 +27,9 @@
}, },
"dependencies": { "dependencies": {
"@excalidraw/random-username": "1.0.0", "@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5", "@sentry/browser": "9.0.1",
"@sentry/integrations": "6.2.5", "callsites": "4.2.0",
"firebase": "8.3.3", "firebase": "11.3.1",
"i18next-browser-languagedetector": "6.1.4", "i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3", "idb-keyval": "6.0.3",
"jotai": "2.11.0", "jotai": "2.11.0",
+45 -2
View File
@@ -1,8 +1,9 @@
import * as Sentry from "@sentry/browser"; import * as Sentry from "@sentry/browser";
import * as SentryIntegrations from "@sentry/integrations"; import callsites from "callsites";
const SentryEnvHostnameMap: { [key: string]: string } = { const SentryEnvHostnameMap: { [key: string]: string } = {
"excalidraw.com": "production", "excalidraw.com": "production",
"staging.excalidraw.com": "staging",
"vercel.app": "staging", "vercel.app": "staging",
}; };
@@ -23,9 +24,13 @@ Sentry.init({
release: import.meta.env.VITE_APP_GIT_SHA, release: import.meta.env.VITE_APP_GIT_SHA,
ignoreErrors: [ ignoreErrors: [
"undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything "undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
"InvalidStateError: Failed to execute 'transaction' on 'IDBDatabase': The database connection is closing.", // Not much we can do about the IndexedDB closing error
/(Failed to fetch|(fetch|loading) dynamically imported module)/i, // This is happening when a service worker tries to load an old asset
/QuotaExceededError: (The quota has been exceeded|.*setItem.*Storage)/i, // localStorage quota exceeded
"Internal error opening backing store for indexedDB.open", // Private mode and disabled indexedDB
], ],
integrations: [ integrations: [
new SentryIntegrations.CaptureConsole({ Sentry.captureConsoleIntegration({
levels: ["error"], levels: ["error"],
}), }),
], ],
@@ -33,6 +38,44 @@ Sentry.init({
if (event.request?.url) { if (event.request?.url) {
event.request.url = event.request.url.replace(/#.*$/, ""); event.request.url = event.request.url.replace(/#.*$/, "");
} }
if (!event.exception) {
event.exception = {
values: [
{
type: "ConsoleError",
value: event.message ?? "Unknown error",
stacktrace: {
frames: callsites()
.slice(1)
.filter(
(frame) =>
frame.getFileName() &&
!frame.getFileName()?.includes("@sentry_browser.js"),
)
.map((frame) => ({
filename: frame.getFileName() ?? undefined,
function: frame.getFunctionName() ?? undefined,
in_app: !(
frame.getFileName()?.includes("node_modules") ?? false
),
lineno: frame.getLineNumber() ?? undefined,
colno: frame.getColumnNumber() ?? undefined,
})),
},
mechanism: {
type: "instrument",
handled: true,
data: {
function: "console.error",
handler: "Sentry.beforeSend",
},
},
},
],
};
}
return event; return event;
}, },
}); });
@@ -10,7 +10,6 @@ import {
computeBoundTextPosition, computeBoundTextPosition,
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
getBoundTextElement, getBoundTextElement,
measureText,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "../element/textElement"; } from "../element/textElement";
import { import {
@@ -35,6 +34,7 @@ import { arrayToMap, getFontString } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { syncMovedIndices } from "../fractionalIndex"; import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { measureText } from "../element/textMeasurements";
export const actionUnbindText = register({ export const actionUnbindText = register({
name: "unbindText", name: "unbindText",
@@ -0,0 +1,211 @@
import React from "react";
import { Excalidraw, mutateElement } from "../index";
import { act, assertElements, render } from "../tests/test-utils";
import { API } from "../tests/helpers/api";
import { actionDeleteSelected } from "./actionDeleteSelected";
const { h } = window;
describe("deleting selected elements when frame selected should keep children + select them", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("frame only", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
API.setElements([f1, r1]);
API.setSelectedElements([f1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
]);
});
it("frame + text container (text's frameId set)", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: r1.id,
frameId: f1.id,
});
mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, r1, t1]);
API.setSelectedElements([f1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + text container (text's frameId not set)", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: r1.id,
frameId: null,
});
mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, r1, t1]);
API.setSelectedElements([f1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + text container (text selected too)", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: r1.id,
frameId: null,
});
mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, r1, t1]);
API.setSelectedElements([f1, t1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + labeled arrow", async () => {
const f1 = API.createElement({
type: "frame",
});
const a1 = API.createElement({
type: "arrow",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: a1.id,
frameId: null,
});
mutateElement(a1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, a1, t1]);
API.setSelectedElements([f1, t1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: a1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + children selected", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
API.setElements([f1, r1]);
API.setSelectedElements([f1, r1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
]);
});
});
@@ -18,6 +18,8 @@ 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[],
@@ -33,10 +35,50 @@ 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);
@@ -59,7 +101,9 @@ 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;
selectedElementIds[el.id] = true; if (!isBoundToContainer(el)) {
selectedElementIds[el.id] = true;
}
return newElementWith(el, { frameId: null }); return newElementWith(el, { frameId: null });
} }
@@ -224,11 +268,13 @@ export const actionDeleteSelected = register({
storeAction: StoreAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
} }
let { elements: nextElements, appState: nextAppState } = let { elements: nextElements, appState: nextAppState } =
deleteSelectedElements(elements, appState, app); deleteSelectedElements(elements, appState, app);
fixBindingsAfterDeletion( fixBindingsAfterDeletion(
nextElements, nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]), nextElements.filter((el) => el.isDeleted),
); );
nextAppState = handleGroupEditingState(nextAppState, nextElements); nextAppState = handleGroupEditingState(nextAppState, nextElements);
@@ -0,0 +1,530 @@
import { Excalidraw } from "../index";
import {
act,
assertElements,
getCloneByOrigId,
render,
} from "../tests/test-utils";
import { API } from "../tests/helpers/api";
import { actionDuplicateSelection } from "./actionDuplicateSelection";
import React from "react";
import { ORIG_ID } from "../constants";
const { h } = window;
describe("actionDuplicateSelection", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
describe("duplicating frames", () => {
it("frame selected only", async () => {
const frame = API.createElement({
type: "frame",
});
const rectangle = API.createElement({
type: "rectangle",
frameId: frame.id,
});
API.setElements([frame, rectangle]);
API.setSelectedElements([frame]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
{ [ORIG_ID]: frame.id, selected: true },
]);
});
it("frame selected only (with text container)", async () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([frame]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id)?.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{ [ORIG_ID]: frame.id, selected: true },
]);
});
it("frame + text container selected (order A)", async () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([frame, rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{
[ORIG_ID]: rectangle.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id)?.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{
[ORIG_ID]: frame.id,
selected: true,
},
]);
});
it("frame + text container selected (order B)", async () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([text, rectangle, frame]);
API.setSelectedElements([rectangle, frame]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ id: frame.id },
{
type: "rectangle",
[ORIG_ID]: `${rectangle.id}`,
},
{
[ORIG_ID]: `${text.id}`,
type: "text",
containerId: getCloneByOrigId(rectangle.id)?.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{ [ORIG_ID]: `${frame.id}`, type: "frame", selected: true },
]);
});
});
describe("duplicating frame children", () => {
it("frame child selected", () => {
const frame = API.createElement({
type: "frame",
});
const rectangle = API.createElement({
type: "rectangle",
frameId: frame.id,
});
API.setElements([frame, rectangle]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
]);
});
it("frame text container selected (rectangle selected)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id).id,
frameId: frame.id,
},
]);
});
it("frame bound text selected (container not selected)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([text]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id).id,
frameId: frame.id,
},
]);
});
it("frame text container selected (text not exists)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
]);
});
// shouldn't happen
it("frame bound text selected (container not exists)", () => {
const frame = API.createElement({
type: "frame",
});
const [, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, text]);
API.setSelectedElements([text]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: text.id, frameId: frame.id },
{ [ORIG_ID]: text.id, frameId: frame.id },
]);
});
it("frame bound container selected (text has no frameId)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({
frameId: frame.id,
label: { frameId: null },
});
API.setElements([frame, rectangle, text]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id).id,
},
]);
});
});
describe("duplicating multiple frames", () => {
it("multiple frames selected (no children)", () => {
const frame1 = API.createElement({
type: "frame",
});
const rect1 = API.createElement({
type: "rectangle",
frameId: frame1.id,
});
const frame2 = API.createElement({
type: "frame",
});
const rect2 = API.createElement({
type: "rectangle",
frameId: frame2.id,
});
const ellipse = API.createElement({
type: "ellipse",
});
API.setElements([rect1, frame1, ellipse, rect2, frame2]);
API.setSelectedElements([frame1, frame2]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rect1.id, frameId: frame1.id },
{ id: frame1.id },
{ [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
{ [ORIG_ID]: frame1.id, selected: true },
{ id: ellipse.id },
{ id: rect2.id, frameId: frame2.id },
{ id: frame2.id },
{ [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
{ [ORIG_ID]: frame2.id, selected: true },
]);
});
it("multiple frames selected (no children) + unrelated element", () => {
const frame1 = API.createElement({
type: "frame",
});
const rect1 = API.createElement({
type: "rectangle",
frameId: frame1.id,
});
const frame2 = API.createElement({
type: "frame",
});
const rect2 = API.createElement({
type: "rectangle",
frameId: frame2.id,
});
const ellipse = API.createElement({
type: "ellipse",
});
API.setElements([rect1, frame1, ellipse, rect2, frame2]);
API.setSelectedElements([frame1, ellipse, frame2]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rect1.id, frameId: frame1.id },
{ id: frame1.id },
{ [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
{ [ORIG_ID]: frame1.id, selected: true },
{ id: ellipse.id },
{ [ORIG_ID]: ellipse.id, selected: true },
{ id: rect2.id, frameId: frame2.id },
{ id: frame2.id },
{ [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
{ [ORIG_ID]: frame2.id, selected: true },
]);
});
});
describe("duplicating containers/bound elements", () => {
it("labeled arrow (arrow selected)", () => {
const [arrow, text] = API.createLabeledArrow();
API.setElements([arrow, text]);
API.setSelectedElements([arrow]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: arrow.id },
{ id: text.id, containerId: arrow.id },
{ [ORIG_ID]: arrow.id, selected: true },
{ [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
]);
});
// shouldn't happen
it("labeled arrow (text selected)", () => {
const [arrow, text] = API.createLabeledArrow();
API.setElements([arrow, text]);
API.setSelectedElements([text]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: arrow.id },
{ id: text.id, containerId: arrow.id },
{ [ORIG_ID]: arrow.id, selected: true },
{ [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
]);
});
});
describe("duplicating groups", () => {
it("duplicate group containing frame (children don't have groupIds set)", () => {
const frame = API.createElement({
type: "frame",
groupIds: ["A"],
});
const [rectangle, text] = API.createTextContainer({
frameId: frame.id,
});
const ellipse = API.createElement({
type: "ellipse",
groupIds: ["A"],
});
API.setElements([rectangle, text, frame, ellipse]);
API.setSelectedElements([frame, ellipse]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, frameId: frame.id },
{ id: frame.id },
{ id: ellipse.id },
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
{ [ORIG_ID]: text.id, frameId: getCloneByOrigId(frame.id)?.id },
{ [ORIG_ID]: frame.id, selected: true },
{ [ORIG_ID]: ellipse.id, selected: true },
]);
});
it("duplicate group containing frame (children have groupIds)", () => {
const frame = API.createElement({
type: "frame",
groupIds: ["A"],
});
const [rectangle, text] = API.createTextContainer({
frameId: frame.id,
groupIds: ["A"],
});
const ellipse = API.createElement({
type: "ellipse",
groupIds: ["A"],
});
API.setElements([rectangle, text, frame, ellipse]);
API.setSelectedElements([frame, ellipse]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, frameId: frame.id },
{ id: frame.id },
{ id: ellipse.id },
{
[ORIG_ID]: rectangle.id,
frameId: getCloneByOrigId(frame.id)?.id,
// FIXME shouldn't be selected (in selectGroupsForSelectedElements)
selected: true,
},
{
[ORIG_ID]: text.id,
frameId: getCloneByOrigId(frame.id)?.id,
// FIXME shouldn't be selected (in selectGroupsForSelectedElements)
selected: true,
},
{ [ORIG_ID]: frame.id, selected: true },
{ [ORIG_ID]: ellipse.id, selected: true },
]);
});
it("duplicating element nested in group", () => {
const ellipse = API.createElement({
type: "ellipse",
groupIds: ["B"],
});
const rect1 = API.createElement({
type: "rectangle",
groupIds: ["A", "B"],
});
const rect2 = API.createElement({
type: "rectangle",
groupIds: ["A", "B"],
});
API.setElements([ellipse, rect1, rect2]);
API.setSelectedElements([ellipse], "B");
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: ellipse.id },
{ [ORIG_ID]: ellipse.id, groupIds: ["B"], selected: true },
{ id: rect1.id, groupIds: ["A", "B"] },
{ id: rect2.id, groupIds: ["A", "B"] },
]);
});
});
});
@@ -5,7 +5,13 @@ import { duplicateElement, getNonDeletedElements } from "../element";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils"; import {
arrayToMap,
castArray,
findLastIndex,
getShortcutKey,
invariant,
} from "../utils";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { import {
selectGroupsForSelectedElements, selectGroupsForSelectedElements,
@@ -19,8 +25,13 @@ import { DEFAULT_GRID_SIZE } from "../constants";
import { import {
bindTextToShapeAfterDuplication, bindTextToShapeAfterDuplication,
getBoundTextElement, getBoundTextElement,
getContainerElement,
} from "../element/textElement"; } from "../element/textElement";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks"; import {
hasBoundTextElement,
isBoundToContainer,
isFrameLikeElement,
} from "../element/typeChecks";
import { normalizeElementOrder } from "../element/sortElements"; import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons"; import { DuplicateIcon } from "../components/icons";
import { import {
@@ -31,7 +42,6 @@ import {
excludeElementsInFramesFromSelection, excludeElementsInFramesFromSelection,
getSelectedElements, getSelectedElements,
} from "../scene/selection"; } from "../scene/selection";
import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
@@ -59,8 +69,20 @@ export const actionDuplicateSelection = register({
} }
} }
const nextState = duplicateElements(elements, appState);
if (app.props.onDuplicate && nextState.elements) {
const mappedElements = app.props.onDuplicate(
nextState.elements,
elements,
);
if (mappedElements) {
nextState.elements = mappedElements;
}
}
return { return {
...duplicateElements(elements, appState), ...nextState,
storeAction: StoreAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
}, },
@@ -82,37 +104,69 @@ export const actionDuplicateSelection = register({
const duplicateElements = ( const duplicateElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
): Partial<ActionResult> => { ): Partial<Exclude<ActionResult, false>> => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// step (1)
const sortedElements = normalizeElementOrder(elements);
const groupIdMap = new Map(); const groupIdMap = new Map();
const newElements: ExcalidrawElement[] = []; const newElements: ExcalidrawElement[] = [];
const oldElements: ExcalidrawElement[] = []; const oldElements: ExcalidrawElement[] = [];
const oldIdToDuplicatedId = new Map(); const oldIdToDuplicatedId = new Map();
const duplicatedElementsMap = new Map<string, ExcalidrawElement>(); const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
const duplicateAndOffsetElement = (element: ExcalidrawElement) => { const elementsMap = arrayToMap(elements);
const newElement = duplicateElement(
appState.editingGroupId, const duplicateAndOffsetElement = <
groupIdMap, T extends ExcalidrawElement | ExcalidrawElement[],
element, >(
{ element: T,
x: element.x + DEFAULT_GRID_SIZE / 2, ): T extends ExcalidrawElement[]
y: element.y + DEFAULT_GRID_SIZE / 2, ? ExcalidrawElement[]
: ExcalidrawElement | null => {
const elements = castArray(element);
const _newElements = elements.reduce(
(acc: ExcalidrawElement[], element) => {
if (processedIds.has(element.id)) {
return acc;
}
processedIds.set(element.id, true);
const newElement = duplicateElement(
appState.editingGroupId,
groupIdMap,
element,
{
x: element.x + DEFAULT_GRID_SIZE / 2,
y: element.y + DEFAULT_GRID_SIZE / 2,
},
);
processedIds.set(newElement.id, true);
duplicatedElementsMap.set(newElement.id, newElement);
oldIdToDuplicatedId.set(element.id, newElement.id);
oldElements.push(element);
newElements.push(newElement);
acc.push(newElement);
return acc;
}, },
[],
); );
duplicatedElementsMap.set(newElement.id, newElement);
oldIdToDuplicatedId.set(element.id, newElement.id); return (
oldElements.push(element); Array.isArray(element) ? _newElements : _newElements[0] || null
newElements.push(newElement); ) as T extends ExcalidrawElement[]
return newElement; ? ExcalidrawElement[]
: ExcalidrawElement | null;
}; };
elements = normalizeElementOrder(elements);
const idsOfElementsToDuplicate = arrayToMap( const idsOfElementsToDuplicate = arrayToMap(
getSelectedElements(sortedElements, appState, { getSelectedElements(elements, appState, {
includeBoundTextElement: true, includeBoundTextElement: true,
includeElementsInFrames: true, includeElementsInFrames: true,
}), }),
@@ -130,122 +184,133 @@ const duplicateElements = (
// loop over them. // loop over them.
const processedIds = new Map<ExcalidrawElement["id"], true>(); const processedIds = new Map<ExcalidrawElement["id"], true>();
const markAsProcessed = (elements: ExcalidrawElement[]) => { const elementsWithClones: ExcalidrawElement[] = elements.slice();
for (const element of elements) {
processedIds.set(element.id, true); const insertAfterIndex = (
index: number,
elements: ExcalidrawElement | null | ExcalidrawElement[],
) => {
invariant(index !== -1, "targetIndex === -1 ");
if (!Array.isArray(elements) && !elements) {
return;
} }
return elements;
elementsWithClones.splice(index + 1, 0, ...castArray(elements));
}; };
const elementsWithClones: ExcalidrawElement[] = []; const frameIdsToDuplicate = new Set(
elements
.filter(
(el) => idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el),
)
.map((el) => el.id),
);
let index = -1; for (const element of elements) {
if (processedIds.has(element.id)) {
while (++index < sortedElements.length) {
const element = sortedElements[index];
if (processedIds.get(element.id)) {
continue; continue;
} }
const boundTextElement = getBoundTextElement(element, arrayToMap(elements)); if (!idsOfElementsToDuplicate.has(element.id)) {
const isElementAFrameLike = isFrameLikeElement(element); continue;
}
if (idsOfElementsToDuplicate.get(element.id)) { // groups
// if a group or a container/bound-text or frame, duplicate atomically // -------------------------------------------------------------------------
if (element.groupIds.length || boundTextElement || isElementAFrameLike) {
const groupId = getSelectedGroupForElement(appState, element);
if (groupId) {
// TODO:
// remove `.flatMap...`
// if the elements in a frame are grouped when the frame is grouped
const groupElements = getElementsInGroup(
sortedElements,
groupId,
).flatMap((element) =>
isFrameLikeElement(element)
? [...getFrameChildren(elements, element.id), element]
: [element],
);
elementsWithClones.push( const groupId = getSelectedGroupForElement(appState, element);
...markAsProcessed([ if (groupId) {
...groupElements, const groupElements = getElementsInGroup(elements, groupId).flatMap(
...groupElements.map((element) => (element) =>
duplicateAndOffsetElement(element), isFrameLikeElement(element)
), ? [...getFrameChildren(elements, element.id), element]
]), : [element],
); );
continue;
}
if (boundTextElement) {
elementsWithClones.push(
...markAsProcessed([
element,
boundTextElement,
duplicateAndOffsetElement(element),
duplicateAndOffsetElement(boundTextElement),
]),
);
continue;
}
if (isElementAFrameLike) {
const elementsInFrame = getFrameChildren(sortedElements, element.id);
elementsWithClones.push( const targetIndex = findLastIndex(elementsWithClones, (el) => {
...markAsProcessed([ return el.groupIds?.includes(groupId);
...elementsInFrame, });
element,
...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
duplicateAndOffsetElement(element),
]),
);
continue; insertAfterIndex(targetIndex, duplicateAndOffsetElement(groupElements));
} continue;
} }
// since elements in frames have a lower z-index than the frame itself,
// they will be looped first and if their frames are selected as well, // frame duplication
// they will have been copied along with the frame atomically in the // -------------------------------------------------------------------------
// above branch, so we must skip those elements here
// if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
// now, for elements do not belong any frames or elements whose frames continue;
// are selected (or elements that are left out from the above }
// steps for whatever reason) we (should at least) duplicate them here
if (!element.frameId || !idsOfElementsToDuplicate.has(element.frameId)) { if (isFrameLikeElement(element)) {
elementsWithClones.push( const frameId = element.id;
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithClones, (el) => {
return el.frameId === frameId || el.id === frameId;
});
insertAfterIndex(
targetIndex,
duplicateAndOffsetElement([...frameChildren, element]),
);
continue;
}
// text container
// -------------------------------------------------------------------------
if (hasBoundTextElement(element)) {
const boundTextElement = getBoundTextElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithClones, (el) => {
return (
el.id === element.id ||
("containerId" in el && el.containerId === element.id)
); );
});
if (boundTextElement) {
insertAfterIndex(
targetIndex,
duplicateAndOffsetElement([element, boundTextElement]),
);
} else {
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
} }
} else {
elementsWithClones.push(...markAsProcessed([element])); continue;
} }
}
// step (2) if (isBoundToContainer(element)) {
const container = getContainerElement(element, elementsMap);
// second pass to remove duplicates. We loop from the end as it's likelier const targetIndex = findLastIndex(elementsWithClones, (el) => {
// that the last elements are in the correct order (contiguous or otherwise). return el.id === element.id || el.id === container?.id;
// Thus we need to reverse as the last step (3). });
const finalElementsReversed: ExcalidrawElement[] = []; if (container) {
insertAfterIndex(
targetIndex,
duplicateAndOffsetElement([container, element]),
);
} else {
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
}
const finalElementIds = new Map<ExcalidrawElement["id"], true>(); continue;
index = elementsWithClones.length;
while (--index >= 0) {
const element = elementsWithClones[index];
if (!finalElementIds.get(element.id)) {
finalElementIds.set(element.id, true);
finalElementsReversed.push(element);
} }
}
// step (3) // default duplication (regular elements)
const finalElements = syncMovedIndices( // -------------------------------------------------------------------------
finalElementsReversed.reverse(),
arrayToMap(newElements), insertAfterIndex(
); findLastIndex(elementsWithClones, (el) => el.id === element.id),
duplicateAndOffsetElement(element),
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -260,7 +325,7 @@ const duplicateElements = (
oldIdToDuplicatedId, oldIdToDuplicatedId,
); );
bindElementsToFramesAfterDuplication( bindElementsToFramesAfterDuplication(
finalElements, elementsWithClones,
oldElements, oldElements,
oldIdToDuplicatedId, oldIdToDuplicatedId,
); );
@@ -269,7 +334,7 @@ const duplicateElements = (
excludeElementsInFramesFromSelection(newElements); excludeElementsInFramesFromSelection(newElements);
return { return {
elements: finalElements, elements: elementsWithClones,
appState: { appState: {
...appState, ...appState,
...selectGroupsForSelectedElements( ...selectGroupsForSelectedElements(
@@ -285,7 +350,7 @@ const duplicateElements = (
{}, {},
), ),
}, },
getNonDeletedElements(finalElements), getNonDeletedElements(elementsWithClones),
appState, appState,
null, null,
), ),
@@ -121,6 +121,7 @@ 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;
@@ -630,25 +631,12 @@ export const actionChangeOpacity = register({
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<label className="control-label"> <Range
{t("labels.opacity")} updateData={updateData}
<input elements={elements}
type="range" appState={appState}
min="0" testId="opacity"
max="100" />
step="10"
onChange={(event) => updateData(+event.target.value)}
value={
getFormValue(
elements,
appState,
(element) => element.opacity,
true,
appState.currentItemOpacity,
) ?? undefined
}
/>
</label>
), ),
}); });
@@ -1617,6 +1605,8 @@ export const actionChangeArrowType = register({
elements, elements,
elementsMap, elementsMap,
appState.zoom, appState.zoom,
false,
true,
); );
const endHoveredElement = const endHoveredElement =
!newElement.endBinding && !newElement.endBinding &&
@@ -1625,6 +1615,8 @@ 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/textElement"; import { measureText } from "../element/textMeasurements";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import type { AppClassProperties } from "../types"; import type { AppClassProperties } from "../types";
+65 -66
View File
@@ -331,17 +331,10 @@ import type { FileSystemHandle } from "../data/filesystem";
import { fileOpen } from "../data/filesystem"; import { fileOpen } from "../data/filesystem";
import { import {
bindTextToShapeAfterDuplication, bindTextToShapeAfterDuplication,
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement, getBoundTextElement,
getContainerCenter, getContainerCenter,
getContainerElement, getContainerElement,
getLineHeightInPx,
getMinTextElementWidth,
isMeasureTextSupported,
isValidTextContainer, isValidTextContainer,
measureText,
normalizeText,
} from "../element/textElement"; } from "../element/textElement";
import { import {
showHyperlinkTooltip, showHyperlinkTooltip,
@@ -465,6 +458,15 @@ import { cropElement } from "../element/cropElement";
import { wrapText } from "../element/textWrapping"; import { wrapText } from "../element/textWrapping";
import { actionCopyElementLink } from "../actions/actionElementLink"; import { actionCopyElementLink } from "../actions/actionElementLink";
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink"; import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
import {
isMeasureTextSupported,
normalizeText,
measureText,
getLineHeightInPx,
getApproxMinLineWidth,
getApproxMinLineHeight,
getMinTextElementWidth,
} from "../element/textMeasurements";
const AppContext = React.createContext<AppClassProperties>(null!); const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!); const AppPropsContext = React.createContext<AppProps>(null!);
@@ -601,7 +603,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(); private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator(this);
hitLinkElement?: NonDeletedExcalidrawElement; hitLinkElement?: NonDeletedExcalidrawElement;
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null; lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
@@ -1522,13 +1524,17 @@ class App extends React.Component<AppProps, AppState> {
const allElementsMap = this.scene.getNonDeletedElementsMap(); const allElementsMap = this.scene.getNonDeletedElementsMap();
const shouldBlockPointerEvents = const shouldBlockPointerEvents =
this.state.selectionElement || // default back to `--ui-pointerEvents` flow if setPointerCapture
this.state.newElement || // not supported
this.state.selectedElementsAreBeingDragged || "setPointerCapture" in HTMLElement.prototype
this.state.resizingElement || ? false
(this.state.activeTool.type === "laser" && : this.state.selectionElement ||
// technically we can just test on this once we make it more safe this.state.newElement ||
this.state.cursorButton === "down"); this.state.selectedElementsAreBeingDragged ||
this.state.resizingElement ||
(this.state.activeTool.type === "laser" &&
// technically we can just test on this once we make it more safe
this.state.cursorButton === "down");
const firstSelectedElement = selectedElements[0]; const firstSelectedElement = selectedElements[0];
@@ -3224,7 +3230,14 @@ class App extends React.Component<AppProps, AppState> {
); );
const prevElements = this.scene.getElementsIncludingDeleted(); const prevElements = this.scene.getElementsIncludingDeleted();
const nextElements = [...prevElements, ...newElements]; let nextElements = [...prevElements, ...newElements];
const mappedNewSceneElements = this.props.onDuplicate?.(
nextElements,
prevElements,
);
nextElements = mappedNewSceneElements || nextElements;
syncMovedIndices(nextElements, arrayToMap(newElements)); syncMovedIndices(nextElements, arrayToMap(newElements));
@@ -4130,51 +4143,10 @@ class App extends React.Component<AppProps, AppState> {
if (selectedElements.length === 1 && arrowKeyPressed) { if (selectedElements.length === 1 && arrowKeyPressed) {
event.preventDefault(); event.preventDefault();
const nextId = this.flowChartNavigator.exploreByDirection( return 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;
} }
} }
} }
@@ -5770,7 +5742,10 @@ class App extends React.Component<AppProps, AppState> {
}); });
} }
if (editingLinearElement?.lastUncommittedPoint != null) { if (editingLinearElement?.lastUncommittedPoint != null) {
this.maybeSuggestBindingAtCursor(scenePointer); this.maybeSuggestBindingAtCursor(
scenePointer,
editingLinearElement.elbowed,
);
} else { } else {
// causes stack overflow if not sync // causes stack overflow if not sync
flushSync(() => { flushSync(() => {
@@ -5790,7 +5765,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.startBoundElement, this.state.startBoundElement,
); );
} else { } else {
this.maybeSuggestBindingAtCursor(scenePointer); this.maybeSuggestBindingAtCursor(scenePointer, false);
} }
} }
@@ -6292,6 +6267,13 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerDown = ( private handleCanvasPointerDown = (
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLElement>,
) => { ) => {
const target = event.target as HTMLElement;
// capture subsequent pointer events to the canvas
// this makes other elements non-interactive until pointer up
if (target.setPointerCapture) {
target.setPointerCapture(event.pointerId);
}
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent); this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
this.maybeUnfollowRemoteUser(); this.maybeUnfollowRemoteUser();
@@ -7728,6 +7710,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.state.zoom, this.state.zoom,
isElbowArrow(element), isElbowArrow(element),
isElbowArrow(element),
); );
this.scene.insertElement(element); this.scene.insertElement(element);
@@ -8427,7 +8410,17 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
const nextSceneElements = [...nextElements, ...elementsToAppend]; let nextSceneElements: ExcalidrawElement[] = [
...nextElements,
...elementsToAppend,
];
const mappedNewSceneElements = this.props.onDuplicate?.(
nextSceneElements,
elements,
);
nextSceneElements = mappedNewSceneElements || nextSceneElements;
syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend)); syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend));
@@ -10005,15 +9998,20 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private maybeSuggestBindingAtCursor = (pointerCoords: { private maybeSuggestBindingAtCursor = (
x: number; pointerCoords: {
y: number; x: number;
}): void => { y: number;
},
considerAll: boolean,
): void => {
const hoveredBindableElement = getHoveredElementForBinding( const hoveredBindableElement = getHoveredElementForBinding(
pointerCoords, pointerCoords,
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.state.zoom, this.state.zoom,
false,
considerAll,
); );
this.setState({ this.setState({
suggestedBindings: suggestedBindings:
@@ -10043,7 +10041,8 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.state.zoom, this.state.zoom,
isArrowElement(linearElement) && isElbowArrow(linearElement), isElbowArrow(linearElement),
isElbowArrow(linearElement),
); );
if ( if (
hoveredBindableElement != null && hoveredBindableElement != null &&
@@ -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 ".."; import { useDevice } from "./App";
import "./IconPicker.scss"; import "./IconPicker.scss";
+200 -130
View File
@@ -1,4 +1,11 @@
import React, { useState, useCallback, useMemo, useRef } from "react"; import React, {
useState,
useCallback,
useMemo,
useEffect,
memo,
useRef,
} from "react";
import type Library from "../data/library"; import type Library from "../data/library";
import { import {
distributeLibraryItemsOnSquareGrid, distributeLibraryItemsOnSquareGrid,
@@ -11,6 +18,7 @@ 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";
@@ -27,9 +35,12 @@ import { useUIAppState } from "../context/ui-appState";
import "./LibraryMenu.scss"; import "./LibraryMenu.scss";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons"; import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import { isShallowEqual } from "../utils"; import type {
import type { NonDeletedExcalidrawElement } from "../element/types"; ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { LIBRARY_DISABLED_TYPES } from "../constants"; import { LIBRARY_DISABLED_TYPES } from "../constants";
import { isShallowEqual } from "../utils";
export const isLibraryMenuOpenAtom = atom(false); export const isLibraryMenuOpenAtom = atom(false);
@@ -37,156 +48,215 @@ const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => {
return <div className="layer-ui__library">{children}</div>; return <div className="layer-ui__library">{children}</div>;
}; };
export const LibraryMenuContent = ({ const LibraryMenuContent = memo(
onInsertLibraryItems, ({
pendingElements, onInsertLibraryItems,
onAddToLibrary, pendingElements,
setAppState, onAddToLibrary,
libraryReturnUrl, setAppState,
library, libraryReturnUrl,
id, library,
theme, id,
selectedItems, theme,
onSelectItems, selectedItems,
}: { onSelectItems,
pendingElements: LibraryItem["elements"]; }: {
onInsertLibraryItems: (libraryItems: LibraryItems) => void; pendingElements: LibraryItem["elements"];
onAddToLibrary: () => void; onInsertLibraryItems: (libraryItems: LibraryItems) => void;
setAppState: React.Component<any, UIAppState>["setState"]; onAddToLibrary: () => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; setAppState: React.Component<any, UIAppState>["setState"];
library: Library; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
id: string; library: Library;
theme: UIAppState["theme"]; id: string;
selectedItems: LibraryItem["id"][]; theme: UIAppState["theme"];
onSelectItems: (id: LibraryItem["id"][]) => void; selectedItems: LibraryItem["id"][];
}) => { onSelectItems: (id: LibraryItem["id"][]) => void;
const [libraryItemsData] = useAtom(libraryItemsAtom); }) => {
const [libraryItemsData] = useAtom(libraryItemsAtom);
const _onAddToLibrary = useCallback( const _onAddToLibrary = useCallback(
(elements: LibraryItem["elements"]) => { (elements: LibraryItem["elements"]) => {
const addToLibrary = async ( const addToLibrary = async (
processedElements: LibraryItem["elements"], processedElements: LibraryItem["elements"],
libraryItems: LibraryItems, libraryItems: LibraryItems,
) => { ) => {
trackEvent("element", "addToLibrary", "ui"); trackEvent("element", "addToLibrary", "ui");
for (const type of LIBRARY_DISABLED_TYPES) { for (const type of LIBRARY_DISABLED_TYPES) {
if (processedElements.some((element) => element.type === type)) { if (processedElements.some((element) => element.type === type)) {
return setAppState({ return setAppState({
errorMessage: t(`errors.libraryElementTypeError.${type}`), errorMessage: t(`errors.libraryElementTypeError.${type}`),
}); });
}
} }
} const nextItems: LibraryItems = [
const nextItems: LibraryItems = [ {
{ status: "unpublished",
status: "unpublished", elements: processedElements,
elements: processedElements, id: randomId(),
id: randomId(), created: Date.now(),
created: Date.now(), },
}, ...libraryItems,
...libraryItems, ];
]; onAddToLibrary();
onAddToLibrary(); library.setLibrary(nextItems).catch(() => {
library.setLibrary(nextItems).catch(() => { setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") }); });
}); };
}; addToLibrary(elements, libraryItemsData.libraryItems);
addToLibrary(elements, libraryItemsData.libraryItems); },
}, [onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
[onAddToLibrary, library, setAppState, libraryItemsData.libraryItems], );
);
const libraryItems = useMemo( const libraryItems = useMemo(
() => libraryItemsData.libraryItems, () => libraryItemsData.libraryItems,
[libraryItemsData], [libraryItemsData],
); );
if (
libraryItemsData.status === "loading" &&
!libraryItemsData.isInitialized
) {
return (
<LibraryMenuWrapper>
<div className="layer-ui__library-message">
<div>
<Spinner size="2em" />
<span>{t("labels.libraryLoadingMessage")}</span>
</div>
</div>
</LibraryMenuWrapper>
);
}
const showBtn =
libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0;
if (
libraryItemsData.status === "loading" &&
!libraryItemsData.isInitialized
) {
return ( return (
<LibraryMenuWrapper> <LibraryMenuWrapper>
<div className="layer-ui__library-message"> <LibraryMenuItems
<div> isLoading={libraryItemsData.status === "loading"}
<Spinner size="2em" /> libraryItems={libraryItems}
<span>{t("labels.libraryLoadingMessage")}</span> onAddToLibrary={_onAddToLibrary}
</div> onInsertLibraryItems={onInsertLibraryItems}
</div> pendingElements={pendingElements}
</LibraryMenuWrapper>
);
}
const showBtn =
libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0;
return (
<LibraryMenuWrapper>
<LibraryMenuItems
isLoading={libraryItemsData.status === "loading"}
libraryItems={libraryItems}
onAddToLibrary={_onAddToLibrary}
onInsertLibraryItems={onInsertLibraryItems}
pendingElements={pendingElements}
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
onSelectItems={onSelectItems}
selectedItems={selectedItems}
/>
{showBtn && (
<LibraryMenuControlButtons
className="library-menu-control-buttons--at-bottom"
style={{ padding: "16px 12px 0 12px" }}
id={id} id={id}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
theme={theme} theme={theme}
onSelectItems={onSelectItems}
selectedItems={selectedItems}
/> />
)} {showBtn && (
</LibraryMenuWrapper> <LibraryMenuControlButtons
); className="library-menu-control-buttons--at-bottom"
}; style={{ padding: "16px 12px 0 12px" }}
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
/>
)}
</LibraryMenuWrapper>
);
},
);
const getPendingElements = (
elements: readonly NonDeletedExcalidrawElement[],
selectedElementIds: UIAppState["selectedElementIds"],
) => ({
elements,
pending: getSelectedElements(
elements,
{ selectedElementIds },
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
),
selectedElementIds,
});
const usePendingElementsMemo = ( const usePendingElementsMemo = (
appState: UIAppState, appState: UIAppState,
elements: readonly NonDeletedExcalidrawElement[], app: AppClassProperties,
) => { ) => {
const create = () => const elements = useExcalidrawElements();
getSelectedElements(elements, appState, { const [state, setState] = useState(() =>
includeBoundTextElement: true, getPendingElements(elements, appState.selectedElementIds),
includeElementsInFrames: true, );
});
const val = useRef(create());
const prevAppState = useRef<UIAppState>(appState);
const prevElements = useRef(elements);
if ( const selectedElementVersions = useRef(
!isShallowEqual( new Map<ExcalidrawElement["id"], ExcalidrawElement["version"]>(),
appState.selectedElementIds, );
prevAppState.current.selectedElementIds,
) || useEffect(() => {
!isShallowEqual(elements, prevElements.current) for (const element of state.pending) {
) { selectedElementVersions.current.set(element.id, element.version);
val.current = create(); }
prevAppState.current = appState; }, [state.pending]);
prevElements.current = elements;
} useEffect(() => {
return val.current; if (
// Only update once pointer is released.
// Reading directly from app.state to make it clear it's not reactive
// (hence, there's potential for stale state)
app.state.cursorButton === "up" &&
app.state.activeTool.type === "selection"
) {
setState((prev) => {
// if selectedElementIds changed, we don't have to compare versions
// ---------------------------------------------------------------------
if (
!isShallowEqual(prev.selectedElementIds, appState.selectedElementIds)
) {
selectedElementVersions.current.clear();
return getPendingElements(elements, appState.selectedElementIds);
}
// otherwise we need to check whether selected elements changed
// ---------------------------------------------------------------------
const elementsMap = app.scene.getNonDeletedElementsMap();
for (const id of Object.keys(appState.selectedElementIds)) {
const currVersion = elementsMap.get(id)?.version;
if (
currVersion &&
currVersion !== selectedElementVersions.current.get(id)
) {
// we can't update the selectedElementVersions in here
// because of double render in StrictMode which would overwrite
// the state in the second pass with the old `prev` state.
// Thus, we update versions in a separate effect. May create
// a race condition since current effect is not fully reactive.
return getPendingElements(elements, appState.selectedElementIds);
}
}
// nothing changed
// ---------------------------------------------------------------------
return prev;
});
}
}, [
app,
app.state.cursorButton,
app.state.activeTool.type,
appState.selectedElementIds,
elements,
]);
return state.pending;
}; };
/** /**
* This component is meant to be rendered inside <Sidebar.Tab/> inside our * This component is meant to be rendered inside <Sidebar.Tab/> inside our
* <DefaultSidebar/> or host apps Sidebar components. * <DefaultSidebar/> or host apps Sidebar components.
*/ */
export const LibraryMenu = () => { export const LibraryMenu = memo(() => {
const { library, id, onInsertElements } = useApp(); const app = useApp();
const { onInsertElements } = app;
const appProps = useAppProps(); const appProps = useAppProps();
const appState = useUIAppState(); const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements();
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]); const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const memoizedLibrary = useMemo(() => library, [library]); const memoizedLibrary = useMemo(() => app.library, [app.library]);
// BUG: pendingElements are still causing some unnecessary rerenders because clicking into canvas returns some ids even when no element is selected. const pendingElements = usePendingElementsMemo(appState, app);
const pendingElements = usePendingElementsMemo(appState, elements);
const onInsertLibraryItems = useCallback( const onInsertLibraryItems = useCallback(
(libraryItems: LibraryItems) => { (libraryItems: LibraryItems) => {
@@ -211,10 +281,10 @@ export const LibraryMenu = () => {
setAppState={setAppState} setAppState={setAppState}
libraryReturnUrl={appProps.libraryReturnUrl} libraryReturnUrl={appProps.libraryReturnUrl}
library={memoizedLibrary} library={memoizedLibrary}
id={id} id={app.id}
theme={appState.theme} theme={appState.theme}
selectedItems={selectedItems} selectedItems={selectedItems}
onSelectItems={setSelectedItems} onSelectItems={setSelectedItems}
/> />
); );
}; });
@@ -145,12 +145,14 @@ export const MobileMenu = ({
<div className="App-toolbar-content"> <div className="App-toolbar-content">
<MainMenuTunnel.Out /> <MainMenuTunnel.Out />
{actionManager.renderAction("toggleEditMenu")} {actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
{actionManager.renderAction( {actionManager.renderAction(
appState.multiElement ? "finalize" : "duplicateSelection", appState.multiElement ? "finalize" : "duplicateSelection",
)} )}
{actionManager.renderAction("deleteSelectedElements")} {actionManager.renderAction("deleteSelectedElements")}
<div>
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
</div>
</div> </div>
); );
}; };
+56
View File
@@ -0,0 +1,56 @@
@import "../css/variables.module.scss";
.excalidraw {
--slider-thumb-size: 16px;
.range-wrapper {
position: relative;
padding-top: 10px;
padding-bottom: 30px;
}
.range-input {
width: 100%;
height: 4px;
-webkit-appearance: none;
background: var(--color-slider-track);
border-radius: 2px;
outline: none;
}
.range-input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: var(--slider-thumb-size);
height: var(--slider-thumb-size);
background: var(--color-slider-thumb);
border-radius: 50%;
cursor: pointer;
border: none;
}
.range-input::-moz-range-thumb {
width: var(--slider-thumb-size);
height: var(--slider-thumb-size);
background: var(--color-slider-thumb);
border-radius: 50%;
cursor: pointer;
border: none;
}
.value-bubble {
position: absolute;
bottom: 0;
transform: translateX(-50%);
font-size: 12px;
color: var(--text-primary-color);
}
.zero-label {
position: absolute;
bottom: 0;
left: 4px;
font-size: 12px;
color: var(--text-primary-color);
}
}
+65
View File
@@ -0,0 +1,65 @@
import React, { useEffect } from "react";
import { getFormValue } from "../actions/actionProperties";
import { t } from "../i18n";
import "./Range.scss";
export type RangeProps = {
updateData: (value: number) => void;
appState: any;
elements: any;
testId?: string;
};
export const Range = ({
updateData,
appState,
elements,
testId,
}: RangeProps) => {
const rangeRef = React.useRef<HTMLInputElement>(null);
const valueRef = React.useRef<HTMLDivElement>(null);
const value = getFormValue(
elements,
appState,
(element) => element.opacity,
true,
appState.currentItemOpacity,
);
useEffect(() => {
if (rangeRef.current && valueRef.current) {
const rangeElement = rangeRef.current;
const valueElement = valueRef.current;
const inputWidth = rangeElement.offsetWidth;
const thumbWidth = 15; // 15 is the width of the thumb
const position =
(value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
valueElement.style.left = `${position}px`;
rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`;
}
}, [value]);
return (
<label className="control-label">
{t("labels.opacity")}
<div className="range-wrapper">
<input
ref={rangeRef}
type="range"
min="0"
max="100"
step="10"
onChange={(event) => {
updateData(+event.target.value);
}}
value={value}
className="range-input"
data-testid={testId}
/>
<div className="value-bubble" ref={valueRef}>
{value !== 0 ? value : null}
</div>
<div className="zero-label">0</div>
</div>
</label>
);
};
@@ -7,7 +7,6 @@ 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";
@@ -20,6 +19,7 @@ import { useStable } from "../hooks/useStable";
import "./SearchMenu.scss"; import "./SearchMenu.scss";
import { round } from "../../math"; import { round } from "../../math";
import { measureText } from "../element/textMeasurements";
const searchQueryAtom = atom<string>(""); const searchQueryAtom = atom<string>("");
export const searchItemInFocusAtom = atom<number | null>(null); export const searchItemInFocusAtom = atom<number | null>(null);
@@ -607,7 +607,6 @@ const getMatchedLines = (
textToStart, textToStart,
getFontString(textElement), getFontString(textElement),
textElement.lineHeight, textElement.lineHeight,
true,
); );
// measureText returns a non-zero width for the empty string // measureText returns a non-zero width for the empty string
@@ -621,7 +620,6 @@ const getMatchedLines = (
lineIndexRange.line, lineIndexRange.line,
getFontString(textElement), getFontString(textElement),
textElement.lineHeight, textElement.lineHeight,
true,
); );
const spaceToStart = const spaceToStart =
+6 -5
View File
@@ -1216,11 +1216,12 @@ export const EdgeRoundIcon = createIcon(
); );
export const ArrowheadNoneIcon = createIcon( export const ArrowheadNoneIcon = createIcon(
<path d="M6 10H34" stroke="currentColor" strokeWidth={2} fill="none" />, <g stroke="currentColor" opacity={0.3} strokeWidth={2}>
{ <path d="M12 12l9 0" />
width: 40, <path d="M3 9l6 6" />
height: 20, <path d="M3 15l6 -6" />
}, </g>,
tablerIconProps,
); );
export const ArrowheadArrowIcon = React.memo( export const ArrowheadArrowIcon = React.memo(
@@ -1,6 +1,6 @@
.excalidraw { .excalidraw {
.excalifont { .excalifont {
font-family: "Excalifont"; font-family: "Excalifont", "Xiaolai";
} }
// WelcomeSreen common // WelcomeSreen common
+3
View File
@@ -458,3 +458,6 @@ export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3; export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
export const ELEMENT_LINK_KEY = "element"; export const ELEMENT_LINK_KEY = "element";
/** used in tests */
export const ORIG_ID = Symbol.for("__test__originalId__");
+7 -1
View File
@@ -649,15 +649,21 @@ body.excalidraw-cursor-resize * {
@include filledButtonOnCanvas; @include filledButtonOnCanvas;
} }
.App-mobile-menu,
.App-menu__left { .App-menu__left {
--button-border: transparent; --button-border: transparent;
--button-bg: var(--color-surface-mid); --button-bg: var(--color-surface-mid);
}
@at-root .excalidraw.theme--dark#{&} { @at-root .excalidraw.theme--dark#{&} {
.App-mobile-menu,
.App-menu__left {
--button-hover-bg: #363541; --button-hover-bg: #363541;
--button-bg: var(--color-surface-high); --button-bg: var(--color-surface-high);
} }
}
.App-menu__left {
.buttonList { .buttonList {
padding: 0.25rem 0; padding: 0.25rem 0;
} }
+5
View File
@@ -53,6 +53,9 @@
--scrollbar-thumb: var(--button-gray-2); --scrollbar-thumb: var(--button-gray-2);
--scrollbar-thumb-hover: var(--button-gray-3); --scrollbar-thumb-hover: var(--button-gray-3);
--color-slider-track: hsl(240, 100%, 90%);
--color-slider-thumb: var(--color-gray-80);
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07), --modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198), 0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275), 0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275),
@@ -207,6 +210,8 @@
--scrollbar-thumb: #{$oc-gray-8}; --scrollbar-thumb: #{$oc-gray-8};
--scrollbar-thumb-hover: #{$oc-gray-7}; --scrollbar-thumb-hover: #{$oc-gray-7};
--color-slider-track: hsl(244, 23%, 39%);
// will be inverted to a lighter color. // will be inverted to a lighter color.
--color-selection: #3530c4; --color-selection: #3530c4;
+35 -1
View File
@@ -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 { detectLineHeight, getContainerElement } from "../element/textElement"; import { getContainerElement } from "../element/textElement";
import { normalizeLink } from "./url"; import { normalizeLink } from "./url";
import { syncInvalidIndices } from "../fractionalIndex"; import { syncInvalidIndices } from "../fractionalIndex";
import { getSizeFromPoints } from "../points"; import { getSizeFromPoints } from "../points";
@@ -59,6 +59,7 @@ import {
} from "../scene"; } from "../scene";
import type { LocalPoint, Radians } from "../../math"; import type { LocalPoint, Radians } from "../../math";
import { isFiniteNumber, pointFrom } from "../../math"; import { isFiniteNumber, pointFrom } from "../../math";
import { detectLineHeight } from "../element/textMeasurements";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@@ -205,6 +206,24 @@ const restoreElementWithProperties = <
"customData" in extra ? extra.customData : element.customData; "customData" in extra ? extra.customData : element.customData;
} }
// NOTE (mtolmacs): This is a temporary check to detect extremely large
// element position or sizing
if (
element.x < -1e6 ||
element.x > 1e6 ||
element.y < -1e6 ||
element.y > 1e6 ||
element.width < -1e6 ||
element.width > 1e6 ||
element.height < -1e6 ||
element.height > 1e6
) {
console.error(
"Restore element with properties size or position is too large",
{ element },
);
}
return { return {
// spread the original element properties to not lose unknown ones // spread the original element properties to not lose unknown ones
// for forward-compatibility // for forward-compatibility
@@ -219,6 +238,21 @@ const restoreElementWithProperties = <
const restoreElement = ( const restoreElement = (
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>, element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
): typeof element | null => { ): typeof element | null => {
// NOTE (mtolmacs): This is a temporary check to detect extremely large
// element position or sizing
if (
element.x < -1e6 ||
element.x > 1e6 ||
element.y < -1e6 ||
element.y > 1e6 ||
element.width < -1e6 ||
element.width > 1e6 ||
element.height < -1e6 ||
element.height > 1e6
) {
console.error("Restore element size or position is too large", { element });
}
switch (element.type) { switch (element.type) {
case "text": case "text":
let fontSize = element.fontSize; let fontSize = element.fontSize;
+1 -1
View File
@@ -19,7 +19,6 @@ import {
newMagicFrameElement, newMagicFrameElement,
newTextElement, newTextElement,
} from "../element/newElement"; } from "../element/newElement";
import { measureText, normalizeText } from "../element/textElement";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
@@ -55,6 +54,7 @@ import { syncInvalidIndices } from "../fractionalIndex";
import { getLineHeight } from "../fonts"; import { getLineHeight } from "../fonts";
import { isArrowElement } from "../element/typeChecks"; import { isArrowElement } from "../element/typeChecks";
import { pointFrom, type LocalPoint } from "../../math"; import { pointFrom, type LocalPoint } from "../../math";
import { measureText, normalizeText } from "../element/textMeasurements";
export type ValidLinearElement = { export type ValidLinearElement = {
type: "arrow" | "line"; type: "arrow" | "line";
+2 -2
View File
@@ -25,7 +25,7 @@ describe("normalizeLink", () => {
expect(normalizeLink("file://")).toBe("file://"); expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)"); expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
expect(normalizeLink("[[test]]")).toBe("[[test]]"); expect(normalizeLink("[[test]]")).toBe("[[test]]");
expect(normalizeLink("<test>")).toBe("&lt;test&gt;"); expect(normalizeLink("<test>")).toBe("<test>");
expect(normalizeLink("test&")).toBe("test&amp;"); expect(normalizeLink("test&")).toBe("test&");
}); });
}); });
+2 -2
View File
@@ -1,12 +1,12 @@
import { sanitizeUrl } from "@braintree/sanitize-url"; import { sanitizeUrl } from "@braintree/sanitize-url";
import { sanitizeHTMLAttribute } from "../utils"; import { escapeDoubleQuotes } from "../utils";
export const normalizeLink = (link: string) => { export const normalizeLink = (link: string) => {
link = link.trim(); link = link.trim();
if (!link) { if (!link) {
return link; return link;
} }
return sanitizeUrl(sanitizeHTMLAttribute(link)); return sanitizeUrl(escapeDoubleQuotes(link));
}; };
export const isLocalLink = (link: string | null) => { export const isLocalLink = (link: string | null) => {
+113 -5
View File
@@ -32,7 +32,6 @@ import type { Bounds } from "./bounds";
import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds"; import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds";
import type { AppState } from "../types"; import type { AppState } from "../types";
import { isPointOnShape } from "../../utils/collision"; import { isPointOnShape } from "../../utils/collision";
import { getElementAtPosition } from "../scene";
import { import {
isArrowElement, isArrowElement,
isBindableElement, isBindableElement,
@@ -49,7 +48,11 @@ import type { ElementUpdate } from "./mutateElement";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import type Scene from "../scene/Scene"; import type Scene from "../scene/Scene";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils"; import {
arrayToMap,
isBindingFallthroughEnabled,
tupleToCoors,
} from "../utils";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes"; import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
@@ -425,7 +428,8 @@ export const maybeBindLinearElement = (
elements, elements,
elementsMap, elementsMap,
appState.zoom, appState.zoom,
isElbowArrow(linearElement) && isElbowArrow(linearElement), isElbowArrow(linearElement),
isElbowArrow(linearElement),
); );
if (hoveredElement !== null) { if (hoveredElement !== null) {
@@ -558,8 +562,65 @@ export const getHoveredElementForBinding = (
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
fullShape?: boolean, fullShape?: boolean,
considerAllElements?: boolean,
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
const hoveredElement = getElementAtPosition( if (considerAllElements) {
let cullRest = false;
const candidateElements = getAllElementsAtPositionForBinding(
elements,
(element) =>
isBindableElement(element, false) &&
bindingBorderTest(
element,
pointerCoords,
elementsMap,
zoom,
(fullShape ||
!isBindingFallthroughEnabled(
element as ExcalidrawBindableElement,
)) &&
// disable fullshape snapping for frame elements so we
// can bind to frame children
!isFrameLikeElement(element),
),
).filter((element) => {
if (cullRest) {
return false;
}
if (!isBindingFallthroughEnabled(element as ExcalidrawBindableElement)) {
cullRest = true;
}
return true;
}) as NonDeleted<ExcalidrawBindableElement>[] | null;
// Return early if there are no candidates or just one candidate
if (!candidateElements || candidateElements.length === 0) {
return null;
}
if (candidateElements.length === 1) {
return candidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
}
// Prefer the shape with the border being tested (if any)
const borderTestElements = candidateElements.filter((element) =>
bindingBorderTest(element, pointerCoords, elementsMap, zoom, false),
);
if (borderTestElements.length === 1) {
return borderTestElements[0];
}
// Prefer smaller shapes
return candidateElements
.sort(
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
)
.pop() as NonDeleted<ExcalidrawBindableElement>;
}
const hoveredElement = getElementAtPositionForBinding(
elements, elements,
(element) => (element) =>
isBindableElement(element, false) && isBindableElement(element, false) &&
@@ -570,13 +631,58 @@ export const getHoveredElementForBinding = (
zoom, zoom,
// disable fullshape snapping for frame elements so we // disable fullshape snapping for frame elements so we
// can bind to frame children // can bind to frame children
fullShape && !isFrameLikeElement(element), (fullShape || !isBindingFallthroughEnabled(element)) &&
!isFrameLikeElement(element),
), ),
); );
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null; return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
}; };
const getElementAtPositionForBinding = (
elements: readonly NonDeletedExcalidrawElement[],
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
) => {
let hitElement = null;
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
if (element.isDeleted) {
continue;
}
if (isAtPositionFn(element)) {
hitElement = element;
break;
}
}
return hitElement;
};
const getAllElementsAtPositionForBinding = (
elements: readonly NonDeletedExcalidrawElement[],
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
) => {
const elementsAtPosition: NonDeletedExcalidrawElement[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
if (element.isDeleted) {
continue;
}
if (isAtPositionFn(element)) {
elementsAtPosition.push(element);
}
}
return elementsAtPosition;
};
const calculateFocusAndGap = ( const calculateFocusAndGap = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
@@ -1220,6 +1326,8 @@ const getElligibleElementForBindingElement = (
elements, elements,
elementsMap, elementsMap,
zoom, zoom,
isElbowArrow(linearElement),
isElbowArrow(linearElement),
); );
}; };
+2 -1
View File
@@ -10,7 +10,7 @@ import type {
NullableGridSize, NullableGridSize,
PointerDownState, PointerDownState,
} from "../types"; } from "../types";
import { getBoundTextElement, getMinTextElementWidth } from "./textElement"; import { getBoundTextElement } from "./textElement";
import type Scene from "../scene/Scene"; import type Scene from "../scene/Scene";
import { import {
isArrowElement, isArrowElement,
@@ -22,6 +22,7 @@ import {
import { getFontString } from "../utils"; import { getFontString } from "../utils";
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants"; import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
import { getGridPoint } from "../snapping"; import { getGridPoint } from "../snapping";
import { getMinTextElementWidth } from "./textMeasurements";
export const dragSelectedElements = ( export const dragSelectedElements = (
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
+148 -15
View File
@@ -1,4 +1,5 @@
import { import {
clamp,
pointDistance, pointDistance,
pointFrom, pointFrom,
pointScaleFromOrigin, pointScaleFromOrigin,
@@ -20,11 +21,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";
@@ -104,7 +105,7 @@ const handleSegmentRenormalization = (
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) => { ) => {
const nextFixedSegments: FixedSegment[] | null = arrow.fixedSegments const nextFixedSegments: FixedSegment[] | null = arrow.fixedSegments
? structuredClone(arrow.fixedSegments) ? arrow.fixedSegments.slice()
: null; : null;
if (nextFixedSegments) { if (nextFixedSegments) {
@@ -244,6 +245,12 @@ const handleSegmentRenormalization = (
); );
} }
import.meta.env.DEV &&
invariant(
validateElbowPoints(nextPoints),
"Invalid elbow points with fixed segments",
);
return normalizeArrowElementUpdate( return normalizeArrowElementUpdate(
nextPoints, nextPoints,
filteredNextFixedSegments, filteredNextFixedSegments,
@@ -264,7 +271,7 @@ const handleSegmentRenormalization = (
const handleSegmentRelease = ( const handleSegmentRelease = (
arrow: ExcalidrawElbowArrowElement, arrow: ExcalidrawElbowArrowElement,
fixedSegments: FixedSegment[], fixedSegments: readonly FixedSegment[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) => { ) => {
const newFixedSegmentIndices = fixedSegments.map((segment) => segment.index); const newFixedSegmentIndices = fixedSegments.map((segment) => segment.index);
@@ -438,7 +445,7 @@ const handleSegmentRelease = (
*/ */
const handleSegmentMove = ( const handleSegmentMove = (
arrow: ExcalidrawElbowArrowElement, arrow: ExcalidrawElbowArrowElement,
fixedSegments: FixedSegment[], fixedSegments: readonly FixedSegment[],
startHeading: Heading, startHeading: Heading,
endHeading: Heading, endHeading: Heading,
hoveredStartElement: ExcalidrawBindableElement | null, hoveredStartElement: ExcalidrawBindableElement | null,
@@ -680,7 +687,7 @@ const handleSegmentMove = (
const handleEndpointDrag = ( const handleEndpointDrag = (
arrow: ExcalidrawElbowArrowElement, arrow: ExcalidrawElbowArrowElement,
updatedPoints: readonly LocalPoint[], updatedPoints: readonly LocalPoint[],
fixedSegments: FixedSegment[], fixedSegments: readonly FixedSegment[],
startHeading: Heading, startHeading: Heading,
endHeading: Heading, endHeading: Heading,
startGlobalPoint: GlobalPoint, startGlobalPoint: GlobalPoint,
@@ -857,6 +864,8 @@ const handleEndpointDrag = (
); );
}; };
const MAX_POS = 1e6;
/** /**
* *
*/ */
@@ -866,6 +875,8 @@ 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;
@@ -875,6 +886,50 @@ 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,
@@ -912,7 +967,11 @@ 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 (elementsMap.size === 0 && updates.points) { if (
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]),
@@ -932,8 +991,8 @@ export const updateElbowArrowPoints = (
? updates.points![1] ? updates.points![1]
: p, : p,
) )
: structuredClone(updates.points) : updates.points.slice()
: structuredClone(arrow.points); : arrow.points.slice();
const { const {
startHeading, startHeading,
@@ -943,17 +1002,48 @@ export const updateElbowArrowPoints = (
hoveredStartElement, hoveredStartElement,
hoveredEndElement, hoveredEndElement,
...rest ...rest
} = getElbowArrowData(arrow, elementsMap, updatedPoints, options); } = getElbowArrowData(
{
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 (!updates.points && !updates.fixedSegments) { if (
!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
//// ////
@@ -1000,6 +1090,7 @@ export const updateElbowArrowPoints = (
//// ////
// 5. Handle resize // 5. Handle resize
////
if (updates.points && updates.fixedSegments) { if (updates.points && updates.fixedSegments) {
return updates; return updates;
} }
@@ -1921,7 +2012,7 @@ const getBindableElementForId = (
const normalizeArrowElementUpdate = ( const normalizeArrowElementUpdate = (
global: GlobalPoint[], global: GlobalPoint[],
nextFixedSegments: FixedSegment[] | null, nextFixedSegments: readonly FixedSegment[] | null,
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
): { ): {
@@ -1930,24 +2021,51 @@ const normalizeArrowElementUpdate = (
y: number; y: number;
width: number; width: number;
height: number; height: number;
fixedSegments: FixedSegment[] | null; fixedSegments: readonly 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];
const points = global.map((p) => let 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: offsetX, x: clamp(offsetX, -1e6, 1e6),
y: offsetY, y: clamp(offsetY, -1e6, 1e6),
fixedSegments: fixedSegments:
(nextFixedSegments?.length ?? 0) > 0 ? nextFixedSegments : null, (nextFixedSegments?.length ?? 0) > 0 ? nextFixedSegments : null,
...getSizeFromPoints(points), ...getSizeFromPoints(points),
@@ -2110,6 +2228,7 @@ const getHoveredElements = (
nonDeletedSceneElementsMap, nonDeletedSceneElementsMap,
zoom, zoom,
true, true,
true,
), ),
getHoveredElementForBinding( getHoveredElementForBinding(
tupleToCoors(origEndGlobalPoint), tupleToCoors(origEndGlobalPoint),
@@ -2117,9 +2236,23 @@ 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);
+4 -8
View File
@@ -1,11 +1,7 @@
import { register } from "../actions/register"; import { register } from "../actions/register";
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants"; import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
import type { ExcalidrawProps } from "../types"; import type { ExcalidrawProps } from "../types";
import { import { escapeDoubleQuotes, getFontString, updateActiveTool } from "../utils";
getFontString,
sanitizeHTMLAttribute,
updateActiveTool,
} from "../utils";
import { setCursorForShape } from "../cursor"; import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement"; import { newTextElement } from "./newElement";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
@@ -212,7 +208,7 @@ export const getEmbedLink = (
// Note that we don't attempt to parse the username as it can consist of // Note that we don't attempt to parse the username as it can consist of
// non-latin1 characters, and the username in the url can be set to anything // non-latin1 characters, and the username in the url can be set to anything
// without affecting the embed. // without affecting the embed.
const safeURL = sanitizeHTMLAttribute( const safeURL = escapeDoubleQuotes(
`https://twitter.com/x/status/${postId}`, `https://twitter.com/x/status/${postId}`,
); );
@@ -231,7 +227,7 @@ export const getEmbedLink = (
if (RE_REDDIT.test(link)) { if (RE_REDDIT.test(link)) {
const [, page, postId, title] = link.match(RE_REDDIT)!; const [, page, postId, title] = link.match(RE_REDDIT)!;
const safeURL = sanitizeHTMLAttribute( const safeURL = escapeDoubleQuotes(
`https://reddit.com/r/${page}/comments/${postId}/${title}`, `https://reddit.com/r/${page}/comments/${postId}/${title}`,
); );
const ret: IframeDataWithSandbox = { const ret: IframeDataWithSandbox = {
@@ -249,7 +245,7 @@ export const getEmbedLink = (
if (RE_GH_GIST.test(link)) { if (RE_GH_GIST.test(link)) {
const [, user, gistId] = link.match(RE_GH_GIST)!; const [, user, gistId] = link.match(RE_GH_GIST)!;
const safeURL = sanitizeHTMLAttribute( const safeURL = escapeDoubleQuotes(
`https://gist.github.com/${user}/${gistId}`, `https://gist.github.com/${user}/${gistId}`,
); );
const ret: IframeDataWithSandbox = { const ret: IframeDataWithSandbox = {
@@ -310,95 +310,4 @@ 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,
);
});
}); });
+98 -102
View File
@@ -10,13 +10,15 @@ import {
import { bindLinearElement } from "./binding"; import { bindLinearElement } from "./binding";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { newArrowElement, newElement } from "./newElement"; import { newArrowElement, newElement } from "./newElement";
import type { import type { SceneElementsMap } from "./types";
ElementsMap, import {
ExcalidrawBindableElement, type ElementsMap,
ExcalidrawElement, type ExcalidrawBindableElement,
ExcalidrawFlowchartNodeElement, type ExcalidrawElement,
NonDeletedSceneElementsMap, type ExcalidrawFlowchartNodeElement,
OrderedExcalidrawElement, type NonDeletedSceneElementsMap,
type Ordered,
type OrderedExcalidrawElement,
} from "./types"; } from "./types";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import type { AppState, PendingExcalidrawElements } from "../types"; import type { AppState, PendingExcalidrawElements } from "../types";
@@ -28,9 +30,13 @@ import {
isFrameElement, isFrameElement,
isFlowchartNodeElement, isFlowchartNodeElement,
} from "./typeChecks"; } from "./typeChecks";
import { invariant } from "../utils"; import { invariant, toBrandedType } from "../utils";
import { pointFrom, type LocalPoint } from "../../math"; import { pointFrom, type LocalPoint } from "../../math";
import { aabbForElement } from "../shapes"; import { aabbForElement } from "../shapes";
import { updateElbowArrowPoints } from "./elbowArrow";
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";
@@ -467,67 +473,75 @@ const createBindingArrow = (
}, },
]); ]);
return bindingArrow; const update = updateElbowArrowPoints(
bindingArrow,
toBrandedType<SceneElementsMap>(
new Map([
...elementsMap.entries(),
[startBindingElement.id, startBindingElement],
[endBindingElement.id, endBindingElement],
[bindingArrow.id, bindingArrow],
] as [string, Ordered<ExcalidrawElement>][]),
),
{ points: bindingArrow.points },
);
return {
...bindingArrow,
...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 sameLevelNodes: ExcalidrawElement[] = []; private app: App;
private sameLevelIndex: number = 0; private siblingNodes: ExcalidrawElement[] = [];
// set it to the opposite of the defalut creation direction private siblingIndex: number = 0;
private direction: LinkDirection | null = null; private direction: LinkDirection | null = null;
// for speedier navigation
private visitedNodes: Set<ExcalidrawElement["id"]> = new Set(); constructor(app: App) {
this.app = app;
}
clear() { clear() {
this.isExploring = false; this.isExploring = false;
this.sameLevelNodes = []; this.siblingNodes = [];
this.sameLevelIndex = 0; this.siblingIndex = 0;
this.direction = null; this.direction = null;
this.visitedNodes.clear();
} }
exploreByDirection( /**
element: ExcalidrawElement, * Explore the flowchart by the given direction.
elementsMap: ElementsMap, *
direction: LinkDirection, * The exploration follows a (near) breadth-first approach: when there're multiple
): ExcalidrawElement["id"] | null { * nodes at the same level, we allow the user to traverse through them.
*/
exploreByDirection(element: ExcalidrawElement, direction: LinkDirection) {
if (!isBindableElement(element)) { if (!isBindableElement(element)) {
return null; return;
} }
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);
}
/** /**
* CASE: * if we're already exploring (holding the alt key)
* - already started exploring, AND * and the direction is the same as the previous one
* - there are multiple nodes at the same level, AND * and there're multiple nodes at the same level
* - still going at the same direction, AND * then we should traverse through them before moving to the next level
*
* 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.sameLevelNodes.length > 1 this.siblingNodes.length > 1
) { ) {
this.sameLevelIndex = this.siblingIndex = (this.siblingIndex + 1) % this.siblingNodes.length;
(this.sameLevelIndex + 1) % this.sameLevelNodes.length; return this.goToNode(this.siblingNodes[this.siblingIndex].id);
return this.sameLevelNodes[this.sameLevelIndex].id;
} }
const nodes = [ const nodes = [
@@ -535,70 +549,52 @@ 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.sameLevelIndex = 0; this.siblingIndex = 0;
this.isExploring = true; this.isExploring = true;
this.sameLevelNodes = nodes; this.siblingNodes = nodes;
this.direction = direction; this.direction = direction;
this.visitedNodes.add(nodes[0].id);
return nodes[0].id; this.goToNode(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,6 +444,8 @@ export class LinearElementEditor {
elements, elements,
elementsMap, elementsMap,
appState.zoom, appState.zoom,
isElbowArrow(element),
isElbowArrow(element),
) )
: null; : null;
@@ -796,6 +798,7 @@ export class LinearElementEditor {
elements, elements,
elementsMap, elementsMap,
app.state.zoom, app.state.zoom,
linearElementEditor.elbowed,
), ),
}; };
+7 -2
View File
@@ -33,13 +33,16 @@ 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 } = updates as any; const { points, fixedSegments, fileId, startBinding, endBinding } =
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(),
@@ -58,6 +61,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
{ {
fixedSegments, fixedSegments,
points, points,
startBinding,
endBinding,
}, },
{ {
isDragging: options?.isDragging, isDragging: options?.isDragging,
+46 -27
View File
@@ -33,11 +33,7 @@ import { getNewGroupIdsForDuplication } from "../groups";
import type { AppState } from "../types"; import type { AppState } from "../types";
import { getElementAbsoluteCoords } from "."; import { getElementAbsoluteCoords } from ".";
import { getResizedElementAbsoluteCoords } from "./bounds"; import { getResizedElementAbsoluteCoords } from "./bounds";
import { import { getBoundTextMaxWidth } from "./textElement";
measureText,
normalizeText,
getBoundTextMaxWidth,
} from "./textElement";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
import { import {
DEFAULT_ELEMENT_PROPS, DEFAULT_ELEMENT_PROPS,
@@ -45,11 +41,13 @@ import {
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN, DEFAULT_VERTICAL_ALIGN,
ORIG_ID,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import type { MarkOptional, Merge, Mutable } from "../utility-types"; import type { MarkOptional, Merge, Mutable } from "../utility-types";
import { getLineHeight } from "../fonts"; import { getLineHeight } from "../fonts";
import type { Radians } from "../../math"; import type { Radians } from "../../math";
import { normalizeText, measureText } from "./textMeasurements";
export type ElementConstructorOpts = MarkOptional< export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">, Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -101,6 +99,28 @@ const _newElementBase = <T extends ExcalidrawElement>(
...rest ...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">, }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => { ) => {
// NOTE (mtolmacs): This is a temporary check to detect extremely large
// element position or sizing
if (
x < -1e6 ||
x > 1e6 ||
y < -1e6 ||
y > 1e6 ||
width < -1e6 ||
width > 1e6 ||
height < -1e6 ||
height > 1e6
) {
console.error("New element size or position is too large", {
x,
y,
width,
height,
// @ts-ignore
points: rest.points,
});
}
// assign type to guard against excess properties // assign type to guard against excess properties
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = { const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
id: rest.id || randomId(), id: rest.id || randomId(),
@@ -592,26 +612,18 @@ export const deepCopyElement = <T extends ExcalidrawElement>(
return _deepCopyElement(val); return _deepCopyElement(val);
}; };
const __test__defineOrigId = (clonedObj: object, origId: string) => {
Object.defineProperty(clonedObj, ORIG_ID, {
value: origId,
writable: false,
enumerable: false,
});
};
/** /**
* utility wrapper to generate new id. In test env it reuses the old + postfix * utility wrapper to generate new id.
* for test assertions.
*/ */
export const regenerateId = ( const regenerateId = () => {
/** supply null if no previous id exists */
previousId: string | null,
) => {
if (isTestEnv() && previousId) {
let nextId = `${previousId}_copy`;
// `window.h` may not be defined in some unit tests
if (
window.h?.app
?.getSceneElementsIncludingDeleted()
.find((el: ExcalidrawElement) => el.id === nextId)
) {
nextId += "_copy";
}
return nextId;
}
return randomId(); return randomId();
}; };
@@ -637,7 +649,11 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
): Readonly<TElement> => { ): Readonly<TElement> => {
let copy = deepCopyElement(element); let copy = deepCopyElement(element);
copy.id = regenerateId(copy.id); if (isTestEnv()) {
__test__defineOrigId(copy, element.id);
}
copy.id = regenerateId();
copy.boundElements = null; copy.boundElements = null;
copy.updated = getUpdatedTimestamp(); copy.updated = getUpdatedTimestamp();
copy.seed = randomInteger(); copy.seed = randomInteger();
@@ -646,7 +662,7 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
editingGroupId, editingGroupId,
(groupId) => { (groupId) => {
if (!groupIdMapForOperation.has(groupId)) { if (!groupIdMapForOperation.has(groupId)) {
groupIdMapForOperation.set(groupId, regenerateId(groupId)); groupIdMapForOperation.set(groupId, regenerateId());
} }
return groupIdMapForOperation.get(groupId)!; return groupIdMapForOperation.get(groupId)!;
}, },
@@ -692,7 +708,7 @@ export const duplicateElements = (
// if we haven't migrated the element id, but an old element with the same // if we haven't migrated the element id, but an old element with the same
// id exists, generate a new id for it and return it // id exists, generate a new id for it and return it
if (origElementsMap.has(id)) { if (origElementsMap.has(id)) {
const newId = regenerateId(id); const newId = regenerateId();
elementNewIdsMap.set(id, newId); elementNewIdsMap.set(id, newId);
return newId; return newId;
} }
@@ -706,6 +722,9 @@ export const duplicateElements = (
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element); const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
clonedElement.id = maybeGetNewId(element.id)!; clonedElement.id = maybeGetNewId(element.id)!;
if (isTestEnv()) {
__test__defineOrigId(clonedElement, element.id);
}
if (opts?.randomizeSeed) { if (opts?.randomizeSeed) {
clonedElement.seed = randomInteger(); clonedElement.seed = randomInteger();
@@ -715,7 +734,7 @@ export const duplicateElements = (
if (clonedElement.groupIds) { if (clonedElement.groupIds) {
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => { clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
if (!groupNewIdsMap.has(groupId)) { if (!groupNewIdsMap.has(groupId)) {
groupNewIdsMap.set(groupId, regenerateId(groupId)); groupNewIdsMap.set(groupId, regenerateId());
} }
return groupNewIdsMap.get(groupId)!; return groupNewIdsMap.get(groupId)!;
}); });
+26 -4
View File
@@ -41,15 +41,11 @@ 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";
@@ -64,6 +60,12 @@ import {
type Radians, type Radians,
type LocalPoint, type LocalPoint,
} from "../../math"; } from "../../math";
import {
getMinTextElementWidth,
measureText,
getApproxMinLineWidth,
getApproxMinLineHeight,
} from "./textMeasurements";
// Returns true when transform (resizing/rotation) happened // Returns true when transform (resizing/rotation) happened
export const transformElements = ( export const transformElements = (
@@ -767,6 +769,26 @@ const getResizedOrigin = (
y: y - (newHeight - prevHeight) / 2, y: y - (newHeight - prevHeight) / 2,
}; };
case "east-side": case "east-side":
// NOTE (mtolmacs): Reverting this for a short period to test if it is
// the cause of the megasized elbow arrows showing up.
if (
Math.abs(
y +
((prevWidth - newWidth) / 2) * Math.sin(angle) +
(prevHeight - newHeight) / 2,
) > 1e6
) {
console.error(
"getResizedOrigin() new calculation creates extremely large (> 1e6) y value where the old calculation resulted in",
{
result:
y +
(newHeight - prevHeight) / 2 +
((prevWidth - newWidth) / 2) * Math.sin(angle),
},
);
}
return { return {
x: x + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1), x: x + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1),
y: y:
+1 -4
View File
@@ -116,8 +116,5 @@ const normalizeBoundElementsOrder = (
export const normalizeElementOrder = ( export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => { ) => {
// console.time(); return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
const ret = normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
// console.timeEnd();
return ret;
}; };
@@ -6,9 +6,8 @@ import {
getContainerCoords, getContainerCoords,
getBoundTextMaxWidth, getBoundTextMaxWidth,
getBoundTextMaxHeight, getBoundTextMaxHeight,
detectLineHeight,
getLineHeightInPx,
} from "./textElement"; } from "./textElement";
import { detectLineHeight, getLineHeightInPx } from "./textMeasurements";
import type { ExcalidrawTextElementWithContainer } from "./types"; import type { ExcalidrawTextElementWithContainer } from "./types";
describe("Test measureText", () => { describe("Test measureText", () => {
+2 -228
View File
@@ -1,4 +1,4 @@
import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils"; import { getFontString, arrayToMap } from "../utils";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawElement, ExcalidrawElement,
@@ -6,7 +6,6 @@ import type {
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
FontString,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
@@ -14,7 +13,6 @@ import {
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO, ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
ARROW_LABEL_WIDTH_FRACTION, ARROW_LABEL_WIDTH_FRACTION,
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
TEXT_ALIGN, TEXT_ALIGN,
VERTICAL_ALIGN, VERTICAL_ALIGN,
@@ -30,18 +28,7 @@ import {
updateOriginalContainerCache, updateOriginalContainerCache,
} from "./containerCache"; } from "./containerCache";
import type { ExtractSetType } from "../utility-types"; import type { ExtractSetType } from "../utility-types";
import { measureText } from "./textMeasurements";
export const normalizeText = (text: string) => {
return (
normalizeEOL(text)
// replace tabs with spaces so they render and measure correctly
.replace(/\t/g, " ")
);
};
const splitIntoLines = (text: string) => {
return normalizeText(text).split("\n");
};
export const redrawTextBoundingBox = ( export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
@@ -281,201 +268,6 @@ export const computeBoundTextPosition = (
return { x, y }; return { x, y };
}; };
export const measureText = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
forceAdvanceWidth?: true,
) => {
const _text = text
.split("\n")
// replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation
.map((x) => x || " ")
.join("\n");
const fontSize = parseFloat(font);
const height = getTextHeight(_text, fontSize, lineHeight);
const width = getTextWidth(_text, font, forceAdvanceWidth);
return { width, height };
};
/**
* To get unitless line-height (if unknown) we can calculate it by dividing
* height-per-line by fontSize.
*/
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
const lineCount = splitIntoLines(textElement.text).length;
return (textElement.height /
lineCount /
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
};
/**
* We calculate the line height from the font size and the unitless line height,
* aligning with the W3C spec.
*/
export const getLineHeightInPx = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return fontSize * lineHeight;
};
// FIXME rename to getApproxMinContainerHeight
export const getApproxMinLineHeight = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
};
let canvas: HTMLCanvasElement | undefined;
/**
* @param forceAdvanceWidth use to force retrieve the "advance width" ~ `metrics.width`, instead of the actual boundind box width.
*
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
*
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
* - text wrapping
* - wysiwyg editor (+padding)
*
* Everything else should be based on the actual bounding box width.
*
* `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies.
*/
export const getLineWidth = (
text: string,
font: FontString,
forceAdvanceWidth?: true,
) => {
if (!canvas) {
canvas = document.createElement("canvas");
}
const canvas2dContext = canvas.getContext("2d")!;
canvas2dContext.font = font;
const metrics = canvas2dContext.measureText(text);
const advanceWidth = metrics.width;
// retrieve the actual bounding box width if these metrics are available (as of now > 95% coverage)
if (
!forceAdvanceWidth &&
window.TextMetrics &&
"actualBoundingBoxLeft" in window.TextMetrics.prototype &&
"actualBoundingBoxRight" in window.TextMetrics.prototype
) {
// could be negative, therefore getting the absolute value
const actualWidth =
Math.abs(metrics.actualBoundingBoxLeft) +
Math.abs(metrics.actualBoundingBoxRight);
// fallback to advance width if the actual width is zero, i.e. on text editing start
// or when actual width does not respect whitespace chars, i.e. spaces
// otherwise actual width should always be bigger
return Math.max(actualWidth, advanceWidth);
}
// since in test env the canvas measureText algo
// doesn't measure text and instead just returns number of
// characters hence we assume that each letteris 10px
if (isTestEnv()) {
return advanceWidth * 10;
}
return advanceWidth;
};
export const getTextWidth = (
text: string,
font: FontString,
forceAdvanceWidth?: true,
) => {
const lines = splitIntoLines(text);
let width = 0;
lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth));
});
return width;
};
export const getTextHeight = (
text: string,
fontSize: number,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const lineCount = splitIntoLines(text).length;
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
};
export const charWidth = (() => {
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
const calculate = (char: string, font: FontString) => {
const unicode = char.charCodeAt(0);
if (!cachedCharWidth[font]) {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][unicode]) {
const width = getLineWidth(char, font, true);
cachedCharWidth[font][unicode] = width;
}
return cachedCharWidth[font][unicode];
};
const getCache = (font: FontString) => {
return cachedCharWidth[font];
};
const clearCache = (font: FontString) => {
cachedCharWidth[font] = [];
};
return {
calculate,
getCache,
clearCache,
};
})();
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
// FIXME rename to getApproxMinContainerWidth
export const getApproxMinLineWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) {
return (
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
BOUND_TEXT_PADDING * 2
);
}
return maxCharWidth + BOUND_TEXT_PADDING * 2;
};
export const getMinCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.min(...cacheWithOutEmpty);
};
export const getMaxCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.max(...cacheWithOutEmpty);
};
export const getBoundTextElementId = (container: ExcalidrawElement | null) => { export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
return container?.boundElements?.length return container?.boundElements?.length
? container?.boundElements?.find((ele) => ele.type === "text")?.id || null ? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
@@ -712,24 +504,6 @@ export const getBoundTextMaxHeight = (
return height - BOUND_TEXT_PADDING * 2; return height - BOUND_TEXT_PADDING * 2;
}; };
export const isMeasureTextSupported = () => {
const width = getTextWidth(
DUMMY_TEXT,
getFontString({
fontSize: DEFAULT_FONT_SIZE,
fontFamily: DEFAULT_FONT_FAMILY,
}),
);
return width > 0;
};
export const getMinTextElementWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
};
/** retrieves text from text elements and concatenates to a single string */ /** retrieves text from text elements and concatenates to a single string */
export const getTextFromElements = ( export const getTextFromElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -0,0 +1,224 @@
import {
BOUND_TEXT_PADDING,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_FAMILY,
} from "../constants";
import { getFontString, isTestEnv, normalizeEOL } from "../utils";
import type { FontString, ExcalidrawTextElement } from "./types";
export const measureText = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const _text = text
.split("\n")
// replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation
.map((x) => x || " ")
.join("\n");
const fontSize = parseFloat(font);
const height = getTextHeight(_text, fontSize, lineHeight);
const width = getTextWidth(_text, font);
return { width, height };
};
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
// FIXME rename to getApproxMinContainerWidth
export const getApproxMinLineWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) {
return (
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
BOUND_TEXT_PADDING * 2
);
}
return maxCharWidth + BOUND_TEXT_PADDING * 2;
};
export const getMinTextElementWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
};
export const isMeasureTextSupported = () => {
const width = getTextWidth(
DUMMY_TEXT,
getFontString({
fontSize: DEFAULT_FONT_SIZE,
fontFamily: DEFAULT_FONT_FAMILY,
}),
);
return width > 0;
};
export const normalizeText = (text: string) => {
return (
normalizeEOL(text)
// replace tabs with spaces so they render and measure correctly
.replace(/\t/g, " ")
);
};
const splitIntoLines = (text: string) => {
return normalizeText(text).split("\n");
};
/**
* To get unitless line-height (if unknown) we can calculate it by dividing
* height-per-line by fontSize.
*/
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
const lineCount = splitIntoLines(textElement.text).length;
return (textElement.height /
lineCount /
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
};
/**
* We calculate the line height from the font size and the unitless line height,
* aligning with the W3C spec.
*/
export const getLineHeightInPx = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return fontSize * lineHeight;
};
// FIXME rename to getApproxMinContainerHeight
export const getApproxMinLineHeight = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
};
let textMetricsProvider: TextMetricsProvider | undefined;
/**
* Set a custom text metrics provider.
*
* Useful for overriding the width calculation algorithm where canvas API is not available / desired.
*/
export const setCustomTextMetricsProvider = (provider: TextMetricsProvider) => {
textMetricsProvider = provider;
};
export interface TextMetricsProvider {
getLineWidth(text: string, fontString: FontString): number;
}
class CanvasTextMetricsProvider implements TextMetricsProvider {
private canvas: HTMLCanvasElement;
constructor() {
this.canvas = document.createElement("canvas");
}
/**
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
* - text wrapping
* - wysiwyg editor (+padding)
*
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
*/
public getLineWidth(text: string, fontString: FontString): number {
const context = this.canvas.getContext("2d")!;
context.font = fontString;
const metrics = context.measureText(text);
const advanceWidth = metrics.width;
// since in test env the canvas measureText algo
// doesn't measure text and instead just returns number of
// characters hence we assume that each letteris 10px
if (isTestEnv()) {
return advanceWidth * 10;
}
return advanceWidth;
}
}
export const getLineWidth = (text: string, font: FontString) => {
if (!textMetricsProvider) {
textMetricsProvider = new CanvasTextMetricsProvider();
}
return textMetricsProvider.getLineWidth(text, font);
};
export const getTextWidth = (text: string, font: FontString) => {
const lines = splitIntoLines(text);
let width = 0;
lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font));
});
return width;
};
export const getTextHeight = (
text: string,
fontSize: number,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const lineCount = splitIntoLines(text).length;
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
};
export const charWidth = (() => {
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
const calculate = (char: string, font: FontString) => {
const unicode = char.charCodeAt(0);
if (!cachedCharWidth[font]) {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][unicode]) {
const width = getLineWidth(char, font);
cachedCharWidth[font][unicode] = width;
}
return cachedCharWidth[font][unicode];
};
const getCache = (font: FontString) => {
return cachedCharWidth[font];
};
const clearCache = (font: FontString) => {
cachedCharWidth[font] = [];
};
return {
calculate,
getCache,
clearCache,
};
})();
export const getMinCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.min(...cacheWithOutEmpty);
};
export const getMaxCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.max(...cacheWithOutEmpty);
};
+6 -6
View File
@@ -1,5 +1,5 @@
import { ENV } from "../constants"; import { ENV } from "../constants";
import { charWidth, getLineWidth } from "./textElement"; import { charWidth, getLineWidth } from "./textMeasurements";
import type { FontString } from "./types"; import type { FontString } from "./types";
let cachedCjkRegex: RegExp | undefined; let cachedCjkRegex: RegExp | undefined;
@@ -385,7 +385,7 @@ export const wrapText = (
const originalLines = text.split("\n"); const originalLines = text.split("\n");
for (const originalLine of originalLines) { for (const originalLine of originalLines) {
const currentLineWidth = getLineWidth(originalLine, font, true); const currentLineWidth = getLineWidth(originalLine, font);
if (currentLineWidth <= maxWidth) { if (currentLineWidth <= maxWidth) {
lines.push(originalLine); lines.push(originalLine);
@@ -423,7 +423,7 @@ const wrapLine = (
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here // cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
const testLineWidth = isSingleCharacter(token) const testLineWidth = isSingleCharacter(token)
? currentLineWidth + charWidth.calculate(token, font) ? currentLineWidth + charWidth.calculate(token, font)
: getLineWidth(testLine, font, true); : getLineWidth(testLine, font);
// build up the current line, skipping length check for possibly trailing whitespaces // build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) { if (/\s/.test(token) || testLineWidth <= maxWidth) {
@@ -443,7 +443,7 @@ const wrapLine = (
// trailing line of the wrapped word might still be joined with next token/s // trailing line of the wrapped word might still be joined with next token/s
currentLine = trailingLine; currentLine = trailingLine;
currentLineWidth = getLineWidth(trailingLine, font, true); currentLineWidth = getLineWidth(trailingLine, font);
iterator = tokenIterator.next(); iterator = tokenIterator.next();
} else { } else {
// push & reset, but don't iterate on the next token, as we didn't use it yet! // push & reset, but don't iterate on the next token, as we didn't use it yet!
@@ -514,7 +514,7 @@ const wrapWord = (
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`. * Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
*/ */
const trimLine = (line: string, font: FontString, maxWidth: number) => { const trimLine = (line: string, font: FontString, maxWidth: number) => {
const shouldTrimWhitespaces = getLineWidth(line, font, true) > maxWidth; const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
if (!shouldTrimWhitespaces) { if (!shouldTrimWhitespaces) {
return line; return line;
@@ -527,7 +527,7 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
"", "",
]; ];
let trimmedLineWidth = getLineWidth(trimmedLine, font, true); let trimmedLineWidth = getLineWidth(trimmedLine, font);
for (const whitespace of Array.from(whitespaces)) { for (const whitespace of Array.from(whitespaces)) {
const _charWidth = charWidth.calculate(whitespace, font); const _charWidth = charWidth.calculate(whitespace, font);
+3 -3
View File
@@ -24,8 +24,6 @@ import {
getBoundTextElementId, getBoundTextElementId,
getContainerElement, getContainerElement,
getTextElementAngle, getTextElementAngle,
getTextWidth,
normalizeText,
redrawTextBoundingBox, redrawTextBoundingBox,
getBoundTextMaxHeight, getBoundTextMaxHeight,
getBoundTextMaxWidth, getBoundTextMaxWidth,
@@ -50,6 +48,8 @@ import {
originalContainerCache, originalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
} from "./containerCache"; } from "./containerCache";
import { getTextWidth } from "./textMeasurements";
import { normalizeText } from "./textMeasurements";
const getTransform = ( const getTransform = (
width: number, width: number,
@@ -350,7 +350,7 @@ export const textWysiwyg = ({
font, font,
getBoundTextMaxWidth(container, boundTextElement), getBoundTextMaxWidth(container, boundTextElement),
); );
const width = getTextWidth(wrappedText, font, true); const width = getTextWidth(wrappedText, font);
editable.style.width = `${width}px`; editable.style.width = `${width}px`;
} }
}; };
+1 -1
View File
@@ -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: FixedSegment[] | null; fixedSegments: readonly 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
+11 -36
View File
@@ -3,11 +3,10 @@ import {
FONT_FAMILY_FALLBACKS, FONT_FAMILY_FALLBACKS,
CJK_HAND_DRAWN_FALLBACK_FONT, CJK_HAND_DRAWN_FALLBACK_FONT,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
isSafari,
getFontFamilyFallbacks, getFontFamilyFallbacks,
} from "../constants"; } from "../constants";
import { isTextElement } from "../element"; import { isTextElement } from "../element";
import { charWidth, getContainerElement } from "../element/textElement"; import { getContainerElement } from "../element/textElement";
import { containsCJK } from "../element/textWrapping"; import { containsCJK } from "../element/textWrapping";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import { getFontString, PromisePool, promiseTry } from "../utils"; import { getFontString, PromisePool, promiseTry } from "../utils";
@@ -32,6 +31,7 @@ import type {
} from "../element/types"; } from "../element/types";
import type Scene from "../scene/Scene"; import type Scene from "../scene/Scene";
import type { ValueOf } from "../utility-types"; import type { ValueOf } from "../utility-types";
import { charWidth } from "../element/textMeasurements";
export class Fonts { export class Fonts {
// it's ok to track fonts across multiple instances only once, so let's use // it's ok to track fonts across multiple instances only once, so let's use
@@ -137,50 +137,28 @@ export class Fonts {
/** /**
* Load font faces for a given scene and trigger scene update. * Load font faces for a given scene and trigger scene update.
*
* FontFaceSet loadingdone event we listen on may not always
* fire (looking at you Safari), so on init we manually load all
* fonts and rerender scene text elements once done.
*
* For Safari we make sure to check against each loaded font face
* with the unique characters per family in the scene,
* otherwise fonts might remain unloaded.
*/ */
public loadSceneFonts = async (): Promise<FontFace[]> => { public loadSceneFonts = async (): Promise<FontFace[]> => {
const sceneFamilies = this.getSceneFamilies(); const sceneFamilies = this.getSceneFamilies();
const charsPerFamily = isSafari const charsPerFamily = Fonts.getCharsPerFamily(
? Fonts.getCharsPerFamily(this.scene.getNonDeletedElements()) this.scene.getNonDeletedElements(),
: undefined; );
return Fonts.loadFontFaces(sceneFamilies, charsPerFamily); return Fonts.loadFontFaces(sceneFamilies, charsPerFamily);
}; };
/** /**
* Load font faces for passed elements - use when the scene is unavailable (i.e. export). * Load font faces for passed elements - use when the scene is unavailable (i.e. export).
*
* For Safari we make sure to check against each loaded font face,
* with the unique characters per family in the elements
* otherwise fonts might remain unloaded.
*/ */
public static loadElementsFonts = async ( public static loadElementsFonts = async (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
): Promise<FontFace[]> => { ): Promise<FontFace[]> => {
const fontFamilies = Fonts.getUniqueFamilies(elements); const fontFamilies = Fonts.getUniqueFamilies(elements);
const charsPerFamily = isSafari const charsPerFamily = Fonts.getCharsPerFamily(elements);
? Fonts.getCharsPerFamily(elements)
: undefined;
return Fonts.loadFontFaces(fontFamilies, charsPerFamily); return Fonts.loadFontFaces(fontFamilies, charsPerFamily);
}; };
/**
* Load all registered font faces.
*/
public static loadAllFonts = async (): Promise<FontFace[]> => {
const allFamilies = Fonts.getAllFamilies();
return Fonts.loadFontFaces(allFamilies);
};
/** /**
* Generate CSS @font-face declarations for the given elements. * Generate CSS @font-face declarations for the given elements.
*/ */
@@ -223,7 +201,7 @@ export class Fonts {
private static async loadFontFaces( private static async loadFontFaces(
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>, fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
charsPerFamily?: Record<number, Set<string>>, charsPerFamily: Record<number, Set<string>>,
) { ) {
// add all registered font faces into the `document.fonts` (if not added already) // add all registered font faces into the `document.fonts` (if not added already)
for (const { fontFaces, metadata } of Fonts.registered.values()) { for (const { fontFaces, metadata } of Fonts.registered.values()) {
@@ -248,7 +226,7 @@ export class Fonts {
private static *fontFacesLoader( private static *fontFacesLoader(
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>, fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
charsPerFamily?: Record<number, Set<string>>, charsPerFamily: Record<number, Set<string>>,
): Generator<Promise<void | readonly [number, FontFace[]]>> { ): Generator<Promise<void | readonly [number, FontFace[]]>> {
for (const [index, fontFamily] of fontFamilies.entries()) { for (const [index, fontFamily] of fontFamilies.entries()) {
const font = getFontString({ const font = getFontString({
@@ -256,12 +234,9 @@ export class Fonts {
fontSize: 16, fontSize: 16,
}); });
// WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one! // WARN: without "text" param it does not have to mean that all font faces are loaded as it could be just one irrelevant font face!
// for Safari on init, we rather check with the "text" param, even though it's less efficient, as otherwise fonts might remain unloaded // instead, we are always checking chars used in the family, so that no required font faces remain unloaded
const text = const text = Fonts.getCharacters(charsPerFamily, fontFamily);
isSafari && charsPerFamily
? Fonts.getCharacters(charsPerFamily, fontFamily)
: "";
if (!window.document.fonts.check(font, text)) { if (!window.document.fonts.check(font, text)) {
yield promiseTry(async () => { yield promiseTry(async () => {
+5 -6
View File
@@ -1,9 +1,8 @@
import React from "react";
import type { ExcalidrawElement } from "./element/types"; import type { ExcalidrawElement } from "./element/types";
import { convertToExcalidrawElements, Excalidraw } from "./index"; import { convertToExcalidrawElements, Excalidraw } from "./index";
import { API } from "./tests/helpers/api"; import { API } from "./tests/helpers/api";
import { Keyboard, Pointer } from "./tests/helpers/ui"; import { Keyboard, Pointer } from "./tests/helpers/ui";
import { render } from "./tests/test-utils"; import { getCloneByOrigId, render } from "./tests/test-utils";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@@ -413,10 +412,10 @@ describe("adding elements to frames", () => {
dragElementIntoFrame(frame, rect2); dragElementIntoFrame(frame, rect2);
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
selectElementAndDuplicate(rect2); selectElementAndDuplicate(rect2);
const rect2_copy = getCloneByOrigId(rect2.id);
expect(rect2_copy.frameId).toBe(frame.id); expect(rect2_copy.frameId).toBe(frame.id);
expect(rect2.frameId).toBe(frame.id); expect(rect2.frameId).toBe(frame.id);
expectEqualIds([rect2_copy, rect2, frame]); expectEqualIds([rect2_copy, rect2, frame]);
@@ -427,11 +426,11 @@ describe("adding elements to frames", () => {
dragElementIntoFrame(frame, rect2); dragElementIntoFrame(frame, rect2);
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
// move the rect2 outside the frame // move the rect2 outside the frame
selectElementAndDuplicate(rect2, [-1000, -1000]); selectElementAndDuplicate(rect2, [-1000, -1000]);
const rect2_copy = getCloneByOrigId(rect2.id);
expect(rect2_copy.frameId).toBe(frame.id); expect(rect2_copy.frameId).toBe(frame.id);
expect(rect2.frameId).toBe(null); expect(rect2.frameId).toBe(null);
expectEqualIds([rect2_copy, frame, rect2]); expectEqualIds([rect2_copy, frame, rect2]);
+4
View File
@@ -46,6 +46,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
onPointerDown, onPointerDown,
onPointerUp, onPointerUp,
onScrollChange, onScrollChange,
onDuplicate,
children, children,
validateEmbeddable, validateEmbeddable,
renderEmbeddable, renderEmbeddable,
@@ -136,6 +137,7 @@ 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}
@@ -293,3 +295,5 @@ 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";
+1
View File
@@ -103,6 +103,7 @@
"@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",
@@ -52,7 +52,6 @@ import {
getBoundTextElement, getBoundTextElement,
getContainerCoords, getContainerCoords,
getContainerElement, getContainerElement,
getLineHeightInPx,
getBoundTextMaxHeight, getBoundTextMaxHeight,
getBoundTextMaxWidth, getBoundTextMaxWidth,
} from "../element/textElement"; } from "../element/textElement";
@@ -64,6 +63,7 @@ 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,7 +16,6 @@ import { LinearElementEditor } from "../element/linearElementEditor";
import { import {
getBoundTextElement, getBoundTextElement,
getContainerElement, getContainerElement,
getLineHeightInPx,
} from "../element/textElement"; } from "../element/textElement";
import { import {
isArrowElement, isArrowElement,
@@ -38,6 +37,7 @@ 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,
-50
View File
@@ -1,8 +1,3 @@
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) =>
@@ -47,48 +42,3 @@ 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);
};
-2
View File
@@ -12,8 +12,6 @@ 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": {
"id0_copy": true, "id1": 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": "id0_copy", "id": "id1",
"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": {
"id0_copy": true, "id1": true,
}, },
}, },
"inserted": { "inserted": {
@@ -2693,7 +2693,7 @@ History {
"elementsChange": ElementsChange { "elementsChange": ElementsChange {
"added": Map {}, "added": Map {},
"removed": Map { "removed": Map {
"id0_copy" => Delta { "id1" => 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": "id0_copy", "id": "id2",
"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 {
"id0_copy" => Delta { "id2" => 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 {
"id0_copy" => Delta { "id6" => 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": [
"id4_copy", "id7",
], ],
"height": 10, "height": 10,
"index": "a0", "index": "a0",
@@ -10652,7 +10652,7 @@ History {
"isDeleted": true, "isDeleted": true,
}, },
}, },
"id1_copy" => Delta { "id8" => 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": [
"id4_copy", "id7",
], ],
"height": 10, "height": 10,
"index": "a1", "index": "a1",
@@ -10685,7 +10685,7 @@ History {
"isDeleted": true, "isDeleted": true,
}, },
}, },
"id2_copy" => Delta { "id9" => 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": [
"id4_copy", "id7",
], ],
"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.getByLabelText("Opacity"), { fireEvent.change(screen.getByTestId("opacity"), {
target: { value: "60" }, target: { value: "60" },
}); });
+1 -1
View File
@@ -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/textElement"; import { getLineHeightInPx } from "../element/textMeasurements";
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.getByLabelText("Opacity"), { fireEvent.change(screen.getByTestId("opacity"), {
target: { value: "60" }, target: { value: "60" },
}); });
+93 -6
View File
@@ -40,6 +40,7 @@ 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.
@@ -68,13 +69,21 @@ export class API {
}); });
}; };
static setSelectedElements = (elements: ExcalidrawElement[]) => { static setSelectedElements = (elements: ExcalidrawElement[], editingGroupId?: string | null) => {
act(() => { act(() => {
h.setState({ h.setState({
selectedElementIds: elements.reduce((acc, element) => { ...selectGroupsForSelectedElements(
acc[element.id] = true; {
return acc; editingGroupId: editingGroupId ?? null,
}, {} as Record<ExcalidrawElement["id"], true>), selectedElementIds: elements.reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as Record<ExcalidrawElement["id"], true>),
},
elements,
h.state,
h.app,
)
}); });
}); });
}; };
@@ -158,7 +167,7 @@ export class API {
isDeleted?: boolean; isDeleted?: boolean;
frameId?: ExcalidrawElement["id"] | null; frameId?: ExcalidrawElement["id"] | null;
index?: ExcalidrawElement["index"]; index?: ExcalidrawElement["index"];
groupIds?: string[]; groupIds?: ExcalidrawElement["groupIds"];
// generic element props // generic element props
strokeColor?: ExcalidrawGenericElement["strokeColor"]; strokeColor?: ExcalidrawGenericElement["strokeColor"];
backgroundColor?: ExcalidrawGenericElement["backgroundColor"]; backgroundColor?: ExcalidrawGenericElement["backgroundColor"];
@@ -369,6 +378,84 @@ 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,
+10 -9
View File
@@ -7,6 +7,7 @@ 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";
@@ -15,7 +16,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 } from "../constants"; import { EXPORT_DATA_TYPES, MIME_TYPES, ORIG_ID } from "../constants";
import type { AppState } from "../types"; import type { AppState } from "../types";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { import {
@@ -1138,8 +1139,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({ id: `${rect1.id}_copy`, isDeleted: true }), expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: true }),
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: true }), expect.objectContaining({ [ORIG_ID]: rect2.id, 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 });
@@ -1151,8 +1152,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({ id: `${rect1.id}_copy`, isDeleted: false }), expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: false }),
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: false }), expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: false }),
]); ]);
expect(h.state.editingGroupId).toBeNull(); expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).not.toEqual( expect(h.state.selectedGroupIds).not.toEqual(
@@ -1171,14 +1172,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({ id: `${rect1.id}_copy`, isDeleted: true }), expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: true }),
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: true }), expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: true }),
expect.objectContaining({ expect.objectContaining({
id: `${rect1.id}_copy_copy`, [ORIG_ID]: getCloneByOrigId(rect1.id)?.id,
isDeleted: false, isDeleted: false,
}), }),
expect.objectContaining({ expect.objectContaining({
id: `${rect2.id}_copy_copy`, [ORIG_ID]: getCloneByOrigId(rect2.id)?.id,
isDeleted: false, isDeleted: false,
}), }),
]), ]),
+28 -23
View File
@@ -1,11 +1,11 @@
import React from "react"; import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import { fireEvent, render, waitFor } from "./test-utils"; import { fireEvent, getCloneByOrigId, render, waitFor } from "./test-utils";
import { act, queryByTestId } from "@testing-library/react"; import { 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 } from "../constants"; import { MIME_TYPES, ORIG_ID } 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({ id: "A_copy" })]); expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
}); });
}); });
@@ -125,23 +125,27 @@ describe("library", () => {
); );
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([ expect(h.elements).toEqual(
expect.objectContaining({ expect.arrayContaining([
id: "rectangle1_copy", expect.objectContaining({
boundElements: expect.arrayContaining([ [ORIG_ID]: "rectangle1",
{ type: "text", id: "text1_copy" }, boundElements: expect.arrayContaining([
{ type: "arrow", id: "arrow1_copy" }, { type: "text", id: getCloneByOrigId("text1").id },
]), { type: "arrow", id: getCloneByOrigId("arrow1").id },
}), ]),
expect.objectContaining({ }),
id: "text1_copy", expect.objectContaining({
containerId: "rectangle1_copy", [ORIG_ID]: "text1",
}), containerId: getCloneByOrigId("rectangle1").id,
expect.objectContaining({ }),
id: "arrow1_copy", expect.objectContaining({
endBinding: expect.objectContaining({ elementId: "rectangle1_copy" }), [ORIG_ID]: "arrow1",
}), endBinding: expect.objectContaining({
]); elementId: getCloneByOrigId("rectangle1").id,
}),
}),
]),
);
}); });
}); });
@@ -170,10 +174,11 @@ describe("library", () => {
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
id: "elem1_copy", [ORIG_ID]: "elem1",
}), }),
expect.objectContaining({ expect.objectContaining({
id: expect.not.stringMatching(/^(elem1_copy|elem1)$/), id: expect.not.stringMatching(/^elem1$/),
[ORIG_ID]: expect.not.stringMatching(/^\w+$/),
}), }),
]); ]);
}); });
@@ -189,7 +194,7 @@ describe("library", () => {
}), }),
); );
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A_copy" })]); expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
}); });
expect(h.state.activeTool.type).toBe("selection"); expect(h.state.activeTool.type).toBe("selection");
}); });
+151
View File
@@ -11,6 +11,10 @@ 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,
@@ -295,3 +299,150 @@ expect.addSnapshotSerializer({
); );
}, },
}); });
export const getCloneByOrigId = <T extends boolean = false>(
origId: ExcalidrawElement["id"],
returnNullIfNotExists: T = false as T,
): T extends true ? ExcalidrawElement | null : ExcalidrawElement => {
const clonedElement = window.h.elements?.find(
(el) => (el as any)[ORIG_ID] === origId,
);
if (clonedElement) {
return clonedElement;
}
if (returnNullIfNotExists !== true) {
throw new Error(`cloned element not found for origId: ${origId}`);
}
return null as T extends true ? ExcalidrawElement | null : ExcalidrawElement;
};
/**
* Assertion helper that strips the actual elements of extra attributes
* so that diffs are easier to read in case of failure.
*
* Asserts element order as well, and selected element ids
* (when `selected: true` set for given element).
*
* If testing cloned elements, you can use { `[ORIG_ID]: origElement.id }
* If you need to refer to cloned element properties, you can use
* `getCloneByOrigId()`, e.g.: `{ frameId: getCloneByOrigId(origFrame.id)?.id }`
*/
export const assertElements = <T extends AllPossibleKeys<ExcalidrawElement>>(
actualElements: readonly ExcalidrawElement[],
/** array order matters */
expectedElements: (Partial<Record<T, any>> & {
/** meta, will be stripped for element attribute checks */
selected?: true;
} & (
| {
id: ExcalidrawElement["id"];
}
| { [ORIG_ID]?: string }
))[],
) => {
const h = window.h;
const expectedElementsWithIds: (typeof expectedElements[number] & {
id: ExcalidrawElement["id"];
})[] = expectedElements.map((el) => {
if ("id" in el) {
return el;
}
const actualElement = actualElements.find(
(act) => (act as any)[ORIG_ID] === el[ORIG_ID],
);
if (actualElement) {
return { ...el, id: actualElement.id };
}
return {
...el,
id: "UNKNOWN_ID",
};
});
const map_expectedElements = arrayToMap(expectedElementsWithIds);
const selectedElementIds = expectedElementsWithIds.reduce(
(acc: Record<ExcalidrawElement["id"], true>, el) => {
if (el.selected) {
acc[el.id] = true;
}
return acc;
},
{},
);
const mappedActualElements = actualElements.map((el) => {
const expectedElement = map_expectedElements.get(el.id);
if (expectedElement) {
const pickedAttrs: Record<string, any> = {};
for (const key of Object.keys(expectedElement)) {
if (key === "selected") {
delete expectedElement.selected;
continue;
}
pickedAttrs[key] = (el as any)[key];
}
if (ORIG_ID in expectedElement) {
// @ts-ignore
pickedAttrs[ORIG_ID] = (el as any)[ORIG_ID];
}
return pickedAttrs;
}
return el;
});
try {
// testing order separately for even easier diffs
expect(actualElements.map((x) => x.id)).toEqual(
expectedElementsWithIds.map((x) => x.id),
);
} catch (err: any) {
let errStr = "\n\nmismatched element order\n\n";
errStr += `actual: ${ansi.lightGray(
`[${err.actual
.map((id: string, index: number) => {
const act = actualElements[index];
return `${
id === err.expected[index] ? ansi.green(id) : ansi.red(id)
} (${act.type.slice(0, 4)}${
ORIG_ID in act ? `${(act as any)[ORIG_ID]}` : ""
})`;
})
.join(", ")}]`,
)}\n${ansi.lightGray(
`expected: [${err.expected
.map((exp: string, index: number) => {
const expEl = actualElements.find((el) => el.id === exp);
const origEl =
expEl &&
actualElements.find((el) => el.id === (expEl as any)[ORIG_ID]);
return expEl
? `${
exp === err.actual[index]
? ansi.green(expEl.id)
: ansi.red(expEl.id)
} (${expEl.type.slice(0, 4)}${origEl ? `${origEl.id}` : ""})`
: exp;
})
.join(", ")}]\n`,
)}`;
const error = new Error(errStr);
const stack = err.stack.split("\n");
stack.splice(1, 1);
error.stack = stack.join("\n");
throw error;
}
expect(mappedActualElements).toEqual(
expect.arrayContaining(expectedElementsWithIds),
);
expect(h.state.selectedElementIds).toEqual(selectedElementIds);
};
+1 -7
View File
@@ -1,4 +1,4 @@
import { isTransparent, sanitizeHTMLAttribute } from "../utils"; import { isTransparent } 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,9 +11,3 @@ 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("&amp;&quot;&#39;&gt;&lt;");
});
});
+35 -35
View File
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { act, render } from "./test-utils"; import { act, getCloneByOrigId, 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: "A_copy" }, { id: getCloneByOrigId("A").id },
{ id: "B" }, { id: "B" },
{ id: "B_copy" }, { id: getCloneByOrigId("B").id },
]); ]);
populateElements([ populateElements([
@@ -930,12 +930,12 @@ describe("z-index manipulation", () => {
{ id: "A" }, { id: "A" },
{ id: "B" }, { id: "B" },
{ {
id: "A_copy", id: getCloneByOrigId("A").id,
groupIds: [expect.stringMatching(/.{3,}/)], groupIds: [expect.stringMatching(/.{3,}/)],
}, },
{ {
id: "B_copy", id: getCloneByOrigId("B").id,
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: "A_copy", id: getCloneByOrigId("A").id,
groupIds: [expect.stringMatching(/.{3,}/)], groupIds: [expect.stringMatching(/.{3,}/)],
}, },
{ {
id: "B_copy", id: getCloneByOrigId("B").id,
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",
"A_copy", getCloneByOrigId("A").id,
"B_copy", getCloneByOrigId("B").id,
"C", "C",
"C_copy", getCloneByOrigId("C").id,
]); ]);
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",
"A_copy", getCloneByOrigId("A").id,
"B_copy", getCloneByOrigId("B").id,
"C", "C",
"D", "D",
"C_copy", getCloneByOrigId("C").id,
"D_copy", getCloneByOrigId("D").id,
]); ]);
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",
"A_copy", getCloneByOrigId("A").id,
"B_copy", getCloneByOrigId("B").id,
"C", "C",
"C_copy", getCloneByOrigId("C").id,
]); ]);
populateElements( populateElements(
@@ -1031,9 +1031,9 @@ describe("z-index manipulation", () => {
"A", "A",
"B", "B",
"C", "C",
"A_copy", getCloneByOrigId("A").id,
"B_copy", getCloneByOrigId("B").id,
"C_copy", getCloneByOrigId("C").id,
]); ]);
populateElements( populateElements(
@@ -1054,15 +1054,15 @@ describe("z-index manipulation", () => {
"A", "A",
"B", "B",
"C", "C",
"A_copy", getCloneByOrigId("A").id,
"B_copy", getCloneByOrigId("B").id,
"C_copy", getCloneByOrigId("C").id,
"D", "D",
"E", "E",
"F", "F",
"D_copy", getCloneByOrigId("D").id,
"E_copy", getCloneByOrigId("E").id,
"F_copy", getCloneByOrigId("F").id,
]); ]);
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",
"A_copy", getCloneByOrigId("A").id,
"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",
"B_copy", getCloneByOrigId("B").id,
"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",
"A_copy", getCloneByOrigId("A").id,
"B", "B",
"B_copy", getCloneByOrigId("B").id,
"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",
"A_copy", getCloneByOrigId("A").id,
"C_copy", getCloneByOrigId("C").id,
"B", "B",
]); ]);
}); });
@@ -1144,9 +1144,9 @@ describe("z-index manipulation", () => {
"A", "A",
"B", "B",
"C", "C",
"A_copy", getCloneByOrigId("A").id,
"B_copy", getCloneByOrigId("B").id,
"C_copy", getCloneByOrigId("C").id,
"D", "D",
]); ]);
}); });
+18
View File
@@ -512,6 +512,22 @@ 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,
@@ -681,6 +697,8 @@ 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<{
+3
View File
@@ -65,3 +65,6 @@ 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;
+17 -12
View File
@@ -9,7 +9,11 @@ import {
isDarwin, isDarwin,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants"; } from "./constants";
import type { FontFamilyValues, FontString } from "./element/types"; import type {
ExcalidrawBindableElement,
FontFamilyValues,
FontString,
} from "./element/types";
import type { import type {
ActiveTool, ActiveTool,
AppState, AppState,
@@ -543,6 +547,9 @@ export const isTransparent = (color: string) => {
); );
}; };
export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) =>
el.fillStyle !== "solid" || isTransparent(el.backgroundColor);
export type ResolvablePromise<T> = Promise<T> & { export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined] resolve: [T] extends [undefined]
? (value?: MaybePromise<Awaited<T>>) => void ? (value?: MaybePromise<Awaited<T>>) => void
@@ -1226,15 +1233,13 @@ export class PromisePool<T> {
} }
} }
export const sanitizeHTMLAttribute = (html: string) => { /**
return ( * use when you need to render unsafe string as HTML attribute, but MAKE SURE
html * the attribute is double-quoted when constructing the HTML string
// note, if we're not doing stupid things, escaping " is enough, */
// but we might end up doing stupid things export const escapeDoubleQuotes = (str: string) => {
.replace(/&/g, "&amp;") return str.replace(/"/g, "&quot;");
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/>/g, "&gt;")
.replace(/</g, "&lt;")
);
}; };
export const castArray = <T>(value: T | T[]): T[] =>
Array.isArray(value) ? value : [value];
Binary file not shown.
Binary file not shown.
+29 -11
View File
@@ -123,6 +123,17 @@ 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",
}); });
@@ -130,6 +141,10 @@ 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),
); );
@@ -141,13 +156,6 @@ 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`),
@@ -165,10 +173,18 @@ 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); fallbackFontsPaths.push(emojiPath_2048, liberationPath_2048);
} else { } else {
fallbackFontsPaths.push(emojiPath); fallbackFontsPaths.push(emojiPath, liberationPath);
} }
// 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
@@ -196,10 +212,12 @@ 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}` ? `${base} & ${xiaolai} & ${emoji} & ${liberation}`
: `${base} & ${emoji}`; : `${base} & ${emoji} & ${liberation}`;
}; };
mergedFont.set({ mergedFont.set({
+3
View File
@@ -7,6 +7,9 @@ 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");
+481 -385
View File
File diff suppressed because it is too large Load Diff