+
{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..ce754e263 100644
--- a/packages/excalidraw/constants.ts
+++ b/packages/excalidraw/constants.ts
@@ -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}";
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 46105b967..29cb4c378 100644
--- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap
+++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap
@@ -239,7 +239,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": [],
@@ -285,7 +285,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": [],
@@ -386,7 +386,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": [],
@@ -487,7 +487,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": [],
@@ -662,7 +662,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": [],
@@ -708,7 +708,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": [],
@@ -754,7 +754,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": [],
@@ -1207,7 +1207,7 @@ exports[`Test Transform > should transform text element 1`] = `
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -1248,7 +1248,7 @@ exports[`Test Transform > should transform text element 2`] = `
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
- "fontFamily": 1,
+ "fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -1581,7 +1581,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": [
@@ -1624,7 +1624,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": [
@@ -1667,7 +1667,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": [
@@ -1710,7 +1710,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": [
@@ -1753,7 +1753,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": [],
@@ -1794,7 +1794,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": [],
@@ -2043,7 +2043,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": [],
@@ -2084,7 +2084,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": [],
@@ -2125,7 +2125,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": [],
@@ -2167,7 +2167,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": [],
@@ -2431,7 +2431,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": [],
@@ -2472,7 +2472,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": [],
@@ -2514,7 +2514,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": [],
@@ -2558,7 +2558,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": [],
@@ -2600,7 +2600,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": [],
@@ -2643,7 +2643,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/restore.ts b/packages/excalidraw/data/restore.ts
index f42770087..c256e4e02 100644
--- a/packages/excalidraw/data/restore.ts
+++ b/packages/excalidraw/data/restore.ts
@@ -44,14 +44,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,
@@ -206,7 +203,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,
diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts
index e93f58502..73f00d63a 100644
--- a/packages/excalidraw/data/transform.ts
+++ b/packages/excalidraw/data/transform.ts
@@ -18,11 +18,7 @@ import {
newMagicFrameElement,
newTextElement,
} from "../element/newElement";
-import {
- getDefaultLineHeight,
- measureText,
- normalizeText,
-} from "../element/textElement";
+import { measureText, normalizeText } from "../element/textElement";
import type {
ElementsMap,
ExcalidrawArrowElement,
@@ -54,6 +50,7 @@ import {
import { getSizeFromPoints } from "../points";
import { randomId } from "../random";
import { syncInvalidIndices } from "../fractionalIndex";
+import { getLineHeight } from "../fonts";
export type ValidLinearElement = {
type: "arrow" | "line";
@@ -568,8 +565,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(
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 0fd0d65ce..048fd3281 100644
--- a/packages/excalidraw/element/newElement.ts
+++ b/packages/excalidraw/element/newElement.ts
@@ -36,7 +36,6 @@ import {
normalizeText,
wrapText,
getBoundTextMaxWidth,
- getDefaultLineHeight,
} from "./textElement";
import {
DEFAULT_ELEMENT_PROPS,
@@ -47,6 +46,7 @@ import {
VERTICAL_ALIGN,
} from "../constants";
import type { MarkOptional, Merge, Mutable } from "../utility-types";
+import { getLineHeight } from "../fonts";
export type ElementConstructorOpts = MarkOptional<
Omit,
@@ -228,7 +228,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,
@@ -514,7 +514,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/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..0a8481370 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,30 +633,6 @@ 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 ||
@@ -866,79 +881,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/fonts/ExcalidrawFont.ts b/packages/excalidraw/fonts/ExcalidrawFont.ts
new file mode 100644
index 000000000..5e14c1160
--- /dev/null
+++ b/packages/excalidraw/fonts/ExcalidrawFont.ts
@@ -0,0 +1,78 @@
+import { stringToBase64, toByteString } from "../data/encode";
+
+export interface Font {
+ url: URL;
+ fontFace: FontFace;
+ getContent(): Promise;
+}
+export const UNPKG_PROD_URL = `https://unpkg.com/${
+ import.meta.env.VITE_PKG_NAME
+}@${import.meta.env.PKG_VERSION}/dist/prod/`;
+
+export class ExcalidrawFont implements Font {
+ public readonly url: URL;
+ public readonly fontFace: FontFace;
+
+ constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) {
+ // 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(/^\/+/, "");
+ let baseUrl: string | undefined = undefined;
+
+ // fallback to unpkg to form a valid URL in case of a passed relative assetUrl
+ let baseUrlBuilder = window.EXCALIDRAW_ASSET_PATH || UNPKG_PROD_URL;
+
+ // 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(baseUrlBuilder)) {
+ baseUrlBuilder = new URL(
+ baseUrlBuilder.replace(/^\.?\/+/, ""),
+ window?.location?.origin,
+ ).toString();
+ }
+
+ // ensure there is a trailing slash, otherwise url won't be correctly concatenated
+ baseUrl = `${baseUrlBuilder.replace(/\/+$/, "")}/`;
+
+ this.url = new URL(assetUrl, baseUrl);
+ this.fontFace = new FontFace(family, `url(${this.url})`, {
+ display: "swap",
+ style: "normal",
+ weight: "400",
+ ...descriptors,
+ });
+ }
+
+ /**
+ * Fetches woff2 content based on the registered url (browser).
+ *
+ * Use dataurl outside the browser environment.
+ */
+ public async getContent(): Promise {
+ if (this.url.protocol === "data:") {
+ // it's dataurl, the font is inlined as base64, no need to fetch
+ return this.url.toString();
+ }
+
+ const response = await fetch(this.url, {
+ headers: {
+ Accept: "font/woff2",
+ },
+ });
+
+ if (!response.ok) {
+ console.error(
+ `Couldn't fetch font-family "${this.fontFace.family}" from url "${this.url}"`,
+ response,
+ );
+ }
+
+ const mimeType = await response.headers.get("Content-Type");
+ const buffer = await response.arrayBuffer();
+
+ return `data:${mimeType};base64,${await stringToBase64(
+ await toByteString(buffer),
+ true,
+ )}`;
+ }
+}
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/CascadiaMono-Regular.woff2 b/packages/excalidraw/fonts/assets/CascadiaMono-Regular.woff2
new file mode 100644
index 000000000..c43ff84cc
Binary files /dev/null and b/packages/excalidraw/fonts/assets/CascadiaMono-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..c56bb3833
--- /dev/null
+++ b/packages/excalidraw/fonts/assets/fonts.css
@@ -0,0 +1,34 @@
+/* Only UI fonts here, which are needed before the editor initializes. */
+/* These also cannot be preprended with `EXCALIDRAW_ASSET_PATH`. */
+
+@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..cc41ad149
--- /dev/null
+++ b/packages/excalidraw/fonts/index.ts
@@ -0,0 +1,308 @@
+import type Scene from "../scene/Scene";
+import type { ValueOf } from "../utility-types";
+import type { 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/CascadiaMono-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;
+ fontFaces: Font[];
+ }
+ >
+ | undefined;
+
+ public static get registered() {
+ if (!Fonts._registered) {
+ // lazy load the fonts
+ Fonts._registered = Fonts.init();
+ }
+
+ return Fonts._registered;
+ }
+
+ public get registered() {
+ return Fonts.registered;
+ }
+
+ private readonly scene: Scene;
+
+ public get sceneFamilies() {
+ return Array.from(
+ this.scene.getNonDeletedElements().reduce((families, element) => {
+ if (isTextElement(element)) {
+ families.add(element.fontFamily);
+ }
+ return families;
+ }, new Set()),
+ );
+ }
+
+ 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();
+ }
+ };
+
+ public load = async () => {
+ // Add all registered font faces into the `document.fonts` (if not added already)
+ for (const { fontFaces } of Fonts.registered.values()) {
+ for (const { fontFace, url } of fontFaces) {
+ if (
+ url.protocol !== LOCAL_FONT_PROTOCOL &&
+ !window.document.fonts.has(fontFace)
+ ) {
+ window.document.fonts.add(fontFace);
+ }
+ }
+ }
+
+ const loaded = await Promise.all(
+ this.sceneFamilies.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}" with error "${e}", given the following registered font:`,
+ JSON.stringify(Fonts.registered.get(fontFamily), undefined, 2),
+ );
+ }
+ }
+
+ return Promise.resolve();
+ }),
+ );
+
+ this.onLoaded(loaded.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; fontFaces: Font[] }
+ >(),
+ };
+
+ 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" },
+ },
+ );
+
+ return fonts.registered;
+ }
+}
+
+/**
+ * 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; fontFaces: 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,
+ fontFaces: 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..ea13b1543
--- /dev/null
+++ b/packages/excalidraw/fonts/metadata.ts
@@ -0,0 +1,125 @@
+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;
+}
+
+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,
+ },
+ [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/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 345c63ad1..2fe441810 100644
--- a/packages/excalidraw/locales/en.json
+++ b/packages/excalidraw/locales/en.json
@@ -109,6 +109,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",
@@ -557,11 +558,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/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/export.ts b/packages/excalidraw/scene/export.ts
index ab51bad8c..6efc2aec8 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,18 @@ 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 {
+ isFrameElement,
+ isFrameLikeElement,
+ isTextElement,
+} from "../element/typeChecks";
import type { RenderableElementsMap } from "./types";
import { syncInvalidIndices } from "../fractionalIndex";
import { renderStaticScene } from "../renderer/staticScene";
+import { Fonts } from "../fonts";
+import { LOCAL_FONT_PROTOCOL } from "../fonts/metadata";
const SVG_EXPORT_TAG = ``;
@@ -95,7 +101,7 @@ const addFrameLabelsAsTextElements = (
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"],
@@ -269,6 +275,7 @@ export const exportToSvg = async (
*/
renderEmbeddables?: boolean;
exportingFrame?: ExcalidrawFrameLikeElement | null;
+ skipInliningFonts?: true;
},
): Promise => {
const frameRendering = getFrameRenderingConfig(
@@ -333,21 +340,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 +363,57 @@ 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 { fontFaces } = Fonts.registered.get(x) ?? {};
+
+ if (!Array.isArray(fontFaces)) {
+ console.error(
+ `Couldn't find registered font-faces for font-family "${x}"`,
+ Fonts.registered,
+ );
+ return;
+ }
+
+ return Promise.all(
+ fontFaces
+ .filter((font) => font.url.protocol !== LOCAL_FONT_PROTOCOL)
+ .map(async (font) => {
+ try {
+ const content = await font.getContent();
+
+ return `@font-face {
+ font-family: ${font.fontFace.family};
+ src: url(${content});
+ }`;
+ } catch (e) {
+ console.error(
+ `Skipped inlining font with URL "${font.url.toString()}"`,
+ e,
+ );
+ return "";
+ }
+ }),
+ );
+ }),
+ );
+
svgRoot.innerHTML = `
${SVG_EXPORT_TAG}
${metadata}
${exportingFrameClipPath}
diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
index 4440d005c..44606feb1 100644
--- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
+++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
@@ -795,10 +795,11 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"top": 40,
},
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -996,10 +997,11 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -1207,10 +1209,11 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -1533,10 +1536,11 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -1859,10 +1863,11 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -2070,10 +2075,11 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -2305,10 +2311,11 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -2601,10 +2608,11 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -2965,10 +2973,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
"currentItemBackgroundColor": "#a5d8ff",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "cross-hatch",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 60,
"currentItemRoughness": 2,
@@ -3435,10 +3444,11 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -3753,10 +3763,11 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -4071,10 +4082,11 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -5252,10 +5264,11 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"top": -7,
},
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -6374,10 +6387,11 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"top": -7,
},
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -7304,10 +7318,11 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"top": -9,
},
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -8211,10 +8226,11 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"top": -7,
},
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
- "currentItemFontFamily": 1,
+ "currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
@@ -9100,10 +9116,11 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"top": 90,
},
"currentChartType": "bar",
+ "currentHoveredFontFamily": null,
"currentItemBackgroundColor": "transparent",
"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
>