summaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/FontPicker/FontPickerList.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/components/FontPicker/FontPickerList.tsx')
-rw-r--r--packages/excalidraw/components/FontPicker/FontPickerList.tsx272
1 files changed, 272 insertions, 0 deletions
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,
+);