Open History to see the last commit
1import { useLegendListRef } from "~/hooks/useLegendListRef";
2import { LegendList, type LegendListRef } from "@legendapp/list/react";
3import { CheckIcon } from "lucide-react";
4import { useEffect, useRef, useState, useSyncExternalStore } from "react";
5import {
6 displayFontFamily,
7 isMonospaceFamily,
8 queryInstalledFontFamilies,
9} from "../../appearanceFonts";
10import {
11 Combobox,
12 ComboboxEmpty,
13 ComboboxSearchInput,
14 ComboboxItem,
15 ComboboxListVirtualized,
16 ComboboxPopup,
17 ComboboxTrigger,
18} from "../ui/combobox";
19import { SelectButton } from "../ui/select";
20import { stackedThreadToast, toastManager } from "../ui/toast";
21
22const DEFAULT_FONT_VALUE = "__default__";
23
24function supportsFontEnumeration(): boolean {
25 return (
26 typeof window !== "undefined" &&
27 typeof (window as { queryLocalFonts?: unknown }).queryLocalFonts === "function"
28 );
29}
30
31type FontEnumerationState =
32 | { readonly status: "unknown" }
33 | { readonly status: "granted"; readonly families: readonly string[] }
34 | { readonly status: "unavailable" };
35
36let enumerationState: FontEnumerationState = supportsFontEnumeration()
37 ? { status: "unknown" }
38 : { status: "unavailable" };
39const enumerationListeners = new Set<() => void>();
40
41function subscribeToEnumeration(listener: () => void): () => void {
42 probeAlreadyGrantedPermission();
43 enumerationListeners.add(listener);
44 return () => enumerationListeners.delete(listener);
45}
46
47function readEnumerationState(): FontEnumerationState {
48 return enumerationState;
49}
50
51let enumerationLoad: Promise<void> | null = null;
52
53/** Query installed fonts; call from a user gesture (the permission prompt needs one). */
54export function discoverInstalledFonts(): void {
55 if (enumerationState.status !== "unknown" || enumerationLoad !== null) return;
56 enumerationLoad = queryInstalledFontFamilies().then((result) => {
57 enumerationState =
58 result.status === "granted"
59 ? { status: "granted", families: result.families }
60 : { status: "unavailable" };
61 enumerationLoad = null;
62 for (const listener of enumerationListeners) listener();
63 });
64}
65
66let grantedProbeStarted = false;
67
68/** Electron and previously granted browsers need no user gesture to enumerate fonts. */
69function probeAlreadyGrantedPermission(): void {
70 if (grantedProbeStarted || enumerationState.status !== "unknown") return;
71 grantedProbeStarted = true;
72 const permissions = typeof navigator !== "undefined" ? navigator.permissions : undefined;
73 if (typeof permissions?.query !== "function") return;
74 permissions.query({ name: "local-fonts" as PermissionName }).then(
75 (status) => {
76 if (status.state === "granted") discoverInstalledFonts();
77 },
78 () => {},
79 );
80}
81
82export function useFontEnumeration(): FontEnumerationState {
83 return useSyncExternalStore(subscribeToEnumeration, readEnumerationState);
84}
85
86export function FontFamilyPicker({
87 ariaLabel,
88 defaultFamily,
89 selectedFamily,
90 requireMonospace = false,
91 initialOpen = false,
92 onSelect,
93}: {
94 ariaLabel: string;
95 defaultFamily: string;
96 selectedFamily: string;
97 requireMonospace?: boolean;
98 initialOpen?: boolean;
99 onSelect: (family: string) => void;
100}) {
101 const [open, setOpen] = useState(false);
102 const [query, setQuery] = useState("");
103 // Base UI needs an initial closed commit; mounting open loses exit transitionend and leaves the popup
104 // stuck.
105 const [openAfterMount] = useState(initialOpen);
106 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens the Base UI popup after its first closed commit so the entrance transition is recorded
107 useEffect(() => {
108 // oxlint-disable-next-line react/set-state-in-effect -- Base UI needs one closed commit first to record the popup's entrance style.
109 if (openAfterMount) setOpen(true);
110 }, [openAfterMount]);
111 const listRef = useRef<LegendListRef | null>(null);
112 const [listHeight, setListHeight] = useState(288);
113 const handleMetricsChange = () => {
114 const metrics = listRef.current?.getState();
115 if (metrics) setListHeight(Math.min(metrics.contentLength, 288));
116 };
117 const scrollRef = useLegendListRef(listRef, handleMetricsChange);
118 const enumeration = useFontEnumeration();
119
120 const handleOpenChange = (nextOpen: boolean) => {
121 setOpen(nextOpen);
122 if (nextOpen) setQuery("");
123 };
124
125 const families = enumeration.status === "granted" ? enumeration.families : [];
126
127 const items = (() => {
128 const trimmedQuery = query.trim().toLowerCase();
129 const result: string[] = [];
130 if (trimmedQuery.length === 0) result.push(DEFAULT_FONT_VALUE);
131 result.push(
132 ...families.filter(
133 (family) => trimmedQuery.length === 0 || family.toLowerCase().includes(trimmedQuery),
134 ),
135 );
136 return result;
137 })();
138
139 const selectedValue = selectedFamily.length === 0 ? DEFAULT_FONT_VALUE : selectedFamily;
140
141 const handlePick = (value: string) => {
142 setOpen(false);
143 if (value !== DEFAULT_FONT_VALUE && requireMonospace && !isMonospaceFamily(value)) {
144 toastManager.add(
145 stackedThreadToast({
146 type: "error",
147 title: `"${value}" isn't monospace`,
148 description: "Code and terminal need a fixed-width font, so the current font was kept.",
149 }),
150 );
151 return;
152 }
153 onSelect(value === DEFAULT_FONT_VALUE ? "" : value);
154 };
155
156 const renderItem = (item: string, index: number) => {
157 const isDefault = item === DEFAULT_FONT_VALUE;
158 const family = isDefault ? defaultFamily : item;
159 return (
160 <ComboboxItem hideIndicator index={index} key={item} value={item}>
161 <div className="flex w-full min-w-0 items-center justify-between gap-2">
162 <span className="min-w-0 truncate" style={{ fontFamily: displayFontFamily(family) }}>
163 {family}
164 </span>
165 <span className="flex shrink-0 items-center gap-1.5">
166 {isDefault ? <span className="text-3xs text-muted-foreground/60">default</span> : null}
167 {item === selectedValue ? (
168 <CheckIcon className="size-3.5 text-muted-foreground" />
169 ) : null}
170 </span>
171 </div>
172 </ComboboxItem>
173 );
174 };
175
176 const extraData = { items, selectedValue, defaultFamily };
177
178 return (
179 <Combobox
180 items={items}
181 filteredItems={items}
182 autoHighlight
183 virtualized
184 open={open}
185 onOpenChange={handleOpenChange}
186 value={selectedValue}
187 onValueChange={(next) => {
188 if (typeof next === "string") handlePick(next);
189 }}
190 onItemHighlighted={(_value, eventDetails) => {
191 if (!open || eventDetails.index < 0 || eventDetails.reason !== "keyboard") return;
192 void listRef.current?.scrollIndexIntoView?.({ index: eventDetails.index, animated: false });
193 }}
194 >
195 <ComboboxTrigger aria-label={ariaLabel} render={<SelectButton size="sm" />}>
196 {selectedFamily.length === 0 ? defaultFamily : selectedFamily}
197 </ComboboxTrigger>
198 <ComboboxPopup align="end" className="flex w-72 flex-col">
199 <ComboboxSearchInput
200 placeholder="Search fonts…"
201 value={query}
202 onChange={(event) => setQuery(event.target.value)}
203 />
204 <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
205 <ComboboxEmpty>No fonts found.</ComboboxEmpty>
206 {/* Short windows cap the popup; every level down to the list must shrink or rows are clipped unreachably. */}
207 <div className="relative flex min-h-0 max-h-72 w-full flex-1 flex-col overflow-hidden">
208 <ComboboxListVirtualized className="flex min-h-0 flex-col">
209 <LegendList<string>
210 ref={scrollRef}
211 data={items}
212 dataKey={query}
213 initialScrollOffset={0}
214 // Base UI selects virtualized rows by index; rerender surviving items when filtering changes
215 // their indices.
216 extraData={extraData}
217 keyExtractor={(item) => item}
218 renderItem={({ item, index }) => renderItem(item, index)}
219 estimatedItemSize={30}
220 drawDistance={360}
221 onScroll={handleMetricsChange}
222 onLayout={handleMetricsChange}
223 onReady={handleMetricsChange}
224 className="min-h-0"
225 style={{ height: listHeight }}
226 />
227 </ComboboxListVirtualized>
228 </div>
229 </div>
230 </ComboboxPopup>
231 </Combobox>
232 );
233}