Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
880afd12c9 | ||
|
|
247d6e2a2e | ||
|
|
9ee0b8ffcb | ||
|
|
16b86d7d16 | ||
|
|
f12b92ce9d | ||
|
|
77dc055d81 | ||
|
|
26f02bebea | ||
|
|
e3060dfb8f |
@@ -25,6 +25,7 @@ import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
|||||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||||
import { getFrame } from "../../packages/excalidraw/utils";
|
import { getFrame } from "../../packages/excalidraw/utils";
|
||||||
import { ExcalidrawLogo } from "../../packages/excalidraw/components/ExcalidrawLogo";
|
import { ExcalidrawLogo } from "../../packages/excalidraw/components/ExcalidrawLogo";
|
||||||
|
import { uploadBytes, ref } from "firebase/storage";
|
||||||
|
|
||||||
export const exportToExcalidrawPlus = async (
|
export const exportToExcalidrawPlus = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
@@ -32,7 +33,7 @@ export const exportToExcalidrawPlus = async (
|
|||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
name: string,
|
name: string,
|
||||||
) => {
|
) => {
|
||||||
const firebase = await loadFirebaseStorage();
|
const storage = await loadFirebaseStorage();
|
||||||
|
|
||||||
const id = `${nanoid(12)}`;
|
const id = `${nanoid(12)}`;
|
||||||
|
|
||||||
@@ -49,15 +50,13 @@ export const exportToExcalidrawPlus = async (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await firebase
|
const storageRef = ref(storage, `/migrations/scenes/${id}`);
|
||||||
.storage()
|
await uploadBytes(storageRef, blob, {
|
||||||
.ref(`/migrations/scenes/${id}`)
|
customMetadata: {
|
||||||
.put(blob, {
|
data: JSON.stringify({ version: 2, name }),
|
||||||
customMetadata: {
|
created: Date.now().toString(),
|
||||||
data: JSON.stringify({ version: 2, name }),
|
},
|
||||||
created: Date.now().toString(),
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const filesMap = new Map<FileId, BinaryFileData>();
|
const filesMap = new Map<FileId, BinaryFileData>();
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
|
|||||||
@@ -22,9 +22,17 @@ import {
|
|||||||
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
||||||
import type { SyncableExcalidrawElement } from ".";
|
import type { SyncableExcalidrawElement } from ".";
|
||||||
import { getSyncableElements } from ".";
|
import { getSyncableElements } from ".";
|
||||||
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
|
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
|
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
|
||||||
|
import { initializeApp } from "firebase/app";
|
||||||
|
import {
|
||||||
|
getFirestore,
|
||||||
|
doc,
|
||||||
|
getDoc,
|
||||||
|
runTransaction,
|
||||||
|
Bytes,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import { getStorage, ref, uploadBytes } from "firebase/storage";
|
||||||
|
|
||||||
// private
|
// private
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -41,80 +49,42 @@ try {
|
|||||||
FIREBASE_CONFIG = {};
|
FIREBASE_CONFIG = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
let firebasePromise: Promise<typeof import("firebase/app").default> | null =
|
let firebaseApp: ReturnType<typeof initializeApp> | null = null;
|
||||||
null;
|
let firestore: ReturnType<typeof getFirestore> | null = null;
|
||||||
let firestorePromise: Promise<any> | null | true = null;
|
let firebaseStorage: ReturnType<typeof getStorage> | null = null;
|
||||||
let firebaseStoragePromise: Promise<any> | null | true = null;
|
|
||||||
|
|
||||||
let isFirebaseInitialized = false;
|
const _initializeFirebase = () => {
|
||||||
|
if (!firebaseApp) {
|
||||||
const _loadFirebase = async () => {
|
firebaseApp = initializeApp(FIREBASE_CONFIG);
|
||||||
const firebase = (
|
|
||||||
await import(/* webpackChunkName: "firebase" */ "firebase/app")
|
|
||||||
).default;
|
|
||||||
|
|
||||||
if (!isFirebaseInitialized) {
|
|
||||||
try {
|
|
||||||
firebase.initializeApp(FIREBASE_CONFIG);
|
|
||||||
} catch (error: any) {
|
|
||||||
// trying initialize again throws. Usually this is harmless, and happens
|
|
||||||
// mainly in dev (HMR)
|
|
||||||
if (error.code === "app/duplicate-app") {
|
|
||||||
console.warn(error.name, error.code);
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isFirebaseInitialized = true;
|
|
||||||
}
|
}
|
||||||
|
return firebaseApp;
|
||||||
return firebase;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const _getFirebase = async (): Promise<
|
const _getFirestore = () => {
|
||||||
typeof import("firebase/app").default
|
if (!firestore) {
|
||||||
> => {
|
firestore = getFirestore(_initializeFirebase());
|
||||||
if (!firebasePromise) {
|
|
||||||
firebasePromise = _loadFirebase();
|
|
||||||
}
|
}
|
||||||
return firebasePromise;
|
return firestore;
|
||||||
|
};
|
||||||
|
|
||||||
|
const _getStorage = () => {
|
||||||
|
if (!firebaseStorage) {
|
||||||
|
firebaseStorage = getStorage(_initializeFirebase());
|
||||||
|
}
|
||||||
|
return firebaseStorage;
|
||||||
};
|
};
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
const loadFirestore = async () => {
|
|
||||||
const firebase = await _getFirebase();
|
|
||||||
if (!firestorePromise) {
|
|
||||||
firestorePromise = import(
|
|
||||||
/* webpackChunkName: "firestore" */ "firebase/firestore"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (firestorePromise !== true) {
|
|
||||||
await firestorePromise;
|
|
||||||
firestorePromise = true;
|
|
||||||
}
|
|
||||||
return firebase;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const loadFirebaseStorage = async () => {
|
export const loadFirebaseStorage = async () => {
|
||||||
const firebase = await _getFirebase();
|
return _getStorage();
|
||||||
if (!firebaseStoragePromise) {
|
|
||||||
firebaseStoragePromise = import(
|
|
||||||
/* webpackChunkName: "storage" */ "firebase/storage"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (firebaseStoragePromise !== true) {
|
|
||||||
await firebaseStoragePromise;
|
|
||||||
firebaseStoragePromise = true;
|
|
||||||
}
|
|
||||||
return firebase;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FirebaseStoredScene {
|
type FirebaseStoredScene = {
|
||||||
sceneVersion: number;
|
sceneVersion: number;
|
||||||
iv: firebase.default.firestore.Blob;
|
iv: Bytes;
|
||||||
ciphertext: firebase.default.firestore.Blob;
|
ciphertext: Bytes;
|
||||||
}
|
};
|
||||||
|
|
||||||
const encryptElements = async (
|
const encryptElements = async (
|
||||||
key: string,
|
key: string,
|
||||||
@@ -175,7 +145,7 @@ export const saveFilesToFirebase = async ({
|
|||||||
prefix: string;
|
prefix: string;
|
||||||
files: { id: FileId; buffer: Uint8Array }[];
|
files: { id: FileId; buffer: Uint8Array }[];
|
||||||
}) => {
|
}) => {
|
||||||
const firebase = await loadFirebaseStorage();
|
const storage = await loadFirebaseStorage();
|
||||||
|
|
||||||
const erroredFiles: FileId[] = [];
|
const erroredFiles: FileId[] = [];
|
||||||
const savedFiles: FileId[] = [];
|
const savedFiles: FileId[] = [];
|
||||||
@@ -183,17 +153,10 @@ export const saveFilesToFirebase = async ({
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
files.map(async ({ id, buffer }) => {
|
files.map(async ({ id, buffer }) => {
|
||||||
try {
|
try {
|
||||||
await firebase
|
const storageRef = ref(storage, `${prefix}/${id}`);
|
||||||
.storage()
|
await uploadBytes(storageRef, buffer, {
|
||||||
.ref(`${prefix}/${id}`)
|
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
|
||||||
.put(
|
});
|
||||||
new Blob([buffer], {
|
|
||||||
type: MIME_TYPES.binary,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
savedFiles.push(id);
|
savedFiles.push(id);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
erroredFiles.push(id);
|
erroredFiles.push(id);
|
||||||
@@ -205,7 +168,6 @@ export const saveFilesToFirebase = async ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createFirebaseSceneDocument = async (
|
const createFirebaseSceneDocument = async (
|
||||||
firebase: ResolutionType<typeof loadFirestore>,
|
|
||||||
elements: readonly SyncableExcalidrawElement[],
|
elements: readonly SyncableExcalidrawElement[],
|
||||||
roomKey: string,
|
roomKey: string,
|
||||||
) => {
|
) => {
|
||||||
@@ -213,10 +175,8 @@ const createFirebaseSceneDocument = async (
|
|||||||
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
||||||
return {
|
return {
|
||||||
sceneVersion,
|
sceneVersion,
|
||||||
ciphertext: firebase.firestore.Blob.fromUint8Array(
|
ciphertext: Bytes.fromUint8Array(new Uint8Array(ciphertext)),
|
||||||
new Uint8Array(ciphertext),
|
iv: Bytes.fromUint8Array(iv),
|
||||||
),
|
|
||||||
iv: firebase.firestore.Blob.fromUint8Array(iv),
|
|
||||||
} as FirebaseStoredScene;
|
} as FirebaseStoredScene;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -236,20 +196,14 @@ export const saveToFirebase = async (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firebase = await loadFirestore();
|
const firestore = _getFirestore();
|
||||||
const firestore = firebase.firestore();
|
const docRef = doc(firestore, "scenes", roomId);
|
||||||
|
|
||||||
const docRef = firestore.collection("scenes").doc(roomId);
|
const storedScene = await runTransaction(firestore, async (transaction) => {
|
||||||
|
|
||||||
const storedScene = await firestore.runTransaction(async (transaction) => {
|
|
||||||
const snapshot = await transaction.get(docRef);
|
const snapshot = await transaction.get(docRef);
|
||||||
|
|
||||||
if (!snapshot.exists) {
|
if (!snapshot.exists()) {
|
||||||
const storedScene = await createFirebaseSceneDocument(
|
const storedScene = await createFirebaseSceneDocument(elements, roomKey);
|
||||||
firebase,
|
|
||||||
elements,
|
|
||||||
roomKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
transaction.set(docRef, storedScene);
|
transaction.set(docRef, storedScene);
|
||||||
|
|
||||||
@@ -269,7 +223,6 @@ export const saveToFirebase = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const storedScene = await createFirebaseSceneDocument(
|
const storedScene = await createFirebaseSceneDocument(
|
||||||
firebase,
|
|
||||||
reconciledElements,
|
reconciledElements,
|
||||||
roomKey,
|
roomKey,
|
||||||
);
|
);
|
||||||
@@ -294,15 +247,13 @@ export const loadFromFirebase = async (
|
|||||||
roomKey: string,
|
roomKey: string,
|
||||||
socket: Socket | null,
|
socket: Socket | null,
|
||||||
): Promise<readonly SyncableExcalidrawElement[] | null> => {
|
): Promise<readonly SyncableExcalidrawElement[] | null> => {
|
||||||
const firebase = await loadFirestore();
|
const firestore = _getFirestore();
|
||||||
const db = firebase.firestore();
|
const docRef = doc(firestore, "scenes", roomId);
|
||||||
|
const docSnap = await getDoc(docRef);
|
||||||
const docRef = db.collection("scenes").doc(roomId);
|
if (!docSnap.exists()) {
|
||||||
const doc = await docRef.get();
|
|
||||||
if (!doc.exists) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const storedScene = doc.data() as FirebaseStoredScene;
|
const storedScene = docSnap.data() as FirebaseStoredScene;
|
||||||
const elements = getSyncableElements(
|
const elements = getSyncableElements(
|
||||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,9 +27,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@excalidraw/random-username": "1.0.0",
|
"@excalidraw/random-username": "1.0.0",
|
||||||
"@sentry/browser": "6.2.5",
|
"@sentry/browser": "9.0.1",
|
||||||
"@sentry/integrations": "6.2.5",
|
"callsites": "4.2.0",
|
||||||
"firebase": "8.3.3",
|
"firebase": "11.3.1",
|
||||||
"i18next-browser-languagedetector": "6.1.4",
|
"i18next-browser-languagedetector": "6.1.4",
|
||||||
"idb-keyval": "6.0.3",
|
"idb-keyval": "6.0.3",
|
||||||
"jotai": "2.11.0",
|
"jotai": "2.11.0",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import * as Sentry from "@sentry/browser";
|
import * as Sentry from "@sentry/browser";
|
||||||
import * as SentryIntegrations from "@sentry/integrations";
|
import callsites from "callsites";
|
||||||
|
|
||||||
const SentryEnvHostnameMap: { [key: string]: string } = {
|
const SentryEnvHostnameMap: { [key: string]: string } = {
|
||||||
"excalidraw.com": "production",
|
"excalidraw.com": "production",
|
||||||
|
"staging.excalidraw.com": "staging",
|
||||||
"vercel.app": "staging",
|
"vercel.app": "staging",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,9 +24,13 @@ Sentry.init({
|
|||||||
release: import.meta.env.VITE_APP_GIT_SHA,
|
release: import.meta.env.VITE_APP_GIT_SHA,
|
||||||
ignoreErrors: [
|
ignoreErrors: [
|
||||||
"undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
|
"undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
|
||||||
|
"InvalidStateError: Failed to execute 'transaction' on 'IDBDatabase': The database connection is closing.", // Not much we can do about the IndexedDB closing error
|
||||||
|
/(Failed to fetch|(fetch|loading) dynamically imported module)/i, // This is happening when a service worker tries to load an old asset
|
||||||
|
/QuotaExceededError: (The quota has been exceeded|.*setItem.*Storage)/i, // localStorage quota exceeded
|
||||||
|
"Internal error opening backing store for indexedDB.open", // Private mode and disabled indexedDB
|
||||||
],
|
],
|
||||||
integrations: [
|
integrations: [
|
||||||
new SentryIntegrations.CaptureConsole({
|
Sentry.captureConsoleIntegration({
|
||||||
levels: ["error"],
|
levels: ["error"],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -33,6 +38,44 @@ Sentry.init({
|
|||||||
if (event.request?.url) {
|
if (event.request?.url) {
|
||||||
event.request.url = event.request.url.replace(/#.*$/, "");
|
event.request.url = event.request.url.replace(/#.*$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!event.exception) {
|
||||||
|
event.exception = {
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
type: "ConsoleError",
|
||||||
|
value: event.message ?? "Unknown error",
|
||||||
|
stacktrace: {
|
||||||
|
frames: callsites()
|
||||||
|
.slice(1)
|
||||||
|
.filter(
|
||||||
|
(frame) =>
|
||||||
|
frame.getFileName() &&
|
||||||
|
!frame.getFileName()?.includes("@sentry_browser.js"),
|
||||||
|
)
|
||||||
|
.map((frame) => ({
|
||||||
|
filename: frame.getFileName() ?? undefined,
|
||||||
|
function: frame.getFunctionName() ?? undefined,
|
||||||
|
in_app: !(
|
||||||
|
frame.getFileName()?.includes("node_modules") ?? false
|
||||||
|
),
|
||||||
|
lineno: frame.getLineNumber() ?? undefined,
|
||||||
|
colno: frame.getColumnNumber() ?? undefined,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
mechanism: {
|
||||||
|
type: "instrument",
|
||||||
|
handled: true,
|
||||||
|
data: {
|
||||||
|
function: "console.error",
|
||||||
|
handler: "Sentry.beforeSend",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return event;
|
return event;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
computeBoundTextPosition,
|
computeBoundTextPosition,
|
||||||
computeContainerDimensionForBoundText,
|
computeContainerDimensionForBoundText,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
measureText,
|
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +34,7 @@ import { arrayToMap, getFontString } from "../utils";
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { syncMovedIndices } from "../fractionalIndex";
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
import { measureText } from "../element/textMeasurements";
|
||||||
|
|
||||||
export const actionUnbindText = register({
|
export const actionUnbindText = register({
|
||||||
name: "unbindText",
|
name: "unbindText",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { isTextElement } from "../element";
|
import { isTextElement } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { measureText } from "../element/textElement";
|
import { measureText } from "../element/textMeasurements";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import type { AppClassProperties } from "../types";
|
import type { AppClassProperties } from "../types";
|
||||||
|
|||||||
@@ -331,17 +331,10 @@ import type { FileSystemHandle } from "../data/filesystem";
|
|||||||
import { fileOpen } from "../data/filesystem";
|
import { fileOpen } from "../data/filesystem";
|
||||||
import {
|
import {
|
||||||
bindTextToShapeAfterDuplication,
|
bindTextToShapeAfterDuplication,
|
||||||
getApproxMinLineHeight,
|
|
||||||
getApproxMinLineWidth,
|
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getContainerCenter,
|
getContainerCenter,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
getLineHeightInPx,
|
|
||||||
getMinTextElementWidth,
|
|
||||||
isMeasureTextSupported,
|
|
||||||
isValidTextContainer,
|
isValidTextContainer,
|
||||||
measureText,
|
|
||||||
normalizeText,
|
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import {
|
import {
|
||||||
showHyperlinkTooltip,
|
showHyperlinkTooltip,
|
||||||
@@ -465,6 +458,15 @@ import { cropElement } from "../element/cropElement";
|
|||||||
import { wrapText } from "../element/textWrapping";
|
import { wrapText } from "../element/textWrapping";
|
||||||
import { actionCopyElementLink } from "../actions/actionElementLink";
|
import { actionCopyElementLink } from "../actions/actionElementLink";
|
||||||
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
|
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
|
||||||
|
import {
|
||||||
|
isMeasureTextSupported,
|
||||||
|
normalizeText,
|
||||||
|
measureText,
|
||||||
|
getLineHeightInPx,
|
||||||
|
getApproxMinLineWidth,
|
||||||
|
getApproxMinLineHeight,
|
||||||
|
getMinTextElementWidth,
|
||||||
|
} from "../element/textMeasurements";
|
||||||
|
|
||||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||||
@@ -601,7 +603,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
private elementsPendingErasure: ElementsPendingErasure = new Set();
|
private elementsPendingErasure: ElementsPendingErasure = new Set();
|
||||||
|
|
||||||
public flowChartCreator: FlowChartCreator = new FlowChartCreator();
|
public flowChartCreator: FlowChartCreator = new FlowChartCreator();
|
||||||
private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator();
|
private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator(this);
|
||||||
|
|
||||||
hitLinkElement?: NonDeletedExcalidrawElement;
|
hitLinkElement?: NonDeletedExcalidrawElement;
|
||||||
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
|
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
|
||||||
@@ -4141,51 +4143,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (selectedElements.length === 1 && arrowKeyPressed) {
|
if (selectedElements.length === 1 && arrowKeyPressed) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const nextId = this.flowChartNavigator.exploreByDirection(
|
return this.flowChartNavigator.exploreByDirection(
|
||||||
selectedElements[0],
|
selectedElements[0],
|
||||||
this.scene.getNonDeletedElementsMap(),
|
|
||||||
getLinkDirectionFromKey(event.key),
|
getLinkDirectionFromKey(event.key),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (nextId) {
|
|
||||||
this.setState((prevState) => ({
|
|
||||||
selectedElementIds: makeNextSelectedElementIds(
|
|
||||||
{
|
|
||||||
[nextId]: true,
|
|
||||||
},
|
|
||||||
prevState,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const nextNode = this.scene
|
|
||||||
.getNonDeletedElementsMap()
|
|
||||||
.get(nextId);
|
|
||||||
|
|
||||||
if (
|
|
||||||
nextNode &&
|
|
||||||
!isElementCompletelyInViewport(
|
|
||||||
[nextNode],
|
|
||||||
this.canvas.width / window.devicePixelRatio,
|
|
||||||
this.canvas.height / window.devicePixelRatio,
|
|
||||||
{
|
|
||||||
offsetLeft: this.state.offsetLeft,
|
|
||||||
offsetTop: this.state.offsetTop,
|
|
||||||
scrollX: this.state.scrollX,
|
|
||||||
scrollY: this.state.scrollY,
|
|
||||||
zoom: this.state.zoom,
|
|
||||||
},
|
|
||||||
this.scene.getNonDeletedElementsMap(),
|
|
||||||
this.getEditorUIOffsets(),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
this.scrollToContent(nextNode, {
|
|
||||||
animate: true,
|
|
||||||
duration: 300,
|
|
||||||
canvasOffsets: this.getEditorUIOffsets(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,12 +145,14 @@ export const MobileMenu = ({
|
|||||||
<div className="App-toolbar-content">
|
<div className="App-toolbar-content">
|
||||||
<MainMenuTunnel.Out />
|
<MainMenuTunnel.Out />
|
||||||
{actionManager.renderAction("toggleEditMenu")}
|
{actionManager.renderAction("toggleEditMenu")}
|
||||||
{actionManager.renderAction("undo")}
|
|
||||||
{actionManager.renderAction("redo")}
|
|
||||||
{actionManager.renderAction(
|
{actionManager.renderAction(
|
||||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||||
)}
|
)}
|
||||||
{actionManager.renderAction("deleteSelectedElements")}
|
{actionManager.renderAction("deleteSelectedElements")}
|
||||||
|
<div>
|
||||||
|
{actionManager.renderAction("undo")}
|
||||||
|
{actionManager.renderAction("redo")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { debounce } from "lodash";
|
|||||||
import type { AppClassProperties } from "../types";
|
import type { AppClassProperties } from "../types";
|
||||||
import { isTextElement, newTextElement } from "../element";
|
import { isTextElement, newTextElement } from "../element";
|
||||||
import type { ExcalidrawTextElement } from "../element/types";
|
import type { ExcalidrawTextElement } from "../element/types";
|
||||||
import { measureText } from "../element/textElement";
|
|
||||||
import { addEventListener, getFontString } from "../utils";
|
import { addEventListener, getFontString } from "../utils";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -20,6 +19,7 @@ import { useStable } from "../hooks/useStable";
|
|||||||
|
|
||||||
import "./SearchMenu.scss";
|
import "./SearchMenu.scss";
|
||||||
import { round } from "../../math";
|
import { round } from "../../math";
|
||||||
|
import { measureText } from "../element/textMeasurements";
|
||||||
|
|
||||||
const searchQueryAtom = atom<string>("");
|
const searchQueryAtom = atom<string>("");
|
||||||
export const searchItemInFocusAtom = atom<number | null>(null);
|
export const searchItemInFocusAtom = atom<number | null>(null);
|
||||||
@@ -607,7 +607,6 @@ const getMatchedLines = (
|
|||||||
textToStart,
|
textToStart,
|
||||||
getFontString(textElement),
|
getFontString(textElement),
|
||||||
textElement.lineHeight,
|
textElement.lineHeight,
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// measureText returns a non-zero width for the empty string
|
// measureText returns a non-zero width for the empty string
|
||||||
@@ -621,7 +620,6 @@ const getMatchedLines = (
|
|||||||
lineIndexRange.line,
|
lineIndexRange.line,
|
||||||
getFontString(textElement),
|
getFontString(textElement),
|
||||||
textElement.lineHeight,
|
textElement.lineHeight,
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const spaceToStart =
|
const spaceToStart =
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import { bumpVersion } from "../element/mutateElement";
|
|||||||
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import type { MarkOptional, Mutable } from "../utility-types";
|
import type { MarkOptional, Mutable } from "../utility-types";
|
||||||
import { detectLineHeight, getContainerElement } from "../element/textElement";
|
import { getContainerElement } from "../element/textElement";
|
||||||
import { normalizeLink } from "./url";
|
import { normalizeLink } from "./url";
|
||||||
import { syncInvalidIndices } from "../fractionalIndex";
|
import { syncInvalidIndices } from "../fractionalIndex";
|
||||||
import { getSizeFromPoints } from "../points";
|
import { getSizeFromPoints } from "../points";
|
||||||
@@ -59,6 +59,7 @@ import {
|
|||||||
} from "../scene";
|
} from "../scene";
|
||||||
import type { LocalPoint, Radians } from "../../math";
|
import type { LocalPoint, Radians } from "../../math";
|
||||||
import { isFiniteNumber, pointFrom } from "../../math";
|
import { isFiniteNumber, pointFrom } from "../../math";
|
||||||
|
import { detectLineHeight } from "../element/textMeasurements";
|
||||||
|
|
||||||
type RestoredAppState = Omit<
|
type RestoredAppState = Omit<
|
||||||
AppState,
|
AppState,
|
||||||
@@ -205,6 +206,24 @@ const restoreElementWithProperties = <
|
|||||||
"customData" in extra ? extra.customData : element.customData;
|
"customData" in extra ? extra.customData : element.customData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE (mtolmacs): This is a temporary check to detect extremely large
|
||||||
|
// element position or sizing
|
||||||
|
if (
|
||||||
|
element.x < -1e6 ||
|
||||||
|
element.x > 1e6 ||
|
||||||
|
element.y < -1e6 ||
|
||||||
|
element.y > 1e6 ||
|
||||||
|
element.width < -1e6 ||
|
||||||
|
element.width > 1e6 ||
|
||||||
|
element.height < -1e6 ||
|
||||||
|
element.height > 1e6
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
"Restore element with properties size or position is too large",
|
||||||
|
{ element },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// spread the original element properties to not lose unknown ones
|
// spread the original element properties to not lose unknown ones
|
||||||
// for forward-compatibility
|
// for forward-compatibility
|
||||||
@@ -219,6 +238,21 @@ const restoreElementWithProperties = <
|
|||||||
const restoreElement = (
|
const restoreElement = (
|
||||||
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
): typeof element | null => {
|
): typeof element | null => {
|
||||||
|
// NOTE (mtolmacs): This is a temporary check to detect extremely large
|
||||||
|
// element position or sizing
|
||||||
|
if (
|
||||||
|
element.x < -1e6 ||
|
||||||
|
element.x > 1e6 ||
|
||||||
|
element.y < -1e6 ||
|
||||||
|
element.y > 1e6 ||
|
||||||
|
element.width < -1e6 ||
|
||||||
|
element.width > 1e6 ||
|
||||||
|
element.height < -1e6 ||
|
||||||
|
element.height > 1e6
|
||||||
|
) {
|
||||||
|
console.error("Restore element size or position is too large", { element });
|
||||||
|
}
|
||||||
|
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "text":
|
case "text":
|
||||||
let fontSize = element.fontSize;
|
let fontSize = element.fontSize;
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
newMagicFrameElement,
|
newMagicFrameElement,
|
||||||
newTextElement,
|
newTextElement,
|
||||||
} from "../element/newElement";
|
} from "../element/newElement";
|
||||||
import { measureText, normalizeText } from "../element/textElement";
|
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
@@ -55,6 +54,7 @@ import { syncInvalidIndices } from "../fractionalIndex";
|
|||||||
import { getLineHeight } from "../fonts";
|
import { getLineHeight } from "../fonts";
|
||||||
import { isArrowElement } from "../element/typeChecks";
|
import { isArrowElement } from "../element/typeChecks";
|
||||||
import { pointFrom, type LocalPoint } from "../../math";
|
import { pointFrom, type LocalPoint } from "../../math";
|
||||||
|
import { measureText, normalizeText } from "../element/textMeasurements";
|
||||||
|
|
||||||
export type ValidLinearElement = {
|
export type ValidLinearElement = {
|
||||||
type: "arrow" | "line";
|
type: "arrow" | "line";
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
NullableGridSize,
|
NullableGridSize,
|
||||||
PointerDownState,
|
PointerDownState,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
|
import { getBoundTextElement } from "./textElement";
|
||||||
import type Scene from "../scene/Scene";
|
import type Scene from "../scene/Scene";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { getFontString } from "../utils";
|
import { getFontString } from "../utils";
|
||||||
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
|
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
|
||||||
import { getGridPoint } from "../snapping";
|
import { getGridPoint } from "../snapping";
|
||||||
|
import { getMinTextElementWidth } from "./textMeasurements";
|
||||||
|
|
||||||
export const dragSelectedElements = (
|
export const dragSelectedElements = (
|
||||||
pointerDownState: PointerDownState,
|
pointerDownState: PointerDownState,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
clamp,
|
||||||
pointDistance,
|
pointDistance,
|
||||||
pointFrom,
|
pointFrom,
|
||||||
pointScaleFromOrigin,
|
pointScaleFromOrigin,
|
||||||
@@ -104,7 +105,7 @@ const handleSegmentRenormalization = (
|
|||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
) => {
|
) => {
|
||||||
const nextFixedSegments: FixedSegment[] | null = arrow.fixedSegments
|
const nextFixedSegments: FixedSegment[] | null = arrow.fixedSegments
|
||||||
? structuredClone(arrow.fixedSegments)
|
? arrow.fixedSegments.slice()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (nextFixedSegments) {
|
if (nextFixedSegments) {
|
||||||
@@ -270,7 +271,7 @@ const handleSegmentRenormalization = (
|
|||||||
|
|
||||||
const handleSegmentRelease = (
|
const handleSegmentRelease = (
|
||||||
arrow: ExcalidrawElbowArrowElement,
|
arrow: ExcalidrawElbowArrowElement,
|
||||||
fixedSegments: FixedSegment[],
|
fixedSegments: readonly FixedSegment[],
|
||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
) => {
|
) => {
|
||||||
const newFixedSegmentIndices = fixedSegments.map((segment) => segment.index);
|
const newFixedSegmentIndices = fixedSegments.map((segment) => segment.index);
|
||||||
@@ -444,7 +445,7 @@ const handleSegmentRelease = (
|
|||||||
*/
|
*/
|
||||||
const handleSegmentMove = (
|
const handleSegmentMove = (
|
||||||
arrow: ExcalidrawElbowArrowElement,
|
arrow: ExcalidrawElbowArrowElement,
|
||||||
fixedSegments: FixedSegment[],
|
fixedSegments: readonly FixedSegment[],
|
||||||
startHeading: Heading,
|
startHeading: Heading,
|
||||||
endHeading: Heading,
|
endHeading: Heading,
|
||||||
hoveredStartElement: ExcalidrawBindableElement | null,
|
hoveredStartElement: ExcalidrawBindableElement | null,
|
||||||
@@ -686,7 +687,7 @@ const handleSegmentMove = (
|
|||||||
const handleEndpointDrag = (
|
const handleEndpointDrag = (
|
||||||
arrow: ExcalidrawElbowArrowElement,
|
arrow: ExcalidrawElbowArrowElement,
|
||||||
updatedPoints: readonly LocalPoint[],
|
updatedPoints: readonly LocalPoint[],
|
||||||
fixedSegments: FixedSegment[],
|
fixedSegments: readonly FixedSegment[],
|
||||||
startHeading: Heading,
|
startHeading: Heading,
|
||||||
endHeading: Heading,
|
endHeading: Heading,
|
||||||
startGlobalPoint: GlobalPoint,
|
startGlobalPoint: GlobalPoint,
|
||||||
@@ -863,6 +864,8 @@ const handleEndpointDrag = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_POS = 1e6;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
@@ -883,6 +886,50 @@ export const updateElbowArrowPoints = (
|
|||||||
return { points: updates.points ?? arrow.points };
|
return { points: updates.points ?? arrow.points };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow
|
||||||
|
// arrow size is valid. This check will be removed once the issue is identified
|
||||||
|
if (
|
||||||
|
arrow.x < -MAX_POS ||
|
||||||
|
arrow.x > MAX_POS ||
|
||||||
|
arrow.y < -MAX_POS ||
|
||||||
|
arrow.y > MAX_POS ||
|
||||||
|
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) <
|
||||||
|
-MAX_POS ||
|
||||||
|
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) >
|
||||||
|
MAX_POS ||
|
||||||
|
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) <
|
||||||
|
-MAX_POS ||
|
||||||
|
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) >
|
||||||
|
MAX_POS ||
|
||||||
|
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) <
|
||||||
|
-MAX_POS ||
|
||||||
|
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) >
|
||||||
|
MAX_POS ||
|
||||||
|
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) <
|
||||||
|
-MAX_POS ||
|
||||||
|
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
"Elbow arrow (or update) is outside reasonable bounds (> 1e6)",
|
||||||
|
{
|
||||||
|
arrow,
|
||||||
|
updates,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// @ts-ignore See above note
|
||||||
|
arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS);
|
||||||
|
// @ts-ignore See above note
|
||||||
|
arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS);
|
||||||
|
if (updates.points) {
|
||||||
|
updates.points = updates.points.map(([x, y]) =>
|
||||||
|
pointFrom<LocalPoint>(
|
||||||
|
clamp(x, -MAX_POS, MAX_POS),
|
||||||
|
clamp(y, -MAX_POS, MAX_POS),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!import.meta.env.PROD) {
|
if (!import.meta.env.PROD) {
|
||||||
invariant(
|
invariant(
|
||||||
!updates.points || updates.points.length >= 2,
|
!updates.points || updates.points.length >= 2,
|
||||||
@@ -944,8 +991,8 @@ export const updateElbowArrowPoints = (
|
|||||||
? updates.points![1]
|
? updates.points![1]
|
||||||
: p,
|
: p,
|
||||||
)
|
)
|
||||||
: structuredClone(updates.points)
|
: updates.points.slice()
|
||||||
: structuredClone(arrow.points);
|
: arrow.points.slice();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
startHeading,
|
startHeading,
|
||||||
@@ -1965,7 +2012,7 @@ const getBindableElementForId = (
|
|||||||
|
|
||||||
const normalizeArrowElementUpdate = (
|
const normalizeArrowElementUpdate = (
|
||||||
global: GlobalPoint[],
|
global: GlobalPoint[],
|
||||||
nextFixedSegments: FixedSegment[] | null,
|
nextFixedSegments: readonly FixedSegment[] | null,
|
||||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||||
): {
|
): {
|
||||||
@@ -1974,24 +2021,51 @@ const normalizeArrowElementUpdate = (
|
|||||||
y: number;
|
y: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
fixedSegments: FixedSegment[] | null;
|
fixedSegments: readonly FixedSegment[] | null;
|
||||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
||||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
||||||
} => {
|
} => {
|
||||||
const offsetX = global[0][0];
|
const offsetX = global[0][0];
|
||||||
const offsetY = global[0][1];
|
const offsetY = global[0][1];
|
||||||
|
|
||||||
const points = global.map((p) =>
|
let points = global.map((p) =>
|
||||||
pointTranslate<GlobalPoint, LocalPoint>(
|
pointTranslate<GlobalPoint, LocalPoint>(
|
||||||
p,
|
p,
|
||||||
vectorScale(vectorFromPoint(global[0]), -1),
|
vectorScale(vectorFromPoint(global[0]), -1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// NOTE (mtolmacs): This is a temporary check to see if the normalization
|
||||||
|
// creates an overly large arrow. This should be removed once we have an answer.
|
||||||
|
if (
|
||||||
|
offsetX < -MAX_POS ||
|
||||||
|
offsetX > MAX_POS ||
|
||||||
|
offsetY < -MAX_POS ||
|
||||||
|
offsetY > MAX_POS ||
|
||||||
|
offsetX + points[points.length - 1][0] < -MAX_POS ||
|
||||||
|
offsetY + points[points.length - 1][0] > MAX_POS ||
|
||||||
|
offsetX + points[points.length - 1][1] < -MAX_POS ||
|
||||||
|
offsetY + points[points.length - 1][1] > MAX_POS
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
"Elbow arrow normalization is outside reasonable bounds (> 1e6)",
|
||||||
|
{
|
||||||
|
x: offsetX,
|
||||||
|
y: offsetY,
|
||||||
|
points,
|
||||||
|
...getSizeFromPoints(points),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
points = points.map(([x, y]) =>
|
||||||
|
pointFrom<LocalPoint>(clamp(x, -1e6, 1e6), clamp(y, -1e6, 1e6)),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
points,
|
points,
|
||||||
x: offsetX,
|
x: clamp(offsetX, -1e6, 1e6),
|
||||||
y: offsetY,
|
y: clamp(offsetY, -1e6, 1e6),
|
||||||
fixedSegments:
|
fixedSegments:
|
||||||
(nextFixedSegments?.length ?? 0) > 0 ? nextFixedSegments : null,
|
(nextFixedSegments?.length ?? 0) > 0 ? nextFixedSegments : null,
|
||||||
...getSizeFromPoints(points),
|
...getSizeFromPoints(points),
|
||||||
|
|||||||
@@ -310,95 +310,4 @@ describe("flow chart navigation", () => {
|
|||||||
Keyboard.keyUp(KEYS.ALT);
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("take the most obvious link when possible", () => {
|
|
||||||
/**
|
|
||||||
* ▨ → ▨ ▨ → ▨
|
|
||||||
* ↓ ↑
|
|
||||||
* ▨ → ▨
|
|
||||||
*/
|
|
||||||
|
|
||||||
API.clearSelection();
|
|
||||||
const rectangle = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
width: 200,
|
|
||||||
height: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rectangle]);
|
|
||||||
API.setSelectedElements([rectangle]);
|
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
||||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
|
||||||
});
|
|
||||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
||||||
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
|
||||||
});
|
|
||||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
||||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
|
||||||
});
|
|
||||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
||||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
|
||||||
});
|
|
||||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
||||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
|
||||||
});
|
|
||||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
|
||||||
|
|
||||||
// last node should be the one that's selected
|
|
||||||
const rightMostNode = h.elements[h.elements.length - 2];
|
|
||||||
expect(rightMostNode.type).toBe("rectangle");
|
|
||||||
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
|
||||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
|
||||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
|
||||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
|
||||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
|
||||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
|
||||||
});
|
|
||||||
Keyboard.keyUp(KEYS.ALT);
|
|
||||||
|
|
||||||
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
|
||||||
|
|
||||||
// going any direction takes us to the predecessor as well
|
|
||||||
const predecessorToRightMostNode = h.elements[h.elements.length - 4];
|
|
||||||
expect(predecessorToRightMostNode.type).toBe("rectangle");
|
|
||||||
|
|
||||||
API.setSelectedElements([rightMostNode]);
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
|
||||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
|
||||||
});
|
|
||||||
Keyboard.keyUp(KEYS.ALT);
|
|
||||||
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
|
||||||
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
API.setSelectedElements([rightMostNode]);
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
|
||||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
|
||||||
});
|
|
||||||
Keyboard.keyUp(KEYS.ALT);
|
|
||||||
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
|
||||||
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
API.setSelectedElements([rightMostNode]);
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
|
||||||
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
|
||||||
});
|
|
||||||
Keyboard.keyUp(KEYS.ALT);
|
|
||||||
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
|
||||||
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ import { invariant, toBrandedType } from "../utils";
|
|||||||
import { pointFrom, type LocalPoint } from "../../math";
|
import { pointFrom, type LocalPoint } from "../../math";
|
||||||
import { aabbForElement } from "../shapes";
|
import { aabbForElement } from "../shapes";
|
||||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||||
|
import type App from "../components/App";
|
||||||
|
import { makeNextSelectedElementIds } from "../scene/selection";
|
||||||
|
import { isElementCompletelyInViewport } from "./sizeHelpers";
|
||||||
|
|
||||||
type LinkDirection = "up" | "right" | "down" | "left";
|
type LinkDirection = "up" | "right" | "down" | "left";
|
||||||
|
|
||||||
@@ -491,62 +494,54 @@ const createBindingArrow = (
|
|||||||
|
|
||||||
export class FlowChartNavigator {
|
export class FlowChartNavigator {
|
||||||
isExploring: boolean = false;
|
isExploring: boolean = false;
|
||||||
// nodes that are ONE link away (successor and predecessor both included)
|
|
||||||
private sameLevelNodes: ExcalidrawElement[] = [];
|
private app: App;
|
||||||
private sameLevelIndex: number = 0;
|
private siblingNodes: ExcalidrawElement[] = [];
|
||||||
// set it to the opposite of the defalut creation direction
|
private siblingIndex: number = 0;
|
||||||
private direction: LinkDirection | null = null;
|
private direction: LinkDirection | null = null;
|
||||||
// for speedier navigation
|
|
||||||
private visitedNodes: Set<ExcalidrawElement["id"]> = new Set();
|
constructor(app: App) {
|
||||||
|
this.app = app;
|
||||||
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.isExploring = false;
|
this.isExploring = false;
|
||||||
this.sameLevelNodes = [];
|
this.siblingNodes = [];
|
||||||
this.sameLevelIndex = 0;
|
this.siblingIndex = 0;
|
||||||
this.direction = null;
|
this.direction = null;
|
||||||
this.visitedNodes.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
exploreByDirection(
|
/**
|
||||||
element: ExcalidrawElement,
|
* Explore the flowchart by the given direction.
|
||||||
elementsMap: ElementsMap,
|
*
|
||||||
direction: LinkDirection,
|
* The exploration follows a (near) breadth-first approach: when there're multiple
|
||||||
): ExcalidrawElement["id"] | null {
|
* nodes at the same level, we allow the user to traverse through them.
|
||||||
|
*/
|
||||||
|
exploreByDirection(element: ExcalidrawElement, direction: LinkDirection) {
|
||||||
if (!isBindableElement(element)) {
|
if (!isBindableElement(element)) {
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const elementsMap = this.app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
// clear if going at a different direction
|
// clear if going at a different direction
|
||||||
if (direction !== this.direction) {
|
if (direction !== this.direction) {
|
||||||
this.clear();
|
this.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// add the current node to the visited
|
|
||||||
if (!this.visitedNodes.has(element.id)) {
|
|
||||||
this.visitedNodes.add(element.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CASE:
|
* if we're already exploring (holding the alt key)
|
||||||
* - already started exploring, AND
|
* and the direction is the same as the previous one
|
||||||
* - there are multiple nodes at the same level, AND
|
* and there're multiple nodes at the same level
|
||||||
* - still going at the same direction, AND
|
* then we should traverse through them before moving to the next level
|
||||||
*
|
|
||||||
* RESULT:
|
|
||||||
* - loop through nodes at the same level
|
|
||||||
*
|
|
||||||
* WHY:
|
|
||||||
* - provides user the capability to loop through nodes at the same level
|
|
||||||
*/
|
*/
|
||||||
if (
|
if (
|
||||||
this.isExploring &&
|
this.isExploring &&
|
||||||
direction === this.direction &&
|
direction === this.direction &&
|
||||||
this.sameLevelNodes.length > 1
|
this.siblingNodes.length > 1
|
||||||
) {
|
) {
|
||||||
this.sameLevelIndex =
|
this.siblingIndex = (this.siblingIndex + 1) % this.siblingNodes.length;
|
||||||
(this.sameLevelIndex + 1) % this.sameLevelNodes.length;
|
return this.goToNode(this.siblingNodes[this.siblingIndex].id);
|
||||||
|
|
||||||
return this.sameLevelNodes[this.sameLevelIndex].id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes = [
|
const nodes = [
|
||||||
@@ -554,70 +549,52 @@ export class FlowChartNavigator {
|
|||||||
...getPredecessors(element, elementsMap, direction),
|
...getPredecessors(element, elementsMap, direction),
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* CASE:
|
|
||||||
* - just started exploring at the given direction
|
|
||||||
*
|
|
||||||
* RESULT:
|
|
||||||
* - go to the first node in the given direction
|
|
||||||
*/
|
|
||||||
if (nodes.length > 0) {
|
if (nodes.length > 0) {
|
||||||
this.sameLevelIndex = 0;
|
this.siblingIndex = 0;
|
||||||
this.isExploring = true;
|
this.isExploring = true;
|
||||||
this.sameLevelNodes = nodes;
|
this.siblingNodes = nodes;
|
||||||
this.direction = direction;
|
this.direction = direction;
|
||||||
this.visitedNodes.add(nodes[0].id);
|
|
||||||
|
|
||||||
return nodes[0].id;
|
this.goToNode(nodes[0].id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* CASE:
|
|
||||||
* - (just started exploring or still going at the same direction) OR
|
|
||||||
* - there're no nodes at the given direction
|
|
||||||
*
|
|
||||||
* RESULT:
|
|
||||||
* - go to some other unvisited linked node
|
|
||||||
*
|
|
||||||
* WHY:
|
|
||||||
* - provide a speedier navigation from a given node to some predecessor
|
|
||||||
* without the user having to change arrow key
|
|
||||||
*/
|
|
||||||
if (direction === this.direction || !this.isExploring) {
|
|
||||||
if (!this.isExploring) {
|
|
||||||
// just started and no other nodes at the given direction
|
|
||||||
// so the current node is technically the first visited node
|
|
||||||
// (this is needed so that we don't get stuck between looping through )
|
|
||||||
this.visitedNodes.add(element.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherDirections: LinkDirection[] = [
|
|
||||||
"up",
|
|
||||||
"right",
|
|
||||||
"down",
|
|
||||||
"left",
|
|
||||||
].filter((dir): dir is LinkDirection => dir !== direction);
|
|
||||||
|
|
||||||
const otherLinkedNodes = otherDirections
|
|
||||||
.map((dir) => [
|
|
||||||
...getSuccessors(element, elementsMap, dir),
|
|
||||||
...getPredecessors(element, elementsMap, dir),
|
|
||||||
])
|
|
||||||
.flat()
|
|
||||||
.filter((linkedNode) => !this.visitedNodes.has(linkedNode.id));
|
|
||||||
|
|
||||||
for (const linkedNode of otherLinkedNodes) {
|
|
||||||
if (!this.visitedNodes.has(linkedNode.id)) {
|
|
||||||
this.visitedNodes.add(linkedNode.id);
|
|
||||||
this.isExploring = true;
|
|
||||||
this.direction = direction;
|
|
||||||
return linkedNode.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private goToNode = (nodeId: ExcalidrawElement["id"]) => {
|
||||||
|
this.app.setState((prevState) => ({
|
||||||
|
selectedElementIds: makeNextSelectedElementIds(
|
||||||
|
{
|
||||||
|
[nodeId]: true,
|
||||||
|
},
|
||||||
|
prevState,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const nextNode = this.app.scene.getNonDeletedElementsMap().get(nodeId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
nextNode &&
|
||||||
|
!isElementCompletelyInViewport(
|
||||||
|
[nextNode],
|
||||||
|
this.app.canvas.width / window.devicePixelRatio,
|
||||||
|
this.app.canvas.height / window.devicePixelRatio,
|
||||||
|
{
|
||||||
|
offsetLeft: this.app.state.offsetLeft,
|
||||||
|
offsetTop: this.app.state.offsetTop,
|
||||||
|
scrollX: this.app.state.scrollX,
|
||||||
|
scrollY: this.app.state.scrollY,
|
||||||
|
zoom: this.app.state.zoom,
|
||||||
|
},
|
||||||
|
this.app.scene.getNonDeletedElementsMap(),
|
||||||
|
this.app.getEditorUIOffsets(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.app.scrollToContent(nextNode, {
|
||||||
|
animate: true,
|
||||||
|
duration: 300,
|
||||||
|
canvasOffsets: this.app.getEditorUIOffsets(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FlowChartCreator {
|
export class FlowChartCreator {
|
||||||
|
|||||||
@@ -33,11 +33,7 @@ import { getNewGroupIdsForDuplication } from "../groups";
|
|||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { getElementAbsoluteCoords } from ".";
|
import { getElementAbsoluteCoords } from ".";
|
||||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||||
import {
|
import { getBoundTextMaxWidth } from "./textElement";
|
||||||
measureText,
|
|
||||||
normalizeText,
|
|
||||||
getBoundTextMaxWidth,
|
|
||||||
} from "./textElement";
|
|
||||||
import { wrapText } from "./textWrapping";
|
import { wrapText } from "./textWrapping";
|
||||||
import {
|
import {
|
||||||
DEFAULT_ELEMENT_PROPS,
|
DEFAULT_ELEMENT_PROPS,
|
||||||
@@ -51,6 +47,7 @@ import {
|
|||||||
import type { MarkOptional, Merge, Mutable } from "../utility-types";
|
import type { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||||
import { getLineHeight } from "../fonts";
|
import { getLineHeight } from "../fonts";
|
||||||
import type { Radians } from "../../math";
|
import type { Radians } from "../../math";
|
||||||
|
import { normalizeText, measureText } from "./textMeasurements";
|
||||||
|
|
||||||
export type ElementConstructorOpts = MarkOptional<
|
export type ElementConstructorOpts = MarkOptional<
|
||||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||||
@@ -102,6 +99,28 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||||||
...rest
|
...rest
|
||||||
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
||||||
) => {
|
) => {
|
||||||
|
// NOTE (mtolmacs): This is a temporary check to detect extremely large
|
||||||
|
// element position or sizing
|
||||||
|
if (
|
||||||
|
x < -1e6 ||
|
||||||
|
x > 1e6 ||
|
||||||
|
y < -1e6 ||
|
||||||
|
y > 1e6 ||
|
||||||
|
width < -1e6 ||
|
||||||
|
width > 1e6 ||
|
||||||
|
height < -1e6 ||
|
||||||
|
height > 1e6
|
||||||
|
) {
|
||||||
|
console.error("New element size or position is too large", {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
// @ts-ignore
|
||||||
|
points: rest.points,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// assign type to guard against excess properties
|
// assign type to guard against excess properties
|
||||||
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
|
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
|
||||||
id: rest.id || randomId(),
|
id: rest.id || randomId(),
|
||||||
|
|||||||
@@ -41,15 +41,11 @@ import type {
|
|||||||
import type { PointerDownState } from "../types";
|
import type { PointerDownState } from "../types";
|
||||||
import type Scene from "../scene/Scene";
|
import type Scene from "../scene/Scene";
|
||||||
import {
|
import {
|
||||||
getApproxMinLineWidth,
|
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getBoundTextElementId,
|
getBoundTextElementId,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
handleBindTextResize,
|
handleBindTextResize,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
getApproxMinLineHeight,
|
|
||||||
measureText,
|
|
||||||
getMinTextElementWidth,
|
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import { wrapText } from "./textWrapping";
|
import { wrapText } from "./textWrapping";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
@@ -64,6 +60,12 @@ import {
|
|||||||
type Radians,
|
type Radians,
|
||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
} from "../../math";
|
} from "../../math";
|
||||||
|
import {
|
||||||
|
getMinTextElementWidth,
|
||||||
|
measureText,
|
||||||
|
getApproxMinLineWidth,
|
||||||
|
getApproxMinLineHeight,
|
||||||
|
} from "./textMeasurements";
|
||||||
|
|
||||||
// Returns true when transform (resizing/rotation) happened
|
// Returns true when transform (resizing/rotation) happened
|
||||||
export const transformElements = (
|
export const transformElements = (
|
||||||
@@ -767,12 +769,32 @@ const getResizedOrigin = (
|
|||||||
y: y - (newHeight - prevHeight) / 2,
|
y: y - (newHeight - prevHeight) / 2,
|
||||||
};
|
};
|
||||||
case "east-side":
|
case "east-side":
|
||||||
|
// NOTE (mtolmacs): Reverting this for a short period to test if it is
|
||||||
|
// the cause of the megasized elbow arrows showing up.
|
||||||
|
if (
|
||||||
|
Math.abs(
|
||||||
|
y +
|
||||||
|
((prevWidth - newWidth) / 2) * Math.sin(angle) +
|
||||||
|
(prevHeight - newHeight) / 2,
|
||||||
|
) > 1e6
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
"getResizedOrigin() new calculation creates extremely large (> 1e6) y value where the old calculation resulted in",
|
||||||
|
{
|
||||||
|
result:
|
||||||
|
y +
|
||||||
|
(newHeight - prevHeight) / 2 +
|
||||||
|
((prevWidth - newWidth) / 2) * Math.sin(angle),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: x + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1),
|
x: x + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1),
|
||||||
y:
|
y:
|
||||||
y +
|
y +
|
||||||
((prevWidth - newWidth) / 2) * Math.sin(angle) +
|
(newHeight - prevHeight) / 2 +
|
||||||
(prevHeight - newHeight) / 2,
|
((prevWidth - newWidth) / 2) * Math.sin(angle),
|
||||||
};
|
};
|
||||||
case "west-side":
|
case "west-side":
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import {
|
|||||||
getContainerCoords,
|
getContainerCoords,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
getBoundTextMaxHeight,
|
getBoundTextMaxHeight,
|
||||||
detectLineHeight,
|
|
||||||
getLineHeightInPx,
|
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
|
import { detectLineHeight, getLineHeightInPx } from "./textMeasurements";
|
||||||
import type { ExcalidrawTextElementWithContainer } from "./types";
|
import type { ExcalidrawTextElementWithContainer } from "./types";
|
||||||
|
|
||||||
describe("Test measureText", () => {
|
describe("Test measureText", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils";
|
import { getFontString, arrayToMap } from "../utils";
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -6,7 +6,6 @@ import type {
|
|||||||
ExcalidrawTextContainer,
|
ExcalidrawTextContainer,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
FontString,
|
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
@@ -14,7 +13,6 @@ import {
|
|||||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
|
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
|
||||||
ARROW_LABEL_WIDTH_FRACTION,
|
ARROW_LABEL_WIDTH_FRACTION,
|
||||||
BOUND_TEXT_PADDING,
|
BOUND_TEXT_PADDING,
|
||||||
DEFAULT_FONT_FAMILY,
|
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
TEXT_ALIGN,
|
TEXT_ALIGN,
|
||||||
VERTICAL_ALIGN,
|
VERTICAL_ALIGN,
|
||||||
@@ -30,18 +28,7 @@ import {
|
|||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
} from "./containerCache";
|
} from "./containerCache";
|
||||||
import type { ExtractSetType } from "../utility-types";
|
import type { ExtractSetType } from "../utility-types";
|
||||||
|
import { measureText } from "./textMeasurements";
|
||||||
export const normalizeText = (text: string) => {
|
|
||||||
return (
|
|
||||||
normalizeEOL(text)
|
|
||||||
// replace tabs with spaces so they render and measure correctly
|
|
||||||
.replace(/\t/g, " ")
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const splitIntoLines = (text: string) => {
|
|
||||||
return normalizeText(text).split("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
export const redrawTextBoundingBox = (
|
export const redrawTextBoundingBox = (
|
||||||
textElement: ExcalidrawTextElement,
|
textElement: ExcalidrawTextElement,
|
||||||
@@ -281,201 +268,6 @@ export const computeBoundTextPosition = (
|
|||||||
return { x, y };
|
return { x, y };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const measureText = (
|
|
||||||
text: string,
|
|
||||||
font: FontString,
|
|
||||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
|
||||||
forceAdvanceWidth?: true,
|
|
||||||
) => {
|
|
||||||
const _text = text
|
|
||||||
.split("\n")
|
|
||||||
// replace empty lines with single space because leading/trailing empty
|
|
||||||
// lines would be stripped from computation
|
|
||||||
.map((x) => x || " ")
|
|
||||||
.join("\n");
|
|
||||||
const fontSize = parseFloat(font);
|
|
||||||
const height = getTextHeight(_text, fontSize, lineHeight);
|
|
||||||
const width = getTextWidth(_text, font, forceAdvanceWidth);
|
|
||||||
return { width, height };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* To get unitless line-height (if unknown) we can calculate it by dividing
|
|
||||||
* height-per-line by fontSize.
|
|
||||||
*/
|
|
||||||
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
|
|
||||||
const lineCount = splitIntoLines(textElement.text).length;
|
|
||||||
return (textElement.height /
|
|
||||||
lineCount /
|
|
||||||
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* We calculate the line height from the font size and the unitless line height,
|
|
||||||
* aligning with the W3C spec.
|
|
||||||
*/
|
|
||||||
export const getLineHeightInPx = (
|
|
||||||
fontSize: ExcalidrawTextElement["fontSize"],
|
|
||||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
|
||||||
) => {
|
|
||||||
return fontSize * lineHeight;
|
|
||||||
};
|
|
||||||
|
|
||||||
// FIXME rename to getApproxMinContainerHeight
|
|
||||||
export const getApproxMinLineHeight = (
|
|
||||||
fontSize: ExcalidrawTextElement["fontSize"],
|
|
||||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
|
||||||
) => {
|
|
||||||
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
|
|
||||||
};
|
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement | undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param forceAdvanceWidth use to force retrieve the "advance width" ~ `metrics.width`, instead of the actual boundind box width.
|
|
||||||
*
|
|
||||||
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
|
|
||||||
*
|
|
||||||
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
|
|
||||||
* - text wrapping
|
|
||||||
* - wysiwyg editor (+padding)
|
|
||||||
*
|
|
||||||
* Everything else should be based on the actual bounding box width.
|
|
||||||
*
|
|
||||||
* `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies.
|
|
||||||
*/
|
|
||||||
export const getLineWidth = (
|
|
||||||
text: string,
|
|
||||||
font: FontString,
|
|
||||||
forceAdvanceWidth?: true,
|
|
||||||
) => {
|
|
||||||
if (!canvas) {
|
|
||||||
canvas = document.createElement("canvas");
|
|
||||||
}
|
|
||||||
const canvas2dContext = canvas.getContext("2d")!;
|
|
||||||
canvas2dContext.font = font;
|
|
||||||
const metrics = canvas2dContext.measureText(text);
|
|
||||||
|
|
||||||
const advanceWidth = metrics.width;
|
|
||||||
|
|
||||||
// retrieve the actual bounding box width if these metrics are available (as of now > 95% coverage)
|
|
||||||
if (
|
|
||||||
!forceAdvanceWidth &&
|
|
||||||
window.TextMetrics &&
|
|
||||||
"actualBoundingBoxLeft" in window.TextMetrics.prototype &&
|
|
||||||
"actualBoundingBoxRight" in window.TextMetrics.prototype
|
|
||||||
) {
|
|
||||||
// could be negative, therefore getting the absolute value
|
|
||||||
const actualWidth =
|
|
||||||
Math.abs(metrics.actualBoundingBoxLeft) +
|
|
||||||
Math.abs(metrics.actualBoundingBoxRight);
|
|
||||||
|
|
||||||
// fallback to advance width if the actual width is zero, i.e. on text editing start
|
|
||||||
// or when actual width does not respect whitespace chars, i.e. spaces
|
|
||||||
// otherwise actual width should always be bigger
|
|
||||||
return Math.max(actualWidth, advanceWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
// since in test env the canvas measureText algo
|
|
||||||
// doesn't measure text and instead just returns number of
|
|
||||||
// characters hence we assume that each letteris 10px
|
|
||||||
if (isTestEnv()) {
|
|
||||||
return advanceWidth * 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
return advanceWidth;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getTextWidth = (
|
|
||||||
text: string,
|
|
||||||
font: FontString,
|
|
||||||
forceAdvanceWidth?: true,
|
|
||||||
) => {
|
|
||||||
const lines = splitIntoLines(text);
|
|
||||||
let width = 0;
|
|
||||||
lines.forEach((line) => {
|
|
||||||
width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth));
|
|
||||||
});
|
|
||||||
|
|
||||||
return width;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getTextHeight = (
|
|
||||||
text: string,
|
|
||||||
fontSize: number,
|
|
||||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
|
||||||
) => {
|
|
||||||
const lineCount = splitIntoLines(text).length;
|
|
||||||
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const charWidth = (() => {
|
|
||||||
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
|
|
||||||
|
|
||||||
const calculate = (char: string, font: FontString) => {
|
|
||||||
const unicode = char.charCodeAt(0);
|
|
||||||
if (!cachedCharWidth[font]) {
|
|
||||||
cachedCharWidth[font] = [];
|
|
||||||
}
|
|
||||||
if (!cachedCharWidth[font][unicode]) {
|
|
||||||
const width = getLineWidth(char, font, true);
|
|
||||||
cachedCharWidth[font][unicode] = width;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cachedCharWidth[font][unicode];
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCache = (font: FontString) => {
|
|
||||||
return cachedCharWidth[font];
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearCache = (font: FontString) => {
|
|
||||||
cachedCharWidth[font] = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
calculate,
|
|
||||||
getCache,
|
|
||||||
clearCache,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
|
||||||
|
|
||||||
// FIXME rename to getApproxMinContainerWidth
|
|
||||||
export const getApproxMinLineWidth = (
|
|
||||||
font: FontString,
|
|
||||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
|
||||||
) => {
|
|
||||||
const maxCharWidth = getMaxCharWidth(font);
|
|
||||||
if (maxCharWidth === 0) {
|
|
||||||
return (
|
|
||||||
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
|
|
||||||
BOUND_TEXT_PADDING * 2
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return maxCharWidth + BOUND_TEXT_PADDING * 2;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getMinCharWidth = (font: FontString) => {
|
|
||||||
const cache = charWidth.getCache(font);
|
|
||||||
if (!cache) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
|
||||||
|
|
||||||
return Math.min(...cacheWithOutEmpty);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getMaxCharWidth = (font: FontString) => {
|
|
||||||
const cache = charWidth.getCache(font);
|
|
||||||
if (!cache) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
|
||||||
return Math.max(...cacheWithOutEmpty);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
|
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
|
||||||
return container?.boundElements?.length
|
return container?.boundElements?.length
|
||||||
? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
|
? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
|
||||||
@@ -712,24 +504,6 @@ export const getBoundTextMaxHeight = (
|
|||||||
return height - BOUND_TEXT_PADDING * 2;
|
return height - BOUND_TEXT_PADDING * 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isMeasureTextSupported = () => {
|
|
||||||
const width = getTextWidth(
|
|
||||||
DUMMY_TEXT,
|
|
||||||
getFontString({
|
|
||||||
fontSize: DEFAULT_FONT_SIZE,
|
|
||||||
fontFamily: DEFAULT_FONT_FAMILY,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return width > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getMinTextElementWidth = (
|
|
||||||
font: FontString,
|
|
||||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
|
||||||
) => {
|
|
||||||
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** retrieves text from text elements and concatenates to a single string */
|
/** retrieves text from text elements and concatenates to a single string */
|
||||||
export const getTextFromElements = (
|
export const getTextFromElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import {
|
||||||
|
BOUND_TEXT_PADDING,
|
||||||
|
DEFAULT_FONT_SIZE,
|
||||||
|
DEFAULT_FONT_FAMILY,
|
||||||
|
} from "../constants";
|
||||||
|
import { getFontString, isTestEnv, normalizeEOL } from "../utils";
|
||||||
|
import type { FontString, ExcalidrawTextElement } from "./types";
|
||||||
|
|
||||||
|
export const measureText = (
|
||||||
|
text: string,
|
||||||
|
font: FontString,
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
) => {
|
||||||
|
const _text = text
|
||||||
|
.split("\n")
|
||||||
|
// replace empty lines with single space because leading/trailing empty
|
||||||
|
// lines would be stripped from computation
|
||||||
|
.map((x) => x || " ")
|
||||||
|
.join("\n");
|
||||||
|
const fontSize = parseFloat(font);
|
||||||
|
const height = getTextHeight(_text, fontSize, lineHeight);
|
||||||
|
const width = getTextWidth(_text, font);
|
||||||
|
return { width, height };
|
||||||
|
};
|
||||||
|
|
||||||
|
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||||
|
|
||||||
|
// FIXME rename to getApproxMinContainerWidth
|
||||||
|
export const getApproxMinLineWidth = (
|
||||||
|
font: FontString,
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
) => {
|
||||||
|
const maxCharWidth = getMaxCharWidth(font);
|
||||||
|
if (maxCharWidth === 0) {
|
||||||
|
return (
|
||||||
|
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
|
||||||
|
BOUND_TEXT_PADDING * 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return maxCharWidth + BOUND_TEXT_PADDING * 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMinTextElementWidth = (
|
||||||
|
font: FontString,
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
) => {
|
||||||
|
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isMeasureTextSupported = () => {
|
||||||
|
const width = getTextWidth(
|
||||||
|
DUMMY_TEXT,
|
||||||
|
getFontString({
|
||||||
|
fontSize: DEFAULT_FONT_SIZE,
|
||||||
|
fontFamily: DEFAULT_FONT_FAMILY,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return width > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeText = (text: string) => {
|
||||||
|
return (
|
||||||
|
normalizeEOL(text)
|
||||||
|
// replace tabs with spaces so they render and measure correctly
|
||||||
|
.replace(/\t/g, " ")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitIntoLines = (text: string) => {
|
||||||
|
return normalizeText(text).split("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To get unitless line-height (if unknown) we can calculate it by dividing
|
||||||
|
* height-per-line by fontSize.
|
||||||
|
*/
|
||||||
|
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
|
||||||
|
const lineCount = splitIntoLines(textElement.text).length;
|
||||||
|
return (textElement.height /
|
||||||
|
lineCount /
|
||||||
|
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We calculate the line height from the font size and the unitless line height,
|
||||||
|
* aligning with the W3C spec.
|
||||||
|
*/
|
||||||
|
export const getLineHeightInPx = (
|
||||||
|
fontSize: ExcalidrawTextElement["fontSize"],
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
) => {
|
||||||
|
return fontSize * lineHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
// FIXME rename to getApproxMinContainerHeight
|
||||||
|
export const getApproxMinLineHeight = (
|
||||||
|
fontSize: ExcalidrawTextElement["fontSize"],
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
) => {
|
||||||
|
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
let textMetricsProvider: TextMetricsProvider | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a custom text metrics provider.
|
||||||
|
*
|
||||||
|
* Useful for overriding the width calculation algorithm where canvas API is not available / desired.
|
||||||
|
*/
|
||||||
|
export const setCustomTextMetricsProvider = (provider: TextMetricsProvider) => {
|
||||||
|
textMetricsProvider = provider;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TextMetricsProvider {
|
||||||
|
getLineWidth(text: string, fontString: FontString): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CanvasTextMetricsProvider implements TextMetricsProvider {
|
||||||
|
private canvas: HTMLCanvasElement;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.canvas = document.createElement("canvas");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
|
||||||
|
* - text wrapping
|
||||||
|
* - wysiwyg editor (+padding)
|
||||||
|
*
|
||||||
|
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
|
||||||
|
*/
|
||||||
|
public getLineWidth(text: string, fontString: FontString): number {
|
||||||
|
const context = this.canvas.getContext("2d")!;
|
||||||
|
context.font = fontString;
|
||||||
|
const metrics = context.measureText(text);
|
||||||
|
const advanceWidth = metrics.width;
|
||||||
|
|
||||||
|
// since in test env the canvas measureText algo
|
||||||
|
// doesn't measure text and instead just returns number of
|
||||||
|
// characters hence we assume that each letteris 10px
|
||||||
|
if (isTestEnv()) {
|
||||||
|
return advanceWidth * 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return advanceWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getLineWidth = (text: string, font: FontString) => {
|
||||||
|
if (!textMetricsProvider) {
|
||||||
|
textMetricsProvider = new CanvasTextMetricsProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
return textMetricsProvider.getLineWidth(text, font);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTextWidth = (text: string, font: FontString) => {
|
||||||
|
const lines = splitIntoLines(text);
|
||||||
|
let width = 0;
|
||||||
|
lines.forEach((line) => {
|
||||||
|
width = Math.max(width, getLineWidth(line, font));
|
||||||
|
});
|
||||||
|
|
||||||
|
return width;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTextHeight = (
|
||||||
|
text: string,
|
||||||
|
fontSize: number,
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
) => {
|
||||||
|
const lineCount = splitIntoLines(text).length;
|
||||||
|
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const charWidth = (() => {
|
||||||
|
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
|
||||||
|
|
||||||
|
const calculate = (char: string, font: FontString) => {
|
||||||
|
const unicode = char.charCodeAt(0);
|
||||||
|
if (!cachedCharWidth[font]) {
|
||||||
|
cachedCharWidth[font] = [];
|
||||||
|
}
|
||||||
|
if (!cachedCharWidth[font][unicode]) {
|
||||||
|
const width = getLineWidth(char, font);
|
||||||
|
cachedCharWidth[font][unicode] = width;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedCharWidth[font][unicode];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCache = (font: FontString) => {
|
||||||
|
return cachedCharWidth[font];
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCache = (font: FontString) => {
|
||||||
|
cachedCharWidth[font] = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
calculate,
|
||||||
|
getCache,
|
||||||
|
clearCache,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
export const getMinCharWidth = (font: FontString) => {
|
||||||
|
const cache = charWidth.getCache(font);
|
||||||
|
if (!cache) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||||
|
|
||||||
|
return Math.min(...cacheWithOutEmpty);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMaxCharWidth = (font: FontString) => {
|
||||||
|
const cache = charWidth.getCache(font);
|
||||||
|
if (!cache) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||||
|
return Math.max(...cacheWithOutEmpty);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ENV } from "../constants";
|
import { ENV } from "../constants";
|
||||||
import { charWidth, getLineWidth } from "./textElement";
|
import { charWidth, getLineWidth } from "./textMeasurements";
|
||||||
import type { FontString } from "./types";
|
import type { FontString } from "./types";
|
||||||
|
|
||||||
let cachedCjkRegex: RegExp | undefined;
|
let cachedCjkRegex: RegExp | undefined;
|
||||||
@@ -385,7 +385,7 @@ export const wrapText = (
|
|||||||
const originalLines = text.split("\n");
|
const originalLines = text.split("\n");
|
||||||
|
|
||||||
for (const originalLine of originalLines) {
|
for (const originalLine of originalLines) {
|
||||||
const currentLineWidth = getLineWidth(originalLine, font, true);
|
const currentLineWidth = getLineWidth(originalLine, font);
|
||||||
|
|
||||||
if (currentLineWidth <= maxWidth) {
|
if (currentLineWidth <= maxWidth) {
|
||||||
lines.push(originalLine);
|
lines.push(originalLine);
|
||||||
@@ -423,7 +423,7 @@ const wrapLine = (
|
|||||||
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
|
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
|
||||||
const testLineWidth = isSingleCharacter(token)
|
const testLineWidth = isSingleCharacter(token)
|
||||||
? currentLineWidth + charWidth.calculate(token, font)
|
? currentLineWidth + charWidth.calculate(token, font)
|
||||||
: getLineWidth(testLine, font, true);
|
: getLineWidth(testLine, font);
|
||||||
|
|
||||||
// build up the current line, skipping length check for possibly trailing whitespaces
|
// build up the current line, skipping length check for possibly trailing whitespaces
|
||||||
if (/\s/.test(token) || testLineWidth <= maxWidth) {
|
if (/\s/.test(token) || testLineWidth <= maxWidth) {
|
||||||
@@ -443,7 +443,7 @@ const wrapLine = (
|
|||||||
|
|
||||||
// trailing line of the wrapped word might still be joined with next token/s
|
// trailing line of the wrapped word might still be joined with next token/s
|
||||||
currentLine = trailingLine;
|
currentLine = trailingLine;
|
||||||
currentLineWidth = getLineWidth(trailingLine, font, true);
|
currentLineWidth = getLineWidth(trailingLine, font);
|
||||||
iterator = tokenIterator.next();
|
iterator = tokenIterator.next();
|
||||||
} else {
|
} else {
|
||||||
// push & reset, but don't iterate on the next token, as we didn't use it yet!
|
// push & reset, but don't iterate on the next token, as we didn't use it yet!
|
||||||
@@ -514,7 +514,7 @@ const wrapWord = (
|
|||||||
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
|
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
|
||||||
*/
|
*/
|
||||||
const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
||||||
const shouldTrimWhitespaces = getLineWidth(line, font, true) > maxWidth;
|
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
|
||||||
|
|
||||||
if (!shouldTrimWhitespaces) {
|
if (!shouldTrimWhitespaces) {
|
||||||
return line;
|
return line;
|
||||||
@@ -527,7 +527,7 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
|||||||
"",
|
"",
|
||||||
];
|
];
|
||||||
|
|
||||||
let trimmedLineWidth = getLineWidth(trimmedLine, font, true);
|
let trimmedLineWidth = getLineWidth(trimmedLine, font);
|
||||||
|
|
||||||
for (const whitespace of Array.from(whitespaces)) {
|
for (const whitespace of Array.from(whitespaces)) {
|
||||||
const _charWidth = charWidth.calculate(whitespace, font);
|
const _charWidth = charWidth.calculate(whitespace, font);
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ import {
|
|||||||
getBoundTextElementId,
|
getBoundTextElementId,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
getTextElementAngle,
|
getTextElementAngle,
|
||||||
getTextWidth,
|
|
||||||
normalizeText,
|
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
getBoundTextMaxHeight,
|
getBoundTextMaxHeight,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
@@ -50,6 +48,8 @@ import {
|
|||||||
originalContainerCache,
|
originalContainerCache,
|
||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
} from "./containerCache";
|
} from "./containerCache";
|
||||||
|
import { getTextWidth } from "./textMeasurements";
|
||||||
|
import { normalizeText } from "./textMeasurements";
|
||||||
|
|
||||||
const getTransform = (
|
const getTransform = (
|
||||||
width: number,
|
width: number,
|
||||||
@@ -350,7 +350,7 @@ export const textWysiwyg = ({
|
|||||||
font,
|
font,
|
||||||
getBoundTextMaxWidth(container, boundTextElement),
|
getBoundTextMaxWidth(container, boundTextElement),
|
||||||
);
|
);
|
||||||
const width = getTextWidth(wrappedText, font, true);
|
const width = getTextWidth(wrappedText, font);
|
||||||
editable.style.width = `${width}px`;
|
editable.style.width = `${width}px`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ export type ExcalidrawElbowArrowElement = Merge<
|
|||||||
elbowed: true;
|
elbowed: true;
|
||||||
startBinding: FixedPointBinding | null;
|
startBinding: FixedPointBinding | null;
|
||||||
endBinding: FixedPointBinding | null;
|
endBinding: FixedPointBinding | null;
|
||||||
fixedSegments: FixedSegment[] | null;
|
fixedSegments: readonly FixedSegment[] | null;
|
||||||
/**
|
/**
|
||||||
* Marks that the 3rd point should be used as the 2nd point of the arrow in
|
* Marks that the 3rd point should be used as the 2nd point of the arrow in
|
||||||
* order to temporarily hide the first segment of the arrow without losing
|
* order to temporarily hide the first segment of the arrow without losing
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
getFontFamilyFallbacks,
|
getFontFamilyFallbacks,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { isTextElement } from "../element";
|
import { isTextElement } from "../element";
|
||||||
import { charWidth, getContainerElement } from "../element/textElement";
|
import { getContainerElement } from "../element/textElement";
|
||||||
import { containsCJK } from "../element/textWrapping";
|
import { containsCJK } from "../element/textWrapping";
|
||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
import { getFontString, PromisePool, promiseTry } from "../utils";
|
import { getFontString, PromisePool, promiseTry } from "../utils";
|
||||||
@@ -31,6 +31,7 @@ import type {
|
|||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import type Scene from "../scene/Scene";
|
import type Scene from "../scene/Scene";
|
||||||
import type { ValueOf } from "../utility-types";
|
import type { ValueOf } from "../utility-types";
|
||||||
|
import { charWidth } from "../element/textMeasurements";
|
||||||
|
|
||||||
export class Fonts {
|
export class Fonts {
|
||||||
// it's ok to track fonts across multiple instances only once, so let's use
|
// it's ok to track fonts across multiple instances only once, so let's use
|
||||||
|
|||||||
@@ -295,3 +295,5 @@ export {
|
|||||||
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
|
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
|
||||||
export { getDataURL } from "./data/blob";
|
export { getDataURL } from "./data/blob";
|
||||||
export { isElementLink } from "./element/elementLink";
|
export { isElementLink } from "./element/elementLink";
|
||||||
|
|
||||||
|
export { setCustomTextMetricsProvider } from "./element/textMeasurements";
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ import {
|
|||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getContainerCoords,
|
getContainerCoords,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
getLineHeightInPx,
|
|
||||||
getBoundTextMaxHeight,
|
getBoundTextMaxHeight,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
@@ -64,6 +63,7 @@ import { getVerticalOffset } from "../fonts";
|
|||||||
import { isRightAngleRads } from "../../math";
|
import { isRightAngleRads } from "../../math";
|
||||||
import { getCornerRadius } from "../shapes";
|
import { getCornerRadius } from "../shapes";
|
||||||
import { getUncroppedImageElement } from "../element/cropElement";
|
import { getUncroppedImageElement } from "../element/cropElement";
|
||||||
|
import { getLineHeightInPx } from "../element/textMeasurements";
|
||||||
|
|
||||||
// using a stronger invert (100% vs our regular 93%) and saturate
|
// using a stronger invert (100% vs our regular 93%) and saturate
|
||||||
// as a temp hack to make images in dark theme look closer to original
|
// as a temp hack to make images in dark theme look closer to original
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { LinearElementEditor } from "../element/linearElementEditor";
|
|||||||
import {
|
import {
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
getLineHeightInPx,
|
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
@@ -38,6 +37,7 @@ import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
|
|||||||
import { getVerticalOffset } from "../fonts";
|
import { getVerticalOffset } from "../fonts";
|
||||||
import { getCornerRadius, isPathALoop } from "../shapes";
|
import { getCornerRadius, isPathALoop } from "../shapes";
|
||||||
import { getUncroppedWidthAndHeight } from "../element/cropElement";
|
import { getUncroppedWidthAndHeight } from "../element/cropElement";
|
||||||
|
import { getLineHeightInPx } from "../element/textMeasurements";
|
||||||
|
|
||||||
const roughSVGDrawWithPrecision = (
|
const roughSVGDrawWithPrecision = (
|
||||||
rsvg: RoughSVG,
|
rsvg: RoughSVG,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { render, waitFor, GlobalTestState } from "./test-utils";
|
|||||||
import { Pointer, Keyboard } from "./helpers/ui";
|
import { Pointer, Keyboard } from "./helpers/ui";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getLineHeightInPx } from "../element/textElement";
|
import { getLineHeightInPx } from "../element/textMeasurements";
|
||||||
import { getElementBounds } from "../element";
|
import { getElementBounds } from "../element";
|
||||||
import type { NormalizedZoomValue } from "../types";
|
import type { NormalizedZoomValue } from "../types";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
|
|||||||
Reference in New Issue
Block a user