Get context mentions UI working

This commit is contained in:
Saoud Rizwan
2024-09-16 17:07:31 -04:00
parent a78c96286e
commit 82490108c5
4 changed files with 495 additions and 11 deletions

View File

@@ -1,7 +1,9 @@
import React, { forwardRef, useState, useCallback, useEffect } from "react" import React, { forwardRef, useCallback, useEffect, useRef, useState, useLayoutEffect } from "react"
import DynamicTextArea from "react-textarea-autosize" import DynamicTextArea from "react-textarea-autosize"
import Thumbnails from "./Thumbnails" import { insertMention, shouldShowContextMenu, getContextMenuOptions, removeMention } from "../utils/mention-context"
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView" import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import ContextMenu from "./ContextMenu"
import Thumbnails from "./Thumbnails"
interface ChatTextAreaProps { interface ChatTextAreaProps {
inputValue: string inputValue: string
@@ -35,18 +37,173 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const [thumbnailsHeight, setThumbnailsHeight] = useState(0) const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined) const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
const [showContextMenu, setShowContextMenu] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
const [searchQuery, setSearchQuery] = useState("")
const containerRef = useRef<HTMLDivElement>(null)
const textAreaRef = useRef<HTMLTextAreaElement | null>(null)
const [isMouseDownOnMenu, setIsMouseDownOnMenu] = useState(false)
const highlightLayerRef = useRef<HTMLDivElement>(null)
const [selectedMenuIndex, setSelectedMenuIndex] = useState(-1)
const [selectedType, setSelectedType] = useState<string | null>(null)
const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false)
const [intendedCursorPosition, setIntendedCursorPosition] = useState<number | null>(null)
const handleMentionSelect = useCallback(
(type: string, value: string) => {
if (value === "File" || value === "Folder") {
setSelectedType(type.toLowerCase())
setSearchQuery("")
setSelectedMenuIndex(0)
return
}
setShowContextMenu(false)
setSelectedType(null)
if (textAreaRef.current) {
let insertValue = value
if (type === "url") {
// For URLs, we insert the value as is
insertValue = value
} else if (type === "file" || type === "folder") {
// For files and folders, we insert the path
insertValue = value
}
const newValue = insertMention(textAreaRef.current.value, cursorPosition, insertValue)
setInputValue(newValue)
const newCursorPosition = newValue.indexOf(" ", newValue.lastIndexOf("@")) + 1
setCursorPosition(newCursorPosition)
setIntendedCursorPosition(newCursorPosition) // Update intended cursor position
textAreaRef.current.focus()
// Remove the direct setSelectionRange call
// textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition)
}
},
[setInputValue, cursorPosition]
)
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => { (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showContextMenu) {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault()
setSelectedMenuIndex((prevIndex) => {
const direction = event.key === "ArrowUp" ? -1 : 1
let newIndex = prevIndex + direction
const options = getContextMenuOptions(searchQuery, selectedType)
const optionsLength = options.length
if (newIndex < 0) newIndex = optionsLength - 1
if (newIndex >= optionsLength) newIndex = 0
while (options[newIndex]?.type === "url") {
newIndex = (newIndex + direction + optionsLength) % optionsLength
}
return newIndex
})
return
}
if (event.key === "Enter" && selectedMenuIndex !== -1) {
event.preventDefault()
const selectedOption = getContextMenuOptions(searchQuery, selectedType)[selectedMenuIndex]
if (selectedOption && selectedOption.type !== "url") {
handleMentionSelect(selectedOption.type, selectedOption.value)
}
return
}
}
const isComposing = event.nativeEvent?.isComposing ?? false const isComposing = event.nativeEvent?.isComposing ?? false
if (event.key === "Enter" && !event.shiftKey && !isComposing) { if (event.key === "Enter" && !event.shiftKey && !isComposing) {
event.preventDefault() event.preventDefault()
onSend() onSend()
} }
if (event.key === "Backspace" && !isComposing) {
const charBeforeCursor = inputValue[cursorPosition - 1]
const charAfterCursor = inputValue[cursorPosition + 1]
const charBeforeIsWhitespace =
charBeforeCursor === " " || charBeforeCursor === "\n" || charBeforeCursor === "\r\n"
const charAfterIsWhitespace =
charAfterCursor === " " || charAfterCursor === "\n" || charAfterCursor === "\r\n"
if (
charBeforeIsWhitespace &&
inputValue.slice(0, cursorPosition - 1).match(/@(\/|\w+:\/\/)[^\s]+$/)
) {
const newCursorPosition = cursorPosition - 1
if (!charAfterIsWhitespace) {
event.preventDefault()
textAreaRef.current?.setSelectionRange(newCursorPosition, newCursorPosition)
setCursorPosition(newCursorPosition)
}
setCursorPosition(newCursorPosition)
setJustDeletedSpaceAfterMention(true)
} else if (justDeletedSpaceAfterMention) {
const { newText, newPosition } = removeMention(inputValue, cursorPosition)
if (newText !== inputValue) {
event.preventDefault()
setInputValue(newText)
setIntendedCursorPosition(newPosition) // Store the new cursor position in state
}
setJustDeletedSpaceAfterMention(false)
setShowContextMenu(false)
} else {
setJustDeletedSpaceAfterMention(false)
}
}
}, },
[onSend] [
onSend,
showContextMenu,
searchQuery,
selectedMenuIndex,
handleMentionSelect,
selectedType,
inputValue,
cursorPosition,
setInputValue,
justDeletedSpaceAfterMention,
]
) )
useLayoutEffect(() => {
if (intendedCursorPosition !== null && textAreaRef.current) {
textAreaRef.current.setSelectionRange(intendedCursorPosition, intendedCursorPosition)
setIntendedCursorPosition(null) // Reset the state
}
}, [inputValue, intendedCursorPosition])
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const newCursorPosition = e.target.selectionStart
setInputValue(newValue)
setCursorPosition(newCursorPosition)
const showMenu = shouldShowContextMenu(newValue, newCursorPosition)
setShowContextMenu(showMenu)
if (showMenu) {
const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
setSearchQuery(newValue.slice(lastAtIndex + 1, newCursorPosition))
setSelectedMenuIndex(2) // Set to "File" option by default
} else {
setSearchQuery("")
setSelectedMenuIndex(-1)
}
},
[setInputValue]
)
const handleBlur = useCallback(() => {
// Only hide the context menu if the user didn't click on it
if (!isMouseDownOnMenu) {
setShowContextMenu(false)
}
setIsTextAreaFocused(false)
}, [isMouseDownOnMenu])
const handlePaste = useCallback( const handlePaste = useCallback(
async (e: React.ClipboardEvent) => { async (e: React.ClipboardEvent) => {
const items = e.clipboardData.items const items = e.clipboardData.items
@@ -100,14 +257,64 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
} }
}, [selectedImages]) }, [selectedImages])
const handleMenuMouseDown = useCallback(() => {
setIsMouseDownOnMenu(true)
}, [])
const updateHighlights = useCallback(() => {
if (!textAreaRef.current || !highlightLayerRef.current) return
const text = textAreaRef.current.value
const mentionRegex = /@(\/|\w+:\/\/)[^\s]+/g
highlightLayerRef.current.innerHTML = text
.replace(/\n$/, "\n\n")
.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" }[c] || c))
.replace(mentionRegex, '<mark class="mention-context-highlight">$&</mark>')
highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
}, [])
useLayoutEffect(() => {
updateHighlights()
}, [inputValue, updateHighlights])
const updateCursorPosition = useCallback(() => {
if (textAreaRef.current) {
setCursorPosition(textAreaRef.current.selectionStart)
}
}, [])
const handleKeyUp = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) {
updateCursorPosition()
}
},
[updateCursorPosition]
)
return ( return (
<div <div
ref={containerRef}
style={{ style={{
padding: "10px 15px", padding: "10px 15px",
opacity: textAreaDisabled ? 0.5 : 1, opacity: textAreaDisabled ? 0.5 : 1,
position: "relative", position: "relative",
display: "flex", display: "flex",
}}> }}>
{showContextMenu && (
<ContextMenu
containerWidth={containerRef.current?.clientWidth || 0}
onSelect={handleMentionSelect}
searchQuery={searchQuery}
onMouseDown={handleMenuMouseDown}
selectedIndex={selectedMenuIndex}
setSelectedIndex={setSelectedMenuIndex}
selectedType={selectedType}
/>
)}
{!isTextAreaFocused && ( {!isTextAreaFocused && (
<div <div
style={{ style={{
@@ -116,18 +323,58 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
border: "1px solid var(--vscode-input-border)", border: "1px solid var(--vscode-input-border)",
borderRadius: 2, borderRadius: 2,
pointerEvents: "none", pointerEvents: "none",
zIndex: 5,
}} }}
/> />
)} )}
<div
ref={highlightLayerRef}
style={{
position: "absolute",
top: 10,
left: 15,
right: 15,
bottom: 10,
pointerEvents: "none",
whiteSpace: "pre-wrap",
wordWrap: "break-word",
color: "transparent",
overflow: "hidden",
backgroundColor: "var(--vscode-input-background)",
fontFamily: "var(--vscode-font-family)",
fontSize: "var(--vscode-editor-font-size)",
lineHeight: "var(--vscode-editor-line-height)",
borderRadius: 2,
borderLeft: 0,
borderRight: 0,
borderTop: 0,
borderColor: "transparent",
borderBottom: `${thumbnailsHeight + 6}px solid transparent`,
padding: "9px 49px 3px 9px",
}}
/>
<DynamicTextArea <DynamicTextArea
ref={ref} ref={(el) => {
if (typeof ref === "function") {
ref(el)
} else if (ref) {
ref.current = el
}
textAreaRef.current = el
}}
value={inputValue} value={inputValue}
disabled={textAreaDisabled} disabled={textAreaDisabled}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => {
handleInputChange(e)
updateHighlights()
}}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onFocus={() => setIsTextAreaFocused(true)} onFocus={() => setIsTextAreaFocused(true)}
onBlur={() => setIsTextAreaFocused(false)} onBlur={handleBlur}
onPaste={handlePaste} onPaste={handlePaste}
onSelect={updateCursorPosition}
onMouseUp={updateCursorPosition}
onHeightChange={(height) => { onHeightChange={(height) => {
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) { if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
setTextAreaBaseHeight(height) setTextAreaBaseHeight(height)
@@ -140,7 +387,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
style={{ style={{
width: "100%", width: "100%",
boxSizing: "border-box", boxSizing: "border-box",
backgroundColor: "var(--vscode-input-background)", backgroundColor: "transparent",
color: "var(--vscode-input-foreground)", color: "var(--vscode-input-foreground)",
//border: "1px solid var(--vscode-input-border)", //border: "1px solid var(--vscode-input-border)",
borderRadius: 2, borderRadius: 2,
@@ -148,19 +395,26 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
fontSize: "var(--vscode-editor-font-size)", fontSize: "var(--vscode-editor-font-size)",
lineHeight: "var(--vscode-editor-line-height)", lineHeight: "var(--vscode-editor-line-height)",
resize: "none", resize: "none",
overflow: "hidden", overflowX: "hidden",
overflowY: "scroll",
scrollbarWidth: "none",
// Since we have maxRows, when text is long enough it starts to overflow the bottom padding, appearing behind the thumbnails. To fix this, we use a transparent border to push the text up instead. (https://stackoverflow.com/questions/42631947/maintaining-a-padding-inside-of-text-area/52538410#52538410) // Since we have maxRows, when text is long enough it starts to overflow the bottom padding, appearing behind the thumbnails. To fix this, we use a transparent border to push the text up instead. (https://stackoverflow.com/questions/42631947/maintaining-a-padding-inside-of-text-area/52538410#52538410)
borderTop: "9px solid transparent", // borderTop: "9px solid transparent",
borderBottom: `${thumbnailsHeight + 9}px solid transparent`, borderLeft: 0,
borderRight: 0,
borderTop: 0,
borderBottom: `${thumbnailsHeight + 6}px solid transparent`,
borderColor: "transparent", borderColor: "transparent",
// borderRight: "54px solid transparent", // borderRight: "54px solid transparent",
// borderLeft: "9px solid transparent", // NOTE: react-textarea-autosize doesn't calculate correct height when using borderLeft/borderRight so we need to use horizontal padding instead // borderLeft: "9px solid transparent", // NOTE: react-textarea-autosize doesn't calculate correct height when using borderLeft/borderRight so we need to use horizontal padding instead
// Instead of using boxShadow, we use a div with a border to better replicate the behavior when the textarea is focused // Instead of using boxShadow, we use a div with a border to better replicate the behavior when the textarea is focused
// boxShadow: "0px 0px 0px 1px var(--vscode-input-border)", // boxShadow: "0px 0px 0px 1px var(--vscode-input-border)",
padding: "0 49px 0 9px", padding: "9px 49px 3px 9px",
cursor: textAreaDisabled ? "not-allowed" : undefined, cursor: textAreaDisabled ? "not-allowed" : undefined,
flex: 1, flex: 1,
zIndex: 1,
}} }}
onScroll={() => updateHighlights()}
/> />
{selectedImages.length > 0 && ( {selectedImages.length > 0 && (
<Thumbnails <Thumbnails
@@ -173,6 +427,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
bottom: 14, bottom: 14,
left: 22, left: 22,
right: 67, // (54 + 9) + 4 extra padding right: 67, // (54 + 9) + 4 extra padding
zIndex: 2,
}} }}
/> />
)} )}
@@ -184,6 +439,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
alignItems: "flex-center", alignItems: "flex-center",
height: textAreaBaseHeight || 31, height: textAreaBaseHeight || 31,
bottom: 9, // should be 10 but doesnt look good on mac bottom: 9, // should be 10 but doesnt look good on mac
zIndex: 2,
}}> }}>
<div style={{ display: "flex", flexDirection: "row", alignItems: "center" }}> <div style={{ display: "flex", flexDirection: "row", alignItems: "center" }}>
<div <div

