import { CodedError, registerWebModule } from 'expo-modules-core'; import FontObserver from 'fontfaceobserver'; import type { ExpoFontLoaderModule } from './ExpoFontLoader'; import type { UnloadFontOptions } from './Font'; import { FontDisplay, type FontResource } from './Font.types'; import { addServerFont, getLoadedServerFonts, getServerResourceDescriptors as readServerResourceDescriptors, isServerFontLoaded, } from './serverContext'; function getFontFaceStyleSheet(): CSSStyleSheet | null { if (typeof window === 'undefined') { return null; } const styleSheet = getStyleElement(); return styleSheet.sheet ? (styleSheet.sheet as CSSStyleSheet) : null; } type RuleItem = { rule: CSSFontFaceRule; index: number }; function getFontFaceRules(): RuleItem[] { const sheet = getFontFaceStyleSheet(); if (sheet) { // @ts-ignore: rule iterator const rules = [...sheet.cssRules]; const items: RuleItem[] = []; for (let i = 0; i < rules.length; i++) { const rule = rules[i]; if (rule instanceof CSSFontFaceRule) { items.push({ rule, index: i }); } } return items; } return []; } function getFontFaceRulesMatchingResource( fontFamilyName: string, options?: UnloadFontOptions ): RuleItem[] { const rules = getFontFaceRules(); return rules.filter(({ rule }) => { return ( rule.style.fontFamily === fontFamilyName && (options && options.display ? options.display === (rule.style as any).fontDisplay : true) ); }); } const ExpoFontLoader: Required = { async unloadAllAsync(): Promise { if (typeof window === 'undefined') return; const element = document.getElementById(ID); if (element && element instanceof HTMLStyleElement) { document.removeChild(element); } }, async unloadAsync(fontFamilyName: string, options?: UnloadFontOptions): Promise { const sheet = getFontFaceStyleSheet(); if (!sheet) return; const items = getFontFaceRulesMatchingResource(fontFamilyName, options); for (const item of items) { sheet.deleteRule(item.index); } }, getServerResources(): string[] { const elements = readServerResourceDescriptors(); return elements .map((element) => { switch (element.type) { case 'style': return ``; case 'link': return ``; default: return ''; } }) .filter(Boolean); }, getServerResourceDescriptors() { return readServerResourceDescriptors(); }, getLoadedFonts(): string[] { if (typeof window === 'undefined') { return getLoadedServerFonts(); } const rules = getFontFaceRules(); return rules.map(({ rule }) => rule.style.fontFamily); }, isLoaded(fontFamilyName: string, resource: UnloadFontOptions = {}): boolean { if (typeof window === 'undefined') { return isServerFontLoaded(fontFamilyName); } return getFontFaceRulesMatchingResource(fontFamilyName, resource)?.length > 0; }, // NOTE(vonovak): This is used in RN vector-icons to load fonts dynamically on web. Changing the signature is breaking. // NOTE(EvanBacon): No async keyword! This cannot return a promise in Node environments. loadAsync(fontFamilyName: string, resource: FontResource): Promise { if (__DEV__ && typeof resource !== 'object') { // to help devving on web, where loadAsync interface is different from native throw new CodedError( 'ERR_FONT_SOURCE', `Expected font resource of type \`object\` instead got: ${typeof resource}` ); } if (typeof window === 'undefined') { addServerFont({ name: fontFamilyName, css: _createWebFontTemplate(fontFamilyName, resource), // @ts-expect-error: typeof string resourceId: resource.uri!, }); return Promise.resolve(); } const canInjectStyle = document.head && typeof document.head.appendChild === 'function'; if (!canInjectStyle) { throw new CodedError( 'ERR_WEB_ENVIRONMENT', `The browser's \`document.head\` element doesn't support injecting fonts.` ); } const style = getStyleElement(); document.head!.appendChild(style); const res = getFontFaceRulesMatchingResource(fontFamilyName, resource); if (!res.length) { _createWebStyle(fontFamilyName, resource); } if (!isFontLoadingListenerSupported()) { return Promise.resolve(); } return new FontObserver(fontFamilyName, { // @ts-expect-error: TODO(@kitten): Typings indicate that the polyfill may not support this? display: resource.display, }).load(resource.testString ?? null, 12000); }, }; const isServer = process.env.EXPO_OS === 'web' && typeof window === 'undefined'; function createExpoFontLoader() { return ExpoFontLoader; } const toExport = isServer ? ExpoFontLoader : // @ts-expect-error: registerWebModule calls `new` on the module implementation. // Normally that'd be a class but that doesn't work on server, so we use a function instead. // TS doesn't like that but we don't need it to be a class. registerWebModule(createExpoFontLoader, 'ExpoFontLoader'); export default toExport as typeof ExpoFontLoader; const ID = 'expo-generated-fonts'; function getStyleElement(): HTMLStyleElement { const element = document.getElementById(ID); if (element && element instanceof HTMLStyleElement) { return element; } const styleElement = document.createElement('style'); styleElement.id = ID; return styleElement; } const CSS_IDENT_RE = /^[a-zA-Z_-][\w-]*$/; export function _createWebFontTemplate(fontFamily: string, resource: FontResource): string { const display = typeof resource.display === 'string' && CSS_IDENT_RE.test(resource.display) ? resource.display : FontDisplay.AUTO; return `@font-face{font-family:${JSON.stringify(fontFamily)};src:url(${JSON.stringify( resource.uri )});font-display:${display}}`; } function _createWebStyle(fontFamily: string, resource: FontResource): HTMLStyleElement { const fontStyle = _createWebFontTemplate(fontFamily, resource); const styleElement = getStyleElement(); // @ts-ignore: TypeScript does not define HTMLStyleElement::styleSheet. This is just for IE and // possibly can be removed if it's unnecessary on IE 11. if (styleElement.styleSheet) { const styleElementIE = styleElement as any; styleElementIE.styleSheet.cssText = styleElementIE.styleSheet.cssText ? styleElementIE.styleSheet.cssText + fontStyle : fontStyle; } else { const textNode = document.createTextNode(fontStyle); styleElement.appendChild(textNode); } return styleElement; } function isFontLoadingListenerSupported(): boolean { const { userAgent } = window.navigator; // WebKit is broken https://github.com/bramstein/fontfaceobserver/issues/95 const isIOS = !!userAgent.match(/iPad|iPhone/i); const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // Edge is broken https://github.com/bramstein/fontfaceobserver/issues/109#issuecomment-333356795 const isEdge = userAgent.includes('Edge'); // Internet Explorer const isIE = userAgent.includes('Trident'); return !isSafari && !isIOS && !isEdge && !isIE; }