Skip to content
File

Blob: src/client/components/editor/controllers/page-mention/picker-panel.tsx

typescript199 lines
1import { useState, useId, useLayoutEffect, useMemo, useRef, useImperativeHandle, type Ref } from "react";
2import { FloatingPortal } from "@floating-ui/react";
3import { FileText } from "lucide-react";
4import { useMenuNavigation } from "../menu/navigation";
5import { preserveEditorSelectionOnMouseDown, useEditorRectPopover } from "../menu/popover";
6 
7export interface PageMentionItem {
8 pageId: string;
9 title: string;
10 icon: string | null;
11}
12 
13export type PageMentionFilterMode = "external" | "internal";
14 
15export interface PageMentionPickerPanelProps {
16 items: PageMentionItem[];
17 filterMode: PageMentionFilterMode;
18 command: (item: PageMentionItem) => void;
19 clientRect: (() => DOMRect | null) | null;
20 contextElement?: HTMLElement | null;
21 onCancel?: () => void;
22 ref?: Ref<PageMentionPickerPanelHandle>;
23}
24 
25export interface PageMentionPickerPanelHandle {
26 onKeyDown: (event: KeyboardEvent) => boolean;
27}
28 
29const MAX_VISIBLE = 20;
30 
31function getInternalSelectedIndex(itemCount: number): number {
32 return itemCount > 0 ? 0 : -1;
33}
34 
35export function PageMentionPickerPanel({
36 items,
37 filterMode,
38 command,
39 clientRect,
40 contextElement,
41 onCancel,
42 ref,
43}: PageMentionPickerPanelProps) {
44 const [query, setQuery] = useState("");
45 const panelRef = useRef<HTMLDivElement>(null);
46 const inputRef = useRef<HTMLInputElement>(null);
47 const listboxId = useId();
48 
49 const visibleItems = useMemo(() => {
50 if (filterMode === "external" || query.length === 0) {
51 return items.slice(0, MAX_VISIBLE);
52 }
53 const normalizedQuery = query.trim().toLowerCase();
54 const nextItems: PageMentionItem[] = [];
55 for (const item of items) {
56 if (!item.title.toLowerCase().includes(normalizedQuery)) continue;
57 nextItems.push(item);
58 if (nextItems.length >= MAX_VISIBLE) break;
59 }
60 return nextItems;
61 }, [items, filterMode, query]);
62 
63 const navigation = useMenuNavigation({
64 items: visibleItems,
65 initialIndex: filterMode === "internal" ? getInternalSelectedIndex(visibleItems.length) : 0,
66 listRef: panelRef,
67 onSelect: command,
68 });
69 const { floatingStyles, setFloating } = useEditorRectPopover({
70 open: true,
71 onClose: onCancel,
72 getAnchorRect: () => clientRect?.() ?? null,
73 contextElement,
74 maxHeight: true,
75 deferOutsidePress: true,
76 });
77 
78 useLayoutEffect(() => {
79 if (filterMode !== "internal") return;
80 
81 const focusInput = () => {
82 const input = inputRef.current;
83 if (!input) return;
84 input.focus();
85 const end = input.value.length;
86 input.setSelectionRange(end, end);
87 };
88 
89 focusInput();
90 const rafId = window.requestAnimationFrame(focusInput);
91 return () => window.cancelAnimationFrame(rafId);
92 }, [filterMode]);
93 
94 useImperativeHandle(
95 ref,
96 () => ({
97 onKeyDown: (event: KeyboardEvent) => {
98 if (event.key === "Escape") {
99 event.preventDefault();
100 onCancel?.();
101 return true;
102 }
103 return navigation.onKeyDown(event);
104 },
105 }),
106 [navigation, onCancel],
107 );
108 
109 function handleInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
110 if (event.key === "Escape") {
111 event.preventDefault();
112 onCancel?.();
113 return;
114 }
115 if (navigation.onKeyDown(event.nativeEvent)) {
116 event.preventDefault();
117 }
118 }
119 
120 return (
121 <FloatingPortal>
122 <div
123 ref={(node) => {
124 setFloating(node);
125 panelRef.current = node;
126 }}
127 className="tiptap-slash-menu tiptap-page-mention-picker"
128 style={{ ...floatingStyles, zIndex: 80 }}
129 onMouseDownCapture={(event) => {
130 // Let native focus reach the search input in internal mode; still
131 // swallow mousedown on non-interactive panel chrome so the editor
132 // does not lose its selection behind the picker.
133 preserveEditorSelectionOnMouseDown(event);
134 }}
135 >
136 {filterMode === "internal" && (
137 <div className="tiptap-page-mention-picker-search">
138 <input
139 ref={inputRef}
140 type="text"
141 value={query}
142 onChange={(event) => setQuery(event.target.value)}
143 onKeyDown={handleInputKeyDown}
144 placeholder="Link to page..."
145 className="tiptap-page-mention-picker-input"
146 autoFocus
147 aria-controls={listboxId}
148 aria-activedescendant={
149 visibleItems.length > 0 && navigation.selectedIndex >= 0
150 ? `${listboxId}-option-${navigation.selectedIndex}`
151 : undefined
152 }
153 aria-expanded="true"
154 aria-autocomplete="list"
155 aria-label="Filter pages"
156 role="combobox"
157 />
158 </div>
159 )}
160 <div className="tiptap-slash-menu-label">{filterMode === "internal" ? "Pages" : "Link to page"}</div>
161 <div id={listboxId} role="listbox" aria-label="Page mentions">
162 {visibleItems.length === 0 ? (
163 <div className="tiptap-slash-menu-item" aria-disabled role="option">
164 <FileText size={18} className="shrink-0 text-zinc-400" />
165 <span>No pages found</span>
166 </div>
167 ) : (
168 visibleItems.map((item, index) => (
169 <button
170 key={item.pageId}
171 id={`${listboxId}-option-${index}`}
172 type="button"
173 data-menu-index={index}
174 className="tiptap-slash-menu-item"
175 aria-selected={index === navigation.selectedIndex}
176 role="option"
177 onMouseEnter={() => navigation.setSelectedIndex(index)}
178 onMouseDown={(event) => {
179 event.preventDefault();
180 command(item);
181 }}
182 >
183 {item.icon ? (
184 <span className="flex h-[18px] w-[18px] shrink-0 items-center justify-center text-base leading-none">
185 {item.icon}
186 </span>
187 ) : (
188 <FileText size={18} className="shrink-0 text-zinc-400" />
189 )}
190 <span>{item.title}</span>
191 </button>
192 ))
193 )}
194 </div>
195 </div>
196 </FloatingPortal>
197 );
198}