View File

@@ -0,0 +1,94 @@
import React, { useEffect, useState } from "react"
import { getContextMenuOptions } from "../utils/mention-context"
interface ContextMenuProps {
containerWidth: number
onSelect: (type: string, value: string) => void
searchQuery: string
onMouseDown: () => void
selectedIndex: number
setSelectedIndex: (index: number) => void
selectedType: string | null
}
const ContextMenu: React.FC<ContextMenuProps> = ({
containerWidth,
onSelect,
searchQuery,
onMouseDown,
selectedIndex,
setSelectedIndex,
selectedType,
}) => {
const [filteredOptions, setFilteredOptions] = useState(getContextMenuOptions(searchQuery, selectedType))
useEffect(() => {
setFilteredOptions(getContextMenuOptions(searchQuery, selectedType))
}, [searchQuery, selectedType])
return (
<div
style={{
position: "absolute",
bottom: "calc(100% - 10px)",
left: 15,
right: 15,
}}
onMouseDown={onMouseDown}>
<div
style={{
backgroundColor: "var(--vscode-dropdown-background)",
border: "1px solid var(--vscode-dropdown-border)",
borderRadius: "3px",
zIndex: 1000,
display: "flex",
flexDirection: "column",
boxShadow: "0 8px 16px rgba(0,0,0,0.24)",
maxHeight: "200px",
overflowY: "auto",
}}>
{filteredOptions.map((option, index) => (
<div
key={option.value}
onClick={() => option.type !== "url" && onSelect(option.type, option.value)}
style={{
padding: "8px 12px",
cursor: option.type !== "url" ? "pointer" : "default",
color: "var(--vscode-dropdown-foreground)",
borderBottom: "1px solid var(--vscode-dropdown-border)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
backgroundColor:
index === selectedIndex && option.type !== "url"
? "var(--vscode-list-activeSelectionBackground)"
: "",
// opacity: option.type === "url" ? 0.5 : 1, // Make URL option appear disabled
}}
onMouseEnter={() => option.type !== "url" && setSelectedIndex(index)}>
<div style={{ display: "flex", alignItems: "center" }}>
<i className={`codicon codicon-${option.icon}`} style={{ marginRight: "8px" }} />
{option.value === "File"
? "Add file"
: option.value === "Folder"
? "Add folder"
: option.value === "URL"
? "Paste URL to scrape"
: option.value}
</div>
{(option.value === "File" || option.value === "Folder") && (
<i className="codicon codicon-chevron-right" style={{ fontSize: "14px" }} />
)}
{(option.type === "file" || option.type === "folder") &&
option.value !== "File" &&
option.value !== "Folder" && (
<i className="codicon codicon-add" style={{ fontSize: "14px" }} />
)}
</div>
))}
</div>
</div>
)
}
export default ContextMenu

