| 1 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; |
| 2 | import { CheckIcon } from "lucide-react"; |
| 3 | import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; |
| 4 | import { isMonospaceFamily, queryInstalledFontFamilies } from "../../appearanceFonts"; |
| 5 | import { |
| 6 | Combobox, |
| 7 | ComboboxEmpty, |
| 8 | ComboboxSearchInput, |
| 9 | ComboboxItem, |
| 10 | ComboboxListVirtualized, |
| 11 | ComboboxPopup, |
| 12 | ComboboxTrigger, |
| 13 | } from "../ui/combobox"; |
| 14 | import { SelectButton } from "../ui/select"; |
| 15 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 16 | |
| 17 | const DEFAULT_FONT_VALUE = "__default__"; |
| 18 | |
| 19 | function supportsFontEnumeration(): boolean { |
| 20 | return ( |
| 21 | typeof window !== "undefined" && |
| 22 | typeof (window as { queryLocalFonts?: unknown }).queryLocalFonts === "function" |
| 23 | ); |
| 24 | } |
| 25 | |
| 26 | type FontEnumerationState = |
| 27 | | { readonly status: "unknown" } |
| 28 | | { readonly status: "granted"; readonly families: readonly string[] } |
| 29 | | { readonly status: "unavailable" }; |
| 30 | |
| 31 | // Shared across every row: once one picker learns the fonts (or learns the |
| 32 | // permission is blocked), the others follow without re-querying — and the |
| 33 | // rows can swap to the plain-input control together. |
| 34 | let enumerationState: FontEnumerationState = supportsFontEnumeration() |
| 35 | ? { status: "unknown" } |
| 36 | : { status: "unavailable" }; |
| 37 | const enumerationListeners = new Set<() => void>(); |
| 38 | |
| 39 | function subscribeToEnumeration(listener: () => void): () => void { |
| 40 | enumerationListeners.add(listener); |
| 41 | return () => enumerationListeners.delete(listener); |
| 42 | } |
| 43 | |
| 44 | function readEnumerationState(): FontEnumerationState { |
| 45 | return enumerationState; |
| 46 | } |
| 47 | |
| 48 | let enumerationLoad: Promise<void> | null = null; |
| 49 | |
| 50 | /** Query installed fonts; call from a user gesture (the permission prompt needs one). */ |
| 51 | export function discoverInstalledFonts(): void { |
| 52 | if (enumerationState.status !== "unknown" || enumerationLoad !== null) return; |
| 53 | enumerationLoad = queryInstalledFontFamilies().then((result) => { |
| 54 | enumerationState = |
| 55 | result.status === "granted" |
| 56 | ? { status: "granted", families: result.families } |
| 57 | : { status: "unavailable" }; |
| 58 | enumerationLoad = null; |
| 59 | for (const listener of enumerationListeners) listener(); |
| 60 | }); |
| 61 | } |
| 62 | |
| 63 | let grantedProbeStarted = false; |
| 64 | |
| 65 | /** |
| 66 | * Discover eagerly when the permission is already granted, so the picker |
| 67 | * renders without waiting for a focus. Electron's default permission handler |
| 68 | * approves silently (it has no prompt UI), and a browser that granted once |
| 69 | * reports "granted" on later visits — in both, no user gesture is needed. |
| 70 | * "prompt" and "denied" states change nothing: the focus-driven flow stays, |
| 71 | * because raising the browser prompt still requires a gesture. |
| 72 | */ |
| 73 | function probeAlreadyGrantedPermission(): void { |
| 74 | if (grantedProbeStarted || enumerationState.status !== "unknown") return; |
| 75 | grantedProbeStarted = true; |
| 76 | const permissions = typeof navigator !== "undefined" ? navigator.permissions : undefined; |
| 77 | if (typeof permissions?.query !== "function") return; |
| 78 | permissions.query({ name: "local-fonts" as PermissionName }).then( |
| 79 | (status) => { |
| 80 | if (status.state === "granted") discoverInstalledFonts(); |
| 81 | }, |
| 82 | () => { |
| 83 | // The engine does not recognize the permission name; keep the |
| 84 | // focus-driven flow. |
| 85 | }, |
| 86 | ); |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Whether the engine can list installed fonts (Local Font Access API — |
| 91 | * Chromium and Electron). "unknown" until discovery resolves the permission; |
| 92 | * rows render a plain family-name input until the state is known granted, |
| 93 | * then upgrade to the picker. Where the permission is already granted, |
| 94 | * discovery starts at mount and the picker appears without a focus. |
| 95 | */ |
| 96 | export function useFontEnumeration(): FontEnumerationState { |
| 97 | useEffect(probeAlreadyGrantedPermission, []); |
| 98 | return useSyncExternalStore(subscribeToEnumeration, readEnumerationState); |
| 99 | } |
| 100 | |
| 101 | /** |
| 102 | * A searchable picker over every installed family, the way native editors |
| 103 | * list system fonts. The trigger always names the font in use: the committed |
| 104 | * family, or what the default stack resolves to on this machine. |
| 105 | */ |
| 106 | export function FontFamilyPicker({ |
| 107 | ariaLabel, |
| 108 | defaultFamily, |
| 109 | selectedFamily, |
| 110 | requireMonospace = false, |
| 111 | initialOpen = false, |
| 112 | onSelect, |
| 113 | }: { |
| 114 | ariaLabel: string; |
| 115 | /** What an unset preference renders as, e.g. "Menlo". */ |
| 116 | defaultFamily: string; |
| 117 | /** Committed family name; empty string means the default is in use. */ |
| 118 | selectedFamily: string; |
| 119 | requireMonospace?: boolean; |
| 120 | /** Open the popup on mount — set when the control upgrades under focus. */ |
| 121 | initialOpen?: boolean; |
| 122 | onSelect: (family: string) => void; |
| 123 | }) { |
| 124 | const [open, setOpen] = useState(false); |
| 125 | const [query, setQuery] = useState(""); |
| 126 | // Open after mount rather than mounting open: a popup that first renders in |
| 127 | // its open state never receives Base UI's entrance style baseline, so the |
| 128 | // exit transition on close has no style delta, never fires transitionend, |
| 129 | // and the popup lingers on screen forever. |
| 130 | useEffect(() => { |
| 131 | if (initialOpen) setOpen(true); |
| 132 | // The prop is only meaningful at mount - the control just swapped in |
| 133 | // under an active focus - so later changes are deliberately ignored. |
| 134 | }, []); |
| 135 | const listRef = useRef<LegendListRef | null>(null); |
| 136 | const enumeration = useFontEnumeration(); |
| 137 | |
| 138 | const handleOpenChange = (nextOpen: boolean) => { |
| 139 | setOpen(nextOpen); |
| 140 | if (nextOpen) setQuery(""); |
| 141 | }; |
| 142 | |
| 143 | const families = useMemo(() => { |
| 144 | if (enumeration.status !== "granted") return []; |
| 145 | return enumeration.families; |
| 146 | }, [enumeration]); |
| 147 | |
| 148 | const items = useMemo(() => { |
| 149 | const trimmedQuery = query.trim().toLowerCase(); |
| 150 | const result: string[] = []; |
| 151 | if (trimmedQuery.length === 0) result.push(DEFAULT_FONT_VALUE); |
| 152 | result.push( |
| 153 | ...families.filter( |
| 154 | (family) => trimmedQuery.length === 0 || family.toLowerCase().includes(trimmedQuery), |
| 155 | ), |
| 156 | ); |
| 157 | return result; |
| 158 | }, [query, families]); |
| 159 | |
| 160 | const selectedValue = selectedFamily.length === 0 ? DEFAULT_FONT_VALUE : selectedFamily; |
| 161 | |
| 162 | const handlePick = (value: string) => { |
| 163 | setOpen(false); |
| 164 | if (value !== DEFAULT_FONT_VALUE && requireMonospace && !isMonospaceFamily(value)) { |
| 165 | toastManager.add( |
| 166 | stackedThreadToast({ |
| 167 | type: "error", |
| 168 | title: `"${value}" isn't monospace`, |
| 169 | description: "Code and terminal need a fixed-width font, so the current font was kept.", |
| 170 | }), |
| 171 | ); |
| 172 | return; |
| 173 | } |
| 174 | onSelect(value === DEFAULT_FONT_VALUE ? "" : value); |
| 175 | }; |
| 176 | |
| 177 | const renderItem = (item: string, index: number) => { |
| 178 | const isDefault = item === DEFAULT_FONT_VALUE; |
| 179 | const family = isDefault ? defaultFamily : item; |
| 180 | return ( |
| 181 | <ComboboxItem hideIndicator index={index} key={item} value={item}> |
| 182 | <div className="flex w-full min-w-0 items-center justify-between gap-2"> |
| 183 | <span className="min-w-0 truncate" style={{ fontFamily: family }}> |
| 184 | {family} |
| 185 | </span> |
| 186 | <span className="flex shrink-0 items-center gap-1.5"> |
| 187 | {isDefault ? <span className="text-3xs text-muted-foreground/60">default</span> : null} |
| 188 | {item === selectedValue ? ( |
| 189 | <CheckIcon className="size-3.5 text-muted-foreground" /> |
| 190 | ) : null} |
| 191 | </span> |
| 192 | </div> |
| 193 | </ComboboxItem> |
| 194 | ); |
| 195 | }; |
| 196 | |
| 197 | return ( |
| 198 | <Combobox |
| 199 | items={items} |
| 200 | filteredItems={items} |
| 201 | autoHighlight |
| 202 | virtualized |
| 203 | open={open} |
| 204 | onOpenChange={handleOpenChange} |
| 205 | value={selectedValue} |
| 206 | onValueChange={(next) => { |
| 207 | if (typeof next === "string") handlePick(next); |
| 208 | }} |
| 209 | onItemHighlighted={(_value, eventDetails) => { |
| 210 | // Keyboard highlights must pull the virtualized row into view, or |
| 211 | // arrow keys walk past the rendered window and navigate blind. |
| 212 | if (!open || eventDetails.index < 0 || eventDetails.reason !== "keyboard") return; |
| 213 | void listRef.current?.scrollIndexIntoView?.({ index: eventDetails.index, animated: false }); |
| 214 | }} |
| 215 | > |
| 216 | <ComboboxTrigger aria-label={ariaLabel} render={<SelectButton size="sm" />}> |
| 217 | {selectedFamily.length === 0 ? defaultFamily : selectedFamily} |
| 218 | </ComboboxTrigger> |
| 219 | <ComboboxPopup align="end" className="flex w-72 flex-col"> |
| 220 | <ComboboxSearchInput |
| 221 | placeholder="Search fonts…" |
| 222 | value={query} |
| 223 | onChange={(event) => setQuery(event.target.value)} |
| 224 | /> |
| 225 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> |
| 226 | <ComboboxEmpty>No fonts found.</ComboboxEmpty> |
| 227 | <div className="relative min-h-0 max-h-72 w-full flex-1 overflow-hidden"> |
| 228 | <ComboboxListVirtualized> |
| 229 | <LegendList<string> |
| 230 | ref={listRef} |
| 231 | data={items} |
| 232 | keyExtractor={(item) => item} |
| 233 | renderItem={({ item, index }) => renderItem(item, index)} |
| 234 | estimatedItemSize={30} |
| 235 | drawDistance={360} |
| 236 | style={{ height: Math.min(items.length * 30, 288) }} |
| 237 | /> |
| 238 | </ComboboxListVirtualized> |
| 239 | </div> |
| 240 | </div> |
| 241 | </ComboboxPopup> |
| 242 | </Combobox> |
| 243 | ); |
| 244 | } |