aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/FontPicker
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/components/FontPicker
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/components/FontPicker')
-rw-r--r--packages/excalidraw/components/FontPicker/FontPicker.scss15
-rw-r--r--packages/excalidraw/components/FontPicker/FontPicker.tsx110
-rw-r--r--packages/excalidraw/components/FontPicker/FontPickerList.tsx272
-rw-r--r--packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx38
-rw-r--r--packages/excalidraw/components/FontPicker/keyboardNavHandlers.ts66
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;
+ }
+};