+
{children || t("welcomeScreen.defaults.helpHint")}
{WelcomeScreenHelpArrow}
diff --git a/packages/excalidraw/components/welcome-screen/WelcomeScreen.scss b/packages/excalidraw/components/welcome-screen/WelcomeScreen.scss
index 9b70cf53a..8472b19fe 100644
--- a/packages/excalidraw/components/welcome-screen/WelcomeScreen.scss
+++ b/packages/excalidraw/components/welcome-screen/WelcomeScreen.scss
@@ -1,6 +1,6 @@
.excalidraw {
- .virgil {
- font-family: "Virgil";
+ .excalifont {
+ font-family: "Excalifont";
}
// WelcomeSreen common
diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts
index 031db6f8a..d73ed0fba 100644
--- a/packages/excalidraw/constants.ts
+++ b/packages/excalidraw/constants.ts
@@ -1,5 +1,5 @@
import cssVariables from "./css/variables.module.scss";
-import type { AppProps } from "./types";
+import type { AppProps, AppState } from "./types";
import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
import { COLOR_PALETTE } from "./colors";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
@@ -114,12 +114,24 @@ export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left",
};
-// 1-based in case we ever do `if(element.fontFamily)`
+/**
+ * // TODO: shouldn't be really `const`, likely neither have integers as values, due to value for the custom fonts, which should likely be some hash.
+ *
+ * Let's think this through and consider:
+ * - https://developer.mozilla.org/en-US/docs/Web/CSS/generic-family
+ * - https://drafts.csswg.org/css-fonts-4/#font-family-prop
+ * - https://learn.microsoft.com/en-us/typography/opentype/spec/ibmfc
+ */
export const FONT_FAMILY = {
Virgil: 1,
Helvetica: 2,
Cascadia: 3,
- Assistant: 4,
+ // leave 4 unused as it was historically used for Assistant (which we don't use anymore) or custom font (Obsidian)
+ Excalifont: 5,
+ Nunito: 6,
+ "Lilita One": 7,
+ "Comic Shanns": 8,
+ "Liberation Sans": 9,
};
export const THEME = {
@@ -147,7 +159,7 @@ export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const MIN_FONT_SIZE = 1;
export const DEFAULT_FONT_SIZE = 20;
-export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
+export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
export const DEFAULT_TEXT_ALIGN = "left";
export const DEFAULT_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}";
@@ -409,3 +421,9 @@ export const DEFAULT_FILENAME = "Untitled";
export const STATS_PANELS = { generalStats: 1, elementProperties: 2 } as const;
export const MIN_WIDTH_OR_HEIGHT = 1;
+
+export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
+ sharp: "sharp",
+ round: "round",
+ elbow: "elbow",
+};
diff --git a/packages/excalidraw/css/styles.scss b/packages/excalidraw/css/styles.scss
index 782969686..f0c4c5d09 100644
--- a/packages/excalidraw/css/styles.scss
+++ b/packages/excalidraw/css/styles.scss
@@ -152,7 +152,7 @@ body.excalidraw-cursor-resize * {
margin-bottom: 0.25rem;
font-size: 0.75rem;
color: var(--text-primary-color);
- font-weight: normal;
+ font-weight: 400;
display: block;
}
@@ -227,14 +227,7 @@ body.excalidraw-cursor-resize * {
label,
button,
.zIndexButton {
- @include outlineButtonStyles;
-
- padding: 0;
-
- svg {
- width: var(--default-icon-size);
- height: var(--default-icon-size);
- }
+ @include outlineButtonIconStyles;
}
}
@@ -394,7 +387,7 @@ body.excalidraw-cursor-resize * {
.App-menu__left {
overflow-y: auto;
padding: 0.75rem;
- width: 202px;
+ width: 200px;
box-sizing: border-box;
position: absolute;
}
@@ -585,7 +578,7 @@ body.excalidraw-cursor-resize * {
// use custom, minimalistic scrollbar
// (doesn't work in Firefox)
::-webkit-scrollbar {
- width: 3px;
+ width: 4px;
height: 3px;
}
@@ -664,6 +657,10 @@ body.excalidraw-cursor-resize * {
--button-hover-bg: #363541;
--button-bg: var(--color-surface-high);
}
+
+ .buttonList {
+ padding: 0.25rem 0;
+ }
}
.excalidraw__paragraph {
@@ -757,7 +754,7 @@ body.excalidraw-cursor-resize * {
padding: 1rem 1.6rem;
border-radius: 12px;
color: #fff;
- font-weight: bold;
+ font-weight: 700;
letter-spacing: 0.6px;
font-family: "Assistant";
}
diff --git a/packages/excalidraw/css/theme.scss b/packages/excalidraw/css/theme.scss
index aa520a0c5..3b1202314 100644
--- a/packages/excalidraw/css/theme.scss
+++ b/packages/excalidraw/css/theme.scss
@@ -151,6 +151,9 @@
--color-border-outline-variant: #c5c5d0;
--color-surface-primary-container: #e0dfff;
+ --color-badge: #0b6513;
+ --background-color-badge: #d3ffd2;
+
&.theme--dark {
&.theme--dark-background-none {
background: none;
diff --git a/packages/excalidraw/css/variables.module.scss b/packages/excalidraw/css/variables.module.scss
index cacce21e2..42e325a4c 100644
--- a/packages/excalidraw/css/variables.module.scss
+++ b/packages/excalidraw/css/variables.module.scss
@@ -124,6 +124,16 @@
}
}
+@mixin outlineButtonIconStyles {
+ @include outlineButtonStyles;
+ padding: 0;
+
+ svg {
+ width: var(--default-icon-size);
+ height: var(--default-icon-size);
+ }
+}
+
@mixin avatarStyles {
width: var(--avatar-size, 1.5rem);
height: var(--avatar-size, 1.5rem);
@@ -135,7 +145,7 @@
align-items: center;
cursor: pointer;
font-size: 0.75rem;
- font-weight: 800;
+ font-weight: 700;
line-height: 1;
color: var(--color-gray-90);
flex: 0 0 auto;
diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap
index 27460ce71..921118eb1 100644
--- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap
+++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap
@@ -84,13 +84,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
- "endArrowhead": null,
+ "elbowed": false,
+ "endArrowhead": "arrow",
"endBinding": {
"elementId": "ellipse-1",
- "fixedPoint": [
- 0.04,
- 0.4633333333333333,
- ],
+ "fixedPoint": null,
"focus": -0.008153707962747813,
"gap": 1,
},
@@ -121,10 +119,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null,
"startBinding": {
"elementId": "id47",
- "fixedPoint": [
- 1.0166666666666666,
- 0.5,
- ],
+ "fixedPoint": null,
"focus": -0.08139534883720931,
"gap": 1,
},
@@ -147,13 +142,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
- "endArrowhead": null,
+ "elbowed": false,
+ "endArrowhead": "arrow",
"endBinding": {
"elementId": "ellipse-1",
- "fixedPoint": [
- -0.01,
- 0.44666666666666666,
- ],
+ "fixedPoint": null,
"focus": 0.10666666666666667,
"gap": 3.834326468444573,
},
@@ -184,10 +177,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null,
"startBinding": {
"elementId": "diamond-1",
- "fixedPoint": [
- 0.9357142857142857,
- -0.05,
- ],
+ "fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -255,7 +245,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -301,7 +291,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -344,13 +334,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
},
],
"customData": undefined,
- "endArrowhead": null,
+ "elbowed": false,
+ "endArrowhead": "arrow",
"endBinding": {
"elementId": "text-2",
- "fixedPoint": [
- -2.05,
- 0.5,
- ],
+ "fixedPoint": null,
"focus": 0,
"gap": 205,
},
@@ -381,10 +369,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"startArrowhead": null,
"startBinding": {
"elementId": "text-1",
- "fixedPoint": [
- 1.0714285714285714,
- 0.5,
- ],
+ "fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -410,7 +395,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"containerId": "id48",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -453,13 +438,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
},
],
"customData": undefined,
- "endArrowhead": null,
+ "elbowed": false,
+ "endArrowhead": "arrow",
"endBinding": {
"elementId": "id40",
- "fixedPoint": [
- 1,
- 0.5,
- ],
+ "fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -490,10 +473,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"startArrowhead": null,
"startBinding": {
"elementId": "id39",
- "fixedPoint": [
- 1.05,
- 0.5,
- ],
+ "fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -519,7 +499,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"containerId": "id37",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -636,13 +616,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
},
],
"customData": undefined,
- "endArrowhead": null,
+ "elbowed": false,
+ "endArrowhead": "arrow",
"endBinding": {
"elementId": "id44",
- "fixedPoint": [
- -0.05,
- 0.5,
- ],
+ "fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -673,10 +651,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"startArrowhead": null,
"startBinding": {
"elementId": "id43",
- "fixedPoint": [
- 1.0714285714285714,
- 0.5,
- ],
+ "fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -702,7 +677,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"containerId": "id41",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -748,7 +723,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -794,7 +769,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -864,7 +839,8 @@ exports[`Test Transform > should transform linear elements 1`] = `
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
- "endArrowhead": null,
+ "elbowed": false,
+ "endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
@@ -911,7 +887,8 @@ exports[`Test Transform > should transform linear elements 2`] = `
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
- "endArrowhead": null,
+ "elbowed": false,
+ "endArrowhead": "triangle",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
@@ -937,7 +914,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
"roughness": 1,
"roundness": null,
"seed": Any
,
- "startArrowhead": null,
+ "startArrowhead": "dot",
"startBinding": null,
"strokeColor": "#1971c2",
"strokeStyle": "solid",
@@ -1247,7 +1224,7 @@ exports[`Test Transform > should transform text element 1`] = `
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -1288,7 +1265,7 @@ exports[`Test Transform > should transform text element 2`] = `
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -1503,13 +1480,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
},
],
"customData": undefined,
- "endArrowhead": null,
+ "elbowed": false,
+ "endArrowhead": "arrow",
"endBinding": {
"elementId": "Alice",
- "fixedPoint": [
- -0.07115855014454081,
- 0.5,
- ],
+ "fixedPoint": null,
"focus": 0,
"gap": 5.299874999999986,
},
@@ -1542,10 +1517,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null,
"startBinding": {
"elementId": "Bob",
- "fixedPoint": [
- 1.0885078135804176,
- 0.5,
- ],
+ "fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -1573,13 +1545,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
},
],
"customData": undefined,
- "endArrowhead": null,
+ "elbowed": false,
+ "endArrowhead": "arrow",
"endBinding": {
"elementId": "B",
- "fixedPoint": [
- -0.030114812723508376,
- 0.48466257668711654,
- ],
+ "fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -1608,10 +1578,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null,
"startBinding": {
"elementId": "Bob",
- "fixedPoint": [
- 0.39381496335223337,
- 1.1136363636363635,
- ],
+ "fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -1637,7 +1604,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "B",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [
@@ -1680,7 +1647,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "A",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [
@@ -1723,7 +1690,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Alice",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [
@@ -1766,7 +1733,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Bob",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [
@@ -1809,7 +1776,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Bob_Alice",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -1850,7 +1817,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Bob_B",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -1893,7 +1860,8 @@ exports[`Test Transform > should transform to labelled arrows when label provide
},
],
"customData": undefined,
- "endArrowhead": null,
+ "elbowed": false,
+ "endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
@@ -1945,7 +1913,8 @@ exports[`Test Transform > should transform to labelled arrows when label provide
},
],
"customData": undefined,
- "endArrowhead": null,
+ "elbowed": false,
+ "endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
@@ -1997,7 +1966,8 @@ exports[`Test Transform > should transform to labelled arrows when label provide
},
],
"customData": undefined,
- "endArrowhead": null,
+ "elbowed": false,
+ "endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
@@ -2049,7 +2019,8 @@ exports[`Test Transform > should transform to labelled arrows when label provide
},
],
"customData": undefined,
- "endArrowhead": null,
+ "elbowed": false,
+ "endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
@@ -2099,7 +2070,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id25",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -2140,7 +2111,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id26",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -2181,7 +2152,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id27",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -2223,7 +2194,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id28",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -2487,7 +2458,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id13",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -2528,7 +2499,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id14",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -2570,7 +2541,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id15",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -2614,7 +2585,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id16",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -2656,7 +2627,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id17",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -2699,7 +2670,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id18",
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
diff --git a/packages/excalidraw/data/reconcile.ts b/packages/excalidraw/data/reconcile.ts
index ea32c8162..aa663ebfa 100644
--- a/packages/excalidraw/data/reconcile.ts
+++ b/packages/excalidraw/data/reconcile.ts
@@ -1,5 +1,11 @@
+import throttle from "lodash.throttle";
+import { ENV } from "../constants";
import type { OrderedExcalidrawElement } from "../element/types";
-import { orderByFractionalIndex, syncInvalidIndices } from "../fractionalIndex";
+import {
+ orderByFractionalIndex,
+ syncInvalidIndices,
+ validateFractionalIndices,
+} from "../fractionalIndex";
import type { AppState } from "../types";
import type { MakeBrand } from "../utility-types";
import { arrayToMap } from "../utils";
@@ -33,6 +39,37 @@ const shouldDiscardRemoteElement = (
return false;
};
+const validateIndicesThrottled = throttle(
+ (
+ orderedElements: readonly OrderedExcalidrawElement[],
+ localElements: readonly OrderedExcalidrawElement[],
+ remoteElements: readonly RemoteExcalidrawElement[],
+ ) => {
+ if (
+ import.meta.env.DEV ||
+ import.meta.env.MODE === ENV.TEST ||
+ window?.DEBUG_FRACTIONAL_INDICES
+ ) {
+ // create new instances due to the mutation
+ const elements = syncInvalidIndices(
+ orderedElements.map((x) => ({ ...x })),
+ );
+
+ validateFractionalIndices(elements, {
+ // throw in dev & test only, to remain functional on `DEBUG_FRACTIONAL_INDICES`
+ shouldThrow: import.meta.env.DEV || import.meta.env.MODE === ENV.TEST,
+ includeBoundTextValidation: true,
+ reconciliationContext: {
+ localElements,
+ remoteElements,
+ },
+ });
+ }
+ },
+ 1000 * 60,
+ { leading: true, trailing: false },
+);
+
export const reconcileElements = (
localElements: readonly OrderedExcalidrawElement[],
remoteElements: readonly RemoteExcalidrawElement[],
@@ -72,6 +109,8 @@ export const reconcileElements = (
const orderedElements = orderByFractionalIndex(reconciledElements);
+ validateIndicesThrottled(orderedElements, localElements, remoteElements);
+
// de-duplicate indices
syncInvalidIndices(orderedElements);
diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts
index fa3ba9b59..0e1e82cce 100644
--- a/packages/excalidraw/data/restore.ts
+++ b/packages/excalidraw/data/restore.ts
@@ -25,6 +25,7 @@ import {
} from "../element";
import {
isArrowElement,
+ isElbowArrow,
isLinearElement,
isTextElement,
isUsingAdaptiveRadius,
@@ -45,14 +46,11 @@ import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import type { MarkOptional, Mutable } from "../utility-types";
-import {
- detectLineHeight,
- getContainerElement,
- getDefaultLineHeight,
-} from "../element/textElement";
+import { detectLineHeight, getContainerElement } from "../element/textElement";
import { normalizeLink } from "./url";
import { syncInvalidIndices } from "../fractionalIndex";
import { getSizeFromPoints } from "../points";
+import { getLineHeight } from "../fonts";
type RestoredAppState = Omit<
AppState,
@@ -96,12 +94,21 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
return DEFAULT_FONT_FAMILY;
};
-const repairBinding = (binding: PointBinding | null) => {
+const repairBinding = (
+ element: ExcalidrawLinearElement,
+ binding: PointBinding | null,
+): PointBinding | null => {
if (!binding) {
return null;
}
- return { ...binding, focus: binding.focus || 0, focusPoint: [0, 0] };
+ return {
+ ...binding,
+ focus: binding.focus || 0,
+ fixedPoint: isElbowArrow(element)
+ ? binding.fixedPoint ?? ([0, 0] as [number, number])
+ : null,
+ };
};
const restoreElementWithProperties = <
@@ -208,7 +215,7 @@ const restoreElement = (
detectLineHeight(element)
: // no element height likely means programmatic use, so default
// to a fixed line height
- getDefaultLineHeight(element.fontFamily));
+ getLineHeight(element.fontFamily));
element = restoreElementWithProperties(element, {
fontSize,
fontFamily,
@@ -267,8 +274,8 @@ const restoreElement = (
(element.type as ExcalidrawElementType | "draw") === "draw"
? "line"
: element.type,
- startBinding: repairBinding(element.startBinding),
- endBinding: repairBinding(element.endBinding),
+ startBinding: repairBinding(element, element.startBinding),
+ endBinding: repairBinding(element, element.endBinding),
lastCommittedPoint: null,
startArrowhead,
endArrowhead,
@@ -296,8 +303,8 @@ const restoreElement = (
// TODO: Separate arrow from linear element
return restoreElementWithProperties(element as ExcalidrawArrowElement, {
type: element.type,
- startBinding: repairBinding(element.startBinding),
- endBinding: repairBinding(element.endBinding),
+ startBinding: repairBinding(element, element.startBinding),
+ endBinding: repairBinding(element, element.endBinding),
lastCommittedPoint: null,
startArrowhead,
endArrowhead,
diff --git a/packages/excalidraw/data/transform.test.ts b/packages/excalidraw/data/transform.test.ts
index e1d85a58d..bdb37bc96 100644
--- a/packages/excalidraw/data/transform.test.ts
+++ b/packages/excalidraw/data/transform.test.ts
@@ -771,9 +771,9 @@ describe("Test Transform", () => {
const [arrow, rect] = excalidrawElements;
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1",
+ fixedPoint: null,
focus: 0,
gap: 205,
- fixedPoint: [-2.05, 0.5],
});
expect(rect.boundElements).toStrictEqual([
{
diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts
index e93f58502..cbddafb70 100644
--- a/packages/excalidraw/data/transform.ts
+++ b/packages/excalidraw/data/transform.ts
@@ -13,16 +13,13 @@ import {
import { bindLinearElement } from "../element/binding";
import type { ElementConstructorOpts } from "../element/newElement";
import {
+ newArrowElement,
newFrameElement,
newImageElement,
newMagicFrameElement,
newTextElement,
} from "../element/newElement";
-import {
- getDefaultLineHeight,
- measureText,
- normalizeText,
-} from "../element/textElement";
+import { measureText, normalizeText } from "../element/textElement";
import type {
ElementsMap,
ExcalidrawArrowElement,
@@ -54,6 +51,8 @@ import {
import { getSizeFromPoints } from "../points";
import { randomId } from "../random";
import { syncInvalidIndices } from "../fractionalIndex";
+import { getLineHeight } from "../fonts";
+import { isArrowElement } from "../element/typeChecks";
export type ValidLinearElement = {
type: "arrow" | "line";
@@ -548,7 +547,7 @@ export const convertToExcalidrawElements = (
case "arrow": {
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
- excalidrawElement = newLinearElement({
+ excalidrawElement = newArrowElement({
width,
height,
endArrowhead: "arrow",
@@ -557,6 +556,7 @@ export const convertToExcalidrawElements = (
[width, height],
],
...element,
+ type: "arrow",
});
Object.assign(
@@ -568,8 +568,7 @@ export const convertToExcalidrawElements = (
case "text": {
const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
- const lineHeight =
- element?.lineHeight || getDefaultLineHeight(fontFamily);
+ const lineHeight = element?.lineHeight || getLineHeight(fontFamily);
const text = element.text ?? "";
const normalizedText = normalizeText(text);
const metrics = measureText(
@@ -659,7 +658,7 @@ export const convertToExcalidrawElements = (
elementStore.add(container);
elementStore.add(text);
- if (container.type === "arrow") {
+ if (isArrowElement(container)) {
const originalStart =
element.type === "arrow" ? element?.start : undefined;
const originalEnd =
@@ -678,7 +677,7 @@ export const convertToExcalidrawElements = (
}
const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement(
- container as ExcalidrawArrowElement,
+ container,
originalStart,
originalEnd,
elementStore,
diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts
index a88068ee3..d50ba1560 100644
--- a/packages/excalidraw/element/binding.ts
+++ b/packages/excalidraw/element/binding.ts
@@ -23,6 +23,8 @@ import type {
ExcalidrawTextElement,
ExcalidrawArrowElement,
OrderedExcalidrawElement,
+ ExcalidrawElbowArrowElement,
+ FixedPoint,
} from "./types";
import type { Bounds } from "./bounds";
@@ -36,6 +38,7 @@ import {
isBindingElement,
isBoundToContainer,
isElbowArrow,
+ isFrameLikeElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
@@ -47,32 +50,29 @@ import { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { getElementShape } from "../shapes";
-import { headingForPointFromElement } from "./routing";
-import type { Heading } from "../math";
import {
aabbForElement,
- compareHeading,
+ clamp,
distanceSq2d,
+ getCenterForBounds,
+ getCenterForElement,
getCornerRadius,
+ pointInsideBounds,
+ pointToVector,
+ rotatePoint,
+} from "../math";
+import {
+ compareHeading,
HEADING_DOWN,
HEADING_LEFT,
HEADING_RIGHT,
HEADING_UP,
- pointInsideBounds,
- pointToVector,
- rotatePoint,
+ headingForPointFromElement,
vectorToHeading,
-} from "../math";
-import {
- debugDrawBounds,
- debugDrawPoint,
- debugDrawSegments,
-} from "../visualdebug";
-import {
- interceptPointsOfLineAndEllipse,
- interceptPointsOfSegmentAndRoundedRectangle,
-} from "../../utils/geometry/geometry";
-import type { LineSegment } from "../../utils/geometry/shape";
+ type Heading,
+} from "./heading";
+import { interceptPointsOfLineAndEllipse, interceptPointsOfSegmentAndRoundedRectangle } from "../../utils/geometry/geometry";
+import { LineSegment } from "../../utils/geometry/shape";
export type SuggestedBinding =
| NonDeleted
@@ -432,22 +432,26 @@ export const bindLinearElement = (
if (!isArrowElement(linearElement)) {
return;
}
+ const binding: PointBinding = {
+ elementId: hoveredElement.id,
+ ...calculateFocusAndGap(
+ linearElement,
+ hoveredElement,
+ startOrEnd,
+ elementsMap,
+ ),
+ ...(isElbowArrow(linearElement)
+ ? calculateFixedPointForElbowArrowBinding(
+ linearElement,
+ hoveredElement,
+ startOrEnd,
+ elementsMap,
+ )
+ : { fixedPoint: null }),
+ };
+
mutateElement(linearElement, {
- [startOrEnd === "start" ? "startBinding" : "endBinding"]: {
- elementId: hoveredElement.id,
- ...calculateFocusAndGap(
- linearElement,
- hoveredElement,
- startOrEnd,
- elementsMap,
- ),
- ...calculateFixedPointForElbowArrowBinding(
- linearElement,
- hoveredElement,
- startOrEnd,
- elementsMap,
- ),
- } as PointBinding,
+ [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
});
const boundElementsMap = arrayToMap(hoveredElement.boundElements || []);
@@ -517,7 +521,14 @@ export const getHoveredElementForBinding = (
elements,
(element) =>
isBindableElement(element, false) &&
- bindingBorderTest(element, pointerCoords, elementsMap, fullShape),
+ bindingBorderTest(
+ element,
+ pointerCoords,
+ elementsMap,
+ // disable fullshape snapping for frame elements so we
+ // can bind to frame children
+ fullShape && !isFrameLikeElement(element),
+ ),
);
return hoveredElement as NonDeleted | null;
};
@@ -687,13 +698,42 @@ const getSimultaneouslyUpdatedElementIds = (
return new Set((simultaneouslyUpdated || []).map((element) => element.id));
};
-// TODO: See if it can be merged with routing.ts: getBindPointHeading()
-const getHeadingForElbowArrowSnap = (
- point: Point,
- otherPoint: Point,
+export const getHeadingForElbowArrowSnap = (
+ point: Readonly,
+ otherPoint: Readonly,
+ bindableElement: ExcalidrawBindableElement | undefined | null,
+ aabb: Bounds | undefined | null,
+ elementsMap: ElementsMap,
+ origPoint: Point,
+): Heading => {
+ const otherPointHeading = vectorToHeading(pointToVector(otherPoint, point));
+
+ if (!bindableElement || !aabb) {
+ return otherPointHeading;
+ }
+
+ const distance = getDistanceForBinding(
+ origPoint,
+ bindableElement,
+ elementsMap,
+ );
+
+ if (!distance) {
+ return vectorToHeading(
+ pointToVector(point, getCenterForElement(bindableElement)),
+ );
+ }
+
+ const pointHeading = headingForPointFromElement(bindableElement, aabb, point);
+
+ return pointHeading;
+};
+
+const getDistanceForBinding = (
+ point: Readonly,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
-): Heading | null => {
+) => {
const distance = distanceToBindableElement(
bindableElement,
point,
@@ -705,79 +745,61 @@ const getHeadingForElbowArrowSnap = (
bindableElement.height,
);
- if (distance > bindDistance) {
- return null;
- }
- const pointHeading = headingForPointFromElement(
- bindableElement,
- aabbForElement(
- bindableElement,
- Array(4).fill(
- distanceToBindableElement(bindableElement, point, elementsMap),
- ) as [number, number, number, number],
- ),
- point,
- );
- const otherPointHeading = vectorToHeading(pointToVector(otherPoint, point));
- const isInner =
- otherPointHeading === HEADING_LEFT || otherPointHeading === HEADING_RIGHT
- ? distance < bindableElement.width * -0.2
- : distance < bindableElement.height * -0.2;
-
- return isInner ? otherPointHeading : pointHeading;
+ return distance > bindDistance ? null : distance;
};
export const bindPointToSnapToElementOutline = (
- point: Point,
- otherPoint: Point,
- bindableElement: ExcalidrawBindableElement,
+ point: Readonly,
+ otherPoint: Readonly,
+ bindableElement: ExcalidrawBindableElement | undefined,
elementsMap: ElementsMap,
): Point => {
- const aabb = aabbForElement(bindableElement, [
- FIXED_BINDING_DISTANCE,
- FIXED_BINDING_DISTANCE,
- FIXED_BINDING_DISTANCE,
- FIXED_BINDING_DISTANCE,
- ]);
- const center = [(aabb[0] + aabb[2]) / 2, (aabb[1] + aabb[3]) / 2] as Point;
+ const aabb = bindableElement && aabbForElement(bindableElement);
- const heading = getHeadingForElbowArrowSnap(
- point,
- otherPoint,
- bindableElement,
- elementsMap,
- );
- //debugDrawBounds(aabb);
- //debugDrawPoint(point, "green");
- if (heading) {
- const headingIsVertical =
- compareHeading(heading, HEADING_UP) ||
- compareHeading(heading, HEADING_DOWN);
- const intersections = _intersectElementWithLine(
- bindableElement,
- headingIsVertical
- ? [point[0], point[1] - 2 * bindableElement.height]
- : [point[0] - 2 * bindableElement.width, point[1]],
- headingIsVertical
- ? [point[0], point[1] + 2 * bindableElement.height]
- : [point[0] + 2 * bindableElement.width, point[1]],
- FIXED_BINDING_DISTANCE,
+ if (bindableElement && aabb) {
+ // TODO: Dirty hack until tangents are properly calculated
+ const intersections = [
+ ...intersectElementWithLine(
+ bindableElement,
+ [point[0], point[1] - 2 * bindableElement.height],
+ [point[0], point[1] + 2 * bindableElement.height],
+ FIXED_BINDING_DISTANCE,
+ elementsMap,
+ ),
+ ...intersectElementWithLine(
+ bindableElement,
+ [point[0] - 2 * bindableElement.width, point[1]],
+ [point[0] + 2 * bindableElement.width, point[1]],
+ FIXED_BINDING_DISTANCE,
+ elementsMap,
+ ),
+ ].map((i) =>
+ distanceToBindableElement(bindableElement, i, elementsMap) >
+ Math.min(bindableElement.width, bindableElement.height) / 2
+ ? ([-1 * i[0], -1 * i[1]] as Point)
+ : i,
);
+
+ const heading = headingForPointFromElement(bindableElement, aabb, point);
+ const isVertical =
+ compareHeading(heading, HEADING_LEFT) ||
+ compareHeading(heading, HEADING_RIGHT);
+ const dist = distanceToBindableElement(bindableElement, point, elementsMap);
+ const isInner = isVertical
+ ? dist < bindableElement.width * -0.1
+ : dist < bindableElement.height * -0.1;
+
intersections.sort(
(a, b) => distanceSq2d(a, point) - distanceSq2d(b, point),
);
- debugDrawSegments([
- headingIsVertical
- ? [point[0], point[1] - 2 * bindableElement.height]
- : [point[0] - 2 * bindableElement.width, point[1]],
- headingIsVertical
- ? [point[0], point[1] + 2 * bindableElement.height]
- : [point[0] + 2 * bindableElement.width, point[1]],
- ]);
- if (intersections.length > 0) {
- intersections.forEach((point) => debugDrawPoint(point, "red"));
- return intersections[0];
- }
+
+ return isInner
+ ? headingToMidBindPoint(otherPoint, bindableElement, aabb)
+ : intersections.filter((i) =>
+ isVertical
+ ? Math.abs(point[1] - i[1]) < 0.1
+ : Math.abs(point[0] - i[0]) < 0.1,
+ )[0] ?? point;
}
return point;
@@ -836,7 +858,42 @@ export const _intersectElementWithLine = (
halfWidth: halfWidth + gap,
halfHeight: halfHeight + gap,
},
- [a, b] as LineSegment,
+ [a, b] as LineSegment, );
+ }
+};
+
+const headingToMidBindPoint = (
+ point: Point,
+ bindableElement: ExcalidrawBindableElement,
+ aabb: Bounds,
+): Point => {
+ const center = getCenterForBounds(aabb);
+ const heading = vectorToHeading(pointToVector(point, center));
+
+ switch (true) {
+ case compareHeading(heading, HEADING_UP):
+ return rotatePoint(
+ [(aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]],
+ center,
+ bindableElement.angle,
+ );
+ case compareHeading(heading, HEADING_RIGHT):
+ return rotatePoint(
+ [aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1],
+ center,
+ bindableElement.angle,
+ );
+ case compareHeading(heading, HEADING_DOWN):
+ return rotatePoint(
+ [(aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]],
+ center,
+ bindableElement.angle,
+ );
+ default:
+ return rotatePoint(
+ [aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1],
+ center,
+ bindableElement.angle,
);
}
};
@@ -845,41 +902,86 @@ export const avoidRectangularCorner = (
element: ExcalidrawBindableElement,
p: Point,
): Point => {
- // NOTE: Only relevant at angle = 0, so no rotation handling
+ const center = getCenterForElement(element);
+ const nonRotatedPoint = rotatePoint(p, center, -element.angle);
- if (p[0] < element.x && p[1] < element.y) {
+ if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
// Top left
- if (p[1] - element.y > -FIXED_BINDING_DISTANCE) {
- return [element.x - FIXED_BINDING_DISTANCE, element.y];
+ if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
+ return rotatePoint(
+ [element.x - FIXED_BINDING_DISTANCE, element.y],
+ center,
+ element.angle,
+ );
}
- return [element.x, element.y - FIXED_BINDING_DISTANCE];
- } else if (p[0] < element.x && p[1] > element.y + element.height) {
- // Bottom left
- if (p[0] - element.x > -FIXED_BINDING_DISTANCE) {
- return [element.x, element.y + element.height + FIXED_BINDING_DISTANCE];
- }
- return [element.x - FIXED_BINDING_DISTANCE, element.y + element.height];
+ return rotatePoint(
+ [element.x, element.y - FIXED_BINDING_DISTANCE],
+ center,
+ element.angle,
+ );
} else if (
- p[0] > element.x + element.width &&
- p[1] > element.y + element.height
+ nonRotatedPoint[0] < element.x &&
+ nonRotatedPoint[1] > element.y + element.height
+ ) {
+ // Bottom left
+ if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
+ return rotatePoint(
+ [element.x, element.y + element.height + FIXED_BINDING_DISTANCE],
+ center,
+ element.angle,
+ );
+ }
+ return rotatePoint(
+ [element.x - FIXED_BINDING_DISTANCE, element.y + element.height],
+ center,
+ element.angle,
+ );
+ } else if (
+ nonRotatedPoint[0] > element.x + element.width &&
+ nonRotatedPoint[1] > element.y + element.height
) {
// Bottom right
- if (p[0] - element.x < element.width + FIXED_BINDING_DISTANCE) {
- return [
- element.x + element.width,
- element.y + element.height + FIXED_BINDING_DISTANCE,
- ];
+ if (
+ nonRotatedPoint[0] - element.x <
+ element.width + FIXED_BINDING_DISTANCE
+ ) {
+ return rotatePoint(
+ [
+ element.x + element.width,
+ element.y + element.height + FIXED_BINDING_DISTANCE,
+ ],
+ center,
+ element.angle,
+ );
}
- return [
- element.x + element.width + FIXED_BINDING_DISTANCE,
- element.y + element.height,
- ];
- } else if (p[0] > element.x + element.width && p[1] < element.y) {
+ return rotatePoint(
+ [
+ element.x + element.width + FIXED_BINDING_DISTANCE,
+ element.y + element.height,
+ ],
+ center,
+ element.angle,
+ );
+ } else if (
+ nonRotatedPoint[0] > element.x + element.width &&
+ nonRotatedPoint[1] < element.y
+ ) {
// Top right
- if (p[0] - element.x < element.width + FIXED_BINDING_DISTANCE) {
- return [element.x + element.width, element.y - FIXED_BINDING_DISTANCE];
+ if (
+ nonRotatedPoint[0] - element.x <
+ element.width + FIXED_BINDING_DISTANCE
+ ) {
+ return rotatePoint(
+ [element.x + element.width, element.y - FIXED_BINDING_DISTANCE],
+ center,
+ element.angle,
+ );
}
- return [element.x + element.width + FIXED_BINDING_DISTANCE, element.y];
+ return rotatePoint(
+ [element.x + element.width + FIXED_BINDING_DISTANCE, element.y],
+ center,
+ element.angle,
+ );
}
return p;
@@ -894,24 +996,29 @@ export const snapToMid = (
const center = [x + width / 2 - 0.1, y + height / 2 - 0.1] as Point;
const nonRotated = rotatePoint(p, center, -angle);
+ // snap-to-center point is adaptive to element size, but we don't want to go
+ // above and below certain px distance
+ const verticalThrehsold = clamp(tolerance * height, 5, 80);
+ const horizontalThrehsold = clamp(tolerance * width, 5, 80);
+
if (
nonRotated[0] <= x + width / 2 &&
- nonRotated[1] > center[1] - tolerance * height &&
- nonRotated[1] < center[1] + tolerance * height
+ nonRotated[1] > center[1] - verticalThrehsold &&
+ nonRotated[1] < center[1] + verticalThrehsold
) {
// LEFT
return rotatePoint([x - FIXED_BINDING_DISTANCE, center[1]], center, angle);
} else if (
nonRotated[1] <= y + height / 2 &&
- nonRotated[0] > center[0] - tolerance * width &&
- nonRotated[0] < center[0] + tolerance * width
+ nonRotated[0] > center[0] - horizontalThrehsold &&
+ nonRotated[0] < center[0] + horizontalThrehsold
) {
// TOP
return rotatePoint([center[0], y - FIXED_BINDING_DISTANCE], center, angle);
} else if (
nonRotated[0] >= x + width / 2 &&
- nonRotated[1] > center[1] - tolerance * height &&
- nonRotated[1] < center[1] + tolerance * height
+ nonRotated[1] > center[1] - verticalThrehsold &&
+ nonRotated[1] < center[1] + verticalThrehsold
) {
// RIGHT
return rotatePoint(
@@ -921,8 +1028,8 @@ export const snapToMid = (
);
} else if (
nonRotated[1] >= y + height / 2 &&
- nonRotated[0] > center[0] - tolerance * width &&
- nonRotated[0] < center[0] + tolerance * width
+ nonRotated[0] > center[0] - horizontalThrehsold &&
+ nonRotated[0] < center[0] + horizontalThrehsold
) {
// DOWN
return rotatePoint(
@@ -955,14 +1062,14 @@ const updateBoundPoint = (
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
if (isElbowArrow(linearElement)) {
- const { fixedPoint } =
- binding ??
+ const fixedPoint =
+ binding.fixedPoint ??
calculateFixedPointForElbowArrowBinding(
linearElement,
bindableElement,
startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
- );
+ ).fixedPoint;
const globalMidPoint = [
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
@@ -1028,12 +1135,12 @@ const updateBoundPoint = (
);
};
-const calculateFixedPointForElbowArrowBinding = (
- linearElement: NonDeleted,
+export const calculateFixedPointForElbowArrowBinding = (
+ linearElement: NonDeleted,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
-) => {
+): { fixedPoint: FixedPoint } => {
const bounds = [
hoveredElement.x,
hoveredElement.y,
@@ -1042,39 +1149,39 @@ const calculateFixedPointForElbowArrowBinding = (
] as Bounds;
const edgePointIndex =
startOrEnd === "start" ? 0 : linearElement.points.length - 1;
- const otherPointIndex =
- startOrEnd === "end" ? 0 : linearElement.points.length - 1;
const globalPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
edgePointIndex,
elementsMap,
);
+ const otherGlobalPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
+ linearElement,
+ edgePointIndex,
+ elementsMap,
+ );
+ const snappedPoint = bindPointToSnapToElementOutline(
+ globalPoint,
+ otherGlobalPoint,
+ hoveredElement,
+ elementsMap,
+ );
const globalMidPoint = [
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
] as Point;
- const nonRotatedGlobalPoint = rotatePoint(
- globalPoint,
+ const nonRotatedSnappedGlobalPoint = rotatePoint(
+ snappedPoint,
globalMidPoint,
-hoveredElement.angle,
) as Point;
- const otherPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
- linearElement,
- otherPointIndex,
- elementsMap,
- );
- const snappedPoint = bindPointToSnapToElementOutline(
- nonRotatedGlobalPoint,
- otherPoint,
- hoveredElement,
- elementsMap,
- );
return {
fixedPoint: [
- (snappedPoint[0] - hoveredElement.x) / hoveredElement.width,
- (snappedPoint[1] - hoveredElement.y) / hoveredElement.height,
- ] as Point,
+ (nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
+ hoveredElement.width,
+ (nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
+ hoveredElement.height,
+ ] as [number, number],
};
};
@@ -1086,18 +1193,18 @@ const maybeCalculateNewGapWhenScaling = (
if (currentBinding == null || newSize == null) {
return currentBinding;
}
- const { gap, focus, elementId, fixedPoint } = currentBinding;
const { width: newWidth, height: newHeight } = newSize;
const { width, height } = changedElement;
const newGap = Math.max(
1,
Math.min(
maxBindingGap(changedElement, newWidth, newHeight),
- gap * (newWidth < newHeight ? newWidth / width : newHeight / height),
+ currentBinding.gap *
+ (newWidth < newHeight ? newWidth / width : newHeight / height),
),
);
- return { elementId, gap: newGap, focus, fixedPoint };
+ return { ...currentBinding, gap: newGap };
};
const getElligibleElementForBindingElement = (
@@ -1225,12 +1332,9 @@ const newBindingAfterDuplication = (
if (binding == null) {
return null;
}
- const { elementId, focus, gap, fixedPoint } = binding;
return {
- focus,
- gap,
- fixedPoint,
- elementId: oldIdToDuplicatedId.get(elementId) ?? elementId,
+ ...binding,
+ elementId: oldIdToDuplicatedId.get(binding.elementId) ?? binding.elementId,
};
};
@@ -2125,3 +2229,62 @@ export class BindableElement {
);
};
}
+
+export const getGlobalFixedPointForBindableElement = (
+ fixedPointRatio: [number, number],
+ element: ExcalidrawBindableElement,
+) => {
+ const [fixedX, fixedY] = fixedPointRatio;
+ return rotatePoint(
+ [element.x + element.width * fixedX, element.y + element.height * fixedY],
+ getCenterForElement(element),
+ element.angle,
+ );
+};
+
+const getGlobalFixedPoints = (
+ arrow: ExcalidrawElbowArrowElement,
+ elementsMap: ElementsMap,
+) => {
+ const startElement =
+ arrow.startBinding &&
+ (elementsMap.get(arrow.startBinding.elementId) as
+ | ExcalidrawBindableElement
+ | undefined);
+ const endElement =
+ arrow.endBinding &&
+ (elementsMap.get(arrow.endBinding.elementId) as
+ | ExcalidrawBindableElement
+ | undefined);
+ const startPoint: Point =
+ startElement && arrow.startBinding
+ ? getGlobalFixedPointForBindableElement(
+ arrow.startBinding.fixedPoint,
+ startElement as ExcalidrawBindableElement,
+ )
+ : [arrow.x + arrow.points[0][0], arrow.y + arrow.points[0][1]];
+ const endPoint: Point =
+ endElement && arrow.endBinding
+ ? getGlobalFixedPointForBindableElement(
+ arrow.endBinding.fixedPoint,
+ endElement as ExcalidrawBindableElement,
+ )
+ : [
+ arrow.x + arrow.points[arrow.points.length - 1][0],
+ arrow.y + arrow.points[arrow.points.length - 1][1],
+ ];
+
+ return [startPoint, endPoint];
+};
+
+export const getArrowLocalFixedPoints = (
+ arrow: ExcalidrawElbowArrowElement,
+ elementsMap: ElementsMap,
+) => {
+ const [startPoint, endPoint] = getGlobalFixedPoints(arrow, elementsMap);
+
+ return [
+ LinearElementEditor.pointFromAbsoluteCoords(arrow, startPoint, elementsMap),
+ LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap),
+ ];
+};
diff --git a/packages/excalidraw/element/collision.ts b/packages/excalidraw/element/collision.ts
index 704c24556..954326ca0 100644
--- a/packages/excalidraw/element/collision.ts
+++ b/packages/excalidraw/element/collision.ts
@@ -18,6 +18,7 @@ import {
isImageElement,
isTextElement,
} from "./typeChecks";
+import { getBoundTextShape } from "../shapes";
export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") {
@@ -97,6 +98,12 @@ export const hitElementBoundingBoxOnly = (
) => {
return (
!hitElementItself(hitArgs) &&
+ // bound text is considered part of the element (even if it's outside the bounding box)
+ !hitElementBoundText(
+ hitArgs.x,
+ hitArgs.y,
+ getBoundTextShape(hitArgs.element, elementsMap),
+ ) &&
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
);
};
@@ -105,6 +112,6 @@ export const hitElementBoundText = (
x: number,
y: number,
textShape: GeometricShape | null,
-) => {
- return textShape && isPointInShape([x, y], textShape);
+): boolean => {
+ return !!textShape && isPointInShape([x, y], textShape);
};
diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts
index 196d29568..f031eb412 100644
--- a/packages/excalidraw/element/dragElements.ts
+++ b/packages/excalidraw/element/dragElements.ts
@@ -19,7 +19,7 @@ import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
- selectedElements: NonDeletedExcalidrawElement[],
+ _selectedElements: NonDeletedExcalidrawElement[],
offset: { x: number; y: number },
scene: Scene,
snapOffset: {
@@ -29,13 +29,24 @@ export const dragSelectedElements = (
gridSize: AppState["gridSize"],
) => {
if (
- selectedElements.length === 1 &&
- isArrowElement(selectedElements[0]) &&
- isElbowArrow(selectedElements[0]) &&
- (selectedElements[0].startBinding || selectedElements[0].endBinding)
+ _selectedElements.length === 1 &&
+ isArrowElement(_selectedElements[0]) &&
+ isElbowArrow(_selectedElements[0]) &&
+ (_selectedElements[0].startBinding || _selectedElements[0].endBinding)
) {
return;
}
+
+ const selectedElements = _selectedElements.filter(
+ (el) =>
+ !(
+ isArrowElement(el) &&
+ isElbowArrow(el) &&
+ el.startBinding &&
+ el.endBinding
+ ),
+ );
+
// we do not want a frame and its elements to be selected at the same time
// but when it happens (due to some bug), we want to avoid updating element
// in the frame twice, hence the use of set
diff --git a/packages/excalidraw/element/heading.ts b/packages/excalidraw/element/heading.ts
new file mode 100644
index 000000000..a8b3a3fa0
--- /dev/null
+++ b/packages/excalidraw/element/heading.ts
@@ -0,0 +1,146 @@
+import { lineAngle } from "../../utils/geometry/geometry";
+import type { Point, Vector } from "../../utils/geometry/shape";
+import {
+ getCenterForBounds,
+ PointInTriangle,
+ rotatePoint,
+ scalePointFromOrigin,
+} from "../math";
+import type { Bounds } from "./bounds";
+import type { ExcalidrawBindableElement } from "./types";
+
+export const HEADING_RIGHT = [1, 0] as Heading;
+export const HEADING_DOWN = [0, 1] as Heading;
+export const HEADING_LEFT = [-1, 0] as Heading;
+export const HEADING_UP = [0, -1] as Heading;
+export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
+
+export const headingForDiamond = (a: Point, b: Point) => {
+ const angle = lineAngle([a, b]);
+ if (angle >= 315 || angle < 45) {
+ return HEADING_UP;
+ } else if (angle >= 45 && angle < 135) {
+ return HEADING_RIGHT;
+ } else if (angle >= 135 && angle < 225) {
+ return HEADING_DOWN;
+ }
+ return HEADING_LEFT;
+};
+
+export const vectorToHeading = (vec: Vector): Heading => {
+ const [x, y] = vec;
+ const absX = Math.abs(x);
+ const absY = Math.abs(y);
+ if (x > absY) {
+ return HEADING_RIGHT;
+ } else if (x <= -absY) {
+ return HEADING_LEFT;
+ } else if (y > absX) {
+ return HEADING_DOWN;
+ }
+ return HEADING_UP;
+};
+
+export const compareHeading = (a: Heading, b: Heading) =>
+ a[0] === b[0] && a[1] === b[1];
+
+// Gets the heading for the point by creating a bounding box around the rotated
+// close fitting bounding box, then creating 4 search cones around the center of
+// the external bbox.
+export const headingForPointFromElement = (
+ element: Readonly,
+ aabb: Readonly,
+ point: Readonly,
+): Heading => {
+ const SEARCH_CONE_MULTIPLIER = 2;
+
+ const midPoint = getCenterForBounds(aabb);
+
+ if (element.type === "diamond") {
+ if (point[0] < element.x) {
+ return HEADING_LEFT;
+ } else if (point[1] < element.y) {
+ return HEADING_UP;
+ } else if (point[0] > element.x + element.width) {
+ return HEADING_RIGHT;
+ } else if (point[1] > element.y + element.height) {
+ return HEADING_DOWN;
+ }
+
+ const top = rotatePoint(
+ scalePointFromOrigin(
+ [element.x + element.width / 2, element.y],
+ midPoint,
+ SEARCH_CONE_MULTIPLIER,
+ ),
+ midPoint,
+ element.angle,
+ );
+ const right = rotatePoint(
+ scalePointFromOrigin(
+ [element.x + element.width, element.y + element.height / 2],
+ midPoint,
+ SEARCH_CONE_MULTIPLIER,
+ ),
+ midPoint,
+ element.angle,
+ );
+ const bottom = rotatePoint(
+ scalePointFromOrigin(
+ [element.x + element.width / 2, element.y + element.height],
+ midPoint,
+ SEARCH_CONE_MULTIPLIER,
+ ),
+ midPoint,
+ element.angle,
+ );
+ const left = rotatePoint(
+ scalePointFromOrigin(
+ [element.x, element.y + element.height / 2],
+ midPoint,
+ SEARCH_CONE_MULTIPLIER,
+ ),
+ midPoint,
+ element.angle,
+ );
+
+ if (PointInTriangle(point, top, right, midPoint)) {
+ return headingForDiamond(top, right);
+ } else if (PointInTriangle(point, right, bottom, midPoint)) {
+ return headingForDiamond(right, bottom);
+ } else if (PointInTriangle(point, bottom, left, midPoint)) {
+ return headingForDiamond(bottom, left);
+ }
+
+ return headingForDiamond(left, top);
+ }
+
+ const topLeft = scalePointFromOrigin(
+ [aabb[0], aabb[1]],
+ midPoint,
+ SEARCH_CONE_MULTIPLIER,
+ );
+ const topRight = scalePointFromOrigin(
+ [aabb[2], aabb[1]],
+ midPoint,
+ SEARCH_CONE_MULTIPLIER,
+ );
+ const bottomLeft = scalePointFromOrigin(
+ [aabb[0], aabb[3]],
+ midPoint,
+ SEARCH_CONE_MULTIPLIER,
+ );
+ const bottomRight = scalePointFromOrigin(
+ [aabb[2], aabb[3]],
+ midPoint,
+ SEARCH_CONE_MULTIPLIER,
+ );
+
+ return PointInTriangle(point, topLeft, topRight, midPoint)
+ ? HEADING_UP
+ : PointInTriangle(point, topRight, bottomRight, midPoint)
+ ? HEADING_RIGHT
+ : PointInTriangle(point, bottomRight, bottomLeft, midPoint)
+ ? HEADING_DOWN
+ : HEADING_LEFT;
+};
diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts
index 1ed8f7bcd..68e400649 100644
--- a/packages/excalidraw/element/linearElementEditor.ts
+++ b/packages/excalidraw/element/linearElementEditor.ts
@@ -7,8 +7,8 @@ import type {
ExcalidrawTextElementWithContainer,
ElementsMap,
NonDeletedSceneElementsMap,
- ExcalidrawArrowElement,
OrderedExcalidrawElement,
+ FixedPointBinding,
} from "./types";
import {
distance2d,
@@ -44,7 +44,11 @@ import {
isBindingEnabled,
} from "./binding";
import { tupleToCoors } from "../utils";
-import { isBindingElement, isElbowArrow } from "./typeChecks";
+import {
+ isBindingElement,
+ isElbowArrow,
+ isFixedPointBinding,
+} from "./typeChecks";
import { KEYS, shouldRotateWithDiscreteAngle } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { DRAGGING_THRESHOLD } from "../constants";
@@ -1423,12 +1427,31 @@ export class LinearElementEditor {
},
) {
if (isElbowArrow(element)) {
+ const bindings: {
+ startBinding?: FixedPointBinding | null;
+ endBinding?: FixedPointBinding | null;
+ } = {};
+ if (otherUpdates?.startBinding !== undefined) {
+ bindings.startBinding =
+ otherUpdates.startBinding !== null &&
+ isFixedPointBinding(otherUpdates.startBinding)
+ ? otherUpdates.startBinding
+ : null;
+ }
+ if (otherUpdates?.endBinding !== undefined) {
+ bindings.endBinding =
+ otherUpdates.endBinding !== null &&
+ isFixedPointBinding(otherUpdates.endBinding)
+ ? otherUpdates.endBinding
+ : null;
+ }
+
mutateElbowArrow(
- element as ExcalidrawArrowElement,
+ element,
scene,
nextPoints,
[offsetX, offsetY],
- otherUpdates,
+ bindings,
options,
);
} else {
diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts
index 1d7e9d46e..de0adeeff 100644
--- a/packages/excalidraw/element/mutateElement.ts
+++ b/packages/excalidraw/element/mutateElement.ts
@@ -107,6 +107,8 @@ export const mutateElement = >(
export const newElementWith = (
element: TElement,
updates: ElementUpdate,
+ /** pass `true` to always regenerate */
+ force = false,
): TElement => {
let didChange = false;
for (const key in updates) {
@@ -123,7 +125,7 @@ export const newElementWith = (
}
}
- if (!didChange) {
+ if (!didChange && !force) {
return element;
}
diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts
index f2133b1f5..1f9bd6805 100644
--- a/packages/excalidraw/element/newElement.ts
+++ b/packages/excalidraw/element/newElement.ts
@@ -37,7 +37,6 @@ import {
normalizeText,
wrapText,
getBoundTextMaxWidth,
- getDefaultLineHeight,
} from "./textElement";
import {
DEFAULT_ELEMENT_PROPS,
@@ -48,6 +47,7 @@ import {
VERTICAL_ALIGN,
} from "../constants";
import type { MarkOptional, Merge, Mutable } from "../utility-types";
+import { getLineHeight } from "../fonts";
export type ElementConstructorOpts = MarkOptional<
Omit,
@@ -229,7 +229,7 @@ export const newTextElement = (
): NonDeleted => {
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
- const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily);
+ const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
const text = normalizeText(opts.text);
const metrics = measureText(
text,
@@ -534,7 +534,7 @@ export const regenerateId = (
if (
window.h?.app
?.getSceneElementsIncludingDeleted()
- .find((el) => el.id === nextId)
+ .find((el: ExcalidrawElement) => el.id === nextId)
) {
nextId += "_copy";
}
diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts
index 2822223a2..ddf9fb1da 100644
--- a/packages/excalidraw/element/resizeElements.ts
+++ b/packages/excalidraw/element/resizeElements.ts
@@ -31,7 +31,7 @@ import {
} from "./typeChecks";
import { mutateElement } from "./mutateElement";
import { getFontString } from "../utils";
-import { updateBoundElements } from "./binding";
+import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
import type {
MaybeTransformHandleType,
TransformHandleDirection,
@@ -52,7 +52,7 @@ import {
} from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups";
-import { getArrowLocalFixedPoints, mutateElbowArrow } from "./routing";
+import { mutateElbowArrow } from "./routing";
export const normalizeAngle = (angle: number): number => {
if (angle < 0) {
diff --git a/packages/excalidraw/element/routing.test.tsx b/packages/excalidraw/element/routing.test.tsx
new file mode 100644
index 000000000..d159deeaa
--- /dev/null
+++ b/packages/excalidraw/element/routing.test.tsx
@@ -0,0 +1,216 @@
+import React from "react";
+import Scene from "../scene/Scene";
+import { API } from "../tests/helpers/api";
+import { Pointer, UI } from "../tests/helpers/ui";
+import {
+ fireEvent,
+ GlobalTestState,
+ queryByTestId,
+ render,
+} from "../tests/test-utils";
+import { bindLinearElement } from "./binding";
+import { Excalidraw } from "../index";
+import { mutateElbowArrow } from "./routing";
+import type {
+ ExcalidrawArrowElement,
+ ExcalidrawBindableElement,
+ ExcalidrawElbowArrowElement,
+} from "./types";
+import { ARROW_TYPE } from "../constants";
+
+const { h } = window;
+
+const mouse = new Pointer("mouse");
+
+const editInput = (input: HTMLInputElement, value: string) => {
+ input.focus();
+ fireEvent.change(input, { target: { value } });
+ input.blur();
+};
+
+const getStatsProperty = (label: string) => {
+ const elementStats = UI.queryStats()?.querySelector("#elementStats");
+
+ if (elementStats) {
+ const properties = elementStats?.querySelector(".statsItem");
+ return (
+ properties?.querySelector?.(
+ `.drag-input-container[data-testid="${label}"]`,
+ ) || null
+ );
+ }
+
+ return null;
+};
+
+describe("elbow arrow routing", () => {
+ it("can properly generate orthogonal arrow points", () => {
+ const scene = new Scene();
+ const arrow = API.createElement({
+ type: "arrow",
+ elbowed: true,
+ }) as ExcalidrawElbowArrowElement;
+ scene.insertElement(arrow);
+ mutateElbowArrow(arrow, scene, [
+ [-45 - arrow.x, -100.1 - arrow.y],
+ [45 - arrow.x, 99.9 - arrow.y],
+ ]);
+ expect(arrow.points).toEqual([
+ [0, 0],
+ [0, 100],
+ [90, 100],
+ [90, 200],
+ ]);
+ expect(arrow.x).toEqual(-45);
+ expect(arrow.y).toEqual(-100.1);
+ expect(arrow.width).toEqual(90);
+ expect(arrow.height).toEqual(200);
+ });
+ it("can generate proper points for bound elbow arrow", () => {
+ const scene = new Scene();
+ const rectangle1 = API.createElement({
+ type: "rectangle",
+ x: -150,
+ y: -150,
+ width: 100,
+ height: 100,
+ }) as ExcalidrawBindableElement;
+ const rectangle2 = API.createElement({
+ type: "rectangle",
+ x: 50,
+ y: 50,
+ width: 100,
+ height: 100,
+ }) as ExcalidrawBindableElement;
+ const arrow = API.createElement({
+ type: "arrow",
+ elbowed: true,
+ x: -45,
+ y: -100.1,
+ width: 90,
+ height: 200,
+ points: [
+ [0, 0],
+ [90, 200],
+ ],
+ }) as ExcalidrawElbowArrowElement;
+ scene.insertElement(rectangle1);
+ scene.insertElement(rectangle2);
+ scene.insertElement(arrow);
+ const elementsMap = scene.getNonDeletedElementsMap();
+ bindLinearElement(arrow, rectangle1, "start", elementsMap);
+ bindLinearElement(arrow, rectangle2, "end", elementsMap);
+
+ expect(arrow.startBinding).not.toBe(null);
+ expect(arrow.endBinding).not.toBe(null);
+
+ mutateElbowArrow(arrow, scene, [
+ [0, 0],
+ [90, 200],
+ ]);
+
+ expect(arrow.points).toEqual([
+ [0, 0],
+ [45, 0],
+ [45, 200],
+ [90, 200],
+ ]);
+ });
+});
+
+describe("elbow arrow ui", () => {
+ beforeEach(async () => {
+ await render();
+ });
+
+ it("can follow bound shapes", async () => {
+ UI.createElement("rectangle", {
+ x: -150,
+ y: -150,
+ width: 100,
+ height: 100,
+ });
+ UI.createElement("rectangle", {
+ x: 50,
+ y: 50,
+ width: 100,
+ height: 100,
+ });
+
+ UI.clickTool("arrow");
+ UI.clickOnTestId("elbow-arrow");
+
+ expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
+
+ mouse.reset();
+ mouse.moveTo(-43, -99);
+ mouse.click();
+ mouse.moveTo(43, 99);
+ mouse.click();
+
+ const arrow = h.scene.getSelectedElements(
+ h.state,
+ )[0] as ExcalidrawArrowElement;
+
+ expect(arrow.type).toBe("arrow");
+ expect(arrow.elbowed).toBe(true);
+ expect(arrow.points).toEqual([
+ [0, 0],
+ [35, 0],
+ [35, 200],
+ [90, 200],
+ ]);
+ });
+
+ it("can follow bound rotated shapes", async () => {
+ UI.createElement("rectangle", {
+ x: -150,
+ y: -150,
+ width: 100,
+ height: 100,
+ });
+ UI.createElement("rectangle", {
+ x: 50,
+ y: 50,
+ width: 100,
+ height: 100,
+ });
+
+ UI.clickTool("arrow");
+ UI.clickOnTestId("elbow-arrow");
+
+ mouse.reset();
+ mouse.moveTo(-43, -99);
+ mouse.click();
+ mouse.moveTo(43, 99);
+ mouse.click();
+
+ const arrow = h.scene.getSelectedElements(
+ h.state,
+ )[0] as ExcalidrawArrowElement;
+
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 1,
+ clientY: 1,
+ });
+ const contextMenu = UI.queryContextMenu();
+ fireEvent.click(queryByTestId(contextMenu!, "stats")!);
+
+ mouse.click(51, 51);
+
+ const inputAngle = getStatsProperty("A")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+ editInput(inputAngle, String("40"));
+
+ expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
+ [0, 0],
+ [35, 0],
+ [35, 90],
+ [25, 90],
+ [25, 165],
+ [103, 165],
+ ]);
+ });
+});
diff --git a/packages/excalidraw/element/routing.ts b/packages/excalidraw/element/routing.ts
index 00e3c9cad..d4745a691 100644
--- a/packages/excalidraw/element/routing.ts
+++ b/packages/excalidraw/element/routing.ts
@@ -1,49 +1,49 @@
import { cross } from "../../utils/geometry/geometry";
import BinaryHeap from "../binaryheap";
-import type { Heading } from "../math";
+import {
+ aabbForElement,
+ arePointsEqual,
+ pointInsideBounds,
+ pointToVector,
+ scalePointFromOrigin,
+ scaleVector,
+ translatePoint,
+} from "../math";
+import { getSizeFromPoints } from "../points";
+import type Scene from "../scene/Scene";
+import type { Point } from "../types";
+import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
+import {
+ bindPointToSnapToElementOutline,
+ distanceToBindableElement,
+ avoidRectangularCorner,
+ getHoveredElementForBinding,
+ FIXED_BINDING_DISTANCE,
+ getHeadingForElbowArrowSnap,
+ getGlobalFixedPointForBindableElement,
+ snapToMid,
+} from "./binding";
+import type { Bounds } from "./bounds";
+import type { Heading } from "./heading";
import {
HEADING_DOWN,
HEADING_LEFT,
HEADING_RIGHT,
HEADING_UP,
- PointInTriangle,
- aabbForElement,
- arePointsEqual,
- isAnyTrue,
- pointInsideBounds,
- pointToVector,
- rotatePoint,
- scalePointFromOrigin,
- scaleVector,
- translatePoint,
vectorToHeading,
-} from "../math";
-import { getSizeFromPoints } from "../points";
-import type Scene from "../scene/Scene";
-import type { Point } from "../types";
-import { toBrandedType, tupleToCoors } from "../utils";
-import {
- bindPointToSnapToElementOutline,
- distanceToBindableElement,
- avoidRectangularCorner,
- snapToMid,
- getHoveredElementForBinding,
- FIXED_BINDING_DISTANCE,
-} from "./binding";
-import type { Bounds } from "./bounds";
-import { LinearElementEditor } from "./linearElementEditor";
+} from "./heading";
import { mutateElement } from "./mutateElement";
import { isBindableElement, isRectanguloidElement } from "./typeChecks";
import type {
+ ExcalidrawElbowArrowElement,
+ FixedPointBinding,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "./types";
-import {
- type ElementsMap,
- type ExcalidrawArrowElement,
- type ExcalidrawBindableElement,
- type OrderedExcalidrawElement,
- type PointBinding,
+import type {
+ ElementsMap,
+ ExcalidrawBindableElement,
+ OrderedExcalidrawElement,
} from "./types";
type Node = {
@@ -66,13 +66,13 @@ type Grid = {
const BASE_PADDING = 40;
export const mutateElbowArrow = (
- arrow: ExcalidrawArrowElement,
+ arrow: ExcalidrawElbowArrowElement,
scene: Scene,
nextPoints: readonly Point[],
offset?: Point,
otherUpdates?: {
- startBinding?: PointBinding | null;
- endBinding?: PointBinding | null;
+ startBinding?: FixedPointBinding | null;
+ endBinding?: FixedPointBinding | null;
},
options?: {
changedElements?: Map;
@@ -115,8 +115,8 @@ export const mutateElbowArrow = (
true,
)
: endElement;
-
const startGlobalPoint = getGlobalPoint(
+ arrow.startBinding?.fixedPoint,
origStartGlobalPoint,
origEndGlobalPoint,
elementsMap,
@@ -125,6 +125,7 @@ export const mutateElbowArrow = (
options?.isDragging,
);
const endGlobalPoint = getGlobalPoint(
+ arrow.endBinding?.fixedPoint,
origEndGlobalPoint,
origStartGlobalPoint,
elementsMap,
@@ -132,21 +133,20 @@ export const mutateElbowArrow = (
hoveredEndElement,
options?.isDragging,
);
-
const startHeading = getBindPointHeading(
startGlobalPoint,
endGlobalPoint,
elementsMap,
hoveredStartElement,
+ origStartGlobalPoint,
);
const endHeading = getBindPointHeading(
endGlobalPoint,
startGlobalPoint,
elementsMap,
hoveredEndElement,
+ origEndGlobalPoint,
);
-
- // Calculate bounds needed for routing
const startPointBounds = [
startGlobalPoint[0] - 2,
startGlobalPoint[1] - 2,
@@ -162,13 +162,25 @@ export const mutateElbowArrow = (
const startElementBounds = hoveredStartElement
? aabbForElement(
hoveredStartElement,
- offsetFromHeading(startHeading, FIXED_BINDING_DISTANCE * 4, 1),
+ offsetFromHeading(
+ startHeading,
+ arrow.startArrowhead
+ ? FIXED_BINDING_DISTANCE * 6
+ : FIXED_BINDING_DISTANCE * 2,
+ 1,
+ ),
)
: startPointBounds;
const endElementBounds = hoveredEndElement
? aabbForElement(
hoveredEndElement,
- offsetFromHeading(endHeading, FIXED_BINDING_DISTANCE * 4, 1),
+ offsetFromHeading(
+ endHeading,
+ arrow.endArrowhead
+ ? FIXED_BINDING_DISTANCE * 6
+ : FIXED_BINDING_DISTANCE * 2,
+ 1,
+ ),
)
: endPointBounds;
const boundsOverlap =
@@ -209,7 +221,10 @@ export const mutateElbowArrow = (
startHeading,
!hoveredStartElement && !hoveredEndElement
? 0
- : BASE_PADDING - FIXED_BINDING_DISTANCE * 4,
+ : BASE_PADDING -
+ (arrow.startArrowhead
+ ? FIXED_BINDING_DISTANCE * 6
+ : FIXED_BINDING_DISTANCE * 2),
BASE_PADDING,
),
boundsOverlap
@@ -222,7 +237,10 @@ export const mutateElbowArrow = (
endHeading,
!hoveredStartElement && !hoveredEndElement
? 0
- : BASE_PADDING - FIXED_BINDING_DISTANCE * 4,
+ : BASE_PADDING -
+ (arrow.endArrowhead
+ ? FIXED_BINDING_DISTANCE * 6
+ : FIXED_BINDING_DISTANCE * 2),
BASE_PADDING,
),
boundsOverlap,
@@ -248,13 +266,6 @@ export const mutateElbowArrow = (
commonBounds,
);
- // dynamicAABBs.forEach((bbox) => debugDrawBounds(bbox));
- // [startElementBounds, endElementBounds]
- // .filter((aabb) => aabb !== null)
- // .forEach((bbox) => debugDrawBounds(bbox, "red"));
- // debugDrawBounds(commonBounds, "cyan");
- // grid.data.forEach((node) => node && debugDrawPoint(node.pos));
-
const startDongle =
startDonglePosition && pointToGridNode(startDonglePosition, grid);
const endDongle =
@@ -296,7 +307,6 @@ export const mutateElbowArrow = (
...otherUpdates,
...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0),
angle: 0,
- roundness: null,
},
options?.informMutation,
);
@@ -323,7 +333,15 @@ const offsetFromHeading = (
};
/**
- * Routing algorithm.
+ * Routing algorithm based on the A* path search algorithm.
+ * @see https://www.geeksforgeeks.org/a-search-algorithm/
+ *
+ * Binary heap is used to optimize node lookup.
+ * See {@link calculateGrid} for the grid calculation details.
+ *
+ * Additional modifications added due to aesthetic route reasons:
+ * 1) Arrow segment direction change is penalized by specific linear constant (bendMultiplier)
+ * 2) Arrow segments are not allowed to go "backwards", overlapping with the previous segment
*/
const astar = (
start: Node,
@@ -453,7 +471,8 @@ const m_dist = (a: Point, b: Point) =>
/**
* Create dynamically resizing, always touching
- * bounding boxes for the given static bounds.
+ * bounding boxes having a minimum extent represented
+ * by the given static bounds.
*/
const generateDynamicAABBs = (
a: Bounds,
@@ -610,8 +629,11 @@ const generateDynamicAABBs = (
};
/**
- * Calculates the grid from which the node points are placed on
- * based on the axis-aligned bounding boxes.
+ * Calculates the grid which is used as nodes at
+ * the grid line intersections by the A* algorithm.
+ *
+ * NOTE: This is not a uniform grid. It is built at
+ * various intersections of bounding boxes.
*/
const calculateGrid = (
aabbs: Bounds[],
@@ -647,8 +669,8 @@ const calculateGrid = (
vertical.add(common[1]);
vertical.add(common[3]);
- const _vertical = Array.from(vertical).sort((a, b) => a - b); // TODO: Do we need sorting?
- const _horizontal = Array.from(horizontal).sort((a, b) => a - b); // TODO: Do we need sorting?
+ const _vertical = Array.from(vertical).sort((a, b) => a - b);
+ const _horizontal = Array.from(horizontal).sort((a, b) => a - b);
return {
row: _vertical.length,
@@ -686,9 +708,6 @@ const getDonglePosition = (
return [bounds[0], point[1]];
};
-export const crossProduct = (a: Point, b: Point): number =>
- a[0] * b[1] - a[1] * b[0];
-
const estimateSegmentCount = (
start: Node,
end: Node,
@@ -846,124 +865,6 @@ const pointToGridNode = (point: Point, grid: Grid): Node | null => {
return null;
};
-// Gets the heading for the point by creating a bounding box around the rotated
-// close fitting bounding box, then creating 4 search cones around the center of
-// the external bbox.
-export const headingForPointFromElement = (
- element: ExcalidrawBindableElement,
- aabb: Bounds,
- point: Point,
-): Heading => {
- const SEARCH_CONE_MULTIPLIER = 2;
-
- const midPoint = getCenterForBounds(aabb);
-
- if (element.type === "diamond") {
- if (point[0] < element.x) {
- return HEADING_LEFT;
- } else if (point[1] < element.y) {
- return HEADING_UP;
- } else if (point[0] > element.x + element.width) {
- return HEADING_RIGHT;
- } else if (point[1] > element.y + element.height) {
- return HEADING_DOWN;
- }
-
- const top = rotatePoint(
- scalePointFromOrigin(
- [element.x + element.width / 2, element.y],
- midPoint,
- SEARCH_CONE_MULTIPLIER,
- ),
- midPoint,
- element.angle,
- );
- const right = rotatePoint(
- scalePointFromOrigin(
- [element.x + element.width, element.y + element.height / 2],
- midPoint,
- SEARCH_CONE_MULTIPLIER,
- ),
- midPoint,
- element.angle,
- );
- const bottom = rotatePoint(
- scalePointFromOrigin(
- [element.x + element.width / 2, element.y + element.height],
- midPoint,
- SEARCH_CONE_MULTIPLIER,
- ),
- midPoint,
- element.angle,
- );
- const left = rotatePoint(
- scalePointFromOrigin(
- [element.x, element.y + element.height / 2],
- midPoint,
- SEARCH_CONE_MULTIPLIER,
- ),
- midPoint,
- element.angle,
- );
-
- if (PointInTriangle(point, top, right, midPoint)) {
- return diamondHeading(top, right);
- } else if (PointInTriangle(point, right, bottom, midPoint)) {
- return diamondHeading(right, bottom);
- } else if (PointInTriangle(point, bottom, left, midPoint)) {
- return diamondHeading(bottom, left);
- }
-
- return diamondHeading(left, top);
- }
-
- const topLeft = scalePointFromOrigin(
- [aabb[0], aabb[1]],
- midPoint,
- SEARCH_CONE_MULTIPLIER,
- );
- const topRight = scalePointFromOrigin(
- [aabb[2], aabb[1]],
- midPoint,
- SEARCH_CONE_MULTIPLIER,
- );
- const bottomLeft = scalePointFromOrigin(
- [aabb[0], aabb[3]],
- midPoint,
- SEARCH_CONE_MULTIPLIER,
- );
- const bottomRight = scalePointFromOrigin(
- [aabb[2], aabb[3]],
- midPoint,
- SEARCH_CONE_MULTIPLIER,
- );
-
- return PointInTriangle(point, topLeft, topRight, midPoint)
- ? HEADING_UP
- : PointInTriangle(point, topRight, bottomRight, midPoint)
- ? HEADING_RIGHT
- : PointInTriangle(point, bottomRight, bottomLeft, midPoint)
- ? HEADING_DOWN
- : HEADING_LEFT;
-};
-
-const lineAngle = (a: Point, b: Point): number => {
- const theta = Math.atan2(b[1] - a[1], b[0] - a[0]) * (180 / Math.PI);
- return theta < 0 ? 360 + theta : theta;
-};
-
-const diamondHeading = (a: Point, b: Point) => {
- const angle = lineAngle(a, b);
- if (angle >= 315 || angle < 45) {
- return HEADING_UP;
- } else if (angle >= 45 && angle < 135) {
- return HEADING_RIGHT;
- } else if (angle >= 135 && angle < 225) {
- return HEADING_DOWN;
- }
- return HEADING_LEFT;
-};
-
const commonAABB = (aabbs: Bounds[]): Bounds => [
Math.min(...aabbs.map((aabb) => aabb[0])),
Math.min(...aabbs.map((aabb) => aabb[1])),
@@ -971,12 +872,7 @@ const commonAABB = (aabbs: Bounds[]): Bounds => [
Math.max(...aabbs.map((aabb) => aabb[3])),
];
-/// UTILS
-
-const getCenterForBounds = (bounds: Bounds): Point => [
- bounds[0] + (bounds[2] - bounds[0]) / 2,
- bounds[1] + (bounds[3] - bounds[1]) / 2,
-];
+/// #region Utils
const getBindableElementForId = (
id: string,
@@ -1039,73 +935,6 @@ const neighborIndexToHeading = (idx: number): Heading => {
return HEADING_LEFT;
};
-const getGlobalFixedPoints = (
- arrow: ExcalidrawArrowElement,
- elementsMap: ElementsMap,
-) => {
- const startElement =
- arrow.startBinding && elementsMap.get(arrow.startBinding.elementId);
- const endElement =
- arrow.endBinding && elementsMap.get(arrow.endBinding.elementId);
- const startPoint: Point =
- startElement && arrow.startBinding
- ? rotatePoint(
- [
- startElement.x +
- startElement.width * arrow.startBinding.fixedPoint[0],
- startElement.y +
- startElement.height * arrow.startBinding.fixedPoint[1],
- ],
- [
- startElement.x + startElement.width / 2,
- startElement.y + startElement.height / 2,
- ],
- startElement.angle,
- )
- : [arrow.x + arrow.points[0][0], arrow.y + arrow.points[0][1]];
- const endPoint: Point =
- endElement && arrow.endBinding
- ? rotatePoint(
- [
- endElement.x + endElement.width * arrow.endBinding.fixedPoint[0],
- endElement.y + endElement.height * arrow.endBinding.fixedPoint[1],
- ],
- [
- endElement.x + endElement.width / 2,
- endElement.y + endElement.height / 2,
- ],
- endElement.angle,
- )
- : [
- arrow.x + arrow.points[arrow.points.length - 1][0],
- arrow.y + arrow.points[arrow.points.length - 1][1],
- ];
-
- return [startPoint, endPoint];
-};
-
-export const getArrowLocalFixedPoints = (
- arrow: ExcalidrawArrowElement,
- elementsMap: ElementsMap,
-) => {
- const [startPoint, endPoint] = getGlobalFixedPoints(arrow, elementsMap);
-
- return [
- LinearElementEditor.pointFromAbsoluteCoords(arrow, startPoint, elementsMap),
- LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap),
- ];
-};
-
-// const aabbsOverlapping = (a: Bounds, b: Bounds) =>
-// pointInsideBounds([a[0], a[1]], b) ||
-// pointInsideBounds([a[2], a[1]], b) ||
-// pointInsideBounds([a[2], a[3]], b) ||
-// pointInsideBounds([a[0], a[3]], b) ||
-// pointInsideBounds([b[0], b[1]], a) ||
-// pointInsideBounds([b[2], b[1]], a) ||
-// pointInsideBounds([b[2], b[3]], a) ||
-// pointInsideBounds([b[0], b[3]], a);
-
const getAllElementsMap = (
scene: Scene,
changedElements?: Map,
@@ -1128,6 +957,7 @@ const getAllElements = (
: scene.getNonDeletedElements();
const getGlobalPoint = (
+ fixedPointRatio: [number, number] | undefined | null,
initialPoint: Point,
otherPoint: Point,
elementsMap: NonDeletedSceneElementsMap,
@@ -1135,48 +965,72 @@ const getGlobalPoint = (
hoveredElement?: ExcalidrawBindableElement | null,
isDragging?: boolean,
): Point => {
- if (isDragging && hoveredElement) {
- const nonCornerPoint = isRectanguloidElement(hoveredElement)
- ? avoidRectangularCorner(hoveredElement, initialPoint)
- : initialPoint;
- const snapPoint =
- hoveredElement &&
- bindPointToSnapToElementOutline(
- nonCornerPoint,
+ if (isDragging) {
+ if (hoveredElement) {
+ const snapPoint = getSnapPoint(
+ initialPoint,
otherPoint,
hoveredElement,
elementsMap,
);
- return snapToMid(hoveredElement, snapPoint);
- } else if (boundElement) {
- return bindPointToSnapToElementOutline(
- initialPoint,
- otherPoint,
+ return snapToMid(hoveredElement, snapPoint);
+ }
+
+ return initialPoint;
+ }
+
+ if (boundElement) {
+ const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
+ fixedPointRatio || [0, 0],
boundElement,
- elementsMap,
);
+
+ // NOTE: Resize scales the binding position point too, so we need to update it
+ return Math.abs(
+ distanceToBindableElement(boundElement, fixedGlobalPoint, elementsMap) -
+ FIXED_BINDING_DISTANCE,
+ ) > 0.01
+ ? getSnapPoint(initialPoint, otherPoint, boundElement, elementsMap)
+ : fixedGlobalPoint;
}
return initialPoint;
};
-// TODO: See if it can be merged with binding.ts: getHeadingForElbowArrowSnap()
+const getSnapPoint = (
+ point: Point,
+ otherPoint: Point,
+ element: ExcalidrawBindableElement,
+ elementsMap: ElementsMap,
+) =>
+ bindPointToSnapToElementOutline(
+ isRectanguloidElement(element)
+ ? avoidRectangularCorner(element, point)
+ : point,
+ otherPoint,
+ element,
+ elementsMap,
+ );
+
const getBindPointHeading = (
point: Point,
otherPoint: Point,
elementsMap: NonDeletedSceneElementsMap,
- hoveredElement?: ExcalidrawBindableElement | null,
+ hoveredElement: ExcalidrawBindableElement | null | undefined,
+ origPoint: Point,
) =>
- hoveredElement
- ? headingForPointFromElement(
+ getHeadingForElbowArrowSnap(
+ point,
+ otherPoint,
+ hoveredElement,
+ hoveredElement &&
+ aabbForElement(
hoveredElement,
- aabbForElement(
- hoveredElement,
- Array(4).fill(
- distanceToBindableElement(hoveredElement, point, elementsMap),
- ) as [number, number, number, number],
- ),
- point,
- )
- : vectorToHeading(pointToVector(otherPoint, point));
+ Array(4).fill(
+ distanceToBindableElement(hoveredElement, point, elementsMap),
+ ) as [number, number, number, number],
+ ),
+ elementsMap,
+ origPoint,
+ );
diff --git a/packages/excalidraw/element/textElement.test.ts b/packages/excalidraw/element/textElement.test.ts
index bc8186a85..178b30cd5 100644
--- a/packages/excalidraw/element/textElement.test.ts
+++ b/packages/excalidraw/element/textElement.test.ts
@@ -1,4 +1,5 @@
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
+import { getLineHeight } from "../fonts";
import { API } from "../tests/helpers/api";
import {
computeContainerDimensionForBoundText,
@@ -8,7 +9,6 @@ import {
wrapText,
detectLineHeight,
getLineHeightInPx,
- getDefaultLineHeight,
parseTokens,
} from "./textElement";
import type { ExcalidrawTextElementWithContainer, FontString } from "./types";
@@ -418,15 +418,15 @@ describe("Test getLineHeightInPx", () => {
describe("Test getDefaultLineHeight", () => {
it("should return line height using default font family when not passed", () => {
//@ts-ignore
- expect(getDefaultLineHeight()).toBe(1.25);
+ expect(getLineHeight()).toBe(1.25);
});
it("should return line height using default font family for unknown font", () => {
const UNKNOWN_FONT = 5;
- expect(getDefaultLineHeight(UNKNOWN_FONT)).toBe(1.25);
+ expect(getLineHeight(UNKNOWN_FONT)).toBe(1.25);
});
it("should return correct line height", () => {
- expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
+ expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
});
});
diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts
index db4230e24..696f809e6 100644
--- a/packages/excalidraw/element/textElement.ts
+++ b/packages/excalidraw/element/textElement.ts
@@ -6,7 +6,6 @@ import type {
ExcalidrawTextContainer,
ExcalidrawTextElement,
ExcalidrawTextElementWithContainer,
- FontFamilyValues,
FontString,
NonDeletedExcalidrawElement,
} from "./types";
@@ -17,7 +16,6 @@ import {
BOUND_TEXT_PADDING,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
- FONT_FAMILY,
TEXT_ALIGN,
VERTICAL_ALIGN,
} from "../constants";
@@ -30,7 +28,7 @@ import {
resetOriginalContainerCache,
updateOriginalContainerCache,
} from "./containerCache";
-import type { ExtractSetType, MakeBrand } from "../utility-types";
+import type { ExtractSetType } from "../utility-types";
export const normalizeText = (text: string) => {
return (
@@ -321,24 +319,6 @@ export const getLineHeightInPx = (
return fontSize * lineHeight;
};
-/**
- * Calculates vertical offset for a text with alphabetic baseline.
- */
-export const getVerticalOffset = (
- fontFamily: ExcalidrawTextElement["fontFamily"],
- fontSize: ExcalidrawTextElement["fontSize"],
- lineHeightPx: number,
-) => {
- const { unitsPerEm, ascender, descender } =
- FONT_METRICS[fontFamily] || FONT_METRICS[FONT_FAMILY.Helvetica];
-
- const fontSizeEm = fontSize / unitsPerEm;
- const lineGap = lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender;
-
- const verticalOffset = fontSizeEm * ascender + lineGap;
- return verticalOffset;
-};
-
// FIXME rename to getApproxMinContainerHeight
export const getApproxMinLineHeight = (
fontSize: ExcalidrawTextElement["fontSize"],
@@ -349,29 +329,72 @@ export const getApproxMinLineHeight = (
let canvas: HTMLCanvasElement | undefined;
-const getLineWidth = (text: string, font: FontString) => {
+/**
+ * @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.
+ */
+const getLineWidth = (
+ text: string,
+ font: FontString,
+ forceAdvanceWidth?: true,
+) => {
if (!canvas) {
canvas = document.createElement("canvas");
}
const canvas2dContext = canvas.getContext("2d")!;
canvas2dContext.font = font;
- const width = canvas2dContext.measureText(text).width;
+ 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 width * 10;
+ return advanceWidth * 10;
}
- return width;
+
+ return advanceWidth;
};
-export const getTextWidth = (text: string, font: FontString) => {
+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));
+ width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth));
});
+
return width;
};
@@ -402,7 +425,11 @@ export const parseTokens = (text: string) => {
return words.join(" ").split(" ");
};
-export const wrapText = (text: string, font: FontString, maxWidth: number) => {
+export const wrapText = (
+ text: string,
+ font: FontString,
+ maxWidth: number,
+): string => {
// if maxWidth is not finite or NaN which can happen in case of bugs in
// computation, we need to make sure we don't continue as we'll end up
// in an infinite loop
@@ -412,7 +439,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
const lines: Array = [];
const originalLines = text.split("\n");
- const spaceWidth = getLineWidth(" ", font);
+ const spaceAdvanceWidth = getLineWidth(" ", font, true);
let currentLine = "";
let currentLineWidthTillNow = 0;
@@ -427,13 +454,14 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
currentLine = "";
currentLineWidthTillNow = 0;
};
- originalLines.forEach((originalLine) => {
- const currentLineWidth = getTextWidth(originalLine, font);
+
+ for (const originalLine of originalLines) {
+ const currentLineWidth = getLineWidth(originalLine, font, true);
// Push the line if its <= maxWidth
if (currentLineWidth <= maxWidth) {
lines.push(originalLine);
- return; // continue
+ continue;
}
const words = parseTokens(originalLine);
@@ -442,7 +470,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
let index = 0;
while (index < words.length) {
- const currentWordWidth = getLineWidth(words[index], font);
+ const currentWordWidth = getLineWidth(words[index], font, true);
// This will only happen when single word takes entire width
if (currentWordWidth === maxWidth) {
@@ -454,7 +482,6 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
else if (currentWordWidth > maxWidth) {
// push current line since the current word exceeds the max width
// so will be appended in next line
-
push(currentLine);
resetParams();
@@ -463,20 +490,26 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
const currentChar = String.fromCodePoint(
words[index].codePointAt(0)!,
);
- const width = charWidth.calculate(currentChar, font);
- currentLineWidthTillNow += width;
+
+ const line = currentLine + currentChar;
+ // use advance width instead of the actual width as it's closest to the browser wapping algo
+ // use width of the whole line instead of calculating individual chars to accomodate for kerning
+ const lineAdvanceWidth = getLineWidth(line, font, true);
+ const charAdvanceWidth = charWidth.calculate(currentChar, font);
+
+ currentLineWidthTillNow = lineAdvanceWidth;
words[index] = words[index].slice(currentChar.length);
if (currentLineWidthTillNow >= maxWidth) {
push(currentLine);
currentLine = currentChar;
- currentLineWidthTillNow = width;
+ currentLineWidthTillNow = charAdvanceWidth;
} else {
- currentLine += currentChar;
+ currentLine = line;
}
}
// push current line if appending space exceeds max width
- if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
+ if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
push(currentLine);
resetParams();
// space needs to be appended before next word
@@ -485,14 +518,18 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
// with css word-wrap
} else if (!currentLine.endsWith("-")) {
currentLine += " ";
- currentLineWidthTillNow += spaceWidth;
+ currentLineWidthTillNow += spaceAdvanceWidth;
}
index++;
} else {
// Start appending words in a line till max width reached
while (currentLineWidthTillNow < maxWidth && index < words.length) {
const word = words[index];
- currentLineWidthTillNow = getLineWidth(currentLine + word, font);
+ currentLineWidthTillNow = getLineWidth(
+ currentLine + word,
+ font,
+ true,
+ );
if (currentLineWidthTillNow > maxWidth) {
push(currentLine);
@@ -512,7 +549,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
}
// Push the word if appending space exceeds max width
- if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
+ if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
if (shouldAppendSpace) {
lines.push(currentLine.slice(0, -1));
} else {
@@ -524,12 +561,14 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
}
}
}
+
if (currentLine.slice(-1) === " ") {
// only remove last trailing space which we have added when joining words
currentLine = currentLine.slice(0, -1);
push(currentLine);
}
- });
+ }
+
return lines.join("\n");
};
@@ -542,7 +581,7 @@ export const charWidth = (() => {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][ascii]) {
- const width = getLineWidth(char, font);
+ const width = getLineWidth(char, font, true);
cachedCharWidth[font][ascii] = width;
}
@@ -594,34 +633,9 @@ export const getMaxCharWidth = (font: FontString) => {
return Math.max(...cacheWithOutEmpty);
};
-export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
- // Generally lower case is used so converting to lower case
- const dummyText = DUMMY_TEXT.toLocaleLowerCase();
- const batchLength = 6;
- let index = 0;
- let widthTillNow = 0;
- let str = "";
- while (widthTillNow <= width) {
- const batch = dummyText.substr(index, index + batchLength);
- str += batch;
- widthTillNow += getLineWidth(str, font);
- if (index === dummyText.length - 1) {
- index = 0;
- }
- index = index + batchLength;
- }
-
- while (widthTillNow > width) {
- str = str.substr(0, str.length - 1);
- widthTillNow = getLineWidth(str, font);
- }
- return str.length;
-};
-
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
return container?.boundElements?.length
- ? container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id ||
- null
+ ? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
: null;
};
@@ -866,79 +880,6 @@ export const isMeasureTextSupported = () => {
return width > 0;
};
-/**
- * Unitless line height
- *
- * In previous versions we used `normal` line height, which browsers interpret
- * differently, and based on font-family and font-size.
- *
- * To make line heights consistent across browsers we hardcode the values for
- * each of our fonts based on most common average line-heights.
- * See https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971
- * where the values come from.
- */
-const DEFAULT_LINE_HEIGHT = {
- // ~1.25 is the average for Virgil in WebKit and Blink.
- // Gecko (FF) uses ~1.28.
- [FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"],
- // ~1.15 is the average for Helvetica in WebKit and Blink.
- [FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"],
- // ~1.2 is the average for Cascadia in WebKit and Blink, and kinda Gecko too
- [FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"],
-};
-
-/** OS/2 sTypoAscender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypoascender */
-type sTypoAscender = number & MakeBrand<"sTypoAscender">;
-
-/** OS/2 sTypoDescender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypodescender */
-type sTypoDescender = number & MakeBrand<"sTypoDescender">;
-
-/** head.unitsPerEm, usually either 1000 or 2048 */
-type unitsPerEm = number & MakeBrand<"unitsPerEm">;
-
-/**
- * Hardcoded metrics for default fonts, read by https://opentype.js.org/font-inspector.html.
- * For custom fonts, read these metrics from OS/2 table and extend this object.
- *
- * WARN: opentype does NOT open WOFF2 correctly, make sure to convert WOFF2 to TTF first.
- */
-export const FONT_METRICS: Record<
- number,
- {
- unitsPerEm: number;
- ascender: sTypoAscender;
- descender: sTypoDescender;
- }
-> = {
- [FONT_FAMILY.Virgil]: {
- unitsPerEm: 1000 as unitsPerEm,
- ascender: 886 as sTypoAscender,
- descender: -374 as sTypoDescender,
- },
- [FONT_FAMILY.Helvetica]: {
- unitsPerEm: 2048 as unitsPerEm,
- ascender: 1577 as sTypoAscender,
- descender: -471 as sTypoDescender,
- },
- [FONT_FAMILY.Cascadia]: {
- unitsPerEm: 2048 as unitsPerEm,
- ascender: 1977 as sTypoAscender,
- descender: -480 as sTypoDescender,
- },
- [FONT_FAMILY.Assistant]: {
- unitsPerEm: 1000 as unitsPerEm,
- ascender: 1021 as sTypoAscender,
- descender: -287 as sTypoDescender,
- },
-};
-
-export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
- if (fontFamily in DEFAULT_LINE_HEIGHT) {
- return DEFAULT_LINE_HEIGHT[fontFamily];
- }
- return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
-};
-
export const getMinTextElementWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
diff --git a/packages/excalidraw/element/textWysiwyg.test.tsx b/packages/excalidraw/element/textWysiwyg.test.tsx
index 2b0266eeb..32fa7bffe 100644
--- a/packages/excalidraw/element/textWysiwyg.test.tsx
+++ b/packages/excalidraw/element/textWysiwyg.test.tsx
@@ -916,13 +916,13 @@ describe("textWysiwyg", () => {
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!");
editor.blur();
- expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil);
+ expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont);
fireEvent.click(screen.getByTitle(/code/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
- ).toEqual(FONT_FAMILY.Cascadia);
+ ).toEqual(FONT_FAMILY["Comic Shanns"]);
//undo
Keyboard.withModifierKeys({ ctrl: true }, () => {
@@ -930,7 +930,7 @@ describe("textWysiwyg", () => {
});
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
- ).toEqual(FONT_FAMILY.Virgil);
+ ).toEqual(FONT_FAMILY.Excalifont);
//redo
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
@@ -938,7 +938,7 @@ describe("textWysiwyg", () => {
});
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
- ).toEqual(FONT_FAMILY.Cascadia);
+ ).toEqual(FONT_FAMILY["Comic Shanns"]);
});
it("should wrap text and vertcially center align once text submitted", async () => {
@@ -1330,14 +1330,14 @@ describe("textWysiwyg", () => {
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
- ).toEqual(FONT_FAMILY.Cascadia);
+ ).toEqual(FONT_FAMILY["Comic Shanns"]);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
fireEvent.click(screen.getByTitle(/Very large/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
).toEqual(36);
- expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(97);
+ expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(100);
});
it("should update line height when font family updated", async () => {
@@ -1357,18 +1357,18 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle(/code/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
- ).toEqual(FONT_FAMILY.Cascadia);
+ ).toEqual(FONT_FAMILY["Comic Shanns"]);
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
- ).toEqual(1.2);
+ ).toEqual(1.25);
fireEvent.click(screen.getByTitle(/normal/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
- ).toEqual(FONT_FAMILY.Helvetica);
+ ).toEqual(FONT_FAMILY.Nunito);
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
- ).toEqual(1.15);
+ ).toEqual(1.35);
});
describe("should align correctly", () => {
diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx
index 632759330..f8397fb73 100644
--- a/packages/excalidraw/element/textWysiwyg.tsx
+++ b/packages/excalidraw/element/textWysiwyg.tsx
@@ -11,7 +11,7 @@ import {
isBoundToContainer,
isTextElement,
} from "./typeChecks";
-import { CLASSES } from "../constants";
+import { CLASSES, isSafari } from "../constants";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@@ -132,10 +132,15 @@ export const textWysiwyg = ({
updatedTextElement,
app.scene.getNonDeletedElementsMap(),
);
+
+ let width = updatedTextElement.width;
+
+ // set to element height by default since that's
+ // what is going to be used for unbounded text
+ let height = updatedTextElement.height;
+
let maxWidth = updatedTextElement.width;
let maxHeight = updatedTextElement.height;
- let textElementWidth = updatedTextElement.width;
- const textElementHeight = updatedTextElement.height;
if (container && updatedTextElement.containerId) {
if (isArrowElement(container)) {
@@ -177,9 +182,9 @@ export const textWysiwyg = ({
);
// autogrow container height if text exceeds
- if (!isArrowElement(container) && textElementHeight > maxHeight) {
+ if (!isArrowElement(container) && height > maxHeight) {
const targetContainerHeight = computeContainerDimensionForBoundText(
- textElementHeight,
+ height,
container.type,
);
@@ -190,10 +195,10 @@ export const textWysiwyg = ({
// is reached when text is removed
!isArrowElement(container) &&
container.height > originalContainerData.height &&
- textElementHeight < maxHeight
+ height < maxHeight
) {
const targetContainerHeight = computeContainerDimensionForBoundText(
- textElementHeight,
+ height,
container.type,
);
mutateElement(container, { height: targetContainerHeight });
@@ -226,30 +231,41 @@ export const textWysiwyg = ({
if (!container) {
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
- textElementWidth = Math.min(textElementWidth, maxWidth);
+ width = Math.min(width, maxWidth);
} else {
- textElementWidth += 0.5;
+ width += 0.5;
}
+ // add 5% buffer otherwise it causes wysiwyg to jump
+ height *= 1.05;
+
+ const font = getFontString(updatedTextElement);
+
+ // adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari)
+ const padding = !isSafari
+ ? Math.ceil(updatedTextElement.fontSize / 2)
+ : 0;
+
// Make sure text editor height doesn't go beyond viewport
const editorMaxHeight =
(appState.height - viewportY) / appState.zoom.value;
Object.assign(editable.style, {
- font: getFontString(updatedTextElement),
+ font,
// must be defined *after* font ¯\_(ツ)_/¯
lineHeight: updatedTextElement.lineHeight,
- width: `${textElementWidth}px`,
- height: `${textElementHeight}px`,
- left: `${viewportX}px`,
+ width: `${width}px`,
+ height: `${height}px`,
+ left: `${viewportX - padding}px`,
top: `${viewportY}px`,
transform: getTransform(
- textElementWidth,
- textElementHeight,
+ width,
+ height,
getTextElementAngle(updatedTextElement, container),
appState,
maxWidth,
editorMaxHeight,
),
+ padding: `0 ${padding}px`,
textAlign,
verticalAlign,
color: updatedTextElement.strokeColor,
@@ -290,7 +306,6 @@ export const textWysiwyg = ({
minHeight: "1em",
backfaceVisibility: "hidden",
margin: 0,
- padding: 0,
border: 0,
outline: 0,
resize: "none",
@@ -336,7 +351,7 @@ export const textWysiwyg = ({
font,
getBoundTextMaxWidth(container, boundTextElement),
);
- const width = getTextWidth(wrappedText, font);
+ const width = getTextWidth(wrappedText, font, true);
editable.style.width = `${width}px`;
}
};
@@ -485,8 +500,10 @@ export const textWysiwyg = ({
};
const stopEvent = (event: Event) => {
- event.preventDefault();
- event.stopPropagation();
+ if (event.target instanceof HTMLCanvasElement) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
};
// using a state variable instead of passing it to the handleSubmit callback
@@ -579,46 +596,15 @@ export const textWysiwyg = ({
// in that same tick.
const target = event?.target;
- const isTargetPickerTrigger =
+ const isPropertiesTrigger =
target instanceof HTMLElement &&
- target.classList.contains("active-color");
+ target.classList.contains("properties-trigger");
setTimeout(() => {
editable.onblur = handleSubmit;
- if (isTargetPickerTrigger) {
- const callback = (
- mutationList: MutationRecord[],
- observer: MutationObserver,
- ) => {
- const radixIsRemoved = mutationList.find(
- (mutation) =>
- mutation.removedNodes.length > 0 &&
- (mutation.removedNodes[0] as HTMLElement).dataset
- ?.radixPopperContentWrapper !== undefined,
- );
-
- if (radixIsRemoved) {
- // should work without this in theory
- // and i think it does actually but radix probably somewhere,
- // somehow sets the focus elsewhere
- setTimeout(() => {
- editable.focus();
- });
-
- observer.disconnect();
- }
- };
-
- const observer = new MutationObserver(callback);
-
- observer.observe(document.querySelector(".excalidraw-container")!, {
- childList: true,
- });
- }
-
// case: clicking on the same property → no change → no update → no focus
- if (!isTargetPickerTrigger) {
+ if (!isPropertiesTrigger) {
editable.focus();
}
});
@@ -626,16 +612,18 @@ export const textWysiwyg = ({
// prevent blur when changing properties from the menu
const onPointerDown = (event: MouseEvent) => {
- const isTargetPickerTrigger =
- event.target instanceof HTMLElement &&
- event.target.classList.contains("active-color");
+ const target = event?.target;
+
+ const isPropertiesTrigger =
+ target instanceof HTMLElement &&
+ target.classList.contains("properties-trigger");
if (
((event.target instanceof HTMLElement ||
event.target instanceof SVGElement) &&
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
!isWritableElement(event.target)) ||
- isTargetPickerTrigger
+ isPropertiesTrigger
) {
editable.onblur = null;
window.addEventListener("pointerup", bindBlurEvent);
@@ -644,7 +632,7 @@ export const textWysiwyg = ({
window.addEventListener("blur", handleSubmit);
} else if (
event.target instanceof HTMLElement &&
- !event.target.contains(editable) &&
+ event.target instanceof HTMLCanvasElement &&
// Vitest simply ignores stopPropagation, capture-mode, or rAF
// so without introducing crazier hacks, nothing we can do
!isTestEnv()
@@ -664,10 +652,10 @@ export const textWysiwyg = ({
// handle updates of textElement properties of editing element
const unbindUpdate = Scene.getScene(element)!.onUpdate(() => {
updateWysiwygStyle();
- const isColorPickerActive = !!document.activeElement?.closest(
- ".color-picker-content",
+ const isPopupOpened = !!document.activeElement?.closest(
+ ".properties-content",
);
- if (!isColorPickerActive) {
+ if (!isPopupOpened) {
editable.focus();
}
});
diff --git a/packages/excalidraw/element/typeChecks.ts b/packages/excalidraw/element/typeChecks.ts
index bb003dbe8..17eaaad54 100644
--- a/packages/excalidraw/element/typeChecks.ts
+++ b/packages/excalidraw/element/typeChecks.ts
@@ -21,6 +21,9 @@ import type {
ExcalidrawIframeLikeElement,
ExcalidrawMagicFrameElement,
ExcalidrawArrowElement,
+ ExcalidrawElbowArrowElement,
+ PointBinding,
+ FixedPointBinding,
} from "./types";
export const isInitializedImageElement = (
@@ -106,7 +109,9 @@ export const isArrowElement = (
return element != null && element.type === "arrow";
};
-export const isElbowArrow = (element?: ExcalidrawElement): boolean => {
+export const isElbowArrow = (
+ element?: ExcalidrawElement,
+): element is ExcalidrawElbowArrowElement => {
return isArrowElement(element) && element.elbowed;
};
@@ -160,6 +165,8 @@ export const isRectanguloidElement = (
return (
element != null &&
(element.type === "rectangle" ||
+ element.type === "diamond" ||
+ element.type === "image" ||
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
@@ -281,3 +288,9 @@ export const getDefaultRoundnessTypeForElement = (
return null;
};
+
+export const isFixedPointBinding = (
+ binding: PointBinding,
+): binding is FixedPointBinding => {
+ return binding.fixedPoint != null;
+};
diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts
index acdc8ced9..52e810482 100644
--- a/packages/excalidraw/element/types.ts
+++ b/packages/excalidraw/element/types.ts
@@ -6,7 +6,12 @@ import type {
THEME,
VERTICAL_ALIGN,
} from "../constants";
-import type { MakeBrand, MarkNonNullable, ValueOf } from "../utility-types";
+import type {
+ MakeBrand,
+ MarkNonNullable,
+ Merge,
+ ValueOf,
+} from "../utility-types";
import type { MagicCacheData } from "../data/magic";
export type ChartType = "bar" | "line";
@@ -228,13 +233,22 @@ export type ExcalidrawTextElementWithContainer = {
containerId: ExcalidrawTextContainer["id"];
} & ExcalidrawTextElement;
+export type FixedPoint = [number, number];
+
export type PointBinding = {
elementId: ExcalidrawBindableElement["id"];
focus: number;
gap: number;
- fixedPoint: [number, number];
+ // Represents the fixed point binding information in form of a vertical and
+ // horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
+ // gives the user selected fixed point by multiplying the bound element width
+ // with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
+ // bound element-local point coordinate.
+ fixedPoint: FixedPoint | null;
};
+export type FixedPointBinding = Merge;
+
export type Arrowhead =
| "arrow"
| "bar"
@@ -263,6 +277,15 @@ export type ExcalidrawArrowElement = ExcalidrawLinearElement &
elbowed: boolean;
}>;
+export type ExcalidrawElbowArrowElement = Merge<
+ ExcalidrawArrowElement,
+ {
+ elbowed: true;
+ startBinding: FixedPointBinding | null;
+ endBinding: FixedPointBinding | null;
+ }
+>;
+
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{
type: "freedraw";
diff --git a/packages/excalidraw/fonts/ExcalidrawFont.ts b/packages/excalidraw/fonts/ExcalidrawFont.ts
new file mode 100644
index 000000000..cb8a76fc0
--- /dev/null
+++ b/packages/excalidraw/fonts/ExcalidrawFont.ts
@@ -0,0 +1,159 @@
+import { stringToBase64, toByteString } from "../data/encode";
+import { LOCAL_FONT_PROTOCOL } from "./metadata";
+
+export interface Font {
+ urls: URL[];
+ fontFace: FontFace;
+ getContent(): Promise;
+}
+export const UNPKG_PROD_URL = `https://unpkg.com/${
+ import.meta.env.VITE_PKG_NAME
+ ? `${import.meta.env.VITE_PKG_NAME}@${import.meta.env.PKG_VERSION}` // should be provided by vite during package build
+ : "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
+}/dist/prod/`;
+
+export class ExcalidrawFont implements Font {
+ public readonly urls: URL[];
+ public readonly fontFace: FontFace;
+
+ constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) {
+ this.urls = ExcalidrawFont.createUrls(uri);
+
+ const sources = this.urls
+ .map((url) => `url(${url}) ${ExcalidrawFont.getFormat(url)}`)
+ .join(", ");
+
+ this.fontFace = new FontFace(family, sources, {
+ display: "swap",
+ style: "normal",
+ weight: "400",
+ ...descriptors,
+ });
+ }
+
+ /**
+ * Tries to fetch woff2 content, based on the registered urls.
+ * Returns last defined url in case of errors.
+ *
+ * Note: uses browser APIs for base64 encoding - use dataurl outside the browser environment.
+ */
+ public async getContent(): Promise {
+ let i = 0;
+ const errorMessages = [];
+
+ while (i < this.urls.length) {
+ const url = this.urls[i];
+
+ if (url.protocol === "data:") {
+ // it's dataurl, the font is inlined as base64, no need to fetch
+ return url.toString();
+ }
+
+ try {
+ const response = await fetch(url, {
+ headers: {
+ Accept: "font/woff2",
+ },
+ });
+
+ if (response.ok) {
+ const mimeType = await response.headers.get("Content-Type");
+ const buffer = await response.arrayBuffer();
+
+ return `data:${mimeType};base64,${await stringToBase64(
+ await toByteString(buffer),
+ true,
+ )}`;
+ }
+
+ // response not ok, try to continue
+ errorMessages.push(
+ `"${url.toString()}" returned status "${response.status}"`,
+ );
+ } catch (e) {
+ errorMessages.push(`"${url.toString()}" returned error "${e}"`);
+ }
+
+ i++;
+ }
+
+ console.error(
+ `Failed to fetch font "${
+ this.fontFace.family
+ }" from urls "${this.urls.toString()}`,
+ JSON.stringify(errorMessages, undefined, 2),
+ );
+
+ // in case of issues, at least return the last url as a content
+ // defaults to unpkg for bundled fonts (so that we don't have to host them forever) and http url for others
+ return this.urls.length ? this.urls[this.urls.length - 1].toString() : "";
+ }
+
+ private static createUrls(uri: string): URL[] {
+ if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
+ // no url for local fonts
+ return [];
+ }
+
+ if (uri.startsWith("http") || uri.startsWith("data")) {
+ // one url for http imports or data url
+ return [new URL(uri)];
+ }
+
+ // absolute assets paths, which are found in tests and excalidraw-app build, won't work with base url, so we are stripping initial slash away
+ const assetUrl: string = uri.replace(/^\/+/, "");
+ const urls: URL[] = [];
+
+ if (typeof window.EXCALIDRAW_ASSET_PATH === "string") {
+ const normalizedBaseUrl = this.normalizeBaseUrl(
+ window.EXCALIDRAW_ASSET_PATH,
+ );
+
+ urls.push(new URL(assetUrl, normalizedBaseUrl));
+ } else if (Array.isArray(window.EXCALIDRAW_ASSET_PATH)) {
+ window.EXCALIDRAW_ASSET_PATH.forEach((path) => {
+ const normalizedBaseUrl = this.normalizeBaseUrl(path);
+ urls.push(new URL(assetUrl, normalizedBaseUrl));
+ });
+ }
+
+ // fallback url for bundled fonts
+ urls.push(new URL(assetUrl, UNPKG_PROD_URL));
+
+ return urls;
+ }
+
+ private static getFormat(url: URL) {
+ try {
+ const pathname = new URL(url).pathname;
+ const parts = pathname.split(".");
+
+ if (parts.length === 1) {
+ return "";
+ }
+
+ return `format('${parts.pop()}')`;
+ } catch (error) {
+ return "";
+ }
+ }
+
+ private static normalizeBaseUrl(baseUrl: string) {
+ let result = baseUrl;
+
+ // in case user passed a root-relative url (~absolute path),
+ // like "/" or "/some/path", or relative (starts with "./"),
+ // prepend it with `location.origin`
+ if (/^\.?\//.test(result)) {
+ result = new URL(
+ result.replace(/^\.?\/+/, ""),
+ window?.location?.origin,
+ ).toString();
+ }
+
+ // ensure there is a trailing slash, otherwise url won't be correctly concatenated
+ result = `${result.replace(/\/+$/, "")}/`;
+
+ return result;
+ }
+}
diff --git a/public/fonts/Assistant-Bold.woff2 b/packages/excalidraw/fonts/assets/Assistant-Bold.woff2
similarity index 100%
rename from public/fonts/Assistant-Bold.woff2
rename to packages/excalidraw/fonts/assets/Assistant-Bold.woff2
diff --git a/public/fonts/Assistant-Medium.woff2 b/packages/excalidraw/fonts/assets/Assistant-Medium.woff2
similarity index 100%
rename from public/fonts/Assistant-Medium.woff2
rename to packages/excalidraw/fonts/assets/Assistant-Medium.woff2
diff --git a/public/fonts/Assistant-Regular.woff2 b/packages/excalidraw/fonts/assets/Assistant-Regular.woff2
similarity index 100%
rename from public/fonts/Assistant-Regular.woff2
rename to packages/excalidraw/fonts/assets/Assistant-Regular.woff2
diff --git a/public/fonts/Assistant-SemiBold.woff2 b/packages/excalidraw/fonts/assets/Assistant-SemiBold.woff2
similarity index 100%
rename from public/fonts/Assistant-SemiBold.woff2
rename to packages/excalidraw/fonts/assets/Assistant-SemiBold.woff2
diff --git a/packages/excalidraw/fonts/assets/CascadiaCode-Regular.woff2 b/packages/excalidraw/fonts/assets/CascadiaCode-Regular.woff2
new file mode 100644
index 000000000..ed0335c2c
Binary files /dev/null and b/packages/excalidraw/fonts/assets/CascadiaCode-Regular.woff2 differ
diff --git a/packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2 b/packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2
new file mode 100644
index 000000000..efa4f1c74
Binary files /dev/null and b/packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2 differ
diff --git a/packages/excalidraw/fonts/assets/Excalifont-Regular.woff2 b/packages/excalidraw/fonts/assets/Excalifont-Regular.woff2
new file mode 100644
index 000000000..24ce44aa1
Binary files /dev/null and b/packages/excalidraw/fonts/assets/Excalifont-Regular.woff2 differ
diff --git a/packages/excalidraw/fonts/assets/LiberationSans-Regular.woff2 b/packages/excalidraw/fonts/assets/LiberationSans-Regular.woff2
new file mode 100644
index 000000000..86ed395a2
Binary files /dev/null and b/packages/excalidraw/fonts/assets/LiberationSans-Regular.woff2 differ
diff --git a/packages/excalidraw/fonts/assets/Virgil-Regular.woff2 b/packages/excalidraw/fonts/assets/Virgil-Regular.woff2
new file mode 100644
index 000000000..bf5d08ce8
Binary files /dev/null and b/packages/excalidraw/fonts/assets/Virgil-Regular.woff2 differ
diff --git a/packages/excalidraw/fonts/assets/fonts.css b/packages/excalidraw/fonts/assets/fonts.css
new file mode 100644
index 000000000..09bb0cd3a
--- /dev/null
+++ b/packages/excalidraw/fonts/assets/fonts.css
@@ -0,0 +1,35 @@
+/* Only UI fonts here, which are needed before the editor initializes. */
+/* These cannot be dynamically prepended with `EXCALIDRAW_ASSET_PATH`. */
+/* WARN: The following content is replaced during excalidraw-app build */
+
+@font-face {
+ font-family: "Assistant";
+ src: url(./Assistant-Regular.woff2) format("woff2");
+ font-weight: 400;
+ style: normal;
+ display: swap;
+}
+
+@font-face {
+ font-family: "Assistant";
+ src: url(./Assistant-Medium.woff2) format("woff2");
+ font-weight: 500;
+ style: normal;
+ display: swap;
+}
+
+@font-face {
+ font-family: "Assistant";
+ src: url(./Assistant-SemiBold.woff2) format("woff2");
+ font-weight: 600;
+ style: normal;
+ display: swap;
+}
+
+@font-face {
+ font-family: "Assistant";
+ src: url(./Assistant-Bold.woff2) format("woff2");
+ font-weight: 700;
+ style: normal;
+ display: swap;
+}
diff --git a/packages/excalidraw/fonts/index.ts b/packages/excalidraw/fonts/index.ts
new file mode 100644
index 000000000..39f6bf8da
--- /dev/null
+++ b/packages/excalidraw/fonts/index.ts
@@ -0,0 +1,359 @@
+import type Scene from "../scene/Scene";
+import type { ValueOf } from "../utility-types";
+import type {
+ ExcalidrawElement,
+ ExcalidrawTextElement,
+ FontFamilyValues,
+} from "../element/types";
+import { ShapeCache } from "../scene/ShapeCache";
+import { isTextElement } from "../element";
+import { getFontString } from "../utils";
+import { FONT_FAMILY } from "../constants";
+import {
+ LOCAL_FONT_PROTOCOL,
+ FONT_METADATA,
+ RANGES,
+ type FontMetadata,
+} from "./metadata";
+import { ExcalidrawFont, type Font } from "./ExcalidrawFont";
+import { getContainerElement } from "../element/textElement";
+
+import Virgil from "./assets/Virgil-Regular.woff2";
+import Excalifont from "./assets/Excalifont-Regular.woff2";
+import Cascadia from "./assets/CascadiaCode-Regular.woff2";
+import ComicShanns from "./assets/ComicShanns-Regular.woff2";
+import LiberationSans from "./assets/LiberationSans-Regular.woff2";
+
+import LilitaLatin from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2";
+import LilitaLatinExt from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2";
+
+import NunitoLatin from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2";
+import NunitoLatinExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2";
+import NunitoCyrilic from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2";
+import NunitoCyrilicExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2";
+import NunitoVietnamese from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2";
+
+export class Fonts {
+ // it's ok to track fonts across multiple instances only once, so let's use
+ // a static member to reduce memory footprint
+ public static readonly loadedFontsCache = new Set();
+
+ private static _registered:
+ | Map<
+ number,
+ {
+ metadata: FontMetadata;
+ fonts: Font[];
+ }
+ >
+ | undefined;
+
+ private static _initialized: boolean = false;
+
+ public static get registered() {
+ // lazy load the font registration
+ if (!Fonts._registered) {
+ Fonts._registered = Fonts.init();
+ } else if (!Fonts._initialized) {
+ // case when host app register fonts before they are lazy loaded
+ // don't override whatever has been previously registered
+ Fonts._registered = new Map([
+ ...Fonts.init().entries(),
+ ...Fonts._registered.entries(),
+ ]);
+ }
+
+ return Fonts._registered;
+ }
+
+ public get registered() {
+ return Fonts.registered;
+ }
+
+ private readonly scene: Scene;
+
+ constructor({ scene }: { scene: Scene }) {
+ this.scene = scene;
+ }
+
+ /**
+ * if we load a (new) font, it's likely that text elements using it have
+ * already been rendered using a fallback font. Thus, we want invalidate
+ * their shapes and rerender. See #637.
+ *
+ * Invalidates text elements and rerenders scene, provided that at least one
+ * of the supplied fontFaces has not already been processed.
+ */
+ public onLoaded = (fontFaces: readonly FontFace[]) => {
+ if (
+ // bail if all fonts with have been processed. We're checking just a
+ // subset of the font properties (though it should be enough), so it
+ // can technically bail on a false positive.
+ fontFaces.every((fontFace) => {
+ const sig = `${fontFace.family}-${fontFace.style}-${fontFace.weight}-${fontFace.unicodeRange}`;
+ if (Fonts.loadedFontsCache.has(sig)) {
+ return true;
+ }
+ Fonts.loadedFontsCache.add(sig);
+ return false;
+ })
+ ) {
+ return false;
+ }
+
+ let didUpdate = false;
+
+ const elementsMap = this.scene.getNonDeletedElementsMap();
+
+ for (const element of this.scene.getNonDeletedElements()) {
+ if (isTextElement(element)) {
+ didUpdate = true;
+ ShapeCache.delete(element);
+ const container = getContainerElement(element, elementsMap);
+ if (container) {
+ ShapeCache.delete(container);
+ }
+ }
+ }
+
+ if (didUpdate) {
+ this.scene.triggerUpdate();
+ }
+ };
+
+ /**
+ * Load font faces for a given scene and trigger scene update.
+ */
+ public loadSceneFonts = async (): Promise => {
+ const sceneFamilies = this.getSceneFontFamilies();
+ const loaded = await Fonts.loadFontFaces(sceneFamilies);
+ this.onLoaded(loaded);
+ return loaded;
+ };
+
+ /**
+ * Gets all the font families for the given scene.
+ */
+ public getSceneFontFamilies = () => {
+ return Fonts.getFontFamilies(this.scene.getNonDeletedElements());
+ };
+
+ /**
+ * Load font faces for passed elements - use when the scene is unavailable (i.e. export).
+ */
+ public static loadFontsForElements = async (
+ elements: readonly ExcalidrawElement[],
+ ): Promise => {
+ const fontFamilies = Fonts.getFontFamilies(elements);
+ return await Fonts.loadFontFaces(fontFamilies);
+ };
+
+ private static async loadFontFaces(
+ fontFamilies: Array,
+ ) {
+ // add all registered font faces into the `document.fonts` (if not added already)
+ for (const { fonts, metadata } of Fonts.registered.values()) {
+ // skip registering font faces for local fonts (i.e. Helvetica)
+ if (metadata.local) {
+ continue;
+ }
+
+ for (const { fontFace } of fonts) {
+ if (!window.document.fonts.has(fontFace)) {
+ window.document.fonts.add(fontFace);
+ }
+ }
+ }
+
+ const loadedFontFaces = await Promise.all(
+ fontFamilies.map(async (fontFamily) => {
+ const fontString = getFontString({
+ fontFamily,
+ fontSize: 16,
+ });
+
+ // WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
+ if (!window.document.fonts.check(fontString)) {
+ try {
+ // WARN: browser prioritizes loading only font faces with unicode ranges for characters which are present in the document (html & canvas), other font faces could stay unloaded
+ // we might want to retry here, i.e. in case CDN is down, but so far I didn't experience any issues - maybe it handles retry-like logic under the hood
+ return await window.document.fonts.load(fontString);
+ } catch (e) {
+ // don't let it all fail if just one font fails to load
+ console.error(
+ `Failed to load font "${fontString}" from urls "${Fonts.registered
+ .get(fontFamily)
+ ?.fonts.map((x) => x.urls)}"`,
+ e,
+ );
+ }
+ }
+
+ return Promise.resolve();
+ }),
+ );
+
+ return loadedFontFaces.flat().filter(Boolean) as FontFace[];
+ }
+
+ /**
+ * WARN: should be called just once on init, even across multiple instances.
+ */
+ private static init() {
+ const fonts = {
+ registered: new Map<
+ ValueOf,
+ { metadata: FontMetadata; fonts: Font[] }
+ >(),
+ };
+
+ // TODO: let's tweak this once we know how `register` will be exposed as part of the custom fonts API
+ const _register = register.bind(fonts);
+
+ _register("Virgil", FONT_METADATA[FONT_FAMILY.Virgil], {
+ uri: Virgil,
+ });
+
+ _register("Excalifont", FONT_METADATA[FONT_FAMILY.Excalifont], {
+ uri: Excalifont,
+ });
+
+ // keeping for backwards compatibility reasons, uses system font (Helvetica on MacOS, Arial on Win)
+ _register("Helvetica", FONT_METADATA[FONT_FAMILY.Helvetica], {
+ uri: LOCAL_FONT_PROTOCOL,
+ });
+
+ // used for server-side pdf & png export instead of helvetica (technically does not need metrics, but kept in for consistency)
+ _register(
+ "Liberation Sans",
+ FONT_METADATA[FONT_FAMILY["Liberation Sans"]],
+ {
+ uri: LiberationSans,
+ },
+ );
+
+ _register("Cascadia", FONT_METADATA[FONT_FAMILY.Cascadia], {
+ uri: Cascadia,
+ });
+
+ _register("Comic Shanns", FONT_METADATA[FONT_FAMILY["Comic Shanns"]], {
+ uri: ComicShanns,
+ });
+
+ _register(
+ "Lilita One",
+ FONT_METADATA[FONT_FAMILY["Lilita One"]],
+ { uri: LilitaLatinExt, descriptors: { unicodeRange: RANGES.LATIN_EXT } },
+ { uri: LilitaLatin, descriptors: { unicodeRange: RANGES.LATIN } },
+ );
+
+ _register(
+ "Nunito",
+ FONT_METADATA[FONT_FAMILY.Nunito],
+ {
+ uri: NunitoCyrilicExt,
+ descriptors: { unicodeRange: RANGES.CYRILIC_EXT, weight: "500" },
+ },
+ {
+ uri: NunitoCyrilic,
+ descriptors: { unicodeRange: RANGES.CYRILIC, weight: "500" },
+ },
+ {
+ uri: NunitoVietnamese,
+ descriptors: { unicodeRange: RANGES.VIETNAMESE, weight: "500" },
+ },
+ {
+ uri: NunitoLatinExt,
+ descriptors: { unicodeRange: RANGES.LATIN_EXT, weight: "500" },
+ },
+ {
+ uri: NunitoLatin,
+ descriptors: { unicodeRange: RANGES.LATIN, weight: "500" },
+ },
+ );
+
+ Fonts._initialized = true;
+
+ return fonts.registered;
+ }
+
+ private static getFontFamilies(
+ elements: ReadonlyArray,
+ ): Array {
+ return Array.from(
+ elements.reduce((families, element) => {
+ if (isTextElement(element)) {
+ families.add(element.fontFamily);
+ }
+ return families;
+ }, new Set()),
+ );
+ }
+}
+
+/**
+ * Register a new font.
+ *
+ * @param family font family
+ * @param metadata font metadata
+ * @param params array of the rest of the FontFace parameters [uri: string, descriptors: FontFaceDescriptors?] ,
+ */
+function register(
+ this:
+ | Fonts
+ | {
+ registered: Map<
+ ValueOf,
+ { metadata: FontMetadata; fonts: Font[] }
+ >;
+ },
+ family: string,
+ metadata: FontMetadata,
+ ...params: Array<{ uri: string; descriptors?: FontFaceDescriptors }>
+) {
+ // TODO: likely we will need to abandon number "id" in order to support custom fonts
+ const familyId = FONT_FAMILY[family as keyof typeof FONT_FAMILY];
+ const registeredFamily = this.registered.get(familyId);
+
+ if (!registeredFamily) {
+ this.registered.set(familyId, {
+ metadata,
+ fonts: params.map(
+ ({ uri, descriptors }) => new ExcalidrawFont(family, uri, descriptors),
+ ),
+ });
+ }
+
+ return this.registered;
+}
+
+/**
+ * Calculates vertical offset for a text with alphabetic baseline.
+ */
+export const getVerticalOffset = (
+ fontFamily: ExcalidrawTextElement["fontFamily"],
+ fontSize: ExcalidrawTextElement["fontSize"],
+ lineHeightPx: number,
+) => {
+ const { unitsPerEm, ascender, descender } =
+ Fonts.registered.get(fontFamily)?.metadata.metrics ||
+ FONT_METADATA[FONT_FAMILY.Virgil].metrics;
+
+ const fontSizeEm = fontSize / unitsPerEm;
+ const lineGap =
+ (lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender) / 2;
+
+ const verticalOffset = fontSizeEm * ascender + lineGap;
+ return verticalOffset;
+};
+
+/**
+ * Gets line height forr a selected family.
+ */
+export const getLineHeight = (fontFamily: FontFamilyValues) => {
+ const { lineHeight } =
+ Fonts.registered.get(fontFamily)?.metadata.metrics ||
+ FONT_METADATA[FONT_FAMILY.Excalifont].metrics;
+
+ return lineHeight as ExcalidrawTextElement["lineHeight"];
+};
diff --git a/packages/excalidraw/fonts/metadata.ts b/packages/excalidraw/fonts/metadata.ts
new file mode 100644
index 000000000..0dec8e427
--- /dev/null
+++ b/packages/excalidraw/fonts/metadata.ts
@@ -0,0 +1,128 @@
+import {
+ FontFamilyCodeIcon,
+ FontFamilyHeadingIcon,
+ FontFamilyNormalIcon,
+ FreedrawIcon,
+} from "../components/icons";
+import { FONT_FAMILY } from "../constants";
+
+/**
+ * Encapsulates font metrics with additional font metadata.
+ * */
+export interface FontMetadata {
+ /** for head & hhea metrics read the woff2 with https://fontdrop.info/ */
+ metrics: {
+ /** head.unitsPerEm metric */
+ unitsPerEm: 1000 | 1024 | 2048;
+ /** hhea.ascender metric */
+ ascender: number;
+ /** hhea.descender metric */
+ descender: number;
+ /** harcoded unitless line-height, https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971 */
+ lineHeight: number;
+ };
+ /** element to be displayed as an icon */
+ icon: JSX.Element;
+ /** flag to indicate a deprecated font */
+ deprecated?: true;
+ /** flag to indicate a server-side only font */
+ serverSide?: true;
+ /** flag to indiccate a local-only font */
+ local?: true;
+}
+
+export const FONT_METADATA: Record = {
+ [FONT_FAMILY.Excalifont]: {
+ metrics: {
+ unitsPerEm: 1000,
+ ascender: 886,
+ descender: -374,
+ lineHeight: 1.25,
+ },
+ icon: FreedrawIcon,
+ },
+ [FONT_FAMILY.Nunito]: {
+ metrics: {
+ unitsPerEm: 1000,
+ ascender: 1011,
+ descender: -353,
+ lineHeight: 1.35,
+ },
+ icon: FontFamilyNormalIcon,
+ },
+ [FONT_FAMILY["Lilita One"]]: {
+ metrics: {
+ unitsPerEm: 1000,
+ ascender: 923,
+ descender: -220,
+ lineHeight: 1.15,
+ },
+ icon: FontFamilyHeadingIcon,
+ },
+ [FONT_FAMILY["Comic Shanns"]]: {
+ metrics: {
+ unitsPerEm: 1000,
+ ascender: 750,
+ descender: -250,
+ lineHeight: 1.25,
+ },
+ icon: FontFamilyCodeIcon,
+ },
+ [FONT_FAMILY.Virgil]: {
+ metrics: {
+ unitsPerEm: 1000,
+ ascender: 886,
+ descender: -374,
+ lineHeight: 1.25,
+ },
+ icon: FreedrawIcon,
+ deprecated: true,
+ },
+ [FONT_FAMILY.Helvetica]: {
+ metrics: {
+ unitsPerEm: 2048,
+ ascender: 1577,
+ descender: -471,
+ lineHeight: 1.15,
+ },
+ icon: FontFamilyNormalIcon,
+ deprecated: true,
+ local: true,
+ },
+ [FONT_FAMILY.Cascadia]: {
+ metrics: {
+ unitsPerEm: 2048,
+ ascender: 1900,
+ descender: -480,
+ lineHeight: 1.2,
+ },
+ icon: FontFamilyCodeIcon,
+ deprecated: true,
+ },
+ [FONT_FAMILY["Liberation Sans"]]: {
+ metrics: {
+ unitsPerEm: 2048,
+ ascender: 1854,
+ descender: -434,
+ lineHeight: 1.15,
+ },
+ icon: FontFamilyNormalIcon,
+ serverSide: true,
+ },
+};
+
+/** Unicode ranges */
+export const RANGES = {
+ LATIN:
+ "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD",
+ LATIN_EXT:
+ "U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF",
+ CYRILIC_EXT:
+ "U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F",
+ CYRILIC: "U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116",
+ VIETNAMESE:
+ "U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB",
+};
+
+/** local protocol to skip the local font from registering or inlining */
+export const LOCAL_FONT_PROTOCOL = "local:";
diff --git a/packages/excalidraw/fractionalIndex.ts b/packages/excalidraw/fractionalIndex.ts
index 01b6d7015..e594a1358 100644
--- a/packages/excalidraw/fractionalIndex.ts
+++ b/packages/excalidraw/fractionalIndex.ts
@@ -6,6 +6,9 @@ import type {
OrderedExcalidrawElement,
} from "./element/types";
import { InvalidFractionalIndexError } from "./errors";
+import { hasBoundTextElement } from "./element/typeChecks";
+import { getBoundTextElement } from "./element/textElement";
+import { arrayToMap } from "./utils";
/**
* Envisioned relation between array order and fractional indices:
@@ -30,17 +33,84 @@ import { InvalidFractionalIndexError } from "./errors";
* @throws `InvalidFractionalIndexError` if invalid index is detected.
*/
export const validateFractionalIndices = (
- indices: (ExcalidrawElement["index"] | undefined)[],
+ elements: readonly ExcalidrawElement[],
+ {
+ shouldThrow = false,
+ includeBoundTextValidation = false,
+ ignoreLogs,
+ reconciliationContext,
+ }: {
+ shouldThrow: boolean;
+ includeBoundTextValidation: boolean;
+ ignoreLogs?: true;
+ reconciliationContext?: {
+ localElements: ReadonlyArray;
+ remoteElements: ReadonlyArray;
+ };
+ },
) => {
+ const errorMessages = [];
+ const stringifyElement = (element: ExcalidrawElement | void) =>
+ `${element?.index}:${element?.id}:${element?.type}:${element?.isDeleted}:${element?.version}:${element?.versionNonce}`;
+
+ const indices = elements.map((x) => x.index);
for (const [i, index] of indices.entries()) {
const predecessorIndex = indices[i - 1];
const successorIndex = indices[i + 1];
if (!isValidFractionalIndex(index, predecessorIndex, successorIndex)) {
- throw new InvalidFractionalIndexError(
- `Fractional indices invariant for element has been compromised - ["${predecessorIndex}", "${index}", "${successorIndex}"] [predecessor, current, successor]`,
+ errorMessages.push(
+ `Fractional indices invariant has been compromised: "${stringifyElement(
+ elements[i - 1],
+ )}", "${stringifyElement(elements[i])}", "${stringifyElement(
+ elements[i + 1],
+ )}"`,
);
}
+
+ // disabled by default, as we don't fix it
+ if (includeBoundTextValidation && hasBoundTextElement(elements[i])) {
+ const container = elements[i];
+ const text = getBoundTextElement(container, arrayToMap(elements));
+
+ if (text && text.index! <= container.index!) {
+ errorMessages.push(
+ `Fractional indices invariant for bound elements has been compromised: "${stringifyElement(
+ text,
+ )}", "${stringifyElement(container)}"`,
+ );
+ }
+ }
+ }
+
+ if (errorMessages.length) {
+ const error = new InvalidFractionalIndexError();
+ const additionalContext = [];
+
+ if (reconciliationContext) {
+ additionalContext.push("Additional reconciliation context:");
+ additionalContext.push(
+ reconciliationContext.localElements.map((x) => stringifyElement(x)),
+ );
+ additionalContext.push(
+ reconciliationContext.remoteElements.map((x) => stringifyElement(x)),
+ );
+ }
+
+ if (!ignoreLogs) {
+ // report just once and with the stacktrace
+ console.error(
+ errorMessages.join("\n\n"),
+ error.stack,
+ elements.map((x) => stringifyElement(x)),
+ ...additionalContext,
+ );
+ }
+
+ if (shouldThrow) {
+ // if enabled, gather all the errors first, throw once
+ throw error;
+ }
}
};
@@ -83,10 +153,19 @@ export const syncMovedIndices = (
// try generatating indices, throws on invalid movedElements
const elementsUpdates = generateIndices(elements, indicesGroups);
+ const elementsCandidates = elements.map((x) =>
+ elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x,
+ );
// ensure next indices are valid before mutation, throws on invalid ones
validateFractionalIndices(
- elements.map((x) => elementsUpdates.get(x)?.index || x.index),
+ elementsCandidates,
+ // we don't autofix invalid bound text indices, hence don't include it in the validation
+ {
+ includeBoundTextValidation: false,
+ shouldThrow: true,
+ ignoreLogs: true,
+ },
);
// split mutation so we don't end up in an incosistent state
diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts
index f02ac521d..469a360ea 100644
--- a/packages/excalidraw/frame.ts
+++ b/packages/excalidraw/frame.ts
@@ -755,15 +755,12 @@ export const isElementInFrame = (
return false;
};
-export const getFrameLikeTitle = (
- element: ExcalidrawFrameLikeElement,
- frameIdx: number,
-) => {
+export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
// TODO name frames "AI" only if specific to AI frames
return element.name === null
? isFrameElement(element)
- ? `Frame ${frameIdx}`
- : `AI Frame $${frameIdx}`
+ ? "Frame"
+ : "AI Frame"
: element.name;
};
diff --git a/packages/excalidraw/global.d.ts b/packages/excalidraw/global.d.ts
index 4aeb8c428..21a5d2057 100644
--- a/packages/excalidraw/global.d.ts
+++ b/packages/excalidraw/global.d.ts
@@ -1,9 +1,10 @@
interface Window {
ClipboardItem: any;
__EXCALIDRAW_SHA__: string | undefined;
- EXCALIDRAW_ASSET_PATH: string | undefined;
+ EXCALIDRAW_ASSET_PATH: string | string[] | undefined;
EXCALIDRAW_EXPORT_SOURCE: string;
EXCALIDRAW_THROTTLE_RENDER: boolean | undefined;
+ DEBUG_FRACTIONAL_INDICES: boolean | undefined;
gtag: Function;
sa_event: Function;
fathom: { trackEvent: Function };
diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx
index 98dd9d8eb..645e5ea8d 100644
--- a/packages/excalidraw/index.tsx
+++ b/packages/excalidraw/index.tsx
@@ -5,7 +5,7 @@ import { isShallowEqual } from "./utils";
import "./css/app.scss";
import "./css/styles.scss";
-import "../../public/fonts/fonts.css";
+import "./fonts/assets/fonts.css";
import polyfill from "./polyfill";
import type { AppProps, ExcalidrawProps } from "./types";
@@ -50,6 +50,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
validateEmbeddable,
renderEmbeddable,
aiEnabled,
+ showDeprecatedFonts,
} = props;
const canvasActions = props.UIOptions?.canvasActions;
@@ -137,6 +138,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
validateEmbeddable={validateEmbeddable}
renderEmbeddable={renderEmbeddable}
aiEnabled={aiEnabled !== false}
+ showDeprecatedFonts={showDeprecatedFonts}
>
{children}
diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json
index a5573bce5..afb213df1 100644
--- a/packages/excalidraw/locales/en.json
+++ b/packages/excalidraw/locales/en.json
@@ -113,6 +113,7 @@
"share": "Share",
"showStroke": "Show stroke color picker",
"showBackground": "Show background color picker",
+ "showFonts": "Show font picker",
"toggleTheme": "Toggle light/dark theme",
"theme": "Theme",
"personalLib": "Personal Library",
@@ -298,6 +299,7 @@
"hints": {
"canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool",
"linearElement": "Click to start multiple points, drag for single line",
+ "arrowTool": "Click to start multiple points, drag for single line. Press {{arrowShortcut}} again to change arrow type.",
"freeDraw": "Click and drag, release when you're finished",
"text": "Tip: you can also add text by double-clicking anywhere with the selection tool",
"embeddable": "Click-drag to create a website embed",
@@ -561,11 +563,19 @@
"syntax": "Mermaid Syntax",
"preview": "Preview"
},
- "userList": {
- "search": {
- "placeholder": "Quick search",
- "empty": "No users found"
+ "quickSearch": {
+ "placeholder": "Quick search"
+ },
+ "fontList": {
+ "badge": {
+ "old": "old"
},
+ "sceneFonts": "In this scene",
+ "availableFonts": "Available fonts",
+ "empty": "No fonts found"
+ },
+ "userList": {
+ "empty": "No users found",
"hint": {
"text": "Click on user to follow",
"followStatus": "You're currently following this user",
diff --git a/packages/excalidraw/math.test.ts b/packages/excalidraw/math.test.ts
index eb5392eed..0d2342838 100644
--- a/packages/excalidraw/math.test.ts
+++ b/packages/excalidraw/math.test.ts
@@ -1,4 +1,9 @@
-import { rangeIntersection, rangesOverlap, rotate } from "./math";
+import {
+ isPointOnSymmetricArc,
+ rangeIntersection,
+ rangesOverlap,
+ rotate,
+} from "./math";
describe("rotate", () => {
it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
@@ -53,3 +58,42 @@ describe("range intersection", () => {
expect(rangeIntersection([1, 4], [5, 7])).toEqual(null);
});
});
+
+describe("point on arc", () => {
+ it("should detect point on simple arc", () => {
+ expect(
+ isPointOnSymmetricArc(
+ {
+ radius: 1,
+ startAngle: -Math.PI / 4,
+ endAngle: Math.PI / 4,
+ },
+ [0.92291667, 0.385],
+ ),
+ ).toBe(true);
+ });
+ it("should not detect point outside of a simple arc", () => {
+ expect(
+ isPointOnSymmetricArc(
+ {
+ radius: 1,
+ startAngle: -Math.PI / 4,
+ endAngle: Math.PI / 4,
+ },
+ [-0.92291667, 0.385],
+ ),
+ ).toBe(false);
+ });
+ it("should not detect point with good angle but incorrect radius", () => {
+ expect(
+ isPointOnSymmetricArc(
+ {
+ radius: 1,
+ startAngle: -Math.PI / 4,
+ endAngle: Math.PI / 4,
+ },
+ [-0.5, 0.5],
+ ),
+ ).toBe(false);
+ });
+});
diff --git a/packages/excalidraw/math.ts b/packages/excalidraw/math.ts
index 92f30efa3..0bf8f8bb9 100644
--- a/packages/excalidraw/math.ts
+++ b/packages/excalidraw/math.ts
@@ -619,7 +619,7 @@ export const pointInsideBounds = (p: Point, bounds: Bounds): boolean =>
* Get the axis-aligned bounding box for a given element
*/
export const aabbForElement = (
- element: ExcalidrawElement,
+ element: Readonly,
offset?: [number, number, number, number],
) => {
const bbox = {
@@ -696,3 +696,50 @@ export const dotProduct = (a: Vector, b: Vector) => a[0] * b[0] + a[1] * b[1];
export const isAnyTrue = (...args: boolean[]): boolean =>
Math.max(...args.map((arg) => (arg ? 1 : 0))) > 0;
+
+/**
+ * Angles are in radians and centered on 0, 0. Zero radians on a 1 radius circle
+ * corresponds to (1, 0) carthesian coordinates (point), i.e. to the "right".
+ */
+type SymmetricArc = { radius: number; startAngle: number; endAngle: number };
+
+/**
+ * Determines if a carthesian point lies on a symmetric arc, i.e. an arc which
+ * is part of a circle contour centered on 0, 0.
+ */
+export const isPointOnSymmetricArc = (
+ { radius: arcRadius, startAngle, endAngle }: SymmetricArc,
+ point: Point,
+): boolean => {
+ const [radius, angle] = carthesian2Polar(point);
+
+ return startAngle < endAngle
+ ? Math.abs(radius - arcRadius) < 0.0000001 &&
+ startAngle <= angle &&
+ endAngle >= angle
+ : startAngle <= angle || endAngle >= angle;
+};
+
+export const getCenterForBounds = (bounds: Bounds): Point => [
+ bounds[0] + (bounds[2] - bounds[0]) / 2,
+ bounds[1] + (bounds[3] - bounds[1]) / 2,
+];
+
+export const getCenterForElement = (element: ExcalidrawElement): Point => [
+ element.x + element.width / 2,
+ element.y + element.height / 2,
+];
+
+export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
+ pointInsideBounds([a[0], a[1]], b) ||
+ pointInsideBounds([a[2], a[1]], b) ||
+ pointInsideBounds([a[2], a[3]], b) ||
+ pointInsideBounds([a[0], a[3]], b) ||
+ pointInsideBounds([b[0], b[1]], a) ||
+ pointInsideBounds([b[2], b[1]], a) ||
+ pointInsideBounds([b[2], b[3]], a) ||
+ pointInsideBounds([b[0], b[3]], a);
+
+export const clamp = (value: number, min: number, max: number) => {
+ return Math.min(Math.max(value, min), max);
+};
diff --git a/packages/excalidraw/mermaid.test.ts b/packages/excalidraw/mermaid.test.ts
new file mode 100644
index 000000000..2a6746391
--- /dev/null
+++ b/packages/excalidraw/mermaid.test.ts
@@ -0,0 +1,15 @@
+import { isMaybeMermaidDefinition } from "./mermaid";
+
+describe("isMaybeMermaidDefinition", () => {
+ it("should return true for a valid mermaid definition", () => {
+ expect(isMaybeMermaidDefinition("flowchart")).toBe(true);
+ expect(isMaybeMermaidDefinition("flowchart LR")).toBe(true);
+ expect(isMaybeMermaidDefinition("flowchart LR\nola")).toBe(true);
+ expect(isMaybeMermaidDefinition("%%{}%%flowchart")).toBe(true);
+ expect(isMaybeMermaidDefinition("%%{}%% flowchart")).toBe(true);
+
+ expect(isMaybeMermaidDefinition("graphs")).toBe(false);
+ expect(isMaybeMermaidDefinition("this flowchart")).toBe(false);
+ expect(isMaybeMermaidDefinition("this\nflowchart")).toBe(false);
+ });
+});
diff --git a/packages/excalidraw/mermaid.ts b/packages/excalidraw/mermaid.ts
index 6114cd002..cb2b400cc 100644
--- a/packages/excalidraw/mermaid.ts
+++ b/packages/excalidraw/mermaid.ts
@@ -2,6 +2,7 @@
export const isMaybeMermaidDefinition = (text: string) => {
const chartTypes = [
"flowchart",
+ "graph",
"sequenceDiagram",
"classDiagram",
"stateDiagram",
@@ -23,9 +24,9 @@ export const isMaybeMermaidDefinition = (text: string) => {
];
const re = new RegExp(
- `^(?:%%{.*?}%%[\\s\\n]*)?\\b${chartTypes
- .map((x) => `${x}(-beta)?`)
- .join("|")}\\b`,
+ `^(?:%%{.*?}%%[\\s\\n]*)?\\b(?:${chartTypes
+ .map((x) => `\\s*${x}(-beta)?`)
+ .join("|")})\\b`,
);
return re.test(text.trim());
diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts
index 9ec0d9170..ab37a1425 100644
--- a/packages/excalidraw/renderer/interactiveScene.ts
+++ b/packages/excalidraw/renderer/interactiveScene.ts
@@ -69,7 +69,7 @@ import type {
InteractiveSceneRenderConfig,
RenderableElementsMap,
} from "../scene/types";
-import { renderVisualDebug } from "../visualdebug";
+import { getCornerRadius } from "../math";
const renderLinearElementPointHighlight = (
context: CanvasRenderingContext2D,
@@ -215,13 +215,18 @@ const renderBindingHighlightForBindableElement = (
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const width = x2 - x1;
const height = y2 - y1;
- const threshold = maxBindingGap(element, width, height);
+ const thickness = 10;
// So that we don't overlap the element itself
const strokeOffset = 4;
context.strokeStyle = "rgba(0,0,0,.05)";
- context.lineWidth = threshold - strokeOffset;
- const padding = strokeOffset / 2 + threshold / 2;
+ context.lineWidth = thickness - strokeOffset;
+ const padding = strokeOffset / 2 + thickness / 2;
+
+ const radius = getCornerRadius(
+ Math.min(element.width, element.height),
+ element,
+ );
switch (element.type) {
case "rectangle":
@@ -240,6 +245,8 @@ const renderBindingHighlightForBindableElement = (
x1 + width / 2,
y1 + height / 2,
element.angle,
+ undefined,
+ radius,
);
break;
case "diamond":
@@ -735,7 +742,12 @@ const _renderInteractiveScene = ({
if (
appState.selectedLinearElement &&
appState.selectedLinearElement.hoverPointIndex >= 0 &&
- !isElbowArrow(selectedElements[0])
+ !(
+ isElbowArrow(selectedElements[0]) &&
+ appState.selectedLinearElement.hoverPointIndex > 0 &&
+ appState.selectedLinearElement.hoverPointIndex <
+ selectedElements[0].points.length - 1
+ )
) {
renderLinearElementPointHighlight(context, appState, elementsMap);
}
@@ -977,8 +989,6 @@ const _renderInteractiveScene = ({
context.restore();
}
- renderVisualDebug(context, appState);
-
return {
scrollBars,
atLeastOneVisibleElement: visibleElements.length > 0,
diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts
index 52d7bd32c..946f5fbf4 100644
--- a/packages/excalidraw/renderer/renderElement.ts
+++ b/packages/excalidraw/renderer/renderElement.ts
@@ -53,12 +53,12 @@ import {
getLineHeightInPx,
getBoundTextMaxHeight,
getBoundTextMaxWidth,
- getVerticalOffset,
} from "../element/textElement";
import { LinearElementEditor } from "../element/linearElementEditor";
import { getContainingFrame } from "../frame";
import { ShapeCache } from "../scene/ShapeCache";
+import { getVerticalOffset } from "../fonts";
// 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
@@ -89,8 +89,16 @@ const shouldResetImageFilter = (
);
};
-const getCanvasPadding = (element: ExcalidrawElement) =>
- element.type === "freedraw" ? element.strokeWidth * 12 : 20;
+const getCanvasPadding = (element: ExcalidrawElement) => {
+ switch (element.type) {
+ case "freedraw":
+ return element.strokeWidth * 12;
+ case "text":
+ return element.fontSize / 2;
+ default:
+ return 20;
+ }
+};
export const getRenderOpacity = (
element: ExcalidrawElement,
@@ -202,7 +210,7 @@ const generateElementCanvas = (
canvas.width = width;
canvas.height = height;
- let canvasOffsetX = 0;
+ let canvasOffsetX = -100;
let canvasOffsetY = 0;
if (isLinearElement(element) || isFreeDrawElement(element)) {
diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts
index 0c2bd919a..19c48ee05 100644
--- a/packages/excalidraw/renderer/staticSvgScene.ts
+++ b/packages/excalidraw/renderer/staticSvgScene.ts
@@ -17,7 +17,6 @@ import {
getBoundTextElement,
getContainerElement,
getLineHeightInPx,
- getVerticalOffset,
} from "../element/textElement";
import {
isArrowElement,
@@ -37,6 +36,7 @@ import type { RenderableElementsMap, SVGRenderConfig } from "../scene/types";
import type { AppState, BinaryFiles } from "../types";
import { getFontFamilyString, isRTL, isTestEnv } from "../utils";
import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
+import { getVerticalOffset } from "../fonts";
const roughSVGDrawWithPrecision = (
rsvg: RoughSVG,
diff --git a/packages/excalidraw/scene/Fonts.ts b/packages/excalidraw/scene/Fonts.ts
deleted file mode 100644
index cc5088142..000000000
--- a/packages/excalidraw/scene/Fonts.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { isTextElement } from "../element";
-import { getContainerElement } from "../element/textElement";
-import type {
- ExcalidrawElement,
- ExcalidrawTextElement,
-} from "../element/types";
-import { getFontString } from "../utils";
-import type Scene from "./Scene";
-import { ShapeCache } from "./ShapeCache";
-
-export class Fonts {
- private scene: Scene;
-
- constructor({ scene }: { scene: Scene }) {
- this.scene = scene;
- }
-
- // it's ok to track fonts across multiple instances only once, so let's use
- // a static member to reduce memory footprint
- private static loadedFontFaces = new Set();
-
- /**
- * if we load a (new) font, it's likely that text elements using it have
- * already been rendered using a fallback font. Thus, we want invalidate
- * their shapes and rerender. See #637.
- *
- * Invalidates text elements and rerenders scene, provided that at least one
- * of the supplied fontFaces has not already been processed.
- */
- public onFontsLoaded = (fontFaces: readonly FontFace[]) => {
- if (
- // bail if all fonts with have been processed. We're checking just a
- // subset of the font properties (though it should be enough), so it
- // can technically bail on a false positive.
- fontFaces.every((fontFace) => {
- const sig = `${fontFace.family}-${fontFace.style}-${fontFace.weight}`;
- if (Fonts.loadedFontFaces.has(sig)) {
- return true;
- }
- Fonts.loadedFontFaces.add(sig);
- return false;
- })
- ) {
- return false;
- }
-
- let didUpdate = false;
-
- const elementsMap = this.scene.getNonDeletedElementsMap();
-
- for (const element of this.scene.getNonDeletedElements()) {
- if (isTextElement(element)) {
- didUpdate = true;
- ShapeCache.delete(element);
- const container = getContainerElement(element, elementsMap);
- if (container) {
- ShapeCache.delete(container);
- }
- }
- }
-
- if (didUpdate) {
- this.scene.triggerUpdate();
- }
- };
-
- public loadFontsForElements = async (
- elements: readonly ExcalidrawElement[],
- ) => {
- const fontFaces = await Promise.all(
- [
- ...new Set(
- elements
- .filter((element) => isTextElement(element))
- .map((element) => (element as ExcalidrawTextElement).fontFamily),
- ),
- ].map((fontFamily) => {
- const fontString = getFontString({
- fontFamily,
- fontSize: 16,
- });
- if (!document.fonts?.check?.(fontString)) {
- return document.fonts?.load?.(fontString);
- }
- return undefined;
- }),
- );
- this.onFontsLoaded(fontFaces.flat().filter(Boolean) as FontFace[]);
- };
-}
diff --git a/packages/excalidraw/scene/Scene.ts b/packages/excalidraw/scene/Scene.ts
index 637415a72..813b3cbf5 100644
--- a/packages/excalidraw/scene/Scene.ts
+++ b/packages/excalidraw/scene/Scene.ts
@@ -1,3 +1,4 @@
+import throttle from "lodash.throttle";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
@@ -50,6 +51,24 @@ const getNonDeletedElements = (
return { elementsMap, elements };
};
+const validateIndicesThrottled = throttle(
+ (elements: readonly ExcalidrawElement[]) => {
+ if (
+ import.meta.env.DEV ||
+ import.meta.env.MODE === ENV.TEST ||
+ window?.DEBUG_FRACTIONAL_INDICES
+ ) {
+ validateFractionalIndices(elements, {
+ // throw only in dev & test, to remain functional on `DEBUG_FRACTIONAL_INDICES`
+ shouldThrow: import.meta.env.DEV || import.meta.env.MODE === ENV.TEST,
+ includeBoundTextValidation: true,
+ });
+ }
+ },
+ 1000 * 60,
+ { leading: true, trailing: false },
+);
+
const hashSelectionOpts = (
opts: Parameters["getSelectedElements"]>[0],
) => {
@@ -274,10 +293,7 @@ class Scene {
: Array.from(nextElements.values());
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
- if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
- // throw on invalid indices in test / dev to potentially detect cases were we forgot to sync moved elements
- validateFractionalIndices(_nextElements.map((x) => x.index));
- }
+ validateIndicesThrottled(_nextElements);
this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear();
diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts
index ab51bad8c..1f693e644 100644
--- a/packages/excalidraw/scene/export.ts
+++ b/packages/excalidraw/scene/export.ts
@@ -13,8 +13,8 @@ import { arrayToMap, distance, getFontString, toBrandedType } from "../utils";
import type { AppState, BinaryFiles } from "../types";
import {
DEFAULT_EXPORT_PADDING,
- FONT_FAMILY,
FRAME_STYLE,
+ FONT_FAMILY,
SVG_NS,
THEME,
THEME_FILTER,
@@ -32,12 +32,13 @@ import {
getRootElements,
} from "../frame";
import { newTextElement } from "../element";
-import type { Mutable } from "../utility-types";
+import { type Mutable } from "../utility-types";
import { newElementWith } from "../element/mutateElement";
-import { isFrameElement, isFrameLikeElement } from "../element/typeChecks";
+import { isFrameLikeElement, isTextElement } from "../element/typeChecks";
import type { RenderableElementsMap } from "./types";
import { syncInvalidIndices } from "../fractionalIndex";
import { renderStaticScene } from "../renderer/staticScene";
+import { Fonts } from "../fonts";
const SVG_EXPORT_TAG = ``;
@@ -83,29 +84,19 @@ const addFrameLabelsAsTextElements = (
opts: Pick,
) => {
const nextElements: NonDeletedExcalidrawElement[] = [];
- let frameIndex = 0;
- let magicFrameIndex = 0;
for (const element of elements) {
if (isFrameLikeElement(element)) {
- if (isFrameElement(element)) {
- frameIndex++;
- } else {
- magicFrameIndex++;
- }
let textElement: Mutable = newTextElement({
x: element.x,
y: element.y - FRAME_STYLE.nameOffsetY,
- fontFamily: FONT_FAMILY.Assistant,
+ fontFamily: FONT_FAMILY.Helvetica,
fontSize: FRAME_STYLE.nameFontSize,
lineHeight:
FRAME_STYLE.nameLineHeight as ExcalidrawTextElement["lineHeight"],
strokeColor: opts.exportWithDarkMode
? FRAME_STYLE.nameColorDarkTheme
: FRAME_STYLE.nameColorLightTheme,
- text: getFrameLikeTitle(
- element,
- isFrameElement(element) ? frameIndex : magicFrameIndex,
- ),
+ text: getFrameLikeTitle(element),
});
textElement.y -= textElement.height;
@@ -182,7 +173,13 @@ export const exportToCanvas = async (
canvas.height = height * appState.exportScale;
return { canvas, scale: appState.exportScale };
},
+ loadFonts: () => Promise = async () => {
+ await Fonts.loadFontsForElements(elements);
+ },
) => {
+ // load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace)
+ await loadFonts();
+
const frameRendering = getFrameRenderingConfig(
exportingFrame ?? null,
appState.frameRendering ?? null,
@@ -269,6 +266,7 @@ export const exportToSvg = async (
*/
renderEmbeddables?: boolean;
exportingFrame?: ExcalidrawFrameLikeElement | null;
+ skipInliningFonts?: true;
},
): Promise => {
const frameRendering = getFrameRenderingConfig(
@@ -333,21 +331,6 @@ export const exportToSvg = async (
svgRoot.setAttribute("filter", THEME_FILTER);
}
- let assetPath = "https://excalidraw.com/";
- // Asset path needs to be determined only when using package
- if (import.meta.env.VITE_IS_EXCALIDRAW_NPM_PACKAGE) {
- assetPath =
- window.EXCALIDRAW_ASSET_PATH ||
- `https://unpkg.com/${import.meta.env.VITE_PKG_NAME}@${
- import.meta.env.VITE_PKG_VERSION
- }`;
-
- if (assetPath?.startsWith("/")) {
- assetPath = assetPath.replace("/", `${window.location.origin}/`);
- }
- assetPath = `${assetPath}/dist/excalidraw-assets/`;
- }
-
const offsetX = -minX + exportPadding;
const offsetY = -minY + exportPadding;
@@ -371,23 +354,50 @@ export const exportToSvg = async (
`;
}
+ const fontFamilies = elements.reduce((acc, element) => {
+ if (isTextElement(element)) {
+ acc.add(element.fontFamily);
+ }
+
+ return acc;
+ }, new Set());
+
+ const fontFaces = opts?.skipInliningFonts
+ ? []
+ : await Promise.all(
+ Array.from(fontFamilies).map(async (x) => {
+ const { fonts, metadata } = Fonts.registered.get(x) ?? {};
+
+ if (!Array.isArray(fonts)) {
+ console.error(
+ `Couldn't find registered fonts for font-family "${x}"`,
+ Fonts.registered,
+ );
+ return;
+ }
+
+ if (metadata?.local) {
+ // don't inline local fonts
+ return;
+ }
+
+ return Promise.all(
+ fonts.map(
+ async (font) => `@font-face {
+ font-family: ${font.fontFace.family};
+ src: url(${await font.getContent()});
+ }`,
+ ),
+ );
+ }),
+ );
+
svgRoot.innerHTML = `
${SVG_EXPORT_TAG}
${metadata}
${exportingFrameClipPath}
diff --git a/packages/excalidraw/shapes.tsx b/packages/excalidraw/shapes.tsx
index 5c0e98676..acdca238d 100644
--- a/packages/excalidraw/shapes.tsx
+++ b/packages/excalidraw/shapes.tsx
@@ -20,6 +20,8 @@ import {
} from "./components/icons";
import { getElementAbsoluteCoords } from "./element";
import { shouldTestInside } from "./element/collision";
+import { LinearElementEditor } from "./element/linearElementEditor";
+import { getBoundTextElement } from "./element/textElement";
import type { ElementsMap, ExcalidrawElement } from "./element/types";
import { KEYS } from "./keys";
import { ShapeCache } from "./scene/ShapeCache";
@@ -159,3 +161,31 @@ export const getElementShape = (
}
}
};
+
+export const getBoundTextShape = (
+ element: ExcalidrawElement,
+ elementsMap: ElementsMap,
+): GeometricShape | null => {
+ const boundTextElement = getBoundTextElement(element, elementsMap);
+
+ if (boundTextElement) {
+ if (element.type === "arrow") {
+ return getElementShape(
+ {
+ ...boundTextElement,
+ // arrow's bound text accurate position is not stored in the element's property
+ // but rather calculated and returned from the following static method
+ ...LinearElementEditor.getBoundTextElementPosition(
+ element,
+ boundTextElement,
+ elementsMap,
+ ),
+ },
+ elementsMap,
+ );
+ }
+ return getElementShape(boundTextElement, elementsMap);
+ }
+
+ return null;
+};
diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
index 319e0cca2..efa1e3ed0 100644
--- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
+++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
@@ -795,11 +795,12 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"top": 40,
},
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
+ "currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
- "currentItemElbowArrow": false,
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -997,11 +998,12 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
+ "currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
- "currentItemElbowArrow": false,
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -1209,11 +1211,12 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
+ "currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
- "currentItemElbowArrow": false,
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -1536,11 +1539,12 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
+ "currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
- "currentItemElbowArrow": false,
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -1863,11 +1867,12 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
+ "currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
- "currentItemElbowArrow": false,
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -2075,11 +2080,12 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
+ "currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
- "currentItemElbowArrow": false,
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -2311,11 +2317,12 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
+ "currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
- "currentItemElbowArrow": false,
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -2608,11 +2615,12 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
+ "currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
- "currentItemElbowArrow": false,
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -2973,11 +2981,12 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
+ "currentItemArrowType": "round",
"currentItemBackgroundColor": "#a5d8ff",
- "currentItemElbowArrow": false,
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "cross-hatch",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 60,
"currentItemRoughness": 2,
@@ -3444,11 +3453,12 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
+ "currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
- "currentItemElbowArrow": false,
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -3763,11 +3773,12 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
+ "currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
- "currentItemElbowArrow": false,
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -4082,11 +4093,12 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
+ "currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
- "currentItemElbowArrow": false,
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -5264,11 +5276,12 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"top": -7,
},
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
+ "currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
- "currentItemElbowArrow": false,
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -6387,11 +6400,12 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"top": -7,
},
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
+ "currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
- "currentItemElbowArrow": false,
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -7318,11 +7332,12 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"top": -9,
},
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
+ "currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
- "currentItemElbowArrow": false,
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -8226,11 +8241,12 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"top": -7,
},
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
+ "currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
- "currentItemElbowArrow": false,
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -9116,11 +9132,12 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"top": 90,
},
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
+ "currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
- "currentItemElbowArrow": false,
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
diff --git a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap
index c06fff7e4..2994cfc3e 100644
--- a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap
+++ b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap
@@ -11,11 +11,7 @@ exports[` > > should render main menu with host menu it
>