| 1 | /** biome-ignore-all lint/suspicious/noExplicitAny: This use requires any */ |
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | |
| 4 | // The font info is now imported statically at the top of the file. |
| 5 | import fontInfos from "../font-preview/fontInfo.json"; |
| 6 | |
| 7 | import { cn } from "@/lib/utils"; |
| 8 | import { |
| 9 | type Font, |
| 10 | type FontPickerProps, |
| 11 | defaultFont, |
| 12 | toString, |
| 13 | } from "../types"; |
| 14 | import { checkLoaded } from "../utils/fontChecker"; |
| 15 | import { sanify } from "../utils/sanify"; |
| 16 | import { getFourVariants, loadFontFromObject } from "../utils/utils"; |
| 17 | import { FontCombobox } from "./FontCombobox"; |
| 18 | import { FontList } from "./FontList"; |
| 19 | |
| 20 | export default function FontPicker({ |
| 21 | defaultValue = "Open Sans", |
| 22 | noMatches = "No matches", |
| 23 | autoLoad = true, |
| 24 | loaderOnly = false, |
| 25 | loadAllVariants = false, |
| 26 | loadFonts = "", |
| 27 | googleFonts = "all", |
| 28 | fontCategories = "all", |
| 29 | localFonts = [], |
| 30 | mode = "combo", |
| 31 | fontVariants, |
| 32 | value, |
| 33 | fontsLoaded, |
| 34 | fontsLoadedTimeout, |
| 35 | className, |
| 36 | selectClassName, |
| 37 | ...rest |
| 38 | }: FontPickerProps) { |
| 39 | const [open, setOpen] = useState(false); |
| 40 | const [searchValue, setSearchValue] = useState(""); |
| 41 | |
| 42 | // All state and effects for loading have been removed (fontInfos, isLoadingFonts, error). |
| 43 | |
| 44 | const allGoogleFonts: Font[] = useMemo(() => { |
| 45 | // The data from fontInfos is available immediately. |
| 46 | return (fontInfos as any[]).map((info: Omit<Font, "cased">) => ({ |
| 47 | ...info, |
| 48 | cased: info.name.toLowerCase(), |
| 49 | })); |
| 50 | }, []); // The dependency on fontInfos is removed as it's now a constant import. |
| 51 | |
| 52 | const fonts = useMemo(() => { |
| 53 | let activeFonts: Font[]; |
| 54 | |
| 55 | if (googleFonts === "all") { |
| 56 | activeFonts = [...allGoogleFonts]; |
| 57 | } else if (typeof googleFonts === "string") { |
| 58 | const fontNames = googleFonts.trim().toLowerCase().split(","); |
| 59 | activeFonts = allGoogleFonts.filter((font) => |
| 60 | fontNames.includes(font.cased), |
| 61 | ); |
| 62 | } else if (typeof googleFonts === "function") { |
| 63 | activeFonts = allGoogleFonts.filter(googleFonts); |
| 64 | } else { |
| 65 | const fontNames = |
| 66 | googleFonts?.map((v) => |
| 67 | typeof v === "string" ? v.toLowerCase() : v.cased, |
| 68 | ) ?? []; |
| 69 | activeFonts = allGoogleFonts.filter((font) => |
| 70 | fontNames.includes(font.cased), |
| 71 | ); |
| 72 | } |
| 73 | |
| 74 | const processedLocalFonts = localFonts.map((font) => ({ |
| 75 | ...font, |
| 76 | cased: font.name.toLowerCase(), |
| 77 | sane: sanify(font.name), |
| 78 | variants: font.variants.map((v) => toString(v)), |
| 79 | isLocal: true, |
| 80 | })); |
| 81 | |
| 82 | activeFonts = [...activeFonts, ...processedLocalFonts]; |
| 83 | |
| 84 | if (fontCategories === "all") { |
| 85 | return activeFonts; |
| 86 | } |
| 87 | |
| 88 | const categories = Array.isArray(fontCategories) |
| 89 | ? fontCategories.map((c) => c.toLowerCase()) |
| 90 | : fontCategories.trim().toLowerCase().split(","); |
| 91 | |
| 92 | return activeFonts.filter((font) => categories.includes(font.category)); |
| 93 | }, [googleFonts, allGoogleFonts, localFonts, fontCategories]); |
| 94 | |
| 95 | const getFontByName = useCallback( |
| 96 | (name: string) => fonts.find((font) => font.name.trim() === name.trim()), |
| 97 | [fonts], |
| 98 | ); |
| 99 | |
| 100 | const saneDefaultValue = useMemo(() => { |
| 101 | if (!fonts || fonts.length === 0) { |
| 102 | return defaultValue; |
| 103 | } |
| 104 | const search = defaultValue.toLowerCase().trim(); |
| 105 | return fonts.some((font) => font.cased === search) |
| 106 | ? defaultValue |
| 107 | : fonts[0]?.name; |
| 108 | }, [fonts, defaultValue]); |
| 109 | |
| 110 | const [currentFont, setCurrentFont] = useState<Font>( |
| 111 | () => getFontByName(saneDefaultValue!) || defaultFont, |
| 112 | ); |
| 113 | |
| 114 | useEffect(() => { |
| 115 | setCurrentFont(getFontByName(saneDefaultValue!) || defaultFont); |
| 116 | }, [saneDefaultValue, getFontByName]); |
| 117 | |
| 118 | const handleFontSelect = useCallback( |
| 119 | (font: Font) => { |
| 120 | if (autoLoad) { |
| 121 | loadFontFromObject(font, loadAllVariants, getFourVariants); |
| 122 | } |
| 123 | fontVariants?.({ fontName: font.name, variants: font.variants }); |
| 124 | value?.(font.name); |
| 125 | }, |
| 126 | [autoLoad, loadAllVariants, fontVariants, value], |
| 127 | ); |
| 128 | |
| 129 | useEffect(() => { |
| 130 | if (!fontsLoaded) return; |
| 131 | |
| 132 | const fontsToLoad: string[] = []; |
| 133 | if (typeof loadFonts === "string" && loadFonts) { |
| 134 | fontsToLoad.push(loadFonts); |
| 135 | } else if (Array.isArray(loadFonts)) { |
| 136 | loadFonts.forEach((font) => { |
| 137 | const fontName = typeof font === "string" ? font : font?.fontName; |
| 138 | if (fontName) fontsToLoad.push(fontName); |
| 139 | }); |
| 140 | } |
| 141 | |
| 142 | console.log("fontsToLoad", fontsToLoad); |
| 143 | const fontsToCheck = [...new Set([currentFont.name, ...fontsToLoad])]; |
| 144 | |
| 145 | const checkFonts = async () => { |
| 146 | try { |
| 147 | const results = await Promise.all( |
| 148 | fontsToCheck.map((font) => |
| 149 | checkLoaded({ fontFamily: font, timeout: fontsLoadedTimeout }), |
| 150 | ), |
| 151 | ); |
| 152 | fontsLoaded?.(!results.some((res) => !res)); |
| 153 | } catch (e) { |
| 154 | console.error("Error checking if font families loaded", e); |
| 155 | fontsLoaded?.(false); |
| 156 | } |
| 157 | }; |
| 158 | |
| 159 | checkFonts(); |
| 160 | }, [loadFonts, currentFont, fontsLoaded, fontsLoadedTimeout]); |
| 161 | |
| 162 | // When acting as a loader-only component, proactively load all fonts passed in props. |
| 163 | useEffect(() => { |
| 164 | if (!loaderOnly) return; |
| 165 | |
| 166 | const fontsToLoad: string[] = []; |
| 167 | if (typeof loadFonts === "string" && loadFonts) { |
| 168 | fontsToLoad.push(loadFonts); |
| 169 | } else if (Array.isArray(loadFonts)) { |
| 170 | loadFonts.forEach((font) => { |
| 171 | const fontName = typeof font === "string" ? font : font?.fontName; |
| 172 | if (fontName) fontsToLoad.push(fontName); |
| 173 | }); |
| 174 | } |
| 175 | |
| 176 | // De-duplicate and load each font family via Google Fonts |
| 177 | [...new Set(fontsToLoad)].forEach((fontName) => { |
| 178 | const font = getFontByName(fontName); |
| 179 | if (font) { |
| 180 | loadFontFromObject(font, loadAllVariants, getFourVariants); |
| 181 | } |
| 182 | }); |
| 183 | }, [loaderOnly, loadFonts, getFontByName, loadAllVariants]); |
| 184 | |
| 185 | if (loaderOnly) { |
| 186 | return null; |
| 187 | } |
| 188 | |
| 189 | // Error display is removed as a static import will fail at build time, not run time. |
| 190 | |
| 191 | return ( |
| 192 | <div className={cn("w-full", className)} {...rest}> |
| 193 | {mode === "combo" ? ( |
| 194 | <FontCombobox |
| 195 | fonts={fonts} |
| 196 | currentFont={currentFont} |
| 197 | onFontSelect={handleFontSelect} |
| 198 | noMatches={noMatches} |
| 199 | searchValue={searchValue} |
| 200 | onSearchChange={setSearchValue} |
| 201 | open={open} |
| 202 | onOpenChange={setOpen} |
| 203 | className={selectClassName} |
| 204 | /> |
| 205 | ) : ( |
| 206 | <FontList |
| 207 | fonts={fonts} |
| 208 | currentFont={currentFont} |
| 209 | onFontSelect={handleFontSelect} |
| 210 | searchValue={searchValue} |
| 211 | onSearchChange={setSearchValue} |
| 212 | isLoadingFonts={false} |
| 213 | /> |
| 214 | )} |
| 215 | </div> |
| 216 | ); |
| 217 | } |
| 218 |