Skip to content

Commit aa45cb0

Browse files
committed
Mentions component
1 parent 567130b commit aa45cb0

File tree

5 files changed

+401
-0
lines changed

5 files changed

+401
-0
lines changed
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import React, { useRef, useCallback, useEffect } from "react"
2+
import { mergeRefs } from "use-callback-ref"
3+
4+
import { cn } from "@/lib/utils"
5+
import { Command, CommandList, CommandGroup, CommandItem, CommandEmpty } from "@/components/ui"
6+
7+
import type { Mentionable } from "./types"
8+
import { useMention } from "./useMention"
9+
import { getCursorOffset, getCursorPos, replaceMention } from "./contentEditable"
10+
11+
interface MentionProps extends React.ComponentProps<"div"> {
12+
suggestions: Mentionable[]
13+
onMention?: (value: Mentionable) => void
14+
}
15+
16+
export const Mention = React.forwardRef<HTMLDivElement, MentionProps>(
17+
({ suggestions, onMention, className, ...props }, ref) => {
18+
const contentEditableRef = useRef<HTMLDivElement | null>(null)
19+
const combinedRef = mergeRefs([contentEditableRef, ref])
20+
const menuRef = useRef<HTMLDivElement | null>(null)
21+
22+
const {
23+
triggerPos,
24+
setTriggerPos,
25+
selectedSuggestion,
26+
setSelectedSuggestion,
27+
openMenu,
28+
closeMenu,
29+
isOpen,
30+
isOpenRef,
31+
} = useMention(suggestions)
32+
33+
const onInput = useCallback(
34+
(event: React.FormEvent<HTMLDivElement>) => {
35+
const content = event.currentTarget.textContent || ""
36+
37+
if (!content) {
38+
closeMenu()
39+
return
40+
}
41+
42+
const selection = window.getSelection()
43+
44+
if (!selection?.rangeCount) {
45+
return
46+
}
47+
48+
const range = selection.getRangeAt(0)
49+
const div = contentEditableRef.current
50+
const offset = getCursorOffset(div, range.endContainer, range.endOffset)
51+
const text = content.slice(0, offset)
52+
const char = text[offset - 1]
53+
54+
if (char === "@") {
55+
const coords = getCursorPos(div, offset - 1)
56+
57+
if (coords) {
58+
setTriggerPos(coords)
59+
openMenu()
60+
return
61+
}
62+
}
63+
64+
if (isOpenRef.current) {
65+
if (/\s/.test(char) || char === "\n") {
66+
closeMenu()
67+
} else {
68+
// const atIndex = text.lastIndexOf("@")
69+
// const query = text.slice(atIndex + 1)
70+
// handleSearch(query)
71+
}
72+
}
73+
},
74+
[setTriggerPos, openMenu, closeMenu, isOpenRef],
75+
)
76+
77+
const onKeyDown = (event: React.KeyboardEvent) => {
78+
if (!isOpen) {
79+
return
80+
}
81+
82+
// Prevent default behavior for all these keys to avoid unwanted scrolling.
83+
if (["Escape", "ArrowDown", "ArrowUp", "Enter", "Tab"].includes(event.key)) {
84+
event.preventDefault()
85+
}
86+
87+
switch (event.key) {
88+
case "Escape":
89+
closeMenu()
90+
setSelectedSuggestion(undefined)
91+
break
92+
case "ArrowDown":
93+
setSelectedSuggestion((current) => {
94+
console.log(`ArrowDown, current = ${current}`)
95+
if (!current) {
96+
return suggestions[0]?.name
97+
}
98+
const currentIndex = suggestions.findIndex((suggestion) => suggestion.name === current)
99+
console.log(`ArrowDown, currentIndex = ${currentIndex}`)
100+
const nextIndex = Math.min(currentIndex + 1, suggestions.length - 1)
101+
return suggestions[nextIndex]?.name
102+
})
103+
break
104+
case "ArrowUp":
105+
setSelectedSuggestion((current) => {
106+
if (!current) {
107+
return suggestions[suggestions.length - 1]?.name
108+
}
109+
const currentIndex = suggestions.findIndex((suggestion) => suggestion.name === current)
110+
const prevIndex = Math.max(currentIndex - 1, 0)
111+
return suggestions[prevIndex]?.name
112+
})
113+
break
114+
case "Enter":
115+
case "Tab":
116+
if (selectedSuggestion) {
117+
onMentionSelect(selectedSuggestion)
118+
}
119+
break
120+
}
121+
}
122+
123+
const onMentionSelect = (name: string) => {
124+
replaceMention(contentEditableRef.current, name)
125+
const mentionable = suggestions.find((suggestion) => suggestion.name === name)
126+
127+
if (mentionable) {
128+
onMention?.(mentionable)
129+
}
130+
131+
closeMenu()
132+
}
133+
134+
const onClickOutside = useCallback(
135+
(event: MouseEvent) => {
136+
if (!isOpen) {
137+
return
138+
}
139+
140+
const target = event.target as Node
141+
const contentEditable = contentEditableRef.current
142+
const menu = menuRef.current
143+
144+
if (contentEditable && menu && !contentEditable.contains(target) && !menu.contains(target)) {
145+
closeMenu()
146+
}
147+
},
148+
[isOpen, closeMenu],
149+
)
150+
151+
useEffect(() => {
152+
document.addEventListener("mousedown", onClickOutside)
153+
return () => document.removeEventListener("mousedown", onClickOutside)
154+
}, [onClickOutside])
155+
156+
const top = triggerPos ? triggerPos.top : 0
157+
const left = triggerPos ? triggerPos.left : 0
158+
159+
return (
160+
<div className="relative">
161+
<div
162+
ref={combinedRef}
163+
contentEditable
164+
onInput={onInput}
165+
onKeyDown={onKeyDown}
166+
className={cn(
167+
"w-[300px] h-[100px] rounded-xs border border-gray-300 bg-gray-200 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 break-words whitespace-pre-wrap overflow-y-auto",
168+
className,
169+
)}
170+
{...props}
171+
/>
172+
{isOpen && triggerPos && (
173+
<div ref={menuRef} className="absolute w-[200px]" style={{ top: `${top}px`, left: `${left}px` }}>
174+
<Command value={selectedSuggestion} onValueChange={setSelectedSuggestion}>
175+
<CommandList>
176+
<CommandGroup>
177+
{suggestions.length > 0 ? (
178+
suggestions.map((suggestion) => (
179+
<CommandItem
180+
key={suggestion.id}
181+
tabIndex={0}
182+
onSelect={() => onMentionSelect(suggestion.name)}>
183+
{suggestion.name}
184+
</CommandItem>
185+
))
186+
) : (
187+
<CommandEmpty>No suggestions found</CommandEmpty>
188+
)}
189+
</CommandGroup>
190+
</CommandList>
191+
</Command>
192+
</div>
193+
)}
194+
</div>
195+
)
196+
},
197+
)
198+
199+
Mention.displayName = "Mention"
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Calcaulte the pixel coordinates (top, left) for cursor offset relative to the
3+
* contentEditable element.
4+
*/
5+
export const getCursorPos = (contentEditable: HTMLDivElement | null, offset: number) => {
6+
const selection = window.getSelection()
7+
8+
if (!selection || !selection.rangeCount || !contentEditable) {
9+
return null
10+
}
11+
12+
// Find the text node and relative offset that contains our target position.
13+
let currentOffset = 0
14+
let targetNode: Node | null = null
15+
let relativeOffset = offset
16+
17+
const walker = document.createTreeWalker(contentEditable, NodeFilter.SHOW_TEXT)
18+
let node = walker.nextNode()
19+
20+
while (node) {
21+
const nodeLength = node.textContent?.length || 0
22+
23+
if (currentOffset + nodeLength > offset) {
24+
targetNode = node
25+
relativeOffset = offset - currentOffset
26+
break
27+
}
28+
29+
currentOffset += nodeLength
30+
node = walker.nextNode()
31+
}
32+
33+
if (!targetNode) {
34+
return null
35+
}
36+
37+
// Create a temporary range using the found node and offset.
38+
let range = document.createRange()
39+
range.setStart(targetNode, relativeOffset)
40+
range.setEnd(targetNode, relativeOffset + 1)
41+
42+
// Rest of the coordinate calculation remains the same.
43+
const rangeRect = range.getBoundingClientRect()
44+
const editableRect = contentEditable.getBoundingClientRect()
45+
const top = rangeRect.top - editableRect.top + rangeRect.height
46+
const left = rangeRect.left - editableRect.left
47+
return { top, left }
48+
}
49+
50+
/**
51+
* Calculates the absolute text position (character offset) of a cursor within
52+
* a contentEditable div element.
53+
*/
54+
export const getCursorOffset = (container: HTMLDivElement | null, node: Node, offset: number) => {
55+
let total = 0
56+
57+
if (!container) {
58+
return 0
59+
}
60+
61+
// Walk through all nodes until we reach our target.
62+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT)
63+
let currentNode = walker.nextNode()
64+
65+
while (currentNode && currentNode !== node) {
66+
total += currentNode.textContent?.length || 0
67+
currentNode = walker.nextNode()
68+
}
69+
70+
return total + offset
71+
}
72+
73+
/**
74+
* Replaces a typed "@" symbol and any text after it with a properly formatted
75+
* mention (e.g., converting "@jo" into "@john_doe ").
76+
*/
77+
export const replaceMention = (contentEditable: HTMLDivElement | null, mention: string) => {
78+
if (!contentEditable) {
79+
return
80+
}
81+
82+
const selection = window.getSelection()
83+
84+
if (!selection || !selection.rangeCount) {
85+
return
86+
}
87+
88+
const range = selection.getRangeAt(0)
89+
const text = contentEditable.textContent || ""
90+
const lastAtPos = text.lastIndexOf("@", range.endOffset)
91+
92+
if (lastAtPos >= 0) {
93+
const before = text.slice(0, lastAtPos)
94+
const after = text.slice(range.endOffset)
95+
const newContent = `${before}@${mention} ${after}`
96+
97+
contentEditable.textContent = newContent
98+
99+
// Find the correct text node and offset for the new cursor position.
100+
const newCursorPos = lastAtPos + mention.length + 2
101+
let currentOffset = 0
102+
let targetNode: Node | null = null
103+
let relativeOffset = newCursorPos
104+
105+
const walker = document.createTreeWalker(contentEditable, NodeFilter.SHOW_TEXT)
106+
let node = walker.nextNode()
107+
108+
while (node) {
109+
const nodeLength = node.textContent?.length || 0
110+
111+
if (currentOffset + nodeLength > newCursorPos) {
112+
targetNode = node
113+
relativeOffset = newCursorPos - currentOffset
114+
break
115+
}
116+
117+
currentOffset += nodeLength
118+
node = walker.nextNode()
119+
}
120+
121+
if (targetNode) {
122+
const newRange = document.createRange()
123+
newRange.setStart(targetNode, relativeOffset)
124+
newRange.setEnd(targetNode, relativeOffset)
125+
selection.removeAllRanges()
126+
selection.addRange(newRange)
127+
}
128+
}
129+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export type Mentionable = {
2+
id: string
3+
name: string
4+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useState, useRef, useCallback } from "react"
2+
3+
import type { Mentionable } from "./types"
4+
5+
export function useMention(suggestions: Mentionable[]) {
6+
const [isOpen, setIsOpen] = useState(false)
7+
const isOpenRef = useRef(false)
8+
const [triggerPos, setTriggerPos] = useState<{ top: number; left: number } | null>(null)
9+
const [selectedSuggestion, setSelectedSuggestion] = useState<string | undefined>(suggestions[0]?.name || undefined)
10+
11+
const openMenu = useCallback(() => {
12+
setIsOpen(true)
13+
isOpenRef.current = true
14+
}, [])
15+
16+
const closeMenu = useCallback(() => {
17+
setIsOpen(false)
18+
isOpenRef.current = false
19+
setSelectedSuggestion(undefined)
20+
}, [])
21+
22+
return {
23+
triggerPos,
24+
setTriggerPos,
25+
selectedSuggestion,
26+
setSelectedSuggestion,
27+
openMenu,
28+
closeMenu,
29+
isOpen,
30+
isOpenRef,
31+
}
32+
}

0 commit comments

Comments
 (0)