import React, { useMemo, useState, useRef, useEffect, useCallback, type KeyboardEventHandler, } from "react"; import { type FontFamilyValues } from "@excalidraw/element/types"; import { arrayToList, debounce, FONT_FAMILY, getFontFamilyString, } from "@excalidraw/common"; import type { ValueOf } from "@excalidraw/common/utility-types"; import { Fonts } from "../../fonts"; import { t } from "../../i18n"; import { useApp, useAppProps, useExcalidrawContainer, useStylesPanelMode, } from "../App"; import { PropertiesPopover } from "../PropertiesPopover"; import { QuickSearch } from "../QuickSearch"; import { ScrollableList } from "../ScrollableList"; import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup"; import { DropDownMenuItemBadgeType, DropDownMenuItemBadge, } from "../dropdownMenu/DropdownMenuItem"; import MenuItemContent from "../dropdownMenu/DropdownMenuItemContent"; import { getDropdownMenuItemClassName } from "../dropdownMenu/common"; import { FontFamilyCodeIcon, FontFamilyHeadingIcon, FontFamilyNormalIcon, FreedrawIcon, } from "../icons"; import { fontPickerKeyHandler } from "./keyboardNavHandlers"; import type { JSX } from "react"; import type { ExcalidrawFontFace } from "../../fonts/ExcalidrawFontFace"; export interface FontDescriptor { value: number; icon: JSX.Element; text: string; deprecated?: true; badge?: { type: ValueOf; placeholder: string; }; } interface FontPickerListProps { selectedFontFamily: FontFamilyValues | null; hoveredFontFamily: FontFamilyValues | null; onSelect: (value: number) => void; onHover: (value: number) => void; onLeave: () => void; onOpen: () => void; onClose: () => void; } const getFontFamilyIcon = (fontFamily: FontFamilyValues): JSX.Element => { switch (fontFamily) { case FONT_FAMILY.Excalifont: case FONT_FAMILY.Virgil: return FreedrawIcon; case FONT_FAMILY.Nunito: case FONT_FAMILY.Helvetica: return FontFamilyNormalIcon; case FONT_FAMILY["Lilita One"]: return FontFamilyHeadingIcon; case FONT_FAMILY["Comic Shanns"]: case FONT_FAMILY.Cascadia: return FontFamilyCodeIcon; default: return FontFamilyNormalIcon; } }; const getFontFamilyLabel = ( fontFamily: FontFamilyValues, fontFaces: ExcalidrawFontFace[], ) => // prefer our config as the browser resolved names may be wrapped in quotes and such Object.entries(FONT_FAMILY).find(([, id]) => id === fontFamily)?.[0] ?? fontFaces[0]?.fontFace?.family ?? "Unknown"; export const FontPickerList = React.memo( ({ selectedFontFamily, hoveredFontFamily, onSelect, onHover, onLeave, onOpen, onClose, }: FontPickerListProps) => { const { container } = useExcalidrawContainer(); const app = useApp(); const { fonts } = app; const { showDeprecatedFonts } = useAppProps(); const stylesPanelMode = useStylesPanelMode(); const [searchTerm, setSearchTerm] = useState(""); const inputRef = useRef(null); const allFonts = useMemo( () => Array.from(Fonts.registered.entries()) .filter( ([_, { metadata }]) => !metadata.private && !metadata.fallback, ) .map(([familyId, { metadata, fontFaces }]) => { const fontDescriptor = { value: familyId, icon: getFontFamilyIcon(familyId), text: getFontFamilyLabel(familyId, fontFaces), }; if (metadata.deprecated) { Object.assign(fontDescriptor, { deprecated: metadata.deprecated, badge: { type: DropDownMenuItemBadgeType.RED, placeholder: t("fontList.badge.old"), }, }); } return fontDescriptor as FontDescriptor; }) .sort((a, b) => a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1, ), [], ); const sceneFamilies = useMemo( () => new Set(fonts.getSceneFamilies()), // cache per selected font family, so hover re-render won't mess it up // eslint-disable-next-line react-hooks/exhaustive-deps [selectedFontFamily], ); const sceneFonts = useMemo( () => allFonts.filter((font) => sceneFamilies.has(font.value)), // always show all the fonts in the scene, even those that were deprecated [allFonts, sceneFamilies], ); const availableFonts = useMemo( () => allFonts.filter( (font) => !sceneFamilies.has(font.value) && (showDeprecatedFonts || !font.deprecated), // skip deprecated fonts ), [allFonts, sceneFamilies, showDeprecatedFonts], ); const filteredFonts = useMemo( () => arrayToList( [...sceneFonts, ...availableFonts].filter((font) => font.text?.toLowerCase().includes(searchTerm), ), ), [sceneFonts, availableFonts, searchTerm], ); const hoveredFont = useMemo(() => { let font; if (hoveredFontFamily) { font = filteredFonts.find((font) => font.value === hoveredFontFamily); } else if (selectedFontFamily) { font = filteredFonts.find((font) => font.value === selectedFontFamily); } if (!font && searchTerm) { if (filteredFonts[0]?.value) { // hover first element on search onHover(filteredFonts[0].value); } else { // re-render cache on no results onLeave(); } } return font; }, [ hoveredFontFamily, selectedFontFamily, searchTerm, filteredFonts, onHover, onLeave, ]); // Create a wrapped onSelect function that preserves caret position const wrappedOnSelect = useCallback( (fontFamily: FontFamilyValues) => { // Save caret position before font selection if editing text let savedSelection: { start: number; end: number } | null = null; if (app.state.editingTextElement) { const textEditor = document.querySelector( ".excalidraw-wysiwyg", ) as HTMLTextAreaElement; if (textEditor) { savedSelection = { start: textEditor.selectionStart, end: textEditor.selectionEnd, }; } } onSelect(fontFamily); // Restore caret position after font selection if editing text if (app.state.editingTextElement && savedSelection) { setTimeout(() => { const textEditor = document.querySelector( ".excalidraw-wysiwyg", ) as HTMLTextAreaElement; if (textEditor && savedSelection) { textEditor.focus(); textEditor.selectionStart = savedSelection.start; textEditor.selectionEnd = savedSelection.end; } }, 0); } }, [onSelect, app.state.editingTextElement], ); const onKeyDown = useCallback>( (event) => { const handled = fontPickerKeyHandler({ event, inputRef, hoveredFont, filteredFonts, onSelect: wrappedOnSelect, onHover, onClose, }); if (handled) { event.preventDefault(); event.stopPropagation(); } }, [hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose], ); useEffect(() => { onOpen(); return () => { onClose(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const sceneFilteredFonts = useMemo( () => filteredFonts.filter((font) => sceneFamilies.has(font.value)), [filteredFonts, sceneFamilies], ); const availableFilteredFonts = useMemo( () => filteredFonts.filter((font) => !sceneFamilies.has(font.value)), [filteredFonts, sceneFamilies], ); const FontPickerListItem = ({ font, order, }: { font: FontDescriptor; order: number; }) => { const ref = useRef(null); const isHovered = font.value === hoveredFont?.value; const isSelected = font.value === selectedFontFamily; useEffect(() => { if (!isHovered) { return; } if (order === 0) { // scroll into the first item differently, so it's visible what is above (i.e. group title) ref.current?.scrollIntoView?.({ block: "end" }); } else { ref.current?.scrollIntoView?.({ block: "nearest" }); } }, [isHovered, order]); return ( ); }; const groups = []; if (sceneFilteredFonts.length) { groups.push( {sceneFilteredFonts.map((font, index) => ( ))} , ); } if (availableFilteredFonts.length) { groups.push( {availableFilteredFonts.map((font, index) => ( ))} , ); } return ( { onClose(); // Refocus text editor when font picker closes if we were editing text if (app.state.editingTextElement) { setTimeout(() => { const textEditor = document.querySelector( ".excalidraw-wysiwyg", ) as HTMLTextAreaElement; if (textEditor) { textEditor.focus(); } }, 0); } }} onPointerLeave={onLeave} onKeyDown={onKeyDown} preventAutoFocusOnTouch={!!app.state.editingTextElement} > {stylesPanelMode === "full" && ( )} {groups.length ? groups : null} ); }, (prev, next) => prev.selectedFontFamily === next.selectedFontFamily && prev.hoveredFontFamily === next.hoveredFontFamily, );