mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-10-06 07:40:16 +02:00
Compare commits
12 Commits
zsviczian-
...
dwelle/uti
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e5da6b08b2 | ||
![]() |
9ee0b8ffcb | ||
![]() |
16b86d7d16 | ||
![]() |
f12b92ce9d | ||
![]() |
77dc055d81 | ||
![]() |
26f02bebea | ||
![]() |
e3060dfb8f | ||
![]() |
c329470b73 | ||
![]() |
c8f4a4cb41 | ||
![]() |
9e49c9254b | ||
![]() |
b0c8c5f7a7 | ||
![]() |
4f64372506 |
@@ -25,6 +25,7 @@ import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||
import { getFrame } from "../../packages/excalidraw/utils";
|
||||
import { ExcalidrawLogo } from "../../packages/excalidraw/components/ExcalidrawLogo";
|
||||
import { uploadBytes, ref } from "firebase/storage";
|
||||
|
||||
export const exportToExcalidrawPlus = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
@@ -32,7 +33,7 @@ export const exportToExcalidrawPlus = async (
|
||||
files: BinaryFiles,
|
||||
name: string,
|
||||
) => {
|
||||
const firebase = await loadFirebaseStorage();
|
||||
const storage = await loadFirebaseStorage();
|
||||
|
||||
const id = `${nanoid(12)}`;
|
||||
|
||||
@@ -49,15 +50,13 @@ export const exportToExcalidrawPlus = async (
|
||||
},
|
||||
);
|
||||
|
||||
await firebase
|
||||
.storage()
|
||||
.ref(`/migrations/scenes/${id}`)
|
||||
.put(blob, {
|
||||
customMetadata: {
|
||||
data: JSON.stringify({ version: 2, name }),
|
||||
created: Date.now().toString(),
|
||||
},
|
||||
});
|
||||
const storageRef = ref(storage, `/migrations/scenes/${id}`);
|
||||
await uploadBytes(storageRef, blob, {
|
||||
customMetadata: {
|
||||
data: JSON.stringify({ version: 2, name }),
|
||||
created: Date.now().toString(),
|
||||
},
|
||||
});
|
||||
|
||||
const filesMap = new Map<FileId, BinaryFileData>();
|
||||
for (const element of elements) {
|
||||
|
@@ -22,9 +22,17 @@ import {
|
||||
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
||||
import type { SyncableExcalidrawElement } from ".";
|
||||
import { getSyncableElements } from ".";
|
||||
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
|
||||
import { initializeApp } from "firebase/app";
|
||||
import {
|
||||
getFirestore,
|
||||
doc,
|
||||
getDoc,
|
||||
runTransaction,
|
||||
Bytes,
|
||||
} from "firebase/firestore";
|
||||
import { getStorage, ref, uploadBytes } from "firebase/storage";
|
||||
|
||||
// private
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -41,80 +49,42 @@ try {
|
||||
FIREBASE_CONFIG = {};
|
||||
}
|
||||
|
||||
let firebasePromise: Promise<typeof import("firebase/app").default> | null =
|
||||
null;
|
||||
let firestorePromise: Promise<any> | null | true = null;
|
||||
let firebaseStoragePromise: Promise<any> | null | true = null;
|
||||
let firebaseApp: ReturnType<typeof initializeApp> | null = null;
|
||||
let firestore: ReturnType<typeof getFirestore> | null = null;
|
||||
let firebaseStorage: ReturnType<typeof getStorage> | null = null;
|
||||
|
||||
let isFirebaseInitialized = false;
|
||||
|
||||
const _loadFirebase = async () => {
|
||||
const firebase = (
|
||||
await import(/* webpackChunkName: "firebase" */ "firebase/app")
|
||||
).default;
|
||||
|
||||
if (!isFirebaseInitialized) {
|
||||
try {
|
||||
firebase.initializeApp(FIREBASE_CONFIG);
|
||||
} catch (error: any) {
|
||||
// trying initialize again throws. Usually this is harmless, and happens
|
||||
// mainly in dev (HMR)
|
||||
if (error.code === "app/duplicate-app") {
|
||||
console.warn(error.name, error.code);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
isFirebaseInitialized = true;
|
||||
const _initializeFirebase = () => {
|
||||
if (!firebaseApp) {
|
||||
firebaseApp = initializeApp(FIREBASE_CONFIG);
|
||||
}
|
||||
|
||||
return firebase;
|
||||
return firebaseApp;
|
||||
};
|
||||
|
||||
const _getFirebase = async (): Promise<
|
||||
typeof import("firebase/app").default
|
||||
> => {
|
||||
if (!firebasePromise) {
|
||||
firebasePromise = _loadFirebase();
|
||||
const _getFirestore = () => {
|
||||
if (!firestore) {
|
||||
firestore = getFirestore(_initializeFirebase());
|
||||
}
|
||||
return firebasePromise;
|
||||
return firestore;
|
||||
};
|
||||
|
||||
const _getStorage = () => {
|
||||
if (!firebaseStorage) {
|
||||
firebaseStorage = getStorage(_initializeFirebase());
|
||||
}
|
||||
return firebaseStorage;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const loadFirestore = async () => {
|
||||
const firebase = await _getFirebase();
|
||||
if (!firestorePromise) {
|
||||
firestorePromise = import(
|
||||
/* webpackChunkName: "firestore" */ "firebase/firestore"
|
||||
);
|
||||
}
|
||||
if (firestorePromise !== true) {
|
||||
await firestorePromise;
|
||||
firestorePromise = true;
|
||||
}
|
||||
return firebase;
|
||||
};
|
||||
|
||||
export const loadFirebaseStorage = async () => {
|
||||
const firebase = await _getFirebase();
|
||||
if (!firebaseStoragePromise) {
|
||||
firebaseStoragePromise = import(
|
||||
/* webpackChunkName: "storage" */ "firebase/storage"
|
||||
);
|
||||
}
|
||||
if (firebaseStoragePromise !== true) {
|
||||
await firebaseStoragePromise;
|
||||
firebaseStoragePromise = true;
|
||||
}
|
||||
return firebase;
|
||||
return _getStorage();
|
||||
};
|
||||
|
||||
interface FirebaseStoredScene {
|
||||
type FirebaseStoredScene = {
|
||||
sceneVersion: number;
|
||||
iv: firebase.default.firestore.Blob;
|
||||
ciphertext: firebase.default.firestore.Blob;
|
||||
}
|
||||
iv: Bytes;
|
||||
ciphertext: Bytes;
|
||||
};
|
||||
|
||||
const encryptElements = async (
|
||||
key: string,
|
||||
@@ -175,7 +145,7 @@ export const saveFilesToFirebase = async ({
|
||||
prefix: string;
|
||||
files: { id: FileId; buffer: Uint8Array }[];
|
||||
}) => {
|
||||
const firebase = await loadFirebaseStorage();
|
||||
const storage = await loadFirebaseStorage();
|
||||
|
||||
const erroredFiles: FileId[] = [];
|
||||
const savedFiles: FileId[] = [];
|
||||
@@ -183,17 +153,10 @@ export const saveFilesToFirebase = async ({
|
||||
await Promise.all(
|
||||
files.map(async ({ id, buffer }) => {
|
||||
try {
|
||||
await firebase
|
||||
.storage()
|
||||
.ref(`${prefix}/${id}`)
|
||||
.put(
|
||||
new Blob([buffer], {
|
||||
type: MIME_TYPES.binary,
|
||||
}),
|
||||
{
|
||||
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
|
||||
},
|
||||
);
|
||||
const storageRef = ref(storage, `${prefix}/${id}`);
|
||||
await uploadBytes(storageRef, buffer, {
|
||||
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
|
||||
});
|
||||
savedFiles.push(id);
|
||||
} catch (error: any) {
|
||||
erroredFiles.push(id);
|
||||
@@ -205,7 +168,6 @@ export const saveFilesToFirebase = async ({
|
||||
};
|
||||
|
||||
const createFirebaseSceneDocument = async (
|
||||
firebase: ResolutionType<typeof loadFirestore>,
|
||||
elements: readonly SyncableExcalidrawElement[],
|
||||
roomKey: string,
|
||||
) => {
|
||||
@@ -213,10 +175,8 @@ const createFirebaseSceneDocument = async (
|
||||
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
||||
return {
|
||||
sceneVersion,
|
||||
ciphertext: firebase.firestore.Blob.fromUint8Array(
|
||||
new Uint8Array(ciphertext),
|
||||
),
|
||||
iv: firebase.firestore.Blob.fromUint8Array(iv),
|
||||
ciphertext: Bytes.fromUint8Array(new Uint8Array(ciphertext)),
|
||||
iv: Bytes.fromUint8Array(iv),
|
||||
} as FirebaseStoredScene;
|
||||
};
|
||||
|
||||
@@ -236,20 +196,14 @@ export const saveToFirebase = async (
|
||||
return null;
|
||||
}
|
||||
|
||||
const firebase = await loadFirestore();
|
||||
const firestore = firebase.firestore();
|
||||
const firestore = _getFirestore();
|
||||
const docRef = doc(firestore, "scenes", roomId);
|
||||
|
||||
const docRef = firestore.collection("scenes").doc(roomId);
|
||||
|
||||
const storedScene = await firestore.runTransaction(async (transaction) => {
|
||||
const storedScene = await runTransaction(firestore, async (transaction) => {
|
||||
const snapshot = await transaction.get(docRef);
|
||||
|
||||
if (!snapshot.exists) {
|
||||
const storedScene = await createFirebaseSceneDocument(
|
||||
firebase,
|
||||
elements,
|
||||
roomKey,
|
||||
);
|
||||
if (!snapshot.exists()) {
|
||||
const storedScene = await createFirebaseSceneDocument(elements, roomKey);
|
||||
|
||||
transaction.set(docRef, storedScene);
|
||||
|
||||
@@ -269,7 +223,6 @@ export const saveToFirebase = async (
|
||||
);
|
||||
|
||||
const storedScene = await createFirebaseSceneDocument(
|
||||
firebase,
|
||||
reconciledElements,
|
||||
roomKey,
|
||||
);
|
||||
@@ -294,15 +247,13 @@ export const loadFromFirebase = async (
|
||||
roomKey: string,
|
||||
socket: Socket | null,
|
||||
): Promise<readonly SyncableExcalidrawElement[] | null> => {
|
||||
const firebase = await loadFirestore();
|
||||
const db = firebase.firestore();
|
||||
|
||||
const docRef = db.collection("scenes").doc(roomId);
|
||||
const doc = await docRef.get();
|
||||
if (!doc.exists) {
|
||||
const firestore = _getFirestore();
|
||||
const docRef = doc(firestore, "scenes", roomId);
|
||||
const docSnap = await getDoc(docRef);
|
||||
if (!docSnap.exists()) {
|
||||
return null;
|
||||
}
|
||||
const storedScene = doc.data() as FirebaseStoredScene;
|
||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
||||
const elements = getSyncableElements(
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||
);
|
||||
|
@@ -27,9 +27,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"firebase": "8.3.3",
|
||||
"@sentry/browser": "9.0.1",
|
||||
"callsites": "4.2.0",
|
||||
"firebase": "11.3.1",
|
||||
"i18next-browser-languagedetector": "6.1.4",
|
||||
"idb-keyval": "6.0.3",
|
||||
"jotai": "2.11.0",
|
||||
|
@@ -1,8 +1,9 @@
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import * as SentryIntegrations from "@sentry/integrations";
|
||||
import callsites from "callsites";
|
||||
|
||||
const SentryEnvHostnameMap: { [key: string]: string } = {
|
||||
"excalidraw.com": "production",
|
||||
"staging.excalidraw.com": "staging",
|
||||
"vercel.app": "staging",
|
||||
};
|
||||
|
||||
@@ -23,9 +24,13 @@ Sentry.init({
|
||||
release: import.meta.env.VITE_APP_GIT_SHA,
|
||||
ignoreErrors: [
|
||||
"undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
|
||||
"InvalidStateError: Failed to execute 'transaction' on 'IDBDatabase': The database connection is closing.", // Not much we can do about the IndexedDB closing error
|
||||
/(Failed to fetch|(fetch|loading) dynamically imported module)/i, // This is happening when a service worker tries to load an old asset
|
||||
/QuotaExceededError: (The quota has been exceeded|.*setItem.*Storage)/i, // localStorage quota exceeded
|
||||
"Internal error opening backing store for indexedDB.open", // Private mode and disabled indexedDB
|
||||
],
|
||||
integrations: [
|
||||
new SentryIntegrations.CaptureConsole({
|
||||
Sentry.captureConsoleIntegration({
|
||||
levels: ["error"],
|
||||
}),
|
||||
],
|
||||
@@ -33,6 +38,44 @@ Sentry.init({
|
||||
if (event.request?.url) {
|
||||
event.request.url = event.request.url.replace(/#.*$/, "");
|
||||
}
|
||||
|
||||
if (!event.exception) {
|
||||
event.exception = {
|
||||
values: [
|
||||
{
|
||||
type: "ConsoleError",
|
||||
value: event.message ?? "Unknown error",
|
||||
stacktrace: {
|
||||
frames: callsites()
|
||||
.slice(1)
|
||||
.filter(
|
||||
(frame) =>
|
||||
frame.getFileName() &&
|
||||
!frame.getFileName()?.includes("@sentry_browser.js"),
|
||||
)
|
||||
.map((frame) => ({
|
||||
filename: frame.getFileName() ?? undefined,
|
||||
function: frame.getFunctionName() ?? undefined,
|
||||
in_app: !(
|
||||
frame.getFileName()?.includes("node_modules") ?? false
|
||||
),
|
||||
lineno: frame.getLineNumber() ?? undefined,
|
||||
colno: frame.getColumnNumber() ?? undefined,
|
||||
})),
|
||||
},
|
||||
mechanism: {
|
||||
type: "instrument",
|
||||
handled: true,
|
||||
data: {
|
||||
function: "console.error",
|
||||
handler: "Sentry.beforeSend",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
});
|
||||
|
@@ -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",
|
||||
|
@@ -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();
|
||||
|
@@ -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";
|
||||
|
@@ -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,
|
||||
|
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
||||
|
@@ -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 =
|
||||
|
@@ -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(
|
||||
|
@@ -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;
|
||||
|
@@ -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";
|
||||
|
@@ -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,
|
||||
|
@@ -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,
|
||||
|
@@ -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),
|
||||
|
@@ -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(),
|
||||
|
@@ -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:
|
||||
|
@@ -6,9 +6,8 @@ import {
|
||||
getContainerCoords,
|
||||
getBoundTextMaxWidth,
|
||||
getBoundTextMaxHeight,
|
||||
detectLineHeight,
|
||||
getLineHeightInPx,
|
||||
} from "./textElement";
|
||||
import { detectLineHeight, getLineHeightInPx } from "./textMeasurements";
|
||||
import type { ExcalidrawTextElementWithContainer } from "./types";
|
||||
|
||||
describe("Test measureText", () => {
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils";
|
||||
import { getFontString, arrayToMap } from "../utils";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FontString,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
|
||||
ARROW_LABEL_WIDTH_FRACTION,
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
@@ -30,18 +28,7 @@ import {
|
||||
updateOriginalContainerCache,
|
||||
} from "./containerCache";
|
||||
import type { ExtractSetType } from "../utility-types";
|
||||
|
||||
export const normalizeText = (text: string) => {
|
||||
return (
|
||||
normalizeEOL(text)
|
||||
// replace tabs with spaces so they render and measure correctly
|
||||
.replace(/\t/g, " ")
|
||||
);
|
||||
};
|
||||
|
||||
const splitIntoLines = (text: string) => {
|
||||
return normalizeText(text).split("\n");
|
||||
};
|
||||
import { measureText } from "./textMeasurements";
|
||||
|
||||
export const redrawTextBoundingBox = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
@@ -281,201 +268,6 @@ export const computeBoundTextPosition = (
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
export const measureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
forceAdvanceWidth?: true,
|
||||
) => {
|
||||
const _text = text
|
||||
.split("\n")
|
||||
// replace empty lines with single space because leading/trailing empty
|
||||
// lines would be stripped from computation
|
||||
.map((x) => x || " ")
|
||||
.join("\n");
|
||||
const fontSize = parseFloat(font);
|
||||
const height = getTextHeight(_text, fontSize, lineHeight);
|
||||
const width = getTextWidth(_text, font, forceAdvanceWidth);
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
/**
|
||||
* To get unitless line-height (if unknown) we can calculate it by dividing
|
||||
* height-per-line by fontSize.
|
||||
*/
|
||||
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
|
||||
const lineCount = splitIntoLines(textElement.text).length;
|
||||
return (textElement.height /
|
||||
lineCount /
|
||||
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
|
||||
};
|
||||
|
||||
/**
|
||||
* We calculate the line height from the font size and the unitless line height,
|
||||
* aligning with the W3C spec.
|
||||
*/
|
||||
export const getLineHeightInPx = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return fontSize * lineHeight;
|
||||
};
|
||||
|
||||
// FIXME rename to getApproxMinContainerHeight
|
||||
export const getApproxMinLineHeight = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
|
||||
/**
|
||||
* @param forceAdvanceWidth use to force retrieve the "advance width" ~ `metrics.width`, instead of the actual boundind box width.
|
||||
*
|
||||
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
|
||||
*
|
||||
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
|
||||
* - text wrapping
|
||||
* - wysiwyg editor (+padding)
|
||||
*
|
||||
* Everything else should be based on the actual bounding box width.
|
||||
*
|
||||
* `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies.
|
||||
*/
|
||||
export const getLineWidth = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
forceAdvanceWidth?: true,
|
||||
) => {
|
||||
if (!canvas) {
|
||||
canvas = document.createElement("canvas");
|
||||
}
|
||||
const canvas2dContext = canvas.getContext("2d")!;
|
||||
canvas2dContext.font = font;
|
||||
const metrics = canvas2dContext.measureText(text);
|
||||
|
||||
const advanceWidth = metrics.width;
|
||||
|
||||
// retrieve the actual bounding box width if these metrics are available (as of now > 95% coverage)
|
||||
if (
|
||||
!forceAdvanceWidth &&
|
||||
window.TextMetrics &&
|
||||
"actualBoundingBoxLeft" in window.TextMetrics.prototype &&
|
||||
"actualBoundingBoxRight" in window.TextMetrics.prototype
|
||||
) {
|
||||
// could be negative, therefore getting the absolute value
|
||||
const actualWidth =
|
||||
Math.abs(metrics.actualBoundingBoxLeft) +
|
||||
Math.abs(metrics.actualBoundingBoxRight);
|
||||
|
||||
// fallback to advance width if the actual width is zero, i.e. on text editing start
|
||||
// or when actual width does not respect whitespace chars, i.e. spaces
|
||||
// otherwise actual width should always be bigger
|
||||
return Math.max(actualWidth, advanceWidth);
|
||||
}
|
||||
|
||||
// since in test env the canvas measureText algo
|
||||
// doesn't measure text and instead just returns number of
|
||||
// characters hence we assume that each letteris 10px
|
||||
if (isTestEnv()) {
|
||||
return advanceWidth * 10;
|
||||
}
|
||||
|
||||
return advanceWidth;
|
||||
};
|
||||
|
||||
export const getTextWidth = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
forceAdvanceWidth?: true,
|
||||
) => {
|
||||
const lines = splitIntoLines(text);
|
||||
let width = 0;
|
||||
lines.forEach((line) => {
|
||||
width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth));
|
||||
});
|
||||
|
||||
return width;
|
||||
};
|
||||
|
||||
export const getTextHeight = (
|
||||
text: string,
|
||||
fontSize: number,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const lineCount = splitIntoLines(text).length;
|
||||
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
|
||||
};
|
||||
|
||||
export const charWidth = (() => {
|
||||
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
|
||||
|
||||
const calculate = (char: string, font: FontString) => {
|
||||
const unicode = char.charCodeAt(0);
|
||||
if (!cachedCharWidth[font]) {
|
||||
cachedCharWidth[font] = [];
|
||||
}
|
||||
if (!cachedCharWidth[font][unicode]) {
|
||||
const width = getLineWidth(char, font, true);
|
||||
cachedCharWidth[font][unicode] = width;
|
||||
}
|
||||
|
||||
return cachedCharWidth[font][unicode];
|
||||
};
|
||||
|
||||
const getCache = (font: FontString) => {
|
||||
return cachedCharWidth[font];
|
||||
};
|
||||
|
||||
const clearCache = (font: FontString) => {
|
||||
cachedCharWidth[font] = [];
|
||||
};
|
||||
|
||||
return {
|
||||
calculate,
|
||||
getCache,
|
||||
clearCache,
|
||||
};
|
||||
})();
|
||||
|
||||
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||
|
||||
// FIXME rename to getApproxMinContainerWidth
|
||||
export const getApproxMinLineWidth = (
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const maxCharWidth = getMaxCharWidth(font);
|
||||
if (maxCharWidth === 0) {
|
||||
return (
|
||||
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
|
||||
BOUND_TEXT_PADDING * 2
|
||||
);
|
||||
}
|
||||
return maxCharWidth + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getMinCharWidth = (font: FontString) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
return 0;
|
||||
}
|
||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||
|
||||
return Math.min(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
export const getMaxCharWidth = (font: FontString) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
return 0;
|
||||
}
|
||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||
return Math.max(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
|
||||
return container?.boundElements?.length
|
||||
? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
|
||||
@@ -712,24 +504,6 @@ export const getBoundTextMaxHeight = (
|
||||
return height - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const isMeasureTextSupported = () => {
|
||||
const width = getTextWidth(
|
||||
DUMMY_TEXT,
|
||||
getFontString({
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
}),
|
||||
);
|
||||
return width > 0;
|
||||
};
|
||||
|
||||
export const getMinTextElementWidth = (
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
/** retrieves text from text elements and concatenates to a single string */
|
||||
export const getTextFromElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
|
224
packages/excalidraw/element/textMeasurements.ts
Normal file
224
packages/excalidraw/element/textMeasurements.ts
Normal 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);
|
||||
};
|
@@ -1,5 +1,5 @@
|
||||
import { ENV } from "../constants";
|
||||
import { charWidth, getLineWidth } from "./textElement";
|
||||
import { charWidth, getLineWidth } from "./textMeasurements";
|
||||
import type { FontString } from "./types";
|
||||
|
||||
let cachedCjkRegex: RegExp | undefined;
|
||||
@@ -385,7 +385,7 @@ export const wrapText = (
|
||||
const originalLines = text.split("\n");
|
||||
|
||||
for (const originalLine of originalLines) {
|
||||
const currentLineWidth = getLineWidth(originalLine, font, true);
|
||||
const currentLineWidth = getLineWidth(originalLine, font);
|
||||
|
||||
if (currentLineWidth <= maxWidth) {
|
||||
lines.push(originalLine);
|
||||
@@ -423,7 +423,7 @@ const wrapLine = (
|
||||
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
|
||||
const testLineWidth = isSingleCharacter(token)
|
||||
? currentLineWidth + charWidth.calculate(token, font)
|
||||
: getLineWidth(testLine, font, true);
|
||||
: getLineWidth(testLine, font);
|
||||
|
||||
// build up the current line, skipping length check for possibly trailing whitespaces
|
||||
if (/\s/.test(token) || testLineWidth <= maxWidth) {
|
||||
@@ -443,7 +443,7 @@ const wrapLine = (
|
||||
|
||||
// trailing line of the wrapped word might still be joined with next token/s
|
||||
currentLine = trailingLine;
|
||||
currentLineWidth = getLineWidth(trailingLine, font, true);
|
||||
currentLineWidth = getLineWidth(trailingLine, font);
|
||||
iterator = tokenIterator.next();
|
||||
} else {
|
||||
// push & reset, but don't iterate on the next token, as we didn't use it yet!
|
||||
@@ -514,7 +514,7 @@ const wrapWord = (
|
||||
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
|
||||
*/
|
||||
const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
||||
const shouldTrimWhitespaces = getLineWidth(line, font, true) > maxWidth;
|
||||
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
|
||||
|
||||
if (!shouldTrimWhitespaces) {
|
||||
return line;
|
||||
@@ -527,7 +527,7 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
||||
"",
|
||||
];
|
||||
|
||||
let trimmedLineWidth = getLineWidth(trimmedLine, font, true);
|
||||
let trimmedLineWidth = getLineWidth(trimmedLine, font);
|
||||
|
||||
for (const whitespace of Array.from(whitespaces)) {
|
||||
const _charWidth = charWidth.calculate(whitespace, font);
|
||||
|
@@ -24,8 +24,6 @@ import {
|
||||
getBoundTextElementId,
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
getTextWidth,
|
||||
normalizeText,
|
||||
redrawTextBoundingBox,
|
||||
getBoundTextMaxHeight,
|
||||
getBoundTextMaxWidth,
|
||||
@@ -50,6 +48,8 @@ import {
|
||||
originalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "./containerCache";
|
||||
import { getTextWidth } from "./textMeasurements";
|
||||
import { normalizeText } from "./textMeasurements";
|
||||
|
||||
const getTransform = (
|
||||
width: number,
|
||||
@@ -350,7 +350,7 @@ export const textWysiwyg = ({
|
||||
font,
|
||||
getBoundTextMaxWidth(container, boundTextElement),
|
||||
);
|
||||
const width = getTextWidth(wrappedText, font, true);
|
||||
const width = getTextWidth(wrappedText, font);
|
||||
editable.style.width = `${width}px`;
|
||||
}
|
||||
};
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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";
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
||||
|
@@ -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);
|
||||
};
|
||||
|
@@ -12,8 +12,6 @@ export {
|
||||
hasStrokeStyle,
|
||||
canHaveArrowheads,
|
||||
canChangeRoundness,
|
||||
getElementAtPosition,
|
||||
getElementsAtPosition,
|
||||
} from "./comparisons";
|
||||
export {
|
||||
getNormalizedZoom,
|
||||
|
@@ -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";
|
||||
|
@@ -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,
|
||||
|
@@ -1,4 +1,5 @@
|
||||
export * from "./export";
|
||||
export * from "./withinBounds";
|
||||
export * from "./bbox";
|
||||
export { elementsOverlappingBBox } from "./withinBounds";
|
||||
export { getCommonBounds } from "../excalidraw/element/bounds";
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@excalidraw/utils",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3-test27",
|
||||
"main": "./dist/prod/index.js",
|
||||
"type": "module",
|
||||
"module": "./dist/prod/index.js",
|
||||
|
@@ -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");
|
||||
|
Reference in New Issue
Block a user