Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions webview-ui/src/components/ui/mention/Mention.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import React, { useRef, useCallback, useEffect } from "react"
import { mergeRefs } from "use-callback-ref"

import { cn } from "@/lib/utils"
import { Command, CommandList, CommandGroup, CommandItem, CommandEmpty } from "@/components/ui"

import type { Mentionable } from "./types"
import { useMention } from "./useMention"
import { getCursorOffset, getCursorPos, replaceMention } from "./contentEditable"

interface MentionProps extends React.ComponentProps<"div"> {
suggestions: Mentionable[]
onMention?: (value: Mentionable) => void
}

export const Mention = React.forwardRef<HTMLDivElement, MentionProps>(
({ suggestions, onMention, className, ...props }, ref) => {
const contentEditableRef = useRef<HTMLDivElement | null>(null)
const combinedRef = mergeRefs([contentEditableRef, ref])
const menuRef = useRef<HTMLDivElement | null>(null)

const {
triggerPos,
setTriggerPos,
selectedSuggestion,
setSelectedSuggestion,
openMenu,
closeMenu,
isOpen,
isOpenRef,
} = useMention(suggestions)

const onInput = useCallback(
(event: React.FormEvent<HTMLDivElement>) => {
const content = event.currentTarget.textContent || ""

if (!content) {
closeMenu()
return
}

const selection = window.getSelection()

if (!selection?.rangeCount) {
return
}

const range = selection.getRangeAt(0)
const div = contentEditableRef.current
const offset = getCursorOffset(div, range.endContainer, range.endOffset)
const text = content.slice(0, offset)
const char = text[offset - 1]

if (char === "@") {
const coords = getCursorPos(div, offset - 1)

if (coords) {
setTriggerPos(coords)
openMenu()
return
}
}

if (isOpenRef.current) {
if (/\s/.test(char) || char === "\n") {
closeMenu()
} else {
// const atIndex = text.lastIndexOf("@")
// const query = text.slice(atIndex + 1)
// handleSearch(query)
}
}
},
[setTriggerPos, openMenu, closeMenu, isOpenRef],
)

const onKeyDown = (event: React.KeyboardEvent) => {
if (!isOpen) {
return
}

// Prevent default behavior for all these keys to avoid unwanted scrolling.
if (["Escape", "ArrowDown", "ArrowUp", "Enter", "Tab"].includes(event.key)) {
event.preventDefault()
}

switch (event.key) {
case "Escape":
closeMenu()
setSelectedSuggestion(undefined)
break
case "ArrowDown":
setSelectedSuggestion((current) => {
console.log(`ArrowDown, current = ${current}`)
if (!current) {
return suggestions[0]?.name
}
const currentIndex = suggestions.findIndex((suggestion) => suggestion.name === current)
console.log(`ArrowDown, currentIndex = ${currentIndex}`)
const nextIndex = Math.min(currentIndex + 1, suggestions.length - 1)
return suggestions[nextIndex]?.name
})
break
case "ArrowUp":
setSelectedSuggestion((current) => {
if (!current) {
return suggestions[suggestions.length - 1]?.name
}
const currentIndex = suggestions.findIndex((suggestion) => suggestion.name === current)
const prevIndex = Math.max(currentIndex - 1, 0)
return suggestions[prevIndex]?.name
})
break
case "Enter":
case "Tab":
if (selectedSuggestion) {
onMentionSelect(selectedSuggestion)
}
break
}
}

const onMentionSelect = (name: string) => {
replaceMention(contentEditableRef.current, name)
const mentionable = suggestions.find((suggestion) => suggestion.name === name)

if (mentionable) {
onMention?.(mentionable)
}

closeMenu()
}

const onClickOutside = useCallback(
(event: MouseEvent) => {
if (!isOpen) {
return
}

const target = event.target as Node
const contentEditable = contentEditableRef.current
const menu = menuRef.current

if (contentEditable && menu && !contentEditable.contains(target) && !menu.contains(target)) {
closeMenu()
}
},
[isOpen, closeMenu],
)

useEffect(() => {
document.addEventListener("mousedown", onClickOutside)
return () => document.removeEventListener("mousedown", onClickOutside)
}, [onClickOutside])

const top = triggerPos ? triggerPos.top : 0
const left = triggerPos ? triggerPos.left : 0

return (
<div className="relative">
<div
ref={combinedRef}
contentEditable
onInput={onInput}
onKeyDown={onKeyDown}
className={cn(
"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",
className,
)}
{...props}
/>
{isOpen && triggerPos && (
<div ref={menuRef} className="absolute w-[200px]" style={{ top: `${top}px`, left: `${left}px` }}>
<Command value={selectedSuggestion} onValueChange={setSelectedSuggestion}>
<CommandList>
<CommandGroup>
{suggestions.length > 0 ? (
suggestions.map((suggestion) => (
<CommandItem
key={suggestion.id}
tabIndex={0}
onSelect={() => onMentionSelect(suggestion.name)}>
{suggestion.name}
</CommandItem>
))
) : (
<CommandEmpty>No suggestions found</CommandEmpty>
)}
</CommandGroup>
</CommandList>
</Command>
</div>
)}
</div>
)
},
)

