Compare commits

...

8 Commits

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

* fixed the position of the redo and undo buttons to the right
2025-02-13 13:07:44 +00:00
David Luzar 16b86d7d16 chore: update firebase@8 to @11 (#9136) 2025-02-13 13:57:14 +01:00
Márk Tolmács f12b92ce9d chore: Upgrade Sentry to latest and update debug messages (#9134)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-02-13 12:47:27 +01:00
Márk Tolmács 77dc055d81 chore: Revert aspect ratio fix with element size limits and chk (#9131) 2025-02-12 15:02:35 +01:00
David Luzar 26f02bebea fix: stop using structuredClone (#9128)
fix: stop using `structuredClone`
2025-02-12 13:02:53 +01:00
Marcel Mraz e3060dfb8f feat: custom text metrics provider (#9121) 2025-02-11 14:23:08 +01:00
29 changed files with 1089 additions and 1008 deletions
@@ -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) {
+49 -98
View File
@@ -22,9 +22,17 @@ import {
import { MIME_TYPES } from "../../packages/excalidraw/constants";
import type { SyncableExcalidrawElement } from ".";
import { getSyncableElements } from ".";
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
import type { Socket } from "socket.io-client";
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
import { initializeApp } from "firebase/app";
import {
getFirestore,
doc,
getDoc,
runTransaction,
Bytes,
} from "firebase/firestore";
import { getStorage, ref, uploadBytes } from "firebase/storage";
// private
// -----------------------------------------------------------------------------
@@ -41,80 +49,42 @@ try {
FIREBASE_CONFIG = {};
}
let firebasePromise: Promise<typeof import("firebase/app").default> | null =
null;
let firestorePromise: Promise<any> | null | true = null;
let firebaseStoragePromise: Promise<any> | null | true = null;
let firebaseApp: ReturnType<typeof initializeApp> | null = null;
let firestore: ReturnType<typeof getFirestore> | null = null;
let firebaseStorage: ReturnType<typeof getStorage> | null = null;
let isFirebaseInitialized = false;
const _loadFirebase = async () => {
const firebase = (
await import(/* webpackChunkName: "firebase" */ "firebase/app")
).default;
if (!isFirebaseInitialized) {
try {
firebase.initializeApp(FIREBASE_CONFIG);
} catch (error: any) {
// trying initialize again throws. Usually this is harmless, and happens
// mainly in dev (HMR)
if (error.code === "app/duplicate-app") {
console.warn(error.name, error.code);
} else {
throw error;
}
}
isFirebaseInitialized = true;
const _initializeFirebase = () => {
if (!firebaseApp) {
firebaseApp = initializeApp(FIREBASE_CONFIG);
}
return firebase;
return firebaseApp;
};
const _getFirebase = async (): Promise<
typeof import("firebase/app").default
> => {
if (!firebasePromise) {
firebasePromise = _loadFirebase();
const _getFirestore = () => {
if (!firestore) {
firestore = getFirestore(_initializeFirebase());
}
return firebasePromise;
return firestore;
};
const _getStorage = () => {
if (!firebaseStorage) {
firebaseStorage = getStorage(_initializeFirebase());
}
return firebaseStorage;
};
// -----------------------------------------------------------------------------
const loadFirestore = async () => {
const firebase = await _getFirebase();
if (!firestorePromise) {
firestorePromise = import(
/* webpackChunkName: "firestore" */ "firebase/firestore"
);
}
if (firestorePromise !== true) {
await firestorePromise;
firestorePromise = true;
}
return firebase;
};
export const loadFirebaseStorage = async () => {
const firebase = await _getFirebase();
if (!firebaseStoragePromise) {
firebaseStoragePromise = import(
/* webpackChunkName: "storage" */ "firebase/storage"
);
}
if (firebaseStoragePromise !== true) {
await firebaseStoragePromise;
firebaseStoragePromise = true;
}
return firebase;
return _getStorage();
};
interface FirebaseStoredScene {
type FirebaseStoredScene = {
sceneVersion: number;
iv: firebase.default.firestore.Blob;
ciphertext: firebase.default.firestore.Blob;
}
iv: Bytes;
ciphertext: Bytes;
};
const encryptElements = async (
key: string,
@@ -175,7 +145,7 @@ export const saveFilesToFirebase = async ({
prefix: string;
files: { id: FileId; buffer: Uint8Array }[];
}) => {
const firebase = await loadFirebaseStorage();
const storage = await loadFirebaseStorage();
const erroredFiles: FileId[] = [];
const savedFiles: FileId[] = [];
@@ -183,17 +153,10 @@ export const saveFilesToFirebase = async ({
await Promise.all(
files.map(async ({ id, buffer }) => {
try {
await firebase
.storage()
.ref(`${prefix}/${id}`)
.put(
new Blob([buffer], {
type: MIME_TYPES.binary,
}),
{
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
},
);
const storageRef = ref(storage, `${prefix}/${id}`);
await uploadBytes(storageRef, buffer, {
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
});
savedFiles.push(id);
} catch (error: any) {
erroredFiles.push(id);
@@ -205,7 +168,6 @@ export const saveFilesToFirebase = async ({
};
const createFirebaseSceneDocument = async (
firebase: ResolutionType<typeof loadFirestore>,
elements: readonly SyncableExcalidrawElement[],
roomKey: string,
) => {
@@ -213,10 +175,8 @@ const createFirebaseSceneDocument = async (
const { ciphertext, iv } = await encryptElements(roomKey, elements);
return {
sceneVersion,
ciphertext: firebase.firestore.Blob.fromUint8Array(
new Uint8Array(ciphertext),
),
iv: firebase.firestore.Blob.fromUint8Array(iv),
ciphertext: Bytes.fromUint8Array(new Uint8Array(ciphertext)),
iv: Bytes.fromUint8Array(iv),
} as FirebaseStoredScene;
};
@@ -236,20 +196,14 @@ export const saveToFirebase = async (
return null;
}
const firebase = await loadFirestore();
const firestore = firebase.firestore();
const firestore = _getFirestore();
const docRef = doc(firestore, "scenes", roomId);
const docRef = firestore.collection("scenes").doc(roomId);
const storedScene = await firestore.runTransaction(async (transaction) => {
const storedScene = await runTransaction(firestore, async (transaction) => {
const snapshot = await transaction.get(docRef);
if (!snapshot.exists) {
const storedScene = await createFirebaseSceneDocument(
firebase,
elements,
roomKey,
);
if (!snapshot.exists()) {
const storedScene = await createFirebaseSceneDocument(elements, roomKey);
transaction.set(docRef, storedScene);
@@ -269,7 +223,6 @@ export const saveToFirebase = async (
);
const storedScene = await createFirebaseSceneDocument(
firebase,
reconciledElements,
roomKey,
);
@@ -294,15 +247,13 @@ export const loadFromFirebase = async (
roomKey: string,
socket: Socket | null,
): Promise<readonly SyncableExcalidrawElement[] | null> => {
const firebase = await loadFirestore();
const db = firebase.firestore();
const docRef = db.collection("scenes").doc(roomId);
const doc = await docRef.get();
if (!doc.exists) {
const firestore = _getFirestore();
const docRef = doc(firestore, "scenes", roomId);
const docSnap = await getDoc(docRef);
if (!docSnap.exists()) {
return null;
}
const storedScene = doc.data() as FirebaseStoredScene;
const storedScene = docSnap.data() as FirebaseStoredScene;
const elements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null),
);
+3 -3
View File
@@ -27,9 +27,9 @@
},
"dependencies": {
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"firebase": "8.3.3",
"@sentry/browser": "9.0.1",
"callsites": "4.2.0",
"firebase": "11.3.1",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"jotai": "2.11.0",
+45 -2
View File
@@ -1,8 +1,9 @@
import * as Sentry from "@sentry/browser";
import * as SentryIntegrations from "@sentry/integrations";
import callsites from "callsites";
const SentryEnvHostnameMap: { [key: string]: string } = {
"excalidraw.com": "production",
"staging.excalidraw.com": "staging",
"vercel.app": "staging",
};
@@ -23,9 +24,13 @@ Sentry.init({
release: import.meta.env.VITE_APP_GIT_SHA,
ignoreErrors: [
"undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
"InvalidStateError: Failed to execute 'transaction' on 'IDBDatabase': The database connection is closing.", // Not much we can do about the IndexedDB closing error
/(Failed to fetch|(fetch|loading) dynamically imported module)/i, // This is happening when a service worker tries to load an old asset
/QuotaExceededError: (The quota has been exceeded|.*setItem.*Storage)/i, // localStorage quota exceeded
"Internal error opening backing store for indexedDB.open", // Private mode and disabled indexedDB
],
integrations: [
new SentryIntegrations.CaptureConsole({
Sentry.captureConsoleIntegration({
levels: ["error"],
}),
],
@@ -33,6 +38,44 @@ Sentry.init({
if (event.request?.url) {
event.request.url = event.request.url.replace(/#.*$/, "");
}
if (!event.exception) {
event.exception = {
values: [
{
type: "ConsoleError",
value: event.message ?? "Unknown error",
stacktrace: {
frames: callsites()
.slice(1)
.filter(
(frame) =>
frame.getFileName() &&
!frame.getFileName()?.includes("@sentry_browser.js"),
)
.map((frame) => ({
filename: frame.getFileName() ?? undefined,
function: frame.getFunctionName() ?? undefined,
in_app: !(
frame.getFileName()?.includes("node_modules") ?? false
),
lineno: frame.getLineNumber() ?? undefined,
colno: frame.getColumnNumber() ?? undefined,
})),
},
mechanism: {
type: "instrument",
handled: true,
data: {
function: "console.error",
handler: "Sentry.beforeSend",
},
},
},
],
};
}
return event;
},
});
@@ -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",
@@ -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";
+11 -50
View File
@@ -331,17 +331,10 @@ import type { FileSystemHandle } from "../data/filesystem";
import { fileOpen } from "../data/filesystem";
import {
bindTextToShapeAfterDuplication,
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement,
getContainerCenter,
getContainerElement,
getLineHeightInPx,
getMinTextElementWidth,
isMeasureTextSupported,
isValidTextContainer,
measureText,
normalizeText,
} from "../element/textElement";
import {
showHyperlinkTooltip,
@@ -465,6 +458,15 @@ import { cropElement } from "../element/cropElement";
import { wrapText } from "../element/textWrapping";
import { actionCopyElementLink } from "../actions/actionElementLink";
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
import {
isMeasureTextSupported,
normalizeText,
measureText,
getLineHeightInPx,
getApproxMinLineWidth,
getApproxMinLineHeight,
getMinTextElementWidth,
} from "../element/textMeasurements";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@@ -601,7 +603,7 @@ class App extends React.Component<AppProps, AppState> {
private elementsPendingErasure: ElementsPendingErasure = new Set();
public flowChartCreator: FlowChartCreator = new FlowChartCreator();
private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator();
private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator(this);
hitLinkElement?: NonDeletedExcalidrawElement;
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
@@ -4141,51 +4143,10 @@ class App extends React.Component<AppProps, AppState> {
if (selectedElements.length === 1 && arrowKeyPressed) {
event.preventDefault();
const nextId = this.flowChartNavigator.exploreByDirection(
return this.flowChartNavigator.exploreByDirection(
selectedElements[0],
this.scene.getNonDeletedElementsMap(),
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">
<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 =
+35 -1
View File
@@ -46,7 +46,7 @@ import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import type { MarkOptional, Mutable } from "../utility-types";
import { detectLineHeight, getContainerElement } from "../element/textElement";
import { getContainerElement } from "../element/textElement";
import { normalizeLink } from "./url";
import { syncInvalidIndices } from "../fractionalIndex";
import { getSizeFromPoints } from "../points";
@@ -59,6 +59,7 @@ import {
} from "../scene";
import type { LocalPoint, Radians } from "../../math";
import { isFiniteNumber, pointFrom } from "../../math";
import { detectLineHeight } from "../element/textMeasurements";
type RestoredAppState = Omit<
AppState,
@@ -205,6 +206,24 @@ const restoreElementWithProperties = <
"customData" in extra ? extra.customData : element.customData;
}
// NOTE (mtolmacs): This is a temporary check to detect extremely large
// element position or sizing
if (
element.x < -1e6 ||
element.x > 1e6 ||
element.y < -1e6 ||
element.y > 1e6 ||
element.width < -1e6 ||
element.width > 1e6 ||
element.height < -1e6 ||
element.height > 1e6
) {
console.error(
"Restore element with properties size or position is too large",
{ element },
);
}
return {
// spread the original element properties to not lose unknown ones
// for forward-compatibility
@@ -219,6 +238,21 @@ const restoreElementWithProperties = <
const restoreElement = (
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
): typeof element | null => {
// NOTE (mtolmacs): This is a temporary check to detect extremely large
// element position or sizing
if (
element.x < -1e6 ||
element.x > 1e6 ||
element.y < -1e6 ||
element.y > 1e6 ||
element.width < -1e6 ||
element.width > 1e6 ||
element.height < -1e6 ||
element.height > 1e6
) {
console.error("Restore element size or position is too large", { element });
}
switch (element.type) {
case "text":
let fontSize = element.fontSize;
+1 -1
View File
@@ -19,7 +19,6 @@ import {
newMagicFrameElement,
newTextElement,
} from "../element/newElement";
import { measureText, normalizeText } from "../element/textElement";
import type {
ElementsMap,
ExcalidrawArrowElement,
@@ -55,6 +54,7 @@ import { syncInvalidIndices } from "../fractionalIndex";
import { getLineHeight } from "../fonts";
import { isArrowElement } from "../element/typeChecks";
import { pointFrom, type LocalPoint } from "../../math";
import { measureText, normalizeText } from "../element/textMeasurements";
export type ValidLinearElement = {
type: "arrow" | "line";
+2 -1
View File
@@ -10,7 +10,7 @@ import type {
NullableGridSize,
PointerDownState,
} from "../types";
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
import { getBoundTextElement } from "./textElement";
import type Scene from "../scene/Scene";
import {
isArrowElement,
@@ -22,6 +22,7 @@ import {
import { getFontString } from "../utils";
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
import { getGridPoint } from "../snapping";
import { getMinTextElementWidth } from "./textMeasurements";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
+85 -11
View File
@@ -1,4 +1,5 @@
import {
clamp,
pointDistance,
pointFrom,
pointScaleFromOrigin,
@@ -104,7 +105,7 @@ const handleSegmentRenormalization = (
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) => {
const nextFixedSegments: FixedSegment[] | null = arrow.fixedSegments
? structuredClone(arrow.fixedSegments)
? arrow.fixedSegments.slice()
: null;
if (nextFixedSegments) {
@@ -270,7 +271,7 @@ const handleSegmentRenormalization = (
const handleSegmentRelease = (
arrow: ExcalidrawElbowArrowElement,
fixedSegments: FixedSegment[],
fixedSegments: readonly FixedSegment[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) => {
const newFixedSegmentIndices = fixedSegments.map((segment) => segment.index);
@@ -444,7 +445,7 @@ const handleSegmentRelease = (
*/
const handleSegmentMove = (
arrow: ExcalidrawElbowArrowElement,
fixedSegments: FixedSegment[],
fixedSegments: readonly FixedSegment[],
startHeading: Heading,
endHeading: Heading,
hoveredStartElement: ExcalidrawBindableElement | null,
@@ -686,7 +687,7 @@ const handleSegmentMove = (
const handleEndpointDrag = (
arrow: ExcalidrawElbowArrowElement,
updatedPoints: readonly LocalPoint[],
fixedSegments: FixedSegment[],
fixedSegments: readonly FixedSegment[],
startHeading: Heading,
endHeading: Heading,
startGlobalPoint: GlobalPoint,
@@ -863,6 +864,8 @@ const handleEndpointDrag = (
);
};
const MAX_POS = 1e6;
/**
*
*/
@@ -883,6 +886,50 @@ export const updateElbowArrowPoints = (
return { points: updates.points ?? arrow.points };
}
// NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow
// arrow size is valid. This check will be removed once the issue is identified
if (
arrow.x < -MAX_POS ||
arrow.x > MAX_POS ||
arrow.y < -MAX_POS ||
arrow.y > MAX_POS ||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) <
-MAX_POS ||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) >
MAX_POS ||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) <
-MAX_POS ||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) >
MAX_POS ||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) <
-MAX_POS ||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) >
MAX_POS ||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) <
-MAX_POS ||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS
) {
console.error(
"Elbow arrow (or update) is outside reasonable bounds (> 1e6)",
{
arrow,
updates,
},
);
}
// @ts-ignore See above note
arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS);
// @ts-ignore See above note
arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS);
if (updates.points) {
updates.points = updates.points.map(([x, y]) =>
pointFrom<LocalPoint>(
clamp(x, -MAX_POS, MAX_POS),
clamp(y, -MAX_POS, MAX_POS),
),
);
}
if (!import.meta.env.PROD) {
invariant(
!updates.points || updates.points.length >= 2,
@@ -944,8 +991,8 @@ export const updateElbowArrowPoints = (
? updates.points![1]
: p,
)
: structuredClone(updates.points)
: structuredClone(arrow.points);
: updates.points.slice()
: arrow.points.slice();
const {
startHeading,
@@ -1965,7 +2012,7 @@ const getBindableElementForId = (
const normalizeArrowElementUpdate = (
global: GlobalPoint[],
nextFixedSegments: FixedSegment[] | null,
nextFixedSegments: readonly FixedSegment[] | null,
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
): {
@@ -1974,24 +2021,51 @@ const normalizeArrowElementUpdate = (
y: number;
width: number;
height: number;
fixedSegments: FixedSegment[] | null;
fixedSegments: readonly FixedSegment[] | null;
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
} => {
const offsetX = global[0][0];
const offsetY = global[0][1];
const points = global.map((p) =>
let points = global.map((p) =>
pointTranslate<GlobalPoint, LocalPoint>(
p,
vectorScale(vectorFromPoint(global[0]), -1),
),
);
// NOTE (mtolmacs): This is a temporary check to see if the normalization
// creates an overly large arrow. This should be removed once we have an answer.
if (
offsetX < -MAX_POS ||
offsetX > MAX_POS ||
offsetY < -MAX_POS ||
offsetY > MAX_POS ||
offsetX + points[points.length - 1][0] < -MAX_POS ||
offsetY + points[points.length - 1][0] > MAX_POS ||
offsetX + points[points.length - 1][1] < -MAX_POS ||
offsetY + points[points.length - 1][1] > MAX_POS
) {
console.error(
"Elbow arrow normalization is outside reasonable bounds (> 1e6)",
{
x: offsetX,
y: offsetY,
points,
...getSizeFromPoints(points),
},
);
}
points = points.map(([x, y]) =>
pointFrom<LocalPoint>(clamp(x, -1e6, 1e6), clamp(y, -1e6, 1e6)),
);
return {
points,
x: offsetX,
y: offsetY,
x: clamp(offsetX, -1e6, 1e6),
y: clamp(offsetY, -1e6, 1e6),
fixedSegments:
(nextFixedSegments?.length ?? 0) > 0 ? nextFixedSegments : null,
...getSizeFromPoints(points),
@@ -310,95 +310,4 @@ describe("flow chart navigation", () => {
Keyboard.keyUp(KEYS.ALT);
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,
);
});
});
+70 -93
View File
@@ -34,6 +34,9 @@ import { invariant, toBrandedType } from "../utils";
import { pointFrom, type LocalPoint } from "../../math";
import { aabbForElement } from "../shapes";
import { updateElbowArrowPoints } from "./elbowArrow";
import type App from "../components/App";
import { makeNextSelectedElementIds } from "../scene/selection";
import { isElementCompletelyInViewport } from "./sizeHelpers";
type LinkDirection = "up" | "right" | "down" | "left";
@@ -491,62 +494,54 @@ const createBindingArrow = (
export class FlowChartNavigator {
isExploring: boolean = false;
// nodes that are ONE link away (successor and predecessor both included)
private sameLevelNodes: ExcalidrawElement[] = [];
private sameLevelIndex: number = 0;
// set it to the opposite of the defalut creation direction
private app: App;
private siblingNodes: ExcalidrawElement[] = [];
private siblingIndex: number = 0;
private direction: LinkDirection | null = null;
// for speedier navigation
private visitedNodes: Set<ExcalidrawElement["id"]> = new Set();
constructor(app: App) {
this.app = app;
}
clear() {
this.isExploring = false;
this.sameLevelNodes = [];
this.sameLevelIndex = 0;
this.siblingNodes = [];
this.siblingIndex = 0;
this.direction = null;
this.visitedNodes.clear();
}
exploreByDirection(
element: ExcalidrawElement,
elementsMap: ElementsMap,
direction: LinkDirection,
): ExcalidrawElement["id"] | null {
/**
* Explore the flowchart by the given direction.
*
* The exploration follows a (near) breadth-first approach: when there're multiple
* nodes at the same level, we allow the user to traverse through them.
*/
exploreByDirection(element: ExcalidrawElement, direction: LinkDirection) {
if (!isBindableElement(element)) {
return null;
return;
}
const elementsMap = this.app.scene.getNonDeletedElementsMap();
// clear if going at a different direction
if (direction !== this.direction) {
this.clear();
}
// add the current node to the visited
if (!this.visitedNodes.has(element.id)) {
this.visitedNodes.add(element.id);
}
/**
* CASE:
* - already started exploring, AND
* - there are multiple nodes at the same level, AND
* - still going at the same direction, AND
*
* RESULT:
* - loop through nodes at the same level
*
* WHY:
* - provides user the capability to loop through nodes at the same level
* if we're already exploring (holding the alt key)
* and the direction is the same as the previous one
* and there're multiple nodes at the same level
* then we should traverse through them before moving to the next level
*/
if (
this.isExploring &&
direction === this.direction &&
this.sameLevelNodes.length > 1
this.siblingNodes.length > 1
) {
this.sameLevelIndex =
(this.sameLevelIndex + 1) % this.sameLevelNodes.length;
return this.sameLevelNodes[this.sameLevelIndex].id;
this.siblingIndex = (this.siblingIndex + 1) % this.siblingNodes.length;
return this.goToNode(this.siblingNodes[this.siblingIndex].id);
}
const nodes = [
@@ -554,70 +549,52 @@ export class FlowChartNavigator {
...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) {
this.sameLevelIndex = 0;
this.siblingIndex = 0;
this.isExploring = true;
this.sameLevelNodes = nodes;
this.siblingNodes = nodes;
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 {
+24 -5
View File
@@ -33,11 +33,7 @@ import { getNewGroupIdsForDuplication } from "../groups";
import type { AppState } from "../types";
import { getElementAbsoluteCoords } from ".";
import { getResizedElementAbsoluteCoords } from "./bounds";
import {
measureText,
normalizeText,
getBoundTextMaxWidth,
} from "./textElement";
import { getBoundTextMaxWidth } from "./textElement";
import { wrapText } from "./textWrapping";
import {
DEFAULT_ELEMENT_PROPS,
@@ -51,6 +47,7 @@ import {
import type { MarkOptional, Merge, Mutable } from "../utility-types";
import { getLineHeight } from "../fonts";
import type { Radians } from "../../math";
import { normalizeText, measureText } from "./textMeasurements";
export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -102,6 +99,28 @@ const _newElementBase = <T extends ExcalidrawElement>(
...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => {
// NOTE (mtolmacs): This is a temporary check to detect extremely large
// element position or sizing
if (
x < -1e6 ||
x > 1e6 ||
y < -1e6 ||
y > 1e6 ||
width < -1e6 ||
width > 1e6 ||
height < -1e6 ||
height > 1e6
) {
console.error("New element size or position is too large", {
x,
y,
width,
height,
// @ts-ignore
points: rest.points,
});
}
// assign type to guard against excess properties
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
id: rest.id || randomId(),
+28 -6
View File
@@ -41,15 +41,11 @@ import type {
import type { PointerDownState } from "../types";
import type Scene from "../scene/Scene";
import {
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextElementId,
getContainerElement,
handleBindTextResize,
getBoundTextMaxWidth,
getApproxMinLineHeight,
measureText,
getMinTextElementWidth,
} from "./textElement";
import { wrapText } from "./textWrapping";
import { LinearElementEditor } from "./linearElementEditor";
@@ -64,6 +60,12 @@ import {
type Radians,
type LocalPoint,
} from "../../math";
import {
getMinTextElementWidth,
measureText,
getApproxMinLineWidth,
getApproxMinLineHeight,
} from "./textMeasurements";
// Returns true when transform (resizing/rotation) happened
export const transformElements = (
@@ -767,12 +769,32 @@ 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:
y +
((prevWidth - newWidth) / 2) * Math.sin(angle) +
(prevHeight - newHeight) / 2,
(newHeight - prevHeight) / 2 +
((prevWidth - newWidth) / 2) * Math.sin(angle),
};
case "west-side":
return {
@@ -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", () => {
+2 -228
View File
@@ -1,4 +1,4 @@
import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils";
import { getFontString, arrayToMap } from "../utils";
import type {
ElementsMap,
ExcalidrawElement,
@@ -6,7 +6,6 @@ import type {
ExcalidrawTextContainer,
ExcalidrawTextElement,
ExcalidrawTextElementWithContainer,
FontString,
NonDeletedExcalidrawElement,
} from "./types";
import { mutateElement } from "./mutateElement";
@@ -14,7 +13,6 @@ import {
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
ARROW_LABEL_WIDTH_FRACTION,
BOUND_TEXT_PADDING,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
TEXT_ALIGN,
VERTICAL_ALIGN,
@@ -30,18 +28,7 @@ import {
updateOriginalContainerCache,
} from "./containerCache";
import type { ExtractSetType } from "../utility-types";
export const normalizeText = (text: string) => {
return (
normalizeEOL(text)
// replace tabs with spaces so they render and measure correctly
.replace(/\t/g, " ")
);
};
const splitIntoLines = (text: string) => {
return normalizeText(text).split("\n");
};
import { measureText } from "./textMeasurements";
export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement,
@@ -281,201 +268,6 @@ export const computeBoundTextPosition = (
return { x, y };
};
export const measureText = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
forceAdvanceWidth?: true,
) => {
const _text = text
.split("\n")
// replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation
.map((x) => x || " ")
.join("\n");
const fontSize = parseFloat(font);
const height = getTextHeight(_text, fontSize, lineHeight);
const width = getTextWidth(_text, font, forceAdvanceWidth);
return { width, height };
};
/**
* To get unitless line-height (if unknown) we can calculate it by dividing
* height-per-line by fontSize.
*/
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
const lineCount = splitIntoLines(textElement.text).length;
return (textElement.height /
lineCount /
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
};
/**
* We calculate the line height from the font size and the unitless line height,
* aligning with the W3C spec.
*/
export const getLineHeightInPx = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return fontSize * lineHeight;
};
// FIXME rename to getApproxMinContainerHeight
export const getApproxMinLineHeight = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
};
let canvas: HTMLCanvasElement | undefined;
/**
* @param forceAdvanceWidth use to force retrieve the "advance width" ~ `metrics.width`, instead of the actual boundind box width.
*
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
*
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
* - text wrapping
* - wysiwyg editor (+padding)
*
* Everything else should be based on the actual bounding box width.
*
* `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies.
*/
export const getLineWidth = (
text: string,
font: FontString,
forceAdvanceWidth?: true,
) => {
if (!canvas) {
canvas = document.createElement("canvas");
}
const canvas2dContext = canvas.getContext("2d")!;
canvas2dContext.font = font;
const metrics = canvas2dContext.measureText(text);
const advanceWidth = metrics.width;
// retrieve the actual bounding box width if these metrics are available (as of now > 95% coverage)
if (
!forceAdvanceWidth &&
window.TextMetrics &&
"actualBoundingBoxLeft" in window.TextMetrics.prototype &&
"actualBoundingBoxRight" in window.TextMetrics.prototype
) {
// could be negative, therefore getting the absolute value
const actualWidth =
Math.abs(metrics.actualBoundingBoxLeft) +
Math.abs(metrics.actualBoundingBoxRight);
// fallback to advance width if the actual width is zero, i.e. on text editing start
// or when actual width does not respect whitespace chars, i.e. spaces
// otherwise actual width should always be bigger
return Math.max(actualWidth, advanceWidth);
}
// since in test env the canvas measureText algo
// doesn't measure text and instead just returns number of
// characters hence we assume that each letteris 10px
if (isTestEnv()) {
return advanceWidth * 10;
}
return advanceWidth;
};
export const getTextWidth = (
text: string,
font: FontString,
forceAdvanceWidth?: true,
) => {
const lines = splitIntoLines(text);
let width = 0;
lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth));
});
return width;
};
export const getTextHeight = (
text: string,
fontSize: number,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const lineCount = splitIntoLines(text).length;
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
};
export const charWidth = (() => {
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
const calculate = (char: string, font: FontString) => {
const unicode = char.charCodeAt(0);
if (!cachedCharWidth[font]) {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][unicode]) {
const width = getLineWidth(char, font, true);
cachedCharWidth[font][unicode] = width;
}
return cachedCharWidth[font][unicode];
};
const getCache = (font: FontString) => {
return cachedCharWidth[font];
};
const clearCache = (font: FontString) => {
cachedCharWidth[font] = [];
};
return {
calculate,
getCache,
clearCache,
};
})();
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
// FIXME rename to getApproxMinContainerWidth
export const getApproxMinLineWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) {
return (
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
BOUND_TEXT_PADDING * 2
);
}
return maxCharWidth + BOUND_TEXT_PADDING * 2;
};
export const getMinCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.min(...cacheWithOutEmpty);
};
export const getMaxCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.max(...cacheWithOutEmpty);
};
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
return container?.boundElements?.length
? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
@@ -712,24 +504,6 @@ export const getBoundTextMaxHeight = (
return height - BOUND_TEXT_PADDING * 2;
};
export const isMeasureTextSupported = () => {
const width = getTextWidth(
DUMMY_TEXT,
getFontString({
fontSize: DEFAULT_FONT_SIZE,
fontFamily: DEFAULT_FONT_FAMILY,
}),
);
return width > 0;
};
export const getMinTextElementWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
};
/** retrieves text from text elements and concatenates to a single string */
export const getTextFromElements = (
elements: readonly ExcalidrawElement[],
@@ -0,0 +1,224 @@
import {
BOUND_TEXT_PADDING,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_FAMILY,
} from "../constants";
import { getFontString, isTestEnv, normalizeEOL } from "../utils";
import type { FontString, ExcalidrawTextElement } from "./types";
export const measureText = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const _text = text
.split("\n")
// replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation
.map((x) => x || " ")
.join("\n");
const fontSize = parseFloat(font);
const height = getTextHeight(_text, fontSize, lineHeight);
const width = getTextWidth(_text, font);
return { width, height };
};
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
// FIXME rename to getApproxMinContainerWidth
export const getApproxMinLineWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) {
return (
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
BOUND_TEXT_PADDING * 2
);
}
return maxCharWidth + BOUND_TEXT_PADDING * 2;
};
export const getMinTextElementWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
};
export const isMeasureTextSupported = () => {
const width = getTextWidth(
DUMMY_TEXT,
getFontString({
fontSize: DEFAULT_FONT_SIZE,
fontFamily: DEFAULT_FONT_FAMILY,
}),
);
return width > 0;
};
export const normalizeText = (text: string) => {
return (
normalizeEOL(text)
// replace tabs with spaces so they render and measure correctly
.replace(/\t/g, " ")
);
};
const splitIntoLines = (text: string) => {
return normalizeText(text).split("\n");
};
/**
* To get unitless line-height (if unknown) we can calculate it by dividing
* height-per-line by fontSize.
*/
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
const lineCount = splitIntoLines(textElement.text).length;
return (textElement.height /
lineCount /
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
};
/**
* We calculate the line height from the font size and the unitless line height,
* aligning with the W3C spec.
*/
export const getLineHeightInPx = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return fontSize * lineHeight;
};
// FIXME rename to getApproxMinContainerHeight
export const getApproxMinLineHeight = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
};
let textMetricsProvider: TextMetricsProvider | undefined;
/**
* Set a custom text metrics provider.
*
* Useful for overriding the width calculation algorithm where canvas API is not available / desired.
*/
export const setCustomTextMetricsProvider = (provider: TextMetricsProvider) => {
textMetricsProvider = provider;
};
export interface TextMetricsProvider {
getLineWidth(text: string, fontString: FontString): number;
}
class CanvasTextMetricsProvider implements TextMetricsProvider {
private canvas: HTMLCanvasElement;
constructor() {
this.canvas = document.createElement("canvas");
}
/**
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
* - text wrapping
* - wysiwyg editor (+padding)
*
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
*/
public getLineWidth(text: string, fontString: FontString): number {
const context = this.canvas.getContext("2d")!;
context.font = fontString;
const metrics = context.measureText(text);
const advanceWidth = metrics.width;
// since in test env the canvas measureText algo
// doesn't measure text and instead just returns number of
// characters hence we assume that each letteris 10px
if (isTestEnv()) {
return advanceWidth * 10;
}
return advanceWidth;
}
}
export const getLineWidth = (text: string, font: FontString) => {
if (!textMetricsProvider) {
textMetricsProvider = new CanvasTextMetricsProvider();
}
return textMetricsProvider.getLineWidth(text, font);
};
export const getTextWidth = (text: string, font: FontString) => {
const lines = splitIntoLines(text);
let width = 0;
lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font));
});
return width;
};
export const getTextHeight = (
text: string,
fontSize: number,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const lineCount = splitIntoLines(text).length;
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
};
export const charWidth = (() => {
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
const calculate = (char: string, font: FontString) => {
const unicode = char.charCodeAt(0);
if (!cachedCharWidth[font]) {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][unicode]) {
const width = getLineWidth(char, font);
cachedCharWidth[font][unicode] = width;
}
return cachedCharWidth[font][unicode];
};
const getCache = (font: FontString) => {
return cachedCharWidth[font];
};
const clearCache = (font: FontString) => {
cachedCharWidth[font] = [];
};
return {
calculate,
getCache,
clearCache,
};
})();
export const getMinCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.min(...cacheWithOutEmpty);
};
export const getMaxCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.max(...cacheWithOutEmpty);
};
+6 -6
View File
@@ -1,5 +1,5 @@
import { ENV } from "../constants";
import { 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);
+3 -3
View File
@@ -24,8 +24,6 @@ import {
getBoundTextElementId,
getContainerElement,
getTextElementAngle,
getTextWidth,
normalizeText,
redrawTextBoundingBox,
getBoundTextMaxHeight,
getBoundTextMaxWidth,
@@ -50,6 +48,8 @@ import {
originalContainerCache,
updateOriginalContainerCache,
} from "./containerCache";
import { getTextWidth } from "./textMeasurements";
import { normalizeText } from "./textMeasurements";
const getTransform = (
width: number,
@@ -350,7 +350,7 @@ export const textWysiwyg = ({
font,
getBoundTextMaxWidth(container, boundTextElement),
);
const width = getTextWidth(wrappedText, font, true);
const width = getTextWidth(wrappedText, font);
editable.style.width = `${width}px`;
}
};
+1 -1
View File
@@ -337,7 +337,7 @@ export type ExcalidrawElbowArrowElement = Merge<
elbowed: true;
startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null;
fixedSegments: FixedSegment[] | null;
fixedSegments: readonly FixedSegment[] | null;
/**
* Marks that the 3rd point should be used as the 2nd point of the arrow in
* order to temporarily hide the first segment of the arrow without losing
+2 -1
View File
@@ -6,7 +6,7 @@ import {
getFontFamilyFallbacks,
} from "../constants";
import { isTextElement } from "../element";
import { charWidth, getContainerElement } from "../element/textElement";
import { getContainerElement } from "../element/textElement";
import { containsCJK } from "../element/textWrapping";
import { ShapeCache } from "../scene/ShapeCache";
import { getFontString, PromisePool, promiseTry } from "../utils";
@@ -31,6 +31,7 @@ import type {
} from "../element/types";
import type Scene from "../scene/Scene";
import type { ValueOf } from "../utility-types";
import { charWidth } from "../element/textMeasurements";
export class Fonts {
// it's ok to track fonts across multiple instances only once, so let's use
+2
View File
@@ -295,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 -1
View File
@@ -5,7 +5,7 @@ import { render, waitFor, GlobalTestState } from "./test-utils";
import { Pointer, Keyboard } from "./helpers/ui";
import { Excalidraw } from "../index";
import { KEYS } from "../keys";
import { getLineHeightInPx } from "../element/textElement";
import { getLineHeightInPx } from "../element/textMeasurements";
import { getElementBounds } from "../element";
import type { NormalizedZoomValue } from "../types";
import { API } from "./helpers/api";
+476 -385
View File
File diff suppressed because it is too large Load Diff