import { Popover } from "radix-ui"; import clsx from "clsx"; import React, { useEffect, useMemo } from "react"; import { isArrowKey, KEYS } from "@excalidraw/common"; import { atom, useAtom } from "../editor-jotai"; import { getLanguage, t } from "../i18n"; import Collapsible from "./Stats/Collapsible"; import { useExcalidrawContainer } from "./App"; import "./IconPicker.scss"; import type { JSX } from "react"; const moreOptionsAtom = atom(false); const PICKER_COLUMNS = 4; const DEFAULT_SECTION_NAME = "default"; type Option = { value: T; text: string; icon: JSX.Element; keyBinding: string | null; }; type PickerSection = { name: string; options: readonly Option[]; }; const flattenOptions = (sections: readonly PickerSection[]) => sections.flatMap((section) => section.options); const findOption = ( sections: readonly PickerSection[], predicate: (option: Option) => boolean, ) => { for (const section of sections) { const option = section.options.find(predicate); if (option) { return option; } } return null; }; const hasOption = ( sections: readonly PickerSection[], predicate: (option: Option) => boolean, ) => sections.some((section) => section.options.some(predicate)); const getNavigationRows = (sections: readonly PickerSection[]) => sections.flatMap((section) => Array.from( { length: Math.ceil(section.options.length / PICKER_COLUMNS) }, (_, index) => section.options.slice( index * PICKER_COLUMNS, index * PICKER_COLUMNS + PICKER_COLUMNS, ), ), ); function Picker({ visibleSections, hiddenSections = [], value, label, onChange, onClose, }: { label: string; value: T; visibleSections: readonly PickerSection[]; hiddenSections?: readonly PickerSection[]; onChange: (value: T) => void; onClose: () => void; }) { const { container } = useExcalidrawContainer(); const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom); const allSections = [...visibleSections, ...hiddenSections]; const allOptions = flattenOptions(allSections); const navigationRows = getNavigationRows([ ...visibleSections, ...(showMoreOptions ? hiddenSections : []), ]); const handleKeyDown = (event: React.KeyboardEvent) => { const pressedOption = allOptions.find( (option) => option.keyBinding === event.key.toLowerCase(), ); if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) { // Keybinding navigation onChange(pressedOption.value); event.preventDefault(); } else if (event.key === KEYS.TAB) { const index = allOptions.findIndex((option) => option.value === value); const nextIndex = event.shiftKey ? (allOptions.length + index - 1) % allOptions.length : (index + 1) % allOptions.length; onChange(allOptions[nextIndex].value); } else if (isArrowKey(event.key)) { // Arrow navigation const isRTL = getLanguage().rtl; const index = allOptions.findIndex((option) => option.value === value); if (index !== -1) { const length = allOptions.length; let nextIndex = index; switch (event.key) { // Select the next option case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT: nextIndex = (index + 1) % length; break; // Select the previous option case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT: nextIndex = (length + index - 1) % length; break; // Go the next row case KEYS.ARROW_DOWN: { const currentRowIndex = navigationRows.findIndex((row) => row.some((option) => option.value === value), ); const currentRow = navigationRows[currentRowIndex]; if (currentRowIndex !== -1 && currentRow) { const column = currentRow.findIndex( (option) => option.value === value, ); const nextRow = navigationRows[(currentRowIndex + 1) % navigationRows.length]; const nextOption = nextRow[Math.min(column, nextRow.length - 1)] ?? allOptions[index]; onChange(nextOption.value); event.preventDefault(); event.nativeEvent.stopImmediatePropagation(); event.stopPropagation(); return; } break; } // Go the previous row case KEYS.ARROW_UP: { const currentRowIndex = navigationRows.findIndex((row) => row.some((option) => option.value === value), ); const currentRow = navigationRows[currentRowIndex]; if (currentRowIndex !== -1 && currentRow) { const column = currentRow.findIndex( (option) => option.value === value, ); const previousRow = navigationRows[ (navigationRows.length + currentRowIndex - 1) % navigationRows.length ]; const previousOption = previousRow[Math.min(column, previousRow.length - 1)] ?? allOptions[index]; onChange(previousOption.value); event.preventDefault(); event.nativeEvent.stopImmediatePropagation(); event.stopPropagation(); return; } break; } } onChange(allOptions[nextIndex].value); } event.preventDefault(); } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) { // Close on escape or enter event.preventDefault(); onClose(); } event.nativeEvent.stopImmediatePropagation(); event.stopPropagation(); }; useEffect(() => { if (hasOption(hiddenSections, (option) => option.value === value)) { setShowMoreOptions(true); } }, [value, hiddenSections, setShowMoreOptions]); const renderOptions = (options: readonly Option[]) => { return (
{options.map((option) => ( ))}
); }; const renderSections = (sections: readonly PickerSection[]) => sections.map((section, index) => section.name === DEFAULT_SECTION_NAME ? ( {renderOptions(section.options)} ) : (
{section.name}
{renderOptions(section.options)}
), ); return (
{renderSections(visibleSections)} {hiddenSections.length > 0 && ( { setShowMoreOptions((value) => !value); }} className="picker-collapsible" >
{renderSections(hiddenSections)}
)}
); } export function IconPicker({ value, label, visibleSections, hiddenSections, onChange, }: { label: string; value: T; visibleSections: readonly PickerSection[]; hiddenSections?: readonly PickerSection[]; onChange: (value: T) => void; }) { const [isActive, setActive] = React.useState(false); const selectedOption = useMemo( () => findOption(visibleSections, (option) => option.value === value) ?? findOption(hiddenSections ?? [], (option) => option.value === value), [visibleSections, hiddenSections, value], ); return (
setActive(open)}> setActive(!isActive)} className={isActive ? "active" : ""} > {selectedOption?.icon} {isActive && ( { setActive(false); }} /> )}
); }