From 1f117995d90a1bb6c452791eff62c3efad66ee61 Mon Sep 17 00:00:00 2001 From: David Luzar <luzar.david@gmail.com> Date: Wed, 23 Nov 2022 21:15:32 +0100 Subject: [PATCH] fix: fonts not rendered on init if `loadingdone` not fired (#5923) * fix: fonts not rendered on init if `loadingdone` not fired * remove unnecessary check --- src/components/App.tsx | 41 ++++++++----------- src/global.d.ts | 2 + src/scene/Fonts.ts | 93 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 23 deletions(-) create mode 100644 src/scene/Fonts.ts diff --git a/src/components/App.tsx b/src/components/App.tsx index 3f664865..1561b382 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -123,11 +123,7 @@ import { } from "../element/binding"; import { LinearElementEditor } from "../element/linearElementEditor"; import { mutateElement, newElementWith } from "../element/mutateElement"; -import { - deepCopyElement, - newFreeDrawElement, - refreshTextDimensions, -} from "../element/newElement"; +import { deepCopyElement, newFreeDrawElement } from "../element/newElement"; import { hasBoundTextElement, isBindingElement, @@ -272,6 +268,7 @@ import { } from "../element/Hyperlink"; import { shouldShowBoundingBox } from "../element/transformHandles"; import { atom } from "jotai"; +import { Fonts } from "../scene/Fonts"; export const isMenuOpenAtom = atom(false); export const isDropdownOpenAtom = atom(false); @@ -354,6 +351,7 @@ class App extends React.Component<AppProps, AppState> { }; public scene: Scene; + private fonts: Fonts; private resizeObserver: ResizeObserver | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined; public library: AppClassProperties["library"]; @@ -445,6 +443,10 @@ class App extends React.Component<AppProps, AppState> { }; this.scene = new Scene(); + this.fonts = new Fonts({ + scene: this.scene, + onSceneUpdated: this.onSceneUpdated, + }); this.history = new History(); this.actionManager = new ActionManager( this.syncActionResult, @@ -730,23 +732,6 @@ class App extends React.Component<AppProps, AppState> { event.preventDefault(); }; - private onFontLoaded = () => { - let didUpdate = false; - this.scene.mapElements((element) => { - if (isTextElement(element)) { - invalidateShapeForElement(element); - didUpdate = true; - return newElementWith(element, { - ...refreshTextDimensions(element), - }); - } - return element; - }); - if (didUpdate) { - this.onSceneUpdated(); - } - }; - private resetHistory = () => { this.history.clear(); }; @@ -846,6 +831,12 @@ class App extends React.Component<AppProps, AppState> { }; } + // FontFaceSet loadingdone event we listen on may not always fire + // (looking at you Safari), so on init we manually load fonts for current + // text elements on canvas, and rerender them once done. This also + // seems faster even in browsers that do fire the loadingdone event. + this.fonts.loadFontsForElements(scene.elements); + this.resetHistory(); this.syncActionResult({ ...scene, @@ -1059,7 +1050,11 @@ class App extends React.Component<AppProps, AppState> { this.updateCurrentCursorPosition, ); // rerender text elements on font load to fix #637 && #1553 - document.fonts?.addEventListener?.("loadingdone", this.onFontLoaded); + document.fonts?.addEventListener?.("loadingdone", (event) => { + const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces; + this.fonts.onFontsLoaded(loadedFontFaces); + }); + // Safari-only desktop pinch zoom document.addEventListener( EVENT.GESTURE_START, diff --git a/src/global.d.ts b/src/global.d.ts index 01464094..df7eeb37 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -2,6 +2,8 @@ interface Document { fonts?: { ready?: Promise<void>; + check?: (font: string, text?: string) => boolean; + load?: (font: string, text?: string) => Promise<FontFace[]>; addEventListener?( type: "loading" | "loadingdone" | "loadingerror", listener: (this: Document, ev: Event) => any, diff --git a/src/scene/Fonts.ts b/src/scene/Fonts.ts new file mode 100644 index 00000000..cc206c77 --- /dev/null +++ b/src/scene/Fonts.ts @@ -0,0 +1,93 @@ +import { isTextElement, refreshTextDimensions } from "../element"; +import { newElementWith } from "../element/mutateElement"; +import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; +import { invalidateShapeForElement } from "../renderer/renderElement"; +import { getFontString } from "../utils"; +import type Scene from "./Scene"; + +export class Fonts { + private scene: Scene; + private onSceneUpdated: () => void; + + constructor({ + scene, + onSceneUpdated, + }: { + scene: Scene; + onSceneUpdated: () => void; + }) { + this.scene = scene; + this.onSceneUpdated = onSceneUpdated; + } + + // 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; + + this.scene.mapElements((element) => { + if (isTextElement(element)) { + invalidateShapeForElement(element); + didUpdate = true; + return newElementWith(element, { + ...refreshTextDimensions(element), + }); + } + return element; + }); + + if (didUpdate) { + this.onSceneUpdated(); + } + }; + + 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[]); + }; +}