View File

@@ -142,3 +142,15 @@ vscode-dropdown::part(listbox) {
.input-icon-button.disabled:hover { .input-icon-button.disabled:hover {
opacity: 0.4; opacity: 0.4;
} }
.mention-context-highlight {
background-color: var(--vscode-textLink-activeForeground, #3794ff);
opacity: 0.3;
border-radius: 2px;
box-shadow: 0 0 0 0.5px var(--vscode-textLink-activeForeground, #3794ff);
color: transparent;
padding: 0.5px;
margin: -0.5px;
position: relative;
bottom: -0.5px;
}

View File

@@ -0,0 +1,122 @@
export const mockPaths = [
{ type: "file", path: "/src/components/Header.tsx" },
{ type: "file", path: "/src/components/Footer.tsx" },
{ type: "file", path: "/src/utils/helpers.ts" },
{ type: "folder", path: "/src/components" },
{ type: "folder", path: "/src/utils" },
{ type: "folder", path: "/public/images" },
{ type: "file", path: "/public/index.html" },
{ type: "file", path: "/package.json" },
{ type: "folder", path: "/node_modules" },
{ type: "file", path: "/README.md" },
]
export function insertMention(text: string, position: number, value: string): string {
const beforeCursor = text.slice(0, position)
const afterCursor = text.slice(position)
// Find the position of the last '@' symbol before the cursor
const lastAtIndex = beforeCursor.lastIndexOf("@")
if (lastAtIndex !== -1) {
// If there's an '@' symbol, replace everything after it with the new mention
const beforeMention = text.slice(0, lastAtIndex)
return beforeMention + "@" + value + " " + afterCursor.replace(/^[^\s]*/, "")
} else {
// If there's no '@' symbol, insert the mention at the cursor position
return beforeCursor + "@" + value + " " + afterCursor
}
}
export function removeMention(text: string, position: number): { newText: string; newPosition: number } {
const mentionRegex = /@(\/|\w+:\/\/)[^\s]+/
const beforeCursor = text.slice(0, position)
const afterCursor = text.slice(position)
// Check if we're at the end of a mention
const matchEnd = beforeCursor.match(new RegExp(mentionRegex.source + "$"))
if (matchEnd) {
// If we're at the end of a mention, remove it
const newText = text.slice(0, position - matchEnd[0].length) + afterCursor.replace(" ", "") // removes the first space after the mention
const newPosition = position - matchEnd[0].length
return { newText, newPosition }
}
// If we're not at the end of a mention, just return the original text and position
return { newText: text, newPosition: position }
}
export function searchPaths(query: string): { type: string; path: string }[] {
const lowerQuery = query.toLowerCase()
return mockPaths.filter(
(item) => item.path.toLowerCase().includes(lowerQuery) || item.type.toLowerCase().includes(lowerQuery)
)
}
export function getContextMenuOptions(
query: string,
selectedType: string | null = null
): { type: string; value: string; icon: string }[] {
if (selectedType === "file") {
return mockPaths
.filter((item) => item.type === "file")
.map((item) => ({ type: "file", value: item.path, icon: "file" }))
}
if (selectedType === "folder") {
return mockPaths
.filter((item) => item.type === "folder")
.map((item) => ({ type: "folder", value: item.path, icon: "folder" }))
}
if (query === "") {
return [
{ type: "url", value: "URL", icon: "link" },
{ type: "folder", value: "Folder", icon: "folder" },
{ type: "file", value: "File", icon: "file" },
]
}
const lowerQuery = query.toLowerCase()
if (query.startsWith("http")) {
// URLs
return [{ type: "url", value: query, icon: "link" }]
} else {
// Search for files and folders
const matchingPaths = mockPaths.filter(
(item) => item.path.toLowerCase().includes(lowerQuery) || item.type.toLowerCase().includes(lowerQuery)
)
if (matchingPaths.length > 0) {
return matchingPaths.map((item) => ({
type: item.type,
value: item.path,
icon: item.type === "file" ? "file" : "folder",
}))
} else {
// If no matches, show all options
return [
{ type: "url", value: "URL", icon: "link" },
{ type: "folder", value: "Folder", icon: "folder" },
{ type: "file", value: "File", icon: "file" },
]
}
}
}
export function shouldShowContextMenu(text: string, position: number): boolean {
const beforeCursor = text.slice(0, position)
const atIndex = beforeCursor.lastIndexOf("@")
if (atIndex === -1) return false
const textAfterAt = beforeCursor.slice(atIndex + 1)
// Check if there's any whitespace after the '@'
if (/\s/.test(textAfterAt)) return false
// Show the menu if there's just '@' or '@' followed by some text
return true
}