diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/components/FontPicker | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/components/FontPicker')
5 files changed, 501 insertions, 0 deletions
diff --git a/packages/excalidraw/components/FontPicker/FontPicker.scss b/packages/excalidraw/components/FontPicker/FontPicker.scss new file mode 100644 index 0000000..5a57258 --- /dev/null +++ b/packages/excalidraw/components/FontPicker/FontPicker.scss @@ -0,0 +1,15 @@ +@import "../../css/variables.module.scss"; + +.excalidraw { + .FontPicker__container { + display: grid; + grid-template-columns: calc(1rem + 3 * var(--default-button-size)) 1rem 1fr; // calc ~ 2 gaps + 4 buttons + align-items: center; + + @include isMobile { + max-width: calc( + 2rem + 4 * var(--default-button-size) + ); // 4 gaps + 4 buttons + } + } +} diff --git a/packages/excalidraw/components/FontPicker/FontPicker.tsx b/packages/excalidraw/components/FontPicker/FontPicker.tsx new file mode 100644 index 0000000..4585c58 --- /dev/null +++ b/packages/excalidraw/components/FontPicker/FontPicker.tsx @@ -0,0 +1,110 @@ +import React, { useCallback, useMemo } from "react"; +import * as Popover from "@radix-ui/react-popover"; + +import { FontPickerList } from "./FontPickerList"; +import { FontPickerTrigger } from "./FontPickerTrigger"; +import { ButtonIconSelect } from "../ButtonIconSelect"; +import { + FontFamilyCodeIcon, + FontFamilyNormalIcon, + FreedrawIcon, +} from "../icons"; +import { ButtonSeparator } from "../ButtonSeparator"; +import type { FontFamilyValues } from "../../element/types"; +import { FONT_FAMILY } from "../../constants"; +import { t } from "../../i18n"; + +import "./FontPicker.scss"; + +export const DEFAULT_FONTS = [ + { + value: FONT_FAMILY.Excalifont, + icon: FreedrawIcon, + text: t("labels.handDrawn"), + testId: "font-family-hand-drawn", + }, + { + value: FONT_FAMILY.Nunito, + icon: FontFamilyNormalIcon, + text: t("labels.normal"), + testId: "font-family-normal", + }, + { + value: FONT_FAMILY["Comic Shanns"], + icon: FontFamilyCodeIcon, + text: t("labels.code"), + testId: "font-family-code", + }, +]; + +const defaultFontFamilies = new Set(DEFAULT_FONTS.map((x) => x.value)); + +export const isDefaultFont = (fontFamily: number | null) => { + if (!fontFamily) { + return false; + } + + return defaultFontFamilies.has(fontFamily); +}; + +interface FontPickerProps { + isOpened: boolean; + selectedFontFamily: FontFamilyValues | null; + hoveredFontFamily: FontFamilyValues | null; + onSelect: (fontFamily: FontFamilyValues) => void; + onHover: (fontFamily: FontFamilyValues) => void; + onLeave: () => void; + onPopupChange: (open: boolean) => void; +} + +export const FontPicker = React.memo( + ({ + isOpened, + selectedFontFamily, + hoveredFontFamily, + onSelect, + onHover, + onLeave, + onPopupChange, + }: FontPickerProps) => { + const defaultFonts = useMemo(() => DEFAULT_FONTS, []); + const onSelectCallback = useCallback( + (value: number | false) => { + if (value) { + onSelect(value); + } + }, + [onSelect], + ); + + return ( + <div role="dialog" aria-modal="true" className="FontPicker__container"> + <ButtonIconSelect<FontFamilyValues | false> + type="button" + options={defaultFonts} + value={selectedFontFamily} + onClick={onSelectCallback} + /> + <ButtonSeparator /> + <Popover.Root open={isOpened} onOpenChange={onPopupChange}> + <FontPickerTrigger selectedFontFamily={selectedFontFamily} /> + {isOpened && ( + <FontPickerList + selectedFontFamily={selectedFontFamily} + hoveredFontFamily={hoveredFontFamily} + onSelect={onSelectCallback} + onHover={onHover} + onLeave={onLeave} + onOpen={() => onPopupChange(true)} + onClose={() => onPopupChange(false)} + /> + )} + </Popover.Root> + </div> + ); + }, + (prev, next) => + prev.isOpened === next.isOpened && + prev.selectedFontFamily === next.selectedFontFamily && + prev.hoveredFontFamily === next.hoveredFontFamily, +); diff --git a/packages/excalidraw/components/FontPicker/FontPickerList.tsx b/packages/excalidraw/components/FontPicker/FontPickerList.tsx new file mode 100644 index 0000000..3a680b8 --- /dev/null +++ b/packages/excalidraw/components/FontPicker/FontPickerList.tsx @@ -0,0 +1,272 @@ +import type { JSX } from "react"; +import React, { + useMemo, + useState, + useRef, + useEffect, + useCallback, + type KeyboardEventHandler, +} from "react"; +import { useApp, useAppProps, useExcalidrawContainer } from "../App"; +import { PropertiesPopover } from "../PropertiesPopover"; +import { QuickSearch } from "../QuickSearch"; +import { ScrollableList } from "../ScrollableList"; +import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup"; +import DropdownMenuItem, { + DropDownMenuItemBadgeType, + DropDownMenuItemBadge, +} from "../dropdownMenu/DropdownMenuItem"; +import { type FontFamilyValues } from "../../element/types"; +import { arrayToList, debounce, getFontFamilyString } from "../../utils"; +import { t } from "../../i18n"; +import { fontPickerKeyHandler } from "./keyboardNavHandlers"; +import { Fonts } from "../../fonts"; +import type { ValueOf } from "../../utility-types"; +import { FontFamilyNormalIcon } from "../icons"; + +export interface FontDescriptor { + value: number; + icon: JSX.Element; + text: string; + deprecated?: true; + badge?: { + type: ValueOf<typeof DropDownMenuItemBadgeType>; + placeholder: string; + }; +} + +interface FontPickerListProps { + selectedFontFamily: FontFamilyValues | null; + hoveredFontFamily: FontFamilyValues | null; + onSelect: (value: number) => void; + onHover: (value: number) => void; + onLeave: () => void; + onOpen: () => void; + onClose: () => void; +} + +export const FontPickerList = React.memo( + ({ + selectedFontFamily, + hoveredFontFamily, + onSelect, + onHover, + onLeave, + onOpen, + onClose, + }: FontPickerListProps) => { + const { container } = useExcalidrawContainer(); + const { fonts } = useApp(); + const { showDeprecatedFonts } = useAppProps(); + + const [searchTerm, setSearchTerm] = useState(""); + const inputRef = useRef<HTMLInputElement>(null); + const allFonts = useMemo( + () => + Array.from(Fonts.registered.entries()) + .filter( + ([_, { metadata }]) => !metadata.serverSide && !metadata.fallback, + ) + .map(([familyId, { metadata, fontFaces }]) => { + const fontDescriptor = { + value: familyId, + icon: metadata.icon ?? FontFamilyNormalIcon, + text: fontFaces[0]?.fontFace?.family ?? "Unknown", + }; + + 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, + ]); + + const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>( + (event) => { + const handled = fontPickerKeyHandler({ + event, + inputRef, + hoveredFont, + filteredFonts, + onSelect, + onHover, + onClose, + }); + + if (handled) { + event.preventDefault(); + event.stopPropagation(); + } + }, + [hoveredFont, filteredFonts, onSelect, 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 renderFont = (font: FontDescriptor, index: number) => ( + <DropdownMenuItem + key={font.value} + icon={font.icon} + value={font.value} + order={index} + textStyle={{ + fontFamily: getFontFamilyString({ fontFamily: font.value }), + }} + hovered={font.value === hoveredFont?.value} + selected={font.value === selectedFontFamily} + // allow to tab between search and selected font + tabIndex={font.value === selectedFontFamily ? 0 : -1} + onClick={(e) => { + onSelect(Number(e.currentTarget.value)); + }} + onMouseMove={() => { + if (hoveredFont?.value !== font.value) { + onHover(font.value); + } + }} + > + {font.text} + {font.badge && ( + <DropDownMenuItemBadge type={font.badge.type}> + {font.badge.placeholder} + </DropDownMenuItemBadge> + )} + </DropdownMenuItem> + ); + + const groups = []; + + if (sceneFilteredFonts.length) { + groups.push( + <DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1"> + {sceneFilteredFonts.map(renderFont)} + </DropdownMenuGroup>, + ); + } + + if (availableFilteredFonts.length) { + groups.push( + <DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2"> + {availableFilteredFonts.map((font, index) => + renderFont(font, index + sceneFilteredFonts.length), + )} + </DropdownMenuGroup>, + ); + } + + return ( + <PropertiesPopover + className="properties-content" + container={container} + style={{ width: "15rem" }} + onClose={onClose} + onPointerLeave={onLeave} + onKeyDown={onKeyDown} + > + <QuickSearch + ref={inputRef} + placeholder={t("quickSearch.placeholder")} + onChange={debounce(setSearchTerm, 20)} + /> + <ScrollableList + className="dropdown-menu fonts manual-hover" + placeholder={t("fontList.empty")} + > + {groups.length ? groups : null} + </ScrollableList> + </PropertiesPopover> + ); + }, + (prev, next) => + prev.selectedFontFamily === next.selectedFontFamily && + prev.hoveredFontFamily === next.hoveredFontFamily, +); diff --git a/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx b/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx new file mode 100644 index 0000000..8652dab --- /dev/null +++ b/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx @@ -0,0 +1,38 @@ +import * as Popover from "@radix-ui/react-popover"; +import { useMemo } from "react"; +import { ButtonIcon } from "../ButtonIcon"; +import { TextIcon } from "../icons"; +import type { FontFamilyValues } from "../../element/types"; +import { t } from "../../i18n"; +import { isDefaultFont } from "./FontPicker"; + +interface FontPickerTriggerProps { + selectedFontFamily: FontFamilyValues | null; +} + +export const FontPickerTrigger = ({ + selectedFontFamily, +}: FontPickerTriggerProps) => { + const isTriggerActive = useMemo( + () => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)), + [selectedFontFamily], + ); + + return ( + <Popover.Trigger asChild> + {/* Empty div as trigger so it's stretched 100% due to different button sizes */} + <div> + <ButtonIcon + standalone + icon={TextIcon} + title={t("labels.showFonts")} + className="properties-trigger" + testId={"font-family-show-fonts"} + active={isTriggerActive} + // no-op + onClick={() => {}} + /> + </div> + </Popover.Trigger> + ); +}; diff --git a/packages/excalidraw/components/FontPicker/keyboardNavHandlers.ts b/packages/excalidraw/components/FontPicker/keyboardNavHandlers.ts new file mode 100644 index 0000000..19c4adc --- /dev/null +++ b/packages/excalidraw/components/FontPicker/keyboardNavHandlers.ts @@ -0,0 +1,66 @@ +import type { Node } from "../../utils"; +import { KEYS } from "../../keys"; +import { type FontDescriptor } from "./FontPickerList"; + +interface FontPickerKeyNavHandlerProps { + event: React.KeyboardEvent<HTMLDivElement>; + inputRef: React.RefObject<HTMLInputElement | null>; + hoveredFont: Node<FontDescriptor> | undefined; + filteredFonts: Node<FontDescriptor>[]; + onClose: () => void; + onSelect: (value: number) => void; + onHover: (value: number) => void; +} + +export const fontPickerKeyHandler = ({ + event, + inputRef, + hoveredFont, + filteredFonts, + onClose, + onSelect, + onHover, +}: FontPickerKeyNavHandlerProps) => { + if ( + !event[KEYS.CTRL_OR_CMD] && + event.shiftKey && + event.key.toLowerCase() === KEYS.F + ) { + // refocus input on the popup trigger shortcut + inputRef.current?.focus(); + return true; + } + + if (event.key === KEYS.ESCAPE) { + onClose(); + return true; + } + + if (event.key === KEYS.ENTER) { + if (hoveredFont?.value) { + onSelect(hoveredFont.value); + } + + return true; + } + + if (event.key === KEYS.ARROW_DOWN) { + if (hoveredFont?.next) { + onHover(hoveredFont.next.value); + } else if (filteredFonts[0]?.value) { + onHover(filteredFonts[0].value); + } + + return true; + } + + if (event.key === KEYS.ARROW_UP) { + if (hoveredFont?.prev) { + onHover(hoveredFont.prev.value); + } else if (filteredFonts[filteredFonts.length - 1]?.value) { + onHover(filteredFonts[filteredFonts.length - 1].value); + } + + return true; + } +}; |
