diff --git a/.env.development b/.env.development
index 72b67ecea..0c2fb5527 100644
--- a/.env.development
+++ b/.env.development
@@ -22,3 +22,13 @@ REACT_APP_DEV_ENABLE_SW=
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
FAST_REFRESH=false
+
+# MATOMO
+REACT_APP_MATOMO_URL=
+REACT_APP_CDN_MATOMO_TRACKER_URL=
+REACT_APP_MATOMO_SITE_ID=
+
+#Debug flags
+
+# To enable bounding box for text containers
+REACT_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=
diff --git a/.env.production b/.env.production
index 183db7ea2..8737c63c7 100644
--- a/.env.production
+++ b/.env.production
@@ -12,6 +12,13 @@ REACT_APP_WS_SERVER_URL=
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
# production-only vars
+# GOOGLE ANALYTICS
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
+# MATOMO
+REACT_APP_MATOMO_URL=https://excalidraw.matomo.cloud/
+REACT_APP_CDN_MATOMO_TRACKER_URL=//cdn.matomo.cloud/excalidraw.matomo.cloud/matomo.js
+REACT_APP_MATOMO_SITE_ID=1
+
+
REACT_APP_PLUS_APP=https://app.excalidraw.com
diff --git a/.gitignore b/.gitignore
index 4a3f6f367..e637a8c0f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,4 @@ src/packages/excalidraw/types
src/packages/excalidraw/example/public/bundle.js
src/packages/excalidraw/example/public/excalidraw-assets-dev
src/packages/excalidraw/example/public/excalidraw.development.js
+coverage
diff --git a/README.md b/README.md
index c5f7f5cd4..48529165e 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@
An open source virtual hand-drawn style whiteboard.
Collaborative and end-to-end encrypted.
-
+
@@ -70,7 +70,7 @@ The Excalidraw editor (npm package) supports:
## Excalidraw.com
-The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/maielo/new-readme/src/excalidraw-app) is part of this repository as well, and the app features:
+The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/master/src/excalidraw-app) is part of this repository as well, and the app features:
- 📡 PWA support (works offline).
- 🤼 Real-time collaboration.
diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx
index a37843c76..08e807907 100644
--- a/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx
+++ b/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx
@@ -1,6 +1,19 @@
# ref
+
- );
-};
-
-export default SingleLibraryItem;
diff --git a/src/components/__snapshots__/App.test.tsx.snap b/src/components/__snapshots__/App.test.tsx.snap
new file mode 100644
index 000000000..b36d678cd
--- /dev/null
+++ b/src/components/__snapshots__/App.test.tsx.snap
@@ -0,0 +1,63 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Test should show error modal when using brave and measureText API is not working 1`] = `
+
+
+ Looks like you are using Brave browser with the
+
+
+ Aggressively Block Fingerprinting
+
+
+ setting enabled
+ .
+
+
+ This could result in breaking the
+
+
+ Text Elements
+
+
+ in your drawings
+ .
+
+
+ We strongly recommend disabling this setting. You can follow
+
+
+
+ these steps
+
+
+ on how to do so
+ .
+
+
+ If disabling this setting doesn't fix the display of text elements, please open an
+
+
+ issue
+
+
+ on our GitHub, or write us on
+
+
+ Discord
+
+ .
+
+
+`;
diff --git a/src/components/icons.tsx b/src/components/icons.tsx
index 046ee490b..784e81024 100644
--- a/src/components/icons.tsx
+++ b/src/components/icons.tsx
@@ -1008,6 +1008,13 @@ export const UngroupIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
+export const FillZigZagIcon = createIcon(
+
+
+ ,
+ modifiedTablerIconProps,
+);
+
export const FillHachureIcon = createIcon(
<>
{
- // FIXME Hack until we tie "t" to lang state
- // eslint-disable-next-line
- const appState = useExcalidrawAppState();
+ const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionLoadScene)) {
@@ -57,9 +56,7 @@ export const LoadScene = () => {
LoadScene.displayName = "LoadScene";
export const SaveToActiveFile = () => {
- // FIXME Hack until we tie "t" to lang state
- // eslint-disable-next-line
- const appState = useExcalidrawAppState();
+ const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionSaveToActiveFile)) {
@@ -80,9 +77,7 @@ SaveToActiveFile.displayName = "SaveToActiveFile";
export const SaveAsImage = () => {
const setAppState = useExcalidrawSetAppState();
- // FIXME Hack until we tie "t" to lang state
- // eslint-disable-next-line
- const appState = useExcalidrawAppState();
+ const { t } = useI18n();
return (
{
SaveAsImage.displayName = "SaveAsImage";
export const Help = () => {
- // FIXME Hack until we tie "t" to lang state
- // eslint-disable-next-line
- const appState = useExcalidrawAppState();
+ const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
@@ -119,10 +112,12 @@ export const Help = () => {
Help.displayName = "Help";
export const ClearCanvas = () => {
- // FIXME Hack until we tie "t" to lang state
- // eslint-disable-next-line
- const appState = useExcalidrawAppState();
- const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
+ const { t } = useI18n();
+
+ const setActiveConfirmDialog = useSetAtom(
+ activeConfirmDialogAtom,
+ jotaiScope,
+ );
const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionClearCanvas)) {
@@ -143,6 +138,7 @@ export const ClearCanvas = () => {
ClearCanvas.displayName = "ClearCanvas";
export const ToggleTheme = () => {
+ const { t } = useI18n();
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
@@ -175,6 +171,7 @@ export const ToggleTheme = () => {
ToggleTheme.displayName = "ToggleTheme";
export const ChangeCanvasBackground = () => {
+ const { t } = useI18n();
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
@@ -195,9 +192,7 @@ export const ChangeCanvasBackground = () => {
ChangeCanvasBackground.displayName = "ChangeCanvasBackground";
export const Export = () => {
- // FIXME Hack until we tie "t" to lang state
- // eslint-disable-next-line
- const appState = useExcalidrawAppState();
+ const { t } = useI18n();
const setAppState = useExcalidrawSetAppState();
return (
void;
isCollaborating: boolean;
}) => {
- // FIXME Hack until we tie "t" to lang state
- // eslint-disable-next-line
- const appState = useExcalidrawAppState();
+ const { t } = useI18n();
return (
any;
}) => {
- // FIXME when we tie t() to lang state
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const appState = useExcalidrawAppState();
-
+ const { t } = useI18n();
return (
{t("labels.liveCollaboration")}
diff --git a/src/constants.ts b/src/constants.ts
index a3dbf751f..40304bfc0 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -1,6 +1,7 @@
import cssVariables from "./css/variables.module.scss";
import { AppProps, NormalizedZoomValue } from "./types";
-import { FontFamilyValues } from "./element/types";
+import { ExcalidrawElement, FontFamilyValues } from "./element/types";
+import oc from "open-color";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform);
@@ -9,6 +10,12 @@ export const isFirefox =
"netscape" in window &&
navigator.userAgent.indexOf("rv:") > 1 &&
navigator.userAgent.indexOf("Gecko") > 1;
+export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
+export const isSafari =
+ !isChrome && navigator.userAgent.indexOf("Safari") !== -1;
+// keeping function so it can be mocked in test
+export const isBrave = () =>
+ (navigator as any).brave?.isBrave?.name === "isBrave";
export const APP_NAME = "Excalidraw";
@@ -252,3 +259,23 @@ export const ROUNDNESS = {
/** key containt id of precedeing elemnt id we use in reconciliation during
* collaboration */
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
+
+export const DEFAULT_ELEMENT_PROPS: {
+ strokeColor: ExcalidrawElement["strokeColor"];
+ backgroundColor: ExcalidrawElement["backgroundColor"];
+ fillStyle: ExcalidrawElement["fillStyle"];
+ strokeWidth: ExcalidrawElement["strokeWidth"];
+ strokeStyle: ExcalidrawElement["strokeStyle"];
+ roughness: ExcalidrawElement["roughness"];
+ opacity: ExcalidrawElement["opacity"];
+ locked: ExcalidrawElement["locked"];
+} = {
+ strokeColor: oc.black,
+ backgroundColor: "transparent",
+ fillStyle: "hachure",
+ strokeWidth: 1,
+ strokeStyle: "solid",
+ roughness: 1,
+ opacity: 100,
+ locked: false,
+};
diff --git a/src/css/styles.scss b/src/css/styles.scss
index 28a42d069..8dafbfbdf 100644
--- a/src/css/styles.scss
+++ b/src/css/styles.scss
@@ -155,6 +155,9 @@
margin: 1px;
}
+ .welcome-screen-menu-item:focus-visible,
+ .dropdown-menu-item:focus-visible,
+ button:focus-visible,
.buttonList label:focus-within,
input:focus-visible {
outline: transparent;
@@ -530,6 +533,7 @@
// (doesn't work in Firefox)
::-webkit-scrollbar {
width: 3px;
+ height: 3px;
}
::-webkit-scrollbar-thumb {
@@ -567,8 +571,8 @@
}
.App-toolbar--mobile {
- overflow-x: hidden;
- max-width: 100vw;
+ overflow-x: auto;
+ max-width: 90vw;
.ToolIcon__keybinding {
display: none;
diff --git a/src/data/blob.ts b/src/data/blob.ts
index 473042b56..47cff293f 100644
--- a/src/data/blob.ts
+++ b/src/data/blob.ts
@@ -7,6 +7,7 @@ import { CanvasError } from "../errors";
import { t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import { AppState, DataURL, LibraryItem } from "../types";
+import { ValueOf } from "../utility-types";
import { bytesToHexString } from "../utils";
import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem";
import { isValidExcalidrawData, isValidLibrary } from "./json";
@@ -156,7 +157,7 @@ export const loadSceneOrLibraryFromBlob = async (
},
localAppState,
localElements,
- { repairBindings: true },
+ { repairBindings: true, refreshDimensions: true },
),
};
} else if (isValidLibrary(data)) {
diff --git a/src/data/index.ts b/src/data/index.ts
index e5a782ec3..f10fa23b6 100644
--- a/src/data/index.ts
+++ b/src/data/index.ts
@@ -97,7 +97,9 @@ export const exportAsImage = async (
return await fileSave(blob, {
description: "Export to PNG",
name,
- extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
+ // FIXME reintroduce `excalidraw.png` when most people upgrade away
+ // from 111.0.5563.64 (arm64), see #6349
+ extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
fileHandle,
});
} else if (type === "clipboard") {
diff --git a/src/data/restore.ts b/src/data/restore.ts
index 0af2f9dc4..fcf5fa132 100644
--- a/src/data/restore.ts
+++ b/src/data/restore.ts
@@ -31,9 +31,15 @@ import {
import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
-import { getUpdatedTimestamp, updateActiveTool } from "../utils";
+import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import oc from "open-color";
+import { MarkOptional, Mutable } from "../utility-types";
+import {
+ detectLineHeight,
+ getDefaultLineHeight,
+ measureBaseline,
+} from "../element/textElement";
type RestoredAppState = Omit<
AppState,
@@ -164,18 +170,40 @@ const restoreElement = (
const [fontPx, _fontFamily]: [string, string] = (
element as any
).font.split(" ");
- fontSize = parseInt(fontPx, 10);
+ fontSize = parseFloat(fontPx);
fontFamily = getFontFamilyByName(_fontFamily);
}
+ const text = element.text ?? "";
+
+ // line-height might not be specified either when creating elements
+ // programmatically, or when importing old diagrams.
+ // For the latter we want to detect the original line height which
+ // will likely differ from our per-font fixed line height we now use,
+ // to maintain backward compatibility.
+ const lineHeight =
+ element.lineHeight ||
+ (element.height
+ ? // detect line-height from current element height and font-size
+ detectLineHeight(element)
+ : // no element height likely means programmatic use, so default
+ // to a fixed line height
+ getDefaultLineHeight(element.fontFamily));
+ const baseline = measureBaseline(
+ element.text,
+ getFontString(element),
+ lineHeight,
+ );
element = restoreElementWithProperties(element, {
fontSize,
fontFamily,
- text: element.text ?? "",
- baseline: element.baseline,
+ text,
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
containerId: element.containerId ?? null,
- originalText: element.originalText || element.text,
+ originalText: element.originalText || text,
+
+ lineHeight,
+ baseline,
});
if (refreshDimensions) {
@@ -341,6 +369,9 @@ export const restoreElements = (
localElements: readonly ExcalidrawElement[] | null | undefined,
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
): ExcalidrawElement[] => {
+ // used to detect duplicate top-level element ids
+ const existingIds = new Set();
+
const localElementsMap = localElements ? arrayToMap(localElements) : null;
const restoredElements = (elements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements,
@@ -355,6 +386,10 @@ export const restoreElements = (
if (localElement && localElement.version > migratedElement.version) {
migratedElement = bumpVersion(migratedElement, localElement.version);
}
+ if (existingIds.has(migratedElement.id)) {
+ migratedElement = { ...migratedElement, id: randomId() };
+ }
+ existingIds.add(migratedElement.id);
elements.push(migratedElement);
}
}
@@ -479,7 +514,9 @@ export const restoreAppState = (
? {
value: appState.zoom as NormalizedZoomValue,
}
- : appState.zoom || defaultAppState.zoom,
+ : appState.zoom?.value
+ ? appState.zoom
+ : defaultAppState.zoom,
// when sidebar docked and user left it open in last session,
// keep it open. If not docked, keep it closed irrespective of last state.
openSidebar:
diff --git a/src/element/bounds.ts b/src/element/bounds.ts
index 2eab1d93d..3245ca3fc 100644
--- a/src/element/bounds.ts
+++ b/src/element/bounds.ts
@@ -23,6 +23,7 @@ import {
import { rescalePoints } from "../points";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
+import { Mutable } from "../utility-types";
// x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [number, number, number, number];
diff --git a/src/element/collision.ts b/src/element/collision.ts
index 54540ae5e..0e7257d79 100644
--- a/src/element/collision.ts
+++ b/src/element/collision.ts
@@ -38,6 +38,7 @@ import { isTextElement } from ".";
import { isTransparent } from "../utils";
import { shouldShowBoundingBox } from "./transformHandles";
import { getBoundTextElement } from "./textElement";
+import { Mutable } from "../utility-types";
const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement,
@@ -785,7 +786,12 @@ export const findFocusPointForEllipse = (
orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) /
squares;
- const n = (-m * px - 1) / py;
+ let n = (-m * px - 1) / py;
+
+ if (n === 0) {
+ // if zero {-0, 0}, fall back to a same-sign value in the similar range
+ n = (Object.is(n, -0) ? -1 : 1) * 0.01;
+ }
const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2);
return GA.point(x, (-m * x - 1) / n);
diff --git a/src/element/linearElementEditor.ts b/src/element/linearElementEditor.ts
index 5c478515d..aedc25974 100644
--- a/src/element/linearElementEditor.ts
+++ b/src/element/linearElementEditor.ts
@@ -41,6 +41,7 @@ import { shouldRotateWithDiscreteAngle } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { getShapeForElement } from "../renderer/renderElement";
import { DRAGGING_THRESHOLD } from "../constants";
+import { Mutable } from "../utility-types";
const editorMidPointsCache: {
version: number | null;
diff --git a/src/element/mutateElement.ts b/src/element/mutateElement.ts
index 52038c163..1c3d66121 100644
--- a/src/element/mutateElement.ts
+++ b/src/element/mutateElement.ts
@@ -5,6 +5,7 @@ import { getSizeFromPoints } from "../points";
import { randomInteger } from "../random";
import { Point } from "../types";
import { getUpdatedTimestamp } from "../utils";
+import { Mutable } from "../utility-types";
type ElementUpdate = Omit<
Partial,
diff --git a/src/element/newElement.test.ts b/src/element/newElement.test.ts
index 991c034e0..ba7c63ee2 100644
--- a/src/element/newElement.test.ts
+++ b/src/element/newElement.test.ts
@@ -1,8 +1,9 @@
-import { duplicateElement } from "./newElement";
+import { duplicateElement, duplicateElements } from "./newElement";
import { mutateElement } from "./mutateElement";
import { API } from "../tests/helpers/api";
import { FONT_FAMILY, ROUNDNESS } from "../constants";
import { isPrimitive } from "../utils";
+import { ExcalidrawLinearElement } from "./types";
const assertCloneObjects = (source: any, clone: any) => {
for (const key in clone) {
@@ -15,79 +16,353 @@ const assertCloneObjects = (source: any, clone: any) => {
}
};
-it("clones arrow element", () => {
- const element = API.createElement({
- type: "arrow",
- x: 0,
- y: 0,
- strokeColor: "#000000",
- backgroundColor: "transparent",
- fillStyle: "hachure",
- strokeWidth: 1,
- strokeStyle: "solid",
- roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
- roughness: 1,
- opacity: 100,
+describe("duplicating single elements", () => {
+ it("clones arrow element", () => {
+ const element = API.createElement({
+ type: "arrow",
+ x: 0,
+ y: 0,
+ strokeColor: "#000000",
+ backgroundColor: "transparent",
+ fillStyle: "hachure",
+ strokeWidth: 1,
+ strokeStyle: "solid",
+ roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
+ roughness: 1,
+ opacity: 100,
+ });
+
+ // @ts-ignore
+ element.__proto__ = { hello: "world" };
+
+ mutateElement(element, {
+ points: [
+ [1, 2],
+ [3, 4],
+ ],
+ });
+
+ const copy = duplicateElement(null, new Map(), element);
+
+ assertCloneObjects(element, copy);
+
+ // assert we clone the object's prototype
+ // @ts-ignore
+ expect(copy.__proto__).toEqual({ hello: "world" });
+ expect(copy.hasOwnProperty("hello")).toBe(false);
+
+ expect(copy.points).not.toBe(element.points);
+ expect(copy).not.toHaveProperty("shape");
+ expect(copy.id).not.toBe(element.id);
+ expect(typeof copy.id).toBe("string");
+ expect(copy.seed).not.toBe(element.seed);
+ expect(typeof copy.seed).toBe("number");
+ expect(copy).toEqual({
+ ...element,
+ id: copy.id,
+ seed: copy.seed,
+ });
});
- // @ts-ignore
- element.__proto__ = { hello: "world" };
+ it("clones text element", () => {
+ const element = API.createElement({
+ type: "text",
+ x: 0,
+ y: 0,
+ strokeColor: "#000000",
+ backgroundColor: "transparent",
+ fillStyle: "hachure",
+ strokeWidth: 1,
+ strokeStyle: "solid",
+ roundness: null,
+ roughness: 1,
+ opacity: 100,
+ text: "hello",
+ fontSize: 20,
+ fontFamily: FONT_FAMILY.Virgil,
+ textAlign: "left",
+ verticalAlign: "top",
+ });
- mutateElement(element, {
- points: [
- [1, 2],
- [3, 4],
- ],
- });
+ const copy = duplicateElement(null, new Map(), element);
- const copy = duplicateElement(null, new Map(), element);
+ assertCloneObjects(element, copy);
- assertCloneObjects(element, copy);
-
- // @ts-ignore
- expect(copy.__proto__).toEqual({ hello: "world" });
- expect(copy.hasOwnProperty("hello")).toBe(false);
-
- expect(copy.points).not.toBe(element.points);
- expect(copy).not.toHaveProperty("shape");
- expect(copy.id).not.toBe(element.id);
- expect(typeof copy.id).toBe("string");
- expect(copy.seed).not.toBe(element.seed);
- expect(typeof copy.seed).toBe("number");
- expect(copy).toEqual({
- ...element,
- id: copy.id,
- seed: copy.seed,
+ expect(copy).not.toHaveProperty("points");
+ expect(copy).not.toHaveProperty("shape");
+ expect(copy.id).not.toBe(element.id);
+ expect(typeof copy.id).toBe("string");
+ expect(typeof copy.seed).toBe("number");
});
});
-it("clones text element", () => {
- const element = API.createElement({
- type: "text",
- x: 0,
- y: 0,
- strokeColor: "#000000",
- backgroundColor: "transparent",
- fillStyle: "hachure",
- strokeWidth: 1,
- strokeStyle: "solid",
- roundness: null,
- roughness: 1,
- opacity: 100,
- text: "hello",
- fontSize: 20,
- fontFamily: FONT_FAMILY.Virgil,
- textAlign: "left",
- verticalAlign: "top",
+describe("duplicating multiple elements", () => {
+ it("duplicateElements should clone bindings", () => {
+ const rectangle1 = API.createElement({
+ type: "rectangle",
+ id: "rectangle1",
+ boundElements: [
+ { id: "arrow1", type: "arrow" },
+ { id: "arrow2", type: "arrow" },
+ { id: "text1", type: "text" },
+ ],
+ });
+
+ const text1 = API.createElement({
+ type: "text",
+ id: "text1",
+ containerId: "rectangle1",
+ });
+
+ const arrow1 = API.createElement({
+ type: "arrow",
+ id: "arrow1",
+ startBinding: {
+ elementId: "rectangle1",
+ focus: 0.2,
+ gap: 7,
+ },
+ });
+
+ const arrow2 = API.createElement({
+ type: "arrow",
+ id: "arrow2",
+ endBinding: {
+ elementId: "rectangle1",
+ focus: 0.2,
+ gap: 7,
+ },
+ boundElements: [{ id: "text2", type: "text" }],
+ });
+
+ const text2 = API.createElement({
+ type: "text",
+ id: "text2",
+ containerId: "arrow2",
+ });
+
+ // -------------------------------------------------------------------------
+
+ const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
+ const clonedElements = duplicateElements(origElements);
+
+ // generic id in-equality checks
+ // --------------------------------------------------------------------------
+ expect(origElements.map((e) => e.type)).toEqual(
+ clonedElements.map((e) => e.type),
+ );
+ origElements.forEach((origElement, idx) => {
+ const clonedElement = clonedElements[idx];
+ expect(origElement).toEqual(
+ expect.objectContaining({
+ id: expect.not.stringMatching(clonedElement.id),
+ type: clonedElement.type,
+ }),
+ );
+ if ("containerId" in origElement) {
+ expect(origElement.containerId).not.toBe(
+ (clonedElement as any).containerId,
+ );
+ }
+ if ("endBinding" in origElement) {
+ if (origElement.endBinding) {
+ expect(origElement.endBinding.elementId).not.toBe(
+ (clonedElement as any).endBinding?.elementId,
+ );
+ } else {
+ expect((clonedElement as any).endBinding).toBeNull();
+ }
+ }
+ if ("startBinding" in origElement) {
+ if (origElement.startBinding) {
+ expect(origElement.startBinding.elementId).not.toBe(
+ (clonedElement as any).startBinding?.elementId,
+ );
+ } else {
+ expect((clonedElement as any).startBinding).toBeNull();
+ }
+ }
+ });
+ // --------------------------------------------------------------------------
+
+ const clonedArrows = clonedElements.filter(
+ (e) => e.type === "arrow",
+ ) as ExcalidrawLinearElement[];
+
+ const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
+ clonedElements as any as typeof origElements;
+
+ expect(clonedText1.containerId).toBe(clonedRectangle.id);
+ expect(
+ clonedRectangle.boundElements!.find((e) => e.id === clonedText1.id),
+ ).toEqual(
+ expect.objectContaining({
+ id: clonedText1.id,
+ type: clonedText1.type,
+ }),
+ );
+
+ clonedArrows.forEach((arrow) => {
+ // console.log(arrow);
+ expect(
+ clonedRectangle.boundElements!.find((e) => e.id === arrow.id),
+ ).toEqual(
+ expect.objectContaining({
+ id: arrow.id,
+ type: arrow.type,
+ }),
+ );
+
+ if (arrow.endBinding) {
+ expect(arrow.endBinding.elementId).toBe(clonedRectangle.id);
+ }
+ if (arrow.startBinding) {
+ expect(arrow.startBinding.elementId).toBe(clonedRectangle.id);
+ }
+ });
+
+ expect(clonedArrow2.boundElements).toEqual([
+ { type: "text", id: clonedArrowLabel.id },
+ ]);
+ expect(clonedArrowLabel.containerId).toBe(clonedArrow2.id);
});
- const copy = duplicateElement(null, new Map(), element);
+ it("should remove id references of elements that aren't found", () => {
+ const rectangle1 = API.createElement({
+ type: "rectangle",
+ id: "rectangle1",
+ boundElements: [
+ // should keep
+ { id: "arrow1", type: "arrow" },
+ // should drop
+ { id: "arrow-not-exists", type: "arrow" },
+ // should drop
+ { id: "text-not-exists", type: "text" },
+ ],
+ });
- assertCloneObjects(element, copy);
+ const arrow1 = API.createElement({
+ type: "arrow",
+ id: "arrow1",
+ startBinding: {
+ elementId: "rectangle1",
+ focus: 0.2,
+ gap: 7,
+ },
+ });
- expect(copy).not.toHaveProperty("points");
- expect(copy).not.toHaveProperty("shape");
- expect(copy.id).not.toBe(element.id);
- expect(typeof copy.id).toBe("string");
- expect(typeof copy.seed).toBe("number");
+ const text1 = API.createElement({
+ type: "text",
+ id: "text1",
+ containerId: "rectangle-not-exists",
+ });
+
+ const arrow2 = API.createElement({
+ type: "arrow",
+ id: "arrow2",
+ startBinding: {
+ elementId: "rectangle1",
+ focus: 0.2,
+ gap: 7,
+ },
+ endBinding: {
+ elementId: "rectangle-not-exists",
+ focus: 0.2,
+ gap: 7,
+ },
+ });
+
+ const arrow3 = API.createElement({
+ type: "arrow",
+ id: "arrow2",
+ startBinding: {
+ elementId: "rectangle-not-exists",
+ focus: 0.2,
+ gap: 7,
+ },
+ endBinding: {
+ elementId: "rectangle1",
+ focus: 0.2,
+ gap: 7,
+ },
+ });
+
+ // -------------------------------------------------------------------------
+
+ const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
+ const clonedElements = duplicateElements(
+ origElements,
+ ) as any as typeof origElements;
+ const [
+ clonedRectangle,
+ clonedText1,
+ clonedArrow1,
+ clonedArrow2,
+ clonedArrow3,
+ ] = clonedElements;
+
+ expect(clonedRectangle.boundElements).toEqual([
+ { id: clonedArrow1.id, type: "arrow" },
+ ]);
+
+ expect(clonedText1.containerId).toBe(null);
+
+ expect(clonedArrow2.startBinding).toEqual({
+ ...arrow2.startBinding,
+ elementId: clonedRectangle.id,
+ });
+ expect(clonedArrow2.endBinding).toBe(null);
+
+ expect(clonedArrow3.startBinding).toBe(null);
+ expect(clonedArrow3.endBinding).toEqual({
+ ...arrow3.endBinding,
+ elementId: clonedRectangle.id,
+ });
+ });
+
+ describe("should duplicate all group ids", () => {
+ it("should regenerate all group ids and keep them consistent across elements", () => {
+ const rectangle1 = API.createElement({
+ type: "rectangle",
+ groupIds: ["g1"],
+ });
+ const rectangle2 = API.createElement({
+ type: "rectangle",
+ groupIds: ["g2", "g1"],
+ });
+ const rectangle3 = API.createElement({
+ type: "rectangle",
+ groupIds: ["g2", "g1"],
+ });
+
+ const origElements = [rectangle1, rectangle2, rectangle3] as const;
+ const clonedElements = duplicateElements(
+ origElements,
+ ) as any as typeof origElements;
+ const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
+ clonedElements;
+
+ expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
+ expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]);
+ expect(rectangle2.groupIds[1]).not.toBe(clonedRectangle2.groupIds[1]);
+
+ expect(clonedRectangle1.groupIds[0]).toBe(clonedRectangle2.groupIds[1]);
+ expect(clonedRectangle2.groupIds[0]).toBe(clonedRectangle3.groupIds[0]);
+ expect(clonedRectangle2.groupIds[1]).toBe(clonedRectangle3.groupIds[1]);
+ });
+
+ it("should keep and regenerate ids of groups even if invalid", () => {
+ // lone element shouldn't be able to be grouped with itself,
+ // but hard to check against in a performant way so we ignore it
+ const rectangle1 = API.createElement({
+ type: "rectangle",
+ groupIds: ["g1"],
+ });
+
+ const [clonedRectangle1] = duplicateElements([rectangle1]);
+
+ expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
+ expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
+ });
+ });
});
diff --git a/src/element/newElement.ts b/src/element/newElement.ts
index 8e7b8ee8a..36c8cc0e0 100644
--- a/src/element/newElement.ts
+++ b/src/element/newElement.ts
@@ -13,7 +13,12 @@ import {
FontFamilyValues,
ExcalidrawTextContainer,
} from "../element/types";
-import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
+import {
+ arrayToMap,
+ getFontString,
+ getUpdatedTimestamp,
+ isTestEnv,
+} from "../utils";
import { randomInteger, randomId } from "../random";
import { mutateElement, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups";
@@ -22,16 +27,25 @@ import { getElementAbsoluteCoords } from ".";
import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds";
import {
- getBoundTextElement,
getBoundTextElementOffset,
getContainerDims,
getContainerElement,
measureText,
normalizeText,
wrapText,
+ getMaxContainerWidth,
+ getDefaultLineHeight,
} from "./textElement";
-import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
+import {
+ DEFAULT_ELEMENT_PROPS,
+ DEFAULT_FONT_FAMILY,
+ DEFAULT_FONT_SIZE,
+ DEFAULT_TEXT_ALIGN,
+ DEFAULT_VERTICAL_ALIGN,
+ VERTICAL_ALIGN,
+} from "../constants";
import { isArrowElement } from "./typeChecks";
+import { MarkOptional, Merge, Mutable } from "../utility-types";
type ElementConstructorOpts = MarkOptional<
Omit,
@@ -44,6 +58,15 @@ type ElementConstructorOpts = MarkOptional<
| "version"
| "versionNonce"
| "link"
+ | "strokeStyle"
+ | "fillStyle"
+ | "strokeColor"
+ | "backgroundColor"
+ | "roughness"
+ | "strokeWidth"
+ | "roundness"
+ | "locked"
+ | "opacity"
>;
const _newElementBase = (
@@ -51,13 +74,13 @@ const _newElementBase = (
{
x,
y,
- strokeColor,
- backgroundColor,
- fillStyle,
- strokeWidth,
- strokeStyle,
- roughness,
- opacity,
+ strokeColor = DEFAULT_ELEMENT_PROPS.strokeColor,
+ backgroundColor = DEFAULT_ELEMENT_PROPS.backgroundColor,
+ fillStyle = DEFAULT_ELEMENT_PROPS.fillStyle,
+ strokeWidth = DEFAULT_ELEMENT_PROPS.strokeWidth,
+ strokeStyle = DEFAULT_ELEMENT_PROPS.strokeStyle,
+ roughness = DEFAULT_ELEMENT_PROPS.roughness,
+ opacity = DEFAULT_ELEMENT_PROPS.opacity,
width = 0,
height = 0,
angle = 0,
@@ -65,7 +88,7 @@ const _newElementBase = (
roundness = null,
boundElements = null,
link = null,
- locked,
+ locked = DEFAULT_ELEMENT_PROPS.locked,
...rest
}: ElementConstructorOpts & Omit, "type">,
) => {
@@ -131,24 +154,39 @@ const getTextElementPositionOffsets = (
export const newTextElement = (
opts: {
text: string;
- fontSize: number;
- fontFamily: FontFamilyValues;
- textAlign: TextAlign;
- verticalAlign: VerticalAlign;
+ fontSize?: number;
+ fontFamily?: FontFamilyValues;
+ textAlign?: TextAlign;
+ verticalAlign?: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"];
+ lineHeight?: ExcalidrawTextElement["lineHeight"];
+ strokeWidth?: ExcalidrawTextElement["strokeWidth"];
} & ElementConstructorOpts,
): NonDeleted => {
+ const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
+ const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
+ const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily);
const text = normalizeText(opts.text);
- const metrics = measureText(text, getFontString(opts));
- const offsets = getTextElementPositionOffsets(opts, metrics);
+ const metrics = measureText(
+ text,
+ getFontString({ fontFamily, fontSize }),
+ lineHeight,
+ );
+ const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
+ const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
+ const offsets = getTextElementPositionOffsets(
+ { textAlign, verticalAlign },
+ metrics,
+ );
+
const textElement = newElementWith(
{
..._newElementBase("text", opts),
text,
- fontSize: opts.fontSize,
- fontFamily: opts.fontFamily,
- textAlign: opts.textAlign,
- verticalAlign: opts.verticalAlign,
+ fontSize,
+ fontFamily,
+ textAlign,
+ verticalAlign,
x: opts.x - offsets.x,
y: opts.y - offsets.y,
width: metrics.width,
@@ -156,6 +194,7 @@ export const newTextElement = (
baseline: metrics.baseline,
containerId: opts.containerId || null,
originalText: text,
+ lineHeight,
},
{},
);
@@ -172,16 +211,13 @@ const getAdjustedDimensions = (
height: number;
baseline: number;
} => {
- let maxWidth = null;
const container = getContainerElement(element);
- if (container) {
- maxWidth = getMaxContainerWidth(container);
- }
+
const {
width: nextWidth,
height: nextHeight,
baseline: nextBaseline,
- } = measureText(nextText, getFontString(element), maxWidth);
+ } = measureText(nextText, getFontString(element), element.lineHeight);
const { textAlign, verticalAlign } = element;
let x: number;
let y: number;
@@ -193,7 +229,7 @@ const getAdjustedDimensions = (
const prevMetrics = measureText(
element.text,
getFontString(element),
- maxWidth,
+ element.lineHeight,
);
const offsets = getTextElementPositionOffsets(element, {
width: nextWidth - prevMetrics.width,
@@ -256,9 +292,9 @@ const getAdjustedDimensions = (
return {
width: nextWidth,
height: nextHeight,
+ baseline: nextBaseline,
x: Number.isFinite(x) ? x : element.x,
y: Number.isFinite(y) ? y : element.y,
- baseline: nextBaseline,
};
};
@@ -266,6 +302,9 @@ export const refreshTextDimensions = (
textElement: ExcalidrawTextElement,
text = textElement.text,
) => {
+ if (textElement.isDeleted) {
+ return;
+ }
const container = getContainerElement(textElement);
if (container) {
text = wrapText(
@@ -278,38 +317,6 @@ export const refreshTextDimensions = (
return { text, ...dimensions };
};
-export const getMaxContainerWidth = (container: ExcalidrawElement) => {
- const width = getContainerDims(container).width;
- if (isArrowElement(container)) {
- const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
- if (containerWidth <= 0) {
- const boundText = getBoundTextElement(container);
- if (boundText) {
- return boundText.width;
- }
- return BOUND_TEXT_PADDING * 8 * 2;
- }
- return containerWidth;
- }
- return width - BOUND_TEXT_PADDING * 2;
-};
-
-export const getMaxContainerHeight = (container: ExcalidrawElement) => {
- const height = getContainerDims(container).height;
- if (isArrowElement(container)) {
- const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
- if (containerHeight <= 0) {
- const boundText = getBoundTextElement(container);
- if (boundText) {
- return boundText.height;
- }
- return BOUND_TEXT_PADDING * 8 * 2;
- }
- return height;
- }
- return height - BOUND_TEXT_PADDING * 2;
-};
-
export const updateTextElement = (
textElement: ExcalidrawTextElement,
{
@@ -383,16 +390,24 @@ export const newImageElement = (
};
};
-// Simplified deep clone for the purpose of cloning ExcalidrawElement only
-// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
+// Simplified deep clone for the purpose of cloning ExcalidrawElement.
+//
+// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
+// Typed arrays and other non-null objects.
//
// Adapted from https://github.com/lukeed/klona
-export const deepCopyElement = (val: any, depth: number = 0) => {
+//
+// The reason for `deepCopyElement()` wrapper is type safety (only allow
+// passing ExcalidrawElement as the top-level argument).
+const _deepCopyElement = (val: any, depth: number = 0) => {
+ // only clone non-primitives
if (val == null || typeof val !== "object") {
return val;
}
- if (Object.prototype.toString.call(val) === "[object Object]") {
+ const objectType = Object.prototype.toString.call(val);
+
+ if (objectType === "[object Object]") {
const tmp =
typeof val.constructor === "function"
? Object.create(Object.getPrototypeOf(val))
@@ -404,7 +419,7 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
if (depth === 0 && (key === "shape" || key === "canvas")) {
continue;
}
- tmp[key] = deepCopyElement(val[key], depth + 1);
+ tmp[key] = _deepCopyElement(val[key], depth + 1);
}
}
return tmp;
@@ -414,14 +429,67 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
let k = val.length;
const arr = new Array(k);
while (k--) {
- arr[k] = deepCopyElement(val[k], depth + 1);
+ arr[k] = _deepCopyElement(val[k], depth + 1);
}
return arr;
}
+ // we're not cloning non-array & non-plain-object objects because we
+ // don't support them on excalidraw elements yet. If we do, we need to make
+ // sure we start cloning them, so let's warn about it.
+ if (process.env.NODE_ENV === "development") {
+ if (
+ objectType !== "[object Object]" &&
+ objectType !== "[object Array]" &&
+ objectType.startsWith("[object ")
+ ) {
+ console.warn(
+ `_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`,
+ );
+ }
+ }
+
return val;
};
+/**
+ * Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or
+ * any value. The purpose is to to break object references for immutability
+ * reasons, whenever we want to keep the original element, but ensure it's not
+ * mutated.
+ *
+ * Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
+ * Typed arrays and other non-null objects.
+ */
+export const deepCopyElement = (
+ val: T,
+): Mutable => {
+ return _deepCopyElement(val);
+};
+
+/**
+ * utility wrapper to generate new id. In test env it reuses the old + postfix
+ * for test assertions.
+ */
+const regenerateId = (
+ /** supply null if no previous id exists */
+ previousId: string | null,
+) => {
+ if (isTestEnv() && previousId) {
+ let nextId = `${previousId}_copy`;
+ // `window.h` may not be defined in some unit tests
+ if (
+ window.h?.app
+ ?.getSceneElementsIncludingDeleted()
+ .find((el) => el.id === nextId)
+ ) {
+ nextId += "_copy";
+ }
+ return nextId;
+ }
+ return randomId();
+};
+
/**
* Duplicate an element, often used in the alt-drag operation.
* Note that this method has gotten a bit complicated since the
@@ -436,27 +504,15 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
* @param element Element to duplicate
* @param overrides Any element properties to override
*/
-export const duplicateElement = >(
+export const duplicateElement = (
editingGroupId: AppState["editingGroupId"],
groupIdMapForOperation: Map,
element: TElement,
overrides?: Partial,
-): TElement => {
- let copy: TElement = deepCopyElement(element);
+): Readonly => {
+ let copy = deepCopyElement(element);
- if (isTestEnv()) {
- copy.id = `${copy.id}_copy`;
- // `window.h` may not be defined in some unit tests
- if (
- window.h?.app
- ?.getSceneElementsIncludingDeleted()
- .find((el) => el.id === copy.id)
- ) {
- copy.id += "_copy";
- }
- } else {
- copy.id = randomId();
- }
+ copy.id = regenerateId(copy.id);
copy.boundElements = null;
copy.updated = getUpdatedTimestamp();
copy.seed = randomInteger();
@@ -465,7 +521,7 @@ export const duplicateElement = >(
editingGroupId,
(groupId) => {
if (!groupIdMapForOperation.has(groupId)) {
- groupIdMapForOperation.set(groupId, randomId());
+ groupIdMapForOperation.set(groupId, regenerateId(groupId));
}
return groupIdMapForOperation.get(groupId)!;
},
@@ -475,3 +531,102 @@ export const duplicateElement = >(
}
return copy;
};
+
+/**
+ * Clones elements, regenerating their ids (including bindings) and group ids.
+ *
+ * If bindings don't exist in the elements array, they are removed. Therefore,
+ * it's advised to supply the whole elements array, or sets of elements that
+ * are encapsulated (such as library items), if the purpose is to retain
+ * bindings to the cloned elements intact.
+ */
+export const duplicateElements = (elements: readonly ExcalidrawElement[]) => {
+ const clonedElements: ExcalidrawElement[] = [];
+
+ const origElementsMap = arrayToMap(elements);
+
+ // used for for migrating old ids to new ids
+ const elementNewIdsMap = new Map<
+ /* orig */ ExcalidrawElement["id"],
+ /* new */ ExcalidrawElement["id"]
+ >();
+
+ const maybeGetNewId = (id: ExcalidrawElement["id"]) => {
+ // if we've already migrated the element id, return the new one directly
+ if (elementNewIdsMap.has(id)) {
+ return elementNewIdsMap.get(id)!;
+ }
+ // if we haven't migrated the element id, but an old element with the same
+ // id exists, generate a new id for it and return it
+ if (origElementsMap.has(id)) {
+ const newId = regenerateId(id);
+ elementNewIdsMap.set(id, newId);
+ return newId;
+ }
+ // if old element doesn't exist, return null to mark it for removal
+ return null;
+ };
+
+ const groupNewIdsMap = new Map* orig */ GroupId, /* new */ GroupId>();
+
+ for (const element of elements) {
+ const clonedElement: Mutable = _deepCopyElement(element);
+
+ clonedElement.id = maybeGetNewId(element.id)!;
+
+ if (clonedElement.groupIds) {
+ clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
+ if (!groupNewIdsMap.has(groupId)) {
+ groupNewIdsMap.set(groupId, regenerateId(groupId));
+ }
+ return groupNewIdsMap.get(groupId)!;
+ });
+ }
+
+ if ("containerId" in clonedElement && clonedElement.containerId) {
+ const newContainerId = maybeGetNewId(clonedElement.containerId);
+ clonedElement.containerId = newContainerId;
+ }
+
+ if ("boundElements" in clonedElement && clonedElement.boundElements) {
+ clonedElement.boundElements = clonedElement.boundElements.reduce(
+ (
+ acc: Mutable>,
+ binding,
+ ) => {
+ const newBindingId = maybeGetNewId(binding.id);
+ if (newBindingId) {
+ acc.push({ ...binding, id: newBindingId });
+ }
+ return acc;
+ },
+ [],
+ );
+ }
+
+ if ("endBinding" in clonedElement && clonedElement.endBinding) {
+ const newEndBindingId = maybeGetNewId(clonedElement.endBinding.elementId);
+ clonedElement.endBinding = newEndBindingId
+ ? {
+ ...clonedElement.endBinding,
+ elementId: newEndBindingId,
+ }
+ : null;
+ }
+ if ("startBinding" in clonedElement && clonedElement.startBinding) {
+ const newEndBindingId = maybeGetNewId(
+ clonedElement.startBinding.elementId,
+ );
+ clonedElement.startBinding = newEndBindingId
+ ? {
+ ...clonedElement.startBinding,
+ elementId: newEndBindingId,
+ }
+ : null;
+ }
+
+ clonedElements.push(clonedElement);
+ }
+
+ return clonedElements;
+};
diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts
index 605ab0c2b..69b8afae7 100644
--- a/src/element/resizeElements.ts
+++ b/src/element/resizeElements.ts
@@ -39,16 +39,16 @@ import {
import { Point, PointerDownState } from "../types";
import Scene from "../scene/Scene";
import {
- getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextElementId,
- getBoundTextElementOffset,
getContainerElement,
handleBindTextResize,
+ getMaxContainerWidth,
+ getApproxMinLineHeight,
measureText,
+ getMaxContainerHeight,
} from "./textElement";
-import { getMaxContainerWidth } from "./newElement";
export const normalizeAngle = (angle: number): number => {
if (angle >= 2 * Math.PI) {
@@ -192,7 +192,7 @@ const rescalePointsInElement = (
const MIN_FONT_SIZE = 1;
-const measureFontSizeFromWH = (
+const measureFontSizeFromWidth = (
element: NonDeleted,
nextWidth: number,
nextHeight: number,
@@ -214,7 +214,7 @@ const measureFontSizeFromWH = (
const metrics = measureText(
element.text,
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
- element.containerId ? width : null,
+ element.lineHeight,
);
return {
size: nextFontSize,
@@ -290,8 +290,8 @@ const resizeSingleTextElement = (
if (scale > 0) {
const nextWidth = element.width * scale;
const nextHeight = element.height * scale;
- const nextFont = measureFontSizeFromWH(element, nextWidth, nextHeight);
- if (nextFont === null) {
+ const metrics = measureFontSizeFromWidth(element, nextWidth, nextHeight);
+ if (metrics === null) {
return;
}
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
@@ -315,10 +315,10 @@ const resizeSingleTextElement = (
deltaY2,
);
mutateElement(element, {
- fontSize: nextFont.size,
+ fontSize: metrics.size,
width: nextWidth,
height: nextHeight,
- baseline: nextFont.baseline,
+ baseline: metrics.baseline,
x: nextElementX,
y: nextElementY,
});
@@ -427,12 +427,16 @@ export const resizeSingleElement = (
};
}
if (shouldMaintainAspectRatio) {
- const boundTextElementPadding =
- getBoundTextElementOffset(boundTextElement);
- const nextFont = measureFontSizeFromWH(
+ const updatedElement = {
+ ...element,
+ width: eleNewWidth,
+ height: eleNewHeight,
+ };
+
+ const nextFont = measureFontSizeFromWidth(
boundTextElement,
- eleNewWidth - boundTextElementPadding * 2,
- eleNewHeight - boundTextElementPadding * 2,
+ getMaxContainerWidth(updatedElement),
+ getMaxContainerHeight(updatedElement),
);
if (nextFont === null) {
return;
@@ -442,8 +446,14 @@ export const resizeSingleElement = (
baseline: nextFont.baseline,
};
} else {
- const minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
- const minHeight = getApproxMinLineHeight(getFontString(boundTextElement));
+ const minWidth = getApproxMinLineWidth(
+ getFontString(boundTextElement),
+ boundTextElement.lineHeight,
+ );
+ const minHeight = getApproxMinLineHeight(
+ boundTextElement.fontSize,
+ boundTextElement.lineHeight,
+ );
eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth));
eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight));
}
@@ -576,8 +586,11 @@ export const resizeSingleElement = (
});
mutateElement(element, resizedElement);
- if (boundTextElement && boundTextFont) {
- mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize });
+ if (boundTextElement && boundTextFont != null) {
+ mutateElement(boundTextElement, {
+ fontSize: boundTextFont.fontSize,
+ baseline: boundTextFont.baseline,
+ });
}
handleBindTextResize(element, transformHandleDirection);
}
@@ -697,26 +710,34 @@ const resizeMultipleElements = (
const boundTextElement = getBoundTextElement(element.latest);
if (boundTextElement || isTextElement(element.orig)) {
- const optionalPadding = getBoundTextElementOffset(boundTextElement) * 2;
- const textMeasurements = measureFontSizeFromWH(
+ const updatedElement = {
+ ...element.latest,
+ width,
+ height,
+ };
+ const metrics = measureFontSizeFromWidth(
boundTextElement ?? (element.orig as ExcalidrawTextElement),
- width - optionalPadding,
- height - optionalPadding,
+ boundTextElement
+ ? getMaxContainerWidth(updatedElement)
+ : updatedElement.width,
+ boundTextElement
+ ? getMaxContainerHeight(updatedElement)
+ : updatedElement.height,
);
- if (!textMeasurements) {
+ if (!metrics) {
return;
}
if (isTextElement(element.orig)) {
- update.fontSize = textMeasurements.size;
- update.baseline = textMeasurements.baseline;
+ update.fontSize = metrics.size;
+ update.baseline = metrics.baseline;
}
if (boundTextElement) {
boundTextUpdates = {
- fontSize: textMeasurements.size,
- baseline: textMeasurements.baseline,
+ fontSize: metrics.size,
+ baseline: metrics.baseline,
};
}
}
diff --git a/src/element/textElement.test.ts b/src/element/textElement.test.ts
index e1b9ff6f0..106ed7bea 100644
--- a/src/element/textElement.test.ts
+++ b/src/element/textElement.test.ts
@@ -1,5 +1,15 @@
-import { BOUND_TEXT_PADDING } from "../constants";
-import { measureText, wrapText } from "./textElement";
+import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
+import { API } from "../tests/helpers/api";
+import {
+ computeContainerDimensionForBoundText,
+ getContainerCoords,
+ getMaxContainerWidth,
+ getMaxContainerHeight,
+ wrapText,
+ detectLineHeight,
+ getLineHeightInPx,
+ getDefaultLineHeight,
+} from "./textElement";
import { FontString } from "./types";
describe("Test wrapText", () => {
@@ -9,7 +19,7 @@ describe("Test wrapText", () => {
const text = "Hello whats up ";
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
const res = wrapText(text, font, maxWidth);
- expect(res).toBe("Hello whats up ");
+ expect(res).toBe(text);
});
it("should work with emojis", () => {
@@ -19,7 +29,7 @@ describe("Test wrapText", () => {
expect(res).toBe("😀");
});
- it("should show the text correctly when min width reached", () => {
+ it("should show the text correctly when max width reached", () => {
const text = "Hello😀";
const maxWidth = 10;
const res = wrapText(text, font, maxWidth);
@@ -28,13 +38,12 @@ describe("Test wrapText", () => {
describe("When text doesn't contain new lines", () => {
const text = "Hello whats up";
+
[
{
desc: "break all words when width of each word is less than container width",
- width: 90,
- res: `Hello
-whats
-up`,
+ width: 80,
+ res: `Hello \nwhats \nup`,
},
{
desc: "break all characters when width of each character is less than container width",
@@ -55,9 +64,8 @@ p`,
{
desc: "break words as per the width",
- width: 150,
- res: `Hello whats
-up`,
+ width: 140,
+ res: `Hello whats \nup`,
},
{
desc: "fit the container",
@@ -65,6 +73,13 @@ up`,
width: 250,
res: "Hello whats up",
},
+ {
+ desc: "should push the word if its equal to max width",
+ width: 60,
+ res: `Hello
+whats
+up`,
+ },
].forEach((data) => {
it(`should ${data.desc}`, () => {
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
@@ -72,16 +87,15 @@ up`,
});
});
});
+
describe("When text contain new lines", () => {
const text = `Hello
whats up`;
[
{
desc: "break all words when width of each word is less than container width",
- width: 90,
- res: `Hello
-whats
-up`,
+ width: 80,
+ res: `Hello\nwhats \nup`,
},
{
desc: "break all characters when width of each character is less than container width",
@@ -120,17 +134,14 @@ whats up`,
});
});
});
+
describe("When text is long", () => {
const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
[
{
desc: "fit characters of long string as per container width",
width: 170,
- res: `hellolongtextth
-isiswhatsupwith
-youIamtypingggg
-gandtypinggg
-break it now`,
+ res: `hellolongtextth\nisiswhatsupwith\nyouIamtypingggg\ngandtypinggg \nbreak it now`,
},
{
@@ -149,8 +160,7 @@ now`,
desc: "fit the long text when container width is greater than text length and move the rest to next line",
width: 600,
- res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg
-break it now`,
+ res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg \nbreak it now`,
},
].forEach((data) => {
it(`should ${data.desc}`, () => {
@@ -159,38 +169,176 @@ break it now`,
});
});
});
+
+ it("should wrap the text correctly when word length is exactly equal to max width", () => {
+ const text = "Hello Excalidraw";
+ // Length of "Excalidraw" is 100 and exacty equal to max width
+ const res = wrapText(text, font, 100);
+ expect(res).toEqual(`Hello \nExcalidraw`);
+ });
+
+ it("should return the text as is if max width is invalid", () => {
+ const text = "Hello Excalidraw";
+ expect(wrapText(text, font, NaN)).toEqual(text);
+ expect(wrapText(text, font, -1)).toEqual(text);
+ expect(wrapText(text, font, Infinity)).toEqual(text);
+ });
});
describe("Test measureText", () => {
- const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
- const text = "Hello World";
+ describe("Test getContainerCoords", () => {
+ const params = { width: 200, height: 100, x: 10, y: 20 };
- it("should add correct attributes when maxWidth is passed", () => {
- const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
- const res = measureText(text, font, maxWidth);
+ it("should compute coords correctly when ellipse", () => {
+ const element = API.createElement({
+ type: "ellipse",
+ ...params,
+ });
+ expect(getContainerCoords(element)).toEqual({
+ x: 44.2893218813452455,
+ y: 39.64466094067262,
+ });
+ });
- expect(res.container).toMatchInlineSnapshot(`
-