Compare commits

..

12 Commits

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

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

* docs

* clarify docs

* fix docs
2025-02-10 14:20:18 +00:00
Márk Tolmács
9e49c9254b fix: IFrame and elbow arrow interaction fix (#9101) 2025-02-06 14:45:49 +01:00
David Luzar
b0c8c5f7a7 feat: change empty arrowhead icon (#9100) 2025-02-06 10:52:03 +01:00
tothatt81
4f64372506 perf: Improved pointer events related performance when the sidebar is docked with a large library open (#9086)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-02-04 22:05:56 +01:00
37 changed files with 1333 additions and 1011 deletions

View File

@@ -25,6 +25,7 @@ import { MIME_TYPES } from "../../packages/excalidraw/constants";
import { trackEvent } from "../../packages/excalidraw/analytics";
import { getFrame } from "../../packages/excalidraw/utils";
import { ExcalidrawLogo } from "../../packages/excalidraw/components/ExcalidrawLogo";
import { uploadBytes, ref } from "firebase/storage";
export const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[],
@@ -32,7 +33,7 @@ export const exportToExcalidrawPlus = async (
files: BinaryFiles,
name: string,
) => {
const firebase = await loadFirebaseStorage();
const storage = await loadFirebaseStorage();
const id = `${nanoid(12)}`;
@@ -49,15 +50,13 @@ export const exportToExcalidrawPlus = async (
},
);
await firebase
.storage()
.ref(`/migrations/scenes/${id}`)
.put(blob, {
customMetadata: {
data: JSON.stringify({ version: 2, name }),
created: Date.now().toString(),
},
});
const storageRef = ref(storage, `/migrations/scenes/${id}`);
await uploadBytes(storageRef, blob, {
customMetadata: {
data: JSON.stringify({ version: 2, name }),
created: Date.now().toString(),
},
});
const filesMap = new Map<FileId, BinaryFileData>();
for (const element of elements) {

View File

@@ -22,9 +22,17 @@ import {
import { MIME_TYPES } from "../../packages/excalidraw/constants";
import type { SyncableExcalidrawElement } from ".";
import { getSyncableElements } from ".";
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
import type { Socket } from "socket.io-client";
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
import { initializeApp } from "firebase/app";
import {
getFirestore,
doc,
getDoc,
runTransaction,
Bytes,
} from "firebase/firestore";
import { getStorage, ref, uploadBytes } from "firebase/storage";
// private
// -----------------------------------------------------------------------------
@@ -41,80 +49,42 @@ try {
FIREBASE_CONFIG = {};
}
let firebasePromise: Promise<typeof import("firebase/app").default> | null =
null;
let firestorePromise: Promise<any> | null | true = null;
let firebaseStoragePromise: Promise<any> | null | true = null;
let firebaseApp: ReturnType<typeof initializeApp> | null = null;
let firestore: ReturnType<typeof getFirestore> | null = null;
let firebaseStorage: ReturnType<typeof getStorage> | null = null;
let isFirebaseInitialized = false;
const _loadFirebase = async () => {
const firebase = (
await import(/* webpackChunkName: "firebase" */ "firebase/app")
).default;
if (!isFirebaseInitialized) {
try {
firebase.initializeApp(FIREBASE_CONFIG);
} catch (error: any) {
// trying initialize again throws. Usually this is harmless, and happens
// mainly in dev (HMR)
if (error.code === "app/duplicate-app") {
console.warn(error.name, error.code);
} else {
throw error;
}
}
isFirebaseInitialized = true;
const _initializeFirebase = () => {
if (!firebaseApp) {
firebaseApp = initializeApp(FIREBASE_CONFIG);
}
return firebase;
return firebaseApp;
};
const _getFirebase = async (): Promise<
typeof import("firebase/app").default
> => {
if (!firebasePromise) {
firebasePromise = _loadFirebase();
const _getFirestore = () => {
if (!firestore) {
firestore = getFirestore(_initializeFirebase());
}
return firebasePromise;
return firestore;
};
const _getStorage = () => {
if (!firebaseStorage) {
firebaseStorage = getStorage(_initializeFirebase());
}
return firebaseStorage;
};
// -----------------------------------------------------------------------------
const loadFirestore = async () => {
const firebase = await _getFirebase();
if (!firestorePromise) {
firestorePromise = import(
/* webpackChunkName: "firestore" */ "firebase/firestore"
);
}
if (firestorePromise !== true) {
await firestorePromise;
firestorePromise = true;
}
return firebase;
};
export const loadFirebaseStorage = async () => {
const firebase = await _getFirebase();
if (!firebaseStoragePromise) {
firebaseStoragePromise = import(
/* webpackChunkName: "storage" */ "firebase/storage"
);
}
if (firebaseStoragePromise !== true) {
await firebaseStoragePromise;
firebaseStoragePromise = true;
}
return firebase;
return _getStorage();
};
interface FirebaseStoredScene {
type FirebaseStoredScene = {
sceneVersion: number;
iv: firebase.default.firestore.Blob;
ciphertext: firebase.default.firestore.Blob;
}
iv: Bytes;
ciphertext: Bytes;
};
const encryptElements = async (
key: string,
@@ -175,7 +145,7 @@ export const saveFilesToFirebase = async ({
prefix: string;
files: { id: FileId; buffer: Uint8Array }[];
}) => {
const firebase = await loadFirebaseStorage();
const storage = await loadFirebaseStorage();
const erroredFiles: FileId[] = [];
const savedFiles: FileId[] = [];
@@ -183,17 +153,10 @@ export const saveFilesToFirebase = async ({
await Promise.all(
files.map(async ({ id, buffer }) => {
try {
await firebase
.storage()
.ref(`${prefix}/${id}`)
.put(
new Blob([buffer], {
type: MIME_TYPES.binary,
}),
{
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
},
);
const storageRef = ref(storage, `${prefix}/${id}`);
await uploadBytes(storageRef, buffer, {
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
});
savedFiles.push(id);
} catch (error: any) {
erroredFiles.push(id);
@@ -205,7 +168,6 @@ export const saveFilesToFirebase = async ({
};
const createFirebaseSceneDocument = async (
firebase: ResolutionType<typeof loadFirestore>,
elements: readonly SyncableExcalidrawElement[],
roomKey: string,
) => {
@@ -213,10 +175,8 @@ const createFirebaseSceneDocument = async (
const { ciphertext, iv } = await encryptElements(roomKey, elements);
return {
sceneVersion,
ciphertext: firebase.firestore.Blob.fromUint8Array(
new Uint8Array(ciphertext),
),
iv: firebase.firestore.Blob.fromUint8Array(iv),
ciphertext: Bytes.fromUint8Array(new Uint8Array(ciphertext)),
iv: Bytes.fromUint8Array(iv),
} as FirebaseStoredScene;
};
@@ -236,20 +196,14 @@ export const saveToFirebase = async (
return null;
}
const firebase = await loadFirestore();
const firestore = firebase.firestore();
const firestore = _getFirestore();
const docRef = doc(firestore, "scenes", roomId);
const docRef = firestore.collection("scenes").doc(roomId);
const storedScene = await firestore.runTransaction(async (transaction) => {
const storedScene = await runTransaction(firestore, async (transaction) => {
const snapshot = await transaction.get(docRef);
if (!snapshot.exists) {
const storedScene = await createFirebaseSceneDocument(
firebase,
elements,
roomKey,
);
if (!snapshot.exists()) {
const storedScene = await createFirebaseSceneDocument(elements, roomKey);
transaction.set(docRef, storedScene);
@@ -269,7 +223,6 @@ export const saveToFirebase = async (
);
const storedScene = await createFirebaseSceneDocument(
firebase,
reconciledElements,
roomKey,
);
@@ -294,15 +247,13 @@ export const loadFromFirebase = async (
roomKey: string,
socket: Socket | null,
): Promise<readonly SyncableExcalidrawElement[] | null> => {
const firebase = await loadFirestore();
const db = firebase.firestore();
const docRef = db.collection("scenes").doc(roomId);
const doc = await docRef.get();
if (!doc.exists) {
const firestore = _getFirestore();
const docRef = doc(firestore, "scenes", roomId);
const docSnap = await getDoc(docRef);
if (!docSnap.exists()) {
return null;
}
const storedScene = doc.data() as FirebaseStoredScene;
const storedScene = docSnap.data() as FirebaseStoredScene;
const elements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null),
);

View File

@@ -27,9 +27,9 @@
},
"dependencies": {
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"firebase": "8.3.3",
"@sentry/browser": "9.0.1",
"callsites": "4.2.0",
"firebase": "11.3.1",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"jotai": "2.11.0",

View File

@@ -1,8 +1,9 @@
import * as Sentry from "@sentry/browser";
import * as SentryIntegrations from "@sentry/integrations";
import callsites from "callsites";
const SentryEnvHostnameMap: { [key: string]: string } = {
"excalidraw.com": "production",
"staging.excalidraw.com": "staging",
"vercel.app": "staging",
};
@@ -23,9 +24,13 @@ Sentry.init({
release: import.meta.env.VITE_APP_GIT_SHA,
ignoreErrors: [
"undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
"InvalidStateError: Failed to execute 'transaction' on 'IDBDatabase': The database connection is closing.", // Not much we can do about the IndexedDB closing error
/(Failed to fetch|(fetch|loading) dynamically imported module)/i, // This is happening when a service worker tries to load an old asset
/QuotaExceededError: (The quota has been exceeded|.*setItem.*Storage)/i, // localStorage quota exceeded
"Internal error opening backing store for indexedDB.open", // Private mode and disabled indexedDB
],
integrations: [
new SentryIntegrations.CaptureConsole({
Sentry.captureConsoleIntegration({
levels: ["error"],
}),
],
@@ -33,6 +38,44 @@ Sentry.init({
if (event.request?.url) {
event.request.url = event.request.url.replace(/#.*$/, "");
}
if (!event.exception) {
event.exception = {
values: [
{
type: "ConsoleError",
value: event.message ?? "Unknown error",
stacktrace: {
frames: callsites()
.slice(1)
.filter(
(frame) =>
frame.getFileName() &&
!frame.getFileName()?.includes("@sentry_browser.js"),
)
.map((frame) => ({
filename: frame.getFileName() ?? undefined,
function: frame.getFunctionName() ?? undefined,
in_app: !(
frame.getFileName()?.includes("node_modules") ?? false
),
lineno: frame.getLineNumber() ?? undefined,
colno: frame.getColumnNumber() ?? undefined,
})),
},
mechanism: {
type: "instrument",
handled: true,
data: {
function: "console.error",
handler: "Sentry.beforeSend",
},
},
},
],
};
}
return event;
},
});

View File

@@ -10,7 +10,6 @@ import {
computeBoundTextPosition,
computeContainerDimensionForBoundText,
getBoundTextElement,
measureText,
redrawTextBoundingBox,
} from "../element/textElement";
import {
@@ -35,6 +34,7 @@ import { arrayToMap, getFontString } from "../utils";
import { register } from "./register";
import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store";
import { measureText } from "../element/textMeasurements";
export const actionUnbindText = register({
name: "unbindText",

View File

@@ -69,8 +69,20 @@ export const actionDuplicateSelection = register({
}
}
const nextState = duplicateElements(elements, appState);
if (app.props.onDuplicate && nextState.elements) {
const mappedElements = app.props.onDuplicate(
nextState.elements,
elements,
);
if (mappedElements) {
nextState.elements = mappedElements;
}
}
return {
...duplicateElements(elements, appState),
...nextState,
storeAction: StoreAction.CAPTURE,
};
},
@@ -92,7 +104,7 @@ export const actionDuplicateSelection = register({
const duplicateElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
): Partial<ActionResult> => {
): Partial<Exclude<ActionResult, false>> => {
// ---------------------------------------------------------------------------
const groupIdMap = new Map();

View File

@@ -1,6 +1,6 @@
import { isTextElement } from "../element";
import { newElementWith } from "../element/mutateElement";
import { measureText } from "../element/textElement";
import { measureText } from "../element/textMeasurements";
import { getSelectedElements } from "../scene";
import { StoreAction } from "../store";
import type { AppClassProperties } from "../types";

View File

@@ -331,17 +331,10 @@ import type { FileSystemHandle } from "../data/filesystem";
import { fileOpen } from "../data/filesystem";
import {
bindTextToShapeAfterDuplication,
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement,
getContainerCenter,
getContainerElement,
getLineHeightInPx,
getMinTextElementWidth,
isMeasureTextSupported,
isValidTextContainer,
measureText,
normalizeText,
} from "../element/textElement";
import {
showHyperlinkTooltip,
@@ -465,6 +458,15 @@ import { cropElement } from "../element/cropElement";
import { wrapText } from "../element/textWrapping";
import { actionCopyElementLink } from "../actions/actionElementLink";
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
import {
isMeasureTextSupported,
normalizeText,
measureText,
getLineHeightInPx,
getApproxMinLineWidth,
getApproxMinLineHeight,
getMinTextElementWidth,
} from "../element/textMeasurements";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@@ -1522,13 +1524,17 @@ class App extends React.Component<AppProps, AppState> {
const allElementsMap = this.scene.getNonDeletedElementsMap();
const shouldBlockPointerEvents =
this.state.selectionElement ||
this.state.newElement ||
this.state.selectedElementsAreBeingDragged ||
this.state.resizingElement ||
(this.state.activeTool.type === "laser" &&
// technically we can just test on this once we make it more safe
this.state.cursorButton === "down");
// default back to `--ui-pointerEvents` flow if setPointerCapture
// not supported
"setPointerCapture" in HTMLElement.prototype
? false
: this.state.selectionElement ||
this.state.newElement ||
this.state.selectedElementsAreBeingDragged ||
this.state.resizingElement ||
(this.state.activeTool.type === "laser" &&
// technically we can just test on this once we make it more safe
this.state.cursorButton === "down");
const firstSelectedElement = selectedElements[0];
@@ -3224,7 +3230,14 @@ class App extends React.Component<AppProps, AppState> {
);
const prevElements = this.scene.getElementsIncludingDeleted();
const nextElements = [...prevElements, ...newElements];
let nextElements = [...prevElements, ...newElements];
const mappedNewSceneElements = this.props.onDuplicate?.(
nextElements,
prevElements,
);
nextElements = mappedNewSceneElements || nextElements;
syncMovedIndices(nextElements, arrayToMap(newElements));
@@ -6295,6 +6308,13 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerDown = (
event: React.PointerEvent<HTMLElement>,
) => {
const target = event.target as HTMLElement;
// capture subsequent pointer events to the canvas
// this makes other elements non-interactive until pointer up
if (target.setPointerCapture) {
target.setPointerCapture(event.pointerId);
}
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
this.maybeUnfollowRemoteUser();
@@ -8431,7 +8451,17 @@ class App extends React.Component<AppProps, AppState> {
}
}
const nextSceneElements = [...nextElements, ...elementsToAppend];
let nextSceneElements: ExcalidrawElement[] = [
...nextElements,
...elementsToAppend,
];
const mappedNewSceneElements = this.props.onDuplicate?.(
nextSceneElements,
elements,
);
nextSceneElements = mappedNewSceneElements || nextSceneElements;
syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend));
@@ -9817,23 +9847,13 @@ class App extends React.Component<AppProps, AppState> {
this.state,
);
let imageFile = await fileOpen({
const imageFile = await fileOpen({
description: "Image",
extensions: Object.keys(
IMAGE_MIME_TYPES,
) as (keyof typeof IMAGE_MIME_TYPES)[],
});
//maybe temporary fix: https://github.com/excalidraw/excalidraw/issues/9091
if (imageFile && !imageFile.type) {
const extension = imageFile.name.split(".").pop()?.toLowerCase();
const mimeType =
IMAGE_MIME_TYPES[extension as keyof typeof IMAGE_MIME_TYPES];
if (mimeType) {
imageFile = new File([imageFile], imageFile.name, { type: mimeType });
}
}
const imageElement = this.createImageElement({
sceneX: x,
sceneY: y,

View File

@@ -2,8 +2,9 @@ import React, {
useState,
useCallback,
useMemo,
useRef,
useEffect,
memo,
useRef,
} from "react";
import type Library from "../data/library";
import {
@@ -17,6 +18,7 @@ import type {
LibraryItem,
ExcalidrawProps,
UIAppState,
AppClassProperties,
} from "../types";
import LibraryMenuItems from "./LibraryMenuItems";
import { trackEvent } from "../analytics";
@@ -33,9 +35,12 @@ import { useUIAppState } from "../context/ui-appState";
import "./LibraryMenu.scss";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import { isShallowEqual } from "../utils";
import type { NonDeletedExcalidrawElement } from "../element/types";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { LIBRARY_DISABLED_TYPES } from "../constants";
import { isShallowEqual } from "../utils";
export const isLibraryMenuOpenAtom = atom(false);
@@ -43,170 +48,215 @@ const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => {
return <div className="layer-ui__library">{children}</div>;
};
export const LibraryMenuContent = ({
onInsertLibraryItems,
pendingElements,
onAddToLibrary,
setAppState,
libraryReturnUrl,
library,
id,
theme,
selectedItems,
onSelectItems,
}: {
pendingElements: LibraryItem["elements"];
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
onAddToLibrary: () => void;
setAppState: React.Component<any, UIAppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
library: Library;
id: string;
theme: UIAppState["theme"];
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
}) => {
const [libraryItemsData] = useAtom(libraryItemsAtom);
const LibraryMenuContent = memo(
({
onInsertLibraryItems,
pendingElements,
onAddToLibrary,
setAppState,
libraryReturnUrl,
library,
id,
theme,
selectedItems,
onSelectItems,
}: {
pendingElements: LibraryItem["elements"];
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
onAddToLibrary: () => void;
setAppState: React.Component<any, UIAppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
library: Library;
id: string;
theme: UIAppState["theme"];
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
}) => {
const [libraryItemsData] = useAtom(libraryItemsAtom);
const _onAddToLibrary = useCallback(
(elements: LibraryItem["elements"]) => {
const addToLibrary = async (
processedElements: LibraryItem["elements"],
libraryItems: LibraryItems,
) => {
trackEvent("element", "addToLibrary", "ui");
for (const type of LIBRARY_DISABLED_TYPES) {
if (processedElements.some((element) => element.type === type)) {
return setAppState({
errorMessage: t(`errors.libraryElementTypeError.${type}`),
});
const _onAddToLibrary = useCallback(
(elements: LibraryItem["elements"]) => {
const addToLibrary = async (
processedElements: LibraryItem["elements"],
libraryItems: LibraryItems,
) => {
trackEvent("element", "addToLibrary", "ui");
for (const type of LIBRARY_DISABLED_TYPES) {
if (processedElements.some((element) => element.type === type)) {
return setAppState({
errorMessage: t(`errors.libraryElementTypeError.${type}`),
});
}
}
}
const nextItems: LibraryItems = [
{
status: "unpublished",
elements: processedElements,
id: randomId(),
created: Date.now(),
},
...libraryItems,
];
onAddToLibrary();
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
};
addToLibrary(elements, libraryItemsData.libraryItems);
},
[onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
);
const nextItems: LibraryItems = [
{
status: "unpublished",
elements: processedElements,
id: randomId(),
created: Date.now(),
},
...libraryItems,
];
onAddToLibrary();
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
};
addToLibrary(elements, libraryItemsData.libraryItems);
},
[onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
);
const libraryItems = useMemo(
() => libraryItemsData.libraryItems,
[libraryItemsData],
);
const libraryItems = useMemo(
() => libraryItemsData.libraryItems,
[libraryItemsData],
);
if (
libraryItemsData.status === "loading" &&
!libraryItemsData.isInitialized
) {
return (
<LibraryMenuWrapper>
<div className="layer-ui__library-message">
<div>
<Spinner size="2em" />
<span>{t("labels.libraryLoadingMessage")}</span>
</div>
</div>
</LibraryMenuWrapper>
);
}
const showBtn =
libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0;
if (
libraryItemsData.status === "loading" &&
!libraryItemsData.isInitialized
) {
return (
<LibraryMenuWrapper>
<div className="layer-ui__library-message">
<div>
<Spinner size="2em" />
<span>{t("labels.libraryLoadingMessage")}</span>
</div>
</div>
</LibraryMenuWrapper>
);
}
const showBtn =
libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0;
return (
<LibraryMenuWrapper>
<LibraryMenuItems
isLoading={libraryItemsData.status === "loading"}
libraryItems={libraryItems}
onAddToLibrary={_onAddToLibrary}
onInsertLibraryItems={onInsertLibraryItems}
pendingElements={pendingElements}
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
onSelectItems={onSelectItems}
selectedItems={selectedItems}
/>
{showBtn && (
<LibraryMenuControlButtons
className="library-menu-control-buttons--at-bottom"
style={{ padding: "16px 12px 0 12px" }}
<LibraryMenuItems
isLoading={libraryItemsData.status === "loading"}
libraryItems={libraryItems}
onAddToLibrary={_onAddToLibrary}
onInsertLibraryItems={onInsertLibraryItems}
pendingElements={pendingElements}
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
onSelectItems={onSelectItems}
selectedItems={selectedItems}
/>
)}
</LibraryMenuWrapper>
);
};
{showBtn && (
<LibraryMenuControlButtons
className="library-menu-control-buttons--at-bottom"
style={{ padding: "16px 12px 0 12px" }}
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
/>
)}
</LibraryMenuWrapper>
);
},
);
const getPendingElements = (
elements: readonly NonDeletedExcalidrawElement[],
selectedElementIds: UIAppState["selectedElementIds"],
) => ({
elements,
pending: getSelectedElements(
elements,
{ selectedElementIds },
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
),
selectedElementIds,
});
const usePendingElementsMemo = (
appState: UIAppState,
elements: readonly NonDeletedExcalidrawElement[],
app: AppClassProperties,
) => {
const create = useCallback(
(appState: UIAppState, elements: readonly NonDeletedExcalidrawElement[]) =>
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
[],
const elements = useExcalidrawElements();
const [state, setState] = useState(() =>
getPendingElements(elements, appState.selectedElementIds),
);
const val = useRef(create(appState, elements));
const prevAppState = useRef<UIAppState>(appState);
const prevElements = useRef(elements);
const selectedElementVersions = useRef(
new Map<ExcalidrawElement["id"], ExcalidrawElement["version"]>(),
);
const update = useCallback(() => {
if (
!isShallowEqual(
appState.selectedElementIds,
prevAppState.current.selectedElementIds,
) ||
!isShallowEqual(elements, prevElements.current)
) {
val.current = create(appState, elements);
prevAppState.current = appState;
prevElements.current = elements;
useEffect(() => {
for (const element of state.pending) {
selectedElementVersions.current.set(element.id, element.version);
}
}, [create, appState, elements]);
}, [state.pending]);
return useMemo(
() => ({
update,
value: val.current,
}),
[update, val],
);
useEffect(() => {
if (
// Only update once pointer is released.
// Reading directly from app.state to make it clear it's not reactive
// (hence, there's potential for stale state)
app.state.cursorButton === "up" &&
app.state.activeTool.type === "selection"
) {
setState((prev) => {
// if selectedElementIds changed, we don't have to compare versions
// ---------------------------------------------------------------------
if (
!isShallowEqual(prev.selectedElementIds, appState.selectedElementIds)
) {
selectedElementVersions.current.clear();
return getPendingElements(elements, appState.selectedElementIds);
}
// otherwise we need to check whether selected elements changed
// ---------------------------------------------------------------------
const elementsMap = app.scene.getNonDeletedElementsMap();
for (const id of Object.keys(appState.selectedElementIds)) {
const currVersion = elementsMap.get(id)?.version;
if (
currVersion &&
currVersion !== selectedElementVersions.current.get(id)
) {
// we can't update the selectedElementVersions in here
// because of double render in StrictMode which would overwrite
// the state in the second pass with the old `prev` state.
// Thus, we update versions in a separate effect. May create
// a race condition since current effect is not fully reactive.
return getPendingElements(elements, appState.selectedElementIds);
}
}
// nothing changed
// ---------------------------------------------------------------------
return prev;
});
}
}, [
app,
app.state.cursorButton,
app.state.activeTool.type,
appState.selectedElementIds,
elements,
]);
return state.pending;
};
/**
* This component is meant to be rendered inside <Sidebar.Tab/> inside our
* <DefaultSidebar/> or host apps Sidebar components.
*/
export const LibraryMenu = () => {
const { library, id, onInsertElements } = useApp();
export const LibraryMenu = memo(() => {
const app = useApp();
const { onInsertElements } = app;
const appProps = useAppProps();
const appState = useUIAppState();
const app = useApp();
const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements();
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const memoizedLibrary = useMemo(() => library, [library]);
// BUG: pendingElements are still causing some unnecessary rerenders because clicking into canvas returns some ids even when no element is selected.
const pendingElements = usePendingElementsMemo(appState, elements);
const memoizedLibrary = useMemo(() => app.library, [app.library]);
const pendingElements = usePendingElementsMemo(appState, app);
const onInsertLibraryItems = useCallback(
(libraryItems: LibraryItems) => {
@@ -223,22 +273,18 @@ export const LibraryMenu = () => {
});
}, [setAppState]);
useEffect(() => {
return app.onPointerUpEmitter.on(() => pendingElements.update());
}, [app, pendingElements]);
return (
<LibraryMenuContent
pendingElements={pendingElements.value}
pendingElements={pendingElements}
onInsertLibraryItems={onInsertLibraryItems}
onAddToLibrary={deselectItems}
setAppState={setAppState}
libraryReturnUrl={appProps.libraryReturnUrl}
library={memoizedLibrary}
id={id}
id={app.id}
theme={appState.theme}
selectedItems={selectedItems}
onSelectItems={setSelectedItems}
/>
);
};
});

View File

@@ -145,12 +145,14 @@ export const MobileMenu = ({
<div className="App-toolbar-content">
<MainMenuTunnel.Out />
{actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
{actionManager.renderAction(
appState.multiElement ? "finalize" : "duplicateSelection",
)}
{actionManager.renderAction("deleteSelectedElements")}
<div>
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
</div>
</div>
);
};

View File

@@ -7,7 +7,6 @@ import { debounce } from "lodash";
import type { AppClassProperties } from "../types";
import { isTextElement, newTextElement } from "../element";
import type { ExcalidrawTextElement } from "../element/types";
import { measureText } from "../element/textElement";
import { addEventListener, getFontString } from "../utils";
import { KEYS } from "../keys";
import clsx from "clsx";
@@ -20,6 +19,7 @@ import { useStable } from "../hooks/useStable";
import "./SearchMenu.scss";
import { round } from "../../math";
import { measureText } from "../element/textMeasurements";
const searchQueryAtom = atom<string>("");
export const searchItemInFocusAtom = atom<number | null>(null);
@@ -607,7 +607,6 @@ const getMatchedLines = (
textToStart,
getFontString(textElement),
textElement.lineHeight,
true,
);
// measureText returns a non-zero width for the empty string
@@ -621,7 +620,6 @@ const getMatchedLines = (
lineIndexRange.line,
getFontString(textElement),
textElement.lineHeight,
true,
);
const spaceToStart =

View File

@@ -1216,11 +1216,12 @@ export const EdgeRoundIcon = createIcon(
);
export const ArrowheadNoneIcon = createIcon(
<path d="M6 10H34" stroke="currentColor" strokeWidth={2} fill="none" />,
{
width: 40,
height: 20,
},
<g stroke="currentColor" opacity={0.3} strokeWidth={2}>
<path d="M12 12l9 0" />
<path d="M3 9l6 6" />
<path d="M3 15l6 -6" />
</g>,
tablerIconProps,
);
export const ArrowheadArrowIcon = React.memo(

View File

@@ -46,7 +46,7 @@ import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import type { MarkOptional, Mutable } from "../utility-types";
import { detectLineHeight, getContainerElement } from "../element/textElement";
import { getContainerElement } from "../element/textElement";
import { normalizeLink } from "./url";
import { syncInvalidIndices } from "../fractionalIndex";
import { getSizeFromPoints } from "../points";
@@ -59,6 +59,7 @@ import {
} from "../scene";
import type { LocalPoint, Radians } from "../../math";
import { isFiniteNumber, pointFrom } from "../../math";
import { detectLineHeight } from "../element/textMeasurements";
type RestoredAppState = Omit<
AppState,
@@ -205,6 +206,24 @@ const restoreElementWithProperties = <
"customData" in extra ? extra.customData : element.customData;
}
// NOTE (mtolmacs): This is a temporary check to detect extremely large
// element position or sizing
if (
element.x < -1e6 ||
element.x > 1e6 ||
element.y < -1e6 ||
element.y > 1e6 ||
element.width < -1e6 ||
element.width > 1e6 ||
element.height < -1e6 ||
element.height > 1e6
) {
console.error(
"Restore element with properties size or position is too large",
{ element },
);
}
return {
// spread the original element properties to not lose unknown ones
// for forward-compatibility
@@ -219,6 +238,21 @@ const restoreElementWithProperties = <
const restoreElement = (
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
): typeof element | null => {
// NOTE (mtolmacs): This is a temporary check to detect extremely large
// element position or sizing
if (
element.x < -1e6 ||
element.x > 1e6 ||
element.y < -1e6 ||
element.y > 1e6 ||
element.width < -1e6 ||
element.width > 1e6 ||
element.height < -1e6 ||
element.height > 1e6
) {
console.error("Restore element size or position is too large", { element });
}
switch (element.type) {
case "text":
let fontSize = element.fontSize;

View File

@@ -19,7 +19,6 @@ import {
newMagicFrameElement,
newTextElement,
} from "../element/newElement";
import { measureText, normalizeText } from "../element/textElement";
import type {
ElementsMap,
ExcalidrawArrowElement,
@@ -55,6 +54,7 @@ import { syncInvalidIndices } from "../fractionalIndex";
import { getLineHeight } from "../fonts";
import { isArrowElement } from "../element/typeChecks";
import { pointFrom, type LocalPoint } from "../../math";
import { measureText, normalizeText } from "../element/textMeasurements";
export type ValidLinearElement = {
type: "arrow" | "line";

View File

@@ -32,7 +32,6 @@ import type { Bounds } from "./bounds";
import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds";
import type { AppState } from "../types";
import { isPointOnShape } from "../../utils/collision";
import { getElementAtPosition } from "../scene";
import {
isArrowElement,
isBindableElement,
@@ -79,7 +78,6 @@ import {
clamp,
} from "../../math";
import { segmentIntersectRectangleElement } from "../../utils/geometry/shape";
import { getElementsAtPosition } from "../scene/comparisons";
export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement>
@@ -568,7 +566,7 @@ export const getHoveredElementForBinding = (
): NonDeleted<ExcalidrawBindableElement> | null => {
if (considerAllElements) {
let cullRest = false;
const candidateElements = getElementsAtPosition(
const candidateElements = getAllElementsAtPositionForBinding(
elements,
(element) =>
isBindableElement(element, false) &&
@@ -622,7 +620,7 @@ export const getHoveredElementForBinding = (
.pop() as NonDeleted<ExcalidrawBindableElement>;
}
const hoveredElement = getElementAtPosition(
const hoveredElement = getElementAtPositionForBinding(
elements,
(element) =>
isBindableElement(element, false) &&
@@ -641,6 +639,50 @@ export const getHoveredElementForBinding = (
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
};
const getElementAtPositionForBinding = (
elements: readonly NonDeletedExcalidrawElement[],
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
) => {
let hitElement = null;
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
if (element.isDeleted) {
continue;
}
if (isAtPositionFn(element)) {
hitElement = element;
break;
}
}
return hitElement;
};
const getAllElementsAtPositionForBinding = (
elements: readonly NonDeletedExcalidrawElement[],
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
) => {
const elementsAtPosition: NonDeletedExcalidrawElement[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
if (element.isDeleted) {
continue;
}
if (isAtPositionFn(element)) {
elementsAtPosition.push(element);
}
}
return elementsAtPosition;
};
const calculateFocusAndGap = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement,

View File

@@ -10,7 +10,7 @@ import type {
NullableGridSize,
PointerDownState,
} from "../types";
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
import { getBoundTextElement } from "./textElement";
import type Scene from "../scene/Scene";
import {
isArrowElement,
@@ -22,6 +22,7 @@ import {
import { getFontString } from "../utils";
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
import { getGridPoint } from "../snapping";
import { getMinTextElementWidth } from "./textMeasurements";
export const dragSelectedElements = (
pointerDownState: PointerDownState,

View File

@@ -1,4 +1,5 @@
import {
clamp,
pointDistance,
pointFrom,
pointScaleFromOrigin,
@@ -104,7 +105,7 @@ const handleSegmentRenormalization = (
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) => {
const nextFixedSegments: FixedSegment[] | null = arrow.fixedSegments
? structuredClone(arrow.fixedSegments)
? arrow.fixedSegments.slice()
: null;
if (nextFixedSegments) {
@@ -270,7 +271,7 @@ const handleSegmentRenormalization = (
const handleSegmentRelease = (
arrow: ExcalidrawElbowArrowElement,
fixedSegments: FixedSegment[],
fixedSegments: readonly FixedSegment[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) => {
const newFixedSegmentIndices = fixedSegments.map((segment) => segment.index);
@@ -444,7 +445,7 @@ const handleSegmentRelease = (
*/
const handleSegmentMove = (
arrow: ExcalidrawElbowArrowElement,
fixedSegments: FixedSegment[],
fixedSegments: readonly FixedSegment[],
startHeading: Heading,
endHeading: Heading,
hoveredStartElement: ExcalidrawBindableElement | null,
@@ -686,7 +687,7 @@ const handleSegmentMove = (
const handleEndpointDrag = (
arrow: ExcalidrawElbowArrowElement,
updatedPoints: readonly LocalPoint[],
fixedSegments: FixedSegment[],
fixedSegments: readonly FixedSegment[],
startHeading: Heading,
endHeading: Heading,
startGlobalPoint: GlobalPoint,
@@ -863,6 +864,8 @@ const handleEndpointDrag = (
);
};
const MAX_POS = 1e6;
/**
*
*/
@@ -883,6 +886,50 @@ export const updateElbowArrowPoints = (
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) {
invariant(
!updates.points || updates.points.length >= 2,
@@ -944,8 +991,8 @@ export const updateElbowArrowPoints = (
? updates.points![1]
: p,
)
: structuredClone(updates.points)
: structuredClone(arrow.points);
: updates.points.slice()
: arrow.points.slice();
const {
startHeading,
@@ -1965,7 +2012,7 @@ const getBindableElementForId = (
const normalizeArrowElementUpdate = (
global: GlobalPoint[],
nextFixedSegments: FixedSegment[] | null,
nextFixedSegments: readonly FixedSegment[] | null,
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
): {
@@ -1974,24 +2021,51 @@ const normalizeArrowElementUpdate = (
y: number;
width: number;
height: number;
fixedSegments: FixedSegment[] | null;
fixedSegments: readonly FixedSegment[] | null;
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
} => {
const offsetX = global[0][0];
const offsetY = global[0][1];
const points = global.map((p) =>
let points = global.map((p) =>
pointTranslate<GlobalPoint, LocalPoint>(
p,
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 {
points,
x: offsetX,
y: offsetY,
x: clamp(offsetX, -1e6, 1e6),
y: clamp(offsetY, -1e6, 1e6),
fixedSegments:
(nextFixedSegments?.length ?? 0) > 0 ? nextFixedSegments : null,
...getSizeFromPoints(points),

View File

@@ -33,11 +33,7 @@ import { getNewGroupIdsForDuplication } from "../groups";
import type { AppState } from "../types";
import { getElementAbsoluteCoords } from ".";
import { getResizedElementAbsoluteCoords } from "./bounds";
import {
measureText,
normalizeText,
getBoundTextMaxWidth,
} from "./textElement";
import { getBoundTextMaxWidth } from "./textElement";
import { wrapText } from "./textWrapping";
import {
DEFAULT_ELEMENT_PROPS,
@@ -51,6 +47,7 @@ import {
import type { MarkOptional, Merge, Mutable } from "../utility-types";
import { getLineHeight } from "../fonts";
import type { Radians } from "../../math";
import { normalizeText, measureText } from "./textMeasurements";
export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -102,6 +99,28 @@ const _newElementBase = <T extends ExcalidrawElement>(
...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => {
// NOTE (mtolmacs): This is a temporary check to detect extremely large
// element position or sizing
if (
x < -1e6 ||
x > 1e6 ||
y < -1e6 ||
y > 1e6 ||
width < -1e6 ||
width > 1e6 ||
height < -1e6 ||
height > 1e6
) {
console.error("New element size or position is too large", {
x,
y,
width,
height,
// @ts-ignore
points: rest.points,
});
}
// assign type to guard against excess properties
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
id: rest.id || randomId(),

View File

@@ -41,15 +41,11 @@ import type {
import type { PointerDownState } from "../types";
import type Scene from "../scene/Scene";
import {
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextElementId,
getContainerElement,
handleBindTextResize,
getBoundTextMaxWidth,
getApproxMinLineHeight,
measureText,
getMinTextElementWidth,
} from "./textElement";
import { wrapText } from "./textWrapping";
import { LinearElementEditor } from "./linearElementEditor";
@@ -64,6 +60,12 @@ import {
type Radians,
type LocalPoint,
} from "../../math";
import {
getMinTextElementWidth,
measureText,
getApproxMinLineWidth,
getApproxMinLineHeight,
} from "./textMeasurements";
// Returns true when transform (resizing/rotation) happened
export const transformElements = (
@@ -767,6 +769,26 @@ const getResizedOrigin = (
y: y - (newHeight - prevHeight) / 2,
};
case "east-side":
// NOTE (mtolmacs): Reverting this for a short period to test if it is
// the cause of the megasized elbow arrows showing up.
if (
Math.abs(
y +
((prevWidth - newWidth) / 2) * Math.sin(angle) +
(prevHeight - newHeight) / 2,
) > 1e6
) {
console.error(
"getResizedOrigin() new calculation creates extremely large (> 1e6) y value where the old calculation resulted in",
{
result:
y +
(newHeight - prevHeight) / 2 +
((prevWidth - newWidth) / 2) * Math.sin(angle),
},
);
}
return {
x: x + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1),
y:

View File

@@ -6,9 +6,8 @@ import {
getContainerCoords,
getBoundTextMaxWidth,
getBoundTextMaxHeight,
detectLineHeight,
getLineHeightInPx,
} from "./textElement";
import { detectLineHeight, getLineHeightInPx } from "./textMeasurements";
import type { ExcalidrawTextElementWithContainer } from "./types";
describe("Test measureText", () => {

View File

@@ -1,4 +1,4 @@
import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils";
import { getFontString, arrayToMap } from "../utils";
import type {
ElementsMap,
ExcalidrawElement,
@@ -6,7 +6,6 @@ import type {
ExcalidrawTextContainer,
ExcalidrawTextElement,
ExcalidrawTextElementWithContainer,
FontString,
NonDeletedExcalidrawElement,
} from "./types";
import { mutateElement } from "./mutateElement";
@@ -14,7 +13,6 @@ import {
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
ARROW_LABEL_WIDTH_FRACTION,
BOUND_TEXT_PADDING,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
TEXT_ALIGN,
VERTICAL_ALIGN,
@@ -30,18 +28,7 @@ import {
updateOriginalContainerCache,
} from "./containerCache";
import type { ExtractSetType } from "../utility-types";
export const normalizeText = (text: string) => {
return (
normalizeEOL(text)
// replace tabs with spaces so they render and measure correctly
.replace(/\t/g, " ")
);
};
const splitIntoLines = (text: string) => {
return normalizeText(text).split("\n");
};
import { measureText } from "./textMeasurements";
export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement,
@@ -281,201 +268,6 @@ export const computeBoundTextPosition = (
return { x, y };
};
export const measureText = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
forceAdvanceWidth?: true,
) => {
const _text = text
.split("\n")
// replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation
.map((x) => x || " ")
.join("\n");
const fontSize = parseFloat(font);
const height = getTextHeight(_text, fontSize, lineHeight);
const width = getTextWidth(_text, font, forceAdvanceWidth);
return { width, height };
};
/**
* To get unitless line-height (if unknown) we can calculate it by dividing
* height-per-line by fontSize.
*/
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
const lineCount = splitIntoLines(textElement.text).length;
return (textElement.height /
lineCount /
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
};
/**
* We calculate the line height from the font size and the unitless line height,
* aligning with the W3C spec.
*/
export const getLineHeightInPx = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return fontSize * lineHeight;
};
// FIXME rename to getApproxMinContainerHeight
export const getApproxMinLineHeight = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
};
let canvas: HTMLCanvasElement | undefined;
/**
* @param forceAdvanceWidth use to force retrieve the "advance width" ~ `metrics.width`, instead of the actual boundind box width.
*
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
*
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
* - text wrapping
* - wysiwyg editor (+padding)
*
* Everything else should be based on the actual bounding box width.
*
* `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies.
*/
export const getLineWidth = (
text: string,
font: FontString,
forceAdvanceWidth?: true,
) => {
if (!canvas) {
canvas = document.createElement("canvas");
}
const canvas2dContext = canvas.getContext("2d")!;
canvas2dContext.font = font;
const metrics = canvas2dContext.measureText(text);
const advanceWidth = metrics.width;
// retrieve the actual bounding box width if these metrics are available (as of now > 95% coverage)
if (
!forceAdvanceWidth &&
window.TextMetrics &&
"actualBoundingBoxLeft" in window.TextMetrics.prototype &&
"actualBoundingBoxRight" in window.TextMetrics.prototype
) {
// could be negative, therefore getting the absolute value
const actualWidth =
Math.abs(metrics.actualBoundingBoxLeft) +
Math.abs(metrics.actualBoundingBoxRight);
// fallback to advance width if the actual width is zero, i.e. on text editing start
// or when actual width does not respect whitespace chars, i.e. spaces
// otherwise actual width should always be bigger
return Math.max(actualWidth, advanceWidth);
}
// since in test env the canvas measureText algo
// doesn't measure text and instead just returns number of
// characters hence we assume that each letteris 10px
if (isTestEnv()) {
return advanceWidth * 10;
}
return advanceWidth;
};
export const getTextWidth = (
text: string,
font: FontString,
forceAdvanceWidth?: true,
) => {
const lines = splitIntoLines(text);
let width = 0;
lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth));
});
return width;
};
export const getTextHeight = (
text: string,
fontSize: number,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const lineCount = splitIntoLines(text).length;
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
};
export const charWidth = (() => {
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
const calculate = (char: string, font: FontString) => {
const unicode = char.charCodeAt(0);
if (!cachedCharWidth[font]) {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][unicode]) {
const width = getLineWidth(char, font, true);
cachedCharWidth[font][unicode] = width;
}
return cachedCharWidth[font][unicode];
};
const getCache = (font: FontString) => {
return cachedCharWidth[font];
};
const clearCache = (font: FontString) => {
cachedCharWidth[font] = [];
};
return {
calculate,
getCache,
clearCache,
};
})();
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
// FIXME rename to getApproxMinContainerWidth
export const getApproxMinLineWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) {
return (
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
BOUND_TEXT_PADDING * 2
);
}
return maxCharWidth + BOUND_TEXT_PADDING * 2;
};
export const getMinCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.min(...cacheWithOutEmpty);
};
export const getMaxCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.max(...cacheWithOutEmpty);
};
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
return container?.boundElements?.length
? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
@@ -712,24 +504,6 @@ export const getBoundTextMaxHeight = (
return height - BOUND_TEXT_PADDING * 2;
};
export const isMeasureTextSupported = () => {
const width = getTextWidth(
DUMMY_TEXT,
getFontString({
fontSize: DEFAULT_FONT_SIZE,
fontFamily: DEFAULT_FONT_FAMILY,
}),
);
return width > 0;
};
export const getMinTextElementWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
};
/** retrieves text from text elements and concatenates to a single string */
export const getTextFromElements = (
elements: readonly ExcalidrawElement[],

View File

@@ -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);
};

View File

@@ -1,5 +1,5 @@
import { ENV } from "../constants";
import { charWidth, getLineWidth } from "./textElement";
import { charWidth, getLineWidth } from "./textMeasurements";
import type { FontString } from "./types";
let cachedCjkRegex: RegExp | undefined;
@@ -385,7 +385,7 @@ export const wrapText = (
const originalLines = text.split("\n");
for (const originalLine of originalLines) {
const currentLineWidth = getLineWidth(originalLine, font, true);
const currentLineWidth = getLineWidth(originalLine, font);
if (currentLineWidth <= maxWidth) {
lines.push(originalLine);
@@ -423,7 +423,7 @@ const wrapLine = (
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
const testLineWidth = isSingleCharacter(token)
? currentLineWidth + charWidth.calculate(token, font)
: getLineWidth(testLine, font, true);
: getLineWidth(testLine, font);
// build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) {
@@ -443,7 +443,7 @@ const wrapLine = (
// trailing line of the wrapped word might still be joined with next token/s
currentLine = trailingLine;
currentLineWidth = getLineWidth(trailingLine, font, true);
currentLineWidth = getLineWidth(trailingLine, font);
iterator = tokenIterator.next();
} else {
// push & reset, but don't iterate on the next token, as we didn't use it yet!
@@ -514,7 +514,7 @@ const wrapWord = (
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
*/
const trimLine = (line: string, font: FontString, maxWidth: number) => {
const shouldTrimWhitespaces = getLineWidth(line, font, true) > maxWidth;
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
if (!shouldTrimWhitespaces) {
return line;
@@ -527,7 +527,7 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
"",
];
let trimmedLineWidth = getLineWidth(trimmedLine, font, true);
let trimmedLineWidth = getLineWidth(trimmedLine, font);
for (const whitespace of Array.from(whitespaces)) {
const _charWidth = charWidth.calculate(whitespace, font);

View File

@@ -24,8 +24,6 @@ import {
getBoundTextElementId,
getContainerElement,
getTextElementAngle,
getTextWidth,
normalizeText,
redrawTextBoundingBox,
getBoundTextMaxHeight,
getBoundTextMaxWidth,
@@ -50,6 +48,8 @@ import {
originalContainerCache,
updateOriginalContainerCache,
} from "./containerCache";
import { getTextWidth } from "./textMeasurements";
import { normalizeText } from "./textMeasurements";
const getTransform = (
width: number,
@@ -350,7 +350,7 @@ export const textWysiwyg = ({
font,
getBoundTextMaxWidth(container, boundTextElement),
);
const width = getTextWidth(wrappedText, font, true);
const width = getTextWidth(wrappedText, font);
editable.style.width = `${width}px`;
}
};

View File

@@ -337,7 +337,7 @@ export type ExcalidrawElbowArrowElement = Merge<
elbowed: true;
startBinding: 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
* order to temporarily hide the first segment of the arrow without losing

View File

@@ -6,7 +6,7 @@ import {
getFontFamilyFallbacks,
} from "../constants";
import { isTextElement } from "../element";
import { charWidth, getContainerElement } from "../element/textElement";
import { getContainerElement } from "../element/textElement";
import { containsCJK } from "../element/textWrapping";
import { ShapeCache } from "../scene/ShapeCache";
import { getFontString, PromisePool, promiseTry } from "../utils";
@@ -31,6 +31,7 @@ import type {
} from "../element/types";
import type Scene from "../scene/Scene";
import type { ValueOf } from "../utility-types";
import { charWidth } from "../element/textMeasurements";
export class Fonts {
// it's ok to track fonts across multiple instances only once, so let's use

View File

@@ -46,6 +46,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
onPointerDown,
onPointerUp,
onScrollChange,
onDuplicate,
children,
validateEmbeddable,
renderEmbeddable,
@@ -136,6 +137,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onScrollChange={onScrollChange}
onDuplicate={onDuplicate}
validateEmbeddable={validateEmbeddable}
renderEmbeddable={renderEmbeddable}
aiEnabled={aiEnabled !== false}
@@ -293,3 +295,5 @@ export {
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
export { getDataURL } from "./data/blob";
export { isElementLink } from "./element/elementLink";
export { setCustomTextMetricsProvider } from "./element/textMeasurements";

View File

@@ -52,7 +52,6 @@ import {
getBoundTextElement,
getContainerCoords,
getContainerElement,
getLineHeightInPx,
getBoundTextMaxHeight,
getBoundTextMaxWidth,
} from "../element/textElement";
@@ -64,6 +63,7 @@ import { getVerticalOffset } from "../fonts";
import { isRightAngleRads } from "../../math";
import { getCornerRadius } from "../shapes";
import { getUncroppedImageElement } from "../element/cropElement";
import { getLineHeightInPx } from "../element/textMeasurements";
// using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original

View File

@@ -16,7 +16,6 @@ import { LinearElementEditor } from "../element/linearElementEditor";
import {
getBoundTextElement,
getContainerElement,
getLineHeightInPx,
} from "../element/textElement";
import {
isArrowElement,
@@ -38,6 +37,7 @@ import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
import { getVerticalOffset } from "../fonts";
import { getCornerRadius, isPathALoop } from "../shapes";
import { getUncroppedWidthAndHeight } from "../element/cropElement";
import { getLineHeightInPx } from "../element/textMeasurements";
const roughSVGDrawWithPrecision = (
rsvg: RoughSVG,

View File

@@ -1,8 +1,3 @@
import { isIframeElement } from "../element/typeChecks";
import type {
ExcalidrawIframeElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import type { ElementOrToolType } from "../types";
export const hasBackground = (type: ElementOrToolType) =>
@@ -47,51 +42,3 @@ export const canChangeRoundness = (type: ElementOrToolType) =>
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
export const getElementAtPosition = (
elements: readonly NonDeletedExcalidrawElement[],
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
) => {
let hitElement = null;
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
if (element.isDeleted) {
continue;
}
if (isAtPositionFn(element)) {
hitElement = element;
break;
}
}
return hitElement;
};
export const getElementsAtPosition = (
elements: readonly NonDeletedExcalidrawElement[],
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
) => {
const iframeLikes: ExcalidrawIframeElement[] = [];
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 (isIframeElement(element)) {
iframeLikes.push(element);
continue;
}
if (isAtPositionFn(element)) {
elementsAtPosition.push(element);
}
}
return elementsAtPosition.concat(iframeLikes);
};

View File

@@ -12,8 +12,6 @@ export {
hasStrokeStyle,
canHaveArrowheads,
canChangeRoundness,
getElementAtPosition,
getElementsAtPosition,
} from "./comparisons";
export {
getNormalizedZoom,

View File

@@ -5,7 +5,7 @@ import { render, waitFor, GlobalTestState } from "./test-utils";
import { Pointer, Keyboard } from "./helpers/ui";
import { Excalidraw } from "../index";
import { KEYS } from "../keys";
import { getLineHeightInPx } from "../element/textElement";
import { getLineHeightInPx } from "../element/textMeasurements";
import { getElementBounds } from "../element";
import type { NormalizedZoomValue } from "../types";
import { API } from "./helpers/api";

View File

@@ -512,6 +512,22 @@ export interface ExcalidrawProps {
data: ClipboardData,
event: ClipboardEvent | null,
) => Promise<boolean> | boolean;
/**
* Called when element(s) are duplicated so you can listen or modify as
* needed.
*
* Called when duplicating via mouse-drag, keyboard, paste, library insert
* etc.
*
* Returned elements will be used in place of the next elements
* (you should return all elements, including deleted, and not mutate
* the element if changes are made)
*/
onDuplicate?: (
nextElements: readonly ExcalidrawElement[],
/** excludes the duplicated elements */
prevElements: readonly ExcalidrawElement[],
) => ExcalidrawElement[] | void;
renderTopRightUI?: (
isMobile: boolean,
appState: UIAppState,

View File

@@ -1,4 +1,5 @@
export * from "./export";
export * from "./withinBounds";
export * from "./bbox";
export { elementsOverlappingBBox } from "./withinBounds";
export { getCommonBounds } from "../excalidraw/element/bounds";

View File

@@ -1,6 +1,6 @@
{
"name": "@excalidraw/utils",
"version": "0.1.2",
"version": "0.1.3-test27",
"main": "./dist/prod/index.js",
"type": "module",
"module": "./dist/prod/index.js",

View File

@@ -7,6 +7,9 @@ import polyfill from "./packages/excalidraw/polyfill";
import { testPolyfills } from "./packages/excalidraw/tests/helpers/polyfills";
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);
require("fake-indexeddb/auto");

861
yarn.lock

File diff suppressed because it is too large Load Diff