253 lines · 9.8 KB · TypeScript
RawDownload
import { LegendList, type LegendListRef } from "@legendapp/list/react";import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react";import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";import { isMonospaceFamily, queryInstalledFontFamilies } from "../../appearanceFonts";import {  Combobox,  ComboboxEmpty,  ComboboxInput,  ComboboxItem,  ComboboxListVirtualized,  ComboboxPopup,  ComboboxTrigger,} from "../ui/combobox"; const DEFAULT_FONT_VALUE = "__default__"; function supportsFontEnumeration(): boolean {  return (    typeof window !== "undefined" &&    typeof (window as { queryLocalFonts?: unknown }).queryLocalFonts === "function"  );} type FontEnumerationState =  | { readonly status: "unknown" }  | { readonly status: "granted"; readonly families: readonly string[] }  | { readonly status: "unavailable" }; // Shared across every row: once one picker learns the fonts (or learns the// permission is blocked), the others follow without re-querying — and the// rows can swap to the plain-input control together.let enumerationState: FontEnumerationState = supportsFontEnumeration()  ? { status: "unknown" }  : { status: "unavailable" };const enumerationListeners = new Set<() => void>(); function subscribeToEnumeration(listener: () => void): () => void {  enumerationListeners.add(listener);  return () => enumerationListeners.delete(listener);} function readEnumerationState(): FontEnumerationState {  return enumerationState;} let enumerationLoad: Promise<void> | null = null; /** Query installed fonts; call from a user gesture (the permission prompt needs one). */export function discoverInstalledFonts(): void {  if (enumerationState.status !== "unknown" || enumerationLoad !== null) return;  enumerationLoad = queryInstalledFontFamilies().then((result) => {    enumerationState =      result.status === "granted"        ? { status: "granted", families: result.families }        : { status: "unavailable" };    enumerationLoad = null;    for (const listener of enumerationListeners) listener();  });} let grantedProbeStarted = false; /** * Discover eagerly when the permission is already granted, so the picker * renders without waiting for a focus. Electron's default permission handler * approves silently (it has no prompt UI), and a browser that granted once * reports "granted" on later visits — in both, no user gesture is needed. * "prompt" and "denied" states change nothing: the focus-driven flow stays, * because raising the browser prompt still requires a gesture. */function probeAlreadyGrantedPermission(): void {  if (grantedProbeStarted || enumerationState.status !== "unknown") return;  grantedProbeStarted = true;  const permissions = typeof navigator !== "undefined" ? navigator.permissions : undefined;  if (typeof permissions?.query !== "function") return;  permissions.query({ name: "local-fonts" as PermissionName }).then(    (status) => {      if (status.state === "granted") discoverInstalledFonts();    },    () => {      // The engine does not recognize the permission name; keep the      // focus-driven flow.    },  );} /** * Whether the engine can list installed fonts (Local Font Access API — * Chromium and Electron). "unknown" until discovery resolves the permission; * rows render a plain family-name input until the state is known granted, * then upgrade to the picker. Where the permission is already granted, * discovery starts at mount and the picker appears without a focus. */export function useFontEnumeration(): FontEnumerationState {  useEffect(probeAlreadyGrantedPermission, []);  return useSyncExternalStore(subscribeToEnumeration, readEnumerationState);} /** * A searchable picker over every installed family, the way native editors * list system fonts. The trigger always names the font in use: the committed * family, or what the default stack resolves to on this machine. */export function FontFamilyPicker({  ariaLabel,  defaultFamily,  selectedFamily,  requireMonospace = false,  initialOpen = false,  onSelect,}: {  ariaLabel: string;  /** What an unset preference renders as, e.g. "Menlo". */  defaultFamily: string;  /** Committed family name; empty string means the default is in use. */  selectedFamily: string;  requireMonospace?: boolean;  /** Open the popup on mount — set when the control upgrades under focus. */  initialOpen?: boolean;  onSelect: (family: string) => void;}) {  const [open, setOpen] = useState(false);  const [query, setQuery] = useState("");  // Open after mount rather than mounting open: a popup that first renders in  // its open state never receives Base UI's entrance style baseline, so the  // exit transition on close has no style delta, never fires transitionend,  // and the popup lingers on screen forever.  useEffect(() => {    if (initialOpen) setOpen(true);    // The prop is only meaningful at mount - the control just swapped in    // under an active focus - so later changes are deliberately ignored.  }, []);  const listRef = useRef<LegendListRef | null>(null);  const enumeration = useFontEnumeration();   const handleOpenChange = (nextOpen: boolean) => {    setOpen(nextOpen);    if (nextOpen) setQuery("");  };   const families = useMemo(() => {    if (enumeration.status !== "granted") return [];    return requireMonospace ? enumeration.families.filter(isMonospaceFamily) : enumeration.families;  }, [enumeration, requireMonospace]);   const items = useMemo(() => {    const trimmedQuery = query.trim().toLowerCase();    const result: string[] = [];    if (trimmedQuery.length === 0) result.push(DEFAULT_FONT_VALUE);    result.push(      ...families.filter(        (family) => trimmedQuery.length === 0 || family.toLowerCase().includes(trimmedQuery),      ),    );    return result;  }, [query, families]);   const selectedValue = selectedFamily.length === 0 ? DEFAULT_FONT_VALUE : selectedFamily;   const handlePick = (value: string) => {    setOpen(false);    onSelect(value === DEFAULT_FONT_VALUE ? "" : value);  };   const renderItem = (item: string, index: number) => {    const isDefault = item === DEFAULT_FONT_VALUE;    const family = isDefault ? defaultFamily : item;    return (      <ComboboxItem hideIndicator index={index} key={item} value={item}>        <div className="flex w-full min-w-0 items-center justify-between gap-2">          <span className="min-w-0 truncate" style={{ fontFamily: family }}>            {family}          </span>          <span className="flex shrink-0 items-center gap-1.5">            {isDefault ? (              <span className="text-[10px] text-muted-foreground/60">default</span>            ) : null}            {item === selectedValue ? (              <CheckIcon className="size-3.5 text-muted-foreground" />            ) : null}          </span>        </div>      </ComboboxItem>    );  };   return (    <Combobox      items={items}      filteredItems={items}      autoHighlight      virtualized      open={open}      onOpenChange={handleOpenChange}      value={selectedValue}      onValueChange={(next) => {        if (typeof next === "string") handlePick(next);      }}      onItemHighlighted={(_value, eventDetails) => {        // Keyboard highlights must pull the virtualized row into view, or        // arrow keys walk past the rendered window and navigate blind.        if (!open || eventDetails.index < 0 || eventDetails.reason !== "keyboard") return;        void listRef.current?.scrollIndexIntoView?.({ index: eventDetails.index, animated: false });      }}    >      <ComboboxTrigger        aria-label={ariaLabel}        className="relative inline-flex min-h-9 w-full min-w-36 cursor-pointer select-none items-center justify-between gap-2 rounded-lg border border-input bg-background px-[calc(--spacing(3)-1px)] text-left text-base text-foreground shadow-xs/5 outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24 sm:min-h-8 sm:text-sm dark:bg-input/32"      >        <span className="min-w-0 truncate">          {selectedFamily.length === 0 ? defaultFamily : selectedFamily}        </span>        <ChevronDownIcon className="-me-1 size-3 shrink-0 text-muted-foreground opacity-50" />      </ComboboxTrigger>      <ComboboxPopup align="end" className="flex w-72 flex-col">        <div className="shrink-0 px-3 pt-2.5">          <div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring">            <SearchIcon              aria-hidden="true"              className="pointer-events-none absolute top-1.5 left-0 size-4 shrink-0 text-muted-foreground/55"            />            <ComboboxInput              className="[&_input]:h-6.5 [&_input]:ps-5 [&_input]:font-sans [&_input]:leading-6.5"              inputClassName="rounded-none bg-transparent text-sm"              placeholder="Search fonts…"              showTrigger={false}              size="sm"              unstyled              value={query}              onChange={(event) => setQuery(event.target.value)}            />          </div>        </div>        <div className="flex min-h-0 flex-1 flex-col overflow-hidden">          <ComboboxEmpty>No fonts found.</ComboboxEmpty>          <div className="relative min-h-0 max-h-72 w-full flex-1 overflow-hidden">            <ComboboxListVirtualized className="size-full min-w-0 p-0">              <LegendList<string>                ref={listRef}                data={items}                keyExtractor={(item) => item}                renderItem={({ item, index }) => renderItem(item, index)}                estimatedItemSize={30}                drawDistance={360}                style={{ height: Math.min(items.length * 30, 288) }}              />            </ComboboxListVirtualized>          </div>        </div>      </ComboboxPopup>    </Combobox>  );}