1import { CodedError, Platform } from 'expo-modules-core'; 2import FontObserver from 'fontfaceobserver'; 3 4import { UnloadFontOptions } from './Font'; 5import { FontDisplay, FontResource } from './Font.types'; 6 7function getFontFaceStyleSheet(): CSSStyleSheet | null { 8 if (!Platform.isDOMAvailable) { 9 return null; 10 } 11 const styleSheet = getStyleElement(); 12 return styleSheet.sheet ? (styleSheet.sheet as CSSStyleSheet) : null; 13} 14 15type RuleItem = { rule: CSSFontFaceRule; index: number }; 16 17function getFontFaceRules(): RuleItem[] { 18 const sheet = getFontFaceStyleSheet(); 19 if (sheet) { 20 // @ts-ignore: rule iterator 21 const rules = [...sheet.cssRules]; 22 23 const items: RuleItem[] = []; 24 25 for (let i = 0; i < rules.length; i++) { 26 const rule = rules[i]; 27 if (rule instanceof CSSFontFaceRule) { 28 items.push({ rule, index: i }); 29 } 30 } 31 return items; 32 } 33 return []; 34} 35 36function getFontFaceRulesMatchingResource( 37 fontFamilyName: string, 38 options?: UnloadFontOptions 39): RuleItem[] { 40 const rules = getFontFaceRules(); 41 return rules.filter(({ rule }) => { 42 return ( 43 rule.style.fontFamily === fontFamilyName && 44 (options && options.display ? options.display === (rule.style as any).fontDisplay : true) 45 ); 46 }); 47} 48 49const serverContext: Set<{ name: string; css: string; resourceId: string }> = new Set(); 50 51function getHeadElements(): { 52 $$type: string; 53 rel?: string; 54 href?: string; 55 as?: string; 56 crossorigin?: string; 57 children?: string; 58 id?: string; 59 type?: string; 60}[] { 61 const entries = [...serverContext.entries()]; 62 if (!entries.length) { 63 return []; 64 } 65 const css = entries.map(([{ css }]) => css).join('\n'); 66 const links = entries.map(([{ resourceId }]) => resourceId); 67 // TODO: Maybe return nothing if no fonts were loaded. 68 return [ 69 { 70 $$type: 'style', 71 children: css, 72 id: ID, 73 type: 'text/css', 74 }, 75 ...links.map((resourceId) => ({ 76 $$type: 'link', 77 rel: 'preload', 78 href: resourceId, 79 as: 'font', 80 crossorigin: '', 81 })), 82 ]; 83} 84 85export default { 86 get name(): string { 87 return 'ExpoFontLoader'; 88 }, 89 90 async unloadAllAsync(): Promise<void> { 91 if (!Platform.isDOMAvailable) return; 92 93 const element = document.getElementById(ID); 94 if (element && element instanceof HTMLStyleElement) { 95 document.removeChild(element); 96 } 97 }, 98 99 async unloadAsync(fontFamilyName: string, options?: UnloadFontOptions): Promise<void> { 100 const sheet = getFontFaceStyleSheet(); 101 if (!sheet) return; 102 const items = getFontFaceRulesMatchingResource(fontFamilyName, options); 103 for (const item of items) { 104 sheet.deleteRule(item.index); 105 } 106 }, 107 108 getServerResources(): string[] { 109 const elements = getHeadElements(); 110 111 return elements 112 .map((element) => { 113 switch (element.$$type) { 114 case 'style': 115 return `<style id="${element.id}" type="${element.type}">${element.children}</style>`; 116 case 'link': 117 return `<link rel="${element.rel}" href="${element.href}" as="${element.as}" crossorigin="${element.crossorigin}" />`; 118 default: 119 return ''; 120 } 121 }) 122 .filter(Boolean); 123 }, 124 125 resetServerContext() { 126 serverContext.clear(); 127 }, 128 129 isLoaded(fontFamilyName: string, resource: UnloadFontOptions = {}): boolean { 130 if (typeof window === 'undefined') { 131 return !![...serverContext.values()].find((asset) => { 132 return asset.name === fontFamilyName; 133 }); 134 } 135 return getFontFaceRulesMatchingResource(fontFamilyName, resource)?.length > 0; 136 }, 137 138 // NOTE(EvanBacon): No async keyword! This cannot return a promise in Node environments. 139 loadAsync(fontFamilyName: string, resource: FontResource): Promise<void> { 140 if (typeof window === 'undefined') { 141 serverContext.add({ 142 name: fontFamilyName, 143 css: _createWebFontTemplate(fontFamilyName, resource), 144 // @ts-expect-error: typeof string 145 resourceId: resource.uri!, 146 }); 147 return Promise.resolve(); 148 } 149 150 const canInjectStyle = document.head && typeof document.head.appendChild === 'function'; 151 if (!canInjectStyle) { 152 throw new CodedError( 153 'ERR_WEB_ENVIRONMENT', 154 `The browser's \`document.head\` element doesn't support injecting fonts.` 155 ); 156 } 157 158 const style = getStyleElement(); 159 document.head!.appendChild(style); 160 161 const res = getFontFaceRulesMatchingResource(fontFamilyName, resource); 162 if (!res.length) { 163 _createWebStyle(fontFamilyName, resource); 164 } 165 166 if (!isFontLoadingListenerSupported()) { 167 return Promise.resolve(); 168 } 169 170 return new FontObserver(fontFamilyName, { display: resource.display }).load(null, 6000); 171 }, 172}; 173 174const ID = 'expo-generated-fonts'; 175 176function getStyleElement(): HTMLStyleElement { 177 const element = document.getElementById(ID); 178 if (element && element instanceof HTMLStyleElement) { 179 return element; 180 } 181 const styleElement = document.createElement('style'); 182 styleElement.id = ID; 183 styleElement.type = 'text/css'; 184 return styleElement; 185} 186 187export function _createWebFontTemplate(fontFamily: string, resource: FontResource): string { 188 return `@font-face{font-family:${fontFamily};src:url(${resource.uri});font-display:${ 189 resource.display || FontDisplay.AUTO 190 }}`; 191} 192 193function _createWebStyle(fontFamily: string, resource: FontResource): HTMLStyleElement { 194 const fontStyle = _createWebFontTemplate(fontFamily, resource); 195 196 const styleElement = getStyleElement(); 197 // @ts-ignore: TypeScript does not define HTMLStyleElement::styleSheet. This is just for IE and 198 // possibly can be removed if it's unnecessary on IE 11. 199 if (styleElement.styleSheet) { 200 const styleElementIE = styleElement as any; 201 styleElementIE.styleSheet.cssText = styleElementIE.styleSheet.cssText 202 ? styleElementIE.styleSheet.cssText + fontStyle 203 : fontStyle; 204 } else { 205 const textNode = document.createTextNode(fontStyle); 206 styleElement.appendChild(textNode); 207 } 208 return styleElement; 209} 210 211function isFontLoadingListenerSupported(): boolean { 212 const { userAgent } = window.navigator; 213 // WebKit is broken https://github.com/bramstein/fontfaceobserver/issues/95 214 const isIOS = !!userAgent.match(/iPad|iPhone/i); 215 const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); 216 // Edge is broken https://github.com/bramstein/fontfaceobserver/issues/109#issuecomment-333356795 217 const isEdge = userAgent.includes('Edge'); 218 // Internet Explorer 219 const isIE = userAgent.includes('Trident'); 220 // Firefox 221 const isFirefox = userAgent.includes('Firefox'); 222 return !isSafari && !isIOS && !isEdge && !isIE && !isFirefox; 223} 224