"use client"; import * as React from "react"; import { ControlFieldLabel } from "../../control-layout"; import { Field, Input, Popover, ScrollFade, PopoverTrigger, SelectTriggerButton, } from "../../primitives"; import type { ControlChangeMeta, ControlValueChangeHandler } from "../control-types"; import { getDefaultFontPickerFontId, getFontPickerFontById, } from "./font-catalog"; import { ColorOpacityControl } from "../color"; import { StaticSelect } from "../select"; import { useMeasuredElementWidth } from "../use-measured-element-width"; import { getFontFamilyStyle, getFontPickerWeightOptions, getStepByValue, getStepIndexByValue, isFontPickerTextCase, letterSpacingSteps, lineHeightSteps, minFontPickerFontSizePx, normalizeFontPickerFontSize, normalizeFontPickerValue, resolveFontPickerFontWeight, textCaseOptions, type FontPickerInputValue, type FontPickerValue, } from "./font-picker-value"; import { FontPickerPopoverContent } from "./font-picker-popover-content"; import { useFontPickerPreviewPipeline } from "./use-font-picker-preview-pipeline"; import { useFontPickerVirtualList } from "./use-font-picker-virtual-list"; export type FontPickerControlProps = { defaultValue?: FontPickerInputValue; disabled?: boolean; name: string; onPreviewChange?: (nextFontId: string | null) => void; onValueChange?: ControlValueChangeHandler; searchPlaceholder?: string; value?: FontPickerInputValue; }; export function FontPickerControl({ defaultValue, disabled = false, name, onPreviewChange, onValueChange, searchPlaceholder = "Find font", value, }: FontPickerControlProps): React.JSX.Element { const [open, setOpen] = React.useState(false); const triggerRef = React.useRef(null); const familyWeightRowRef = React.useRef(null); const familyWeightRowWidth = useMeasuredElementWidth(familyWeightRowRef); const normalizedValue = normalizeFontPickerValue(value); const normalizedDefaultValue = normalizeFontPickerValue(defaultValue); const [fontSizeDraft, setFontSizeDraft] = React.useState( String(normalizedValue.fontSize), ); const selectedFont = getFontPickerFontById(normalizedValue.fontId); const { attachScrollViewport, bottomSpacerHeight, cancelOpenSelectedScroll, category, filteredFonts, pinnedSelectedRowSide, prepareForOpen, query, scrollToFontIndex, searchInputRef, selectedFontIndex, setCategoryWithReset, setQueryWithReset, topSpacerHeight, virtualEndIndex, virtualStartIndex, visibleFonts, } = useFontPickerVirtualList({ open, selectedFont, }); const { cancelHoverPreviewIntent, clearHoverPreview, emitPreviewChange, handleHoverPreview, scheduleHoverPreviewIntent, warmFontPreview, } = useFontPickerPreviewPipeline({ disabled, onPreviewChange, open, selectedFont, visibleFonts, }); const emitChange = React.useCallback( (nextValue: FontPickerValue, meta?: ControlChangeMeta) => { onValueChange?.(nextValue, meta); }, [onValueChange], ); React.useEffect(() => { setFontSizeDraft(String(normalizedValue.fontSize)); }, [normalizedValue.fontSize]); function commitFontSizeDraft(nextDraft = fontSizeDraft): void { const nextSize = nextDraft.trim() === "" ? normalizedDefaultValue.fontSize : normalizeFontPickerFontSize(Number(nextDraft)); setFontSizeDraft(String(nextSize)); if (nextSize !== normalizedValue.fontSize) { emitChange( { ...normalizedValue, fontSize: nextSize, }, { history: "merge" }, ); } } const handleOpenChange = React.useCallback( (nextOpen: boolean) => { if (nextOpen) { prepareForOpen(selectedFont?.category ?? "sans-serif"); } setOpen(nextOpen); if (!nextOpen) { cancelOpenSelectedScroll(); clearHoverPreview(); window.requestAnimationFrame(() => { if (document.activeElement === triggerRef.current) { triggerRef.current?.blur(); } }); } }, [ cancelOpenSelectedScroll, clearHoverPreview, prepareForOpen, selectedFont?.category, ], ); const selectedFamily = selectedFont?.family ?? getFontPickerFontById(getDefaultFontPickerFontId())?.family ?? "Inter"; const fontWeightOptions = getFontPickerWeightOptions(selectedFont); const selectedFontPreviewStyle = selectedFont ? { ...getFontFamilyStyle(selectedFont), fontWeight: normalizedValue.fontWeight, } : undefined; const letterSpacingStep = getStepByValue( letterSpacingSteps, normalizedValue.letterSpacing, "normal", ); const letterSpacingStepIndex = getStepIndexByValue( letterSpacingSteps, normalizedValue.letterSpacing, "normal", ); const lineHeightStep = getStepByValue( lineHeightSteps, normalizedValue.lineHeight, "normal", ); const lineHeightStepIndex = getStepIndexByValue( lineHeightSteps, normalizedValue.lineHeight, "normal", ); return (
{name} } > {selectedFamily} { cancelHoverPreviewIntent(); handleHoverPreview(font); }} onFontMouseEnter={(font) => scheduleHoverPreviewIntent(font)} onFontSelect={(font) => { cancelHoverPreviewIntent(); warmFontPreview(font, "high"); emitPreviewChange(null, { immediate: true }); emitChange({ ...normalizedValue, fontId: font.id, fontWeight: resolveFontPickerFontWeight( font, normalizedValue.fontWeight, ), }); }} onLetterSpacingValueChange={(nextIndex) => { const nextStep = letterSpacingSteps[nextIndex] ?? letterSpacingStep; emitChange( { ...normalizedValue, letterSpacing: nextStep.value, }, { history: "merge" }, ); }} onLineHeightValueChange={(nextIndex) => { const nextStep = lineHeightSteps[nextIndex] ?? lineHeightStep; emitChange( { ...normalizedValue, lineHeight: nextStep.value, }, { history: "merge" }, ); }} onQueryChange={setQueryWithReset} pinnedSelectedRowSide={pinnedSelectedRowSide} popoverWidth={familyWeightRowWidth || undefined} query={query} scrollToFontIndex={scrollToFontIndex} searchInputRef={searchInputRef} searchPlaceholder={searchPlaceholder} selectedFont={selectedFont} selectedFontId={normalizedValue.fontId} selectedFontIndex={selectedFontIndex} selectedFontPreviewStyle={selectedFontPreviewStyle} topSpacerHeight={topSpacerHeight} virtualEndIndex={virtualEndIndex} virtualStartIndex={virtualStartIndex} visibleFonts={visibleFonts} />
Weight { emitChange( { ...normalizedValue, fontWeight: resolveFontPickerFontWeight( selectedFont, nextWeight, ), }, { history: "merge" }, ); }} options={fontWeightOptions.map((weight) => ({ label: weight, value: weight, }))} scrollFadeValue={false} triggerClassName="min-w-0" value={normalizedValue.fontWeight} />
Size commitFontSizeDraft()} onChange={(event) => setFontSizeDraft(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); commitFontSizeDraft(event.currentTarget.value); event.currentTarget.blur(); return; } if (event.key === "Escape") { event.preventDefault(); setFontSizeDraft(String(normalizedValue.fontSize)); event.currentTarget.blur(); } }} step={1} type="text" value={fontSizeDraft} />
Case { emitChange( { ...normalizedValue, textCase: isFontPickerTextCase(nextTextCase) ? nextTextCase : "original", }, { history: "merge" }, ); }} options={textCaseOptions} scrollFadeValue={false} value={normalizedValue.textCase} />
{ emitChange( { ...normalizedValue, color: nextColor.hex, opacity: nextColor.opacity, }, meta ?? { history: "merge" }, ); }} opacity={normalizedValue.opacity} showLabel />
); }