From 5a0771ad9c68f5011784b46c96f8360da1599d42 Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Tue, 30 Jul 2024 17:23:35 +0200 Subject: [PATCH] fix: load fonts for `exportToCanvas` (#8298) --- packages/excalidraw/components/App.tsx | 2 +- .../components/FontPicker/FontPickerList.tsx | 2 +- packages/excalidraw/fonts/index.ts | 83 +++++++++++++++---- packages/excalidraw/scene/export.ts | 6 ++ 4 files changed, 73 insertions(+), 20 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index cf23af641b..f198d3016a 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -2345,7 +2345,7 @@ class App extends React.Component { // fire (looking at you Safari), so on init we manually load all // fonts and rerender scene text elements once done. This also // seems faster even in browsers that do fire the loadingdone event. - this.fonts.load(); + this.fonts.loadSceneFonts(); }; private isMobileBreakpoint = (width: number, height: number) => { diff --git a/packages/excalidraw/components/FontPicker/FontPickerList.tsx b/packages/excalidraw/components/FontPicker/FontPickerList.tsx index 7adf53eafc..91d537ebfc 100644 --- a/packages/excalidraw/components/FontPicker/FontPickerList.tsx +++ b/packages/excalidraw/components/FontPicker/FontPickerList.tsx @@ -89,7 +89,7 @@ export const FontPickerList = React.memo( ); const sceneFamilies = useMemo( - () => new Set(fonts.sceneFamilies), + () => new Set(fonts.getSceneFontFamilies()), // cache per selected font family, so hover re-render won't mess it up // eslint-disable-next-line react-hooks/exhaustive-deps [selectedFontFamily], diff --git a/packages/excalidraw/fonts/index.ts b/packages/excalidraw/fonts/index.ts index f528d34b98..e301504814 100644 --- a/packages/excalidraw/fonts/index.ts +++ b/packages/excalidraw/fonts/index.ts @@ -1,6 +1,10 @@ import type Scene from "../scene/Scene"; import type { ValueOf } from "../utility-types"; -import type { ExcalidrawTextElement, FontFamilyValues } from "../element/types"; +import type { + ExcalidrawElement, + ExcalidrawTextElement, + FontFamilyValues, +} from "../element/types"; import { ShapeCache } from "../scene/ShapeCache"; import { isTextElement } from "../element"; import { getFontString } from "../utils"; @@ -44,10 +48,19 @@ export class Fonts { > | undefined; + private static _initialized: boolean = false; + public static get registered() { + // lazy load the font registration if (!Fonts._registered) { - // lazy load the fonts Fonts._registered = Fonts.init(); + } else if (!Fonts._initialized) { + // case when host app register fonts before they are lazy loaded + // don't override whatever has been previously registered + Fonts._registered = new Map([ + ...Fonts.init().entries(), + ...Fonts._registered.entries(), + ]); } return Fonts._registered; @@ -59,17 +72,6 @@ export class Fonts { 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; } @@ -119,7 +121,36 @@ export class Fonts { } }; - public load = async () => { + /** + * Load font faces for a given scene and trigger scene update. + */ + public loadSceneFonts = async (): Promise => { + const sceneFamilies = this.getSceneFontFamilies(); + const loaded = await Fonts.loadFontFaces(sceneFamilies); + this.onLoaded(loaded); + return loaded; + }; + + /** + * Gets all the font families for the given scene. + */ + public getSceneFontFamilies = () => { + return Fonts.getFontFamilies(this.scene.getNonDeletedElements()); + }; + + /** + * Load font faces for passed elements - use when the scene is unavailable (i.e. export). + */ + public static loadFontsForElements = async ( + elements: readonly ExcalidrawElement[], + ): Promise => { + const fontFamilies = Fonts.getFontFamilies(elements); + return await Fonts.loadFontFaces(fontFamilies); + }; + + private static async loadFontFaces( + fontFamilies: Array, + ) { // Add all registered font faces into the `document.fonts` (if not added already) for (const { fonts } of Fonts.registered.values()) { for (const { fontFace } of fonts) { @@ -129,8 +160,8 @@ export class Fonts { } } - const loaded = await Promise.all( - this.sceneFamilies.map(async (fontFamily) => { + const loadedFontFaces = await Promise.all( + fontFamilies.map(async (fontFamily) => { const fontString = getFontString({ fontFamily, fontSize: 16, @@ -157,8 +188,8 @@ export class Fonts { }), ); - this.onLoaded(loaded.flat().filter(Boolean) as FontFace[]); - }; + return loadedFontFaces.flat().filter(Boolean) as FontFace[]; + } /** * WARN: should be called just once on init, even across multiple instances. @@ -171,6 +202,7 @@ export class Fonts { >(), }; + // TODO: let's tweak this once we know how `register` will be exposed as part of the custom fonts API const _register = register.bind(fonts); _register("Virgil", FONT_METADATA[FONT_FAMILY.Virgil], { @@ -235,8 +267,23 @@ export class Fonts { }, ); + Fonts._initialized = true; + return fonts.registered; } + + private static getFontFamilies( + elements: ReadonlyArray, + ): Array { + return Array.from( + elements.reduce((families, element) => { + if (isTextElement(element)) { + families.add(element.fontFamily); + } + return families; + }, new Set()), + ); + } } /** diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 205cfa761d..4167c71f94 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -187,7 +187,13 @@ export const exportToCanvas = async ( canvas.height = height * appState.exportScale; return { canvas, scale: appState.exportScale }; }, + loadFonts: () => Promise = async () => { + await Fonts.loadFontsForElements(elements); + }, ) => { + // load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace) + await loadFonts(); + const frameRendering = getFrameRenderingConfig( exportingFrame ?? null, appState.frameRendering ?? null,