Mention.displayName = "Mention"
129 changes: 129 additions & 0 deletions webview-ui/src/components/ui/mention/contentEditable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* Calcaulte the pixel coordinates (top, left) for cursor offset relative to the
* contentEditable element.
*/
export const getCursorPos = (contentEditable: HTMLDivElement | null, offset: number) => {
const selection = window.getSelection()

if (!selection || !selection.rangeCount || !contentEditable) {
return null
}

// Find the text node and relative offset that contains our target position.
let currentOffset = 0
let targetNode: Node | null = null
let relativeOffset = offset

const walker = document.createTreeWalker(contentEditable, NodeFilter.SHOW_TEXT)
let node = walker.nextNode()

while (node) {
const nodeLength = node.textContent?.length || 0

if (currentOffset + nodeLength > offset) {
targetNode = node
relativeOffset = offset - currentOffset
break
}

currentOffset += nodeLength
node = walker.nextNode()
}

if (!targetNode) {
return null
}

// Create a temporary range using the found node and offset.
let range = document.createRange()
range.setStart(targetNode, relativeOffset)
range.setEnd(targetNode, relativeOffset + 1)

// Rest of the coordinate calculation remains the same.
const rangeRect = range.getBoundingClientRect()
const editableRect = contentEditable.getBoundingClientRect()
const top = rangeRect.top - editableRect.top + rangeRect.height
const left = rangeRect.left - editableRect.left
return { top, left }
}

/**
* Calculates the absolute text position (character offset) of a cursor within
* a contentEditable div element.
*/
export const getCursorOffset = (container: HTMLDivElement | null, node: Node, offset: number) => {
let total = 0

if (!container) {
return 0
}

// Walk through all nodes until we reach our target.
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT)
let currentNode = walker.nextNode()

while (currentNode && currentNode !== node) {
total += currentNode.textContent?.length || 0
currentNode = walker.nextNode()
}

return total + offset
}

/**
* Replaces a typed "@" symbol and any text after it with a properly formatted
* mention (e.g., converting "@jo" into "@john_doe ").
*/
export const replaceMention = (contentEditable: HTMLDivElement | null, mention: string) => {
if (!contentEditable) {
return
}

const selection = window.getSelection()

if (!selection || !selection.rangeCount) {
return
}

const range = selection.getRangeAt(0)
const text = contentEditable.textContent || ""
const lastAtPos = text.lastIndexOf("@", range.endOffset)

if (lastAtPos >= 0) {
const before = text.slice(0, lastAtPos)
const after = text.slice(range.endOffset)
const newContent = `${before}@${mention} ${after}`

contentEditable.textContent = newContent

// Find the correct text node and offset for the new cursor position.
const newCursorPos = lastAtPos + mention.length + 2
let currentOffset = 0
let targetNode: Node | null = null
let relativeOffset = newCursorPos

const walker = document.createTreeWalker(contentEditable, NodeFilter.SHOW_TEXT)
let node = walker.nextNode()

while (node) {
const nodeLength = node.textContent?.length || 0

if (currentOffset + nodeLength > newCursorPos) {
targetNode = node
relativeOffset = newCursorPos - currentOffset
break
}

currentOffset += nodeLength
node = walker.nextNode()
}

if (targetNode) {
const newRange = document.createRange()
newRange.setStart(targetNode, relativeOffset)
newRange.setEnd(targetNode, relativeOffset)
selection.removeAllRanges()
selection.addRange(newRange)
}
}
}
4 changes: 4 additions & 0 deletions webview-ui/src/components/ui/mention/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type Mentionable = {
id: string
name: string
}
32 changes: 32 additions & 0 deletions webview-ui/src/components/ui/mention/useMention.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useState, useRef, useCallback } from "react"

import type { Mentionable } from "./types"

export function useMention(suggestions: Mentionable[]) {
const [isOpen, setIsOpen] = useState(false)
const isOpenRef = useRef(false)
const [triggerPos, setTriggerPos] = useState<{ top: number; left: number } | null>(null)
const [selectedSuggestion, setSelectedSuggestion] = useState<string | undefined>(suggestions[0]?.name || undefined)

const openMenu = useCallback(() => {
setIsOpen(true)
isOpenRef.current = true
}, [])

const closeMenu = useCallback(() => {
setIsOpen(false)
isOpenRef.current = false
setSelectedSuggestion(undefined)
}, [])

return {
triggerPos,
setTriggerPos,
selectedSuggestion,
setSelectedSuggestion,
openMenu,
closeMenu,
isOpen,
isOpenRef,
}
}
Loading
Loading