Skip to content

Commit ca9fa9b

Browse files
committed
fix: fix cursor position bug
1 parent 68dd56f commit ca9fa9b

File tree

12 files changed

+269
-180
lines changed

12 files changed

+269
-180
lines changed

src/webview/components/chat/editor/chat-editor.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { forwardRef, useImperativeHandle } from 'react'
1+
import { forwardRef, useCallback, useImperativeHandle } from 'react'
22
import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'
33
import {
44
LexicalComposer,
@@ -18,7 +18,12 @@ import {
1818
type MentionPluginProps
1919
} from '@webview/lexical/plugins/mention-plugin'
2020
import { cn } from '@webview/utils/common'
21-
import type { EditorState, LexicalEditor } from 'lexical'
21+
import {
22+
$getSelection,
23+
$isRangeSelection,
24+
type EditorState,
25+
type LexicalEditor
26+
} from 'lexical'
2227

2328
const onError = (error: unknown) => {
2429
// eslint-disable-next-line no-console
@@ -46,6 +51,7 @@ export interface ChatEditorProps
4651

4752
export interface ChatEditorRef {
4853
editor: LexicalEditor
54+
insertSpaceAndAt: () => void
4955
}
5056

5157
export const ChatEditor = forwardRef<ChatEditorRef, ChatEditorProps>(
@@ -103,7 +109,17 @@ const ChatEditorInner = forwardRef<ChatEditorRef, ChatEditorProps>(
103109
) => {
104110
const [editor] = useLexicalComposerContext()
105111

106-
useImperativeHandle(ref, () => ({ editor }), [editor])
112+
const insertSpaceAndAt = useCallback(() => {
113+
editor.focus()
114+
editor.update(() => {
115+
const selection = $getSelection()
116+
if ($isRangeSelection(selection)) {
117+
selection.insertText(' @')
118+
}
119+
})
120+
}, [editor])
121+
122+
useImperativeHandle(ref, () => ({ editor, insertSpaceAndAt }), [editor])
107123

108124
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
109125
if (e.key === 'Enter' && e.ctrlKey) {

src/webview/components/chat/editor/chat-input.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({
6565
setContext={setContext}
6666
conversation={conversation}
6767
setConversation={setConversation}
68+
onClickMentionSelector={() => {
69+
editorRef.current?.insertSpaceAndAt()
70+
}}
6871
onClose={focusOnEditor}
6972
/>
7073
<Button

src/webview/components/chat/selectors/context-selector.tsx

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,13 @@
22
import React, { useState } from 'react'
33
import { ImageIcon } from '@radix-ui/react-icons'
44
import { Button } from '@webview/components/ui/button'
5-
import { createMentionOptions } from '@webview/lexical/mentions'
65
import type {
76
ChatContext,
87
Conversation,
98
ModelOption
109
} from '@webview/types/chat'
1110
import type { Updater } from 'use-immer'
1211

13-
import {
14-
MentionSelector,
15-
type SelectedMentionStrategy
16-
} from './mention-selector'
1712
import { ModelSelector } from './model-selector'
1813

1914
interface ContextSelectorProps {
@@ -22,14 +17,16 @@ interface ContextSelectorProps {
2217
conversation: Conversation
2318
setConversation: Updater<Conversation>
2419
onClose?: () => void
20+
onClickMentionSelector?: () => void
2521
}
2622

2723
export const ContextSelector: React.FC<ContextSelectorProps> = ({
2824
context,
2925
setContext,
3026
conversation,
3127
setConversation,
32-
onClose
28+
onClose,
29+
onClickMentionSelector
3330
}) => {
3431
const [modelOptions] = useState<ModelOption[]>([
3532
{ value: 'gpt-4', label: 'GPT-4' },
@@ -45,11 +42,6 @@ export const ContextSelector: React.FC<ContextSelectorProps> = ({
4542
})
4643
}
4744

48-
const handleSelectMention = (option: SelectedMentionStrategy) => {
49-
// Handle mention selection
50-
console.log('Selected mention:', option)
51-
}
52-
5345
const handleSelectImage = () => {
5446
const input = document.createElement('input')
5547
input.type = 'file'
@@ -79,15 +71,9 @@ export const ContextSelector: React.FC<ContextSelectorProps> = ({
7971
{selectedModel?.label}
8072
</Button>
8173
</ModelSelector>
82-
<MentionSelector
83-
mentionOptions={createMentionOptions()}
84-
onSelect={handleSelectMention}
85-
onOpenChange={isOpen => !isOpen && onClose?.()}
86-
>
87-
<Button variant="ghost" size="xs">
88-
@ Mention
89-
</Button>
90-
</MentionSelector>
74+
<Button variant="ghost" size="xs" onClick={onClickMentionSelector}>
75+
@ Mention
76+
</Button>
9177
<Button variant="ghost" size="xs" onClick={handleSelectImage}>
9278
<ImageIcon className="h-3 w-3 mr-1" />
9379
Image

src/webview/components/chat/selectors/mention-selector.tsx

Lines changed: 69 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { useCallback, useEffect, useRef, type FC, type ReactNode } from 'react'
1+
import React, { useEffect, useMemo, useRef, useState } from 'react'
22
import {
33
Command,
44
CommandEmpty,
55
CommandGroup,
6-
CommandInput,
76
CommandItem,
87
CommandList
98
} from '@webview/components/ui/command'
@@ -12,157 +11,120 @@ import {
1211
PopoverContent,
1312
PopoverTrigger
1413
} from '@webview/components/ui/popover'
15-
import { useCallbackRef } from '@webview/hooks/use-callback-ref'
1614
import { useControllableState } from '@webview/hooks/use-controllable-state'
17-
import type { IMentionStrategy, MentionOption } from '@webview/types/chat'
15+
import { useKeyboardNavigation } from '@webview/hooks/use-keyboard-navigation'
16+
import { IMentionStrategy, MentionOption } from '@webview/types/chat'
17+
import { cn } from '@webview/utils/common'
18+
import { useEvent } from 'react-use'
1819

1920
export interface SelectedMentionStrategy {
2021
strategy: IMentionStrategy
2122
strategyAddData: any
2223
}
2324

2425
interface MentionSelectorProps {
26+
searchQuery?: string
2527
mentionOptions: MentionOption[]
2628
onSelect: (option: SelectedMentionStrategy) => void
2729
open?: boolean
2830
onOpenChange?: (open: boolean) => void
29-
lexicalMode?: boolean
30-
searchQuery?: string
31-
onSearchQueryChange?: (searchQuery: string) => void
32-
children: ReactNode
31+
onCloseWithoutSelect?: () => void
32+
children: React.ReactNode
3333
}
3434

35-
export const MentionSelector: FC<MentionSelectorProps> = ({
35+
export const MentionSelector: React.FC<MentionSelectorProps> = ({
36+
searchQuery = '',
3637
mentionOptions,
3738
onSelect,
3839
open,
3940
onOpenChange,
40-
lexicalMode,
41-
searchQuery,
42-
onSearchQueryChange,
41+
onCloseWithoutSelect,
4342
children
4443
}) => {
4544
const commandRef = useRef<HTMLDivElement>(null)
45+
const [currentOptions, setCurrentOptions] =
46+
useState<MentionOption[]>(mentionOptions)
4647

4748
const [isOpen = false, setIsOpen] = useControllableState({
4849
prop: open,
4950
defaultProp: false,
5051
onChange: onOpenChange
5152
})
5253

53-
const [internalSearchQuery, setInternalSearchQuery] = useControllableState({
54-
prop: searchQuery,
55-
defaultProp: '',
56-
onChange: onSearchQueryChange
57-
})
58-
5954
useEffect(() => {
60-
if (!lexicalMode || mentionOptions.length > 0) return
61-
62-
setIsOpen(false)
63-
}, [mentionOptions.length, setIsOpen, lexicalMode])
64-
65-
const handleSelect = useCallback(
66-
(currentValue: string) => {
67-
const selectedOption = mentionOptions.find(
68-
option => option.category === currentValue
69-
)
70-
if (selectedOption) {
71-
onSelect({
72-
strategy: selectedOption.mentionStrategies[0]!,
73-
strategyAddData: { label: selectedOption.label }
74-
})
75-
}
76-
setIsOpen(false)
77-
},
78-
[mentionOptions, onSelect]
79-
)
80-
81-
const filterMentions = useCallback(
82-
(value: string, search: string) => {
83-
const option = mentionOptions.find(opt => opt.category === value)
84-
if (!option) return 0
85-
86-
const label = option.label.toLowerCase()
87-
search = search.toLowerCase()
88-
89-
if (label === search) return 1
90-
if (label.startsWith(search)) return 0.8
91-
if (label.includes(search)) return 0.6
92-
93-
// Calculate a basic fuzzy match score
94-
let score = 0
95-
let searchIndex = 0
96-
for (let i = 0; i < label.length && searchIndex < search.length; i++) {
97-
if (label[i] === search[searchIndex]) {
98-
score += 1
99-
searchIndex++
100-
}
101-
}
102-
return score / Math.max(label.length, search.length)
103-
},
104-
[mentionOptions]
105-
)
106-
107-
const handleKeyDown = useCallbackRef((event: KeyboardEvent) => {
108-
if (!lexicalMode || !isOpen) return
55+
if (!isOpen) {
56+
setCurrentOptions(mentionOptions)
57+
}
58+
}, [isOpen, mentionOptions])
10959

110-
const commandEl = commandRef.current
111-
if (!commandEl) return
60+
const filteredOptions = useMemo(() => {
61+
if (!searchQuery) return currentOptions
62+
return currentOptions.filter(option =>
63+
option.label.toLowerCase().includes(searchQuery.toLowerCase())
64+
)
65+
}, [currentOptions, searchQuery])
11266

113-
if (['ArrowUp', 'ArrowDown', 'Enter'].includes(event.key)) {
114-
event.preventDefault()
67+
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
68+
const { focusedIndex, setFocusedIndex, handleKeyDown } =
69+
useKeyboardNavigation({
70+
itemCount: filteredOptions.length,
71+
itemRefs,
72+
onEnter: el => el?.click()
73+
})
11574

116-
const syntheticEvent = new KeyboardEvent(event.type, {
117-
key: event.key,
118-
code: event.code,
119-
isComposing: event.isComposing,
120-
location: event.location,
121-
repeat: event.repeat,
122-
bubbles: true
123-
})
124-
commandEl.dispatchEvent(syntheticEvent)
125-
}
126-
})
75+
useEvent('keydown', handleKeyDown)
12776

12877
useEffect(() => {
129-
if (!lexicalMode) return
130-
131-
document.addEventListener('keydown', handleKeyDown)
132-
return () => {
133-
document.removeEventListener('keydown', handleKeyDown)
78+
setFocusedIndex(0)
79+
}, [filteredOptions])
80+
81+
const handleSelect = (option: MentionOption) => {
82+
if (option.children) {
83+
setCurrentOptions(option.children)
84+
onCloseWithoutSelect?.()
85+
} else {
86+
onSelect({
87+
strategy: option.mentionStrategies[0]!,
88+
strategyAddData: option.data || { label: option.label }
89+
})
90+
setIsOpen(false)
13491
}
135-
}, [lexicalMode, handleKeyDown, isOpen])
92+
}
13693

13794
return (
13895
<Popover open={isOpen} onOpenChange={setIsOpen}>
13996
<PopoverTrigger asChild>{children}</PopoverTrigger>
14097
<PopoverContent
141-
className="w-[200px] p-0"
98+
className={cn('w-[200px] p-0', !isOpen && 'hidden')}
14299
updatePositionStrategy="always"
143100
side="top"
144-
onOpenAutoFocus={e => lexicalMode && e.preventDefault()}
145-
onCloseAutoFocus={e => lexicalMode && e.preventDefault()}
101+
align="start"
102+
onOpenAutoFocus={e => e.preventDefault()}
103+
onCloseAutoFocus={e => e.preventDefault()}
146104
onKeyDown={e => e.stopPropagation()}
147105
>
148-
<Command ref={commandRef} filter={filterMentions}>
149-
<CommandInput
150-
hidden={lexicalMode}
151-
showSearchIcon={false}
152-
placeholder="Search mention..."
153-
className="h-9"
154-
value={internalSearchQuery}
155-
onValueChange={setInternalSearchQuery}
156-
/>
106+
<Command ref={commandRef} shouldFilter={false}>
157107
<CommandList>
158-
<CommandEmpty>No mention type found.</CommandEmpty>
159-
<CommandGroup>
160-
{mentionOptions.map(option => (
108+
<CommandEmpty>No results found.</CommandEmpty>
109+
<CommandGroup
110+
className={cn(filteredOptions.length === 0 ? 'p-0' : 'p-1')}
111+
>
112+
{filteredOptions.map((option, index) => (
161113
<CommandItem
162-
key={option.category}
163-
value={option.category}
164-
onSelect={handleSelect}
165-
className="px-1.5 py-1"
114+
key={option.label}
115+
defaultValue=""
116+
value=""
117+
onSelect={() => handleSelect(option)}
118+
className={cn(
119+
'px-1.5 py-1',
120+
focusedIndex === index &&
121+
'bg-primary text-primary-foreground'
122+
)}
123+
ref={el => {
124+
if (itemRefs.current) {
125+
itemRefs.current[index] = el
126+
}
127+
}}
166128
>
167129
{option.label}
168130
</CommandItem>

src/webview/components/ui/command.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ const CommandEmpty = React.forwardRef<
7878
>((props, ref) => (
7979
<CommandPrimitive.Empty
8080
ref={ref}
81-
className="py-6 text-center text-sm"
81+
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none"
8282
{...props}
8383
/>
8484
))

0 commit comments

Comments
 (0)