import { useState, useId, useLayoutEffect, useMemo, useRef, useImperativeHandle, type Ref } from "react"; import { FloatingPortal } from "@floating-ui/react"; import { FileText } from "lucide-react"; import { useMenuNavigation } from "../menu/navigation"; import { preserveEditorSelectionOnMouseDown, useEditorRectPopover } from "../menu/popover"; export interface PageMentionItem { pageId: string; title: string; icon: string | null; } export type PageMentionFilterMode = "external" | "internal"; export interface PageMentionPickerPanelProps { items: PageMentionItem[]; filterMode: PageMentionFilterMode; command: (item: PageMentionItem) => void; clientRect: (() => DOMRect | null) | null; contextElement?: HTMLElement | null; onCancel?: () => void; ref?: Ref; } export interface PageMentionPickerPanelHandle { onKeyDown: (event: KeyboardEvent) => boolean; } const MAX_VISIBLE = 20; function getInternalSelectedIndex(itemCount: number): number { return itemCount > 0 ? 0 : -1; } export function PageMentionPickerPanel({ items, filterMode, command, clientRect, contextElement, onCancel, ref, }: PageMentionPickerPanelProps) { const [query, setQuery] = useState(""); const panelRef = useRef(null); const inputRef = useRef(null); const listboxId = useId(); const visibleItems = useMemo(() => { if (filterMode === "external" || query.length === 0) { return items.slice(0, MAX_VISIBLE); } const normalizedQuery = query.trim().toLowerCase(); const nextItems: PageMentionItem[] = []; for (const item of items) { if (!item.title.toLowerCase().includes(normalizedQuery)) continue; nextItems.push(item); if (nextItems.length >= MAX_VISIBLE) break; } return nextItems; }, [items, filterMode, query]); const navigation = useMenuNavigation({ items: visibleItems, initialIndex: filterMode === "internal" ? getInternalSelectedIndex(visibleItems.length) : 0, listRef: panelRef, onSelect: command, }); const { floatingStyles, setFloating } = useEditorRectPopover({ open: true, onClose: onCancel, getAnchorRect: () => clientRect?.() ?? null, contextElement, maxHeight: true, deferOutsidePress: true, }); useLayoutEffect(() => { if (filterMode !== "internal") return; const focusInput = () => { const input = inputRef.current; if (!input) return; input.focus(); const end = input.value.length; input.setSelectionRange(end, end); }; focusInput(); const rafId = window.requestAnimationFrame(focusInput); return () => window.cancelAnimationFrame(rafId); }, [filterMode]); useImperativeHandle( ref, () => ({ onKeyDown: (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); onCancel?.(); return true; } return navigation.onKeyDown(event); }, }), [navigation, onCancel], ); function handleInputKeyDown(event: React.KeyboardEvent) { if (event.key === "Escape") { event.preventDefault(); onCancel?.(); return; } if (navigation.onKeyDown(event.nativeEvent)) { event.preventDefault(); } } return (
{ setFloating(node); panelRef.current = node; }} className="tiptap-slash-menu tiptap-page-mention-picker" style={{ ...floatingStyles, zIndex: 80 }} onMouseDownCapture={(event) => { // Let native focus reach the search input in internal mode; still // swallow mousedown on non-interactive panel chrome so the editor // does not lose its selection behind the picker. preserveEditorSelectionOnMouseDown(event); }} > {filterMode === "internal" && (
setQuery(event.target.value)} onKeyDown={handleInputKeyDown} placeholder="Link to page..." className="tiptap-page-mention-picker-input" autoFocus aria-controls={listboxId} aria-activedescendant={ visibleItems.length > 0 && navigation.selectedIndex >= 0 ? `${listboxId}-option-${navigation.selectedIndex}` : undefined } aria-expanded="true" aria-autocomplete="list" aria-label="Filter pages" role="combobox" />
)}
{filterMode === "internal" ? "Pages" : "Link to page"}
{visibleItems.length === 0 ? (
No pages found
) : ( visibleItems.map((item, index) => ( )) )}
); }