import { Button, DropdownMenu, Tooltip } from "@cloudflare/kumo"; import type { MessageDescriptor } from "@lingui/core"; import { msg } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { CaretDown, TextH, TextHFive, TextHFour, TextHOne, TextHSix, TextHThree, TextHTwo, type Icon, } from "@phosphor-icons/react"; import type { Editor } from "@tiptap/core"; import { useEditorState } from "@tiptap/react"; import * as React from "react"; import { cn } from "../../lib/utils.js"; /** * TipTap's heading-dropdown-menu API adapted to EmDash's Kumo primitives. * Source pattern: `npx @tiptap/cli add heading-dropdown-menu`. */ export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; const DEFAULT_LEVELS: readonly HeadingLevel[] = [1, 2, 3, 4, 5, 6]; const HEADING_LABELS: Record = { 1: msg`Heading 1`, 2: msg`Heading 2`, 3: msg`Heading 3`, 4: msg`Heading 4`, 5: msg`Heading 5`, 6: msg`Heading 6`, }; const HEADING_ICONS: Record = { 1: TextHOne, 2: TextHTwo, 3: TextHThree, 4: TextHFour, 5: TextHFive, 6: TextHSix, }; export function getActiveHeadingLevel( editor: Editor | null, levels: readonly HeadingLevel[] = DEFAULT_LEVELS, ): HeadingLevel | undefined { if (!editor || !editor.isEditable) return undefined; return levels.find((level) => editor.isActive("heading", { level })); } function canToggleHeading(editor: Editor | null, levels: readonly HeadingLevel[]): boolean { if (!editor || !editor.isEditable || !editor.schema.nodes.heading) return false; return levels.some( (level) => editor.can().setNode("heading", { level }) || editor.can().clearNodes(), ); } function toggleHeading(editor: Editor | null, level: HeadingLevel): boolean { if (!editor || !canToggleHeading(editor, [level])) return false; return editor.chain().focus().toggleHeading({ level }).run(); } export interface UseHeadingDropdownMenuConfig { editor?: Editor | null; levels?: readonly HeadingLevel[]; hideWhenUnavailable?: boolean; } export function useHeadingDropdownMenu(config: UseHeadingDropdownMenuConfig = {}) { const { editor = null, levels = DEFAULT_LEVELS, hideWhenUnavailable = false } = config; const state = useEditorState({ editor, selector: ({ editor: currentEditor }) => { const activeLevel = getActiveHeadingLevel(currentEditor, levels); const canToggle = canToggleHeading(currentEditor, levels); return { activeLevel, isActive: activeLevel !== undefined, canToggle, isVisible: !hideWhenUnavailable || canToggle, }; }, }); const activeLevel = state?.activeLevel; return { isVisible: state?.isVisible ?? !hideWhenUnavailable, activeLevel, isActive: state?.isActive ?? false, canToggle: state?.canToggle ?? false, levels, Icon: activeLevel ? HEADING_ICONS[activeLevel] : TextH, }; } export interface HeadingDropdownMenuProps extends UseHeadingDropdownMenuConfig { className?: string; onOpenChange?: (isOpen: boolean) => void; } export const HeadingDropdownMenu = React.forwardRef( function HeadingDropdownMenu( { editor = null, levels = DEFAULT_LEVELS, hideWhenUnavailable = false, className, onOpenChange, }, ref, ) { const { t } = useLingui(); const [open, setOpen] = React.useState(false); const { isVisible, activeLevel, isActive, canToggle, Icon } = useHeadingDropdownMenu({ editor, levels, hideWhenUnavailable, }); const handleOpenChange = React.useCallback( (nextOpen: boolean) => { if (nextOpen && !canToggle) return; setOpen(nextOpen); onOpenChange?.(nextOpen); }, [canToggle, onOpenChange], ); if (!isVisible) return null; return ( event.preventDefault()} aria-label={t`Headings`} aria-haspopup="menu" aria-expanded={open} > ); }, );