1"use client";
2
3
25import { useCallback, useEffect, useMemo, useRef, useState } from "react";
26import { cn } from "@/lib/utils";
27import { useMentionSources } from "@/hooks/use-mention-sources";
28import {
29 buildMentionGroups,
30 mentionActive,
31 type MentionItem,
32 type MentionMember,
33 type MentionSource
34} from "@/components/mentions/mention-model";
35import { MentionPopup } from "@/components/mentions/MentionPopup";
36
37export interface MentionInputProps {
38 value: string;
39 onChange: (value: string) => void;
40 members: MentionMember[];
41 placeholder?: string;
42 className?: string;
43 autoFocus?: boolean;
44 onSubmit?: () => void;
45 onEscape?: () => void;
46
47 workspaceId?: string;
48
49 currentUserId?: string | null;
50
51 testId?: string;
52}
53
54export function MentionInput({
55 value,
56 onChange,
57 members,
58 placeholder,
59 className,
60 autoFocus,
61 onSubmit,
62 onEscape,
63 workspaceId,
64 currentUserId,
65 testId
66}: MentionInputProps) {
67 const ref = useRef<HTMLDivElement>(null);
68 const [open, setOpen] = useState(false);
69 const [query, setQuery] = useState("");
70 const [highlight, setHighlight] = useState(0);
71 const [anchor, setAnchor] = useState<{ start: number; end: number } | null>(null);
72
73 const ws = workspaceId ?? "";
74 const { source: wsSource, isLoading } = useMentionSources(ws);
75
76
77
78 const source = useMemo<MentionSource>(
79 () =>
80 ws
81 ? wsSource
82 : { people: members, teams: [], pages: [], databases: [] },
83 [ws, wsSource, members]
84 );
85
86
87
88
89 useEffect(() => {
90 if (ref.current && ref.current.textContent !== value) {
91 ref.current.textContent = value;
92 }
93 }, [value]);
94
95 const groups = useMemo(
96 () =>
97 buildMentionGroups(source, {
98 query,
99 currentUserId,
100 existingText: value
101 }),
102 [source, query, currentUserId, value]
103 );
104 const flatItems = useMemo(() => groups.flatMap((g) => g.items), [groups]);
105
106 const updateFromDOM = useCallback(() => {
107 const text = ref.current?.textContent ?? "";
108 onChange(text);
109
110
111 const caret = window.getSelection();
112 const selStart = caret?.anchorOffset ?? text.length;
113 const active = mentionActive(text.slice(0, selStart));
114 if (active) {
115 setAnchor({ start: active.start, end: active.end });
116 setQuery(active.query);
117 setOpen(true);
118 setHighlight(0);
119 } else {
120 setOpen(false);
121 setAnchor(null);
122 }
123 }, [onChange]);
124
125 const pick = useCallback(
126 (item: MentionItem) => {
127 const el = ref.current;
128 if (!el || !anchor) return;
129 const full = el.textContent ?? "";
130 const inserted = `${item.token} `;
131 const next = `${full.slice(0, anchor.start)}${inserted}${full.slice(anchor.end)}`;
132 el.textContent = next;
133
134 const caretPos = anchor.start + inserted.length;
135 const sel = window.getSelection();
136 if (sel && el.firstChild) {
137 const range = document.createRange();
138 const node = el.firstChild as Text;
139 const at = Math.min(caretPos, node.textContent?.length ?? caretPos);
140 range.setStart(node, at);
141 range.collapse(true);
142 sel.removeAllRanges();
143 sel.addRange(range);
144 }
145 onChange(next);
146 setOpen(false);
147 setAnchor(null);
148 el.focus();
149 },
150 [anchor, onChange]
151 );
152
153 const onKeyDown = (e: React.KeyboardEvent) => {
154 if (open && flatItems.length > 0) {
155 if (e.key === "ArrowDown") {
156 e.preventDefault();
157 setHighlight((h) => (h + 1) % flatItems.length);
158 return;
159 }
160 if (e.key === "ArrowUp") {
161 e.preventDefault();
162 setHighlight((h) => (h - 1 + flatItems.length) % flatItems.length);
163 return;
164 }
165 if (e.key === "Enter" || e.key === "Tab") {
166 e.preventDefault();
167 const item = flatItems[highlight];
168 if (item) pick(item);
169 return;
170 }
171 if (e.key === "Escape") {
172 setOpen(false);
173 return;
174 }
175 }
176 if (e.key === "Enter" && !e.shiftKey) {
177 e.preventDefault();
178 onSubmit?.();
179 return;
180 }
181 if (e.key === "Escape") {
182 onEscape?.();
183 }
184 };
185
186 return (
187 <div className={cn("relative", className)}>
188 <div
189 ref={ref}
190 contentEditable
191 suppressContentEditableWarning
192 role="textbox"
193 aria-multiline="true"
194 aria-label="Comment"
195 data-placeholder={placeholder}
196 data-testid={testId}
197 autoFocus={autoFocus}
198 className={cn(
199 "min-h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring empty:before:text-muted-foreground",
200 "empty:before:absolute empty:before:px-3 empty:before:py-2 empty:before:content-[attr(data-placeholder)]"
201 )}
202 onInput={updateFromDOM}
203 onKeyDown={onKeyDown}
204 onBlur={() => setTimeout(() => setOpen(false), 150)}
205 />
206 {open && (
207 <MentionPopup
208 groups={groups}
209 highlight={highlight}
210 loading={isLoading}
211 onPick={pick}
212 onHover={setHighlight}
213 />
214 )}
215 </div>
216 );
217}
218