fix: fonts not rendered on init if loadingdone not fired (#5923)

* fix: fonts not rendered on init if `loadingdone` not fired

* remove unnecessary check
This commit is contained in:
David Luzar 2022-11-23 21:15:32 +01:00 committed by GitHub
parent 52c96a6870
commit 1f117995d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 113 additions and 23 deletions

View File

@ -123,11 +123,7 @@ import {
} from "../element/binding"; } from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
deepCopyElement,
newFreeDrawElement,
refreshTextDimensions,
} from "../element/newElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
isBindingElement, isBindingElement,
@ -272,6 +268,7 @@ import {
} from "../element/Hyperlink"; } from "../element/Hyperlink";
import { shouldShowBoundingBox } from "../element/transformHandles"; import { shouldShowBoundingBox } from "../element/transformHandles";
import { atom } from "jotai"; import { atom } from "jotai";
import { Fonts } from "../scene/Fonts";
export const isMenuOpenAtom = atom(false); export const isMenuOpenAtom = atom(false);
export const isDropdownOpenAtom = atom(false); export const isDropdownOpenAtom = atom(false);
@ -354,6 +351,7 @@ class App extends React.Component<AppProps, AppState> {
}; };
public scene: Scene; public scene: Scene;
private fonts: Fonts;
private resizeObserver: ResizeObserver | undefined; private resizeObserver: ResizeObserver | undefined;
private nearestScrollableContainer: HTMLElement | Document | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined;
public library: AppClassProperties["library"]; public library: AppClassProperties["library"];
@ -445,6 +443,10 @@ class App extends React.Component<AppProps, AppState> {
}; };
this.scene = new Scene(); this.scene = new Scene();
this.fonts = new Fonts({
scene: this.scene,
onSceneUpdated: this.onSceneUpdated,
});
this.history = new History(); this.history = new History();
this.actionManager = new ActionManager( this.actionManager = new ActionManager(
this.syncActionResult, this.syncActionResult,
@ -730,23 +732,6 @@ class App extends React.Component<AppProps, AppState> {
event.preventDefault(); 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 = () => { private resetHistory = () => {
this.history.clear(); 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.resetHistory();
this.syncActionResult({ this.syncActionResult({
...scene, ...scene,
@ -1059,7 +1050,11 @@ class App extends React.Component<AppProps, AppState> {
this.updateCurrentCursorPosition, this.updateCurrentCursorPosition,
); );
// rerender text elements on font load to fix #637 && #1553 // 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 // Safari-only desktop pinch zoom
document.addEventListener( document.addEventListener(
EVENT.GESTURE_START, EVENT.GESTURE_START,

2
src/global.d.ts vendored
View File

@ -2,6 +2,8 @@
interface Document { interface Document {
fonts?: { fonts?: {
ready?: Promise<void>; ready?: Promise<void>;
check?: (font: string, text?: string) => boolean;
load?: (font: string, text?: string) => Promise<FontFace[]>;
addEventListener?( addEventListener?(
type: "loading" | "loadingdone" | "loadingerror", type: "loading" | "loadingdone" | "loadingerror",
listener: (this: Document, ev: Event) => any, listener: (this: Document, ev: Event) => any,

93
src/scene/Fonts.ts Normal file
View File

@ -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[]);
};
}