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