feat: introduce font picker (#8012)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Marcel Mraz 2024-07-25 18:55:55 +02:00 committed by GitHub
parent 4c5408263c
commit 62228e0bbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
120 changed files with 3390 additions and 1106 deletions

View file

@ -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<string>();
/**
* 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[]);
};
}

View file

@ -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 = `<!-- svg-source:excalidraw -->`;
@ -95,7 +101,7 @@ const addFrameLabelsAsTextElements = (
let textElement: Mutable<ExcalidrawTextElement> = 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<SVGSVGElement> => {
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 (
</clipPath>`;
}
const fontFamilies = elements.reduce((acc, element) => {
if (isTextElement(element)) {
acc.add(element.fontFamily);
}
return acc;
}, new Set<number>());
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}
<defs>
<style class="style-fonts">
@font-face {
font-family: "Virgil";
src: url("${assetPath}Virgil.woff2");
}
@font-face {
font-family: "Cascadia";
src: url("${assetPath}Cascadia.woff2");
}
@font-face {
font-family: "Assistant";
src: url("${assetPath}Assistant-Regular.woff2");
}
${fontFaces.flat().filter(Boolean).join("\n")}
</style>
${exportingFrameClipPath}
</defs>