feat: introduce font picker (#8012)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { stringToBase64, toByteString } from "../data/encode";
|
||||
|
||||
export interface Font {
|
||||
url: URL;
|
||||
fontFace: FontFace;
|
||||
getContent(): Promise<string>;
|
||||
}
|
||||
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<string> {
|
||||
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,
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
}
|
||||
@@ -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<string>();
|
||||
|
||||
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<number>()),
|
||||
);
|
||||
}
|
||||
|
||||
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<typeof FONT_FAMILY>,
|
||||
{ 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<typeof FONT_FAMILY>,
|
||||
{ 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"];
|
||||
};
|
||||
@@ -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<number, FontMetadata> = {
|
||||
[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:";
|
||||
Reference in New Issue
Block a user