flowboard-react 0.6.8 → 0.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/lib/module/components/FlowboardFlow.js +24 -66
- package/lib/module/components/FlowboardFlow.js.map +1 -1
- package/lib/module/components/FlowboardRenderer.js +590 -196
- package/lib/module/components/FlowboardRenderer.js.map +1 -1
- package/lib/module/components/layout/stackOverlayModel.js +156 -0
- package/lib/module/components/layout/stackOverlayModel.js.map +1 -0
- package/lib/module/fonts/fontLoader.js +285 -0
- package/lib/module/fonts/fontLoader.js.map +1 -0
- package/lib/module/fonts/fontResolver.js +38 -0
- package/lib/module/fonts/fontResolver.js.map +1 -0
- package/lib/module/fonts/google-fonts-meta.json +1 -0
- package/lib/module/fonts/googleFontCatalog.js +56 -0
- package/lib/module/fonts/googleFontCatalog.js.map +1 -0
- package/lib/module/fonts/googleFontLoader.js +101 -0
- package/lib/module/fonts/googleFontLoader.js.map +1 -0
- package/lib/module/fonts/googleFontsLoader.js +68 -0
- package/lib/module/fonts/googleFontsLoader.js.map +1 -0
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/FlowboardFlow.d.ts.map +1 -1
- package/lib/typescript/src/components/FlowboardRenderer.d.ts +11 -4
- package/lib/typescript/src/components/FlowboardRenderer.d.ts.map +1 -1
- package/lib/typescript/src/components/layout/stackOverlayModel.d.ts +13 -0
- package/lib/typescript/src/components/layout/stackOverlayModel.d.ts.map +1 -0
- package/lib/typescript/src/fonts/fontLoader.d.ts +17 -0
- package/lib/typescript/src/fonts/fontLoader.d.ts.map +1 -0
- package/lib/typescript/src/fonts/fontResolver.d.ts +11 -0
- package/lib/typescript/src/fonts/fontResolver.d.ts.map +1 -0
- package/lib/typescript/src/fonts/googleFontCatalog.d.ts +4 -0
- package/lib/typescript/src/fonts/googleFontCatalog.d.ts.map +1 -0
- package/lib/typescript/src/fonts/googleFontLoader.d.ts +7 -0
- package/lib/typescript/src/fonts/googleFontLoader.d.ts.map +1 -0
- package/lib/typescript/src/fonts/googleFontsLoader.d.ts +18 -0
- package/lib/typescript/src/fonts/googleFontsLoader.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +2 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +14 -2
- package/src/components/FlowboardFlow.tsx +33 -82
- package/src/components/FlowboardRenderer.tsx +852 -210
- package/src/components/layout/stackOverlayModel.ts +185 -0
- package/src/fonts/fontLoader.ts +426 -0
- package/src/fonts/fontResolver.ts +69 -0
- package/src/fonts/google-fonts-meta.json +1 -0
- package/src/fonts/googleFontCatalog.ts +77 -0
- package/src/fonts/googleFontLoader.ts +136 -0
- package/src/fonts/googleFontsLoader.ts +124 -0
- package/src/index.tsx +13 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { ViewStyle } from 'react-native';
|
|
2
|
+
import { parseDimension } from '../../utils/flowboardUtils';
|
|
3
|
+
|
|
4
|
+
type StackFitBehavior = 'loose' | 'expand';
|
|
5
|
+
type StackClipBehavior = 'none' | 'hardEdge';
|
|
6
|
+
|
|
7
|
+
const POSITION_KEYS = ['top', 'right', 'bottom', 'left'] as const;
|
|
8
|
+
|
|
9
|
+
type OverlayAlignment =
|
|
10
|
+
| 'topStart'
|
|
11
|
+
| 'top'
|
|
12
|
+
| 'topEnd'
|
|
13
|
+
| 'centerStart'
|
|
14
|
+
| 'start'
|
|
15
|
+
| 'center'
|
|
16
|
+
| 'centerEnd'
|
|
17
|
+
| 'end'
|
|
18
|
+
| 'bottomStart'
|
|
19
|
+
| 'bottom'
|
|
20
|
+
| 'bottomEnd';
|
|
21
|
+
|
|
22
|
+
const AMBIGUOUS_OVERLAY_ALIGNMENTS: ReadonlySet<OverlayAlignment> = new Set([
|
|
23
|
+
'start',
|
|
24
|
+
'center',
|
|
25
|
+
'end',
|
|
26
|
+
'centerStart',
|
|
27
|
+
'centerEnd',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function hasPositioning(props: Record<string, any> | undefined): boolean {
|
|
31
|
+
if (!props || typeof props !== 'object') return false;
|
|
32
|
+
return POSITION_KEYS.some(
|
|
33
|
+
(key) => props[key] !== undefined && props[key] !== null
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isPositionedChild(
|
|
38
|
+
child: Record<string, any> | undefined
|
|
39
|
+
): boolean {
|
|
40
|
+
if (!child || typeof child !== 'object') return false;
|
|
41
|
+
if (String(child.type ?? '') === 'positioned') return true;
|
|
42
|
+
return hasPositioning((child.properties ?? {}) as Record<string, any>);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isOverlayStack(
|
|
46
|
+
children: Array<Record<string, any>> | undefined,
|
|
47
|
+
props: Record<string, any>
|
|
48
|
+
): boolean {
|
|
49
|
+
if (String(props.axis ?? '').toLowerCase() === 'overlay') return true;
|
|
50
|
+
if (String(props.mode ?? '').toLowerCase() === 'overlay') return true;
|
|
51
|
+
return (children ?? []).some((child) => isPositionedChild(child));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseOverlayAlignment(value: unknown): OverlayAlignment | undefined {
|
|
55
|
+
const raw = String(value ?? '').trim();
|
|
56
|
+
switch (raw) {
|
|
57
|
+
case 'topStart':
|
|
58
|
+
case 'top':
|
|
59
|
+
case 'topEnd':
|
|
60
|
+
case 'centerStart':
|
|
61
|
+
case 'start':
|
|
62
|
+
case 'center':
|
|
63
|
+
case 'centerEnd':
|
|
64
|
+
case 'end':
|
|
65
|
+
case 'bottomStart':
|
|
66
|
+
case 'bottom':
|
|
67
|
+
case 'bottomEnd':
|
|
68
|
+
return raw;
|
|
69
|
+
case 'topLeft':
|
|
70
|
+
return 'topStart';
|
|
71
|
+
case 'topCenter':
|
|
72
|
+
return 'top';
|
|
73
|
+
case 'topRight':
|
|
74
|
+
return 'topEnd';
|
|
75
|
+
case 'centerLeft':
|
|
76
|
+
return 'start';
|
|
77
|
+
case 'centerRight':
|
|
78
|
+
return 'end';
|
|
79
|
+
case 'bottomLeft':
|
|
80
|
+
return 'bottomStart';
|
|
81
|
+
case 'bottomCenter':
|
|
82
|
+
return 'bottom';
|
|
83
|
+
case 'bottomRight':
|
|
84
|
+
return 'bottomEnd';
|
|
85
|
+
default:
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeOverlayAlignment(value: unknown): OverlayAlignment {
|
|
91
|
+
return parseOverlayAlignment(value) ?? 'topStart';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolveAlignmentToCss(
|
|
95
|
+
value: unknown
|
|
96
|
+
): Pick<ViewStyle, 'justifyContent' | 'alignItems'> {
|
|
97
|
+
switch (normalizeOverlayAlignment(value)) {
|
|
98
|
+
case 'topStart':
|
|
99
|
+
return { justifyContent: 'flex-start', alignItems: 'flex-start' };
|
|
100
|
+
case 'top':
|
|
101
|
+
return { justifyContent: 'flex-start', alignItems: 'center' };
|
|
102
|
+
case 'topEnd':
|
|
103
|
+
return { justifyContent: 'flex-start', alignItems: 'flex-end' };
|
|
104
|
+
case 'centerStart':
|
|
105
|
+
case 'start':
|
|
106
|
+
return { justifyContent: 'center', alignItems: 'flex-start' };
|
|
107
|
+
case 'centerEnd':
|
|
108
|
+
case 'end':
|
|
109
|
+
return { justifyContent: 'center', alignItems: 'flex-end' };
|
|
110
|
+
case 'bottomStart':
|
|
111
|
+
return { justifyContent: 'flex-end', alignItems: 'flex-start' };
|
|
112
|
+
case 'bottom':
|
|
113
|
+
return { justifyContent: 'flex-end', alignItems: 'center' };
|
|
114
|
+
case 'bottomEnd':
|
|
115
|
+
return { justifyContent: 'flex-end', alignItems: 'flex-end' };
|
|
116
|
+
case 'center':
|
|
117
|
+
default:
|
|
118
|
+
return { justifyContent: 'center', alignItems: 'center' };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function resolveOverlayAlignmentValue(
|
|
123
|
+
props: Record<string, any> | undefined
|
|
124
|
+
): OverlayAlignment {
|
|
125
|
+
const overlayAlignment = parseOverlayAlignment(props?.overlayAlignment);
|
|
126
|
+
const alignment = parseOverlayAlignment(props?.alignment);
|
|
127
|
+
|
|
128
|
+
if (overlayAlignment) {
|
|
129
|
+
if (!alignment) return overlayAlignment;
|
|
130
|
+
if (
|
|
131
|
+
AMBIGUOUS_OVERLAY_ALIGNMENTS.has(alignment) &&
|
|
132
|
+
!AMBIGUOUS_OVERLAY_ALIGNMENTS.has(overlayAlignment)
|
|
133
|
+
) {
|
|
134
|
+
return overlayAlignment;
|
|
135
|
+
}
|
|
136
|
+
return alignment;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return alignment ?? 'topStart';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function resolveFitBehavior(value: unknown): StackFitBehavior {
|
|
143
|
+
return String(value ?? '').toLowerCase() === 'expand' ? 'expand' : 'loose';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function resolveClipBehavior(value: unknown): StackClipBehavior {
|
|
147
|
+
return String(value ?? '').toLowerCase() === 'hardedge' ? 'hardEdge' : 'none';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parsePositionDimension(value: any): number | string | undefined {
|
|
151
|
+
if (typeof value === 'string') {
|
|
152
|
+
const trimmed = value.trim();
|
|
153
|
+
const lowered = trimmed.toLowerCase();
|
|
154
|
+
if (
|
|
155
|
+
lowered === 'double.infinity' ||
|
|
156
|
+
lowered === 'infinite' ||
|
|
157
|
+
lowered === 'infinity' ||
|
|
158
|
+
lowered === '+infinity'
|
|
159
|
+
) {
|
|
160
|
+
return Number.POSITIVE_INFINITY;
|
|
161
|
+
}
|
|
162
|
+
if (trimmed.endsWith('%')) {
|
|
163
|
+
const numeric = Number(trimmed.slice(0, -1));
|
|
164
|
+
if (!Number.isNaN(numeric) && Number.isFinite(numeric)) {
|
|
165
|
+
return `${numeric}%`;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return parseDimension(value);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function resolvePositionedStyle(
|
|
173
|
+
positionProps: Record<string, any> | undefined
|
|
174
|
+
): ViewStyle {
|
|
175
|
+
const props = positionProps ?? {};
|
|
176
|
+
return {
|
|
177
|
+
position: 'absolute',
|
|
178
|
+
left: parsePositionDimension(props.left),
|
|
179
|
+
top: parsePositionDimension(props.top),
|
|
180
|
+
right: parsePositionDimension(props.right),
|
|
181
|
+
bottom: parsePositionDimension(props.bottom),
|
|
182
|
+
width: parsePositionDimension(props.width),
|
|
183
|
+
height: parsePositionDimension(props.height),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import type { ResolvedFontSpec } from './fontResolver';
|
|
2
|
+
|
|
3
|
+
type ExpoFontLoadAsync = (
|
|
4
|
+
fontMap: Record<string, unknown>
|
|
5
|
+
) => Promise<void> | void;
|
|
6
|
+
|
|
7
|
+
type DynamicNativeFontLoader = (
|
|
8
|
+
name: string,
|
|
9
|
+
data: string,
|
|
10
|
+
type?: 'ttf' | 'otf'
|
|
11
|
+
) => Promise<string | void> | string | void;
|
|
12
|
+
|
|
13
|
+
const WEIGHT_SUFFIX_BY_VALUE: Record<number, string> = {
|
|
14
|
+
100: 'Thin',
|
|
15
|
+
200: 'ExtraLight',
|
|
16
|
+
300: 'Light',
|
|
17
|
+
400: 'Regular',
|
|
18
|
+
500: 'Medium',
|
|
19
|
+
600: 'SemiBold',
|
|
20
|
+
700: 'Bold',
|
|
21
|
+
800: 'ExtraBold',
|
|
22
|
+
900: 'Black',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type FontProviderRequest = {
|
|
26
|
+
spec: ResolvedFontSpec;
|
|
27
|
+
nativeFontName: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type FontProvider = {
|
|
31
|
+
loadFont: (
|
|
32
|
+
request: FontProviderRequest
|
|
33
|
+
) => Promise<boolean | string | void> | boolean | string | void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const loadedFontKeys = new Set<string>();
|
|
37
|
+
const inFlightLoads = new Map<string, Promise<void>>();
|
|
38
|
+
const loadedFontFamilyByKey = new Map<string, string>();
|
|
39
|
+
const subscribers = new Set<() => void>();
|
|
40
|
+
const warnedMissingProviders = new Set<string>();
|
|
41
|
+
|
|
42
|
+
const expoGoogleFontModuleByFamily = new Map<
|
|
43
|
+
string,
|
|
44
|
+
Record<string, unknown> | null
|
|
45
|
+
>();
|
|
46
|
+
|
|
47
|
+
let customFontProvider: FontProvider | null = null;
|
|
48
|
+
|
|
49
|
+
let dynamicRequire: ((moduleName: string) => any) | null = null;
|
|
50
|
+
try {
|
|
51
|
+
// eslint-disable-next-line no-eval
|
|
52
|
+
const runtimeRequire = eval('require') as unknown;
|
|
53
|
+
if (typeof runtimeRequire === 'function') {
|
|
54
|
+
dynamicRequire = runtimeRequire as (moduleName: string) => any;
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
dynamicRequire = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function safeDynamicRequire(moduleName: string): any | null {
|
|
61
|
+
if (!dynamicRequire) return null;
|
|
62
|
+
try {
|
|
63
|
+
return dynamicRequire(moduleName);
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isDevMode(): boolean {
|
|
70
|
+
return (
|
|
71
|
+
(globalThis as { __DEV__?: boolean }).__DEV__ ??
|
|
72
|
+
process.env.NODE_ENV !== 'production'
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isDebugEnabled(): boolean {
|
|
77
|
+
const runtimeFlag =
|
|
78
|
+
(globalThis as { __FLOWBOARD_FONT_DEBUG__?: boolean })
|
|
79
|
+
.__FLOWBOARD_FONT_DEBUG__ === true;
|
|
80
|
+
const envFlag = String(process.env.FLOWBOARD_FONT_DEBUG ?? '') === '1';
|
|
81
|
+
return isDevMode() && (runtimeFlag || envFlag);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function debugLog(event: string, payload: Record<string, unknown>): void {
|
|
85
|
+
if (!isDebugEnabled()) return;
|
|
86
|
+
console.log(`[flowboard-react][fonts] ${event}`, JSON.stringify(payload));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function notifySubscribers(): void {
|
|
90
|
+
subscribers.forEach((subscriber) => {
|
|
91
|
+
try {
|
|
92
|
+
subscriber();
|
|
93
|
+
} catch {
|
|
94
|
+
// Ignore subscriber callback errors.
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeGooglePackageSlug(family: string): string {
|
|
100
|
+
return family
|
|
101
|
+
.trim()
|
|
102
|
+
.toLowerCase()
|
|
103
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
104
|
+
.replace(/^-+|-+$/g, '');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveExpoGoogleFontModule(
|
|
108
|
+
family: string
|
|
109
|
+
): Record<string, unknown> | null {
|
|
110
|
+
if (expoGoogleFontModuleByFamily.has(family)) {
|
|
111
|
+
return expoGoogleFontModuleByFamily.get(family) ?? null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const packageSlug = normalizeGooglePackageSlug(family);
|
|
115
|
+
if (!packageSlug) {
|
|
116
|
+
expoGoogleFontModuleByFamily.set(family, null);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const maybeModule = safeDynamicRequire(`@expo-google-fonts/${packageSlug}`);
|
|
121
|
+
const normalizedModule =
|
|
122
|
+
maybeModule && typeof maybeModule === 'object'
|
|
123
|
+
? (maybeModule as Record<string, unknown>)
|
|
124
|
+
: null;
|
|
125
|
+
expoGoogleFontModuleByFamily.set(family, normalizedModule);
|
|
126
|
+
return normalizedModule;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function listExpoGoogleNativeNames(family: string): string[] {
|
|
130
|
+
const module = resolveExpoGoogleFontModule(family);
|
|
131
|
+
if (!module) return [];
|
|
132
|
+
return Object.keys(module).filter((key) => /_(\d{3})[A-Za-z]+$/.test(key));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolveExpoNativeName(
|
|
136
|
+
family: string,
|
|
137
|
+
weight: number | undefined
|
|
138
|
+
): string | undefined {
|
|
139
|
+
const nativeNames = listExpoGoogleNativeNames(family);
|
|
140
|
+
if (nativeNames.length === 0) return undefined;
|
|
141
|
+
|
|
142
|
+
if (weight === undefined) {
|
|
143
|
+
const regular = nativeNames.find((name) => /_400[A-Za-z]+$/.test(name));
|
|
144
|
+
if (regular) return regular;
|
|
145
|
+
return nativeNames[0];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const candidates = nativeNames.filter((name) =>
|
|
149
|
+
new RegExp(`_${weight}[A-Za-z]+$`).test(name)
|
|
150
|
+
);
|
|
151
|
+
if (candidates.length === 0) return undefined;
|
|
152
|
+
|
|
153
|
+
const preferredSuffix = WEIGHT_SUFFIX_BY_VALUE[weight];
|
|
154
|
+
if (preferredSuffix) {
|
|
155
|
+
const preferred = candidates.find((name) => name.endsWith(preferredSuffix));
|
|
156
|
+
if (preferred) return preferred;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const nonItalic = candidates.find((name) => !/italic$/i.test(name));
|
|
160
|
+
return nonItalic ?? candidates[0];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function toNativeFamilyPrefix(family: string): string {
|
|
164
|
+
const segments = family.match(/[a-z0-9]+/gi) ?? [];
|
|
165
|
+
if (segments.length === 0) return 'Font';
|
|
166
|
+
return segments
|
|
167
|
+
.map((segment) => {
|
|
168
|
+
const lower = segment.toLowerCase();
|
|
169
|
+
return `${lower.charAt(0).toUpperCase()}${lower.slice(1)}`;
|
|
170
|
+
})
|
|
171
|
+
.join('');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildFallbackNativeName(
|
|
175
|
+
family: string,
|
|
176
|
+
weight: number | undefined
|
|
177
|
+
): string {
|
|
178
|
+
if (weight === undefined) return toNativeFamilyPrefix(family);
|
|
179
|
+
const suffix = WEIGHT_SUFFIX_BY_VALUE[weight] ?? 'Regular';
|
|
180
|
+
return `${toNativeFamilyPrefix(family)}_${weight}${suffix}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function resolveNativeFontName(spec: ResolvedFontSpec): string {
|
|
184
|
+
const expoNative = resolveExpoNativeName(
|
|
185
|
+
spec.resolvedFamily,
|
|
186
|
+
spec.resolvedWeight
|
|
187
|
+
);
|
|
188
|
+
if (expoNative) return expoNative;
|
|
189
|
+
return buildFallbackNativeName(spec.resolvedFamily, spec.resolvedWeight);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function resolveNativeFontFamilyName(spec: ResolvedFontSpec): string {
|
|
193
|
+
return resolveNativeFontName(spec);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function resolveExpoLoadAsync(): ExpoFontLoadAsync | null {
|
|
197
|
+
const expoFontModule = safeDynamicRequire('expo-font');
|
|
198
|
+
if (!expoFontModule) return null;
|
|
199
|
+
const loadAsync =
|
|
200
|
+
expoFontModule.loadAsync ??
|
|
201
|
+
expoFontModule.default?.loadAsync ??
|
|
202
|
+
expoFontModule.Font?.loadAsync;
|
|
203
|
+
return typeof loadAsync === 'function'
|
|
204
|
+
? (loadAsync as ExpoFontLoadAsync)
|
|
205
|
+
: null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function resolveDynamicNativeFontLoader(): DynamicNativeFontLoader | null {
|
|
209
|
+
const dynamicFontsModule = safeDynamicRequire('react-native-dynamic-fonts');
|
|
210
|
+
if (!dynamicFontsModule) return null;
|
|
211
|
+
const loadFont =
|
|
212
|
+
dynamicFontsModule.loadFont ?? dynamicFontsModule.default?.loadFont;
|
|
213
|
+
return typeof loadFont === 'function'
|
|
214
|
+
? (loadFont as DynamicNativeFontLoader)
|
|
215
|
+
: null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function resolveFontAssetUri(fontAsset: unknown): string | undefined {
|
|
219
|
+
if (typeof fontAsset === 'string' && fontAsset.trim()) {
|
|
220
|
+
return fontAsset.trim();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const reactNativeModule = safeDynamicRequire('react-native');
|
|
224
|
+
const resolveAssetSource =
|
|
225
|
+
reactNativeModule?.Image?.resolveAssetSource ??
|
|
226
|
+
reactNativeModule?.default?.Image?.resolveAssetSource;
|
|
227
|
+
|
|
228
|
+
if (
|
|
229
|
+
typeof resolveAssetSource === 'function' &&
|
|
230
|
+
(typeof fontAsset === 'number' ||
|
|
231
|
+
(fontAsset && typeof fontAsset === 'object'))
|
|
232
|
+
) {
|
|
233
|
+
try {
|
|
234
|
+
const resolvedSource = resolveAssetSource(fontAsset);
|
|
235
|
+
if (
|
|
236
|
+
resolvedSource &&
|
|
237
|
+
typeof resolvedSource.uri === 'string' &&
|
|
238
|
+
resolvedSource.uri.trim()
|
|
239
|
+
) {
|
|
240
|
+
return resolvedSource.uri.trim();
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function inferFontTypeFromUri(uri: string): 'ttf' | 'otf' {
|
|
251
|
+
return /\.otf(?:\?|$)/i.test(uri) ? 'otf' : 'ttf';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function arrayBufferToBase64(buffer: ArrayBuffer): string | null {
|
|
255
|
+
const bufferModule = safeDynamicRequire('buffer');
|
|
256
|
+
const BufferCtor = bufferModule?.Buffer;
|
|
257
|
+
if (typeof BufferCtor !== 'function') return null;
|
|
258
|
+
return BufferCtor.from(buffer).toString('base64');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function tryLoadWithDynamicFonts(
|
|
262
|
+
nativeFontName: string,
|
|
263
|
+
fontAsset: unknown
|
|
264
|
+
): Promise<string | false> {
|
|
265
|
+
const loadFont = resolveDynamicNativeFontLoader();
|
|
266
|
+
if (!loadFont) return false;
|
|
267
|
+
|
|
268
|
+
const uri = resolveFontAssetUri(fontAsset);
|
|
269
|
+
if (!uri) return false;
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const response = await fetch(uri);
|
|
273
|
+
if (!response.ok) return false;
|
|
274
|
+
const buffer = await response.arrayBuffer();
|
|
275
|
+
const base64 = arrayBufferToBase64(buffer);
|
|
276
|
+
if (!base64) return false;
|
|
277
|
+
|
|
278
|
+
const loadedName = await Promise.resolve(
|
|
279
|
+
loadFont(nativeFontName, base64, inferFontTypeFromUri(uri))
|
|
280
|
+
);
|
|
281
|
+
if (typeof loadedName === 'string' && loadedName.trim()) {
|
|
282
|
+
return loadedName.trim();
|
|
283
|
+
}
|
|
284
|
+
return nativeFontName;
|
|
285
|
+
} catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function tryLoadWithExpo(
|
|
291
|
+
spec: ResolvedFontSpec
|
|
292
|
+
): Promise<string | false> {
|
|
293
|
+
const nativeFontName = resolveNativeFontName(spec);
|
|
294
|
+
const packageModule = resolveExpoGoogleFontModule(spec.resolvedFamily);
|
|
295
|
+
if (!packageModule) return false;
|
|
296
|
+
|
|
297
|
+
const fontAsset = packageModule[nativeFontName];
|
|
298
|
+
if (!fontAsset) return false;
|
|
299
|
+
|
|
300
|
+
const loadAsync = resolveExpoLoadAsync();
|
|
301
|
+
if (!loadAsync) {
|
|
302
|
+
return tryLoadWithDynamicFonts(nativeFontName, fontAsset);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
await Promise.resolve(loadAsync({ [nativeFontName]: fontAsset }));
|
|
306
|
+
return nativeFontName;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function warnMissingProvider(
|
|
310
|
+
spec: ResolvedFontSpec,
|
|
311
|
+
nativeFontName: string
|
|
312
|
+
): void {
|
|
313
|
+
if (!isDevMode()) return;
|
|
314
|
+
const key = `${spec.fontKey}:${nativeFontName}`;
|
|
315
|
+
if (warnedMissingProviders.has(key)) return;
|
|
316
|
+
warnedMissingProviders.add(key);
|
|
317
|
+
console.warn(
|
|
318
|
+
`[flowboard-react] Unable to load Google Font "${spec.resolvedFamily}" (${nativeFontName}). ` +
|
|
319
|
+
'Install `expo-font` + `@expo-google-fonts/<family>` (Expo), or `react-native-dynamic-fonts` + `@expo-google-fonts/<family>` (bare React Native), or register a Google font provider with `setFontProvider`.'
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function setFontProvider(provider: FontProvider | null): void {
|
|
324
|
+
customFontProvider = provider;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function isFontLoaded(spec: ResolvedFontSpec): boolean {
|
|
328
|
+
return loadedFontKeys.has(spec.fontKey);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function getLoadedFontFamily(
|
|
332
|
+
spec: ResolvedFontSpec
|
|
333
|
+
): string | undefined {
|
|
334
|
+
return loadedFontFamilyByKey.get(spec.fontKey);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function resolveAppliedFontFamily(
|
|
338
|
+
spec: ResolvedFontSpec
|
|
339
|
+
): string | undefined {
|
|
340
|
+
return getLoadedFontFamily(spec) ?? spec.resolvedFamily;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export async function ensureFontLoaded(spec: ResolvedFontSpec): Promise<void> {
|
|
344
|
+
if (!spec.resolvedFamily) return;
|
|
345
|
+
if (loadedFontKeys.has(spec.fontKey)) return;
|
|
346
|
+
|
|
347
|
+
const pending = inFlightLoads.get(spec.fontKey);
|
|
348
|
+
if (pending) return pending;
|
|
349
|
+
|
|
350
|
+
const nativeFontName = resolveNativeFontName(spec);
|
|
351
|
+
const loadPromise = (async () => {
|
|
352
|
+
const customLoaded = customFontProvider
|
|
353
|
+
? await Promise.resolve(
|
|
354
|
+
customFontProvider.loadFont({
|
|
355
|
+
spec,
|
|
356
|
+
nativeFontName,
|
|
357
|
+
})
|
|
358
|
+
)
|
|
359
|
+
: false;
|
|
360
|
+
|
|
361
|
+
if (typeof customLoaded === 'string' && customLoaded.trim()) {
|
|
362
|
+
loadedFontKeys.add(spec.fontKey);
|
|
363
|
+
loadedFontFamilyByKey.set(spec.fontKey, customLoaded.trim());
|
|
364
|
+
debugLog('loaded', {
|
|
365
|
+
family: spec.resolvedFamily,
|
|
366
|
+
weight: spec.resolvedWeight,
|
|
367
|
+
provider: 'custom',
|
|
368
|
+
});
|
|
369
|
+
notifySubscribers();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (customLoaded === true) {
|
|
374
|
+
loadedFontKeys.add(spec.fontKey);
|
|
375
|
+
loadedFontFamilyByKey.set(spec.fontKey, nativeFontName);
|
|
376
|
+
debugLog('loaded', {
|
|
377
|
+
family: spec.resolvedFamily,
|
|
378
|
+
weight: spec.resolvedWeight,
|
|
379
|
+
provider: 'custom',
|
|
380
|
+
});
|
|
381
|
+
notifySubscribers();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const expoLoaded = await tryLoadWithExpo(spec);
|
|
386
|
+
if (expoLoaded) {
|
|
387
|
+
loadedFontKeys.add(spec.fontKey);
|
|
388
|
+
loadedFontFamilyByKey.set(spec.fontKey, expoLoaded);
|
|
389
|
+
debugLog('loaded', {
|
|
390
|
+
family: spec.resolvedFamily,
|
|
391
|
+
weight: spec.resolvedWeight,
|
|
392
|
+
provider: 'expo',
|
|
393
|
+
});
|
|
394
|
+
notifySubscribers();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
debugLog('fallback', {
|
|
399
|
+
family: spec.resolvedFamily,
|
|
400
|
+
requestedWeight: spec.resolvedWeight,
|
|
401
|
+
});
|
|
402
|
+
warnMissingProvider(spec, nativeFontName);
|
|
403
|
+
})().finally(() => {
|
|
404
|
+
inFlightLoads.delete(spec.fontKey);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
inFlightLoads.set(spec.fontKey, loadPromise);
|
|
408
|
+
return loadPromise;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function subscribeFontLoad(listener: () => void): () => void {
|
|
412
|
+
subscribers.add(listener);
|
|
413
|
+
return () => {
|
|
414
|
+
subscribers.delete(listener);
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function __resetFontLoaderForTests(): void {
|
|
419
|
+
loadedFontKeys.clear();
|
|
420
|
+
inFlightLoads.clear();
|
|
421
|
+
loadedFontFamilyByKey.clear();
|
|
422
|
+
subscribers.clear();
|
|
423
|
+
warnedMissingProviders.clear();
|
|
424
|
+
expoGoogleFontModuleByFamily.clear();
|
|
425
|
+
customFontProvider = null;
|
|
426
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { normalizeFamilyName, pickWeight } from './googleFontCatalog';
|
|
2
|
+
|
|
3
|
+
export type ResolveFontSpecInput = {
|
|
4
|
+
family: string;
|
|
5
|
+
requestedWeight?: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type ResolvedFontSpec = {
|
|
9
|
+
resolvedFamily: string;
|
|
10
|
+
resolvedWeight?: number;
|
|
11
|
+
fontKey: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function normalizeFontKeyFamily(family: string): string {
|
|
15
|
+
return family
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
18
|
+
.replace(/^-+|-+$/g, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isFontDebugEnabled(): boolean {
|
|
22
|
+
const runtimeFlag =
|
|
23
|
+
(globalThis as { __FLOWBOARD_FONT_DEBUG__?: boolean })
|
|
24
|
+
.__FLOWBOARD_FONT_DEBUG__ === true;
|
|
25
|
+
const envFlag =
|
|
26
|
+
typeof process !== 'undefined' &&
|
|
27
|
+
String(process.env.FLOWBOARD_FONT_DEBUG ?? '').toLowerCase() === '1';
|
|
28
|
+
const devMode =
|
|
29
|
+
(globalThis as { __DEV__?: boolean }).__DEV__ ??
|
|
30
|
+
process.env.NODE_ENV !== 'production';
|
|
31
|
+
return devMode && (runtimeFlag || envFlag);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function logResolution(
|
|
35
|
+
family: string,
|
|
36
|
+
requestedWeight: number | undefined,
|
|
37
|
+
resolvedWeight: number | undefined
|
|
38
|
+
): void {
|
|
39
|
+
if (!isFontDebugEnabled()) return;
|
|
40
|
+
const fallback =
|
|
41
|
+
requestedWeight !== undefined && resolvedWeight !== requestedWeight;
|
|
42
|
+
console.log(
|
|
43
|
+
'[flowboard-react][fonts] resolve',
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
family,
|
|
46
|
+
requestedWeight,
|
|
47
|
+
resolvedWeight,
|
|
48
|
+
fallback,
|
|
49
|
+
})
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function resolveFontSpec({
|
|
54
|
+
family,
|
|
55
|
+
requestedWeight,
|
|
56
|
+
}: ResolveFontSpecInput): ResolvedFontSpec {
|
|
57
|
+
const resolvedFamily = normalizeFamilyName(family);
|
|
58
|
+
const resolvedWeight = pickWeight(resolvedFamily, requestedWeight);
|
|
59
|
+
const fontKeyFamily = normalizeFontKeyFamily(resolvedFamily) || 'font';
|
|
60
|
+
const fontKey = `${fontKeyFamily}-${resolvedWeight ?? 'base'}`;
|
|
61
|
+
|
|
62
|
+
logResolution(resolvedFamily, requestedWeight, resolvedWeight);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
resolvedFamily,
|
|
66
|
+
resolvedWeight,
|
|
67
|
+
fontKey,
|
|
68
|
+
};
|
|
69
|
+
}
|